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,122 @@
<?php
use App\Actions\Applications\GenerateDeployKey;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Support\Facades\Process;
use Inertia\Testing\AssertableInertia;
it('lists applications with environments as the primary deployment surface', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
Environment::factory()->for($application)->create(['name' => 'production']);
$response = $this->actingAs($user)->get(route('applications.index', [
'organisation' => $organisation->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('applications/Index', false)
->has('applications.0.environments', 1));
});
it('shows an application with environments services and attachments', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create(['name' => 'production']);
$environment->services()->create(\App\Models\Service::factory()->make([
'organisation_id' => $organisation->id,
])->toArray());
$response = $this->actingAs($user)->get(route('applications.show', [
'organisation' => $organisation->id,
'application' => $application->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('applications/Show', false)
->has('application.environments', 1)
->has('application.environments.0.services', 1));
});
it('shows the create application page', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$response = $this->actingAs($user)->get(route('applications.create', [
'organisation' => $organisation->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('applications/Create', false));
});
it('stores an application with a deploy key and default laravel environment', function () {
$this->app->bind(GenerateDeployKey::class, fn () => new class extends GenerateDeployKey
{
public function execute(Application $application, ?array $keyPair = null): Application
{
return parent::execute($application, [
'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone',
'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => 'SHA256:test',
]);
}
});
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$response = $this->actingAs($user)->post(route('applications.store', [
'organisation' => $organisation->id,
]), [
'name' => 'Billing API',
'repository_url' => 'git@example.com:org/billing-api.git',
'default_branch' => 'main',
'environment_name' => 'production',
]);
$application = Application::query()->where('name', 'Billing API')->firstOrFail();
$response->assertRedirect(route('applications.show', [
'organisation' => $organisation->id,
'application' => $application->id,
]));
expect($application->deploy_key_public)->toStartWith('ssh-ed25519')
->and($application->environments()->where('name', 'production')->exists())->toBeTrue()
->and($application->environments()->first()->services()->where('name', 'web')->exists())->toBeTrue();
});
it('verifies repository access for an application deploy key', function () {
Process::fake([
'*' => Process::result(output: "abc123\trefs/heads/main\n"),
]);
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create([
'repository_url' => 'git@example.com:org/repo.git',
'default_branch' => 'main',
]);
app(GenerateDeployKey::class)->execute($application, [
'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone',
'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => 'SHA256:test',
]);
$response = $this->actingAs($user)->post(route('applications.verify-repository', [
'organisation' => $organisation->id,
'application' => $application->id,
]));
$response->assertRedirect();
expect($application->refresh()->deploy_key_installed_at)->not->toBeNull();
});

View File

@@ -0,0 +1,104 @@
<?php
use App\Actions\Applications\CreateLaravelEnvironment;
use App\Actions\Applications\GenerateDeployKey;
use App\Actions\Environments\BuildApplicationArtifact;
use App\Actions\Environments\PlanBuildArtifact;
use App\Enums\BuildArtifactStatus;
use App\Enums\RegistryType;
use App\Models\Application;
use App\Models\Network;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Services\Operations\RemoteCommandRunner;
beforeEach(function () {
$this->remoteRunner = new class implements RemoteCommandRunner
{
/** @var array<int, string> */
public array $scripts = [];
public function run(Server $server, string $script): string
{
$this->scripts[] = $script;
return str_contains($script, 'docker manifest inspect')
? "image_digest=sha256:registrydigest\n"
: "image_digest=billing-api:aaaaaaaaaaaa@sha256:localdigest\n";
}
};
app()->instance(RemoteCommandRunner::class, $this->remoteRunner);
});
it('builds a target-server artifact over ssh with a temporary deploy key and stores the resolved digest', function () {
$organisation = Organisation::factory()->create();
$server = buildServerFor($organisation);
$application = Application::factory()->for($organisation)->create([
'name' => 'Billing API',
'repository_url' => 'git@example.com:org/repo.git',
]);
app(GenerateDeployKey::class)->execute($application, [
'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone',
'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => 'SHA256:test',
]);
$environment = app(CreateLaravelEnvironment::class)->execute($application->refresh(), 'production');
$environment->services()->first()->update(['server_id' => $server->id]);
$artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('a', 40));
$built = app(BuildApplicationArtifact::class)->execute($artifact);
expect($built->status)->toBe(BuildArtifactStatus::AVAILABLE)
->and($built->image_digest)->toBe('sha256:localdigest')
->and($this->remoteRunner->scripts[0])->toContain('GIT_SSH_COMMAND')
->and($this->remoteRunner->scripts[0])->toContain('git clone --depth 1 --branch')
->and($this->remoteRunner->scripts[0])->toContain('docker build --file Dockerfile.keystone')
->and($this->remoteRunner->scripts[0])->toContain('/home/keystone/operations/build-')
->and($this->remoteRunner->scripts[0])->toContain('trap cleanup EXIT');
});
it('resolves external registry artifacts without building locally', function () {
$organisation = Organisation::factory()->create();
$server = buildServerFor($organisation);
$organisation->registries()->create([
'name' => 'GHCR',
'type' => RegistryType::GHCR,
'url' => 'ghcr.io/example',
]);
$application = Application::factory()->for($organisation)->create([
'name' => 'Billing API',
'repository_url' => 'git@example.com:org/repo.git',
]);
$environment = app(CreateLaravelEnvironment::class)->execute($application->refresh(), 'production');
$environment->services()->first()->update(['server_id' => $server->id]);
$environment->services()->first()->update(['desired_replicas' => 2]);
$artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('b', 40));
$built = app(BuildApplicationArtifact::class)->execute($artifact);
expect($built->registry_ref)->toBe('ghcr.io/example/billing-api:bbbbbbbbbbbb')
->and($built->image_digest)->toBe('sha256:registrydigest')
->and($this->remoteRunner->scripts[0])->toContain('docker manifest inspect')
->and($this->remoteRunner->scripts[0])->toContain('ghcr.io/example/billing-api:bbbbbbbbbbbb')
->and($this->remoteRunner->scripts[0])->not->toContain('docker build')
->and($this->remoteRunner->scripts[0])->not->toContain('git clone');
});
function buildServerFor(Organisation $organisation): Server
{
$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();
}

View File

@@ -0,0 +1,81 @@
<?php
use App\Actions\Environments\PlanBuildArtifact;
use App\Enums\BuildArtifactStatus;
use App\Enums\BuildStrategy;
use App\Enums\DeployPolicy;
use App\Enums\RegistryType;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\Service;
it('plans single-server builds on the target server without requiring a registry', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->for($organisation)->create(['name' => 'Billing API']);
$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' => 1,
]);
$artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('a', 40));
expect($artifact->status)->toBe(BuildArtifactStatus::PENDING)
->and($artifact->image_tag)->toBe('billing-api:aaaaaaaaaaaa')
->and($artifact->registry_ref)->toBeNull()
->and($artifact->metadata['build_strategy'])->toBe(BuildStrategy::TARGET_SERVER->value);
});
it('requires a registry before planning multi-server builds', 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 () => app(PlanBuildArtifact::class)->execute($environment, str_repeat('b', 40)))
->toThrow(RuntimeException::class, 'A registry is required before building artifacts for multi-server deployments.');
});
it('plans multi-server builds against the configured external registry', function () {
$organisation = Organisation::factory()->create();
$organisation->registries()->create([
'name' => 'GHCR',
'type' => RegistryType::GHCR,
'url' => 'ghcr.io/example',
]);
$application = Application::factory()->for($organisation)->create(['name' => 'Billing API']);
$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,
]);
$artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('c', 40));
expect($artifact->registry_ref)->toBe('ghcr.io/example/billing-api:cccccccccccc')
->and($artifact->metadata['build_strategy'])->toBe(BuildStrategy::EXTERNAL_REGISTRY->value);
});

View File

@@ -0,0 +1,174 @@
<?php
use App\Enums\DeployPolicy;
use App\Enums\EnvironmentVariableSource;
use App\Enums\SchedulerMode;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Environment;
use App\Models\Service;
use App\Services\Compose\ComposeRenderer;
it('renders docker compose for managed postgres services', function () {
$service = Service::factory()->create([
'name' => 'primary-postgres',
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
'deploy_policy' => DeployPolicy::DEPENDENCY_ONLY,
'credentials' => [
'user' => 'keystone',
'password' => 'secret',
'db' => 'keystone',
],
'default_cpu_limit' => 1.5,
'default_memory_limit_mb' => 1024,
]);
$compose = app(ComposeRenderer::class)->render($service);
expect($compose)
->toContain('services:')
->toContain('primary_postgres:')
->toContain('image: "postgres:18"')
->toContain('cpus: 1.500')
->toContain('mem_limit: "1024m"')
->toContain("keystone_service_{$service->id}_postgres_data:");
});
it('renders compose for caddy gateway with public ports and named volumes', function () {
$service = Service::factory()->create([
'name' => 'gateway',
'category' => ServiceCategory::GATEWAY,
'type' => ServiceType::CADDY,
'version' => '2',
'version_track' => '2',
'driver_name' => 'caddy.2',
]);
$compose = app(ComposeRenderer::class)->render($service);
expect($compose)
->toContain('image: "caddy:2"')
->toContain('- "80:80"')
->toContain('- "443:443"')
->toContain("keystone_service_{$service->id}_caddy_data:");
});
it('renders laravel scheduler and worker runtime roles into compose', function () {
$environment = Environment::factory()->create([
'scheduler_enabled' => true,
'scheduler_mode' => SchedulerMode::SINGLE,
]);
$web = Service::factory()->for($environment)->create([
'name' => 'web',
'category' => ServiceCategory::APPLICATION,
'type' => ServiceType::LARAVEL,
'version' => 'php-8.4',
'version_track' => 'php-8.4',
'driver_name' => 'laravel.php-8.4',
'process_roles' => ['web', 'scheduler'],
'desired_replicas' => 1,
]);
$environment->forceFill(['scheduler_target_service_id' => $web->id])->save();
$worker = Service::factory()->for($environment)->create([
'name' => 'worker',
'category' => ServiceCategory::APPLICATION,
'type' => ServiceType::LARAVEL,
'version' => 'php-8.4',
'version_track' => 'php-8.4',
'driver_name' => 'laravel.php-8.4',
'process_roles' => ['worker'],
'config' => [
'command' => 'php artisan queue:work --sleep=3 --tries=3',
],
]);
expect(app(ComposeRenderer::class)->render($web))
->toContain('AUTORUN_LARAVEL_SCHEDULER: "true"')
->toContain('healthcheck:')
->and(app(ComposeRenderer::class)->render($worker))
->toContain('command: "php artisan queue:work --sleep=3 --tries=3"')
->not->toContain('healthcheck:');
});
it('enforces scheduler mode when rendering laravel runtime env', function () {
$environment = Environment::factory()->create([
'scheduler_enabled' => true,
'scheduler_mode' => SchedulerMode::SINGLE,
]);
$target = Service::factory()->for($environment)->create([
'name' => 'web',
'category' => ServiceCategory::APPLICATION,
'type' => ServiceType::LARAVEL,
'version' => 'php-8.4',
'version_track' => 'php-8.4',
'driver_name' => 'laravel.php-8.4',
'process_roles' => ['web', 'scheduler'],
'desired_replicas' => 1,
]);
$nonTarget = Service::factory()->for($environment)->create([
'name' => 'worker',
'category' => ServiceCategory::APPLICATION,
'type' => ServiceType::LARAVEL,
'version' => 'php-8.4',
'version_track' => 'php-8.4',
'driver_name' => 'laravel.php-8.4',
'process_roles' => ['worker', 'scheduler'],
'desired_replicas' => 1,
]);
$environment->forceFill(['scheduler_target_service_id' => $target->id])->save();
expect(app(ComposeRenderer::class)->render($target->refresh()))
->toContain('AUTORUN_LARAVEL_SCHEDULER: "true"')
->and(app(ComposeRenderer::class)->render($nonTarget->refresh()))
->not->toContain('AUTORUN_LARAVEL_SCHEDULER');
$environment->forceFill([
'scheduler_mode' => SchedulerMode::EVERY_REPLICA,
'scheduler_target_service_id' => null,
])->save();
expect(app(ComposeRenderer::class)->render($target->refresh()))
->toContain('AUTORUN_LARAVEL_SCHEDULER: "true"')
->and(app(ComposeRenderer::class)->render($nonTarget->refresh()))
->toContain('AUTORUN_LARAVEL_SCHEDULER: "true"');
});
it('renders environment variables into laravel runtime compose', function () {
$environment = Environment::factory()->create(['name' => 'production']);
$environment->variables()->create([
'key' => 'DB_CONNECTION',
'value' => 'pgsql',
'source' => EnvironmentVariableSource::MANAGED_ATTACHMENT,
'overridable' => false,
]);
$environment->variables()->create([
'key' => 'FEATURE_FLAG',
'value' => 'enabled',
'source' => EnvironmentVariableSource::USER,
'overridable' => true,
]);
$service = Service::factory()->for($environment)->create([
'name' => 'web',
'category' => ServiceCategory::APPLICATION,
'type' => ServiceType::LARAVEL,
'version' => 'php-8.4',
'version_track' => 'php-8.4',
'driver_name' => 'laravel.php-8.4',
'process_roles' => ['web'],
]);
expect(app(ComposeRenderer::class)->render($service))
->toContain('DB_CONNECTION: "pgsql"')
->toContain('FEATURE_FLAG: "enabled"')
->toContain('APP_ENV: "production"');
expect(app(ComposeRenderer::class)->renderEnvironmentFile($service))
->toContain('DB_CONNECTION=pgsql')
->toContain('FEATURE_FLAG=enabled')
->toContain('APP_ENV=production');
});

View File

@@ -0,0 +1,454 @@
<?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.');
});

View File

@@ -0,0 +1,21 @@
<?php
use App\Drivers\Driver;
it('requires every configured v1 driver to expose runtime capabilities', function () {
foreach (config('keystone.drivers') as $versions) {
foreach ($versions as $driverClass) {
$driver = new $driverClass;
expect($driver)->toBeInstanceOf(Driver::class)
->and($driver->serviceType()->value)->not->toBeEmpty()
->and($driver->versionTrack())->not->toBeEmpty()
->and($driver->defaultImage())->not->toBeEmpty()
->and($driver->defaultPorts())->toBeArray()
->and($driver->firewallRules())->toBeArray()
->and($driver->environmentSchema())->toBeArray()
->and($driver->resourceDefaults())->toBeArray()
->and($driver->updateBehavior())->not->toBeEmpty();
}
}
});

View File

@@ -0,0 +1,76 @@
<?php
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\Service;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
it('shows the managed attachment create page for an environment', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
'name' => 'postgres',
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
]);
$response = $this->actingAs($user)->get(route('environment-attachments.create', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('environment-attachments/Create', false)
->has('services', 1)
->where('roles.0', EnvironmentAttachmentRole::DATABASE->value));
});
it('stores a managed attachment and generated environment variables', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create(['name' => 'Billing API']);
$environment = Environment::factory()->for($application)->create(['name' => 'production']);
$service = Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
'name' => 'postgres',
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
]);
$response = $this->actingAs($user)->post(route('environment-attachments.store', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]), [
'service_id' => $service->id,
'role' => EnvironmentAttachmentRole::DATABASE->value,
'name' => 'billing_api',
'is_primary' => true,
]);
$response->assertRedirect(route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
expect($environment->attachments()->where('service_id', $service->id)->exists())->toBeTrue()
->and($environment->variables()->where('key', 'DB_CONNECTION')->first()->value)->toBe('pgsql')
->and($service->slices()->where('name', 'billing_api')->exists())->toBeTrue();
});

View File

@@ -0,0 +1,95 @@
<?php
use App\Actions\Applications\GenerateDeployKey;
use App\Enums\DeployPolicy;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
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\User;
use App\Services\Operations\RemoteCommandRunner;
use Illuminate\Support\Facades\Process;
it('runs an environment deployment from the application surface', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$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();
app(GenerateDeployKey::class)->execute($application, [
'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone',
'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => 'SHA256:test',
]);
$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,
]);
app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner
{
public function run(Server $server, string $script): string
{
return "container_id=container-1\nhealth_status=running\nimage_digest=billing-api:aaaaaaaaaaaa@sha256:controllerdigest\n";
}
});
Process::fake([
'*' => Process::result(output: str_repeat('a', 40)."\trefs/heads/main\n"),
]);
$response = $this->actingAs($user)->post(route('environment-deployments.store', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$response->assertRedirect(route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$parent = Operation::query()
->whereMorphedTo('target', $environment)
->where('kind', OperationKind::ENVIRONMENT_DEPLOY)
->first();
$serviceDeploy = Operation::query()
->whereMorphedTo('target', $service)
->where('kind', OperationKind::SERVICE_DEPLOY)
->first();
expect($parent)->not->toBeNull()
->and($parent->status)->toBe(OperationStatus::COMPLETED)
->and($serviceDeploy)->not->toBeNull()
->and($serviceDeploy->status)->toBe(OperationStatus::COMPLETED)
->and($serviceDeploy->steps()->where('name', 'Render Compose files')->first()->script)->toContain("base64 -d > /home/keystone/services/{$service->id}/compose.yml")
->and($serviceDeploy->steps()->where('name', 'Render Compose files')->first()->script)->toContain("base64 -d > /home/keystone/services/{$service->id}/.env")
->and($service->refresh()->replicas)->toHaveCount(1)
->and($service->available_image_digest)->toBe('sha256:controllerdigest');
});

View File

@@ -0,0 +1,113 @@
<?php
use App\Actions\Environments\PlanEnvironmentDeployment;
use App\Enums\DeployPolicy;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\SchedulerMode;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\Service;
it('deploys only with-environment services and checks dependency attachments', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$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,
]);
$postgres = Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
'name' => 'postgres',
'deploy_policy' => DeployPolicy::DEPENDENCY_ONLY,
]);
$environment->attachments()->create([
'service_id' => $postgres->id,
'role' => EnvironmentAttachmentRole::DATABASE,
'is_primary' => true,
]);
$plan = app(PlanEnvironmentDeployment::class)->execute($environment);
expect($plan->services)
->toHaveCount(1)
->and($plan->services[0]->is($web))->toBeTrue()
->and($plan->dependencies)->toHaveCount(1)
->and($plan->dependencies[0]->is($postgres))->toBeTrue();
});
it('blocks multi-server environment deployments when no registry exists', 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,
'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,
]);
$plan = app(PlanEnvironmentDeployment::class)->execute($environment);
expect($plan->requiresRegistry)->toBeTrue();
});
it('warns about sync queues without creating worker services', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$environment->variables()->create([
'key' => 'QUEUE_CONNECTION',
'value' => 'sync',
'source' => 'user',
'overridable' => true,
]);
$plan = app(PlanEnvironmentDeployment::class)->execute($environment);
expect($plan->warnings)
->toContain('QUEUE_CONNECTION=sync is not recommended for deployed Laravel environments.');
});
it('blocks single scheduler mode when the scheduler target has multiple replicas', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create([
'scheduler_enabled' => true,
'scheduler_mode' => 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();
$plan = app(PlanEnvironmentDeployment::class)->execute($environment->refresh());
expect($plan->blockers)->toContain('Scheduler mode single requires the scheduler target service to run exactly one replica.');
});

View File

@@ -0,0 +1,56 @@
<?php
use App\Actions\Environments\CreateMigrationOperation;
use App\Enums\OperationKind;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\Service;
use App\Models\User;
it('creates a manual migration operation for a laravel environment', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$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',
'process_roles' => ['web'],
'config' => [
'migration_mode' => 'manual',
'migration_command' => 'php artisan migrate --force',
],
]);
$response = $this->actingAs($user)->post(route('environment-migrations.store', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$response->assertRedirect(route('applications.show', [
'organisation' => $organisation->id,
'application' => $application->id,
]));
$operation = $service->operations()->firstOrFail();
expect($operation->kind)->toBe(OperationKind::CONFIG_CHANGE)
->and($operation->steps()->first()->script)
->toBe("docker compose -f /home/keystone/services/{$service->id}/compose.yml run --rm web php artisan migrate --force");
});
it('rejects migration operations without a laravel runtime service', function () {
$environment = Environment::factory()->create();
expect(fn () => app(CreateMigrationOperation::class)->execute($environment))
->toThrow(InvalidArgumentException::class, 'Laravel migrations must run against a Laravel runtime service.');
});

View File

@@ -0,0 +1,54 @@
<?php
use App\Enums\EnvironmentVariableSource;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
it('shows the user environment variable create page', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$response = $this->actingAs($user)->get(route('environment-variables.create', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('environment-variables/Create', false));
});
it('stores editable user-defined environment variables', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$response = $this->actingAs($user)->post(route('environment-variables.store', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]), [
'key' => 'APP_DEBUG',
'value' => 'false',
]);
$response->assertRedirect(route('applications.show', [
'organisation' => $organisation->id,
'application' => $application->id,
]));
$variable = $environment->variables()->firstOrFail();
expect($variable->key)->toBe('APP_DEBUG')
->and($variable->value)->toBe('false')
->and($variable->source)->toBe(EnvironmentVariableSource::USER)
->and($variable->overridable)->toBeTrue()
->and($variable->service_slice_id)->toBeNull();
});

View File

@@ -0,0 +1,55 @@
<?php
use App\Enums\DeployPolicy;
use App\Enums\ServiceType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\User;
it('creates a dedicated laravel worker service for an environment', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create([
'build_config' => [
'php_version' => '8.4',
],
]);
$response = $this->actingAs($user)->post(route('environment-workers.store', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]));
$response->assertRedirect(route('applications.show', [
'organisation' => $organisation->id,
'application' => $application->id,
]));
$worker = $environment->services()->where('name', 'worker')->firstOrFail();
expect($worker->type)->toBe(ServiceType::LARAVEL)
->and($worker->deploy_policy)->toBe(DeployPolicy::WITH_ENVIRONMENT)
->and($worker->process_roles)->toBe(['worker'])
->and($worker->config['command'])->toBe('php artisan queue:work --sleep=3 --tries=3');
});
it('does not create duplicate worker services for repeated clicks', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$route = route('environment-workers.store', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]);
$this->actingAs($user)->post($route);
$this->actingAs($user)->post($route);
expect($environment->services()->where('name', 'worker')->count())->toBe(1);
});

View File

@@ -0,0 +1,115 @@
<?php
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\SchedulerMode;
use App\Models\Application;
use App\Models\BuildArtifact;
use App\Models\Environment;
use App\Models\Operation;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceReplica;
use App\Models\ServiceSlice;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('creates an environment as the primary application deployment unit', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
expect($environment)
->toBeInstanceOf(Environment::class)
->and($environment->scheduler_enabled)->toBeTrue()
->and($environment->scheduler_mode)->toBe(SchedulerMode::SINGLE);
});
it('records operations and operation steps for service deployments', function () {
$service = Service::factory()->create();
$operation = Operation::factory()->make([
'kind' => OperationKind::SERVICE_DEPLOY,
'status' => OperationStatus::PENDING,
]);
$service->operations()->save($operation);
$operation->steps()->create([
'name' => 'Render Compose file',
'order' => 1,
'status' => OperationStatus::PENDING,
'script' => 'docker compose config',
]);
expect($operation)
->toBeInstanceOf(Operation::class)
->and($operation->hash)->not->toBeEmpty()
->and($operation->steps)->toHaveCount(1)
->and($operation->steps->first()->operation->is($operation))->toBeTrue();
});
it('models replicas slices attachments variables and build artifacts', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$service = Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
]);
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = $organisation->networks()->create([
'name' => 'keystone',
'external_id' => 'net-12345',
'provider_id' => $provider->id,
'ip_range' => fake()->ipv4().'/24',
]);
$server = Server::factory()->create([
'organisation_id' => $organisation->id,
'provider_id' => $provider->id,
'network_id' => $network->id,
]);
$replica = $service->replicas()->create([
'server_id' => $server->id,
'container_name' => 'postgres-1',
'image_digest' => 'sha256:postgres',
'internal_host' => 'postgres-1',
'internal_port' => 5432,
'status' => 'running',
]);
$slice = ServiceSlice::factory()->for($service)->create([
'environment_id' => $environment->id,
'name' => 'production',
'credentials' => ['username' => 'app', 'password' => 'secret', 'database' => 'app'],
]);
$attachment = $environment->attachments()->create([
'service_id' => $service->id,
'service_slice_id' => $slice->id,
'role' => 'database',
'is_primary' => true,
]);
$variable = $environment->variables()->create([
'key' => 'DB_PASSWORD',
'value' => 'secret',
'source' => 'managed_attachment',
'service_slice_id' => $slice->id,
'overridable' => false,
]);
$artifact = $environment->buildArtifacts()->create([
'commit_sha' => str_repeat('a', 40),
'image_tag' => 'app:abc123',
'image_digest' => 'sha256:abc123',
'status' => 'available',
]);
expect($service->slices->first())->toBeInstanceOf(ServiceSlice::class)
->and($replica)->toBeInstanceOf(ServiceReplica::class)
->and($attachment->serviceSlice->is($slice))->toBeTrue()
->and($variable->value)->toBe('secret')
->and($artifact)->toBeInstanceOf(BuildArtifact::class);
});

View File

@@ -0,0 +1,71 @@
<?php
use App\Actions\Applications\CreateLaravelEnvironment;
use App\Enums\DeployPolicy;
use App\Enums\SchedulerMode;
use App\Enums\ServiceType;
use App\Models\Application;
use App\Models\Organisation;
it('creates a laravel environment with a primary web scheduler target and no worker by default', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->for($organisation)->create([
'default_branch' => 'main',
]);
$environment = app(CreateLaravelEnvironment::class)->execute($application, 'production');
$web = $environment->services()->first();
expect($environment->branch)->toBe('main')
->and($environment->scheduler_enabled)->toBeTrue()
->and($environment->scheduler_mode)->toBe(SchedulerMode::SINGLE)
->and($environment->scheduler_target_service_id)->toBe($web->id)
->and($web->type)->toBe(ServiceType::LARAVEL)
->and($web->deploy_policy)->toBe(DeployPolicy::WITH_ENVIRONMENT)
->and($web->process_roles)->toBe(['web', 'scheduler'])
->and($environment->services()->whereJsonContains('process_roles', 'worker')->exists())->toBeFalse();
});
it('provides a managed serversideup frankenphp dockerfile template', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->for($organisation)->create();
$environment = app(CreateLaravelEnvironment::class)->execute($application, 'production');
$web = $environment->services()->first();
$dockerfile = $web->driver()->dockerfileTemplate();
expect($dockerfile)
->toContain('FROM serversideup/php:8.4-frankenphp')
->toContain('composer install --no-dev')
->toContain('SERVER_DOCUMENT_ROOT=/var/www/html/public');
});
it('renders configurable javascript build steps for managed laravel artifacts', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->for($organisation)->create();
$environment = app(CreateLaravelEnvironment::class)->execute($application, 'production');
$web = $environment->services()->first();
$web->update([
'config' => [
...$web->config,
'js_package_manager' => 'bun',
'js_build_command' => 'bun run build',
],
]);
expect($web->refresh()->driver()->dockerfileTemplate())
->toContain('bun install --frozen-lockfile')
->toContain('bun run build');
$web->update([
'config' => [
...$web->config,
'js_package_manager' => 'npm',
'js_build_command' => 'npm run build',
],
]);
expect($web->refresh()->driver()->dockerfileTemplate())
->toContain('npm ci && npm run build');
});

View File

@@ -0,0 +1,99 @@
<?php
use App\Actions\Environments\AttachManagedService;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\EnvironmentVariableSource;
use App\Enums\OperationKind;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Models\Service;
it('creates a postgres database slice and managed environment variables', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->for($organisation)->create(['name' => 'Billing API']);
$environment = Environment::factory()->for($application)->create(['name' => 'production']);
$service = Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
'name' => 'postgres',
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
]);
$attachment = app(AttachManagedService::class)->execute(
environment: $environment,
service: $service,
role: EnvironmentAttachmentRole::DATABASE,
);
expect($attachment->serviceSlice)
->not->toBeNull()
->and($attachment->serviceSlice->type)->toBe('database_user')
->and($environment->variables()->where('key', 'DB_CONNECTION')->first()->value)->toBe('pgsql')
->and($environment->variables()->where('key', 'DB_PASSWORD')->first()->source)->toBe(EnvironmentVariableSource::MANAGED_ATTACHMENT)
->and($environment->variables()->where('key', 'DB_PASSWORD')->first()->overridable)->toBeFalse()
->and($attachment->serviceSlice->operations()->first()->kind)->toBe(OperationKind::SLICE_PROVISION)
->and($attachment->serviceSlice->operations()->first()->steps()->first()->script)->toContain('CREATE DATABASE');
});
it('creates a valkey logical slice without silently changing queue behavior', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$service = Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
'name' => 'valkey',
'category' => ServiceCategory::CACHE,
'type' => ServiceType::VALKEY,
'version' => '8',
'version_track' => '8',
'driver_name' => 'valkey.8',
]);
app(AttachManagedService::class)->execute(
environment: $environment,
service: $service,
role: EnvironmentAttachmentRole::CACHE,
);
expect($environment->variables()->pluck('key')->all())
->toContain('REDIS_HOST', 'REDIS_PORT', 'REDIS_DB', 'CACHE_STORE')
->not->toContain('QUEUE_CONNECTION');
$slice = $service->slices()->first();
expect($slice->config['database'])->toBe(1)
->and($slice->operations()->first()->kind)->toBe(OperationKind::SLICE_PROVISION)
->and($slice->operations()->first()->steps()->first()->script)->toContain('valkey-cli');
});
it('creates a caddy route slice with an independent provision operation', function () {
$organisation = Organisation::factory()->create();
$application = Application::factory()->for($organisation)->create();
$environment = Environment::factory()->for($application)->create();
$service = Service::factory()->for($environment)->create([
'organisation_id' => $organisation->id,
'name' => 'gateway',
'category' => ServiceCategory::GATEWAY,
'type' => ServiceType::CADDY,
'version' => '2',
'version_track' => '2',
'driver_name' => 'caddy.2',
]);
$attachment = app(AttachManagedService::class)->execute(
environment: $environment,
service: $service,
role: EnvironmentAttachmentRole::GATEWAY,
name: 'example.com',
);
expect($attachment->serviceSlice->type)->toBe('route')
->and($attachment->serviceSlice->operations()->first()->kind)->toBe(OperationKind::SLICE_PROVISION)
->and($attachment->serviceSlice->operations()->first()->steps()->first()->script)->toContain('/home/keystone/gateway/Caddyfile.d');
});

View File

@@ -0,0 +1,43 @@
<?php
use App\Models\Provider;
use App\Models\Server;
use Illuminate\Support\Facades\File;
it('renders the v1 provisioning script with docker compose and management keys', function () {
File::ensureDirectoryExists(storage_path('app/private/ssh'));
File::put(storage_path('app/private/ssh/id_ed25519.pub'), 'ssh-ed25519 keystone-public-key');
$organisation = \App\Models\Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = $organisation->networks()->create([
'name' => 'keystone',
'external_id' => 'net-12345',
'provider_id' => $provider->id,
'ip_range' => fake()->ipv4().'/24',
]);
$server = Server::factory()->create([
'organisation_id' => $organisation->id,
'provider_id' => $provider->id,
'network_id' => $network->id,
]);
$response = $this->get(route('provision-script', [
'sudo_password' => 'secret-password',
'hostname' => 'keystone-test',
'server_id' => $server->id,
]));
$response->assertOk();
expect($response->content())
->toContain('docker-compose-plugin')
->toContain('fail2ban')
->toContain('ufw --force enable')
->toContain('PasswordAuthentication no')
->toContain('ssh-ed25519 keystone-public-key')
->toContain('chmod 600 /home/keystone/.ssh/id_ed25519')
->not->toContain('[!hostname!]')
->not->toContain('[!sudo_password!]')
->not->toContain('[!server_id!]')
->not->toContain('[!keystonepublickey!]');
});

View File

@@ -0,0 +1,49 @@
<?php
use App\Enums\RegistryType;
use App\Models\Organisation;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
it('shows the create registry page', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$response = $this->actingAs($user)->get(route('registries.create', [
'organisation' => $organisation->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('registries/Create', false)
->where('registryTypes.0', RegistryType::GENERIC->value));
});
it('stores a registry for multi-server build artifacts', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$response = $this->actingAs($user)->post(route('registries.store', [
'organisation' => $organisation->id,
]), [
'name' => 'GHCR',
'type' => RegistryType::GHCR->value,
'url' => 'ghcr.io/example/',
'username' => 'keystone',
'password' => 'secret',
]);
$response->assertRedirect(route('organisations.show', [
'organisation' => $organisation->id,
]));
$registry = $organisation->registries()->firstOrFail();
expect($registry->name)->toBe('GHCR')
->and($registry->type)->toBe(RegistryType::GHCR)
->and($registry->url)->toBe('ghcr.io/example')
->and($registry->credentials)->toMatchArray([
'username' => 'keystone',
'password' => 'secret',
]);
});

View File

@@ -0,0 +1,54 @@
<?php
use App\Actions\Applications\GenerateDeployKey;
use App\Actions\Applications\VerifyRepositoryAccess;
use App\Models\Application;
use Illuminate\Support\Facades\Process;
it('stores generated deploy keys on the application without marking them installed', function () {
$application = Application::factory()->create([
'deploy_key_installed_at' => now(),
]);
app(GenerateDeployKey::class)->execute($application, [
'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone',
'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => 'SHA256:test-fingerprint',
]);
expect($application->refresh())
->deploy_key_public->toStartWith('ssh-ed25519')
->deploy_key_private->toContain('OPENSSH PRIVATE KEY')
->deploy_key_fingerprint->toBe('SHA256:test-fingerprint')
->deploy_key_installed_at->toBeNull();
});
it('verifies repository access with git ls-remote and a temporary ssh command', function () {
Process::fake([
'*' => Process::result(output: "abc123\trefs/heads/main\n"),
]);
$application = Application::factory()->create([
'repository_url' => 'git@example.com:org/repo.git',
'default_branch' => 'main',
]);
app(GenerateDeployKey::class)->execute($application, [
'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone',
'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => 'SHA256:test-fingerprint',
]);
$verified = app(VerifyRepositoryAccess::class)->execute($application->refresh());
expect($verified)->toBeTrue()
->and($application->refresh()->deploy_key_installed_at)->not->toBeNull();
Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return str_contains($command, 'git')
&& str_contains($command, 'ls-remote')
&& str_contains($command, 'git@example.com:org/repo.git')
&& ($process->environment['GIT_SSH_COMMAND'] ?? null) !== null;
});
});

View File

@@ -42,7 +42,7 @@ test('index route displays servers for an organisation', function () {
$response = $this->get(route('servers.index', ['organisation' => $organisation->id]));
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Index'));
->component('servers/Index', false));
});
test('create route returns inertia view', function () {
@@ -50,7 +50,7 @@ test('create route returns inertia view', function () {
$response = $this->get(route('servers.create', ['organisation' => $organisation->id]));
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Create'));
->component('servers/Create', false));
});
test('store route fails with invalid provider', function () {
@@ -86,6 +86,9 @@ test('store route creates a server with valid data', function () {
]);
$this->partialMock(HetznerService::class, function (MockInterface $mock) use ($network) {
$mock->shouldReceive('forProvider')
->andReturnSelf();
$mock->shouldReceive('createServer')
->once()
->andReturn(new CreatedServer(
@@ -141,5 +144,5 @@ test('show route displays a single server', function () {
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('servers/Show'));
->component('servers/Show', false));
});

View File

@@ -2,20 +2,21 @@
use App\Actions\Services\CreateService;
use App\Drivers\Driver;
use App\Enums\DeployPolicy;
use App\Enums\ServiceCategory;
use App\Enums\ServiceStatus;
use App\Enums\ServiceType;
use App\Jobs\Services\DeployService;
use App\Models\Network;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Models\Service;
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Config;
use Inertia\Testing\AssertableInertia;
uses(RefreshDatabase::class);
@@ -24,11 +25,11 @@ function setupTestEnvironment()
$user = User::factory()->create();
$organisation = Organisation::factory()->create([
'owner_id' => $user->id
'owner_id' => $user->id,
]);
$provider = Provider::factory()->create([
'organisation_id' => $organisation->id
'organisation_id' => $organisation->id,
]);
$network = Network::create([
@@ -61,13 +62,13 @@ test('create service page is accessible', function () {
$response = $this->get(route('services.create', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id
'server' => $setup['server']->id,
]));
$response->assertStatus(200);
$response->assertInertia(
fn(AssertableInertia $page) => $page
->component('services/Create')
fn (AssertableInertia $page) => $page
->component('services/Create', false)
->has('server')
->has('services')
);
@@ -81,7 +82,7 @@ test('store service with valid data', function () {
$mockDefaultCredentials = [
'user' => 'test-user',
'password' => 'test-password',
'db' => 'test-db'
'db' => 'test-db',
];
$mockDriver = Mockery::mock(Driver::class);
@@ -98,18 +99,18 @@ test('store service with valid data', function () {
'name' => 'test-postgres-database',
'category' => ServiceCategory::DATABASE->value,
'type' => ServiceType::POSTGRES->value,
'version' => '17',
'version' => '18',
];
$response = $this->post(route('services.store', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id
'server' => $setup['server']->id,
]), $data);
// Since we're not mocking the entire CreateService action, we should get a proper redirect
$response->assertRedirect(route('servers.show', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id
'server' => $setup['server']->id,
]));
$response->assertSessionHas('success', 'Service created successfully');
@@ -118,10 +119,28 @@ test('store service with valid data', function () {
'server_id' => $setup['server']->id,
'category' => ServiceCategory::DATABASE->value,
'type' => ServiceType::POSTGRES->value,
'version' => '17',
'driver_name' => 'postgres.17',
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
'deploy_policy' => DeployPolicy::DEPENDENCY_ONLY->value,
'status' => ServiceStatus::NOT_INSTALLED->value,
]);
$service = Service::query()->where('name', 'test-postgres-database')->firstOrFail();
expect($service->credentials)
->toHaveKey('user')
->toHaveKey('password')
->toHaveKey('db');
$this->assertDatabaseHas('service_replicas', [
'service_id' => $service->id,
'server_id' => $setup['server']->id,
'container_name' => "keystone-service-{$service->id}-1",
'internal_host' => "keystone-service-{$service->id}",
'internal_port' => 5432,
'status' => 'pending',
'health_status' => 'unknown',
]);
Bus::assertDispatched(DeployService::class);
});
@@ -140,7 +159,7 @@ test('store service with invalid data', function () {
$response = $this->post(route('services.store', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id
'server' => $setup['server']->id,
]), $data);
$response->assertSessionHasErrors(['name', 'category', 'type', 'version']);
@@ -152,29 +171,79 @@ test('store service validates version exists in config', function () {
$this->actingAs($setup['user']);
// Mock the config to simulate the version not existing
Config::set('keystone.services.' . ServiceCategory::DATABASE->value . '.' . ServiceType::POSTGRES->value . '.versions', [
'16' => [
'name' => 'PostgreSQL 16',
'description' => 'PostgreSQL 16',
'image' => 'postgres:16',
]
Config::set('keystone.services.'.ServiceCategory::DATABASE->value.'.'.ServiceType::POSTGRES->value.'.versions', [
'17' => [
'name' => 'PostgreSQL 17',
'description' => 'PostgreSQL 17',
'image' => 'postgres:17',
],
]);
$data = [
'name' => 'test-postgres-database',
'category' => ServiceCategory::DATABASE->value,
'type' => ServiceType::POSTGRES->value,
'version' => '17', // This version doesn't exist in our mocked config
'version' => '18', // This version doesn't exist in our mocked config
];
$response = $this->post(route('services.store', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id
'server' => $setup['server']->id,
]), $data);
$response->assertSessionHasErrors(['version']);
});
test('store service prevents duplicate gateway on the same server', function () {
$setup = setupTestEnvironment();
$this->actingAs($setup['user']);
Service::factory()->for($setup['server'])->create([
'organisation_id' => $setup['organisation']->id,
'name' => 'gateway',
'category' => ServiceCategory::GATEWAY,
'type' => ServiceType::CADDY,
'version' => '2',
'version_track' => '2',
'driver_name' => 'caddy.2',
]);
$response = $this->post(route('services.store', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id,
]), [
'name' => 'another-gateway',
'category' => ServiceCategory::GATEWAY->value,
'type' => ServiceType::CADDY->value,
'version' => '2',
]);
$response->assertSessionHasErrors(['category' => 'This server already has a gateway service.']);
});
test('create service action prevents duplicate gateway on the same server', function () {
$setup = setupTestEnvironment();
Service::factory()->for($setup['server'])->create([
'organisation_id' => $setup['organisation']->id,
'name' => 'gateway',
'category' => ServiceCategory::GATEWAY,
'type' => ServiceType::CADDY,
'version' => '2',
'version_track' => '2',
'driver_name' => 'caddy.2',
]);
expect(fn () => app(CreateService::class)->execute(
server: $setup['server'],
name: 'another-gateway',
category: ServiceCategory::GATEWAY,
type: ServiceType::CADDY,
version: '2',
))->toThrow(RuntimeException::class, 'This server already has a gateway service.');
});
test('store service with non-existent server returns 404', function () {
$setup = setupTestEnvironment();
@@ -184,12 +253,12 @@ test('store service with non-existent server returns 404', function () {
'name' => 'test-postgres-database',
'category' => ServiceCategory::DATABASE->value,
'type' => ServiceType::POSTGRES->value,
'version' => '17',
'version' => '18',
];
$response = $this->post(route('services.store', [
'organisation' => $setup['organisation']->id,
'server' => 9999
'server' => 9999,
]), $data);
$response->assertStatus(404);
@@ -202,7 +271,7 @@ test('create service page with non-existent server returns 404', function () {
$response = $this->get(route('services.create', [
'organisation' => $setup['organisation']->id,
'server' => 9999
'server' => 9999,
]));
$response->assertStatus(404);
@@ -217,7 +286,7 @@ test('store service is properly created and dispatched', function () {
->andReturn([
'user' => 'test-user',
'password' => 'test-password',
'db' => 'test-db'
'db' => 'test-db',
])
->getMock();
@@ -226,7 +295,7 @@ test('store service is properly created and dispatched', function () {
'name' => 'test-postgres-database',
'category' => ServiceCategory::DATABASE->value,
'type' => ServiceType::POSTGRES->value,
'version' => '17',
'version' => '18',
];
// Mock service class to return our mock driver
@@ -243,7 +312,7 @@ test('store service is properly created and dispatched', function () {
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => $testData['version'],
'driver_name' => 'postgres.17',
'driver_name' => 'postgres.18',
'status' => ServiceStatus::NOT_INSTALLED,
]);
@@ -266,27 +335,15 @@ test('store service is properly created and dispatched', function () {
// Execute request
$response = $this->post(route('services.store', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id
'server' => $setup['server']->id,
]), $testData);
// Assert response
$response->assertRedirect(route('servers.show', [
'organisation' => $setup['organisation']->id,
'server' => $setup['server']->id
'server' => $setup['server']->id,
]));
$response->assertSessionHas('success', 'Service created successfully');
// Assert database state
$this->assertDatabaseHas('services', [
'name' => $testData['name'],
'server_id' => $setup['server']->id,
'category' => ServiceCategory::DATABASE->value,
'type' => ServiceType::POSTGRES->value,
'version' => $testData['version'],
'driver_name' => 'postgres.17',
'status' => ServiceStatus::NOT_INSTALLED->value,
]);
// Assert job was dispatched
Bus::assertDispatched(DeployService::class);
Bus::assertNotDispatched(DeployService::class);
});

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();
}

View File

@@ -0,0 +1,61 @@
<?php
use App\Actions\Services\RegisterServiceEndpoint;
use App\Enums\ServiceEndpointScope;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceReplica;
it('prefers docker network endpoints for same-server communication', function () {
$server = endpointTestServer();
$service = Service::factory()->create(['server_id' => $server->id]);
$producer = ServiceReplica::factory()->for($service)->for($server)->create([
'internal_host' => 'postgres-1',
'internal_port' => 5432,
]);
$consumer = ServiceReplica::factory()->for($server)->create();
$endpoint = app(RegisterServiceEndpoint::class)->execute($producer, $consumer);
expect($endpoint->scope)->toBe(ServiceEndpointScope::DOCKER_NETWORK)
->and($endpoint->hostname)->toBe('postgres-1')
->and($endpoint->ip_address)->toBeNull()
->and($endpoint->priority)->toBe(10);
});
it('uses private networking across servers before public fallback', function () {
$producerServer = endpointTestServer(['private_ip' => '10.0.0.10', 'ipv4' => '203.0.113.10']);
$consumerServer = endpointTestServer(['private_ip' => '10.0.0.11', 'ipv4' => '203.0.113.11']);
$service = Service::factory()->create(['server_id' => $producerServer->id]);
$producer = ServiceReplica::factory()->for($service)->for($producerServer)->create([
'internal_port' => 8080,
]);
$consumer = ServiceReplica::factory()->for($consumerServer)->create();
$endpoint = app(RegisterServiceEndpoint::class)->execute($producer, $consumer, allowPublicFallback: true);
expect($endpoint->scope)->toBe(ServiceEndpointScope::PRIVATE_NETWORK)
->and($endpoint->hostname)->toBe('10.0.0.10')
->and($endpoint->priority)->toBe(20);
});
function endpointTestServer(array $attributes = []): Server
{
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = $organisation->networks()->create([
'name' => 'keystone',
'external_id' => 'net-12345',
'provider_id' => $provider->id,
'ip_range' => fake()->ipv4().'/24',
]);
return Server::factory()->create([
'organisation_id' => $organisation->id,
'provider_id' => $provider->id,
'network_id' => $network->id,
...$attributes,
]);
}

View File

@@ -0,0 +1,85 @@
<?php
use App\Actions\Services\ResolveServiceImageDigest;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Network;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Models\Service;
use App\Services\Operations\RemoteCommandRunner;
it('resolves a service driver image tag to an immutable digest', function () {
app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner
{
public string $script = '';
public function run(Server $server, string $script): string
{
$this->script = $script;
return "image_digest=postgres:18@sha256:resolveddigest\n";
}
});
$service = Service::factory()->for(serviceDigestServer())->create([
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
'credentials' => [
'user' => 'keystone',
'password' => 'secret',
'db' => 'keystone',
],
]);
expect(app(ResolveServiceImageDigest::class)->execute($service))->toBe('sha256:resolveddigest');
});
it('pulls the image before failing digest resolution when it is not present locally', function () {
$runner = new class implements RemoteCommandRunner
{
public string $script = '';
public function run(Server $server, string $script): string
{
$this->script = $script;
return 'image_digest=valkey/valkey:8@sha256:pulleddigest';
}
};
app()->instance(RemoteCommandRunner::class, $runner);
$service = Service::factory()->for(serviceDigestServer())->create([
'category' => ServiceCategory::CACHE,
'type' => ServiceType::VALKEY,
'version' => '8',
'version_track' => '8',
'driver_name' => 'valkey.8',
]);
expect(app(ResolveServiceImageDigest::class)->execute($service))->toBe('sha256:pulleddigest')
->and($runner->script)->toContain('docker pull "$image"');
});
function serviceDigestServer(): 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();
}

View File

@@ -0,0 +1,93 @@
<?php
use App\Enums\OperationKind;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Network;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server;
use App\Models\Service;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
it('shows the stateful service update page with backup capability', function () {
[$user, $organisation, $server, $service] = serviceUpdateFixture([
'backup_enabled' => true,
'backup_command' => 'pg_dump keystone',
]);
$response = $this->actingAs($user)->get(route('service-updates.create', [
'organisation' => $organisation->id,
'server' => $server->id,
'service' => $service->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('services/updates/Create', false)
->where('backupAvailable', true)
->where('service.id', $service->id));
});
it('stores an explicit stateful update operation', function () {
[$user, $organisation, $server, $service] = serviceUpdateFixture([
'backup_enabled' => true,
'backup_command' => 'pg_dump keystone',
]);
$response = $this->actingAs($user)->post(route('service-updates.store', [
'organisation' => $organisation->id,
'server' => $server->id,
'service' => $service->id,
]), [
'image_digest' => 'sha256:newdigest',
'backup_requested' => true,
]);
$response->assertRedirect(route('servers.show', [
'organisation' => $organisation->id,
'server' => $server->id,
]));
$operation = $service->operations()->first();
expect($operation->kind)->toBe(OperationKind::SERVICE_DEPLOY)
->and($operation->steps()->where('name', 'Run pre-update backup')->exists())->toBeTrue()
->and($service->refresh()->available_image_digest)->toBe('sha256:newdigest')
->and($service->update_status)->toBe('update_pending');
});
/**
* @return array{0: User, 1: Organisation, 2: Server, 3: Service}
*/
function serviceUpdateFixture(array $serviceConfig = []): array
{
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$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();
$service = Service::factory()->create([
'organisation_id' => $organisation->id,
'server_id' => $server->id,
'name' => 'postgres',
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
'config' => $serviceConfig,
]);
return [$user, $organisation, $server, $service];
}

View File

@@ -0,0 +1,43 @@
<?php
use App\Enums\SourceProviderType;
use App\Models\Organisation;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
it('shows the create source provider page', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$response = $this->actingAs($user)->get(route('source-providers.create', [
'organisation' => $organisation->id,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('source-providers/Create', false)
->where('sourceProviderTypes.0', SourceProviderType::GITEA->value));
});
it('stores a source provider for repository onboarding', function () {
$user = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
$response = $this->actingAs($user)->post(route('source-providers.store', [
'organisation' => $organisation->id,
]), [
'name' => 'Gitea',
'type' => SourceProviderType::GITEA->value,
'url' => 'https://gitea.example.com/',
]);
$response->assertRedirect(route('organisations.show', [
'organisation' => $organisation->id,
]));
$sourceProvider = $organisation->sourceProviders()->firstOrFail();
expect($sourceProvider->name)->toBe('Gitea')
->and($sourceProvider->type)->toBe(SourceProviderType::GITEA)
->and($sourceProvider->url)->toBe('https://gitea.example.com');
});

View File

@@ -0,0 +1,80 @@
<?php
use App\Actions\Services\CreateStatefulServiceUpdateOperation;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Service;
it('creates explicit downtime-aware update operations for postgres', function () {
$service = Service::factory()->create([
'name' => 'postgres',
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
'current_image_digest' => 'sha256:old',
'config' => [
'backup_enabled' => true,
'backup_command' => 'pg_dump --format=custom keystone > /home/keystone/backups/pre-update.dump',
],
]);
$operation = app(CreateStatefulServiceUpdateOperation::class)->execute(
service: $service,
imageDigest: 'sha256:new',
backupRequested: true,
);
expect($service->refresh()->available_image_digest)->toBe('sha256:new')
->and($service->update_status)->toBe('update_pending')
->and($operation->steps()->pluck('name')->all())
->toBe([
'Acknowledge downtime and data risk',
'Run pre-update backup',
'Render compose with updated image digest',
'Stop existing container',
'Preserve named volume',
'Start service with updated image digest',
'Health check updated service',
])
->and($operation->steps()->where('name', 'Run pre-update backup')->first()->script)
->toBe('pg_dump --format=custom keystone > /home/keystone/backups/pre-update.dump')
->and($operation->steps()->where('name', 'Render compose with updated image digest')->first()->script)
->toContain('base64 -d')
->and($operation->steps()->where('name', 'Stop existing container')->first()->script)
->toBe("docker compose -f /home/keystone/services/{$service->id}/compose.yml stop {$service->name}")
->and($operation->steps()->where('name', 'Preserve named volume')->first()->script)
->toBe("docker volume inspect keystone_service_{$service->id}_postgres_data >/dev/null")
->and($operation->steps()->where('name', 'Health check updated service')->first()->script)
->toContain('docker inspect --format');
});
it('rejects backup requests when no backup capability is configured', function () {
$service = Service::factory()->create([
'category' => ServiceCategory::DATABASE,
'type' => ServiceType::POSTGRES,
'version' => '18',
'version_track' => '18',
'driver_name' => 'postgres.18',
'config' => [
'backup_enabled' => false,
],
]);
expect(fn () => app(CreateStatefulServiceUpdateOperation::class)->execute($service, 'sha256:new', backupRequested: true))
->toThrow(InvalidArgumentException::class, 'Backups are not configured for this service.');
});
it('rejects stateful update operations for stateless laravel services', function () {
$service = Service::factory()->create([
'category' => ServiceCategory::APPLICATION,
'type' => ServiceType::LARAVEL,
'version' => 'php-8.4',
'version_track' => 'php-8.4',
'driver_name' => 'laravel.php-8.4',
]);
expect(fn () => app(CreateStatefulServiceUpdateOperation::class)->execute($service, 'sha256:new'))
->toThrow(InvalidArgumentException::class, 'Only Postgres and Valkey have v1 stateful update operations.');
});

View File

@@ -6,5 +6,10 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
}