@celilo/cli 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +3 -0
- package/CELILO_SUBSYSTEMS.md +71 -2
- package/docs/ALERTING.md +298 -0
- package/docs/INDEX.md +103 -0
- package/drizzle/0016_trusted_sources.sql +10 -0
- package/drizzle/0017_alerting.sql +127 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -2
- package/schemas/system_config.json +9 -0
- package/src/ansible/inventory.ts +2 -2
- package/src/cli/commands/alerts-act.ts +107 -0
- package/src/cli/commands/alerts-list.ts +62 -0
- package/src/cli/commands/alerts-poll.ts +129 -0
- package/src/cli/commands/alerts-sweep.ts +156 -0
- package/src/cli/commands/module-list.ts +50 -3
- package/src/cli/commands/monitor.ts +178 -0
- package/src/cli/commands/notify-config.ts +453 -0
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-update.ts +1 -0
- package/src/cli/completion.ts +26 -0
- package/src/cli/generate-zsh-completion.ts +2 -0
- package/src/cli/index.ts +58 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/schema.ts +358 -0
- package/src/hooks/capability-loader.ts +158 -46
- package/src/hooks/capability-map-coverage.test.ts +101 -0
- package/src/manifest/schema.ts +60 -1
- package/src/services/alerting/ack.test.ts +212 -0
- package/src/services/alerting/ack.ts +119 -0
- package/src/services/alerting/builtin-monitors.test.ts +132 -0
- package/src/services/alerting/builtin-monitors.ts +84 -0
- package/src/services/alerting/builtin-source.ts +82 -0
- package/src/services/alerting/coverage-source.ts +38 -0
- package/src/services/alerting/deferral.test.ts +161 -0
- package/src/services/alerting/delivery-loop.test.ts +396 -0
- package/src/services/alerting/deploy-hooks.test.ts +125 -0
- package/src/services/alerting/deploy-hooks.ts +111 -0
- package/src/services/alerting/escalation.test.ts +207 -0
- package/src/services/alerting/escalation.ts +151 -0
- package/src/services/alerting/format.test.ts +193 -0
- package/src/services/alerting/format.ts +150 -0
- package/src/services/alerting/health-coverage.ts +81 -0
- package/src/services/alerting/inbound-poller.test.ts +298 -0
- package/src/services/alerting/inbound-poller.ts +236 -0
- package/src/services/alerting/inbound.test.ts +201 -0
- package/src/services/alerting/inbound.ts +112 -0
- package/src/services/alerting/interview-responder.test.ts +169 -0
- package/src/services/alerting/interview-responder.ts +158 -0
- package/src/services/alerting/keys.test.ts +155 -0
- package/src/services/alerting/keys.ts +190 -0
- package/src/services/alerting/monitors.ts +185 -0
- package/src/services/alerting/notification-responder.test.ts +290 -0
- package/src/services/alerting/notification-responder.ts +260 -0
- package/src/services/alerting/notifier.ts +219 -0
- package/src/services/alerting/people.ts +178 -0
- package/src/services/alerting/quiet-hours.test.ts +140 -0
- package/src/services/alerting/quiet-hours.ts +99 -0
- package/src/services/alerting/reconcile.test.ts +190 -0
- package/src/services/alerting/reconcile.ts +166 -0
- package/src/services/alerting/run-monitor.test.ts +185 -0
- package/src/services/alerting/run-monitor.ts +177 -0
- package/src/services/alerting/store.test.ts +222 -0
- package/src/services/alerting/store.ts +289 -0
- package/src/services/alerting/suppression.test.ts +228 -0
- package/src/services/alerting/suppression.ts +142 -0
- package/src/services/alerting/sweep-runner.test.ts +229 -0
- package/src/services/alerting/sweep-runner.ts +204 -0
- package/src/services/alerting/sweep.test.ts +61 -0
- package/src/services/alerting/sweep.ts +41 -0
- package/src/services/alerting/tokens.test.ts +152 -0
- package/src/services/alerting/tokens.ts +119 -0
- package/src/services/alerting/transport-loader.ts +48 -0
- package/src/services/aspect-runner.ts +2 -2
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/trusted-sources.test.ts +137 -0
- package/src/services/audit/trusted-sources.ts +124 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/firewall-reach.ts +83 -0
- package/src/services/health-runner.test.ts +50 -0
- package/src/services/health-runner.ts +116 -82
- package/src/services/module-deploy.ts +32 -3
- package/src/services/ssh-key-manager.test.ts +14 -0
- package/src/services/ssh-key-manager.ts +12 -0
- package/src/services/system-config-validator.test.ts +31 -1
- package/src/services/trusted-sources.test.ts +221 -0
- package/src/services/trusted-sources.ts +159 -0
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/templates/generator.ts +6 -29
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import type { DbClient } from '../../db/client';
|
|
7
|
+
import { type Monitor, alerts, monitorRuns, monitors } from '../../db/schema';
|
|
8
|
+
import { setupTestDatabase } from '../../test-utils/setup-test-db';
|
|
9
|
+
import type { HealthCheckResult } from '../health-runner';
|
|
10
|
+
import { moduleAlertKey, moduleCheckAlertKey } from './keys';
|
|
11
|
+
import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
|
|
12
|
+
import { loadLiveAlerts, markUnsuppressed } from './store';
|
|
13
|
+
|
|
14
|
+
const NOW = new Date('2026-07-28T03:00:00Z');
|
|
15
|
+
const LATER = new Date('2026-07-28T03:15:00Z');
|
|
16
|
+
const MODULE = 'caddy';
|
|
17
|
+
const DISK = moduleCheckAlertKey(MODULE, 'disk-space');
|
|
18
|
+
const MODULE_KEY = moduleAlertKey(MODULE);
|
|
19
|
+
|
|
20
|
+
const healthy: HealthCheckResult = { moduleId: MODULE, status: 'healthy', checks: [] };
|
|
21
|
+
const diskFailing: HealthCheckResult = {
|
|
22
|
+
moduleId: MODULE,
|
|
23
|
+
status: 'unhealthy',
|
|
24
|
+
checks: [{ name: 'disk-space', status: 'fail', message: '/var 94% used' }],
|
|
25
|
+
};
|
|
26
|
+
const unreachable: HealthCheckResult = {
|
|
27
|
+
moduleId: MODULE,
|
|
28
|
+
status: 'error',
|
|
29
|
+
checks: [],
|
|
30
|
+
error: 'ssh: connect to host caddy.dmz port 22: timed out',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('runOneMonitor', () => {
|
|
34
|
+
let dir: string;
|
|
35
|
+
let db: DbClient;
|
|
36
|
+
let monitor: Monitor;
|
|
37
|
+
|
|
38
|
+
function deps(result: HealthCheckResult, now: Date = NOW): MonitorRunDeps {
|
|
39
|
+
return {
|
|
40
|
+
runModuleCheck: async () => result,
|
|
41
|
+
runBuiltinCheck: async () => [],
|
|
42
|
+
loadModuleCoverage: () => [],
|
|
43
|
+
now: () => now,
|
|
44
|
+
graceMs: 60_000,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const liveKeys = () =>
|
|
49
|
+
loadLiveAlerts(db, monitor.id)
|
|
50
|
+
.map((a) => a.key)
|
|
51
|
+
.sort();
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
dir = mkdtempSync(join(tmpdir(), 'run-monitor-'));
|
|
55
|
+
const dbPath = join(dir, 'celilo.db');
|
|
56
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
57
|
+
db = await setupTestDatabase(dbPath);
|
|
58
|
+
db.insert(monitors)
|
|
59
|
+
.values({
|
|
60
|
+
id: 'mon-1',
|
|
61
|
+
kind: 'module_hook',
|
|
62
|
+
target: MODULE,
|
|
63
|
+
intervalMinutes: 15,
|
|
64
|
+
severity: 'critical',
|
|
65
|
+
})
|
|
66
|
+
.run();
|
|
67
|
+
monitor = db.select().from(monitors).where(eq(monitors.id, 'mon-1')).get() as Monitor;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
db.$client.close();
|
|
72
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
73
|
+
try {
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
} catch {
|
|
76
|
+
/* ignore */
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a failing check creates an alert and records a successful run', async () => {
|
|
81
|
+
const summary = await runOneMonitor(db, monitor, deps(diskFailing));
|
|
82
|
+
expect(summary.outcome).toBe('success');
|
|
83
|
+
expect(liveKeys()).toEqual([DISK]);
|
|
84
|
+
|
|
85
|
+
const runs = db.select().from(monitorRuns).all();
|
|
86
|
+
expect(runs).toHaveLength(1);
|
|
87
|
+
expect(runs[0].outcome).toBe('success');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a clean check resolves what it no longer reports', async () => {
|
|
91
|
+
await runOneMonitor(db, monitor, deps(diskFailing));
|
|
92
|
+
await runOneMonitor(db, monitor, deps(healthy, LATER));
|
|
93
|
+
expect(liveKeys()).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('lastRunAt advances so the sweep will not immediately re-run it', async () => {
|
|
97
|
+
await runOneMonitor(db, monitor, deps(diskFailing));
|
|
98
|
+
const row = db.select().from(monitors).where(eq(monitors.id, 'mon-1')).get();
|
|
99
|
+
expect(row?.lastRunAt).toEqual(NOW);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The end-to-end version of the guard. A problem is already firing when the
|
|
103
|
+
// box drops off the network.
|
|
104
|
+
describe('when the system becomes unreachable', () => {
|
|
105
|
+
test('the firing alert is NOT resolved', async () => {
|
|
106
|
+
await runOneMonitor(db, monitor, deps(diskFailing));
|
|
107
|
+
await runOneMonitor(db, monitor, deps(unreachable, LATER));
|
|
108
|
+
|
|
109
|
+
const disk = db.select().from(alerts).where(eq(alerts.key, DISK)).get();
|
|
110
|
+
expect(disk?.state).not.toBe('resolved');
|
|
111
|
+
expect(disk?.resolvedAt).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('the monitor-level alert fires carrying the real error', async () => {
|
|
115
|
+
await runOneMonitor(db, monitor, deps(diskFailing));
|
|
116
|
+
await runOneMonitor(db, monitor, deps(unreachable, LATER));
|
|
117
|
+
|
|
118
|
+
const moduleAlert = db.select().from(alerts).where(eq(alerts.key, MODULE_KEY)).get();
|
|
119
|
+
expect(moduleAlert).toBeDefined();
|
|
120
|
+
expect(moduleAlert?.message).toContain('timed out');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('the run is recorded as errored, not as a clean pass', async () => {
|
|
124
|
+
await runOneMonitor(db, monitor, deps(unreachable));
|
|
125
|
+
const run = db.select().from(monitorRuns).all()[0];
|
|
126
|
+
expect(run.outcome).toBe('error');
|
|
127
|
+
expect(run.errorMessage).toContain('timed out');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('both alerts stand until a run actually executes again', async () => {
|
|
131
|
+
await runOneMonitor(db, monitor, deps(diskFailing));
|
|
132
|
+
await runOneMonitor(db, monitor, deps(unreachable, LATER));
|
|
133
|
+
expect(liveKeys()).toEqual([DISK, MODULE_KEY].sort());
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('recovery', () => {
|
|
138
|
+
test('a successful run clears the monitor-level alert but keeps a real failure', async () => {
|
|
139
|
+
await runOneMonitor(db, monitor, deps(diskFailing));
|
|
140
|
+
await runOneMonitor(db, monitor, deps(unreachable, LATER));
|
|
141
|
+
await runOneMonitor(db, monitor, deps(diskFailing, new Date('2026-07-28T03:30:00Z')));
|
|
142
|
+
|
|
143
|
+
expect(liveKeys()).toEqual([DISK]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('a successful clean run clears everything the outage froze', async () => {
|
|
147
|
+
await runOneMonitor(db, monitor, deps(diskFailing));
|
|
148
|
+
await runOneMonitor(db, monitor, deps(unreachable, LATER));
|
|
149
|
+
await runOneMonitor(db, monitor, deps(healthy, new Date('2026-07-28T03:30:00Z')));
|
|
150
|
+
|
|
151
|
+
expect(liveKeys()).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('confirmation after un-suppression', () => {
|
|
156
|
+
test('a still-failing key clears awaitingConfirmation', async () => {
|
|
157
|
+
await runOneMonitor(db, monitor, deps(diskFailing));
|
|
158
|
+
const [alert] = loadLiveAlerts(db, monitor.id);
|
|
159
|
+
markUnsuppressed(db, alert.id, LATER);
|
|
160
|
+
|
|
161
|
+
await runOneMonitor(db, monitor, deps(diskFailing, LATER));
|
|
162
|
+
const row = db.select().from(alerts).where(eq(alerts.id, alert.id)).get();
|
|
163
|
+
expect(row?.awaitingConfirmation).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// An errored run confirms nothing — the alert must keep waiting rather
|
|
167
|
+
// than becoming eligible to page on the strength of a run that never ran.
|
|
168
|
+
test('an errored run does NOT clear awaitingConfirmation', async () => {
|
|
169
|
+
await runOneMonitor(db, monitor, deps(diskFailing));
|
|
170
|
+
const [alert] = loadLiveAlerts(db, monitor.id);
|
|
171
|
+
markUnsuppressed(db, alert.id, LATER);
|
|
172
|
+
|
|
173
|
+
await runOneMonitor(db, monitor, deps(unreachable, LATER));
|
|
174
|
+
const row = db.select().from(alerts).where(eq(alerts.id, alert.id)).get();
|
|
175
|
+
expect(row?.awaitingConfirmation).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a module with no health_check hook is a clean run, not a fault', async () => {
|
|
180
|
+
const noChecks: HealthCheckResult = { moduleId: MODULE, status: 'no-checks', checks: [] };
|
|
181
|
+
const summary = await runOneMonitor(db, monitor, deps(noChecks));
|
|
182
|
+
expect(summary.outcome).toBe('success');
|
|
183
|
+
expect(liveKeys()).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running one monitor: check → reconcile → persist.
|
|
3
|
+
*
|
|
4
|
+
* This is the Execution half of the monitoring loop. Everything it decides was
|
|
5
|
+
* decided elsewhere — which monitors are due (`sweep.ts`), what a result means
|
|
6
|
+
* (`keys.ts`, `builtin-monitors.ts`), and what should change (`reconcile.ts`) —
|
|
7
|
+
* so this function's only job is to sequence those and write the outcome down.
|
|
8
|
+
*
|
|
9
|
+
* The checks themselves are injected rather than imported. A monitor run
|
|
10
|
+
* otherwise reaches SSH, the module hook executor, and the audit subsystem,
|
|
11
|
+
* which would make the loop untestable without a live fleet.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { eq } from 'drizzle-orm';
|
|
15
|
+
import type { DbClient } from '../../db/client';
|
|
16
|
+
import { type AlertSeverity, type Monitor, monitorRuns, monitors } from '../../db/schema';
|
|
17
|
+
import type { DriftCategory, DriftFinding } from '../audit/types';
|
|
18
|
+
import type { HealthCheckResult } from '../health-runner';
|
|
19
|
+
import { failingKeysFromFindings } from './builtin-monitors';
|
|
20
|
+
import { HEALTH_COVERAGE_CHECK, healthCoverageFailingKeys } from './health-coverage';
|
|
21
|
+
import type { ModuleCoverageInput } from './health-coverage';
|
|
22
|
+
import {
|
|
23
|
+
type FailingKey,
|
|
24
|
+
builtinMonitorKey,
|
|
25
|
+
failingKeysFromHealthItems,
|
|
26
|
+
moduleAlertKey,
|
|
27
|
+
} from './keys';
|
|
28
|
+
import { reconcile } from './reconcile';
|
|
29
|
+
import { applyReconcileActions, confirmStillFailing, loadLiveAlerts } from './store';
|
|
30
|
+
|
|
31
|
+
export interface MonitorRunDeps {
|
|
32
|
+
/** Run a module's health_check hook unattended. */
|
|
33
|
+
runModuleCheck(moduleId: string): Promise<HealthCheckResult>;
|
|
34
|
+
/** Run one audit category. */
|
|
35
|
+
runBuiltinCheck(category: DriftCategory): Promise<DriftFinding[]>;
|
|
36
|
+
/** Module roster for the health-coverage check. */
|
|
37
|
+
loadModuleCoverage(): ModuleCoverageInput[];
|
|
38
|
+
now(): Date;
|
|
39
|
+
/** Grace window before a new alert may notify. */
|
|
40
|
+
graceMs: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MonitorRunOutcomeSummary {
|
|
44
|
+
monitorId: string;
|
|
45
|
+
outcome: 'success' | 'error';
|
|
46
|
+
errorMessage?: string;
|
|
47
|
+
failingKeyCount: number;
|
|
48
|
+
createdIds: string[];
|
|
49
|
+
resolvedIds: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What the check produced, before it becomes alert state.
|
|
54
|
+
*
|
|
55
|
+
* `outcome` is the load-bearing field: `error` means the check could not run,
|
|
56
|
+
* which says nothing about any individual key and must never resolve anything.
|
|
57
|
+
*/
|
|
58
|
+
interface CheckProduct {
|
|
59
|
+
outcome: 'success' | 'error';
|
|
60
|
+
failingKeys: FailingKey[];
|
|
61
|
+
errorMessage?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runCheck(
|
|
65
|
+
monitor: Monitor,
|
|
66
|
+
deps: MonitorRunDeps,
|
|
67
|
+
severity: AlertSeverity,
|
|
68
|
+
): Promise<CheckProduct> {
|
|
69
|
+
if (monitor.kind === 'module_hook') {
|
|
70
|
+
const result = await deps.runModuleCheck(monitor.target);
|
|
71
|
+
|
|
72
|
+
// `error` is the hook failing to execute. `no-checks` means the module
|
|
73
|
+
// declares no health_check hook — nothing ran, but nothing was expected to,
|
|
74
|
+
// so it is a successful run with an empty failing set rather than a fault.
|
|
75
|
+
if (result.status === 'error') {
|
|
76
|
+
return { outcome: 'error', failingKeys: [], errorMessage: result.error };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
outcome: 'success',
|
|
80
|
+
failingKeys: failingKeysFromHealthItems(monitor.target, result.checks, severity),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The coverage check reads local state only, so it has no failure mode worth
|
|
85
|
+
// distinguishing from an empty result.
|
|
86
|
+
if (monitor.target === HEALTH_COVERAGE_CHECK) {
|
|
87
|
+
return {
|
|
88
|
+
outcome: 'success',
|
|
89
|
+
failingKeys: healthCoverageFailingKeys(deps.loadModuleCoverage()),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const category = monitor.target as DriftCategory;
|
|
94
|
+
try {
|
|
95
|
+
const findings = await deps.runBuiltinCheck(category);
|
|
96
|
+
return {
|
|
97
|
+
outcome: 'success',
|
|
98
|
+
failingKeys: failingKeysFromFindings(category, findings, severity),
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
outcome: 'error',
|
|
103
|
+
failingKeys: [],
|
|
104
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The key meaning "this monitor could not run". */
|
|
110
|
+
export function monitorLevelKeyFor(monitor: Monitor): string {
|
|
111
|
+
return monitor.kind === 'module_hook'
|
|
112
|
+
? moduleAlertKey(monitor.target)
|
|
113
|
+
: builtinMonitorKey(monitor.target);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Run one monitor and apply its consequences.
|
|
118
|
+
*
|
|
119
|
+
* Records the run's outcome before anything else derived from it, so that a
|
|
120
|
+
* crash between checking and reconciling leaves evidence of what happened
|
|
121
|
+
* rather than a silent gap.
|
|
122
|
+
*/
|
|
123
|
+
export async function runOneMonitor(
|
|
124
|
+
db: DbClient,
|
|
125
|
+
monitor: Monitor,
|
|
126
|
+
deps: MonitorRunDeps,
|
|
127
|
+
): Promise<MonitorRunOutcomeSummary> {
|
|
128
|
+
const now = deps.now();
|
|
129
|
+
const product = await runCheck(monitor, deps, monitor.severity);
|
|
130
|
+
|
|
131
|
+
db.insert(monitorRuns)
|
|
132
|
+
.values({
|
|
133
|
+
monitorId: monitor.id,
|
|
134
|
+
ranAt: now,
|
|
135
|
+
outcome: product.outcome,
|
|
136
|
+
errorMessage: product.errorMessage,
|
|
137
|
+
})
|
|
138
|
+
.run();
|
|
139
|
+
|
|
140
|
+
const liveAlerts = loadLiveAlerts(db, monitor.id);
|
|
141
|
+
const actions = reconcile({
|
|
142
|
+
liveAlerts,
|
|
143
|
+
outcome: product.outcome,
|
|
144
|
+
failingKeys: product.failingKeys,
|
|
145
|
+
monitorLevelKey: monitorLevelKeyFor(monitor),
|
|
146
|
+
errorMessage: product.errorMessage,
|
|
147
|
+
monitorSeverity: monitor.severity,
|
|
148
|
+
now,
|
|
149
|
+
graceMs: deps.graceMs,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const { createdIds, resolvedIds } = applyReconcileActions(db, actions, {
|
|
153
|
+
monitorId: monitor.id,
|
|
154
|
+
escalationPolicyId: monitor.escalationPolicyId,
|
|
155
|
+
now,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// A successful run that still reports a key confirms any alert waiting on
|
|
159
|
+
// confirmation after un-suppression. Only a run that EXECUTED may do this —
|
|
160
|
+
// an errored run has confirmed nothing.
|
|
161
|
+
if (product.outcome === 'success') {
|
|
162
|
+
const stillFailing = new Set(product.failingKeys.map((f) => f.key));
|
|
163
|
+
const confirmable = liveAlerts.filter((a) => stillFailing.has(a.key)).map((a) => a.id);
|
|
164
|
+
confirmStillFailing(db, confirmable, now);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
db.update(monitors).set({ lastRunAt: now }).where(eq(monitors.id, monitor.id)).run();
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
monitorId: monitor.id,
|
|
171
|
+
outcome: product.outcome,
|
|
172
|
+
errorMessage: product.errorMessage,
|
|
173
|
+
failingKeyCount: product.failingKeys.length,
|
|
174
|
+
createdIds,
|
|
175
|
+
resolvedIds,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import type { DbClient } from '../../db/client';
|
|
7
|
+
import { alerts, monitors } from '../../db/schema';
|
|
8
|
+
import { setupTestDatabase } from '../../test-utils/setup-test-db';
|
|
9
|
+
import { moduleAlertKey, moduleCheckAlertKey } from './keys';
|
|
10
|
+
import type { ReconcileAction } from './reconcile';
|
|
11
|
+
import {
|
|
12
|
+
applyReconcileActions,
|
|
13
|
+
confirmStillFailing,
|
|
14
|
+
loadLiveAlerts,
|
|
15
|
+
markSuppressed,
|
|
16
|
+
markUnsuppressed,
|
|
17
|
+
promoteReadyAlerts,
|
|
18
|
+
summariseByModule,
|
|
19
|
+
} from './store';
|
|
20
|
+
|
|
21
|
+
const NOW = new Date('2026-07-28T03:00:00Z');
|
|
22
|
+
const LATER = new Date('2026-07-28T03:20:00Z');
|
|
23
|
+
const MONITOR = 'mon-1';
|
|
24
|
+
const DISK = moduleCheckAlertKey('caddy', 'disk-space');
|
|
25
|
+
|
|
26
|
+
describe('alert store', () => {
|
|
27
|
+
let dir: string;
|
|
28
|
+
let db: DbClient;
|
|
29
|
+
|
|
30
|
+
const context = { monitorId: MONITOR, escalationPolicyId: null, now: NOW };
|
|
31
|
+
|
|
32
|
+
const create = (key: string): ReconcileAction => ({
|
|
33
|
+
type: 'create',
|
|
34
|
+
key,
|
|
35
|
+
severity: 'critical',
|
|
36
|
+
message: 'bad',
|
|
37
|
+
graceUntil: NOW,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
dir = mkdtempSync(join(tmpdir(), 'alert-store-'));
|
|
42
|
+
const dbPath = join(dir, 'celilo.db');
|
|
43
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
44
|
+
db = await setupTestDatabase(dbPath);
|
|
45
|
+
db.insert(monitors)
|
|
46
|
+
.values({
|
|
47
|
+
id: MONITOR,
|
|
48
|
+
kind: 'module_hook',
|
|
49
|
+
target: 'caddy',
|
|
50
|
+
intervalMinutes: 15,
|
|
51
|
+
severity: 'critical',
|
|
52
|
+
})
|
|
53
|
+
.run();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
db.$client.close();
|
|
58
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
59
|
+
try {
|
|
60
|
+
rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
} catch {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('create inserts a live alert discoverable by loadLiveAlerts', () => {
|
|
67
|
+
const { createdIds } = applyReconcileActions(db, [create(DISK)], context);
|
|
68
|
+
expect(createdIds).toHaveLength(1);
|
|
69
|
+
expect(loadLiveAlerts(db, MONITOR)).toEqual([{ id: createdIds[0], key: DISK }]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('refresh updates message and severity without changing identity', () => {
|
|
73
|
+
const { createdIds } = applyReconcileActions(db, [create(DISK)], context);
|
|
74
|
+
applyReconcileActions(
|
|
75
|
+
db,
|
|
76
|
+
[
|
|
77
|
+
{
|
|
78
|
+
type: 'refresh',
|
|
79
|
+
alertId: createdIds[0],
|
|
80
|
+
key: DISK,
|
|
81
|
+
severity: 'warning',
|
|
82
|
+
message: '/var 80% used',
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
{ ...context, now: LATER },
|
|
86
|
+
);
|
|
87
|
+
const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get();
|
|
88
|
+
expect(row?.message).toBe('/var 80% used');
|
|
89
|
+
expect(row?.severity).toBe('warning');
|
|
90
|
+
expect(row?.lastSeenAt).toEqual(LATER);
|
|
91
|
+
expect(row?.firstFiredAt).toEqual(NOW);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('resolve clears activeKey so the alert leaves the live set', () => {
|
|
95
|
+
const { createdIds } = applyReconcileActions(db, [create(DISK)], context);
|
|
96
|
+
applyReconcileActions(db, [{ type: 'resolve', alertId: createdIds[0], key: DISK }], context);
|
|
97
|
+
expect(loadLiveAlerts(db, MONITOR)).toEqual([]);
|
|
98
|
+
const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get();
|
|
99
|
+
expect(row?.state).toBe('resolved');
|
|
100
|
+
expect(row?.activeKey).toBeNull();
|
|
101
|
+
expect(row?.resolvedAt).toEqual(NOW);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// The whole point of the activeKey/index pairing: history accumulates, but
|
|
105
|
+
// only one row per key is ever live.
|
|
106
|
+
test('a key can re-fire after resolving, and both rows persist', () => {
|
|
107
|
+
const first = applyReconcileActions(db, [create(DISK)], context);
|
|
108
|
+
applyReconcileActions(
|
|
109
|
+
db,
|
|
110
|
+
[{ type: 'resolve', alertId: first.createdIds[0], key: DISK }],
|
|
111
|
+
context,
|
|
112
|
+
);
|
|
113
|
+
const second = applyReconcileActions(db, [create(DISK)], { ...context, now: LATER });
|
|
114
|
+
|
|
115
|
+
expect(second.createdIds[0]).not.toBe(first.createdIds[0]);
|
|
116
|
+
expect(loadLiveAlerts(db, MONITOR)).toEqual([{ id: second.createdIds[0], key: DISK }]);
|
|
117
|
+
const all = db.select().from(alerts).where(eq(alerts.key, DISK)).all();
|
|
118
|
+
expect(all).toHaveLength(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('two live alerts for one key are rejected by the index', () => {
|
|
122
|
+
applyReconcileActions(db, [create(DISK)], context);
|
|
123
|
+
expect(() => applyReconcileActions(db, [create(DISK)], context)).toThrow();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('suppression is recorded with its cause', () => {
|
|
127
|
+
const { createdIds } = applyReconcileActions(db, [create(DISK)], context);
|
|
128
|
+
markSuppressed(db, createdIds[0], { alertId: 'ancestor-1' });
|
|
129
|
+
const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get();
|
|
130
|
+
expect(row?.state).toBe('suppressed');
|
|
131
|
+
expect(row?.suppressedByAlertId).toBe('ancestor-1');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Un-suppression must not page immediately, and must not fire every step
|
|
135
|
+
// whose delay elapsed while suppressed.
|
|
136
|
+
test('un-suppression sets awaitingConfirmation and restarts the clock', () => {
|
|
137
|
+
const { createdIds } = applyReconcileActions(db, [create(DISK)], context);
|
|
138
|
+
markSuppressed(db, createdIds[0], { alertId: 'ancestor-1' });
|
|
139
|
+
markUnsuppressed(db, createdIds[0], LATER);
|
|
140
|
+
|
|
141
|
+
const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get();
|
|
142
|
+
expect(row?.state).toBe('firing');
|
|
143
|
+
expect(row?.suppressedByAlertId).toBeNull();
|
|
144
|
+
expect(row?.awaitingConfirmation).toBe(true);
|
|
145
|
+
expect(row?.unsuppressedAt).toEqual(LATER);
|
|
146
|
+
expect(row?.escalationStep).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('a confirming run clears awaitingConfirmation', () => {
|
|
150
|
+
const { createdIds } = applyReconcileActions(db, [create(DISK)], context);
|
|
151
|
+
markUnsuppressed(db, createdIds[0], LATER);
|
|
152
|
+
confirmStillFailing(db, createdIds, LATER);
|
|
153
|
+
const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get();
|
|
154
|
+
expect(row?.awaitingConfirmation).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('confirming an empty list is a no-op', () => {
|
|
158
|
+
expect(() => confirmStillFailing(db, [], NOW)).not.toThrow();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('promoteReadyAlerts', () => {
|
|
162
|
+
// Nothing else writes pending -> firing, so without this every alert reads
|
|
163
|
+
// as pending forever.
|
|
164
|
+
test('a pending alert past its grace window becomes firing', () => {
|
|
165
|
+
applyReconcileActions(db, [create(DISK)], context);
|
|
166
|
+
expect(promoteReadyAlerts(db, LATER)).toBe(1);
|
|
167
|
+
const row = db.select().from(alerts).where(eq(alerts.key, DISK)).get();
|
|
168
|
+
expect(row?.state).toBe('firing');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a pending alert inside its grace window is left alone', () => {
|
|
172
|
+
applyReconcileActions(
|
|
173
|
+
db,
|
|
174
|
+
[{ type: 'create', key: DISK, severity: 'critical', message: 'bad', graceUntil: LATER }],
|
|
175
|
+
context,
|
|
176
|
+
);
|
|
177
|
+
expect(promoteReadyAlerts(db, NOW)).toBe(0);
|
|
178
|
+
const row = db.select().from(alerts).where(eq(alerts.key, DISK)).get();
|
|
179
|
+
expect(row?.state).toBe('pending');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Promoting these would lose the record of what explains them.
|
|
183
|
+
test('a suppressed alert is not promoted', () => {
|
|
184
|
+
const { createdIds } = applyReconcileActions(db, [create(DISK)], context);
|
|
185
|
+
markSuppressed(db, createdIds[0], { alertId: 'ancestor-1' });
|
|
186
|
+
expect(promoteReadyAlerts(db, LATER)).toBe(0);
|
|
187
|
+
const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get();
|
|
188
|
+
expect(row?.state).toBe('suppressed');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('promotion is idempotent', () => {
|
|
192
|
+
applyReconcileActions(db, [create(DISK)], context);
|
|
193
|
+
expect(promoteReadyAlerts(db, LATER)).toBe(1);
|
|
194
|
+
expect(promoteReadyAlerts(db, LATER)).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('summariseByModule', () => {
|
|
200
|
+
const row = (key: string, state: 'firing' | 'suppressed') =>
|
|
201
|
+
({ key, severity: 'critical', state }) as never;
|
|
202
|
+
|
|
203
|
+
test('groups item and module keys under the module', () => {
|
|
204
|
+
const summary = summariseByModule([
|
|
205
|
+
row(moduleCheckAlertKey('caddy', 'disk'), 'firing'),
|
|
206
|
+
row(moduleAlertKey('caddy'), 'firing'),
|
|
207
|
+
row(moduleAlertKey('forgejo'), 'firing'),
|
|
208
|
+
]);
|
|
209
|
+
expect(summary.get('caddy')).toHaveLength(2);
|
|
210
|
+
expect(summary.get('forgejo')).toHaveLength(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('marks suppressed alerts so the column can say so', () => {
|
|
214
|
+
const summary = summariseByModule([row(moduleAlertKey('caddy'), 'suppressed')]);
|
|
215
|
+
expect(summary.get('caddy')?.[0].suppressed).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('ignores built-in alerts, which are not module-scoped', () => {
|
|
219
|
+
const summary = summariseByModule([row('builtin:machines_reachable/machine:iot', 'firing')]);
|
|
220
|
+
expect(summary.size).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
});
|