mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 02:27:57 +00:00
feat(v5): expose corrosion and firewall diagnostics
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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(':', [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)`,
|
||||
}}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user