Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -4,10 +4,15 @@ import { maintenanceTick, plutoniumStatus } from './runtime.js';
|
||||
import type { MaintenanceOverview } from '../../maintenance.js';
|
||||
|
||||
export function maintenanceOverview(selectedJob: number | null = null): MaintenanceOverview {
|
||||
return { policies: maintenancePolicies(), recovery: watchdogOverview(), runtime: plutoniumStatus(selectedJob) };
|
||||
return {
|
||||
policies: maintenancePolicies(),
|
||||
recovery: watchdogOverview(),
|
||||
runtime: plutoniumStatus(selectedJob)
|
||||
};
|
||||
}
|
||||
export async function runMaintenance(): Promise<void> {
|
||||
// One failing observer must not prevent the other capabilities from reconciling.
|
||||
const results = await Promise.allSettled([maintenanceTick(), watchdogTick()]);
|
||||
for (const result of results) if (result.status === 'rejected') console.error('[maintenance]', result.reason);
|
||||
for (const result of results)
|
||||
if (result.status === 'rejected') console.error('[maintenance]', result.reason);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,31 @@ import { db } from '../db/index.js';
|
||||
import type { MaintenancePolicy, MaintenancePolicies } from '../../maintenance.js';
|
||||
|
||||
function setting(key: string): string | undefined {
|
||||
return (db().prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||
return (
|
||||
db().prepare('SELECT value FROM settings WHERE key = ?').get(key) as
|
||||
{ value: string } | undefined
|
||||
)?.value;
|
||||
}
|
||||
function save(key: string, value: boolean) {
|
||||
db().prepare('INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, value ? '1' : '0');
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value'
|
||||
)
|
||||
.run(key, value ? '1' : '0');
|
||||
}
|
||||
export function maintenancePolicies(): MaintenancePolicies {
|
||||
// Preserve the old recovery switch without opting anyone into runtime upgrades.
|
||||
const legacy = setting('watchdog_enabled') !== '0';
|
||||
return {
|
||||
paused: setting('maintenance_paused') === '1',
|
||||
health: setting('maintenance_health') === undefined ? legacy : setting('maintenance_health') === '1',
|
||||
drift: setting('maintenance_drift') === undefined ? legacy : setting('maintenance_drift') === '1',
|
||||
health:
|
||||
setting('maintenance_health') === undefined
|
||||
? legacy
|
||||
: setting('maintenance_health') === '1',
|
||||
drift:
|
||||
setting('maintenance_drift') === undefined
|
||||
? legacy
|
||||
: setting('maintenance_drift') === '1',
|
||||
updates: setting('maintenance_updates') === '1'
|
||||
};
|
||||
}
|
||||
@@ -21,5 +34,9 @@ export function maintenanceAllowed(policy?: MaintenancePolicy): boolean {
|
||||
const policies = maintenancePolicies();
|
||||
return !policies.paused && (!policy || policies[policy]);
|
||||
}
|
||||
export function setMaintenancePolicy(policy: MaintenancePolicy, enabled: boolean) { save(`maintenance_${policy}`, enabled); }
|
||||
export function pauseMaintenance(paused: boolean) { save('maintenance_paused', paused); }
|
||||
export function setMaintenancePolicy(policy: MaintenancePolicy, enabled: boolean) {
|
||||
save(`maintenance_${policy}`, enabled);
|
||||
}
|
||||
export function pauseMaintenance(paused: boolean) {
|
||||
save('maintenance_paused', paused);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,36 @@ import { db } from '../db/index.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
import { getServer, listServers } from '../fleet/servers.js';
|
||||
import { hasActiveProvisioning } from '../fleet/provisioning.js';
|
||||
import { inspectForMonitoring, rconForMonitoring, monitoringStatus, monitoringIdentity, MonitoringChangedError } from '../fleet/health-observations.js';
|
||||
import {
|
||||
inspectForMonitoring,
|
||||
rconForMonitoring,
|
||||
monitoringStatus,
|
||||
monitoringIdentity,
|
||||
MonitoringChangedError
|
||||
} from '../fleet/health-observations.js';
|
||||
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
||||
import { latestGameSubmission, telemetryEvidence, telemetryReport } from '../fleet/status-evidence.js';
|
||||
import {
|
||||
latestGameSubmission,
|
||||
telemetryEvidence,
|
||||
telemetryReport
|
||||
} from '../fleet/status-evidence.js';
|
||||
import { prepareDriftCampaign, createDriftRebuildCampaign, previewDrift } from '../fleet/drift.js';
|
||||
import { cancelProvisioning, requestWatchdogLifecycle, escalateWatchdogRestart } from '../provisioning/requests.js';
|
||||
import {
|
||||
cancelProvisioning,
|
||||
requestWatchdogLifecycle,
|
||||
escalateWatchdogRestart
|
||||
} from '../provisioning/requests.js';
|
||||
import { deploymentStage, finishDeployment } from '../provisioning/history.js';
|
||||
import { recordProgress } from '../provisioning/progress.js';
|
||||
import { withOperationSource } from '../hosts/operation-context.js';
|
||||
import type { WatchdogAction, WatchdogDetail, WatchdogEvidence, WatchdogIncident, WatchdogOverview, WatchdogState } from '../../watchdog.js';
|
||||
import type {
|
||||
WatchdogAction,
|
||||
WatchdogDetail,
|
||||
WatchdogEvidence,
|
||||
WatchdogIncident,
|
||||
WatchdogOverview,
|
||||
WatchdogState
|
||||
} from '../../watchdog.js';
|
||||
|
||||
import { maintenanceAllowed, setMaintenancePolicy } from './policies.js';
|
||||
|
||||
@@ -21,8 +42,18 @@ export const WATCHDOG_FAILURE_MS = 120_000;
|
||||
export const WATCHDOG_RECOVERY_MS = 300_000;
|
||||
const iso = () => new Date().toISOString();
|
||||
const pendingStates = ['queued', 'waiting', 'running', 'awaiting-health'];
|
||||
const setting = (key: string) => (db().prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined)?.value ?? null;
|
||||
function setSetting(key: string, value: string) { db().prepare('INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, value); }
|
||||
const setting = (key: string) =>
|
||||
(
|
||||
db().prepare('SELECT value FROM settings WHERE key = ?').get(key) as
|
||||
{ value: string } | undefined
|
||||
)?.value ?? null;
|
||||
function setSetting(key: string, value: string) {
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value'
|
||||
)
|
||||
.run(key, value);
|
||||
}
|
||||
export const watchdogEnabled = () => maintenanceAllowed('health') || maintenanceAllowed('drift');
|
||||
export function setWatchdogEnabled(enabled: boolean) {
|
||||
setSetting('watchdog_enabled', enabled ? '1' : '0');
|
||||
@@ -30,18 +61,37 @@ export function setWatchdogEnabled(enabled: boolean) {
|
||||
setMaintenancePolicy('drift', enabled);
|
||||
}
|
||||
|
||||
function incident(id: string) { return db().prepare('SELECT * FROM watchdog_incidents WHERE id = ?').get(id) as WatchdogIncident | undefined; }
|
||||
function active(server: string, kind: 'health' | 'drift') { return db().prepare("SELECT * FROM watchdog_incidents WHERE server = ? AND server_deleted_at IS NULL AND kind = ? AND state != 'resolved'").get(server, kind) as WatchdogIncident | undefined; }
|
||||
function event(id: string, message: string) { db().prepare('INSERT INTO watchdog_events(incident_id,created_at,message) VALUES (?,?,?)').run(id, iso(), message); }
|
||||
function incident(id: string) {
|
||||
return db().prepare('SELECT * FROM watchdog_incidents WHERE id = ?').get(id) as
|
||||
WatchdogIncident | undefined;
|
||||
}
|
||||
function active(server: string, kind: 'health' | 'drift') {
|
||||
return db()
|
||||
.prepare(
|
||||
"SELECT * FROM watchdog_incidents WHERE server = ? AND server_deleted_at IS NULL AND kind = ? AND state != 'resolved'"
|
||||
)
|
||||
.get(server, kind) as WatchdogIncident | undefined;
|
||||
}
|
||||
function event(id: string, message: string) {
|
||||
db()
|
||||
.prepare('INSERT INTO watchdog_events(incident_id,created_at,message) VALUES (?,?,?)')
|
||||
.run(id, iso(), message);
|
||||
}
|
||||
function transition(id: string, state: WatchdogState, summary: string) {
|
||||
const previous = incident(id);
|
||||
if (!previous || (previous.state === state && previous.summary === summary)) return;
|
||||
db().prepare('UPDATE watchdog_incidents SET state=?, summary=?, finished_at=? WHERE id=?')
|
||||
db()
|
||||
.prepare('UPDATE watchdog_incidents SET state=?, summary=?, finished_at=? WHERE id=?')
|
||||
.run(state, summary, state === 'resolved' || state === 'dismissed' ? iso() : null, id);
|
||||
event(id, summary);
|
||||
}
|
||||
function detect(server: string, kind: 'health' | 'drift', summary: string, details: WatchdogEvidence): WatchdogIncident {
|
||||
const sampledAt = kind === 'health' ? details.observedAt ?? iso() : iso();
|
||||
function detect(
|
||||
server: string,
|
||||
kind: 'health' | 'drift',
|
||||
summary: string,
|
||||
details: WatchdogEvidence
|
||||
): WatchdogIncident {
|
||||
const sampledAt = kind === 'health' ? (details.observedAt ?? iso()) : iso();
|
||||
const sampledTime = Date.parse(sampledAt);
|
||||
const previous = active(server, kind);
|
||||
if (previous) {
|
||||
@@ -49,40 +99,92 @@ function detect(server: string, kind: 'health' | 'drift', summary: string, detai
|
||||
const sampleGap = sampledTime - Date.parse(previous.last_seen_at);
|
||||
const actionable = (details as WatchdogEvidence & { actionable?: boolean }).actionable;
|
||||
const prior = JSON.parse(previous.details) as WatchdogEvidence & { actionable?: boolean };
|
||||
const newSample = sampleGap > 0
|
||||
&& Date.now() - Date.parse(prior.countedAt ?? previous.last_seen_at) >= WATCHDOG_SCAN_MS;
|
||||
const newSample =
|
||||
sampleGap > 0 &&
|
||||
Date.now() - Date.parse(prior.countedAt ?? previous.last_seen_at) >= WATCHDOG_SCAN_MS;
|
||||
const priorActionable = prior.actionable;
|
||||
// A successful routine/manual probe between watchdog samples breaks
|
||||
// the failure streak, even if our next probe happens to fail again.
|
||||
const after = latestGameSubmission(server);
|
||||
const successful = [getServer(server)?.last_seen_at, telemetryReport(server)?.receivedAt]
|
||||
.some(at => at && Date.parse(at) > Date.parse(previous.last_seen_at)
|
||||
&& Date.parse(at) <= sampledTime && (!after || Date.parse(at) > Date.parse(after)));
|
||||
const reset = kind === 'health' && (successful || (actionable === true && priorActionable !== true)
|
||||
|| prior.identity !== details.identity || sampleGap > WATCHDOG_SCAN_MS + ONLINE_MAX_MS);
|
||||
db().prepare('UPDATE watchdog_incidents SET details=?, last_seen_at=?, failures=?, first_seen_at=? WHERE id=?')
|
||||
.run(JSON.stringify({ ...details, countedAt: newSample || reset ? iso() : prior.countedAt ?? previous.last_seen_at }), newSample || reset ? sampledAt : previous.last_seen_at,
|
||||
reset ? 1 : previous.failures + (newSample ? 1 : 0), reset ? sampledAt : previous.first_seen_at, previous.id);
|
||||
const successful = [
|
||||
getServer(server)?.last_seen_at,
|
||||
telemetryReport(server)?.receivedAt
|
||||
].some(
|
||||
(at) =>
|
||||
at &&
|
||||
Date.parse(at) > Date.parse(previous.last_seen_at) &&
|
||||
Date.parse(at) <= sampledTime &&
|
||||
(!after || Date.parse(at) > Date.parse(after))
|
||||
);
|
||||
const reset =
|
||||
kind === 'health' &&
|
||||
(successful ||
|
||||
(actionable === true && priorActionable !== true) ||
|
||||
prior.identity !== details.identity ||
|
||||
sampleGap > WATCHDOG_SCAN_MS + ONLINE_MAX_MS);
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE watchdog_incidents SET details=?, last_seen_at=?, failures=?, first_seen_at=? WHERE id=?'
|
||||
)
|
||||
.run(
|
||||
JSON.stringify({
|
||||
...details,
|
||||
countedAt:
|
||||
newSample || reset ? iso() : (prior.countedAt ?? previous.last_seen_at)
|
||||
}),
|
||||
newSample || reset ? sampledAt : previous.last_seen_at,
|
||||
reset ? 1 : previous.failures + (newSample ? 1 : 0),
|
||||
reset ? sampledAt : previous.first_seen_at,
|
||||
previous.id
|
||||
);
|
||||
if (previous.state === 'open') transition(previous.id, 'open', summary);
|
||||
return incident(previous.id)!;
|
||||
}
|
||||
const id = randomUUID();
|
||||
db().prepare(`INSERT INTO watchdog_incidents(id,server,kind,state,summary,details,first_seen_at,last_seen_at)
|
||||
VALUES (?,?,?,'open',?,?,?,?)`).run(id, server, kind, summary, JSON.stringify({ ...details, countedAt: iso() }), sampledAt, sampledAt);
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO watchdog_incidents(id,server,kind,state,summary,details,first_seen_at,last_seen_at)
|
||||
VALUES (?,?,?,'open',?,?,?,?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
server,
|
||||
kind,
|
||||
summary,
|
||||
JSON.stringify({ ...details, countedAt: iso() }),
|
||||
sampledAt,
|
||||
sampledAt
|
||||
);
|
||||
event(id, summary);
|
||||
return incident(id)!;
|
||||
}
|
||||
|
||||
/** Call only after verifying container readiness and current game evidence. */
|
||||
export function resolveRecoveredHealthOperation(operationId: string, players: number, source: 'rcon' | 'telemetry' | null): boolean {
|
||||
export function resolveRecoveredHealthOperation(
|
||||
operationId: string,
|
||||
players: number,
|
||||
source: 'rcon' | 'telemetry' | null
|
||||
): boolean {
|
||||
return db().transaction(() => {
|
||||
const operation = db().prepare(`SELECT d.server, d.action, d.state, d.submitted_at, d.triggered_by, i.id AS incident_id
|
||||
const operation = db()
|
||||
.prepare(
|
||||
`SELECT d.server, d.action, d.state, d.submitted_at, d.triggered_by, i.id AS incident_id
|
||||
FROM server_deployments d JOIN provisioning_sources s ON s.operation_id = d.id AND s.kind = 'watchdog'
|
||||
JOIN watchdog_incidents i ON i.id = s.source_id AND i.server = d.server
|
||||
WHERE d.id = ? AND d.server_deleted_at IS NULL AND i.server_deleted_at IS NULL
|
||||
AND i.kind = 'health' AND i.state IN ('open','recovering')
|
||||
AND NOT EXISTS (SELECT 1 FROM provisioning_sources other WHERE other.operation_id = d.id AND other.kind != 'watchdog')`)
|
||||
.get(operationId) as { server: string; action: string; state: string; submitted_at: string | null; triggered_by: string; incident_id: string } | undefined;
|
||||
AND NOT EXISTS (SELECT 1 FROM provisioning_sources other WHERE other.operation_id = d.id AND other.kind != 'watchdog')`
|
||||
)
|
||||
.get(operationId) as
|
||||
| {
|
||||
server: string;
|
||||
action: string;
|
||||
state: string;
|
||||
submitted_at: string | null;
|
||||
triggered_by: string;
|
||||
incident_id: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!operation || getServer(operation.server)?.desired_state !== 'running') return false;
|
||||
const waiting = operation.state === 'waiting' && operation.submitted_at === null;
|
||||
if (waiting && !operation.triggered_by.startsWith('watchdog:')) return false;
|
||||
@@ -90,17 +192,48 @@ export function resolveRecoveredHealthOperation(operationId: string, players: nu
|
||||
// exists, and lets that work proceed. Submitted recovery must retain
|
||||
// exclusive ownership before accepting evidence or releasing its slot.
|
||||
if (!waiting && hasActiveProvisioning(operation.server, operationId)) return false;
|
||||
if (!waiting && (operation.action !== 'restart' || !['awaiting-health','failed'].includes(operation.state))) return false;
|
||||
const message = waiting ? 'Server recovered before automatic recovery was submitted' : 'Fresh container and game health verified after restart';
|
||||
if (
|
||||
!waiting &&
|
||||
(operation.action !== 'restart' ||
|
||||
!['awaiting-health', 'failed'].includes(operation.state))
|
||||
)
|
||||
return false;
|
||||
const message = waiting
|
||||
? 'Server recovered before automatic recovery was submitted'
|
||||
: 'Fresh container and game health verified after restart';
|
||||
if (waiting && !cancelProvisioning(operationId, message)) return false;
|
||||
const now = iso();
|
||||
db().prepare(`UPDATE restart_rollout SET checked_at = ?, healthy_at = ?, players = ?, error = NULL
|
||||
WHERE server = ? AND operation_id = ? AND healthy_at IS NULL`).run(now, now, players, operation.server, operationId);
|
||||
db().prepare('UPDATE server_deployments SET checked_at = ?, players = ? WHERE id = ?').run(now, players, operationId);
|
||||
recordProgress(operationId, { blocker: null, observedAt: now,
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE restart_rollout SET checked_at = ?, healthy_at = ?, players = ?, error = NULL
|
||||
WHERE server = ? AND operation_id = ? AND healthy_at IS NULL`
|
||||
)
|
||||
.run(now, now, players, operation.server, operationId);
|
||||
db()
|
||||
.prepare('UPDATE server_deployments SET checked_at = ?, players = ? WHERE id = ?')
|
||||
.run(now, players, operationId);
|
||||
recordProgress(operationId, {
|
||||
blocker: null,
|
||||
observedAt: now,
|
||||
recovery: { state: 'released', at: now, operationId, message },
|
||||
checks: [{ key: 'container', label: 'Container readiness', source: 'agent', result: 'pass', checkedAt: now },
|
||||
{ key: 'game', label: 'Game status', source, result: 'pass', actual: players, checkedAt: now }] });
|
||||
checks: [
|
||||
{
|
||||
key: 'container',
|
||||
label: 'Container readiness',
|
||||
source: 'agent',
|
||||
result: 'pass',
|
||||
checkedAt: now
|
||||
},
|
||||
{
|
||||
key: 'game',
|
||||
label: 'Game status',
|
||||
source,
|
||||
result: 'pass',
|
||||
actual: players,
|
||||
checkedAt: now
|
||||
}
|
||||
]
|
||||
});
|
||||
if (operation.state === 'awaiting-health') {
|
||||
deploymentStage(operationId, message);
|
||||
finishDeployment(operationId, { ok: true });
|
||||
@@ -110,112 +243,278 @@ export function resolveRecoveredHealthOperation(operationId: string, players: nu
|
||||
})();
|
||||
}
|
||||
function actions(id: string): WatchdogAction[] {
|
||||
return db().prepare(`SELECT a.*, d.state AS operation_state, d.stage AS operation_stage, d.error AS operation_error
|
||||
return db()
|
||||
.prepare(
|
||||
`SELECT a.*, d.state AS operation_state, d.stage AS operation_stage, d.error AS operation_error
|
||||
FROM watchdog_actions a LEFT JOIN server_deployments d ON d.id=a.operation_id
|
||||
WHERE a.incident_id=? ORDER BY a.rowid`).all(id) as WatchdogAction[];
|
||||
WHERE a.incident_id=? ORDER BY a.rowid`
|
||||
)
|
||||
.all(id) as WatchdogAction[];
|
||||
}
|
||||
export function watchdogOverview(): WatchdogOverview {
|
||||
return { enabled: watchdogEnabled(), lastScanAt: setting('watchdog_last_scan'), scanError: setting('watchdog_scan_error') || null,
|
||||
incidents: db().prepare(`SELECT i.*, (SELECT d.stage FROM watchdog_actions a
|
||||
return {
|
||||
enabled: watchdogEnabled(),
|
||||
lastScanAt: setting('watchdog_last_scan'),
|
||||
scanError: setting('watchdog_scan_error') || null,
|
||||
incidents: db()
|
||||
.prepare(
|
||||
`SELECT i.*, (SELECT d.stage FROM watchdog_actions a
|
||||
JOIN server_deployments d ON d.id = a.operation_id WHERE a.incident_id = i.id AND a.cycle = i.cycle
|
||||
ORDER BY a.rowid DESC LIMIT 1) AS operation_stage
|
||||
FROM watchdog_incidents i WHERE state != 'resolved' OR id IN (
|
||||
SELECT id FROM watchdog_incidents WHERE state = 'resolved' ORDER BY first_seen_at DESC, rowid DESC LIMIT 100)
|
||||
ORDER BY CASE WHEN state='resolved' THEN 1 ELSE 0 END, first_seen_at DESC, rowid DESC`).all() as WatchdogIncident[] };
|
||||
ORDER BY CASE WHEN state='resolved' THEN 1 ELSE 0 END, first_seen_at DESC, rowid DESC`
|
||||
)
|
||||
.all() as WatchdogIncident[]
|
||||
};
|
||||
}
|
||||
export function watchdogDetail(id: string): WatchdogDetail | null {
|
||||
const row = incident(id);
|
||||
return row ? { incident: row, actions: actions(id), events: db().prepare('SELECT id,created_at,message FROM watchdog_events WHERE incident_id=? ORDER BY id').all(id) as WatchdogDetail['events'] } : null;
|
||||
return row
|
||||
? {
|
||||
incident: row,
|
||||
actions: actions(id),
|
||||
events: db()
|
||||
.prepare(
|
||||
'SELECT id,created_at,message FROM watchdog_events WHERE incident_id=? ORDER BY id'
|
||||
)
|
||||
.all(id) as WatchdogDetail['events']
|
||||
}
|
||||
: null;
|
||||
}
|
||||
function scanSuppressed(server: string, kind?: 'health' | 'drift'): boolean {
|
||||
// An explicit retry can reclaim its own earlier attempt's recovery slot.
|
||||
// Keep other findings and every active lifecycle operation suppressed.
|
||||
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 AND d.server_deleted_at IS NULL
|
||||
JOIN watchdog_actions a ON a.operation_id = d.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 i.server = r.server AND i.server_deleted_at IS NULL AND i.state = 'open' AND a.cycle < i.cycle
|
||||
AND (? IS NULL OR i.kind = ?) LIMIT 1`).get(server, kind ?? null, kind ?? null) as { operation_id: string } | undefined;
|
||||
AND (? IS NULL OR i.kind = ?) LIMIT 1`
|
||||
)
|
||||
.get(server, kind ?? null, kind ?? null) as { operation_id: string } | undefined;
|
||||
return hasActiveProvisioning(server, retained?.operation_id);
|
||||
}
|
||||
function protectedWork(server: string): boolean {
|
||||
return !!db().prepare(`SELECT 1 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)
|
||||
|| !!db().prepare(`SELECT 1 FROM deployment_servers ds WHERE ds.server=? AND ds.server_deleted_at IS NULL AND ds.restart_state IN ('pending','held','failed','manual')
|
||||
AND NOT EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.campaign_id=ds.deployment_id) LIMIT 1`).get(server);
|
||||
return (
|
||||
!!db()
|
||||
.prepare(
|
||||
`SELECT 1 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) ||
|
||||
!!db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM deployment_servers ds WHERE ds.server=? AND ds.server_deleted_at IS NULL AND ds.restart_state IN ('pending','held','failed','manual')
|
||||
AND NOT EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.campaign_id=ds.deployment_id) LIMIT 1`
|
||||
)
|
||||
.get(server)
|
||||
);
|
||||
}
|
||||
function addAction(row: WatchdogIncident, operationId: string, action: 'restart' | 'rebuild', campaignId: string | null = null) {
|
||||
db().prepare('INSERT INTO watchdog_actions(id,incident_id,action,operation_id,campaign_id,created_at,cycle) VALUES (?,?,?,?,?,?,?)')
|
||||
function addAction(
|
||||
row: WatchdogIncident,
|
||||
operationId: string,
|
||||
action: 'restart' | 'rebuild',
|
||||
campaignId: string | null = null
|
||||
) {
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO watchdog_actions(id,incident_id,action,operation_id,campaign_id,created_at,cycle) VALUES (?,?,?,?,?,?,?)'
|
||||
)
|
||||
.run(randomUUID(), row.id, action, operationId, campaignId, iso(), row.cycle);
|
||||
transition(row.id, 'recovering', action === 'restart' ? 'Restart queued; waiting for safe recovery' : 'Rebuild queued with the latest promoted setup');
|
||||
event(row.id, `${action === 'restart' ? 'Restart' : 'Rebuild'} requested: ${operationId}${campaignId ? `; campaign ${campaignId}` : ''}`);
|
||||
transition(
|
||||
row.id,
|
||||
'recovering',
|
||||
action === 'restart'
|
||||
? 'Restart queued; waiting for safe recovery'
|
||||
: 'Rebuild queued with the latest promoted setup'
|
||||
);
|
||||
event(
|
||||
row.id,
|
||||
`${action === 'restart' ? 'Restart' : 'Rebuild'} requested: ${operationId}${campaignId ? `; campaign ${campaignId}` : ''}`
|
||||
);
|
||||
}
|
||||
export function updateWatchdogIncident(id: string, action: 'retry' | 'dismiss') {
|
||||
return db().transaction(() => {
|
||||
const row = incident(id);
|
||||
if (!row) return { ok: false as const, status: 404, error: 'Watchdog incident not found.' };
|
||||
if (row.server_deleted_at) return { ok: false as const, status: 409, error: 'This server was deleted; its recovery history cannot be changed.' };
|
||||
if (actions(id).some(a => pendingStates.includes(a.operation_state ?? ''))) return { ok: false as const, status: 409, error: 'Provisioning is still active. Complete or cancel that operation first.' };
|
||||
if (row.server_deleted_at)
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'This server was deleted; its recovery history cannot be changed.'
|
||||
};
|
||||
if (actions(id).some((a) => pendingStates.includes(a.operation_state ?? '')))
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'Provisioning is still active. Complete or cancel that operation first.'
|
||||
};
|
||||
if (action === 'dismiss') {
|
||||
if (!['open','attention'].includes(row.state)) return { ok: false as const, status: 409, error: 'Only detected incidents or incidents needing attention can be dismissed.' };
|
||||
transition(id, 'dismissed', 'Dismissed by operator; automatic attempts remain suppressed until the condition clears');
|
||||
if (!['open', 'attention'].includes(row.state))
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'Only detected incidents or incidents needing attention can be dismissed.'
|
||||
};
|
||||
transition(
|
||||
id,
|
||||
'dismissed',
|
||||
'Dismissed by operator; automatic attempts remain suppressed until the condition clears'
|
||||
);
|
||||
} else {
|
||||
if (!['attention','dismissed'].includes(row.state)) return { ok: false as const, status: 409, error: 'Only incidents needing attention or dismissed incidents can be retried.' };
|
||||
if (!getServer(row.server)) return { ok: false as const, status: 409, error: 'The server is no longer registered; inspect its provisioning history.' };
|
||||
db().prepare('UPDATE watchdog_incidents SET cycle=cycle+1, failures=1, first_seen_at=?, last_seen_at=? WHERE id=?').run(iso(), iso(), id);
|
||||
transition(id, 'open', 'Operator requested a fresh recovery attempt; checking current conditions');
|
||||
if (!['attention', 'dismissed'].includes(row.state))
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'Only incidents needing attention or dismissed incidents can be retried.'
|
||||
};
|
||||
if (!getServer(row.server))
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 409,
|
||||
error: 'The server is no longer registered; inspect its provisioning history.'
|
||||
};
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE watchdog_incidents SET cycle=cycle+1, failures=1, first_seen_at=?, last_seen_at=? WHERE id=?'
|
||||
)
|
||||
.run(iso(), iso(), id);
|
||||
transition(
|
||||
id,
|
||||
'open',
|
||||
'Operator requested a fresh recovery attempt; checking current conditions'
|
||||
);
|
||||
}
|
||||
return { ok: true as const };
|
||||
})();
|
||||
}
|
||||
|
||||
async function reconcileActions(): Promise<void> {
|
||||
const rows = db().prepare("SELECT * FROM watchdog_incidents WHERE server_deleted_at IS NULL AND state IN ('recovering','attention')").all() as WatchdogIncident[];
|
||||
const rows = db()
|
||||
.prepare(
|
||||
"SELECT * FROM watchdog_incidents WHERE server_deleted_at IS NULL AND state IN ('recovering','attention')"
|
||||
)
|
||||
.all() as WatchdogIncident[];
|
||||
for (const row of rows) {
|
||||
const action = actions(row.id).filter(a => a.cycle === row.cycle).at(-1);
|
||||
const action = actions(row.id)
|
||||
.filter((a) => a.cycle === row.cycle)
|
||||
.at(-1);
|
||||
if (!action) continue;
|
||||
// Continue observing our own recovery, but do not verify or escalate
|
||||
// against files and health owned by another lifecycle operation.
|
||||
if (hasActiveProvisioning(row.server, action.operation_id)) continue;
|
||||
const operation = db().prepare('SELECT state,submitted_at,started_at,stage,error FROM server_deployments WHERE id=?').get(action.operation_id) as
|
||||
{ state: string; submitted_at: string | null; started_at: string; stage: string; error: string | null } | undefined;
|
||||
if (!operation) { transition(row.id, 'attention', 'Provisioning history is missing; inspect the server before retrying'); continue; }
|
||||
if (['done','warning'].includes(operation.state)) {
|
||||
const operation = db()
|
||||
.prepare(
|
||||
'SELECT state,submitted_at,started_at,stage,error FROM server_deployments WHERE id=?'
|
||||
)
|
||||
.get(action.operation_id) as
|
||||
| {
|
||||
state: string;
|
||||
submitted_at: string | null;
|
||||
started_at: string;
|
||||
stage: string;
|
||||
error: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!operation) {
|
||||
transition(
|
||||
row.id,
|
||||
'attention',
|
||||
'Provisioning history is missing; inspect the server before retrying'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (['done', 'warning'].includes(operation.state)) {
|
||||
if (row.kind === 'drift') {
|
||||
try {
|
||||
const plan = await previewDrift([row.server]);
|
||||
if (incident(row.id)?.cycle !== row.cycle || hasActiveProvisioning(row.server, action.operation_id)) continue;
|
||||
if (plan.files.length) { transition(row.id, 'attention', 'Rebuild completed but drift remains; inspect the campaign and server files'); continue; }
|
||||
if (
|
||||
incident(row.id)?.cycle !== row.cycle ||
|
||||
hasActiveProvisioning(row.server, action.operation_id)
|
||||
)
|
||||
continue;
|
||||
if (plan.files.length) {
|
||||
transition(
|
||||
row.id,
|
||||
'attention',
|
||||
'Rebuild completed but drift remains; inspect the campaign and server files'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!hasActiveProvisioning(row.server, action.operation_id))
|
||||
transition(row.id, 'attention', `Cannot verify rebuilt files: ${(err as Error).message}`);
|
||||
transition(
|
||||
row.id,
|
||||
'attention',
|
||||
`Cannot verify rebuilt files: ${(err as Error).message}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
transition(row.id, 'resolved', action.action === 'restart' ? 'Server recovered after restart' : 'Server recovered after rebuild');
|
||||
transition(
|
||||
row.id,
|
||||
'resolved',
|
||||
action.action === 'restart'
|
||||
? 'Server recovered after restart'
|
||||
: 'Server recovered after rebuild'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (row.state === 'attention') continue; // Exhausted attempts stay latched, including across app restarts.
|
||||
if (['cancelled','interrupted'].includes(operation.state)) {
|
||||
transition(row.id, 'attention', `Provisioning ${operation.state}; inspect the recorded operation before retrying`); continue;
|
||||
}
|
||||
if (action.action === 'rebuild') {
|
||||
if (operation.state === 'failed') transition(row.id, 'attention', `Rebuild failed: ${operation.error ?? operation.stage}`);
|
||||
else if (operation.state === 'awaiting-health' && Date.now() - Date.parse(operation.submitted_at ?? operation.started_at) >= WATCHDOG_RECOVERY_MS)
|
||||
transition(row.id, 'attention', 'Rebuild has not recovered within five minutes; provisioning continues verification');
|
||||
if (['cancelled', 'interrupted'].includes(operation.state)) {
|
||||
transition(
|
||||
row.id,
|
||||
'attention',
|
||||
`Provisioning ${operation.state}; inspect the recorded operation before retrying`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!['awaiting-health','failed'].includes(operation.state)) continue;
|
||||
if (Date.now() - Date.parse(operation.submitted_at ?? operation.started_at) < WATCHDOG_RECOVERY_MS) continue;
|
||||
if (action.action === 'rebuild') {
|
||||
if (operation.state === 'failed')
|
||||
transition(
|
||||
row.id,
|
||||
'attention',
|
||||
`Rebuild failed: ${operation.error ?? operation.stage}`
|
||||
);
|
||||
else if (
|
||||
operation.state === 'awaiting-health' &&
|
||||
Date.now() - Date.parse(operation.submitted_at ?? operation.started_at) >=
|
||||
WATCHDOG_RECOVERY_MS
|
||||
)
|
||||
transition(
|
||||
row.id,
|
||||
'attention',
|
||||
'Rebuild has not recovered within five minutes; provisioning continues verification'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!['awaiting-health', 'failed'].includes(operation.state)) continue;
|
||||
if (
|
||||
Date.now() - Date.parse(operation.submitted_at ?? operation.started_at) <
|
||||
WATCHDOG_RECOVERY_MS
|
||||
)
|
||||
continue;
|
||||
if (!maintenanceAllowed(row.kind)) continue;
|
||||
const current = getServer(row.server);
|
||||
if (!current || current.desired_state === 'stopped' || protectedWork(row.server)) continue;
|
||||
// A recovery deadline is a reason to check again, not evidence that
|
||||
// rebuilding is necessary. The worker may not have polled this tick yet.
|
||||
const observation = await observe(row.server, action.operation_id);
|
||||
if (!observation || incident(row.id)?.cycle !== row.cycle || hasActiveProvisioning(row.server, action.operation_id)) continue;
|
||||
if (
|
||||
!observation ||
|
||||
incident(row.id)?.cycle !== row.cycle ||
|
||||
hasActiveProvisioning(row.server, action.operation_id)
|
||||
)
|
||||
continue;
|
||||
if (observation.healthy) {
|
||||
resolveRecoveredHealthOperation(action.operation_id, observation.players!, observation.source ?? null);
|
||||
resolveRecoveredHealthOperation(
|
||||
action.operation_id,
|
||||
observation.players!,
|
||||
observation.source ?? null
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!observation.actionable) continue;
|
||||
@@ -224,45 +523,110 @@ async function reconcileActions(): Promise<void> {
|
||||
if (next.ok) addAction(row, next.deploymentId, 'rebuild');
|
||||
return next;
|
||||
})();
|
||||
if (!result.ok && result.status !== 409 && result.status !== 503) transition(row.id, 'attention', `Could not queue rebuild: ${result.error}`);
|
||||
if (!result.ok && result.status !== 409 && result.status !== 503)
|
||||
transition(row.id, 'attention', `Could not queue rebuild: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface Observation { healthy: boolean; actionable: boolean; reason: string; containerState: string | null; observedAt: string; identity: string; players?: number; source?: 'rcon' | 'telemetry' }
|
||||
async function observe(server: string, operationId?: string, fresh = false): Promise<Observation | null> {
|
||||
interface Observation {
|
||||
healthy: boolean;
|
||||
actionable: boolean;
|
||||
reason: string;
|
||||
containerState: string | null;
|
||||
observedAt: string;
|
||||
identity: string;
|
||||
players?: number;
|
||||
source?: 'rcon' | 'telemetry';
|
||||
}
|
||||
async function observe(
|
||||
server: string,
|
||||
operationId?: string,
|
||||
fresh = false
|
||||
): Promise<Observation | null> {
|
||||
const epoch = latestGameSubmission(server);
|
||||
const suppressed = () => (operationId ? hasActiveProvisioning(server, operationId) : scanSuppressed(server))
|
||||
|| protectedWork(server) || getServer(server)?.desired_state !== 'running' || epoch !== latestGameSubmission(server);
|
||||
const suppressed = () =>
|
||||
(operationId ? hasActiveProvisioning(server, operationId) : scanSuppressed(server)) ||
|
||||
protectedWork(server) ||
|
||||
getServer(server)?.desired_state !== 'running' ||
|
||||
epoch !== latestGameSubmission(server);
|
||||
// Routine watchdog passes only consume the status monitor. Manual checks and
|
||||
// recovery verification still obtain fresh, independently verified evidence.
|
||||
const snapshot = !fresh && !operationId ? monitoringStatus(server) : null;
|
||||
if (!fresh && !operationId && !snapshot) return null;
|
||||
const inspected = snapshot?.container ?? await inspectForMonitoring(server, true).catch(error => {
|
||||
if (error instanceof MonitoringChangedError) return null;
|
||||
throw error;
|
||||
});
|
||||
const inspected =
|
||||
snapshot?.container ??
|
||||
(await inspectForMonitoring(server, true).catch((error) => {
|
||||
if (error instanceof MonitoringChangedError) return null;
|
||||
throw error;
|
||||
}));
|
||||
if (!inspected) return null;
|
||||
const health = inspected.result;
|
||||
if (suppressed()) return null;
|
||||
const identity = snapshot?.identity ?? monitoringIdentity(server);
|
||||
const containerEvidence = { identity, observedAt: inspected.observedAt };
|
||||
const state = health.state?.toLowerCase() ?? null;
|
||||
if (!health.ok) return { ...containerEvidence, healthy: false, actionable: !!state && ['missing','stopped','exited','created','dead','unhealthy','restarting','starting'].includes(state), reason: health.reason, containerState: state };
|
||||
if (!health.ok)
|
||||
return {
|
||||
...containerEvidence,
|
||||
healthy: false,
|
||||
actionable:
|
||||
!!state &&
|
||||
[
|
||||
'missing',
|
||||
'stopped',
|
||||
'exited',
|
||||
'created',
|
||||
'dead',
|
||||
'unhealthy',
|
||||
'restarting',
|
||||
'starting'
|
||||
].includes(state),
|
||||
reason: health.reason,
|
||||
containerState: state
|
||||
};
|
||||
const telemetry = telemetryEvidence(server);
|
||||
if (telemetry) return { identity, observedAt: telemetry.receivedAt, healthy: true, actionable: false, reason: 'Container healthy with fresh telemetry', containerState: state, players: telemetry.players, source: 'telemetry' };
|
||||
const probed = snapshot ? snapshot.game : await rconForMonitoring(server, true).catch(error => {
|
||||
if (error instanceof MonitoringChangedError) return null;
|
||||
throw error;
|
||||
});
|
||||
if (telemetry)
|
||||
return {
|
||||
identity,
|
||||
observedAt: telemetry.receivedAt,
|
||||
healthy: true,
|
||||
actionable: false,
|
||||
reason: 'Container healthy with fresh telemetry',
|
||||
containerState: state,
|
||||
players: telemetry.players,
|
||||
source: 'telemetry'
|
||||
};
|
||||
const probed = snapshot
|
||||
? snapshot.game
|
||||
: await rconForMonitoring(server, true).catch((error) => {
|
||||
if (error instanceof MonitoringChangedError) return null;
|
||||
throw error;
|
||||
});
|
||||
if (!probed) return null;
|
||||
const rcon = probed.result;
|
||||
if (suppressed()) return null;
|
||||
const latest = telemetryEvidence(server);
|
||||
const gameEvidence = { identity: snapshot?.identity ?? monitoringIdentity(server), observedAt: latest?.receivedAt ?? probed.observedAt };
|
||||
if (rcon.ok || latest) return { ...gameEvidence, healthy: true, actionable: false, reason: 'Container healthy with fresh game status', containerState: state,
|
||||
players: latest?.players ?? (rcon.ok ? rcon.status.players : 0), source: latest ? 'telemetry' : 'rcon' };
|
||||
return { ...gameEvidence, healthy: false, actionable: rcon.reason === 'timeout' || rcon.reason === 'send-error',
|
||||
reason: `Game status unavailable: ${rcon.reason}; waiting for player-count verification before recovery`, containerState: state };
|
||||
const gameEvidence = {
|
||||
identity: snapshot?.identity ?? monitoringIdentity(server),
|
||||
observedAt: latest?.receivedAt ?? probed.observedAt
|
||||
};
|
||||
if (rcon.ok || latest)
|
||||
return {
|
||||
...gameEvidence,
|
||||
healthy: true,
|
||||
actionable: false,
|
||||
reason: 'Container healthy with fresh game status',
|
||||
containerState: state,
|
||||
players: latest?.players ?? (rcon.ok ? rcon.status.players : 0),
|
||||
source: latest ? 'telemetry' : 'rcon'
|
||||
};
|
||||
return {
|
||||
...gameEvidence,
|
||||
healthy: false,
|
||||
actionable: rcon.reason === 'timeout' || rcon.reason === 'send-error',
|
||||
reason: `Game status unavailable: ${rcon.reason}; waiting for player-count verification before recovery`,
|
||||
containerState: state
|
||||
};
|
||||
}
|
||||
async function scanServer(server: string, forceScan: boolean): Promise<void> {
|
||||
const row = getServer(server);
|
||||
@@ -270,16 +634,31 @@ async function scanServer(server: string, forceScan: boolean): Promise<void> {
|
||||
if (scanSuppressed(server) || protectedWork(server)) return;
|
||||
const observation = await observe(server, undefined, forceScan);
|
||||
// Probes can overlap an API request; recheck ownership before queuing anything.
|
||||
if (!observation || scanSuppressed(server) || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return;
|
||||
if (
|
||||
!observation ||
|
||||
scanSuppressed(server) ||
|
||||
protectedWork(server) ||
|
||||
getServer(server)?.desired_state === 'stopped'
|
||||
)
|
||||
return;
|
||||
if (observation.identity !== monitoringIdentity(server)) return;
|
||||
if (!scanSuppressed(server, 'health') && observation.healthy) {
|
||||
const current = active(server, 'health');
|
||||
if (current) transition(current.id, 'resolved', 'Fresh game status and host agent health verified');
|
||||
if (current)
|
||||
transition(current.id, 'resolved', 'Fresh game status and host agent health verified');
|
||||
} else if (!scanSuppressed(server, 'health')) {
|
||||
const problem = detect(server, 'health', observation.reason, observation);
|
||||
const startup = ['starting','restarting'].includes(observation.containerState ?? '');
|
||||
const oldEnough = Date.parse(observation.observedAt) - Date.parse(problem.first_seen_at) >= (startup ? WATCHDOG_RECOVERY_MS : WATCHDOG_FAILURE_MS);
|
||||
if (problem.state === 'open' && observation.actionable && oldEnough && problem.failures >= 3 && maintenanceAllowed('health')) {
|
||||
const startup = ['starting', 'restarting'].includes(observation.containerState ?? '');
|
||||
const oldEnough =
|
||||
Date.parse(observation.observedAt) - Date.parse(problem.first_seen_at) >=
|
||||
(startup ? WATCHDOG_RECOVERY_MS : WATCHDOG_FAILURE_MS);
|
||||
if (
|
||||
problem.state === 'open' &&
|
||||
observation.actionable &&
|
||||
oldEnough &&
|
||||
problem.failures >= 3 &&
|
||||
maintenanceAllowed('health')
|
||||
) {
|
||||
// A missing container has nothing to restart. Recreate it through
|
||||
// the normal rebuild queue, with its identity and safety checks.
|
||||
const action = observation.containerState === 'missing' ? 'rebuild' : 'restart';
|
||||
@@ -288,49 +667,85 @@ async function scanServer(server: string, forceScan: boolean): Promise<void> {
|
||||
if (next.ok) addAction(problem, next.deploymentId, action);
|
||||
return next;
|
||||
})();
|
||||
if (!result.ok && result.status !== 409 && result.status !== 503) transition(problem.id, 'attention', result.error);
|
||||
if (!result.ok && result.status !== 409 && result.status !== 503)
|
||||
transition(problem.id, 'attention', result.error);
|
||||
}
|
||||
}
|
||||
// Missing-container recovery belongs to the health policy. Do not turn
|
||||
// absent files into drift or bypass the sustained health-failure window.
|
||||
// Unknown container health is an observation failure, not proof that a rebuild is safe.
|
||||
if (observation.containerState === 'missing' || (!observation.healthy && !observation.actionable) || scanSuppressed(server, 'drift')) return;
|
||||
if (
|
||||
observation.containerState === 'missing' ||
|
||||
(!observation.healthy && !observation.actionable) ||
|
||||
scanSuppressed(server, 'drift')
|
||||
)
|
||||
return;
|
||||
// File trees change far less often than liveness. Persist the last successful
|
||||
// scan per registration/process so health checks do not rescan every minute.
|
||||
// Manual checks and new lifecycle submissions always get fresh file evidence.
|
||||
const driftKey = `watchdog_drift_scan:${server}`;
|
||||
const instance = nativeInstance(server);
|
||||
const identity = JSON.stringify([row.created_at, instance?.id, instance?.generation, latestGameSubmission(server)]);
|
||||
const identity = JSON.stringify([
|
||||
row.created_at,
|
||||
instance?.id,
|
||||
instance?.generation,
|
||||
latestGameSubmission(server)
|
||||
]);
|
||||
let previous: { identity?: string; at?: string } = {};
|
||||
try { previous = JSON.parse(setting(driftKey) ?? '{}'); } catch { /* rescan legacy/invalid state */ }
|
||||
try {
|
||||
previous = JSON.parse(setting(driftKey) ?? '{}');
|
||||
} catch {
|
||||
/* rescan legacy/invalid state */
|
||||
}
|
||||
const age = Date.now() - Date.parse(previous?.at ?? '');
|
||||
if (!forceScan && previous?.identity === identity && age >= 0 && age < WATCHDOG_DRIFT_SCAN_MS) return;
|
||||
if (!forceScan && previous?.identity === identity && age >= 0 && age < WATCHDOG_DRIFT_SCAN_MS)
|
||||
return;
|
||||
const prepared = await prepareDriftCampaign(server);
|
||||
if (scanSuppressed(server, 'drift') || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return;
|
||||
if (
|
||||
scanSuppressed(server, 'drift') ||
|
||||
protectedWork(server) ||
|
||||
getServer(server)?.desired_state === 'stopped'
|
||||
)
|
||||
return;
|
||||
setSetting(driftKey, JSON.stringify({ identity, at: iso() }));
|
||||
if (!prepared.plan.files.length) {
|
||||
const current = active(server, 'drift');
|
||||
if (current) transition(current.id, 'resolved', 'Server files match the promoted setup');
|
||||
return;
|
||||
}
|
||||
const problem = detect(server, 'drift', `${prepared.plan.files.length} file difference(s) from the promoted setup`, { files: prepared.plan.files, fingerprint: prepared.plan.fingerprint });
|
||||
const problem = detect(
|
||||
server,
|
||||
'drift',
|
||||
`${prepared.plan.files.length} file difference(s) from the promoted setup`,
|
||||
{ files: prepared.plan.files, fingerprint: prepared.plan.fingerprint }
|
||||
);
|
||||
if (problem.state !== 'open' || !maintenanceAllowed('drift')) return;
|
||||
const result = db().transaction(() => {
|
||||
const next = requestWatchdogLifecycle(server, 'rebuild', problem.id);
|
||||
if (next.ok) {
|
||||
const campaignId = createDriftRebuildCampaign(server, next.deploymentId, problem.id, prepared);
|
||||
const campaignId = createDriftRebuildCampaign(
|
||||
server,
|
||||
next.deploymentId,
|
||||
problem.id,
|
||||
prepared
|
||||
);
|
||||
addAction(problem, next.deploymentId, 'rebuild', campaignId);
|
||||
}
|
||||
return next;
|
||||
})();
|
||||
if (!result.ok && result.status !== 409 && result.status !== 503) transition(problem.id, 'attention', `Could not queue drift rebuild: ${result.error}`);
|
||||
if (!result.ok && result.status !== 409 && result.status !== 503)
|
||||
transition(problem.id, 'attention', `Could not queue drift rebuild: ${result.error}`);
|
||||
}
|
||||
|
||||
let inFlight: Promise<void> | null = null;
|
||||
export function watchdogTick(forceScan = false): Promise<void> {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = withOperationSource(forceScan ? 'Manual maintenance check' : 'Maintenance watchdog', () => run(forceScan))
|
||||
.finally(() => { inFlight = null; });
|
||||
inFlight = withOperationSource(
|
||||
forceScan ? 'Manual maintenance check' : 'Maintenance watchdog',
|
||||
() => run(forceScan)
|
||||
).finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
async function run(forceScan: boolean): Promise<void> {
|
||||
@@ -338,8 +753,12 @@ async function run(forceScan: boolean): Promise<void> {
|
||||
const lastScan = setting('watchdog_last_scan');
|
||||
if (!forceScan && lastScan && Date.now() - Date.parse(lastScan) < WATCHDOG_SCAN_MS) return;
|
||||
setSetting('watchdog_last_scan', iso());
|
||||
if (!listServers().some(server => nativeInstance(server.name))) {
|
||||
setSetting('watchdog_scan_error', 'Register a native host instance before automatic recovery. RCON or fresh telemetry verifies game status.'); return;
|
||||
if (!listServers().some((server) => nativeInstance(server.name))) {
|
||||
setSetting(
|
||||
'watchdog_scan_error',
|
||||
'Register a native host instance before automatic recovery. RCON or fresh telemetry verifies game status.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const errors: string[] = [];
|
||||
const servers = listServers();
|
||||
@@ -347,21 +766,34 @@ async function run(forceScan: boolean): Promise<void> {
|
||||
async function pump() {
|
||||
while (cursor < servers.length) {
|
||||
const server = servers[cursor++];
|
||||
try { await scanServer(server.name, forceScan); }
|
||||
catch (err) {
|
||||
if (!hasActiveProvisioning(server.name)) errors.push(`${server.name}: ${(err as Error).message}`);
|
||||
try {
|
||||
await scanServer(server.name, forceScan);
|
||||
} catch (err) {
|
||||
if (!hasActiveProvisioning(server.name))
|
||||
errors.push(`${server.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(4, servers.length) }, pump));
|
||||
// Preserve incidents for temporarily absent rebuild instances, but close retired registrations.
|
||||
const abandoned = db().prepare("SELECT * FROM watchdog_incidents WHERE server_deleted_at IS NULL AND state != 'resolved'").all() as WatchdogIncident[];
|
||||
const abandoned = db()
|
||||
.prepare(
|
||||
"SELECT * FROM watchdog_incidents WHERE server_deleted_at IS NULL AND state != 'resolved'"
|
||||
)
|
||||
.all() as WatchdogIncident[];
|
||||
for (const row of abandoned) {
|
||||
if (getServer(row.server) || hasActiveProvisioning(row.server)) continue;
|
||||
const last = db().prepare('SELECT action,state FROM server_deployments WHERE server=? ORDER BY rowid DESC LIMIT 1').get(row.server) as { action: string; state: string } | undefined;
|
||||
const last = db()
|
||||
.prepare(
|
||||
'SELECT action,state FROM server_deployments WHERE server=? ORDER BY rowid DESC LIMIT 1'
|
||||
)
|
||||
.get(row.server) as { action: string; state: string } | undefined;
|
||||
// An absent registry can also mean a failed rebuild. Only a completed
|
||||
// destruction retires that failure without requiring inspection.
|
||||
if (['open','dismissed'].includes(row.state) || (last?.action === 'destroy' && ['done','warning'].includes(last.state)))
|
||||
if (
|
||||
['open', 'dismissed'].includes(row.state) ||
|
||||
(last?.action === 'destroy' && ['done', 'warning'].includes(last.state))
|
||||
)
|
||||
transition(row.id, 'resolved', 'Server is no longer registered');
|
||||
}
|
||||
setSetting('watchdog_scan_error', errors.join('\n'));
|
||||
|
||||
@@ -6,42 +6,77 @@ import { nextMaintenanceAt } from '../../plutonium.js';
|
||||
import type { MaintenanceJob, MaintenanceSchedule, PlutoniumStatus } from '../../plutonium.js';
|
||||
|
||||
import { readSetting, writeSetting } from './settings.js';
|
||||
import { checkPlutonium, probeVersion, release, freshRelease, runningVersions, HOUR } from '../integrations/plutonium.js';
|
||||
import {
|
||||
checkPlutonium,
|
||||
probeVersion,
|
||||
release,
|
||||
freshRelease,
|
||||
runningVersions,
|
||||
HOUR
|
||||
} from '../integrations/plutonium.js';
|
||||
import { maintenanceAllowed } from './policies.js';
|
||||
export { checkPlutonium } from '../integrations/plutonium.js';
|
||||
const nowIso = () => new Date().toISOString();
|
||||
function schedule(): MaintenanceSchedule {
|
||||
// Legacy schedules were explicitly UTC. Preserve their timing until saved.
|
||||
return { timeZone: 'UTC', ...readSetting('plutonium_schedule', { enabled: false, time: '06:00', nextAt: null }) };
|
||||
return {
|
||||
timeZone: 'UTC',
|
||||
...readSetting('plutonium_schedule', { enabled: false, time: '06:00', nextAt: null })
|
||||
};
|
||||
}
|
||||
export function saveMaintenanceSchedule(enabled: boolean, time: string, timeZone: string): void {
|
||||
const nextAt = nextMaintenanceAt(time, new Date(), timeZone);
|
||||
writeSetting('plutonium_schedule', { enabled, time, timeZone, nextAt: enabled ? nextAt : null });
|
||||
writeSetting('plutonium_schedule', {
|
||||
enabled,
|
||||
time,
|
||||
timeZone,
|
||||
nextAt: enabled ? nextAt : null
|
||||
});
|
||||
}
|
||||
export function plutoniumStatus(selectedJob: number | null = null): PlutoniumStatus {
|
||||
return {
|
||||
release: release(), schedule: schedule(), servers: runningVersions(),
|
||||
jobs: db().prepare(`SELECT m.*, d.stage AS operation_stage, d.state AS operation_state
|
||||
release: release(),
|
||||
schedule: schedule(),
|
||||
servers: runningVersions(),
|
||||
jobs: db()
|
||||
.prepare(
|
||||
`SELECT m.*, d.stage AS operation_stage, d.state AS operation_state
|
||||
FROM maintenance_jobs m LEFT JOIN server_deployments d ON d.id = m.provisioning_id
|
||||
WHERE m.state IN ('queued','restarting')
|
||||
OR (m.state = 'failed' AND NOT EXISTS (SELECT 1 FROM maintenance_jobs n WHERE n.server = m.server AND n.kind = m.kind AND n.id > m.id))
|
||||
OR m.id IN (SELECT id FROM maintenance_jobs ORDER BY id DESC LIMIT 100) OR m.id = ? ORDER BY m.id DESC`).all(selectedJob) as MaintenanceJob[]
|
||||
OR m.id IN (SELECT id FROM maintenance_jobs ORDER BY id DESC LIMIT 100) OR m.id = ? ORDER BY m.id DESC`
|
||||
)
|
||||
.all(selectedJob) as MaintenanceJob[]
|
||||
};
|
||||
}
|
||||
|
||||
export function queueMaintenance(servers: string[], kind: MaintenanceJob['kind'] = 'legacy', origin: MaintenanceJob['origin'] = 'manual'): number {
|
||||
const names = new Set(listServers().map(s => s.name));
|
||||
if (!servers.length || servers.some(s => !names.has(s))) throw new Error('Choose existing fleet servers.');
|
||||
export function queueMaintenance(
|
||||
servers: string[],
|
||||
kind: MaintenanceJob['kind'] = 'legacy',
|
||||
origin: MaintenanceJob['origin'] = 'manual'
|
||||
): number {
|
||||
const names = new Set(listServers().map((s) => s.name));
|
||||
if (!servers.length || servers.some((s) => !names.has(s)))
|
||||
throw new Error('Choose existing fleet servers.');
|
||||
return db().transaction(() => {
|
||||
const target = kind === 'schedule' ? null : freshRelease();
|
||||
if (kind === 'update' && target === null) throw new Error('Check the published revision before requesting an update.');
|
||||
if (kind === 'update' && target === null)
|
||||
throw new Error('Check the published revision before requesting an update.');
|
||||
let added = 0;
|
||||
for (const server of new Set(servers)) {
|
||||
if (getServer(server)?.desired_state === 'stopped') continue;
|
||||
if (origin !== 'manual' && hasActiveProvisioning(server)) continue;
|
||||
added += db().prepare(`INSERT OR IGNORE INTO maintenance_jobs (server, target_revision, requested_at, kind, origin)
|
||||
VALUES (?, ?, ?, ?, ?)`).run(server, target, nowIso(), kind, origin).changes;
|
||||
const job = db().prepare("SELECT id FROM maintenance_jobs WHERE server = ? AND server_deleted_at IS NULL AND state IN ('queued','restarting')").get(server) as { id: number };
|
||||
added += db()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO maintenance_jobs (server, target_revision, requested_at, kind, origin)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(server, target, nowIso(), kind, origin).changes;
|
||||
const job = db()
|
||||
.prepare(
|
||||
"SELECT id FROM maintenance_jobs WHERE server = ? AND server_deleted_at IS NULL AND state IN ('queued','restarting')"
|
||||
)
|
||||
.get(server) as { id: number };
|
||||
requestRestart(server, { kind: 'maintenance', id: String(job.id) });
|
||||
}
|
||||
return added;
|
||||
@@ -50,52 +85,98 @@ export function queueMaintenance(servers: string[], kind: MaintenanceJob['kind']
|
||||
|
||||
/** One automatic attempt per server/revision. Explicit retries preserve the old outcome. */
|
||||
export function queueUpdates(servers?: string[], automatic = false): number {
|
||||
if (!maintenanceAllowed()) throw new Error('Maintenance is paused. Resume it before requesting updates.');
|
||||
if (!maintenanceAllowed())
|
||||
throw new Error('Maintenance is paused. Resume it before requesting updates.');
|
||||
if (automatic && !maintenanceAllowed('updates')) return 0;
|
||||
if (freshRelease() === null) throw new Error('Check the published revision before requesting an update.');
|
||||
if (freshRelease() === null)
|
||||
throw new Error('Check the published revision before requesting an update.');
|
||||
const versions = runningVersions();
|
||||
if (servers && (!servers.length || servers.some(server => !versions.some(v => v.server === server)))) throw new Error('Choose existing fleet servers.');
|
||||
const candidates = versions.filter(v => v.update_status === 'outdated' && (!servers || servers.includes(v.server)));
|
||||
const names = candidates.filter(v => !automatic || !db().prepare(`SELECT 1 FROM maintenance_jobs
|
||||
WHERE server = ? AND server_deleted_at IS NULL AND target_revision = ? AND kind = 'update'`).get(v.server, freshRelease())).map(v => v.server);
|
||||
if (
|
||||
servers &&
|
||||
(!servers.length || servers.some((server) => !versions.some((v) => v.server === server)))
|
||||
)
|
||||
throw new Error('Choose existing fleet servers.');
|
||||
const candidates = versions.filter(
|
||||
(v) => v.update_status === 'outdated' && (!servers || servers.includes(v.server))
|
||||
);
|
||||
const names = candidates
|
||||
.filter(
|
||||
(v) =>
|
||||
!automatic ||
|
||||
!db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM maintenance_jobs
|
||||
WHERE server = ? AND server_deleted_at IS NULL AND target_revision = ? AND kind = 'update'`
|
||||
)
|
||||
.get(v.server, freshRelease())
|
||||
)
|
||||
.map((v) => v.server);
|
||||
return names.length ? queueMaintenance(names, 'update', automatic ? 'automatic' : 'manual') : 0;
|
||||
}
|
||||
|
||||
export function retryMaintenance(id: number): number {
|
||||
const job = db().prepare("SELECT * FROM maintenance_jobs WHERE id = ? AND state = 'failed'").get(id) as MaintenanceJob | undefined;
|
||||
const job = db()
|
||||
.prepare("SELECT * FROM maintenance_jobs WHERE id = ? AND state = 'failed'")
|
||||
.get(id) as MaintenanceJob | undefined;
|
||||
if (!job) throw new Error('Only failed maintenance can be retried.');
|
||||
if (job.server_deleted_at) throw new Error('This server was deleted; its maintenance history cannot be retried.');
|
||||
if (job.server_deleted_at)
|
||||
throw new Error('This server was deleted; its maintenance history cannot be retried.');
|
||||
if (!maintenanceAllowed()) throw new Error('Maintenance is paused.');
|
||||
if (getServer(job.server)?.desired_state === 'stopped') throw new Error('This server is intentionally stopped.');
|
||||
if (getServer(job.server)?.desired_state === 'stopped')
|
||||
throw new Error('This server is intentionally stopped.');
|
||||
// Ambiguous lifecycle failures retain their slot and require diagnostics, not another request.
|
||||
if (job.provisioning_id) {
|
||||
const operation = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(job.provisioning_id) as { state: string } | undefined;
|
||||
if (operation?.state !== 'done') throw new Error('Inspect and recover the existing Provisioning operation before retrying.');
|
||||
const operation = db()
|
||||
.prepare('SELECT state FROM server_deployments WHERE id = ?')
|
||||
.get(job.provisioning_id) as { state: string } | undefined;
|
||||
if (operation?.state !== 'done')
|
||||
throw new Error(
|
||||
'Inspect and recover the existing Provisioning operation before retrying.'
|
||||
);
|
||||
}
|
||||
return queueMaintenance([job.server], job.kind, 'manual');
|
||||
}
|
||||
export function cancelMaintenance(id: number): boolean {
|
||||
return db().transaction(() => {
|
||||
const job = db().prepare("SELECT provisioning_id FROM maintenance_jobs WHERE id = ? AND state = 'queued'").get(id) as { provisioning_id: string | null } | undefined;
|
||||
const job = db()
|
||||
.prepare(
|
||||
"SELECT provisioning_id FROM maintenance_jobs WHERE id = ? AND state = 'queued'"
|
||||
)
|
||||
.get(id) as { provisioning_id: string | null } | undefined;
|
||||
if (!job) return false;
|
||||
if (job.provisioning_id && !cancelProvisioning(job.provisioning_id)) return false;
|
||||
return db().prepare(`UPDATE maintenance_jobs SET state = 'cancelled', finished_at = ?, detail = 'Cancelled by operator'
|
||||
WHERE id = ? AND state = 'queued'`).run(nowIso(), id).changes > 0;
|
||||
return (
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE maintenance_jobs SET state = 'cancelled', finished_at = ?, detail = 'Cancelled by operator'
|
||||
WHERE id = ? AND state = 'queued'`
|
||||
)
|
||||
.run(nowIso(), id).changes > 0
|
||||
);
|
||||
})();
|
||||
}
|
||||
|
||||
function finish(job: MaintenanceJob, state: 'done' | 'failed' | 'cancelled', detail: string) {
|
||||
db().prepare('UPDATE maintenance_jobs SET state = ?, detail = ?, finished_at = ? WHERE id = ? AND server_deleted_at IS NULL')
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE maintenance_jobs SET state = ?, detail = ?, finished_at = ? WHERE id = ? AND server_deleted_at IS NULL'
|
||||
)
|
||||
.run(state, detail, nowIso(), job.id);
|
||||
}
|
||||
function hold(job: MaintenanceJob, detail: string) {
|
||||
db().prepare('UPDATE maintenance_jobs SET detail = ? WHERE id = ? AND server_deleted_at IS NULL').run(detail, job.id);
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE maintenance_jobs SET detail = ? WHERE id = ? AND server_deleted_at IS NULL'
|
||||
)
|
||||
.run(detail, job.id);
|
||||
}
|
||||
|
||||
let ticking: Promise<void> | null = null;
|
||||
export function maintenanceTick(): Promise<void> {
|
||||
if (ticking) return ticking;
|
||||
ticking = runTick().finally(() => { ticking = null; });
|
||||
ticking = runTick().finally(() => {
|
||||
ticking = null;
|
||||
});
|
||||
return ticking;
|
||||
}
|
||||
async function runTick(): Promise<void> {
|
||||
@@ -103,51 +184,94 @@ async function runTick(): Promise<void> {
|
||||
// Provisioning refreshes the manifest independently; that must not postpone
|
||||
// the hourly version checks for the rest of the fleet.
|
||||
const versionsCheckedAt = readSetting<string | null>('plutonium_versions_checked_at', null);
|
||||
if (!latest.checkedAt || Date.now() - Date.parse(latest.checkedAt) >= HOUR
|
||||
|| !versionsCheckedAt || Date.now() - Date.parse(versionsCheckedAt) >= HOUR) await checkPlutonium();
|
||||
if (
|
||||
!latest.checkedAt ||
|
||||
Date.now() - Date.parse(latest.checkedAt) >= HOUR ||
|
||||
!versionsCheckedAt ||
|
||||
Date.now() - Date.parse(versionsCheckedAt) >= HOUR
|
||||
)
|
||||
await checkPlutonium();
|
||||
if (maintenanceAllowed('updates') && freshRelease() !== null) queueUpdates(undefined, true);
|
||||
db().transaction(() => {
|
||||
const plan = schedule();
|
||||
if (maintenanceAllowed() && plan.enabled && plan.nextAt && plan.nextAt <= nowIso()) {
|
||||
const names = listServers().filter(s => s.desired_state !== 'stopped').map(s => s.name);
|
||||
const names = listServers()
|
||||
.filter((s) => s.desired_state !== 'stopped')
|
||||
.map((s) => s.name);
|
||||
if (names.length) queueMaintenance(names, 'schedule', 'schedule');
|
||||
// Coalesce missed days after downtime into one run; no restart backlog.
|
||||
writeSetting('plutonium_schedule', { ...plan, nextAt: nextMaintenanceAt(plan.time, new Date(), plan.timeZone) });
|
||||
writeSetting('plutonium_schedule', {
|
||||
...plan,
|
||||
nextAt: nextMaintenanceAt(plan.time, new Date(), plan.timeZone)
|
||||
});
|
||||
}
|
||||
})();
|
||||
const jobs = db().prepare(`SELECT * FROM maintenance_jobs WHERE server_deleted_at IS NULL AND state IN ('queued','restarting') ORDER BY id`).all() as MaintenanceJob[];
|
||||
const jobs = db()
|
||||
.prepare(
|
||||
`SELECT * FROM maintenance_jobs WHERE server_deleted_at IS NULL AND state IN ('queued','restarting') ORDER BY id`
|
||||
)
|
||||
.all() as MaintenanceJob[];
|
||||
for (const job of jobs) {
|
||||
try { await processJob(job); }
|
||||
catch { hold(job, 'Maintenance check failed; waiting for the next check.'); }
|
||||
try {
|
||||
await processJob(job);
|
||||
} catch {
|
||||
hold(job, 'Maintenance check failed; waiting for the next check.');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
async function processJob(job: MaintenanceJob): Promise<void> {
|
||||
const server = getServer(job.server);
|
||||
if (job.server_deleted_at || !server || server.destroyed_at) return finish(job, 'cancelled', 'Server removed.');
|
||||
if (job.server_deleted_at || !server || server.destroyed_at)
|
||||
return finish(job, 'cancelled', 'Server removed.');
|
||||
if (!job.provisioning_id && !job.restarted_at && hasActiveProvisioning(job.server)) {
|
||||
return hold(job, 'Waiting for another provisioning operation.');
|
||||
}
|
||||
const operation = requestRestart(job.server, { kind: 'maintenance', id: String(job.id) });
|
||||
if (['failed','interrupted','cancelled'].includes(operation.state)) {
|
||||
return finish(job, operation.state === 'cancelled' ? 'cancelled' : 'failed', operation.error ?? operation.stage);
|
||||
if (['failed', 'interrupted', 'cancelled'].includes(operation.state)) {
|
||||
return finish(
|
||||
job,
|
||||
operation.state === 'cancelled' ? 'cancelled' : 'failed',
|
||||
operation.error ?? operation.stage
|
||||
);
|
||||
}
|
||||
if (operation.state !== 'done') {
|
||||
if (operation.submitted_at) {
|
||||
db().prepare("UPDATE maintenance_jobs SET state = 'restarting', restarted_at = ? WHERE id = ?").run(operation.submitted_at, job.id);
|
||||
db().prepare('UPDATE plutonium_versions SET revision = NULL, error = ? WHERE server = ?').run('Waiting for restart recovery.', job.server);
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE maintenance_jobs SET state = 'restarting', restarted_at = ? WHERE id = ?"
|
||||
)
|
||||
.run(operation.submitted_at, job.id);
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE plutonium_versions SET revision = NULL, error = ? WHERE server = ?'
|
||||
)
|
||||
.run('Waiting for restart recovery.', job.server);
|
||||
}
|
||||
return hold(job, operation.stage);
|
||||
}
|
||||
if (hasActiveProvisioning(job.server, operation.id)) {
|
||||
return hold(job, 'Waiting for another provisioning operation before verifying the running revision.');
|
||||
return hold(
|
||||
job,
|
||||
'Waiting for another provisioning operation before verifying the running revision.'
|
||||
);
|
||||
}
|
||||
const revision = await probeVersion(job.server, { maintenance: true });
|
||||
if (hasActiveProvisioning(job.server, operation.id)) {
|
||||
return hold(job, 'Waiting for another provisioning operation before verifying the running revision.');
|
||||
return hold(
|
||||
job,
|
||||
'Waiting for another provisioning operation before verifying the running revision.'
|
||||
);
|
||||
}
|
||||
if (job.target_revision && (revision === null || revision < job.target_revision)) {
|
||||
return finish(job, 'failed', `Server recovered, but r${job.target_revision} was not verified (${revision === null ? 'version unknown' : `running r${revision}`}). Check Plutainer auto-update settings.`);
|
||||
return finish(
|
||||
job,
|
||||
'failed',
|
||||
`Server recovered, but r${job.target_revision} was not verified (${revision === null ? 'version unknown' : `running r${revision}`}). Check Plutainer auto-update settings.`
|
||||
);
|
||||
}
|
||||
return finish(job, 'done', `Server recovered; ${revision === null ? 'running revision unknown' : `running r${revision}`}.`);
|
||||
return finish(
|
||||
job,
|
||||
'done',
|
||||
`Server recovered; ${revision === null ? 'running revision unknown' : `running r${revision}`}.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { db } from '../db/index.js';
|
||||
export function readSetting<T>(key: string, fallback: T): T {
|
||||
const row = db().prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
const row = db().prepare('SELECT value FROM settings WHERE key = ?').get(key) as
|
||||
{ value: string } | undefined;
|
||||
return row ? JSON.parse(row.value) : fallback;
|
||||
}
|
||||
export function writeSetting(key: string, value: unknown): void {
|
||||
db().prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
)
|
||||
.run(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user