Implement Keystone environment deployments
This commit is contained in:
122
tests/Feature/ApplicationControllerTest.php
Normal file
122
tests/Feature/ApplicationControllerTest.php
Normal 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();
|
||||
});
|
||||
104
tests/Feature/BuildApplicationArtifactTest.php
Normal file
104
tests/Feature/BuildApplicationArtifactTest.php
Normal 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();
|
||||
}
|
||||
81
tests/Feature/BuildArtifactPlanningTest.php
Normal file
81
tests/Feature/BuildArtifactPlanningTest.php
Normal 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);
|
||||
});
|
||||
174
tests/Feature/ComposeRendererTest.php
Normal file
174
tests/Feature/ComposeRendererTest.php
Normal 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');
|
||||
});
|
||||
454
tests/Feature/DeployEnvironmentJobTest.php
Normal file
454
tests/Feature/DeployEnvironmentJobTest.php
Normal 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.');
|
||||
});
|
||||
21
tests/Feature/DriverContractTest.php
Normal file
21
tests/Feature/DriverContractTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
76
tests/Feature/EnvironmentAttachmentControllerTest.php
Normal file
76
tests/Feature/EnvironmentAttachmentControllerTest.php
Normal 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();
|
||||
});
|
||||
95
tests/Feature/EnvironmentDeploymentControllerTest.php
Normal file
95
tests/Feature/EnvironmentDeploymentControllerTest.php
Normal 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');
|
||||
});
|
||||
113
tests/Feature/EnvironmentDeploymentPlanTest.php
Normal file
113
tests/Feature/EnvironmentDeploymentPlanTest.php
Normal 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.');
|
||||
});
|
||||
56
tests/Feature/EnvironmentMigrationControllerTest.php
Normal file
56
tests/Feature/EnvironmentMigrationControllerTest.php
Normal 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.');
|
||||
});
|
||||
54
tests/Feature/EnvironmentVariableControllerTest.php
Normal file
54
tests/Feature/EnvironmentVariableControllerTest.php
Normal 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();
|
||||
});
|
||||
55
tests/Feature/EnvironmentWorkerControllerTest.php
Normal file
55
tests/Feature/EnvironmentWorkerControllerTest.php
Normal 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);
|
||||
});
|
||||
115
tests/Feature/KeystoneDomainModelTest.php
Normal file
115
tests/Feature/KeystoneDomainModelTest.php
Normal 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);
|
||||
});
|
||||
71
tests/Feature/LaravelEnvironmentDefaultsTest.php
Normal file
71
tests/Feature/LaravelEnvironmentDefaultsTest.php
Normal 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');
|
||||
});
|
||||
99
tests/Feature/ManagedAttachmentTest.php
Normal file
99
tests/Feature/ManagedAttachmentTest.php
Normal 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');
|
||||
});
|
||||
43
tests/Feature/ProvisionScriptTest.php
Normal file
43
tests/Feature/ProvisionScriptTest.php
Normal 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!]');
|
||||
});
|
||||
49
tests/Feature/RegistryControllerTest.php
Normal file
49
tests/Feature/RegistryControllerTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
54
tests/Feature/RepositoryAccessTest.php
Normal file
54
tests/Feature/RepositoryAccessTest.php
Normal 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;
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
151
tests/Feature/ServiceDeploymentOperationTest.php
Normal file
151
tests/Feature/ServiceDeploymentOperationTest.php
Normal 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();
|
||||
}
|
||||
61
tests/Feature/ServiceEndpointTest.php
Normal file
61
tests/Feature/ServiceEndpointTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
85
tests/Feature/ServiceImageDigestTest.php
Normal file
85
tests/Feature/ServiceImageDigestTest.php
Normal 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();
|
||||
}
|
||||
93
tests/Feature/ServiceUpdateControllerTest.php
Normal file
93
tests/Feature/ServiceUpdateControllerTest.php
Normal 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];
|
||||
}
|
||||
43
tests/Feature/SourceProviderControllerTest.php
Normal file
43
tests/Feature/SourceProviderControllerTest.php
Normal 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');
|
||||
});
|
||||
80
tests/Feature/StatefulServiceUpdateTest.php
Normal file
80
tests/Feature/StatefulServiceUpdateTest.php
Normal 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.');
|
||||
});
|
||||
@@ -6,5 +6,10 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutVite();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user