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

@@ -0,0 +1,87 @@
<?php
namespace App\Support\V5;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Server as V5Server;
/**
* Single source of truth for the canvas resource payloads served by the
* dashboard Inertia props and broadcast by V5CanvasResourceUpdated — the two
* must stay identical for websocket vs. initial-load parity.
*/
class CanvasResourceSerializer
{
public const CARD_WIDTH = 320;
public const CARD_HEIGHT = 144;
public const CARD_GAP = 32;
/**
* @return array<string, mixed>
*/
public function serializeApplication(V5Application $application): array
{
$application->loadMissing(['server', 'domains', 'project', 'environment']);
$server = $application->server;
$isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server);
return [
'id' => $application->uuid,
'name' => $application->name,
'image' => $application->image,
'containerName' => $application->container_name,
'status' => $application->status,
'statusMessage' => $application->status_message,
'effectiveStatus' => $isServerReachable ? $application->status : 'unknown',
'effectiveStatusMessage' => $isServerReachable
? $application->status_message
: $this->serverStatusMessage($server),
'runtimeContainerId' => $application->runtime_container_id,
'serverName' => $server?->name,
'serverStatus' => $server?->status,
'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null,
'isServerReachable' => $isServerReachable,
'serverIngressEnabled' => (bool) $server?->isIngress(),
'meshNamespace' => $application->mesh_namespace,
'ingressEnabled' => $application->ingress_enabled,
'internalPort' => $application->internal_port,
'domains' => $application->domains->pluck('domain')->values()->all(),
'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal',
'projectUuid' => $application->project?->uuid,
'environmentUuid' => $application->environment?->uuid,
'canvasX' => $application->canvas_x,
'canvasY' => $application->canvas_y,
];
}
/**
* @return array<string, mixed>
*/
public function serializeCaddyIngress(V5Server $server, int $index = 0): array
{
$isServerReachable = $this->isServerReachable($server);
return [
'id' => $server->uuid,
'name' => $server->name,
'host' => $server->host,
'type' => $server->ingressType(),
'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable',
'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server),
'canvasX' => $server->canvas_x ?? -(self::CARD_WIDTH + self::CARD_GAP),
'canvasY' => $server->canvas_y ?? $index * (self::CARD_HEIGHT + self::CARD_GAP),
];
}
private function isServerReachable(V5Server $server): bool
{
return $server->status !== 'unreachable';
}
private function serverStatusMessage(?V5Server $server): ?string
{
return $server?->last_status_output ?: null;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Support\V5;
use App\Models\V5\Cluster as V5Cluster;
use App\Models\V5\Server as V5Server;
/**
* Single source of truth for the cluster payload served by the Clusters
* Inertia props and broadcast by V5ClusterUpdated — the two must stay
* identical for websocket vs. initial-load parity.
*/
class ClusterSerializer
{
/**
* @return array<string, mixed>
*/
public function serialize(V5Cluster $cluster): array
{
return [
'id' => $cluster->uuid,
'name' => $cluster->name,
'description' => $cluster->description,
'wireguardInterface' => $cluster->wireguard_interface,
'wireguardManagementPool' => $cluster->wireguard_management_pool,
'wireguardListenPort' => $cluster->wireguard_listen_port,
'containerNetworkPool' => $cluster->container_network_pool,
'containerNetworkPrefix' => $cluster->container_network_prefix,
'namespaces' => $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES,
'defaultDenyContainers' => $cluster->default_deny_containers,
'cooldVersion' => $cluster->coold_version,
'corrosionVersion' => $cluster->corrosion_version,
'corrosionGossipPort' => $cluster->corrosion_gossip_port,
'corrosionApiPort' => $cluster->corrosion_api_port,
'builderEnabled' => $cluster->builder_enabled,
'builderCapacity' => $cluster->builder_capacity,
'builderCpuQuota' => $cluster->builder_cpu_quota,
'builderMemoryMax' => $cluster->builder_memory_max,
'builderTimeoutSecs' => $cluster->builder_timeout_secs,
'lastCliAction' => $cluster->last_cli_action,
'lastCliStatus' => $cluster->last_cli_status,
'lastCliSummary' => $cluster->last_cli_summary,
'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(),
'serversCount' => $cluster->servers_count ?? $cluster->servers->count(),
'servers' => $cluster->servers->map(fn (V5Server $server) => [
'id' => $server->uuid,
'name' => $server->name,
'host' => $server->host,
'status' => $server->status,
'capabilities' => $server->capabilities ?? [],
'builderEnabled' => $server->builder_enabled,
'builderCapacity' => $server->builder_capacity,
'builderCpuQuota' => $server->builder_cpu_quota,
'ingressEnabled' => $server->isIngress(),
'ingressType' => $server->ingress_type,
'uuid' => $server->uuid,
'nodeAddress' => $server->node_address,
'wireguardListenPortOverride' => $server->wireguard_listen_port_override,
'wireguardEndpointOverride' => $server->wireguard_endpoint_override,
'wireguardManagementIp' => $server->wireguard_management_ip,
'wireguardPublicKey' => $server->wireguard_public_key,
'containerSubnets' => $server->container_subnets ?? [],
'privateKeyName' => $server->privateKey?->name,
'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(),
'lastBootstrapAction' => $server->last_bootstrap_action,
'lastBootstrapStatus' => $server->last_bootstrap_status,
'lastBootstrapOutput' => $server->last_bootstrap_output,
'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(),
'lastStatusOutput' => $server->last_status_output,
'lastStatusCheckedAt' => $server->last_status_checked_at?->toJSON(),
])->all(),
];
}
/**
* Reload servers (with keys) and counts before serializing so the payload
* always reflects the latest database state.
*
* @return array<string, mixed>
*/
public function serializeFresh(V5Cluster $cluster): array
{
$cluster->load(['servers' => fn ($query) => $query
->with('privateKey')
->orderBy('name')]);
$cluster->loadCount('servers');
return $this->serialize($cluster);
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Support\V5;
use App\Exceptions\V5\UnsupportedCooldVerb;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Single source of truth for deriving node firewall rules from a resource
* connection's DB rules and converging them through Flux. Reusable from
* controllers, jobs, and events alike; deterministic rule ids keep repeated
* syncs and compensating rollbacks idempotent.
*/
class ConnectionFirewallSync
{
/**
* @return Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}>
*/
public function rulesFor(ResourceConnection $connection): Collection
{
$applicationIds = $connection->rules
->flatMap(fn ($rule) => [$rule->source_resource_id, $rule->target_resource_id])
->unique()
->values();
$applications = V5Application::query()
->whereIn('id', $applicationIds)
->with('server')
->get()
->keyBy('id');
return $connection->rules
->flatMap(function ($rule) use ($applications, $connection): Collection {
$source = $applications->get($rule->source_resource_id);
$target = $applications->get($rule->target_resource_id);
if (! $source instanceof V5Application || ! $target instanceof V5Application) {
return collect();
}
$missingHost = collect([$source, $target])
->first(function (V5Application $application): bool {
$hostId = $application->server?->fluxHostId();
return ! is_string($hostId) || $hostId === '';
});
if ($missingHost instanceof V5Application) {
throw new \RuntimeException("Application {$missingHost->name} has no reachable server host id, so its firewall rules cannot be synced.");
}
$hostIds = collect([$source->server, $target->server])
->map(fn (V5Server $server) => $server->fluxHostId())
->unique()
->values();
$firewallRule = [
'id' => $this->ruleId($connection, $rule),
'namespace' => $target->mesh_namespace ?: 'default',
'src' => $source->container_name,
'dst' => $target->container_name,
'proto' => $rule->protocol ?: 'tcp',
'port' => (int) $rule->port,
];
return $hostIds->map(fn (string $hostId): array => [
'id' => $firewallRule['id'],
'hostId' => $hostId,
'rule' => $firewallRule,
]);
})
->values();
}
/**
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $oldRules
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $newRules
*/
public function sync(FluxClient $fluxClient, Collection $oldRules, Collection $newRules): void
{
$newRuleKeys = $newRules->map(fn (array $rule): string => $this->syncKey($rule))->all();
$oldRuleKeys = $oldRules->map(fn (array $rule): string => $this->syncKey($rule))->all();
$oldRules
->reject(fn (array $oldRule): bool => in_array($this->syncKey($oldRule), $newRuleKeys, true))
->each(fn (array $oldRule): ?string => $this->revokeRuleIfPresent($fluxClient, $oldRule['hostId'], $oldRule['id']));
$newRules
->reject(fn (array $newRule): bool => in_array($this->syncKey($newRule), $oldRuleKeys, true))
->each(function (array $newRule) use ($fluxClient): void {
try {
$fluxClient->applyFirewallRule($newRule['hostId'], $newRule['rule']);
} catch (UnsupportedCooldVerb $exception) {
Log::warning('V5 resource connection firewall rule skipped: coold verb unsupported', [
'host_id' => $newRule['hostId'],
'rule_id' => $newRule['id'],
'verb' => $exception->verb,
'message' => $exception->getMessage(),
]);
}
});
}
public function revokeRuleIfPresent(FluxClient $fluxClient, string $hostId, string $ruleId): ?string
{
try {
return $fluxClient->revokeFirewallRule($hostId, $ruleId);
} catch (UnsupportedCooldVerb $exception) {
Log::warning('V5 resource connection firewall revoke skipped: coold verb unsupported', [
'host_id' => $hostId,
'rule_id' => $ruleId,
'verb' => $exception->verb,
'message' => $exception->getMessage(),
]);
return null;
} catch (\RuntimeException $exception) {
if (str_contains(Str::lower($exception->getMessage()), 'not found')) {
return null;
}
throw $exception;
}
}
/**
* Deterministic node-side rule id derived only from the connection id and
* the rule's stable attributes — never from the rule row's primary key —
* so rewritten or restored DB rows resolve to the same firewall rule ids
* and compensating re-syncs stay idempotent.
*/
public function ruleId(ResourceConnection $connection, mixed $rule): string
{
return implode(':', [
'v5-resource-connection',
$connection->id,
$rule->source_resource_id,
$rule->target_resource_id,
$rule->protocol ?: 'tcp',
(int) $rule->port,
]);
}
/**
* @param array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}} $rule
*/
private function syncKey(array $rule): string
{
return $rule['hostId'].'|'.$rule['id'];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Support\V5;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection;
use Illuminate\Support\Collection;
/**
* Single source of truth for the resource connection payloads served by the
* dashboard Inertia props and the connection endpoints — the wire format is
* consumed by resources/js/v5/types.ts and must stay stable.
*/
class ResourceConnectionSerializer
{
/**
* @return array<string, mixed>
*/
public function serialize(ResourceConnection $connection): array
{
$applications = $this->applicationsById($connection);
$resourceOneUuid = $applications->get($connection->resource_one_id)?->uuid;
$resourceTwoUuid = $applications->get($connection->resource_two_id)?->uuid;
$applicationsById = $applications;
return [
'id' => $connection->uuid,
'applicationIds' => array_values(array_filter([
$resourceOneUuid,
$resourceTwoUuid,
])),
'fromApplicationId' => $resourceOneUuid,
'toApplicationId' => $resourceTwoUuid,
'portsByDirection' => $connection->rules
->groupBy(function ($rule) use ($applicationsById): string {
$sourceUuid = $applicationsById->get($rule->source_resource_id)?->uuid;
$targetUuid = $applicationsById->get($rule->target_resource_id)?->uuid;
return "{$sourceUuid}->{$targetUuid}";
})
->filter(fn (Collection $rules, string $direction): bool => ! str_starts_with($direction, '->') && ! str_ends_with($direction, '->'))
->map(fn (Collection $rules) => $rules
->sortBy('port')
->pluck('port')
->map(fn ($port) => (string) $port)
->values()
->all())
->all(),
];
}
/**
* @return Collection<string, V5Application>
*/
public function applicationsByUuid(ResourceConnection $connection): Collection
{
return $this->applicationsById($connection)->keyBy('uuid');
}
/**
* @return Collection<int, V5Application>
*/
public function applicationsById(ResourceConnection $connection): Collection
{
return V5Application::query()
->whereIn('id', [
(int) $connection->resource_one_id,
(int) $connection->resource_two_id,
])
->get()
->keyBy('id');
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Support\V5;
use App\Enums\V5\ApplicationStatus;
use App\Enums\V5\ContainerState;
use App\Enums\V5\IngressStatus;
use App\Enums\V5\ServerStatus;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Log;
/**
* Shared status-observation watermarking and enum normalization used by every
* v5 status write path (the flux webhook, the reconcile job, and the manual
* refresh endpoint) so out-of-order updates are dropped and raw coold states
* are normalized identically everywhere.
*/
class StatusObservation
{
/**
* A write whose observation timestamp is older than the one already
* persisted is stale (delivered or computed out of order) and must not
* clobber the newer state.
*
* @param array<string, mixed> $logContext
*/
public static function isStale(?CarbonInterface $observedAt, ?CarbonInterface $currentObservedAt, string $context, array $logContext): bool
{
if ($observedAt === null || $currentObservedAt === null || ! $observedAt->lt($currentObservedAt)) {
return false;
}
Log::debug("Dropping stale flux {$context} update.", [
...$logContext,
'observed_at' => $observedAt->toIso8601String(),
'current_status_observed_at' => $currentObservedAt->toIso8601String(),
]);
return true;
}
/**
* Map a raw status string onto the given status enum. Unknown values are
* never written to the database: they fall back to the enum's Unknown case
* and are logged. Returns null only when no raw value is supplied.
*
* @param class-string<ApplicationStatus|ContainerState|IngressStatus|ServerStatus> $enumClass
*/
public static function normalize(?string $raw, string $enumClass): ?string
{
if ($raw === null || $raw === '') {
return null;
}
$status = $enumClass::tryFrom(strtolower($raw));
if ($status === null) {
Log::warning('Received unknown flux resource status; falling back to unknown.', [
'raw_status' => $raw,
'status_enum' => $enumClass,
]);
return $enumClass::Unknown->value;
}
return $status->value;
}
}