Implement Keystone environment deployments
This commit is contained in:
@@ -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