feat(v5): expose corrosion and firewall diagnostics

This commit is contained in:
Andras Bacsai
2026-06-22 13:52:17 +02:00
parent 4cdd45070d
commit 2007d802ef
9 changed files with 938 additions and 35 deletions
+12 -9
View File
@@ -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([
+5 -2
View File
@@ -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']);
+113 -13
View File
@@ -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(':', [
+25
View File
@@ -126,6 +126,21 @@ class FluxClient
return $this->output($payload, 'Firewall rule removed.');
}
/**
* @return array<int, array{id?: string, namespace?: string, src?: string, dst?: string, proto?: string, port?: int}>
*/
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<string, mixed> $command
* @return array<string, mixed>
+414 -1
View File
@@ -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<string | null>(null);
const [cooldLogsError, setCooldLogsError] = useState<string | null>(null);
const [isLoadingCooldLogs, setIsLoadingCooldLogs] = useState(false);
const [isCorrosionTablesDialogOpen, setIsCorrosionTablesDialogOpen] = useState(false);
const [corrosionTablesServer, setCorrosionTablesServer] = useState<V5Server | null>(null);
const [corrosionTablesOutput, setCorrosionTablesOutput] = useState('');
const [corrosionTablesFetchedAt, setCorrosionTablesFetchedAt] = useState<string | null>(null);
const [corrosionTablesError, setCorrosionTablesError] = useState<string | null>(null);
const [isLoadingCorrosionTables, setIsLoadingCorrosionTables] = useState(false);
const [isFirewallRulesDialogOpen, setIsFirewallRulesDialogOpen] = useState(false);
const [firewallRulesServer, setFirewallRulesServer] = useState<V5Server | null>(null);
const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([]);
const [firewallRulesFetchedAt, setFirewallRulesFetchedAt] = useState<string | null>(null);
const [firewallRulesError, setFirewallRulesError] = useState<string | null>(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<void> {
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<void> {
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({
<DropdownMenuItem onClick={() => void loadCooldLogs(server)}>
Coold logs
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void loadCorrosionTables(server)}>
Corrosion tables
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void loadFirewallRules(server)}>
Firewall rules
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEditServerDialog(server)}>
Edit server
</DropdownMenuItem>
@@ -1193,6 +1336,53 @@ export default function Clusters({
</div>
</div>
<section className="mb-5 rounded-lg border border-border bg-background p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-base font-semibold text-foreground">
Firewall rules
</h3>
<p className="text-sm text-muted-foreground">
Inspect defined coold allow rules persisted on each initialized server.
</p>
</div>
</div>
{initializedServers.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-border p-6 text-center">
<p className="text-sm text-muted-foreground">
Initialize a server to inspect its firewall rules.
</p>
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-3 xl:grid-cols-2">
{initializedServers.map((server) => (
<div
key={`firewall-rules-${server.id}`}
className="flex items-center justify-between gap-3 rounded-md border border-border bg-card p-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{server.name}
</p>
<p className="truncate text-xs text-muted-foreground">
Host ID: {server.wireguardManagementIp ?? server.nodeAddress ?? 'Not assigned'}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void loadFirewallRules(server)}
>
View rules
</Button>
</div>
))}
</div>
)}
</section>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-base font-semibold text-foreground">
@@ -1883,13 +2073,236 @@ export default function Clusters({
</div>
) : null}
<pre className="max-h-[32rem] overflow-auto rounded-lg border border-border bg-black p-4 font-mono text-xs leading-relaxed text-white">
<pre className="max-h-[32rem] max-w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap wrap-anywhere rounded-lg border border-border bg-black p-4 font-mono text-xs leading-relaxed text-white">
{isLoadingCooldLogs ? 'Loading coold logs...' : cooldLogsOutput || 'No coold logs returned.'}
</pre>
</div>
</DialogContent>
</Dialog>
<Dialog
open={isCorrosionTablesDialogOpen}
onOpenChange={(open) => {
setIsCorrosionTablesDialogOpen(open);
if (!open) {
setCorrosionTablesServer(null);
setCorrosionTablesOutput('');
setCorrosionTablesFetchedAt(null);
setCorrosionTablesError(null);
}
}}
>
<DialogContent className="max-w-6xl">
<DialogHeader>
<DialogTitle>Corrosion tables</DialogTitle>
<DialogDescription>
Corrosion table snapshots for {corrosionTablesServer?.name ?? 'this server'}.
</DialogDescription>
</DialogHeader>
<div className="mt-5 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{corrosionTablesFetchedAt
? `Fetched ${formatDate(corrosionTablesFetchedAt)}`
: 'First 200 rows per table'}
</p>
{corrosionTablesServer ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isLoadingCorrosionTables}
onClick={() => void loadCorrosionTables(corrosionTablesServer)}
>
{isLoadingCorrosionTables ? 'Loading...' : 'Refresh'}
</Button>
) : null}
</div>
{corrosionTablesError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{corrosionTablesError}
</div>
) : null}
<div className="max-h-[32rem] overflow-auto rounded-lg border border-border bg-card p-4">
{isLoadingCorrosionTables ? (
<p className="text-sm text-muted-foreground">Loading Corrosion tables...</p>
) : (() => {
const dump = parseCorrosionTables(corrosionTablesOutput);
if (!dump || !dump.tables?.length) {
return (
<pre className="font-mono text-xs leading-relaxed text-muted-foreground">
{corrosionTablesOutput || 'No Corrosion tables returned.'}
</pre>
);
}
return (
<div className="flex flex-col gap-6">
{dump.tables.map((table) => (
<section key={table.name} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">{table.name}</h3>
<span className="text-xs text-muted-foreground">
{table.rows.length} row{table.rows.length === 1 ? '' : 's'}
</span>
</div>
<div className="overflow-auto rounded-md border border-border">
<table className="min-w-full divide-y divide-border text-left text-xs">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
{table.columns.map((column) => (
<th key={column} className="whitespace-nowrap px-3 py-2 font-medium">
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{table.rows.length > 0 ? (
table.rows.map((row, rowIndex) => (
<tr key={`${table.name}-${rowIndex}`}>
{table.columns.map((column, columnIndex) => (
<td
key={`${table.name}-${rowIndex}-${column}`}
className="max-w-80 truncate px-3 py-2 font-mono text-muted-foreground"
title={formatCorrosionCell(row[columnIndex])}
>
{formatCorrosionCell(row[columnIndex])}
</td>
))}
</tr>
))
) : (
<tr>
<td
colSpan={Math.max(table.columns.length, 1)}
className="px-3 py-4 text-center text-muted-foreground"
>
No rows
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
))}
</div>
);
})()}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={isFirewallRulesDialogOpen}
onOpenChange={(open) => {
setIsFirewallRulesDialogOpen(open);
if (!open) {
setFirewallRulesServer(null);
setFirewallRules([]);
setFirewallRulesFetchedAt(null);
setFirewallRulesError(null);
}
}}
>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>Firewall rules</DialogTitle>
<DialogDescription>
Defined coold allow rules for {firewallRulesServer?.name ?? 'this server'}.
</DialogDescription>
</DialogHeader>
<div className="mt-5 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{firewallRulesFetchedAt
? `Fetched ${formatDate(firewallRulesFetchedAt)}`
: 'Rules currently persisted by coold'}
</p>
{firewallRulesServer ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isLoadingFirewallRules}
onClick={() => void loadFirewallRules(firewallRulesServer)}
>
{isLoadingFirewallRules ? 'Loading...' : 'Refresh'}
</Button>
) : null}
</div>
{firewallRulesError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{firewallRulesError}
</div>
) : null}
<div className="max-h-[32rem] overflow-auto rounded-lg border border-border">
<table className="min-w-full divide-y divide-border text-left text-xs">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<th className="whitespace-nowrap px-3 py-2 font-medium">Rule ID</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">Namespace</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">Source</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">Destination</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">Protocol</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">Port</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{isLoadingFirewallRules ? (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-muted-foreground">
Loading firewall rules...
</td>
</tr>
) : firewallRules.length > 0 ? (
firewallRules.map((rule, index) => (
<tr key={rule.id ?? `firewall-rule-${index}`}>
<td className="max-w-80 truncate px-3 py-2 font-mono text-muted-foreground" title={rule.id}>
{rule.id ?? '—'}
</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
{rule.namespace ?? '—'}
</td>
<td className="whitespace-nowrap px-3 py-2 font-mono text-muted-foreground">
{rule.src ?? '—'}
</td>
<td className="whitespace-nowrap px-3 py-2 font-mono text-muted-foreground">
{rule.dst ?? '—'}
</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
{rule.proto ?? '—'}
</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
{rule.port ?? '—'}
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-muted-foreground">
No firewall rules defined.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={isEditServerDialogOpen}
onOpenChange={(open) => {
+25 -8
View File
@@ -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<void> {
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
</div>
{notice && (
<div className="absolute right-4 top-20 z-30 max-w-sm rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive shadow-lg">
{notice}
<div className="absolute right-4 top-20 z-30 flex max-w-sm items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive shadow-lg">
<span>{notice}</span>
<button
type="button"
aria-label="Dismiss notice"
onClick={() => setNotice(null)}
className="-m-1 rounded p-1 text-destructive/80 transition hover:bg-destructive/10 hover:text-destructive"
>
<span aria-hidden="true">×</span>
<span className="sr-only">Dismiss notice</span>
</button>
</div>
)}
@@ -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)`,
}}
+2
View File
@@ -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');
});
+325 -2
View File
@@ -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}</p>');
});
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('<span className="sr-only">Dismiss notice</span>');
});
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
@@ -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);