Implement Keystone environment deployments

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

View File

@@ -2,15 +2,20 @@
namespace App\Drivers\Caddy;
use App\Data\Operations\Plan;
use App\Data\Operations\PlannedStep;
use App\Drivers\Concerns\RendersCompose;
use App\Drivers\Concerns\SupportsSlices;
use App\Drivers\GatewayDriver;
use App\Data\Deployments\Plan;
use App\Data\Deployments\PlannedStep as Step;
use App\Enums\DeploymentStatus;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceType;
use App\Models\Service;
use App\Models\ServiceSlice;
class Caddy2Driver extends GatewayDriver
class Caddy2Driver extends GatewayDriver implements RendersCompose, SupportsSlices
{
public ?string $containerName;
public ?string $containerId;
public function __construct(
@@ -23,55 +28,112 @@ class Caddy2Driver extends GatewayDriver
$this->service = $service;
}
public function getDeploymentPlan(string $deploymentHash): Plan
public function getOperationPlan(string $operationHash): Plan
{
$previousDeployment = $this->service?->deployments()
->where('status', DeploymentStatus::COMPLETED)
->first();
return new Plan(steps: [
new Step(
name: 'Generate Caddyfile',
script: function () {
$script = collect();
$script->push('cd ~');
$script->push('test -d services || mkdir services');
$script->push('cd services');
$script->push("test -d {$this->service->id} || mkdir {$this->service->id}");
$script->push("cd {$this->service->id}");
return $script->join("\n");
}
new PlannedStep(
name: 'Render Caddy Compose files',
script: "mkdir -p /home/keystone/gateway /home/keystone/services/{$this->service?->id}",
),
new Step(
name: 'Run the docker image',
script: function () use ($previousDeployment, $deploymentHash) {
$script = collect();
if ($this->containerName && $previousDeployment) {
$script->push("docker stop \"{$this->containerName}-{$previousDeployment->hash}\" || true");
} elseif ($this->containerId) {
$script->push('docker stop ' . $this->containerId . ' || true');
}
$runCommand = 'docker run -d';
if ($this->containerName) {
$runCommand .= " --name \"{$this->containerName}-{$deploymentHash}\"";
}
$runCommand .= ' -p 80:80 -p 443:443 caddy:2';
$script->push($runCommand);
return $script->join(" && ");
}
new PlannedStep(
name: 'Start Caddy gateway',
script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d",
),
]);
}
public function buildCaddyfile(): string
public function serviceType(): ServiceType
{
$caddyfile = "http://{$this->service->name} {\n";
$caddyfile .= " reverse_proxy {$this->service->credentials['backend']}\n";
$caddyfile .= "}\n";
return ServiceType::CADDY;
}
return $caddyfile;
public function versionTrack(): string
{
return '2';
}
public function defaultImage(): string
{
return 'caddy:2';
}
public function defaultPorts(): array
{
return [80, 443];
}
public function firewallRules(): array
{
return ['80/tcp', '443/tcp'];
}
public function environmentSchema(): array
{
return [];
}
public function resourceDefaults(): array
{
return [];
}
public function updateBehavior(): string
{
return 'stateless_redeploy';
}
public function composeService(): array
{
return [
'image' => $this->service?->available_image_digest
?: $this->service?->current_image_digest
?: $this->defaultImage(),
'restart' => 'unless-stopped',
'ports' => ['80:80', '443:443'],
'volumes' => [
'/home/keystone/gateway/Caddyfile:/etc/caddy/Caddyfile:ro',
"keystone_service_{$this->service?->id}_caddy_data:/data",
"keystone_service_{$this->service?->id}_caddy_config:/config",
],
'healthcheck' => [
'test' => ['CMD', 'caddy', 'version'],
'interval' => '10s',
'timeout' => '5s',
'retries' => 5,
],
];
}
public function composeVolumes(): array
{
return [
"keystone_service_{$this->service?->id}_caddy_data" => null,
"keystone_service_{$this->service?->id}_caddy_config" => null,
];
}
public function environmentExports(): array
{
return [];
}
public function supportedSliceTypes(): array
{
return ['route'];
}
public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array
{
return [];
}
public function provisionSliceScript(ServiceSlice $slice): string
{
return implode("\n", [
'set -euo pipefail',
'mkdir -p /home/keystone/gateway/Caddyfile.d',
'test -f /home/keystone/gateway/Caddyfile || touch /home/keystone/gateway/Caddyfile',
"test ! -e /home/keystone/gateway/Caddyfile.d/{$slice->id}.caddy || true",
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Drivers\Concerns;
interface RendersCompose
{
/**
* @return array<string, mixed>
*/
public function composeService(): array;
/**
* @return array<string, mixed>
*/
public function composeVolumes(): array;
/**
* @return array<string, string>
*/
public function environmentExports(): array;
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Drivers\Concerns;
use App\Enums\EnvironmentAttachmentRole;
use App\Models\ServiceSlice;
interface SupportsSlices
{
/**
* @return array<int, string>
*/
public function supportedSliceTypes(): array;
/**
* @return array<string, string>
*/
public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array;
public function provisionSliceScript(ServiceSlice $slice): string;
}

View File

@@ -2,7 +2,9 @@
namespace App\Drivers;
use App\Data\Deployments\Plan;
use App\Data\Operations\Plan;
use App\Data\Operations\PlannedStep;
use App\Enums\ServiceType;
use App\Models\Service;
abstract class Driver
@@ -19,5 +21,41 @@ abstract class Driver
?Service $service = null,
);
abstract public function getDeploymentPlan(string $deploymentHash): Plan;
abstract public function getOperationPlan(string $operationHash): Plan;
abstract public function serviceType(): ServiceType;
abstract public function versionTrack(): string;
abstract public function defaultImage(): string;
/**
* @return array<int, int>
*/
abstract public function defaultPorts(): array;
/**
* @return array<int, string>
*/
abstract public function firewallRules(): array;
/**
* @return array<string, string>
*/
abstract public function environmentSchema(): array;
/**
* @return array{cpu?: string, memory_mb?: int}
*/
abstract public function resourceDefaults(): array;
abstract public function updateBehavior(): string;
/**
* @return array<int, PlannedStep>
*/
public function preSwitchSteps(): array
{
return [];
}
}

View File

@@ -0,0 +1,205 @@
<?php
namespace App\Drivers\Laravel;
use App\Actions\Environments\BuildMigrationScript;
use App\Data\Operations\Plan;
use App\Data\Operations\PlannedStep;
use App\Drivers\Concerns\RendersCompose;
use App\Drivers\Driver;
use App\Enums\SchedulerMode;
use App\Enums\ServiceType;
use App\Models\Service;
class LaravelRuntimeDriver extends Driver implements RendersCompose
{
public function __construct(
public ?string $containerName = null,
public ?string $containerId = null,
public ?Service $service = null,
) {
//
}
public function getOperationPlan(string $operationHash): Plan
{
return new Plan(steps: [
new PlannedStep(
name: 'Render Laravel Compose file',
script: "mkdir -p /home/keystone/services/{$this->service?->id}",
),
new PlannedStep(
name: 'Run migrations',
script: $this->service
? app(BuildMigrationScript::class)->execute($this->service)
: 'true',
),
new PlannedStep(
name: 'Start Laravel replica',
script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d",
),
]);
}
public function serviceType(): ServiceType
{
return ServiceType::LARAVEL;
}
public function versionTrack(): string
{
return 'php-8.4';
}
public function defaultImage(): string
{
return 'serversideup/php:8.4-frankenphp';
}
public function defaultPorts(): array
{
return [80];
}
public function firewallRules(): array
{
return [];
}
public function environmentSchema(): array
{
return [
'APP_ENV' => 'string',
'SERVER_NAME' => 'string',
];
}
public function resourceDefaults(): array
{
return [];
}
public function updateBehavior(): string
{
return 'stateless_gateway_cutover';
}
public function composeService(): array
{
$image = $this->service?->available_image_digest
?: $this->service?->current_image_digest
?: ($this->service?->config['image'] ?? $this->defaultImage());
$service = [
'image' => $image,
'restart' => 'unless-stopped',
'environment' => $this->environmentExports(),
];
if ($command = $this->service?->config['command'] ?? null) {
$service['command'] = $command;
}
if (! in_array('worker', $this->service?->process_roles ?? [], true)) {
$service['healthcheck'] = [
'test' => ['CMD-SHELL', 'curl -fsS http://localhost'.($this->service?->config['health_path'] ?? '/up').' || exit 1'],
'interval' => '10s',
'timeout' => '5s',
'retries' => 5,
];
}
if ($this->service?->default_cpu_limit) {
$service['cpus'] = (string) $this->service->default_cpu_limit;
}
if ($this->service?->default_memory_limit_mb) {
$service['mem_limit'] = "{$this->service->default_memory_limit_mb}m";
$service['memswap_limit'] = "{$this->service->default_memory_limit_mb}m";
}
return $service;
}
public function composeVolumes(): array
{
return [];
}
public function environmentExports(): array
{
$environment = $this->service?->environment?->variables()
->pluck('value', 'key')
->all() ?? [];
$environment = [
...$environment,
'APP_ENV' => $this->service?->environment?->name ?? 'production',
'SERVER_NAME' => ':80',
];
if ($this->shouldAutorunScheduler()) {
$environment['AUTORUN_LARAVEL_SCHEDULER'] = 'true';
}
return $environment;
}
private function shouldAutorunScheduler(): bool
{
if (! in_array('scheduler', $this->service?->process_roles ?? [], true)) {
return false;
}
$environment = $this->service?->environment;
if (! $environment?->scheduler_enabled) {
return false;
}
if ($environment->scheduler_target_service_id && $environment->scheduler_target_service_id !== $this->service?->id) {
return false;
}
return $environment->scheduler_mode !== SchedulerMode::SINGLE
|| (int) $this->service?->desired_replicas === 1;
}
public function dockerfileTemplate(): string
{
$phpVersion = $this->service?->config['php_version'] ?? '8.4';
$documentRoot = $this->service?->config['document_root'] ?? 'public';
$jsBuildCommand = $this->service?->config['js_build_command'] ?? $this->service?->environment?->build_config['js_build_command'] ?? null;
$jsPackageManager = $this->service?->config['js_package_manager'] ?? $this->service?->environment?->build_config['js_package_manager'] ?? 'bun';
$jsBuildSteps = $this->jsBuildSteps($jsPackageManager, $jsBuildCommand);
return <<<DOCKERFILE
FROM serversideup/php:{$phpVersion}-frankenphp
ENV SSL_MODE=off
ENV AUTORUN_ENABLED=true
ENV PHP_OPCACHE_ENABLE=1
WORKDIR /var/www/html
COPY --chown=www-data:www-data . .
RUN composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
{$jsBuildSteps}
ENV SERVER_DOCUMENT_ROOT=/var/www/html/{$documentRoot}
DOCKERFILE;
}
private function jsBuildSteps(string $packageManager, ?string $buildCommand): string
{
if (! $buildCommand) {
return '';
}
return match ($packageManager) {
'npm' => "\nRUN npm ci && {$buildCommand}",
default => "\nRUN curl -fsSL https://bun.sh/install | bash && export PATH=\"/root/.bun/bin:\$PATH\" && bun install --frozen-lockfile && {$buildCommand}",
};
}
}

View File

@@ -1,94 +0,0 @@
<?php
namespace App\Drivers\Postgres;
use App\Data\Deployments\Plan;
use App\Data\Deployments\PlannedStep as Step;
use App\Drivers\DatabaseDriver;
use App\Enums\DeploymentStatus;
use App\Models\Service;
use Illuminate\Support\Str;
class Postgres17Driver extends DatabaseDriver
{
public Plan $deploymentPlan;
public function __construct(
public ?string $containerName = null,
public ?string $containerId = null,
public ?Service $service = null,
public ?array $credentials = null,
) {
$credentials = $credentials ?? $this->defaultCredentials();
}
public function getDeploymentPlan(string $deploymentHash): Plan
{
$user = $credentials['user'] ?? null;
$password = $credentials['password'] ?? null;
$db = $credentials['db'] ?? null;
if (!$user || !$password || !$db) {
throw new \InvalidArgumentException('Missing required credentials');
}
$previousDeployment = $this->service?->deployments()
->where('status', DeploymentStatus::COMPLETED)
->first();
return new Plan(steps: [
new Step(
name: 'Run the docker image',
secrets: [
'password' => $password
],
script: function () use ($user, $password, $db, $previousDeployment, $deploymentHash) {
$script = collect();
if ($this->containerName && $previousDeployment) {
$script->push("docker stop \"{$this->containerName}-{$previousDeployment->hash}\" || true");
} elseif ($this->containerId) {
$script->push('docker stop ' . $this->containerId . ' || true');
}
$runCommand = 'docker run -d';
if ($this->containerName) {
$runCommand .= " --name \"{$this->containerName}-{$deploymentHash}\"";
}
if ($password) {
$runCommand .= ' -e POSTGRES_PASSWORD=[!password!]';
}
if ($user) {
$runCommand .= " -e POSTGRES_USER={$user}";
}
if ($db) {
$runCommand .= " -e POSTGRES_DB={$db}";
}
$runCommand .= ' -p 5432:5432 postgres:17';
$script->push($runCommand);
return $script->join(" && ");
}
),
new Step(
name: 'Configure firewall', // @todo this should create a Firewallrule
script: 'ufw allow 5432/tcp || true',
),
]);
}
public function defaultCredentials(): array
{
return [
'password' => Str::random(16),
'user' => 'keystone',
'db' => 'keystone',
];
}
public function createUser(string $user, string $password): string
{
return "psql -U {$this->credentials['user']} -d {$this->credentials['db']} -c \"CREATE USER {$user} WITH PASSWORD '{$password}';\"";
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Drivers\Postgres;
use App\Data\Operations\Plan;
use App\Data\Operations\PlannedStep;
use App\Drivers\Concerns\RendersCompose;
use App\Drivers\Concerns\SupportsSlices;
use App\Drivers\DatabaseDriver;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceType;
use App\Models\Service;
use App\Models\ServiceSlice;
use Illuminate\Support\Str;
class Postgres18Driver extends DatabaseDriver implements RendersCompose, SupportsSlices
{
public Plan $operationPlan;
public function __construct(
public ?string $containerName = null,
public ?string $containerId = null,
public ?Service $service = null,
public ?array $credentials = null,
) {
$this->credentials = $credentials ?? $this->defaultCredentials();
}
public function getOperationPlan(string $operationHash): Plan
{
$credentials = $this->credentials ?? $this->defaultCredentials();
$user = $credentials['user'] ?? null;
$password = $credentials['password'] ?? null;
$db = $credentials['db'] ?? null;
if (! $user || ! $password || ! $db) {
throw new \InvalidArgumentException('Missing required credentials');
}
return new Plan(steps: [
new PlannedStep(
name: 'Render Compose file',
script: "mkdir -p /home/keystone/services/{$this->service?->id}",
),
new PlannedStep(
name: 'Start Postgres service',
script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d",
),
new PlannedStep(
name: 'Check Postgres health',
script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml ps --status running",
),
new PlannedStep(
name: 'Configure firewall',
script: 'ufw allow 5432/tcp || true',
),
]);
}
public function serviceType(): ServiceType
{
return ServiceType::POSTGRES;
}
public function versionTrack(): string
{
return '18';
}
public function defaultImage(): string
{
return 'postgres:18';
}
public function defaultPorts(): array
{
return [5432];
}
public function firewallRules(): array
{
return ['5432/tcp'];
}
public function environmentSchema(): array
{
return [
'POSTGRES_USER' => 'string',
'POSTGRES_PASSWORD' => 'secret',
'POSTGRES_DB' => 'string',
];
}
public function resourceDefaults(): array
{
return [];
}
public function updateBehavior(): string
{
return 'stateful_downtime';
}
public function defaultCredentials(): array
{
return [
'password' => Str::random(32),
'user' => 'keystone',
'db' => 'keystone',
];
}
public function createUser(string $user, string $password): string
{
return "psql -U {$this->credentials['user']} -d {$this->credentials['db']} -c \"CREATE USER {$user} WITH PASSWORD '{$password}';\"";
}
public function supportedSliceTypes(): array
{
return ['database_user'];
}
public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array
{
$credentials = $slice->credentials ?? [];
return [
'DB_CONNECTION' => 'pgsql',
'DB_HOST' => $slice->config['host'] ?? "keystone-service-{$slice->service_id}",
'DB_PORT' => (string) ($slice->config['port'] ?? 5432),
'DB_DATABASE' => $credentials['database'] ?? $slice->name,
'DB_USERNAME' => $credentials['username'] ?? $slice->name,
'DB_PASSWORD' => $credentials['password'] ?? '',
];
}
public function provisionSliceScript(ServiceSlice $slice): string
{
$credentials = $slice->credentials ?? [];
$database = $credentials['database'] ?? $slice->name;
$username = $credentials['username'] ?? $slice->name;
$password = $credentials['password'] ?? Str::password(32);
$admin = ($this->credentials ?? $this->defaultCredentials())['user'] ?? 'keystone';
$serviceKey = str($slice->service->name)->slug('_')->value() ?: 'postgres';
return implode("\n", [
'set -euo pipefail',
"docker compose -f /home/keystone/services/{$slice->service_id}/compose.yml exec -T {$serviceKey} psql -U ".escapeshellarg($admin).' -d postgres <<\'KEYSTONE_SQL\'',
"SELECT 'CREATE DATABASE \"{$this->sqlIdentifier($database)}\"' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '{$this->sqlLiteral($database)}')\\gexec",
'DO $$ BEGIN CREATE USER "'.$this->sqlIdentifier($username).'" WITH PASSWORD \''.$this->sqlLiteral($password).'\'; EXCEPTION WHEN duplicate_object THEN ALTER USER "'.$this->sqlIdentifier($username).'" WITH PASSWORD \''.$this->sqlLiteral($password).'\'; END $$;',
"GRANT ALL PRIVILEGES ON DATABASE \"{$this->sqlIdentifier($database)}\" TO \"{$this->sqlIdentifier($username)}\";",
'KEYSTONE_SQL',
]);
}
public function composeService(): array
{
$credentials = $this->credentials ?? $this->defaultCredentials();
return [
'image' => $this->service?->available_image_digest
?: $this->service?->current_image_digest
?: $this->defaultImage(),
'restart' => 'unless-stopped',
'environment' => [
'POSTGRES_USER' => $credentials['user'],
'POSTGRES_PASSWORD' => $credentials['password'],
'POSTGRES_DB' => $credentials['db'],
],
'volumes' => [
"keystone_service_{$this->service?->id}_postgres_data:/var/lib/postgresql/data",
],
'healthcheck' => [
'test' => ['CMD-SHELL', 'pg_isready -U '.$credentials['user']],
'interval' => '10s',
'timeout' => '5s',
'retries' => 5,
],
];
}
public function composeVolumes(): array
{
return [
"keystone_service_{$this->service?->id}_postgres_data" => null,
];
}
public function environmentExports(): array
{
return [];
}
private function sqlIdentifier(string $value): string
{
return str_replace('"', '""', $value);
}
private function sqlLiteral(string $value): string
{
return str_replace("'", "''", $value);
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Drivers\Valkey;
use App\Data\Operations\Plan;
use App\Data\Operations\PlannedStep;
use App\Drivers\Concerns\RendersCompose;
use App\Drivers\Concerns\SupportsSlices;
use App\Drivers\Driver;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceType;
use App\Models\Service;
use App\Models\ServiceSlice;
class Valkey8Driver extends Driver implements RendersCompose, SupportsSlices
{
public function __construct(
public ?string $containerName = null,
public ?string $containerId = null,
public ?Service $service = null,
) {
//
}
public function getOperationPlan(string $operationHash): Plan
{
return new Plan(steps: [
new PlannedStep(
name: 'Render Compose file',
script: "mkdir -p /home/keystone/services/{$this->service?->id}",
),
new PlannedStep(
name: 'Start Valkey service',
script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d",
),
]);
}
public function serviceType(): ServiceType
{
return ServiceType::VALKEY;
}
public function versionTrack(): string
{
return '8';
}
public function defaultImage(): string
{
return 'valkey/valkey:8';
}
public function defaultPorts(): array
{
return [6379];
}
public function firewallRules(): array
{
return ['6379/tcp'];
}
public function environmentSchema(): array
{
return [];
}
public function resourceDefaults(): array
{
return [];
}
public function updateBehavior(): string
{
return 'stateful_downtime';
}
public function supportedSliceTypes(): array
{
return ['logical_database'];
}
public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array
{
$exports = [
'REDIS_HOST' => $slice->config['host'] ?? "keystone-service-{$slice->service_id}",
'REDIS_PORT' => (string) ($slice->config['port'] ?? 6379),
'REDIS_DB' => (string) ($slice->config['database'] ?? 0),
];
return match ($role) {
EnvironmentAttachmentRole::CACHE => [
...$exports,
'CACHE_STORE' => 'redis',
],
EnvironmentAttachmentRole::QUEUE => [
...$exports,
'QUEUE_CONNECTION' => 'redis',
],
EnvironmentAttachmentRole::CUSTOM,
EnvironmentAttachmentRole::DATABASE,
EnvironmentAttachmentRole::GATEWAY,
EnvironmentAttachmentRole::STORAGE,
null => $exports,
};
}
public function provisionSliceScript(ServiceSlice $slice): string
{
$serviceKey = str($slice->service->name)->slug('_')->value() ?: 'valkey';
return 'docker compose -f /home/keystone/services/'.$slice->service_id.'/compose.yml exec -T '.$serviceKey.' valkey-cli -n '.escapeshellarg((string) ($slice->config['database'] ?? 0)).' PING';
}
public function composeService(): array
{
$service = [
'image' => $this->service?->available_image_digest
?: $this->service?->current_image_digest
?: $this->defaultImage(),
'restart' => 'unless-stopped',
'healthcheck' => [
'test' => ['CMD', 'valkey-cli', 'ping'],
'interval' => '10s',
'timeout' => '5s',
'retries' => 5,
],
];
if ($this->service?->config['persistence'] ?? false) {
$service['volumes'] = ["keystone_service_{$this->service->id}_valkey_data:/data"];
$service['command'] = ['valkey-server', '--appendonly', 'yes'];
}
return $service;
}
public function composeVolumes(): array
{
if (! ($this->service?->config['persistence'] ?? false)) {
return [];
}
return [
"keystone_service_{$this->service->id}_valkey_data" => null,
];
}
public function environmentExports(): array
{
return [];
}
}