Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -12,8 +12,12 @@ export interface ActivityQuery {
|
||||
}
|
||||
|
||||
export function activitySummary(id: string): { total: number; latestId: number | null } {
|
||||
return db().prepare(`SELECT COUNT(*) AS total, MAX(id) AS latestId
|
||||
FROM server_deployment_events WHERE deployment_id = ?`).get(id) as { total: number; latestId: number | null };
|
||||
return db()
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS total, MAX(id) AS latestId
|
||||
FROM server_deployment_events WHERE deployment_id = ?`
|
||||
)
|
||||
.get(id) as { total: number; latestId: number | null };
|
||||
}
|
||||
|
||||
/** ID ordering and a fixed upper watermark keep older pages stable as new events arrive. */
|
||||
@@ -25,32 +29,60 @@ export function deploymentActivity(id: string, options: ActivityQuery = {}): Pro
|
||||
const query = options.query ?? '';
|
||||
const base = 'deployment_id = ? AND id <= ? AND instr(lower(message), lower(?)) > 0';
|
||||
const values: (string | number)[] = [id, watermark, query];
|
||||
const total = (db().prepare(`SELECT COUNT(*) AS n FROM server_deployment_events WHERE ${base}`)
|
||||
.get(...values) as { n: number }).n;
|
||||
const total = (
|
||||
db()
|
||||
.prepare(`SELECT COUNT(*) AS n FROM server_deployment_events WHERE ${base}`)
|
||||
.get(...values) as { n: number }
|
||||
).n;
|
||||
const cursor = options.cursor === undefined ? '' : ` AND id ${newest ? '<' : '>'} ?`;
|
||||
if (options.cursor !== undefined) values.push(options.cursor);
|
||||
const rows = db().prepare(`SELECT id, created_at, message, kind, phase_key, details_json
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT id, created_at, message, kind, phase_key, details_json
|
||||
FROM server_deployment_events WHERE ${base}${cursor}
|
||||
ORDER BY id ${newest ? 'DESC' : 'ASC'} LIMIT ?`).all(...values, limit + 1) as ActivityRow[];
|
||||
ORDER BY id ${newest ? 'DESC' : 'ASC'} LIMIT ?`
|
||||
)
|
||||
.all(...values, limit + 1) as ActivityRow[];
|
||||
const more = rows.length > limit;
|
||||
const events = rows.slice(0, limit);
|
||||
return { events, total, totalEvents: summary.total, latestId: summary.latestId, watermark,
|
||||
nextCursor: more ? events.at(-1)!.id : null };
|
||||
return {
|
||||
events,
|
||||
total,
|
||||
totalEvents: summary.total,
|
||||
latestId: summary.latestId,
|
||||
watermark,
|
||||
nextCursor: more ? events.at(-1)!.id : null
|
||||
};
|
||||
}
|
||||
|
||||
export function deploymentEvents(id: string, recent = false): ProvisioningEvent[] {
|
||||
if (recent) return (db().prepare(`SELECT id, created_at, message, kind, phase_key, details_json
|
||||
FROM server_deployment_events WHERE deployment_id = ? ORDER BY id DESC LIMIT 5`).all(id) as ActivityRow[]).reverse();
|
||||
return (db().prepare(`SELECT id, created_at, message, kind, phase_key, details_json
|
||||
FROM server_deployment_events WHERE deployment_id = ? ORDER BY id`).all(id) as ActivityRow[]);
|
||||
if (recent)
|
||||
return (
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT id, created_at, message, kind, phase_key, details_json
|
||||
FROM server_deployment_events WHERE deployment_id = ? ORDER BY id DESC LIMIT 5`
|
||||
)
|
||||
.all(id) as ActivityRow[]
|
||||
).reverse();
|
||||
return db()
|
||||
.prepare(
|
||||
`SELECT id, created_at, message, kind, phase_key, details_json
|
||||
FROM server_deployment_events WHERE deployment_id = ? ORDER BY id`
|
||||
)
|
||||
.all(id) as ActivityRow[];
|
||||
}
|
||||
|
||||
export function latestFailureEvent(id: string): ProvisioningEvent | null {
|
||||
const row = db().prepare(`SELECT id, created_at, message, kind, phase_key, details_json
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT id, created_at, message, kind, phase_key, details_json
|
||||
FROM server_deployment_events WHERE deployment_id = ?
|
||||
AND (kind IN ('failed', 'interrupted') OR (kind = 'outcome'
|
||||
AND CASE WHEN json_valid(details_json) THEN json_extract(details_json, '$.state') END IN ('failed', 'interrupted'))
|
||||
OR (kind IS NULL AND (message LIKE 'Failed:%' OR message LIKE 'Interrupted by application restart.%')))
|
||||
ORDER BY id DESC LIMIT 1`).get(id) as ActivityRow | undefined;
|
||||
ORDER BY id DESC LIMIT 1`
|
||||
)
|
||||
.get(id) as ActivityRow | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
@@ -4,25 +4,52 @@ import { recordProgress } from './progress.js';
|
||||
/** Record receipts in the report transaction, independently of the execution worker. */
|
||||
export function recordAgentReceipt(operationId: string, state: string, at: string): void {
|
||||
// Host operations also serve installations that have no provisioning schema yet.
|
||||
if (!db().prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='native_provisioning'").get()) return;
|
||||
const job = db().prepare(`SELECT d.id, d.phase_key, COALESCE(n.operation_id,m.remove_operation_id) AS operation_id FROM native_provisioning n
|
||||
if (
|
||||
!db()
|
||||
.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='native_provisioning'"
|
||||
)
|
||||
.get()
|
||||
)
|
||||
return;
|
||||
const job = db()
|
||||
.prepare(
|
||||
`SELECT d.id, d.phase_key, COALESCE(n.operation_id,m.remove_operation_id) AS operation_id FROM native_provisioning n
|
||||
JOIN server_deployments d ON d.id=n.deployment_id
|
||||
LEFT JOIN ec_host_migrations m ON m.deployment_id=n.deployment_id
|
||||
WHERE (n.operation_id=? OR n.stage_operation_id=? OR m.remove_operation_id=?) AND d.server_deleted_at IS NULL
|
||||
AND d.state IN ('queued','waiting','running','awaiting-health','interrupted')`).get(operationId, operationId, operationId) as
|
||||
AND d.state IN ('queued','waiting','running','awaiting-health','interrupted')`
|
||||
)
|
||||
.get(operationId, operationId, operationId) as
|
||||
{ id: string; phase_key: string | null; operation_id: string | null } | undefined;
|
||||
if (!job) return;
|
||||
const staging = job.operation_id !== operationId;
|
||||
const message = `${staging ? 'File staging' : 'Host agent command'} ${state} (${operationId})`;
|
||||
if (!staging && job.phase_key === 'container-action') {
|
||||
db().prepare('UPDATE server_deployments SET stage=? WHERE id=?').run(`Host agent command ${state}`, job.id);
|
||||
db()
|
||||
.prepare('UPDATE server_deployments SET stage=? WHERE id=?')
|
||||
.run(`Host agent command ${state}`, job.id);
|
||||
}
|
||||
recordProgress(job.id, {
|
||||
blocker: null,
|
||||
event: { kind: 'agent', message, details: { operationId, state, receivedAt: at } },
|
||||
...(['accepted', 'running', 'completed'].includes(state) ? {
|
||||
checkpoint: { key: `${staging ? 'staging' : 'agent'}-${state}`, label: message, at },
|
||||
...(!staging ? { command: { state: 'accepted' as const, message: 'Host agent acknowledged the command.', at } } : {})
|
||||
} : {})
|
||||
...(['accepted', 'running', 'completed'].includes(state)
|
||||
? {
|
||||
checkpoint: {
|
||||
key: `${staging ? 'staging' : 'agent'}-${state}`,
|
||||
label: message,
|
||||
at
|
||||
},
|
||||
...(!staging
|
||||
? {
|
||||
command: {
|
||||
state: 'accepted' as const,
|
||||
message: 'Host agent acknowledged the command.',
|
||||
at
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
: {})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { beginDeployment, cancelRemoval, deploymentStage, finishDeployment } fro
|
||||
import { recordProgress } from './progress.js';
|
||||
|
||||
export function canCancelCreation(id: string): boolean {
|
||||
return !!db().prepare(`SELECT 1 FROM server_deployments d
|
||||
return !!db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM server_deployments d
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
LEFT JOIN native_instances i ON i.id=n.instance_id AND i.retired_at IS NULL
|
||||
AND (i.generation=n.generation OR (n.recovery_step='rebuild' AND i.generation=n.generation+1
|
||||
@@ -21,55 +23,110 @@ export function canCancelCreation(id: string): boolean {
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments other WHERE other.server=d.server
|
||||
AND other.server_deleted_at IS NULL AND other.rowid>d.rowid AND other.state!='cancelled'
|
||||
AND NOT (other.action='destroy' AND other.state='waiting'
|
||||
AND NOT EXISTS (SELECT 1 FROM native_provisioning p WHERE p.deployment_id=other.id AND p.operation_id IS NOT NULL)))`).get(id);
|
||||
AND NOT EXISTS (SELECT 1 FROM native_provisioning p WHERE p.deployment_id=other.id AND p.operation_id IS NOT NULL)))`
|
||||
)
|
||||
.get(id);
|
||||
}
|
||||
|
||||
/** Stops observation immediately; remote cleanup remains a durable, separately visible job. */
|
||||
export function cancelCreation(id: string, triggeredBy = 'ui', pausePolicy = true): string | null {
|
||||
return db().transaction(() => {
|
||||
const prior = db().prepare('SELECT cleanup_id FROM native_creation_cancellations WHERE creation_id=?').get(id) as { cleanup_id: string } | undefined;
|
||||
const prior = db()
|
||||
.prepare('SELECT cleanup_id FROM native_creation_cancellations WHERE creation_id=?')
|
||||
.get(id) as { cleanup_id: string } | undefined;
|
||||
if (prior) return prior.cleanup_id;
|
||||
if (!canCancelCreation(id)) return null;
|
||||
if (pausePolicy) pauseScalingForOperation(id);
|
||||
const job = db().prepare(`SELECT d.server,n.operation_id,n.stage_operation_id,n.generation,n.recovery_step FROM server_deployments d
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=d.id WHERE d.id=?`).get(id) as
|
||||
{ server: string; operation_id: string | null; stage_operation_id: string | null; generation: number | null; recovery_step: 'restart' | 'rebuild' | null };
|
||||
const job = db()
|
||||
.prepare(
|
||||
`SELECT d.server,n.operation_id,n.stage_operation_id,n.generation,n.recovery_step FROM server_deployments d
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=d.id WHERE d.id=?`
|
||||
)
|
||||
.get(id) as {
|
||||
server: string;
|
||||
operation_id: string | null;
|
||||
stage_operation_id: string | null;
|
||||
generation: number | null;
|
||||
recovery_step: 'restart' | 'rebuild' | null;
|
||||
};
|
||||
const instance = nativeInstance(job.server);
|
||||
// Replace a removal still waiting on occupancy, including one whose asynchronous probe is outstanding.
|
||||
for (const removal of db().prepare("SELECT id FROM server_deployments WHERE server=? AND action='destroy' AND state='waiting' AND server_deleted_at IS NULL").all(job.server) as { id: string }[]) {
|
||||
for (const removal of db()
|
||||
.prepare(
|
||||
"SELECT id FROM server_deployments WHERE server=? AND action='destroy' AND state='waiting' AND server_deleted_at IS NULL"
|
||||
)
|
||||
.all(job.server) as { id: string }[]) {
|
||||
cancelRemoval(removal.id);
|
||||
}
|
||||
const cleanupId = beginDeployment(job.server, 'destroy', triggeredBy);
|
||||
db().prepare('INSERT INTO native_creation_cancellations(creation_id,cleanup_id) VALUES(?,?)').run(id, cleanupId);
|
||||
db().prepare("UPDATE server_deployments SET state='cancelled',finished_at=?,error=NULL WHERE id=?").run(new Date().toISOString(), id);
|
||||
deploymentStage(id, 'Creation cancelled; cleanup requested', { blocker: null,
|
||||
event: { kind: 'cancelled', message: 'Creation cancelled. Its partial instance will be removed without a player-count check.', details: { cleanupId } } });
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO native_creation_cancellations(creation_id,cleanup_id) VALUES(?,?)'
|
||||
)
|
||||
.run(id, cleanupId);
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE server_deployments SET state='cancelled',finished_at=?,error=NULL WHERE id=?"
|
||||
)
|
||||
.run(new Date().toISOString(), id);
|
||||
deploymentStage(id, 'Creation cancelled; cleanup requested', {
|
||||
blocker: null,
|
||||
event: {
|
||||
kind: 'cancelled',
|
||||
message:
|
||||
'Creation cancelled. Its partial instance will be removed without a player-count check.',
|
||||
details: { cleanupId }
|
||||
}
|
||||
});
|
||||
if (!job.operation_id && !job.stage_operation_id) {
|
||||
// No remote mutation was ever queued. The reservation is the only resource to remove.
|
||||
if (instance) {
|
||||
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(new Date().toISOString(), instance.id);
|
||||
db()
|
||||
.prepare('UPDATE native_instances SET retired_at=? WHERE id=?')
|
||||
.run(new Date().toISOString(), instance.id);
|
||||
releaseHostPorts(instance.id);
|
||||
}
|
||||
deploymentStage(cleanupId, 'Unsubmitted creation cancelled; reservations released', { phase: 'cleanup',
|
||||
checkpoint: { key: 'cleanup', label: 'No host command submitted; local reservations released' } });
|
||||
deploymentStage(cleanupId, 'Unsubmitted creation cancelled; reservations released', {
|
||||
phase: 'cleanup',
|
||||
checkpoint: {
|
||||
key: 'cleanup',
|
||||
label: 'No host command submitted; local reservations released'
|
||||
}
|
||||
});
|
||||
finishDeployment(cleanupId, { ok: true });
|
||||
finishCreationCancellation(cleanupId);
|
||||
} else {
|
||||
if (!instance) throw new Error('The original native instance is unavailable.');
|
||||
let generation = instance.generation;
|
||||
const payload: { createOperationId: string; recoveryRebuildOperationId?: string; recoveryGeneration?: number } = { createOperationId: job.operation_id ?? id };
|
||||
const payload: {
|
||||
createOperationId: string;
|
||||
recoveryRebuildOperationId?: string;
|
||||
recoveryGeneration?: number;
|
||||
} = { createOperationId: job.operation_id ?? id };
|
||||
if (job.recovery_step) {
|
||||
// Recovery replaces operation_id, but cancellation must retain the
|
||||
// immutable ownership label from the original launch.
|
||||
const original = db().prepare(`SELECT id FROM ec_host_operations WHERE host_id=? AND instance_id=?
|
||||
AND generation=? AND kind='instance.create' ORDER BY rowid DESC LIMIT 1`)
|
||||
.get(instance.host_id, instance.id, job.generation) as { id: string } | undefined;
|
||||
if (!original || !job.generation) throw new Error('The original creation command is unavailable.');
|
||||
const original = db()
|
||||
.prepare(
|
||||
`SELECT id FROM ec_host_operations WHERE host_id=? AND instance_id=?
|
||||
AND generation=? AND kind='instance.create' ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(instance.host_id, instance.id, job.generation) as
|
||||
{ id: string } | undefined;
|
||||
if (!original || !job.generation)
|
||||
throw new Error('The original creation command is unavailable.');
|
||||
payload.createOperationId = original.id;
|
||||
generation = job.generation;
|
||||
if (job.recovery_step === 'rebuild') {
|
||||
const recovery = job.operation_id ? getHostOperationStatus(job.operation_id) : null;
|
||||
if (!recovery || recovery.kind !== 'instance.rebuild' || recovery.instanceId !== instance.id || recovery.generation !== generation) {
|
||||
const recovery = job.operation_id
|
||||
? getHostOperationStatus(job.operation_id)
|
||||
: null;
|
||||
if (
|
||||
!recovery ||
|
||||
recovery.kind !== 'instance.rebuild' ||
|
||||
recovery.instanceId !== instance.id ||
|
||||
recovery.generation !== generation
|
||||
) {
|
||||
throw new Error('The creation recovery command is unavailable.');
|
||||
}
|
||||
// A delivered rebuild may have created the next generation
|
||||
@@ -78,33 +135,77 @@ export function cancelCreation(id: string, triggeredBy = 'ui', pausePolicy = tru
|
||||
payload.recoveryGeneration = generation + 1;
|
||||
}
|
||||
}
|
||||
const operation = queueHostOperation(instance.host_id, 'instance.cancel-create', payload,
|
||||
{ instanceId: instance.id, generation, idempotencyKey: cleanupId });
|
||||
db().prepare('UPDATE native_provisioning SET operation_id=? WHERE deployment_id=?').run(operation.id, cleanupId);
|
||||
db().prepare('UPDATE server_deployments SET submitted_at=? WHERE id=?').run(new Date().toISOString(), cleanupId);
|
||||
deploymentStage(cleanupId, 'Waiting for host agent to cancel creation and remove its partial instance', { phase: 'container-action', blocker: null,
|
||||
command: { state: 'attempted', message: `Creation cancellation queued as agent operation ${operation.id}. Ports remain reserved until cleanup is confirmed.` } });
|
||||
const operation = queueHostOperation(
|
||||
instance.host_id,
|
||||
'instance.cancel-create',
|
||||
payload,
|
||||
{ instanceId: instance.id, generation, idempotencyKey: cleanupId }
|
||||
);
|
||||
db()
|
||||
.prepare('UPDATE native_provisioning SET operation_id=? WHERE deployment_id=?')
|
||||
.run(operation.id, cleanupId);
|
||||
db()
|
||||
.prepare('UPDATE server_deployments SET submitted_at=? WHERE id=?')
|
||||
.run(new Date().toISOString(), cleanupId);
|
||||
deploymentStage(
|
||||
cleanupId,
|
||||
'Waiting for host agent to cancel creation and remove its partial instance',
|
||||
{
|
||||
phase: 'container-action',
|
||||
blocker: null,
|
||||
command: {
|
||||
state: 'attempted',
|
||||
message: `Creation cancellation queued as agent operation ${operation.id}. Ports remain reserved until cleanup is confirmed.`
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
return cleanupId;
|
||||
})();
|
||||
}
|
||||
|
||||
export function finishCreationCancellation(cleanupId: string): void {
|
||||
const cancellation = db().prepare(`SELECT c.creation_id,n.operation_id,d.server FROM native_creation_cancellations c
|
||||
const cancellation = db()
|
||||
.prepare(
|
||||
`SELECT c.creation_id,n.operation_id,d.server FROM native_creation_cancellations c
|
||||
JOIN server_deployments d ON d.id=c.creation_id
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=c.creation_id WHERE c.cleanup_id=?`).get(cleanupId) as
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=c.creation_id WHERE c.cleanup_id=?`
|
||||
)
|
||||
.get(cleanupId) as
|
||||
{ creation_id: string; operation_id: string | null; server: string } | undefined;
|
||||
if (!cancellation) return;
|
||||
releaseServerKey(cancellation.server);
|
||||
const now = new Date().toISOString();
|
||||
db().prepare('UPDATE server_deployments SET server_deleted_at=? WHERE id IN (?,?)').run(now, cancellation.creation_id, cleanupId);
|
||||
deploymentStage(cancellation.creation_id, 'Creation cancelled; cleanup completed', { blocker: null,
|
||||
checkpoint: { key: 'cleanup', phase: 'cleanup', label: 'Cancellation cleanup confirmed; instance and reservations removed' } });
|
||||
db()
|
||||
.prepare('UPDATE server_deployments SET server_deleted_at=? WHERE id IN (?,?)')
|
||||
.run(now, cancellation.creation_id, cleanupId);
|
||||
deploymentStage(cancellation.creation_id, 'Creation cancelled; cleanup completed', {
|
||||
blocker: null,
|
||||
checkpoint: {
|
||||
key: 'cleanup',
|
||||
phase: 'cleanup',
|
||||
label: 'Cancellation cleanup confirmed; instance and reservations removed'
|
||||
}
|
||||
});
|
||||
// The original command may have lost its receipt. Cleanup proves that its effects are now gone.
|
||||
if (cancellation.operation_id) {
|
||||
const result = db().prepare("UPDATE ec_host_operations SET state='failed',error='Creation cancelled; cleanup confirmed',updated_at=? WHERE id=? AND state NOT IN ('completed','failed')")
|
||||
const result = db()
|
||||
.prepare(
|
||||
"UPDATE ec_host_operations SET state='failed',error='Creation cancelled; cleanup confirmed',updated_at=? WHERE id=? AND state NOT IN ('completed','failed')"
|
||||
)
|
||||
.run(now, cancellation.operation_id);
|
||||
if (result.changes) db().prepare("INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,'failed',?,'Creation cancelled; cleanup confirmed')").run(cancellation.operation_id, now);
|
||||
if (result.changes)
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,'failed',?,'Creation cancelled; cleanup confirmed')"
|
||||
)
|
||||
.run(cancellation.operation_id, now);
|
||||
}
|
||||
recordProgress(cleanupId, { event: { kind: 'cleanup', message: 'Cancelled creation cleanup completed.', details: { creationId: cancellation.creation_id } } });
|
||||
recordProgress(cleanupId, {
|
||||
event: {
|
||||
kind: 'cleanup',
|
||||
message: 'Cancelled creation cleanup completed.',
|
||||
details: { creationId: cancellation.creation_id }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,12 +8,19 @@ export const rebalanceCancellationGuard = `NOT (d.triggered_by LIKE 'rebalance:%
|
||||
SELECT 1 FROM ec_host_migrations m WHERE m.deployment_id=d.id AND m.remove_operation_id IS NOT NULL))`;
|
||||
|
||||
export function rebalanceCancellationBlocker(id: string): string | null {
|
||||
return db().prepare(`SELECT 1 FROM server_deployments d WHERE d.id=? AND NOT (${rebalanceCancellationGuard})`).get(id)
|
||||
? 'This rebalance move has committed source removal and must finish recreating the lobby. Stop the rebalance run to withdraw its remaining moves.' : null;
|
||||
return db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM server_deployments d WHERE d.id=? AND NOT (${rebalanceCancellationGuard})`
|
||||
)
|
||||
.get(id)
|
||||
? 'This rebalance move has committed source removal and must finish recreating the lobby. Stop the rebalance run to withdraw its remaining moves.'
|
||||
: null;
|
||||
}
|
||||
|
||||
export function provisioningCancelled(id: string): boolean {
|
||||
return !!db().prepare("SELECT 1 FROM server_deployments WHERE id=? AND state='cancelled'").get(id);
|
||||
return !!db()
|
||||
.prepare("SELECT 1 FROM server_deployments WHERE id=? AND state='cancelled'")
|
||||
.get(id);
|
||||
}
|
||||
|
||||
export function assertProvisioningNotCancelled(id: string): void {
|
||||
@@ -21,49 +28,102 @@ export function assertProvisioningNotCancelled(id: string): void {
|
||||
}
|
||||
|
||||
export function canCancelProvisioningJob(id: string): boolean {
|
||||
return !!db().prepare(`SELECT 1 FROM server_deployments d WHERE d.id=? AND d.server_deleted_at IS NULL
|
||||
AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') AND ${rebalanceCancellationGuard}`).get(id);
|
||||
return !!db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM server_deployments d WHERE d.id=? AND d.server_deleted_at IS NULL
|
||||
AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') AND ${rebalanceCancellationGuard}`
|
||||
)
|
||||
.get(id);
|
||||
}
|
||||
|
||||
/** Cancel remaining work, not effects already performed by the host. */
|
||||
export function cancelProvisioningJob(id: string, reason = 'Cancelled by operator', options: { preserveRebalanceRun?: boolean } = {}): boolean {
|
||||
export function cancelProvisioningJob(
|
||||
id: string,
|
||||
reason = 'Cancelled by operator',
|
||||
options: { preserveRebalanceRun?: boolean } = {}
|
||||
): boolean {
|
||||
ensureHostSchema(db());
|
||||
return db().transaction(() => {
|
||||
if (provisioningCancelled(id)) return true;
|
||||
if (!canCancelProvisioningJob(id)) return false;
|
||||
const rebalance = !!db().prepare("SELECT 1 FROM server_deployments WHERE id=? AND triggered_by LIKE 'rebalance:%'").get(id);
|
||||
const rebalance = !!db()
|
||||
.prepare(
|
||||
"SELECT 1 FROM server_deployments WHERE id=? AND triggered_by LIKE 'rebalance:%'"
|
||||
)
|
||||
.get(id);
|
||||
if (rebalance) {
|
||||
if (!options.preserveRebalanceRun) requestRebalanceStopForOperation(id);
|
||||
} else pauseScalingForOperation(id);
|
||||
const at = new Date().toISOString();
|
||||
// Only commands never delivered can be withdrawn. Delivered commands retain
|
||||
// their immutable IDs and receipts; replacements wait for their resolution.
|
||||
const commands = db().prepare(`SELECT o.id FROM ec_host_operations o
|
||||
const commands = db()
|
||||
.prepare(
|
||||
`SELECT o.id FROM ec_host_operations o
|
||||
WHERE o.state='pending' AND o.last_delivered_at IS NULL AND o.id IN (
|
||||
SELECT operation_id FROM native_provisioning WHERE deployment_id=?
|
||||
UNION SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?
|
||||
UNION SELECT remove_operation_id FROM ec_host_migrations WHERE deployment_id=?)`).all(id, id, id) as { id: string }[];
|
||||
UNION SELECT remove_operation_id FROM ec_host_migrations WHERE deployment_id=?)`
|
||||
)
|
||||
.all(id, id, id) as { id: string }[];
|
||||
for (const command of commands) {
|
||||
db().prepare("UPDATE ec_host_operations SET state='failed',error=?,updated_at=? WHERE id=?").run(reason, at, command.id);
|
||||
db().prepare("INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,'failed',?,?)").run(command.id, at, reason);
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE ec_host_operations SET state='failed',error=?,updated_at=? WHERE id=?"
|
||||
)
|
||||
.run(reason, at, command.id);
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,'failed',?,?)"
|
||||
)
|
||||
.run(command.id, at, reason);
|
||||
}
|
||||
db().prepare(`UPDATE staged_file_changes SET state='cancelled' WHERE state='pending' AND EXISTS (
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE staged_file_changes SET state='cancelled' WHERE state='pending' AND EXISTS (
|
||||
SELECT 1 FROM provisioning_sources s WHERE s.operation_id=? AND s.kind=staged_file_changes.source_kind
|
||||
AND s.source_id=staged_file_changes.source_id AND s.server=staged_file_changes.server)`).run(id);
|
||||
AND s.source_id=staged_file_changes.source_id AND s.server=staged_file_changes.server)`
|
||||
)
|
||||
.run(id);
|
||||
db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(id);
|
||||
db().prepare("UPDATE server_deployments SET state='cancelled',finished_at=?,stage=?,error=NULL WHERE id=?").run(at, reason, id);
|
||||
recordProgress(id, { blocker: null, checks: [],
|
||||
recovery: { state: 'released', at, operationId: id, message: 'Recovery capacity released by cancellation; this does not certify server health.' },
|
||||
event: { kind: 'outcome', message: reason, details: { state: 'cancelled', finishedAt: at, error: null, warnings: [] } } });
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE server_deployments SET state='cancelled',finished_at=?,stage=?,error=NULL WHERE id=?"
|
||||
)
|
||||
.run(at, reason, id);
|
||||
recordProgress(id, {
|
||||
blocker: null,
|
||||
checks: [],
|
||||
recovery: {
|
||||
state: 'released',
|
||||
at,
|
||||
operationId: id,
|
||||
message:
|
||||
'Recovery capacity released by cancellation; this does not certify server health.'
|
||||
},
|
||||
event: {
|
||||
kind: 'outcome',
|
||||
message: reason,
|
||||
details: { state: 'cancelled', finishedAt: at, error: null, warnings: [] }
|
||||
}
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
/** A cancelled controller job must not let a replacement overtake a delivered mutation. */
|
||||
export function unsettledCancelledCommand(server: string, replacementId: string): {
|
||||
deploymentId: string; operationId: string; state: string; error: string | null;
|
||||
export function unsettledCancelledCommand(
|
||||
server: string,
|
||||
replacementId: string
|
||||
): {
|
||||
deploymentId: string;
|
||||
operationId: string;
|
||||
state: string;
|
||||
error: string | null;
|
||||
} | null {
|
||||
const row = db().prepare(`SELECT d.id AS deploymentId,o.id AS operationId,o.state,o.error FROM server_deployments d
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT d.id AS deploymentId,o.id AS operationId,o.state,o.error FROM server_deployments d
|
||||
JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
LEFT JOIN ec_host_migrations m ON m.deployment_id=d.id
|
||||
JOIN ec_host_operations o ON o.id=n.operation_id OR o.id=m.remove_operation_id
|
||||
@@ -73,7 +133,10 @@ export function unsettledCancelledCommand(server: string, replacementId: string)
|
||||
OR (o.state='failed' AND o.id=n.operation_id AND o.kind='instance.rebuild'
|
||||
AND json_type(o.result,'$.rebuildFailure') IS NOT NULL AND n.applied_at IS NULL))
|
||||
AND NOT EXISTS (SELECT 1 FROM native_creation_cancellations c WHERE c.creation_id=d.id AND c.cleanup_id=?)
|
||||
ORDER BY d.rowid,o.rowid LIMIT 1`).get(server, replacementId) as
|
||||
{ deploymentId: string; operationId: string; state: string; error: string | null } | undefined;
|
||||
ORDER BY d.rowid,o.rowid LIMIT 1`
|
||||
)
|
||||
.get(server, replacementId) as
|
||||
| { deploymentId: string; operationId: string; state: string; error: string | null }
|
||||
| undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
@@ -7,14 +7,22 @@ export const preparingJobs = new Set<string>();
|
||||
/** A committed rebalance keeps its startup reservation while between source removal and creation. */
|
||||
export const committedRebalanceStartup = `(triggered_by LIKE 'rebalance:%' AND state IN ('waiting','interrupted')
|
||||
AND EXISTS (SELECT 1 FROM ec_host_migrations m WHERE m.deployment_id=server_deployments.id AND m.remove_operation_id IS NOT NULL))`;
|
||||
interface Holder { id: string; action: string; host_id: string | null; target_host_id: string | null; removing: number }
|
||||
interface Holder {
|
||||
id: string;
|
||||
action: string;
|
||||
host_id: string | null;
|
||||
target_host_id: string | null;
|
||||
removing: number;
|
||||
}
|
||||
|
||||
export function lifecycleHolders(): Holder[] {
|
||||
ensureHostSchema(db());
|
||||
// Find capacity owners through their active state or outstanding command.
|
||||
// A correlated OR over d used to walk every retained job and fetch its agent
|
||||
// receipt on every admission, even when all that history was completed.
|
||||
return db().prepare(`SELECT d.id,CASE WHEN n.recovery_step='rebuild' THEN 'rebuild' ELSE d.action END AS action,i.host_id,m.target_host_id,
|
||||
return db()
|
||||
.prepare(
|
||||
`SELECT d.id,CASE WHEN n.recovery_step='rebuild' THEN 'rebuild' ELSE d.action END AS action,i.host_id,m.target_host_id,
|
||||
CASE WHEN m.remove_operation_id IS NOT NULL AND m.removed_at IS NULL THEN 1 ELSE 0 END AS removing
|
||||
FROM server_deployments d
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
@@ -33,29 +41,64 @@ export function lifecycleHolders(): Holder[] {
|
||||
UNION SELECT m.deployment_id FROM ec_host_operations o
|
||||
JOIN ec_host_migrations m ON m.remove_operation_id=o.id
|
||||
WHERE o.state IN ('pending','accepted','running','unknown')
|
||||
)`)
|
||||
)`
|
||||
)
|
||||
.all(JSON.stringify([...preparingJobs])) as Holder[];
|
||||
}
|
||||
|
||||
/** Recheck inside the command transaction after every asynchronous preflight. */
|
||||
export function concurrencyBlocker(id: string, action: string, hostId?: string, removing = false): string | null {
|
||||
export function concurrencyBlocker(
|
||||
id: string,
|
||||
action: string,
|
||||
hostId?: string,
|
||||
removing = false
|
||||
): string | null {
|
||||
const settings = fleetSettings();
|
||||
const holders = lifecycleHolders();
|
||||
const others = holders.filter(row => row.id !== id);
|
||||
const own = holders.find(row => row.id === id);
|
||||
const others = holders.filter((row) => row.id !== id);
|
||||
const own = holders.find((row) => row.id === id);
|
||||
const continuing = own && !preparingJobs.has(id);
|
||||
const full = (limit: number, used: number) => limit > 0 && used >= limit;
|
||||
if (!continuing && full(settings.lifecycleConcurrency, others.length)) return 'Waiting for combined lifecycle capacity.';
|
||||
if (!continuing && action === 'rebuild' && full(settings.rebuildConcurrency, others.filter(row => row.action === 'rebuild').length)) return 'Waiting for rebuild capacity.';
|
||||
if (!continuing && full(settings.lifecycleConcurrency, others.length))
|
||||
return 'Waiting for combined lifecycle capacity.';
|
||||
if (
|
||||
!continuing &&
|
||||
action === 'rebuild' &&
|
||||
full(settings.rebuildConcurrency, others.filter((row) => row.action === 'rebuild').length)
|
||||
)
|
||||
return 'Waiting for rebuild capacity.';
|
||||
if (!hostId || continuing) return null;
|
||||
if (['create','rebuild'].includes(action) && full(settings.createConcurrencyPerHost,
|
||||
others.filter(row => ['create','rebuild'].includes(row.action) && (row.host_id === hostId || row.target_host_id === hostId)).length)) return 'Waiting for startup capacity on this host.';
|
||||
if ((action === 'destroy' || removing) && full(settings.destroyConcurrencyPerHost,
|
||||
others.filter(row => (row.action === 'destroy' || row.removing) && row.host_id === hostId).length)) return 'Waiting for destruction capacity on this host.';
|
||||
if (['start','restart','rebuild'].includes(action)) {
|
||||
const slots = db().prepare(`SELECT r.operation_id FROM restart_rollout r JOIN native_instances i
|
||||
ON i.server=r.server AND i.retired_at IS NULL WHERE r.healthy_at IS NULL AND i.host_id=? AND (r.operation_id IS NULL OR r.operation_id!=?)`).all(hostId, id);
|
||||
if (full(settings.restartConcurrencyPerHost, slots.length)) return 'Waiting for restart recovery capacity on this host.';
|
||||
if (
|
||||
['create', 'rebuild'].includes(action) &&
|
||||
full(
|
||||
settings.createConcurrencyPerHost,
|
||||
others.filter(
|
||||
(row) =>
|
||||
['create', 'rebuild'].includes(row.action) &&
|
||||
(row.host_id === hostId || row.target_host_id === hostId)
|
||||
).length
|
||||
)
|
||||
)
|
||||
return 'Waiting for startup capacity on this host.';
|
||||
if (
|
||||
(action === 'destroy' || removing) &&
|
||||
full(
|
||||
settings.destroyConcurrencyPerHost,
|
||||
others.filter(
|
||||
(row) => (row.action === 'destroy' || row.removing) && row.host_id === hostId
|
||||
).length
|
||||
)
|
||||
)
|
||||
return 'Waiting for destruction capacity on this host.';
|
||||
if (['start', 'restart', 'rebuild'].includes(action)) {
|
||||
const slots = db()
|
||||
.prepare(
|
||||
`SELECT r.operation_id FROM restart_rollout r JOIN native_instances i
|
||||
ON i.server=r.server AND i.retired_at IS NULL WHERE r.healthy_at IS NULL AND i.host_id=? AND (r.operation_id IS NULL OR r.operation_id!=?)`
|
||||
)
|
||||
.all(hostId, id);
|
||||
if (full(settings.restartConcurrencyPerHost, slots.length))
|
||||
return 'Waiting for restart recovery capacity on this host.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
||||
import { monitoringIdentity } from '../fleet/health-observations.js';
|
||||
import { getServer, setServerFields, type ServerRow } from '../fleet/servers.js';
|
||||
import { latestGameSubmission, telemetryReport, type VerifiedGameStatus } from '../fleet/status-evidence.js';
|
||||
import {
|
||||
latestGameSubmission,
|
||||
telemetryReport,
|
||||
type VerifiedGameStatus
|
||||
} from '../fleet/status-evidence.js';
|
||||
import { serverObservation } from '../fleet/status.js';
|
||||
import { activityFields, successfulStatusFields } from '../fleet/status-updates.js';
|
||||
|
||||
/** Called inside the completion transaction; evidence must still describe this process. */
|
||||
export function handoffVerifiedHealth(server: string, identity: string,
|
||||
container: { state: string; observedAt: string }, game: VerifiedGameStatus): boolean {
|
||||
export function handoffVerifiedHealth(
|
||||
server: string,
|
||||
identity: string,
|
||||
container: { state: string; observedAt: string },
|
||||
game: VerifiedGameStatus
|
||||
): boolean {
|
||||
const current = getServer(server);
|
||||
if (!current || current.desired_state !== 'running' || monitoringIdentity(server) !== identity) return false;
|
||||
if (!current || current.desired_state !== 'running' || monitoringIdentity(server) !== identity)
|
||||
return false;
|
||||
const fresh = (at: string) => {
|
||||
const age = Date.now() - Date.parse(at);
|
||||
return age >= 0 && age <= ONLINE_MAX_MS;
|
||||
};
|
||||
const gameAt = game.source === 'telemetry' ? game.receivedAt : game.observedAt;
|
||||
const submission = latestGameSubmission(server);
|
||||
if (!fresh(container.observedAt) || !fresh(gameAt) || (submission && gameAt <= submission)) return false;
|
||||
if (!fresh(container.observedAt) || !fresh(gameAt) || (submission && gameAt <= submission))
|
||||
return false;
|
||||
|
||||
const updates: Partial<ServerRow> = {};
|
||||
if (!current.container_checked_at || current.container_checked_at <= container.observedAt) {
|
||||
updates.container_state = container.state;
|
||||
updates.container_checked_at = container.observedAt;
|
||||
}
|
||||
if (game.source === 'rcon' && (!current.last_checked_at || current.last_checked_at <= game.observedAt)) {
|
||||
if (
|
||||
game.source === 'rcon' &&
|
||||
(!current.last_checked_at || current.last_checked_at <= game.observedAt)
|
||||
) {
|
||||
Object.assign(updates, successfulStatusFields(game.rcon, game.observedAt));
|
||||
}
|
||||
const candidate = { ...current, ...updates };
|
||||
// Evaluate the health readers will see after the operation and recovery slot
|
||||
// complete. Newer contradictory evidence must keep verification pending.
|
||||
const status = serverObservation(candidate, { provisioning: false, settling: false,
|
||||
submission, telemetry: telemetryReport(server) });
|
||||
const status = serverObservation(candidate, {
|
||||
provisioning: false,
|
||||
settling: false,
|
||||
submission,
|
||||
telemetry: telemetryReport(server)
|
||||
});
|
||||
if (status.health !== 'online') return false;
|
||||
setServerFields(server, { ...updates, ...activityFields(candidate, status) });
|
||||
return true;
|
||||
|
||||
@@ -4,52 +4,107 @@ import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { recipeSummaryForDeployment } from '../fleet/launch-recipes.js';
|
||||
import { actionLabels, type ProvisioningAction } from '../../provisioning.js';
|
||||
import type { ProvisioningCapacity, ProvisioningDeployment, ProvisioningDetail, ProvisioningSource } from '../../provisioning-detail.js';
|
||||
import type {
|
||||
ProvisioningCapacity,
|
||||
ProvisioningDeployment,
|
||||
ProvisioningDetail,
|
||||
ProvisioningSource
|
||||
} from '../../provisioning-detail.js';
|
||||
import { fleetSettings } from '../settings.js';
|
||||
import { activitySummary, deploymentEvents, latestFailureEvent } from './activity.js';
|
||||
import { readProgress, recordProgress, type ProgressUpdate } from './progress.js';
|
||||
import { bindNativeOperation } from './native.js';
|
||||
import { workerActivity } from '../pipeline/activity.js';
|
||||
import { canCancelCreation } from './cancel-creation.js';
|
||||
import { canCancelProvisioningJob, provisioningCancelled, rebalanceCancellationGuard } from './cancellation.js';
|
||||
import {
|
||||
canCancelProvisioningJob,
|
||||
provisioningCancelled,
|
||||
rebalanceCancellationGuard
|
||||
} from './cancellation.js';
|
||||
import { committedRebalanceStartup } from './concurrency.js';
|
||||
import { nativeDeployment } from '../backends/native.js';
|
||||
import { ensureHostSchema } from '../hosts/schema.js';
|
||||
import { safetyOverride, safetyOverrideAvailability } from './safety-override.js';
|
||||
import { serverTitle } from '../../ui/server-labels.js';
|
||||
import { provisioningQueueStatus } from '../../ui/provisioning-queue.js';
|
||||
import { provisioningPageSize, provisioningPageOffset, provisioningSourceKinds, type ProvisioningListOptions, type ProvisioningListPage, type ProvisioningListRow } from '../../provisioning-list.js';
|
||||
import {
|
||||
provisioningPageSize,
|
||||
provisioningPageOffset,
|
||||
provisioningSourceKinds,
|
||||
type ProvisioningListOptions,
|
||||
type ProvisioningListPage,
|
||||
type ProvisioningListRow
|
||||
} from '../../provisioning-list.js';
|
||||
|
||||
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
|
||||
export function beginDeployment(
|
||||
server: string,
|
||||
action: ProvisioningAction,
|
||||
triggeredBy = 'ui'
|
||||
): string {
|
||||
const id = randomUUID();
|
||||
db().transaction(() => {
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by, requested_by)
|
||||
VALUES (?, ?, ?, 'running', 'Starting', ?, ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy,
|
||||
triggeredBy === 'ui' || triggeredBy.startsWith('rebalance:') ? currentRequestUsername() : null);
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by, requested_by)
|
||||
VALUES (?, ?, ?, 'running', 'Starting', ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
server,
|
||||
action,
|
||||
new Date().toISOString(),
|
||||
triggeredBy,
|
||||
triggeredBy === 'ui' || triggeredBy.startsWith('rebalance:')
|
||||
? currentRequestUsername()
|
||||
: null
|
||||
);
|
||||
bindNativeOperation(id, server);
|
||||
recordProgress(id, { phase: 'requested', observedAt: null, checkpoint: { key: 'requested', phase: 'requested',
|
||||
label: action === 'create' ? 'Server creation requested'
|
||||
: action === 'destroy' ? 'Server destruction requested' : `${actionLabels[action]} server requested` } });
|
||||
recordProgress(id, {
|
||||
phase: 'requested',
|
||||
observedAt: null,
|
||||
checkpoint: {
|
||||
key: 'requested',
|
||||
phase: 'requested',
|
||||
label:
|
||||
action === 'create'
|
||||
? 'Server creation requested'
|
||||
: action === 'destroy'
|
||||
? 'Server destruction requested'
|
||||
: `${actionLabels[action]} server requested`
|
||||
}
|
||||
});
|
||||
})();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function deploymentStage(id: string, stage: string, update?: ProgressUpdate): void {
|
||||
db().transaction(() => {
|
||||
const previous = db().prepare('SELECT stage FROM server_deployments WHERE id = ?').get(id) as { stage: string } | undefined;
|
||||
const previous = db()
|
||||
.prepare('SELECT stage FROM server_deployments WHERE id = ?')
|
||||
.get(id) as { stage: string } | undefined;
|
||||
const changed = !!previous && previous.stage !== stage;
|
||||
const before = update && changed ? activitySummary(id).latestId ?? 0 : 0;
|
||||
const before = update && changed ? (activitySummary(id).latestId ?? 0) : 0;
|
||||
db().prepare('UPDATE server_deployments SET stage = ? WHERE id = ?').run(stage, id);
|
||||
if (update) recordProgress(id, update);
|
||||
// Structured transitions may already carry the exact legacy stage text.
|
||||
const alreadyRecorded = update && changed && db().prepare(`SELECT 1 FROM server_deployment_events
|
||||
WHERE deployment_id = ? AND id > ? AND message = ? LIMIT 1`).get(id, before, stage);
|
||||
const alreadyRecorded =
|
||||
update &&
|
||||
changed &&
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM server_deployment_events
|
||||
WHERE deployment_id = ? AND id > ? AND message = ? LIMIT 1`
|
||||
)
|
||||
.get(id, before, stage);
|
||||
if (changed && !alreadyRecorded) recordDeploymentEvent(id, stage);
|
||||
})();
|
||||
}
|
||||
|
||||
export function recordDeploymentEvent(id: string, message: string): void {
|
||||
db().prepare('INSERT INTO server_deployment_events (deployment_id, created_at, message) VALUES (?, ?, ?)')
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO server_deployment_events (deployment_id, created_at, message) VALUES (?, ?, ?)'
|
||||
)
|
||||
.run(id, new Date().toISOString(), message);
|
||||
}
|
||||
|
||||
@@ -58,103 +113,239 @@ function warningMessages(raw: string): string[] {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return ['Recorded warnings could not be read.'];
|
||||
const messages = parsed.filter((value): value is string => typeof value === 'string');
|
||||
if (messages.length !== parsed.length) messages.push('Some recorded warnings could not be read.');
|
||||
if (messages.length !== parsed.length)
|
||||
messages.push('Some recorded warnings could not be read.');
|
||||
return messages;
|
||||
} catch { return ['Recorded warnings could not be read.']; }
|
||||
} catch {
|
||||
return ['Recorded warnings could not be read.'];
|
||||
}
|
||||
}
|
||||
|
||||
function sourceDetails(source: Omit<ProvisioningSource, 'label' | 'href'>): ProvisioningSource {
|
||||
const id = source.source_id;
|
||||
if (source.kind === 'rebalance') {
|
||||
const exists = !!db().prepare('SELECT 1 FROM rebalancing_runs WHERE id=?').get(id);
|
||||
return { ...source, label: exists ? 'Rebalance run' : 'Rebalance history expired',
|
||||
href: exists ? `/capacity?rebalance=1&rebalanceRun=${encodeURIComponent(id)}` : null };
|
||||
return {
|
||||
...source,
|
||||
label: exists ? 'Rebalance run' : 'Rebalance history expired',
|
||||
href: exists ? `/capacity?rebalance=1&rebalanceRun=${encodeURIComponent(id)}` : null
|
||||
};
|
||||
}
|
||||
if (source.kind === 'autoscaling') {
|
||||
const exists = !!db().prepare('SELECT 1 FROM autoscaling_policies WHERE id=?').get(id);
|
||||
return { ...source, label: exists ? 'Autoscaling policy' : 'Deleted autoscaling policy', href: exists ? `/autoscaling#${encodeURIComponent(id)}` : null };
|
||||
return {
|
||||
...source,
|
||||
label: exists ? 'Autoscaling policy' : 'Deleted autoscaling policy',
|
||||
href: exists ? `/autoscaling#${encodeURIComponent(id)}` : null
|
||||
};
|
||||
}
|
||||
if (source.kind === 'campaign') {
|
||||
const row = db().prepare(`SELECT d.action, d.beta_id, s.name FROM deployments d
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT d.action, d.beta_id, s.name FROM deployments d
|
||||
LEFT JOIN scripts s ON s.id=COALESCE(d.script_id, (SELECT script_id FROM deployment_scripts WHERE deployment_id=d.id ORDER BY script_id LIMIT 1))
|
||||
WHERE d.id=?`).get(id) as { action: string; beta_id: number | null; name: string | null } | undefined;
|
||||
const action = row?.action === 'promote' ? 'Promotion' : row?.action === 'rollback' ? 'Rollback' : row?.action === 'drift' ? 'Maintenance repair' : 'Campaign';
|
||||
return { ...source, label: row ? `${action}${row.beta_id ? ` · Beta #${row.beta_id}` : row.name ? ` · ${row.name}` : action === 'Campaign' ? '' : ' campaign'}` : 'Campaign unavailable', href: row ? `/campaigns/${encodeURIComponent(id)}` : null };
|
||||
WHERE d.id=?`
|
||||
)
|
||||
.get(id) as { action: string; beta_id: number | null; name: string | null } | undefined;
|
||||
const action =
|
||||
row?.action === 'promote'
|
||||
? 'Promotion'
|
||||
: row?.action === 'rollback'
|
||||
? 'Rollback'
|
||||
: row?.action === 'drift'
|
||||
? 'Maintenance repair'
|
||||
: 'Campaign';
|
||||
return {
|
||||
...source,
|
||||
label: row
|
||||
? `${action}${row.beta_id ? ` · Beta #${row.beta_id}` : row.name ? ` · ${row.name}` : action === 'Campaign' ? '' : ' campaign'}`
|
||||
: 'Campaign unavailable',
|
||||
href: row ? `/campaigns/${encodeURIComponent(id)}` : null
|
||||
};
|
||||
}
|
||||
if (source.kind === 'beta') {
|
||||
const row = db().prepare('SELECT notes FROM betas WHERE id = ?').get(id) as { notes: string | null } | undefined;
|
||||
const row = db().prepare('SELECT notes FROM betas WHERE id = ?').get(id) as
|
||||
{ notes: string | null } | undefined;
|
||||
const label = row?.notes?.trim().split('\n')[0].slice(0, 120);
|
||||
return { ...source, label: label ? `Beta #${id}: ${label}` : `Beta #${id}`, href: row ? `/betas/${encodeURIComponent(id)}` : null };
|
||||
return {
|
||||
...source,
|
||||
label: label ? `Beta #${id}: ${label}` : `Beta #${id}`,
|
||||
href: row ? `/betas/${encodeURIComponent(id)}` : null
|
||||
};
|
||||
}
|
||||
if (source.kind === 'watchdog') {
|
||||
const row = db().prepare('SELECT summary FROM watchdog_incidents WHERE id = ?').get(id) as { summary: string } | undefined;
|
||||
return { ...source, label: row?.summary ?? `Maintenance finding ${id.slice(0, 8)}`, href: row ? `/maintenance/${encodeURIComponent(id)}` : null };
|
||||
const row = db().prepare('SELECT summary FROM watchdog_incidents WHERE id = ?').get(id) as
|
||||
{ summary: string } | undefined;
|
||||
return {
|
||||
...source,
|
||||
label: row?.summary ?? `Maintenance finding ${id.slice(0, 8)}`,
|
||||
href: row ? `/maintenance/${encodeURIComponent(id)}` : null
|
||||
};
|
||||
}
|
||||
const row = db().prepare('SELECT kind FROM maintenance_jobs WHERE id = ?').get(id) as { kind: string } | undefined;
|
||||
return { ...source, label: row && row.kind !== 'legacy' ? `Maintenance ${row.kind} #${id}` : `Maintenance #${id}`,
|
||||
href: row ? `/maintenance?view=activity#job-${encodeURIComponent(id)}` : null };
|
||||
const row = db().prepare('SELECT kind FROM maintenance_jobs WHERE id = ?').get(id) as
|
||||
{ kind: string } | undefined;
|
||||
return {
|
||||
...source,
|
||||
label:
|
||||
row && row.kind !== 'legacy' ? `Maintenance ${row.kind} #${id}` : `Maintenance #${id}`,
|
||||
href: row ? `/maintenance?view=activity#job-${encodeURIComponent(id)}` : null
|
||||
};
|
||||
}
|
||||
|
||||
/** Active operations get current pool snapshots, not a second admission/eligibility engine. */
|
||||
function capacityFor(deployment: ProvisioningDeployment, evaluatedAt: string): ProvisioningCapacity {
|
||||
if (!['queued', 'waiting', 'running', 'awaiting-health'].includes(deployment.state)) return { evaluatedAt, pools: [] };
|
||||
function capacityFor(
|
||||
deployment: ProvisioningDeployment,
|
||||
evaluatedAt: string
|
||||
): ProvisioningCapacity {
|
||||
if (!['queued', 'waiting', 'running', 'awaiting-health'].includes(deployment.state))
|
||||
return { evaluatedAt, pools: [] };
|
||||
const settings = fleetSettings();
|
||||
const pools: ProvisioningCapacity['pools'] = [];
|
||||
if (deployment.action === 'create' || deployment.action === 'rebuild') {
|
||||
const jobs = db().prepare(`SELECT id, server, state FROM server_deployments
|
||||
const jobs = db()
|
||||
.prepare(
|
||||
`SELECT id, server, state FROM server_deployments
|
||||
WHERE server_deleted_at IS NULL AND action IN ('create','rebuild') AND (state IN ('queued','running','awaiting-health') OR ${committedRebalanceStartup})
|
||||
ORDER BY started_at, rowid`).all() as { id: string; server: string; state: string }[];
|
||||
const holders = jobs.filter(job => job.state !== 'queued').map(({ id, server }) => ({ id, server }));
|
||||
const order = jobs.filter(job => job.state === 'queued').findIndex(job => job.id === deployment.id);
|
||||
pools.push({ kind: 'startup', limit: settings.createConcurrency, used: holders.length, holders,
|
||||
requestOrder: order < 0 ? null : order + 1, ownsSlot: holders.some(holder => holder.id === deployment.id) });
|
||||
ORDER BY started_at, rowid`
|
||||
)
|
||||
.all() as { id: string; server: string; state: string }[];
|
||||
const holders = jobs
|
||||
.filter((job) => job.state !== 'queued')
|
||||
.map(({ id, server }) => ({ id, server }));
|
||||
const order = jobs
|
||||
.filter((job) => job.state === 'queued')
|
||||
.findIndex((job) => job.id === deployment.id);
|
||||
pools.push({
|
||||
kind: 'startup',
|
||||
limit: settings.createConcurrency,
|
||||
used: holders.length,
|
||||
holders,
|
||||
requestOrder: order < 0 ? null : order + 1,
|
||||
ownsSlot: holders.some((holder) => holder.id === deployment.id)
|
||||
});
|
||||
}
|
||||
const creationRecovery = deployment.action === 'create' && db().prepare(`SELECT 1 FROM native_provisioning
|
||||
WHERE deployment_id=? AND recovery_step IS NOT NULL`).get(deployment.id);
|
||||
const creationRecovery =
|
||||
deployment.action === 'create' &&
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM native_provisioning
|
||||
WHERE deployment_id=? AND recovery_step IS NOT NULL`
|
||||
)
|
||||
.get(deployment.id);
|
||||
if (['start', 'restart', 'rebuild'].includes(deployment.action) || creationRecovery) {
|
||||
const holders = db().prepare(`SELECT operation_id AS id, server FROM restart_rollout WHERE healthy_at IS NULL
|
||||
ORDER BY requested_at, server`).all() as { id: string | null; server: string }[];
|
||||
pools.push({ kind: 'restart', limit: settings.restartConcurrency, used: holders.length, holders,
|
||||
requestOrder: null, ownsSlot: holders.some(holder => holder.id === deployment.id) });
|
||||
const holders = db()
|
||||
.prepare(
|
||||
`SELECT operation_id AS id, server FROM restart_rollout WHERE healthy_at IS NULL
|
||||
ORDER BY requested_at, server`
|
||||
)
|
||||
.all() as { id: string | null; server: string }[];
|
||||
pools.push({
|
||||
kind: 'restart',
|
||||
limit: settings.restartConcurrency,
|
||||
used: holders.length,
|
||||
holders,
|
||||
requestOrder: null,
|
||||
ownsSlot: holders.some((holder) => holder.id === deployment.id)
|
||||
});
|
||||
}
|
||||
return { evaluatedAt, pools };
|
||||
}
|
||||
|
||||
/** The default retains the full legacy event response. Page clients opt into a bounded window. */
|
||||
export function getDeployment(id: string, options: { events?: 'all' | 'recent' } = {}): ProvisioningDetail | null {
|
||||
export function getDeployment(
|
||||
id: string,
|
||||
options: { events?: 'all' | 'recent' } = {}
|
||||
): ProvisioningDetail | null {
|
||||
return db().transaction(() => {
|
||||
const row = db().prepare(`SELECT d.*, j.expected_map, (d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists
|
||||
FROM server_deployments d LEFT JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE d.id = ?`).get(id) as
|
||||
(ProvisioningDeployment & { observation_json: string | null; phase_key: string | null; phase_started_at: string | null; observed_at: string | null }) | undefined;
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT d.*, j.expected_map, (d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists
|
||||
FROM server_deployments d LEFT JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE d.id = ?`
|
||||
)
|
||||
.get(id) as
|
||||
| (ProvisioningDeployment & {
|
||||
observation_json: string | null;
|
||||
phase_key: string | null;
|
||||
phase_started_at: string | null;
|
||||
observed_at: string | null;
|
||||
})
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
const generatedAt = new Date().toISOString();
|
||||
const native = nativeDeployment(id);
|
||||
const agentOperation = native ? db().prepare(`SELECT o.id, o.host_id AS hostId, o.state, o.updated_at AS updatedAt, o.created_at AS createdAt, o.error
|
||||
const agentOperation = native
|
||||
? (db()
|
||||
.prepare(
|
||||
`SELECT o.id, o.host_id AS hostId, o.state, o.updated_at AS updatedAt, o.created_at AS createdAt, o.error
|
||||
FROM native_provisioning n LEFT JOIN ec_host_migrations m ON m.deployment_id=n.deployment_id
|
||||
JOIN ec_host_operations o ON o.id=COALESCE(n.operation_id,m.remove_operation_id) WHERE n.deployment_id=?`).get(id) as ProvisioningDetail['agentOperation'] : null;
|
||||
const cancellation = db().prepare(`SELECT c.cleanup_id AS cleanupId,d.state,d.stage,d.error FROM native_creation_cancellations c
|
||||
JOIN server_deployments d ON d.id=c.cleanup_id WHERE c.creation_id=?`).get(id) as ProvisioningDetail['cancellation'];
|
||||
const { observation_json: _snapshot, phase_key: _phase, phase_started_at: _phaseStarted, observed_at: _observed, ...deployment } = row;
|
||||
const sources = (db().prepare('SELECT kind, source_id, server FROM provisioning_sources WHERE operation_id = ? ORDER BY kind, source_id')
|
||||
.all(id) as Omit<ProvisioningSource, 'label' | 'href'>[]).map(sourceDetails);
|
||||
if (row.triggered_by.startsWith('rebalance:')) sources.push(sourceDetails({ kind: 'rebalance', source_id: row.triggered_by.slice('rebalance:'.length) }));
|
||||
JOIN ec_host_operations o ON o.id=COALESCE(n.operation_id,m.remove_operation_id) WHERE n.deployment_id=?`
|
||||
)
|
||||
.get(id) as ProvisioningDetail['agentOperation'])
|
||||
: null;
|
||||
const cancellation = db()
|
||||
.prepare(
|
||||
`SELECT c.cleanup_id AS cleanupId,d.state,d.stage,d.error FROM native_creation_cancellations c
|
||||
JOIN server_deployments d ON d.id=c.cleanup_id WHERE c.creation_id=?`
|
||||
)
|
||||
.get(id) as ProvisioningDetail['cancellation'];
|
||||
const {
|
||||
observation_json: _snapshot,
|
||||
phase_key: _phase,
|
||||
phase_started_at: _phaseStarted,
|
||||
observed_at: _observed,
|
||||
...deployment
|
||||
} = row;
|
||||
const sources = (
|
||||
db()
|
||||
.prepare(
|
||||
'SELECT kind, source_id, server FROM provisioning_sources WHERE operation_id = ? ORDER BY kind, source_id'
|
||||
)
|
||||
.all(id) as Omit<ProvisioningSource, 'label' | 'href'>[]
|
||||
).map(sourceDetails);
|
||||
if (row.triggered_by.startsWith('rebalance:'))
|
||||
sources.push(
|
||||
sourceDetails({
|
||||
kind: 'rebalance',
|
||||
source_id: row.triggered_by.slice('rebalance:'.length)
|
||||
})
|
||||
);
|
||||
const launch = recipeSummaryForDeployment(id);
|
||||
const collection = db().prepare(`SELECT c.id, c.name FROM (
|
||||
const collection = db()
|
||||
.prepare(
|
||||
`SELECT c.id, c.name FROM (
|
||||
SELECT collection_id, deployment_id FROM server_collection_members
|
||||
UNION ALL SELECT collection_id, deployment_id FROM server_collection_removals) m
|
||||
JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ? ORDER BY c.created_at, c.id LIMIT 1`).get(id) as { id: string; name: string } | undefined;
|
||||
JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ? ORDER BY c.created_at, c.id LIMIT 1`
|
||||
)
|
||||
.get(id) as { id: string; name: string } | undefined;
|
||||
const override = safetyOverrideAvailability(id);
|
||||
return { deployment, backend: 'native' as const, agentOperation: agentOperation ?? null,
|
||||
canOverrideSafety: override.applicable, safetyOverrideUnavailableReason: override.unavailableReason, safetyOverride: safetyOverride(id),
|
||||
canCancel: canCancelProvisioningJob(id), canCancelCreation: canCancelCreation(id), cancellation: cancellation ?? null,
|
||||
workerActivity: workerActivity(), events: deploymentEvents(id, options.events === 'recent'),
|
||||
activity: { ...activitySummary(id), failureExcerpt: latestFailureEvent(id) }, sources,
|
||||
recipe: launch?.recipe ?? null, blueprintName: launch?.blueprintName ?? null,
|
||||
canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id),
|
||||
canResumeRebuild: canResumeRebuild(id), canRetryWithRebuild: canRetryWithRebuild(id),
|
||||
canCancelRemoval: canCancelRemoval(id), collection: collection ?? null,
|
||||
progress: readProgress(row), capacity: capacityFor(deployment, generatedAt),
|
||||
warningMessages: warningMessages(deployment.warnings), generatedAt };
|
||||
return {
|
||||
deployment,
|
||||
backend: 'native' as const,
|
||||
agentOperation: agentOperation ?? null,
|
||||
canOverrideSafety: override.applicable,
|
||||
safetyOverrideUnavailableReason: override.unavailableReason,
|
||||
safetyOverride: safetyOverride(id),
|
||||
canCancel: canCancelProvisioningJob(id),
|
||||
canCancelCreation: canCancelCreation(id),
|
||||
cancellation: cancellation ?? null,
|
||||
workerActivity: workerActivity(),
|
||||
events: deploymentEvents(id, options.events === 'recent'),
|
||||
activity: { ...activitySummary(id), failureExcerpt: latestFailureEvent(id) },
|
||||
sources,
|
||||
recipe: launch?.recipe ?? null,
|
||||
blueprintName: launch?.blueprintName ?? null,
|
||||
canResumeVerification: canResumeVerification(id),
|
||||
canResumeCleanup: canResumeCleanup(id),
|
||||
canResumeRebuild: canResumeRebuild(id),
|
||||
canRetryWithRebuild: canRetryWithRebuild(id),
|
||||
canCancelRemoval: canCancelRemoval(id),
|
||||
collection: collection ?? null,
|
||||
progress: readProgress(row),
|
||||
capacity: capacityFor(deployment, generatedAt),
|
||||
warningMessages: warningMessages(deployment.warnings),
|
||||
generatedAt
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -162,29 +353,46 @@ export function getDeployment(id: string, options: { events?: 'all' | 'recent' }
|
||||
export const REMOVING_SERVER = 'Removing server files and registry';
|
||||
|
||||
export function canCancelRemoval(id: string): boolean {
|
||||
return !!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state = 'waiting' AND stage != ?").get(id, REMOVING_SERVER);
|
||||
return !!db()
|
||||
.prepare(
|
||||
"SELECT id FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state = 'waiting' AND stage != ?"
|
||||
)
|
||||
.get(id, REMOVING_SERVER);
|
||||
}
|
||||
|
||||
/** Competes atomically with the worker's claim, before any external removal. */
|
||||
export function cancelRemoval(id: string, pausePolicy = true): boolean {
|
||||
return db().transaction(() => {
|
||||
const finishedAt = new Date().toISOString();
|
||||
const result = db().prepare(`UPDATE server_deployments SET state = 'cancelled', stage = 'Removal cancelled', finished_at = ?, error = NULL
|
||||
WHERE id = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state = 'waiting' AND stage != ?`).run(finishedAt, id, REMOVING_SERVER);
|
||||
const result = db()
|
||||
.prepare(
|
||||
`UPDATE server_deployments SET state = 'cancelled', stage = 'Removal cancelled', finished_at = ?, error = NULL
|
||||
WHERE id = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state = 'waiting' AND stage != ?`
|
||||
)
|
||||
.run(finishedAt, id, REMOVING_SERVER);
|
||||
if (!result.changes) return false;
|
||||
if (pausePolicy) pauseScalingForOperation(id);
|
||||
recordProgress(id, { blocker: null, checks: [], event: { kind: 'outcome',
|
||||
message: 'Queued removal cancelled. The server and its files were kept.',
|
||||
details: { state: 'cancelled', finishedAt } } });
|
||||
recordProgress(id, {
|
||||
blocker: null,
|
||||
checks: [],
|
||||
event: {
|
||||
kind: 'outcome',
|
||||
message: 'Queued removal cancelled. The server and its files were kept.',
|
||||
details: { state: 'cancelled', finishedAt }
|
||||
}
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
export function canResumeCleanup(id: string): boolean {
|
||||
return !!db().prepare(`SELECT d.id FROM server_deployments d
|
||||
return !!db()
|
||||
.prepare(
|
||||
`SELECT d.id FROM server_deployments d
|
||||
WHERE d.id = ? AND (d.server_deleted_at IS NULL OR NOT EXISTS (SELECT 1 FROM servers s WHERE s.name = d.server))
|
||||
AND d.action = 'destroy' AND d.state IN ('failed', 'interrupted') AND d.stage = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`)
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`
|
||||
)
|
||||
.get(id, REMOVING_SERVER);
|
||||
}
|
||||
|
||||
@@ -192,16 +400,30 @@ export function resumeCleanup(id: string): boolean {
|
||||
return db().transaction(() => {
|
||||
if (!canResumeCleanup(id)) return false;
|
||||
preserveOutcome(id);
|
||||
db().prepare("UPDATE server_deployments SET state = 'waiting', finished_at = NULL, error = NULL WHERE id = ?").run(id);
|
||||
recordProgress(id, { phase: 'cleanup', blocker: null, checks: [], observedAt: null,
|
||||
event: { kind: 'resumed', message: 'Resumed local cleanup after confirmed instance removal' } });
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE server_deployments SET state = 'waiting', finished_at = NULL, error = NULL WHERE id = ?"
|
||||
)
|
||||
.run(id);
|
||||
recordProgress(id, {
|
||||
phase: 'cleanup',
|
||||
blocker: null,
|
||||
checks: [],
|
||||
observedAt: null,
|
||||
event: {
|
||||
kind: 'resumed',
|
||||
message: 'Resumed local cleanup after confirmed instance removal'
|
||||
}
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
export function canResumeVerification(id: string): boolean {
|
||||
ensureHostSchema(db());
|
||||
return !!db().prepare(`SELECT d.id FROM server_deployments d
|
||||
return !!db()
|
||||
.prepare(
|
||||
`SELECT d.id FROM server_deployments d
|
||||
JOIN server_creation_jobs j ON j.deployment_id = d.id
|
||||
JOIN servers s ON s.name = j.server AND s.game_port = j.port AND s.destroyed_at IS NULL
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
@@ -215,14 +437,17 @@ export function canResumeVerification(id: string): boolean {
|
||||
OR (o.kind='instance.rebuild' AND i.generation IN (n.generation,n.generation+1))))
|
||||
OR (n.deployment_id IS NULL AND (d.action='create' OR EXISTS (
|
||||
SELECT 1 FROM server_rebuild_jobs r WHERE r.deployment_id=d.id AND r.removed_at IS NOT NULL))))
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`)
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`
|
||||
)
|
||||
.get(id);
|
||||
}
|
||||
|
||||
/** A conclusively failed initial create can enter the normal rebuild flow. */
|
||||
export function canRetryWithRebuild(id: string): boolean {
|
||||
ensureHostSchema(db());
|
||||
return !!db().prepare(`SELECT d.id FROM server_deployments d
|
||||
return !!db()
|
||||
.prepare(
|
||||
`SELECT d.id FROM server_deployments d
|
||||
JOIN server_creation_jobs j ON j.deployment_id=d.id
|
||||
JOIN servers s ON s.name=d.server AND s.game_port=j.port AND s.destroyed_at IS NULL
|
||||
JOIN native_provisioning n ON n.deployment_id=d.id AND n.recovery_step IS NULL
|
||||
@@ -233,14 +458,18 @@ export function canRetryWithRebuild(id: string): boolean {
|
||||
WHERE d.id=? AND d.server_deleted_at IS NULL AND d.action='create'
|
||||
AND d.state IN ('failed','interrupted') AND o.kind='instance.create' AND o.state='failed'
|
||||
AND NOT EXISTS (SELECT 1 FROM native_creation_cancellations c WHERE c.creation_id=d.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server=d.server AND newer.rowid>d.rowid)`)
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server=d.server AND newer.rowid>d.rowid)`
|
||||
)
|
||||
.get(id);
|
||||
}
|
||||
|
||||
export function canResumeRebuild(id: string): boolean {
|
||||
return !!db().prepare(`SELECT d.id FROM server_deployments d JOIN server_rebuild_jobs r ON r.deployment_id = d.id
|
||||
return !!db()
|
||||
.prepare(
|
||||
`SELECT d.id FROM server_deployments d JOIN server_rebuild_jobs r ON r.deployment_id = d.id
|
||||
WHERE d.id = ? AND d.server_deleted_at IS NULL AND d.state IN ('failed','interrupted') AND d.stage = ? AND r.removed_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`)
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`
|
||||
)
|
||||
.get(id, REMOVING_SERVER);
|
||||
}
|
||||
|
||||
@@ -248,9 +477,21 @@ export function resumeRebuild(id: string): boolean {
|
||||
return db().transaction(() => {
|
||||
if (!canResumeRebuild(id)) return false;
|
||||
preserveOutcome(id);
|
||||
db().prepare("UPDATE server_deployments SET state = 'waiting', finished_at = NULL, error = NULL WHERE id = ?").run(id);
|
||||
recordProgress(id, { phase: 'cleanup', blocker: null, checks: [], observedAt: null,
|
||||
event: { kind: 'resumed', message: 'Resumed rebuild cleanup after confirmed instance removal' } });
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE server_deployments SET state = 'waiting', finished_at = NULL, error = NULL WHERE id = ?"
|
||||
)
|
||||
.run(id);
|
||||
recordProgress(id, {
|
||||
phase: 'cleanup',
|
||||
blocker: null,
|
||||
checks: [],
|
||||
observedAt: null,
|
||||
event: {
|
||||
kind: 'resumed',
|
||||
message: 'Resumed rebuild cleanup after confirmed instance removal'
|
||||
}
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
@@ -260,46 +501,118 @@ export function resumeVerification(id: string): boolean {
|
||||
return db().transaction(() => {
|
||||
if (!canResumeVerification(id)) return false;
|
||||
preserveOutcome(id);
|
||||
db().prepare("UPDATE server_deployments SET state = 'awaiting-health', finished_at = NULL, error = NULL WHERE id = ?").run(id);
|
||||
deploymentStage(id, 'Waiting for host agent health', { phase: 'verify-container', blocker: null, checks: [], observedAt: null,
|
||||
event: { kind: 'resumed', message: 'Resumed verification of the existing server' } });
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE server_deployments SET state = 'awaiting-health', finished_at = NULL, error = NULL WHERE id = ?"
|
||||
)
|
||||
.run(id);
|
||||
deploymentStage(id, 'Waiting for host agent health', {
|
||||
phase: 'verify-container',
|
||||
blocker: null,
|
||||
checks: [],
|
||||
observedAt: null,
|
||||
event: { kind: 'resumed', message: 'Resumed verification of the existing server' }
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
export function finishDeployment(id: string, result: { ok: boolean; message?: string; warnings?: string[] }): void {
|
||||
export function finishDeployment(
|
||||
id: string,
|
||||
result: { ok: boolean; message?: string; warnings?: string[] }
|
||||
): void {
|
||||
// Creation failure cleanup deletes its own registry row, whose history trigger
|
||||
// marks it cancelled. Preserve that real failure; explicit cancellation stays terminal.
|
||||
if (provisioningCancelled(id) && !db().prepare("SELECT 1 FROM server_deployments WHERE id=? AND server_deleted_at IS NOT NULL AND error='Server deleted'").get(id)) return;
|
||||
if (
|
||||
provisioningCancelled(id) &&
|
||||
!db()
|
||||
.prepare(
|
||||
"SELECT 1 FROM server_deployments WHERE id=? AND server_deleted_at IS NOT NULL AND error='Server deleted'"
|
||||
)
|
||||
.get(id)
|
||||
)
|
||||
return;
|
||||
const state = !result.ok ? 'failed' : result.warnings?.length ? 'warning' : 'done';
|
||||
db().transaction(() => {
|
||||
const finishedAt = new Date().toISOString();
|
||||
db().prepare(`UPDATE server_deployments SET state = ?, finished_at = ?, error = ?, warnings = ? WHERE id = ?`)
|
||||
.run(state, finishedAt, result.message ?? null, JSON.stringify(result.warnings ?? []), id);
|
||||
for (const warning of result.warnings ?? []) recordProgress(id, { event: { kind: 'warning', message: warning } });
|
||||
recordProgress(id, { blocker: null, event: { kind: 'outcome',
|
||||
message: result.ok ? (state === 'warning' ? 'Completed with warnings' : 'Completed') : `Failed: ${result.message ?? 'Unknown error'}`,
|
||||
details: { state, finishedAt, error: result.message ?? null, warnings: result.warnings ?? [] } } });
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE server_deployments SET state = ?, finished_at = ?, error = ?, warnings = ? WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
state,
|
||||
finishedAt,
|
||||
result.message ?? null,
|
||||
JSON.stringify(result.warnings ?? []),
|
||||
id
|
||||
);
|
||||
for (const warning of result.warnings ?? [])
|
||||
recordProgress(id, { event: { kind: 'warning', message: warning } });
|
||||
recordProgress(id, {
|
||||
blocker: null,
|
||||
event: {
|
||||
kind: 'outcome',
|
||||
message: result.ok
|
||||
? state === 'warning'
|
||||
? 'Completed with warnings'
|
||||
: 'Completed'
|
||||
: `Failed: ${result.message ?? 'Unknown error'}`,
|
||||
details: {
|
||||
state,
|
||||
finishedAt,
|
||||
error: result.message ?? null,
|
||||
warnings: result.warnings ?? []
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
/** Legacy failures also need a durable structured result before same-ID resume clears it. */
|
||||
function preserveOutcome(id: string): void {
|
||||
const row = db().prepare('SELECT state, finished_at, error, warnings, observation_json FROM server_deployments WHERE id = ?').get(id) as
|
||||
{ state: string; finished_at: string | null; error: string | null; warnings: string; observation_json: string | null } | undefined;
|
||||
if (!row || !['failed', 'interrupted', 'warning', 'done', 'cancelled'].includes(row.state)) return;
|
||||
const previous = db().prepare("SELECT details_json FROM server_deployment_events WHERE deployment_id = ? AND kind = 'outcome' ORDER BY id DESC LIMIT 1")
|
||||
const row = db()
|
||||
.prepare(
|
||||
'SELECT state, finished_at, error, warnings, observation_json FROM server_deployments WHERE id = ?'
|
||||
)
|
||||
.get(id) as
|
||||
| {
|
||||
state: string;
|
||||
finished_at: string | null;
|
||||
error: string | null;
|
||||
warnings: string;
|
||||
observation_json: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row || !['failed', 'interrupted', 'warning', 'done', 'cancelled'].includes(row.state))
|
||||
return;
|
||||
const previous = db()
|
||||
.prepare(
|
||||
"SELECT details_json FROM server_deployment_events WHERE deployment_id = ? AND kind = 'outcome' ORDER BY id DESC LIMIT 1"
|
||||
)
|
||||
.get(id) as { details_json: string | null } | undefined;
|
||||
try {
|
||||
const details = JSON.parse(previous?.details_json ?? 'null');
|
||||
if (details?.state === row.state && details?.finishedAt === row.finished_at) return;
|
||||
} catch { /* Preserve the row's known outcome when old event metadata is missing. */ }
|
||||
recordProgress(id, { observedAt: readProgress(row)?.observedAt ?? null, event: { kind: 'outcome',
|
||||
message: `${row.state === 'interrupted' ? 'Interrupted' : 'Recorded outcome'}: ${row.error ?? row.state}`,
|
||||
details: { state: row.state, finishedAt: row.finished_at, error: row.error, warnings: warningMessages(row.warnings) } } });
|
||||
} catch {
|
||||
/* Preserve the row's known outcome when old event metadata is missing. */
|
||||
}
|
||||
recordProgress(id, {
|
||||
observedAt: readProgress(row)?.observedAt ?? null,
|
||||
event: {
|
||||
kind: 'outcome',
|
||||
message: `${row.state === 'interrupted' ? 'Interrupted' : 'Recorded outcome'}: ${row.error ?? row.state}`,
|
||||
details: {
|
||||
state: row.state,
|
||||
finishedAt: row.finished_at,
|
||||
error: row.error,
|
||||
warnings: warningMessages(row.warnings)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const activeDeployment = "d.server_deleted_at IS NULL AND d.state IN ('queued', 'awaiting-health', 'running', 'waiting')";
|
||||
const activeDeployment =
|
||||
"d.server_deleted_at IS NULL AND d.state IN ('queued', 'awaiting-health', 'running', 'waiting')";
|
||||
const reviewDeployment = `d.state IN ('failed', 'interrupted', 'warning') AND NOT EXISTS (
|
||||
SELECT 1 FROM retention_controls c WHERE c.category='provisioning' AND c.record_id=d.id
|
||||
AND c.resolved_at >= COALESCE(d.finished_at,d.started_at))`;
|
||||
@@ -314,29 +627,53 @@ const deploymentListColumns = `d.id, d.server, d.server_deleted_at, d.action, d.
|
||||
`;
|
||||
|
||||
function deploymentListQuery(options: ProvisioningListOptions) {
|
||||
const conditions = [options.view === 'active' ? `(${activeDeployment})`
|
||||
: options.view === 'history' ? `NOT (${activeDeployment})` : '1'];
|
||||
const conditions = [
|
||||
options.view === 'active'
|
||||
? `(${activeDeployment})`
|
||||
: options.view === 'history'
|
||||
? `NOT (${activeDeployment})`
|
||||
: '1'
|
||||
];
|
||||
const parameters: string[] = [];
|
||||
if (options.action) { conditions.push('d.action = ?'); parameters.push(options.action); }
|
||||
if (options.action) {
|
||||
conditions.push('d.action = ?');
|
||||
parameters.push(options.action);
|
||||
}
|
||||
if (options.status === 'attention') conditions.push(reviewDeployment);
|
||||
else if (options.status === 'pending') conditions.push("d.state IN ('queued','waiting')");
|
||||
else if (options.status) { conditions.push('d.state=?'); parameters.push(options.status); }
|
||||
const linked = (kinds: readonly string[]) => `(EXISTS (SELECT 1 FROM provisioning_sources src WHERE src.operation_id=d.id AND src.kind IN (${kinds.map(kind => `'${kind}'`).join(',')}))
|
||||
OR ${kinds.map(kind => `d.triggered_by LIKE '${kind}:%'`).join(' OR ')})`;
|
||||
else if (options.status) {
|
||||
conditions.push('d.state=?');
|
||||
parameters.push(options.status);
|
||||
}
|
||||
const linked = (
|
||||
kinds: readonly string[]
|
||||
) => `(EXISTS (SELECT 1 FROM provisioning_sources src WHERE src.operation_id=d.id AND src.kind IN (${kinds.map((kind) => `'${kind}'`).join(',')}))
|
||||
OR ${kinds.map((kind) => `d.triggered_by LIKE '${kind}:%'`).join(' OR ')})`;
|
||||
if (options.source === 'manual' || options.source === 'system') {
|
||||
conditions.push(`NOT ${linked(Object.values(provisioningSourceKinds).flat())}`);
|
||||
conditions.push(options.source === 'manual' ? "d.triggered_by='ui'" : "d.triggered_by!='ui'");
|
||||
conditions.push(
|
||||
options.source === 'manual' ? "d.triggered_by='ui'" : "d.triggered_by!='ui'"
|
||||
);
|
||||
} else if (options.source) conditions.push(linked(provisioningSourceKinds[options.source]));
|
||||
const search = (options.server ?? '').slice(0, 100).toLowerCase();
|
||||
if (search) {
|
||||
// Read only distinct names to preserve friendly map/lobby searches, including deleted servers.
|
||||
const names = (db().prepare('SELECT DISTINCT server FROM server_deployments').all() as { server: string }[])
|
||||
.map(row => row.server).filter(name => `${name} ${serverTitle(name)}`.toLowerCase().includes(search));
|
||||
const names = (
|
||||
db().prepare('SELECT DISTINCT server FROM server_deployments').all() as {
|
||||
server: string;
|
||||
}[]
|
||||
)
|
||||
.map((row) => row.server)
|
||||
.filter((name) => `${name} ${serverTitle(name)}`.toLowerCase().includes(search));
|
||||
conditions.push('d.server IN (SELECT value FROM json_each(?))');
|
||||
parameters.push(JSON.stringify(names));
|
||||
}
|
||||
return { where: conditions.join(' AND '), parameters,
|
||||
limit: provisioningPageSize(options.limit ?? 25), offset: provisioningPageOffset(options.offset ?? 0) };
|
||||
return {
|
||||
where: conditions.join(' AND '),
|
||||
parameters,
|
||||
limit: provisioningPageSize(options.limit ?? 25),
|
||||
offset: provisioningPageOffset(options.offset ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
function activeDeploymentRank(row: ProvisioningListRow): number {
|
||||
@@ -346,32 +683,72 @@ function activeDeploymentRank(row: ProvisioningListRow): number {
|
||||
// Launch preparation keeps its queued/waiting state until command submission.
|
||||
if (row.phase === 'prepare' && !row.waitCode) return 1;
|
||||
// Remote file staging currently records its progress only in the stage message.
|
||||
if (row.waitCode === 'native-host' && ['Staging and verifying files on the host.',
|
||||
'Waiting for verified remote file staging.'].includes(row.stage)) return 1;
|
||||
if (
|
||||
row.waitCode === 'native-host' &&
|
||||
[
|
||||
'Staging and verifying files on the host.',
|
||||
'Waiting for verified remote file staging.'
|
||||
].includes(row.stage)
|
||||
)
|
||||
return 1;
|
||||
if (group === 'queued') return 3;
|
||||
// A submitted command remains in progress while its agent result is pending.
|
||||
if (row.state === 'running' && row.phase === 'container-action' && row.waitCode === 'native-host') return 1;
|
||||
if (
|
||||
row.state === 'running' &&
|
||||
row.phase === 'container-action' &&
|
||||
row.waitCode === 'native-host'
|
||||
)
|
||||
return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function deploymentListRows(query: ReturnType<typeof deploymentListQuery>, active: boolean): ProvisioningListRow[] {
|
||||
const rows = db().prepare(`SELECT ${deploymentListColumns} FROM server_deployments d WHERE ${query.where}
|
||||
ORDER BY ${active ? 'd.started_at ASC, d.rowid ASC' : 'd.started_at DESC, d.rowid DESC LIMIT ? OFFSET ?'}`)
|
||||
.all(REMOVING_SERVER, ...query.parameters, ...(active ? [] : [query.limit, query.offset])) as ProvisioningListRow[];
|
||||
function deploymentListRows(
|
||||
query: ReturnType<typeof deploymentListQuery>,
|
||||
active: boolean
|
||||
): ProvisioningListRow[] {
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT ${deploymentListColumns} FROM server_deployments d WHERE ${query.where}
|
||||
ORDER BY ${active ? 'd.started_at ASC, d.rowid ASC' : 'd.started_at DESC, d.rowid DESC LIMIT ? OFFSET ?'}`
|
||||
)
|
||||
.all(
|
||||
REMOVING_SERVER,
|
||||
...query.parameters,
|
||||
...(active ? [] : [query.limit, query.offset])
|
||||
) as ProvisioningListRow[];
|
||||
if (!rows.length) return rows;
|
||||
// Stable sorting keeps request/insertion order within each progress group.
|
||||
if (active) rows.sort((left, right) => activeDeploymentRank(left) - activeDeploymentRank(right));
|
||||
const links = db().prepare(`SELECT operation_id,kind,source_id FROM provisioning_sources
|
||||
WHERE operation_id IN (SELECT value FROM json_each(?)) ORDER BY kind,source_id`).all(JSON.stringify(rows.map(row => row.id))) as
|
||||
(Omit<ProvisioningSource, 'label' | 'href'> & { operation_id: string })[];
|
||||
if (active)
|
||||
rows.sort((left, right) => activeDeploymentRank(left) - activeDeploymentRank(right));
|
||||
const links = db()
|
||||
.prepare(
|
||||
`SELECT operation_id,kind,source_id FROM provisioning_sources
|
||||
WHERE operation_id IN (SELECT value FROM json_each(?)) ORDER BY kind,source_id`
|
||||
)
|
||||
.all(JSON.stringify(rows.map((row) => row.id))) as (Omit<
|
||||
ProvisioningSource,
|
||||
'label' | 'href'
|
||||
> & { operation_id: string })[];
|
||||
const byOperation = new Map<string, Omit<ProvisioningSource, 'label' | 'href'>[]>();
|
||||
for (const { operation_id, ...link } of links) byOperation.set(operation_id, [...(byOperation.get(operation_id) ?? []), link]);
|
||||
for (const { operation_id, ...link } of links)
|
||||
byOperation.set(operation_id, [...(byOperation.get(operation_id) ?? []), link]);
|
||||
const cache = new Map<string, ProvisioningSource>();
|
||||
for (const row of rows) {
|
||||
const sources = byOperation.get(row.id) ?? [];
|
||||
const fallback = row.triggered_by.match(/^(campaign|beta|maintenance|watchdog|autoscaling|rebalance):(.+)$/);
|
||||
if (fallback && !sources.some(source => source.kind === fallback[1] && source.source_id === fallback[2])) sources.unshift({ kind: fallback[1] as ProvisioningSource['kind'], source_id: fallback[2] });
|
||||
row.sources = sources.map(source => {
|
||||
const fallback = row.triggered_by.match(
|
||||
/^(campaign|beta|maintenance|watchdog|autoscaling|rebalance):(.+)$/
|
||||
);
|
||||
if (
|
||||
fallback &&
|
||||
!sources.some(
|
||||
(source) => source.kind === fallback[1] && source.source_id === fallback[2]
|
||||
)
|
||||
)
|
||||
sources.unshift({
|
||||
kind: fallback[1] as ProvisioningSource['kind'],
|
||||
source_id: fallback[2]
|
||||
});
|
||||
row.sources = sources.map((source) => {
|
||||
const key = `${source.kind}:${source.source_id}`;
|
||||
if (!cache.has(key)) cache.set(key, sourceDetails(source));
|
||||
return cache.get(key)!;
|
||||
@@ -389,10 +766,20 @@ export function deploymentListPage(options: ProvisioningListOptions = {}): Provi
|
||||
return db().transaction(() => {
|
||||
const query = deploymentListQuery(options);
|
||||
const deployments = deploymentListRows(query, options.view === 'active');
|
||||
const total = options.view === 'active' ? deployments.length : (db().prepare(
|
||||
`SELECT COUNT(*) AS total FROM server_deployments d WHERE ${query.where}`
|
||||
).get(...query.parameters) as { total: number }).total;
|
||||
const { needsReview } = db().prepare(`SELECT COUNT(*) AS needsReview FROM server_deployments d WHERE ${reviewDeployment}`)
|
||||
const total =
|
||||
options.view === 'active'
|
||||
? deployments.length
|
||||
: (
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS total FROM server_deployments d WHERE ${query.where}`
|
||||
)
|
||||
.get(...query.parameters) as { total: number }
|
||||
).total;
|
||||
const { needsReview } = db()
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS needsReview FROM server_deployments d WHERE ${reviewDeployment}`
|
||||
)
|
||||
.get() as { needsReview: number };
|
||||
return { deployments, total, needsReview, limit: query.limit, offset: query.offset };
|
||||
})();
|
||||
@@ -400,12 +787,30 @@ export function deploymentListPage(options: ProvisioningListOptions = {}): Provi
|
||||
|
||||
export function interruptDeployments(): void {
|
||||
db().transaction(() => {
|
||||
const native = 'SELECT deployment_id FROM native_provisioning UNION ALL SELECT deployment_id FROM native_creation_intents';
|
||||
const running = db().prepare(`SELECT id FROM server_deployments WHERE state = 'running' AND id NOT IN (${native})`).all() as { id: string }[];
|
||||
const native =
|
||||
'SELECT deployment_id FROM native_provisioning UNION ALL SELECT deployment_id FROM native_creation_intents';
|
||||
const running = db()
|
||||
.prepare(
|
||||
`SELECT id FROM server_deployments WHERE state = 'running' AND id NOT IN (${native})`
|
||||
)
|
||||
.all() as { id: string }[];
|
||||
const finishedAt = new Date().toISOString();
|
||||
const error = 'The application restarted before this operation finished. Check the server before retrying.';
|
||||
db().prepare(`UPDATE server_deployments SET state = 'interrupted', finished_at = ?, error = ? WHERE state = 'running' AND id NOT IN (${native})`).run(finishedAt, error);
|
||||
for (const { id } of running) recordProgress(id, { blocker: null, event: { kind: 'outcome',
|
||||
message: 'Interrupted by application restart. Check the server before retrying.', details: { state: 'interrupted', finishedAt, error } } });
|
||||
const error =
|
||||
'The application restarted before this operation finished. Check the server before retrying.';
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE server_deployments SET state = 'interrupted', finished_at = ?, error = ? WHERE state = 'running' AND id NOT IN (${native})`
|
||||
)
|
||||
.run(finishedAt, error);
|
||||
for (const { id } of running)
|
||||
recordProgress(id, {
|
||||
blocker: null,
|
||||
event: {
|
||||
kind: 'outcome',
|
||||
message:
|
||||
'Interrupted by application restart. Check the server before retrying.',
|
||||
details: { state: 'interrupted', finishedAt, error }
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -7,50 +7,107 @@ import { beginDeployment, deploymentStage } from './history.js';
|
||||
import type { NativePlacement } from '../../hosts.js';
|
||||
|
||||
export { captureMoveRecipe } from './move-recipes.js';
|
||||
export { hostMigration, isRebalanceMigration, processMigrationRemoval, requestInstanceMove } from './migrations.js';
|
||||
export {
|
||||
hostMigration,
|
||||
isRebalanceMigration,
|
||||
processMigrationRemoval,
|
||||
requestInstanceMove
|
||||
} from './migrations.js';
|
||||
|
||||
/** Drain requests survive controller restarts and never make a source slot available early. */
|
||||
export function reconcileHostDrains(): void {
|
||||
for (const host of listHosts().filter(host => host.mode === 'draining' && !host.decommission)) {
|
||||
for (const host of listHosts().filter(
|
||||
(host) => host.mode === 'draining' && !host.decommission
|
||||
)) {
|
||||
db().transaction(() => {
|
||||
// Accepted launches that have no instance yet can be placed elsewhere.
|
||||
const queued = db().prepare(`SELECT q.deployment_id,q.selection FROM native_creation_intents q
|
||||
const queued = db()
|
||||
.prepare(
|
||||
`SELECT q.deployment_id,q.selection FROM native_creation_intents q
|
||||
JOIN server_deployments d ON d.id=q.deployment_id
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
WHERE d.state='queued' AND d.server_deleted_at IS NULL AND d.submitted_at IS NULL
|
||||
AND n.operation_id IS NULL AND n.stage_operation_id IS NULL
|
||||
AND json_extract(q.selection,'$.hostId')=?`).all(host.id) as { deployment_id: string; selection: string }[];
|
||||
AND json_extract(q.selection,'$.hostId')=?`
|
||||
)
|
||||
.all(host.id) as { deployment_id: string; selection: string }[];
|
||||
for (const row of queued) {
|
||||
const selection: NativePlacement = JSON.parse(row.selection);
|
||||
db().prepare('UPDATE native_creation_intents SET selection=? WHERE deployment_id=?')
|
||||
.run(JSON.stringify({ libraryId: selection.libraryId, image: selection.image, automatic: true, network: {} }), row.deployment_id);
|
||||
db()
|
||||
.prepare('UPDATE native_creation_intents SET selection=? WHERE deployment_id=?')
|
||||
.run(
|
||||
JSON.stringify({
|
||||
libraryId: selection.libraryId,
|
||||
image: selection.image,
|
||||
automatic: true,
|
||||
network: {}
|
||||
}),
|
||||
row.deployment_id
|
||||
);
|
||||
}
|
||||
const instances = db().prepare('SELECT * FROM native_instances WHERE host_id=? AND retired_at IS NULL').all(host.id) as NativeInstance[];
|
||||
const instances = db()
|
||||
.prepare('SELECT * FROM native_instances WHERE host_id=? AND retired_at IS NULL')
|
||||
.all(host.id) as NativeInstance[];
|
||||
for (const instance of instances) {
|
||||
const server = getServer(instance.server);
|
||||
if (!server?.map_slug || activeBetaRemovalBlocker(instance.server)) continue;
|
||||
// An explicit cancellation withdraws this instance's current drain attempt.
|
||||
const latest = db().prepare(`SELECT d.state FROM ec_host_migrations m JOIN server_deployments d ON d.id=m.deployment_id
|
||||
const latest = db()
|
||||
.prepare(
|
||||
`SELECT d.state FROM ec_host_migrations m JOIN server_deployments d ON d.id=m.deployment_id
|
||||
WHERE m.instance_id=? AND m.source_host_id=? AND d.triggered_by='host-drain'
|
||||
AND d.server_deleted_at IS NULL ORDER BY d.rowid DESC LIMIT 1`).get(instance.id, host.id) as { state: string } | undefined;
|
||||
AND d.server_deleted_at IS NULL ORDER BY d.rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(instance.id, host.id) as { state: string } | undefined;
|
||||
if (latest?.state === 'cancelled') continue;
|
||||
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server=? AND server_deleted_at IS NULL
|
||||
AND state IN ('queued','waiting','running','awaiting-health','interrupted')`).get(instance.server)) continue;
|
||||
const recipe = { ...captureRecipePlan(server.game as 't5' | 't6', server.map_slug), image: instance.image,
|
||||
native: { libraryId: instance.library_id, image: instance.image } };
|
||||
if (
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM server_deployments WHERE server=? AND server_deleted_at IS NULL
|
||||
AND state IN ('queued','waiting','running','awaiting-health','interrupted')`
|
||||
)
|
||||
.get(instance.server)
|
||||
)
|
||||
continue;
|
||||
const recipe = {
|
||||
...captureRecipePlan(server.game as 't5' | 't6', server.map_slug),
|
||||
image: instance.image,
|
||||
native: { libraryId: instance.library_id, image: instance.image }
|
||||
};
|
||||
const id = beginDeployment(instance.server, 'rebuild', 'host-drain');
|
||||
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(?,?,NULL,?,?,?)')
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO server_creation_jobs(deployment_id,server,port,game_mode,map_slug,expected_map) VALUES(?,?,NULL,?,?,?)'
|
||||
)
|
||||
.run(id, instance.server, '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);
|
||||
db().prepare('INSERT INTO ec_host_migrations(deployment_id,source_host_id,instance_id) VALUES(?,?,?)').run(id, host.id, instance.id);
|
||||
deploymentStage(id, 'Host draining; waiting for an empty lobby and destination capacity');
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO server_launch_recipes(deployment_id,recipe,recipe_summary) VALUES(?,?,?)'
|
||||
)
|
||||
.run(id, stored.recipe, stored.recipeSummary);
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO ec_host_migrations(deployment_id,source_host_id,instance_id) VALUES(?,?,?)'
|
||||
)
|
||||
.run(id, host.id, instance.id);
|
||||
deploymentStage(
|
||||
id,
|
||||
'Host draining; waiting for an empty lobby and destination capacity'
|
||||
);
|
||||
}
|
||||
const unfinished = db().prepare(`SELECT 1 FROM ec_host_migrations m JOIN server_deployments d ON d.id=m.deployment_id
|
||||
WHERE m.source_host_id=? AND d.state NOT IN ('done','warning','cancelled') AND d.server_deleted_at IS NULL LIMIT 1`).get(host.id);
|
||||
const ports = db().prepare('SELECT 1 FROM ec_host_ports WHERE host_id=? LIMIT 1').get(host.id);
|
||||
if (!instances.length && !unfinished && !ports) updateHost(host.id, { mode: 'disabled' });
|
||||
const unfinished = db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM ec_host_migrations m JOIN server_deployments d ON d.id=m.deployment_id
|
||||
WHERE m.source_host_id=? AND d.state NOT IN ('done','warning','cancelled') AND d.server_deleted_at IS NULL LIMIT 1`
|
||||
)
|
||||
.get(host.id);
|
||||
const ports = db()
|
||||
.prepare('SELECT 1 FROM ec_host_ports WHERE host_id=? LIMIT 1')
|
||||
.get(host.id);
|
||||
if (!instances.length && !unfinished && !ports)
|
||||
updateHost(host.id, { mode: 'disabled' });
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
// Request/observation boundary used by APIs, campaigns, beta tests, and schedules.
|
||||
// Execution is driven exclusively by the provisioning worker.
|
||||
export { requestLifecycle, requestRetryWithRebuild, requestRestart, requestWatchdogLifecycle, escalateWatchdogRestart, provisioningOperation, cancelProvisioning, releaseRestartRequest } from './requests.js';
|
||||
export {
|
||||
requestLifecycle,
|
||||
requestRetryWithRebuild,
|
||||
requestRestart,
|
||||
requestWatchdogLifecycle,
|
||||
escalateWatchdogRestart,
|
||||
provisioningOperation,
|
||||
cancelProvisioning,
|
||||
releaseRestartRequest
|
||||
} from './requests.js';
|
||||
export type { LifecycleOperation, RestartSource } from './requests.js';
|
||||
export { queueCreateServer, destroyServer, previewCreation, creationQueue, nextLobbyNumber } from './instances.js';
|
||||
export { getDeployment, listDeployments, resumeVerification, resumeCleanup, resumeRebuild, cancelRemoval } from './history.js';
|
||||
export {
|
||||
queueCreateServer,
|
||||
destroyServer,
|
||||
previewCreation,
|
||||
creationQueue,
|
||||
nextLobbyNumber
|
||||
} from './instances.js';
|
||||
export {
|
||||
getDeployment,
|
||||
listDeployments,
|
||||
resumeVerification,
|
||||
resumeCleanup,
|
||||
resumeRebuild,
|
||||
cancelRemoval
|
||||
} from './history.js';
|
||||
export { deploymentActivity } from './activity.js';
|
||||
export { restartSettling, restartRecoveryPending } from './restarts.js';
|
||||
|
||||
@@ -5,17 +5,22 @@ import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
||||
import type { GameMode, ZombieGame } from '../fleet/map-aliases.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
import { beginDeployment, deploymentStage, resumeCleanup } from './history.js';
|
||||
import { previewNativeCreation, queueNativeCreation, queueNativeRebuild, cancelQueuedNativeCreation } from './native.js';
|
||||
import {
|
||||
previewNativeCreation,
|
||||
queueNativeCreation,
|
||||
queueNativeRebuild,
|
||||
cancelQueuedNativeCreation
|
||||
} from './native.js';
|
||||
|
||||
export type CreateServerFailure =
|
||||
| 'bad-request'
|
||||
| 'conflict'
|
||||
| 'unconfigured' // native host configuration missing
|
||||
| 'unknown-map' // slug isn't in the catalog
|
||||
| 'no-slot' // all lobby slots for this map are taken
|
||||
| 'no-port' // port range exhausted
|
||||
| 'fs' // couldn't create host dir
|
||||
| 'db'; // shouldn't happen, but wraps INSERT failures
|
||||
| 'unconfigured' // native host configuration missing
|
||||
| 'unknown-map' // slug isn't in the catalog
|
||||
| 'no-slot' // all lobby slots for this map are taken
|
||||
| 'no-port' // port range exhausted
|
||||
| 'fs' // couldn't create host dir
|
||||
| 'db'; // shouldn't happen, but wraps INSERT failures
|
||||
|
||||
export type CreateServerResult =
|
||||
| { ok: true; name: string; port: number | null; deploymentId: string }
|
||||
@@ -23,7 +28,12 @@ export type CreateServerResult =
|
||||
|
||||
export type DestroyServerResult =
|
||||
| { ok: true; warnings: string[] }
|
||||
| { ok: false; kind: 'not-found' | 'conflict' | 'fs' | 'db'; message: string; warnings: string[] };
|
||||
| {
|
||||
ok: false;
|
||||
kind: 'not-found' | 'conflict' | 'fs' | 'db';
|
||||
message: string;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export interface CreateServerInput {
|
||||
hostId?: string;
|
||||
@@ -32,26 +42,28 @@ export interface CreateServerInput {
|
||||
bindAddress?: string;
|
||||
publicAddress?: string;
|
||||
publicPort?: number;
|
||||
game?: ZombieGame; // omitted by older clients: T5
|
||||
gameMode: GameMode; // 'zombie' — only value supported today
|
||||
mapSlug: string; // 'cosmodrome'
|
||||
plutoKey?: string; // added to the game pool and reserved for this launch when supplied
|
||||
game?: ZombieGame; // omitted by older clients: T5
|
||||
gameMode: GameMode; // 'zombie' — only value supported today
|
||||
mapSlug: string; // 'cosmodrome'
|
||||
plutoKey?: string; // added to the game pool and reserved for this launch when supplied
|
||||
expectedName?: string; // optional preview guard; never selects a lobby
|
||||
previewId?: string;
|
||||
createdBy?: string; // 'ui' by default
|
||||
createdBy?: string; // 'ui' by default
|
||||
}
|
||||
|
||||
export function nextLobbyNumber(baseName: string, additionalNames: string[] = []): number | null {
|
||||
const rows = db()
|
||||
.prepare(`SELECT name FROM servers WHERE name LIKE ? UNION
|
||||
.prepare(
|
||||
`SELECT name FROM servers WHERE name LIKE ? UNION
|
||||
SELECT server AS name FROM native_instances WHERE retired_at IS NULL UNION
|
||||
SELECT server_id AS name FROM server_key_leases UNION
|
||||
SELECT j.server AS name FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
|
||||
WHERE d.server_deleted_at IS NULL AND (d.state IN ('queued', 'running', 'awaiting-health') OR (d.state = 'cancelled' AND d.submitted_at IS NOT NULL) OR (d.action = 'rebuild' AND (d.state = 'waiting' OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry'))))`)
|
||||
WHERE d.server_deleted_at IS NULL AND (d.state IN ('queued', 'running', 'awaiting-health') OR (d.state = 'cancelled' AND d.submitted_at IS NOT NULL) OR (d.action = 'rebuild' AND (d.state = 'waiting' OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry'))))`
|
||||
)
|
||||
.all(`${baseName}_%`) as Array<{ name: string }>;
|
||||
const taken = new Set<number>();
|
||||
const prefix = `${baseName}_`;
|
||||
for (const r of [...rows, ...additionalNames.map(name => ({ name }))]) {
|
||||
for (const r of [...rows, ...additionalNames.map((name) => ({ name }))]) {
|
||||
if (!r.name.startsWith(prefix)) continue;
|
||||
const tail = r.name.slice(prefix.length);
|
||||
if (/^\d+$/.test(tail)) taken.add(Number.parseInt(tail, 10));
|
||||
@@ -62,41 +74,94 @@ export function nextLobbyNumber(baseName: string, additionalNames: string[] = []
|
||||
return null;
|
||||
}
|
||||
|
||||
export function previewCreation(input: Parameters<typeof previewNativeCreation>[0]) { return previewNativeCreation(input); }
|
||||
export function queueCreateServer(input: CreateServerInput): CreateServerResult { return queueNativeCreation(input); }
|
||||
export const queueRebuildServer = (name: string, triggeredBy = 'ui') => queueNativeRebuild(name, triggeredBy);
|
||||
export function previewCreation(input: Parameters<typeof previewNativeCreation>[0]) {
|
||||
return previewNativeCreation(input);
|
||||
}
|
||||
export function queueCreateServer(input: CreateServerInput): CreateServerResult {
|
||||
return queueNativeCreation(input);
|
||||
}
|
||||
export const queueRebuildServer = (name: string, triggeredBy = 'ui') =>
|
||||
queueNativeRebuild(name, triggeredBy);
|
||||
|
||||
// Persist the request before returning so navigation and client disconnects cannot lose it.
|
||||
function hasActiveCreation(name: string): boolean {
|
||||
return !!db().prepare("SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health')").get(name);
|
||||
return !!db()
|
||||
.prepare(
|
||||
"SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health')"
|
||||
)
|
||||
.get(name);
|
||||
}
|
||||
|
||||
export function destroyServer(name: string, triggeredBy = 'ui', autoscalingDecisionId?: string): DestroyServerResult & { deploymentId?: string } {
|
||||
export function destroyServer(
|
||||
name: string,
|
||||
triggeredBy = 'ui',
|
||||
autoscalingDecisionId?: string
|
||||
): DestroyServerResult & { deploymentId?: string } {
|
||||
const owned = scalingOwnershipBlocker(name);
|
||||
if (autoscalingDecisionId) {
|
||||
const identity = mapIdentity(getServer(name) ?? { name });
|
||||
if (!identity) return { ok: false, kind: 'conflict', message: 'Map identity unavailable.', warnings: [] };
|
||||
if (!identity)
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'conflict',
|
||||
message: 'Map identity unavailable.',
|
||||
warnings: []
|
||||
};
|
||||
assertScalingOwnership(identity.game, identity.mapSlug, autoscalingDecisionId);
|
||||
} else if (owned) return { ok: false, kind: 'conflict', message: owned, warnings: [] };
|
||||
const cancelled = cancelQueuedNativeCreation(name, triggeredBy);
|
||||
if (cancelled) return { ok: true, warnings: [], deploymentId: cancelled };
|
||||
const blocker = activeBetaRemovalBlocker(name);
|
||||
if (blocker) return { ok: false, kind: 'conflict', message: blocker, warnings: [] };
|
||||
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild')
|
||||
AND state IN ('waiting','queued','running')`).get(name)) {
|
||||
return { ok: false, kind: 'conflict', message: 'Wait for the current provisioning operation before destroying this server.', warnings: [] };
|
||||
if (
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild')
|
||||
AND state IN ('waiting','queued','running')`
|
||||
)
|
||||
.get(name)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'conflict',
|
||||
message: 'Wait for the current provisioning operation before destroying this server.',
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined;
|
||||
const existing = db()
|
||||
.prepare(
|
||||
"SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state IN ('waiting', 'running')"
|
||||
)
|
||||
.get(name) as { id: string } | undefined;
|
||||
if (existing) return { ok: true, warnings: [], deploymentId: existing.id };
|
||||
const previous = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1").get(name) as { id: string } | undefined;
|
||||
if (previous && resumeCleanup(previous.id)) return { ok: true, warnings: [], deploymentId: previous.id };
|
||||
if (!getServer(name) && !hasActiveCreation(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
|
||||
if (!nativeInstance(name)) return { ok: false, kind: 'conflict', message: 'Server has no native host instance.', warnings: [] };
|
||||
const previous = db()
|
||||
.prepare(
|
||||
'SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1'
|
||||
)
|
||||
.get(name) as { id: string } | undefined;
|
||||
if (previous && resumeCleanup(previous.id))
|
||||
return { ok: true, warnings: [], deploymentId: previous.id };
|
||||
if (!getServer(name) && !hasActiveCreation(name))
|
||||
return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
|
||||
if (!nativeInstance(name))
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'conflict',
|
||||
message: 'Server has no native host instance.',
|
||||
warnings: []
|
||||
};
|
||||
const operation = db().transaction(() => {
|
||||
const id = beginDeployment(name, 'destroy', triggeredBy);
|
||||
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
|
||||
deploymentStage(id, 'Waiting to verify the server is empty', { phase: 'eligibility', observedAt: null,
|
||||
blocker: { code: 'eligibility', message: 'Waiting for safe removal eligibility checks.', automatic: true } });
|
||||
deploymentStage(id, 'Waiting to verify the server is empty', {
|
||||
phase: 'eligibility',
|
||||
observedAt: null,
|
||||
blocker: {
|
||||
code: 'eligibility',
|
||||
message: 'Waiting for safe removal eligibility checks.',
|
||||
automatic: true
|
||||
}
|
||||
});
|
||||
return id;
|
||||
})();
|
||||
return { ok: true, warnings: [], deploymentId: operation };
|
||||
@@ -105,8 +170,15 @@ export function destroyServer(name: string, triggeredBy = 'ui', autoscalingDecis
|
||||
/** One host-wide FIFO, shared by collections and individual launches. */
|
||||
export function creationQueue() {
|
||||
type Entry = { id: string; server: string; state: string; stage: string };
|
||||
const jobs = db().prepare(`SELECT id, server, state, stage FROM server_deployments
|
||||
const jobs = db()
|
||||
.prepare(
|
||||
`SELECT id, server, state, stage FROM server_deployments
|
||||
WHERE server_deleted_at IS NULL AND action IN ('create', 'rebuild') AND state IN ('queued', 'running', 'awaiting-health')
|
||||
ORDER BY started_at, rowid`).all() as Entry[];
|
||||
return { active: jobs.filter(job => job.state !== 'queued'), queued: jobs.filter(job => job.state === 'queued') };
|
||||
ORDER BY started_at, rowid`
|
||||
)
|
||||
.all() as Entry[];
|
||||
return {
|
||||
active: jobs.filter((job) => job.state !== 'queued'),
|
||||
queued: jobs.filter((job) => job.state === 'queued')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,50 +30,83 @@ export interface PreparedNativeLaunch {
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let sequence = 0;
|
||||
const pending = new Map<number, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}>();
|
||||
const pending = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
|
||||
function stop(error: Error, current = worker): Promise<number> | undefined {
|
||||
if (current !== worker) return;
|
||||
worker = null;
|
||||
for (const request of pending.values()) { clearTimeout(request.timer); request.reject(error); }
|
||||
for (const request of pending.values()) {
|
||||
clearTimeout(request.timer);
|
||||
request.reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
return current?.terminate();
|
||||
}
|
||||
|
||||
/** The reader and JSON encoder run off the controller's request thread. */
|
||||
function run<T>(kind: 'launch' | 'placement', input: NativeLaunchInput | PlacementOptions): Promise<T> {
|
||||
function run<T>(
|
||||
kind: 'launch' | 'placement',
|
||||
input: NativeLaunchInput | PlacementOptions
|
||||
): Promise<T> {
|
||||
db(); // Migrations must finish before the independent read-only connection opens.
|
||||
if (!worker) {
|
||||
const current = worker = new Worker(resolve(process.env.NODE_ENV === 'production'
|
||||
? 'build/launch/worker.mjs' : '.svelte-kit/launch/worker.mjs'), {
|
||||
workerData: { dbPath: resolve(config.dbPath) }
|
||||
const current = (worker = new Worker(
|
||||
resolve(
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'build/launch/worker.mjs'
|
||||
: '.svelte-kit/launch/worker.mjs'
|
||||
),
|
||||
{
|
||||
workerData: { dbPath: resolve(config.dbPath) }
|
||||
}
|
||||
));
|
||||
current.on(
|
||||
'message',
|
||||
({ id, value, error }: { id: number; value: unknown; error?: string }) => {
|
||||
if (current !== worker) return;
|
||||
const request = pending.get(id);
|
||||
if (!request) return;
|
||||
pending.delete(id);
|
||||
clearTimeout(request.timer);
|
||||
if (error) request.reject(new Error(error));
|
||||
else request.resolve(value);
|
||||
if (!pending.size) current.unref();
|
||||
}
|
||||
);
|
||||
current.on('error', (error) => {
|
||||
void stop(error, current);
|
||||
});
|
||||
current.on('message', ({ id, value, error }: { id: number; value: unknown; error?: string }) => {
|
||||
if (current !== worker) return;
|
||||
const request = pending.get(id);
|
||||
if (!request) return;
|
||||
pending.delete(id); clearTimeout(request.timer);
|
||||
if (error) request.reject(new Error(error)); else request.resolve(value);
|
||||
if (!pending.size) current.unref();
|
||||
current.on('exit', () => {
|
||||
void stop(
|
||||
new Error('Launch preparation worker stopped; preparation will retry.'),
|
||||
current
|
||||
);
|
||||
});
|
||||
current.on('error', error => { void stop(error, current); });
|
||||
current.on('exit', () => { void stop(new Error('Launch preparation worker stopped; preparation will retry.'), current); });
|
||||
}
|
||||
worker.ref();
|
||||
const id = ++sequence;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => { void stop(new Error('Launch preparation timed out; preparation will retry.')); }, 60_000);
|
||||
pending.set(id, { resolve: value => resolve(value as T), reject, timer });
|
||||
const timer = setTimeout(() => {
|
||||
void stop(new Error('Launch preparation timed out; preparation will retry.'));
|
||||
}, 60_000);
|
||||
pending.set(id, { resolve: (value) => resolve(value as T), reject, timer });
|
||||
worker!.postMessage({ id, kind, input });
|
||||
});
|
||||
}
|
||||
|
||||
export function prepareNativeLaunch(input: NativeLaunchInput): Promise<PreparedNativeLaunch> { return run('launch', input); }
|
||||
export function evaluateNativePlacement(input: PlacementOptions): Promise<{ hostId: string; lastHeartbeat: string | null }> {
|
||||
export function prepareNativeLaunch(input: NativeLaunchInput): Promise<PreparedNativeLaunch> {
|
||||
return run('launch', input);
|
||||
}
|
||||
export function evaluateNativePlacement(
|
||||
input: PlacementOptions
|
||||
): Promise<{ hostId: string; lastHeartbeat: string | null }> {
|
||||
return run('placement', input);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,64 +2,143 @@ import { parentPort } from 'node:worker_threads';
|
||||
import { posix } from 'node:path';
|
||||
import { db } from '../db/index.js';
|
||||
import { relDestForKind } from '../config.js';
|
||||
import { captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js';
|
||||
import {
|
||||
captureRecipe,
|
||||
recipeForDeployment,
|
||||
summarizeRecipe,
|
||||
type LaunchRecipe
|
||||
} from '../fleet/launch-recipes.js';
|
||||
import { sha256Buffer } from '../fleet/files.js';
|
||||
import type { NativeLaunchInput } from './launch-preparation.js';
|
||||
import { choosePlacement, type PlacementOptions } from '../hosts/capacity.js';
|
||||
|
||||
parentPort!.on('message', (message: { id: number; kind: 'launch'; input: NativeLaunchInput } | { id: number; kind: 'placement'; input: PlacementOptions }) => {
|
||||
const { id } = message;
|
||||
try {
|
||||
if (message.kind === 'placement') {
|
||||
const decision = db().transaction(() => choosePlacement(message.input))();
|
||||
parentPort!.postMessage({ id, value: { hostId: decision.host.id, lastHeartbeat: decision.host.lastHeartbeat } });
|
||||
return;
|
||||
parentPort!.on(
|
||||
'message',
|
||||
(
|
||||
message:
|
||||
| { id: number; kind: 'launch'; input: NativeLaunchInput }
|
||||
| { id: number; kind: 'placement'; input: PlacementOptions }
|
||||
) => {
|
||||
const { id } = message;
|
||||
try {
|
||||
if (message.kind === 'placement') {
|
||||
const decision = db().transaction(() => choosePlacement(message.input))();
|
||||
parentPort!.postMessage({
|
||||
id,
|
||||
value: { hostId: decision.host.id, lastHeartbeat: decision.host.lastHeartbeat }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const input = message.input;
|
||||
// All file versions and composed configuration belong to one snapshot.
|
||||
const { recipe, password } = db().transaction(() => {
|
||||
if (!input.frozenRecipeId)
|
||||
return {
|
||||
recipe: {
|
||||
...captureRecipe(input.game, input.mapSlug, input.rconPassword),
|
||||
native: input.native,
|
||||
image: input.instance.image
|
||||
},
|
||||
password: input.rconPassword
|
||||
};
|
||||
const stored = recipeForDeployment(input.frozenRecipeId)?.recipe;
|
||||
const migration = db()
|
||||
.prepare(
|
||||
'SELECT runtime_password FROM ec_host_migrations WHERE deployment_id=?'
|
||||
)
|
||||
.get(input.frozenRecipeId) as { runtime_password: string | null } | undefined;
|
||||
if (
|
||||
!stored ||
|
||||
!migration?.runtime_password ||
|
||||
stored.files.some((file) => file.content === undefined)
|
||||
) {
|
||||
throw new Error(
|
||||
'The preserved deployed recipe or runtime credentials are unavailable.'
|
||||
);
|
||||
}
|
||||
for (const file of stored.files) {
|
||||
const bytes = Buffer.from(file.content!, 'base64');
|
||||
if (bytes.length !== file.size || sha256Buffer(bytes) !== file.sha256)
|
||||
throw new Error('Preserved deployed file verification failed.');
|
||||
}
|
||||
return {
|
||||
recipe: {
|
||||
...stored,
|
||||
native: input.native,
|
||||
image: input.instance.image
|
||||
} as LaunchRecipe,
|
||||
password: migration.runtime_password
|
||||
};
|
||||
})();
|
||||
const instance = input.instance;
|
||||
const payload = {
|
||||
name: instance.server,
|
||||
game: recipe.game,
|
||||
libraryId: instance.library_id,
|
||||
image: instance.image,
|
||||
port: instance.port,
|
||||
bindAddress: instance.bind_address,
|
||||
rconPassword: password,
|
||||
env: {
|
||||
PLUTAINER_SERVER_NAME: instance.server,
|
||||
PLUTAINER_PORT: String(instance.port),
|
||||
PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp',
|
||||
PLUTAINER_CONFIG_FILE: recipe.configFile,
|
||||
PLUTAINER_MAP_ROTATE: 'false',
|
||||
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${instance.server} +map_rotate`,
|
||||
PLUTO_SERVER_KEY: input.serverKey
|
||||
},
|
||||
files: recipe.files.map((file) => ({
|
||||
path: posix.join(relDestForKind(file.kind, recipe.game), file.path),
|
||||
contentSha256: file.sha256,
|
||||
sha256: file.sha256
|
||||
})),
|
||||
...(input.rebuild
|
||||
? {
|
||||
nextGeneration: instance.generation + 1,
|
||||
...(input.safetyOverride ? { safetyOverride: true } : {}),
|
||||
...(input.unhealthyRecovery ? { unhealthyRecovery: true } : {})
|
||||
}
|
||||
: {})
|
||||
};
|
||||
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
|
||||
const blobs = [
|
||||
...new Map(
|
||||
recipe.files.map((file) => [
|
||||
file.sha256,
|
||||
{
|
||||
sha256: file.sha256,
|
||||
content: new Uint8Array(Buffer.from(file.content, 'base64'))
|
||||
}
|
||||
])
|
||||
).values()
|
||||
];
|
||||
const storedRecipe = {
|
||||
...recipe,
|
||||
files: recipe.files.map(({ content, ...file }) => ({
|
||||
...file,
|
||||
contentHash: file.sha256
|
||||
}))
|
||||
};
|
||||
const recipeJson = encode(storedRecipe);
|
||||
const payloadJson = encode(payload);
|
||||
const plan = {
|
||||
...recipe,
|
||||
files: recipe.files.map(({ content: _content, ...file }) => file)
|
||||
};
|
||||
const recipeSummary = JSON.stringify(
|
||||
summarizeRecipe(plan, sha256Buffer(Buffer.from(JSON.stringify(recipe))))
|
||||
);
|
||||
// Transfer ownership of the encoded bytes; do not clone giant strings or objects.
|
||||
parentPort!.postMessage(
|
||||
{ id, value: { recipe: plan, recipeSummary, recipeJson, payloadJson, blobs } },
|
||||
[recipeJson.buffer, payloadJson.buffer, ...blobs.map((blob) => blob.content.buffer)]
|
||||
);
|
||||
} catch (error) {
|
||||
parentPort!.postMessage({
|
||||
id,
|
||||
error: error instanceof Error ? error.message : 'Launch preparation failed.'
|
||||
});
|
||||
}
|
||||
const input = message.input;
|
||||
// All file versions and composed configuration belong to one snapshot.
|
||||
const { recipe, password } = db().transaction(() => {
|
||||
if (!input.frozenRecipeId) return { recipe: { ...captureRecipe(input.game, input.mapSlug, input.rconPassword),
|
||||
native: input.native, image: input.instance.image }, password: input.rconPassword };
|
||||
const stored = recipeForDeployment(input.frozenRecipeId)?.recipe;
|
||||
const migration = db().prepare('SELECT runtime_password FROM ec_host_migrations WHERE deployment_id=?')
|
||||
.get(input.frozenRecipeId) as { runtime_password: string | null } | undefined;
|
||||
if (!stored || !migration?.runtime_password || stored.files.some(file => file.content === undefined)) {
|
||||
throw new Error('The preserved deployed recipe or runtime credentials are unavailable.');
|
||||
}
|
||||
for (const file of stored.files) {
|
||||
const bytes = Buffer.from(file.content!, 'base64');
|
||||
if (bytes.length !== file.size || sha256Buffer(bytes) !== file.sha256) throw new Error('Preserved deployed file verification failed.');
|
||||
}
|
||||
return { recipe: { ...stored, native: input.native, image: input.instance.image } as LaunchRecipe,
|
||||
password: migration.runtime_password };
|
||||
})();
|
||||
const instance = input.instance;
|
||||
const payload = {
|
||||
name: instance.server, game: recipe.game, libraryId: instance.library_id, image: instance.image,
|
||||
port: instance.port, bindAddress: instance.bind_address, rconPassword: password,
|
||||
env: { PLUTAINER_SERVER_NAME: instance.server, PLUTAINER_PORT: String(instance.port),
|
||||
PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp', PLUTAINER_CONFIG_FILE: recipe.configFile,
|
||||
PLUTAINER_MAP_ROTATE: 'false',
|
||||
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${instance.server} +map_rotate`,
|
||||
PLUTO_SERVER_KEY: input.serverKey },
|
||||
files: recipe.files.map(file => ({ path: posix.join(relDestForKind(file.kind, recipe.game), file.path),
|
||||
contentSha256: file.sha256, sha256: file.sha256 })),
|
||||
...(input.rebuild ? { nextGeneration: instance.generation + 1,
|
||||
...(input.safetyOverride ? { safetyOverride: true } : {}),
|
||||
...(input.unhealthyRecovery ? { unhealthyRecovery: true } : {}) } : {})
|
||||
};
|
||||
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
|
||||
const blobs = [...new Map(recipe.files.map(file => [file.sha256,
|
||||
{ sha256: file.sha256, content: new Uint8Array(Buffer.from(file.content, 'base64')) }])).values()];
|
||||
const storedRecipe = { ...recipe, files: recipe.files.map(({ content, ...file }) => ({ ...file, contentHash: file.sha256 })) };
|
||||
const recipeJson = encode(storedRecipe);
|
||||
const payloadJson = encode(payload);
|
||||
const plan = { ...recipe, files: recipe.files.map(({ content: _content, ...file }) => file) };
|
||||
const recipeSummary = JSON.stringify(summarizeRecipe(plan, sha256Buffer(Buffer.from(JSON.stringify(recipe)))));
|
||||
// Transfer ownership of the encoded bytes; do not clone giant strings or objects.
|
||||
parentPort!.postMessage({ id, value: { recipe: plan, recipeSummary, recipeJson, payloadJson, blobs } },
|
||||
[recipeJson.buffer, payloadJson.buffer, ...blobs.map(blob => blob.content.buffer)]);
|
||||
} catch (error) {
|
||||
parentPort!.postMessage({ id, error: error instanceof Error ? error.message : 'Launch preparation failed.' });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { concurrencyBlocker, preparingJobs, committedRebalanceStartup } from './concurrency.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { provisioningCancelled } from './cancellation.js';
|
||||
import { nativeInstance, nativeRequest, runtimePasswordForServer, type NativeInstance } from '../backends/native.js';
|
||||
import { getHost, getHostOperationStatus, queueHostOperation, reserveHostPort } from '../hosts/index.js';
|
||||
import {
|
||||
nativeInstance,
|
||||
nativeRequest,
|
||||
runtimePasswordForServer,
|
||||
type NativeInstance
|
||||
} from '../backends/native.js';
|
||||
import {
|
||||
getHost,
|
||||
getHostOperationStatus,
|
||||
queueHostOperation,
|
||||
reserveHostPort
|
||||
} from '../hosts/index.js';
|
||||
import { choosePlacement } from '../hosts/capacity.js';
|
||||
import { scheduleAvailabilityRefresh } from '../hosts/availability.js';
|
||||
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
||||
import { getServerKey } from '../fleet/server-keys.js';
|
||||
import { recipeForDeployment, recipeStorage, recipeSummaryForDeployment, type LaunchRecipePlan } from '../fleet/launch-recipes.js';
|
||||
import {
|
||||
recipeForDeployment,
|
||||
recipeStorage,
|
||||
recipeSummaryForDeployment,
|
||||
type LaunchRecipePlan
|
||||
} from '../fleet/launch-recipes.js';
|
||||
import { captureMoveRecipe, moveRecipeMatches, moveRecipeStorage } from './move-recipes.js';
|
||||
import { rebalanceAdmission } from '../rebalancing/admission.js';
|
||||
import { probePlayerEvidence } from '../fleet/status-evidence.js';
|
||||
@@ -16,168 +31,394 @@ import { fleetSettings } from '../settings.js';
|
||||
import type { NativePlacement } from '../../hosts.js';
|
||||
|
||||
interface Migration {
|
||||
deployment_id: string; source_host_id: string; instance_id: string;
|
||||
target_host_id: string | null; target_port: number | null; target_bind: string | null;
|
||||
remove_operation_id: string | null; removed_at: string | null;
|
||||
deployment_id: string;
|
||||
source_host_id: string;
|
||||
instance_id: string;
|
||||
target_host_id: string | null;
|
||||
target_port: number | null;
|
||||
target_bind: string | null;
|
||||
remove_operation_id: string | null;
|
||||
removed_at: string | null;
|
||||
}
|
||||
interface Job {
|
||||
id: string;
|
||||
server: string;
|
||||
action: string;
|
||||
state: string;
|
||||
stage: string;
|
||||
}
|
||||
interface Job { id: string; server: string; action: string; state: string; stage: string }
|
||||
type Recipe = LaunchRecipePlan & { native: NativePlacement };
|
||||
export { captureMoveRecipe } from './move-recipes.js';
|
||||
|
||||
export function isRebalanceMigration(id: string): boolean {
|
||||
return !!db().prepare("SELECT 1 FROM server_deployments WHERE id=? AND triggered_by LIKE 'rebalance:%'").get(id);
|
||||
return !!db()
|
||||
.prepare("SELECT 1 FROM server_deployments WHERE id=? AND triggered_by LIKE 'rebalance:%'")
|
||||
.get(id);
|
||||
}
|
||||
|
||||
/** Queue a move to one explicit host; its source remains active throughout. */
|
||||
export function requestInstanceMove(serverName: string, targetHostId: string,
|
||||
options: { triggeredBy: string; recipe?: LaunchRecipePlan }): string {
|
||||
export function requestInstanceMove(
|
||||
serverName: string,
|
||||
targetHostId: string,
|
||||
options: { triggeredBy: string; recipe?: LaunchRecipePlan }
|
||||
): string {
|
||||
return db().transaction(() => {
|
||||
const instance = nativeInstance(serverName);
|
||||
const server = getServer(serverName);
|
||||
if (!instance || !server?.map_slug) throw new Error('The native instance is unavailable.');
|
||||
if (instance.host_id === targetHostId) throw new Error('Choose a different destination host.');
|
||||
if (getHost(instance.host_id)?.mode !== 'active') throw new Error('The source host must be active.');
|
||||
if (!options.triggeredBy.startsWith('rebalance:')) throw new Error('An instance move requires a rebalance run.');
|
||||
if (instance.host_id === targetHostId)
|
||||
throw new Error('Choose a different destination host.');
|
||||
if (getHost(instance.host_id)?.mode !== 'active')
|
||||
throw new Error('The source host must be active.');
|
||||
if (!options.triggeredBy.startsWith('rebalance:'))
|
||||
throw new Error('An instance move requires a rebalance run.');
|
||||
const blocker = activeBetaRemovalBlocker(serverName);
|
||||
if (blocker) throw new Error(blocker);
|
||||
if (!getServerKey(serverName)) throw new Error('Save a server key before moving this instance.');
|
||||
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server=? AND server_deleted_at IS NULL
|
||||
AND state IN ('queued','waiting','running','awaiting-health','interrupted')`).get(serverName)) {
|
||||
if (!getServerKey(serverName))
|
||||
throw new Error('Save a server key before moving this instance.');
|
||||
if (
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM server_deployments WHERE server=? AND server_deleted_at IS NULL
|
||||
AND state IN ('queued','waiting','running','awaiting-health','interrupted')`
|
||||
)
|
||||
.get(serverName)
|
||||
) {
|
||||
throw new Error('Another provisioning operation is active for this instance.');
|
||||
}
|
||||
const recipe = options.recipe ?? captureMoveRecipe(serverName);
|
||||
if (recipe.game !== server.game || recipe.mapSlug !== server.map_slug || recipe.image !== instance.image
|
||||
|| !moveRecipeMatches(serverName, recipe)) throw new Error('The deployed files changed; generate a fresh rebalance preview.');
|
||||
if (
|
||||
recipe.game !== server.game ||
|
||||
recipe.mapSlug !== server.map_slug ||
|
||||
recipe.image !== instance.image ||
|
||||
!moveRecipeMatches(serverName, recipe)
|
||||
)
|
||||
throw new Error('The deployed files changed; generate a fresh rebalance preview.');
|
||||
const password = runtimePasswordForServer(serverName);
|
||||
if (!password) throw new Error('The deployed runtime credentials are unavailable.');
|
||||
choosePlacement({ hostId: targetHostId, excludeHostIds: [instance.host_id], libraryId: instance.library_id,
|
||||
image: instance.image, thorough: true, dispatch: true });
|
||||
choosePlacement({
|
||||
hostId: targetHostId,
|
||||
excludeHostIds: [instance.host_id],
|
||||
libraryId: instance.library_id,
|
||||
image: instance.image,
|
||||
thorough: true,
|
||||
dispatch: true
|
||||
});
|
||||
const id = beginDeployment(serverName, 'rebuild', options.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(?,?,NULL,?,?,?)')
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO server_creation_jobs(deployment_id,server,port,game_mode,map_slug,expected_map) VALUES(?,?,NULL,?,?,?)'
|
||||
)
|
||||
.run(id, serverName, 'zombie', recipe.mapSlug, recipe.expectedMap);
|
||||
const stored = moveRecipeStorage({ ...recipe, native: { libraryId: instance.library_id, image: instance.image, hostId: targetHostId } });
|
||||
db().prepare('INSERT INTO server_launch_recipes(deployment_id,recipe,recipe_summary) VALUES(?,?,?)').run(id, stored.recipe, stored.recipeSummary);
|
||||
db().prepare('INSERT INTO ec_host_migrations(deployment_id,source_host_id,instance_id,runtime_password) VALUES(?,?,?,?)')
|
||||
const stored = moveRecipeStorage({
|
||||
...recipe,
|
||||
native: { libraryId: instance.library_id, image: instance.image, hostId: targetHostId }
|
||||
});
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO server_launch_recipes(deployment_id,recipe,recipe_summary) VALUES(?,?,?)'
|
||||
)
|
||||
.run(id, stored.recipe, stored.recipeSummary);
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO ec_host_migrations(deployment_id,source_host_id,instance_id,runtime_password) VALUES(?,?,?,?)'
|
||||
)
|
||||
.run(id, instance.host_id, instance.id, password);
|
||||
deploymentStage(id, 'Rebalance queued; waiting to confirm an empty lobby and destination capacity', { phase: 'eligibility', recipeStatus: 'queued' });
|
||||
deploymentStage(
|
||||
id,
|
||||
'Rebalance queued; waiting to confirm an empty lobby and destination capacity',
|
||||
{ phase: 'eligibility', recipeStatus: 'queued' }
|
||||
);
|
||||
return id;
|
||||
})();
|
||||
}
|
||||
|
||||
export function hostMigration(id: string): Migration | undefined {
|
||||
return db().prepare('SELECT * FROM ec_host_migrations WHERE deployment_id=?').get(id) as Migration | undefined;
|
||||
return db().prepare('SELECT * FROM ec_host_migrations WHERE deployment_id=?').get(id) as
|
||||
Migration | undefined;
|
||||
}
|
||||
|
||||
/** Reserve a destination, remove the source once, then hand creation to the normal health-verified rebuild queue. */
|
||||
export async function processMigrationRemoval(job: Job, instance: NativeInstance, migration: Migration,
|
||||
eligibility: () => string | null): Promise<void> {
|
||||
export async function processMigrationRemoval(
|
||||
job: Job,
|
||||
instance: NativeInstance,
|
||||
migration: Migration,
|
||||
eligibility: () => string | null
|
||||
): Promise<void> {
|
||||
if (provisioningCancelled(job.id)) return;
|
||||
const hold = (message: string) => { if (!provisioningCancelled(job.id)) deploymentStage(job.id, message, { blocker: { code: 'native-host', message, automatic: true } }); };
|
||||
const hold = (message: string) => {
|
||||
if (!provisioningCancelled(job.id))
|
||||
deploymentStage(job.id, message, {
|
||||
blocker: { code: 'native-host', message, automatic: true }
|
||||
});
|
||||
};
|
||||
if (migration.remove_operation_id) {
|
||||
const operation = getHostOperationStatus(migration.remove_operation_id);
|
||||
if (operation?.state === 'failed' && Date.now() - Date.parse(operation.updatedAt) >= 60_000) {
|
||||
if (
|
||||
operation?.state === 'failed' &&
|
||||
Date.now() - Date.parse(operation.updatedAt) >= 60_000
|
||||
) {
|
||||
// A failed receipt certifies no mutation began. Unknown results keep
|
||||
// their reservation and original operation ID until reconciled.
|
||||
db().transaction(() => {
|
||||
db().prepare('DELETE FROM ec_host_ports WHERE host_id=? AND instance_id=?').run(migration.target_host_id, instance.id);
|
||||
db().prepare(`UPDATE ec_host_migrations SET target_host_id=NULL,target_port=NULL,target_bind=NULL,
|
||||
remove_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);
|
||||
db()
|
||||
.prepare('DELETE FROM ec_host_ports WHERE host_id=? AND instance_id=?')
|
||||
.run(migration.target_host_id, instance.id);
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE ec_host_migrations SET target_host_id=NULL,target_port=NULL,target_bind=NULL,
|
||||
remove_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);
|
||||
scheduleAvailabilityRefresh();
|
||||
})();
|
||||
hold('Source removal failed before mutation; checking destination capacity again before retrying.');
|
||||
hold(
|
||||
'Source removal failed before mutation; checking destination capacity again before retrying.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (operation?.state !== 'completed') {
|
||||
hold(operation?.error ?? 'Waiting for confirmed source removal. The original agent operation will reconcile.');
|
||||
hold(
|
||||
operation?.error ??
|
||||
'Waiting for confirmed source removal. The original agent operation will reconcile.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
db().transaction(() => {
|
||||
if (hostMigration(job.id)?.removed_at) return;
|
||||
const current = nativeInstance(job.server);
|
||||
if (current?.id !== instance.id || current.generation !== instance.generation || current.host_id !== migration.source_host_id) throw new Error('Migration source changed.');
|
||||
if (
|
||||
current?.id !== instance.id ||
|
||||
current.generation !== instance.generation ||
|
||||
current.host_id !== migration.source_host_id
|
||||
)
|
||||
throw new Error('Migration source changed.');
|
||||
const target = getHost(migration.target_host_id!);
|
||||
if (!target) throw new Error('Migration destination is unavailable.');
|
||||
const rebalance = isRebalanceMigration(job.id);
|
||||
const recipe = (rebalance ? { format: 1, ...recipeSummaryForDeployment(job.id)!.recipe }
|
||||
: recipeForDeployment(job.id)!.recipe) as Recipe;
|
||||
recipe.native = { libraryId: instance.library_id, image: instance.image, hostId: target.id,
|
||||
bindAddress: migration.target_bind!, publicAddress: target.publicAddress };
|
||||
const recipe = (
|
||||
rebalance
|
||||
? { format: 1, ...recipeSummaryForDeployment(job.id)!.recipe }
|
||||
: recipeForDeployment(job.id)!.recipe
|
||||
) as Recipe;
|
||||
recipe.native = {
|
||||
libraryId: instance.library_id,
|
||||
image: instance.image,
|
||||
hostId: target.id,
|
||||
bindAddress: migration.target_bind!,
|
||||
publicAddress: target.publicAddress
|
||||
};
|
||||
const stored = rebalance ? moveRecipeStorage(recipe) : recipeStorage(recipe);
|
||||
db().prepare('UPDATE server_launch_recipes SET recipe=?,recipe_summary=? WHERE deployment_id=?').run(stored.recipe, stored.recipeSummary, job.id);
|
||||
db().prepare('DELETE FROM ec_host_ports WHERE host_id=? AND instance_id=?').run(instance.host_id, instance.id);
|
||||
db().prepare(`UPDATE native_instances SET host_id=?,generation=generation+1,bind_address=?,port=?,public_address=?,public_port=NULL WHERE id=?`)
|
||||
.run(target.id, migration.target_bind, migration.target_port, target.publicAddress, instance.id);
|
||||
db().prepare('UPDATE native_provisioning SET generation=generation+1 WHERE deployment_id=?').run(job.id);
|
||||
db().prepare('UPDATE server_creation_jobs SET port=? WHERE deployment_id=?').run(migration.target_port, job.id);
|
||||
db().prepare(`UPDATE servers SET game_port=?,container_state='starting',container_checked_at=NULL,
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE server_launch_recipes SET recipe=?,recipe_summary=? WHERE deployment_id=?'
|
||||
)
|
||||
.run(stored.recipe, stored.recipeSummary, job.id);
|
||||
db()
|
||||
.prepare('DELETE FROM ec_host_ports WHERE host_id=? AND instance_id=?')
|
||||
.run(instance.host_id, instance.id);
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE native_instances SET host_id=?,generation=generation+1,bind_address=?,port=?,public_address=?,public_port=NULL WHERE id=?`
|
||||
)
|
||||
.run(
|
||||
target.id,
|
||||
migration.target_bind,
|
||||
migration.target_port,
|
||||
target.publicAddress,
|
||||
instance.id
|
||||
);
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE native_provisioning SET generation=generation+1 WHERE deployment_id=?'
|
||||
)
|
||||
.run(job.id);
|
||||
db()
|
||||
.prepare('UPDATE server_creation_jobs SET port=? WHERE deployment_id=?')
|
||||
.run(migration.target_port, job.id);
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE servers SET game_port=?,container_state='starting',container_checked_at=NULL,
|
||||
last_players=NULL,last_roster=NULL,last_seen_at=NULL,last_error=NULL,rcon_failures=0,active_since=NULL,inactive_since=NULL,
|
||||
game_started_at=NULL,game_ended_at=NULL WHERE name=?`).run(migration.target_port, job.server);
|
||||
db().prepare('UPDATE ec_host_migrations SET removed_at=? WHERE deployment_id=?').run(operation.updatedAt, job.id);
|
||||
db().prepare("UPDATE server_deployments SET state='waiting',submitted_at=NULL WHERE id=?").run(job.id);
|
||||
deploymentStage(job.id, `Source removed; rebuilding on ${target.name}`, { phase: 'startup-queue', blocker: null });
|
||||
game_started_at=NULL,game_ended_at=NULL WHERE name=?`
|
||||
)
|
||||
.run(migration.target_port, job.server);
|
||||
db()
|
||||
.prepare('UPDATE ec_host_migrations SET removed_at=? WHERE deployment_id=?')
|
||||
.run(operation.updatedAt, job.id);
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE server_deployments SET state='waiting',submitted_at=NULL WHERE id=?"
|
||||
)
|
||||
.run(job.id);
|
||||
deploymentStage(job.id, `Source removed; rebuilding on ${target.name}`, {
|
||||
phase: 'startup-queue',
|
||||
blocker: null
|
||||
});
|
||||
scheduleAvailabilityRefresh();
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (!getHost(instance.host_id)?.connected) { hold('Waiting for the source host to reconnect.'); return; }
|
||||
const reason = rebalanceAdmission(job.id) ?? eligibility() ?? activeBetaRemovalBlocker(job.server);
|
||||
if (reason) { hold(reason); return; }
|
||||
if (!getServerKey(job.server)) { hold('Save a server key before migrating this instance.'); return; }
|
||||
if (!getHost(instance.host_id)?.connected) {
|
||||
hold('Waiting for the source host to reconnect.');
|
||||
return;
|
||||
}
|
||||
const reason =
|
||||
rebalanceAdmission(job.id) ?? eligibility() ?? activeBetaRemovalBlocker(job.server);
|
||||
if (reason) {
|
||||
hold(reason);
|
||||
return;
|
||||
}
|
||||
if (!getServerKey(job.server)) {
|
||||
hold('Save a server key before migrating this instance.');
|
||||
return;
|
||||
}
|
||||
const rebalance = isRebalanceMigration(job.id);
|
||||
const recipe = rebalance ? recipeSummaryForDeployment(job.id)!.recipe : null;
|
||||
if (rebalance && !moveRecipeMatches(job.server, { format: 1, ...recipe! })) {
|
||||
hold('The deployed files changed; stop this run and generate a fresh rebalance preview.'); return;
|
||||
hold('The deployed files changed; stop this run and generate a fresh rebalance preview.');
|
||||
return;
|
||||
}
|
||||
const options = { libraryId: instance.library_id, image: instance.image, thorough: true, dispatch: true,
|
||||
excludeHostIds: [instance.host_id], ...(rebalance ? { hostId: recipe!.native!.hostId } : {}) };
|
||||
const options = {
|
||||
libraryId: instance.library_id,
|
||||
image: instance.image,
|
||||
thorough: true,
|
||||
dispatch: true,
|
||||
excludeHostIds: [instance.host_id],
|
||||
...(rebalance ? { hostId: recipe!.native!.hostId } : {})
|
||||
};
|
||||
// No source mutation is submitted unless another host can take this instance.
|
||||
choosePlacement(options);
|
||||
const health = await nativeRequest<{ state: string; healthy?: boolean }>(instance, 'instance.inspect');
|
||||
const health = await nativeRequest<{ state: string; healthy?: boolean }>(
|
||||
instance,
|
||||
'instance.inspect'
|
||||
);
|
||||
if (provisioningCancelled(job.id)) return;
|
||||
if (rebalance && (health.state !== 'running' || health.healthy !== true)) {
|
||||
hold('The source lobby is no longer healthy and running. Waiting for current health evidence.'); return;
|
||||
hold(
|
||||
'The source lobby is no longer healthy and running. Waiting for current health evidence.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!['stopped','exited','created','dead','missing'].includes(health.state)) {
|
||||
if (!['stopped', 'exited', 'created', 'dead', 'missing'].includes(health.state)) {
|
||||
const occupancy = await probePlayerEvidence(job.server);
|
||||
if (provisioningCancelled(job.id)) return;
|
||||
if (occupancy.players !== 0) { hold(occupancy.players === null ? 'Waiting for a fresh local player count.' : `Waiting for ${occupancy.players} player(s) to leave.`); return; }
|
||||
if (occupancy.players !== 0) {
|
||||
hold(
|
||||
occupancy.players === null
|
||||
? 'Waiting for a fresh local player count.'
|
||||
: `Waiting for ${occupancy.players} player(s) to leave.`
|
||||
);
|
||||
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 || !['waiting','queued','interrupted'].includes(state.state) || hostMigration(job.id)?.remove_operation_id) return;
|
||||
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 ||
|
||||
!['waiting', 'queued', 'interrupted'].includes(state.state) ||
|
||||
hostMigration(job.id)?.remove_operation_id
|
||||
)
|
||||
return;
|
||||
const current = nativeInstance(job.server);
|
||||
if (current?.id !== instance.id || current.generation !== instance.generation
|
||||
|| getHost(instance.host_id)?.mode !== (rebalance ? 'active' : 'draining')) return;
|
||||
const blocker = rebalanceAdmission(job.id) ?? eligibility() ?? activeBetaRemovalBlocker(job.server);
|
||||
if (blocker) { hold(blocker); return; }
|
||||
if (
|
||||
current?.id !== instance.id ||
|
||||
current.generation !== instance.generation ||
|
||||
getHost(instance.host_id)?.mode !== (rebalance ? 'active' : 'draining')
|
||||
)
|
||||
return;
|
||||
const blocker =
|
||||
rebalanceAdmission(job.id) ?? eligibility() ?? activeBetaRemovalBlocker(job.server);
|
||||
if (blocker) {
|
||||
hold(blocker);
|
||||
return;
|
||||
}
|
||||
if (rebalance && !moveRecipeMatches(job.server, { format: 1, ...recipe! })) {
|
||||
hold('The deployed files changed; stop this run and generate a fresh rebalance preview.'); return;
|
||||
hold(
|
||||
'The deployed files changed; stop this run and generate a fresh rebalance preview.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (rebalance) {
|
||||
const startups = db().prepare(`SELECT COUNT(*) AS n FROM (
|
||||
const startups = db()
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM (
|
||||
SELECT id FROM server_deployments WHERE id!=? AND server_deleted_at IS NULL
|
||||
AND action IN ('create','rebuild') AND (state IN ('running','awaiting-health') OR ${committedRebalanceStartup})
|
||||
UNION SELECT value FROM json_each(?) WHERE value!=?)`).get(job.id, JSON.stringify([...preparingJobs]), job.id) as { n: number };
|
||||
if (startups.n >= fleetSettings().createConcurrency) { hold('Waiting for startup capacity before moving this lobby.'); return; }
|
||||
UNION SELECT value FROM json_each(?) WHERE value!=?)`
|
||||
)
|
||||
.get(job.id, JSON.stringify([...preparingJobs]), job.id) as { n: number };
|
||||
if (startups.n >= fleetSettings().createConcurrency) {
|
||||
hold('Waiting for startup capacity before moving this lobby.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const removing = db()
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS n FROM ec_host_operations WHERE kind='instance.remove' AND state IN ('pending','accepted','running','unknown')"
|
||||
)
|
||||
.get() as { n: number };
|
||||
if (removing.n >= fleetSettings().destroyConcurrency) {
|
||||
hold('Waiting for destruction capacity.');
|
||||
return;
|
||||
}
|
||||
const removing = db().prepare("SELECT COUNT(*) AS n FROM ec_host_operations WHERE kind='instance.remove' AND state IN ('pending','accepted','running','unknown')").get() as { n: number };
|
||||
if (removing.n >= fleetSettings().destroyConcurrency) { hold('Waiting for destruction capacity.'); return; }
|
||||
const { host, port } = choosePlacement(options);
|
||||
const capacityBlocker = concurrencyBlocker(job.id, 'rebuild', instance.host_id, true) ?? concurrencyBlocker(job.id, 'rebuild', host.id);
|
||||
if (capacityBlocker) { hold(capacityBlocker); return; }
|
||||
reserveHostPort(host.id, instance.id, { port, bindAddress: host.bindAddress, protocol: 'udp' });
|
||||
reserveHostPort(host.id, instance.id, { port, bindAddress: host.bindAddress, protocol: 'tcp' });
|
||||
const attempt = (db().prepare(`SELECT COUNT(*) AS n FROM ec_host_operations
|
||||
WHERE host_id=? AND instance_id=? AND kind='instance.remove' AND idempotency_key LIKE ?`)
|
||||
.get(instance.host_id, instance.id, `${job.id}:migrate-remove:%`) as { n: number }).n;
|
||||
const operation = queueHostOperation(instance.host_id, 'instance.remove', {}, {
|
||||
instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:migrate-remove:${attempt}`
|
||||
const capacityBlocker =
|
||||
concurrencyBlocker(job.id, 'rebuild', instance.host_id, true) ??
|
||||
concurrencyBlocker(job.id, 'rebuild', host.id);
|
||||
if (capacityBlocker) {
|
||||
hold(capacityBlocker);
|
||||
return;
|
||||
}
|
||||
reserveHostPort(host.id, instance.id, {
|
||||
port,
|
||||
bindAddress: host.bindAddress,
|
||||
protocol: 'udp'
|
||||
});
|
||||
db().prepare('UPDATE ec_host_migrations SET target_host_id=?,target_port=?,target_bind=?,remove_operation_id=? WHERE deployment_id=?')
|
||||
reserveHostPort(host.id, instance.id, {
|
||||
port,
|
||||
bindAddress: host.bindAddress,
|
||||
protocol: 'tcp'
|
||||
});
|
||||
const attempt = (
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM ec_host_operations
|
||||
WHERE host_id=? AND instance_id=? AND kind='instance.remove' AND idempotency_key LIKE ?`
|
||||
)
|
||||
.get(instance.host_id, instance.id, `${job.id}:migrate-remove:%`) as { n: number }
|
||||
).n;
|
||||
const operation = queueHostOperation(
|
||||
instance.host_id,
|
||||
'instance.remove',
|
||||
{},
|
||||
{
|
||||
instanceId: instance.id,
|
||||
generation: instance.generation,
|
||||
idempotencyKey: `${job.id}:migrate-remove:${attempt}`
|
||||
}
|
||||
);
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE ec_host_migrations SET target_host_id=?,target_port=?,target_bind=?,remove_operation_id=? WHERE deployment_id=?'
|
||||
)
|
||||
.run(host.id, port, host.bindAddress, operation.id, job.id);
|
||||
db().prepare("UPDATE server_deployments SET state='running',submitted_at=? WHERE id=?").run(new Date().toISOString(), job.id);
|
||||
deploymentStage(job.id, `Capacity reserved on ${host.name}; waiting for source removal`, { phase: 'container-action', blocker: null });
|
||||
db()
|
||||
.prepare("UPDATE server_deployments SET state='running',submitted_at=? WHERE id=?")
|
||||
.run(new Date().toISOString(), job.id);
|
||||
deploymentStage(job.id, `Capacity reserved on ${host.name}; waiting for source removal`, {
|
||||
phase: 'container-action',
|
||||
blocker: null
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -2,58 +2,112 @@ import { db } from '../db/index.js';
|
||||
import { nativeInstance, runtimePasswordForServer } from '../backends/native.js';
|
||||
import { getServer } from '../fleet/servers.js';
|
||||
import { listServerScripts } from '../fleet/inventory.js';
|
||||
import { recipeStorage, recipeSummaryForDeployment, type LaunchRecipePlan } from '../fleet/launch-recipes.js';
|
||||
import {
|
||||
recipeStorage,
|
||||
recipeSummaryForDeployment,
|
||||
type LaunchRecipePlan
|
||||
} from '../fleet/launch-recipes.js';
|
||||
|
||||
/** Preserve the recorded deployed inventory, including changes made after launch. */
|
||||
export function captureMoveRecipe(server: string): LaunchRecipePlan {
|
||||
const instance = nativeInstance(server);
|
||||
const row = getServer(server);
|
||||
if (!instance || !row?.map_slug) throw new Error('A live native instance and saved map are required before moving.');
|
||||
if (!runtimePasswordForServer(server)) throw new Error('The deployed runtime credentials are unavailable.');
|
||||
const launch = db().prepare(`SELECT d.id FROM server_deployments d
|
||||
if (!instance || !row?.map_slug)
|
||||
throw new Error('A live native instance and saved map are required before moving.');
|
||||
if (!runtimePasswordForServer(server))
|
||||
throw new Error('The deployed runtime credentials are unavailable.');
|
||||
const launch = db()
|
||||
.prepare(
|
||||
`SELECT d.id FROM server_deployments d
|
||||
JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
JOIN server_launch_recipes r ON r.deployment_id=d.id
|
||||
WHERE n.instance_id=? AND d.state IN ('done','warning') AND d.server_deleted_at IS NULL
|
||||
ORDER BY d.rowid DESC LIMIT 1`).get(instance.id) as { id: string } | undefined;
|
||||
ORDER BY d.rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(instance.id) as { id: string } | undefined;
|
||||
const previous = launch && recipeSummaryForDeployment(launch.id)?.recipe;
|
||||
if (!previous || previous.game !== row.game || previous.mapSlug !== row.map_slug) {
|
||||
throw new Error('The deployed launch recipe is unavailable or differs from the saved map.');
|
||||
}
|
||||
const inventory = listServerScripts(server);
|
||||
if (!inventory.length) throw new Error('The deployed file inventory is unavailable.');
|
||||
const files = inventory.map(file => {
|
||||
const before = previous.files.find(item => item.path === file.rel_path);
|
||||
const version = file.script_version_id === null ? undefined : db().prepare(`SELECT v.version,v.kind,s.name
|
||||
FROM script_versions v JOIN scripts s ON s.id=v.script_id WHERE v.id=?`).get(file.script_version_id) as
|
||||
{ version: number; kind: string; name: string } | undefined;
|
||||
const kind = version?.kind === 'baseline' ? 'config' : version?.kind ?? before?.kind;
|
||||
if (!['script', 'plugin', 'config'].includes(kind ?? '')) throw new Error(`The deployed file type is unavailable for ${file.rel_path}.`);
|
||||
if (!db().prepare('SELECT 1 FROM file_blobs WHERE sha256=? AND size=?').get(file.sha256, file.size)) {
|
||||
const files = inventory.map((file) => {
|
||||
const before = previous.files.find((item) => item.path === file.rel_path);
|
||||
const version =
|
||||
file.script_version_id === null
|
||||
? undefined
|
||||
: (db()
|
||||
.prepare(
|
||||
`SELECT v.version,v.kind,s.name
|
||||
FROM script_versions v JOIN scripts s ON s.id=v.script_id WHERE v.id=?`
|
||||
)
|
||||
.get(file.script_version_id) as
|
||||
{ version: number; kind: string; name: string } | undefined);
|
||||
const kind = version?.kind === 'baseline' ? 'config' : (version?.kind ?? before?.kind);
|
||||
if (!['script', 'plugin', 'config'].includes(kind ?? ''))
|
||||
throw new Error(`The deployed file type is unavailable for ${file.rel_path}.`);
|
||||
if (
|
||||
!db()
|
||||
.prepare('SELECT 1 FROM file_blobs WHERE sha256=? AND size=?')
|
||||
.get(file.sha256, file.size)
|
||||
) {
|
||||
throw new Error(`The deployed bytes are unavailable for ${file.rel_path}.`);
|
||||
}
|
||||
return { name: version?.name ?? before?.name ?? file.rel_path, kind: kind as 'script' | 'plugin' | 'config',
|
||||
path: file.rel_path, versionId: file.script_version_id, version: version?.version ?? null,
|
||||
sha256: file.sha256, size: file.size };
|
||||
return {
|
||||
name: version?.name ?? before?.name ?? file.rel_path,
|
||||
kind: kind as 'script' | 'plugin' | 'config',
|
||||
path: file.rel_path,
|
||||
versionId: file.script_version_id,
|
||||
version: version?.version ?? null,
|
||||
sha256: file.sha256,
|
||||
size: file.size
|
||||
};
|
||||
});
|
||||
if (!files.some(file => file.path === 'electric_cherry_map.cfg' && file.kind === 'config')) {
|
||||
if (!files.some((file) => file.path === 'electric_cherry_map.cfg' && file.kind === 'config')) {
|
||||
throw new Error('The deployed map configuration is unavailable.');
|
||||
}
|
||||
return { format: 1, game: previous.game, mapSlug: previous.mapSlug, mapName: previous.mapName,
|
||||
expectedMap: previous.expectedMap, configFile: previous.configFile,
|
||||
configSource: 'Preserved deployed files', capturedAt: new Date().toISOString(), image: instance.image,
|
||||
native: { libraryId: instance.library_id, image: instance.image }, files };
|
||||
return {
|
||||
format: 1,
|
||||
game: previous.game,
|
||||
mapSlug: previous.mapSlug,
|
||||
mapName: previous.mapName,
|
||||
expectedMap: previous.expectedMap,
|
||||
configFile: previous.configFile,
|
||||
configSource: 'Preserved deployed files',
|
||||
capturedAt: new Date().toISOString(),
|
||||
image: instance.image,
|
||||
native: { libraryId: instance.library_id, image: instance.image },
|
||||
files
|
||||
};
|
||||
}
|
||||
|
||||
/** Immutable hash references keep deployed bytes alive without decoding plugins on the controller. */
|
||||
export function moveRecipeStorage(recipe: LaunchRecipePlan): ReturnType<typeof recipeStorage> {
|
||||
const metadata = { ...recipe, files: recipe.files.map(({ content: _content, ...file }) => file) };
|
||||
const metadata = {
|
||||
...recipe,
|
||||
files: recipe.files.map(({ content: _content, ...file }) => file)
|
||||
};
|
||||
const stored = recipeStorage(metadata);
|
||||
return { recipeSummary: stored.recipeSummary,
|
||||
recipe: JSON.stringify({ ...metadata, files: metadata.files.map(file => ({ ...file, contentHash: file.sha256 })) }) };
|
||||
return {
|
||||
recipeSummary: stored.recipeSummary,
|
||||
recipe: JSON.stringify({
|
||||
...metadata,
|
||||
files: metadata.files.map((file) => ({ ...file, contentHash: file.sha256 }))
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export function moveRecipeMatches(server: string, recipe: LaunchRecipePlan): boolean {
|
||||
const inventory = listServerScripts(server);
|
||||
return inventory.length === recipe.files.length && inventory.every(file => recipe.files.some(saved =>
|
||||
saved.path === file.rel_path && saved.sha256 === file.sha256 && saved.size === file.size));
|
||||
return (
|
||||
inventory.length === recipe.files.length &&
|
||||
inventory.every((file) =>
|
||||
recipe.files.some(
|
||||
(saved) =>
|
||||
saved.path === file.rel_path &&
|
||||
saved.sha256 === file.sha256 &&
|
||||
saved.size === file.size
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+1643
-397
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,26 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { invalidateFleet } from '../pipeline/bus.js';
|
||||
import { provisioningPhases, type ProvisioningPhase, type ProvisioningProgress, type ProvisioningCheck,
|
||||
type ProvisioningCheckpoint, type ProvisioningBlocker, type RecoveryObservation } from '../../provisioning-detail.js';
|
||||
import {
|
||||
provisioningPhases,
|
||||
type ProvisioningPhase,
|
||||
type ProvisioningProgress,
|
||||
type ProvisioningCheck,
|
||||
type ProvisioningCheckpoint,
|
||||
type ProvisioningBlocker,
|
||||
type RecoveryObservation
|
||||
} from '../../provisioning-detail.js';
|
||||
|
||||
export type CheckInput = Omit<ProvisioningCheck, 'checkedAt' | 'receivedAt' | 'expected' | 'actual' | 'message'> &
|
||||
Partial<Pick<ProvisioningCheck, 'checkedAt' | 'receivedAt' | 'expected' | 'actual' | 'message'>>;
|
||||
export type CheckpointInput = Omit<ProvisioningCheckpoint, 'at' | 'phase'> & { at?: string; phase?: ProvisioningPhase | null };
|
||||
export type CheckInput = Omit<
|
||||
ProvisioningCheck,
|
||||
'checkedAt' | 'receivedAt' | 'expected' | 'actual' | 'message'
|
||||
> &
|
||||
Partial<
|
||||
Pick<ProvisioningCheck, 'checkedAt' | 'receivedAt' | 'expected' | 'actual' | 'message'>
|
||||
>;
|
||||
export type CheckpointInput = Omit<ProvisioningCheckpoint, 'at' | 'phase'> & {
|
||||
at?: string;
|
||||
phase?: ProvisioningPhase | null;
|
||||
};
|
||||
export interface ProgressUpdate {
|
||||
phase?: ProvisioningPhase | null;
|
||||
blocker?: ProvisioningBlocker | null;
|
||||
@@ -20,35 +35,112 @@ export interface ProgressUpdate {
|
||||
event?: { kind: string; message: string; details?: Record<string, unknown> };
|
||||
}
|
||||
|
||||
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const nullableString = (value: unknown) => value === null || typeof value === 'string';
|
||||
const phase = (value: unknown): value is ProvisioningPhase => provisioningPhases.includes(value as ProvisioningPhase);
|
||||
const scalar = (value: unknown) => nullableString(value) || typeof value === 'number' && Number.isFinite(value);
|
||||
const phase = (value: unknown): value is ProvisioningPhase =>
|
||||
provisioningPhases.includes(value as ProvisioningPhase);
|
||||
const scalar = (value: unknown) =>
|
||||
nullableString(value) || (typeof value === 'number' && Number.isFinite(value));
|
||||
|
||||
/** Invalid/old snapshots never manufacture evidence. Raw legacy stages remain available. */
|
||||
export function readProgress(row: { observation_json?: string | null }): ProvisioningProgress | null {
|
||||
export function readProgress(row: {
|
||||
observation_json?: string | null;
|
||||
}): ProvisioningProgress | null {
|
||||
if (!row.observation_json) return null;
|
||||
try {
|
||||
const v: unknown = JSON.parse(row.observation_json);
|
||||
if (!object(v) || v.version !== 1 || !(v.phase === null || phase(v.phase)) || !nullableString(v.phaseStartedAt)
|
||||
|| !nullableString(v.observedAt) || !Array.isArray(v.checks) || !Array.isArray(v.checkpoints)) return null;
|
||||
if (v.blocker !== null && (!object(v.blocker) || typeof v.blocker.code !== 'string' || typeof v.blocker.message !== 'string'
|
||||
|| typeof v.blocker.automatic !== 'boolean' || (v.blocker.href !== undefined && !nullableString(v.blocker.href))
|
||||
|| (v.blocker.label !== undefined && !nullableString(v.blocker.label)))) return null;
|
||||
if (v.checks.length > 10 || !v.checks.every(c => object(c) && ['container', 'game', 'map', 'plutonium-release', 'plutonium-version', 'occupancy', 'stopped'].includes(String(c.key))
|
||||
&& typeof c.label === 'string' && ['pass', 'waiting', 'fail', 'unknown'].includes(String(c.result))
|
||||
&& (c.source === null || ['agent', 'rcon', 'telemetry', 'combined', 'manifest'].includes(String(c.source)))
|
||||
&& typeof c.checkedAt === 'string' && nullableString(c.receivedAt) && scalar(c.expected) && scalar(c.actual) && nullableString(c.message))) return null;
|
||||
if (v.checkpoints.length > 100 || !v.checkpoints.every(c => object(c) && typeof c.key === 'string' && typeof c.label === 'string'
|
||||
&& typeof c.at === 'string' && (c.phase === null || phase(c.phase)))) return null;
|
||||
if (v.command !== null && (!object(v.command) || !['attempted', 'accepted', 'rejected', 'uncertain'].includes(String(v.command.state))
|
||||
|| typeof v.command.at !== 'string' || typeof v.command.message !== 'string')) return null;
|
||||
if (v.recovery !== null && (!object(v.recovery) || !['held', 'released', 'transferred', 'unknown'].includes(String(v.recovery.state))
|
||||
|| typeof v.recovery.at !== 'string' || typeof v.recovery.message !== 'string'
|
||||
|| (v.recovery.operationId !== undefined && !nullableString(v.recovery.operationId)))) return null;
|
||||
if (v.recipeStatus !== null && v.recipeStatus !== 'queued' && v.recipeStatus !== 'attempted') return null;
|
||||
if (
|
||||
!object(v) ||
|
||||
v.version !== 1 ||
|
||||
!(v.phase === null || phase(v.phase)) ||
|
||||
!nullableString(v.phaseStartedAt) ||
|
||||
!nullableString(v.observedAt) ||
|
||||
!Array.isArray(v.checks) ||
|
||||
!Array.isArray(v.checkpoints)
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
v.blocker !== null &&
|
||||
(!object(v.blocker) ||
|
||||
typeof v.blocker.code !== 'string' ||
|
||||
typeof v.blocker.message !== 'string' ||
|
||||
typeof v.blocker.automatic !== 'boolean' ||
|
||||
(v.blocker.href !== undefined && !nullableString(v.blocker.href)) ||
|
||||
(v.blocker.label !== undefined && !nullableString(v.blocker.label)))
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
v.checks.length > 10 ||
|
||||
!v.checks.every(
|
||||
(c) =>
|
||||
object(c) &&
|
||||
[
|
||||
'container',
|
||||
'game',
|
||||
'map',
|
||||
'plutonium-release',
|
||||
'plutonium-version',
|
||||
'occupancy',
|
||||
'stopped'
|
||||
].includes(String(c.key)) &&
|
||||
typeof c.label === 'string' &&
|
||||
['pass', 'waiting', 'fail', 'unknown'].includes(String(c.result)) &&
|
||||
(c.source === null ||
|
||||
['agent', 'rcon', 'telemetry', 'combined', 'manifest'].includes(
|
||||
String(c.source)
|
||||
)) &&
|
||||
typeof c.checkedAt === 'string' &&
|
||||
nullableString(c.receivedAt) &&
|
||||
scalar(c.expected) &&
|
||||
scalar(c.actual) &&
|
||||
nullableString(c.message)
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
v.checkpoints.length > 100 ||
|
||||
!v.checkpoints.every(
|
||||
(c) =>
|
||||
object(c) &&
|
||||
typeof c.key === 'string' &&
|
||||
typeof c.label === 'string' &&
|
||||
typeof c.at === 'string' &&
|
||||
(c.phase === null || phase(c.phase))
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
v.command !== null &&
|
||||
(!object(v.command) ||
|
||||
!['attempted', 'accepted', 'rejected', 'uncertain'].includes(
|
||||
String(v.command.state)
|
||||
) ||
|
||||
typeof v.command.at !== 'string' ||
|
||||
typeof v.command.message !== 'string')
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
v.recovery !== null &&
|
||||
(!object(v.recovery) ||
|
||||
!['held', 'released', 'transferred', 'unknown'].includes(
|
||||
String(v.recovery.state)
|
||||
) ||
|
||||
typeof v.recovery.at !== 'string' ||
|
||||
typeof v.recovery.message !== 'string' ||
|
||||
(v.recovery.operationId !== undefined && !nullableString(v.recovery.operationId)))
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
v.recipeStatus !== null &&
|
||||
v.recipeStatus !== 'queued' &&
|
||||
v.recipeStatus !== 'attempted'
|
||||
)
|
||||
return null;
|
||||
return v as unknown as ProvisioningProgress;
|
||||
} catch { return null; }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,15 +150,26 @@ export function readProgress(row: { observation_json?: string | null }): Provisi
|
||||
export function recordProgress(id: string, update: ProgressUpdate): void {
|
||||
const handle = db();
|
||||
handle.transaction(() => {
|
||||
const row = handle.prepare('SELECT observation_json FROM server_deployments WHERE id = ?').get(id) as
|
||||
{ observation_json: string | null } | undefined;
|
||||
const row = handle
|
||||
.prepare('SELECT observation_json FROM server_deployments WHERE id = ?')
|
||||
.get(id) as { observation_json: string | null } | undefined;
|
||||
if (!row) return;
|
||||
const now = new Date().toISOString();
|
||||
const previous = readProgress(row);
|
||||
const next: ProvisioningProgress = previous ? { ...previous, checkpoints: [...previous.checkpoints] } : {
|
||||
version: 1, phase: null, phaseStartedAt: null, observedAt: null, blocker: null, checks: [], checkpoints: [],
|
||||
command: null, recovery: null, recipeStatus: null
|
||||
};
|
||||
const next: ProvisioningProgress = previous
|
||||
? { ...previous, checkpoints: [...previous.checkpoints] }
|
||||
: {
|
||||
version: 1,
|
||||
phase: null,
|
||||
phaseStartedAt: null,
|
||||
observedAt: null,
|
||||
blocker: null,
|
||||
checks: [],
|
||||
checkpoints: [],
|
||||
command: null,
|
||||
recovery: null,
|
||||
recipeStatus: null
|
||||
};
|
||||
const events: { kind: string; message: string; details?: Record<string, unknown> }[] = [];
|
||||
if (update.phase !== undefined && update.phase !== next.phase) {
|
||||
next.phase = update.phase;
|
||||
@@ -78,45 +181,99 @@ export function recordProgress(id: string, update: ProgressUpdate): void {
|
||||
const old = next.blocker;
|
||||
next.blocker = update.blocker;
|
||||
// Counts/error wording can change on every poll without a new kind of hold.
|
||||
if (update.blocker && (old?.code !== update.blocker.code || old?.href !== update.blocker.href)) {
|
||||
events.push({ kind: 'blocker', message: update.blocker.message,
|
||||
details: { code: update.blocker.code, href: update.blocker.href ?? null } });
|
||||
if (
|
||||
update.blocker &&
|
||||
(old?.code !== update.blocker.code || old?.href !== update.blocker.href)
|
||||
) {
|
||||
events.push({
|
||||
kind: 'blocker',
|
||||
message: update.blocker.message,
|
||||
details: { code: update.blocker.code, href: update.blocker.href ?? null }
|
||||
});
|
||||
}
|
||||
}
|
||||
if (update.checks !== undefined) {
|
||||
next.checks = update.checks.map(check => ({ ...check, checkedAt: check.checkedAt ?? now,
|
||||
receivedAt: check.receivedAt ?? null, expected: check.expected ?? null, actual: check.actual ?? null,
|
||||
message: check.message ?? null }));
|
||||
next.checks = update.checks.map((check) => ({
|
||||
...check,
|
||||
checkedAt: check.checkedAt ?? now,
|
||||
receivedAt: check.receivedAt ?? null,
|
||||
expected: check.expected ?? null,
|
||||
actual: check.actual ?? null,
|
||||
message: check.message ?? null
|
||||
}));
|
||||
}
|
||||
for (const input of [...(update.checkpoints ?? []), ...(update.checkpoint ? [update.checkpoint] : [])]) {
|
||||
if (next.checkpoints.some(c => c.key === input.key)) continue;
|
||||
const point: ProvisioningCheckpoint = { ...input, at: input.at ?? now, phase: input.phase ?? (phase(input.key) ? input.key : next.phase) };
|
||||
for (const input of [
|
||||
...(update.checkpoints ?? []),
|
||||
...(update.checkpoint ? [update.checkpoint] : [])
|
||||
]) {
|
||||
if (next.checkpoints.some((c) => c.key === input.key)) continue;
|
||||
const point: ProvisioningCheckpoint = {
|
||||
...input,
|
||||
at: input.at ?? now,
|
||||
phase: input.phase ?? (phase(input.key) ? input.key : next.phase)
|
||||
};
|
||||
next.checkpoints.push(point);
|
||||
events.push({ kind: 'checkpoint', message: point.label, details: { key: point.key, at: point.at } });
|
||||
events.push({
|
||||
kind: 'checkpoint',
|
||||
message: point.label,
|
||||
details: { key: point.key, at: point.at }
|
||||
});
|
||||
}
|
||||
if (update.command !== undefined) {
|
||||
if (!update.command) next.command = null;
|
||||
else if (next.command?.state !== update.command.state || next.command?.message !== update.command.message) {
|
||||
else if (
|
||||
next.command?.state !== update.command.state ||
|
||||
next.command?.message !== update.command.message
|
||||
) {
|
||||
next.command = { ...update.command, at: update.command.at ?? now };
|
||||
events.push({ kind: 'command', message: next.command.message, details: { state: next.command.state, at: next.command.at } });
|
||||
events.push({
|
||||
kind: 'command',
|
||||
message: next.command.message,
|
||||
details: { state: next.command.state, at: next.command.at }
|
||||
});
|
||||
}
|
||||
}
|
||||
if (update.recovery !== undefined) {
|
||||
if (!update.recovery) next.recovery = null;
|
||||
else {
|
||||
const changed = next.recovery?.state !== update.recovery.state || next.recovery?.operationId !== update.recovery.operationId;
|
||||
next.recovery = { ...update.recovery, at: changed ? update.recovery.at ?? now : next.recovery!.at };
|
||||
if (changed) events.push({ kind: 'recovery', message: next.recovery.message,
|
||||
details: { state: next.recovery.state, at: next.recovery.at, operationId: next.recovery.operationId ?? null } });
|
||||
const changed =
|
||||
next.recovery?.state !== update.recovery.state ||
|
||||
next.recovery?.operationId !== update.recovery.operationId;
|
||||
next.recovery = {
|
||||
...update.recovery,
|
||||
at: changed ? (update.recovery.at ?? now) : next.recovery!.at
|
||||
};
|
||||
if (changed)
|
||||
events.push({
|
||||
kind: 'recovery',
|
||||
message: next.recovery.message,
|
||||
details: {
|
||||
state: next.recovery.state,
|
||||
at: next.recovery.at,
|
||||
operationId: next.recovery.operationId ?? null
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (update.recipeStatus !== undefined) next.recipeStatus = update.recipeStatus;
|
||||
if (update.event) events.push(update.event);
|
||||
handle.prepare(`UPDATE server_deployments SET phase_key = ?, phase_started_at = ?, observed_at = ?, observation_json = ? WHERE id = ?`)
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE server_deployments SET phase_key = ?, phase_started_at = ?, observed_at = ?, observation_json = ? WHERE id = ?`
|
||||
)
|
||||
.run(next.phase, next.phaseStartedAt, next.observedAt, JSON.stringify(next), id);
|
||||
const insert = handle.prepare(`INSERT INTO server_deployment_events (deployment_id, created_at, message, kind, phase_key, details_json)
|
||||
const insert =
|
||||
handle.prepare(`INSERT INTO server_deployment_events (deployment_id, created_at, message, kind, phase_key, details_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`);
|
||||
for (const event of events) insert.run(id, now, event.message, event.kind, next.phase, event.details ? JSON.stringify(event.details) : null);
|
||||
for (const event of events)
|
||||
insert.run(
|
||||
id,
|
||||
now,
|
||||
event.message,
|
||||
event.kind,
|
||||
next.phase,
|
||||
event.details ? JSON.stringify(event.details) : null
|
||||
);
|
||||
if (events.length) invalidateFleet();
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -3,21 +3,38 @@ import { currentRequestUsername } from '../auth/request-context.js';
|
||||
import { maintenanceAllowed } from '../maintenance/policies.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
||||
import { beginDeployment, canRetryWithRebuild, deploymentStage, finishDeployment, recordDeploymentEvent } from './history.js';
|
||||
import {
|
||||
beginDeployment,
|
||||
canRetryWithRebuild,
|
||||
deploymentStage,
|
||||
finishDeployment,
|
||||
recordDeploymentEvent
|
||||
} from './history.js';
|
||||
import { queueRebuildServer } from './instances.js';
|
||||
import { runtimeActions, type RuntimeAction } from '../../provisioning.js';
|
||||
import { recordProgress } from './progress.js';
|
||||
import { nativeInstance, unplacedNativeCreation } from '../backends/native.js';
|
||||
|
||||
export interface LifecycleOperation {
|
||||
id: string; server: string; action: string; state: string; stage: string;
|
||||
submitted_at: string | null; finished_at: string | null; checked_at: string | null;
|
||||
players: number | null; error: string | null;
|
||||
id: string;
|
||||
server: string;
|
||||
action: string;
|
||||
state: string;
|
||||
stage: string;
|
||||
submitted_at: string | null;
|
||||
finished_at: string | null;
|
||||
checked_at: string | null;
|
||||
players: number | null;
|
||||
error: string | null;
|
||||
started_at: string;
|
||||
}
|
||||
export interface RestartSource { kind: 'campaign' | 'beta' | 'maintenance'; id: string }
|
||||
export interface RestartSource {
|
||||
kind: 'campaign' | 'beta' | 'maintenance';
|
||||
id: string;
|
||||
}
|
||||
export function provisioningOperation(id: string): LifecycleOperation | undefined {
|
||||
return db().prepare('SELECT * FROM server_deployments WHERE id = ?').get(id) as LifecycleOperation | undefined;
|
||||
return db().prepare('SELECT * FROM server_deployments WHERE id = ?').get(id) as
|
||||
LifecycleOperation | undefined;
|
||||
}
|
||||
|
||||
const sourceTables = {
|
||||
@@ -30,12 +47,17 @@ const sourceTables = {
|
||||
export function requestRestart(server: string, source: RestartSource): LifecycleOperation {
|
||||
return db().transaction(() => {
|
||||
const { table, key } = sourceTables[source.kind];
|
||||
const row = db().prepare(`SELECT provisioning_id, restarted_at FROM ${table} WHERE ${key} = ? AND server = ? AND server_deleted_at IS NULL`)
|
||||
.get(source.id, server) as { provisioning_id: string | null; restarted_at: string | null } | undefined;
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT provisioning_id, restarted_at FROM ${table} WHERE ${key} = ? AND server = ? AND server_deleted_at IS NULL`
|
||||
)
|
||||
.get(source.id, server) as
|
||||
{ provisioning_id: string | null; restarted_at: string | null } | undefined;
|
||||
if (!row) throw new Error('Restart source no longer exists');
|
||||
if (row.provisioning_id) {
|
||||
const operation = provisioningOperation(row.provisioning_id);
|
||||
if (!operation) throw new Error('Provisioning job is missing; inspect the server before retrying');
|
||||
if (!operation)
|
||||
throw new Error('Provisioning job is missing; inspect the server before retrying');
|
||||
if (row.restarted_at && !operation.submitted_at && operation.state === 'waiting') {
|
||||
adoptSubmittedRestart(operation.id, server, row.restarted_at);
|
||||
return provisioningOperation(operation.id)!;
|
||||
@@ -46,68 +68,167 @@ export function requestRestart(server: string, source: RestartSource): Lifecycle
|
||||
if (row.restarted_at) {
|
||||
// The previous release already recorded some submissions in history.
|
||||
// Bind to that exact interval, never to a newer restart of the server.
|
||||
id = (db().prepare(`SELECT d.id FROM restart_rollout r JOIN server_deployments d ON d.id = r.operation_id
|
||||
WHERE r.server = ? AND d.server_deleted_at IS NULL AND d.action = 'restart' AND d.started_at <= ? AND r.requested_at >= ?`)
|
||||
.get(server, row.restarted_at, row.restarted_at) as { id: string } | undefined)?.id;
|
||||
if (id) db().prepare('UPDATE server_deployments SET submitted_at = COALESCE(submitted_at, ?) WHERE id = ?').run(row.restarted_at, id);
|
||||
id = (
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT d.id FROM restart_rollout r JOIN server_deployments d ON d.id = r.operation_id
|
||||
WHERE r.server = ? AND d.server_deleted_at IS NULL AND d.action = 'restart' AND d.started_at <= ? AND r.requested_at >= ?`
|
||||
)
|
||||
.get(server, row.restarted_at, row.restarted_at) as { id: string } | undefined
|
||||
)?.id;
|
||||
if (id)
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE server_deployments SET submitted_at = COALESCE(submitted_at, ?) WHERE id = ?'
|
||||
)
|
||||
.run(row.restarted_at, id);
|
||||
}
|
||||
if (!id) {
|
||||
id = beginDeployment(server, 'restart', `${source.kind}:${source.id}`);
|
||||
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
|
||||
deploymentStage(id, 'Waiting to verify the server is empty', { phase: 'eligibility', observedAt: null,
|
||||
blocker: { code: 'eligibility', message: 'Waiting for eligibility and a fresh empty or stopped server.', automatic: true } });
|
||||
deploymentStage(id, 'Waiting to verify the server is empty', {
|
||||
phase: 'eligibility',
|
||||
observedAt: null,
|
||||
blocker: {
|
||||
code: 'eligibility',
|
||||
message: 'Waiting for eligibility and a fresh empty or stopped server.',
|
||||
automatic: true
|
||||
}
|
||||
});
|
||||
if (row.restarted_at) adoptSubmittedRestart(id, server, row.restarted_at);
|
||||
}
|
||||
db().prepare('INSERT OR IGNORE INTO provisioning_sources (operation_id, kind, source_id, server) VALUES (?, ?, ?, ?)')
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT OR IGNORE INTO provisioning_sources (operation_id, kind, source_id, server) VALUES (?, ?, ?, ?)'
|
||||
)
|
||||
.run(id, source.kind, source.id, server);
|
||||
db().prepare(`UPDATE ${table} SET provisioning_id = ? WHERE ${key} = ? AND server = ?`).run(id, source.id, server);
|
||||
db()
|
||||
.prepare(`UPDATE ${table} SET provisioning_id = ? WHERE ${key} = ? AND server = ?`)
|
||||
.run(id, source.id, server);
|
||||
return provisioningOperation(id)!;
|
||||
})();
|
||||
}
|
||||
|
||||
/** Upgrade observation only: existing restart intent is never resubmitted. */
|
||||
function adoptSubmittedRestart(id: string, server: string, submittedAt: string): void {
|
||||
const slot = db().prepare('SELECT operation_id, requested_at, checked_at, healthy_at, players FROM restart_rollout WHERE server = ?').get(server) as
|
||||
{ operation_id: string | null; requested_at: string; checked_at: string | null; healthy_at: string | null; players: number | null } | undefined;
|
||||
db().prepare("UPDATE server_deployments SET state = 'awaiting-health', submitted_at = ? WHERE id = ?").run(submittedAt, id);
|
||||
recordProgress(id, { command: { state: 'attempted', at: submittedAt, message: 'Adopted a previously recorded submission; no command is replayed.' } });
|
||||
if (slot && ((slot.operation_id && slot.requested_at >= submittedAt) || (slot.requested_at < submittedAt && !slot.healthy_at))) {
|
||||
const slot = db()
|
||||
.prepare(
|
||||
'SELECT operation_id, requested_at, checked_at, healthy_at, players FROM restart_rollout WHERE server = ?'
|
||||
)
|
||||
.get(server) as
|
||||
| {
|
||||
operation_id: string | null;
|
||||
requested_at: string;
|
||||
checked_at: string | null;
|
||||
healthy_at: string | null;
|
||||
players: number | null;
|
||||
}
|
||||
| undefined;
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE server_deployments SET state = 'awaiting-health', submitted_at = ? WHERE id = ?"
|
||||
)
|
||||
.run(submittedAt, id);
|
||||
recordProgress(id, {
|
||||
command: {
|
||||
state: 'attempted',
|
||||
at: submittedAt,
|
||||
message: 'Adopted a previously recorded submission; no command is replayed.'
|
||||
}
|
||||
});
|
||||
if (
|
||||
slot &&
|
||||
((slot.operation_id && slot.requested_at >= submittedAt) ||
|
||||
(slot.requested_at < submittedAt && !slot.healthy_at))
|
||||
) {
|
||||
const finishedAt = new Date().toISOString();
|
||||
const error = 'Existing restart cannot be correlated safely. Inspect the server before retrying.';
|
||||
db().prepare("UPDATE server_deployments SET state = 'interrupted', finished_at = ?, error = ? WHERE id = ?")
|
||||
const error =
|
||||
'Existing restart cannot be correlated safely. Inspect the server before retrying.';
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE server_deployments SET state = 'interrupted', finished_at = ?, error = ? WHERE id = ?"
|
||||
)
|
||||
.run(finishedAt, error, id);
|
||||
recordProgress(id, { recovery: { state: 'unknown', message: 'Existing restart recovery could not be correlated with this operation.', operationId: id },
|
||||
observedAt: null, event: { kind: 'outcome', message: error, details: { state: 'interrupted', finishedAt, error } } });
|
||||
recordProgress(id, {
|
||||
recovery: {
|
||||
state: 'unknown',
|
||||
message: 'Existing restart recovery could not be correlated with this operation.',
|
||||
operationId: id
|
||||
},
|
||||
observedAt: null,
|
||||
event: {
|
||||
kind: 'outcome',
|
||||
message: error,
|
||||
details: { state: 'interrupted', finishedAt, error }
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (slot && slot.requested_at >= submittedAt) {
|
||||
db().prepare('UPDATE restart_rollout SET operation_id = ? WHERE server = ?').run(id, server);
|
||||
db()
|
||||
.prepare('UPDATE restart_rollout SET operation_id = ? WHERE server = ?')
|
||||
.run(id, server);
|
||||
} else {
|
||||
db().prepare(`INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, ?)
|
||||
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,
|
||||
checked_at = NULL, healthy_at = NULL, players = NULL, error = NULL`).run(server, submittedAt, id);
|
||||
checked_at = NULL, healthy_at = NULL, players = NULL, error = NULL`
|
||||
)
|
||||
.run(server, submittedAt, id);
|
||||
}
|
||||
if (slot?.healthy_at && slot.requested_at >= submittedAt) {
|
||||
db().prepare("UPDATE server_deployments SET state = 'done', finished_at = ?, players = ? WHERE id = ?").run(slot.healthy_at, slot.players, id);
|
||||
recordProgress(id, { observedAt: slot.healthy_at, event: { kind: 'outcome', message: 'Adopted recorded recovery completion.',
|
||||
details: { state: 'done', finishedAt: slot.healthy_at, error: null, warnings: [] } } });
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE server_deployments SET state = 'done', finished_at = ?, players = ? WHERE id = ?"
|
||||
)
|
||||
.run(slot.healthy_at, slot.players, id);
|
||||
recordProgress(id, {
|
||||
observedAt: slot.healthy_at,
|
||||
event: {
|
||||
kind: 'outcome',
|
||||
message: 'Adopted recorded recovery completion.',
|
||||
details: { state: 'done', finishedAt: slot.healthy_at, error: null, warnings: [] }
|
||||
}
|
||||
});
|
||||
}
|
||||
deploymentStage(id, 'Adopted existing restart; verifying recovery without restarting again', {
|
||||
phase: 'verify-container', checks: [], blocker: null, observedAt: slot?.checked_at ?? null,
|
||||
recovery: { state: slot?.healthy_at && slot.requested_at >= submittedAt ? 'released' : 'held',
|
||||
phase: 'verify-container',
|
||||
checks: [],
|
||||
blocker: null,
|
||||
observedAt: slot?.checked_at ?? null,
|
||||
recovery: {
|
||||
state: slot?.healthy_at && slot.requested_at >= submittedAt ? 'released' : 'held',
|
||||
at: slot?.healthy_at && slot.requested_at >= submittedAt ? slot.healthy_at : undefined,
|
||||
message: slot?.healthy_at && slot.requested_at >= submittedAt ? 'Adopted recorded recovery completion.' : 'Observing the previously submitted restart without replaying it.', operationId: id }
|
||||
message:
|
||||
slot?.healthy_at && slot.requested_at >= submittedAt
|
||||
? 'Adopted recorded recovery completion.'
|
||||
: 'Observing the previously submitted restart without replaying it.',
|
||||
operationId: id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelProvisioning(id: string, reason = 'Cancelled by requester'): boolean {
|
||||
return db().transaction(() => {
|
||||
const finishedAt = new Date().toISOString();
|
||||
const result = db().prepare(`UPDATE server_deployments SET state = 'cancelled', finished_at = ?, stage = ?, error = NULL
|
||||
const result = db()
|
||||
.prepare(
|
||||
`UPDATE server_deployments SET state = 'cancelled', finished_at = ?, stage = ?, error = NULL
|
||||
WHERE id = ? AND action IN ('start','restart','stop','rebuild') AND state = 'waiting' AND submitted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM ec_host_migrations m WHERE m.deployment_id=server_deployments.id AND m.remove_operation_id IS NOT NULL)`)
|
||||
AND NOT EXISTS (SELECT 1 FROM ec_host_migrations m WHERE m.deployment_id=server_deployments.id AND m.remove_operation_id IS NOT NULL)`
|
||||
)
|
||||
.run(finishedAt, reason, id);
|
||||
if (result.changes) recordProgress(id, { blocker: null, checks: [], event: { kind: 'outcome', message: reason, details: { state: 'cancelled', finishedAt, error: null, warnings: [] } } });
|
||||
if (result.changes)
|
||||
recordProgress(id, {
|
||||
blocker: null,
|
||||
checks: [],
|
||||
event: {
|
||||
kind: 'outcome',
|
||||
message: reason,
|
||||
details: { state: 'cancelled', finishedAt, error: null, warnings: [] }
|
||||
}
|
||||
});
|
||||
return result.changes > 0;
|
||||
})();
|
||||
}
|
||||
@@ -115,191 +236,420 @@ export function cancelProvisioning(id: string, reason = 'Cancelled by requester'
|
||||
/** Explicit caller retry starts a new job and keeps the old job as history. */
|
||||
export function releaseRestartRequest(server: string, source: RestartSource): void {
|
||||
const { table, key } = sourceTables[source.kind];
|
||||
const row = db().prepare(`SELECT provisioning_id FROM ${table} WHERE ${key} = ? AND server = ? AND server_deleted_at IS NULL`).get(source.id, server) as { provisioning_id: string | null } | undefined;
|
||||
if (row?.provisioning_id) cancelProvisioning(row.provisioning_id, 'Superseded by a fresh caller attempt');
|
||||
db().prepare(`UPDATE ${table} SET provisioning_id = NULL WHERE ${key} = ? AND server = ? AND server_deleted_at IS NULL`).run(source.id, server);
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT provisioning_id FROM ${table} WHERE ${key} = ? AND server = ? AND server_deleted_at IS NULL`
|
||||
)
|
||||
.get(source.id, server) as { provisioning_id: string | null } | undefined;
|
||||
if (row?.provisioning_id)
|
||||
cancelProvisioning(row.provisioning_id, 'Superseded by a fresh caller attempt');
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE ${table} SET provisioning_id = NULL WHERE ${key} = ? AND server = ? AND server_deleted_at IS NULL`
|
||||
)
|
||||
.run(source.id, server);
|
||||
}
|
||||
|
||||
export function requestLifecycle(server: string, action: RuntimeAction) {
|
||||
if (!runtimeActions.includes(action)) return { ok: false as const, status: 400, error: 'Choose start, restart, rebuild, or stop.' };
|
||||
if (!runtimeActions.includes(action))
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 400,
|
||||
error: 'Choose start, restart, rebuild, or stop.'
|
||||
};
|
||||
// Restart and rebuild preserve the lineup; their pending work holds scaling decisions.
|
||||
const owned = action === 'start' || action === 'stop' ? scalingOwnershipBlocker(server) : null;
|
||||
if (owned) return { ok: false as const, status: 409, error: owned };
|
||||
if (!nativeInstance(server) && !unplacedNativeCreation(server)) return { ok: false as const, status: 503, error: 'Server has no native host instance.' };
|
||||
if (!nativeInstance(server) && !unplacedNativeCreation(server))
|
||||
return { ok: false as const, status: 503, error: 'Server has no native host instance.' };
|
||||
return db().transaction(() => {
|
||||
const blocker = action === 'rebuild' ? activeBetaRemovalBlocker(server) : null;
|
||||
if (blocker) return { ok: false as const, status: 409, error: blocker };
|
||||
const existing = db().prepare(`SELECT id, action, state, submitted_at, triggered_by FROM server_deployments WHERE server = ?
|
||||
const existing = db()
|
||||
.prepare(
|
||||
`SELECT id, action, state, submitted_at, triggered_by FROM server_deployments WHERE server = ?
|
||||
AND server_deleted_at IS NULL
|
||||
AND state IN ('queued','waiting','running','awaiting-health') ORDER BY rowid DESC LIMIT 1`).get(server) as
|
||||
{ id: string; action: string; state: string; submitted_at: string | null; triggered_by: string } | undefined;
|
||||
AND state IN ('queued','waiting','running','awaiting-health') ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(server) as
|
||||
| {
|
||||
id: string;
|
||||
action: string;
|
||||
state: string;
|
||||
submitted_at: string | null;
|
||||
triggered_by: string;
|
||||
}
|
||||
| undefined;
|
||||
if (existing) {
|
||||
if (existing.action !== action) return { ok: false as const, status: 409, error: 'Another provisioning operation is already pending for this server.' };
|
||||
db().prepare('UPDATE servers SET desired_state = ? WHERE name = ?').run(action === 'stop' ? 'stopped' : 'running', server);
|
||||
if (existing.state === 'waiting' && !existing.submitted_at && existing.triggered_by.startsWith('watchdog:')
|
||||
&& db().prepare(`SELECT 1 FROM provisioning_sources s JOIN watchdog_incidents i ON i.id = s.source_id
|
||||
WHERE s.operation_id = ? AND s.kind = 'watchdog' AND i.kind = 'health'`).get(existing.id)) {
|
||||
if (existing.action !== action)
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'Another provisioning operation is already pending for this server.'
|
||||
};
|
||||
db()
|
||||
.prepare('UPDATE servers SET desired_state = ? WHERE name = ?')
|
||||
.run(action === 'stop' ? 'stopped' : 'running', server);
|
||||
if (
|
||||
existing.state === 'waiting' &&
|
||||
!existing.submitted_at &&
|
||||
existing.triggered_by.startsWith('watchdog:') &&
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM provisioning_sources s JOIN watchdog_incidents i ON i.id = s.source_id
|
||||
WHERE s.operation_id = ? AND s.kind = 'watchdog' AND i.kind = 'health'`
|
||||
)
|
||||
.get(existing.id)
|
||||
) {
|
||||
// An operator explicitly requesting this queued action keeps
|
||||
// that intent even if the original health condition clears.
|
||||
db().prepare("UPDATE server_deployments SET triggered_by = 'ui', requested_by = ? WHERE id = ?")
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE server_deployments SET triggered_by = 'ui', requested_by = ? WHERE id = ?"
|
||||
)
|
||||
.run(currentRequestUsername(), existing.id);
|
||||
recordDeploymentEvent(existing.id, 'Operator explicitly requested the queued health recovery action');
|
||||
recordDeploymentEvent(
|
||||
existing.id,
|
||||
'Operator explicitly requested the queued health recovery action'
|
||||
);
|
||||
}
|
||||
return { ok: true as const, deploymentId: existing.id };
|
||||
}
|
||||
const row = getServer(server);
|
||||
if (!row || row.destroyed_at) return { ok: false as const, status: 404, error: 'Server not found.' };
|
||||
if (!row || row.destroyed_at)
|
||||
return { ok: false as const, status: 404, error: 'Server not found.' };
|
||||
if (action === 'rebuild') {
|
||||
const result = queueRebuildServer(server);
|
||||
if (result.ok) db().prepare("UPDATE servers SET desired_state = 'running' WHERE name = ?").run(server);
|
||||
return result.ok ? { ok: true as const, deploymentId: result.deploymentId }
|
||||
: { ok: false as const, status: result.kind === 'conflict' ? 409 : 400, error: result.message };
|
||||
if (result.ok)
|
||||
db()
|
||||
.prepare("UPDATE servers SET desired_state = 'running' WHERE name = ?")
|
||||
.run(server);
|
||||
return result.ok
|
||||
? { ok: true as const, deploymentId: result.deploymentId }
|
||||
: {
|
||||
ok: false as const,
|
||||
status: result.kind === 'conflict' ? 409 : 400,
|
||||
error: result.message
|
||||
};
|
||||
}
|
||||
const id = beginDeployment(server, action);
|
||||
db().prepare('UPDATE servers SET desired_state = ? WHERE name = ?').run(action === 'stop' ? 'stopped' : 'running', server);
|
||||
db()
|
||||
.prepare('UPDATE servers SET desired_state = ? WHERE name = ?')
|
||||
.run(action === 'stop' ? 'stopped' : 'running', server);
|
||||
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
|
||||
deploymentStage(id, action === 'start' ? 'Waiting to start the container' : 'Waiting to verify the server is empty', { phase: 'eligibility', observedAt: null,
|
||||
blocker: { code: 'eligibility', message: action === 'start' ? 'Waiting for start eligibility and recovery capacity.' : 'Waiting for eligibility and a fresh empty or stopped server.', automatic: true } });
|
||||
deploymentStage(
|
||||
id,
|
||||
action === 'start'
|
||||
? 'Waiting to start the container'
|
||||
: 'Waiting to verify the server is empty',
|
||||
{
|
||||
phase: 'eligibility',
|
||||
observedAt: null,
|
||||
blocker: {
|
||||
code: 'eligibility',
|
||||
message:
|
||||
action === 'start'
|
||||
? 'Waiting for start eligibility and recovery capacity.'
|
||||
: 'Waiting for eligibility and a fresh empty or stopped server.',
|
||||
automatic: true
|
||||
}
|
||||
}
|
||||
);
|
||||
return { ok: true as const, deploymentId: id };
|
||||
})();
|
||||
}
|
||||
|
||||
type LifecycleRequestResult = { ok: true; deploymentId: string } | { ok: false; status: number; error: string };
|
||||
type LifecycleRequestResult =
|
||||
{ ok: true; deploymentId: string } | { ok: false; status: number; error: string };
|
||||
|
||||
/** Keep the failed launch immutable and let normal rebuild admission own recovery. */
|
||||
export function requestRetryWithRebuild(id: string): LifecycleRequestResult {
|
||||
return db().transaction(() => {
|
||||
if (!canRetryWithRebuild(id)) return { ok: false as const, status: 409,
|
||||
error: 'This failed creation can no longer be retried with a rebuild. Refresh and inspect its current operation.' };
|
||||
if (!canRetryWithRebuild(id))
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'This failed creation can no longer be retried with a rebuild. Refresh and inspect its current operation.'
|
||||
};
|
||||
const original = provisioningOperation(id)!;
|
||||
const result = requestLifecycle(original.server, 'rebuild');
|
||||
if (!result.ok) return result;
|
||||
recordProgress(id, { recovery: { state: 'transferred', operationId: result.deploymentId,
|
||||
message: 'A new rebuild was requested for this server. The failed creation and its error are preserved.' },
|
||||
event: { kind: 'retry-requested', message: 'Retry with rebuild requested', details: { operationId: result.deploymentId } } });
|
||||
recordProgress(result.deploymentId, { event: { kind: 'retry-requested',
|
||||
message: 'Rebuild requested after failed creation', details: { previousOperationId: id } } });
|
||||
recordProgress(id, {
|
||||
recovery: {
|
||||
state: 'transferred',
|
||||
operationId: result.deploymentId,
|
||||
message:
|
||||
'A new rebuild was requested for this server. The failed creation and its error are preserved.'
|
||||
},
|
||||
event: {
|
||||
kind: 'retry-requested',
|
||||
message: 'Retry with rebuild requested',
|
||||
details: { operationId: result.deploymentId }
|
||||
}
|
||||
});
|
||||
recordProgress(result.deploymentId, {
|
||||
event: {
|
||||
kind: 'retry-requested',
|
||||
message: 'Rebuild requested after failed creation',
|
||||
details: { previousOperationId: id }
|
||||
}
|
||||
});
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
function watchdogRequestGuard(server: string, incidentId: string): Extract<LifecycleRequestResult, { ok: false }> | null {
|
||||
const incident = db().prepare("SELECT kind FROM watchdog_incidents WHERE id = ? AND server = ? AND server_deleted_at IS NULL AND state IN ('open','recovering')").get(incidentId, server) as { kind: 'health' | 'drift' } | undefined;
|
||||
if (!incident) return { ok: false, status: 409, error: 'The Watchdog incident is no longer active.' };
|
||||
function watchdogRequestGuard(
|
||||
server: string,
|
||||
incidentId: string
|
||||
): Extract<LifecycleRequestResult, { ok: false }> | null {
|
||||
const incident = db()
|
||||
.prepare(
|
||||
"SELECT kind FROM watchdog_incidents WHERE id = ? AND server = ? AND server_deleted_at IS NULL AND state IN ('open','recovering')"
|
||||
)
|
||||
.get(incidentId, server) as { kind: 'health' | 'drift' } | undefined;
|
||||
if (!incident)
|
||||
return { ok: false, status: 409, error: 'The Watchdog incident is no longer active.' };
|
||||
if (!maintenanceAllowed(incident.kind)) {
|
||||
return { ok: false, status: 409, error: 'This maintenance policy is paused.' };
|
||||
}
|
||||
const row = getServer(server);
|
||||
if (!row || row.destroyed_at) return { ok: false, status: 404, error: 'Server not found.' };
|
||||
if (row.desired_state === 'stopped') return { ok: false, status: 409, error: 'This server is intentionally stopped.' };
|
||||
if (!nativeInstance(server)) return { ok: false, status: 503, error: 'Configure a host before provisioning.' };
|
||||
if (row.desired_state === 'stopped')
|
||||
return { ok: false, status: 409, error: 'This server is intentionally stopped.' };
|
||||
if (!nativeInstance(server))
|
||||
return { ok: false, status: 503, error: 'Configure a host before provisioning.' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function existingWatchdogAction(incidentId: string, action: 'restart' | 'rebuild'): string | undefined {
|
||||
return (db().prepare(`SELECT a.operation_id FROM watchdog_actions a JOIN watchdog_incidents i ON i.id = a.incident_id
|
||||
WHERE a.incident_id = ? AND a.action = ? AND a.cycle = i.cycle`).get(incidentId, action) as { operation_id: string } | undefined)?.operation_id;
|
||||
function existingWatchdogAction(
|
||||
incidentId: string,
|
||||
action: 'restart' | 'rebuild'
|
||||
): string | undefined {
|
||||
return (
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT a.operation_id FROM watchdog_actions a JOIN watchdog_incidents i ON i.id = a.incident_id
|
||||
WHERE a.incident_id = ? AND a.action = ? AND a.cycle = i.cycle`
|
||||
)
|
||||
.get(incidentId, action) as { operation_id: string } | undefined
|
||||
)?.operation_id;
|
||||
}
|
||||
|
||||
function queueWatchdogAction(server: string, action: 'restart' | 'rebuild', incidentId: string): LifecycleRequestResult {
|
||||
function queueWatchdogAction(
|
||||
server: string,
|
||||
action: 'restart' | 'rebuild',
|
||||
incidentId: string
|
||||
): LifecycleRequestResult {
|
||||
let id: string;
|
||||
if (action === 'rebuild') {
|
||||
const result = queueRebuildServer(server, `watchdog:${incidentId}`);
|
||||
if (!result.ok) return { ok: false, status: result.kind === 'conflict' ? 409 : 400, error: result.message };
|
||||
if (!result.ok)
|
||||
return {
|
||||
ok: false,
|
||||
status: result.kind === 'conflict' ? 409 : 400,
|
||||
error: result.message
|
||||
};
|
||||
id = result.deploymentId;
|
||||
} else {
|
||||
id = beginDeployment(server, action, `watchdog:${incidentId}`);
|
||||
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
|
||||
deploymentStage(id, 'Waiting to verify the server is empty', { phase: 'eligibility', observedAt: null,
|
||||
blocker: { code: 'eligibility', message: 'Waiting for eligibility and a fresh empty or stopped server.', automatic: true } });
|
||||
deploymentStage(id, 'Waiting to verify the server is empty', {
|
||||
phase: 'eligibility',
|
||||
observedAt: null,
|
||||
blocker: {
|
||||
code: 'eligibility',
|
||||
message: 'Waiting for eligibility and a fresh empty or stopped server.',
|
||||
automatic: true
|
||||
}
|
||||
});
|
||||
}
|
||||
db().prepare("INSERT INTO provisioning_sources (operation_id, kind, source_id, server) VALUES (?, 'watchdog', ?, ?)").run(id, incidentId, server);
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO provisioning_sources (operation_id, kind, source_id, server) VALUES (?, 'watchdog', ?, ?)"
|
||||
)
|
||||
.run(id, incidentId, server);
|
||||
return { ok: true, deploymentId: id };
|
||||
}
|
||||
|
||||
/** Watchdog submits durable work; Provisioning remains the only execution owner. */
|
||||
export function requestWatchdogLifecycle(server: string, action: 'restart' | 'rebuild', incidentId: string): LifecycleRequestResult {
|
||||
export function requestWatchdogLifecycle(
|
||||
server: string,
|
||||
action: 'restart' | 'rebuild',
|
||||
incidentId: string
|
||||
): LifecycleRequestResult {
|
||||
return db().transaction(() => {
|
||||
const previous = existingWatchdogAction(incidentId, action);
|
||||
if (previous) return { ok: true as const, deploymentId: previous };
|
||||
const error = watchdogRequestGuard(server, incidentId);
|
||||
if (error) return error;
|
||||
const pending = db().prepare(`SELECT d.id, d.action, s.source_id FROM server_deployments d
|
||||
const pending = db()
|
||||
.prepare(
|
||||
`SELECT d.id, d.action, s.source_id FROM server_deployments d
|
||||
LEFT JOIN provisioning_sources s ON s.operation_id = d.id AND s.kind = 'watchdog'
|
||||
WHERE d.server = ? AND d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health') ORDER BY d.rowid DESC LIMIT 1`)
|
||||
WHERE d.server = ? AND d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health') ORDER BY d.rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(server) as { id: string; action: string; source_id: string | null } | undefined;
|
||||
if (pending) return pending.source_id === incidentId && pending.action === action
|
||||
? { ok: true as const, deploymentId: pending.id }
|
||||
: { ok: false as const, status: 409, error: 'Another provisioning operation is already pending for this server.' };
|
||||
if (pending)
|
||||
return pending.source_id === incidentId && pending.action === action
|
||||
? { ok: true as const, deploymentId: pending.id }
|
||||
: {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'Another provisioning operation is already pending for this server.'
|
||||
};
|
||||
const next = queueWatchdogAction(server, action, incidentId);
|
||||
if (!next.ok) return next;
|
||||
// An operator retry starts a new incident cycle. Keep the previous
|
||||
// failed attempt's capacity, but let that new attempt use it instead
|
||||
// of waiting forever behind its own incident's terminal operation.
|
||||
const retained = db().prepare(`SELECT r.operation_id FROM restart_rollout r
|
||||
const retained = db()
|
||||
.prepare(
|
||||
`SELECT r.operation_id FROM restart_rollout r
|
||||
JOIN server_deployments d ON d.id = r.operation_id
|
||||
JOIN watchdog_actions a ON a.operation_id = d.id AND a.incident_id = ?
|
||||
JOIN watchdog_incidents i ON i.id = a.incident_id
|
||||
WHERE r.server = ? AND r.healthy_at IS NULL AND d.state IN ('failed','interrupted','cancelled')
|
||||
AND a.cycle < i.cycle`).get(incidentId, server) as { operation_id: string } | undefined;
|
||||
AND a.cycle < i.cycle`
|
||||
)
|
||||
.get(incidentId, server) as { operation_id: string } | undefined;
|
||||
if (retained) {
|
||||
const transferred = db().prepare('UPDATE restart_rollout SET operation_id = ? WHERE server = ? AND operation_id = ? AND healthy_at IS NULL')
|
||||
const transferred = db()
|
||||
.prepare(
|
||||
'UPDATE restart_rollout SET operation_id = ? WHERE server = ? AND operation_id = ? AND healthy_at IS NULL'
|
||||
)
|
||||
.run(next.deploymentId, server, retained.operation_id);
|
||||
if (transferred.changes) {
|
||||
recordProgress(retained.operation_id, { recovery: { state: 'transferred', operationId: next.deploymentId,
|
||||
message: 'Recovery capacity transferred to the explicit new incident attempt.' } });
|
||||
recordProgress(next.deploymentId, { recovery: { state: 'held', operationId: next.deploymentId,
|
||||
message: 'Recovery capacity retained from the prior incident attempt.' } });
|
||||
recordProgress(retained.operation_id, {
|
||||
recovery: {
|
||||
state: 'transferred',
|
||||
operationId: next.deploymentId,
|
||||
message:
|
||||
'Recovery capacity transferred to the explicit new incident attempt.'
|
||||
}
|
||||
});
|
||||
recordProgress(next.deploymentId, {
|
||||
recovery: {
|
||||
state: 'held',
|
||||
operationId: next.deploymentId,
|
||||
message: 'Recovery capacity retained from the prior incident attempt.'
|
||||
}
|
||||
});
|
||||
}
|
||||
recordDeploymentEvent(retained.operation_id, 'Recovery capacity handed to explicit Watchdog retry ' + next.deploymentId);
|
||||
recordDeploymentEvent(next.deploymentId, 'Retained recovery capacity from prior incident attempt ' + retained.operation_id);
|
||||
recordDeploymentEvent(
|
||||
retained.operation_id,
|
||||
'Recovery capacity handed to explicit Watchdog retry ' + next.deploymentId
|
||||
);
|
||||
recordDeploymentEvent(
|
||||
next.deploymentId,
|
||||
'Retained recovery capacity from prior incident attempt ' + retained.operation_id
|
||||
);
|
||||
}
|
||||
return next;
|
||||
})();
|
||||
}
|
||||
|
||||
/** Atomically reserve a replacement before handing it the failed restart's capacity. */
|
||||
export function escalateWatchdogRestart(incidentId: string, operationId: string, recoveryTimeoutMs = 300_000): LifecycleRequestResult {
|
||||
export function escalateWatchdogRestart(
|
||||
incidentId: string,
|
||||
operationId: string,
|
||||
recoveryTimeoutMs = 300_000
|
||||
): LifecycleRequestResult {
|
||||
return db().transaction(() => {
|
||||
const previous = existingWatchdogAction(incidentId, 'rebuild');
|
||||
if (previous) return { ok: true as const, deploymentId: previous };
|
||||
const operation = provisioningOperation(operationId);
|
||||
if (!operation || operation.action !== 'restart' || !['awaiting-health','failed'].includes(operation.state)
|
||||
|| !db().prepare("SELECT 1 FROM provisioning_sources WHERE operation_id = ? AND kind = 'watchdog' AND source_id = ?").get(operationId, incidentId)) {
|
||||
return { ok: false as const, status: 409, error: 'The restart is not eligible for Watchdog escalation.' };
|
||||
if (
|
||||
!operation ||
|
||||
operation.action !== 'restart' ||
|
||||
!['awaiting-health', 'failed'].includes(operation.state) ||
|
||||
!db()
|
||||
.prepare(
|
||||
"SELECT 1 FROM provisioning_sources WHERE operation_id = ? AND kind = 'watchdog' AND source_id = ?"
|
||||
)
|
||||
.get(operationId, incidentId)
|
||||
) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'The restart is not eligible for Watchdog escalation.'
|
||||
};
|
||||
}
|
||||
const error = watchdogRequestGuard(operation.server, incidentId);
|
||||
if (error) return error;
|
||||
const pending = db().prepare(`SELECT d.id, d.action, s.source_id FROM server_deployments d
|
||||
const pending = db()
|
||||
.prepare(
|
||||
`SELECT d.id, d.action, s.source_id FROM server_deployments d
|
||||
LEFT JOIN provisioning_sources s ON s.operation_id = d.id AND s.kind = 'watchdog'
|
||||
WHERE d.server = ? AND d.server_deleted_at IS NULL AND d.id != ? AND d.state IN ('queued','waiting','running','awaiting-health') LIMIT 1`)
|
||||
.get(operation.server, operationId) as { id: string; action: string; source_id: string | null } | undefined;
|
||||
if (pending) return pending.action === 'rebuild' && pending.source_id === incidentId
|
||||
? { ok: true as const, deploymentId: pending.id }
|
||||
: { ok: false as const, status: 409, error: 'Another provisioning operation is already pending for this server.' };
|
||||
const slot = db().prepare('SELECT operation_id, requested_at, healthy_at FROM restart_rollout WHERE server = ?').get(operation.server) as
|
||||
{ operation_id: string | null; requested_at: string; healthy_at: string | null } | undefined;
|
||||
const anchor = Date.parse(slot?.requested_at ?? operation.submitted_at ?? operation.started_at);
|
||||
if ((slot && (slot.operation_id !== operationId || slot.healthy_at))
|
||||
|| !Number.isFinite(anchor) || Date.now() - anchor < Math.max(300_000, recoveryTimeoutMs)) {
|
||||
return { ok: false as const, status: 409, error: 'Waiting for the submitted restart to finish its recovery window.' };
|
||||
WHERE d.server = ? AND d.server_deleted_at IS NULL AND d.id != ? AND d.state IN ('queued','waiting','running','awaiting-health') LIMIT 1`
|
||||
)
|
||||
.get(operation.server, operationId) as
|
||||
{ id: string; action: string; source_id: string | null } | undefined;
|
||||
if (pending)
|
||||
return pending.action === 'rebuild' && pending.source_id === incidentId
|
||||
? { ok: true as const, deploymentId: pending.id }
|
||||
: {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'Another provisioning operation is already pending for this server.'
|
||||
};
|
||||
const slot = db()
|
||||
.prepare(
|
||||
'SELECT operation_id, requested_at, healthy_at FROM restart_rollout WHERE server = ?'
|
||||
)
|
||||
.get(operation.server) as
|
||||
| { operation_id: string | null; requested_at: string; healthy_at: string | null }
|
||||
| undefined;
|
||||
const anchor = Date.parse(
|
||||
slot?.requested_at ?? operation.submitted_at ?? operation.started_at
|
||||
);
|
||||
if (
|
||||
(slot && (slot.operation_id !== operationId || slot.healthy_at)) ||
|
||||
!Number.isFinite(anchor) ||
|
||||
Date.now() - anchor < Math.max(300_000, recoveryTimeoutMs)
|
||||
) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'Waiting for the submitted restart to finish its recovery window.'
|
||||
};
|
||||
}
|
||||
const replacement = queueWatchdogAction(operation.server, 'rebuild', incidentId);
|
||||
if (!replacement.ok) return replacement;
|
||||
const reason = 'Watchdog restart recovery timed out; handed recovery to rebuild ' + replacement.deploymentId;
|
||||
if (operation.state === 'awaiting-health') finishDeployment(operationId, { ok: false, message: reason });
|
||||
const reason =
|
||||
'Watchdog restart recovery timed out; handed recovery to rebuild ' +
|
||||
replacement.deploymentId;
|
||||
if (operation.state === 'awaiting-health')
|
||||
finishDeployment(operationId, { ok: false, message: reason });
|
||||
else recordDeploymentEvent(operationId, reason);
|
||||
if (slot) {
|
||||
const transferred = db().prepare('UPDATE restart_rollout SET operation_id = ?, error = ? WHERE server = ? AND operation_id = ? AND healthy_at IS NULL')
|
||||
const transferred = db()
|
||||
.prepare(
|
||||
'UPDATE restart_rollout SET operation_id = ?, error = ? WHERE server = ? AND operation_id = ? AND healthy_at IS NULL'
|
||||
)
|
||||
.run(replacement.deploymentId, reason, operation.server, operationId);
|
||||
if (transferred.changes) {
|
||||
recordProgress(operationId, { recovery: { state: 'transferred', operationId: replacement.deploymentId,
|
||||
message: 'Recovery capacity transferred to the rebuild operation.' } });
|
||||
recordProgress(replacement.deploymentId, { recovery: { state: 'held', operationId: replacement.deploymentId,
|
||||
message: 'Recovery capacity retained from the previous restart.' } });
|
||||
recordProgress(operationId, {
|
||||
recovery: {
|
||||
state: 'transferred',
|
||||
operationId: replacement.deploymentId,
|
||||
message: 'Recovery capacity transferred to the rebuild operation.'
|
||||
}
|
||||
});
|
||||
recordProgress(replacement.deploymentId, {
|
||||
recovery: {
|
||||
state: 'held',
|
||||
operationId: replacement.deploymentId,
|
||||
message: 'Recovery capacity retained from the previous restart.'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
recordDeploymentEvent(replacement.deploymentId, 'Recovery handed off from restart ' + operationId + '; waiting for a fresh empty or stopped server');
|
||||
recordDeploymentEvent(
|
||||
replacement.deploymentId,
|
||||
'Recovery handed off from restart ' +
|
||||
operationId +
|
||||
'; waiting for a fresh empty or stopped server'
|
||||
);
|
||||
return replacement;
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ interface RestartSlot {
|
||||
}
|
||||
|
||||
function slotFor(server: string): RestartSlot | undefined {
|
||||
return db().prepare('SELECT * FROM restart_rollout WHERE server = ?').get(server) as RestartSlot | undefined;
|
||||
return db().prepare('SELECT * FROM restart_rollout WHERE server = ?').get(server) as
|
||||
RestartSlot | undefined;
|
||||
}
|
||||
|
||||
export function restartRecoveryPending(server: string): boolean {
|
||||
@@ -24,51 +25,107 @@ export function restartRecoveryPending(server: string): boolean {
|
||||
}
|
||||
|
||||
type SettlingSlot = Pick<RestartSlot, 'requested_at' | 'healthy_at' | 'operation_id'>;
|
||||
interface SettlingOperation { id: string; action: string; state: string }
|
||||
interface SettlingOperation {
|
||||
id: string;
|
||||
action: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
/** Submitted lifecycle work stays Starting until verification ends, including slow restarts. */
|
||||
export function recoverySettling(slot: SettlingSlot | undefined, operation?: SettlingOperation | null): boolean {
|
||||
export function recoverySettling(
|
||||
slot: SettlingSlot | undefined,
|
||||
operation?: SettlingOperation | null
|
||||
): boolean {
|
||||
if (!slot || slot.healthy_at) return false;
|
||||
if (slot.operation_id) return operation?.id === slot.operation_id
|
||||
&& ['start', 'restart', 'rebuild'].includes(operation.action)
|
||||
&& ['running', 'awaiting-health'].includes(operation.state);
|
||||
if (slot.operation_id)
|
||||
return (
|
||||
operation?.id === slot.operation_id &&
|
||||
['start', 'restart', 'rebuild'].includes(operation.action) &&
|
||||
['running', 'awaiting-health'].includes(operation.state)
|
||||
);
|
||||
// Legacy recovery slots have no operation whose verification can be followed.
|
||||
return Date.now() - Date.parse(slot.requested_at) < HEALTH_POLL_MS;
|
||||
}
|
||||
|
||||
export function restartSettling(server: string): boolean {
|
||||
const slot = slotFor(server);
|
||||
const operation = slot?.operation_id ? db().prepare(`SELECT id, action, state FROM server_deployments
|
||||
WHERE server = ? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1`).get(server) as SettlingOperation | undefined : undefined;
|
||||
const operation = slot?.operation_id
|
||||
? (db()
|
||||
.prepare(
|
||||
`SELECT id, action, state FROM server_deployments
|
||||
WHERE server = ? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(server) as SettlingOperation | undefined)
|
||||
: undefined;
|
||||
return recoverySettling(slot, operation);
|
||||
}
|
||||
|
||||
export function rolloutWaitReason(server: string, operationId = '', action: RuntimeAction = 'restart'): string | null {
|
||||
export function rolloutWaitReason(
|
||||
server: string,
|
||||
operationId = '',
|
||||
action: RuntimeAction = 'restart'
|
||||
): string | null {
|
||||
return rolloutBlocker(server, operationId, action)?.message ?? null;
|
||||
}
|
||||
|
||||
export function rolloutBlocker(server: string, operationId = '', action: RuntimeAction = 'restart'): ProvisioningBlocker | null {
|
||||
const conflict = db().prepare(`SELECT id FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
|
||||
export function rolloutBlocker(
|
||||
server: string,
|
||||
operationId = '',
|
||||
action: RuntimeAction = 'restart'
|
||||
): ProvisioningBlocker | null {
|
||||
const conflict = db()
|
||||
.prepare(
|
||||
`SELECT id FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
|
||||
AND ((action IN ('start','stop','rebuild') AND state IN ('running','awaiting-health'))
|
||||
OR (action = 'destroy' AND state IN ('waiting','running')))`).get(server, operationId) as { id: string } | undefined;
|
||||
OR (action = 'destroy' AND state IN ('waiting','running')))`
|
||||
)
|
||||
.get(server, operationId) as { id: string } | undefined;
|
||||
if (conflict) {
|
||||
return { code: 'operation-conflict', message: 'Waiting for the current provisioning operation', automatic: true,
|
||||
href: `/provisioning/${encodeURIComponent(conflict.id)}`, label: 'Blocking operation' };
|
||||
return {
|
||||
code: 'operation-conflict',
|
||||
message: 'Waiting for the current provisioning operation',
|
||||
automatic: true,
|
||||
href: `/provisioning/${encodeURIComponent(conflict.id)}`,
|
||||
label: 'Blocking operation'
|
||||
};
|
||||
}
|
||||
const slot = slotFor(server);
|
||||
const ownHandoff = slot?.operation_id === operationId && !!db().prepare(`SELECT 1 FROM server_deployments d
|
||||
const ownHandoff =
|
||||
slot?.operation_id === operationId &&
|
||||
!!db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM server_deployments d
|
||||
WHERE d.id = ? AND d.server_deleted_at IS NULL AND d.action IN ('restart','rebuild') AND EXISTS (
|
||||
SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'watchdog')`).get(operationId);
|
||||
SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'watchdog')`
|
||||
)
|
||||
.get(operationId);
|
||||
if (slot?.healthy_at === null && !ownHandoff) {
|
||||
return { code: 'previous-recovery', message: `Waiting for this server to recover from its previous operation${slot.error ? `: ${slot.error}` : ''}`, automatic: true,
|
||||
href: slot.operation_id ? `/provisioning/${encodeURIComponent(slot.operation_id)}` : null, label: 'Previous operation' };
|
||||
return {
|
||||
code: 'previous-recovery',
|
||||
message: `Waiting for this server to recover from its previous operation${slot.error ? `: ${slot.error}` : ''}`,
|
||||
automatic: true,
|
||||
href: slot.operation_id
|
||||
? `/provisioning/${encodeURIComponent(slot.operation_id)}`
|
||||
: null,
|
||||
label: 'Previous operation'
|
||||
};
|
||||
}
|
||||
const { n } = db().prepare('SELECT COUNT(*) AS n FROM restart_rollout WHERE healthy_at IS NULL').get() as { n: number };
|
||||
return n - (ownHandoff && slot?.healthy_at === null ? 1 : 0) >= fleetSettings().restartConcurrency
|
||||
? { code: 'restart-capacity', message: `Queued to ${action}; waiting for another server to recover`, automatic: true } : null;
|
||||
const { n } = db()
|
||||
.prepare('SELECT COUNT(*) AS n FROM restart_rollout WHERE healthy_at IS NULL')
|
||||
.get() as { n: number };
|
||||
return n - (ownHandoff && slot?.healthy_at === null ? 1 : 0) >=
|
||||
fleetSettings().restartConcurrency
|
||||
? {
|
||||
code: 'restart-capacity',
|
||||
message: `Queued to ${action}; waiting for another server to recover`,
|
||||
automatic: true
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function rolloutHealth(server: string): { ok: true; players: number } | { ok: false; reason: string } {
|
||||
export function rolloutHealth(
|
||||
server: string
|
||||
): { ok: true; players: number } | { ok: false; reason: string } {
|
||||
const slot = slotFor(server);
|
||||
if (slot?.healthy_at) return { ok: true, players: slot.players ?? 0 };
|
||||
return { ok: false, reason: slot?.error ?? 'Waiting for restart recovery and health checks' };
|
||||
|
||||
@@ -6,13 +6,24 @@ import type { ProvisioningDetail } from '../../provisioning-detail.js';
|
||||
export const safetyOverrideCapability = 'instance.safety-override';
|
||||
|
||||
export function safetyOverride(id: string): ProvisioningDetail['safetyOverride'] {
|
||||
return db().prepare(`SELECT actor_id AS actorId, actor_name AS actorName, confirmed_at AS confirmedAt
|
||||
FROM provisioning_safety_overrides WHERE deployment_id=?`).get(id) as ProvisioningDetail['safetyOverride'] ?? null;
|
||||
return (
|
||||
(db()
|
||||
.prepare(
|
||||
`SELECT actor_id AS actorId, actor_name AS actorName, confirmed_at AS confirmedAt
|
||||
FROM provisioning_safety_overrides WHERE deployment_id=?`
|
||||
)
|
||||
.get(id) as ProvisioningDetail['safetyOverride']) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Approval belongs to one unsubmitted operation and its original live instance. */
|
||||
export function safetyOverrideAvailability(id: string): { applicable: boolean; unavailableReason: string | null } {
|
||||
const target = db().prepare(`SELECT i.host_id FROM server_deployments d
|
||||
export function safetyOverrideAvailability(id: string): {
|
||||
applicable: boolean;
|
||||
unavailableReason: string | null;
|
||||
} {
|
||||
const target = db()
|
||||
.prepare(
|
||||
`SELECT i.host_id FROM server_deployments d
|
||||
JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
JOIN native_instances i ON i.id=n.instance_id AND i.server=d.server AND i.generation=n.generation AND i.retired_at IS NULL
|
||||
WHERE d.id=? AND d.server_deleted_at IS NULL AND d.action IN ('restart','stop','rebuild','destroy')
|
||||
@@ -20,26 +31,53 @@ export function safetyOverrideAvailability(id: string): { applicable: boolean; u
|
||||
AND NOT EXISTS (SELECT 1 FROM ec_host_migrations m WHERE m.deployment_id=d.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM native_creation_cancellations c WHERE c.cleanup_id=d.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM autoscaling_decisions a WHERE a.operation_id=d.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM provisioning_safety_overrides o WHERE o.deployment_id=d.id)`)
|
||||
AND NOT EXISTS (SELECT 1 FROM provisioning_safety_overrides o WHERE o.deployment_id=d.id)`
|
||||
)
|
||||
.get(id) as { host_id: string } | undefined;
|
||||
if (!target) return { applicable: false, unavailableReason: null };
|
||||
return { applicable: true, unavailableReason: getHost(target.host_id)?.inventory.capabilities?.includes(safetyOverrideCapability)
|
||||
? null : 'Update this host’s agent before using admin override.' };
|
||||
return {
|
||||
applicable: true,
|
||||
unavailableReason: getHost(target.host_id)?.inventory.capabilities?.includes(
|
||||
safetyOverrideCapability
|
||||
)
|
||||
? null
|
||||
: 'Update this host’s agent before using admin override.'
|
||||
};
|
||||
}
|
||||
|
||||
export function enableSafetyOverride(id: string, actor: { id: string; username: string }): { ok: true } | { ok: false; error: string } {
|
||||
export function enableSafetyOverride(
|
||||
id: string,
|
||||
actor: { id: string; username: string }
|
||||
): { ok: true } | { ok: false; error: string } {
|
||||
return db().transaction(() => {
|
||||
// A retried confirmation never sends or repeats a command.
|
||||
if (safetyOverride(id)) return { ok: true as const };
|
||||
const availability = safetyOverrideAvailability(id);
|
||||
if (!availability.applicable) return { ok: false as const, error: 'This operation can no longer be overridden. Refresh to see its current state.' };
|
||||
if (availability.unavailableReason) return { ok: false as const, error: availability.unavailableReason };
|
||||
if (!availability.applicable)
|
||||
return {
|
||||
ok: false as const,
|
||||
error: 'This operation can no longer be overridden. Refresh to see its current state.'
|
||||
};
|
||||
if (availability.unavailableReason)
|
||||
return { ok: false as const, error: availability.unavailableReason };
|
||||
const confirmedAt = new Date().toISOString();
|
||||
db().prepare('INSERT INTO provisioning_safety_overrides(deployment_id,actor_id,actor_name,confirmed_at) VALUES(?,?,?,?)')
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO provisioning_safety_overrides(deployment_id,actor_id,actor_name,confirmed_at) VALUES(?,?,?,?)'
|
||||
)
|
||||
.run(id, actor.id, actor.username, confirmedAt);
|
||||
recordProgress(id, { event: { kind: 'safety-override',
|
||||
message: `Admin override confirmed by ${actor.username}: bypass player safety checks for this operation.`,
|
||||
details: { actorId: actor.id, actorName: actor.username, confirmedAt, checks: ['player-occupancy'] } } });
|
||||
recordProgress(id, {
|
||||
event: {
|
||||
kind: 'safety-override',
|
||||
message: `Admin override confirmed by ${actor.username}: bypass player safety checks for this operation.`,
|
||||
details: {
|
||||
actorId: actor.id,
|
||||
actorName: actor.username,
|
||||
confirmedAt,
|
||||
checks: ['player-occupancy']
|
||||
}
|
||||
}
|
||||
});
|
||||
return { ok: true as const };
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -8,43 +8,110 @@ import type { RuntimeAction } from '../../provisioning.js';
|
||||
import type { ProvisioningBlocker } from '../../provisioning-detail.js';
|
||||
import { processNativeProvisioning } from './native.js';
|
||||
|
||||
interface Source { kind: 'campaign' | 'beta' | 'maintenance' | 'watchdog'; source_id: string }
|
||||
const sourcesFor = (id: string) => db().prepare('SELECT kind, source_id FROM provisioning_sources WHERE operation_id = ?').all(id) as Source[];
|
||||
const priority = (sources: Source[]) => sources.some(s => s.kind === 'watchdog') ? 2 : sources.some(s => s.kind === 'beta') ? 0 : sources.some(s => s.kind === 'campaign') ? 1 : sources.some(s => s.kind === 'maintenance') ? 3 : 2;
|
||||
interface Source {
|
||||
kind: 'campaign' | 'beta' | 'maintenance' | 'watchdog';
|
||||
source_id: string;
|
||||
}
|
||||
const sourcesFor = (id: string) =>
|
||||
db()
|
||||
.prepare('SELECT kind, source_id FROM provisioning_sources WHERE operation_id = ?')
|
||||
.all(id) as Source[];
|
||||
const priority = (sources: Source[]) =>
|
||||
sources.some((s) => s.kind === 'watchdog')
|
||||
? 2
|
||||
: sources.some((s) => s.kind === 'beta')
|
||||
? 0
|
||||
: sources.some((s) => s.kind === 'campaign')
|
||||
? 1
|
||||
: sources.some((s) => s.kind === 'maintenance')
|
||||
? 3
|
||||
: 2;
|
||||
|
||||
/** Source work may hold or withdraw a request, but never executes it. */
|
||||
function sourceEligibility(operation: Operation, onHold?: (source: Source) => void): 'ready' | 'held' | 'cancelled' {
|
||||
function sourceEligibility(
|
||||
operation: Operation,
|
||||
onHold?: (source: Source) => void
|
||||
): 'ready' | 'held' | 'cancelled' {
|
||||
const sources = sourcesFor(operation.id);
|
||||
if (!sources.length) return 'ready';
|
||||
// Watchdog intent gates every linked source, including its drift campaign.
|
||||
for (const source of sources.filter(source => source.kind === 'watchdog')) {
|
||||
if (!db().prepare("SELECT 1 FROM watchdog_incidents WHERE id = ? AND server = ? AND server_deleted_at IS NULL AND state IN ('open','recovering')").get(source.source_id, operation.server)
|
||||
|| getServer(operation.server)?.desired_state === 'stopped') return 'cancelled';
|
||||
const incident = db().prepare('SELECT kind FROM watchdog_incidents WHERE id = ?').get(source.source_id) as { kind: 'health' | 'drift' };
|
||||
if (!maintenanceAllowed(incident.kind)) { onHold?.(source); return 'held'; }
|
||||
for (const source of sources.filter((source) => source.kind === 'watchdog')) {
|
||||
if (
|
||||
!db()
|
||||
.prepare(
|
||||
"SELECT 1 FROM watchdog_incidents WHERE id = ? AND server = ? AND server_deleted_at IS NULL AND state IN ('open','recovering')"
|
||||
)
|
||||
.get(source.source_id, operation.server) ||
|
||||
getServer(operation.server)?.desired_state === 'stopped'
|
||||
)
|
||||
return 'cancelled';
|
||||
const incident = db()
|
||||
.prepare('SELECT kind FROM watchdog_incidents WHERE id = ?')
|
||||
.get(source.source_id) as { kind: 'health' | 'drift' };
|
||||
if (!maintenanceAllowed(incident.kind)) {
|
||||
onHold?.(source);
|
||||
return 'held';
|
||||
}
|
||||
}
|
||||
let held = false;
|
||||
let ready = false;
|
||||
for (const source of sources) {
|
||||
if (source.kind === 'campaign') {
|
||||
const row = db().prepare('SELECT restart_state, files_changed, provisioning_id FROM deployment_servers WHERE deployment_id = ? AND server = ? AND server_deleted_at IS NULL')
|
||||
.get(source.source_id, operation.server) as { restart_state: string; files_changed: number; provisioning_id: string } | undefined;
|
||||
const row = db()
|
||||
.prepare(
|
||||
'SELECT restart_state, files_changed, provisioning_id FROM deployment_servers WHERE deployment_id = ? AND server = ? AND server_deleted_at IS NULL'
|
||||
)
|
||||
.get(source.source_id, operation.server) as
|
||||
| { restart_state: string; files_changed: number; provisioning_id: string }
|
||||
| undefined;
|
||||
if (row?.provisioning_id !== operation.id || !row.files_changed) continue;
|
||||
if (['pending','failed'].includes(row.restart_state)) ready = true;
|
||||
if (['held','manual'].includes(row.restart_state)) { held = true; onHold?.(source); }
|
||||
if (['pending', 'failed'].includes(row.restart_state)) ready = true;
|
||||
if (['held', 'manual'].includes(row.restart_state)) {
|
||||
held = true;
|
||||
onHold?.(source);
|
||||
}
|
||||
} else if (source.kind === 'beta') {
|
||||
const row = db().prepare(`SELECT b.state, bs.stage, bs.provisioning_id, p.outcome FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
|
||||
LEFT JOIN beta_test_plans p ON p.beta_id = b.id WHERE bs.beta_id = ? AND bs.server = ? AND bs.server_deleted_at IS NULL`)
|
||||
.get(source.source_id, operation.server) as { state: string; stage: string; provisioning_id: string; outcome: string | null } | undefined;
|
||||
if (row?.provisioning_id !== operation.id || !['running','ready-promote'].includes(row.state)) continue;
|
||||
if (!['awaiting-empty','restarting','awaiting-health'].includes(row.stage)) continue;
|
||||
if (!row.outcome || ['active','passed'].includes(row.outcome)) ready = true;
|
||||
if (row.outcome === 'paused') { held = true; onHold?.(source); }
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT b.state, bs.stage, bs.provisioning_id, p.outcome FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
|
||||
LEFT JOIN beta_test_plans p ON p.beta_id = b.id WHERE bs.beta_id = ? AND bs.server = ? AND bs.server_deleted_at IS NULL`
|
||||
)
|
||||
.get(source.source_id, operation.server) as
|
||||
| { state: string; stage: string; provisioning_id: string; outcome: string | null }
|
||||
| undefined;
|
||||
if (
|
||||
row?.provisioning_id !== operation.id ||
|
||||
!['running', 'ready-promote'].includes(row.state)
|
||||
)
|
||||
continue;
|
||||
if (!['awaiting-empty', 'restarting', 'awaiting-health'].includes(row.stage)) continue;
|
||||
if (!row.outcome || ['active', 'passed'].includes(row.outcome)) ready = true;
|
||||
if (row.outcome === 'paused') {
|
||||
held = true;
|
||||
onHold?.(source);
|
||||
}
|
||||
} else if (source.kind === 'maintenance') {
|
||||
const row = db().prepare('SELECT state, provisioning_id, kind, origin FROM maintenance_jobs WHERE id = ? AND server_deleted_at IS NULL').get(source.source_id) as { state: string; provisioning_id: string; kind: string; origin: string } | undefined;
|
||||
if (row?.provisioning_id === operation.id && ['queued','restarting'].includes(row.state)) {
|
||||
const row = db()
|
||||
.prepare(
|
||||
'SELECT state, provisioning_id, kind, origin FROM maintenance_jobs WHERE id = ? AND server_deleted_at IS NULL'
|
||||
)
|
||||
.get(source.source_id) as
|
||||
| { state: string; provisioning_id: string; kind: string; origin: string }
|
||||
| undefined;
|
||||
if (
|
||||
row?.provisioning_id === operation.id &&
|
||||
['queued', 'restarting'].includes(row.state)
|
||||
) {
|
||||
if (getServer(operation.server)?.desired_state === 'stopped') return 'cancelled';
|
||||
if (!maintenanceAllowed() || (row.kind === 'update' && row.origin === 'automatic' && !maintenanceAllowed('updates'))) { onHold?.(source); return 'held'; }
|
||||
if (
|
||||
!maintenanceAllowed() ||
|
||||
(row.kind === 'update' &&
|
||||
row.origin === 'automatic' &&
|
||||
!maintenanceAllowed('updates'))
|
||||
) {
|
||||
onHold?.(source);
|
||||
return 'held';
|
||||
}
|
||||
ready = true;
|
||||
}
|
||||
} else ready = true;
|
||||
@@ -55,56 +122,114 @@ function sourceEligibility(operation: Operation, onHold?: (source: Source) => vo
|
||||
function blocker(server: string, id: string, action: RuntimeAction): ProvisioningBlocker | null {
|
||||
const sources = sourcesFor(id);
|
||||
const rank = priority(sources);
|
||||
const others = db().prepare(`SELECT id, state, action, rowid FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
|
||||
AND state IN ('queued','waiting','running','awaiting-health')`).all(server, id) as { id: string; state: string; action: string; rowid: number }[];
|
||||
const own = db().prepare('SELECT rowid FROM server_deployments WHERE id = ?').get(id) as { rowid: number };
|
||||
const conflict = others.find(other => other.state !== 'waiting' || other.action === 'destroy'
|
||||
|| priority(sourcesFor(other.id)) < rank || (priority(sourcesFor(other.id)) === rank && other.rowid < own.rowid));
|
||||
const others = db()
|
||||
.prepare(
|
||||
`SELECT id, state, action, rowid FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
|
||||
AND state IN ('queued','waiting','running','awaiting-health')`
|
||||
)
|
||||
.all(server, id) as { id: string; state: string; action: string; rowid: number }[];
|
||||
const own = db().prepare('SELECT rowid FROM server_deployments WHERE id = ?').get(id) as {
|
||||
rowid: number;
|
||||
};
|
||||
const conflict = others.find(
|
||||
(other) =>
|
||||
other.state !== 'waiting' ||
|
||||
other.action === 'destroy' ||
|
||||
priority(sourcesFor(other.id)) < rank ||
|
||||
(priority(sourcesFor(other.id)) === rank && other.rowid < own.rowid)
|
||||
);
|
||||
if (conflict) {
|
||||
return { code: 'operation-conflict', message: 'Waiting for another provisioning operation on this server', automatic: true,
|
||||
href: `/provisioning/${encodeURIComponent(conflict.id)}`, label: 'Blocking operation' };
|
||||
return {
|
||||
code: 'operation-conflict',
|
||||
message: 'Waiting for another provisioning operation on this server',
|
||||
automatic: true,
|
||||
href: `/provisioning/${encodeURIComponent(conflict.id)}`,
|
||||
label: 'Blocking operation'
|
||||
};
|
||||
}
|
||||
// Beta activation must not wait on the campaign rows that the beta itself
|
||||
// holds. Campaign/maintenance callers likewise never block on their own job.
|
||||
const beta = rank > 0 ? db().prepare(`SELECT b.id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
|
||||
WHERE bs.server = ? AND bs.server_deleted_at IS NULL AND b.state IN ('running','ready-promote') LIMIT 1`).get(server) as { id: string } | undefined : undefined;
|
||||
if (beta) return { code: 'beta-work', message: 'Waiting for beta work on this server', automatic: true, href: `/betas/${encodeURIComponent(beta.id)}`, label: 'Blocking beta' };
|
||||
const campaign = rank > 1 ? db().prepare(`SELECT ds.deployment_id FROM deployment_servers ds WHERE ds.server = ? AND ds.server_deleted_at IS NULL
|
||||
const beta =
|
||||
rank > 0
|
||||
? (db()
|
||||
.prepare(
|
||||
`SELECT b.id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
|
||||
WHERE bs.server = ? AND bs.server_deleted_at IS NULL AND b.state IN ('running','ready-promote') LIMIT 1`
|
||||
)
|
||||
.get(server) as { id: string } | undefined)
|
||||
: undefined;
|
||||
if (beta)
|
||||
return {
|
||||
code: 'beta-work',
|
||||
message: 'Waiting for beta work on this server',
|
||||
automatic: true,
|
||||
href: `/betas/${encodeURIComponent(beta.id)}`,
|
||||
label: 'Blocking beta'
|
||||
};
|
||||
const campaign =
|
||||
rank > 1
|
||||
? (db()
|
||||
.prepare(
|
||||
`SELECT ds.deployment_id FROM deployment_servers ds WHERE ds.server = ? AND ds.server_deleted_at IS NULL
|
||||
AND ds.restart_state IN ('pending','failed','held') AND (ds.provisioning_id IS NULL OR EXISTS (
|
||||
SELECT 1 FROM server_deployments d WHERE d.id = ds.provisioning_id AND d.state IN ('queued','waiting','running','awaiting-health')))
|
||||
AND NOT EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.operation_id = ? AND a.campaign_id = ds.deployment_id)
|
||||
LIMIT 1`).get(server, id) as { deployment_id: string } | undefined : undefined;
|
||||
if (campaign) return { code: 'campaign-work', message: 'Waiting for campaign work on this server', automatic: true,
|
||||
href: `/campaigns/${encodeURIComponent(campaign.deployment_id)}`, label: 'Blocking campaign' };
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(server, id) as { deployment_id: string } | undefined)
|
||||
: undefined;
|
||||
if (campaign)
|
||||
return {
|
||||
code: 'campaign-work',
|
||||
message: 'Waiting for campaign work on this server',
|
||||
automatic: true,
|
||||
href: `/campaigns/${encodeURIComponent(campaign.deployment_id)}`,
|
||||
label: 'Blocking campaign'
|
||||
};
|
||||
return rolloutBlocker(server, id, action);
|
||||
}
|
||||
|
||||
interface Operation { id: string; server: string; action: RuntimeAction; state: string; stage: string }
|
||||
interface Operation {
|
||||
id: string;
|
||||
server: string;
|
||||
action: RuntimeAction;
|
||||
state: string;
|
||||
stage: string;
|
||||
}
|
||||
let awaitedPass: Promise<void> | null = null;
|
||||
export function processProvisioning(options: { wait?: boolean } = {}): Promise<void> {
|
||||
if (options.wait !== false && awaitedPass) return awaitedPass;
|
||||
// Each job owns its flight, so slow observations do not block new work.
|
||||
const pass = nativePass(options);
|
||||
if (options.wait === false) return pass;
|
||||
awaitedPass = pass.finally(() => { awaitedPass = null; });
|
||||
awaitedPass = pass.finally(() => {
|
||||
awaitedPass = null;
|
||||
});
|
||||
return awaitedPass;
|
||||
}
|
||||
|
||||
export const processLifecycle = processProvisioning;
|
||||
|
||||
function nativePass(options: { wait?: boolean }): Promise<void> {
|
||||
return processNativeProvisioning(operation => {
|
||||
return processNativeProvisioning((operation) => {
|
||||
const scaling = scalingAdmission(operation.id);
|
||||
if (scaling) return scaling;
|
||||
if (operation.action === 'create') return null;
|
||||
if (operation.action === 'destroy') {
|
||||
const other = db().prepare(`SELECT id FROM server_deployments WHERE server=? AND id!=? AND server_deleted_at IS NULL
|
||||
AND (state='running' OR (state='awaiting-health' AND action NOT IN ('create','rebuild'))) LIMIT 1`).get(operation.server, operation.id);
|
||||
const other = db()
|
||||
.prepare(
|
||||
`SELECT id FROM server_deployments WHERE server=? AND id!=? AND server_deleted_at IS NULL
|
||||
AND (state='running' OR (state='awaiting-health' AND action NOT IN ('create','rebuild'))) LIMIT 1`
|
||||
)
|
||||
.get(operation.server, operation.id);
|
||||
return other ? 'Waiting for the current server operation.' : null;
|
||||
}
|
||||
const typed = operation as Operation;
|
||||
const eligibility = sourceEligibility(typed);
|
||||
if (eligibility === 'cancelled') { cancelProvisioning(operation.id, 'Requester cancelled or replaced this operation'); return 'Requester cancelled this operation.'; }
|
||||
if (eligibility === 'cancelled') {
|
||||
cancelProvisioning(operation.id, 'Requester cancelled or replaced this operation');
|
||||
return 'Requester cancelled this operation.';
|
||||
}
|
||||
if (eligibility === 'held') return 'Waiting for the requester to resume.';
|
||||
return blocker(operation.server, operation.id, typed.action)?.message ?? null;
|
||||
}, options);
|
||||
|
||||
Reference in New Issue
Block a user