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:
parent
9db103cc77
commit
51ed86fe63
13 changed files with 607 additions and 5 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
75
resources/js/v5/lib/canvas-collision.test.ts
Normal file
75
resources/js/v5/lib/canvas-collision.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
96
resources/js/v5/lib/canvas-geometry.test.ts
Normal file
96
resources/js/v5/lib/canvas-geometry.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
47
tests/v5/Browser/DashboardSmokeTest.php
Normal file
47
tests/v5/Browser/DashboardSmokeTest.php
Normal 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
14
vitest.config.ts
Normal 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}'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue