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