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:
Andras Bacsai committed 2026-07-06 17:40:37 +02:00
1 parent df854feee8
commit 6ae45684f9
147 files changed
+22444 -10407

No files matched your search

+435 -32
View File
@@ -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.'));
}
}
/**
+53
View File
@@ -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(),
]);
}
}
+255
View File
@@ -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;
}
}
+89
View File
@@ -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();
}
}
+150
View File
@@ -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;
}
}
+65
View File
@@ -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(),
]);
}
});
}
}
+325
View File
@@ -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(),
]);
}
}
}