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),
|
||||
|
||||
Reference in New Issue
Block a user