455 lines
19 KiB
PHP
455 lines
19 KiB
PHP
<?php
|
|
|
|
use App\Actions\Applications\GenerateDeployKey;
|
|
use App\Enums\DeployPolicy;
|
|
use App\Enums\EnvironmentAttachmentRole;
|
|
use App\Enums\OperationKind;
|
|
use App\Enums\ServiceCategory;
|
|
use App\Enums\ServiceType;
|
|
use App\Jobs\Environments\DeployEnvironment;
|
|
use App\Jobs\Services\RunStep;
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\Network;
|
|
use App\Models\Operation;
|
|
use App\Models\Organisation;
|
|
use App\Models\Provider;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceSlice;
|
|
use App\Services\Operations\RemoteCommandRunner;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Process;
|
|
|
|
beforeEach(function () {
|
|
Bus::fake();
|
|
|
|
app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner
|
|
{
|
|
public function run(Server $server, string $script): string
|
|
{
|
|
return "image_digest=billing-api:aaaaaaaaaaaa@sha256:deploymentdigest\n";
|
|
}
|
|
});
|
|
|
|
Process::fake(function ($process) {
|
|
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
|
|
|
|
return match (true) {
|
|
str_contains($command, 'git ls-remote') => Process::result(output: str_repeat('a', 40)."\trefs/heads/main\n"),
|
|
str_contains($command, 'docker image inspect') => Process::result(output: "billing-api:aaaaaaaaaaaa@sha256:deploymentdigest\n"),
|
|
default => Process::result(),
|
|
};
|
|
});
|
|
});
|
|
|
|
it('creates a parent environment operation with child service deploy operations', function () {
|
|
$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',
|
|
]);
|
|
$server = Server::factory()
|
|
->forOrganisation($organisation->id)
|
|
->forProvider($provider->id)
|
|
->forNetwork($network->id)
|
|
->create();
|
|
$application = Application::factory()->for($organisation)->create();
|
|
generateDeployKey($application);
|
|
$environment = Environment::factory()->for($application)->create();
|
|
$service = Service::factory()->for($environment)->for($server)->create([
|
|
'organisation_id' => $organisation->id,
|
|
'name' => 'web',
|
|
'category' => ServiceCategory::APPLICATION,
|
|
'type' => ServiceType::LARAVEL,
|
|
'version' => 'php-8.4',
|
|
'version_track' => 'php-8.4',
|
|
'driver_name' => 'laravel.php-8.4',
|
|
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
|
]);
|
|
|
|
(new DeployEnvironment($environment))->handle();
|
|
|
|
$parent = Operation::query()
|
|
->where('target_type', $environment->getMorphClass())
|
|
->where('target_id', $environment->id)
|
|
->first();
|
|
$child = Operation::query()
|
|
->where('parent_id', $parent->id)
|
|
->where('target_type', $service->getMorphClass())
|
|
->where('target_id', $service->id)
|
|
->first();
|
|
|
|
expect($parent->kind)->toBe(OperationKind::ENVIRONMENT_DEPLOY)
|
|
->and($child->kind)->toBe(OperationKind::SERVICE_DEPLOY)
|
|
->and($child->steps)->toHaveCount(7)
|
|
->and($child->steps()->where('name', 'Render Compose files')->first()->script)->toContain("base64 -d > /home/keystone/services/{$service->id}/compose.yml")
|
|
->and($child->steps()->where('name', 'Render Compose files')->first()->script)->toContain("base64 -d > /home/keystone/services/{$service->id}/.env")
|
|
->and($child->steps()->where('name', 'Run migrations')->first()->script)->toContain('docker compose -f /home/keystone/services/'.$service->id.'/compose.yml run --rm web php artisan migrate --force')
|
|
->and($child->steps()->where('name', 'Deploy replicas')->first()->script)->toContain('docker compose -f /home/keystone/services/'.$service->id.'/compose.yml up -d --scale web=1')
|
|
->and($child->steps()->where('name', 'Update gateway routes')->exists())->toBeFalse()
|
|
->and($child->steps()->pluck('script')->implode("\n"))->not->toContain('echo ')
|
|
->and($environment->buildArtifacts()->first()->image_digest)->toBe('sha256:deploymentdigest')
|
|
->and($service->refresh()->available_image_digest)->toBe('sha256:deploymentdigest')
|
|
->and($service->desired_revision)->toBe(str_repeat('a', 40));
|
|
|
|
Bus::assertDispatched(RunStep::class);
|
|
});
|
|
|
|
it('creates replica route configure and gateway cutover child operations', function () {
|
|
$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',
|
|
]);
|
|
$server = Server::factory()
|
|
->forOrganisation($organisation->id)
|
|
->forProvider($provider->id)
|
|
->forNetwork($network->id)
|
|
->create();
|
|
$application = Application::factory()->for($organisation)->create();
|
|
generateDeployKey($application);
|
|
$environment = Environment::factory()->for($application)->create();
|
|
$web = Service::factory()->for($environment)->for($server)->create([
|
|
'organisation_id' => $organisation->id,
|
|
'name' => 'web',
|
|
'category' => ServiceCategory::APPLICATION,
|
|
'type' => ServiceType::LARAVEL,
|
|
'version' => 'php-8.4',
|
|
'version_track' => 'php-8.4',
|
|
'driver_name' => 'laravel.php-8.4',
|
|
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
|
'process_roles' => ['web'],
|
|
'desired_replicas' => 2,
|
|
]);
|
|
$web->replicas()->create([
|
|
'server_id' => $server->id,
|
|
'container_name' => 'web-1',
|
|
'internal_host' => 'web-1',
|
|
'internal_port' => 80,
|
|
'status' => 'running',
|
|
'health_status' => 'healthy',
|
|
'config' => [],
|
|
]);
|
|
$web->replicas()->create([
|
|
'server_id' => $server->id,
|
|
'container_name' => 'web-2',
|
|
'internal_host' => 'web-2',
|
|
'internal_port' => 80,
|
|
'status' => 'running',
|
|
'health_status' => 'healthy',
|
|
'config' => [],
|
|
]);
|
|
$gateway = Service::factory()->for($environment)->for($server)->create([
|
|
'organisation_id' => $organisation->id,
|
|
'name' => 'gateway',
|
|
'category' => ServiceCategory::GATEWAY,
|
|
'type' => ServiceType::CADDY,
|
|
'version' => '2',
|
|
'version_track' => '2',
|
|
'driver_name' => 'caddy.2',
|
|
'deploy_policy' => DeployPolicy::MANUAL_OR_ON_ROUTE_CHANGE,
|
|
]);
|
|
$gateway->replicas()->create([
|
|
'server_id' => $server->id,
|
|
'container_name' => 'gateway-1',
|
|
'internal_host' => 'gateway-1',
|
|
'internal_port' => 80,
|
|
'status' => 'running',
|
|
'health_status' => 'healthy',
|
|
'config' => [],
|
|
]);
|
|
$route = ServiceSlice::factory()->for($gateway)->create([
|
|
'environment_id' => $environment->id,
|
|
'type' => 'route',
|
|
'name' => 'example.com',
|
|
]);
|
|
$environment->attachments()->create([
|
|
'service_id' => $gateway->id,
|
|
'service_slice_id' => $route->id,
|
|
'role' => EnvironmentAttachmentRole::GATEWAY,
|
|
'is_primary' => true,
|
|
]);
|
|
$organisation->registries()->create([
|
|
'name' => 'registry',
|
|
'type' => 'generic',
|
|
'url' => 'registry.example.com',
|
|
]);
|
|
|
|
(new DeployEnvironment($environment))->handle();
|
|
|
|
$parent = Operation::query()
|
|
->where('target_type', $environment->getMorphClass())
|
|
->where('target_id', $environment->id)
|
|
->where('kind', OperationKind::ENVIRONMENT_DEPLOY)
|
|
->first();
|
|
|
|
$serviceDeploy = $parent->children()->where('kind', OperationKind::SERVICE_DEPLOY)->first();
|
|
|
|
expect($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->count())->toBe(2)
|
|
->and($parent->children()->where('kind', OperationKind::SLICE_CONFIGURE)->count())->toBe(1)
|
|
->and($parent->children()->where('kind', OperationKind::GATEWAY_CUTOVER)->count())->toBe(1)
|
|
->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Start replica 1')->first()->script)
|
|
->toContain('docker compose -p keystone_service_')
|
|
->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Start replica 1')->first()->script)
|
|
->toContain('container_id=')
|
|
->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Pull image for replica 1')->first()->script)
|
|
->toContain('docker pull')
|
|
->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Pull image for replica 1')->first()->script)
|
|
->toContain('@sha256:deploymentdigest')
|
|
->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Health check replica 1')->first()->script)
|
|
->toContain('health_status=')
|
|
->and($parent->children()->where('kind', OperationKind::SLICE_CONFIGURE)->first()->steps()->first()->script)
|
|
->toContain('/home/keystone/gateway/Caddyfile.d')
|
|
->and($parent->children()->where('kind', OperationKind::SLICE_CONFIGURE)->first()->steps()->first()->script)
|
|
->toContain('reverse_proxy web-1:80 web-2:80')
|
|
->and($web->endpoints()->count())->toBe(2)
|
|
->and($parent->children()->where('kind', OperationKind::GATEWAY_CUTOVER)->first()->steps()->pluck('name')->all())
|
|
->toBe([
|
|
'Validate Caddy route configuration',
|
|
'Reload Caddy',
|
|
'Verify new upstreams are reachable',
|
|
'Drain old upstreams',
|
|
])
|
|
->and($parent->children()->where('kind', OperationKind::GATEWAY_CUTOVER)->first()->steps()->where('name', 'Reload Caddy')->first()->script)
|
|
->toContain('gateway-1');
|
|
});
|
|
|
|
it('honors manual disabled and post-switch migration settings', function (string $mode, string $timing, string $expectedScript, int $expectedOrder) {
|
|
$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',
|
|
]);
|
|
$server = Server::factory()
|
|
->forOrganisation($organisation->id)
|
|
->forProvider($provider->id)
|
|
->forNetwork($network->id)
|
|
->create();
|
|
$application = Application::factory()->for($organisation)->create();
|
|
generateDeployKey($application);
|
|
$environment = Environment::factory()->for($application)->create();
|
|
$service = Service::factory()->for($environment)->for($server)->create([
|
|
'organisation_id' => $organisation->id,
|
|
'name' => 'web',
|
|
'category' => ServiceCategory::APPLICATION,
|
|
'type' => ServiceType::LARAVEL,
|
|
'version' => 'php-8.4',
|
|
'version_track' => 'php-8.4',
|
|
'driver_name' => 'laravel.php-8.4',
|
|
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
|
'config' => [
|
|
'migration_mode' => $mode,
|
|
'migration_timing' => $timing,
|
|
'migration_command' => 'php artisan migrate --force',
|
|
],
|
|
]);
|
|
|
|
(new DeployEnvironment($environment))->handle();
|
|
|
|
$step = $service->operations()->where('kind', OperationKind::SERVICE_DEPLOY)->first()->steps()->where('name', 'Run migrations')->first();
|
|
|
|
$expectedScript = $expectedScript === 'migration'
|
|
? "docker compose -f /home/keystone/services/{$service->id}/compose.yml run --rm web php artisan migrate --force"
|
|
: $expectedScript;
|
|
|
|
expect($step->script)->toBe($expectedScript)
|
|
->and($step->order)->toBe($expectedOrder);
|
|
})->with([
|
|
'manual pre-switch' => ['manual', 'pre_switch', 'true', 4],
|
|
'disabled pre-switch' => ['disabled', 'pre_switch', 'true', 4],
|
|
'auto post-switch' => ['auto', 'post_switch', 'migration', 6],
|
|
]);
|
|
|
|
it('assigns replica operations and artifact metadata to service replicas', function () {
|
|
$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',
|
|
]);
|
|
$server = Server::factory()
|
|
->forOrganisation($organisation->id)
|
|
->forProvider($provider->id)
|
|
->forNetwork($network->id)
|
|
->create();
|
|
$application = Application::factory()->for($organisation)->create();
|
|
generateDeployKey($application);
|
|
$environment = Environment::factory()->for($application)->create();
|
|
$service = Service::factory()->for($environment)->for($server)->create([
|
|
'organisation_id' => $organisation->id,
|
|
'name' => 'web',
|
|
'category' => ServiceCategory::APPLICATION,
|
|
'type' => ServiceType::LARAVEL,
|
|
'version' => 'php-8.4',
|
|
'version_track' => 'php-8.4',
|
|
'driver_name' => 'laravel.php-8.4',
|
|
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
|
'desired_replicas' => 2,
|
|
]);
|
|
|
|
$organisation->registries()->create([
|
|
'name' => 'registry',
|
|
'type' => 'generic',
|
|
'url' => 'registry.example.com',
|
|
]);
|
|
|
|
(new DeployEnvironment($environment))->handle();
|
|
|
|
$replicas = $service->replicas()->orderBy('id')->get();
|
|
|
|
expect($replicas)->toHaveCount(2)
|
|
->and($replicas[0]->operation_id)->not->toBeNull()
|
|
->and($replicas[0]->image_digest)->toBe('sha256:deploymentdigest')
|
|
->and($replicas[0]->status)->toBe('pending')
|
|
->and($replicas[0]->health_status)->toBe('unknown')
|
|
->and($replicas[0]->operation->kind)->toBe(OperationKind::REPLICA_DEPLOY)
|
|
->and($replicas[0]->operation->parent->kind)->toBe(OperationKind::SERVICE_DEPLOY)
|
|
->and($replicas[0]->operation->target->is($replicas[0]))->toBeTrue();
|
|
});
|
|
|
|
it('places desired replicas across configured server placements', function () {
|
|
$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',
|
|
]);
|
|
$servers = Server::factory()
|
|
->count(2)
|
|
->forOrganisation($organisation->id)
|
|
->forProvider($provider->id)
|
|
->forNetwork($network->id)
|
|
->create();
|
|
$application = Application::factory()->for($organisation)->create();
|
|
generateDeployKey($application);
|
|
$environment = Environment::factory()->for($application)->create();
|
|
$service = Service::factory()->for($environment)->create([
|
|
'organisation_id' => $organisation->id,
|
|
'name' => 'web',
|
|
'category' => ServiceCategory::APPLICATION,
|
|
'type' => ServiceType::LARAVEL,
|
|
'version' => 'php-8.4',
|
|
'version_track' => 'php-8.4',
|
|
'driver_name' => 'laravel.php-8.4',
|
|
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
|
'desired_replicas' => 2,
|
|
'config' => [
|
|
'server_ids' => $servers->pluck('id')->all(),
|
|
],
|
|
]);
|
|
$organisation->registries()->create([
|
|
'name' => 'registry',
|
|
'type' => 'generic',
|
|
'url' => 'registry.example.com',
|
|
]);
|
|
|
|
(new DeployEnvironment($environment))->handle();
|
|
|
|
expect($service->replicas()->pluck('server_id')->all())
|
|
->toBe($servers->pluck('id')->all());
|
|
});
|
|
|
|
it('skips environment service operations when the target revision is already available', function () {
|
|
$organisation = Organisation::factory()->create();
|
|
$application = Application::factory()->for($organisation)->create();
|
|
generateDeployKey($application);
|
|
$environment = Environment::factory()->for($application)->create();
|
|
Service::factory()->for($environment)->create([
|
|
'organisation_id' => $organisation->id,
|
|
'name' => 'web',
|
|
'category' => ServiceCategory::APPLICATION,
|
|
'type' => ServiceType::LARAVEL,
|
|
'version' => 'php-8.4',
|
|
'version_track' => 'php-8.4',
|
|
'driver_name' => 'laravel.php-8.4',
|
|
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
|
'desired_revision' => str_repeat('a', 40),
|
|
'available_image_digest' => 'sha256:existing',
|
|
]);
|
|
|
|
(new DeployEnvironment($environment))->handle();
|
|
|
|
$parent = Operation::query()
|
|
->where('target_type', $environment->getMorphClass())
|
|
->where('target_id', $environment->id)
|
|
->where('kind', OperationKind::ENVIRONMENT_DEPLOY)
|
|
->first();
|
|
|
|
expect($parent->status)->toBe(\App\Enums\OperationStatus::COMPLETED)
|
|
->and($parent->children()->count())->toBe(0)
|
|
->and($environment->buildArtifacts()->count())->toBe(0);
|
|
});
|
|
|
|
function generateDeployKey(Application $application): void
|
|
{
|
|
app(GenerateDeployKey::class)->execute($application, [
|
|
'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone',
|
|
'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
|
|
'fingerprint' => 'SHA256:test',
|
|
]);
|
|
}
|
|
|
|
it('blocks multi-server deploys that do not have a registry', function () {
|
|
$organisation = Organisation::factory()->create();
|
|
$application = Application::factory()->for($organisation)->create();
|
|
$environment = Environment::factory()->for($application)->create();
|
|
Service::factory()->for($environment)->create([
|
|
'organisation_id' => $organisation->id,
|
|
'category' => ServiceCategory::APPLICATION,
|
|
'type' => ServiceType::LARAVEL,
|
|
'version' => 'php-8.4',
|
|
'version_track' => 'php-8.4',
|
|
'driver_name' => 'laravel.php-8.4',
|
|
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
|
'desired_replicas' => 2,
|
|
]);
|
|
|
|
expect(fn () => (new DeployEnvironment($environment))->handle())
|
|
->toThrow(RuntimeException::class, 'A registry is required before deploying this environment across multiple servers.');
|
|
});
|
|
|
|
it('blocks deployment when single scheduler mode would run on multiple replicas', function () {
|
|
$organisation = Organisation::factory()->create();
|
|
$application = Application::factory()->for($organisation)->create();
|
|
generateDeployKey($application);
|
|
$environment = Environment::factory()->for($application)->create([
|
|
'scheduler_enabled' => true,
|
|
'scheduler_mode' => \App\Enums\SchedulerMode::SINGLE,
|
|
]);
|
|
$web = Service::factory()->for($environment)->create([
|
|
'organisation_id' => $organisation->id,
|
|
'name' => 'web',
|
|
'category' => ServiceCategory::APPLICATION,
|
|
'type' => ServiceType::LARAVEL,
|
|
'version' => 'php-8.4',
|
|
'version_track' => 'php-8.4',
|
|
'driver_name' => 'laravel.php-8.4',
|
|
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
|
'process_roles' => ['web', 'scheduler'],
|
|
'desired_replicas' => 2,
|
|
]);
|
|
$environment->forceFill(['scheduler_target_service_id' => $web->id])->save();
|
|
$organisation->registries()->create([
|
|
'name' => 'registry',
|
|
'type' => 'generic',
|
|
'url' => 'registry.example.com',
|
|
]);
|
|
|
|
expect(fn () => (new DeployEnvironment($environment->refresh()))->handle())
|
|
->toThrow(RuntimeException::class, 'Scheduler mode single requires the scheduler target service to run exactly one replica.');
|
|
});
|