feat(v5): add coold restart action

Add the V5 server endpoint and UI action to restart coold over SSH with
a fresh host token, plus Flux reconnect handling and coverage for the
new backend, browser, and canvas test suites.
This commit is contained in:
Andras Bacsai 2026-07-06 22:30:50 +02:00
parent 9db103cc77
commit 51ed86fe63
13 changed files with 607 additions and 5 deletions

View file

@ -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<int, array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}>
*/
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);

View file

@ -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

View file

@ -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",

View file

@ -10,6 +10,9 @@
<testsuite name="v4">
<directory suffix="Test.php">./tests/v4</directory>
</testsuite>
<testsuite name="v5">
<directory suffix="Test.php">./tests/v5</directory>
</testsuite>
</testsuites>
<coverage/>
<php>

View file

@ -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<string>();
const restartingCooldServers = usePendingIds<string>();
const [serverConnectionNotice, setServerConnectionNotice] = useState<ServerConnectionNotice | null>(null);
const [isBootstrapLogsDialogOpen, setIsBootstrapLogsDialogOpen] = useState(false);
const [bootstrapLogsServerId, setBootstrapLogsServerId] = useState<string | null>(null);
@ -480,6 +489,7 @@ export default function Clusters({
const [firewallRulesServer, setFirewallRulesServer] = useState<V5Server | null>(null);
const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([]);
const [firewallRulesFetchedAt, setFirewallRulesFetchedAt] = useState<string | null>(null);
const [firewallRulesSource, setFirewallRulesSource] = useState<'flux' | 'ssh' | null>(null);
const [firewallRulesError, setFirewallRulesError] = useState<string | null>(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<void> {
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<void> {
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
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={isRestartingCoold}
onClick={() => void restartCoold(server)}
>
{isRestartingCoold ? 'Restarting coold...' : 'Restart coold'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void loadCooldLogs(server)}>
Coold logs
</DropdownMenuItem>
@ -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({
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{firewallRulesFetchedAt
? `Fetched ${formatDate(firewallRulesFetchedAt)}`
? `Fetched ${formatDate(firewallRulesFetchedAt)} · Source: ${diagnosticsSourceLabel(firewallRulesSource)}`
: 'Rules currently persisted by coold'}
</p>
{firewallRulesServer ? (

View file

@ -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']);
});
});

View file

@ -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 });
});
});

View file

@ -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');

View file

@ -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();

View file

@ -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')

View file

@ -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');
/*
|--------------------------------------------------------------------------

View file

@ -0,0 +1,47 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 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();
});

14
vitest.config.ts Normal file
View file

@ -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}'],
},
});