mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-19 20:14:59 +00:00
feat(v5): add server reconciliation and canvas APIs
Split V5 dashboard behavior into domain controllers and policies, add agent token rotation/revocation, status reconciliation jobs, ingress firewall syncing, and canvas connection APIs. Add migrations for V5 status tracking, server capabilities, resource connection aliases, and revoked agent tokens.
This commit is contained in:
1 parent
df854feee8
commit
6ae45684f9
147 files changed
+22444
-10407
No files matched your search
@@ -3,39 +3,47 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const BOOTSTRAP_MARKER_PATH = '/etc/coolify/v5-node.json';
|
||||
|
||||
public const TIMEOUT_SECONDS = 7200;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 7200;
|
||||
public int $timeout = self::TIMEOUT_SECONDS;
|
||||
|
||||
/**
|
||||
* Second idempotency layer on top of the controller's DB bootstrap claim,
|
||||
* aligned with its running-claim window (TIMEOUT_SECONDS plus margin).
|
||||
*/
|
||||
public int $uniqueFor = self::TIMEOUT_SECONDS + 300;
|
||||
|
||||
public function __construct(public int $clusterId, public int $serverId) {}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return [(new WithoutOverlapping("v5-bootstrap-server-{$this->serverId}"))->expireAfter(7200)->dontRelease()];
|
||||
return (string) $this->serverId;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
@@ -58,18 +66,28 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$started = V5Server::query()
|
||||
->whereKey($server->id)
|
||||
->where('last_bootstrap_status', 'queued')
|
||||
->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => 'running',
|
||||
'last_bootstrap_output' => "Starting Coolify CLI {$action} for {$server->name}...",
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
if ($started === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server->refresh();
|
||||
|
||||
if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) {
|
||||
$this->markFailed($server, $action, 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => 'running',
|
||||
'last_bootstrap_output' => "Starting Coolify CLI {$action} for {$server->name}...",
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
@@ -89,18 +107,23 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$existingBootstrap = $this->detectExistingBootstrap($server, $sshConfigLocation);
|
||||
|
||||
if (($existingBootstrap['cluster_id'] ?? null) !== null) {
|
||||
if ((string) $existingBootstrap['cluster_id'] !== (string) $cluster->id) {
|
||||
$markerClusterUuid = $existingBootstrap['cluster_uuid'] ?? null;
|
||||
|
||||
if (
|
||||
(string) $existingBootstrap['cluster_id'] !== (string) $cluster->id
|
||||
|| (is_string($markerClusterUuid) && $markerClusterUuid !== $cluster->uuid)
|
||||
) {
|
||||
$this->markFailed($server, $action, 'This server is already bootstrapped for another cluster. Reset the host bootstrap state before joining this cluster.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->adoptExistingBootstrap($server, $existingBootstrap);
|
||||
$this->adoptExistingBootstrap($cluster, $server, $existingBootstrap, $sshConfigLocation);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = Process::timeout(300)
|
||||
$result = Process::timeout(7200)
|
||||
->run($this->bootstrapCommand($cluster, $servers, $server, $sshConfigLocation, $action));
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
$successful = $result->successful();
|
||||
@@ -117,22 +140,26 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$capabilities = collect($server->capabilities ?? [])
|
||||
->push('coold')
|
||||
->when($server->isIngress(), fn ($capabilities) => $capabilities->push('ingress'))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$this->persistBootstrapAssignments($cluster, $server, $result->output(), $sshConfigLocation);
|
||||
$server->refresh();
|
||||
|
||||
// Resolve the coold version once so the on-host marker and the
|
||||
// database row always agree.
|
||||
$cooldVersion = $this->bootstrappedCooldVersion($cluster, $result->output());
|
||||
|
||||
$this->writeBootstrapMarker($cluster, $server, $sshConfigLocation, $cooldVersion);
|
||||
|
||||
$this->enrollCooldIntoFlux($server, $sshConfigLocation);
|
||||
$this->waitForFluxHostConnection($server);
|
||||
|
||||
$server->update([
|
||||
'status' => 'installed',
|
||||
'capabilities' => $capabilities,
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'has_coold' => true,
|
||||
'coold_version' => $cooldVersion,
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
$this->writeBootstrapMarker($cluster, $server, $sshConfigLocation);
|
||||
|
||||
if ($server->isIngress()) {
|
||||
StartCaddyIngress::run($server->fresh('privateKey'));
|
||||
}
|
||||
@@ -183,11 +210,13 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
'init',
|
||||
$action,
|
||||
'--format',
|
||||
'table',
|
||||
'json',
|
||||
'--nodes',
|
||||
$servers->map(fn (V5Server $server) => $this->bootstrapNode($server))->implode(','),
|
||||
'--ssh-config',
|
||||
$sshConfigLocation,
|
||||
'--ssh-user',
|
||||
$newServer->ssh_user,
|
||||
'--namespaces',
|
||||
implode(',', $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES),
|
||||
'--container-pool',
|
||||
@@ -308,14 +337,17 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
/**
|
||||
* @param array<string, mixed> $marker
|
||||
*/
|
||||
private function adoptExistingBootstrap(V5Server $server, array $marker): void
|
||||
private function adoptExistingBootstrap(V5Cluster $cluster, V5Server $server, array $marker, string $sshConfigLocation): void
|
||||
{
|
||||
$bootstrapNode = $this->bootstrapNode($server);
|
||||
$serverUuid = is_string($marker['server_uuid'] ?? null) ? $marker['server_uuid'] : null;
|
||||
$updates = [
|
||||
'wireguard_management_ip' => is_string($marker['wireguard_management_ip'] ?? null) ? $marker['wireguard_management_ip'] : $server->wireguard_management_ip,
|
||||
'wireguard_public_key' => is_string($marker['wireguard_public_key'] ?? null) ? $marker['wireguard_public_key'] : $server->wireguard_public_key,
|
||||
'coold_version' => is_string($marker['coold_version'] ?? null) && trim($marker['coold_version']) !== '' ? trim($marker['coold_version']) : $cluster->coold_version,
|
||||
'container_subnets' => is_array($marker['container_subnets'] ?? null) ? $marker['container_subnets'] : $server->container_subnets,
|
||||
'status' => 'installed',
|
||||
'has_coold' => true,
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'last_bootstrap_status' => 'succeeded',
|
||||
'last_bootstrap_output' => 'Adopted existing Coolify bootstrap state for this cluster.',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
@@ -329,28 +361,399 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$server->update($updates);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
$this->enrollCooldIntoFlux($server->fresh(), $sshConfigLocation, $bootstrapNode);
|
||||
$this->waitForFluxHostConnection($server->fresh());
|
||||
|
||||
if ($server->isIngress()) {
|
||||
StartCaddyIngress::run($server->fresh('privateKey'));
|
||||
}
|
||||
}
|
||||
|
||||
private function writeBootstrapMarker(V5Cluster $cluster, V5Server $server, string $sshConfigLocation): void
|
||||
private function persistBootstrapAssignments(V5Cluster $cluster, V5Server $server, string $output, string $sshConfigLocation): void
|
||||
{
|
||||
$verifiedNode = $this->verifiedBootstrapNode($output, $server);
|
||||
$wireguardManagementIp = is_array($verifiedNode) && is_string($verifiedNode['wireguard_ip'] ?? null)
|
||||
? $verifiedNode['wireguard_ip']
|
||||
: null;
|
||||
$warnings = [];
|
||||
|
||||
if (! is_string($wireguardManagementIp) || $wireguardManagementIp === '') {
|
||||
$wireguardManagementIp = $this->readWireguardManagementIp($cluster, $server, $sshConfigLocation, $warnings);
|
||||
}
|
||||
|
||||
$wireguardPublicKey = $this->readWireguardPublicKey($cluster, $server, $sshConfigLocation, $warnings);
|
||||
$containerSubnets = $this->readContainerSubnets($cluster, $server, $sshConfigLocation, $warnings);
|
||||
$updates = [];
|
||||
|
||||
if ($wireguardManagementIp !== null && $wireguardManagementIp !== '') {
|
||||
$updates['wireguard_management_ip'] = $wireguardManagementIp;
|
||||
|
||||
if (! is_string($server->node_address) || $server->node_address === '' || $server->node_address === $server->host) {
|
||||
$updates['node_address'] = $wireguardManagementIp;
|
||||
}
|
||||
} else {
|
||||
$warnings[] = 'Warning: could not determine the WireGuard management IP from the CLI output.';
|
||||
}
|
||||
|
||||
if ($wireguardPublicKey !== null && $wireguardPublicKey !== '') {
|
||||
$updates['wireguard_public_key'] = $wireguardPublicKey;
|
||||
}
|
||||
|
||||
if ($containerSubnets !== []) {
|
||||
$updates['container_subnets'] = $containerSubnets;
|
||||
}
|
||||
|
||||
if ($warnings !== []) {
|
||||
$updates['last_bootstrap_output'] = str(trim($server->last_bootstrap_output."\n".implode("\n", $warnings)))
|
||||
->limit(20000)
|
||||
->toString();
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$server->update($updates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $warnings
|
||||
*/
|
||||
private function readWireguardManagementIp(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): ?string
|
||||
{
|
||||
$interface = escapeshellarg($cluster->wireguard_interface);
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
"\$SUDO ip -4 -o addr show dev {$interface} | awk '{print \$4}' | cut -d/ -f1 | head -n1",
|
||||
]);
|
||||
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
$ipAddress = trim($result->output());
|
||||
|
||||
if (! $result->successful() || filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
|
||||
$warnings[] = 'Warning: could not read the WireGuard management IP from the server.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ipAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function verifiedBootstrapNode(string $output, V5Server $server): ?array
|
||||
{
|
||||
$decoded = $this->decodedBootstrapOutput($output);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$verifiedNodes = data_get($decoded, 'verified');
|
||||
|
||||
if (! is_array($verifiedNodes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bootstrapNode = $this->bootstrapNode($server);
|
||||
|
||||
foreach ($verifiedNodes as $verifiedNode) {
|
||||
if (! is_array($verifiedNode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$host = $verifiedNode['host'] ?? $verifiedNode['node'] ?? $verifiedNode['name'] ?? null;
|
||||
|
||||
if ($host === $bootstrapNode || $host === $server->uuid || $host === $server->name || $host === $server->host) {
|
||||
return $verifiedNode;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function decodedBootstrapOutput(string $output): ?array
|
||||
{
|
||||
$output = trim($output);
|
||||
|
||||
if ($output === '' || ! str_starts_with($output, '{')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($output, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLI init JSON output does not currently report the installed coold
|
||||
* version, so fall back to the version the cluster asked the CLI to
|
||||
* install (`--coold-version`). If a future CLI adds a `coold_version` key
|
||||
* to its JSON output, prefer that.
|
||||
*/
|
||||
private function bootstrappedCooldVersion(V5Cluster $cluster, string $output): ?string
|
||||
{
|
||||
$reported = data_get($this->decodedBootstrapOutput($output), 'coold_version');
|
||||
|
||||
if (is_string($reported) && trim($reported) !== '') {
|
||||
return trim($reported);
|
||||
}
|
||||
|
||||
return $cluster->coold_version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $warnings
|
||||
*/
|
||||
private function readWireguardPublicKey(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): ?string
|
||||
{
|
||||
$interface = escapeshellarg($cluster->wireguard_interface);
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
"\$SUDO wg show {$interface} public-key",
|
||||
]);
|
||||
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
$publicKey = trim($result->output());
|
||||
|
||||
if (! $result->successful() || $publicKey === '') {
|
||||
$warnings[] = 'Warning: could not read the WireGuard public key from the server.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The container subnets are allocated by the coolify CLI on the host; the podman
|
||||
* networks it creates are the source of truth, so read them back instead of
|
||||
* re-deriving the allocation locally.
|
||||
*
|
||||
* @param array<int, string> $warnings
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function readContainerSubnets(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): array
|
||||
{
|
||||
$namespaces = $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES;
|
||||
|
||||
if ($namespaces === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$namespaceArguments = collect($namespaces)
|
||||
->map(fn (string $namespace): string => escapeshellarg($namespace))
|
||||
->implode(' ');
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
"for ns in {$namespaceArguments}; do",
|
||||
' printf \'%s=\' "$ns"',
|
||||
' $SUDO podman network inspect "coolify-${ns}-mesh" --format \'{{range .Subnets}}{{.Subnet}}{{end}}\' 2>/dev/null || true',
|
||||
' printf \'\n\'',
|
||||
'done',
|
||||
]);
|
||||
|
||||
$result = Process::timeout(30)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$warnings[] = 'Warning: could not read the container subnets from the server.';
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$subnets = [];
|
||||
|
||||
foreach (preg_split('/\r?\n/', trim($result->output())) ?: [] as $line) {
|
||||
[$namespace, $subnet] = array_pad(explode('=', trim($line), 2), 2, null);
|
||||
|
||||
if (! is_string($namespace) || ! in_array($namespace, $namespaces, true) || ! $this->isIpv4Cidr($subnet)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subnets[$namespace] = $subnet;
|
||||
}
|
||||
|
||||
if (count($subnets) !== count($namespaces)) {
|
||||
$warnings[] = 'Warning: could not read every container subnet from the server; the stored subnets may be incomplete.';
|
||||
}
|
||||
|
||||
return $subnets;
|
||||
}
|
||||
|
||||
private function isIpv4Cidr(?string $value): bool
|
||||
{
|
||||
if (! is_string($value) || ! str_contains($value, '/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$ip, $prefix] = explode('/', $value, 2);
|
||||
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false
|
||||
&& ctype_digit($prefix)
|
||||
&& (int) $prefix <= 32;
|
||||
}
|
||||
|
||||
private function enrollCooldIntoFlux(V5Server $server, string $sshConfigLocation, ?string $bootstrapNode = null): void
|
||||
{
|
||||
$fluxUrl = trim((string) config('coold.flux_url', ''));
|
||||
|
||||
if ($fluxUrl === '') {
|
||||
throw new \RuntimeException('COOLIFY_COOLD_FLUX_URL is not configured, so the server cannot be enrolled into Flux. Set it and retry the bootstrap.');
|
||||
}
|
||||
|
||||
$jwtPath = trim((string) config('coold.flux_host_jwt_path', '/etc/coolify/host-jwt'));
|
||||
|
||||
if ($jwtPath === '') {
|
||||
$jwtPath = '/etc/coolify/host-jwt';
|
||||
}
|
||||
|
||||
$fluxUrl = str_replace(["\r", "\n"], '', $fluxUrl);
|
||||
$jwtPath = str_replace(["\r", "\n"], '', $jwtPath);
|
||||
$hostId = $server->fluxHostId();
|
||||
$token = app(AgentTokenIssuer::class)->issueForServer($server);
|
||||
$tokenArgument = $this->shellArg($token);
|
||||
$hostId = str_replace(["\r", "\n"], '', $hostId);
|
||||
$jwtPathArgument = $this->shellPathArg($jwtPath);
|
||||
$dropInDirectory = '/etc/systemd/system/coold.service.d';
|
||||
$dropInPath = "{$dropInDirectory}/10-flux.conf";
|
||||
$script = <<<SH
|
||||
set -e
|
||||
SUDO=''
|
||||
if [ "\$(id -u)" != "0" ]; then SUDO='sudo'; fi
|
||||
\$SUDO mkdir -p /etc/coolify {$dropInDirectory}
|
||||
printf %s {$tokenArgument} | \$SUDO tee {$jwtPathArgument} >/dev/null
|
||||
\$SUDO chmod 600 {$jwtPathArgument}
|
||||
cat <<'COOLIFY_FLUX_ENV' | \$SUDO tee {$dropInPath} >/dev/null
|
||||
[Service]
|
||||
Environment=COOLIFY_COOLD_FLUX_URL={$fluxUrl}
|
||||
Environment=COOLIFY_COOLD_HOST_ID={$hostId}
|
||||
Environment=COOLIFY_COOLD_HOST_JWT_PATH={$jwtPath}
|
||||
COOLIFY_FLUX_ENV
|
||||
\$SUDO systemctl daemon-reload
|
||||
\$SUDO systemctl restart coold.service
|
||||
SH;
|
||||
|
||||
$result = Process::timeout(60)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$bootstrapNode ?? $this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
throw new \RuntimeException(
|
||||
($output !== '' ? $output : 'Could not enroll coold into Flux.')
|
||||
."\nThe WireGuard mesh was created successfully; retrying this bootstrap is safe and will resume from Flux enrollment."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function waitForFluxHostConnection(V5Server $server): void
|
||||
{
|
||||
$timeoutSeconds = (int) config('flux.bootstrap_host_connection_timeout_seconds', 30);
|
||||
|
||||
if ($timeoutSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
throw new \RuntimeException('Server is missing its Flux host id after bootstrap.');
|
||||
}
|
||||
|
||||
$deadline = time() + $timeoutSeconds;
|
||||
$lastError = null;
|
||||
|
||||
do {
|
||||
try {
|
||||
app(FluxClient::class)->cooldLogs($hostId, 1);
|
||||
|
||||
return;
|
||||
} catch (\Throwable $exception) {
|
||||
$lastError = $exception->getMessage();
|
||||
sleep(1);
|
||||
}
|
||||
} while (time() < $deadline);
|
||||
|
||||
throw new \RuntimeException(
|
||||
'The server was bootstrapped, but coold did not connect to Flux in time. '
|
||||
.'Wait a moment and retry the bootstrap before deploying applications.'
|
||||
.($lastError !== null ? " Last Flux error: {$lastError}" : '')
|
||||
);
|
||||
}
|
||||
|
||||
private function shellArg(string $value): string
|
||||
{
|
||||
return escapeshellarg($value);
|
||||
}
|
||||
|
||||
private function shellPathArg(string $value): string
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9_\/:.,@%+=-]+$/', $value) === 1) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $this->shellArg($value);
|
||||
}
|
||||
|
||||
private function writeBootstrapMarker(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, ?string $cooldVersion = null): void
|
||||
{
|
||||
$payload = base64_encode(json_encode([
|
||||
'cluster_id' => $cluster->id,
|
||||
'cluster_uuid' => $cluster->uuid,
|
||||
'server_uuid' => $server->uuid,
|
||||
'wireguard_management_ip' => $server->wireguard_management_ip,
|
||||
'wireguard_public_key' => $server->wireguard_public_key,
|
||||
'coold_version' => $cooldVersion ?? $server->coold_version ?? $cluster->coold_version,
|
||||
'container_subnets' => $server->container_subnets ?? [],
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
Process::timeout(15)->run([
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
"payload='{$payload}'; if [ \"$(id -u)\" = \"0\" ]; then mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d > ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH)."; else sudo mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d | sudo tee ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH).' >/dev/null; fi',
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
throw new \RuntimeException('Could not write the bootstrap marker to the server: '.($output !== '' ? $output : 'the SSH command failed.'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Application\DeployNginxApplication;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5DeployApplicationJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
/**
|
||||
* Job timeout plus a safety margin so a lost lock can never block
|
||||
* redeploys of the same application forever.
|
||||
*/
|
||||
public int $uniqueFor = 360;
|
||||
|
||||
public function __construct(public int $applicationId) {}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->applicationId;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$application = V5Application::query()->find($this->applicationId);
|
||||
|
||||
if (! $application instanceof V5Application) {
|
||||
return;
|
||||
}
|
||||
|
||||
DeployNginxApplication::run($application);
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
V5Application::query()->find($this->applicationId)?->update([
|
||||
'status' => 'failed',
|
||||
'status_message' => str($exception?->getMessage() ?? 'The deploy job failed.')->limit(10000)->toString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
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\Services\Flux\FluxClient;
|
||||
use App\Support\V5\StatusObservation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Actively reconciles one v5 server against the containers coold actually
|
||||
* reports. V5 status is normally push-only (coold -> flux -> webhook), so a
|
||||
* dropped webhook leaves rows stale forever; this job is the pull-based
|
||||
* safety net scheduled via V5ReconcileServersJob.
|
||||
*/
|
||||
class V5ReconcileServerStateJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Reconcile runs on its own queue so the 5-minute fleet fan-out (one
|
||||
* blocking flux call per server) can never starve user-triggered deploys
|
||||
* and bootstraps sharing the default queue. Set via onQueue() in the
|
||||
* constructor rather than a `$queue` property redeclaration, which the
|
||||
* Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 120;
|
||||
|
||||
public function __construct(public int $serverId)
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(FluxClient $fluxClient): void
|
||||
{
|
||||
$server = V5Server::query()->find($this->serverId);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if ($hostId === '') {
|
||||
Log::warning('V5 reconcile skipped: server is missing a Flux host id.', ['server_id' => $server->id]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The moment we query coold is the observation time for every row this
|
||||
// pass writes; a webhook that lands with a newer observation while this
|
||||
// (possibly delayed) snapshot is processed must win the watermark.
|
||||
$observedAt = CarbonImmutable::now();
|
||||
|
||||
try {
|
||||
$containers = collect($fluxClient->listContainers($hostId));
|
||||
} catch (\Throwable $exception) {
|
||||
$this->markServerUnreachable($server, $exception, $observedAt);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markServerReachable($server, $containers->count(), $observedAt);
|
||||
$this->refreshContainerStatuses($server, $containers, $observedAt);
|
||||
$this->reconcileApplications($server, $containers, $observedAt);
|
||||
}
|
||||
|
||||
private function markServerUnreachable(V5Server $server, \Throwable $exception, CarbonInterface $observedAt): void
|
||||
{
|
||||
Log::warning('V5 reconcile could not reach the server via flux.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$attributes = [
|
||||
'last_status_check' => 'reconcile',
|
||||
'last_status_output' => str($exception->getMessage())->limit(1000)->toString(),
|
||||
'last_status_checked_at' => now(),
|
||||
];
|
||||
|
||||
if (! StatusObservation::isStale($observedAt, $server->status_observed_at, 'server status', ['server_id' => $server->id])) {
|
||||
// Only an installed server can degrade to unreachable; added or
|
||||
// failed servers keep their bootstrap-driven status.
|
||||
$attributes['status'] = $server->status === ServerStatus::Installed->value
|
||||
? ServerStatus::Unreachable->value
|
||||
: $server->status;
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$server->update($attributes);
|
||||
}
|
||||
|
||||
private function markServerReachable(V5Server $server, int $containerCount, CarbonInterface $observedAt): void
|
||||
{
|
||||
$attributes = [
|
||||
'last_status_check' => 'reconcile',
|
||||
'last_status_output' => "Reconciled {$containerCount} containers from coold.",
|
||||
'last_status_checked_at' => now(),
|
||||
];
|
||||
|
||||
if (! StatusObservation::isStale($observedAt, $server->status_observed_at, 'server status', ['server_id' => $server->id])) {
|
||||
$attributes['status'] = $server->status === ServerStatus::Unreachable->value
|
||||
? ServerStatus::Installed->value
|
||||
: $server->status;
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$server->update($attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $containers
|
||||
*/
|
||||
private function refreshContainerStatuses(V5Server $server, Collection $containers, CarbonInterface $observedAt): void
|
||||
{
|
||||
$containers->each(function (mixed $container) use ($server, $observedAt): void {
|
||||
if (! is_array($container) || ! is_string($container['id'] ?? null) || $container['id'] === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', $container['id'])
|
||||
->first();
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $existing?->status_observed_at, 'container status', [
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $container['id'],
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
ContainerStatus::query()->updateOrCreate([
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $container['id'],
|
||||
], [
|
||||
'team_id' => $server->team_id,
|
||||
'container_name' => is_string($container['name'] ?? null) ? $container['name'] : null,
|
||||
'image' => is_string($container['image'] ?? null) ? $container['image'] : null,
|
||||
'status' => $this->containerState($container, ContainerState::class),
|
||||
'status_message' => 'Container state reconciled from coold.',
|
||||
'status_observed_at' => $observedAt,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $containers
|
||||
*/
|
||||
private function reconcileApplications(V5Server $server, Collection $containers, CarbonInterface $observedAt): void
|
||||
{
|
||||
V5Application::query()
|
||||
->where('server_id', $server->id)
|
||||
->get()
|
||||
->each(function (V5Application $application) use ($containers, $observedAt): void {
|
||||
try {
|
||||
$this->reconcileApplication($application, $containers, $observedAt);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 reconcile failed for an application.', [
|
||||
'application_id' => $application->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $containers
|
||||
*/
|
||||
private function reconcileApplication(V5Application $application, Collection $containers, CarbonInterface $observedAt): void
|
||||
{
|
||||
$container = $containers->first(function (mixed $container) use ($application): bool {
|
||||
return is_array($container)
|
||||
&& (($application->runtime_container_id !== null && ($container['id'] ?? null) === $application->runtime_container_id)
|
||||
|| ($container['name'] ?? null) === $application->container_name);
|
||||
});
|
||||
|
||||
if (! is_array($container)) {
|
||||
// A creating application without a container id simply has not
|
||||
// materialized yet; the deploy job will settle it.
|
||||
if ($application->status === ApplicationStatus::Creating->value && $application->runtime_container_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'status' => ApplicationStatus::Exited->value,
|
||||
'status_observed_at' => $observedAt,
|
||||
];
|
||||
|
||||
if ($application->status !== ApplicationStatus::Exited->value) {
|
||||
$attributes['status_message'] = 'Container not found on server during reconcile.';
|
||||
}
|
||||
|
||||
$application->update($attributes);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $this->containerState($container, ApplicationStatus::class);
|
||||
|
||||
$attributes = [
|
||||
'status' => $status,
|
||||
'status_observed_at' => $observedAt,
|
||||
'runtime_container_id' => is_string($container['id'] ?? null) && $container['id'] !== ''
|
||||
? $container['id']
|
||||
: $application->runtime_container_id,
|
||||
];
|
||||
|
||||
// Only write status_message when the status actually changes: the
|
||||
// status column is what a viewer cares about, and a constant message
|
||||
// would otherwise fire a broadcast + full re-serialization every cycle.
|
||||
if ($status !== $application->status) {
|
||||
$attributes['status_message'] = 'Container state reconciled from coold.';
|
||||
}
|
||||
|
||||
$application->update($attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $container
|
||||
* @param class-string<ApplicationStatus|ContainerState> $enumClass
|
||||
*/
|
||||
private function containerState(array $container, string $enumClass): string
|
||||
{
|
||||
$state = $container['state'] ?? null;
|
||||
$raw = is_string($state) && $state !== '' ? $state : null;
|
||||
|
||||
return StatusObservation::normalize($raw, $enumClass) ?? $enumClass::Unknown->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
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 Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Scheduled fan-out for the v5 reconciliation loop: dispatches one
|
||||
* V5ReconcileServerStateJob per managed server and prunes container status
|
||||
* rows that no webhook has refreshed within the TTL.
|
||||
*/
|
||||
class V5ReconcileServersJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const CONTAINER_STATUS_TTL_HOURS = 24;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Reconcile runs on its own queue so the 5-minute fleet fan-out can never
|
||||
* starve user-triggered deploys and bootstraps sharing the default queue.
|
||||
* Set via onQueue() rather than a `$queue` property redeclaration, which
|
||||
* the Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->dispatchReconcileJobs();
|
||||
$this->pruneContainerStatuses();
|
||||
}
|
||||
|
||||
private function dispatchReconcileJobs(): void
|
||||
{
|
||||
V5Server::query()
|
||||
// Unreachable servers stay in the loop so a recovered node is
|
||||
// restored to installed by its next successful reconcile.
|
||||
->whereIn('status', [ServerStatus::Installed->value, ServerStatus::Unreachable->value])
|
||||
->where('has_coold', true)
|
||||
->get()
|
||||
->each(function (V5Server $server): void {
|
||||
try {
|
||||
V5ReconcileServerStateJob::dispatch($server->id);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 reconcile dispatch failed for a server.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function pruneContainerStatuses(): void
|
||||
{
|
||||
$cutoff = now()->subHours(self::CONTAINER_STATUS_TTL_HOURS);
|
||||
$liveContainerIds = V5Application::query()
|
||||
->whereNotNull('runtime_container_id')
|
||||
->pluck('runtime_container_id')
|
||||
->all();
|
||||
|
||||
ContainerStatus::query()
|
||||
->where(function ($query) use ($cutoff): void {
|
||||
$query
|
||||
->where('last_seen_at', '<', $cutoff)
|
||||
->orWhere(function ($query) use ($cutoff): void {
|
||||
$query->whereNull('last_seen_at')->where('created_at', '<', $cutoff);
|
||||
})
|
||||
->orWhereNotIn('server_id', V5Server::query()->select('id'));
|
||||
})
|
||||
->when($liveContainerIds !== [], fn ($query) => $query->whereNotIn('container_id', $liveContainerIds))
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Server\PushHostAgentToken;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Re-mints and delivers a fresh host JWT for one managed server before its
|
||||
* on-disk token expires.
|
||||
*
|
||||
* RPC-FIRST, SSH-FALLBACK: the new token is delivered over the live coold RPC
|
||||
* stream by default (Laravel -> flux UDS -> coold's `host.jwt.set` command),
|
||||
* because that reuses the already authenticated flux<->coold channel and works
|
||||
* while the CURRENT token is still valid — which is exactly when rotation runs
|
||||
* (at ~12h remaining, well before the 24h exp). Only if the RPC push fails (the
|
||||
* host's stream is down because its token already lapsed, flux rejects the verb,
|
||||
* a timeout, etc.) do we fall back to the SSH push, which recovers a node whose
|
||||
* token already expired and whose stream is therefore gone.
|
||||
*
|
||||
* PUSH-THEN-PERSIST: the new token is delivered to the host FIRST, and the
|
||||
* server's jti/expires_at are only advanced AFTER a successful delivery via
|
||||
* EITHER path. If both delivery paths fail the DB is left untouched, so the old
|
||||
* expires_at keeps the server inside the dispatcher's rotation window and the
|
||||
* next cycle simply retries — we never advance the watermark on a token the
|
||||
* host never received (which would strand the host on the expiring old token
|
||||
* until it fully lapsed).
|
||||
*
|
||||
* NO-REVOKE-ON-ROTATION: the previously issued jti is intentionally NOT revoked
|
||||
* here. The old token is still legitimately valid until its own exp and coold
|
||||
* may still be connected on it; revoking it would risk cutting the live stream.
|
||||
* Revocation belongs to teardown/re-home (RemoveBootstrapMarker), not routine
|
||||
* rotation — the old token simply ages out on its own exp.
|
||||
*/
|
||||
class V5RotateAgentTokenJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Rotation shares the reconcile queue so the hourly fleet fan-out can never
|
||||
* starve user-triggered deploys and bootstraps on the default queue. Set via
|
||||
* onQueue() rather than a `$queue` property redeclaration, which the
|
||||
* Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public function __construct(public int $serverId)
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$server = V5Server::query()->with('privateKey')->find($this->serverId);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->isEligible($server)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if ($hostId === '') {
|
||||
Log::warning('V5 token rotation skipped: server is missing a Flux host id.', ['server_id' => $server->id]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ttl = (int) config('flux.host_token_ttl');
|
||||
$jti = (string) Str::uuid();
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issue($hostId, null, $ttl, [
|
||||
'jti' => $jti,
|
||||
'team_id' => (string) $server->team_id,
|
||||
'cluster_id' => (string) $server->cluster_id,
|
||||
'server_id' => $hostId,
|
||||
'wireguard_management_ip' => (string) $server->wireguard_management_ip,
|
||||
]);
|
||||
|
||||
$delivery = $this->deliverToken($server, $hostId, $token);
|
||||
|
||||
if ($delivery === null) {
|
||||
Log::warning('V5 token rotation could not deliver the new host token; leaving the existing token in place.', [
|
||||
'server_id' => $server->id,
|
||||
'host' => $server->host,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'agent_token_jti' => $jti,
|
||||
'agent_token_expires_at' => now()->addSeconds($ttl),
|
||||
]);
|
||||
|
||||
Log::debug('V5 token rotation delivered a fresh host token.', [
|
||||
'server_id' => $server->id,
|
||||
'delivery' => $delivery,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the freshly minted token to the host, preferring the live coold
|
||||
* RPC stream and falling back to the SSH push on any RPC failure.
|
||||
*
|
||||
* @return 'rpc'|'ssh'|null The path that succeeded, or null if both failed.
|
||||
*/
|
||||
private function deliverToken(V5Server $server, string $hostId, string $token): ?string
|
||||
{
|
||||
try {
|
||||
app(FluxClient::class)->pushHostToken($hostId, $token);
|
||||
|
||||
return 'rpc';
|
||||
} catch (\Throwable $exception) {
|
||||
Log::info('V5 token rotation RPC push failed; falling back to SSH.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (PushHostAgentToken::run($server, $token)) {
|
||||
return 'ssh';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isEligible(V5Server $server): bool
|
||||
{
|
||||
return $server->status === ServerStatus::Installed->value
|
||||
&& (bool) $server->has_coold
|
||||
&& $server->last_bootstrapped_at !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Scheduled fan-out for host JWT rotation: dispatches one V5RotateAgentTokenJob
|
||||
* per managed server whose on-disk token is missing or within the configured
|
||||
* refresh threshold of expiry, so a fresh token is always on disk before the
|
||||
* current one lapses.
|
||||
*/
|
||||
class V5RotateAgentTokensJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Rotation shares the reconcile queue so the hourly fleet fan-out can never
|
||||
* starve user-triggered deploys and bootstraps on the default queue. Set via
|
||||
* onQueue() rather than a `$queue` property redeclaration, which the
|
||||
* Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$threshold = now()->addSeconds((int) config('flux.host_token_refresh_threshold'));
|
||||
|
||||
V5Server::query()
|
||||
->where('status', ServerStatus::Installed->value)
|
||||
->where('has_coold', true)
|
||||
->whereNotNull('last_bootstrapped_at')
|
||||
->where(function ($query) use ($threshold): void {
|
||||
$query
|
||||
->whereNull('agent_token_expires_at')
|
||||
->orWhere('agent_token_expires_at', '<', $threshold);
|
||||
})
|
||||
->get()
|
||||
->each(function (V5Server $server): void {
|
||||
try {
|
||||
V5RotateAgentTokenJob::dispatch($server->id);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 token rotation dispatch failed for a server.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Application\DestroyNginxApplication;
|
||||
use App\Actions\V5\Proxy\StopCaddyIngress;
|
||||
use App\Actions\V5\Server\RemoveBootstrapMarker;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Best-effort, on-host teardown for a team that is being deleted.
|
||||
*
|
||||
* Deleting a v4 Team DB-cascades every v5_servers / v5_applications /
|
||||
* v5_container_statuses / v5_resource_connections row (see the
|
||||
* cascadeOnDelete() foreign keys in the v5 migrations) WITHOUT running the
|
||||
* app-level teardown that the per-resource destroy flows use. That would leave
|
||||
* orphaned podman containers, a running Caddy ingress, the WireGuard mesh and
|
||||
* coold itself alive on every host with no DB record left to reach them.
|
||||
*
|
||||
* This job mirrors the ServerController::destroy / ApplicationController::destroy
|
||||
* teardown sequence for every v5 server owned by the team:
|
||||
* 1. remove each application's container (DestroyNginxApplication),
|
||||
* 2. stop the Caddy ingress on ingress servers (StopCaddyIngress),
|
||||
* 3. remove the on-host bootstrap identity — marker, host-jwt and Flux
|
||||
* drop-in (RemoveBootstrapMarker).
|
||||
*
|
||||
* Because the cascade deletes the servers, applications and private keys the
|
||||
* moment the team is gone, the payload is captured at dispatch time (from the
|
||||
* Team `deleting` hook, which fires BEFORE the rows vanish) as plain arrays,
|
||||
* including the SSH private-key material needed to reach each host. The job
|
||||
* rebuilds in-memory, non-persisted models from that payload so it can reuse
|
||||
* the exact same actions without touching the (now missing) DB rows.
|
||||
*
|
||||
* BEST-EFFORT / LIMITATIONS: teardown is best-effort. Each host and each action
|
||||
* is guarded so a single unreachable host can never abort teardown of the other
|
||||
* hosts, and the team deletion itself never fails because of teardown. The
|
||||
* payload is fully self-contained (host, SSH creds/private key, applications,
|
||||
* token jti + expiry), so a framework-level queue retry is safe — every step is
|
||||
* idempotent (podman rm -f / ingress stop / rm -f are all no-ops when the target
|
||||
* is already gone).
|
||||
*
|
||||
* RESIDUAL LIMITATION: a host that is unreachable at team-deletion time orphans
|
||||
* its containers, ingress and mesh PERMANENTLY — the DB rows the reconcilers key
|
||||
* off are gone, so there is no later reconciliation. The single operator-facing
|
||||
* signal is the `Log::error` emitted at the end of handle() listing the host
|
||||
* ids/hosts that could not be torn down; grep for "v5 team teardown incomplete"
|
||||
* to find them.
|
||||
*/
|
||||
class V5TeardownTeamJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* A small retry budget: the payload is self-contained and every teardown
|
||||
* step is idempotent, so retrying an unreachable host is safe. There is no
|
||||
* point retrying forever — the host may simply be gone.
|
||||
*/
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $servers Self-contained per-server teardown payload captured before the cascade.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $teamId,
|
||||
public array $servers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Collect the team's v5 servers (with their applications and SSH key
|
||||
* material) into a self-contained payload and dispatch the teardown job.
|
||||
*
|
||||
* Must be called from the Team `deleting` hook, while the rows still exist.
|
||||
* Returns without dispatching when the team owns no v5 servers.
|
||||
*/
|
||||
public static function dispatchForTeam(Team $team): void
|
||||
{
|
||||
// Guard against contexts where the v5 tables do not exist (e.g. v4-only
|
||||
// schemas) so team deletion is never broken by this teardown.
|
||||
if (! Schema::hasTable('v5_servers')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$servers = V5Server::query()
|
||||
->where('team_id', $team->id)
|
||||
->with('privateKey')
|
||||
->get();
|
||||
|
||||
if ($servers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicationsByServer = V5Application::query()
|
||||
->where('team_id', $team->id)
|
||||
->whereNotNull('server_id')
|
||||
->get()
|
||||
->groupBy('server_id');
|
||||
|
||||
$payload = $servers->map(function (V5Server $server) use ($applicationsByServer): array {
|
||||
return [
|
||||
'id' => $server->id,
|
||||
'uuid' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'ssh_user' => $server->ssh_user,
|
||||
'ssh_port' => (int) $server->ssh_port,
|
||||
'node_address' => $server->node_address,
|
||||
'wireguard_management_ip' => $server->wireguard_management_ip,
|
||||
'is_ingress' => (bool) $server->is_ingress,
|
||||
'ingress_type' => $server->ingress_type,
|
||||
'status' => $server->status,
|
||||
'last_bootstrapped_at' => $server->last_bootstrapped_at?->toISOString(),
|
||||
// Captured before the cascade removes the row so the job can
|
||||
// revoke the host token after the DB rows are gone.
|
||||
'agent_token_jti' => $server->agent_token_jti,
|
||||
'agent_token_expires_at' => $server->agent_token_expires_at?->toISOString(),
|
||||
// Encrypted at rest on the model; needed to SSH into the host.
|
||||
'private_key' => $server->privateKey instanceof PrivateKey ? $server->privateKey->private_key : null,
|
||||
'applications' => ($applicationsByServer[$server->id] ?? collect())
|
||||
->map(fn (V5Application $application): array => [
|
||||
'id' => $application->id,
|
||||
'container_name' => $application->container_name,
|
||||
'runtime_container_id' => $application->runtime_container_id,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
})->all();
|
||||
|
||||
self::dispatch($team->id, $payload);
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$incompleteHosts = [];
|
||||
|
||||
foreach ($this->servers as $serverPayload) {
|
||||
if (! $this->teardownServer($serverPayload)) {
|
||||
$incompleteHosts[] = [
|
||||
'server_id' => $serverPayload['id'] ?? null,
|
||||
'host' => $serverPayload['host'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Teardown is best-effort and never fails the job (an unreachable host
|
||||
// must not abort the others), so this is the single operator-facing
|
||||
// signal that some hosts could not be reached and may now hold orphaned
|
||||
// containers/mesh with no DB row left to reconcile them.
|
||||
if ($incompleteHosts !== []) {
|
||||
Log::error('v5 team teardown incomplete — '.count($incompleteHosts).' host(s) may have orphaned containers/mesh', [
|
||||
'team_id' => $this->teamId,
|
||||
'hosts' => $incompleteHosts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down a single host. Returns false when any on-host teardown step
|
||||
* (container removal, ingress stop, bootstrap-marker removal) failed, so the
|
||||
* caller can surface the host as potentially orphaned. Never throws: a
|
||||
* single unreachable host must not abort teardown of the other hosts.
|
||||
*
|
||||
* @param array<string, mixed> $serverPayload
|
||||
*/
|
||||
private function teardownServer(array $serverPayload): bool
|
||||
{
|
||||
$server = $this->reconstructServer($serverPayload);
|
||||
$serverId = $serverPayload['id'] ?? null;
|
||||
$host = $serverPayload['host'] ?? null;
|
||||
$succeeded = true;
|
||||
|
||||
foreach ($serverPayload['applications'] ?? [] as $applicationPayload) {
|
||||
try {
|
||||
$application = $this->reconstructApplication($applicationPayload, $server);
|
||||
DestroyNginxApplication::run($application);
|
||||
} catch (\Throwable $exception) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: failed to remove application container', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'container_name' => $applicationPayload['container_name'] ?? null,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($server->isIngress() && $server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StopCaddyIngress::run($server);
|
||||
} catch (\Throwable $exception) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: failed to stop Caddy ingress', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (($serverPayload['last_bootstrapped_at'] ?? null) !== null) {
|
||||
try {
|
||||
if (! RemoveBootstrapMarker::run($server)) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: could not remove on-host bootstrap identity over SSH', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: bootstrap marker removal threw', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation is best-effort and independent of the on-host cleanup: a
|
||||
// failed flux push does not mean the host is orphaned, so it never flips
|
||||
// $succeeded (it is logged separately inside AgentTokenIssuer::revoke).
|
||||
$this->revokeAgentTokenIfSupported($server, $serverId, $host);
|
||||
|
||||
return $succeeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a non-persisted V5Server (with its private key relation
|
||||
* pre-set) so the teardown actions never hit the deleted DB rows.
|
||||
*
|
||||
* @param array<string, mixed> $serverPayload
|
||||
*/
|
||||
private function reconstructServer(array $serverPayload): V5Server
|
||||
{
|
||||
$server = new V5Server;
|
||||
$server->forceFill([
|
||||
'id' => $serverPayload['id'] ?? null,
|
||||
'uuid' => $serverPayload['uuid'] ?? null,
|
||||
'name' => $serverPayload['name'] ?? null,
|
||||
'host' => $serverPayload['host'] ?? null,
|
||||
'ssh_user' => $serverPayload['ssh_user'] ?? null,
|
||||
'ssh_port' => $serverPayload['ssh_port'] ?? 22,
|
||||
'node_address' => $serverPayload['node_address'] ?? null,
|
||||
'wireguard_management_ip' => $serverPayload['wireguard_management_ip'] ?? null,
|
||||
'is_ingress' => (bool) ($serverPayload['is_ingress'] ?? false),
|
||||
'ingress_type' => $serverPayload['ingress_type'] ?? null,
|
||||
'status' => $serverPayload['status'] ?? null,
|
||||
'agent_token_jti' => $serverPayload['agent_token_jti'] ?? null,
|
||||
'agent_token_expires_at' => $serverPayload['agent_token_expires_at'] ?? null,
|
||||
]);
|
||||
// Non-persisted: StopCaddyIngress / the actions must not try to update a
|
||||
// row that the cascade already removed.
|
||||
$server->exists = false;
|
||||
|
||||
$privateKeyMaterial = $serverPayload['private_key'] ?? null;
|
||||
if (is_string($privateKeyMaterial) && $privateKeyMaterial !== '') {
|
||||
$privateKey = new PrivateKey;
|
||||
$privateKey->forceFill(['private_key' => $privateKeyMaterial]);
|
||||
$server->setRelation('privateKey', $privateKey);
|
||||
} else {
|
||||
$server->setRelation('privateKey', null);
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $applicationPayload
|
||||
*/
|
||||
private function reconstructApplication(array $applicationPayload, V5Server $server): V5Application
|
||||
{
|
||||
$application = new V5Application;
|
||||
$application->forceFill([
|
||||
'id' => $applicationPayload['id'] ?? null,
|
||||
'container_name' => $applicationPayload['container_name'] ?? null,
|
||||
'runtime_container_id' => $applicationPayload['runtime_container_id'] ?? null,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$application->exists = false;
|
||||
$application->setRelation('server', $server);
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
/**
|
||||
* If a coold-side agent-token revocation ever lands on AgentTokenIssuer,
|
||||
* call it best-effort. Guarded so this job never hard-depends on a method
|
||||
* that may not exist yet.
|
||||
*/
|
||||
private function revokeAgentTokenIfSupported(V5Server $server, mixed $serverId, mixed $host): void
|
||||
{
|
||||
if (! method_exists(AgentTokenIssuer::class, 'revokeForServer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app(AgentTokenIssuer::class)->revokeForServer($server);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 team teardown: agent token revocation failed', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user