701 lines
55 KiB
TypeScript
701 lines
55 KiB
TypeScript
import { isIP } from 'node:net';
|
|
import { setImmediate as yieldToController } from 'node:timers/promises';
|
|
import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js';
|
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
import { posix } from 'node:path';
|
|
import { db } from '../db/index.js';
|
|
import { ensureHostSchema } from '../hosts/schema.js';
|
|
import { unsettledCancelledCommand } from './cancellation.js';
|
|
import { config, relDestForKind, serverDir } from '../config.js';
|
|
import { nativeInstance, nativeRequest, unplacedNativeCreation, type NativeInstance } from '../backends/native.js';
|
|
import { getHost, getHostOperationStatus, queueHostOperation, queueHostOperationSerialized, releaseHostPorts, reserveHostPort } from '../hosts/index.js';
|
|
import { newestReadyLibraryId, assertReadyLibrary } from '../game-files/index.js';
|
|
import { blueprintRecipe, captureRecipePlan, recipeForDeployment, recipeStorage, summarizeRecipe, type LaunchRecipePlan } from '../fleet/launch-recipes.js';
|
|
import { prepareNativeLaunch, evaluateNativePlacement, type PreparedNativeLaunch } from './launch-preparation.js';
|
|
import { getServerKey, saveServerKey } from '../fleet/server-keys.js';
|
|
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
|
import { nextLobbyNumber, type CreateServerInput, type CreateServerResult } from './instances.js';
|
|
import { beginDeployment, deploymentStage, finishDeployment, REMOVING_SERVER } from './history.js';
|
|
import { recordProgress, type CheckInput } from './progress.js';
|
|
import { trackActivity } from '../pipeline/activity.js';
|
|
import { fleetSettings } from '../settings.js';
|
|
import { resolveRecoveredHealthOperation } from '../maintenance/recovery.js';
|
|
import { probeServerStatus, probePlayerEvidence } from '../fleet/status-evidence.js';
|
|
import { checkRelease, freshRelease, probeVersion } from '../integrations/plutonium.js';
|
|
import { getServerScript, recordScript, removeScript } from '../fleet/inventory.js';
|
|
import { sha256Buffer } from '../fleet/files.js';
|
|
import type { StagedFileChange } from '../fleet/staged-files.js';
|
|
import type { LaunchPreview } from '../../deployment-plan.js';
|
|
import type { ProvisioningAction } from '../../provisioning.js';
|
|
import { hostAgentUpdatesEnabled } from '../../hosts.js';
|
|
import { cancelCreation, finishCreationCancellation } from './cancel-creation.js';
|
|
import { hostMigration, processMigrationRemoval, reconcileHostDrains } from './host-drain.js';
|
|
|
|
export type NativeSelection = import('../../hosts.js').NativePlacement;
|
|
type NativeRecipe = LaunchRecipePlan & { native?: NativeSelection };
|
|
interface Job { selection?: string | null; id: string; server: string; action: ProvisioningAction; state: string; submitted_at: string | null; stage: string;
|
|
instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; files_json: string | null; applied_at: string | null }
|
|
interface FilePlan { id: number; path: string; contentBase64: string | null; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
|
|
|
|
/** Validate a launch request without selecting a host or consuming capacity. */
|
|
export function nativeLaunchSelection(input: Record<string, unknown>, game: string): NativeSelection {
|
|
if (input.hostId !== undefined && typeof input.hostId !== 'string') throw new Error('Invalid host.');
|
|
const libraryId = newestReadyLibraryId(game);
|
|
if (!libraryId) throw new Error(`Upload ${game.toUpperCase()} game files before launching a server.`);
|
|
if (input.libraryId !== undefined && input.libraryId !== '' && input.libraryId !== libraryId) {
|
|
throw new Error(`The current ${game.toUpperCase()} game files changed. Refresh the launch preview.`);
|
|
}
|
|
const image = input.image === undefined || input.image === '' ? config.komodo.defaultImage : input.image;
|
|
if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(image)) throw new Error('Invalid runtime image.');
|
|
const automatic = input.automatic === true || !input.hostId;
|
|
if (!automatic) {
|
|
const host = getHost(input.hostId as string);
|
|
if (!host) throw new Error('The selected host was not found.');
|
|
if (host.decommission) throw new Error('The selected host is being removed. Choose another host.');
|
|
}
|
|
const rawNetwork = input.automatic === true ? input.network ?? {} : input;
|
|
if (!rawNetwork || typeof rawNetwork !== 'object' || Array.isArray(rawNetwork)) throw new Error('Invalid network overrides.');
|
|
const network = rawNetwork as NativeSelection;
|
|
const bindAddress = network.bindAddress || undefined;
|
|
if (bindAddress !== undefined && (typeof bindAddress !== 'string' || !isIP(bindAddress))) throw new Error('Invalid bind address.');
|
|
if (network.publicAddress !== undefined && (typeof network.publicAddress !== 'string' || network.publicAddress.length > 253)) throw new Error('Invalid public address.');
|
|
if (network.publicPort !== undefined && (!Number.isInteger(network.publicPort) || Number(network.publicPort) < 1 || Number(network.publicPort) > 65535)) throw new Error('Invalid public port.');
|
|
return { hostId: automatic ? undefined : input.hostId as string, libraryId, image, automatic,
|
|
bindAddress, publicAddress: network.publicAddress, publicPort: network.publicPort,
|
|
network: { bindAddress, publicAddress: network.publicAddress, publicPort: network.publicPort } };
|
|
}
|
|
|
|
export function nativeSelection(input: Record<string, unknown>, game: string, options: Pick<PlacementOptions, 'planned'> = {}): NativeSelection {
|
|
const selection = nativeLaunchSelection(input, game);
|
|
const { host } = choosePlacement({ hostId: selection.hostId, libraryId: selection.libraryId,
|
|
image: selection.image!, bindAddress: selection.bindAddress, ...options });
|
|
return { ...selection, hostId: host.id, bindAddress: selection.bindAddress ?? host.bindAddress,
|
|
publicAddress: selection.publicAddress ?? host.publicAddress };
|
|
}
|
|
|
|
export function previewNativeCreation(input: Record<string, unknown>): LaunchPreview {
|
|
let recipe: NativeRecipe;
|
|
let blueprintName: string | null = null;
|
|
if (typeof input.deploymentId === 'string') {
|
|
const previous = recipeForDeployment(input.deploymentId);
|
|
if (!previous) throw new Error('The recorded deployment is unavailable.');
|
|
recipe = captureRecipePlan(previous.recipe.game, previous.recipe.mapSlug);
|
|
blueprintName = previous.blueprintName;
|
|
} else if (typeof input.blueprintId === 'string') ({ recipe, blueprintName } = blueprintRecipe(input.blueprintId));
|
|
else {
|
|
if ((input.game !== 't5' && input.game !== 't6') || typeof input.mapSlug !== 'string') throw new Error('Choose a supported game and map.');
|
|
recipe = captureRecipePlan(input.game, input.mapSlug);
|
|
}
|
|
const selection = nativeSelection(input, recipe.game);
|
|
recipe.native = selection; recipe.image = selection.image!;
|
|
const base = `${recipe.game}_zombie_${recipe.mapSlug}`;
|
|
const lobby = nextLobbyNumber(base);
|
|
const name = lobby === null ? null : `${base}_${lobby}`;
|
|
const port = nextNativePort(selection);
|
|
const id = randomUUID(); const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
|
db().prepare('INSERT INTO server_launch_previews(id,expires_at,server,port,recipe,blueprint_name) VALUES(?,?,?,?,?,?)')
|
|
.run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName);
|
|
const checks: LaunchPreview['checks'] = [
|
|
{ label: 'Host capacity', state: 'pass', detail: selection.automatic ? 'Capacity available. The least loaded eligible host is selected when provisioning starts.' : `${getHost(selection.hostId!)!.name}: capacity available.` },
|
|
{ label: 'Game files', state: 'pass', detail: `The latest ${recipe.game.toUpperCase()} game files are verified on an eligible host.` },
|
|
{ label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All lobby IDs for this map are reserved.' },
|
|
{ label: 'Host port', state: port ? 'pass' : 'blocked', detail: port ? selection.automatic ? 'UDP/TCP capacity available; port assigned at placement.' : `${selection.bindAddress}:${port} / UDP and TCP` : 'No available UDP/TCP port on this host.' },
|
|
{ label: 'Configuration', state: 'pass', detail: recipe.configSource }
|
|
];
|
|
return { id, expiresAt, name, port, hasSavedKey: !!name && !!getServerKey(name), recipe: summarizeRecipe(recipe), blueprintName, checks,
|
|
ready: checks.every(check => check.state !== 'blocked') };
|
|
}
|
|
|
|
export function nextNativePort(selection: NativeSelection, taken: number[] = []): number | null {
|
|
const host = selection.hostId ? getHost(selection.hostId) : null;
|
|
return host ? availableHostPort(host, { bindAddress: selection.bindAddress,
|
|
planned: taken.map(port => ({ hostId: host.id, port })) }) : null;
|
|
}
|
|
|
|
export function queueNativeCreation(input: CreateServerInput): CreateServerResult {
|
|
try {
|
|
return db().transaction(() => {
|
|
const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id=?').get(input.previewId) as
|
|
{ id: string; recipe: string; server: string; port: number | null; expires_at: string; deployment_id: string | null; blueprint_name: string | null } | undefined : undefined;
|
|
if (input.previewId && !preview) throw new Error('Review a fresh launch preview.');
|
|
if (preview?.deployment_id) return { ok: true as const, name: preview.server, port: preview.port, deploymentId: preview.deployment_id };
|
|
if (preview && preview.expires_at <= new Date().toISOString()) throw new Error('Launch preview expired.');
|
|
const saved = preview ? JSON.parse(preview.recipe) as NativeRecipe : undefined;
|
|
const game = input.game ?? 't5';
|
|
if (input.gameMode !== 'zombie' || (saved && (saved.game !== game || saved.mapSlug !== input.mapSlug))) throw new Error('Selection changed. Review a fresh launch preview.');
|
|
const selection = nativeLaunchSelection((saved?.native ?? input) as unknown as Record<string, unknown>, game);
|
|
const lobby = nextLobbyNumber(`${game}_zombie_${input.mapSlug}`);
|
|
if (lobby === null) throw new Error('All lobby IDs are reserved.');
|
|
const name = `${game}_zombie_${input.mapSlug}_${lobby}`;
|
|
if ((input.expectedName && input.expectedName !== name) || (preview && preview.server !== name)) throw new Error('Available lobby changed. Review a fresh preview.');
|
|
const result = queueNativeCreationIntent({ server: name, game, mapSlug: input.mapSlug, selection,
|
|
plutoKey: input.plutoKey, createdBy: input.createdBy, blueprintName: preview?.blueprint_name,
|
|
recipe: saved ?? captureRecipePlan(game, input.mapSlug) });
|
|
if (result.ok && preview) db().prepare('UPDATE server_launch_previews SET deployment_id=?,port=NULL WHERE id=?').run(result.deploymentId, preview.id);
|
|
return result;
|
|
})();
|
|
} catch (error) { return { ok: false, kind: 'bad-request', message: (error as Error).message }; }
|
|
}
|
|
|
|
/** Persist the reviewed logical identity; all host/port/resource decisions belong to provisioning. */
|
|
export function queueNativeCreationIntent(input: { server: string; game: 't5' | 't6'; mapSlug: string; selection: NativeSelection;
|
|
plutoKey?: string; createdBy?: string; blueprintName?: string | null; recipe?: LaunchRecipePlan }): CreateServerResult {
|
|
try {
|
|
return db().transaction(() => {
|
|
const expected = `${input.game}_zombie_${input.mapSlug}_${nextLobbyNumber(`${input.game}_zombie_${input.mapSlug}`)}`;
|
|
if (input.server !== expected || nativeInstance(input.server)) throw new Error('Available lobby changed. Review a fresh preview.');
|
|
const key = input.plutoKey?.trim() || getServerKey(input.server);
|
|
if (!key) throw new Error(`Save a Plutonium server key for ${input.server}.`);
|
|
saveServerKey(input.server, key);
|
|
const recipe: NativeRecipe = { ...(input.recipe ?? captureRecipePlan(input.game, input.mapSlug)),
|
|
native: input.selection, image: input.selection.image! };
|
|
const id = beginDeployment(input.server, 'create', input.createdBy ?? 'ui');
|
|
db().prepare("UPDATE server_deployments SET state='queued' WHERE id=?").run(id);
|
|
db().prepare('INSERT INTO server_creation_jobs(deployment_id,server,port,game_mode,map_slug,expected_map) VALUES(?,?,NULL,?,?,?)')
|
|
.run(id, input.server, 'zombie', input.mapSlug, recipe.expectedMap);
|
|
db().prepare('INSERT INTO native_creation_intents(deployment_id,selection) VALUES(?,?)').run(id, JSON.stringify(input.selection));
|
|
const stored = recipeStorage(recipe);
|
|
db().prepare('INSERT INTO server_launch_recipes(deployment_id,recipe,recipe_summary,blueprint_name) VALUES(?,?,?,?)')
|
|
.run(id, stored.recipe, stored.recipeSummary, input.blueprintName ?? null);
|
|
deploymentStage(id, 'Awaiting placement', { phase: 'eligibility', recipeStatus: 'queued' });
|
|
return { ok: true as const, name: input.server, port: null, deploymentId: id };
|
|
})();
|
|
} catch (error) { return { ok: false, kind: 'bad-request', message: (error as Error).message }; }
|
|
}
|
|
|
|
export function bindNativeOperation(id: string, server: string): void {
|
|
const instance = nativeInstance(server);
|
|
if (instance) db().prepare('INSERT OR IGNORE INTO native_provisioning(deployment_id,instance_id,generation) VALUES(?,?,?)').run(id, instance.id, instance.generation);
|
|
}
|
|
|
|
/** Cancelling an unsubmitted launch releases only its local reservation. */
|
|
export function cancelQueuedNativeCreation(server: string, triggeredBy: string): string | null {
|
|
if (getServer(server)) return null;
|
|
const unplaced = unplacedNativeCreation(server);
|
|
if (unplaced) return cancelCreation(unplaced, triggeredBy);
|
|
const instance = nativeInstance(server);
|
|
if (!instance) return null;
|
|
return db().transaction(() => {
|
|
const creation = db().prepare(`SELECT d.id FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id
|
|
WHERE n.instance_id=? AND d.action='create' AND d.state='queued' AND n.operation_id IS NULL`).get(instance.id) as { id: string } | undefined;
|
|
if (!creation) return null;
|
|
return cancelCreation(creation.id, triggeredBy);
|
|
})();
|
|
}
|
|
|
|
export function queueNativeRebuild(name: string, triggeredBy: string): CreateServerResult {
|
|
try {
|
|
const instance = nativeInstance(name)!; const server = getServer(name);
|
|
const blocker = activeBetaRemovalBlocker(name);
|
|
if (blocker) throw new Error(blocker);
|
|
if (!server?.map_slug || !getServerKey(name)) throw new Error('A saved map and server key are required before rebuilding.');
|
|
assertReadyLibrary(instance.host_id, instance.library_id);
|
|
const recipe: NativeRecipe = { ...captureRecipePlan(server.game as 't5' | 't6', server.map_slug), image: instance.image,
|
|
native: { hostId: instance.host_id, libraryId: instance.library_id, image: instance.image, bindAddress: instance.bind_address } };
|
|
return db().transaction(() => {
|
|
const id = beginDeployment(name, 'rebuild', triggeredBy);
|
|
db().prepare("UPDATE server_deployments SET state='waiting' WHERE id=?").run(id);
|
|
db().prepare('INSERT INTO server_creation_jobs(deployment_id,server,port,game_mode,map_slug,expected_map) VALUES(?,?,?,?,?,?)')
|
|
.run(id, name, instance.port, 'zombie', recipe.mapSlug, recipe.expectedMap);
|
|
const stored = recipeStorage(recipe);
|
|
db().prepare('INSERT INTO server_launch_recipes(deployment_id,recipe,recipe_summary) VALUES(?,?,?)').run(id, stored.recipe, stored.recipeSummary);
|
|
deploymentStage(id, 'Waiting to rebuild when empty', { phase: 'eligibility', recipeStatus: 'queued' });
|
|
return { ok: true as const, name, port: instance.port, deploymentId: id };
|
|
})();
|
|
} catch (error) { return { ok: false, kind: 'bad-request', message: (error as Error).message }; }
|
|
}
|
|
|
|
function filesFor(job: Job): FilePlan[] {
|
|
const rows = db().prepare(`SELECT f.* FROM staged_file_changes f JOIN provisioning_sources s
|
|
ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
|
|
WHERE s.operation_id=? AND f.server=? AND f.state='pending' ORDER BY f.id`).all(job.id, job.server) as StagedFileChange[];
|
|
return rows.map(row => ({ id: row.id, path: posix.join(relDestForKind(row.kind, job.server.startsWith('t6_') ? 't6' : 't5'), row.rel_path),
|
|
contentBase64: row.content?.toString('base64') ?? null, sha256: row.content ? sha256Buffer(row.content) : null,
|
|
checkBefore: !!row.check_before, expectedBeforeSha: row.expected_before_sha }));
|
|
}
|
|
|
|
/** Agent receipts are the authoritative before images; do not read a controller mount. */
|
|
function applyReceipt(job: Job, result: Record<string, unknown>) {
|
|
if (job.applied_at || !job.files_json) return;
|
|
const plans = JSON.parse(job.files_json) as FilePlan[];
|
|
const before = result.before as { id?: number; path: string; contentBase64: string | null }[] | undefined;
|
|
db().transaction(() => {
|
|
for (const plan of plans) {
|
|
const snapshot = before?.find(item => item.id === plan.id || item.path === plan.path);
|
|
if (!snapshot) throw new Error('Agent activation receipt is missing a before image.');
|
|
const row = db().prepare('SELECT * FROM staged_file_changes WHERE id=?').get(plan.id) as StagedFileChange;
|
|
if (!row || row.state === 'applied') continue;
|
|
const bytes = snapshot.contentBase64 === null ? null : Buffer.from(snapshot.contentBase64, 'base64');
|
|
const prior = getServerScript(job.server, row.rel_path);
|
|
if (row.source_kind === 'beta') {
|
|
if (bytes !== null) db().prepare(`INSERT OR IGNORE INTO beta_file_snapshots(beta_id,server,rel_path,prior_content,prior_sha256,prior_size) VALUES(?,?,?,?,?,?)`)
|
|
.run(row.source_id, job.server, row.rel_path, bytes, sha256Buffer(bytes), bytes.length);
|
|
db().prepare(`UPDATE beta_server_scripts SET had_original=?,prior_version_id=? WHERE beta_id=? AND server=?
|
|
AND script_id IN (SELECT id FROM scripts WHERE target_rel_path=? AND kind=?)`).run(bytes === null ? 0 : 1, prior?.script_version_id ?? null, row.source_id, job.server, row.rel_path, row.kind);
|
|
db().prepare('INSERT OR IGNORE INTO beta_file_changes(beta_id,server,rel_path) VALUES(?,?,?)').run(row.source_id, job.server, row.rel_path);
|
|
db().prepare('UPDATE beta_servers SET deployed_at=?,updated_at=? WHERE beta_id=? AND server=?').run(new Date().toISOString(), new Date().toISOString(), row.source_id, job.server);
|
|
}
|
|
if (row.deployment_file_id && !row.preserve_before_image) db().prepare('UPDATE deployment_files SET verb=?,content_before=?,sha256_before=? WHERE id=?')
|
|
.run(row.content === null ? 'removed' : bytes === null ? 'added' : 'updated', bytes, bytes === null ? null : sha256Buffer(bytes), row.deployment_file_id);
|
|
if (row.content === null) removeScript(job.server, row.rel_path);
|
|
else recordScript(job.server, row.rel_path, row.script_version_id && db().prepare('SELECT 1 FROM script_versions WHERE id=?').get(row.script_version_id) ? row.script_version_id : null,
|
|
sha256Buffer(row.content), row.content.length, row.inventory_source);
|
|
db().prepare("UPDATE staged_file_changes SET state='applied',applied_at=? WHERE id=?").run(new Date().toISOString(), row.id);
|
|
}
|
|
db().prepare('UPDATE native_provisioning SET applied_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id);
|
|
})();
|
|
}
|
|
|
|
/** Retain confirmed file snapshots and instance identity even after observation is cancelled. */
|
|
export function reconcileNativeProvisioningReceipt(id: string): void {
|
|
const job = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.operation_id,n.files_json,n.applied_at
|
|
FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id
|
|
WHERE d.id=? AND d.server_deleted_at IS NULL`).get(id) as Job | undefined;
|
|
if (!job) return;
|
|
const migration = hostMigration(id);
|
|
if (job.state === 'cancelled' && migration?.remove_operation_id && !migration.removed_at) {
|
|
const removal = getHostOperationStatus(migration.remove_operation_id);
|
|
if (removal?.state === 'failed') {
|
|
db().prepare('DELETE FROM ec_host_ports WHERE host_id=? AND instance_id=?').run(migration.target_host_id, migration.instance_id);
|
|
} else if (removal?.state === 'completed') {
|
|
const original = nativeInstance(job.server);
|
|
if (original?.id === migration.instance_id && original.host_id === migration.source_host_id && original.generation === job.generation) {
|
|
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(removal.updatedAt, original.id);
|
|
releaseHostPorts(original.id);
|
|
db().prepare('DELETE FROM servers WHERE name=?').run(job.server);
|
|
db().prepare('UPDATE ec_host_migrations SET removed_at=? WHERE deployment_id=?').run(removal.updatedAt, id);
|
|
recordProgress(id, { event: { kind: 'cleanup', message: 'Cancelled migration: source removal completed; replacement creation was not started.' } });
|
|
}
|
|
}
|
|
}
|
|
if (!job.operation_id) return;
|
|
const operation = getHostOperationStatus(job.operation_id);
|
|
if (operation?.state === 'failed') {
|
|
for (const file of JSON.parse(job.files_json ?? '[]') as FilePlan[]) {
|
|
db().prepare("UPDATE staged_file_changes SET state=? WHERE id=? AND state='applying'")
|
|
.run(job.state === 'cancelled' ? 'cancelled' : 'pending', file.id);
|
|
}
|
|
return;
|
|
}
|
|
if (operation?.state !== 'completed') return;
|
|
const instance = nativeInstance(job.server);
|
|
if (!instance || instance.id !== job.instance_id) return;
|
|
if (job.action === 'destroy') {
|
|
if (instance.generation !== job.generation) return;
|
|
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(operation.updatedAt, instance.id);
|
|
releaseHostPorts(instance.id);
|
|
db().prepare('DELETE FROM servers WHERE name=?').run(job.server);
|
|
finishCreationCancellation(job.id);
|
|
return;
|
|
}
|
|
if (job.action === 'rebuild' && !hostMigration(id) && instance.generation === job.generation) {
|
|
db().prepare('UPDATE native_instances SET generation=generation+1 WHERE id=?').run(instance.id);
|
|
// Replacements queued while the delivered rebuild was settling bound the
|
|
// previous generation. Only untouched, newer jobs may follow this receipt.
|
|
if (job.state === 'cancelled') db().prepare(`UPDATE native_provisioning SET generation=?
|
|
WHERE instance_id=? AND generation=? AND operation_id IS NULL AND stage_operation_id IS NULL
|
|
AND deployment_id IN (SELECT newer.id FROM server_deployments newer
|
|
WHERE newer.rowid>(SELECT rowid FROM server_deployments WHERE id=?)
|
|
AND newer.server_deleted_at IS NULL AND newer.state IN ('queued','waiting'))`)
|
|
.run(job.generation + 1, instance.id, job.generation, id);
|
|
} else if (instance.generation !== job.generation && !(job.action === 'rebuild' && instance.generation === job.generation + 1)) return;
|
|
applyReceipt(job, (operation.result ?? {}) as Record<string, unknown>);
|
|
db().prepare('UPDATE native_provisioning SET applied_at=COALESCE(applied_at,?) WHERE deployment_id=?').run(operation.updatedAt, id);
|
|
}
|
|
|
|
function hold(job: Job, message: string) { if (!stillActive(job)) return; deploymentStage(job.id, message, { blocker: { code: 'native-host', message, automatic: true } }); }
|
|
function stillActive(job: Job): boolean {
|
|
return !!db().prepare(`SELECT 1 FROM server_deployments WHERE id=? AND server_deleted_at IS NULL
|
|
AND state IN ('queued','waiting','running','awaiting-health','interrupted')`).get(job.id);
|
|
}
|
|
function complete(job: Job) {
|
|
db().transaction(() => {
|
|
db().prepare('UPDATE native_provisioning SET verified_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id);
|
|
db().prepare('UPDATE restart_rollout SET healthy_at=?,error=NULL WHERE operation_id=?').run(new Date().toISOString(), job.id);
|
|
finishDeployment(job.id, { ok: true });
|
|
})();
|
|
}
|
|
|
|
export async function processNativeProvisioning(eligibility: (operation: { id: string; server: string; action: string; state: string; stage: string }) => string | null,
|
|
options: { wait?: boolean } = {}): Promise<void> {
|
|
ensureHostSchema(db());
|
|
const cancelled = db().prepare(`SELECT d.id FROM server_deployments d
|
|
JOIN native_provisioning n ON n.deployment_id=d.id
|
|
LEFT JOIN ec_host_operations o ON o.id=n.operation_id
|
|
LEFT JOIN ec_host_migrations m ON m.deployment_id=d.id
|
|
LEFT JOIN ec_host_operations removal ON removal.id=m.remove_operation_id
|
|
WHERE d.state='cancelled' AND d.server_deleted_at IS NULL AND (
|
|
(o.state='completed' AND (n.applied_at IS NULL OR d.action='destroy'))
|
|
OR (o.state='failed' AND EXISTS (SELECT 1 FROM staged_file_changes f JOIN provisioning_sources s
|
|
ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
|
|
WHERE s.operation_id=d.id AND f.state='applying'))
|
|
OR (m.removed_at IS NULL AND (removal.state='completed' OR (removal.state='failed'
|
|
AND EXISTS (SELECT 1 FROM ec_host_ports p WHERE p.host_id=m.target_host_id AND p.instance_id=m.instance_id))))
|
|
)`).all() as { id: string }[];
|
|
for (const row of cancelled) {
|
|
try { reconcileNativeProvisioningReceipt(row.id); }
|
|
catch (error) {
|
|
deploymentStage(row.id, 'Cancelled; waiting to reconcile the delivered command: ' + (error as Error).message);
|
|
}
|
|
}
|
|
reconcileHostDrains();
|
|
const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.files_json,n.applied_at,q.selection
|
|
FROM server_deployments d LEFT JOIN native_provisioning n ON n.deployment_id=d.id
|
|
LEFT JOIN native_creation_intents q ON q.deployment_id=d.id
|
|
WHERE (n.deployment_id IS NOT NULL OR q.deployment_id IS NOT NULL) AND d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') ORDER BY d.started_at,d.rowid`).all() as Job[];
|
|
// One host's observation must not prevent another job being admitted.
|
|
// Per-job ownership also covers overlapping direct calls and worker passes.
|
|
const work = jobs.map(job => {
|
|
const pending = activeJobs.get(job.id);
|
|
if (pending) return pending;
|
|
const work = trackActivity(`provisioning:${job.id}`, `Evaluating ${job.action} for ${job.server}`,
|
|
() => processJob(job, eligibility))
|
|
.catch(error => { if (stillActive(job)) hold(job, (error as Error).message); })
|
|
.finally(() => { activeJobs.delete(job.id); preparingJobs.delete(job.id); });
|
|
activeJobs.set(job.id, work);
|
|
return work;
|
|
});
|
|
if (options.wait !== false) { await Promise.all(work); reconcileHostDrains(); }
|
|
}
|
|
|
|
const activeJobs = new Map<string, Promise<void>>();
|
|
// Preparation owns startup capacity until its command is committed or discarded.
|
|
const preparingJobs = new Set<string>();
|
|
function otherPreparations(id: string): number { return preparingJobs.size - Number(preparingJobs.has(id)); }
|
|
|
|
let launchCommit: Promise<void> = Promise.resolve();
|
|
function commitPreparedLaunch(commit: () => void): Promise<void> {
|
|
// Worker replies can arrive together. Persist one launch per event-loop turn
|
|
// so a batch of large SQLite writes cannot hold off requests and health timers.
|
|
const next = launchCommit.then(async () => { await yieldToController(); commit(); });
|
|
launchCommit = next.catch(() => {});
|
|
return next;
|
|
}
|
|
|
|
function sameLaunchFiles(first: LaunchRecipePlan, second: LaunchRecipePlan): boolean {
|
|
const selected = (recipe: LaunchRecipePlan) => ({ game: recipe.game, mapSlug: recipe.mapSlug,
|
|
expectedMap: recipe.expectedMap, configFile: recipe.configFile, configSource: recipe.configSource, files: recipe.files });
|
|
return JSON.stringify(selected(first)) === JSON.stringify(selected(second));
|
|
}
|
|
|
|
/** Capacity history and host ranking run in the background reader, one job at a time. */
|
|
async function placeQueuedCreation(job: Job, instance: NativeInstance | null): Promise<NativeInstance | null> {
|
|
const recipe = recipeForDeployment(job.id)!.recipe as NativeRecipe;
|
|
const prior: NativeSelection = job.selection ? JSON.parse(job.selection) : recipe.native!;
|
|
const network = prior.automatic ? prior.network ?? {} : prior;
|
|
const options: PlacementOptions = { hostId: prior.automatic ? undefined : prior.hostId,
|
|
libraryId: prior.libraryId, image: prior.image ?? recipe.image, bindAddress: network.bindAddress,
|
|
thorough: true, dispatch: true, excludeInstanceId: instance?.id };
|
|
const decision = await evaluateNativePlacement(options);
|
|
return db().transaction(() => {
|
|
const state = db().prepare('SELECT state FROM server_deployments WHERE id=? AND server_deleted_at IS NULL').get(job.id) as { state: string } | undefined;
|
|
if (state?.state !== 'queued') return null;
|
|
const live = nativeInstance(job.server);
|
|
if (instance ? live?.id !== instance.id || live.generation !== instance.generation : !!live || !!getServer(job.server)) return null;
|
|
const active = (db().prepare(`SELECT count(*) AS n FROM server_deployments WHERE id!=? AND server_deleted_at IS NULL
|
|
AND action IN ('create','rebuild') AND state IN ('running','awaiting-health')`).get(job.id) as { n: number }).n;
|
|
if (active + otherPreparations(job.id) >= fleetSettings().createConcurrency) { hold(job, 'Waiting for startup capacity.'); return null; }
|
|
if (getHost(decision.hostId)?.lastHeartbeat !== decision.lastHeartbeat) { hold(job, 'Host measurements changed; waiting for another placement check.'); return null; }
|
|
// A short current-state check protects against reservations made while the
|
|
// reader ranked hosts. It never scans resource history or contacts agents.
|
|
const { host, port } = choosePlacement({ ...options, hostId: decision.hostId, thorough: false });
|
|
const bind = network.bindAddress ?? host.bindAddress;
|
|
const instanceId = instance?.id ?? randomUUID();
|
|
if (instance) releaseHostPorts(instanceId);
|
|
reserveHostPort(host.id, instanceId, { port, bindAddress: bind, protocol: 'udp' });
|
|
reserveHostPort(host.id, instanceId, { port, bindAddress: bind, protocol: 'tcp' });
|
|
if (instance) db().prepare(`UPDATE native_instances SET host_id=?,bind_address=?,port=?,public_address=?,public_port=? WHERE id=?`)
|
|
.run(host.id, bind, port, network.publicAddress ?? host.publicAddress, network.publicPort ?? null, instanceId);
|
|
else {
|
|
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,bind_address,port,public_address,public_port,created_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?)`).run(instanceId, job.server, host.id, prior.libraryId, options.image, bind, port,
|
|
network.publicAddress ?? host.publicAddress, network.publicPort ?? null, new Date().toISOString());
|
|
db().prepare('INSERT INTO native_provisioning(deployment_id,instance_id,generation) VALUES(?,?,1)').run(job.id, instanceId);
|
|
}
|
|
db().prepare('UPDATE server_creation_jobs SET port=? WHERE deployment_id=?').run(port, job.id);
|
|
recipe.native = { ...prior, hostId: host.id, bindAddress: bind, publicAddress: network.publicAddress ?? host.publicAddress, publicPort: network.publicPort };
|
|
const stored = recipeStorage(recipe);
|
|
db().prepare('UPDATE server_launch_recipes SET recipe=?,recipe_summary=? WHERE deployment_id=?').run(stored.recipe, stored.recipeSummary, job.id);
|
|
db().prepare('UPDATE server_launch_previews SET port=? WHERE deployment_id=?').run(port, job.id);
|
|
deploymentStage(job.id, `Placed on ${host.name} after CPU, memory, history and port checks`, { phase: 'eligibility' });
|
|
preparingJobs.add(job.id);
|
|
return nativeInstance(job.server)!;
|
|
})();
|
|
}
|
|
|
|
async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
|
if (!stillActive(job)) return;
|
|
const unsettled = unsettledCancelledCommand(job.server, job.id);
|
|
if (unsettled) { hold(job, 'Waiting for the already-delivered command from cancelled operation ' + unsettled + ' to settle.'); return; }
|
|
let instance = nativeInstance(job.server);
|
|
if (job.action === 'create' && !job.operation_id && !job.stage_operation_id && !job.submitted_at) {
|
|
// A resumed bound job owns its original identity. An unplaced intent
|
|
// cannot adopt an instance or registered server that appeared later.
|
|
if (job.instance_id ? !instance || instance.id !== job.instance_id || instance.generation !== job.generation
|
|
: !!instance || !!getServer(job.server)) {
|
|
finishDeployment(job.id, { ok: false, message: 'Original instance was retired or replaced; no command was sent.' }); return;
|
|
}
|
|
const settings = fleetSettings();
|
|
const active = db().prepare(`SELECT COUNT(*) AS n FROM server_deployments WHERE id!=? AND server_deleted_at IS NULL
|
|
AND action IN ('create','rebuild') AND state IN ('running','awaiting-health')`).get(job.id) as { n: number };
|
|
if (active.n + otherPreparations(job.id) >= settings.createConcurrency) { hold(job, 'Waiting for startup capacity.'); return; }
|
|
if (eligibility(job)) return;
|
|
instance = await placeQueuedCreation(job, instance);
|
|
if (!instance) return;
|
|
job.instance_id = instance.id; job.generation = instance.generation;
|
|
}
|
|
const migration = hostMigration(job.id);
|
|
if (!instance || instance.id !== job.instance_id || (instance.generation !== job.generation && !(job.action === 'rebuild' && !migration && instance.generation === job.generation + 1))) {
|
|
finishDeployment(job.id, { ok: false, message: 'Original instance was retired or replaced; no command was sent.' }); return;
|
|
}
|
|
if (migration && !migration.removed_at) {
|
|
await processMigrationRemoval(job, instance, migration, () => eligibility(job));
|
|
return;
|
|
}
|
|
const host = getHost(instance.host_id);
|
|
if (!host?.connected || host.mode === 'disabled') {
|
|
db().prepare("UPDATE servers SET container_state='unknown',last_players=NULL,last_roster=NULL WHERE name=?").run(job.server);
|
|
hold(job, 'Host is unavailable; existing server state is unknown. Waiting for reconnection.'); return;
|
|
}
|
|
if (job.operation_id) {
|
|
const operation = getHostOperationStatus(job.operation_id);
|
|
if (operation?.kind === 'instance.cancel-create' && !['completed', 'failed'].includes(operation.state) && !host.inventory.capabilities?.includes('instance.cancel-create')) {
|
|
hold(job, hostAgentUpdatesEnabled(host.inventory)
|
|
? 'Waiting for the host agent update. New command delivery is paused so its current batch can finish and the automatic updater can run. Cleanup resumes after the updated agent connects.'
|
|
: 'Update the host agent to support creation cancellation: rerun the current installer on this host. Automatic updates are not enabled. New command delivery is paused; cleanup and port release await the updated agent.'); return;
|
|
}
|
|
if (!operation || operation.state === 'unknown') { hold(job, 'Agent result is unknown. Reconciling the original operation; no replacement or repeated command will be sent.'); return; }
|
|
if (operation.state === 'failed') {
|
|
if (migration) {
|
|
if (Date.now() - Date.parse(operation.updatedAt) >= 60_000) {
|
|
db().transaction(() => {
|
|
db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id);
|
|
db().prepare('UPDATE native_provisioning SET operation_id=NULL WHERE deployment_id=?').run(job.id);
|
|
db().prepare("UPDATE server_deployments SET state='waiting',submitted_at=NULL WHERE id=?").run(job.id);
|
|
})();
|
|
}
|
|
hold(job, `${operation.error ?? 'Destination creation failed before mutation.'} Retrying after fresh capacity checks.`);
|
|
return;
|
|
}
|
|
// The agent reports failed only when no mutation began. Ambiguous
|
|
// post-mutation failures remain unknown and retain their capacity.
|
|
db().transaction(() => {
|
|
db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id);
|
|
const files = JSON.parse(job.files_json ?? '[]') as FilePlan[];
|
|
for (const file of files) db().prepare("UPDATE staged_file_changes SET state='pending' WHERE id=? AND state='applying'").run(file.id);
|
|
finishDeployment(job.id, { ok: false, message: operation.error ?? 'Agent operation failed before mutation.' });
|
|
})();
|
|
return;
|
|
}
|
|
if (operation.state !== 'completed') {
|
|
const blocked = operation.result as { message?: string } | null;
|
|
hold(job, blocked?.message ?? `Agent operation ${operation.state}`); return;
|
|
}
|
|
if (job.action === 'destroy') {
|
|
db().transaction(() => {
|
|
deploymentStage(job.id, REMOVING_SERVER, { phase: 'cleanup', checkpoint: { key: 'container-action', label: 'Agent confirmed instance and container removal', at: operation.updatedAt } });
|
|
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(new Date().toISOString(), instance!.id);
|
|
releaseHostPorts(instance!.id);
|
|
db().prepare('DELETE FROM servers WHERE name=?').run(job.server);
|
|
recordProgress(job.id, { checkpoint: { key: 'cleanup', label: 'Instance retired and port reservations released' } });
|
|
complete(job);
|
|
finishCreationCancellation(job.id);
|
|
})();
|
|
return;
|
|
}
|
|
if (job.action === 'rebuild' && !migration && instance.generation === job.generation) {
|
|
db().prepare('UPDATE native_instances SET generation=? WHERE id=? AND generation=?').run(job.generation + 1, instance.id, job.generation);
|
|
instance = nativeInstance(job.server)!;
|
|
}
|
|
applyReceipt(job, (operation.result ?? {}) as Record<string, unknown>);
|
|
if (job.state !== 'awaiting-health') {
|
|
db().prepare("UPDATE server_deployments SET state='awaiting-health',error=NULL,finished_at=NULL WHERE id=?").run(job.id);
|
|
recordProgress(job.id, { command: { state: 'accepted', message: `Agent confirmed ${job.action}; verifying the resulting instance.` },
|
|
checkpoint: { key: 'container-action', label: `Agent confirmed ${job.action}`, at: operation.updatedAt } });
|
|
}
|
|
const checks: CheckInput[] = [];
|
|
const check = (value: CheckInput, phase: import('../../provisioning-detail.js').ProvisioningPhase) => {
|
|
checks.push({ ...value, checkedAt: new Date().toISOString() });
|
|
recordProgress(job.id, { checks, ...(value.result === 'pass' ? { checkpoint: { key: phase, label: value.label } } : {}) });
|
|
};
|
|
deploymentStage(job.id, 'Checking container state with host agent', { phase: job.action === 'stop' ? 'verify-stopped' : 'verify-container', blocker: null, checks });
|
|
const health = await nativeRequest<{ healthy: boolean; state: string }>(instance, 'instance.inspect');
|
|
if (!stillActive(job)) return;
|
|
if (job.action === 'stop') {
|
|
const stopped = ['stopped','exited','created','dead'].includes(health.state);
|
|
check({ key: 'stopped', label: 'Stopped container verified', source: 'agent', result: stopped ? 'pass' : 'waiting', actual: health.state }, 'verify-stopped');
|
|
if (!stopped) { hold(job, 'Waiting for the agent to verify a stopped container.'); return; }
|
|
db().prepare("UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?").run(health.state, job.server);
|
|
complete(job); return;
|
|
}
|
|
check({ key: 'container', label: 'Container readiness verified', source: 'agent', result: health.healthy ? 'pass' : 'waiting', actual: health.state }, 'verify-container');
|
|
if (!health.healthy) { hold(job, `Waiting for container readiness: ${health.state}`); return; }
|
|
deploymentStage(job.id, 'Checking game status', { phase: 'verify-game', blocker: null });
|
|
const status = await probeServerStatus(job.server, operation.updatedAt);
|
|
if (!stillActive(job)) return;
|
|
check({ key: 'game', label: 'Fresh game status verified', source: status.ok ? status.source : null,
|
|
result: status.ok ? 'pass' : 'unknown', actual: status.ok ? status.players : null,
|
|
message: status.ok ? null : status.reason }, 'verify-game');
|
|
if (!status.ok) { hold(job, `Waiting for game status: ${status.reason}`); return; }
|
|
if (job.action === 'create' || job.action === 'rebuild') {
|
|
deploymentStage(job.id, 'Checking selected engine map', { phase: 'verify-map', blocker: null });
|
|
const recipe = db().prepare('SELECT expected_map AS expectedMap FROM server_creation_jobs WHERE deployment_id=?')
|
|
.get(job.id) as { expectedMap: string };
|
|
check({ key: 'map', label: 'Selected engine map verified', source: status.source,
|
|
result: status.map === recipe.expectedMap ? 'pass' : 'waiting', expected: recipe.expectedMap, actual: status.map }, 'verify-map');
|
|
if (status.map !== recipe.expectedMap) { hold(job, `Waiting for selected map ${recipe.expectedMap}; observed ${status.map ?? 'unknown'}.`); return; }
|
|
deploymentStage(job.id, 'Checking production Plutonium release', { phase: 'verify-plutonium', blocker: null });
|
|
await checkRelease();
|
|
if (!stillActive(job)) return;
|
|
const target = freshRelease();
|
|
checks.push({ key: 'plutonium-release', label: 'Production Plutonium release', source: 'manifest',
|
|
result: target === null ? 'unknown' : 'pass', actual: target });
|
|
recordProgress(job.id, { checks });
|
|
deploymentStage(job.id, 'Checking running Plutonium revision', { phase: 'verify-plutonium', blocker: null });
|
|
const revision = await probeVersion(job.server, { operationId: job.id });
|
|
if (!stillActive(job)) return;
|
|
check({ key: 'plutonium-version', label: 'Plutonium revision verified', source: 'rcon', expected: target, actual: revision,
|
|
result: revision === null || target === null ? 'unknown' : revision < target ? 'fail' : 'pass' }, 'verify-plutonium');
|
|
if (revision === null || target === null) { hold(job, 'Waiting to verify the Plutonium runtime revision.'); return; }
|
|
if (revision < target) { finishDeployment(job.id, { ok: false, message: `Running Plutonium r${revision} is older than production r${target}.` }); return; }
|
|
}
|
|
db().prepare('UPDATE servers SET container_state=?,container_checked_at=? WHERE name=?').run(health.state, new Date().toISOString(), job.server);
|
|
recordProgress(job.id, { blocker: null, checks });
|
|
complete(job); return;
|
|
}
|
|
const reason = eligibility(job);
|
|
if (reason) { hold(job, reason); return; }
|
|
if (job.action === 'destroy' || job.action === 'rebuild') {
|
|
const beta = activeBetaRemovalBlocker(job.server); if (beta) { hold(job, beta); return; }
|
|
}
|
|
const files = job.files_json ? JSON.parse(job.files_json) as FilePlan[] : filesFor(job);
|
|
if (files.length && !job.stage_operation_id) {
|
|
const stage = queueHostOperation(instance.host_id, 'files.stage', { stageId: job.id, files }, { instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:stage` });
|
|
db().prepare('UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?').run(stage.id, JSON.stringify(files), job.id);
|
|
hold(job, 'Staging and verifying files on the host.'); return;
|
|
}
|
|
if (job.stage_operation_id) {
|
|
const stage = getHostOperationStatus(job.stage_operation_id);
|
|
if (stage?.state === 'failed') { finishDeployment(job.id, { ok: false, message: stage.error ?? 'File staging failed.' }); return; }
|
|
if (stage?.state !== 'completed') { hold(job, 'Waiting for verified remote file staging.'); return; }
|
|
if (files.some(file => (db().prepare('SELECT state FROM staged_file_changes WHERE id=?').get(file.id) as { state: string } | undefined)?.state !== 'pending')) {
|
|
finishDeployment(job.id, { ok: false, message: 'Staged file request was cancelled or changed before activation.' }); return;
|
|
}
|
|
}
|
|
const settings = fleetSettings();
|
|
const count = (actions: string[]) => (db().prepare(`SELECT COUNT(*) AS n FROM server_deployments WHERE id != ? AND server_deleted_at IS NULL
|
|
AND action IN (${actions.map(() => '?').join(',')}) AND state IN ('running','awaiting-health')`).get(job.id, ...actions) as { n: number }).n;
|
|
if ((job.action === 'create' || job.action === 'rebuild') && count(['create','rebuild']) + otherPreparations(job.id) >= settings.createConcurrency) {
|
|
deploymentStage(job.id, 'Waiting for startup capacity.', { phase: 'startup-queue',
|
|
blocker: { code: 'startup-capacity', message: 'Waiting for startup capacity.', automatic: true } }); return;
|
|
}
|
|
if (job.action === 'destroy' && count(['destroy']) >= settings.destroyConcurrency) { hold(job, 'Waiting for destruction capacity.'); return; }
|
|
deploymentStage(job.id, 'Checking host eligibility', { phase: 'eligibility', blocker: null });
|
|
if (job.action === 'create' || migration) choosePlacement({ hostId: instance.host_id, libraryId: instance.library_id, image: instance.image,
|
|
bindAddress: instance.bind_address, excludeInstanceId: instance.id, thorough: false, dispatch: true });
|
|
if (job.action === 'create' || job.action === 'rebuild') assertReadyLibrary(instance.host_id, instance.library_id);
|
|
const checkOccupancy = async (): Promise<boolean> => {
|
|
if (!stillActive(job)) return false;
|
|
// Fast controller check keeps occupied lobbies out of the operation slots.
|
|
// The agent repeats this immediately before its local mutation.
|
|
deploymentStage(job.id, 'Checking container and player occupancy before submission', { phase: 'eligibility', blocker: null });
|
|
const health = await nativeRequest<{ state: string; healthy?: boolean }>(instance, 'instance.inspect');
|
|
if (!stillActive(job)) return false;
|
|
if (!['stopped','exited','created','dead','missing'].includes(health.state)) {
|
|
const occupancy = await probePlayerEvidence(job.server);
|
|
if (!stillActive(job)) return false;
|
|
const recoveredWatchdog = health.healthy && occupancy.players !== null && db().prepare(`SELECT 1 FROM provisioning_sources s
|
|
JOIN watchdog_incidents i ON i.id=s.source_id JOIN server_deployments d ON d.id=s.operation_id
|
|
WHERE s.operation_id=? AND s.kind='watchdog' AND i.kind='health' AND d.triggered_by LIKE 'watchdog:%'`).get(job.id);
|
|
if (recoveredWatchdog && occupancy.players !== null) { resolveRecoveredHealthOperation(job.id, occupancy.players, occupancy.source); return false; }
|
|
if (occupancy.players !== 0) { hold(job, occupancy.players !== null ? `Waiting for ${occupancy.players} player(s) to leave.` : 'Waiting for a fresh local player count.'); return false; }
|
|
}
|
|
return true;
|
|
};
|
|
if (!migration && job.action !== 'create' && job.action !== 'start' && !await checkOccupancy()) return;
|
|
const current = db().prepare('SELECT state FROM server_deployments WHERE id=? AND server_deleted_at IS NULL').get(job.id) as { state: string } | undefined;
|
|
if (!current || !['queued','waiting','interrupted'].includes(current.state) || eligibility(job)) return;
|
|
let prepared: PreparedNativeLaunch | undefined;
|
|
let preparedKey: string | null = null;
|
|
try {
|
|
if (job.action === 'create' || job.action === 'rebuild') {
|
|
if (count(['create','rebuild']) + otherPreparations(job.id) >= fleetSettings().createConcurrency) {
|
|
hold(job, 'Waiting for startup capacity.'); return;
|
|
}
|
|
preparingJobs.add(job.id);
|
|
preparedKey = getServerKey(job.server) ?? null;
|
|
if (!preparedKey) throw new Error('The saved server key was removed.');
|
|
const prior = recipeForDeployment(job.id)!.recipe as NativeRecipe;
|
|
deploymentStage(job.id, 'Preparing launch files for the host agent', { phase: 'prepare',
|
|
checkpoint: { key: 'startup-queue', label: 'Startup capacity available' } });
|
|
prepared = await prepareNativeLaunch({ game: prior.game, mapSlug: prior.mapSlug, native: prior.native,
|
|
instance, serverKey: preparedKey, rconPassword: config.rcon.password || randomBytes(32).toString('hex'),
|
|
rebuild: job.action === 'rebuild' && !migration });
|
|
if (job.action === 'rebuild' && !migration && !await checkOccupancy()) return;
|
|
}
|
|
const commit = db().transaction(() => {
|
|
const state = db().prepare('SELECT state FROM server_deployments WHERE id=? AND server_deleted_at IS NULL').get(job.id) as { state: string } | undefined;
|
|
if (!state || !['queued','waiting','interrupted'].includes(state.state) || eligibility(job)) return;
|
|
const live = nativeInstance(job.server);
|
|
if (live?.id !== instance!.id || live.generation !== instance!.generation) throw new Error('Instance changed during preflight.');
|
|
const liveHost = getHost(instance!.host_id);
|
|
if (!liveHost?.connected || liveHost.mode === 'disabled') { hold(job, 'Host is unavailable; waiting for reconnection.'); return; }
|
|
if (job.action === 'destroy' || job.action === 'rebuild') {
|
|
const beta = activeBetaRemovalBlocker(job.server);
|
|
if (beta) { hold(job, beta); return; }
|
|
}
|
|
if (JSON.stringify(filesFor(job)) !== JSON.stringify(files)) {
|
|
finishDeployment(job.id, { ok: false, message: 'File requests changed during preflight. Review and submit a fresh activation.' }); return;
|
|
}
|
|
// Async occupancy checks can overlap another job claiming the final slot.
|
|
if ((job.action === 'create' || job.action === 'rebuild') && count(['create','rebuild']) + otherPreparations(job.id) >= fleetSettings().createConcurrency) {
|
|
deploymentStage(job.id, 'Waiting for startup capacity.', { phase: 'startup-queue', blocker: { code: 'startup-capacity', message: 'Waiting for startup capacity.', automatic: true } }); return;
|
|
}
|
|
if (job.action === 'destroy' && count(['destroy']) >= fleetSettings().destroyConcurrency) { hold(job, 'Waiting for destruction capacity.'); return; }
|
|
if (prepared) {
|
|
if (getServerKey(job.server) !== preparedKey || !sameLaunchFiles(prepared.recipe,
|
|
captureRecipePlan(prepared.recipe.game, prepared.recipe.mapSlug))) {
|
|
hold(job, 'Launch files or credentials changed during preparation; preparing the current selection next pass.'); return;
|
|
}
|
|
assertReadyLibrary(instance!.host_id, instance!.library_id);
|
|
if (job.action === 'create' || migration) choosePlacement({ hostId: instance!.host_id, libraryId: instance!.library_id, image: instance!.image,
|
|
bindAddress: instance!.bind_address, excludeInstanceId: instance!.id, thorough: false, dispatch: true });
|
|
}
|
|
if (job.action === 'start' || job.action === 'restart' || job.action === 'rebuild') {
|
|
db().prepare(`INSERT INTO restart_rollout(server,requested_at,operation_id) VALUES(?,?,?) ON CONFLICT(server)
|
|
DO UPDATE SET requested_at=excluded.requested_at,operation_id=excluded.operation_id,healthy_at=NULL,error=NULL`).run(job.server, new Date().toISOString(), job.id);
|
|
}
|
|
recordProgress(job.id, { blocker: null, checkpoint: { key: 'eligibility', label: 'Host eligibility and safety checks passed' } });
|
|
const payload: Record<string, unknown> = files.length ? { stageId: job.id } : {};
|
|
if (prepared) {
|
|
deploymentStage(job.id, 'Preparing launch files for the host agent', { phase: 'prepare', recipeStatus: 'attempted',
|
|
checkpoint: { key: 'startup-queue', label: 'Startup capacity available' } });
|
|
const recipe = prepared.recipe;
|
|
db().prepare('UPDATE server_launch_recipes SET recipe=CAST(? AS TEXT),recipe_summary=? WHERE deployment_id=?')
|
|
.run(Buffer.from(prepared.recipeJson.buffer, prepared.recipeJson.byteOffset, prepared.recipeJson.byteLength), prepared.recipeSummary, job.id);
|
|
if (job.action === 'create') db().prepare(`INSERT INTO servers(name,komodo_type,komodo_name,game,map,game_mode,map_slug,host_dir,config_file,game_id,image,game_port,created_by,container_state)
|
|
VALUES(?,'deployment','',?,?,'zombie',?,?,?,?,?,?,'native','starting')`).run(job.server, recipe.game, recipe.expectedMap, recipe.mapSlug, serverDir(job.server),
|
|
recipe.configFile, recipe.game === 't6' ? 't6zm' : 't5sp', recipe.image, instance!.port);
|
|
for (const file of recipe.files) recordScript(job.server, file.path, file.versionId, file.sha256, file.size, 'native-create');
|
|
recordProgress(job.id, { checkpoint: { key: 'prepare', label: 'Launch files selected and command payload prepared' } });
|
|
}
|
|
const attempt = migration ? (db().prepare(`SELECT COUNT(*) AS n FROM ec_host_operations
|
|
WHERE host_id=? AND instance_id=? AND generation=? AND kind='instance.create'`).get(instance!.host_id, instance!.id, instance!.generation) as { n: number }).n : 0;
|
|
const operationOptions = { instanceId: instance!.id, generation: instance!.generation,
|
|
idempotencyKey: migration ? `${job.id}:migration-create:${attempt}` : job.id };
|
|
const kind = `instance.${migration ? 'create' : job.action === 'destroy' ? 'remove' : job.action}`;
|
|
const operation = prepared ? queueHostOperationSerialized(instance!.host_id, kind,
|
|
Buffer.from(prepared.payloadJson.buffer, prepared.payloadJson.byteOffset, prepared.payloadJson.byteLength), operationOptions,
|
|
{ name: instance!.server, game: prepared.recipe.game, libraryId: instance!.library_id })
|
|
: queueHostOperation(instance!.host_id, kind, payload, operationOptions);
|
|
db().prepare('UPDATE native_provisioning SET operation_id=? WHERE deployment_id=?').run(operation.id, job.id);
|
|
for (const file of files) db().prepare("UPDATE staged_file_changes SET state='applying' WHERE id=? AND state='pending'").run(file.id);
|
|
db().prepare("UPDATE server_deployments SET state='running',submitted_at=? WHERE id=?").run(new Date().toISOString(), job.id);
|
|
deploymentStage(job.id, `Waiting for host agent to acknowledge ${job.action}`, { phase: 'container-action', blocker: null,
|
|
command: { state: 'attempted', message: `Durable agent operation ${operation.id}; reconciliation always uses this ID.` } });
|
|
});
|
|
if (prepared) await commitPreparedLaunch(commit);
|
|
else commit();
|
|
} finally { preparingJobs.delete(job.id); }
|
|
}
|