375 lines
15 KiB
PHP
375 lines
15 KiB
PHP
<?php
|
||
|
||
use App\Actions\RestartInstance;
|
||
use App\Livewire\Admin\ConfirmRestartInstance;
|
||
use App\Livewire\Admin\Instances as AdminInstances;
|
||
use App\Livewire\Cloud;
|
||
use App\Livewire\ConfirmRestartCloud;
|
||
use App\Models\Host;
|
||
use App\Models\Instance;
|
||
use App\Models\Order;
|
||
use App\Models\ProvisioningRun;
|
||
use App\Models\User;
|
||
use App\Provisioning\RunRunner;
|
||
use App\Provisioning\Steps\Customer\CompleteRestart;
|
||
use App\Provisioning\Steps\Customer\ShutDownVirtualMachine;
|
||
use App\Support\ProvisioningSettings;
|
||
use Illuminate\Auth\Access\AuthorizationException;
|
||
use Illuminate\Support\Facades\Queue;
|
||
use Livewire\Livewire;
|
||
|
||
/**
|
||
* Restarting a customer's machine — the thing the product could not do.
|
||
*
|
||
* ProxmoxClient had startVm and no way to stop anything, so a plan change that
|
||
* grew a running guest wrote the new cores and memory into the VM's definition,
|
||
* set `restart_required_since`, and left the flag to be cleared by a resize step
|
||
* that happened to find the machine stopped — which nothing could arrange. A
|
||
* paid upgrade could stay unreached for the life of the contract, and the cloud
|
||
* page's "Neu starten" was a toast.
|
||
*
|
||
* These tests are about the two halves of the fix: a shutdown that is a request
|
||
* and never a power cut, and a flag that is cleared by the machine coming back
|
||
* rather than by anybody pressing anything.
|
||
*/
|
||
|
||
/**
|
||
* A live instance whose contract has been grown but whose guest is still
|
||
* running on what it booted with — exactly the state a plan change leaves.
|
||
*/
|
||
function restartableInstance(array $instanceAttributes = []): array
|
||
{
|
||
$services = fakeServices();
|
||
$pve = $services['pve'];
|
||
|
||
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']);
|
||
$order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => 'business']);
|
||
|
||
$instance = Instance::factory()->create(array_merge([
|
||
'order_id' => $order->id,
|
||
'customer_id' => $order->customer_id,
|
||
'host_id' => $host->id,
|
||
'vmid' => 101,
|
||
'guest_ip' => '10.20.0.7',
|
||
'status' => 'active',
|
||
// What the contract now says. The guest below is running on less.
|
||
'cores' => 8,
|
||
'ram_mb' => 16384,
|
||
'restart_required_since' => now()->subDays(3),
|
||
], $instanceAttributes));
|
||
|
||
// The machine as it actually is: booted small, then given a bigger
|
||
// definition it has not read yet.
|
||
$pve->setCloudInit('pve', 101, ['cores' => 2, 'memory' => 4096]);
|
||
$pve->startVm('pve', 101);
|
||
$pve->setCloudInit('pve', 101, ['cores' => 8, 'memory' => 16384]);
|
||
|
||
// The address the guest comes back on. The restart pipeline reads it — a
|
||
// cold boot is the one event that can move a DHCP lease — and would poll for
|
||
// it until the step's deadline if the fake answered nothing.
|
||
$pve->guestScript('hostname -I', 0, '10.20.0.7');
|
||
|
||
return compact('services', 'pve', 'host', 'order', 'instance');
|
||
}
|
||
|
||
/**
|
||
* Advance a run until it settles, clearing the backoff between steps.
|
||
*
|
||
* next_attempt_at is what makes a poll a wait; a test that did not clear it
|
||
* would simply watch the runner decline to do anything.
|
||
*/
|
||
function driveRun(ProvisioningRun $run, int $limit = 25): ProvisioningRun
|
||
{
|
||
$runner = app(RunRunner::class);
|
||
|
||
for ($i = 0; $i < $limit; $i++) {
|
||
$run->refresh();
|
||
|
||
if (in_array($run->status, [ProvisioningRun::STATUS_COMPLETED, ProvisioningRun::STATUS_FAILED], true)) {
|
||
break;
|
||
}
|
||
|
||
$run->forceFill(['next_attempt_at' => null])->save();
|
||
$runner->advance($run);
|
||
}
|
||
|
||
return $run->refresh();
|
||
}
|
||
|
||
it('asks the guest to shut down, brings it back, and only then stops saying a restart is due', function () {
|
||
Queue::fake();
|
||
['pve' => $pve, 'instance' => $instance] = restartableInstance();
|
||
$this->actingAs(admin(), 'operator');
|
||
|
||
// Before: the definition is big, the running machine is not — which is what
|
||
// "Neustart erforderlich" has been telling the customer for three days.
|
||
expect($pve->vmStatus('pve', 101)['cpus'])->toBe(2)
|
||
->and($instance->restartIsPending())->toBeTrue();
|
||
|
||
$run = app(RestartInstance::class)($instance);
|
||
expect($run)->not->toBeNull();
|
||
|
||
// Pressing the button does not clear it. Nothing has happened to the
|
||
// machine yet, and a flag that clears on intent is a flag that lies.
|
||
expect($instance->fresh()->restartIsPending())->toBeTrue();
|
||
|
||
driveRun($run);
|
||
|
||
expect($run->fresh()->status)->toBe(ProvisioningRun::STATUS_COMPLETED)
|
||
// The guest was ASKED, and it was asked exactly once.
|
||
->and($pve->shutdownCalls)->toHaveCount(1)
|
||
->and($pve->shutdownCalls[0]['vmid'])->toBe(101)
|
||
// It came back — and came back on the definition it had been given,
|
||
// which is the only thing that makes the upgrade real.
|
||
->and($pve->vmStatus('pve', 101)['status'])->toBe('running')
|
||
->and($pve->vmStatus('pve', 101)['cpus'])->toBe(8)
|
||
->and((int) ($pve->vmStatus('pve', 101)['maxmem'] / 1048576))->toBe(16384)
|
||
->and($instance->fresh()->restartIsPending())->toBeFalse();
|
||
});
|
||
|
||
it('never pulls the plug on a guest that will not shut down', function () {
|
||
Queue::fake();
|
||
['pve' => $pve, 'instance' => $instance, 'order' => $order] = restartableInstance();
|
||
$order->update(['status' => 'active']);
|
||
$this->actingAs(admin(), 'operator');
|
||
|
||
// A guest that ignores ACPI — a wedged handler, or a machine busy with
|
||
// something it will not be interrupted in.
|
||
$pve->shutdownIgnored = true;
|
||
|
||
$run = app(RestartInstance::class)($instance);
|
||
|
||
driveRun($run);
|
||
// Past the step's grace period but inside the runner's own step timeout, so
|
||
// the step's deliberate failure fires rather than a generic retry.
|
||
$this->travel(11)->minutes();
|
||
$run = driveRun($run);
|
||
|
||
expect($run->status)->toBe(ProvisioningRun::STATUS_FAILED)
|
||
->and($run->error)->toBe('shutdown_timeout')
|
||
// The machine is still running. Nextcloud is a database and this
|
||
// product has no power cut to escalate to — the run fails and a person
|
||
// decides what happens next.
|
||
->and($pve->runningVmids)->toContain(101)
|
||
->and($pve->vmStatus('pve', 101)['status'])->toBe('running')
|
||
// And nothing has been declared finished on the strength of a failure.
|
||
->and($instance->fresh()->restartIsPending())->toBeTrue()
|
||
// A maintenance pipeline failing must not release a paid, running
|
||
// customer — RunRunner's subject hook only fires for the pipeline that
|
||
// BUILDS the subject, and a restart is not that one.
|
||
->and($order->fresh()->status)->toBe('active')
|
||
->and($instance->fresh()->status)->toBe('active');
|
||
});
|
||
|
||
it('refuses to call a restart done when the machine came back smaller than it was sold', function () {
|
||
Queue::fake();
|
||
['pve' => $pve, 'instance' => $instance, 'order' => $order] = restartableInstance();
|
||
|
||
// The machine went round but the bigger definition never took — a config
|
||
// write that silently failed, a host that could not honour it.
|
||
$pve->shutdownVm('pve', 101, 600);
|
||
$pve->vmConfig[101] = ['cores' => 2, 'memory' => 4096];
|
||
$pve->startVm('pve', 101);
|
||
|
||
$run = ProvisioningRun::factory()->create([
|
||
'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'restart',
|
||
'context' => ['instance_id' => $instance->id, 'node' => 'pve', 'vmid' => 101],
|
||
]);
|
||
|
||
$result = app(CompleteRestart::class)->execute($run);
|
||
|
||
expect($result->type)->toBe('fail')
|
||
->and($result->reason)->toContain('2 statt 8 vCPU')
|
||
->and($instance->fresh()->restartIsPending())->toBeTrue();
|
||
});
|
||
|
||
it('leaves the pending restart alone until the very last step of the run', function () {
|
||
Queue::fake();
|
||
['instance' => $instance] = restartableInstance();
|
||
$this->actingAs(admin(), 'operator');
|
||
|
||
$run = app(RestartInstance::class)($instance);
|
||
$runner = app(RunRunner::class);
|
||
|
||
// Read off the pipeline rather than written out, so inserting a step cannot
|
||
// quietly turn "the last one" into "the one before it" and leave this test
|
||
// green while proving something weaker.
|
||
$last = count((array) config('provisioning.pipelines.restart')) - 1;
|
||
|
||
// Shutdown asked, shutdown observed, machine started, agent answered, address
|
||
// read — and through all of it the flag stands, because none of those is the
|
||
// machine running on what was bought.
|
||
for ($i = 0; $i < 10; $i++) {
|
||
$run->refresh();
|
||
|
||
if ($run->current_step >= $last) {
|
||
break;
|
||
}
|
||
|
||
$run->forceFill(['next_attempt_at' => null])->save();
|
||
$runner->advance($run);
|
||
|
||
expect($instance->fresh()->restartIsPending())->toBeTrue();
|
||
}
|
||
|
||
expect($run->refresh()->current_step)->toBe($last)
|
||
->and($instance->fresh()->restartIsPending())->toBeTrue();
|
||
|
||
driveRun($run);
|
||
|
||
expect($instance->fresh()->restartIsPending())->toBeFalse();
|
||
});
|
||
|
||
it('lets a customer restart their own machine and nobody else’s', function () {
|
||
Queue::fake();
|
||
['instance' => $mine] = restartableInstance();
|
||
['instance' => $theirs] = restartableInstance();
|
||
|
||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||
$mine->customer->update(['user_id' => $user->id, 'email' => $user->email]);
|
||
|
||
$this->actingAs($user);
|
||
|
||
// Checked in the action, not in the view. A Livewire method is reachable by
|
||
// anyone who can POST to /livewire/update, so hiding a button proves
|
||
// nothing at all about who may press it.
|
||
expect(fn () => app(RestartInstance::class)($theirs))->toThrow(AuthorizationException::class);
|
||
expect(app(RestartInstance::class)->allows($theirs))->toBeFalse()
|
||
->and(app(RestartInstance::class)->allows($mine))->toBeTrue();
|
||
|
||
expect(app(RestartInstance::class)($mine))->not->toBeNull();
|
||
});
|
||
|
||
it('lets an operator with the capability restart any machine, and refuses one without it', function () {
|
||
Queue::fake();
|
||
['instance' => $instance] = restartableInstance();
|
||
|
||
$this->actingAs(operator('Read-only'), 'operator');
|
||
expect(fn () => app(RestartInstance::class)($instance))->toThrow(AuthorizationException::class);
|
||
expect(ProvisioningRun::query()->where('pipeline', 'restart')->count())->toBe(0);
|
||
|
||
$this->actingAs(admin(), 'operator');
|
||
expect(app(RestartInstance::class)($instance))->not->toBeNull()
|
||
->and(ProvisioningRun::query()->where('pipeline', 'restart')->count())->toBe(1);
|
||
});
|
||
|
||
it('does not start a restart while another run is already in flight for that instance', function () {
|
||
Queue::fake();
|
||
['instance' => $instance, 'order' => $order] = restartableInstance();
|
||
|
||
// A plan change still working on the same machine. Stopping the guest
|
||
// underneath it would race the config it is about to boot.
|
||
ProvisioningRun::factory()->create([
|
||
'subject_type' => Order::class,
|
||
'subject_id' => $order->id,
|
||
'pipeline' => 'plan-change',
|
||
'status' => ProvisioningRun::STATUS_RUNNING,
|
||
]);
|
||
|
||
$this->actingAs(admin(), 'operator');
|
||
|
||
expect(app(RestartInstance::class)($instance))->toBeNull()
|
||
->and(ProvisioningRun::query()->where('pipeline', 'restart')->count())->toBe(0);
|
||
});
|
||
|
||
it('refuses a machine there is nothing to restart', function () {
|
||
Queue::fake();
|
||
['instance' => $instance] = restartableInstance(['status' => 'provisioning']);
|
||
|
||
$this->actingAs(admin(), 'operator');
|
||
|
||
// Still being built: its own run is writing this machine, and a shutdown
|
||
// beside it would stop the guest that run is talking to.
|
||
expect(app(RestartInstance::class)($instance))->toBeNull();
|
||
});
|
||
|
||
it('confirms in a modal on both sides and drives the real action from the page', function () {
|
||
Queue::fake();
|
||
['instance' => $instance] = restartableInstance();
|
||
|
||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||
$instance->customer->update(['user_id' => $user->id, 'email' => $user->email]);
|
||
|
||
// Customer: the modal only says yes; Cloud::restart() does the work.
|
||
Livewire::actingAs($user)->test(ConfirmRestartCloud::class)
|
||
->call('proceed')
|
||
->assertDispatched('cloud-restart-confirmed');
|
||
|
||
Livewire::actingAs($user)->test(Cloud::class)
|
||
->dispatch('cloud-restart-confirmed')
|
||
->assertDispatched('notify');
|
||
|
||
expect(ProvisioningRun::query()->where('pipeline', 'restart')->count())->toBe(1);
|
||
|
||
// Operator: same shape, addressed by uuid (R11).
|
||
ProvisioningRun::query()->delete();
|
||
|
||
Livewire::actingAs(admin(), 'operator')->test(ConfirmRestartInstance::class, ['uuid' => $instance->uuid])
|
||
->call('proceed')
|
||
->assertDispatched('instance-restart-confirmed');
|
||
|
||
Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
|
||
->dispatch('instance-restart-confirmed', uuid: $instance->uuid);
|
||
|
||
expect(ProvisioningRun::query()->where('pipeline', 'restart')->count())->toBe(1);
|
||
});
|
||
|
||
it('refuses the console action to an operator who does not hold the capability', function () {
|
||
Queue::fake();
|
||
['instance' => $instance] = restartableInstance();
|
||
|
||
Livewire::actingAs(operator('Read-only'), 'operator')->test(AdminInstances::class)
|
||
->dispatch('instance-restart-confirmed', uuid: $instance->uuid)
|
||
->assertForbidden();
|
||
|
||
expect(ProvisioningRun::query()->where('pipeline', 'restart')->count())->toBe(0);
|
||
});
|
||
|
||
it('follows the guest when a restart brings it back on another address', function () {
|
||
// cloud-init hands every guest ip=dhcp, so the address is a lease and not a
|
||
// property of the machine. The router file on the serving host names it as the
|
||
// backend — so a machine that comes back somewhere else, with nothing
|
||
// rewriting that file, is a customer's cloud answering 502 for the rest of its
|
||
// life. Nothing in the product re-read the address at all, and the route guard
|
||
// compared only the hostname list, so neither half could notice.
|
||
Queue::fake();
|
||
['pve' => $pve, 'instance' => $instance] = restartableInstance([
|
||
// What the router was last told, and where the machine actually is now.
|
||
'routed_backend' => '10.20.0.7',
|
||
'route_written' => true,
|
||
'routed_hostnames' => ['berger.'.ProvisioningSettings::dnsZone()],
|
||
'subdomain' => 'berger',
|
||
'cert_ok' => true,
|
||
]);
|
||
$pve->guestScript('hostname -I', 0, '10.20.0.44 fe80::1');
|
||
$this->actingAs(admin(), 'operator');
|
||
|
||
driveRun(app(RestartInstance::class)($instance));
|
||
|
||
expect($instance->fresh()->guest_ip)->toBe('10.20.0.44')
|
||
// An address run, because writing the router is that pipeline's job. It is
|
||
// allowed to start beside the restart because the guard asks whether the
|
||
// run in flight carries out the address steps, and a restart carries
|
||
// neither of them.
|
||
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
|
||
|
||
driveRun(ProvisioningRun::query()->where('pipeline', 'address')->sole());
|
||
|
||
expect($instance->fresh()->routed_backend)->toBe('10.20.0.44');
|
||
});
|
||
|
||
it('gives the shutdown step nothing to do when the guest is already stopped', function () {
|
||
Queue::fake();
|
||
['pve' => $pve, 'instance' => $instance, 'order' => $order] = restartableInstance();
|
||
|
||
$pve->runningVmids = [];
|
||
|
||
$run = ProvisioningRun::factory()->create([
|
||
'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'restart',
|
||
'context' => ['instance_id' => $instance->id, 'node' => 'pve', 'vmid' => 101],
|
||
]);
|
||
|
||
expect(app(ShutDownVirtualMachine::class)->execute($run)->type)->toBe('advance')
|
||
->and($pve->shutdownCalls)->toBe([]);
|
||
});
|