instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner { public function run(Server $server, string $script): string { return "image_digest=postgres:18@sha256:postgresdigest\n"; } }); }); it('creates service deploy operations that upload generated compose files first', function () { Bus::fake(); $service = Service::factory()->for(serviceDeploymentServer())->create([ 'name' => 'postgres', 'category' => ServiceCategory::DATABASE, 'type' => ServiceType::POSTGRES, 'version' => '18', 'version_track' => '18', 'driver_name' => 'postgres.18', 'credentials' => [ 'user' => 'keystone', 'password' => 'secret', 'db' => 'keystone', ], ]); (new DeployService($service))->handle(); $operation = $service->operations()->first(); $firstStep = $operation->steps()->orderBy('order')->first(); $postgresStep = $operation->steps()->where('name', 'Start Postgres service')->first(); expect($operation->kind)->toBe(OperationKind::SERVICE_DEPLOY) ->and($firstStep->name)->toBe('Upload Compose file') ->and($firstStep->script)->toContain('compose.yml') ->and($firstStep->script)->toContain('/.env') ->and($firstStep->script)->toContain('base64 -d') ->and($service->refresh()->available_image_digest)->toBe('sha256:postgresdigest') ->and($postgresStep->script)->toBe("docker compose -f /home/keystone/services/{$service->id}/compose.yml up -d") ->and($operation->steps()->where('name', 'Check Postgres health')->first()->script)->toContain('docker compose') ->and($operation->steps()->pluck('script')->implode("\n"))->not->toContain('docker run'); Bus::assertDispatched(RunStep::class); }); it('resolves encrypted operation step secrets only for execution', function () { $step = new OperationStep([ 'script' => 'docker login --password [!password!]', 'secrets' => ['password' => 'secret'], ]); expect($step->script)->toContain('[!password!]') ->and($step->scriptForExecution())->toBe('docker login --password secret'); }); it('extracts runtime state markers from operation step logs', function () { $step = new OperationStep([ 'logs' => "starting\ncontainer_id=abc123\nhealth_status=healthy\n", ]); expect($step->capturedRuntimeState())->toBe([ 'container_id' => 'abc123', 'health_status' => 'healthy', ]); }); it('cascades operation cancellation when a step fails', function () { $server = serviceDeploymentServer(); $service = Service::factory()->for($server)->create(); $parent = $service->operations()->create([ 'kind' => OperationKind::ENVIRONMENT_DEPLOY, 'status' => OperationStatus::IN_PROGRESS, ]); $serviceDeploy = $service->operations()->create([ 'parent_id' => $parent->id, 'kind' => OperationKind::SERVICE_DEPLOY, 'status' => OperationStatus::IN_PROGRESS, ]); $replicaDeploy = $service->operations()->create([ 'parent_id' => $serviceDeploy->id, 'kind' => OperationKind::REPLICA_DEPLOY, 'status' => OperationStatus::PENDING, ]); $gatewayCutover = $service->operations()->create([ 'parent_id' => $parent->id, 'kind' => OperationKind::GATEWAY_CUTOVER, 'status' => OperationStatus::PENDING, ]); $step = $serviceDeploy->steps()->create([ 'name' => 'Failing step', 'order' => 1, 'status' => OperationStatus::IN_PROGRESS, 'script' => 'false', ]); $replicaDeploy->steps()->create([ 'name' => 'Replica step', 'order' => 1, 'status' => OperationStatus::PENDING, 'script' => 'true', ]); $gatewayCutover->steps()->create([ 'name' => 'Gateway step', 'order' => 1, 'status' => OperationStatus::PENDING, 'script' => 'true', ]); (new RunStep($step))->failed(new RuntimeException('boom')); expect($serviceDeploy->refresh()->status)->toBe(OperationStatus::FAILED) ->and($parent->refresh()->status)->toBe(OperationStatus::FAILED) ->and($replicaDeploy->refresh()->status)->toBe(OperationStatus::CANCELLED) ->and($gatewayCutover->refresh()->status)->toBe(OperationStatus::CANCELLED) ->and($gatewayCutover->steps()->first()->status)->toBe(OperationStatus::CANCELLED); }); function serviceDeploymentServer(): Server { $organisation = Organisation::factory()->create(); $provider = Provider::factory()->forOrganisation($organisation)->create(); $network = Network::create([ 'organisation_id' => $organisation->id, 'provider_id' => $provider->id, 'name' => 'test-network', 'ip_range' => '10.0.0.0/24', ]); return Server::factory() ->forOrganisation($organisation->id) ->forProvider($provider->id) ->forNetwork($network->id) ->create(); }