[FakeAdvanceStep::class, FakeRetryStep::class]]); expect($registry->count('host'))->toBe(2) ->and($registry->resolve('host', 0))->toBeInstanceOf(FakeAdvanceStep::class) ->and($registry->resolve('host', 1))->toBeInstanceOf(FakeRetryStep::class); }); it('throws for an unknown pipeline', function () { (new PipelineRegistry([]))->stepsFor('nope'); })->throws(InvalidArgumentException::class); it('throws for an out-of-range step index', function () { (new PipelineRegistry(['host' => [FakeAdvanceStep::class]]))->resolve('host', 5); })->throws(InvalidArgumentException::class); it('answers whether a run still to come carries out another pipeline whole', function () { // The question an action about to start a run has to ask about the run already // in flight. Asking merely "is something running?" is what made a domain // proven during a restart never get routed, and a storage pack booked during // an address run get charged monthly and never delivered. $registry = new PipelineRegistry([ 'both' => [FakeAdvanceStep::class, FakeRetryStep::class], 'first' => [FakeAdvanceStep::class], 'second' => [FakeRetryStep::class], ]); // Everything still ahead of it. expect($registry->stillCovers('both', 0, 'first'))->toBeTrue() ->and($registry->stillCovers('both', 0, 'second'))->toBeTrue() // EVERY step, not any: half of an ordered piece of work is worse than // none of it — a quota enforced over a filesystem that never grew. ->and($registry->stillCovers('first', 0, 'both'))->toBeFalse() // Already past it: a run standing at its second step will not go back and // do its first, which is the case a name comparison cannot see at all. ->and($registry->stillCovers('both', 1, 'first'))->toBeFalse() ->and($registry->stillCovers('both', 1, 'second'))->toBeTrue() // The step it is standing on counts as still to come — it is about to run, // or running, and every step reads the state when it gets there. ->and($registry->stillCovers('second', 0, 'second'))->toBeTrue(); }); it('asks that question of the runs actually in flight against an order', function () { $order = Order::factory()->create(); $work = app(WorkInFlight::class); $running = fn (string $pipeline) => ProvisioningRun::factory()->create([ 'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => $pipeline, 'status' => ProvisioningRun::STATUS_RUNNING, ]); expect($work->covers($order, 'address'))->toBeFalse() ->and($work->covers($order, 'storage'))->toBeFalse(); // A quota run writes the figure Nextcloud enforces and nothing else — it does // not grow the disk that figure is a promise about, so it does not stand for a // storage change. This is the pair that had a booked pack charged every month // and never delivered. $running('quota'); expect($work->covers($order, 'storage'))->toBeFalse() ->and($work->covers($order, 'address'))->toBeFalse(); // A plan change carries both pipelines whole; there is genuinely nothing to add. ProvisioningRun::query()->delete(); $running('plan-change'); expect($work->covers($order, 'storage'))->toBeTrue() ->and($work->covers($order, 'address'))->toBeTrue(); // A run that has finished covers nothing, however much it contained. ProvisioningRun::query()->update(['status' => ProvisioningRun::STATUS_COMPLETED]); expect($work->covers($order, 'storage'))->toBeFalse() ->and($work->covers($order, 'address'))->toBeFalse(); // A pipeline that no longer exists — renamed by a deploy while a run was in // flight — covers nothing either. Refusing to start the work over it would // lose the work as well as the run. ProvisioningRun::query()->delete(); $running('gone-in-a-deploy'); expect($work->covers($order, 'address'))->toBeFalse(); }); it('lays the maintenance pipelines out so a half-finished address is a safe one', function () { // Nextcloud BEFORE the router, in the address pipeline and in the plan change // that reuses it. Router first meant a run could get the certificate and then // fail at Nextcloud — leaving the customer's own domain serving Nextcloud's // untrusted-domain error under a valid certificate, with domain_cert_ok true // so the portal called it live. The reasoning for both directions is written // out above the `address` pipeline in config/provisioning.php. $nextcloud = ConfigureNextcloud::class; $dns = ConfigureDnsAndTls::class; foreach (['address', 'plan-change', 'customer'] as $pipeline) { $steps = (array) config('provisioning.pipelines.'.$pipeline); expect(array_search($nextcloud, $steps, true)) ->toBeLessThan(array_search($dns, $steps, true)); } });