Add native host management and role-based operator authentication
Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
This commit is contained in:
@@ -10,8 +10,10 @@ async function exclusive<T>(id: number, action: () => Promise<T>): Promise<T> {
|
||||
try { return await action(); } finally { operations.delete(id); }
|
||||
}
|
||||
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { readFile, exists } from '../backends/files.js';
|
||||
import { getHostOperation } from '../hosts/index.js';
|
||||
|
||||
import { join, posix } from 'node:path';
|
||||
import { betaArchiveOf, configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
@@ -204,6 +206,14 @@ export class BetaError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function assertNativeActivationSettled(betaId: number): void {
|
||||
const pending = db().prepare(`SELECT n.operation_id FROM native_provisioning n
|
||||
JOIN provisioning_sources s ON s.operation_id=n.deployment_id
|
||||
WHERE s.kind='beta' AND s.source_id=? AND n.operation_id IS NOT NULL
|
||||
AND n.files_json IS NOT NULL AND n.applied_at IS NULL`).all(String(betaId)) as { operation_id: string }[];
|
||||
if (pending.some(row => getHostOperation(row.operation_id)?.state !== 'failed')) throw new BetaError('The host is still reconciling file activation. Wait for its durable before images before promoting or rolling back.', 'conflict');
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
@@ -563,6 +573,7 @@ export async function rollbackBeta(betaId: number, options: { restart?: 'manual'
|
||||
return exclusive(betaId, () => rollbackBetaInternal(betaId, options));
|
||||
}
|
||||
async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual' | 'when-empty' }): Promise<RollbackResult> {
|
||||
assertNativeActivationSettled(betaId);
|
||||
const beta = getBeta(betaId);
|
||||
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
|
||||
if (beta.state !== 'running' && beta.state !== 'ready-promote') {
|
||||
@@ -658,7 +669,7 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
|
||||
|
||||
// Wipe archive + close out beta.
|
||||
const archive = betaArchiveOf(betaId);
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
if (await exists(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
// Per-version test-failed transition. Skip a version if (a) another beta
|
||||
// still gates it at ready-promote/promoted, or (b) it's the currently-
|
||||
@@ -764,6 +775,7 @@ export async function promoteBeta(betaId: number, reviewedToken?: string): Promi
|
||||
return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken));
|
||||
}
|
||||
async function promoteBetaInternal(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
|
||||
assertNativeActivationSettled(betaId);
|
||||
const test = storedPlan(betaId);
|
||||
if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote');
|
||||
const beta = getBeta(betaId);
|
||||
@@ -842,7 +854,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
// Non-beta matching-scope server: current on-disk file (if any)
|
||||
// is the "before" state for a promote-then-rollback round trip.
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
if (existsSync(targetPath)) {
|
||||
if (await exists(targetPath)) {
|
||||
const priorContent = await readFile(targetPath);
|
||||
const inv = getServerScript(name, script.target_rel_path);
|
||||
plan = {
|
||||
@@ -906,13 +918,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
hadFile = true;
|
||||
}
|
||||
}
|
||||
if (!betaServerSet.has(name) && existsSync(targetPath)) {
|
||||
if (!betaServerSet.has(name) && await exists(targetPath)) {
|
||||
// Capture an initial preview; activation records the actual before image.
|
||||
priorContent = await readFile(targetPath);
|
||||
priorSha = sha256Buffer(priorContent);
|
||||
hadFile = true;
|
||||
}
|
||||
const requiresActivation = !betaServerSet.has(name) || !existsSync(targetPath)
|
||||
const requiresActivation = !betaServerSet.has(name) || !await exists(targetPath)
|
||||
|| sha256Buffer(await readFile(targetPath)) !== composedSha;
|
||||
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation });
|
||||
}
|
||||
@@ -1038,7 +1050,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
|
||||
|
||||
const archive = betaArchiveOf(betaId);
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
if (await exists(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
const landed = Array.from(new Set([
|
||||
...plans.map((p) => p.server),
|
||||
|
||||
@@ -11,6 +11,8 @@ import { getServerKey } from './server-keys.js';
|
||||
import { serverObservation } from './status.js';
|
||||
import { activeBetaRemovalBlocker, getServer } from './servers.js';
|
||||
import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js';
|
||||
import { nativeSelection, nextNativePort } from '../provisioning/native.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
|
||||
function coverage(entries: BlueprintEntry[]) {
|
||||
type Server = { name: string; game: string | null; map_slug: string | null; map: string | null };
|
||||
@@ -76,18 +78,25 @@ function allocate(entries: BlueprintEntry[], releasingServers: string[] = []): C
|
||||
const base = `${entry.recipe.game}_zombie_${entry.recipe.mapSlug}`;
|
||||
const lobby = nextLobbyNumber(base, names);
|
||||
const name = lobby === null ? null : `${base}_${lobby}`;
|
||||
const port = nextAvailablePort(ports, releasingServers);
|
||||
const port = entry.recipe.native ? nextNativePort(entry.recipe.native, ports) : nextAvailablePort(ports, releasingServers);
|
||||
if (name) names.push(name);
|
||||
if (port !== null) ports.push(port);
|
||||
return { entryIndex, name, port, hasSavedKey: !!name && !!getServerKey(name) };
|
||||
}));
|
||||
}
|
||||
|
||||
function blockersFor(servers: CollectionPreview['servers']): string[] {
|
||||
function blockersFor(servers: CollectionPreview['servers'], entries: BlueprintEntry[] = []): string[] {
|
||||
const blockers: string[] = [];
|
||||
if (!servers.length) return blockers;
|
||||
if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Configure Komodo credentials and a target host before launching.');
|
||||
if (!rconConfigured()) blockers.push('Configure RCON_PASSWORD so every new server can be verified.');
|
||||
if (entries.some(entry => entry.recipe.native)) {
|
||||
for (const entry of entries) {
|
||||
try { nativeSelection(entry.recipe.native as unknown as Record<string, unknown>, entry.recipe.game); }
|
||||
catch (error) { blockers.push((error as Error).message); }
|
||||
}
|
||||
} else {
|
||||
if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Select and prepare a host before launching.');
|
||||
if (!rconConfigured()) blockers.push('Configure RCON_PASSWORD so every new server can be verified.');
|
||||
}
|
||||
if (servers.some(server => !server.name)) blockers.push('There are not enough free lobbies for one or more maps (20 per map). Reduce their counts or free capacity.');
|
||||
if (servers.some(server => server.port === null)) blockers.push(`There are not enough free game ports in ${config.portRange.min}–${config.portRange.max} for the whole collection.`);
|
||||
return blockers;
|
||||
@@ -95,7 +104,7 @@ function blockersFor(servers: CollectionPreview['servers']): string[] {
|
||||
|
||||
function removalBlockersFor(removals: CollectionPreview['removals']): string[] {
|
||||
const blockers: string[] = [];
|
||||
if (removals.length && (!komodoConfigured() || !rconConfigured())) blockers.push('Configure Komodo and RCON before removing servers.');
|
||||
if (removals.some(server => !nativeInstance(server.name)) && (!komodoConfigured() || !rconConfigured())) blockers.push('Configure the legacy backend before removing its servers.');
|
||||
for (const server of removals) {
|
||||
const blocker = activeBetaRemovalBlocker(server.name);
|
||||
if (blocker) blockers.push(blocker);
|
||||
@@ -103,14 +112,21 @@ function removalBlockersFor(removals: CollectionPreview['removals']): string[] {
|
||||
return blockers;
|
||||
}
|
||||
|
||||
export function previewCollection(blueprintId: unknown, mode: unknown = 'fill'): CollectionPreview {
|
||||
export function previewCollection(blueprintId: unknown, mode: unknown = 'fill', selection?: Record<string, unknown>): CollectionPreview {
|
||||
if (typeof blueprintId !== 'string' || !blueprintId) throw new Error('Choose a collection to launch.');
|
||||
if (mode !== 'fill' && mode !== 'exact') throw new Error('Choose fill or exact collection mode.');
|
||||
return db().transaction((): CollectionPreview => {
|
||||
const { entries, blueprintName } = blueprintEntries(blueprintId);
|
||||
if (selection?.hostId !== undefined || !config.komodo.address) {
|
||||
const libraries = selection?.libraryIds as Record<string, unknown> | undefined;
|
||||
for (const entry of entries) {
|
||||
entry.recipe.native = nativeSelection({ ...selection, libraryId: libraries?.[entry.recipe.game] ?? selection?.libraryId }, entry.recipe.game);
|
||||
entry.recipe.image = entry.recipe.native.image!;
|
||||
}
|
||||
}
|
||||
const { counts, retainedServers, removals } = collectionPlan(entries, mode);
|
||||
const servers = allocate(entries, removals.map(server => server.name));
|
||||
const blockers = [...blockersFor(servers), ...removalBlockersFor(removals)];
|
||||
const blockers = [...blockersFor(servers, entries), ...removalBlockersFor(removals)];
|
||||
const id = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
db().prepare('DELETE FROM server_collection_previews WHERE expires_at < ? AND collection_id IS NULL').run(new Date().toISOString());
|
||||
@@ -141,7 +157,7 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
|
||||
const { retainedServers, removals } = collectionPlan(entries, preview.mode);
|
||||
const releasingServers = removals.map(server => server.name);
|
||||
const servers = allocate(entries, releasingServers);
|
||||
const blockers = [...blockersFor(servers), ...removalBlockersFor(removals)];
|
||||
const blockers = [...blockersFor(servers, entries), ...removalBlockersFor(removals)];
|
||||
if (blockers.length) throw new Error(blockers.join(' '));
|
||||
if (servers.length !== reviewed.length || servers.some((server, i) => server.entryIndex !== reviewed[i]?.entryIndex || server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) {
|
||||
throw new Error('Fleet counts, lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { readdir, readFile, stat } from '../backends/files.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
|
||||
import { join, posix, relative, sep } from 'node:path';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { readFile, stat } from '../backends/files.js';
|
||||
|
||||
export async function sha256(path: string): Promise<string> {
|
||||
const buf = await readFile(path);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from '.
|
||||
import { sha256Buffer } from './files.js';
|
||||
|
||||
export interface LaunchRecipe {
|
||||
native?: { hostId: string; libraryId: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number };
|
||||
format: 1;
|
||||
game: ZombieGame;
|
||||
mapSlug: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { open, stat } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { rootOfServer, gameOfServer } from '../config.js';
|
||||
import { nativeTail } from '../backends/files.js';
|
||||
|
||||
// Whitelisted log files exposed to the UI, mapped to their path relative
|
||||
// to each server's root directory. Adding a new supported log = one entry
|
||||
@@ -63,6 +64,8 @@ export async function tailLog(
|
||||
path: string,
|
||||
lines: number = DEFAULT_TAIL_LINES
|
||||
): Promise<TailResult | null> {
|
||||
const remote = await nativeTail(path, lines);
|
||||
if (remote !== undefined) return remote;
|
||||
let st;
|
||||
try {
|
||||
st = await stat(path);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { existsSync } from 'node:fs';
|
||||
import { config, destOf, serverDir, rootOfServer } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { parseServerName } from './map-aliases.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
import { stat as managedStat } from '../backends/files.js';
|
||||
|
||||
/**
|
||||
* All server folders under the game roots. Used by seedServerRegistry to backfill
|
||||
@@ -46,6 +48,10 @@ export async function ensureDest(
|
||||
create: boolean
|
||||
): Promise<{ ok: true; path: string } | { ok: false; reason: string }> {
|
||||
const dest = destOf(server);
|
||||
if (nativeInstance(server)) {
|
||||
try { return (await managedStat(dest)).isDirectory() ? { ok: true, path: dest } : { ok: false, reason: 'Agent script directory is unavailable' }; }
|
||||
catch (error) { return { ok: false, reason: (error as Error).message }; }
|
||||
}
|
||||
if (existsSync(dest)) {
|
||||
try {
|
||||
const s = await stat(dest);
|
||||
@@ -63,6 +69,10 @@ export async function ensureDest(
|
||||
// -- server registry --
|
||||
|
||||
export interface ServerRow {
|
||||
native_instance_id?: string | null;
|
||||
host_id?: string | null;
|
||||
instance_generation?: number | null;
|
||||
game_library_id?: string | null;
|
||||
name: string;
|
||||
komodo_type: 'deployment' | 'stack' | 'container';
|
||||
komodo_name: string;
|
||||
@@ -146,11 +156,13 @@ export async function seedServerRegistry(): Promise<number> {
|
||||
}
|
||||
|
||||
export function getServer(name: string): ServerRow | undefined {
|
||||
return db().prepare('SELECT * FROM servers WHERE name = ?').get(name) as ServerRow | undefined;
|
||||
return db().prepare(`SELECT s.*, n.id AS native_instance_id, n.host_id, n.generation AS instance_generation, n.library_id AS game_library_id
|
||||
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL WHERE s.name=?`).get(name) as ServerRow | undefined;
|
||||
}
|
||||
|
||||
export function listServers(): ServerRow[] {
|
||||
return db().prepare('SELECT * FROM servers WHERE destroyed_at IS NULL ORDER BY name').all() as ServerRow[];
|
||||
return db().prepare(`SELECT s.*, n.id AS native_instance_id, n.host_id, n.generation AS instance_generation, n.library_id AS game_library_id
|
||||
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL WHERE s.destroyed_at IS NULL ORDER BY s.name`).all() as ServerRow[];
|
||||
}
|
||||
|
||||
/** Beta ownership lasts until explicit closure, regardless of test outcome or stage. */
|
||||
|
||||
Reference in New Issue
Block a user