Fix transient health checks and avoid unnecessary recovery
This commit is contained in:
@@ -153,6 +153,105 @@ describe('Watchdog detection and ownership', () => {
|
||||
expect(operations()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['no-config', 'no-port', 'bad-reply', 'no-players'] as const)('keeps %s RCON failures observational', async reason => {
|
||||
vi.mocked(rcon.rconStatusResult).mockResolvedValue({ ...missingRcon, reason });
|
||||
for (let n = 0; n < 4; n++) { await watchdog.watchdogTick(true); advance(watchdog.WATCHDOG_SCAN_MS); }
|
||||
expect(onlyIncident()).toMatchObject({ state: 'open' });
|
||||
expect(operations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('breaks the sustained failure window on a successful probe between watchdog samples', async () => {
|
||||
vi.mocked(rcon.rconStatusResult).mockResolvedValue(missingRcon);
|
||||
await watchdog.watchdogTick(true);
|
||||
advance(watchdog.WATCHDOG_SCAN_MS);
|
||||
await watchdog.watchdogTick(true);
|
||||
advance(20_000);
|
||||
const { POST } = await import('../../src/routes/api/servers/[name]/probe-rcon/+server.js');
|
||||
vi.mocked(rcon.rconStatusResult).mockResolvedValueOnce(healthyRcon);
|
||||
await POST({ params: { name: server } } as Parameters<typeof POST>[0]);
|
||||
advance(40_000);
|
||||
await watchdog.watchdogTick(true);
|
||||
expect(onlyIncident()).toMatchObject({ failures: 1, state: 'open', first_seen_at: new Date().toISOString() });
|
||||
expect(operations()).toEqual([]);
|
||||
advance(watchdog.WATCHDOG_SCAN_MS); await watchdog.watchdogTick(true);
|
||||
expect(operations()).toEqual([]);
|
||||
advance(watchdog.WATCHDOG_SCAN_MS); await watchdog.watchdogTick(true);
|
||||
expect(operations()).toEqual([expect.objectContaining({ action: 'restart' })]);
|
||||
});
|
||||
|
||||
it.each([0, 2])('cancels queued health recovery when fresh RCON responds with %i players', async players => {
|
||||
const row = await sustainedFailure();
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
||||
vi.mocked(rcon.rconGetPlayerCount).mockResolvedValue(players);
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
await processProvisioning();
|
||||
expect(operations()).toEqual([expect.objectContaining({ action: 'restart', state: 'cancelled' })]);
|
||||
expect(watchdog.watchdogDetail(row.id)?.incident.state).toBe('resolved');
|
||||
expect(komodo.komodoRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('holds queued health recovery when container readiness becomes unknown', async () => {
|
||||
await sustainedFailure();
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: false, reason: 'Komodo timed out' });
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
await processProvisioning();
|
||||
expect(operations()[0].state).toBe('waiting');
|
||||
expect(komodo.komodoRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not apply a recovered occupancy probe after its operation is replaced', async () => {
|
||||
const row = await sustainedFailure();
|
||||
const restart = operations()[0];
|
||||
const { cancelProvisioning, requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
||||
vi.mocked(rcon.rconGetPlayerCount).mockImplementationOnce(async () => {
|
||||
expect(cancelProvisioning(restart.id)).toBe(true);
|
||||
expect(requestLifecycle(server, 'stop').ok).toBe(true);
|
||||
return 0;
|
||||
});
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
await processProvisioning();
|
||||
expect(watchdog.watchdogDetail(row.id)?.incident.state).toBe('recovering');
|
||||
expect(operations()).toEqual([expect.objectContaining({ action: 'restart', state: 'cancelled' }),
|
||||
expect.objectContaining({ action: 'stop', state: 'waiting' })]);
|
||||
expect(komodo.komodoRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels obsolete health recovery so a later queued maintenance restart can proceed', async () => {
|
||||
const row = await sustainedFailure();
|
||||
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
|
||||
const job = database.db().prepare('INSERT INTO maintenance_jobs (server, requested_at) VALUES (?, ?)')
|
||||
.run(server, new Date().toISOString()).lastInsertRowid;
|
||||
const maintenance = requestRestart(server, { kind: 'maintenance', id: String(job) });
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
await processProvisioning();
|
||||
expect(watchdog.watchdogDetail(row.id)?.incident.state).toBe('resolved');
|
||||
expect(operations()).toEqual([expect.objectContaining({ state: 'cancelled' }),
|
||||
expect.objectContaining({ id: maintenance.id, state: 'awaiting-health' })]);
|
||||
expect(komodo.komodoRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['restart', 'rebuild'] as const)('preserves an operator request adopting a queued health %s', async action => {
|
||||
await sustainedFailure();
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
if (action === 'rebuild') {
|
||||
await processProvisioning();
|
||||
advance(watchdog.WATCHDOG_RECOVERY_MS);
|
||||
await watchdog.watchdogTick(true);
|
||||
}
|
||||
const queued = operations().at(-1)!;
|
||||
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
|
||||
expect(requestLifecycle(server, action)).toEqual({ ok: true, deploymentId: queued.id });
|
||||
expect(database.db().prepare('SELECT triggered_by FROM server_deployments WHERE id = ?').get(queued.id)).toEqual({ triggered_by: 'ui' });
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
||||
if (action === 'rebuild') vi.mocked(komodo.komodoDestroyDeployment).mockImplementationOnce(async () => { down(); return { ok: true }; });
|
||||
await processProvisioning();
|
||||
expect(operations().at(-1)).toMatchObject({ id: queued.id, action, state: action === 'restart' ? 'awaiting-health' : 'queued' });
|
||||
if (action === 'restart') expect(komodo.komodoRestart).toHaveBeenCalledTimes(1);
|
||||
else expect(komodo.komodoDestroyDeployment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves intentional stop intent after completion and database reopening', async () => {
|
||||
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
|
||||
const stop = requestLifecycle(server, 'stop');
|
||||
@@ -241,6 +340,79 @@ describe('Watchdog detection and ownership', () => {
|
||||
});
|
||||
|
||||
describe('Watchdog recovery persistence', () => {
|
||||
it('verifies recovery at the deadline before deciding whether to rebuild', async () => {
|
||||
const row = await sustainedFailure();
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
await processProvisioning();
|
||||
advance(watchdog.WATCHDOG_RECOVERY_MS);
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
||||
await watchdog.watchdogTick(true);
|
||||
expect(operations()).toEqual([expect.objectContaining({ action: 'restart', state: 'done' })]);
|
||||
expect(watchdog.watchdogDetail(row.id)?.incident.state).toBe('resolved');
|
||||
expect(database.db().prepare('SELECT healthy_at FROM restart_rollout WHERE server = ?').get(server))
|
||||
.toEqual({ healthy_at: new Date().toISOString() });
|
||||
});
|
||||
|
||||
it('defers escalation while current container health is unknown', async () => {
|
||||
await sustainedFailure();
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
await processProvisioning();
|
||||
advance(watchdog.WATCHDOG_RECOVERY_MS);
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: false, reason: 'Komodo timed out' });
|
||||
await watchdog.watchdogTick(true);
|
||||
expect(operations()).toEqual([expect.objectContaining({ action: 'restart', state: 'awaiting-health' })]);
|
||||
});
|
||||
|
||||
it('keeps failed submission history when fresh evidence later releases recovery', async () => {
|
||||
const row = await sustainedFailure();
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
vi.mocked(komodo.komodoRestart).mockResolvedValueOnce({ ok: false, kind: 'unreachable', message: 'Reply lost' });
|
||||
await processProvisioning();
|
||||
advance(watchdog.WATCHDOG_RECOVERY_MS);
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
||||
await watchdog.watchdogTick(true);
|
||||
expect(operations()).toEqual([expect.objectContaining({ action: 'restart', state: 'failed' })]);
|
||||
expect(watchdog.watchdogDetail(row.id)?.incident.state).toBe('resolved');
|
||||
expect(database.db().prepare('SELECT healthy_at FROM restart_rollout WHERE server = ?').get(server))
|
||||
.toEqual({ healthy_at: new Date().toISOString() });
|
||||
expect(watchdog.watchdogDetail(row.id)?.actions[0].operation_error).toContain('Reply lost');
|
||||
});
|
||||
|
||||
it('discards escalation evidence if another lifecycle submission appears during its probe', async () => {
|
||||
const row = await sustainedFailure();
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
await processProvisioning();
|
||||
advance(watchdog.WATCHDOG_RECOVERY_MS);
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
||||
vi.mocked(rcon.rconStatusResult).mockImplementationOnce(async () => {
|
||||
database.db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, submitted_at, triggered_by)
|
||||
VALUES ('newer', ?, 'restart', 'done', 'Complete', ?, ?, 'manual')`).run(server, new Date().toISOString(), new Date().toISOString());
|
||||
return healthyRcon;
|
||||
});
|
||||
await watchdog.watchdogTick(true);
|
||||
expect(operations()[0]).toMatchObject({ action: 'restart', state: 'awaiting-health' });
|
||||
expect(operations().some(op => op.action === 'rebuild')).toBe(false);
|
||||
expect(watchdog.watchdogDetail(row.id)?.incident.state).toBe('recovering');
|
||||
expect(database.db().prepare('SELECT healthy_at FROM restart_rollout WHERE server = ?').get(server)).toEqual({ healthy_at: null });
|
||||
});
|
||||
|
||||
it('cancels a queued health rebuild and releases its transferred slot when the game recovers', async () => {
|
||||
const row = await sustainedFailure();
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
await processProvisioning();
|
||||
advance(watchdog.WATCHDOG_RECOVERY_MS);
|
||||
await watchdog.watchdogTick(true);
|
||||
expect(operations().map(op => op.action)).toEqual(['restart', 'rebuild']);
|
||||
const rebuild = operations()[1];
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
||||
await processProvisioning();
|
||||
expect(operations()).toEqual([expect.objectContaining({ state: 'failed' }), expect.objectContaining({ state: 'cancelled' })]);
|
||||
expect(watchdog.watchdogDetail(row.id)?.incident.state).toBe('resolved');
|
||||
expect(database.db().prepare('SELECT operation_id, healthy_at FROM restart_rollout WHERE server = ?').get(server))
|
||||
.toEqual({ operation_id: rebuild.id, healthy_at: new Date().toISOString() });
|
||||
expect(komodo.komodoDestroyDeployment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the original finding as deleted history after an intentional successful destroy', async () => {
|
||||
const { removeServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||
const { destroyServer, processDestructions } = await import('../../src/lib/server/provisioning/instances.js');
|
||||
|
||||
Reference in New Issue
Block a user