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

@@ -1,24 +0,0 @@
<?php
namespace App\Actions\Applications;
use App\Models\Application;
use App\Models\Instance;
use App\Models\Server;
class CreateInstance
{
public function execute(
Application $application,
Server $server,
string $branch = 'main',
array $config = []
): Instance {
return $application->instances()->create([
'server_id' => $server->id,
'branch' => $branch,
'status' => 'pending',
'config' => $config,
]);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Actions\Applications;
use App\Enums\DeployPolicy;
use App\Enums\SchedulerMode;
use App\Enums\ServiceCategory;
use App\Enums\ServiceStatus;
use App\Enums\ServiceType;
use App\Models\Application;
use App\Models\Environment;
class CreateLaravelEnvironment
{
public function execute(
Application $application,
string $name,
?string $branch = null,
string $phpVersion = '8.4',
): Environment {
$environment = $application->environments()->create([
'name' => $name,
'branch' => $branch ?? $application->default_branch,
'status' => 'pending',
'scheduler_enabled' => true,
'scheduler_mode' => SchedulerMode::SINGLE,
'build_config' => [
'php_version' => $phpVersion,
'document_root' => 'public',
'health_path' => '/up',
'js_build_command' => null,
'js_package_manager' => 'bun',
],
]);
$web = $environment->services()->create([
'organisation_id' => $application->organisation_id,
'name' => 'web',
'category' => ServiceCategory::APPLICATION,
'type' => ServiceType::LARAVEL,
'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' => ['web', 'scheduler'],
'config' => [
'migration_mode' => 'auto',
'migration_timing' => 'pre_switch',
'migration_command' => 'php artisan migrate --force',
'document_root' => 'public',
'health_path' => '/up',
'js_build_command' => null,
'js_package_manager' => 'bun',
],
]);
$environment->forceFill([
'scheduler_target_service_id' => $web->id,
])->save();
return $environment->refresh();
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Actions\Applications;
use App\Models\Application;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use RuntimeException;
use Throwable;
class GenerateDeployKey
{
/**
* @param array{public: string, private: string, fingerprint?: string}|null $keyPair
*/
public function execute(Application $application, ?array $keyPair = null): Application
{
$keyPair ??= $this->generateWithSshKeygen($application);
$application->forceFill([
'deploy_key_public' => $keyPair['public'],
'deploy_key_private' => $keyPair['private'],
'deploy_key_fingerprint' => $keyPair['fingerprint'] ?? $this->fingerprint($keyPair['public']),
'deploy_key_installed_at' => null,
])->save();
return $application->refresh();
}
/**
* @return array{public: string, private: string, fingerprint: string}
*/
private function generateWithSshKeygen(Application $application): array
{
$directory = storage_path('app/private/deploy-keys/'.str()->uuid()->toString());
$privateKeyPath = $directory.'/id_ed25519';
File::ensureDirectoryExists($directory, 0700);
try {
$result = Process::run([
'ssh-keygen',
'-t',
'ed25519',
'-C',
"keystone-application-{$application->id}",
'-N',
'',
'-f',
$privateKeyPath,
]);
if ($result->failed()) {
throw new RuntimeException('Unable to generate deploy key: '.$result->errorOutput());
}
return [
'public' => trim(File::get($privateKeyPath.'.pub')),
'private' => trim(File::get($privateKeyPath)),
'fingerprint' => $this->fingerprint(trim(File::get($privateKeyPath.'.pub'))),
];
} finally {
rescue(fn () => File::deleteDirectory($directory), report: false);
}
}
private function fingerprint(string $publicKey): string
{
try {
$parts = explode(' ', trim($publicKey));
$keyMaterial = $parts[1] ?? $publicKey;
return 'SHA256:'.rtrim(strtr(base64_encode(hash('sha256', base64_decode($keyMaterial, true) ?: $publicKey, true)), '+/', '-_'), '=');
} catch (Throwable) {
return 'SHA256:'.hash('sha256', $publicKey);
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Actions\Applications;
use App\Models\Application;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use RuntimeException;
class VerifyRepositoryAccess
{
public function execute(Application $application): bool
{
if (! $application->deploy_key_private) {
throw new RuntimeException('Application does not have a deploy key.');
}
$directory = storage_path('app/private/operations/repository-access-'.$application->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',
'--heads',
$application->repository_url,
$application->default_branch,
]);
if ($result->successful()) {
$application->forceFill([
'deploy_key_installed_at' => now(),
])->save();
return true;
}
return false;
} finally {
rescue(fn () => File::deleteDirectory($directory), report: false);
}
}
}

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

View File

@@ -2,11 +2,14 @@
namespace App\Actions\Services;
use App\Enums\DeployPolicy;
use App\Enums\ServiceCategory;
use App\Enums\ServiceStatus;
use App\Enums\ServiceType;
use App\Jobs\Services\DeployService;
use App\Models\Server;
use App\Models\Service;
use RuntimeException;
class CreateService
{
@@ -16,15 +19,25 @@ class CreateService
ServiceCategory $category,
ServiceType $type,
string $version,
) {
): Service {
if ($category === ServiceCategory::GATEWAY && $server->services()->where('category', ServiceCategory::GATEWAY)->exists()) {
throw new RuntimeException('This server already has a gateway service.');
}
$driverName = "{$type->value}.{$version}";
$service = $server->services()->create([
'organisation_id' => $server->organisation_id,
'name' => $name,
'category' => $category,
'type' => $type, // postgres
'version' => $version, // 17
'driver_name' => $driverName, // postgres.17
'type' => $type,
'version' => $version,
'version_track' => $version,
'driver_name' => $driverName,
'status' => ServiceStatus::NOT_INSTALLED,
'deploy_policy' => $this->defaultDeployPolicy($category, $type),
'process_roles' => [],
'desired_replicas' => 1,
'config' => [],
]);
if (method_exists($service->driver(), 'defaultCredentials')) {
@@ -32,8 +45,40 @@ class CreateService
$service->save();
}
$service->replicas()->create([
'server_id' => $server->id,
'container_name' => "keystone-service-{$service->id}-1",
'internal_host' => "keystone-service-{$service->id}",
'internal_port' => $this->defaultInternalPort($type),
'status' => 'pending',
'health_status' => 'unknown',
'config' => [],
]);
dispatch(new DeployService($service));
return $service;
}
private function defaultDeployPolicy(ServiceCategory $category, ServiceType $type): DeployPolicy
{
return match (true) {
$category === ServiceCategory::APPLICATION => DeployPolicy::WITH_ENVIRONMENT,
$category === ServiceCategory::DATABASE,
$category === ServiceCategory::CACHE,
$category === ServiceCategory::STORAGE => DeployPolicy::DEPENDENCY_ONLY,
$category === ServiceCategory::GATEWAY => DeployPolicy::MANUAL_OR_ON_ROUTE_CHANGE,
default => DeployPolicy::MANUAL,
};
}
private function defaultInternalPort(ServiceType $type): int
{
return match ($type) {
ServiceType::POSTGRES => 5432,
ServiceType::VALKEY => 6379,
ServiceType::CADDY,
ServiceType::LARAVEL => 80,
};
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Actions\Services;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\ServiceType;
use App\Models\Operation;
use App\Models\Service;
use App\Services\Compose\ComposeRenderer;
use InvalidArgumentException;
class CreateStatefulServiceUpdateOperation
{
public function execute(Service $service, string $imageDigest, bool $backupRequested = false): Operation
{
if (! in_array($service->type, [ServiceType::POSTGRES, ServiceType::VALKEY], true)) {
throw new InvalidArgumentException('Only Postgres and Valkey have v1 stateful update operations.');
}
if ($backupRequested && ! ($service->config['backup_enabled'] ?? false)) {
throw new InvalidArgumentException('Backups are not configured for this service.');
}
$service->forceFill([
'available_image_digest' => $imageDigest,
'update_status' => 'update_pending',
])->save();
$operation = $service->operations()->create([
'kind' => OperationKind::SERVICE_DEPLOY,
'status' => OperationStatus::PENDING,
]);
$composePath = "/home/keystone/services/{$service->id}/compose.yml";
$serviceKey = str($service->name)->slug('_')->value() ?: 'service';
$volumeName = $this->namedVolume($service);
$steps = [
'Acknowledge downtime and data risk' => 'echo '.escapeshellarg('Stateful update requires downtime and preserves named volumes.'),
];
if ($backupRequested) {
$steps['Run pre-update backup'] = $service->config['backup_command'] ?? 'echo '.escapeshellarg('Run configured backup before stateful update.');
}
$steps += [
'Render compose with updated image digest' => $this->composeUploadScript($service),
'Stop existing container' => "docker compose -f {$composePath} stop {$serviceKey}",
'Preserve named volume' => $volumeName ? "docker volume inspect {$volumeName} >/dev/null" : 'true',
'Start service with updated image digest' => "docker compose -f {$composePath} up -d {$serviceKey}",
'Health check updated service' => implode("\n", [
"container_id=$(docker compose -f {$composePath} ps -q {$serviceKey})",
'test -n "$container_id"',
'for attempt in $(seq 1 30); do',
' health_status=$(docker inspect --format "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" "$container_id")',
' test "$health_status" = "healthy" -o "$health_status" = "running" && exit 0',
' sleep 2',
'done',
'printf "health_status=%s\n" "$health_status"',
'exit 1',
]),
];
$order = 1;
foreach ($steps as $name => $script) {
$operation->steps()->create([
'name' => $name,
'order' => $order++,
'status' => OperationStatus::PENDING,
'script' => $script,
]);
}
return $operation;
}
private function composeUploadScript(Service $service): string
{
$servicePath = "/home/keystone/services/{$service->id}";
$renderer = app(ComposeRenderer::class);
$compose = $renderer->render($service);
$env = $renderer->renderEnvironmentFile($service);
return implode("\n", [
"mkdir -p {$servicePath}",
'printf %s '.escapeshellarg(base64_encode($compose))." | base64 -d > {$servicePath}/compose.yml",
'printf %s '.escapeshellarg(base64_encode($env))." | base64 -d > {$servicePath}/.env",
]);
}
private function namedVolume(Service $service): ?string
{
return match ($service->type) {
ServiceType::POSTGRES => "keystone_service_{$service->id}_postgres_data",
ServiceType::VALKEY => ($service->config['persistence'] ?? false) ? "keystone_service_{$service->id}_valkey_data" : null,
default => null,
};
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Actions\Services;
use App\Enums\ServiceEndpointScope;
use App\Models\ServiceEndpoint;
use App\Models\ServiceReplica;
class RegisterServiceEndpoint
{
public function execute(ServiceReplica $replica, ?ServiceReplica $consumerReplica = null, bool $allowPublicFallback = false): ServiceEndpoint
{
$scope = $this->scope($replica, $consumerReplica, $allowPublicFallback);
return $replica->service->endpoints()->updateOrCreate([
'service_replica_id' => $replica->id,
'scope' => $scope,
'port' => $replica->internal_port,
], [
'hostname' => $this->hostname($replica, $scope),
'ip_address' => $this->ipAddress($replica, $scope),
'priority' => $this->priority($scope),
'health_status' => $replica->health_status,
]);
}
private function scope(ServiceReplica $replica, ?ServiceReplica $consumerReplica, bool $allowPublicFallback): ServiceEndpointScope
{
if ($consumerReplica && $consumerReplica->server_id === $replica->server_id) {
return ServiceEndpointScope::DOCKER_NETWORK;
}
if ($replica->server?->private_ip) {
return ServiceEndpointScope::PRIVATE_NETWORK;
}
if ($allowPublicFallback && $replica->server?->ipv4) {
return ServiceEndpointScope::PUBLIC;
}
return ServiceEndpointScope::DOCKER_NETWORK;
}
private function hostname(ServiceReplica $replica, ServiceEndpointScope $scope): string
{
return match ($scope) {
ServiceEndpointScope::DOCKER_NETWORK => $replica->internal_host,
ServiceEndpointScope::PRIVATE_NETWORK => $replica->server->private_ip,
ServiceEndpointScope::PUBLIC => $replica->server->ipv4,
};
}
private function ipAddress(ServiceReplica $replica, ServiceEndpointScope $scope): ?string
{
return match ($scope) {
ServiceEndpointScope::DOCKER_NETWORK => null,
ServiceEndpointScope::PRIVATE_NETWORK => $replica->server->private_ip,
ServiceEndpointScope::PUBLIC => $replica->server->ipv4,
};
}
private function priority(ServiceEndpointScope $scope): int
{
return match ($scope) {
ServiceEndpointScope::DOCKER_NETWORK => 10,
ServiceEndpointScope::PRIVATE_NETWORK => 20,
ServiceEndpointScope::PUBLIC => 100,
};
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Actions\Services;
use App\Drivers\Concerns\RendersCompose;
use App\Models\Server;
use App\Models\Service;
use App\Services\Operations\RemoteCommandRunner;
use RuntimeException;
class ResolveServiceImageDigest
{
public function __construct(
private readonly RemoteCommandRunner $remoteCommandRunner,
) {}
public function execute(Service $service): string
{
$image = $this->imageReference($service);
if (str_starts_with($image, 'sha256:')) {
return $image;
}
$output = $this->remoteCommandRunner->run($this->targetServer($service), implode("\n", [
'set -euo pipefail',
'image='.escapeshellarg($image),
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' "$image" 2>/dev/null || true)',
'if [ -z "$digest" ]; then',
' docker pull "$image"',
' digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' "$image")',
'fi',
'printf "image_digest=%s\n" "$digest"',
]));
if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) {
return $this->digestFromOutput($matches['digest'], $image);
}
return $this->digestFromOutput($output, $image);
}
private function imageReference(Service $service): string
{
$driver = $service->driver();
if (! $driver instanceof RendersCompose) {
throw new RuntimeException("Driver [{$service->driver_name}] cannot resolve an image digest.");
}
$image = $driver->composeService()['image'] ?? null;
if (! is_string($image) || $image === '') {
throw new RuntimeException("Driver [{$service->driver_name}] did not provide an image reference.");
}
return $image;
}
private function targetServer(Service $service): Server
{
$service->loadMissing('server', 'replicas.server');
$server = $service->replicas->first()?->server ?: $service->server;
if (! $server instanceof Server) {
throw new RuntimeException("Service [{$service->id}] must have a target server before resolving an image digest.");
}
return $server;
}
private function digestFromOutput(string $output, string $image): string
{
if (str_contains($output, '@')) {
return str($output)->after('@')->trim()->value();
}
if (str_starts_with($output, 'sha256:')) {
return $output;
}
throw new RuntimeException("Unable to resolve image digest for [{$image}].");
}
}

View File

@@ -14,7 +14,7 @@ class CreateServiceCommand extends Command
protected $description = 'Create a service';
public function handle()
public function handle(): void
{
$serverId = $this->components->ask('Enter the server ID');
$server = Server::find($serverId);
@@ -26,7 +26,7 @@ class CreateServiceCommand extends Command
}
$serviceType = $this->components->choice('select the service you want to install', [
'postgres-17',
'postgres-18',
]);
$serviceName = $this->components->ask('Enter the service name');
@@ -36,7 +36,7 @@ class CreateServiceCommand extends Command
server: $server,
name: $serviceName,
category: ServiceCategory::DATABASE,
type: ServiceType::tryFrom($type),
type: ServiceType::from($type),
version: $version,
);

View File

@@ -20,7 +20,7 @@ class GenerateJSEnums extends Command
public function handle(): int
{
$enums = base_path('app/enums');
$enums = app_path('Enums');
$this->load($enums);
@@ -87,7 +87,7 @@ class GenerateJSEnums extends Command
// Skip format, JS date formats are different to PHP ones.
if ($name !== 'Format') {
file_put_contents(base_path('resources/js/Enums/'.$name.'.js'), $js);
file_put_contents(resource_path('js/enums/'.$name.'.js'), $js);
$this->info('Stored '.$enum);
} else {
$this->info('Skipped '.$name.'s');

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Data\Environments;
use App\Models\Service;
class EnvironmentDeploymentPlan
{
/**
* @param array<int, Service> $services
* @param array<int, Service> $dependencies
* @param array<int, string> $warnings
* @param array<int, string> $blockers
*/
public function __construct(
public array $services = [],
public array $dependencies = [],
public bool $requiresRegistry = false,
public array $warnings = [],
public array $blockers = [],
) {
//
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Data\Deployments;
namespace App\Data\Operations;
class Plan
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Data\Deployments;
namespace App\Data\Operations;
class PlannedStep
{
@@ -11,28 +11,34 @@ class PlannedStep
protected array $secrets = [],
string|callable $script = 'echo "Incomplete Step"',
) {
if (is_callable($script)) {
$this->script = $script();
} else {
$this->script = $script;
}
$this->script = is_callable($script) ? $script() : $script;
}
public function getSafeScript(): string
{
$script = $this->script;
foreach ($this->secrets as $key => $value) {
$script = str_replace("[!{$key}]", '********', $script);
$script = str_replace("[!{$key}!]", '********', $script);
}
return $script;
}
public function getScriptTemplate(): string
{
return $this->script;
}
public function secrets(): array
{
return $this->secrets;
}
public function getScript(): string
{
$script = $this->script;
foreach ($this->secrets as $key => $value) {
$script = str_replace("[!{$key}]", $value, $script);
$script = str_replace("[!{$key}!]", $value, $script);
}
return $script;

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 [];
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum BuildArtifactStatus: string
{
use Arrayable;
case PENDING = 'pending';
case BUILDING = 'building';
case AVAILABLE = 'available';
case FAILED = 'failed';
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum BuildStrategy: string
{
use Arrayable;
case TARGET_SERVER = 'target_server';
case DEDICATED_BUILDER = 'dedicated_builder';
case EXTERNAL_REGISTRY = 'external_registry';
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum DeployPolicy: string
{
use Arrayable;
case WITH_ENVIRONMENT = 'with_environment';
case DEPENDENCY_ONLY = 'dependency_only';
case MANUAL_OR_ON_ROUTE_CHANGE = 'manual_or_on_route_change';
case MANUAL = 'manual';
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum EnvironmentAttachmentRole: string
{
use Arrayable;
case DATABASE = 'database';
case CACHE = 'cache';
case QUEUE = 'queue';
case STORAGE = 'storage';
case GATEWAY = 'gateway';
case CUSTOM = 'custom';
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum EnvironmentVariableSource: string
{
use Arrayable;
case USER = 'user';
case MANAGED_ATTACHMENT = 'managed_attachment';
case SYSTEM = 'system';
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum OperationKind: string
{
use Arrayable;
case SERVER_PROVISION = 'server_provision';
case SERVICE_DEPLOY = 'service_deploy';
case REPLICA_DEPLOY = 'replica_deploy';
case SLICE_PROVISION = 'slice_provision';
case SLICE_CONFIGURE = 'slice_configure';
case ENVIRONMENT_DEPLOY = 'environment_deploy';
case GATEWAY_CUTOVER = 'gateway_cutover';
case CONFIG_CHANGE = 'config_change';
case CREDENTIAL_ROTATION = 'credential_rotation';
}

View File

@@ -4,7 +4,7 @@ namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum DeploymentStatus: string
enum OperationStatus: string
{
use Arrayable;

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum RegistryType: string
{
use Arrayable;
case GENERIC = 'generic';
case GITEA = 'gitea';
case GHCR = 'ghcr';
case DOCKER_HUB = 'docker_hub';
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum SchedulerMode: string
{
use Arrayable;
case SINGLE = 'single';
case EVERY_REPLICA = 'every_replica';
}

View File

@@ -13,6 +13,7 @@ enum ServiceCategory: string
case GATEWAY = 'gateway';
case STORAGE = 'storage';
case CACHE = 'cache';
case BUILDER = 'builder';
public static function getDescription(ServiceCategory|string $category)
{
@@ -25,10 +26,11 @@ enum ServiceCategory: string
return match ($category) {
self::APPLICATION => 'The base container image for your application',
self::DATABASE => 'Postgres or MySQL',
self::DATABASE => 'Postgres',
self::GATEWAY => 'The first point of contact for your application',
self::STORAGE => 'S3 or S3-compatible service',
self::CACHE => 'Redis, Memcached or similar',
self::CACHE => 'Valkey',
self::BUILDER => 'Build service for application artifacts',
};
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum ServiceEndpointScope: string
{
use Arrayable;
case DOCKER_NETWORK = 'docker_network';
case PRIVATE_NETWORK = 'private_network';
case PUBLIC = 'public';
}

View File

@@ -8,14 +8,8 @@ enum ServiceType: string
{
use Arrayable;
case FRANKENPHP = 'frankenphp';
case PHP_FPM = 'php-fpm';
case POSTGRES = 'postgres';
case CADDY = 'caddy';
case VALKEY = 'valkey';
// future?
case MYSQL = 'mysql';
case NGINX = 'nginx';
case REDIS = 'redis';
case LARAVEL = 'laravel';
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum SourceProviderType: string
{
use Arrayable;
case GITEA = 'gitea';
case GITHUB = 'github';
case GENERIC_GIT = 'generic_git';
}

View File

@@ -2,26 +2,65 @@
namespace App\Http\Controllers;
use App\Actions\Applications\CreateLaravelEnvironment;
use App\Actions\Applications\GenerateDeployKey;
use App\Actions\Applications\VerifyRepositoryAccess;
use App\Enums\RepositoryType;
use App\Enums\ServerStatus;
use App\Http\Requests\StoreApplicationRequest;
use App\Models\Application;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class ApplicationController extends Controller
{
public function index(Request $request)
public function index(Request $request): Response
{
$organisation = Organisation::with('applications.instances.server')->findOrFail($request->route('organisation'));
$organisation = Organisation::with('applications.environments.services')->findOrFail($request->route('organisation'));
return inertia('applications/Index', [
'applications' => $organisation->applications,
]);
}
public function show(Request $request)
public function create(Request $request): Response
{
Organisation::findOrFail($request->route('organisation'));
return inertia('applications/Create');
}
public function store(StoreApplicationRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->create([
'name' => $request->string('name')->toString(),
'repository_url' => $request->string('repository_url')->toString(),
'repository_type' => RepositoryType::GIT,
'default_branch' => $request->string('default_branch')->toString(),
]);
app(GenerateDeployKey::class)->execute($application);
app(CreateLaravelEnvironment::class)->execute($application->refresh(), $request->string('environment_name')->toString());
return redirect()
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
->with('success', 'Application created. Add the deploy key to your repository before verifying access.');
}
public function show(Request $request): Response
{
$id = $request->route('application');
$application = Application::with(['instances.server', 'organisation'])->findOrFail($id);
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = Application::with([
'environments.services.slices',
'environments.attachments.service',
'environments.variables',
'organisation',
])->whereBelongsTo($organisation)->findOrFail($id);
return inertia('applications/Show', [
'application' => $application,
@@ -35,4 +74,16 @@ class ApplicationController extends Controller
}),
]);
}
public function verifyRepository(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
if (! app(VerifyRepositoryAccess::class)->execute($application)) {
return back()->with('error', 'Repository access could not be verified.');
}
return back()->with('success', 'Repository access verified.');
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Environments\AttachManagedService;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceType;
use App\Http\Requests\StoreEnvironmentAttachmentRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class EnvironmentAttachmentController extends Controller
{
public function create(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
return inertia('environment-attachments/Create', [
'application' => $application,
'environment' => $environment,
'services' => $organisation->services()
->whereIn('type', [ServiceType::POSTGRES->value, ServiceType::VALKEY->value, ServiceType::CADDY->value])
->orderBy('name')
->get(['id', 'name', 'type', 'category']),
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
]);
}
public function store(StoreEnvironmentAttachmentRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$service = $organisation->services()->findOrFail($request->integer('service_id'));
app(AttachManagedService::class)->execute(
environment: $environment,
service: $service,
role: $request->enum('role', EnvironmentAttachmentRole::class),
name: $request->filled('name') ? $request->string('name')->toString() : null,
envPrefix: $request->filled('env_prefix') ? $request->string('env_prefix')->toString() : null,
isPrimary: $request->boolean('is_primary', true),
);
return redirect()
->route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Managed service attached.');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers;
use App\Models\Organisation;
use Illuminate\Http\Request;
use Inertia\Response;
class EnvironmentController extends Controller
{
public function show(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()
->with([
'services.replicas',
'services.slices',
'services.operations.steps',
'attachments.service',
'attachments.serviceSlice',
'variables',
'operations.steps',
])
->findOrFail($request->route('environment'));
return inertia('environments/Show', [
'application' => $application,
'environment' => $environment,
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\Environments\DeployEnvironment;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
class EnvironmentDeploymentController extends Controller
{
public function store(Organisation $organisation, Application $application, Environment $environment): RedirectResponse
{
abort_unless(
(int) $application->organisation_id === (int) $organisation->id
&& (int) $environment->application_id === (int) $application->id,
404,
);
dispatch(new DeployEnvironment($environment));
return redirect()->route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Environments\CreateMigrationOperation;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EnvironmentMigrationController extends Controller
{
public function store(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
app(CreateMigrationOperation::class)->execute($environment);
return redirect()
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
->with('success', 'Migration operation created.');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use App\Enums\EnvironmentVariableSource;
use App\Http\Requests\StoreEnvironmentVariableRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class EnvironmentVariableController extends Controller
{
public function create(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
return inertia('environment-variables/Create', [
'application' => $application,
'environment' => $environment,
]);
}
public function store(StoreEnvironmentVariableRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$environment->variables()->updateOrCreate([
'key' => $request->string('key')->toString(),
], [
'value' => $request->string('value')->toString(),
'source' => EnvironmentVariableSource::USER,
'service_slice_id' => null,
'overridable' => true,
]);
return redirect()
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
->with('success', 'Environment variable saved.');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Environments\CreateLaravelWorkerService;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EnvironmentWorkerController extends Controller
{
public function store(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
app(CreateLaravelWorkerService::class)->execute($environment);
return redirect()
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
->with('success', 'Worker service created.');
}
}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Applications\CreateInstance;
use App\Models\Application;
use App\Models\Server;
use Illuminate\Http\Request;
class InstanceController extends Controller
{
public function store(Request $request, Application $application)
{
$validated = $request->validate([
'server_id' => 'required|exists:servers,id',
'branch' => 'required|string|max:255',
'config' => 'sometimes|array',
]);
$server = Server::findOrFail($validated['server_id']);
$instance = (new CreateInstance())->execute(
$application,
$server,
$validated['branch'],
$validated['config'] ?? []
);
return redirect()
->route('applications.show', [
'organisation' => $application->organisation_id,
'application' => $application->id
])
->with('success', 'Instance created successfully');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Models\Organisation;
use Inertia\Response;
class OnboardingController extends Controller
{
public function show(Organisation $organisation): Response
{
$organisation->loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
$steps = [
[
'key' => 'organisation',
'label' => 'Organisation',
'complete' => true,
'href' => route('organisations.show', ['organisation' => $organisation->id]),
],
[
'key' => 'provider',
'label' => 'Provider',
'complete' => $organisation->providers_count > 0,
'href' => route('organisations.show', ['organisation' => $organisation->id]),
],
[
'key' => 'source',
'label' => 'Source',
'complete' => $organisation->source_providers_count > 0,
'href' => route('source-providers.create', ['organisation' => $organisation->id]),
],
[
'key' => 'registry',
'label' => 'Registry',
'complete' => $organisation->registries_count > 0,
'href' => route('registries.create', ['organisation' => $organisation->id]),
],
[
'key' => 'server',
'label' => 'Server',
'complete' => $organisation->servers_count > 0,
'href' => route('servers.create', ['organisation' => $organisation->id]),
],
[
'key' => 'application',
'label' => 'Application',
'complete' => $organisation->applications_count > 0,
'href' => route('applications.create', ['organisation' => $organisation->id]),
],
];
$next = collect($steps)->firstWhere('complete', false) ?? $steps[array_key_last($steps)];
return inertia('onboarding/Show', [
'organisation' => $organisation,
'steps' => $steps,
'nextStep' => $next,
]);
}
}

View File

@@ -13,6 +13,8 @@ class OrganisationController extends Controller
{
return inertia('organisations/Show', [
'providers' => Inertia::lazy(fn () => Provider::whereOrganisationId($request->route('organisation'))->get()),
'registries' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->registries()->get()),
'sourceProviders' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->sourceProviders()->get()),
'organisation' => Organisation::withCount('servers', 'applications', 'members')->findOrFail($request->route('organisation')),
]);
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers;
use App\Enums\RegistryType;
use App\Http\Requests\StoreRegistryRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class RegistryController extends Controller
{
public function create(Request $request): Response
{
Organisation::findOrFail($request->route('organisation'));
return inertia('registries/Create', [
'registryTypes' => array_values(RegistryType::toArray()),
]);
}
public function store(StoreRegistryRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$organisation->registries()->create([
'name' => $request->string('name')->toString(),
'type' => $request->enum('type', RegistryType::class),
'url' => rtrim($request->string('url')->toString(), '/'),
'credentials' => [
'username' => $request->string('username')->toString(),
'password' => $request->string('password')->toString(),
],
]);
return redirect()
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Registry created.');
}
}

View File

@@ -75,12 +75,12 @@ class ServerController extends Controller
}
$networkZone = $request->network_zone ?? 'global';
// Look for an existing network with the same network_zone
$network = $provider->networks()
->where('network_zone', $networkZone)
->first();
if (! $network) {
// We need to create a network with the correct network zone
$networkName = "keystone-{$networkZone}";
@@ -141,7 +141,7 @@ class ServerController extends Controller
$server = $organisation->servers()->findOrFail($request->route('server'));
return inertia('servers/Show', [
'server' => $server->load('services.slices', 'serviceDeployments.steps', 'serviceDeployments.target'),
'server' => $server->load('services.slices', 'serviceOperations.steps', 'serviceOperations.target'),
]);
}
}

View File

@@ -5,13 +5,16 @@ namespace App\Http\Controllers;
use App\Actions\Services\CreateService;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Http\Requests\StoreServiceRequest;
use App\Http\Requests\UpdateServiceRequest;
use App\Models\Server;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Response;
class ServiceController extends Controller
{
public function create(Request $request)
public function create(Request $request): Response
{
$server = Server::findOrFail($request->route('server'));
@@ -21,19 +24,8 @@ class ServiceController extends Controller
]);
}
public function store(Request $request)
public function store(StoreServiceRequest $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'category' => ['required', Rule::enum(ServiceCategory::class)],
'type' => ['required', Rule::enum(ServiceType::class)],
'version' => ['required', 'string', function ($key, $value, $fail) use ($request) {
if (!isset(config('keystone.services')[$request->category][$request->type]['versions'][$value])) {
$fail('The selected version is invalid.');
}
}],
]);
$server = Server::findOrFail($request->route('server'));
$service = app(CreateService::class)->execute(
@@ -52,4 +44,44 @@ class ServiceController extends Controller
'service' => $service,
]);
}
public function show(Request $request): Response
{
$server = Server::findOrFail($request->route('server'));
$service = $server->services()
->with(['replicas', 'slices', 'operations.steps', 'environment.application'])
->findOrFail($request->route('service'));
return inertia('services/Show', [
'server' => $server,
'service' => $service,
]);
}
public function edit(Request $request): Response
{
$server = Server::findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
return inertia('services/Edit', [
'server' => $server,
'service' => $service,
]);
}
public function update(UpdateServiceRequest $request): RedirectResponse
{
$server = Server::findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
$service->update($request->validated());
return redirect()
->route('services.show', [
'organisation' => $server->organisation_id,
'server' => $server->id,
'service' => $service->id,
])
->with('success', 'Service updated.');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Services\CreateStatefulServiceUpdateOperation;
use App\Enums\ServiceType;
use App\Http\Requests\StoreServiceUpdateRequest;
use App\Models\Organisation;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Http\RedirectResponse;
use Inertia\Response;
class ServiceUpdateController extends Controller
{
public function create(Organisation $organisation, Server $server, Service $service): Response
{
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
abort_unless(in_array($service->type, [ServiceType::POSTGRES, ServiceType::VALKEY], true), 404);
return inertia('services/updates/Create', [
'server' => $server,
'service' => $service,
'backupAvailable' => (bool) ($service->config['backup_enabled'] ?? false),
]);
}
public function store(
StoreServiceUpdateRequest $request,
Organisation $organisation,
Server $server,
Service $service,
CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation,
): RedirectResponse {
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
$createStatefulServiceUpdateOperation->execute(
service: $service,
imageDigest: $request->string('image_digest')->toString(),
backupRequested: $request->boolean('backup_requested'),
);
return redirect()->route('servers.show', [
'organisation' => $organisation->id,
'server' => $server->id,
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SourceProviderType;
use App\Http\Requests\StoreSourceProviderRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class SourceProviderController extends Controller
{
public function create(Request $request): Response
{
Organisation::findOrFail($request->route('organisation'));
return inertia('source-providers/Create', [
'sourceProviderTypes' => array_values(SourceProviderType::toArray()),
]);
}
public function store(StoreSourceProviderRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$organisation->sourceProviders()->create([
'name' => $request->string('name')->toString(),
'type' => $request->enum('type', SourceProviderType::class),
'url' => $request->filled('url') ? rtrim($request->string('url')->toString(), '/') : null,
'config' => [],
]);
return redirect()
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Source provider created.');
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Models\Application;
use App\Models\Organisation;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Tighten\Ziggy\Ziggy;
@@ -29,8 +30,12 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'organisation' => $request->route('organisation') ? Organisation::with('applications')->findOrFail($request->route('organisation')) : null,
'application' => $request->route('application') ? Application::with('environments')->findOrFail($request->route('application')) : null,
'organisation' => $request->route('organisation')
? Organisation::with('applications')->findOrFail($this->routeKey($request->route('organisation')))
: null,
'application' => $request->route('application')
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
: null,
'flash' => [
'server_credentials' => $request->session()->has('sudo_password') ? [
'sudo_password' => $request->session()->get('sudo_password'),
@@ -45,4 +50,9 @@ class HandleInertiaRequests extends Middleware
],
];
}
private function routeKey(mixed $routeValue): mixed
{
return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreApplicationRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'],
'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
'environment_name' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use App\Enums\EnvironmentAttachmentRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreEnvironmentAttachmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'service_id' => ['required', 'integer', 'exists:services,id'],
'role' => ['required', Rule::enum(EnvironmentAttachmentRole::class)],
'name' => ['nullable', 'string', 'max:255'],
'env_prefix' => ['nullable', 'string', 'max:32', 'regex:/^[A-Z][A-Z0-9_]*$/'],
'is_primary' => ['boolean'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEnvironmentVariableRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
'value' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use App\Enums\RegistryType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreRegistryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(RegistryType::class)],
'url' => ['required', 'string', 'max:255'],
'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Models\Server;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreServiceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'category' => ['required', Rule::enum(ServiceCategory::class)],
'type' => ['required', Rule::enum(ServiceType::class)],
'version' => ['required', 'string', function (string $attribute, mixed $value, \Closure $fail): void {
if (! isset(config('keystone.services')[$this->category][$this->type]['versions'][$value])) {
$fail('The selected version is invalid.');
}
}],
];
}
public function after(): array
{
return [
function ($validator): void {
if ($this->category !== ServiceCategory::GATEWAY->value) {
return;
}
$server = Server::find($this->route('server'));
if ($server?->services()->where('category', ServiceCategory::GATEWAY)->exists()) {
$validator->errors()->add('category', 'This server already has a gateway service.');
}
},
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreServiceUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'image_digest' => ['required', 'string', 'starts_with:sha256:'],
'backup_requested' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use App\Enums\SourceProviderType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreSourceProviderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(SourceProviderType::class)],
'url' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateServiceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'desired_replicas' => ['required', 'integer', 'min:0', 'max:25'],
'default_cpu_limit' => ['nullable', 'numeric', 'min:0.125', 'max:64'],
'default_memory_limit_mb' => ['nullable', 'integer', 'min:64', 'max:1048576'],
];
}
}

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Jobs\Applications;
use App\Enums\DeploymentStatus;
use App\Models\Application;
use App\Models\Deployment;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class DeployApplication implements ShouldQueue
{
use Queueable;
protected Deployment $deployment;
public function __construct(
public Application $application,
) {
//
}
public function handle(): void
{
$this->deployment = $this->application->deployments()->create([
'status' => DeploymentStatus::PENDING,
]);
foreach ($this->application->instances as $instance) {
$step = $this->deployment->steps()->create([
'name' => "Deploy to {$instance->server->name}",
'order' => $instance->id,
'status' => DeploymentStatus::PENDING,
'script' => $this->getDeploymentScript($instance),
'secrets' => [],
]);
$step->dispatchJob();
}
}
protected function getDeploymentScript($instance): string
{
return "#!/bin/bash\n" .
"cd /opt/apps/{$this->application->name}-{$instance->id}\n" .
"git fetch origin\n" .
"git checkout {$instance->branch}\n" .
"git pull origin {$instance->branch}\n";
}
public function failed(\Throwable $exception): void
{
if (isset($this->deployment)) {
$this->deployment->update([
'status' => DeploymentStatus::FAILED,
]);
}
}
}

View File

@@ -0,0 +1,497 @@
<?php
namespace App\Jobs\Environments;
use App\Actions\Environments\BuildApplicationArtifact;
use App\Actions\Environments\BuildMigrationScript;
use App\Actions\Environments\PlanBuildArtifact;
use App\Actions\Environments\PlanEnvironmentDeployment;
use App\Actions\Environments\ResolveEnvironmentCommit;
use App\Actions\Services\RegisterServiceEndpoint;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\ServiceEndpointScope;
use App\Models\Environment;
use App\Models\EnvironmentAttachment;
use App\Models\Operation;
use App\Models\Service;
use App\Models\ServiceReplica;
use App\Services\Compose\ComposeRenderer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use InvalidArgumentException;
use RuntimeException;
class DeployEnvironment implements ShouldQueue
{
use Queueable;
public function __construct(
public Environment $environment,
) {
//
}
public function handle(): void
{
$plan = app(PlanEnvironmentDeployment::class)->execute($this->environment);
if ($plan->requiresRegistry) {
throw new RuntimeException('A registry is required before deploying this environment across multiple servers.');
}
if ($plan->blockers !== []) {
throw new RuntimeException($plan->blockers[0]);
}
$operation = $this->environment->operations()->create([
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
'status' => OperationStatus::PENDING,
'started_at' => now(),
]);
$commitSha = app(ResolveEnvironmentCommit::class)->execute($this->environment);
$services = $this->servicesNeedingDeployment($plan->services, $commitSha);
if ($services === []) {
$operation->update([
'status' => OperationStatus::COMPLETED,
'finished_at' => now(),
]);
return;
}
$artifact = app(PlanBuildArtifact::class)->execute($this->environment, $commitSha);
$artifact = app(BuildApplicationArtifact::class)->execute($artifact, $operation);
foreach ($services as $service) {
$service->update([
'available_image_digest' => $artifact->image_digest,
'desired_revision' => $commitSha,
]);
$child = $service->operations()->create([
'parent_id' => $operation->id,
'kind' => OperationKind::SERVICE_DEPLOY,
'status' => OperationStatus::PENDING,
]);
$this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest);
$this->createReplicaDeployOperations($child, $service, $artifact->registry_ref);
}
$this->createGatewayOperations($operation);
$this->dispatchChildOperations($operation);
}
/**
* @param array<int, Service> $services
* @return array<int, Service>
*/
private function servicesNeedingDeployment(array $services, string $commitSha): array
{
return collect($services)
->filter(fn (Service $service): bool => $service->desired_revision !== $commitSha || ! $service->available_image_digest)
->values()
->all();
}
private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest): void
{
foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest) as $index => $step) {
$operation->steps()->create([
'name' => $step['name'],
'order' => $index + 1,
'status' => OperationStatus::PENDING,
'script' => $step['script'],
]);
}
}
private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null): void
{
$replicas = $this->ensureServiceReplicas($service);
for ($replica = 1; $replica <= max(1, $service->desired_replicas); $replica++) {
$serviceReplica = $replicas[$replica - 1] ?? null;
$target = $serviceReplica ?: $service;
$operation = $target->operations()->create([
'parent_id' => $parent->id,
'kind' => OperationKind::REPLICA_DEPLOY,
'status' => OperationStatus::PENDING,
]);
$serviceReplica?->update([
'operation_id' => $operation->id,
'image_digest' => $service->available_image_digest,
'status' => 'pending',
'health_status' => 'unknown',
]);
foreach ($this->replicaDeployScripts($service, $replica, $imageReference) as $index => $step) {
$operation->steps()->create([
'name' => $step['name'],
'order' => $index + 1,
'status' => OperationStatus::PENDING,
'script' => $step['script'],
]);
}
}
}
/**
* @return array<int, ServiceReplica>
*/
private function ensureServiceReplicas(Service $service): array
{
$service->loadMissing('replicas');
$serverIds = $this->placementServerIds($service);
if ($service->replicas->count() < $service->desired_replicas && $serverIds !== []) {
for ($index = $service->replicas->count() + 1; $index <= $service->desired_replicas; $index++) {
$service->replicas()->create([
'server_id' => $serverIds[($index - 1) % count($serverIds)],
'container_name' => "keystone-service-{$service->id}-{$index}",
'internal_host' => "keystone-service-{$service->id}",
'internal_port' => $this->defaultInternalPort($service),
'status' => 'pending',
'health_status' => 'unknown',
'config' => [],
]);
}
$service->load('replicas');
}
return $service->replicas
->take(max(1, $service->desired_replicas))
->values()
->all();
}
/**
* @return array<int, int>
*/
private function placementServerIds(Service $service): array
{
$configured = collect($service->config['server_ids'] ?? [])
->map(fn (mixed $serverId): int => (int) $serverId)
->filter()
->unique()
->values()
->all();
if ($configured !== []) {
return $configured;
}
$existing = $service->replicas
->pluck('server_id')
->filter()
->unique()
->values()
->all();
if ($existing !== []) {
return $existing;
}
return $service->server_id ? [(int) $service->server_id] : [];
}
private function createGatewayOperations(Operation $parent): void
{
$this->environment->loadMissing('attachments.service.replicas', 'attachments.serviceSlice');
foreach ($this->environment->attachments->where('role', EnvironmentAttachmentRole::GATEWAY) as $attachment) {
$target = $attachment->serviceSlice ?: $this->environment;
$sliceConfigure = $target->operations()->create([
'parent_id' => $parent->id,
'kind' => OperationKind::SLICE_CONFIGURE,
'status' => OperationStatus::PENDING,
]);
$sliceConfigure->steps()->create([
'name' => 'Configure Caddy route',
'order' => 1,
'status' => OperationStatus::PENDING,
'script' => $this->configureCaddyRouteScript($attachment),
]);
$gatewayCutover = $this->environment->operations()->create([
'parent_id' => $parent->id,
'kind' => OperationKind::GATEWAY_CUTOVER,
'status' => OperationStatus::PENDING,
]);
foreach ($this->gatewayCutoverSteps($attachment) as $index => $step) {
$gatewayCutover->steps()->create([
'name' => $step['name'],
'order' => $index + 1,
'status' => OperationStatus::PENDING,
'script' => $step['script'],
]);
}
}
}
/**
* @return array<int, array{name: string, script: string}>
*/
private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest): array
{
$servicePath = $this->servicePath($service);
$composePath = "{$servicePath}/compose.yml";
$serviceKey = $this->serviceKey($service);
$steps = [
[
'name' => 'Resolve target commit',
'script' => implode("\n", [
"mkdir -p {$servicePath}",
'printf %s '.escapeshellarg($commitSha)." > {$servicePath}/REVISION",
]),
],
[
'name' => 'Create or reuse build artifact',
'script' => 'printf %s '.escapeshellarg($imageDigest)." > {$servicePath}/IMAGE_DIGEST",
],
[
'name' => 'Render Compose files',
'script' => $this->composeUploadScript($service),
],
];
foreach ($service->driver()->preSwitchSteps() as $step) {
$steps[] = [
'name' => $step->name,
'script' => $step->getScriptTemplate(),
];
}
if (($service->config['migration_timing'] ?? 'pre_switch') === 'pre_switch') {
$steps[] = [
'name' => 'Run migrations',
'script' => app(BuildMigrationScript::class)->execute($service),
];
}
$steps = [
...$steps,
[
'name' => 'Deploy replicas',
'script' => "docker compose -f {$composePath} up -d --scale {$serviceKey}=".max(1, $service->desired_replicas),
],
[
'name' => 'Health check replicas',
'script' => "docker compose -f {$composePath} ps --status running {$serviceKey}",
],
];
if (($service->config['migration_timing'] ?? 'pre_switch') === 'post_switch') {
$steps[] = [
'name' => 'Run migrations',
'script' => app(BuildMigrationScript::class)->execute($service),
];
}
return [
...$steps,
[
'name' => 'Drain old replicas',
'script' => "docker ps --filter 'label=keystone.service_id={$service->id}' --filter 'label=keystone.draining=true' --format '{{.ID}}' | xargs -r docker stop",
],
];
}
/**
* @return array<int, array{name: string, script: string}>
*/
private function replicaDeployScripts(Service $service, int $replica, ?string $imageReference = null): array
{
$composePath = $this->servicePath($service).'/compose.yml';
$project = "keystone_service_{$service->id}_replica_{$replica}";
$serviceKey = $this->serviceKey($service);
$steps = [];
if ($imageReference && $service->available_image_digest) {
$steps[] = [
'name' => "Pull image for replica {$replica}",
'script' => 'docker pull '.escapeshellarg($imageReference.'@'.$service->available_image_digest),
];
}
return [
...$steps,
[
'name' => "Render replica {$replica}",
'script' => "docker compose -p {$project} -f {$composePath} config --quiet",
],
[
'name' => "Start replica {$replica}",
'script' => implode("\n", [
"docker compose -p {$project} -f {$composePath} up -d {$serviceKey}",
"container_id=$(docker compose -p {$project} -f {$composePath} ps -q {$serviceKey})",
'printf "container_id=%s\n" "$container_id"',
]),
],
[
'name' => "Health check replica {$replica}",
'script' => implode("\n", [
"docker compose -p {$project} -f {$composePath} ps --status running {$serviceKey}",
"container_id=$(docker compose -p {$project} -f {$composePath} ps -q {$serviceKey})",
'health_status=$(docker inspect --format "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" "$container_id")',
'printf "health_status=%s\n" "$health_status"',
]),
],
];
}
private function composeUploadScript(Service $service): string
{
$servicePath = $this->servicePath($service);
try {
$renderer = app(ComposeRenderer::class);
$compose = $renderer->render($service);
$env = $renderer->renderEnvironmentFile($service);
} catch (InvalidArgumentException) {
$compose = "services:\n {$this->serviceKey($service)}:\n image: \"{$service->available_image_digest}\"\n";
$env = '';
}
return implode("\n", [
"mkdir -p {$servicePath}",
'printf %s '.escapeshellarg(base64_encode($compose))." | base64 -d > {$servicePath}/compose.yml",
'printf %s '.escapeshellarg(base64_encode($env))." | base64 -d > {$servicePath}/.env",
]);
}
/**
* @return array<int, array{name: string, script: string}>
*/
private function gatewayCutoverSteps(EnvironmentAttachment $attachment): array
{
$containerName = $attachment->service->replicas()->first()?->container_name;
$reloadCommand = $containerName
? 'docker exec '.escapeshellarg($containerName).' caddy reload --config /etc/caddy/Caddyfile'
: "docker compose -f /home/keystone/services/{$attachment->service_id}/compose.yml exec -T {$this->serviceKey($attachment->service)} caddy reload --config /etc/caddy/Caddyfile";
return [
[
'name' => 'Validate Caddy route configuration',
'script' => 'test -s /home/keystone/gateway/Caddyfile',
],
[
'name' => 'Reload Caddy',
'script' => $reloadCommand,
],
[
'name' => 'Verify new upstreams are reachable',
'script' => 'curl --fail --silent --show-error http://127.0.0.1/ >/dev/null || true',
],
[
'name' => 'Drain old upstreams',
'script' => implode("\n", [
"docker ps --filter 'label=keystone.environment_id={$this->environment->id}' --filter 'label=keystone.draining=true' --format '{{.ID}}' | xargs -r docker stop --time 30",
]),
],
];
}
private function configureCaddyRouteScript(EnvironmentAttachment $attachment): string
{
$route = $attachment->serviceSlice?->name ?? $this->environment->name;
$upstreams = $this->gatewayUpstreams($attachment);
return implode("\n", [
'mkdir -p /home/keystone/gateway/Caddyfile.d',
"cat > /home/keystone/gateway/Caddyfile.d/{$attachment->id}.caddy <<'KEYSTONE_CADDY_ROUTE'",
"{$route} {",
' reverse_proxy '.implode(' ', $upstreams),
'}',
'KEYSTONE_CADDY_ROUTE',
'cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile',
]);
}
/**
* @return array<int, string>
*/
private function gatewayUpstreams(EnvironmentAttachment $attachment): array
{
$gatewayReplica = $attachment->service->replicas()->first();
return $this->environment->services()
->where('type', \App\Enums\ServiceType::LARAVEL)
->get()
->filter(fn (Service $service): bool => in_array('web', $service->process_roles ?? [], true))
->flatMap(function (Service $service) use ($gatewayReplica) {
return $service->replicas
->map(function (ServiceReplica $replica) use ($gatewayReplica) {
$endpoint = app(RegisterServiceEndpoint::class)->execute(
replica: $replica,
consumerReplica: $gatewayReplica,
allowPublicFallback: false,
);
return [
'priority' => $endpoint->priority,
'target' => $this->endpointTarget($endpoint->scope, $endpoint->hostname, $endpoint->port),
];
});
})
->sortBy('priority')
->pluck('target')
->values()
->whenEmpty(fn ($targets) => $targets->push('web:80'))
->all();
}
private function endpointTarget(ServiceEndpointScope $scope, string $hostname, int $port): string
{
return $scope === ServiceEndpointScope::DOCKER_NETWORK
? $hostname.':'.$port
: 'http://'.$hostname.':'.$port;
}
private function dispatchChildOperations(Operation $operation): void
{
$operation->update(['status' => OperationStatus::IN_PROGRESS]);
$operation->children()
->with('steps')
->orderBy('id')
->get()
->first(fn (Operation $child): bool => $child->steps->isNotEmpty())
?->steps
->sortBy('order')
->first()
?->dispatchJob();
}
private function servicePath(Service $service): string
{
return "/home/keystone/services/{$service->id}";
}
private function serviceKey(Service $service): string
{
return str($service->name)->slug('_')->value() ?: 'service';
}
private function defaultInternalPort(Service $service): int
{
return match ($service->type) {
\App\Enums\ServiceType::POSTGRES => 5432,
\App\Enums\ServiceType::VALKEY => 6379,
\App\Enums\ServiceType::CADDY,
\App\Enums\ServiceType::LARAVEL => 80,
};
}
}

View File

@@ -2,18 +2,23 @@
namespace App\Jobs\Services;
use App\Enums\DeploymentStatus;
use App\Actions\Services\ResolveServiceImageDigest;
use App\Data\Operations\PlannedStep;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\ServiceStatus;
use App\Models\Deployment;
use App\Models\Operation;
use App\Models\Service;
use App\Services\Compose\ComposeRenderer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use InvalidArgumentException;
class DeployService implements ShouldQueue
{
use Queueable;
protected Deployment $deployment;
protected Operation $operation;
public function __construct(
public Service $service,
@@ -24,20 +29,26 @@ class DeployService implements ShouldQueue
public function handle(): void
{
$driver = $this->service->driver();
$this->service->forceFill([
'available_image_digest' => app(ResolveServiceImageDigest::class)->execute($this->service),
])->save();
$this->service->update([
'status' => ServiceStatus::INSTALLING,
]);
$this->deployment = $this->service->deployments()->create([
'status' => DeploymentStatus::PENDING,
$this->operation = $this->service->operations()->create([
'kind' => OperationKind::SERVICE_DEPLOY,
'status' => OperationStatus::PENDING,
]);
$deploymentPlan = $driver->getDeploymentPlan($this->deployment->hash);
foreach ($deploymentPlan->steps as $index => $plannedStep) {
$step = $this->deployment->steps()->create([
$operationPlan = $driver->getOperationPlan($this->operation->hash);
$steps = $this->stepsWithComposeUpload($operationPlan->steps);
foreach ($steps as $index => $plannedStep) {
$step = $this->operation->steps()->create([
'name' => $plannedStep->name,
'order' => $index + 1,
'status' => DeploymentStatus::PENDING,
'script' => $plannedStep->getSafeScript(),
'secrets' => $this->service->credentials,
'status' => OperationStatus::PENDING,
'script' => $plannedStep->getScriptTemplate(),
'secrets' => $plannedStep->secrets(),
]);
if ($index === 0) {
$step->dispatchJob();
@@ -45,11 +56,45 @@ class DeployService implements ShouldQueue
}
}
/**
* @param array<int, \App\Data\Operations\PlannedStep> $steps
* @return array<int, \App\Data\Operations\PlannedStep>
*/
private function stepsWithComposeUpload(array $steps): array
{
try {
$renderer = app(ComposeRenderer::class);
$compose = $renderer->render($this->service);
$env = $renderer->renderEnvironmentFile($this->service);
} catch (InvalidArgumentException) {
return $steps;
}
return [
new PlannedStep(
name: 'Upload Compose file',
script: $this->composeUploadScript($compose, $env),
),
...$steps,
];
}
private function composeUploadScript(string $compose, string $env): string
{
$servicePath = "/home/keystone/services/{$this->service->id}";
return implode("\n", [
"mkdir -p {$servicePath}",
'printf %s '.escapeshellarg(base64_encode($compose))." | base64 -d > {$servicePath}/compose.yml",
'printf %s '.escapeshellarg(base64_encode($env))." | base64 -d > {$servicePath}/.env",
]);
}
public function failed(\Throwable $exception): void
{
if (isset($this->deployment)) {
$this->deployment->update([
'status' => DeploymentStatus::FAILED,
if (isset($this->operation)) {
$this->operation->update([
'status' => OperationStatus::FAILED,
]);
$this->service->update([
'status' => ServiceStatus::ERROR,

View File

@@ -2,95 +2,251 @@
namespace App\Jobs\Services;
use App\Enums\DeploymentStatus;
use App\Enums\OperationStatus;
use App\Enums\ServiceStatus;
use App\Models\Step;
use App\Models\Environment;
use App\Models\Operation;
use App\Models\OperationStep;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceReplica;
use App\Models\ServiceSlice;
use App\Services\Operations\RemoteCommandRunner;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Symfony\Component\Process\Process;
class RunStep implements ShouldQueue
{
use Queueable;
public function __construct(
protected Step $step,
protected OperationStep $step,
) {
//
}
public function handle(): void
{
$this->step->load('deployment.target.server');
$this->step->load('operation.target');
$this->step->operation->update([
'status' => OperationStatus::IN_PROGRESS,
]);
$this->step->update([
'status' => DeploymentStatus::IN_PROGRESS,
'status' => OperationStatus::IN_PROGRESS,
'started_at' => now(),
]);
$server = $this->step->deployment->target->server;
$server = $this->targetServer();
$ssh = $server->sshClient()
->onOutput(function ($type, $output) {
if (trim($output) === '') {
return;
}
if ($type === Process::OUT) {
$this->step->update([
'logs' => $this->step->logs . "\n" . trim($output),
]);
} else {
$this->step->update([
'error_logs' => $this->step->error_logs . "\n" . trim($output),
]);
}
});
$result = $ssh->execute($this->step->script);
if (! $result->isSuccessful()) {
$this->step->update([
'status' => DeploymentStatus::FAILED,
'finished_at' => now(),
'error_logs' => $this->step->error_logs . "\n" . trim($result->getErrorOutput()),
]);
try {
$output = app(RemoteCommandRunner::class)->run($server, $this->step->scriptForExecution());
} catch (\Throwable $exception) {
$this->failStep($exception->getMessage());
return;
}
$this->step->update([
'status' => DeploymentStatus::COMPLETED,
'status' => OperationStatus::COMPLETED,
'finished_at' => now(),
'logs' => trim($this->step->logs."\n".$output),
'secrets' => null,
]);
$this->captureRuntimeState();
// Dispatch the next step if available
if ($nextStep = $this->step->deployment->steps()->where('order', '>', $this->step->order)->orderBy('order', 'asc')->first()) {
if ($nextStep = $this->step->operation->steps()->where('order', '>', $this->step->order)->orderBy('order', 'asc')->first()) {
$nextStep->dispatchJob();
} elseif ($this->dispatchNextChildOperation($this->step->operation)) {
return;
} else {
$this->step->deployment->update([
'status' => DeploymentStatus::COMPLETED,
'finished_at' => now(),
]);
$this->step->deployment->target->update([
$this->completeOperation($this->step->operation);
$this->dispatchNextOperationAfter($this->step->operation);
}
}
private function captureRuntimeState(): void
{
$target = $this->step->operation->target;
if (! $target instanceof ServiceReplica) {
return;
}
$state = $this->step->refresh()->capturedRuntimeState();
if ($state !== []) {
$target->update($state);
}
}
private function markTargetCompleted(): void
{
$target = $this->step->operation->target;
if ($target instanceof Service) {
$target->update([
'status' => ServiceStatus::RUNNING,
]);
return;
}
if ($target instanceof ServiceReplica) {
$target->update([
'status' => 'running',
'health_status' => $target->health_status === 'unknown' ? 'healthy' : $target->health_status,
]);
}
}
private function completeOperation(Operation $operation): void
{
$operation->update([
'status' => OperationStatus::COMPLETED,
'finished_at' => now(),
]);
if ($operation->is($this->step->operation)) {
$this->markTargetCompleted();
}
}
private function dispatchNextChildOperation(Operation $operation): bool
{
$child = $operation->children()
->where('status', OperationStatus::PENDING)
->orderBy('id')
->get()
->first(fn (Operation $child): bool => $child->steps()->exists());
if (! $child) {
return false;
}
$child->steps()->orderBy('order')->first()?->dispatchJob();
return true;
}
private function dispatchNextOperationAfter(Operation $operation): void
{
$operation->loadMissing('parent');
if (! $operation->parent_id) {
return;
}
$nextSibling = $operation->parent
?->children()
->where('id', '>', $operation->id)
->where('status', OperationStatus::PENDING)
->orderBy('id')
->first();
if ($nextSibling) {
$nextSibling->steps()->orderBy('order')->first()?->dispatchJob();
return;
}
$parent = $operation->parent;
if ($parent && $parent->status === OperationStatus::IN_PROGRESS) {
$this->completeOperation($parent);
$this->dispatchNextOperationAfter($parent);
}
}
private function targetServer(): Server
{
$target = $this->step->operation->target;
$server = match (true) {
$target instanceof ServiceReplica => $target->server,
$target instanceof Service => $target->replicas()->with('server')->first()?->server ?: $target->server,
$target instanceof ServiceSlice => $target->service->replicas()->with('server')->first()?->server ?: $target->service->server,
$target instanceof Environment => $target->services()->with(['server', 'replicas.server'])->get()
->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter())
->first() ?: $target->services()->with('server')->get()->pluck('server')->filter()->first(),
default => null,
};
if (! $server instanceof Server) {
throw new \RuntimeException('Operation target does not have a server for SSH execution.');
}
return $server;
}
public function failed(\Throwable $exception): void
{
$this->failStep($exception->getMessage());
}
private function failStep(string $message): void
{
$this->step->update([
'status' => DeploymentStatus::FAILED,
'status' => OperationStatus::FAILED,
'finished_at' => now(),
'error_logs' => $this->step->error_logs . "\n" . trim($exception->getMessage()),
'error_logs' => $this->step->error_logs."\n".trim($message),
]);
$this->step->deployment->steps()->where('order', '>', $this->step->order)->update([
'status' => DeploymentStatus::CANCELLED,
$this->step->operation->steps()->where('order', '>', $this->step->order)->update([
'status' => OperationStatus::CANCELLED,
]);
$this->step->deployment->update([
'status' => DeploymentStatus::FAILED,
$this->step->operation->update([
'status' => OperationStatus::FAILED,
'finished_at' => now(),
]);
$this->cancelDescendants($this->step->operation);
$this->cancelPendingSiblingsAndAncestors($this->step->operation);
}
private function cancelDescendants(Operation $operation): void
{
$operation->children()->with('children')->get()->each(function (Operation $child): void {
$child->steps()->where('status', OperationStatus::PENDING)->update([
'status' => OperationStatus::CANCELLED,
]);
$child->update([
'status' => OperationStatus::CANCELLED,
'finished_at' => now(),
]);
$this->cancelDescendants($child);
});
}
private function cancelPendingSiblingsAndAncestors(Operation $operation): void
{
$operation->loadMissing('parent');
if (! $operation->parent) {
return;
}
$operation->parent->children()
->where('id', '!=', $operation->id)
->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])
->get()
->each(function (Operation $sibling): void {
$sibling->steps()->where('status', OperationStatus::PENDING)->update([
'status' => OperationStatus::CANCELLED,
]);
$sibling->update([
'status' => OperationStatus::CANCELLED,
'finished_at' => now(),
]);
$this->cancelDescendants($sibling);
});
$operation->parent->update([
'status' => OperationStatus::FAILED,
'finished_at' => now(),
]);
$this->cancelPendingSiblingsAndAncestors($operation->parent);
}
}

View File

@@ -3,20 +3,24 @@
namespace App\Models;
use App\Enums\RepositoryType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Application extends Model
{
use HasFactory;
protected $guarded = [];
protected function casts(): array
{
return [
'repository_type' => RepositoryType::class,
'deploy_key_private' => 'encrypted',
'deploy_key_installed_at' => 'datetime',
];
}
@@ -25,18 +29,13 @@ class Application extends Model
return $this->belongsTo(Organisation::class);
}
public function instances(): HasMany
public function environments(): HasMany
{
return $this->hasMany(Instance::class);
return $this->hasMany(Environment::class);
}
public function servers(): HasManyThrough
public function operations(): MorphMany
{
return $this->hasManyThrough(Server::class, Instance::class);
}
public function deployments(): MorphMany
{
return $this->morphMany(Deployment::class, 'target');
return $this->morphMany(Operation::class, 'target');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Enums\BuildArtifactStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BuildArtifact extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'status' => BuildArtifactStatus::class,
'metadata' => 'array',
];
}
public function environment(): BelongsTo
{
return $this->belongsTo(Environment::class);
}
public function builtByOperation(): BelongsTo
{
return $this->belongsTo(Operation::class, 'built_by_operation_id');
}
public function builtByService(): BelongsTo
{
return $this->belongsTo(Service::class, 'built_by_service_id');
}
}

View File

@@ -1,39 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Deployment extends Model
{
protected $guarded = [];
public static function boot(): void
{
parent::boot();
static::creating(function (self $deployment) {
$deployment->hash = str()->random(16);
});
}
protected function casts(): array
{
return [
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
}
public function steps(): HasMany
{
return $this->hasMany(Step::class);
}
public function target(): MorphTo
{
return $this->morphTo('target');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Enums\SchedulerMode;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Environment extends Model
{
use HasFactory;
protected $guarded = [];
protected function casts(): array
{
return [
'scheduler_enabled' => 'boolean',
'scheduler_mode' => SchedulerMode::class,
'build_config' => 'array',
];
}
public function application(): BelongsTo
{
return $this->belongsTo(Application::class);
}
public function services(): HasMany
{
return $this->hasMany(Service::class);
}
public function attachments(): HasMany
{
return $this->hasMany(EnvironmentAttachment::class);
}
public function variables(): HasMany
{
return $this->hasMany(EnvironmentVariable::class);
}
public function buildArtifacts(): HasMany
{
return $this->hasMany(BuildArtifact::class);
}
public function operations(): MorphMany
{
return $this->morphMany(Operation::class, 'target');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Enums\EnvironmentAttachmentRole;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EnvironmentAttachment extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'is_primary' => 'boolean',
'role' => EnvironmentAttachmentRole::class,
];
}
public function environment(): BelongsTo
{
return $this->belongsTo(Environment::class);
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function serviceSlice(): BelongsTo
{
return $this->belongsTo(ServiceSlice::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Enums\EnvironmentVariableSource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EnvironmentVariable extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'value' => 'encrypted',
'source' => EnvironmentVariableSource::class,
'overridable' => 'boolean',
];
}
public function environment(): BelongsTo
{
return $this->belongsTo(Environment::class);
}
public function serviceSlice(): BelongsTo
{
return $this->belongsTo(ServiceSlice::class);
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Instance extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'config' => 'array',
];
}
public function application(): BelongsTo
{
return $this->belongsTo(Application::class);
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function deployments(): MorphMany
{
return $this->morphMany(Deployment::class, 'target');
}
}

57
app/Models/Operation.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Operation extends Model
{
use HasFactory;
protected $guarded = [];
public static function boot(): void
{
parent::boot();
static::creating(function (self $operation) {
$operation->hash ??= str()->random(16);
});
}
protected function casts(): array
{
return [
'kind' => OperationKind::class,
'status' => OperationStatus::class,
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
}
public function parent(): BelongsTo
{
return $this->belongsTo(Operation::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(Operation::class, 'parent_id');
}
public function steps(): HasMany
{
return $this->hasMany(OperationStep::class);
}
public function target(): MorphTo
{
return $this->morphTo('target');
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use App\Enums\OperationStatus;
use App\Jobs\Services\RunStep;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class OperationStep extends Model
{
protected $guarded = [];
protected $appends = [
'logs_excerpt',
'error_logs_excerpt',
];
protected function casts(): array
{
return [
'status' => OperationStatus::class,
'started_at' => 'datetime',
'finished_at' => 'datetime',
'secrets' => 'encrypted:array',
];
}
public function operation(): BelongsTo
{
return $this->belongsTo(Operation::class);
}
public function logsExcerpt(): Attribute
{
return Attribute::make(
get: fn () => $this->logs ? Str::afterLast($this->logs, "\n") : null,
);
}
public function errorLogsExcerpt(): Attribute
{
return Attribute::make(
get: fn () => $this->error_logs ? Str::afterLast($this->error_logs, "\n") : null,
);
}
public function dispatchJob(): void
{
dispatch(new RunStep($this));
}
public function scriptForExecution(): string
{
$script = $this->script;
foreach (($this->secrets ?? []) as $key => $value) {
$script = str_replace("[!{$key}!]", $value, $script);
}
return $script;
}
/**
* @return array{container_id?: string, health_status?: string}
*/
public function capturedRuntimeState(): array
{
$state = [];
foreach (explode("\n", (string) $this->logs) as $line) {
if (str_starts_with($line, 'container_id=')) {
$state['container_id'] = trim((string) str($line)->after('container_id='));
}
if (str_starts_with($line, 'health_status=')) {
$state['health_status'] = trim((string) str($line)->after('health_status='));
}
}
return array_filter($state, fn (string $value): bool => $value !== '');
}
}

View File

@@ -40,6 +40,21 @@ class Organisation extends Model
return $this->hasMany(Application::class);
}
public function services(): HasMany
{
return $this->hasMany(Service::class);
}
public function registries(): HasMany
{
return $this->hasMany(Registry::class);
}
public function sourceProviders(): HasMany
{
return $this->hasMany(SourceProvider::class);
}
public function providers(): HasMany
{
return $this->hasMany(Provider::class);

27
app/Models/Registry.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use App\Enums\RegistryType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Registry extends Model
{
protected $guarded = [];
protected $hidden = ['credentials'];
protected function casts(): array
{
return [
'type' => RegistryType::class,
'credentials' => 'encrypted:array',
];
}
public function organisation(): BelongsTo
{
return $this->belongsTo(Organisation::class);
}
}

View File

@@ -44,14 +44,9 @@ class Server extends Model
return $this->hasMany(Service::class);
}
public function instances(): HasMany
public function serviceReplicas(): HasMany
{
return $this->hasMany(Instance::class);
}
public function applications(): HasManyThrough
{
return $this->hasManyThrough(Application::class, Instance::class);
return $this->hasMany(ServiceReplica::class);
}
public function firewallRules(): HasMany
@@ -64,26 +59,16 @@ class Server extends Model
return $this->belongsTo(Provider::class);
}
public function serviceDeployments(): HasManyThrough
public function serviceOperations(): HasManyThrough
{
return $this->hasManyThrough(
Deployment::class,
Operation::class,
Service::class,
'server_id',
'target_id',
)->where('target_type', (new Service)->getMorphClass());
}
public function applicationDeployments(): HasManyThrough
{
return $this->hasManyThrough(
Deployment::class,
Application::class,
'server_id',
'target_id',
)->where('target_type', (new Application)->getMorphClass());
}
public function sshClient(string $user = 'root'): Ssh
{
return Ssh::create($user, $this->ipv4)

View File

@@ -4,16 +4,21 @@ namespace App\Models;
use App\Drivers\DatabaseDriver;
use App\Drivers\Driver;
use App\Enums\DeployPolicy;
use App\Enums\ServiceCategory;
use App\Enums\ServiceStatus;
use App\Enums\ServiceType;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Service extends Model
{
use HasFactory;
protected $guarded = [];
protected $hidden = ['credentials', 'container_name', 'container_id'];
@@ -24,6 +29,10 @@ class Service extends Model
'status' => ServiceStatus::class,
'category' => ServiceCategory::class,
'type' => ServiceType::class,
'deploy_policy' => DeployPolicy::class,
'process_roles' => 'array',
'default_cpu_limit' => 'decimal:3',
'config' => 'array',
'credentials' => 'encrypted:array',
];
}
@@ -31,7 +40,7 @@ class Service extends Model
public function folderName(): Attribute
{
return new Attribute(
get: fn() => $this->name . '-' . $this->id,
get: fn () => $this->name.'-'.$this->id,
);
}
@@ -40,14 +49,41 @@ class Service extends Model
return $this->belongsTo(Server::class);
}
public function deployments(): MorphMany
public function organisation(): BelongsTo
{
return $this->morphMany(Deployment::class, 'target');
return $this->belongsTo(Organisation::class);
}
public function environment(): BelongsTo
{
return $this->belongsTo(Environment::class);
}
public function replicas(): HasMany
{
return $this->hasMany(ServiceReplica::class);
}
public function slices(): HasMany
{
return $this->hasMany(ServiceSlice::class);
}
public function endpoints(): HasMany
{
return $this->hasMany(ServiceEndpoint::class);
}
public function operations(): MorphMany
{
return $this->morphMany(Operation::class, 'target');
}
public function driver(): Driver
{
$class = config("keystone.drivers.{$this->driver_name}");
[$driverType, $versionTrack] = array_pad(explode('.', $this->driver_name, 2), 2, null);
$class = config('keystone.drivers')[$driverType][$versionTrack] ?? null;
if (! class_exists($class)) {
throw new \Exception("Driver class {$class} not found");
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use App\Enums\ServiceEndpointScope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServiceEndpoint extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'scope' => ServiceEndpointScope::class,
];
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function serviceReplica(): BelongsTo
{
return $this->belongsTo(ServiceReplica::class);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class ServiceReplica extends Model
{
use HasFactory;
protected $guarded = [];
protected function casts(): array
{
return [
'cpu_limit' => 'decimal:3',
'config' => 'array',
];
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function operation(): BelongsTo
{
return $this->belongsTo(Operation::class);
}
public function operations(): MorphMany
{
return $this->morphMany(Operation::class, 'target');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class ServiceSlice extends Model
{
use HasFactory;
protected $guarded = [];
protected function casts(): array
{
return [
'config' => 'array',
'credentials' => 'encrypted:array',
];
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function environment(): BelongsTo
{
return $this->belongsTo(Environment::class);
}
public function attachments(): HasMany
{
return $this->hasMany(EnvironmentAttachment::class);
}
public function operations(): MorphMany
{
return $this->morphMany(Operation::class, 'target');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use App\Enums\SourceProviderType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SourceProvider extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'type' => SourceProviderType::class,
'config' => 'array',
];
}
public function organisation(): BelongsTo
{
return $this->belongsTo(Organisation::class);
}
}

View File

@@ -1,52 +0,0 @@
<?php
namespace App\Models;
use App\Jobs\Services\RunStep;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class Step extends Model
{
protected $guarded = [];
protected $appends = [
'logs_excerpt',
'error_logs_excerpt',
];
protected function casts(): array
{
return [
'started_at' => 'datetime',
'finished_at' => 'datetime',
'secrets' => 'encrypted:array',
];
}
public function deployment(): BelongsTo
{
return $this->belongsTo(Deployment::class);
}
public function logsExcerpt(): Attribute
{
return Attribute::make(
get: fn () => $this->logs ? Str::afterLast($this->logs, "\n"): null,
);
}
public function errorLogsExcerpt(): Attribute
{
return Attribute::make(
get: fn () => $this->error_logs ? Str::afterLast($this->error_logs, "\n"): null,
);
}
public function dispatchJob(): void
{
dispatch(new RunStep($this));
}
}

View File

@@ -3,14 +3,18 @@
namespace App\Providers;
use App\Models\Application;
use App\Models\Deployment;
use App\Models\Instance;
use App\Models\Environment;
use App\Models\Operation;
use App\Models\OperationStep;
use App\Models\Organisation;
use App\Models\OrganisationUser;
use App\Models\Server;
use App\Models\Service;
use App\Models\Step;
use App\Models\ServiceReplica;
use App\Models\ServiceSlice;
use App\Models\User;
use App\Services\Operations\RemoteCommandRunner;
use App\Services\Operations\SshRemoteCommandRunner;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\ServiceProvider;
@@ -21,7 +25,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->bind(RemoteCommandRunner::class, SshRemoteCommandRunner::class);
}
/**
@@ -31,13 +35,15 @@ class AppServiceProvider extends ServiceProvider
{
Relation::enforceMorphMap([
'application' => Application::class,
'deployment' => Deployment::class,
'instance' => Instance::class,
'environment' => Environment::class,
'organisation' => Organisation::class,
'organisation-user' => OrganisationUser::class,
'operation' => Operation::class,
'server' => Server::class,
'service' => Service::class,
'step' => Step::class,
'service-replica' => ServiceReplica::class,
'service-slice' => ServiceSlice::class,
'operation-step' => OperationStep::class,
'user' => User::class,
]);
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Services\Compose;
use App\Drivers\Concerns\RendersCompose;
use App\Models\Service;
use InvalidArgumentException;
class ComposeRenderer
{
public function render(Service $service): string
{
$driver = $service->driver();
if (! $driver instanceof RendersCompose) {
throw new InvalidArgumentException("Driver [{$service->driver_name}] cannot render Docker Compose.");
}
$composeService = $driver->composeService();
if ($service->default_cpu_limit && ! isset($composeService['cpus'])) {
$composeService['cpus'] = (string) $service->default_cpu_limit;
}
if ($service->default_memory_limit_mb && ! isset($composeService['mem_limit'])) {
$composeService['mem_limit'] = "{$service->default_memory_limit_mb}m";
$composeService['memswap_limit'] = "{$service->default_memory_limit_mb}m";
}
$document = [
'services' => [
$this->serviceKey($service) => $composeService,
],
];
$volumes = array_filter($driver->composeVolumes(), fn (mixed $volume): bool => $volume !== false);
if ($volumes !== []) {
$document['volumes'] = $volumes;
}
return $this->toYaml($document);
}
public function renderEnvironmentFile(Service $service): string
{
$driver = $service->driver();
if (! method_exists($driver, 'environmentExports')) {
return '';
}
return collect($driver->environmentExports())
->map(fn (mixed $value, string $key): string => $key.'='.$this->formatEnvValue($value))
->implode("\n");
}
private function serviceKey(Service $service): string
{
return str($service->name)->slug('_')->value() ?: 'service';
}
/**
* @param array<string, mixed> $document
*/
private function toYaml(array $document, int $indent = 0): string
{
$lines = [];
foreach ($document as $key => $value) {
$prefix = str_repeat(' ', $indent);
if (is_array($value)) {
if ($value === []) {
$lines[] = "{$prefix}{$key}: []";
continue;
}
$lines[] = "{$prefix}{$key}:";
$lines[] = $this->arrayToYaml($value, $indent + 1);
continue;
}
if ($value === null) {
$lines[] = "{$prefix}{$key}: {}";
continue;
}
$lines[] = "{$prefix}{$key}: ".$this->formatScalar($value);
}
return implode("\n", array_filter($lines))."\n";
}
/**
* @param array<mixed> $items
*/
private function arrayToYaml(array $items, int $indent): string
{
if (array_is_list($items)) {
return $this->listToYaml($items, $indent);
}
return $this->toYaml($items, $indent);
}
/**
* @param array<int, mixed> $items
*/
private function listToYaml(array $items, int $indent): string
{
$lines = [];
$prefix = str_repeat(' ', $indent);
foreach ($items as $item) {
if (is_array($item)) {
$lines[] = "{$prefix}-";
$lines[] = $this->arrayToYaml($item, $indent + 1);
continue;
}
$lines[] = "{$prefix}- ".$this->formatScalar($item);
}
return implode("\n", $lines);
}
private function formatScalar(mixed $value): string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_numeric($value)) {
return (string) $value;
}
return '"'.str_replace('"', '\"', (string) $value).'"';
}
private function formatEnvValue(mixed $value): string
{
return str_replace(["\n", "\r"], ['\n', ''], (string) $value);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Services\Operations;
use App\Models\Server;
interface RemoteCommandRunner
{
public function run(Server $server, string $script): string;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Services\Operations;
use App\Models\Server;
use RuntimeException;
class SshRemoteCommandRunner implements RemoteCommandRunner
{
public function run(Server $server, string $script): string
{
$result = $server->sshClient()->execute($script);
if (! $result->isSuccessful()) {
throw new RuntimeException(trim($result->getErrorOutput()) ?: 'Remote command failed.');
}
return trim($result->getOutput());
}
}