Rework as scripts-centric, upload-only, gated beta lifecycle
Scripts become first-class: named, versioned, uploaded via the UI, optionally scoped to a game or map. Any change that would land on scope-matched servers (marking-required or new version of a scoped script) must first pass a beta. The beta runs a per-server state machine — deploy -beta.gsc → wait empty → Komodo restart → wait healthy → optionally wait for a real match end (games_sp.log 'ExitLevel: executed') — before it's eligible for promotion. Rollback is always available and restores from .OLD. The shared/scripts mirror and its betas/ staging folder are gone. Every server now has a tracked script inventory (server_scripts table), populated only by explicit upload flows. New: /scripts, /scripts/[id], /betas/[id] UI; /api/scripts + /api/betas + /api/servers/[name]/scripts endpoints; scripts.ts, inventory.ts, beta.ts (rewritten), beta-runner.ts, match-log.ts, komodoInspectHealthy(). Removed: push.ts, /api/push, BetaPanel, legacy importer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -251,6 +251,103 @@ export async function komodoResolveGamePort(serverName: string): Promise<number
|
||||
return result.ok ? result.port : null;
|
||||
}
|
||||
|
||||
// ---------- Health probe ----------
|
||||
|
||||
export type KomodoHealthResult =
|
||||
| { ok: true; state: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
// Walks a Komodo read response looking for a container state string, matching
|
||||
// the shapes we've seen: docker-inspect ({ State: { Status, Running } }),
|
||||
// GetDeployment ({ state } / { status: { state } }), and GetStack similar.
|
||||
function extractContainerState(payload: unknown): string | null {
|
||||
const seen = new Set<unknown>();
|
||||
let found: string | null = null;
|
||||
const walk = (v: unknown): void => {
|
||||
if (found || v === null || typeof v !== 'object' || seen.has(v)) return;
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) walk(item);
|
||||
return;
|
||||
}
|
||||
const obj = v as Record<string, unknown>;
|
||||
// Prefer the explicit docker inspect shape.
|
||||
const s = obj['State'];
|
||||
if (s && typeof s === 'object') {
|
||||
const status = (s as Record<string, unknown>)['Status'];
|
||||
const running = (s as Record<string, unknown>)['Running'];
|
||||
if (typeof status === 'string') {
|
||||
found = status;
|
||||
return;
|
||||
}
|
||||
if (running === true) {
|
||||
found = 'running';
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof obj['state'] === 'string') {
|
||||
found = obj['state'] as string;
|
||||
return;
|
||||
}
|
||||
for (const val of Object.values(obj)) walk(val);
|
||||
};
|
||||
walk(payload);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask Komodo whether the server's container is currently running. Used by the
|
||||
* beta runner during the `awaiting-health` stage after a restart. A truthy
|
||||
* `ok` doesn't imply the game is fully ready — the runner also gates on RCON
|
||||
* before advancing.
|
||||
*/
|
||||
export async function komodoInspectHealthy(serverName: string): Promise<KomodoHealthResult> {
|
||||
if (!komodoConfigured()) return { ok: false, reason: 'komodo not configured' };
|
||||
const row = getServer(serverName);
|
||||
if (!row?.komodo_name) return { ok: false, reason: 'no komodo_name for server' };
|
||||
|
||||
const type = row.komodo_type ?? 'deployment';
|
||||
let path: string;
|
||||
let body: Record<string, string>;
|
||||
switch (type) {
|
||||
case 'stack':
|
||||
path = 'read/GetStack';
|
||||
body = { stack: row.komodo_name };
|
||||
break;
|
||||
case 'container':
|
||||
if (!row.komodo_server) return { ok: false, reason: 'container type needs komodo_server' };
|
||||
path = 'read/InspectContainer';
|
||||
body = { server: row.komodo_server, container: row.komodo_name };
|
||||
break;
|
||||
case 'deployment':
|
||||
default:
|
||||
path = 'read/GetDeployment';
|
||||
body = { deployment: row.komodo_name };
|
||||
break;
|
||||
}
|
||||
|
||||
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await komodoCall(path, body);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: describeFetchError(err, url) };
|
||||
}
|
||||
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `unparseable json: ${(err as Error).message}` };
|
||||
}
|
||||
const state = extractContainerState(json);
|
||||
if (!state) return { ok: false, reason: 'no container state found in response' };
|
||||
const healthy = state.toLowerCase() === 'running' || state.toLowerCase() === 'healthy';
|
||||
if (!healthy) return { ok: false, reason: `container state: ${state}` };
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
// ---------- Extractor ----------
|
||||
|
||||
// Property names Komodo/Docker use for the collection of port mappings.
|
||||
|
||||
Reference in New Issue
Block a user