Implement Keystone environment deployments
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
65
app/Actions/Applications/CreateLaravelEnvironment.php
Normal file
65
app/Actions/Applications/CreateLaravelEnvironment.php
Normal 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();
|
||||
}
|
||||
}
|
||||
78
app/Actions/Applications/GenerateDeployKey.php
Normal file
78
app/Actions/Applications/GenerateDeployKey.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Actions/Applications/VerifyRepositoryAccess.php
Normal file
51
app/Actions/Applications/VerifyRepositoryAccess.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
152
app/Actions/Environments/AttachManagedService.php
Normal file
152
app/Actions/Environments/AttachManagedService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
192
app/Actions/Environments/BuildApplicationArtifact.php
Normal file
192
app/Actions/Environments/BuildApplicationArtifact.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
20
app/Actions/Environments/BuildMigrationScript.php
Normal file
20
app/Actions/Environments/BuildMigrationScript.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
40
app/Actions/Environments/CreateLaravelWorkerService.php
Normal file
40
app/Actions/Environments/CreateLaravelWorkerService.php
Normal 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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
44
app/Actions/Environments/CreateMigrationOperation.php
Normal file
44
app/Actions/Environments/CreateMigrationOperation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
76
app/Actions/Environments/PlanBuildArtifact.php
Normal file
76
app/Actions/Environments/PlanBuildArtifact.php
Normal 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;
|
||||
}
|
||||
}
|
||||
91
app/Actions/Environments/PlanEnvironmentDeployment.php
Normal file
91
app/Actions/Environments/PlanEnvironmentDeployment.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
61
app/Actions/Environments/ResolveEnvironmentCommit.php
Normal file
61
app/Actions/Environments/ResolveEnvironmentCommit.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
100
app/Actions/Services/CreateStatefulServiceUpdateOperation.php
Normal file
100
app/Actions/Services/CreateStatefulServiceUpdateOperation.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
70
app/Actions/Services/RegisterServiceEndpoint.php
Normal file
70
app/Actions/Services/RegisterServiceEndpoint.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
85
app/Actions/Services/ResolveServiceImageDigest.php
Normal file
85
app/Actions/Services/ResolveServiceImageDigest.php
Normal 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}].");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user