Implement Keystone environment deployments

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

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Actions\Environments;
use App\Drivers\Concerns\SupportsSlices;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\EnvironmentVariableSource;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\ServiceType;
use App\Models\Environment;
use App\Models\EnvironmentAttachment;
use App\Models\Service;
use App\Models\ServiceSlice;
use Illuminate\Support\Str;
use InvalidArgumentException;
class AttachManagedService
{
public function execute(
Environment $environment,
Service $service,
EnvironmentAttachmentRole $role,
?string $name = null,
?string $envPrefix = null,
bool $isPrimary = true,
): EnvironmentAttachment {
$slice = $this->createDefaultSlice($environment, $service, $role, $name);
$attachment = $environment->attachments()->create([
'service_id' => $service->id,
'service_slice_id' => $slice?->id,
'role' => $role,
'env_prefix' => $envPrefix,
'is_primary' => $isPrimary,
]);
$this->syncManagedVariables($environment, $service, $slice, $envPrefix, $role);
$this->createSliceProvisionOperation($service, $slice);
return $attachment;
}
private function createDefaultSlice(
Environment $environment,
Service $service,
EnvironmentAttachmentRole $role,
?string $name,
): ?ServiceSlice {
return match ($service->type) {
ServiceType::POSTGRES => $service->slices()->firstOrCreate([
'environment_id' => $environment->id,
'type' => 'database_user',
'name' => $name ?? $this->sliceName($environment),
], [
'status' => 'pending',
'config' => [],
'credentials' => [
'database' => $name ?? $this->sliceName($environment),
'username' => $name ?? $this->sliceName($environment),
'password' => Str::password(32),
],
]),
ServiceType::VALKEY => $service->slices()->firstOrCreate([
'environment_id' => $environment->id,
'type' => 'logical_database',
'name' => $name ?? $this->sliceName($environment),
], [
'status' => 'pending',
'config' => [
'database' => $this->nextValkeyDatabase($service),
],
]),
ServiceType::CADDY => $service->slices()->firstOrCreate([
'environment_id' => $environment->id,
'type' => 'route',
'name' => $name ?? $environment->name,
], [
'status' => 'pending',
'config' => [],
]),
default => $role === EnvironmentAttachmentRole::CUSTOM ? null : throw new InvalidArgumentException("Service [{$service->type->value}] does not support managed attachments."),
};
}
private function syncManagedVariables(Environment $environment, Service $service, ?ServiceSlice $slice, ?string $envPrefix, EnvironmentAttachmentRole $role): void
{
if (! $slice) {
return;
}
$driver = $service->driver();
if (! $driver instanceof SupportsSlices) {
return;
}
foreach ($driver->environmentExportsForSlice($slice, $role) as $key => $value) {
$environment->variables()->updateOrCreate([
'key' => $this->variableKey($key, $envPrefix),
], [
'value' => $value,
'source' => EnvironmentVariableSource::MANAGED_ATTACHMENT,
'service_slice_id' => $slice->id,
'overridable' => false,
]);
}
}
private function createSliceProvisionOperation(Service $service, ?ServiceSlice $slice): void
{
if (! $slice || ! $slice->wasRecentlyCreated) {
return;
}
$driver = $service->driver();
if (! $driver instanceof SupportsSlices) {
return;
}
$operation = $slice->operations()->create([
'kind' => OperationKind::SLICE_PROVISION,
'status' => OperationStatus::PENDING,
]);
$operation->steps()->create([
'name' => 'Provision '.$service->type->value.' slice',
'order' => 1,
'status' => OperationStatus::PENDING,
'script' => $driver->provisionSliceScript($slice),
]);
}
private function nextValkeyDatabase(Service $service): int
{
return ((int) $service->slices()
->where('type', 'logical_database')
->get()
->max(fn (ServiceSlice $slice): int => (int) ($slice->config['database'] ?? 0))) + 1;
}
private function sliceName(Environment $environment): string
{
return str($environment->application->name.' '.$environment->name)->slug('_')->value();
}
private function variableKey(string $key, ?string $envPrefix): string
{
return $envPrefix ? $envPrefix.'_'.$key : $key;
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Actions\Environments;
use App\Enums\BuildArtifactStatus;
use App\Enums\BuildStrategy;
use App\Models\BuildArtifact;
use App\Models\Operation;
use App\Models\Server;
use App\Models\Service;
use App\Services\Operations\RemoteCommandRunner;
use RuntimeException;
class BuildApplicationArtifact
{
public function __construct(
private readonly RemoteCommandRunner $remoteCommandRunner,
) {}
public function execute(BuildArtifact $artifact, ?Operation $operation = null): BuildArtifact
{
$artifact->loadMissing('environment.application', 'builtByService.server', 'builtByService.replicas.server');
$application = $artifact->environment->application;
$strategy = BuildStrategy::tryFrom($artifact->metadata['build_strategy'] ?? BuildStrategy::TARGET_SERVER->value)
?? BuildStrategy::TARGET_SERVER;
$server = $this->buildServer($artifact);
$artifact->update([
'status' => BuildArtifactStatus::BUILDING,
'built_by_operation_id' => $operation?->id,
]);
try {
$output = $this->remoteCommandRunner->run(
$server,
$strategy === BuildStrategy::EXTERNAL_REGISTRY
? $this->manifestDigestScript($artifact)
: $this->buildScript($artifact, $strategy)
);
$artifact->update([
'image_digest' => $this->digestFromOutput($output),
'status' => BuildArtifactStatus::AVAILABLE,
]);
return $artifact->refresh();
} catch (\Throwable $exception) {
$artifact->update([
'status' => BuildArtifactStatus::FAILED,
'metadata' => [
...($artifact->metadata ?? []),
'error' => $exception->getMessage(),
],
]);
throw $exception;
}
}
private function buildServer(BuildArtifact $artifact): Server
{
if ($artifact->builtByService instanceof Service) {
$server = $artifact->builtByService->replicas->first()?->server ?: $artifact->builtByService->server;
if ($server instanceof Server) {
return $server;
}
}
if (($artifact->metadata['build_strategy'] ?? null) === BuildStrategy::DEDICATED_BUILDER->value) {
throw new RuntimeException('Dedicated builder strategy requires a builder service.');
}
$services = $artifact->environment->services()
->with(['server', 'replicas.server'])
->get();
$server = $services
->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter())
->first() ?: $services->pluck('server')->filter()->first();
if (! $server instanceof Server) {
$serverId = $services
->flatMap(fn (Service $service) => collect($service->config['server_ids'] ?? []))
->filter()
->first();
$server = $serverId ? Server::find($serverId) : null;
}
if (! $server instanceof Server) {
throw new RuntimeException('A target server is required to build this artifact over SSH.');
}
return $server;
}
private function buildScript(BuildArtifact $artifact, BuildStrategy $strategy): string
{
$application = $artifact->environment->application;
if (! $application->deploy_key_private) {
throw new RuntimeException('Application does not have a deploy key.');
}
$operationDirectory = '/home/keystone/operations/build-'.$artifact->id.'-'.str()->random(8);
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
$pushCommand = $strategy === BuildStrategy::DEDICATED_BUILDER && $artifact->registry_ref
? "\ndocker push ".escapeshellarg($imageReference)
: '';
return implode("\n", [
'set -euo pipefail',
'operation_dir='.escapeshellarg($operationDirectory),
'source_dir="$operation_dir/source"',
'rm -rf "$operation_dir"',
'mkdir -p "$operation_dir"',
'chmod 700 "$operation_dir"',
'cleanup() { rm -rf "$operation_dir"; }',
'trap cleanup EXIT',
$this->writeFileCommand('$operation_dir/deploy_key', $application->deploy_key_private),
'chmod 600 "$operation_dir/deploy_key"',
'export GIT_SSH_COMMAND="ssh -i $operation_dir/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=no"',
'git clone --depth 1 --branch '.escapeshellarg($artifact->environment->branch).' '.escapeshellarg($application->repository_url).' "$source_dir"',
$this->writeFileCommand('$source_dir/Dockerfile.keystone', $this->dockerfile($artifact)),
'cd "$source_dir"',
'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .'.$pushCommand,
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')',
'printf "image_digest=%s\n" "$digest"',
]);
}
private function manifestDigestScript(BuildArtifact $artifact): string
{
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
return implode("\n", [
'set -euo pipefail',
'manifest=$(docker manifest inspect '.escapeshellarg($imageReference).')',
'digest=$(printf "%s" "$manifest" | sed -n '.escapeshellarg('s/.*"digest": "\(sha256:[^"]*\)".*/\1/p').' | head -n 1)',
'test -n "$digest"',
'printf "image_digest=%s\n" "$digest"',
]);
}
private function dockerfile(BuildArtifact $artifact): string
{
$service = $artifact->environment->services()
->where('type', \App\Enums\ServiceType::LARAVEL)
->first();
if ($service && method_exists($service->driver(), 'dockerfileTemplate')) {
return $service->driver()->dockerfileTemplate();
}
return <<<'DOCKERFILE'
FROM serversideup/php:8.4-frankenphp
WORKDIR /var/www/html
COPY --chown=www-data:www-data . .
RUN composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
ENV SERVER_DOCUMENT_ROOT=/var/www/html/public
DOCKERFILE;
}
private function writeFileCommand(string $path, string $contents): string
{
return implode("\n", [
'cat > '.$path." <<'KEYSTONE_FILE'",
rtrim($contents),
'KEYSTONE_FILE',
]);
}
private function digestFromOutput(string $output): string
{
if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) {
return $this->digestFromOutput($matches['digest']);
}
if (str_contains($output, '@')) {
return str($output)->after('@')->trim()->value();
}
if (str_starts_with($output, 'sha256:')) {
return $output;
}
throw new RuntimeException('Unable to resolve built image digest.');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Actions\Environments;
use App\Models\Service;
class BuildMigrationScript
{
public function execute(Service $service, bool $respectAutomaticMode = true): string
{
if ($respectAutomaticMode && in_array($service->config['migration_mode'] ?? 'auto', ['disabled', 'manual'], true)) {
return 'true';
}
$command = $service->config['migration_command'] ?? 'php artisan migrate --force';
$serviceKey = str($service->name)->slug('_')->value() ?: 'service';
return "docker compose -f /home/keystone/services/{$service->id}/compose.yml run --rm {$serviceKey} {$command}";
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Actions\Environments;
use App\Enums\DeployPolicy;
use App\Enums\ServiceCategory;
use App\Enums\ServiceStatus;
use App\Enums\ServiceType;
use App\Models\Environment;
use App\Models\Service;
class CreateLaravelWorkerService
{
public function execute(Environment $environment): Service
{
$environment->loadMissing('application');
$phpVersion = $environment->build_config['php_version'] ?? '8.4';
return $environment->services()->firstOrCreate([
'name' => 'worker',
'type' => ServiceType::LARAVEL,
], [
'organisation_id' => $environment->application->organisation_id,
'category' => ServiceCategory::APPLICATION,
'version' => "php-{$phpVersion}",
'version_track' => "php-{$phpVersion}",
'driver_name' => "laravel.php-{$phpVersion}",
'status' => ServiceStatus::NOT_INSTALLED,
'desired_replicas' => 1,
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
'process_roles' => ['worker'],
'config' => [
'command' => 'php artisan queue:work --sleep=3 --tries=3',
'health_path' => null,
'migration_mode' => 'disabled',
],
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Actions\Environments;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\ServiceType;
use App\Models\Environment;
use App\Models\Operation;
use App\Models\Service;
use InvalidArgumentException;
class CreateMigrationOperation
{
public function __construct(
private readonly BuildMigrationScript $buildMigrationScript,
) {}
public function execute(Environment $environment, ?Service $service = null): Operation
{
$service ??= $environment->services()
->where('type', ServiceType::LARAVEL)
->get()
->first(fn (Service $service): bool => in_array('web', $service->process_roles ?? [], true));
if (! $service || $service->type !== ServiceType::LARAVEL) {
throw new InvalidArgumentException('Laravel migrations must run against a Laravel runtime service.');
}
$operation = $service->operations()->create([
'kind' => OperationKind::CONFIG_CHANGE,
'status' => OperationStatus::PENDING,
]);
$operation->steps()->create([
'name' => 'Run Laravel migrations',
'order' => 1,
'status' => OperationStatus::PENDING,
'script' => $this->buildMigrationScript->execute($service, respectAutomaticMode: false),
]);
return $operation;
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Actions\Environments;
use App\Enums\BuildArtifactStatus;
use App\Enums\BuildStrategy;
use App\Enums\ServiceCategory;
use App\Models\BuildArtifact;
use App\Models\Environment;
use RuntimeException;
class PlanBuildArtifact
{
public function execute(Environment $environment, string $commitSha): BuildArtifact
{
$environment->loadMissing(['application.organisation.registries', 'services.replicas']);
$existingArtifact = $environment->buildArtifacts()
->where('commit_sha', $commitSha)
->whereIn('status', [BuildArtifactStatus::PENDING, BuildArtifactStatus::BUILDING, BuildArtifactStatus::AVAILABLE])
->latest()
->first();
if ($existingArtifact) {
return $existingArtifact;
}
$targetServerCount = $this->targetServerCount($environment);
$registry = $environment->application->organisation->registries()->first();
if ($targetServerCount > 1 && ! $registry) {
throw new RuntimeException('A registry is required before building artifacts for multi-server deployments.');
}
$builder = $environment->application->organisation->services()
->where('category', ServiceCategory::BUILDER)
->first();
$strategy = match (true) {
$registry !== null => BuildStrategy::EXTERNAL_REGISTRY,
$builder !== null => BuildStrategy::DEDICATED_BUILDER,
default => BuildStrategy::TARGET_SERVER,
};
$imageTag = str($environment->application->name)
->slug()
->append(':'.substr($commitSha, 0, 12))
->value();
return $environment->buildArtifacts()->create([
'commit_sha' => $commitSha,
'image_tag' => $imageTag,
'registry_ref' => $registry ? rtrim((string) $registry->url, '/').'/'.$imageTag : null,
'built_by_service_id' => $builder?->id,
'status' => BuildArtifactStatus::PENDING,
'metadata' => [
'build_strategy' => $strategy->value,
'target_server_count' => $targetServerCount,
],
]);
}
private function targetServerCount(Environment $environment): int
{
$replicaServerCount = $environment->services
->flatMap(fn ($service) => $service->replicas->pluck('server_id')->filter())
->unique()
->count();
if ($replicaServerCount > 0) {
return $replicaServerCount;
}
return $environment->services->sum('desired_replicas') > 1 ? 2 : 1;
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Actions\Environments;
use App\Data\Environments\EnvironmentDeploymentPlan;
use App\Enums\DeployPolicy;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\SchedulerMode;
use App\Models\Environment;
class PlanEnvironmentDeployment
{
public function execute(Environment $environment): EnvironmentDeploymentPlan
{
$environment->loadMissing([
'services',
'attachments.service',
'attachments.serviceSlice',
]);
$deployableServices = $environment->services
->where('deploy_policy', DeployPolicy::WITH_ENVIRONMENT)
->values();
$dependencies = $environment->attachments
->map(fn ($attachment) => $attachment->service)
->filter()
->unique('id')
->values();
$targetServerCount = $deployableServices
->flatMap(fn ($service) => $service->replicas->pluck('server_id')->filter())
->unique()
->count();
if ($targetServerCount === 0) {
$targetServerCount = $deployableServices->sum('desired_replicas') > 1 ? 2 : 1;
}
return new EnvironmentDeploymentPlan(
services: $deployableServices->all(),
dependencies: $dependencies->all(),
requiresRegistry: $targetServerCount > 1 && $environment->application->organisation->registries()->doesntExist(),
warnings: $this->warnings($environment),
blockers: $this->blockers($environment),
);
}
/**
* @return array<int, string>
*/
private function warnings(Environment $environment): array
{
$warnings = [];
if ($environment->variables()
->where('key', 'QUEUE_CONNECTION')
->get()
->contains(fn ($variable) => $variable->value === 'sync')) {
$warnings[] = 'QUEUE_CONNECTION=sync is not recommended for deployed Laravel environments.';
}
if ($environment->attachments->contains('role', EnvironmentAttachmentRole::QUEUE)) {
$hasWorker = $environment->services->contains(fn ($service) => in_array('worker', $service->process_roles ?? [], true));
if (! $hasWorker) {
$warnings[] = 'Queue attachment exists without a dedicated worker service.';
}
}
return $warnings;
}
/**
* @return array<int, string>
*/
private function blockers(Environment $environment): array
{
if (! $environment->scheduler_enabled || $environment->scheduler_mode !== SchedulerMode::SINGLE) {
return [];
}
$target = $environment->services->firstWhere('id', $environment->scheduler_target_service_id);
if ($target && $target->desired_replicas > 1 && in_array('scheduler', $target->process_roles ?? [], true)) {
return ['Scheduler mode single requires the scheduler target service to run exactly one replica.'];
}
return [];
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Actions\Environments;
use App\Models\Environment;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use RuntimeException;
class ResolveEnvironmentCommit
{
public function execute(Environment $environment): string
{
$environment->loadMissing('application');
$application = $environment->application;
if (! $application->deploy_key_private) {
throw new RuntimeException('Application does not have a deploy key.');
}
$directory = storage_path('app/private/operations/resolve-'.$environment->id.'-'.str()->random(8));
$keyPath = $directory.'/deploy_key';
File::ensureDirectoryExists($directory, 0700);
File::put($keyPath, $application->deploy_key_private);
File::chmod($keyPath, 0600);
try {
$result = Process::path($directory)
->env([
'GIT_SSH_COMMAND' => 'ssh -i '.$keyPath.' -o IdentitiesOnly=yes -o StrictHostKeyChecking=no',
])
->run([
'git',
'ls-remote',
$application->repository_url,
'refs/heads/'.$environment->branch,
]);
if ($result->failed()) {
throw new RuntimeException(trim($result->errorOutput()) ?: 'Unable to resolve environment commit.');
}
return $this->commitFromOutput($result->output(), $environment->branch);
} finally {
rescue(fn () => File::deleteDirectory($directory), report: false);
}
}
private function commitFromOutput(string $output, string $branch): string
{
$commit = str($output)->before("\t")->trim()->value();
if (preg_match('/^[a-f0-9]{40}$/i', $commit) !== 1) {
throw new RuntimeException("Unable to resolve commit for branch {$branch}.");
}
return strtolower($commit);
}
}