CluPilotCloud/app/Actions/RestartInstance.php

164 lines
6.4 KiB
PHP

<?php
namespace App\Actions;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Take a customer's machine round once — properly.
*
* The product could not restart anything at all. ProxmoxClient had startVm and
* nothing else, the cloud page's "Neu starten" was a toast, and
* `restart_required_since` — set by every plan change that touches CPU or memory
* on a running guest — could only ever be cleared by a resize step that happened
* to find the machine stopped. Nothing could stop it. So a paid upgrade's cores
* and RAM were written into the VM's definition and could stay unreached for the
* life of the contract.
*
* This is the missing trigger. It does not do the work: a restart is remote work
* on a live machine, so it goes through the same run machinery as everything
* else — see the `restart` pipeline — where it is retried, logged, and visible
* in the console instead of happening inside a web request.
*/
class RestartInstance
{
/**
* May whoever is signed in RIGHT NOW restart this machine?
*
* Asked here and not only where the button is drawn. A Livewire action is
* reachable by anyone who can POST to /livewire/update with a component
* snapshot — hiding a button hides a button, it does not close a door — so
* the rule has to be re-checked at the point the run is created.
*
* Two identities, two answers (R21): an operator holding `instances.restart`
* may restart any machine on the estate, because that is what the console is
* for; a portal user may restart their own and nobody else's. Both guards
* are read, and either may say yes — the two sessions coexist in one browser
* by design (see config/auth.php), so asking only the console guard would
* refuse an operator their own cloud, and asking only the portal guard would
* refuse every operator everything.
*
* The customer is resolved from the signed-in user rather than taken from
* the caller: a caller that passes the customer is a caller that can pass
* the wrong one.
*/
public function allows(Instance $instance): bool
{
$operator = Auth::guard('operator')->user();
if ($operator !== null && $operator->isActive() && $operator->can('instances.restart')) {
return true;
}
$customer = Customer::forUser(Auth::guard('web')->user());
return $customer !== null && (int) $instance->customer_id === (int) $customer->id;
}
/**
* Start a restart run for this instance, or return null when there is
* nothing to start.
*
* Null is the ordinary "no" and not a failure — a machine that is still
* being built, one whose VM has gone, one whose service has ended, or one
* that already has a run in flight all have a better reason to be left
* alone than a restart has to interrupt them. A refusal on AUTHORISATION is
* the one case that throws, because it is not an ordinary outcome and the
* caller must not be able to mistake it for one.
*
* @throws AuthorizationException
*/
public function __invoke(Instance $instance): ?ProvisioningRun
{
if (! $this->allows($instance)) {
throw new AuthorizationException;
}
if (! $instance->hasLiveMachine()) {
return null;
}
// The same lock ReapplyInstanceAddress takes, for the same reason: two
// requests can reach this in one instant — the customer pressing the
// button while an operator presses it for them is the obvious pair —
// and two restart runs against one machine would have the second one
// shutting the guest down while the first waits for it to come back.
// Nobody waits for the lock: a restart that lost the race has nothing
// to add, because the run that won does exactly the same thing.
$lock = Cache::lock('instance-restart:'.$instance->uuid, 30);
if (! $lock->get()) {
return null;
}
try {
return $this->start($instance);
} finally {
$lock->release();
}
}
private function start(Instance $instance): ?ProvisioningRun
{
$order = $instance->order;
// Checked against the ORDER, because that is the subject every customer
// pipeline shares. A restart beside an unfinished build would stop the
// machine the build is still writing to; a restart beside a plan change
// would race the config PUT it is about to boot. Whatever is in flight
// gets to finish, and the customer can press the button again after.
if ($this->hasRunInFlight($order)) {
return null;
}
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'restart',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Everything the four steps read: instance_id is how CustomerStep
// finds the machine, node and vmid are how it reaches it.
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Restarting a customer instance.', [
'instance' => $instance->uuid,
'restart_pending_since' => $instance->restart_required_since?->toIso8601String(),
'run' => $run->uuid,
]);
return $run;
}
private function hasRunInFlight(Order $order): bool
{
return ProvisioningRun::query()
->where('subject_type', Order::class)
->where('subject_id', $order->id)
->whereIn('status', [
ProvisioningRun::STATUS_PENDING,
ProvisioningRun::STATUS_RUNNING,
ProvisioningRun::STATUS_WAITING,
ProvisioningRun::STATUS_PAUSED,
])
->exists();
}
}