From 2007d802ef3dfc80132841b02a04e1c08bfdc5c8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:52:17 +0200 Subject: [PATCH] feat(v5): expose corrosion and firewall diagnostics --- app/Actions/V5/Proxy/StartCaddyIngress.php | 21 +- app/Actions/V5/Proxy/StopCaddyIngress.php | 7 +- .../Controllers/V5/DashboardController.php | 126 +++++- app/Services/Flux/FluxClient.php | 25 ++ resources/js/v5/Pages/Clusters.tsx | 415 +++++++++++++++++- resources/js/v5/Pages/Dashboard.tsx | 33 +- routes/v5.php | 2 + tests/Feature/V5/DashboardTest.php | 327 +++++++++++++- .../Unit/V5/CaddyIngressConfigurationTest.php | 17 + 9 files changed, 938 insertions(+), 35 deletions(-) diff --git a/app/Actions/V5/Proxy/StartCaddyIngress.php b/app/Actions/V5/Proxy/StartCaddyIngress.php index c5a1208ae..778ccf3c1 100644 --- a/app/Actions/V5/Proxy/StartCaddyIngress.php +++ b/app/Actions/V5/Proxy/StartCaddyIngress.php @@ -12,7 +12,7 @@ class StartCaddyIngress { use AsAction; - private const FIREWALL_RULE_ID = 'v5-caddy-ingress:80'; + private const FIREWALL_PORTS = [80, 443]; public function __construct(private readonly FluxClient $fluxClient) {} @@ -30,14 +30,17 @@ class StartCaddyIngress $configuration = GenerateCaddyIngressConfiguration::run($this->applications($server)); $output = $this->fluxClient->applyIngress($hostId, 'caddy', $configuration['caddyfile'], $this->ingressApps($configuration['apps'])); - $this->fluxClient->applyFirewallRule($hostId, [ - 'id' => self::FIREWALL_RULE_ID, - 'namespace' => 'default', - 'src' => '0.0.0.0/0', - 'dst' => 'coolify-v5-caddy', - 'proto' => 'tcp', - 'port' => 80, - ]); + + foreach (self::FIREWALL_PORTS as $port) { + $this->fluxClient->applyFirewallRule($hostId, [ + 'id' => "v5-caddy-ingress:{$port}", + 'namespace' => 'default', + 'src' => '0.0.0.0/0', + 'dst' => 'coolify-v5-caddy', + 'proto' => 'tcp', + 'port' => $port, + ]); + } if ($server->exists) { $server->update([ diff --git a/app/Actions/V5/Proxy/StopCaddyIngress.php b/app/Actions/V5/Proxy/StopCaddyIngress.php index aa1899554..2b3e27442 100644 --- a/app/Actions/V5/Proxy/StopCaddyIngress.php +++ b/app/Actions/V5/Proxy/StopCaddyIngress.php @@ -10,7 +10,7 @@ class StopCaddyIngress { use AsAction; - private const FIREWALL_RULE_ID = 'v5-caddy-ingress:80'; + private const FIREWALL_PORTS = [80, 443]; public function __construct(private readonly FluxClient $fluxClient) {} @@ -23,7 +23,10 @@ class StopCaddyIngress } $output = $this->fluxClient->stopIngress($hostId, 'caddy'); - $this->fluxClient->revokeFirewallRule($hostId, self::FIREWALL_RULE_ID); + + foreach (self::FIREWALL_PORTS as $port) { + $this->fluxClient->revokeFirewallRule($hostId, "v5-caddy-ingress:{$port}"); + } if ($server->exists) { $server->update(['ingress_status' => 'exited']); diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index 669157852..73dcc814a 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -601,6 +601,8 @@ class DashboardController extends Controller try { $this->syncConnectionFirewallRules($fluxClient, $oldFirewallRules, $newFirewallRules); } catch (\RuntimeException $exception) { + report($exception); + return response()->json([ 'message' => 'Could not sync firewall rules through Flux.', 'detail' => $exception->getMessage(), @@ -625,6 +627,8 @@ class DashboardController extends Controller try { $this->syncConnectionFirewallRules($fluxClient, $oldFirewallRules, collect()); } catch (\RuntimeException $exception) { + report($exception); + return response()->json([ 'message' => 'Could not sync firewall rules through Flux.', 'detail' => $exception->getMessage(), @@ -1010,6 +1014,86 @@ class DashboardController extends Controller ]); } + public function serverCorrosionTables(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if ( + ! $currentTeam instanceof Team + || $cluster->team_id !== $currentTeam->id + || $server->team_id !== $currentTeam->id + || $server->cluster_id !== $cluster->id + ) { + abort(404); + } + + $validated = $request->validate([ + 'limit' => ['sometimes', 'integer', 'min:1', 'max:1000'], + ]); + + $hostId = $server->wireguard_management_ip ?: $server->node_address; + + if (! is_string($hostId) || $hostId === '') { + return response()->json([ + 'message' => 'This server is missing its Flux host id.', + ], 422); + } + + try { + $output = $fluxClient->corrosionTables($hostId, (int) ($validated['limit'] ?? 200)); + } catch (\Throwable $e) { + return response()->json([ + 'message' => $e->getMessage(), + ], 502); + } + + return response()->json([ + 'output' => $output, + 'source' => 'flux', + 'fetchedAt' => now()->toJSON(), + ]); + } + + public function serverFirewallRules(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if ( + ! $currentTeam instanceof Team + || $cluster->team_id !== $currentTeam->id + || $server->team_id !== $currentTeam->id + || $server->cluster_id !== $cluster->id + ) { + abort(404); + } + + $validated = $request->validate([ + 'namespace' => ['sometimes', 'string', 'max:63'], + ]); + + $hostId = $server->wireguard_management_ip ?: $server->node_address; + + if (! is_string($hostId) || $hostId === '') { + return response()->json([ + 'message' => 'This server is missing its Flux host id.', + ], 422); + } + + try { + $rules = $fluxClient->listFirewallRules($hostId, (string) ($validated['namespace'] ?? '')); + } catch (\Throwable $e) { + return response()->json([ + 'message' => $e->getMessage(), + ], 502); + } + + return response()->json([ + 'rules' => $rules, + 'source' => 'flux', + 'fetchedAt' => now()->toJSON(), + ]); + } + public function destroyServer(Request $request, V5Cluster $cluster, V5Server $server): \Illuminate\Http\Response|JsonResponse { $currentTeam = $request->attributes->get('v5.currentTeam'); @@ -1452,18 +1536,27 @@ class DashboardController extends Controller ->keyBy('id'); return $connection->rules - ->map(function ($rule) use ($applications, $connection): ?array { + ->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 || ! $target->server instanceof V5Server) { - return null; + if ( + ! $source instanceof V5Application + || ! $target instanceof V5Application + || ! $source->server instanceof V5Server + || ! $target->server instanceof V5Server + ) { + return collect(); } - $hostId = $target->server->wireguard_management_ip ?: $target->server->node_address; + $hostIds = collect([$source->server, $target->server]) + ->map(fn (V5Server $server) => $server->wireguard_management_ip ?: $server->node_address) + ->filter(fn (mixed $hostId): bool => is_string($hostId) && $hostId !== '') + ->unique() + ->values(); - if (! is_string($hostId) || $hostId === '') { - return null; + if ($hostIds->isEmpty()) { + return collect(); } $firewallRule = [ @@ -1475,13 +1568,12 @@ class DashboardController extends Controller 'port' => (int) $rule->port, ]; - return [ + return $hostIds->map(fn (string $hostId): array => [ 'id' => $firewallRule['id'], 'hostId' => $hostId, 'rule' => $firewallRule, - ]; + ]); }) - ->filter() ->values(); } @@ -1491,18 +1583,26 @@ class DashboardController extends Controller */ private function syncConnectionFirewallRules(FluxClient $fluxClient, Collection $oldRules, Collection $newRules): void { - $newRuleIds = $newRules->pluck('id')->all(); - $oldRuleIds = $oldRules->pluck('id')->all(); + $newRuleKeys = $newRules->map(fn (array $rule): string => $this->firewallRuleSyncKey($rule))->all(); + $oldRuleKeys = $oldRules->map(fn (array $rule): string => $this->firewallRuleSyncKey($rule))->all(); $oldRules - ->reject(fn (array $oldRule): bool => in_array($oldRule['id'], $newRuleIds, true)) + ->reject(fn (array $oldRule): bool => in_array($this->firewallRuleSyncKey($oldRule), $newRuleKeys, true)) ->each(fn (array $oldRule): string => $fluxClient->revokeFirewallRule($oldRule['hostId'], $oldRule['id'])); $newRules - ->reject(fn (array $newRule): bool => in_array($newRule['id'], $oldRuleIds, true)) + ->reject(fn (array $newRule): bool => in_array($this->firewallRuleSyncKey($newRule), $oldRuleKeys, true)) ->each(fn (array $newRule): string => $fluxClient->applyFirewallRule($newRule['hostId'], $newRule['rule'])); } + /** + * @param array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}} $rule + */ + private function firewallRuleSyncKey(array $rule): string + { + return $rule['hostId'].'|'.$rule['id']; + } + private function connectionFirewallRuleId(ResourceConnection $connection, mixed $rule): string { return implode(':', [ diff --git a/app/Services/Flux/FluxClient.php b/app/Services/Flux/FluxClient.php index e2ef80ced..fa5caacd5 100644 --- a/app/Services/Flux/FluxClient.php +++ b/app/Services/Flux/FluxClient.php @@ -126,6 +126,21 @@ class FluxClient return $this->output($payload, 'Firewall rule removed.'); } + /** + * @return array + */ + public function listFirewallRules(string $hostId, string $namespace = ''): array + { + $payload = $this->dispatch($hostId, [ + 'type' => 'firewall.list', + 'namespace' => $namespace, + ]); + + $data = $payload['data'] ?? []; + + return is_array($data) ? $data : []; + } + public function cooldLogs(string $hostId, int $tail = 200): string { $payload = $this->dispatch($hostId, [ @@ -136,6 +151,16 @@ class FluxClient return $this->output($payload, 'No coold logs returned.'); } + public function corrosionTables(string $hostId, int $limit = 200): string + { + $payload = $this->dispatch($hostId, [ + 'type' => 'corrosion.tables', + 'limit' => max(1, min($limit, 1000)), + ]); + + return $this->output($payload, '{"limit":200,"tables":[]}'); + } + /** * @param array $command * @return array diff --git a/resources/js/v5/Pages/Clusters.tsx b/resources/js/v5/Pages/Clusters.tsx index 68a5af593..fa66d8b80 100644 --- a/resources/js/v5/Pages/Clusters.tsx +++ b/resources/js/v5/Pages/Clusters.tsx @@ -92,6 +92,36 @@ type CooldLogsResponse = { fetchedAt: string; }; +type CorrosionTablesResponse = { + output: string; + fetchedAt: string; +}; + +type FirewallRule = { + id?: string; + namespace?: string; + src?: string; + dst?: string; + proto?: string; + port?: number; +}; + +type FirewallRulesResponse = { + rules: FirewallRule[]; + fetchedAt: string; +}; + +type CorrosionTableDump = { + limit?: number; + tables?: CorrosionTable[]; +}; + +type CorrosionTable = { + name: string; + columns: string[]; + rows: unknown[][]; +}; + type BootstrapServerResponse = { cluster?: V5Cluster; message?: string; @@ -107,6 +137,32 @@ type V5ClusterUpdatedEvent = { cluster: V5Cluster | null; }; +function formatCorrosionCell(value: unknown): string { + if (value === null || value === undefined) { + return 'null'; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return JSON.stringify(value); +} + +function parseCorrosionTables(output: string): CorrosionTableDump | null { + if (!output.trim()) { + return null; + } + + try { + const parsed = JSON.parse(output) as CorrosionTableDump; + + return Array.isArray(parsed.tables) ? parsed : null; + } catch { + return null; + } +} + function statusLabel(status: string): string { return status .split(/[-_\s]+/) @@ -260,6 +316,18 @@ export default function Clusters({ const [cooldLogsFetchedAt, setCooldLogsFetchedAt] = useState(null); const [cooldLogsError, setCooldLogsError] = useState(null); const [isLoadingCooldLogs, setIsLoadingCooldLogs] = useState(false); + const [isCorrosionTablesDialogOpen, setIsCorrosionTablesDialogOpen] = useState(false); + const [corrosionTablesServer, setCorrosionTablesServer] = useState(null); + const [corrosionTablesOutput, setCorrosionTablesOutput] = useState(''); + const [corrosionTablesFetchedAt, setCorrosionTablesFetchedAt] = useState(null); + const [corrosionTablesError, setCorrosionTablesError] = useState(null); + const [isLoadingCorrosionTables, setIsLoadingCorrosionTables] = useState(false); + const [isFirewallRulesDialogOpen, setIsFirewallRulesDialogOpen] = useState(false); + const [firewallRulesServer, setFirewallRulesServer] = useState(null); + const [firewallRules, setFirewallRules] = useState([]); + const [firewallRulesFetchedAt, setFirewallRulesFetchedAt] = useState(null); + const [firewallRulesError, setFirewallRulesError] = useState(null); + const [isLoadingFirewallRules, setIsLoadingFirewallRules] = useState(false); const [showAdvancedConfiguration, setShowAdvancedConfiguration] = useState(false); const [showAdvancedServerConfiguration, setShowAdvancedServerConfiguration] = useState(false); @@ -657,6 +725,75 @@ export default function Clusters({ setIsLoadingCooldLogs(false); } + async function loadCorrosionTables(server: V5Server): Promise { + if (!selectedCluster) { + return; + } + + setCorrosionTablesServer(server); + setIsCorrosionTablesDialogOpen(true); + setIsLoadingCorrosionTables(true); + setCorrosionTablesError(null); + setCorrosionTablesOutput(''); + setCorrosionTablesFetchedAt(null); + + const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/corrosion-tables?limit=200`, { + method: 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + }, + }); + + const payload = (await response.json().catch(() => null)) as CorrosionTablesResponse & { message?: string } | null; + + if (!response.ok) { + setCorrosionTablesError(payload?.message ?? 'Unable to load Corrosion tables.'); + setIsLoadingCorrosionTables(false); + + return; + } + + setCorrosionTablesOutput(payload?.output ?? ''); + setCorrosionTablesFetchedAt(payload?.fetchedAt ?? null); + setIsLoadingCorrosionTables(false); + } + + + async function loadFirewallRules(server: V5Server): Promise { + if (!selectedCluster) { + return; + } + + setFirewallRulesServer(server); + setIsFirewallRulesDialogOpen(true); + setIsLoadingFirewallRules(true); + setFirewallRulesError(null); + setFirewallRules([]); + setFirewallRulesFetchedAt(null); + + const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/firewall-rules`, { + method: 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + }, + }); + + const payload = (await response.json().catch(() => null)) as FirewallRulesResponse & { message?: string } | null; + + if (!response.ok) { + setFirewallRulesError(payload?.message ?? 'Unable to load firewall rules.'); + setIsLoadingFirewallRules(false); + + return; + } + + setFirewallRules(Array.isArray(payload?.rules) ? payload.rules : []); + setFirewallRulesFetchedAt(payload?.fetchedAt ?? null); + setIsLoadingFirewallRules(false); + } + function openDeleteClusterDialog(): void { if (!selectedCluster || selectedCluster.serversCount !== 0) { setDeleteClusterError('Only empty clusters can be deleted.'); @@ -917,6 +1054,12 @@ export default function Clusters({ void loadCooldLogs(server)}> Coold logs + void loadCorrosionTables(server)}> + Corrosion tables + + void loadFirewallRules(server)}> + Firewall rules + openEditServerDialog(server)}> Edit server @@ -1193,6 +1336,53 @@ export default function Clusters({ +
+
+
+

+ Firewall rules +

+

+ Inspect defined coold allow rules persisted on each initialized server. +

+
+
+ + {initializedServers.length === 0 ? ( +
+

+ Initialize a server to inspect its firewall rules. +

+
+ ) : ( +
+ {initializedServers.map((server) => ( +
+
+

+ {server.name} +

+

+ Host ID: {server.wireguardManagementIp ?? server.nodeAddress ?? 'Not assigned'} +

+
+ +
+ ))} +
+ )} +
+

@@ -1883,13 +2073,236 @@ export default function Clusters({

) : null} -
+                                    
                                         {isLoadingCooldLogs ? 'Loading coold logs...' : cooldLogsOutput || 'No coold logs returned.'}
                                     
+ { + setIsCorrosionTablesDialogOpen(open); + + if (!open) { + setCorrosionTablesServer(null); + setCorrosionTablesOutput(''); + setCorrosionTablesFetchedAt(null); + setCorrosionTablesError(null); + } + }} + > + + + Corrosion tables + + Corrosion table snapshots for {corrosionTablesServer?.name ?? 'this server'}. + + + +
+
+

+ {corrosionTablesFetchedAt + ? `Fetched ${formatDate(corrosionTablesFetchedAt)}` + : 'First 200 rows per table'} +

+ {corrosionTablesServer ? ( + + ) : null} +
+ + {corrosionTablesError ? ( +
+ {corrosionTablesError} +
+ ) : null} + +
+ {isLoadingCorrosionTables ? ( +

Loading Corrosion tables...

+ ) : (() => { + const dump = parseCorrosionTables(corrosionTablesOutput); + + if (!dump || !dump.tables?.length) { + return ( +
+                                                        {corrosionTablesOutput || 'No Corrosion tables returned.'}
+                                                    
+ ); + } + + return ( +
+ {dump.tables.map((table) => ( +
+
+

{table.name}

+ + {table.rows.length} row{table.rows.length === 1 ? '' : 's'} + +
+
+ + + + {table.columns.map((column) => ( + + ))} + + + + {table.rows.length > 0 ? ( + table.rows.map((row, rowIndex) => ( + + {table.columns.map((column, columnIndex) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {column} +
+ {formatCorrosionCell(row[columnIndex])} +
+ No rows +
+
+
+ ))} +
+ ); + })()} +
+
+
+
+ + { + setIsFirewallRulesDialogOpen(open); + + if (!open) { + setFirewallRulesServer(null); + setFirewallRules([]); + setFirewallRulesFetchedAt(null); + setFirewallRulesError(null); + } + }} + > + + + Firewall rules + + Defined coold allow rules for {firewallRulesServer?.name ?? 'this server'}. + + + +
+
+

+ {firewallRulesFetchedAt + ? `Fetched ${formatDate(firewallRulesFetchedAt)}` + : 'Rules currently persisted by coold'} +

+ {firewallRulesServer ? ( + + ) : null} +
+ + {firewallRulesError ? ( +
+ {firewallRulesError} +
+ ) : null} + +
+ + + + + + + + + + + + + {isLoadingFirewallRules ? ( + + + + ) : firewallRules.length > 0 ? ( + firewallRules.map((rule, index) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
Rule IDNamespaceSourceDestinationProtocolPort
+ Loading firewall rules... +
+ {rule.id ?? '—'} + + {rule.namespace ?? '—'} + + {rule.src ?? '—'} + + {rule.dst ?? '—'} + + {rule.proto ?? '—'} + + {rule.port ?? '—'} +
+ No firewall rules defined. +
+
+
+
+
+ { diff --git a/resources/js/v5/Pages/Dashboard.tsx b/resources/js/v5/Pages/Dashboard.tsx index 41ff84983..e33e8c7e6 100644 --- a/resources/js/v5/Pages/Dashboard.tsx +++ b/resources/js/v5/Pages/Dashboard.tsx @@ -100,7 +100,7 @@ type PointerState = }; const APPLICATION_CARD_WIDTH = 320; -const APPLICATION_CARD_HEIGHT = 136; +const APPLICATION_CARD_HEIGHT = 160; const CANVAS_CARD_GAP = 16; const CONNECTOR_SIDES: ConnectorSide[] = ['top', 'right', 'bottom', 'left']; const MIN_CANVAS_ZOOM = 0.5; @@ -391,6 +391,14 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection }; } + function responseErrorMessage(payload: { message?: string; detail?: string }, fallback: string): string { + if (payload.message && payload.detail) { + return `${payload.message} ${payload.detail}`; + } + + return payload.message ?? fallback; + } + async function persistNewConnection(fromApplicationId: string, toApplicationId: string): Promise { setNotice(null); @@ -409,10 +417,10 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection resource_two: { type: 'application', id: Number(toApplicationId) }, }), }); - const payload = (await response.json()) as { connection?: V5ResourceConnection; message?: string }; + const payload = (await response.json()) as { connection?: V5ResourceConnection; message?: string; detail?: string }; if (!response.ok || !payload.connection) { - setNotice(payload.message ?? 'Could not save resource connection.'); + setNotice(responseErrorMessage(payload, 'Could not save resource connection.')); return; } @@ -450,10 +458,10 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection }, body: JSON.stringify({ ports_by_direction: portsByDirection }), }); - const payload = (await response.json()) as { connection?: V5ResourceConnection; message?: string }; + const payload = (await response.json()) as { connection?: V5ResourceConnection; message?: string; detail?: string }; if (!response.ok || !payload.connection) { - setNotice(payload.message ?? 'Could not save allowed ports.'); + setNotice(responseErrorMessage(payload, 'Could not save allowed ports.')); return; } @@ -1364,8 +1372,17 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection {notice && ( -
- {notice} +
+ {notice} +
)} @@ -1656,7 +1673,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection key={application.id} data-application-card="application-card" data-application-id={application.id} - className="group/application absolute min-h-[8.5rem] w-80 select-none overflow-visible rounded-xl border border-border bg-card p-4 shadow-xl transition-shadow hover:shadow-2xl" + className="group/application absolute h-40 w-80 select-none overflow-visible rounded-xl border border-border bg-card p-4 shadow-xl transition-shadow hover:shadow-2xl" style={{ transform: `translate3d(${application.canvasX}px, ${application.canvasY}px, 0)`, }} diff --git a/routes/v5.php b/routes/v5.php index d7b590c3a..1e4ffa7f2 100644 --- a/routes/v5.php +++ b/routes/v5.php @@ -25,6 +25,8 @@ Route::middleware('v5.authenticated')->group(function () { Route::patch('/clusters/{cluster}/servers/{server}', [DashboardController::class, 'updateServer'])->name('clusters.servers.update'); Route::post('/clusters/{cluster}/servers/{server}/check', [DashboardController::class, 'checkServer'])->name('clusters.servers.check'); Route::get('/clusters/{cluster}/servers/{server}/coold-logs', [DashboardController::class, 'serverCooldLogs'])->name('clusters.servers.coold-logs'); + Route::get('/clusters/{cluster}/servers/{server}/corrosion-tables', [DashboardController::class, 'serverCorrosionTables'])->name('clusters.servers.corrosion-tables'); + Route::get('/clusters/{cluster}/servers/{server}/firewall-rules', [DashboardController::class, 'serverFirewallRules'])->name('clusters.servers.firewall-rules'); Route::post('/clusters/{cluster}/servers/{server}/bootstrap', [DashboardController::class, 'bootstrapServer'])->name('clusters.servers.bootstrap'); Route::delete('/clusters/{cluster}/servers/{server}', [DashboardController::class, 'destroyServer'])->name('clusters.servers.destroy'); }); diff --git a/tests/Feature/V5/DashboardTest.php b/tests/Feature/V5/DashboardTest.php index c0aa71485..26bae7947 100644 --- a/tests/Feature/V5/DashboardTest.php +++ b/tests/Feature/V5/DashboardTest.php @@ -29,6 +29,7 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Exceptions; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Route; @@ -65,6 +66,7 @@ it('registers the v5 dashboard route', function () { ->and(Route::has('v5.clusters.servers.update'))->toBeTrue() ->and(Route::has('v5.clusters.servers.check'))->toBeTrue() ->and(Route::has('v5.clusters.servers.coold-logs'))->toBeTrue() + ->and(Route::has('v5.clusters.servers.corrosion-tables'))->toBeTrue() ->and(Route::has('v5.clusters.servers.bootstrap'))->toBeTrue() ->and(Route::has('v5.clusters.servers.destroy'))->toBeTrue() ->and(Route::has('v5.applications.nginx'))->toBeTrue() @@ -122,6 +124,15 @@ it('does not render v5 application status messages on dashboard cards', function ->not->toContain('{application.statusMessage}

'); }); +it('lets users dismiss v5 dashboard notices', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain('aria-label="Dismiss notice"') + ->toContain('onClick={() => setNotice(null)}') + ->toContain('Dismiss notice'); +}); + it('uses the shared dialog and button components for the application ingress modal', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); @@ -324,6 +335,14 @@ it('shows v5 application connector dots after selecting a canvas card', function ->toContain('opacity-100'); }); +it('keeps side connector geometry aligned with the rendered v5 application card height', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain('const APPLICATION_CARD_HEIGHT = 160;') + ->toContain('h-40 w-80'); +}); + it('shows a loading state on v5 application delete buttons', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); @@ -991,6 +1010,200 @@ it('persists generic v5 resource connections and direction-specific ports', func ->assertSee("\"{$target->id}->{$source->id}\":[\"443\"]", false); }); +it('reports flux failures when syncing v5 resource connection firewall rules', function () { + app()->detectEnvironment(fn () => 'local'); + + $this->withoutVite(); + createSharedUserAndTeamTables(); + Exceptions::fake(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $cluster = Cluster::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'Production Mesh', + ]); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'wireguard_management_ip' => '100.64.0.10', + 'capabilities' => [], + ]); + $source = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'api', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-api', + 'mesh_namespace' => 'default', + 'status' => 'running', + ]); + $target = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'postgres', + 'image' => 'docker.io/library/postgres:16', + 'container_name' => 'coolify-v5-postgres', + 'mesh_namespace' => 'default', + 'status' => 'running', + ]); + + $connection = ResourceConnection::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'resource_one_type' => $source->getMorphClass(), + 'resource_one_id' => $source->id, + 'resource_two_type' => $target->getMorphClass(), + 'resource_two_id' => $target->id, + 'resource_pair_key' => "application:{$source->id}|application:{$target->id}", + 'created_by_user_id' => $user->id, + ]); + + $fluxClient = Mockery::mock(FluxClient::class); + $fluxClient + ->shouldReceive('applyFirewallRule') + ->once() + ->andThrow(new RuntimeException('resolve firewall endpoint coolify-v5-api on coolify-default-mesh')); + app()->instance(FluxClient::class, $fluxClient); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token']) + ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') + ->patchJson("/v5/resource-connections/{$connection->id}", [ + 'ports_by_direction' => [ + "{$source->id}->{$target->id}" => [5432], + ], + ]) + ->assertStatus(502) + ->assertJsonPath('detail', 'resolve firewall endpoint coolify-v5-api on coolify-default-mesh'); + + Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'resolve firewall endpoint coolify-v5-api on coolify-default-mesh'); +}); + +it('syncs cross-server v5 resource connection ports on both endpoint hosts', function () { + app()->detectEnvironment(fn () => 'local'); + + $this->withoutVite(); + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $cluster = Cluster::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'Production Mesh', + ]); + $sourceServer = V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'wireguard_management_ip' => '100.64.0.10', + 'capabilities' => [], + ]); + $targetServer = V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-02', + 'host' => '203.0.113.11', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'wireguard_management_ip' => '100.64.0.11', + 'capabilities' => [], + ]); + $source = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $sourceServer->id, + 'created_by_user_id' => $user->id, + 'name' => 'api', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-api', + 'mesh_namespace' => 'default', + 'status' => 'running', + ]); + $target = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $targetServer->id, + 'created_by_user_id' => $user->id, + 'name' => 'postgres', + 'image' => 'docker.io/library/postgres:16', + 'container_name' => 'coolify-v5-postgres', + 'mesh_namespace' => 'default', + 'status' => 'running', + ]); + + $connection = ResourceConnection::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'resource_one_type' => $source->getMorphClass(), + 'resource_one_id' => $source->id, + 'resource_two_type' => $target->getMorphClass(), + 'resource_two_id' => $target->id, + 'resource_pair_key' => "application:{$source->id}|application:{$target->id}", + 'created_by_user_id' => $user->id, + ]); + + $firewallRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432"; + $expectedRule = [ + 'id' => $firewallRuleId, + 'namespace' => 'default', + 'src' => 'coolify-v5-api', + 'dst' => 'coolify-v5-postgres', + 'proto' => 'tcp', + 'port' => 5432, + ]; + + $fluxClient = Mockery::mock(FluxClient::class); + $fluxClient + ->shouldReceive('applyFirewallRule') + ->once() + ->with('100.64.0.10', $expectedRule) + ->andReturn('Firewall rule applied on source host.'); + $fluxClient + ->shouldReceive('applyFirewallRule') + ->once() + ->with('100.64.0.11', $expectedRule) + ->andReturn('Firewall rule applied on target host.'); + app()->instance(FluxClient::class, $fluxClient); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token']) + ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') + ->patchJson("/v5/resource-connections/{$connection->id}", [ + 'ports_by_direction' => [ + "{$source->id}->{$target->id}" => [5432], + ], + ]) + ->assertSuccessful(); +}); + it('syncs v5 resource connection ports through flux firewall primitives', function () { app()->detectEnvironment(fn () => 'local'); @@ -2382,13 +2595,111 @@ it('fetches v5 server coold logs through flux', function () { ->assertJsonStructure(['output', 'fetchedAt']); }); -it('renders a coold logs action in the v5 server menu', function () { +it('fetches v5 server corrosion tables through flux', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $cluster = Cluster::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'Production Mesh', + 'description' => null, + ]); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'name' => 'prod-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'builder_enabled' => false, + 'builder_capacity' => 0, + 'wireguard_management_ip' => '100.64.0.10', + 'node_address' => '203.0.113.10', + ]); + + $this->mock(FluxClient::class, function (MockInterface $mock): void { + $mock->shouldReceive('corrosionTables') + ->once() + ->with('100.64.0.10', 200) + ->andReturn('{"limit":200,"tables":[{"name":"service_endpoints","columns":["container_name"],"rows":[["coolify-v5-nginx"]]}]}'); + }); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->getJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/corrosion-tables?limit=200") + ->assertSuccessful() + ->assertJsonPath('output', '{"limit":200,"tables":[{"name":"service_endpoints","columns":["container_name"],"rows":[["coolify-v5-nginx"]]}]}') + ->assertJsonStructure(['output', 'fetchedAt']); +}); + +it('fetches v5 server firewall rules through flux', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $cluster = Cluster::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'Production Mesh', + 'description' => null, + ]); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'name' => 'prod-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'builder_enabled' => false, + 'builder_capacity' => 0, + 'wireguard_management_ip' => '100.64.0.10', + 'node_address' => '203.0.113.10', + ]); + + $this->mock(FluxClient::class, function (MockInterface $mock): void { + $mock->shouldReceive('listFirewallRules') + ->once() + ->with('100.64.0.10', '') + ->andReturn([[ + 'id' => 'v5-resource-connection:1:1:2:tcp:5432', + 'namespace' => 'default', + 'src' => 'coolify-v5-api', + 'dst' => 'coolify-v5-postgres', + 'proto' => 'tcp', + 'port' => 5432, + ]]); + }); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->getJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/firewall-rules") + ->assertSuccessful() + ->assertJsonPath('rules.0.id', 'v5-resource-connection:1:1:2:tcp:5432') + ->assertJsonPath('rules.0.port', 5432) + ->assertJsonStructure(['rules', 'fetchedAt']); +}); + +it('renders coold diagnostics actions in the v5 server menu', function () { $clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx')); expect($clustersPage) ->toContain('Coold logs') + ->toContain('Corrosion tables') + ->toContain('Firewall rules') ->toContain('/coold-logs?tail=200') - ->toContain('Latest journalctl entries'); + ->toContain('/corrosion-tables?limit=200') + ->toContain('/firewall-rules') + ->toContain('Latest journalctl entries') + ->toContain('Corrosion table snapshots') + ->toContain('Defined coold allow rules') + ->toContain('overflow-y-auto overflow-x-hidden') + ->toContain('whitespace-pre-wrap wrap-anywhere'); }); it('renders v5 server status on cluster server cards', function () { @@ -5297,6 +5608,18 @@ function expectCaddyIngressFirewallRule(mixed $fluxClient): void 'port' => 80, ]) ->andReturn('Firewall rule applied.'); + $fluxClient + ->shouldReceive('applyFirewallRule') + ->once() + ->with('100.64.0.10', [ + 'id' => 'v5-caddy-ingress:443', + 'namespace' => 'default', + 'src' => '0.0.0.0/0', + 'dst' => 'coolify-v5-caddy', + 'proto' => 'tcp', + 'port' => 443, + ]) + ->andReturn('Firewall rule applied.'); } function createSharedUserAndTeamTables(): void diff --git a/tests/Unit/V5/CaddyIngressConfigurationTest.php b/tests/Unit/V5/CaddyIngressConfigurationTest.php index ce407a55a..09ef7a746 100644 --- a/tests/Unit/V5/CaddyIngressConfigurationTest.php +++ b/tests/Unit/V5/CaddyIngressConfigurationTest.php @@ -131,6 +131,18 @@ it('applies caddy ingress configuration through flux instead of ssh', function ( 'port' => 80, ]) ->andReturn('Firewall rule applied.'); + $fluxClient + ->shouldReceive('applyFirewallRule') + ->once() + ->with('100.64.0.10', [ + 'id' => 'v5-caddy-ingress:443', + 'namespace' => 'default', + 'src' => '0.0.0.0/0', + 'dst' => 'coolify-v5-caddy', + 'proto' => 'tcp', + 'port' => 443, + ]) + ->andReturn('Firewall rule applied.'); app()->instance(FluxClient::class, $fluxClient); $result = StartCaddyIngress::run($server); @@ -166,6 +178,11 @@ it('stops caddy ingress through flux instead of ssh', function () { ->once() ->with('100.64.0.10', 'v5-caddy-ingress:80') ->andReturn('Firewall rule removed.'); + $fluxClient + ->shouldReceive('revokeFirewallRule') + ->once() + ->with('100.64.0.10', 'v5-caddy-ingress:443') + ->andReturn('Firewall rule removed.'); app()->instance(FluxClient::class, $fluxClient); $result = StopCaddyIngress::run($server);