164 lines
6.4 KiB
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();
|
|
}
|
|
}
|