diff --git a/app/Http/Controllers/V5/ServerController.php b/app/Http/Controllers/V5/ServerController.php index 6f92a844e..469f5fd73 100644 --- a/app/Http/Controllers/V5/ServerController.php +++ b/app/Http/Controllers/V5/ServerController.php @@ -17,6 +17,7 @@ use App\Models\V5\Cluster as V5Cluster; use App\Models\V5\Server as V5Server; use App\Rules\ValidServerIp; +use App\Services\Flux\AgentTokenIssuer; use App\Services\Flux\FluxClient; use App\Support\V5\ClusterSerializer; use Illuminate\Http\JsonResponse; @@ -273,6 +274,78 @@ public function check(Request $request, V5Cluster $cluster, V5Server $server): J ]); } + public function restartCoold(Request $request, V5Cluster $cluster, V5Server $server, AgentTokenIssuer $agentTokenIssuer, FluxClient $fluxClient): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('restartCoold', [$server, $currentTeam, $cluster]); + + $server->loadMissing('privateKey'); + + if (! $server->privateKey instanceof PrivateKey) { + return response()->json([ + 'message' => 'No private key is attached to this server.', + ], 422); + } + + try { + $token = $agentTokenIssuer->issueForServer($server); + $output = $this->restartCooldOverSsh($server, $token); + } catch (\Throwable $e) { + Log::warning('V5 coold restart over SSH failed', [ + 'server_id' => $server->id, + 'message' => $e->getMessage(), + ]); + + return response()->json([ + 'message' => str($e->getMessage() !== '' ? $e->getMessage() : 'Could not restart coold over SSH.')->limit(10000)->toString(), + ], 502); + } + + $connected = false; + + try { + usleep(500_000); + $fluxClient->cooldLogs($server->fluxHostId(), 1); + $connected = true; + $server->forceFill([ + 'status' => ServerStatus::Installed->value, + 'last_status_check' => 'flux', + 'last_status_output' => 'coold restarted over SSH and reconnected to Flux.', + ])->save(); + } catch (\Throwable $e) { + Log::info('V5 coold restart succeeded but Flux reconnect is not confirmed yet', [ + 'server_id' => $server->id, + 'message' => $e->getMessage(), + ]); + } + + return response()->json([ + 'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster), + 'output' => $output, + 'connected' => $connected, + 'restartedAt' => now()->toJSON(), + ]); + } + + private function restartCooldOverSsh(V5Server $server, string $token): string + { + $encodedToken = base64_encode($token); + $script = implode(PHP_EOL, [ + 'set -e', + "SUDO=''", + 'if [ "$(id -u)" != "0" ]; then SUDO="sudo -n"; fi', + '$SUDO mkdir -p /etc/coolify', + 'printf %s '.escapeshellarg($encodedToken).' | base64 -d | $SUDO tee /etc/coolify/host-jwt >/dev/null', + '$SUDO chmod 600 /etc/coolify/host-jwt', + '$SUDO systemctl reset-failed coold.service || true', + '$SUDO systemctl restart coold.service', + '$SUDO systemctl is-active coold.service', + '$SUDO systemctl status coold.service --no-pager -l | sed -n "1,18p"', + ]); + + return $this->runServerSshCommand($server, $script, 'SSH coold restart command failed.', 45); + } + public function cooldLogs(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse { $currentTeam = $this->currentTeamOrFail($request); @@ -334,7 +407,7 @@ private function cooldLogsOverSsh(V5Server $server, int $tail): string ); } - private function runServerSshCommand(V5Server $server, string $remoteCommand, string $failureMessage): string + private function runServerSshCommand(V5Server $server, string $remoteCommand, string $failureMessage, int $timeout = 15): string { $keyDirectory = storage_path('app/ssh/keys'); if (! is_dir($keyDirectory)) { @@ -350,7 +423,7 @@ private function runServerSshCommand(V5Server $server, string $remoteCommand, st chmod($keyLocation, 0600); try { - $result = Process::timeout(15)->run([ + $result = Process::timeout($timeout)->run([ 'ssh', '-o', 'BatchMode=yes', @@ -505,6 +578,21 @@ public function firewallRules(Request $request, V5Cluster $cluster, V5Server $se 'message' => $e->getMessage(), ]); + if ($server->privateKey instanceof PrivateKey) { + try { + return response()->json([ + 'rules' => $this->firewallRulesOverSsh($server, (string) ($validated['namespace'] ?? '')), + 'source' => 'ssh', + 'fetchedAt' => now()->toJSON(), + ]); + } catch (\Throwable $sshException) { + Log::warning('V5 firewall rules SSH fallback failed', [ + 'server_id' => $server->id, + 'message' => $sshException->getMessage(), + ]); + } + } + return response()->json([ 'message' => 'Could not fetch firewall rules through Flux. Check the Flux and coold status, then try again.', ], 502); @@ -517,6 +605,58 @@ public function firewallRules(Request $request, V5Cluster $cluster, V5Server $se ]); } + /** + * @return array + */ + private function firewallRulesOverSsh(V5Server $server, string $namespace): array + { + $script = str_replace('__NAMESPACE__', json_encode($namespace, JSON_THROW_ON_ERROR), <<<'PYTHON' +python3 - <<'PY' +import json +from pathlib import Path + +namespace = __NAMESPACE__ +path = Path("/etc/coolify/firewall-rules.tsv") +rules = [] + +if path.exists(): + for line in path.read_text().splitlines(): + parts = line.split("\t") + if len(parts) == 6: + rule_id, rule_namespace, src, dst, proto, port = parts + elif len(parts) == 5: + rule_id = "" + rule_namespace, src, dst, proto, port = parts + else: + continue + + if namespace and rule_namespace != namespace: + continue + + try: + port = int(port) + except ValueError: + continue + + rules.append({ + "id": rule_id, + "namespace": rule_namespace, + "src": src, + "dst": dst, + "proto": proto, + "port": port, + }) + +print(json.dumps(rules, separators=(",", ":"))) +PY +PYTHON); + + $output = $this->runServerSshCommand($server, $script, 'SSH firewall rules command failed.'); + $rules = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + + return is_array($rules) ? $rules : []; + } + public function bootstrap(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse { $currentTeam = $this->currentTeamOrFail($request); diff --git a/app/Policies/V5/ServerPolicy.php b/app/Policies/V5/ServerPolicy.php index af828b398..b24a28bca 100644 --- a/app/Policies/V5/ServerPolicy.php +++ b/app/Policies/V5/ServerPolicy.php @@ -57,6 +57,14 @@ public function bootstrap(User $user, Server $server, Team $team, Cluster $clust return $this->allowIfAdminAndScoped($user, $server, $team, $cluster); } + /** + * Determine whether the user can restart coold over SSH. + */ + public function restartCoold(User $user, Server $server, Team $team, Cluster $cluster): Response + { + return $this->allowIfAdminAndScoped($user, $server, $team, $cluster); + } + /** * Determine whether the user can view server diagnostics (coold logs, * corrosion tables, firewall rules). Read-only, so gated on team diff --git a/package.json b/package.json index e325fd580..b8935ca99 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "scripts": { "dev": "vite", "build": "vite build", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", diff --git a/phpunit.xml b/phpunit.xml index a516aa4ac..f8ed5dcc4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -10,6 +10,9 @@ ./tests/v4 + + ./tests/v5 + diff --git a/resources/js/v5/Pages/Clusters.tsx b/resources/js/v5/Pages/Clusters.tsx index 7e5989336..2d3e391e0 100644 --- a/resources/js/v5/Pages/Clusters.tsx +++ b/resources/js/v5/Pages/Clusters.tsx @@ -85,6 +85,13 @@ type CheckServerResponse = { checkedAt: string; }; +type RestartCooldResponse = { + cluster?: V5Cluster; + output?: string; + connected?: boolean; + restartedAt?: string; +}; + type DeleteServerResponse = { cluster: V5Cluster; }; @@ -112,6 +119,7 @@ type FirewallRule = { type FirewallRulesResponse = { rules: FirewallRule[]; + source: 'flux' | 'ssh'; fetchedAt: string; }; @@ -448,6 +456,7 @@ export default function Clusters({ const [isServerSubmitting, setIsServerSubmitting] = useState(false); const [isServerUpdateSubmitting, setIsServerUpdateSubmitting] = useState(false); const checkingServers = usePendingIds(); + const restartingCooldServers = usePendingIds(); const [serverConnectionNotice, setServerConnectionNotice] = useState(null); const [isBootstrapLogsDialogOpen, setIsBootstrapLogsDialogOpen] = useState(false); const [bootstrapLogsServerId, setBootstrapLogsServerId] = useState(null); @@ -480,6 +489,7 @@ export default function Clusters({ const [firewallRulesServer, setFirewallRulesServer] = useState(null); const [firewallRules, setFirewallRules] = useState([]); const [firewallRulesFetchedAt, setFirewallRulesFetchedAt] = useState(null); + const [firewallRulesSource, setFirewallRulesSource] = useState<'flux' | 'ssh' | null>(null); const [firewallRulesError, setFirewallRulesError] = useState(null); const [isLoadingFirewallRules, setIsLoadingFirewallRules] = useState(false); const [showAdvancedConfiguration, setShowAdvancedConfiguration] = useState(false); @@ -748,6 +758,37 @@ export default function Clusters({ checkingServers.finish(server.id); } + async function restartCoold(server: V5Server): Promise { + if (!selectedCluster) { + return; + } + + restartingCooldServers.start(server.id); + + const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/restart-coold`, { + method: 'POST', + }).catch(() => null); + const payload = (await response?.json().catch(() => null)) as (RestartCooldResponse & { message?: string }) | null; + + if (payload?.cluster) { + setClusterList((currentClusters) => + currentClusters.map((cluster) => (cluster.id === payload.cluster?.id ? payload.cluster : cluster)), + ); + } + + setServerConnectionNotice({ + message: response?.ok ? `Restarted coold on ${server.name}` : `Failed to restart coold on ${server.name}`, + description: response?.ok + ? payload?.connected + ? 'coold restarted over SSH and reconnected to Flux.' + : (payload?.output ?? 'coold restarted over SSH. Flux reconnection is not confirmed yet.') + : (payload?.message ?? 'Unable to restart coold over SSH.'), + variant: response?.ok ? 'success' : 'danger', + }); + + restartingCooldServers.finish(server.id); + } + async function bootstrapServer(server: V5Server): Promise { if (!selectedCluster) { return; @@ -856,6 +897,7 @@ export default function Clusters({ setFirewallRulesError(null); setFirewallRules([]); setFirewallRulesFetchedAt(null); + setFirewallRulesSource(null); const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/firewall-rules`, { method: 'GET', @@ -872,6 +914,7 @@ export default function Clusters({ setFirewallRules(Array.isArray(payload?.rules) ? payload.rules : []); setFirewallRulesFetchedAt(payload?.fetchedAt ?? null); + setFirewallRulesSource(payload?.source ?? null); setIsLoadingFirewallRules(false); } @@ -1048,6 +1091,7 @@ export default function Clusters({ function renderServerCard(server: V5Server) { const isCheckingServer = checkingServers.has(server.id); + const isRestartingCoold = restartingCooldServers.has(server.id); const isBootstrapInProgress = ['queued', 'running'].includes(server.lastBootstrapStatus ?? ''); const isBootstrappingServer = bootstrappingServers.has(server.id) || isBootstrapInProgress; const isDeletingServer = deletingServers.has(server.id); @@ -1119,6 +1163,12 @@ export default function Clusters({ View install logs ) : null} + void restartCoold(server)} + > + {isRestartingCoold ? 'Restarting coold...' : 'Restart coold'} + void loadCooldLogs(server)}> Coold logs @@ -2356,6 +2406,7 @@ export default function Clusters({ setFirewallRulesServer(null); setFirewallRules([]); setFirewallRulesFetchedAt(null); + setFirewallRulesSource(null); setFirewallRulesError(null); } }} @@ -2372,7 +2423,7 @@ export default function Clusters({

{firewallRulesFetchedAt - ? `Fetched ${formatDate(firewallRulesFetchedAt)}` + ? `Fetched ${formatDate(firewallRulesFetchedAt)} · Source: ${diagnosticsSourceLabel(firewallRulesSource)}` : 'Rules currently persisted by coold'}

{firewallRulesServer ? ( diff --git a/resources/js/v5/lib/canvas-collision.test.ts b/resources/js/v5/lib/canvas-collision.test.ts new file mode 100644 index 000000000..de184cdfd --- /dev/null +++ b/resources/js/v5/lib/canvas-collision.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCanvasNodeLayout, resolveCanvasNodePosition, type CanvasNodeBounds } from '@/lib/canvas-collision'; + +const GAP = 16; + +function node(id: string, x: number, y: number, width = 320, height = 160): CanvasNodeBounds { + return { id, x, y, width, height }; +} + +function nodesOverlap(first: CanvasNodeBounds, second: CanvasNodeBounds, gap: number): boolean { + return ( + first.x < second.x + second.width + gap && + first.x + first.width + gap > second.x && + first.y < second.y + second.height + gap && + first.y + first.height + gap > second.y + ); +} + +describe('resolveCanvasNodePosition', () => { + it('keeps the position when there is no collision', () => { + const position = resolveCanvasNodePosition(node('a', 0, 0), [node('b', 1000, 1000)], GAP); + + expect(position).toEqual({ x: 0, y: 0 }); + }); + + it('ignores the node itself when checking collisions', () => { + const subject = node('a', 0, 0); + + const position = resolveCanvasNodePosition(subject, [subject], GAP); + + expect(position).toEqual({ x: 0, y: 0 }); + }); + + it('moves an overlapping node to a gap-respecting position', () => { + const settled = node('a', 0, 0); + + const position = resolveCanvasNodePosition(node('b', 10, 10), [settled], GAP); + + expect(nodesOverlap({ ...node('b', 10, 10), ...position }, settled, GAP)).toBe(false); + }); + + it('picks the candidate closest to the requested position', () => { + const settled = node('a', 0, 0); + + // Dropped near the settled node's top-left corner: the slot above is nearest. + const position = resolveCanvasNodePosition(node('b', 40, 0), [settled], GAP); + + expect(position).toEqual({ x: 40, y: -(160 + GAP) }); + }); +}); + +describe('resolveCanvasNodeLayout', () => { + it('settles all nodes without overlaps', () => { + const settled = resolveCanvasNodeLayout( + [node('a', 0, 0), node('b', 0, 0), node('c', 0, 0), node('d', 0, 0)], + GAP, + ); + + expect(settled).toHaveLength(4); + + for (const first of settled) { + for (const second of settled) { + if (first.id !== second.id) { + expect(nodesOverlap(first, second, GAP)).toBe(false); + } + } + } + }); + + it('preserves node order and ids', () => { + const settled = resolveCanvasNodeLayout([node('a', 0, 0), node('b', 0, 0)], GAP); + + expect(settled.map((settledNode) => settledNode.id)).toEqual(['a', 'b']); + }); +}); diff --git a/resources/js/v5/lib/canvas-geometry.test.ts b/resources/js/v5/lib/canvas-geometry.test.ts new file mode 100644 index 000000000..91619c05e --- /dev/null +++ b/resources/js/v5/lib/canvas-geometry.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { + APPLICATION_CARD_HEIGHT, + APPLICATION_CARD_WIDTH, + CANVAS_CARD_GAP, + connectorPoint, + resolveApplicationPosition, + settleCanvasResources, + shortestConnectionPoints, +} from '@/lib/canvas-geometry'; +import type { V5Application, V5CaddyIngress } from '@/types'; + +function application(id: string, canvasX: number, canvasY: number): V5Application { + return { id, canvasX, canvasY } as V5Application; +} + +function ingress(id: string, canvasX: number, canvasY: number): V5CaddyIngress { + return { id, canvasX, canvasY } as V5CaddyIngress; +} + +describe('connectorPoint', () => { + const node = { canvasX: 100, canvasY: 200 }; + + it('returns the midpoint of each card edge', () => { + expect(connectorPoint(node, 'top')).toEqual({ x: 100 + APPLICATION_CARD_WIDTH / 2, y: 200 }); + expect(connectorPoint(node, 'right')).toEqual({ x: 100 + APPLICATION_CARD_WIDTH, y: 200 + APPLICATION_CARD_HEIGHT / 2 }); + expect(connectorPoint(node, 'bottom')).toEqual({ x: 100 + APPLICATION_CARD_WIDTH / 2, y: 200 + APPLICATION_CARD_HEIGHT }); + expect(connectorPoint(node, 'left')).toEqual({ x: 100, y: 200 + APPLICATION_CARD_HEIGHT / 2 }); + }); +}); + +describe('shortestConnectionPoints', () => { + it('connects facing edges of horizontally separated cards', () => { + const left = { canvasX: 0, canvasY: 0 }; + const right = { canvasX: 1000, canvasY: 0 }; + + const { from, to } = shortestConnectionPoints(left, right); + + expect(from).toEqual(connectorPoint(left, 'right')); + expect(to).toEqual(connectorPoint(right, 'left')); + }); + + it('connects facing edges of vertically separated cards', () => { + const top = { canvasX: 0, canvasY: 0 }; + const bottom = { canvasX: 0, canvasY: 1000 }; + + const { from, to } = shortestConnectionPoints(top, bottom); + + expect(from).toEqual(connectorPoint(top, 'bottom')); + expect(to).toEqual(connectorPoint(bottom, 'top')); + }); +}); + +describe('settleCanvasResources', () => { + it('keeps non-overlapping resources in place', () => { + const apps = [application('app-1', 0, 0), application('app-2', 1000, 0)]; + + const settled = settleCanvasResources(apps, []); + + expect(settled.applications[0]).toMatchObject({ canvasX: 0, canvasY: 0 }); + expect(settled.applications[1]).toMatchObject({ canvasX: 1000, canvasY: 0 }); + }); + + it('separates overlapping applications and ingresses', () => { + const settled = settleCanvasResources([application('app-1', 0, 0)], [ingress('ingress-1', 0, 0)]); + + const app = settled.applications[0]; + const caddy = settled.ingresses[0]; + const horizontalGap = Math.abs(app.canvasX - caddy.canvasX); + const verticalGap = Math.abs(app.canvasY - caddy.canvasY); + + expect( + horizontalGap >= APPLICATION_CARD_WIDTH + CANVAS_CARD_GAP || verticalGap >= APPLICATION_CARD_HEIGHT + CANVAS_CARD_GAP, + ).toBe(true); + }); +}); + +describe('resolveApplicationPosition', () => { + it('moves a dragged application off an occupied slot', () => { + const occupied = application('app-1', 0, 0); + const dragged = application('app-2', 8, 8); + + const resolved = resolveApplicationPosition(dragged, [occupied, dragged], []); + + expect(resolved.canvasX !== 8 || resolved.canvasY !== 8).toBe(true); + }); + + it('keeps a dragged application on a free slot', () => { + const occupied = application('app-1', 0, 0); + const dragged = application('app-2', 1000, 1000); + + const resolved = resolveApplicationPosition(dragged, [occupied, dragged], []); + + expect(resolved).toMatchObject({ canvasX: 1000, canvasY: 1000 }); + }); +}); diff --git a/routes/v5.php b/routes/v5.php index acec7a869..6fceab4ec 100644 --- a/routes/v5.php +++ b/routes/v5.php @@ -29,6 +29,7 @@ Route::post('/clusters/{cluster}/servers', [ServerController::class, 'store'])->name('clusters.servers.store'); Route::patch('/clusters/{cluster}/servers/{server}', [ServerController::class, 'update'])->name('clusters.servers.update'); Route::post('/clusters/{cluster}/servers/{server}/check', [ServerController::class, 'check'])->name('clusters.servers.check'); + Route::post('/clusters/{cluster}/servers/{server}/restart-coold', [ServerController::class, 'restartCoold'])->name('clusters.servers.restart-coold'); Route::get('/clusters/{cluster}/servers/{server}/coold-logs', [ServerController::class, 'cooldLogs'])->name('clusters.servers.coold-logs'); Route::get('/clusters/{cluster}/servers/{server}/corrosion-tables', [ServerController::class, 'corrosionTables'])->name('clusters.servers.corrosion-tables'); Route::get('/clusters/{cluster}/servers/{server}/firewall-rules', [ServerController::class, 'firewallRules'])->name('clusters.servers.firewall-rules'); diff --git a/tests/Feature/V5/ServerControllerTest.php b/tests/Feature/V5/ServerControllerTest.php index b7f9f9b30..3d7d0dab0 100644 --- a/tests/Feature/V5/ServerControllerTest.php +++ b/tests/Feature/V5/ServerControllerTest.php @@ -5,6 +5,7 @@ use App\Models\V5\ApplicationDomain as V5ApplicationDomain; use App\Models\V5\Cluster; use App\Models\V5\Server as V5Server; +use App\Services\Flux\AgentTokenIssuer; use App\Services\Flux\FluxClient; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; @@ -46,6 +47,109 @@ ->and($server->canvas_y)->toBe(240); }); +it('restarts v5 server coold over ssh with a freshly minted host jwt', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $privateKey = createV5PrivateKey($team, 'Production SSH Key'); + $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, + 'private_key_id' => $privateKey->id, + 'name' => 'prod-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'unreachable', + 'builder_enabled' => false, + 'builder_capacity' => 0, + 'wireguard_management_ip' => '100.64.0.10', + 'node_address' => '203.0.113.10', + ]); + + $this->mock(AgentTokenIssuer::class, function (MockInterface $mock) use ($server): void { + $mock->shouldReceive('issueForServer') + ->once() + ->with(Mockery::on(fn (V5Server $subject): bool => $subject->is($server))) + ->andReturn('fresh-host-jwt'); + }); + + $this->mock(FluxClient::class, function (MockInterface $mock): void { + $mock->shouldReceive('cooldLogs') + ->once() + ->with(Mockery::type('string'), 1) + ->andReturn('coold restarted'); + }); + + Process::fake([ + '*' => Process::result(output: "active\n● coold.service - Coolify host agent\n"), + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/restart-coold") + ->assertSuccessful() + ->assertJsonPath('connected', true) + ->assertJsonPath('output', "active\n● coold.service - Coolify host agent") + ->assertJsonStructure(['cluster', 'output', 'connected', 'restartedAt']); + + expect($server->refresh()->status)->toBe('installed') + ->and($server->last_status_check)->toBe('flux') + ->and($server->last_status_output)->toBe('coold restarted over SSH and reconnected to Flux.'); + + Process::assertRan(function ($process): bool { + $command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command)); + + return str_contains($command, '203.0.113.10') + && str_contains($command, base64_encode('fresh-host-jwt')) + && str_contains($command, '/etc/coolify/host-jwt') + && str_contains($command, 'systemctl restart coold.service'); + }); +}); + +it('requires a private key before restarting v5 server coold over ssh', 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' => 'unreachable', + 'builder_enabled' => false, + 'builder_capacity' => 0, + 'wireguard_management_ip' => '100.64.0.10', + 'node_address' => '203.0.113.10', + ]); + + Process::fake(); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/restart-coold") + ->assertUnprocessable() + ->assertJsonPath('message', 'No private key is attached to this server.'); +}); + it('fetches v5 server coold logs through flux', function () { createSharedUserAndTeamTables(); @@ -336,6 +440,62 @@ ->assertJsonStructure(['rules', 'fetchedAt']); }); +it('falls back to ssh for v5 server firewall rules when flux fails', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $privateKey = createV5PrivateKey($team, 'Production SSH Key'); + $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, + 'private_key_id' => $privateKey->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(Mockery::type('string'), '') + ->andThrow(new RuntimeException('host is not connected')); + }); + + Process::fake([ + '*' => Process::result(output: '[{"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->uuid}/servers/{$server->uuid}/firewall-rules") + ->assertSuccessful() + ->assertJsonPath('rules.0.id', 'v5-resource-connection:1:1:2:tcp:5432') + ->assertJsonPath('rules.0.port', 5432) + ->assertJsonPath('source', 'ssh'); + + Process::assertRan(function ($process): bool { + $command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command)); + + return str_contains($command, '/etc/coolify/firewall-rules.tsv') + && str_contains($command, '203.0.113.10') + && in_array('LogLevel=ERROR', $process->command, true); + }); +}); + it('adds a v5 server to a cluster for the current team', function () { createSharedUserAndTeamTables(); diff --git a/tests/Feature/V5/V5FrontendSourceContractTest.php b/tests/Feature/V5/V5FrontendSourceContractTest.php index 64c224018..2b7c85786 100644 --- a/tests/Feature/V5/V5FrontendSourceContractTest.php +++ b/tests/Feature/V5/V5FrontendSourceContractTest.php @@ -289,15 +289,20 @@ expect($clustersPage) ->toContain('Coold logs') ->toContain('Corrosion tables') + ->toContain('Restart coold') + ->toContain('Restarting coold...') ->toContain('Firewall rules') + ->toContain('/restart-coold') ->toContain('/coold-logs?tail=200') ->toContain('/corrosion-tables?limit=200') ->toContain('/firewall-rules') ->toContain("source: 'flux' | 'ssh';") ->toContain('setCooldLogsSource(payload?.source ?? null)') ->toContain('setCorrosionTablesSource(payload?.source ?? null)') + ->toContain('setFirewallRulesSource(payload?.source ?? null)') ->toContain('Source: {diagnosticsSourceLabel(cooldLogsSource)}') ->toContain('Source: {diagnosticsSourceLabel(corrosionTablesSource)}') + ->toContain('Source: ${diagnosticsSourceLabel(firewallRulesSource)}') ->toContain('Latest journalctl entries') ->toContain('Corrosion table snapshots') ->toContain('Defined coold allow rules') diff --git a/tests/Pest.php b/tests/Pest.php index c7c2f3c0a..638ccea0c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -14,7 +14,7 @@ | need to change it using the "uses()" function to bind a different classes or traits. | */ -uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser'); +uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser', 'v5/Browser'); /* |-------------------------------------------------------------------------- diff --git a/tests/v5/Browser/DashboardSmokeTest.php b/tests/v5/Browser/DashboardSmokeTest.php new file mode 100644 index 000000000..3b376399e --- /dev/null +++ b/tests/v5/Browser/DashboardSmokeTest.php @@ -0,0 +1,47 @@ + 0, 'is_sponsorship_popup_enabled' => false]); + + $this->user = User::factory()->create([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); +}); + +it('redirects guests to login', function () { + $page = visit('/v5'); + + $page->assertPathIs('/login') + ->screenshot(); +}); + +it('shows the dashboard canvas for an authenticated user', function () { + $this->actingAs($this->user); + + $page = visit('/v5'); + + $page->assertSee('Deploy') + ->assertSee('No applications on this canvas yet.') + ->assertNoJavaScriptErrors() + ->screenshot(); +}); + +it('shows the clusters page for an authenticated user', function () { + $this->actingAs($this->user); + + $page = visit('/v5/clusters'); + + $page->assertSee('Clusters') + ->assertNoJavaScriptErrors() + ->screenshot(); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..4e800f954 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./resources/js/v5', import.meta.url)), + }, + }, + test: { + environment: 'node', + include: ['resources/js/v5/**/*.test.{ts,tsx}'], + }, +});