74 lines
4.6 KiB
TypeScript
74 lines
4.6 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { db } from '../db/index.js';
|
|
import { getHost, queueHostOperation, waitHostOperationStatus } from '../hosts/index.js';
|
|
import { trackActivity } from '../pipeline/activity.js';
|
|
import { latestGameSubmission } from '../fleet/lifecycle-evidence.js';
|
|
|
|
export interface NativeInstance {
|
|
id: string; server: string; host_id: string; generation: number; library_id: string;
|
|
image: string; bind_address: string; port: number; public_address: string | null;
|
|
public_port: number | null; created_at: string; retired_at: string | null;
|
|
}
|
|
|
|
export function nativeInstance(server: string): NativeInstance | null {
|
|
return db().prepare('SELECT * FROM native_instances WHERE server = ? AND retired_at IS NULL').get(server) as NativeInstance | undefined ?? null;
|
|
}
|
|
|
|
export function nativeDeployment(id: string): boolean {
|
|
return !!db().prepare(`SELECT 1 FROM native_provisioning WHERE deployment_id = ?
|
|
UNION ALL SELECT 1 FROM native_creation_intents WHERE deployment_id = ?
|
|
UNION ALL SELECT 1 FROM native_creation_cancellations c
|
|
JOIN native_creation_intents i ON i.deployment_id=c.creation_id WHERE c.cleanup_id = ? LIMIT 1`).get(id, id, id);
|
|
}
|
|
|
|
export function unplacedNativeCreation(server: string): string | null {
|
|
const row = db().prepare(`SELECT d.id FROM server_deployments d
|
|
JOIN native_creation_intents i ON i.deployment_id=d.id
|
|
LEFT JOIN native_provisioning n ON n.deployment_id=d.id
|
|
WHERE d.server=? AND d.server_deleted_at IS NULL AND d.action='create' AND d.state='queued'
|
|
AND n.deployment_id IS NULL ORDER BY d.rowid DESC LIMIT 1`).get(server) as { id: string } | undefined;
|
|
return row?.id ?? null;
|
|
}
|
|
|
|
const observations = new Map<string, Promise<unknown>>();
|
|
const readKinds = new Set(['instance.inspect', 'files.read', 'files.stat', 'files.list', 'files.manifest', 'logs.read']);
|
|
|
|
/** Share overlapping observations only, never completed results or mutations. */
|
|
export async function nativeRequest<T = Record<string, unknown>>(instance: NativeInstance, kind: string, payload: Record<string, unknown> = {}, key?: string): Promise<T> {
|
|
const host = getHost(instance.host_id);
|
|
if (!host || host.connectionState !== 'online') throw new Error('Host agent is offline; instance state is unknown.');
|
|
const readOnly = readKinds.has(kind) || (kind === 'rcon.execute' && ['status', 'getstatus', 'version'].includes(String(payload.command)));
|
|
if (key || !readOnly) return executeRequest<T>(instance, kind, payload, key);
|
|
// A probe after a lifecycle submission must not join an old-process read.
|
|
const identity = JSON.stringify([instance.host_id, instance.id, instance.generation, instance.server,
|
|
latestGameSubmission(instance.server), kind, payload]);
|
|
const pending = observations.get(identity);
|
|
if (pending) return pending as Promise<T>;
|
|
const request = executeRequest<T>(instance, kind, payload).finally(() => { observations.delete(identity); });
|
|
observations.set(identity, request);
|
|
return request;
|
|
}
|
|
|
|
/** Only the agent receives host paths, Docker access, or game credentials. */
|
|
async function executeRequest<T>(instance: NativeInstance, kind: string, payload: Record<string, unknown>, key?: string): Promise<T> {
|
|
const operation = queueHostOperation(instance.host_id, kind, payload, {
|
|
instanceId: instance.id, generation: instance.generation, idempotencyKey: key ?? randomUUID()
|
|
});
|
|
const result = await trackActivity(`agent:${operation.id}`, `Waiting for ${kind} on ${instance.server}`,
|
|
() => waitHostOperationStatus(operation.id, 25_000));
|
|
if (result.state !== 'completed') throw new Error(result.error || `Agent operation ${result.id} is ${result.state}; its original ID is retained.`);
|
|
// Do not apply replies from a retired generation to a replacement server.
|
|
const current = nativeInstance(instance.server);
|
|
if (current?.id !== instance.id || current.generation !== instance.generation) throw new Error('Instance was replaced during the operation.');
|
|
return result.result as T;
|
|
}
|
|
|
|
export async function nativeHealth(server: string): Promise<{ ok: true; state: string } | { ok: false; reason: string; state?: string }> {
|
|
const instance = nativeInstance(server);
|
|
if (!instance) return { ok: false, reason: 'Native instance is not registered' };
|
|
try {
|
|
const result = await nativeRequest<{ state: string; healthy: boolean }>(instance, 'instance.inspect');
|
|
return result.healthy ? { ok: true, state: result.state } : { ok: false, state: result.state, reason: `Container ${result.state}` };
|
|
} catch (error) { return { ok: false, state: 'unknown', reason: (error as Error).message }; }
|
|
}
|