101 lines
4.0 KiB
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,
|
|
};
|
|
}
|
|
}
|