Files
electric-cherry/src/lib/server/provisioning/native.ts
T

2284 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { assertPublicFileTarget, isPrivateConfigPath } from '../fleet/private-config.js';
import { assertScalingOwnership } from '../autoscaling/ownership.js';
import { removalAuthorization } from '../autoscaling/authorization.js';
import { scalingIntent } from '../autoscaling/admission.js';
import { putFileBlob, getFileBlob, blobSize } from '../db/file-blobs.js';
import {
concurrencyBlocker,
preparingJobs,
committedRebalanceStartup,
lifecycleHolders
} from './concurrency.js';
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 { bankCredential } from '../auth/bank.js';
import { posix } from 'node:path';
import { db } from '../db/index.js';
import { unsettledCancelledCommand } from './cancellation.js';
import { config, relDestForKind, serverDir } from '../config.js';
import {
runtimePasswordForServer,
nativeInstance,
nativeRequest,
unplacedNativeCreation,
type NativeInstance
} from '../backends/native.js';
import {
getHost,
listHosts,
getHostOperationStatus,
queueHostOperation,
queueHostOperationSerialized,
releaseHostPorts,
reserveHostPort,
type HostOperationStatus
} from '../hosts/index.js';
import { newestReadyLibraryId, assertReadyLibrary } from '../game-files/index.js';
import {
blueprintRecipe,
captureRecipePlan,
recipeForDeployment,
recipeSummaryForDeployment,
recipeStorage,
summarizeRecipe,
type LaunchRecipePlan
} from '../fleet/launch-recipes.js';
import {
prepareNativeLaunch,
evaluateNativePlacement,
type PreparedNativeLaunch
} from './launch-preparation.js';
import {
getServerKey,
reserveServerKey,
releaseServerKey,
availableServerKeyCount
} from '../fleet/server-keys.js';
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
import { applyActivationFiles } from './activation-files.js';
import { nextLobbyNumber, type CreateServerInput, type CreateServerResult } from './instances.js';
import {
beginDeployment,
deploymentStage,
finishDeployment,
REMOVING_SERVER,
cancelRemoval
} 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 { monitoringIdentity } from '../fleet/health-observations.js';
import { handoffVerifiedHealth } from './health-handoff.js';
import { checkRelease, freshRelease, probeVersion } from '../integrations/plutonium.js';
import { recordScript, removeScript } from '../fleet/inventory.js';
import { sha256Buffer } from '../fleet/files.js';
import { captureBetaBeforeImage, 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 { reconcileHostDrains } from './host-drain.js';
import { hostMigration, isRebalanceMigration, processMigrationRemoval } from './migrations.js';
import { safetyOverride, safetyOverrideCapability } from './safety-override.js';
import { runtimeLaunchSql } from '../fleet/runtime-config.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;
applied_at: string | null;
recovery_step: 'restart' | 'rebuild' | null;
}
interface FilePlan {
id: number;
path: string;
sha256: string | null;
checkBefore: boolean;
expectedBeforeSha: string | null;
}
const unhealthyRecoveryCapability = 'instance.unhealthy-recovery';
/** 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.defaultRuntimeImage : 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.');
}
// Only the controller-configured default may be prepared automatically.
// Custom runtimes must be reported as admin-prepared by the host agent; a
// launch request must never authorize a registry download.
if (
image !== config.defaultRuntimeImage &&
!listHosts().some(
(host) =>
!host.decommission &&
(automatic ? host.mode === 'active' : host.id === input.hostId) &&
host.inventory.preparedRuntimeImages?.includes(image)
)
) {
throw new Error(
'Custom runtime images must be prepared by an admin from the host page before launching.'
);
}
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 && availableServerKeyCount(recipe.game) > 0,
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;
autoscalingDecisionId?: string;
}): CreateServerResult {
try {
return db().transaction(() => {
assertScalingOwnership(input.game, input.mapSlug, input.autoscalingDecisionId);
const expected = `${input.game}_zombie_${input.mapSlug}_${nextLobbyNumber(`${input.game}_zombie_${input.mapSlug}`)}`;
const reserved = db()
.prepare(
`SELECT 1 FROM server_creation_jobs j JOIN server_deployments d ON d.id=j.deployment_id WHERE j.server=?
AND d.server_deleted_at IS NULL AND (d.state IN ('queued','waiting','running','awaiting-health','interrupted') OR (d.state='cancelled' AND d.submitted_at IS NOT NULL))`
)
.get(input.server);
if (
(!input.autoscalingDecisionId && input.server !== expected) ||
nativeInstance(input.server) ||
getServer(input.server) ||
reserved
)
throw new Error('Available lobby changed. Review a fresh preview.');
reserveServerKey(input.server, input.game, input.plutoKey);
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.id,f.kind,f.rel_path,f.content_hash,f.check_before,f.expected_before_sha
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 Array<
Pick<
StagedFileChange,
'id' | 'kind' | 'rel_path' | 'check_before' | 'expected_before_sha'
> & { content_hash: string | null }
>;
for (const row of rows) assertPublicFileTarget(job.server, row.kind, row.rel_path);
return rows.map((row) => ({
id: row.id,
path: posix.join(
relDestForKind(row.kind, job.server.startsWith('t6_') ? 't6' : 't5'),
row.rel_path
),
sha256: row.content_hash,
checkBefore: !!row.check_before,
expectedBeforeSha: row.expected_before_sha
}));
}
function fileMetadata({
contentBase64,
...file
}: FilePlan & { contentBase64?: string | null }): FilePlan {
return file;
}
function storedFiles(job: Job): FilePlan[] {
const row = db()
.prepare('SELECT files_json FROM native_provisioning WHERE deployment_id=?')
.get(job.id) as { files_json: string | null } | undefined;
const files = (JSON.parse(row?.files_json ?? '[]') as FilePlan[]).map(fileMetadata);
if (files.some((file) => isPrivateConfigPath(file.path)))
throw new Error(
'Private bank configuration cannot be captured or changed by a file campaign.'
);
return files;
}
/** 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.recovery_step) return;
const plans = storedFiles(job);
if (!plans.length) return;
const before = result.before as
| { id?: number; path: string; contentBase64: string | null; contentSha256?: string }[]
| 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 *, ${blobSize('content_hash')} AS size FROM staged_file_changes WHERE id=?`
)
.get(plan.id) as Omit<StagedFileChange, 'content'> & {
content_hash: string | null;
size: number;
};
if (!row || row.state === 'applied') continue;
const bytes = snapshot.contentSha256
? getFileBlob(snapshot.contentSha256)
: snapshot.contentBase64 === null
? null
: Buffer.from(snapshot.contentBase64, 'base64');
if (row.source_kind === 'beta') {
captureBetaBeforeImage(row, bytes);
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_hash=?,sha256_before=?,snapshot_pending=0 WHERE id=?'
)
.run(
row.content_hash === null
? 'removed'
: bytes === null
? 'added'
: 'updated',
putFileBlob(bytes),
bytes === null ? null : sha256Buffer(bytes),
row.deployment_file_id
);
if (row.source_kind === 'campaign')
db()
.prepare(
'INSERT INTO campaign_events (deployment_id, message) VALUES (?, ?)'
)
.run(
row.source_id,
`${job.server}: captured rollback snapshot for ${row.rel_path}`
);
}
if (row.content_hash === 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,
row.content_hash,
row.size,
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);
})();
}
/** Consume a conclusive partial failure without treating removal as a healthy rebuild. */
function applyIncompleteRebuildReceipt(job: Job, operation: HostOperationStatus): void {
const result = operation.result as {
reconciled?: unknown;
rebuildFailure?: {
phase?: unknown;
generation?: unknown;
nextGeneration?: unknown;
oldContainerMissing?: unknown;
replacementContainerMissing?: unknown;
replacementFilesRemoved?: unknown;
} | null;
} | null;
if (!result || !Object.prototype.hasOwnProperty.call(result, 'rebuildFailure')) return;
const failure = result.rebuildFailure;
if (
operation.state !== 'failed' ||
operation.kind !== 'instance.rebuild' ||
result.reconciled !== true ||
!failure ||
failure.phase !== 'disrupting' ||
failure.generation !== job.generation ||
failure.nextGeneration !== job.generation + 1 ||
failure.oldContainerMissing !== true ||
failure.replacementContainerMissing !== true ||
failure.replacementFilesRemoved !== true
) {
throw new Error(
'The interrupted rebuild receipt does not confirm safe replacement cleanup. Inspect the original host command.'
);
}
db().transaction(() => {
const binding = db()
.prepare(
'SELECT operation_id,applied_at FROM native_provisioning WHERE deployment_id=?'
)
.get(job.id) as { operation_id: string | null; applied_at: string | null } | undefined;
if (binding?.operation_id !== operation.id)
throw new Error('The interrupted rebuild command no longer belongs to this operation.');
if (binding.applied_at) return;
const requested = db()
.prepare(
`SELECT COALESCE(json_extract(COALESCE(p.payload,o.payload),'$.nextGeneration'),
json_extract(COALESCE(p.payload,o.payload),'$.newGeneration')) AS nextGeneration FROM ec_host_operations o
LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id WHERE o.id=?`
)
.get(operation.id) as { nextGeneration: number | null } | undefined;
if (requested?.nextGeneration !== failure.nextGeneration) {
throw new Error(
'The interrupted rebuild receipt does not match its requested replacement generation.'
);
}
const instance = nativeInstance(job.server);
if (
!instance ||
instance.id !== job.instance_id ||
instance.generation !== job.generation ||
operation.instanceId !== instance.id ||
operation.hostId !== instance.host_id ||
operation.generation !== instance.generation
) {
throw new Error(
'The interrupted rebuild belongs to a different instance or generation. Inspect its host command.'
);
}
db()
.prepare(
"UPDATE servers SET container_state='missing',container_checked_at=?,last_players=NULL,last_roster=NULL WHERE name=?"
)
.run(operation.updatedAt, job.server);
db()
.prepare('UPDATE native_provisioning SET applied_at=? WHERE deployment_id=?')
.run(operation.updatedAt, job.id);
recordProgress(job.id, {
checks: [
{
key: 'container',
label: 'Container state after interrupted rebuild',
source: 'agent',
result: 'fail',
actual: 'missing',
checkedAt: operation.updatedAt,
message:
'The old container is absent and replacement creation never started. Partial replacement files were removed.'
}
],
event: {
kind: 'reconciled',
message:
'Interrupted rebuild settled: old container removed; replacement creation failed.',
details: {
operationId: operation.id,
generation: job.generation,
nextGeneration: failure.nextGeneration
}
}
});
})();
}
/** 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.applied_at,n.recovery_step
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);
releaseServerKey(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') {
applyIncompleteRebuildReceipt(job, operation);
for (const file of storedFiles(job)) {
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.state === 'cancelled' &&
job.action === 'create' &&
db().prepare('SELECT 1 FROM native_creation_cancellations WHERE creation_id=?').get(id)
) {
// Cleanup owns both generations of a cancelled recovery rebuild. Do not
// advance the registry underneath its already-bound removal command.
db()
.prepare(
'UPDATE native_provisioning SET applied_at=COALESCE(applied_at,?) WHERE deployment_id=?'
)
.run(operation.updatedAt, 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);
releaseServerKey(job.server);
finishCreationCancellation(job.id);
return;
}
if (operation.kind === 'instance.rebuild' && 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 &&
!(operation.kind === 'instance.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);
if (job.recovery_step)
recordProgress(job.id, {
recovery: {
state: 'released',
operationId: job.id,
message: 'Container and game health verified after provisioning recovery.'
}
});
finishDeployment(job.id, { ok: true });
})();
}
/** Continue the same launch with at most one restart and one replacement. */
function queueUnhealthyRecovery(job: Job, instance: NativeInstance): void {
db().transaction(() => {
if (!stillActive(job)) return;
const live = nativeInstance(job.server);
if (live?.id !== instance.id || live.generation !== instance.generation) return;
if (job.recovery_step === 'rebuild') {
const message =
'Container is still unhealthy after recovery restart and rebuild. Inspect the server before retrying.';
db()
.prepare('UPDATE restart_rollout SET error=? WHERE operation_id=?')
.run(message, job.id);
finishDeployment(job.id, { ok: false, message });
return;
}
const host = getHost(instance.host_id);
if (!host?.connected || host.mode === 'disabled') {
hold(job, 'Host is unavailable; waiting for reconnection.');
return;
}
if (!host.inventory.capabilities?.includes(unhealthyRecoveryCapability)) {
hold(
job,
'Update this host’s agent to recover unhealthy containers during provisioning.'
);
return;
}
const step = job.recovery_step === 'restart' ? 'rebuild' : 'restart';
const settings = fleetSettings();
const slots = db()
.prepare(
`SELECT i.host_id FROM restart_rollout r LEFT JOIN native_instances i
ON i.server=r.server AND i.retired_at IS NULL WHERE r.healthy_at IS NULL AND (r.operation_id IS NULL OR r.operation_id!=?)`
)
.all(job.id) as { host_id: string | null }[];
if (settings.restartConcurrency > 0 && slots.length >= settings.restartConcurrency) {
hold(job, 'Waiting for restart recovery capacity.');
return;
}
if (
settings.restartConcurrencyPerHost > 0 &&
slots.filter((slot) => slot.host_id === instance.host_id).length >=
settings.restartConcurrencyPerHost
) {
hold(job, 'Waiting for restart recovery capacity on this host.');
return;
}
let payload: Record<string, unknown> = { unhealthyRecovery: true };
if (step === 'rebuild') {
const beta = activeBetaRemovalBlocker(
job.server,
job.action === 'rebuild' ? job.id : undefined
);
if (beta) {
hold(job, beta);
return;
}
if (job.action !== 'rebuild' && settings.rebuildConcurrency > 0) {
if (
lifecycleHolders().filter(
(holder) => holder.id !== job.id && holder.action === 'rebuild'
).length >= settings.rebuildConcurrency
) {
hold(job, 'Waiting for rebuild capacity.');
return;
}
}
assertReadyLibrary(instance.host_id, instance.library_id);
// Recover the files and credentials selected for this launch, even
// if new versions have been promoted while readiness was pending.
const launch = db()
.prepare(
`SELECT COALESCE(p.payload,o.payload) AS payload FROM ec_host_operations o
JOIN native_instances i ON ${runtimeLaunchSql}
LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id
WHERE i.id=? AND o.state='completed' AND o.history_expired_at IS NULL ORDER BY o.rowid DESC LIMIT 1`
)
.get(instance.id) as { payload: string } | undefined;
if (!launch) {
hold(job, 'Waiting for the original launch payload before recovery rebuild.');
return;
}
payload = {
...JSON.parse(launch.payload),
nextGeneration: instance.generation + 1,
unhealthyRecovery: true
};
delete payload.safetyOverride;
delete payload.stageId;
}
const operation = queueHostOperationSerialized(
instance.host_id,
`instance.${step}`,
Buffer.from(JSON.stringify(payload)),
{
instanceId: instance.id,
generation: instance.generation,
idempotencyKey: `${job.id}:unhealthy:${step}`
},
{ name: instance.server, unhealthyRecovery: true }
);
const at = new Date().toISOString();
db()
.prepare(
'UPDATE native_provisioning SET operation_id=?,generation=?,recovery_step=?,applied_at=NULL WHERE deployment_id=?'
)
.run(operation.id, instance.generation, step, job.id);
db()
.prepare(
"UPDATE server_deployments SET state='running',submitted_at=?,error=NULL,finished_at=NULL WHERE id=?"
)
.run(at, job.id);
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, at, job.id);
const message = `Container unhealthy; ${step === 'restart' ? 'forcing a restart' : 'forcing a rebuild after restart recovery failed'}.`;
deploymentStage(job.id, message, {
phase: 'container-action',
blocker: null,
checks: [],
command: {
state: 'attempted',
message: `Recovery ${step} queued as agent operation ${operation.id}.`
},
recovery: {
state: 'held',
operationId: job.id,
message: 'Recovery runs within this provisioning operation.'
},
event: {
kind: 'unhealthy-recovery',
message,
details: { step, operationId: operation.id, generation: instance.generation }
}
});
})();
}
export async function processNativeProvisioning(
eligibility: (operation: {
id: string;
server: string;
action: string;
state: string;
stage: string;
}) => string | null,
options: { wait?: boolean } = {}
): Promise<void> {
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 o.kind='instance.rebuild' AND json_type(o.result,'$.rebuildFailure') IS NOT NULL AND n.applied_at IS NULL)
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.applied_at,n.recovery_step,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.
function otherPreparations(id: string): number {
if (!preparingJobs.size) return 0;
const reserved = new Set(
(
db()
.prepare(
`SELECT id FROM server_deployments WHERE id IN (SELECT value FROM json_each(?))
AND ${committedRebalanceStartup}`
)
.all(JSON.stringify([...preparingJobs])) as { id: string }[]
).map((row) => row.id)
);
return [...preparingJobs].filter((other) => other !== id && !reserved.has(other)).length;
}
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,
eligibility: (job: Job) => string | 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;
if (eligibility(job)) 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;
}
const capacityBlocker = concurrencyBlocker(job.id, job.action, decision.hostId);
if (capacityBlocker) {
hold(job, capacityBlocker);
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) {
const pendingReceipt = unsettled.state === 'completed' || unsettled.state === 'failed';
const message =
`Waiting for the host command from cancelled operation ${unsettled.deploymentId} ${pendingReceipt ? 'to be reconciled' : 'to settle'}. Agent state: ${unsettled.state}.` +
(unsettled.error ? ` Last error: ${unsettled.error.slice(0, 500)}` : '');
deploymentStage(job.id, message, {
blocker: {
code: 'cancelled-command',
message,
automatic: true,
href: `/provisioning/${encodeURIComponent(unsettled.deploymentId)}`,
label: 'View cancelled operation'
}
});
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') OR ${committedRebalanceStartup})`
)
.get(job.id) as { n: number };
if (active.n + otherPreparations(job.id) >= settings.createConcurrency) {
hold(job, 'Waiting for startup capacity.');
return;
}
const capacityBlocker = concurrencyBlocker(job.id, job.action);
if (capacityBlocker) {
hold(job, capacityBlocker);
return;
}
if (eligibility(job)) return;
instance = await placeQueuedCreation(job, instance, eligibility);
if (!instance) return;
job.instance_id = instance.id;
job.generation = instance.generation;
}
const migration = hostMigration(job.id);
const rebuilding =
job.recovery_step === 'rebuild' ||
(!job.recovery_step && job.action === 'rebuild' && !migration);
if (
!instance ||
instance.id !== job.instance_id ||
(instance.generation !== job.generation &&
!(rebuilding && 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);
const recoveryCleanup =
operation?.kind === 'instance.cancel-create' &&
!!db()
.prepare(
`SELECT 1 FROM ec_host_operations
WHERE id=? AND json_extract(payload,'$.recoveryRebuildOperationId') IS NOT NULL`
)
.get(operation.id);
if (
operation?.kind === 'instance.cancel-create' &&
!['completed', 'failed'].includes(operation.state) &&
(!host.inventory.capabilities?.includes('instance.cancel-create') ||
(recoveryCleanup &&
!host.inventory.capabilities?.includes(unhealthyRecoveryCapability)))
) {
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') {
applyIncompleteRebuildReceipt(job, operation);
if (
job.action === 'destroy' &&
scalingIntent(job.id) &&
(operation.result as { autoscalingInvalidated?: boolean } | null)
?.autoscalingInvalidated === true
) {
db().transaction(() => {
db()
.prepare(
"UPDATE server_deployments SET state='cancelled',stage='Automatic removal withdrawn; new empty interval required',finished_at=?,error=NULL WHERE id=?"
)
.run(new Date().toISOString(), job.id);
recordProgress(job.id, {
blocker: null,
event: {
kind: 'outcome',
message:
'Automatic removal was invalidated before mutation. A new decision and empty interval are required.',
details: { state: 'cancelled' }
}
});
})();
return;
}
if (migration && !job.recovery_step) {
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;
}
// Failed commands either never mutated or have a conclusively
// reconciled partial rebuild above. Ambiguous outcomes retain capacity.
db().transaction(() => {
db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id);
const files = storedFiles(job);
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);
releaseServerKey(job.server);
recordProgress(job.id, {
checkpoint: {
key: 'cleanup',
label: 'Instance retired and port reservations released'
}
});
complete(job);
finishCreationCancellation(job.id);
})();
return;
}
if (operation.kind === 'instance.rebuild' && 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>);
db()
.prepare(
'UPDATE native_provisioning SET applied_at=COALESCE(applied_at,?) WHERE deployment_id=?'
)
.run(operation.updatedAt, job.id);
if (job.state !== 'awaiting-health') {
db()
.prepare(
"UPDATE server_deployments SET state='awaiting-health',error=NULL,finished_at=NULL WHERE id=?"
)
.run(job.id);
const action = job.recovery_step ? `recovery ${job.recovery_step}` : job.action;
recordProgress(job.id, {
command: {
state: 'accepted',
message: `Agent confirmed ${action}; verifying the resulting instance.`
},
checkpoint: {
key: job.recovery_step ? `recovery-${job.recovery_step}` : 'container-action',
label: `Agent confirmed ${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 verificationIdentity = monitoringIdentity(job.server);
const health = await nativeRequest<{ healthy: boolean; state: string }>(
instance,
'instance.inspect'
);
const containerObservedAt = new Date().toISOString();
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().transaction(() => {
db()
.prepare(
'UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?'
)
.run(health.state, job.server);
db()
.prepare(
`UPDATE leaderboard_matches SET status='interrupted', ended_at=?
WHERE server=? AND status='active' AND server_deleted_at IS NULL`
)
.run(containerObservedAt, 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) {
if (
health.state === 'unhealthy' &&
(job.action === 'create' || job.action === 'rebuild')
)
queueUnhealthyRecovery(job, instance);
else 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;
}
}
const completed = db().transaction(() => {
if (
!stillActive(job) ||
!handoffVerifiedHealth(
job.server,
verificationIdentity,
{ state: health.state, observedAt: containerObservedAt },
status
)
)
return false;
recordProgress(job.id, { blocker: null, checks });
complete(job);
return true;
})();
if (!completed)
hold(job, 'Waiting for current health evidence before completing verification.');
return;
}
// A queued activation from a previous controller release may still say restart.
// Upgrade only intent that has never submitted a host command.
if (
job.action === 'restart' &&
job.state === 'waiting' &&
!job.submitted_at &&
filesFor(job).length
) {
db().prepare("UPDATE server_deployments SET action='rebuild' WHERE id=?").run(job.id);
job.action = 'rebuild';
}
const reason = eligibility(job);
if (reason) {
hold(job, reason);
return;
}
const overrideSafety = !!safetyOverride(job.id);
if (overrideSafety && !host.inventory.capabilities?.includes(safetyOverrideCapability)) {
hold(job, 'Update this host’s agent before using admin override.');
return;
}
if (job.action === 'destroy' || job.action === 'rebuild') {
const beta = activeBetaRemovalBlocker(
job.server,
job.action === 'rebuild' ? job.id : undefined
);
if (beta) {
hold(job, beta);
return;
}
}
const files = job.stage_operation_id ? storedFiles(job) : filesFor(job);
if (
job.action === 'rebuild' &&
files.length &&
!host.inventory.capabilities?.includes('instance.rebuild-files')
) {
hold(job, 'Update the host agent to support beta and campaign file rebuilds.');
return;
}
if (job.action === 'rebuild' && !recipeSummaryForDeployment(job.id)) {
const server = getServer(job.server);
if (!server?.map_slug) throw new Error('A saved map is required before rebuilding.');
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
}
};
const stored = recipeStorage(recipe);
db().transaction(() => {
db()
.prepare(
'INSERT INTO server_creation_jobs(deployment_id,server,port,game_mode,map_slug,expected_map) VALUES(?,?,?,?,?,?)'
)
.run(
job.id,
job.server,
instance!.port,
'zombie',
recipe.mapSlug,
recipe.expectedMap
);
db()
.prepare(
'INSERT INTO server_launch_recipes(deployment_id,recipe,recipe_summary) VALUES(?,?,?)'
)
.run(job.id, stored.recipe, stored.recipeSummary);
})();
}
if (files.length && !job.stage_operation_id) {
db().transaction(() => {
const stage = queueHostOperationSerialized(
instance.host_id,
'files.stage',
Buffer.from(
JSON.stringify({
stageId: job.id,
files: files.map((file) => ({
...file,
...(file.sha256
? { contentSha256: file.sha256 }
: { contentBase64: null })
}))
})
),
{
instanceId: instance.id,
generation: instance.generation,
idempotencyKey: `${job.id}:stage`
},
{ stageId: job.id }
);
db()
.prepare(
'UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?'
)
.run(stage.id, JSON.stringify(files.map(fileMetadata)), 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 capacityBlocker = concurrencyBlocker(job.id, job.action, instance.host_id);
if (capacityBlocker) {
hold(job, capacityBlocker);
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') OR ${committedRebalanceStartup})`
)
.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;
if (overrideSafety) return true;
// 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 (job.action === 'rebuild' && !files.length && health.state === 'unhealthy') {
if (
!getHost(instance.host_id)?.inventory.capabilities?.includes(
unhealthyRecoveryCapability
)
) {
hold(
job,
'Update this host’s agent to recover unhealthy containers during provisioning.'
);
return false;
}
return true;
}
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) {
if (job.action === 'destroy' && scalingIntent(job.id)) cancelRemoval(job.id, false);
else
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;
let preparedPassword: string | undefined;
try {
if (job.action === 'create' || job.action === 'rebuild') {
if (
count(['create', 'rebuild']) + otherPreparations(job.id) >=
fleetSettings().createConcurrency
) {
hold(job, 'Waiting for startup capacity.');
return;
}
const capacityBlocker = concurrencyBlocker(job.id, job.action, instance.host_id);
if (capacityBlocker) {
hold(job, capacityBlocker);
return;
}
preparingJobs.add(job.id);
preparedKey = getServerKey(job.server) ?? null;
if (!preparedKey) throw new Error('The saved server key was removed.');
const frozenRecipeId = migration && isRebalanceMigration(job.id) ? job.id : undefined;
const prior = (
frozenRecipeId
? { format: 1, ...recipeSummaryForDeployment(job.id)!.recipe }
: 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' }
});
preparedPassword =
(files.length ? runtimePasswordForServer(job.server) : undefined) ||
config.rcon.password ||
randomBytes(32).toString('hex');
prepared = await prepareNativeLaunch({
game: prior.game,
mapSlug: prior.mapSlug,
native: prior.native,
instance,
serverKey: preparedKey,
rconPassword: preparedPassword,
bankToken: bankCredential(instance.id),
rebuild: job.action === 'rebuild' && !migration,
safetyOverride: overrideSafety,
unhealthyRecovery:
!files.length &&
host.inventory.capabilities?.includes(unhealthyRecoveryCapability),
activationId: files.length ? job.id : undefined,
frozenRecipeId
});
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;
}
// Confirmation can arrive while preflight or file preparation is awaiting I/O.
if (!!safetyOverride(job.id) !== overrideSafety) return;
if (
overrideSafety &&
!liveHost.inventory.capabilities?.includes(safetyOverrideCapability)
) {
hold(job, 'Update this host’s agent before using admin override.');
return;
}
if (job.action === 'destroy' || job.action === 'rebuild') {
const beta = activeBetaRemovalBlocker(
job.server,
job.action === 'rebuild' ? job.id : undefined
);
if (beta) {
hold(job, beta);
return;
}
}
if (
JSON.stringify(filesFor(job).map(fileMetadata)) !==
JSON.stringify(files.map(fileMetadata))
) {
finishDeployment(job.id, {
ok: false,
message:
'File requests changed during preflight. Review and submit a fresh activation.'
});
return;
}
const capacityBlocker = concurrencyBlocker(job.id, job.action, instance!.host_id);
if (capacityBlocker) {
hold(job, capacityBlocker);
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) {
// Compare the same runtime config bytes while rechecking current promoted files.
const expected =
migration && isRebalanceMigration(job.id)
? { format: 1 as const, ...recipeSummaryForDeployment(job.id)!.recipe }
: captureRecipePlan(
prepared.recipe.game,
prepared.recipe.mapSlug,
preparedPassword
);
if (files.length) applyActivationFiles(expected, job.id);
if (
getServerKey(job.server) !== preparedKey ||
!sameLaunchFiles(prepared.recipe, expected)
) {
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: overrideSafety
? 'Host eligibility checked; player safety checks bypassed by admin override'
: 'Host eligibility and safety checks passed'
}
});
const payload: Record<string, unknown> = files.length ? { stageId: job.id } : {};
if (overrideSafety) payload.safetyOverride = true;
if (job.action === 'destroy') {
const authorization = removalAuthorization(job.id, instance!.host_id);
if (authorization) {
if (overrideSafety)
throw new Error('Autoscaling never bypasses player safety.');
payload.autoscaling = authorization;
}
}
if (prepared) {
deploymentStage(job.id, 'Preparing launch files for the host agent', {
phase: 'prepare',
recipeStatus: 'attempted',
checkpoint: { key: 'startup-queue', label: 'Startup capacity available' }
});
for (const blob of prepared.blobs ?? []) putFileBlob(blob.content);
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,game,map,game_mode,map_slug,host_dir,config_file,game_id,image,game_port,created_by,container_state)
VALUES(?,?,?,'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.filter(
(file) =>
!files.some(
(change) =>
change.path ===
posix.join(relDestForKind(file.kind, recipe.game), file.path)
)
))
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);
}
}