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:
@@ -28,6 +28,8 @@ import { getVersion } from '../fleet/scripts.js';
|
||||
import type { ProvisioningBlocker } from '../../provisioning-detail.js';
|
||||
import { recordProgress, type ProgressUpdate } from './progress.js';
|
||||
import { checkRelease, freshRelease, probeVersion, release } from '../integrations/plutonium.js';
|
||||
import { nativeInstance, nativeDeployment } from '../backends/native.js';
|
||||
import { previewNativeCreation, queueNativeCreation, queueNativeRebuild, cancelQueuedNativeCreation } from './native.js';
|
||||
|
||||
export type CreateServerFailure =
|
||||
| 'bad-request'
|
||||
@@ -49,6 +51,12 @@ export type DestroyServerResult =
|
||||
| { ok: false; kind: 'not-found' | 'conflict' | 'komodo' | 'fs' | 'db'; message: string; warnings: string[] };
|
||||
|
||||
export interface CreateServerInput {
|
||||
hostId?: string;
|
||||
libraryId?: string;
|
||||
image?: string;
|
||||
bindAddress?: string;
|
||||
publicAddress?: string;
|
||||
publicPort?: number;
|
||||
game?: ZombieGame; // omitted by older clients: T5
|
||||
gameMode: GameMode; // 'zombie' — only value supported today
|
||||
mapSlug: string; // 'cosmodrome'
|
||||
@@ -167,7 +175,8 @@ interface PreviewRow {
|
||||
}
|
||||
|
||||
/** Read-only checks; a preview never reserves a slot or writes credentials. */
|
||||
export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blueprintId?: unknown; deploymentId?: unknown }): LaunchPreview {
|
||||
export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blueprintId?: unknown; deploymentId?: unknown; hostId?: unknown; libraryId?: unknown }): LaunchPreview {
|
||||
if (input.hostId !== undefined || !config.komodo.address) return previewNativeCreation(input);
|
||||
if (input.blueprintId !== undefined && input.deploymentId !== undefined) throw new Error('Choose one starting point.');
|
||||
let recipe: LaunchRecipe;
|
||||
let blueprintName: string | null = null;
|
||||
@@ -208,6 +217,8 @@ export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blue
|
||||
|
||||
// releasingServers is internal to atomic collection queueing; HTTP callers cannot set it.
|
||||
export function queueCreateServer(input: CreateServerInput, releasingServers: string[] = []): CreateServerResult {
|
||||
const nativePreview = input.previewId ? db().prepare("SELECT 1 FROM server_launch_previews WHERE id=? AND json_extract(recipe,'$.native.hostId') IS NOT NULL").get(input.previewId) : null;
|
||||
if (input.hostId || nativePreview || !config.komodo.address) return queueNativeCreation(input);
|
||||
return db().transaction((): CreateServerResult => {
|
||||
const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id = ?').get(input.previewId) as PreviewRow | undefined : undefined;
|
||||
if (input.previewId && !preview) return { ok: false, kind: 'bad-request', message: 'Launch preview expired or was not found. Review a fresh preview.' };
|
||||
@@ -243,6 +254,7 @@ export function queueCreateServer(input: CreateServerInput, releasingServers: st
|
||||
|
||||
/** Reserve the same identity and port before removing any part of the instance. */
|
||||
export function queueRebuildServer(name: string, triggeredBy = 'ui'): CreateServerResult {
|
||||
if (nativeInstance(name)) return queueNativeRebuild(name, triggeredBy);
|
||||
const blocker = activeBetaRemovalBlocker(name);
|
||||
if (blocker) return { ok: false, kind: 'conflict', message: blocker };
|
||||
const server = getServer(name);
|
||||
@@ -535,6 +547,8 @@ function hasActiveCreation(name: string): boolean {
|
||||
}
|
||||
|
||||
export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } {
|
||||
const cancelled = cancelQueuedNativeCreation(name, triggeredBy);
|
||||
if (cancelled) return { ok: true, warnings: [], deploymentId: cancelled };
|
||||
const blocker = activeBetaRemovalBlocker(name);
|
||||
if (blocker) return { ok: false, kind: 'conflict', message: blocker, warnings: [] };
|
||||
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild')
|
||||
@@ -597,6 +611,7 @@ async function runDestructions(): Promise<void> {
|
||||
}
|
||||
|
||||
async function processDestruction(operation: DestructionJob): Promise<void> {
|
||||
if (nativeDeployment(operation.id)) return;
|
||||
if (creating || destroying.has(operation.server)) return;
|
||||
if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) return;
|
||||
destroying.add(operation.server);
|
||||
@@ -859,6 +874,7 @@ async function runCreations(): Promise<CreationOutcomes> {
|
||||
// deployment. A known interrupted startup must retain its readiness gate.
|
||||
const interrupted = db().prepare("SELECT id FROM server_deployments WHERE server_deleted_at IS NULL AND action IN ('create', 'rebuild') AND state = 'interrupted' ORDER BY started_at, rowid").all() as { id: string }[];
|
||||
for (const { id } of interrupted) {
|
||||
if (nativeDeployment(id)) continue;
|
||||
if (resumeVerification(id)) continue;
|
||||
// A queued removal does not make the interrupted container disappear.
|
||||
// Keep its slot until removal finishes, without resuming normal work or
|
||||
@@ -883,6 +899,7 @@ async function runCreations(): Promise<CreationOutcomes> {
|
||||
WHERE d.server_deleted_at IS NULL AND d.state = 'awaiting-health' ORDER BY d.started_at, d.rowid`).all() as CreationJob[];
|
||||
for (const job of checking) {
|
||||
const id = job.deployment_id;
|
||||
if (nativeDeployment(id)) continue;
|
||||
if (getServer(job.server)?.game_port !== job.port) {
|
||||
finishDeployment(id, { ok: false, message: 'Server was removed or replaced before creation health checks completed' });
|
||||
continue;
|
||||
@@ -980,6 +997,7 @@ async function runCreations(): Promise<CreationOutcomes> {
|
||||
const queue = creationQueue();
|
||||
const available = Math.max(0, fleetSettings().createConcurrency - queue.active.length);
|
||||
const eligible = queue.queued.filter(({ id }) => {
|
||||
if (nativeDeployment(id)) return false;
|
||||
const blocker = creationBlocker(id);
|
||||
if (blocker) deploymentStage(id, blocker.message, { phase: 'startup-queue', blocker });
|
||||
else recordProgress(id, { phase: 'startup-queue', blocker: { code: available ? 'startup-queue' : 'startup-capacity',
|
||||
|
||||
Reference in New Issue
Block a user