103 lines
6.2 KiB
TypeScript
103 lines
6.2 KiB
TypeScript
import { db } from '../db/index.js';
|
|
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
|
import type { GameMode, ZombieGame } from '../fleet/map-aliases.js';
|
|
import { nativeInstance } from '../backends/native.js';
|
|
import { beginDeployment, deploymentStage, resumeCleanup } from './history.js';
|
|
import { previewNativeCreation, queueNativeCreation, queueNativeRebuild, cancelQueuedNativeCreation } from './native.js';
|
|
|
|
export type CreateServerFailure =
|
|
| 'bad-request'
|
|
| 'conflict'
|
|
| 'unconfigured' // native host configuration missing
|
|
| 'unknown-map' // slug isn't in the catalog
|
|
| 'no-slot' // all lobby slots for this map are taken
|
|
| 'no-port' // port range exhausted
|
|
| 'fs' // couldn't create host dir
|
|
| 'db'; // shouldn't happen, but wraps INSERT failures
|
|
|
|
export type CreateServerResult =
|
|
| { ok: true; name: string; port: number | null; deploymentId: string }
|
|
| { ok: false; kind: CreateServerFailure; message: string };
|
|
|
|
export type DestroyServerResult =
|
|
| { ok: true; warnings: string[] }
|
|
| { ok: false; kind: 'not-found' | 'conflict' | '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'
|
|
plutoKey?: string; // saved for this server ID when supplied
|
|
expectedName?: string; // optional preview guard; never selects a lobby
|
|
previewId?: string;
|
|
createdBy?: string; // 'ui' by default
|
|
}
|
|
|
|
export function nextLobbyNumber(baseName: string, additionalNames: string[] = []): number | null {
|
|
const rows = db()
|
|
.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.server_deleted_at IS NULL AND (d.state IN ('queued', 'running', 'awaiting-health') OR (d.state = 'cancelled' AND d.submitted_at IS NOT NULL) OR (d.action = 'rebuild' AND (d.state = 'waiting' OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry'))))`)
|
|
.all(`${baseName}_%`) as Array<{ name: string }>;
|
|
const taken = new Set<number>();
|
|
const prefix = `${baseName}_`;
|
|
for (const r of [...rows, ...additionalNames.map(name => ({ name }))]) {
|
|
if (!r.name.startsWith(prefix)) continue;
|
|
const tail = r.name.slice(prefix.length);
|
|
if (/^\d+$/.test(tail)) taken.add(Number.parseInt(tail, 10));
|
|
}
|
|
for (let n = 1; n <= 20; n++) {
|
|
if (!taken.has(n)) return n;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function previewCreation(input: Parameters<typeof previewNativeCreation>[0]) { return previewNativeCreation(input); }
|
|
export function queueCreateServer(input: CreateServerInput): CreateServerResult { return queueNativeCreation(input); }
|
|
export const queueRebuildServer = (name: string, triggeredBy = 'ui') => queueNativeRebuild(name, triggeredBy);
|
|
|
|
// Persist the request before returning so navigation and client disconnects cannot lose it.
|
|
function hasActiveCreation(name: string): boolean {
|
|
return !!db().prepare("SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health')").get(name);
|
|
}
|
|
|
|
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')
|
|
AND state IN ('waiting','queued','running')`).get(name)) {
|
|
return { ok: false, kind: 'conflict', message: 'Wait for the current provisioning operation before destroying this server.', warnings: [] };
|
|
}
|
|
const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined;
|
|
if (existing) return { ok: true, warnings: [], deploymentId: existing.id };
|
|
const previous = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1").get(name) as { id: string } | undefined;
|
|
if (previous && resumeCleanup(previous.id)) return { ok: true, warnings: [], deploymentId: previous.id };
|
|
if (!getServer(name) && !hasActiveCreation(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
|
|
if (!nativeInstance(name)) return { ok: false, kind: 'conflict', message: 'Server has no native host instance.', warnings: [] };
|
|
const operation = db().transaction(() => {
|
|
const id = beginDeployment(name, 'destroy', triggeredBy);
|
|
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
|
|
deploymentStage(id, 'Waiting to verify the server is empty', { phase: 'eligibility', observedAt: null,
|
|
blocker: { code: 'eligibility', message: 'Waiting for safe removal eligibility checks.', automatic: true } });
|
|
return id;
|
|
})();
|
|
return { ok: true, warnings: [], deploymentId: operation };
|
|
}
|
|
|
|
/** One host-wide FIFO, shared by collections and individual launches. */
|
|
export function creationQueue() {
|
|
type Entry = { id: string; server: string; state: string; stage: string };
|
|
const jobs = db().prepare(`SELECT id, server, state, stage FROM server_deployments
|
|
WHERE server_deleted_at IS NULL AND action IN ('create', 'rebuild') AND state IN ('queued', 'running', 'awaiting-health')
|
|
ORDER BY started_at, rowid`).all() as Entry[];
|
|
return { active: jobs.filter(job => job.state !== 'queued'), queued: jobs.filter(job => job.state === 'queued') };
|
|
}
|