mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-17 22:14:14 +00:00
chore(v5): archive V5 implementation and remove runtime integration
Move V5 source, migrations, UI, scripts, and tests into documentation, then remove V5 routes, models, jobs, configuration, dependencies, and application hooks.
This commit is contained in:
1 parent
1440d35750
commit
76030a30d4
237 files changed
+288
-7079
No files matched your search
@@ -1,168 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Application;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Application;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DeployNginxApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Application $application): Application
|
||||
{
|
||||
$application->loadMissing('server');
|
||||
$server = $application->server;
|
||||
|
||||
if ($server === null) {
|
||||
return $this->markFailed($application, 'No server is attached to this application.');
|
||||
}
|
||||
|
||||
if ($server->status !== ServerStatus::Installed->value || $server->last_bootstrapped_at === null) {
|
||||
return $this->markFailed($application, "Bootstrap server {$server->name} before deploying to it.");
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
return $this->markFailed($application, 'No Flux host ID is available for this server.');
|
||||
}
|
||||
|
||||
$containerId = null;
|
||||
|
||||
try {
|
||||
$this->fluxClient->pullImage($hostId, $application->image);
|
||||
$containerId = $this->fluxClient->createContainer($hostId, $this->containerSpec($application));
|
||||
|
||||
// Persist the runtime id the instant the container exists, before
|
||||
// start/inspect. A worker SIGKILL at the job timeout would otherwise
|
||||
// orphan a created container whose id only lived in this local var,
|
||||
// leaving failed()/reconcile unable to find and clean it by id.
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Created->value,
|
||||
'status_message' => 'Container created.',
|
||||
'runtime_container_id' => $containerId,
|
||||
]);
|
||||
|
||||
$this->fluxClient->startContainer($hostId, $containerId);
|
||||
$inspect = $this->fluxClient->inspectContainer($hostId, $containerId);
|
||||
|
||||
if (! $this->isContainerRunning($inspect)) {
|
||||
$this->cleanUpContainer($application, $hostId, $containerId);
|
||||
|
||||
return $this->markFailed($application, 'Container did not stay running.');
|
||||
}
|
||||
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Running->value,
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => $containerId,
|
||||
]);
|
||||
|
||||
return $application->refresh()->load('server');
|
||||
} catch (\Throwable $e) {
|
||||
if (is_string($containerId) && $containerId !== '') {
|
||||
$this->cleanUpContainer($application, $hostId, $containerId);
|
||||
}
|
||||
|
||||
return $this->markFailed($application, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort compensation for a failed deploy: stop and force-remove the
|
||||
* container this run created so it is never left orphaned on the node, then
|
||||
* null the runtime id we persisted right after create so a cleaned-up
|
||||
* failure never leaves a dangling id that reconcile would try to reap.
|
||||
* Cleanup failures only log a warning and never mask the original error.
|
||||
*/
|
||||
private function cleanUpContainer(Application $application, string $hostId, string $containerId): void
|
||||
{
|
||||
try {
|
||||
$this->fluxClient->stopContainer($hostId, $containerId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Could not stop the container created by a failed v5 deploy.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'container_id' => $containerId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->fluxClient->removeContainer($hostId, $containerId, force: true);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Could not remove the container created by a failed v5 deploy.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'container_id' => $containerId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($application->runtime_container_id === $containerId) {
|
||||
$application->update(['runtime_container_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function containerSpec(Application $application): array
|
||||
{
|
||||
$network = $this->meshNetwork($application);
|
||||
$containerName = $application->container_name;
|
||||
|
||||
return [
|
||||
'name' => $containerName,
|
||||
'image' => $application->image,
|
||||
'networks' => [$network],
|
||||
'network_aliases' => [$containerName],
|
||||
'dns_search' => [$this->meshDnsSearchDomain($application)],
|
||||
'restart_policy' => 'unless-stopped',
|
||||
];
|
||||
}
|
||||
|
||||
private function meshNetwork(Application $application): string
|
||||
{
|
||||
$namespace = $application->mesh_namespace ?: 'default';
|
||||
|
||||
return "coolify-{$namespace}-mesh";
|
||||
}
|
||||
|
||||
private function meshDnsSearchDomain(Application $application): string
|
||||
{
|
||||
$namespace = $application->mesh_namespace ?: 'default';
|
||||
|
||||
return "{$namespace}.coolify.internal";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $inspect
|
||||
*/
|
||||
private function isContainerRunning(array $inspect): bool
|
||||
{
|
||||
$state = $inspect['State'] ?? [];
|
||||
|
||||
if (is_array($state) && ($state['Running'] ?? null) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($inspect['state'] ?? null) && $inspect['state'] === ContainerState::Running->value;
|
||||
}
|
||||
|
||||
private function markFailed(Application $application, string $message): Application
|
||||
{
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Failed->value,
|
||||
'status_message' => str($message)->limit(10000)->toString(),
|
||||
]);
|
||||
|
||||
return $application->refresh()->load('server');
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Application;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Application;
|
||||
use Illuminate\Contracts\Process\ProcessResult;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DestroyNginxApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Application $application): ?string
|
||||
{
|
||||
$application->loadMissing('server.privateKey');
|
||||
$server = $application->server;
|
||||
|
||||
if ($server === null || ! $server->privateKey instanceof PrivateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$keyLocation = $this->writeTemporaryPrivateKey($server->privateKey);
|
||||
|
||||
try {
|
||||
$result = Process::timeout(120)->run([
|
||||
'ssh',
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'LogLevel=ERROR',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=no',
|
||||
'-o',
|
||||
'UserKnownHostsFile=/dev/null',
|
||||
'-o',
|
||||
'ConnectTimeout=10',
|
||||
'-o',
|
||||
'IdentitiesOnly=yes',
|
||||
'-i',
|
||||
$keyLocation,
|
||||
'-p',
|
||||
(string) $server->ssh_port,
|
||||
"{$server->ssh_user}@{$server->host}",
|
||||
$this->remoteCommand($application),
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
return $this->processOutput($result);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return $e->getMessage();
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
|
||||
private function remoteCommand(Application $application): string
|
||||
{
|
||||
$containerName = escapeshellarg($application->container_name);
|
||||
|
||||
return implode(PHP_EOL, [
|
||||
'set -e',
|
||||
'if [ "$(id -u)" = "0" ]; then podman=podman; else podman="sudo -n podman"; fi',
|
||||
"\$podman rm -f {$containerName} >/dev/null 2>&1 || true",
|
||||
]);
|
||||
}
|
||||
|
||||
private function processOutput(ProcessResult $result): string
|
||||
{
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
return $output !== '' ? $output : 'Could not delete nginx container.';
|
||||
}
|
||||
|
||||
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
|
||||
{
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_nginx_destroy_key_');
|
||||
if ($keyLocation === false) {
|
||||
throw new \RuntimeException('Could not create a temporary SSH key file.');
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
return $keyLocation;
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Flux;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Support\V5\StatusObservation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ApplyFluxResourceStatusUpdate
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function handle(array $payload): ?Model
|
||||
{
|
||||
$resourceType = strtolower((string) data_get($payload, 'resource_type', data_get($payload, 'type', '')));
|
||||
|
||||
$containerStatus = $resourceType === 'container' ? $this->upsertContainerStatus($payload) : null;
|
||||
|
||||
if ($this->isCaddyIngressStatusUpdate($payload, $resourceType)) {
|
||||
return $this->updateCaddyIngress($payload) ?? $containerStatus;
|
||||
}
|
||||
|
||||
if (in_array($resourceType, ['server', 'node', 'host'], true)) {
|
||||
return $this->updateServer($payload);
|
||||
}
|
||||
|
||||
return $this->updateApplication($payload) ?? $containerStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function upsertContainerStatus(array $payload): ?ContainerStatus
|
||||
{
|
||||
$status = $this->status($payload, ContainerState::class);
|
||||
$containerId = $this->stringValue($payload, 'container_id') ?? $this->stringValue($payload, 'runtime_container_id');
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if ($status === null || $containerId === null || ! $server instanceof V5Server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $this->observedAt($payload);
|
||||
$existing = ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', $containerId)
|
||||
->first();
|
||||
|
||||
if ($this->isStaleObservation($observedAt, $existing?->status_observed_at, 'container status', [
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $containerId,
|
||||
])) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'team_id' => $server->team_id,
|
||||
'container_name' => $this->stringValue($payload, 'container_name') ?? $this->stringValue($payload, 'name'),
|
||||
'image' => $this->stringValue($payload, 'image'),
|
||||
'status' => $status,
|
||||
'status_message' => $this->statusMessage($payload, 'Container state received from coold.'),
|
||||
'last_seen_at' => now(),
|
||||
];
|
||||
|
||||
if ($observedAt !== null) {
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
ContainerStatus::query()->updateOrCreate([
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $containerId,
|
||||
], $attributes);
|
||||
|
||||
return ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', $containerId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function updateApplication(array $payload): ?V5Application
|
||||
{
|
||||
$status = $this->status($payload, ApplicationStatus::class);
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$application = $this->findApplication($payload);
|
||||
|
||||
if (! $application instanceof V5Application) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $this->observedAt($payload);
|
||||
|
||||
if ($this->isStaleObservation($observedAt, $application->status_observed_at, 'application status', [
|
||||
'application_id' => $application->id,
|
||||
])) {
|
||||
return $application;
|
||||
}
|
||||
|
||||
$payloadContainerId = $this->stringValue($payload, 'runtime_container_id')
|
||||
?? $this->stringValue($payload, 'container_id');
|
||||
|
||||
// Payloads may carry no timestamp, so the container id remains an
|
||||
// ordering signal as a second layer: an update for a superseded
|
||||
// container is stale and must not overwrite the current one's state.
|
||||
if (
|
||||
$payloadContainerId !== null
|
||||
&& $application->runtime_container_id !== null
|
||||
&& $payloadContainerId !== $application->runtime_container_id
|
||||
) {
|
||||
return $application;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'status' => $status,
|
||||
'status_message' => $this->statusMessage($payload, 'Status updated by flux.'),
|
||||
'runtime_container_id' => $payloadContainerId ?? $application->runtime_container_id,
|
||||
];
|
||||
|
||||
if ($observedAt !== null) {
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$application->update($attributes);
|
||||
|
||||
return $application->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function updateServer(array $payload): ?V5Server
|
||||
{
|
||||
$status = $this->status($payload, ServerStatus::class);
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $this->observedAt($payload);
|
||||
|
||||
if ($this->isStaleObservation($observedAt, $server->status_observed_at, 'server status', [
|
||||
'server_id' => $server->id,
|
||||
])) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'status' => $status,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'),
|
||||
'last_status_checked_at' => now(),
|
||||
];
|
||||
|
||||
if ($observedAt !== null) {
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$server->update($attributes);
|
||||
|
||||
return $server->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ingress state shares the server row but describes a different
|
||||
* resource, so it deliberately does not read or write the server's
|
||||
* `status_observed_at` watermark.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function updateCaddyIngress(array $payload): ?V5Server
|
||||
{
|
||||
$status = $this->status($payload, IngressStatus::class);
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if (! $server instanceof V5Server || ! $server->isIngress()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'ingress_type' => 'caddy',
|
||||
'ingress_status' => $status,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'),
|
||||
'last_status_checked_at' => now(),
|
||||
]);
|
||||
|
||||
return $server->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function findApplication(array $payload): ?V5Application
|
||||
{
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = V5Application::query()
|
||||
->with('server')
|
||||
->where('server_id', $server->id)
|
||||
->where('team_id', $server->team_id);
|
||||
|
||||
$applicationUuid = $this->stringValue($payload, 'application_uuid') ?? $this->stringValue($payload, 'resource_uuid');
|
||||
|
||||
if ($applicationUuid !== null) {
|
||||
return $query->where('uuid', $applicationUuid)->first();
|
||||
}
|
||||
|
||||
$containerName = $this->stringValue($payload, 'container_name') ?? $this->stringValue($payload, 'name');
|
||||
|
||||
if ($containerName !== null) {
|
||||
return $query->where('container_name', $containerName)->first();
|
||||
}
|
||||
|
||||
$containerId = $this->stringValue($payload, 'runtime_container_id') ?? $this->stringValue($payload, 'container_id');
|
||||
|
||||
if ($containerId !== null) {
|
||||
return $query->where('runtime_container_id', $containerId)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function isCaddyIngressStatusUpdate(array $payload, string $resourceType): bool
|
||||
{
|
||||
if (in_array($resourceType, ['caddy_ingress', 'caddy-ingress'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->stringValue($payload, 'container_name') === 'coolify-v5-caddy'
|
||||
|| $this->stringValue($payload, 'name') === 'coolify-v5-caddy';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function findServer(array $payload): ?V5Server
|
||||
{
|
||||
$serverUuid = $this->stringValue($payload, 'server_uuid') ?? $this->stringValue($payload, 'host_server_uuid');
|
||||
|
||||
if ($serverUuid !== null) {
|
||||
return V5Server::query()->where('uuid', $serverUuid)->first();
|
||||
}
|
||||
|
||||
$hostId = $this->stringValue($payload, 'host_id')
|
||||
?? $this->stringValue($payload, 'node_id')
|
||||
?? $this->stringValue($payload, 'server_host');
|
||||
|
||||
if ($hostId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$matches = V5Server::query()
|
||||
->where('uuid', $hostId)
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if ($matches->count() > 1) {
|
||||
Log::warning('Dropping flux resource status update: host id matches multiple v5 servers.', [
|
||||
'host_id' => $hostId,
|
||||
'server_ids' => $matches->pluck('id')->all(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $matches->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the raw payload status onto the given status enum. Unknown values
|
||||
* are never written to the database: they fall back to the enum's
|
||||
* Unknown case and are logged.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @param class-string<ApplicationStatus|ContainerState|IngressStatus|ServerStatus> $enumClass
|
||||
*/
|
||||
private function status(array $payload, string $enumClass): ?string
|
||||
{
|
||||
$raw = $this->stringValue($payload, 'status') ?? $this->stringValue($payload, 'state');
|
||||
|
||||
return StatusObservation::normalize($raw, $enumClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function observedAt(array $payload): ?CarbonInterface
|
||||
{
|
||||
$observedAt = $this->stringValue($payload, 'observed_at');
|
||||
|
||||
if ($observedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rescue(fn (): CarbonImmutable => CarbonImmutable::parse($observedAt), null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* A payload that carries an observation timestamp older than the one
|
||||
* already persisted is stale (delivered out of order) and must not
|
||||
* clobber the newer state.
|
||||
*
|
||||
* @param array<string, mixed> $logContext
|
||||
*/
|
||||
private function isStaleObservation(?CarbonInterface $observedAt, ?CarbonInterface $currentObservedAt, string $context, array $logContext): bool
|
||||
{
|
||||
return StatusObservation::isStale($observedAt, $currentObservedAt, $context, $logContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function statusMessage(array $payload, string $fallback): string
|
||||
{
|
||||
return $this->stringValue($payload, 'status_message')
|
||||
?? $this->stringValue($payload, 'message')
|
||||
?? $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function stringValue(array $payload, string $key): ?string
|
||||
{
|
||||
$value = data_get($payload, $key);
|
||||
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Proxy;
|
||||
|
||||
use App\Models\V5\Application;
|
||||
use App\Models\V5\ApplicationDomain;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class GenerateCaddyIngressConfiguration
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* Strict RFC 1123 hostname: dot-separated alphanumeric labels with inner
|
||||
* hyphens, max 253 characters. Anchored with \A/\z (never $) so values
|
||||
* containing newlines, braces, quotes, whitespace, or control characters
|
||||
* can never inject extra directives into the generated Caddyfile.
|
||||
*/
|
||||
private const HOSTNAME_PATTERN = '/\A(?=.{1,253}\z)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\z/i';
|
||||
|
||||
/**
|
||||
* @param Collection<int, Application>|null $applications
|
||||
* @return array{compose: string, caddyfile: string, apps: array<int, array{name: string, caddyfile: string}>}
|
||||
*/
|
||||
public function handle(?Collection $applications = null): array
|
||||
{
|
||||
return [
|
||||
'compose' => $this->compose(),
|
||||
'caddyfile' => $this->rootCaddyfile(),
|
||||
'apps' => $this->appCaddyfiles($applications ?? collect()),
|
||||
];
|
||||
}
|
||||
|
||||
private function compose(): string
|
||||
{
|
||||
return Yaml::dump([
|
||||
'services' => [
|
||||
'caddy' => [
|
||||
'image' => 'docker.io/library/caddy:2-alpine',
|
||||
'container_name' => 'coolify-v5-caddy',
|
||||
'restart' => 'unless-stopped',
|
||||
'ports' => [
|
||||
'80:80',
|
||||
],
|
||||
'volumes' => [
|
||||
'./Caddyfile:/etc/caddy/Caddyfile:ro',
|
||||
'./apps:/etc/caddy/apps:ro',
|
||||
'./data:/data',
|
||||
'./config:/config',
|
||||
],
|
||||
],
|
||||
],
|
||||
], 8, 2);
|
||||
}
|
||||
|
||||
private function rootCaddyfile(): string
|
||||
{
|
||||
return <<<'CADDY'
|
||||
:80 {
|
||||
respond /coolify-health 200
|
||||
respond 404
|
||||
}
|
||||
|
||||
import apps/*.caddy
|
||||
CADDY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Application> $applications
|
||||
* @return array<int, array{name: string, caddyfile: string}>
|
||||
*/
|
||||
private function appCaddyfiles(Collection $applications): array
|
||||
{
|
||||
return $applications
|
||||
->each(fn (Application $application) => $application->loadMissing('domains'))
|
||||
->map(fn (Application $application) => [
|
||||
'name' => $this->appFileName($application),
|
||||
'caddyfile' => $this->applicationCaddyfile($application),
|
||||
])
|
||||
->filter(fn (array $file) => $file['caddyfile'] !== '')
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function applicationCaddyfile(Application $application): string
|
||||
{
|
||||
if (! $application->ingress_enabled || ! $application->internal_port) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $application->domains
|
||||
->map(fn (ApplicationDomain $domain) => $this->applicationRoute($application, $domain))
|
||||
->filter()
|
||||
->sort()
|
||||
->implode("\n\n");
|
||||
}
|
||||
|
||||
private function applicationRoute(Application $application, ApplicationDomain $domain): ?string
|
||||
{
|
||||
if ($domain->domain === null || $domain->domain === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$namespace = $application->mesh_namespace ?: 'default';
|
||||
$internalPort = (int) $application->internal_port;
|
||||
|
||||
if (! $this->isSafeHostname($domain->domain)) {
|
||||
Log::warning('Skipping a caddy ingress route with an unsafe domain.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'domain' => $domain->domain,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isSafeHostname($application->container_name) || ! $this->isSafeHostname($namespace)) {
|
||||
Log::warning('Skipping a caddy ingress route with an unsafe container name or namespace.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'container_name' => $application->container_name,
|
||||
'namespace' => $namespace,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($internalPort < 1 || $internalPort > 65535) {
|
||||
Log::warning('Skipping a caddy ingress route with an out-of-range internal port.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'internal_port' => $application->internal_port,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$upstream = "{$application->container_name}.{$namespace}.coolify.internal:{$internalPort}";
|
||||
|
||||
return implode("\n", [
|
||||
"http://{$domain->domain} {",
|
||||
" reverse_proxy {$upstream}",
|
||||
'}',
|
||||
]);
|
||||
}
|
||||
|
||||
private function isSafeHostname(mixed $value): bool
|
||||
{
|
||||
return is_string($value) && preg_match(self::HOSTNAME_PATTERN, $value) === 1;
|
||||
}
|
||||
|
||||
private function appFileName(Application $application): string
|
||||
{
|
||||
return 'app_'.$application->getKey();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Proxy;
|
||||
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\V5\Application;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StartCaddyIngress
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
private const FIREWALL_PORTS = [80];
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Server $server): string
|
||||
{
|
||||
if (! $server->isIngress()) {
|
||||
return 'Server is not an ingress server.';
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
throw new \RuntimeException('Server is missing its Flux host id.');
|
||||
}
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run($this->applications($server));
|
||||
$output = $this->fluxClient->applyIngress($hostId, 'caddy', $configuration['caddyfile'], $this->ingressApps($configuration['apps']));
|
||||
|
||||
$firewallWarning = null;
|
||||
|
||||
foreach (self::FIREWALL_PORTS as $port) {
|
||||
try {
|
||||
$this->fluxClient->applyFirewallRule($hostId, [
|
||||
'id' => "v5-caddy-ingress:{$port}",
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => $port,
|
||||
]);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
$firewallWarning = "Caddy ingress is running, but this node's coold does not support {$exception->verb}, so the managed firewall was not updated for port {$port}.";
|
||||
Log::warning('V5 caddy ingress firewall rule skipped: coold verb unsupported', [
|
||||
'server_id' => $server->id,
|
||||
'port' => $port,
|
||||
'verb' => $exception->verb,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($server->exists) {
|
||||
$server->update([
|
||||
'ingress_type' => 'caddy',
|
||||
'ingress_status' => 'running',
|
||||
...($firewallWarning === null ? [] : [
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => $firewallWarning,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, caddyfile: string}> $apps
|
||||
* @return array<int, array{name: string, config: string}>
|
||||
*/
|
||||
private function ingressApps(array $apps): array
|
||||
{
|
||||
return array_map(
|
||||
fn (array $app): array => [
|
||||
'name' => $app['name'],
|
||||
'config' => $app['caddyfile'],
|
||||
],
|
||||
$apps
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Application>
|
||||
*/
|
||||
private function applications(Server $server): Collection
|
||||
{
|
||||
if (! $server->exists) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Application::query()
|
||||
->where('team_id', $server->team_id)
|
||||
->where('server_id', $server->id)
|
||||
->with('domains')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Proxy;
|
||||
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StopCaddyIngress
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
private const FIREWALL_PORTS = [80];
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Server $server): string
|
||||
{
|
||||
if (! $server->isIngress() && $server->ingress_type === null) {
|
||||
return 'Server is not an ingress server.';
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
throw new \RuntimeException('Server is missing its Flux host id.');
|
||||
}
|
||||
|
||||
// Revoke first: if stopping the container fails the allow rules must not
|
||||
// stay orphaned on the host.
|
||||
foreach (self::FIREWALL_PORTS as $port) {
|
||||
$this->revokeFirewallRuleIfPresent($hostId, "v5-caddy-ingress:{$port}");
|
||||
}
|
||||
|
||||
$output = $this->fluxClient->stopIngress($hostId, 'caddy');
|
||||
|
||||
if ($server->exists) {
|
||||
$server->update(['ingress_status' => IngressStatus::Exited->value]);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
private function revokeFirewallRuleIfPresent(string $hostId, string $ruleId): void
|
||||
{
|
||||
try {
|
||||
$this->fluxClient->revokeFirewallRule($hostId, $ruleId);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
Log::warning('V5 caddy ingress firewall revoke skipped: coold verb unsupported', [
|
||||
'host_id' => $hostId,
|
||||
'rule_id' => $ruleId,
|
||||
'verb' => $exception->verb,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
} catch (\RuntimeException $exception) {
|
||||
if (! str_contains(Str::lower($exception->getMessage()), 'not found')) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Server;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Server;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class PushHostAgentToken
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* Best-effort SSH push of a freshly minted host JWT to the on-host jwt path.
|
||||
*
|
||||
* coold re-reads the JWT file on every reconnect and flux drops the stream
|
||||
* at the token's exp, so overwriting the file in place is enough for the
|
||||
* next reconnect to pick up the new token — coold is intentionally NOT
|
||||
* restarted here (a restart would force an unnecessary disconnect of a
|
||||
* stream that is still valid on the current token).
|
||||
*
|
||||
* Mirrors V5BootstrapServerJob::enrollCooldIntoFlux for the write mechanics
|
||||
* (printf %s <token> | sudo tee <path>; chmod 600) and RemoveBootstrapMarker
|
||||
* for the SSH/temp-key mechanics. Returns whether the write succeeded;
|
||||
* every failure path (missing key, SSH error, exception) resolves to false
|
||||
* and always cleans up the temporary key file.
|
||||
*/
|
||||
public function handle(Server $server, string $token): bool
|
||||
{
|
||||
$server->loadMissing('privateKey');
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$jwtPath = trim((string) config('coold.flux_host_jwt_path', '/etc/coolify/host-jwt'));
|
||||
|
||||
if ($jwtPath === '') {
|
||||
$jwtPath = '/etc/coolify/host-jwt';
|
||||
}
|
||||
|
||||
$jwtPath = str_replace(["\r", "\n"], '', $jwtPath);
|
||||
$token = str_replace(["\r", "\n"], '', $token);
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$tokenArgument = escapeshellarg($token);
|
||||
$jwtPathArgument = $this->shellPathArg($jwtPath);
|
||||
$script = <<<SH
|
||||
set -e
|
||||
SUDO=''
|
||||
if [ "\$(id -u)" != "0" ]; then SUDO='sudo'; fi
|
||||
\$SUDO mkdir -p /etc/coolify
|
||||
printf %s {$tokenArgument} | \$SUDO tee {$jwtPathArgument} >/dev/null
|
||||
\$SUDO chmod 600 {$jwtPathArgument}
|
||||
SH;
|
||||
|
||||
try {
|
||||
$result = Process::timeout(30)->run([
|
||||
'ssh',
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'IdentitiesOnly=yes',
|
||||
'-i', $keyLocation,
|
||||
'-p', (string) $server->ssh_port,
|
||||
"{$server->ssh_user}@{$server->host}",
|
||||
$script,
|
||||
]);
|
||||
|
||||
return $result->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
|
||||
private function shellPathArg(string $value): string
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9_\/:.,@%+=-]+$/', $value) === 1) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return escapeshellarg($value);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Server;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class RemoveBootstrapMarker
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* Best-effort removal of the on-host bootstrap identity (marker, host JWT and
|
||||
* Flux drop-in) so a re-added server can never silently adopt stale state.
|
||||
*
|
||||
* The host token jti is revoked first (a local DB write plus a best-effort
|
||||
* push to the flux revocation store) so a captured or pre-copied token is
|
||||
* recorded revoked even when the host is unreachable — see
|
||||
* AgentTokenIssuer::revoke.
|
||||
*/
|
||||
public function handle(Server $server): bool
|
||||
{
|
||||
app(AgentTokenIssuer::class)->revoke($server);
|
||||
|
||||
$server->loadMissing('privateKey');
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
'$SUDO rm -f /etc/coolify/v5-node.json /etc/coolify/host-jwt /etc/systemd/system/coold.service.d/10-flux.conf',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'IdentitiesOnly=yes',
|
||||
'-i', $keyLocation,
|
||||
'-p', (string) $server->ssh_port,
|
||||
"{$server->ssh_user}@{$server->host}",
|
||||
$script,
|
||||
]);
|
||||
|
||||
return $result->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Server;
|
||||
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
/**
|
||||
* Registers local Lima development VMs (provisioned by scripts/dev.sh) as
|
||||
* cluster servers. They are intentionally seeded as Installed with
|
||||
* last_bootstrapped_at already set but has_coold=false, so they skip the real
|
||||
* bootstrap flow by design: V5BootstrapServerJob early-returns on a non-null
|
||||
* last_bootstrapped_at, and V5ReconcileServersJob ignores them until
|
||||
* something marks has_coold=true.
|
||||
*/
|
||||
class SyncDevLimaServers
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @param array<int, array{
|
||||
* name: string,
|
||||
* host: string,
|
||||
* ssh_user: string,
|
||||
* ssh_port: int,
|
||||
* wireguard_management_ip?: ?string,
|
||||
* wireguard_listen_port_override?: ?int,
|
||||
* wireguard_endpoint_override?: ?string
|
||||
* }> $servers
|
||||
*/
|
||||
public function handle(
|
||||
Team $team,
|
||||
User $user,
|
||||
?PrivateKey $privateKey,
|
||||
string $clusterName,
|
||||
array $servers,
|
||||
): Cluster {
|
||||
$cluster = Cluster::query()->updateOrCreate([
|
||||
'team_id' => $team->id,
|
||||
'name' => $clusterName,
|
||||
], [
|
||||
'created_by_user_id' => $user->id,
|
||||
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
|
||||
]);
|
||||
|
||||
foreach ($servers as $server) {
|
||||
$wireguardManagementIp = $server['wireguard_management_ip'] ?? null;
|
||||
$values = [
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey?->id,
|
||||
'host' => $server['host'],
|
||||
'ssh_user' => $server['ssh_user'],
|
||||
'ssh_port' => $server['ssh_port'],
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'has_coold' => false,
|
||||
'is_ingress' => false,
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'node_address' => $wireguardManagementIp ?: $server['host'],
|
||||
'wireguard_management_ip' => $wireguardManagementIp,
|
||||
'last_bootstrapped_at' => now(),
|
||||
];
|
||||
|
||||
if (array_key_exists('wireguard_listen_port_override', $server)) {
|
||||
$values['wireguard_listen_port_override'] = $server['wireguard_listen_port_override'];
|
||||
}
|
||||
|
||||
if (array_key_exists('wireguard_endpoint_override', $server)) {
|
||||
$values['wireguard_endpoint_override'] = $server['wireguard_endpoint_override'];
|
||||
}
|
||||
|
||||
Server::query()->updateOrCreate([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'name' => $server['name'],
|
||||
], $values);
|
||||
}
|
||||
|
||||
return $cluster->refresh();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user