Queue server creation and verify health and selected map before completion
This commit is contained in:
@@ -178,6 +178,6 @@ RCON parsing (fake UDP socket).
|
|||||||
|
|
||||||
Campaigns (`/campaigns`) contains script/config rollouts, drift reconciliation, restart progress, file diffs, and rollbacks. Existing rollout records are preserved in the original database tables. The campaign API lives at `/api/campaigns`; legacy rollout detail API endpoints remain available for compatibility.
|
Campaigns (`/campaigns`) contains script/config rollouts, drift reconciliation, restart progress, file diffs, and rollbacks. Existing rollout records are preserved in the original database tables. The campaign API lives at `/api/campaigns`; legacy rollout detail API endpoints remain available for compatibility.
|
||||||
|
|
||||||
Deployments (`/deployments`, `/api/deployments`) now tracks server creations and destructions with live stages and persistent history, including failures, warnings, and interrupted operations. History starts with operations performed after this update and survives server removal. Creation completion means the server was provisioned and Komodo accepted the container start request; runtime health remains on the Fleet page.
|
Deployments (`/deployments`, `/api/deployments`) now tracks server creations and destructions with live stages and persistent history, including failures, warnings, and interrupted operations. History starts with operations performed after this update and survives server removal. New creation requests return HTTP 202 with a deployment ID and open the deployment detail page. Creation is complete only when Komodo reports healthy, RCON responds successfully, and the RCON map exactly matches the selected map. Queued requests reserve their server name and port; queued creation and pending health checks survive application restarts. The detail page records startup progress and explains any health or map mismatch that is still blocking completion.
|
||||||
|
|
||||||
Each server deployment opens a detail page with its flow and timestamped activity. Destroy uses an in-app confirmation modal and immediately returns a queued deployment (`DELETE /api/servers/:name` returns HTTP 202 with `deploymentId`). The worker waits for a fresh RCON player count of zero before teardown; occupied or unreachable servers remain queued and are checked each worker tick. Waiting requests survive application restarts, while interrupted teardown is marked for inspection. Completed destruction stays on the deployment detail page.
|
Each server deployment opens a detail page with its flow and timestamped activity. Destroy uses an in-app confirmation modal and immediately returns a queued deployment (`DELETE /api/servers/:name` returns HTTP 202 with `deploymentId`). The worker waits for a fresh RCON player count of zero before teardown; occupied or unreachable servers remain queued and are checked each worker tick. Waiting requests survive application restarts, while interrupted teardown is marked for inspection. Completed destruction stays on the deployment detail page.
|
||||||
|
|||||||
@@ -276,3 +276,12 @@ CREATE TABLE IF NOT EXISTS server_deployment_events (
|
|||||||
message TEXT NOT NULL
|
message TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_server_deployment_events ON server_deployment_events(deployment_id, id);
|
CREATE INDEX IF NOT EXISTS idx_server_deployment_events ON server_deployment_events(deployment_id, id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_creation_jobs (
|
||||||
|
deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE,
|
||||||
|
server TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
game_mode TEXT NOT NULL,
|
||||||
|
map_slug TEXT NOT NULL,
|
||||||
|
expected_map TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|||||||
+110
-24
@@ -1,4 +1,4 @@
|
|||||||
import { rconGetPlayerCount } from '../integrations/rcon.js';
|
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
|
||||||
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
|
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
|
||||||
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
@@ -6,7 +6,7 @@ import { config, destOfKind } from '../config.js';
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { getServerKey, saveServerKey } from './server-keys.js';
|
import { getServerKey, saveServerKey } from './server-keys.js';
|
||||||
import { getServer } from './servers.js';
|
import { getServer } from './servers.js';
|
||||||
import { t5ZombieMapBySlug, type GameMode } from './map-aliases.js';
|
import { t5ZombieMapBySlug, type GameMode, type T5MapEntry } from './map-aliases.js';
|
||||||
import {
|
import {
|
||||||
komodoCreateDeployment,
|
komodoCreateDeployment,
|
||||||
komodoDeploy,
|
komodoDeploy,
|
||||||
@@ -40,7 +40,7 @@ export type CreateServerFailure =
|
|||||||
| 'db'; // shouldn't happen, but wraps INSERT failures
|
| 'db'; // shouldn't happen, but wraps INSERT failures
|
||||||
|
|
||||||
export type CreateServerResult =
|
export type CreateServerResult =
|
||||||
| { ok: true; name: string; port: number }
|
| { ok: true; name: string; port: number; deploymentId: string }
|
||||||
| { ok: false; kind: CreateServerFailure; message: string };
|
| { ok: false; kind: CreateServerFailure; message: string };
|
||||||
|
|
||||||
export type DestroyServerResult =
|
export type DestroyServerResult =
|
||||||
@@ -62,11 +62,14 @@ export interface CreateServerInput {
|
|||||||
*/
|
*/
|
||||||
function nextLobbyNumber(baseName: string): number | null {
|
function nextLobbyNumber(baseName: string): number | null {
|
||||||
const rows = db()
|
const rows = db()
|
||||||
.prepare('SELECT name FROM servers WHERE name LIKE ?')
|
.prepare(`SELECT name FROM servers WHERE name LIKE ? UNION
|
||||||
|
SELECT j.server AS name FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
|
||||||
|
WHERE d.state IN ('queued', 'running', 'awaiting-health')`)
|
||||||
.all(`${baseName}_%`) as Array<{ name: string }>;
|
.all(`${baseName}_%`) as Array<{ name: string }>;
|
||||||
const taken = new Set<number>();
|
const taken = new Set<number>();
|
||||||
const prefix = `${baseName}_`;
|
const prefix = `${baseName}_`;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
if (!r.name.startsWith(prefix)) continue;
|
||||||
const tail = r.name.slice(prefix.length);
|
const tail = r.name.slice(prefix.length);
|
||||||
if (/^\d+$/.test(tail)) taken.add(Number.parseInt(tail, 10));
|
if (/^\d+$/.test(tail)) taken.add(Number.parseInt(tail, 10));
|
||||||
}
|
}
|
||||||
@@ -83,7 +86,9 @@ function nextLobbyNumber(baseName: string): number | null {
|
|||||||
*/
|
*/
|
||||||
function nextAvailablePort(): number | null {
|
function nextAvailablePort(): number | null {
|
||||||
const rows = db()
|
const rows = db()
|
||||||
.prepare('SELECT game_port FROM servers WHERE game_port IS NOT NULL')
|
.prepare(`SELECT game_port FROM servers WHERE game_port IS NOT NULL UNION
|
||||||
|
SELECT j.port AS game_port FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
|
||||||
|
WHERE d.state IN ('queued', 'running', 'awaiting-health')`)
|
||||||
.all() as Array<{ game_port: number }>;
|
.all() as Array<{ game_port: number }>;
|
||||||
const taken = new Set(rows.map((r) => r.game_port));
|
const taken = new Set(rows.map((r) => r.game_port));
|
||||||
for (let p = config.portRange.min; p <= config.portRange.max; p++) {
|
for (let p = config.portRange.min; p <= config.portRange.max; p++) {
|
||||||
@@ -139,25 +144,32 @@ function buildSpec(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let creating = false;
|
let creating = false;
|
||||||
export async function createServer(input: CreateServerInput): Promise<CreateServerResult> {
|
interface PreparedCreation {
|
||||||
if (creating || destroying.size > 0) return { ok: false, kind: 'no-slot', message: 'Another server operation is in progress. Please retry shortly.' };
|
ok: true; name: string; port: number; plutoKey: string; mapEntry: T5MapEntry;
|
||||||
creating = true;
|
|
||||||
let operation: string | undefined;
|
|
||||||
try {
|
|
||||||
const result = await createServerUnlocked(input, (name) => {
|
|
||||||
operation = beginDeployment(name, 'create', input.createdBy);
|
|
||||||
return operation;
|
|
||||||
});
|
|
||||||
if (operation) finishDeployment(operation, result);
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
if (operation) finishDeployment(operation, { ok: false, message: (err as Error).message });
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
finally { creating = false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createServerUnlocked(input: CreateServerInput, begin: (name: string) => string): Promise<CreateServerResult> {
|
export function queueCreateServer(input: CreateServerInput): CreateServerResult {
|
||||||
|
return db().transaction((): CreateServerResult => {
|
||||||
|
const prepared = prepareCreation(input);
|
||||||
|
if (!prepared.ok) return prepared;
|
||||||
|
const id = beginDeployment(prepared.name, 'create', input.createdBy);
|
||||||
|
db().prepare("UPDATE server_deployments SET state = 'queued' WHERE id = ?").run(id);
|
||||||
|
db().prepare('INSERT INTO server_creation_jobs (deployment_id, server, port, game_mode, map_slug, expected_map) VALUES (?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(id, prepared.name, prepared.port, input.gameMode, prepared.mapEntry.slug, prepared.mapEntry.canonical);
|
||||||
|
deploymentStage(id, 'Queued for server creation');
|
||||||
|
return { ok: true, name: prepared.name, port: prepared.port, deploymentId: id };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Await provisioning for internal callers; completion still requires worker health verification.
|
||||||
|
export async function createServer(input: CreateServerInput): Promise<CreateServerResult> {
|
||||||
|
if (creating || destroying.size > 0) return { ok: false, kind: 'no-slot', message: 'Another server operation is in progress. Please retry shortly.' };
|
||||||
|
const queued = queueCreateServer(input);
|
||||||
|
if (!queued.ok) return queued;
|
||||||
|
return await provisionCreation(queued.deploymentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<CreateServerResult, { ok: false }> {
|
||||||
if (input.gameMode !== 'zombie') {
|
if (input.gameMode !== 'zombie') {
|
||||||
return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` };
|
return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` };
|
||||||
}
|
}
|
||||||
@@ -198,7 +210,11 @@ async function createServerUnlocked(input: CreateServerInput, begin: (name: stri
|
|||||||
try { saveServerKey(name, plutoKey); }
|
try { saveServerKey(name, plutoKey); }
|
||||||
catch (err) { return { ok: false, kind: 'bad-request', message: (err as Error).message }; }
|
catch (err) { return { ok: false, kind: 'bad-request', message: (err as Error).message }; }
|
||||||
|
|
||||||
const operation = begin(name);
|
return { ok: true, name, port, plutoKey, mapEntry };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> {
|
||||||
|
const { name, port, plutoKey, mapEntry } = prepared;
|
||||||
deploymentStage(operation, 'Preparing server files');
|
deploymentStage(operation, 'Preparing server files');
|
||||||
const hostDir = `${config.serversRoot}/${name}`;
|
const hostDir = `${config.serversRoot}/${name}`;
|
||||||
const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical);
|
const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical);
|
||||||
@@ -304,7 +320,7 @@ async function createServerUnlocked(input: CreateServerInput, begin: (name: stri
|
|||||||
// now instead of on the next 15s tick.
|
// now instead of on the next 15s tick.
|
||||||
void kick().catch((err) => console.error('[createServer] post-create kick failed:', err));
|
void kick().catch((err) => console.error('[createServer] post-create kick failed:', err));
|
||||||
|
|
||||||
return { ok: true, name, port };
|
return { ok: true, name, port, deploymentId: operation };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -453,5 +469,75 @@ async function destroyServerUnlocked(name: string, operation: string): Promise<D
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unfinished = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'create' AND state = 'awaiting-health'").all(name) as { id: string }[];
|
||||||
|
for (const { id } of unfinished) finishDeployment(id, { ok: false, message: 'Server destroyed before creation health checks completed' });
|
||||||
return { ok: true, warnings };
|
return { ok: true, warnings };
|
||||||
|
}␍interface CreationJob {
|
||||||
|
deployment_id: string; server: string; port: number; game_mode: GameMode; map_slug: string; expected_map: string; triggered_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function provisionCreation(id: string): Promise<CreateServerResult> {
|
||||||
|
const job = db().prepare('SELECT j.*, d.triggered_by FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE j.deployment_id = ?').get(id) as CreationJob;
|
||||||
|
creating = true;
|
||||||
|
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(id);
|
||||||
|
try {
|
||||||
|
const mapEntry = t5ZombieMapBySlug(job.map_slug);
|
||||||
|
const plutoKey = getServerKey(job.server);
|
||||||
|
if (!mapEntry || !plutoKey) {
|
||||||
|
const result = { ok: false as const, kind: 'bad-request' as const, message: 'Saved server key or selected map is no longer available' };
|
||||||
|
finishDeployment(id, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const result = await createServerUnlocked({ gameMode: job.game_mode, mapSlug: job.map_slug, createdBy: job.triggered_by },
|
||||||
|
{ ok: true, name: job.server, port: job.port, mapEntry, plutoKey }, id);
|
||||||
|
if (!result.ok) finishDeployment(id, result);
|
||||||
|
else {
|
||||||
|
db().prepare("UPDATE server_deployments SET state = 'awaiting-health' WHERE id = ?").run(id);
|
||||||
|
deploymentStage(id, 'Waiting for Komodo health');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
finishDeployment(id, { ok: false, message: (err as Error).message });
|
||||||
|
throw err;
|
||||||
|
} finally { creating = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let creationTick: Promise<void> | null = null;
|
||||||
|
export function processCreations(): Promise<void> {
|
||||||
|
if (creationTick) return creationTick;
|
||||||
|
creationTick = runCreations().finally(() => { creationTick = null; });
|
||||||
|
return creationTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCreations(): Promise<void> {
|
||||||
|
if (creating || destroying.size > 0) return;
|
||||||
|
const queued = db().prepare("SELECT id FROM server_deployments WHERE action = 'create' AND state = 'queued' ORDER BY started_at").all() as { id: string }[];
|
||||||
|
for (const { id } of queued) {
|
||||||
|
if (creating || destroying.size > 0) break;
|
||||||
|
await provisionCreation(id).catch(err => console.error('[creation] provisioning failed:', err));
|
||||||
|
}
|
||||||
|
const checking = db().prepare("SELECT j.* FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state = 'awaiting-health'").all() as CreationJob[];
|
||||||
|
for (const job of checking) {
|
||||||
|
const id = job.deployment_id;
|
||||||
|
if (!getServer(job.server)) {
|
||||||
|
finishDeployment(id, { ok: false, message: 'Server was removed before creation health checks completed' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(job.server)) continue;
|
||||||
|
try {
|
||||||
|
const komodo = await komodoInspectHealthy(job.server);
|
||||||
|
if (!komodo.ok) { deploymentStage(id, 'Waiting for Komodo health: ' + komodo.reason); continue; }
|
||||||
|
const rcon = await rconStatusResult(job.server);
|
||||||
|
if (!rcon.ok) { deploymentStage(id, 'Waiting for RCON health: ' + rcon.reason); continue; }
|
||||||
|
const actualMap = rcon.status.map;
|
||||||
|
if (actualMap !== job.expected_map) {
|
||||||
|
deploymentStage(id, 'Waiting for selected map: expected ' + job.expected_map + ', RCON reports ' + (actualMap ?? 'no map'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
deploymentStage(id, 'Verified Komodo healthy, RCON healthy, and map ' + actualMap);
|
||||||
|
finishDeployment(id, { ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
deploymentStage(id, 'Waiting for health verification: ' + (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export function recordDeploymentEvent(id: string, message: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDeployment(id: string) {
|
export function getDeployment(id: string) {
|
||||||
const deployment = db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
|
const deployment = db().prepare(`SELECT d.*, j.expected_map, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
|
||||||
FROM server_deployments d WHERE d.id = ?`).get(id);
|
FROM server_deployments d LEFT JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE d.id = ?`).get(id);
|
||||||
if (!deployment) return null;
|
if (!deployment) return null;
|
||||||
const events = db().prepare('SELECT id, created_at, message FROM server_deployment_events WHERE deployment_id = ? ORDER BY id').all(id);
|
const events = db().prepare('SELECT id, created_at, message FROM server_deployment_events WHERE deployment_id = ? ORDER BY id').all(id);
|
||||||
return { deployment, events };
|
return { deployment, events };
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ const STATE_KEYS = new Set([
|
|||||||
'docker_status'
|
'docker_status'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function extractContainerState(payload: unknown): string | null {
|
export function extractContainerState(payload: unknown): string | null {
|
||||||
const seen = new Set<unknown>();
|
const seen = new Set<unknown>();
|
||||||
let found: string | null = null;
|
let found: string | null = null;
|
||||||
|
|
||||||
@@ -297,6 +297,12 @@ function extractContainerState(payload: unknown): string | null {
|
|||||||
const status = so['Status'];
|
const status = so['Status'];
|
||||||
const running = so['Running'];
|
const running = so['Running'];
|
||||||
const health = so['Health'];
|
const health = so['Health'];
|
||||||
|
// A running process can still be starting or unhealthy. Honor an
|
||||||
|
// explicit Docker health check before accepting its running state.
|
||||||
|
if ((running === true || status === 'running') && health && typeof health === 'object') {
|
||||||
|
const hs = (health as Record<string, unknown>)['Status'];
|
||||||
|
if (typeof hs === 'string' && hs.toLowerCase() !== 'healthy') { found = hs; return; }
|
||||||
|
}
|
||||||
if (typeof status === 'string') { found = status; return; }
|
if (typeof status === 'string') { found = status; return; }
|
||||||
if (running === true) { found = 'running'; return; }
|
if (running === true) { found = 'running'; return; }
|
||||||
if (health && typeof health === 'object') {
|
if (health && typeof health === 'object') {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { processDestructions } from '../fleet/create.js';
|
import { processDestructions, processCreations } from '../fleet/create.js';
|
||||||
import { sweep } from './sweep.js';
|
import { sweep } from './sweep.js';
|
||||||
import { betaTick } from './beta-runner.js';
|
import { betaTick } from './beta-runner.js';
|
||||||
import { emit } from './bus.js';
|
import { emit } from './bus.js';
|
||||||
@@ -28,6 +28,8 @@ function tick(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runTick(): Promise<void> {
|
async function runTick(): Promise<void> {
|
||||||
|
try { await processCreations(); }
|
||||||
|
catch (err) { console.error('[worker] creation error:', err); }
|
||||||
try { await processDestructions(); }
|
try { await processDestructions(); }
|
||||||
catch (err) { console.error('[worker] destruction error:', err); }
|
catch (err) { console.error('[worker] destruction error:', err); }
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -66,7 +66,8 @@
|
|||||||
const [keysRes, serversRes] = await Promise.all([fetch('/api/server-keys'), fetch('/api/servers')]);
|
const [keysRes, serversRes] = await Promise.all([fetch('/api/server-keys'), fetch('/api/servers')]);
|
||||||
if (!keysRes.ok || !serversRes.ok) throw new Error('Could not load saved keys and server slots. Reopen this dialog to retry.');
|
if (!keysRes.ok || !serversRes.ok) throw new Error('Could not load saved keys and server slots. Reopen this dialog to retry.');
|
||||||
savedIds = (await keysRes.json()).keys.map((k: { server_id: string }) => k.server_id);
|
savedIds = (await keysRes.json()).keys.map((k: { server_id: string }) => k.server_id);
|
||||||
deployedIds = (await serversRes.json()).servers.map((s: { name: string }) => s.name);
|
const fleet = await serversRes.json();
|
||||||
|
deployedIds = [...fleet.servers.map((s: { name: string }) => s.name), ...(fleet.reservedNames ?? [])];
|
||||||
keysLoaded = true;
|
keysLoaded = true;
|
||||||
} catch (err) { flash = (err as Error).message; }
|
} catch (err) { flash = (err as Error).message; }
|
||||||
}
|
}
|
||||||
@@ -100,15 +101,15 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined })
|
body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined })
|
||||||
});
|
});
|
||||||
const body = (await res.json()) as { ok: boolean; name?: string; port?: number; error?: string; kind?: string };
|
const body = (await res.json()) as { ok: boolean; name?: string; port?: number; deploymentId?: string; error?: string; kind?: string };
|
||||||
if (!body.ok || !body.name) {
|
if (!res.ok || !body.ok || !body.deploymentId) {
|
||||||
flash = body.error ?? 'Deploy failed';
|
flash = body.error ?? 'Deploy failed';
|
||||||
if (body.kind === 'no-slot') await loadKeys();
|
if (body.kind === 'no-slot') await loadKeys();
|
||||||
deploying = false;
|
deploying = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
close();
|
close();
|
||||||
await goto(`/servers/${encodeURIComponent(body.name)}`);
|
await goto(`/deployments/${encodeURIComponent(body.deploymentId)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
flash = (err as Error).message;
|
flash = (err as Error).message;
|
||||||
deploying = false;
|
deploying = false;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import { kick } from '$lib/server/pipeline/worker.js';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { listServers, heldByBeta } from '$lib/server/fleet/servers.js';
|
import { listServers, heldByBeta } from '$lib/server/fleet/servers.js';
|
||||||
import { classifyHealth } from '$lib/server/fleet/health.js';
|
import { classifyHealth } from '$lib/server/fleet/health.js';
|
||||||
import { createServer } from '$lib/server/fleet/create.js';
|
import { queueCreateServer } from '$lib/server/fleet/create.js';
|
||||||
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
|
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
|
||||||
import type { RconPlayer } from '$lib/server/integrations/rcon.js';
|
import type { RconPlayer } from '$lib/server/integrations/rcon.js';
|
||||||
import type { GameMode } from '$lib/server/fleet/map-aliases.js';
|
import type { GameMode } from '$lib/server/fleet/map-aliases.js';
|
||||||
@@ -29,6 +31,7 @@ export async function GET() {
|
|||||||
}));
|
}));
|
||||||
return json({
|
return json({
|
||||||
servers,
|
servers,
|
||||||
|
reservedNames: (db().prepare("SELECT j.server FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state IN ('queued', 'running', 'awaiting-health')").all() as { server: string }[]).map(row => row.server),
|
||||||
integrations: {
|
integrations: {
|
||||||
rcon: rconConfigured(),
|
rcon: rconConfigured(),
|
||||||
komodo: komodoConfigured(),
|
komodo: komodoConfigured(),
|
||||||
@@ -51,7 +54,7 @@ export async function POST({ request }: { request: Request }) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const result = await createServer({
|
const result = queueCreateServer({
|
||||||
gameMode: body.gameMode as GameMode,
|
gameMode: body.gameMode as GameMode,
|
||||||
mapSlug: body.mapSlug,
|
mapSlug: body.mapSlug,
|
||||||
plutoKey: body.plutoKey,
|
plutoKey: body.plutoKey,
|
||||||
@@ -64,5 +67,6 @@ export async function POST({ request }: { request: Request }) {
|
|||||||
: 500;
|
: 500;
|
||||||
return json({ ok: false, error: result.message, kind: result.kind }, { status });
|
return json({ ok: false, error: result.message, kind: result.kind }, { status });
|
||||||
}
|
}
|
||||||
return json({ ok: true, name: result.name, port: result.port });
|
void kick().catch((err) => console.error('[create] worker kick failed:', err));
|
||||||
|
return json({ ok: true, name: result.name, port: result.port, deploymentId: result.deploymentId }, { status: 202 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
interface Deployment {
|
interface Deployment {
|
||||||
id: string; server: string; action: 'create' | 'destroy';
|
id: string; server: string; action: 'create' | 'destroy';
|
||||||
state: 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted';
|
state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted';
|
||||||
stage: string; started_at: string; finished_at: string | null;
|
stage: string; started_at: string; finished_at: string | null;
|
||||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
let action = $state('all');
|
let action = $state('all');
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let refreshing = false;
|
let refreshing = false;
|
||||||
let active = $derived(deployments.filter(d => (d.state === 'running' || d.state === 'waiting')));
|
let active = $derived(deployments.filter(d => ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)));
|
||||||
let history = $derived(deployments.filter(d => d.state !== 'running' && d.state !== 'waiting' &&
|
let history = $derived(deployments.filter(d => !['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state) &&
|
||||||
(action === 'all' || d.action === action) && d.server.toLowerCase().includes(search.toLowerCase())));
|
(action === 'all' || d.action === action) && d.server.toLowerCase().includes(search.toLowerCase())));
|
||||||
async function load() {
|
async function load() {
|
||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
||||||
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
||||||
function status(d: Deployment) {
|
function status(d: Deployment) {
|
||||||
return { waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' }[d.state];
|
return { queued: 'Queued', 'awaiting-health': 'Verifying health', waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' }[d.state];
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
|
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p>{d.state === 'done' ? (d.action === 'create' ? 'Server created; container start requested.' : 'Server destroyed.') : d.stage}</p>
|
<p>{d.state === 'done' ? (d.action === 'create' ? 'Server creation completed.' : 'Server destroyed.') : d.stage}</p>
|
||||||
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
|
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
|
||||||
{#if d.error}<p class="error">{d.error}</p>{/if}
|
{#if d.error}<p class="error">{d.error}</p>{/if}
|
||||||
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
|
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
|
|
||||||
interface Deployment {
|
interface Deployment {
|
||||||
id: string; server: string; action: 'create' | 'destroy'; state: string;
|
id: string; expected_map?: string; server: string; action: 'create' | 'destroy'; state: string;
|
||||||
stage: string; started_at: string; finished_at: string | null;
|
stage: string; started_at: string; finished_at: string | null;
|
||||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||||
}
|
}
|
||||||
@@ -15,24 +15,25 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let refreshing = false;
|
let refreshing = false;
|
||||||
let deployment = $derived(detail?.deployment);
|
let deployment = $derived(detail?.deployment);
|
||||||
let active = $derived(deployment?.state === 'waiting' || deployment?.state === 'running');
|
let active = $derived(['queued', 'awaiting-health', 'waiting', 'running'].includes(deployment?.state ?? ''));
|
||||||
let warnings = $derived.by(() => {
|
let warnings = $derived.by(() => {
|
||||||
try { return JSON.parse(deployment?.warnings ?? '[]') as string[]; } catch { return []; }
|
try { return JSON.parse(deployment?.warnings ?? '[]') as string[]; } catch { return []; }
|
||||||
});
|
});
|
||||||
let steps = $derived(deployment?.action === 'destroy'
|
let steps = $derived(deployment?.action === 'destroy'
|
||||||
? ['Request destruction', 'Wait for empty server', 'Stop and remove container', 'Delete Komodo deployment', 'Remove server files and registry']
|
? ['Request destruction', 'Wait for empty server', 'Stop and remove container', 'Delete Komodo deployment', 'Remove server files and registry']
|
||||||
: ['Request creation', 'Prepare server files', 'Create Komodo deployment', 'Start container']);
|
: ['Request creation', 'Prepare server files', 'Create Komodo deployment', 'Start container', 'Verify Komodo health', 'Verify RCON health', 'Verify selected map']);
|
||||||
let currentStep = $derived.by(() => {
|
let currentStep = $derived.by(() => {
|
||||||
if (!deployment) return 0;
|
if (!deployment) return 0;
|
||||||
const stages = deployment.action === 'destroy'
|
const stages = deployment.action === 'destroy'
|
||||||
? ['Starting', 'Waiting', 'Stopping and removing container', 'Deleting Komodo deployment', 'Removing server files and registry']
|
? ['Starting', 'Waiting', 'Stopping and removing container', 'Deleting Komodo deployment', 'Removing server files and registry']
|
||||||
: ['Starting', 'Preparing server files', 'Creating Komodo deployment', 'Starting container'];
|
: ['Starting', 'Preparing server files', 'Creating Komodo deployment', 'Starting container', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map'];
|
||||||
if (deployment.state === 'waiting') return 1;
|
if (deployment.state === 'waiting') return 1;
|
||||||
|
if (deployment.state === 'awaiting-health' && deployment.stage.startsWith('Waiting for health verification')) return 4;
|
||||||
if (deployment.state === 'done') return stages.length;
|
if (deployment.state === 'done') return stages.length;
|
||||||
return Math.max(0, stages.findIndex(stage => deployment.stage.startsWith(stage)));
|
return Math.max(0, stages.findIndex(stage => deployment.stage.startsWith(stage)));
|
||||||
});
|
});
|
||||||
function stateLabel(state: string) {
|
function stateLabel(state: string) {
|
||||||
return ({ waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' } as Record<string, string>)[state] ?? state;
|
return ({ queued: 'Queued', 'awaiting-health': 'Verifying health', waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' } as Record<string, string>)[state] ?? state;
|
||||||
}
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
@@ -65,10 +66,11 @@
|
|||||||
<div class="top"><Badge variant={deployment.state === 'done' ? 'online' : deployment.state === 'failed' ? 'danger' : 'warning'}>{stateLabel(deployment.state)}</Badge>
|
<div class="top"><Badge variant={deployment.state === 'done' ? 'online' : deployment.state === 'failed' ? 'danger' : 'warning'}>{stateLabel(deployment.state)}</Badge>
|
||||||
{#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
|
{#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
|
||||||
</div>
|
</div>
|
||||||
<h2>{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server created; container start requested') : deployment.stage}</h2>
|
<h2>{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server creation completed') : deployment.stage}</h2>
|
||||||
{#if deployment.state === 'waiting'}<p>The server stays online while players finish. Player count is checked automatically; destruction starts only after a live check reports zero players.</p>{/if}
|
{#if deployment.state === 'waiting'}<p>The server stays online while players finish. Player count is checked automatically; destruction starts only after a live check reports zero players.</p>{/if}
|
||||||
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
|
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
|
||||||
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
|
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
|
||||||
|
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
|
||||||
<dl><div><dt>Started</dt><dd>{fmt(deployment.started_at)}</dd></div><div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}</dd></div><div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div></dl>
|
<dl><div><dt>Started</dt><dd>{fmt(deployment.started_at)}</dd></div><div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}</dd></div><div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div></dl>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
|
|||||||
komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })),
|
komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })),
|
||||||
komodoUpdateTagsOnResource: vi.fn(async () => ({ ok: true }))
|
komodoUpdateTagsOnResource: vi.fn(async () => ({ ok: true }))
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0) }));
|
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||||
|
rconGetPlayerCount: vi.fn(async () => 0),
|
||||||
|
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 27016 }, rawReply: '',
|
||||||
|
status: { players: 0, map: 'zombie_cosmodrome', gametype: null, roster: [] } }))
|
||||||
|
}));
|
||||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
|
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
|
||||||
|
|
||||||
let fixture: Fixture;
|
let fixture: Fixture;
|
||||||
@@ -54,6 +58,62 @@ async function promotedConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('server creation map selection', () => {
|
describe('server creation map selection', () => {
|
||||||
|
it('returns a queued creation before provisioning and reserves its name and port', async () => {
|
||||||
|
const { POST } = await import('../../src/routes/api/servers/+server.js');
|
||||||
|
const { queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
const { getDeployment, interruptDeployments } = await import('../../src/lib/server/fleet/deployments.js');
|
||||||
|
const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const response = await POST({ request: new Request('http://localhost/api/servers', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||||
|
}) });
|
||||||
|
expect(response.status).toBe(202);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toMatchObject({ name: serverName, deploymentId: expect.any(String) });
|
||||||
|
expect(komodoCreateDeployment).not.toHaveBeenCalled();
|
||||||
|
expect(getDeployment(body.deploymentId)?.deployment).toMatchObject({ state: 'queued', expected_map: 'zombie_cosmodrome' });
|
||||||
|
expect(queueCreateServer({ ...input, expectedName: serverName })).toMatchObject({ ok: false, kind: 'no-slot' });
|
||||||
|
const second = queueCreateServer(input);
|
||||||
|
expect(second).toMatchObject({ ok: true, name: 't5_zombie_cosmodrome_2' });
|
||||||
|
if (second.ok) expect(second.port).not.toBe(body.port);
|
||||||
|
closeDb();
|
||||||
|
interruptDeployments();
|
||||||
|
expect(getDeployment(body.deploymentId)?.deployment).toMatchObject({ state: 'queued' });
|
||||||
|
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'starting' }).mockResolvedValueOnce({ ok: false, reason: 'starting' });
|
||||||
|
await Promise.all([processCreations(), processCreations()]);
|
||||||
|
expect(komodoCreateDeployment).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getDeployment(body.deploymentId)?.deployment).toMatchObject({ state: 'awaiting-health', finished_at: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes creation only when Komodo, RCON, and the selected map all pass', async () => {
|
||||||
|
const { createServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
const { getDeployment, interruptDeployments } = await import('../../src/lib/server/fleet/deployments.js');
|
||||||
|
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
|
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
|
||||||
|
const created = await createServer(input);
|
||||||
|
if (!created.ok) throw new Error('fixture creation failed');
|
||||||
|
const id = created.deploymentId;
|
||||||
|
expect(getDeployment(id)?.deployment).toMatchObject({ state: 'awaiting-health', finished_at: null });
|
||||||
|
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'starting' });
|
||||||
|
await processCreations();
|
||||||
|
expect(rconStatusResult).not.toHaveBeenCalled();
|
||||||
|
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: true, state: 'running' });
|
||||||
|
vi.mocked(rconStatusResult).mockResolvedValueOnce({ ok: false, endpoint: null, reason: 'timeout', detail: 'not ready' });
|
||||||
|
await processCreations();
|
||||||
|
expect(getDeployment(id)?.deployment).toMatchObject({ state: 'awaiting-health', stage: 'Waiting for RCON health: timeout' });
|
||||||
|
for (const map of [null, 'zombie_moon']) {
|
||||||
|
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: true, state: 'running' });
|
||||||
|
vi.mocked(rconStatusResult).mockResolvedValueOnce({ ok: true, endpoint: { host: 'localhost', port: created.port }, rawReply: '', status: { players: 0, map, gametype: null, roster: [] } });
|
||||||
|
await processCreations();
|
||||||
|
expect(getDeployment(id)?.deployment).toMatchObject({ state: 'awaiting-health', finished_at: null, stage: expect.stringContaining('expected zombie_cosmodrome') });
|
||||||
|
}
|
||||||
|
interruptDeployments();
|
||||||
|
expect(getDeployment(id)?.deployment).toMatchObject({ state: 'awaiting-health' });
|
||||||
|
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: true, state: 'running' });
|
||||||
|
await processCreations();
|
||||||
|
expect(getDeployment(id)?.deployment).toMatchObject({ state: 'done', finished_at: expect.any(String), stage: 'Verified Komodo healthy, RCON healthy, and map zombie_cosmodrome' });
|
||||||
|
});
|
||||||
|
|
||||||
it('returns a deployment URL identifier immediately and serves its lifecycle detail', async () => {
|
it('returns a deployment URL identifier immediately and serves its lifecycle detail', async () => {
|
||||||
const { createServer } = await import('../../src/lib/server/fleet/create.js');
|
const { createServer } = await import('../../src/lib/server/fleet/create.js');
|
||||||
const { DELETE } = await import('../../src/routes/api/servers/[name]/+server.js');
|
const { DELETE } = await import('../../src/routes/api/servers/[name]/+server.js');
|
||||||
@@ -116,7 +176,7 @@ describe('server creation map selection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('records in-flight creation and retains both operations after destruction', async () => {
|
it('records in-flight creation and retains both operations after destruction', async () => {
|
||||||
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
const { createServer, destroyServer, processDestructions, processCreations } = await import('../../src/lib/server/fleet/create.js');
|
||||||
const { listDeployments } = await import('../../src/lib/server/fleet/deployments.js');
|
const { listDeployments } = await import('../../src/lib/server/fleet/deployments.js');
|
||||||
const { komodoDeploy, komodoInspectHealthy, komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
const { komodoDeploy, komodoInspectHealthy, komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
vi.mocked(komodoDeploy).mockImplementationOnce(async () => {
|
vi.mocked(komodoDeploy).mockImplementationOnce(async () => {
|
||||||
@@ -124,6 +184,8 @@ describe('server creation map selection', () => {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
expect((await createServer(input)).ok).toBe(true);
|
expect((await createServer(input)).ok).toBe(true);
|
||||||
|
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: true, state: 'running' });
|
||||||
|
await processCreations();
|
||||||
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'gone' });
|
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'gone' });
|
||||||
vi.mocked(komodoDestroyDeployment).mockImplementationOnce(async () => {
|
vi.mocked(komodoDestroyDeployment).mockImplementationOnce(async () => {
|
||||||
expect(listDeployments()).toContainEqual(expect.objectContaining({ action: 'destroy', state: 'running' }));
|
expect(listDeployments()).toContainEqual(expect.objectContaining({ action: 'destroy', state: 'running' }));
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { extractUdpPort } from '../../src/lib/server/integrations/komodo.js';
|
import { extractUdpPort, extractContainerState } from '../../src/lib/server/integrations/komodo.js';
|
||||||
|
|
||||||
|
describe('container readiness', () => {
|
||||||
|
it.each(['starting', 'unhealthy'])('does not accept a running container whose health is %s', (health) => {
|
||||||
|
expect(extractContainerState({ State: { Status: 'running', Running: true, Health: { Status: health } } })).toBe(health);
|
||||||
|
});
|
||||||
|
it('accepts healthy running containers and preserves stopped states', () => {
|
||||||
|
expect(extractContainerState({ State: { Status: 'running', Running: true, Health: { Status: 'healthy' } } })).toBe('running');
|
||||||
|
expect(extractContainerState({ State: { Status: 'exited', Running: false, Health: { Status: 'healthy' } } })).toBe('exited');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('extractUdpPort', () => {
|
describe('extractUdpPort', () => {
|
||||||
it('returns the published port from docker-compose-style object entries', () => {
|
it('returns the published port from docker-compose-style object entries', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user