@celilo/cli 0.13.3 → 0.14.1

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.
Files changed (88) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +71 -2
  3. package/docs/ALERTING.md +298 -0
  4. package/docs/INDEX.md +103 -0
  5. package/drizzle/0016_trusted_sources.sql +10 -0
  6. package/drizzle/0017_alerting.sql +127 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +3 -2
  9. package/schemas/system_config.json +9 -0
  10. package/src/capabilities/well-known.ts +11 -1
  11. package/src/cli/commands/alerts-act.ts +107 -0
  12. package/src/cli/commands/alerts-list.ts +62 -0
  13. package/src/cli/commands/alerts-poll.ts +129 -0
  14. package/src/cli/commands/alerts-sweep.ts +156 -0
  15. package/src/cli/commands/module-list.ts +50 -3
  16. package/src/cli/commands/module-show.ts +11 -3
  17. package/src/cli/commands/monitor.ts +178 -0
  18. package/src/cli/commands/notify-config.ts +453 -0
  19. package/src/cli/commands/system-audit.ts +2 -0
  20. package/src/cli/commands/system-update.ts +1 -0
  21. package/src/cli/completion.ts +26 -0
  22. package/src/cli/generate-zsh-completion.ts +2 -0
  23. package/src/cli/index.ts +58 -0
  24. package/src/cli/tui/audit-state.ts +2 -0
  25. package/src/db/schema.ts +371 -2
  26. package/src/hooks/capability-loader.ts +158 -46
  27. package/src/hooks/capability-map-coverage.test.ts +101 -0
  28. package/src/manifest/schema.ts +77 -4
  29. package/src/services/alerting/ack.test.ts +212 -0
  30. package/src/services/alerting/ack.ts +119 -0
  31. package/src/services/alerting/builtin-monitors.test.ts +132 -0
  32. package/src/services/alerting/builtin-monitors.ts +84 -0
  33. package/src/services/alerting/builtin-source.ts +82 -0
  34. package/src/services/alerting/coverage-source.ts +38 -0
  35. package/src/services/alerting/deferral.test.ts +161 -0
  36. package/src/services/alerting/delivery-loop.test.ts +396 -0
  37. package/src/services/alerting/deploy-hooks.test.ts +125 -0
  38. package/src/services/alerting/deploy-hooks.ts +111 -0
  39. package/src/services/alerting/escalation.test.ts +207 -0
  40. package/src/services/alerting/escalation.ts +151 -0
  41. package/src/services/alerting/format.test.ts +193 -0
  42. package/src/services/alerting/format.ts +150 -0
  43. package/src/services/alerting/health-coverage.ts +81 -0
  44. package/src/services/alerting/inbound-poller.test.ts +298 -0
  45. package/src/services/alerting/inbound-poller.ts +236 -0
  46. package/src/services/alerting/inbound.test.ts +201 -0
  47. package/src/services/alerting/inbound.ts +112 -0
  48. package/src/services/alerting/interview-responder.test.ts +169 -0
  49. package/src/services/alerting/interview-responder.ts +158 -0
  50. package/src/services/alerting/keys.test.ts +155 -0
  51. package/src/services/alerting/keys.ts +190 -0
  52. package/src/services/alerting/monitors.ts +185 -0
  53. package/src/services/alerting/notification-responder.test.ts +290 -0
  54. package/src/services/alerting/notification-responder.ts +260 -0
  55. package/src/services/alerting/notifier.ts +219 -0
  56. package/src/services/alerting/people.ts +178 -0
  57. package/src/services/alerting/quiet-hours.test.ts +140 -0
  58. package/src/services/alerting/quiet-hours.ts +99 -0
  59. package/src/services/alerting/reconcile.test.ts +190 -0
  60. package/src/services/alerting/reconcile.ts +166 -0
  61. package/src/services/alerting/run-monitor.test.ts +185 -0
  62. package/src/services/alerting/run-monitor.ts +177 -0
  63. package/src/services/alerting/store.test.ts +222 -0
  64. package/src/services/alerting/store.ts +289 -0
  65. package/src/services/alerting/suppression.test.ts +228 -0
  66. package/src/services/alerting/suppression.ts +142 -0
  67. package/src/services/alerting/sweep-runner.test.ts +229 -0
  68. package/src/services/alerting/sweep-runner.ts +204 -0
  69. package/src/services/alerting/sweep.test.ts +61 -0
  70. package/src/services/alerting/sweep.ts +41 -0
  71. package/src/services/alerting/tokens.test.ts +152 -0
  72. package/src/services/alerting/tokens.ts +119 -0
  73. package/src/services/alerting/transport-loader.ts +48 -0
  74. package/src/services/audit/index.test.ts +1 -0
  75. package/src/services/audit/index.ts +3 -0
  76. package/src/services/audit/trusted-sources.test.ts +137 -0
  77. package/src/services/audit/trusted-sources.ts +124 -0
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/firewall-reach.ts +83 -0
  80. package/src/services/health-runner.test.ts +50 -0
  81. package/src/services/health-runner.ts +116 -82
  82. package/src/services/machine-pool.ts +2 -1
  83. package/src/services/module-deploy.ts +17 -0
  84. package/src/services/system-config-validator.test.ts +31 -1
  85. package/src/services/trusted-sources.test.ts +221 -0
  86. package/src/services/trusted-sources.ts +159 -0
  87. package/src/services/update/orchestrator.test.ts +1 -0
  88. package/src/templates/generator.ts +6 -29
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Alert persistence — applying reconciler decisions to the alerts table.
3
+ *
4
+ * Kept separate from `reconcile.ts` on purpose: the decisions are pure and
5
+ * exhaustively tested without a database, and this layer only has to get the
6
+ * writes right. The one piece of judgement here is `activeKey`, which must
7
+ * move in lockstep with `state` or the "one live alert per key" index either
8
+ * rejects legitimate re-fires or permits duplicates.
9
+ */
10
+
11
+ import { randomUUID } from 'node:crypto';
12
+ import { and, eq, inArray, isNotNull, lte } from 'drizzle-orm';
13
+ import type { DbClient } from '../../db/client';
14
+ import { type Alert, type AlertSeverity, alerts } from '../../db/schema';
15
+ import type { LiveAlert, ReconcileAction } from './reconcile';
16
+
17
+ /** Alert states that are still "live" — i.e. not resolved. */
18
+ const LIVE_STATES = ['pending', 'firing', 'acked', 'suppressed'] as const;
19
+
20
+ /** Load the alerts a monitor currently owns that have not resolved. */
21
+ export function loadLiveAlerts(db: DbClient, monitorId: string): LiveAlert[] {
22
+ return db
23
+ .select({ id: alerts.id, key: alerts.key })
24
+ .from(alerts)
25
+ .where(and(eq(alerts.monitorId, monitorId), isNotNull(alerts.activeKey)))
26
+ .all();
27
+ }
28
+
29
+ export interface ApplyContext {
30
+ monitorId: string;
31
+ escalationPolicyId: string | null;
32
+ now: Date;
33
+ }
34
+
35
+ /**
36
+ * Apply reconciler actions.
37
+ *
38
+ * Returns the ids of alerts created and resolved, so the caller can drive
39
+ * notification (a resolve fans out an all-clear only to routes that were
40
+ * actually told about the alert).
41
+ */
42
+ export function applyReconcileActions(
43
+ db: DbClient,
44
+ actions: ReconcileAction[],
45
+ context: ApplyContext,
46
+ ): { createdIds: string[]; resolvedIds: string[] } {
47
+ const createdIds: string[] = [];
48
+ const resolvedIds: string[] = [];
49
+
50
+ for (const action of actions) {
51
+ if (action.type === 'create') {
52
+ const id = randomUUID();
53
+ db.insert(alerts)
54
+ .values({
55
+ id,
56
+ key: action.key,
57
+ // Mirrors `key` while live; NULLed on resolve. This is what the
58
+ // unique index keys off — SQLite treats NULLs as distinct, so any
59
+ // number of resolved rows may share a key while at most one live
60
+ // row may hold it.
61
+ activeKey: action.key,
62
+ monitorId: context.monitorId,
63
+ state: 'pending',
64
+ severity: action.severity,
65
+ firstFiredAt: context.now,
66
+ lastSeenAt: context.now,
67
+ graceUntil: action.graceUntil,
68
+ escalationStep: 0,
69
+ escalationPolicyId: context.escalationPolicyId,
70
+ message: action.message,
71
+ details: action.details,
72
+ })
73
+ .run();
74
+ createdIds.push(id);
75
+ continue;
76
+ }
77
+
78
+ if (action.type === 'refresh') {
79
+ // Severity and message can change between runs (a warning becoming a
80
+ // failure, a percentage climbing); the alert's identity does not.
81
+ db.update(alerts)
82
+ .set({
83
+ lastSeenAt: context.now,
84
+ severity: action.severity,
85
+ message: action.message,
86
+ details: action.details,
87
+ })
88
+ .where(eq(alerts.id, action.alertId))
89
+ .run();
90
+ continue;
91
+ }
92
+
93
+ db.update(alerts)
94
+ .set({ state: 'resolved', activeKey: null, resolvedAt: context.now })
95
+ .where(eq(alerts.id, action.alertId))
96
+ .run();
97
+ resolvedIds.push(action.alertId);
98
+ }
99
+
100
+ return { createdIds, resolvedIds };
101
+ }
102
+
103
+ /**
104
+ * Record that an alert has become suppressed by an ancestor alert or a deploy
105
+ * window. Idempotent: re-suppressing an already-suppressed alert is a no-op
106
+ * rather than resetting its bookkeeping.
107
+ */
108
+ export function markSuppressed(
109
+ db: DbClient,
110
+ alertId: string,
111
+ by: { alertId?: string; windowId?: string },
112
+ ): void {
113
+ db.update(alerts)
114
+ .set({
115
+ state: 'suppressed',
116
+ suppressedByAlertId: by.alertId ?? null,
117
+ suppressedByWindowId: by.windowId ?? null,
118
+ })
119
+ .where(eq(alerts.id, alertId))
120
+ .run();
121
+ }
122
+
123
+ /**
124
+ * Lift suppression.
125
+ *
126
+ * Two things happen together, and both matter:
127
+ *
128
+ * - `awaitingConfirmation` is set, so the alert does NOT notify until a
129
+ * subsequent run confirms it is still failing. When a machine comes back,
130
+ * its modules almost always came back with it; paging for them would
131
+ * recreate the storm suppression just prevented.
132
+ * - the escalation clock is restarted from now. Running it from the original
133
+ * fire time would fire every step whose delay had nominally elapsed during
134
+ * the suppression, all at once.
135
+ */
136
+ export function markUnsuppressed(db: DbClient, alertId: string, now: Date): void {
137
+ db.update(alerts)
138
+ .set({
139
+ state: 'firing',
140
+ suppressedByAlertId: null,
141
+ suppressedByWindowId: null,
142
+ unsuppressedAt: now,
143
+ awaitingConfirmation: true,
144
+ escalationStep: 0,
145
+ nextEscalationAt: now,
146
+ })
147
+ .where(eq(alerts.id, alertId))
148
+ .run();
149
+ }
150
+
151
+ /**
152
+ * Clear `awaitingConfirmation` for alerts a SUCCESSFUL run has just confirmed
153
+ * are still failing. Only a run that executed may clear it — that is the same
154
+ * asymmetry the reconciler rests on, applied to a different column.
155
+ */
156
+ export function confirmStillFailing(db: DbClient, alertIds: string[], now: Date): void {
157
+ if (alertIds.length === 0) return;
158
+ db.update(alerts)
159
+ .set({ awaitingConfirmation: false, nextEscalationAt: now })
160
+ .where(inArray(alerts.id, alertIds))
161
+ .run();
162
+ }
163
+
164
+ /**
165
+ * Promote `pending` alerts whose grace window has elapsed to `firing`.
166
+ *
167
+ * Without this an alert never leaves `pending`, because nothing else writes
168
+ * that transition — `reconcile` only creates and resolves. Escalation would
169
+ * still behave correctly (it compares `graceUntil` directly), but every alert
170
+ * would read as `pending` forever in `celilo alerts` and in the health column,
171
+ * which is a lie about the system's state even though nothing downstream acts
172
+ * on it.
173
+ *
174
+ * Suppressed alerts are left alone: they are already accounted for, and moving
175
+ * them to `firing` would lose the record of what explains them.
176
+ */
177
+ export function promoteReadyAlerts(db: DbClient, now: Date): number {
178
+ const ready = db
179
+ .select({ id: alerts.id })
180
+ .from(alerts)
181
+ .where(and(eq(alerts.state, 'pending'), lte(alerts.graceUntil, now)))
182
+ .all();
183
+
184
+ if (ready.length === 0) return 0;
185
+ db.update(alerts)
186
+ .set({ state: 'firing' })
187
+ .where(
188
+ inArray(
189
+ alerts.id,
190
+ ready.map((r) => r.id),
191
+ ),
192
+ )
193
+ .run();
194
+ return ready.length;
195
+ }
196
+
197
+ /**
198
+ * Record that an escalation step was taken.
199
+ *
200
+ * Called for a delivered message AND for one deferred by quiet hours, because
201
+ * the step is what advances, not the message (D13). Without this the sweep
202
+ * would re-decide the same step every five minutes and never reach the
203
+ * secondary.
204
+ */
205
+ export function recordStepTaken(
206
+ db: DbClient,
207
+ alertId: string,
208
+ taken: { stepIndex: number; nextStepDueAt: Date | null },
209
+ ): void {
210
+ db.update(alerts)
211
+ .set({ escalationStep: taken.stepIndex + 1, nextEscalationAt: taken.nextStepDueAt })
212
+ .where(eq(alerts.id, alertId))
213
+ .run();
214
+ }
215
+
216
+ /**
217
+ * Hold a message until someone's quiet hours end.
218
+ *
219
+ * Keeps the EARLIEST pending deferral rather than the latest: if a second step
220
+ * defers behind a longer window, the operator still hears about it as soon as
221
+ * anyone is reachable.
222
+ */
223
+ export function deferNotification(
224
+ db: DbClient,
225
+ alertId: string,
226
+ deferral: { routeId: string; until: Date },
227
+ ): void {
228
+ const current = db
229
+ .select({ until: alerts.deferredUntil })
230
+ .from(alerts)
231
+ .where(eq(alerts.id, alertId))
232
+ .get();
233
+ if (current?.until && current.until <= deferral.until) return;
234
+
235
+ db.update(alerts)
236
+ .set({ deferredUntil: deferral.until, deferredRouteId: deferral.routeId })
237
+ .where(eq(alerts.id, alertId))
238
+ .run();
239
+ }
240
+
241
+ /** Alerts whose deferral window has ended and are owed a message. */
242
+ export function dueDeferrals(db: DbClient, now: Date): Alert[] {
243
+ return db
244
+ .select()
245
+ .from(alerts)
246
+ .where(and(inArray(alerts.state, [...LIVE_STATES]), lte(alerts.deferredUntil, now)))
247
+ .all();
248
+ }
249
+
250
+ export function clearDeferral(db: DbClient, alertId: string): void {
251
+ db.update(alerts)
252
+ .set({ deferredUntil: null, deferredRouteId: null })
253
+ .where(eq(alerts.id, alertId))
254
+ .run();
255
+ }
256
+
257
+ /** Every live alert, for the notify sweep and the health column. */
258
+ export function loadAllLiveAlerts(db: DbClient): Alert[] {
259
+ return db
260
+ .select()
261
+ .from(alerts)
262
+ .where(inArray(alerts.state, [...LIVE_STATES]))
263
+ .all();
264
+ }
265
+
266
+ export interface AlertSummary {
267
+ key: string;
268
+ severity: AlertSeverity;
269
+ suppressed: boolean;
270
+ }
271
+
272
+ /** Live alerts grouped by module id, for the `celilo module list` column. */
273
+ export function summariseByModule(liveAlerts: Alert[]): Map<string, AlertSummary[]> {
274
+ const byModule = new Map<string, AlertSummary[]>();
275
+ for (const alert of liveAlerts) {
276
+ if (!alert.key.startsWith('module:')) continue;
277
+ const rest = alert.key.slice('module:'.length);
278
+ const moduleId = rest.split('/')[0];
279
+ if (!moduleId) continue;
280
+ const list = byModule.get(moduleId) ?? [];
281
+ list.push({
282
+ key: alert.key,
283
+ severity: alert.severity,
284
+ suppressed: alert.state === 'suppressed',
285
+ });
286
+ byModule.set(moduleId, list);
287
+ }
288
+ return byModule;
289
+ }
@@ -0,0 +1,228 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { moduleAlertKey, moduleCheckAlertKey } from './keys';
3
+ import {
4
+ type SuppressionTopology,
5
+ ancestorKeysFor,
6
+ findSuppressor,
7
+ machineAlertKey,
8
+ } from './suppression';
9
+
10
+ /**
11
+ * Six modules on machine `iot`, plus a resolver providing dns_internal for the
12
+ * `internal` zone, plus caddy off in the dmz on a container.
13
+ */
14
+ const TOPOLOGY: SuppressionTopology = {
15
+ moduleSystems: [
16
+ ...['homebridge', 'module-b', 'module-c', 'module-d', 'module-e', 'module-f'].map(
17
+ (moduleId) => ({
18
+ moduleId,
19
+ hostname: 'iot',
20
+ zone: 'internal',
21
+ infraType: 'machine' as const,
22
+ }),
23
+ ),
24
+ { moduleId: 'technitium', hostname: 'dns', zone: 'internal', infraType: 'machine' as const },
25
+ {
26
+ moduleId: 'caddy',
27
+ hostname: 'caddy-1',
28
+ zone: 'dmz',
29
+ infraType: 'container_service' as const,
30
+ },
31
+ ],
32
+ zoneProviders: [{ moduleId: 'technitium', capabilityName: 'dns_internal', zones: ['internal'] }],
33
+ };
34
+
35
+ const noSuppression = {
36
+ suppressible: true,
37
+ modulesInDeployWindow: new Set<string>(),
38
+ topology: TOPOLOGY,
39
+ };
40
+
41
+ describe('ancestorKeysFor', () => {
42
+ test('a check item is owned by its module, nearest first', () => {
43
+ const ancestors = ancestorKeysFor(moduleCheckAlertKey('homebridge', 'port'), TOPOLOGY);
44
+ expect(ancestors[0]).toBe(moduleAlertKey('homebridge'));
45
+ });
46
+
47
+ test('a module is owned by the machine it runs on', () => {
48
+ expect(ancestorKeysFor(moduleAlertKey('homebridge'), TOPOLOGY)).toContain(
49
+ machineAlertKey('iot'),
50
+ );
51
+ });
52
+
53
+ test('a module is owned by a zone-capability provider covering its zone', () => {
54
+ expect(ancestorKeysFor(moduleAlertKey('homebridge'), TOPOLOGY)).toContain(
55
+ moduleAlertKey('technitium'),
56
+ );
57
+ });
58
+
59
+ // Without this the resolver, whose own check fails BECAUSE dns is down,
60
+ // silences its own alert — and the one thing naming the cause vanishes.
61
+ test('a zone provider is not its own ancestor', () => {
62
+ expect(ancestorKeysFor(moduleAlertKey('technitium'), TOPOLOGY)).not.toContain(
63
+ moduleAlertKey('technitium'),
64
+ );
65
+ });
66
+
67
+ test('a module in another zone is unaffected by that zone provider', () => {
68
+ expect(ancestorKeysFor(moduleAlertKey('caddy'), TOPOLOGY)).not.toContain(
69
+ moduleAlertKey('technitium'),
70
+ );
71
+ });
72
+
73
+ // machines_reachable probes the machine POOL, so a container has no
74
+ // corresponding alert. A real gap, documented rather than papered over.
75
+ test('a container-hosted module gets no machine ancestor', () => {
76
+ const ancestors = ancestorKeysFor(moduleAlertKey('caddy'), TOPOLOGY);
77
+ expect(ancestors.filter((k) => k.startsWith('builtin:machines_reachable'))).toEqual([]);
78
+ });
79
+
80
+ test('a built-in alert is a root cause with no ancestors', () => {
81
+ expect(ancestorKeysFor(machineAlertKey('iot'), TOPOLOGY)).toEqual([]);
82
+ });
83
+
84
+ test('an unparseable key yields no ancestors rather than throwing', () => {
85
+ expect(ancestorKeysFor('not-a-key', TOPOLOGY)).toEqual([]);
86
+ });
87
+ });
88
+
89
+ describe('findSuppressor — six pages become one', () => {
90
+ test('every module on a downed machine is suppressed', () => {
91
+ const firingKeys = new Set([machineAlertKey('iot')]);
92
+ const moduleIds = ['homebridge', 'module-b', 'module-c', 'module-d', 'module-e', 'module-f'];
93
+ for (const moduleId of moduleIds) {
94
+ expect(
95
+ findSuppressor({ key: moduleAlertKey(moduleId), firingKeys, ...noSuppression }),
96
+ ).toEqual({
97
+ kind: 'alert',
98
+ key: machineAlertKey('iot'),
99
+ });
100
+ }
101
+ });
102
+
103
+ test('their check items are suppressed too', () => {
104
+ const firingKeys = new Set([machineAlertKey('iot')]);
105
+ const suppressor = findSuppressor({
106
+ key: moduleCheckAlertKey('homebridge', 'port'),
107
+ firingKeys,
108
+ ...noSuppression,
109
+ });
110
+ expect(suppressor).toEqual({ kind: 'alert', key: machineAlertKey('iot') });
111
+ });
112
+
113
+ test('the machine alert itself is never suppressed — it is the page you get', () => {
114
+ const firingKeys = new Set([machineAlertKey('iot')]);
115
+ expect(
116
+ findSuppressor({ key: machineAlertKey('iot'), firingKeys, ...noSuppression }),
117
+ ).toBeNull();
118
+ });
119
+
120
+ test('a module on a DIFFERENT machine is not suppressed', () => {
121
+ const firingKeys = new Set([machineAlertKey('iot')]);
122
+ expect(
123
+ findSuppressor({ key: moduleAlertKey('caddy'), firingKeys, ...noSuppression }),
124
+ ).toBeNull();
125
+ });
126
+
127
+ test('the module-level alert suppresses its own check items', () => {
128
+ const firingKeys = new Set([moduleAlertKey('homebridge')]);
129
+ expect(
130
+ findSuppressor({
131
+ key: moduleCheckAlertKey('homebridge', 'port'),
132
+ firingKeys,
133
+ ...noSuppression,
134
+ }),
135
+ ).toEqual({ kind: 'alert', key: moduleAlertKey('homebridge') });
136
+ });
137
+
138
+ test('nothing firing means nothing suppressed', () => {
139
+ expect(
140
+ findSuppressor({
141
+ key: moduleAlertKey('homebridge'),
142
+ firingKeys: new Set(),
143
+ ...noSuppression,
144
+ }),
145
+ ).toBeNull();
146
+ });
147
+
148
+ test('the nearest ancestor is reported as the cause', () => {
149
+ // Both the module-level alert and the machine alert are firing; the module
150
+ // one is the more specific explanation.
151
+ const firingKeys = new Set([moduleAlertKey('homebridge'), machineAlertKey('iot')]);
152
+ expect(
153
+ findSuppressor({
154
+ key: moduleCheckAlertKey('homebridge', 'port'),
155
+ firingKeys,
156
+ ...noSuppression,
157
+ }),
158
+ ).toEqual({ kind: 'alert', key: moduleAlertKey('homebridge') });
159
+ });
160
+ });
161
+
162
+ describe('findSuppressor — dead resolver', () => {
163
+ test('a firing zone provider suppresses modules in its zone', () => {
164
+ const firingKeys = new Set([moduleAlertKey('technitium')]);
165
+ expect(
166
+ findSuppressor({ key: moduleAlertKey('homebridge'), firingKeys, ...noSuppression }),
167
+ ).toEqual({ kind: 'alert', key: moduleAlertKey('technitium') });
168
+ });
169
+
170
+ test('but not itself', () => {
171
+ const firingKeys = new Set([moduleAlertKey('technitium')]);
172
+ expect(
173
+ findSuppressor({ key: moduleAlertKey('technitium'), firingKeys, ...noSuppression }),
174
+ ).toBeNull();
175
+ });
176
+ });
177
+
178
+ describe('findSuppressor — guards', () => {
179
+ // A cascading failure must never silence the component reporting it.
180
+ test('an unsuppressible monitor is never suppressed, whatever is firing', () => {
181
+ const firingKeys = new Set([machineAlertKey('iot'), moduleAlertKey('technitium')]);
182
+ expect(
183
+ findSuppressor({
184
+ key: moduleAlertKey('homebridge'),
185
+ firingKeys,
186
+ suppressible: false,
187
+ modulesInDeployWindow: new Set(),
188
+ topology: TOPOLOGY,
189
+ }),
190
+ ).toBeNull();
191
+ });
192
+
193
+ test('a deploy window suppresses the module being deployed', () => {
194
+ expect(
195
+ findSuppressor({
196
+ key: moduleCheckAlertKey('forgejo', 'http'),
197
+ firingKeys: new Set(),
198
+ suppressible: true,
199
+ modulesInDeployWindow: new Set(['forgejo']),
200
+ topology: TOPOLOGY,
201
+ }),
202
+ ).toEqual({ kind: 'deploy_window', moduleId: 'forgejo' });
203
+ });
204
+
205
+ test('a deploy window does not suppress other modules', () => {
206
+ expect(
207
+ findSuppressor({
208
+ key: moduleAlertKey('homebridge'),
209
+ firingKeys: new Set(),
210
+ suppressible: true,
211
+ modulesInDeployWindow: new Set(['forgejo']),
212
+ topology: TOPOLOGY,
213
+ }),
214
+ ).toBeNull();
215
+ });
216
+
217
+ test('a deploy window outranks an ancestor alert as the explanation', () => {
218
+ expect(
219
+ findSuppressor({
220
+ key: moduleAlertKey('homebridge'),
221
+ firingKeys: new Set([machineAlertKey('iot')]),
222
+ suppressible: true,
223
+ modulesInDeployWindow: new Set(['homebridge']),
224
+ topology: TOPOLOGY,
225
+ }),
226
+ ).toEqual({ kind: 'deploy_window', moduleId: 'homebridge' });
227
+ });
228
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Suppression — deciding which alerts explain other alerts.
3
+ *
4
+ * When a machine drops off the network, every module on it fails its health
5
+ * check. Reporting all of them is not thoroughness, it is six pages at 3am for
6
+ * one fact. Suppression collapses that to the page that names the cause.
7
+ *
8
+ * The edges are DERIVED from deployment state, never configured. celilo already
9
+ * records which modules run on which systems, in which zones, and which modules
10
+ * provide zone-scoped capabilities; asking an operator to restate that would
11
+ * guarantee it drifts out of date, and a stale suppression graph is worse than
12
+ * none — it hides real failures.
13
+ *
14
+ * zone ─ (a module providing a zone-scoped capability, e.g. dns_internal)
15
+ * └── system ─ (builtin:machines_reachable/machine:<hostname>)
16
+ * └── module ─ (module:<id>)
17
+ * └── check item ─ (module:<id>/check:<name>)
18
+ *
19
+ * See openspec/changes/add-alerting/design.md D7.
20
+ */
21
+
22
+ import { builtinAlertKey, moduleAlertKey, parseAlertKey } from './keys';
23
+
24
+ /** The audit category whose findings represent an unreachable system. */
25
+ export const MACHINES_REACHABLE_CHECK = 'machines_reachable';
26
+
27
+ export interface ModuleSystemRow {
28
+ moduleId: string;
29
+ /** User-facing hostname — the identifier a machine alert is keyed by. */
30
+ hostname: string;
31
+ zone: string;
32
+ infraType: 'machine' | 'container_service';
33
+ }
34
+
35
+ export interface ZoneCapabilityProviderRow {
36
+ moduleId: string;
37
+ capabilityName: string;
38
+ /** Zones this capability is scoped to. Empty means zone-agnostic. */
39
+ zones: string[];
40
+ }
41
+
42
+ export interface SuppressionTopology {
43
+ moduleSystems: ModuleSystemRow[];
44
+ zoneProviders: ZoneCapabilityProviderRow[];
45
+ }
46
+
47
+ /** The machine-level key for a system, by its user-facing hostname. */
48
+ export function machineAlertKey(hostname: string): string {
49
+ return builtinAlertKey(MACHINES_REACHABLE_CHECK, 'machine', hostname);
50
+ }
51
+
52
+ /**
53
+ * The keys that, if firing, would explain `key` — nearest ancestor first.
54
+ *
55
+ * Order matters: the nearest firing ancestor is the one reported as the cause,
56
+ * and "caddy's hook could not run" is a better explanation than "something in
57
+ * the dmz is broken" when both are true.
58
+ */
59
+ export function ancestorKeysFor(key: string, topology: SuppressionTopology): string[] {
60
+ const parsed = parseAlertKey(key);
61
+
62
+ // A built-in alert (an unreachable machine, a coverage gap) is already a
63
+ // root cause in this model. Nothing above it explains it.
64
+ if (!parsed || parsed.source !== 'module') return [];
65
+
66
+ const ancestors: string[] = [];
67
+ const moduleId = parsed.moduleId;
68
+
69
+ // 1. The module-level key owns its check items.
70
+ if (parsed.check) ancestors.push(moduleAlertKey(moduleId));
71
+
72
+ const systems = topology.moduleSystems.filter((s) => s.moduleId === moduleId);
73
+
74
+ // 2. The systems the module runs on.
75
+ //
76
+ // Only machine-pool systems: `machines_reachable` probes the machine pool,
77
+ // so a container_service instance has no corresponding alert to be suppressed
78
+ // by. A module hosted only in containers therefore gets no system-level
79
+ // suppression — a real gap, not an oversight, and one that wants a
80
+ // container-reachability check before it can close.
81
+ for (const system of systems) {
82
+ if (system.infraType === 'machine') ancestors.push(machineAlertKey(system.hostname));
83
+ }
84
+
85
+ // 3. Modules providing a zone-scoped capability covering a zone this module
86
+ // sits in — a dead resolver or firewall explains everything behind it.
87
+ const zones = new Set(systems.map((s) => s.zone));
88
+ for (const provider of topology.zoneProviders) {
89
+ // A provider never suppresses itself through its own zone edge. Without
90
+ // this, a resolver whose health check fails BECAUSE dns is down would
91
+ // silence its own alert, and the one thing naming the cause disappears.
92
+ if (provider.moduleId === moduleId) continue;
93
+ if (provider.zones.some((zone) => zones.has(zone))) {
94
+ ancestors.push(moduleAlertKey(provider.moduleId));
95
+ }
96
+ }
97
+
98
+ return ancestors;
99
+ }
100
+
101
+ export interface SuppressorLookup {
102
+ key: string;
103
+ /** Keys of alerts currently firing (candidate suppressors). */
104
+ firingKeys: ReadonlySet<string>;
105
+ /** False for monitors watching the alerting system itself. */
106
+ suppressible: boolean;
107
+ /** Modules currently inside a deploy window. */
108
+ modulesInDeployWindow: ReadonlySet<string>;
109
+ topology: SuppressionTopology;
110
+ }
111
+
112
+ export type Suppressor =
113
+ | { kind: 'alert'; key: string }
114
+ | { kind: 'deploy_window'; moduleId: string };
115
+
116
+ /**
117
+ * Find what is suppressing `key`, or null if it should be reported.
118
+ *
119
+ * Evaluated at NOTIFY time rather than when the alert fires: monitors do not
120
+ * run in a guaranteed order, so a module's check can fail seconds before the
121
+ * machine check that explains it. Deciding at fire time pages for the symptom
122
+ * moments before the cause arrives.
123
+ */
124
+ export function findSuppressor(lookup: SuppressorLookup): Suppressor | null {
125
+ // A self-monitor is never suppressed. A cascading failure must not silence
126
+ // the component reporting the cascade.
127
+ if (!lookup.suppressible) return null;
128
+
129
+ const parsed = parseAlertKey(lookup.key);
130
+
131
+ // A deploy is the same mechanism with a window as the source instead of an
132
+ // ancestor alert — which is why deploy auto-silencing is not a second feature.
133
+ if (parsed?.source === 'module' && lookup.modulesInDeployWindow.has(parsed.moduleId)) {
134
+ return { kind: 'deploy_window', moduleId: parsed.moduleId };
135
+ }
136
+
137
+ for (const ancestor of ancestorKeysFor(lookup.key, lookup.topology)) {
138
+ if (lookup.firingKeys.has(ancestor)) return { kind: 'alert', key: ancestor };
139
+ }
140
+
141
+ return null;
142
+ }