Implement Keystone environment deployments

This commit is contained in:
2026-05-13 16:11:23 +01:00
parent 65d3142d03
commit aa680b25fd
175 changed files with 10258 additions and 740 deletions

View File

@@ -0,0 +1,151 @@
<?php
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Jobs\Services\DeployService;
use App\Jobs\Services\RunStep;
use App\Models\Network;
use App\Models\OperationStep;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Models\Service;
use App\Services\Operations\RemoteCommandRunner;
use Illuminate\Support\Facades\Bus;
beforeEach(function () {
app()->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();
}