Files
keystone/app/Actions/Services/CreateStatefulServiceUpdateOperation.php

101 lines
4.0 KiB
PHP

<?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,
};
}
}