import { maintenanceAllowed } from '../maintenance/policies.js'; import { resolveRecoveredHealthOperation } from '../maintenance/recovery.js'; import { db } from '../db/index.js'; import { provisioningCancelled } from './cancellation.js'; import { komodoConfigured, rconConfigured } from '../config.js'; import { getServer } from '../fleet/servers.js'; import { deploymentStage, finishDeployment, REMOVING_SERVER } from './history.js'; import { processCreations, processDestructions, rebuildServer } from './instances.js'; import { komodoInspectHealthy, komodoRuntimeAction } from '../integrations/komodo.js'; import { latestGameSubmission, probePlayerEvidence } from '../fleet/status-evidence.js'; import { refreshRestartRollout, restartInRollout, rolloutBlocker } from './restarts.js'; import { cancelProvisioning } from './requests.js'; import type { RuntimeAction } from '../../provisioning.js'; import type { ProvisioningBlocker } from '../../provisioning-detail.js'; import { recordProgress } from './progress.js'; import { nativeInstance, nativeDeployment } from '../backends/native.js'; import { processNativeProvisioning } from './native.js'; import { trackActivity } from '../pipeline/activity.js'; const stoppedStates = new Set(['stopped', 'exited', 'created', 'dead']); export const isStopped = (state?: string) => stoppedStates.has(state?.toLowerCase() ?? ''); 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' { 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'; } } 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; 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); } } 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); } } 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)) { if (getServer(operation.server)?.desired_state === 'stopped') return 'cancelled'; if (!maintenanceAllowed() || (row.kind === 'update' && row.origin === 'automatic' && !maintenanceAllowed('updates'))) { onHold?.(source); return 'held'; } ready = true; } } else ready = true; } return held ? 'held' : ready ? 'ready' : 'cancelled'; } 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)); 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' }; } // 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 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' }; return rolloutBlocker(server, id, action); } interface Operation { id: string; server: string; action: RuntimeAction; state: string; stage: string } let ticking: Promise | null = null; let awaitedPass: Promise | null = null; export function processProvisioning(options: { wait?: boolean } = {}): Promise { if (options.wait !== false && awaitedPass) return awaitedPass; // Native jobs own their individual flights. A slow observation must not // prevent the next scheduler pass admitting newly arrived native work. const native = nativePass(options); if (!ticking) ticking = trackActivity('legacy-provisioning', 'Processing legacy provisioning', run) .finally(() => { ticking = null; }); if (options.wait === false) { void ticking.catch(error => console.error('[worker] legacy provisioning error:', error)); return native; } awaitedPass = Promise.all([native, ticking]).then(() => {}).finally(() => { awaitedPass = null; }); return awaitedPass; } export const processLifecycle = processProvisioning; function nativePass(options: { wait?: boolean }): Promise { return processNativeProvisioning(operation => { 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); 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 === 'held') return 'Waiting for the requester to resume.'; return blocker(operation.server, operation.id, typed.action)?.message ?? null; }, options); } async function run(): Promise { await processCreations(); await processDestructions(); await refreshRestartRollout(); const operations = db().prepare(`SELECT id, server, action, state, stage FROM server_deployments WHERE server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild') AND (state = 'waiting' OR (action = 'stop' AND state = 'awaiting-health')) ORDER BY started_at, rowid`).all() as Operation[]; for (const operation of operations) { if (nativeDeployment(operation.id)) continue; try { await processOperation(operation); } catch (err) { if (provisioningCancelled(operation.id)) continue; // Observation failures are retried; remote mutations are never replayed. deploymentStage(operation.id, 'Waiting for verification: ' + (err as Error).message, { checks: [], blocker: { code: 'observation-unavailable', message: (err as Error).message, automatic: true } }); } } } async function processOperation(operation: Operation): Promise { const { id, server, action } = operation; if (provisioningCancelled(id)) return; // An explicit cleanup resume follows confirmed remote deletion. Complete // that durable checkpoint even if the incident now needs attention or the // Watchdog is paused; never strand the reserved replacement identity. if (action === 'rebuild' && operation.stage === REMOVING_SERVER) { await rebuildServer(id); return; } if (operation.state === 'waiting') { const heldBy: Source[] = []; const eligibility = sourceEligibility(operation, source => heldBy.push(source)); if (eligibility === 'cancelled') { cancelProvisioning(id, 'Requester cancelled or replaced this operation'); return; } if (eligibility === 'held') { const source = heldBy[0]; const href = source ? source.kind === 'campaign' ? `/campaigns/${encodeURIComponent(source.source_id)}` : source.kind === 'beta' ? `/betas/${encodeURIComponent(source.source_id)}` : source.kind === 'watchdog' ? `/maintenance/${encodeURIComponent(source.source_id)}` : `/maintenance?view=activity#job-${encodeURIComponent(source.source_id)}` : null; deploymentStage(id, 'Waiting for the requester to resume', { phase: 'eligibility', checks: [], blocker: { code: 'requester-held', message: 'Waiting for the requester to resume', automatic: false, href, label: 'Requesting work' } }); return; } } if (!getServer(server)) { finishDeployment(id, { ok: false, message: 'Server is no longer registered.' }); return; } const watchdog = sourcesFor(id).some(source => source.kind === 'watchdog'); if (!komodoConfigured() || (!rconConfigured() && !watchdog)) { deploymentStage(id, 'Waiting for Komodo and RCON configuration', { phase: 'eligibility', checks: [], blocker: { code: 'configuration', message: 'Waiting for Komodo and RCON configuration', automatic: false, href: '/settings', label: 'Integration settings' } }); return; } if (operation.state === 'awaiting-health') { const health = await komodoInspectHealthy(server); if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND state = 'awaiting-health'").get(id)) return; recordProgress(id, { phase: 'verify-stopped', checks: [{ key: 'stopped', label: 'Container stopped', source: 'komodo', result: isStopped(health.state) ? 'pass' : health.state ? 'waiting' : 'unknown', actual: health.state ?? null, message: health.ok ? null : health.reason }], blocker: isStopped(health.state) ? null : { code: 'stopped-state', message: 'Waiting for an explicit stopped container state.', automatic: true } }); if (isStopped(health.state)) { db().transaction(() => { db().prepare("UPDATE servers SET container_state = ?, last_players = NULL, last_roster = NULL, game_started_at = NULL, game_ended_at = NULL WHERE name = ?").run(health.state, server); deploymentStage(id, 'Verified container stopped', { checkpoint: { key: 'verify-stopped', label: 'Stopped container state verified' } }); finishDeployment(id, { ok: true }); })(); } else deploymentStage(id, 'Waiting for container to stop' + (!health.ok ? ': ' + health.reason : '')); return; } const reason = blocker(server, id, action); if (reason) { deploymentStage(id, reason.message, { phase: 'eligibility', checks: [], blocker: reason }); return; } const healthRecovery = watchdog && !!db().prepare(`SELECT 1 FROM provisioning_sources s JOIN watchdog_incidents i ON i.id = s.source_id JOIN server_deployments d ON d.id = s.operation_id WHERE s.operation_id = ? AND s.kind = 'watchdog' AND i.kind = 'health' AND d.triggered_by LIKE 'watchdog:%'`).get(id); const epoch = latestGameSubmission(server); const health = await komodoInspectHealthy(server); if (provisioningCancelled(id)) return; if (healthRecovery && epoch !== latestGameSubmission(server)) return; if (healthRecovery && !health.ok && !['stopped','exited','created','dead','unhealthy','starting','restarting'].includes(health.state?.toLowerCase() ?? '')) { deploymentStage(id, 'Waiting for current container health before automatic recovery', { phase: 'eligibility', checks: [], blocker: { code: 'observation-unavailable', message: health.reason, automatic: true } }); return; } if (action !== 'start' && !isStopped(health.state)) { const evidence = await probePlayerEvidence(server); if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND state = 'waiting'").get(id)) return; const players = evidence.players; if (healthRecovery && epoch !== latestGameSubmission(server)) return; if (healthRecovery && health.ok && players !== null) { // The fresh occupancy check also proves that the game recovered. // Empty is not a reason to execute an obsolete health restart. resolveRecoveredHealthOperation(id, players, evidence.source); return; } recordProgress(id, { phase: 'eligibility', checks: [{ key: 'occupancy', label: 'Player occupancy', result: players === 0 ? 'pass' : players === null ? 'unknown' : 'waiting', source: evidence.source, checkedAt: evidence.observedAt, receivedAt: evidence.receivedAt, expected: 0, actual: players }], blocker: players === 0 ? null : { code: players === null ? 'players-unknown' : 'players-present', message: players === null ? 'Waiting for a fresh player count' : `Waiting for ${players} player(s) to leave`, automatic: true } }); db().prepare('UPDATE server_deployments SET players = ?, checked_at = ? WHERE id = ?').run(players, new Date().toISOString(), id); if (players !== 0) { deploymentStage(id, players === null ? 'Waiting for a fresh player count' : `Waiting for ${players} player(s) to leave`); return; } } else if (action !== 'start') { if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND state = 'waiting'").get(id)) return; recordProgress(id, { phase: 'eligibility', blocker: null, checks: [{ key: 'stopped', label: 'Stopped container permits disruption', source: 'komodo', result: 'pass', actual: health.state ?? null }] }); } // Requests can arrive during the probes. Recheck before claiming the action. const latestReason = blocker(server, id, action); if (latestReason) { deploymentStage(id, latestReason.message, { phase: 'eligibility', blocker: latestReason }); return; } if (sourceEligibility(operation) !== 'ready') return; if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND state = 'waiting'").get(id)) return; if (action === 'rebuild') { await rebuildServer(id); return; } const claim = db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ? AND server_deleted_at IS NULL AND state = 'waiting'").run(id); if (!claim.changes) return; if (action === 'start' || action === 'restart') { const result = await restartInRollout(server, { operationId: id, action }); if (provisioningCancelled(id)) return; if (result.kind === 'queued') { db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id); deploymentStage(id, result.message); } return; } db().transaction(() => { db().prepare('UPDATE server_deployments SET submitted_at = ? WHERE id = ?').run(new Date().toISOString(), id); deploymentStage(id, 'Stopping container', { phase: 'container-action', blocker: null, checks: [], checkpoint: { key: 'eligibility', label: 'Eligibility checks passed' }, command: { state: 'attempted', message: 'Stop submission attempted.' } }); })(); // Persist intent first; a lost reply must not issue another stop later. try { const result = await komodoRuntimeAction(server, 'stop'); if (provisioningCancelled(id)) return; if (!result.ok) { recordProgress(id, { command: { state: result.kind === 'no-name' || result.kind === 'unconfigured' ? 'rejected' : 'uncertain', message: result.message } }); finishDeployment(id, { ok: false, message: result.message }); return; } db().transaction(() => { db().prepare("UPDATE server_deployments SET state = 'awaiting-health' WHERE id = ?").run(id); deploymentStage(id, 'Waiting for container to stop', { phase: 'verify-stopped', checkpoint: { key: 'container-action', label: 'Stop command accepted' }, command: { state: 'accepted', message: 'Komodo accepted the stop request; stopped state still requires verification.' } }); })(); } catch (err) { if (provisioningCancelled(id)) return; recordProgress(id, { command: { state: 'uncertain', message: (err as Error).message } }); finishDeployment(id, { ok: false, message: (err as Error).message }); } }