@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.
- 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/capabilities/well-known.ts +11 -1
- 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/module-show.ts +11 -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 +371 -2
- package/src/hooks/capability-loader.ts +158 -46
- package/src/hooks/capability-map-coverage.test.ts +101 -0
- package/src/manifest/schema.ts +77 -4
- 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/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/machine-pool.ts +2 -1
- package/src/services/module-deploy.ts +17 -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,140 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
type QuietHoursWindow,
|
|
4
|
+
isWithinQuietHours,
|
|
5
|
+
localMinutesOfDay,
|
|
6
|
+
parseClockTime,
|
|
7
|
+
quietHoursEndAfter,
|
|
8
|
+
} from './quiet-hours';
|
|
9
|
+
|
|
10
|
+
const LA = 'America/Los_Angeles';
|
|
11
|
+
|
|
12
|
+
/** 22:00–07:00 local — the common overnight window, which wraps midnight. */
|
|
13
|
+
const OVERNIGHT: QuietHoursWindow = { start: '22:00', end: '07:00', timezone: LA };
|
|
14
|
+
/** 09:00–17:00 — a same-day window, which does not wrap. */
|
|
15
|
+
const WORKDAY: QuietHoursWindow = { start: '09:00', end: '17:00', timezone: LA };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A UTC instant for a given LA wall-clock hour (PDT = UTC-7 in July).
|
|
19
|
+
*
|
|
20
|
+
* No modulo on the hour: `Date.UTC` rolls the date forward on overflow, which
|
|
21
|
+
* is what makes 17:00 PDT land on the following UTC day rather than silently
|
|
22
|
+
* on the same one.
|
|
23
|
+
*/
|
|
24
|
+
const laTime = (hour: number, minute = 0) => new Date(Date.UTC(2026, 6, 28, hour + 7, minute));
|
|
25
|
+
|
|
26
|
+
describe('parseClockTime', () => {
|
|
27
|
+
test.each([
|
|
28
|
+
['00:00', 0],
|
|
29
|
+
['07:00', 420],
|
|
30
|
+
['22:30', 1350],
|
|
31
|
+
['23:59', 1439],
|
|
32
|
+
])('parses %p', (value, expected) => {
|
|
33
|
+
expect(parseClockTime(value)).toBe(expected);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test.each(['24:00', '7:00', '22:60', '', 'noon', '22'])('rejects %p', (value) => {
|
|
37
|
+
expect(parseClockTime(value)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('localMinutesOfDay', () => {
|
|
42
|
+
test('converts a UTC instant to local minutes', () => {
|
|
43
|
+
expect(localMinutesOfDay(laTime(23, 30), LA)).toBe(23 * 60 + 30);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('midnight is zero, not 1440', () => {
|
|
47
|
+
expect(localMinutesOfDay(laTime(0), LA)).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('isWithinQuietHours — overnight window (wraps midnight)', () => {
|
|
52
|
+
test.each([
|
|
53
|
+
[23, true, 'after the start, before midnight'],
|
|
54
|
+
[2, true, 'after midnight, before the end'],
|
|
55
|
+
[6, true, 'just before the end'],
|
|
56
|
+
[7, false, 'at the end — exclusive'],
|
|
57
|
+
[12, false, 'midday'],
|
|
58
|
+
[21, false, 'just before the start'],
|
|
59
|
+
[22, true, 'at the start — inclusive'],
|
|
60
|
+
])('%p:00 → %p (%s)', (hour, expected) => {
|
|
61
|
+
expect(isWithinQuietHours(OVERNIGHT, laTime(hour))).toBe(expected);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('isWithinQuietHours — same-day window', () => {
|
|
66
|
+
test.each([
|
|
67
|
+
[8, false],
|
|
68
|
+
[9, true],
|
|
69
|
+
[12, true],
|
|
70
|
+
[16, true],
|
|
71
|
+
[17, false],
|
|
72
|
+
[23, false],
|
|
73
|
+
])('%p:00 → %p', (hour, expected) => {
|
|
74
|
+
expect(isWithinQuietHours(WORKDAY, laTime(hour))).toBe(expected);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('isWithinQuietHours — degenerate configurations', () => {
|
|
79
|
+
test('no window configured means always reachable', () => {
|
|
80
|
+
const none: QuietHoursWindow = { start: null, end: null, timezone: LA };
|
|
81
|
+
expect(isWithinQuietHours(none, laTime(3))).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a half-configured window means always reachable', () => {
|
|
85
|
+
const half: QuietHoursWindow = { start: '22:00', end: null, timezone: LA };
|
|
86
|
+
expect(isWithinQuietHours(half, laTime(3))).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// A typo must not silence someone permanently.
|
|
90
|
+
test('start equal to end is treated as no window, not as always quiet', () => {
|
|
91
|
+
const degenerate: QuietHoursWindow = { start: '09:00', end: '09:00', timezone: LA };
|
|
92
|
+
expect(isWithinQuietHours(degenerate, laTime(3))).toBe(false);
|
|
93
|
+
expect(isWithinQuietHours(degenerate, laTime(9))).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a malformed window means always reachable', () => {
|
|
97
|
+
const bad: QuietHoursWindow = { start: 'evening', end: 'morning', timezone: LA };
|
|
98
|
+
expect(isWithinQuietHours(bad, laTime(3))).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('quietHoursEndAfter', () => {
|
|
103
|
+
test('a page at 02:00 waits until 07:00', () => {
|
|
104
|
+
expect(quietHoursEndAfter(OVERNIGHT, laTime(2))).toEqual(laTime(7));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The window started last night; the end is tomorrow morning, not today's.
|
|
108
|
+
test('a page at 23:00 waits until 07:00 the next morning', () => {
|
|
109
|
+
const deliverAt = quietHoursEndAfter(OVERNIGHT, laTime(23));
|
|
110
|
+
expect(deliverAt).toEqual(new Date(laTime(23).getTime() + 8 * 60 * 60_000));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('outside the window there is nothing to defer', () => {
|
|
114
|
+
expect(quietHoursEndAfter(OVERNIGHT, laTime(12))).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a same-day window defers to its end on the same day', () => {
|
|
118
|
+
expect(quietHoursEndAfter(WORKDAY, laTime(10))).toEqual(laTime(17));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('timezone is honoured, not assumed', () => {
|
|
123
|
+
// The same instant is inside one person's window and outside another's.
|
|
124
|
+
test('two people in different zones disagree about the same moment', () => {
|
|
125
|
+
const instant = laTime(23);
|
|
126
|
+
const pacific: QuietHoursWindow = { start: '22:00', end: '07:00', timezone: LA };
|
|
127
|
+
const newYork: QuietHoursWindow = {
|
|
128
|
+
start: '22:00',
|
|
129
|
+
end: '07:00',
|
|
130
|
+
timezone: 'America/New_York',
|
|
131
|
+
};
|
|
132
|
+
expect(isWithinQuietHours(pacific, instant)).toBe(true);
|
|
133
|
+
// 23:00 Pacific is 02:00 Eastern — also inside, but for a different reason.
|
|
134
|
+
expect(isWithinQuietHours(newYork, instant)).toBe(true);
|
|
135
|
+
|
|
136
|
+
const midday = laTime(12); // 15:00 Eastern
|
|
137
|
+
expect(isWithinQuietHours(pacific, midday)).toBe(false);
|
|
138
|
+
expect(isWithinQuietHours(newYork, midday)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiet hours — when a person may be reached.
|
|
3
|
+
*
|
|
4
|
+
* Quiet hours defer delivery of EVERY severity, criticals included. This is a
|
|
5
|
+
* home lab, not a pager rotation: very little here is worth waking someone for,
|
|
6
|
+
* and an alerting system that wakes you for things you would not have acted on
|
|
7
|
+
* until morning trains you to ignore it.
|
|
8
|
+
*
|
|
9
|
+
* Escalation steps continue to ADVANCE during a quiet window — only delivery
|
|
10
|
+
* defers — so nothing is skipped; it arrives when the window opens. And a
|
|
11
|
+
* deferred alert that resolves before the window ends is delivered as neither a
|
|
12
|
+
* page nor an all-clear, because delivery re-evaluates current state rather
|
|
13
|
+
* than replaying history.
|
|
14
|
+
*
|
|
15
|
+
* `escalation_policies.bypass_quiet_hours` is the escape hatch for the few
|
|
16
|
+
* conditions that genuinely cannot wait. Reserved, unused in MVP.
|
|
17
|
+
*
|
|
18
|
+
* See openspec/changes/add-alerting/design.md D13.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface QuietHoursWindow {
|
|
22
|
+
/** Local "HH:MM", inclusive. Null (either field) means always reachable. */
|
|
23
|
+
start: string | null;
|
|
24
|
+
end: string | null;
|
|
25
|
+
/** IANA timezone the window is expressed in, e.g. "America/Los_Angeles". */
|
|
26
|
+
timezone: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
30
|
+
|
|
31
|
+
/** Parse "HH:MM" to minutes since local midnight, or null if malformed. */
|
|
32
|
+
export function parseClockTime(value: string): number | null {
|
|
33
|
+
const match = HHMM.exec(value);
|
|
34
|
+
if (!match) return null;
|
|
35
|
+
return Number.parseInt(match[1], 10) * 60 + Number.parseInt(match[2], 10);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Minutes since local midnight for `instant` in `timezone`.
|
|
40
|
+
*
|
|
41
|
+
* Uses Intl rather than manual offset arithmetic so DST is handled by the
|
|
42
|
+
* platform's tz database. A window is expressed in wall-clock terms — "22:00
|
|
43
|
+
* to 07:00" means those local hours on whichever side of a DST boundary the
|
|
44
|
+
* instant falls.
|
|
45
|
+
*/
|
|
46
|
+
export function localMinutesOfDay(instant: Date, timezone: string): number {
|
|
47
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
48
|
+
timeZone: timezone,
|
|
49
|
+
hour: '2-digit',
|
|
50
|
+
minute: '2-digit',
|
|
51
|
+
hour12: false,
|
|
52
|
+
}).formatToParts(instant);
|
|
53
|
+
const hour = Number.parseInt(parts.find((p) => p.type === 'hour')?.value ?? '0', 10);
|
|
54
|
+
const minute = Number.parseInt(parts.find((p) => p.type === 'minute')?.value ?? '0', 10);
|
|
55
|
+
// Intl renders midnight as 24 in some locales/options combinations.
|
|
56
|
+
return (hour % 24) * 60 + minute;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether `instant` falls inside the window.
|
|
61
|
+
*
|
|
62
|
+
* Windows normally wrap midnight (22:00–07:00), so a naive `start <= t <= end`
|
|
63
|
+
* comparison would be wrong for the common case rather than an edge case.
|
|
64
|
+
*/
|
|
65
|
+
export function isWithinQuietHours(window: QuietHoursWindow, instant: Date): boolean {
|
|
66
|
+
if (!window.start || !window.end) return false;
|
|
67
|
+
|
|
68
|
+
const start = parseClockTime(window.start);
|
|
69
|
+
const end = parseClockTime(window.end);
|
|
70
|
+
if (start === null || end === null) return false;
|
|
71
|
+
|
|
72
|
+
// Degenerate: start === end is treated as "no window", not "always quiet".
|
|
73
|
+
// The alternative silences someone permanently through a typo.
|
|
74
|
+
if (start === end) return false;
|
|
75
|
+
|
|
76
|
+
const now = localMinutesOfDay(instant, window.timezone);
|
|
77
|
+
return start < end
|
|
78
|
+
? now >= start && now < end
|
|
79
|
+
: // Wraps midnight: inside if after the start OR before the end.
|
|
80
|
+
now >= start || now < end;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The instant a deferred notification becomes deliverable — the next time the
|
|
85
|
+
* window's end is reached, at or after `instant`.
|
|
86
|
+
*
|
|
87
|
+
* Returns null when the instant is not inside a window (nothing to defer).
|
|
88
|
+
*/
|
|
89
|
+
export function quietHoursEndAfter(window: QuietHoursWindow, instant: Date): Date | null {
|
|
90
|
+
if (!isWithinQuietHours(window, instant)) return null;
|
|
91
|
+
|
|
92
|
+
const end = parseClockTime(window.end ?? '');
|
|
93
|
+
if (end === null) return null;
|
|
94
|
+
|
|
95
|
+
const nowLocal = localMinutesOfDay(instant, window.timezone);
|
|
96
|
+
// Minutes until the window's end, wrapping to tomorrow when already past it.
|
|
97
|
+
const minutesUntilEnd = end > nowLocal ? end - nowLocal : 24 * 60 - nowLocal + end;
|
|
98
|
+
return new Date(instant.getTime() + minutesUntilEnd * 60_000);
|
|
99
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { moduleAlertKey, moduleCheckAlertKey } from './keys';
|
|
3
|
+
import { type ReconcileAction, type ReconcileInput, reconcile } from './reconcile';
|
|
4
|
+
|
|
5
|
+
const NOW = new Date('2026-07-28T03:00:00Z');
|
|
6
|
+
const GRACE_MS = 60_000;
|
|
7
|
+
|
|
8
|
+
const MODULE = 'caddy';
|
|
9
|
+
const MODULE_KEY = moduleAlertKey(MODULE);
|
|
10
|
+
const DISK = moduleCheckAlertKey(MODULE, 'disk-space');
|
|
11
|
+
const CERT = moduleCheckAlertKey(MODULE, 'cert-validity');
|
|
12
|
+
|
|
13
|
+
function input(over: Partial<ReconcileInput> = {}): ReconcileInput {
|
|
14
|
+
return {
|
|
15
|
+
liveAlerts: [],
|
|
16
|
+
outcome: 'success',
|
|
17
|
+
failingKeys: [],
|
|
18
|
+
monitorLevelKey: MODULE_KEY,
|
|
19
|
+
monitorSeverity: 'critical',
|
|
20
|
+
now: NOW,
|
|
21
|
+
graceMs: GRACE_MS,
|
|
22
|
+
...over,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const failing = (key: string, message = 'bad') => ({ key, severity: 'critical', message }) as const;
|
|
27
|
+
|
|
28
|
+
const keysOf = (actions: ReconcileAction[], type: ReconcileAction['type']) =>
|
|
29
|
+
actions.filter((a) => a.type === type).map((a) => a.key);
|
|
30
|
+
|
|
31
|
+
describe('successful run', () => {
|
|
32
|
+
test('a newly failing key is created with a grace window', () => {
|
|
33
|
+
const actions = reconcile(input({ failingKeys: [failing(DISK)] }));
|
|
34
|
+
expect(actions).toHaveLength(1);
|
|
35
|
+
const [action] = actions;
|
|
36
|
+
expect(action.type).toBe('create');
|
|
37
|
+
if (action.type !== 'create') throw new Error('expected create');
|
|
38
|
+
expect(action.key).toBe(DISK);
|
|
39
|
+
expect(action.graceUntil).toEqual(new Date(NOW.getTime() + GRACE_MS));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a still-failing key refreshes rather than duplicating', () => {
|
|
43
|
+
const actions = reconcile(
|
|
44
|
+
input({
|
|
45
|
+
liveAlerts: [{ id: 'a1', key: DISK }],
|
|
46
|
+
failingKeys: [failing(DISK, '/var 96% used')],
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
expect(actions).toEqual([
|
|
50
|
+
{
|
|
51
|
+
type: 'refresh',
|
|
52
|
+
alertId: 'a1',
|
|
53
|
+
key: DISK,
|
|
54
|
+
severity: 'critical',
|
|
55
|
+
message: '/var 96% used',
|
|
56
|
+
details: undefined,
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a key that stopped being reported resolves', () => {
|
|
62
|
+
const actions = reconcile(input({ liveAlerts: [{ id: 'a1', key: DISK }], failingKeys: [] }));
|
|
63
|
+
expect(actions).toEqual([{ type: 'resolve', alertId: 'a1', key: DISK }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('resolves only what disappeared, keeping what persists', () => {
|
|
67
|
+
const actions = reconcile(
|
|
68
|
+
input({
|
|
69
|
+
liveAlerts: [
|
|
70
|
+
{ id: 'a1', key: DISK },
|
|
71
|
+
{ id: 'a2', key: CERT },
|
|
72
|
+
],
|
|
73
|
+
failingKeys: [failing(DISK)],
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
expect(keysOf(actions, 'refresh')).toEqual([DISK]);
|
|
77
|
+
expect(keysOf(actions, 'resolve')).toEqual([CERT]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// A module upgrade renaming a check is indistinguishable from one problem
|
|
81
|
+
// clearing and another appearing — which is correct, and beats a zombie
|
|
82
|
+
// alert firing forever for a key nobody reports any more.
|
|
83
|
+
test('a renamed check resolves the old key and fires the new one', () => {
|
|
84
|
+
const renamed = moduleCheckAlertKey(MODULE, 'disk-usage');
|
|
85
|
+
const actions = reconcile(
|
|
86
|
+
input({ liveAlerts: [{ id: 'a1', key: DISK }], failingKeys: [failing(renamed)] }),
|
|
87
|
+
);
|
|
88
|
+
expect(keysOf(actions, 'resolve')).toEqual([DISK]);
|
|
89
|
+
expect(keysOf(actions, 'create')).toEqual([renamed]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// The hook ran, so "could not run" is no longer true. It is never in the
|
|
93
|
+
// failing set, so set difference retires it without a special case.
|
|
94
|
+
test('a successful run resolves a standing monitor-level alert', () => {
|
|
95
|
+
const actions = reconcile(
|
|
96
|
+
input({ liveAlerts: [{ id: 'm1', key: MODULE_KEY }], failingKeys: [failing(DISK)] }),
|
|
97
|
+
);
|
|
98
|
+
expect(keysOf(actions, 'resolve')).toEqual([MODULE_KEY]);
|
|
99
|
+
expect(keysOf(actions, 'create')).toEqual([DISK]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a clean run against no live alerts does nothing', () => {
|
|
103
|
+
expect(reconcile(input())).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('errored run — the false-all-clear guard', () => {
|
|
108
|
+
const errored = (liveAlerts: ReconcileInput['liveAlerts']) =>
|
|
109
|
+
reconcile(
|
|
110
|
+
input({
|
|
111
|
+
outcome: 'error',
|
|
112
|
+
liveAlerts,
|
|
113
|
+
// A caller that leaks an errored run's empty item list into failingKeys
|
|
114
|
+
// must still not cause a resolve. The branch ignores this field.
|
|
115
|
+
failingKeys: [],
|
|
116
|
+
errorMessage: 'ssh: connect to host caddy.dmz port 22: timed out',
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// THE case. Disk has been failing since yesterday; now the box drops off the
|
|
121
|
+
// network. A naive set-difference sends "✅ RESOLVED — disk-space" at 3am for
|
|
122
|
+
// a problem that just got much worse, and fires nothing to replace it.
|
|
123
|
+
test('an unreachable system does NOT resolve a firing alert', () => {
|
|
124
|
+
const actions = errored([{ id: 'a1', key: DISK }]);
|
|
125
|
+
expect(keysOf(actions, 'resolve')).toEqual([]);
|
|
126
|
+
expect(actions.some((a) => a.type === 'resolve')).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('it fires the monitor-level alert carrying the real error', () => {
|
|
130
|
+
const actions = errored([{ id: 'a1', key: DISK }]);
|
|
131
|
+
const created = actions.filter((a) => a.type === 'create');
|
|
132
|
+
expect(created).toHaveLength(1);
|
|
133
|
+
expect(created[0].key).toBe(MODULE_KEY);
|
|
134
|
+
expect(created[0].type === 'create' && created[0].message).toContain('timed out');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('existing item alerts are frozen — not refreshed either', () => {
|
|
138
|
+
const actions = errored([
|
|
139
|
+
{ id: 'a1', key: DISK },
|
|
140
|
+
{ id: 'a2', key: CERT },
|
|
141
|
+
]);
|
|
142
|
+
const touched = actions.filter((a) => a.key === DISK || a.key === CERT);
|
|
143
|
+
expect(touched).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('a repeated failure refreshes the monitor-level alert instead of duplicating', () => {
|
|
147
|
+
const actions = errored([
|
|
148
|
+
{ id: 'm1', key: MODULE_KEY },
|
|
149
|
+
{ id: 'a1', key: DISK },
|
|
150
|
+
]);
|
|
151
|
+
expect(actions).toHaveLength(1);
|
|
152
|
+
expect(actions[0].type).toBe('refresh');
|
|
153
|
+
expect(actions[0].key).toBe(MODULE_KEY);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('falls back to a message when the caller supplies no error text', () => {
|
|
157
|
+
const actions = reconcile(input({ outcome: 'error', errorMessage: undefined }));
|
|
158
|
+
expect(actions[0].type === 'create' && actions[0].message).toBe('Monitor run failed');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('recovery after an outage', () => {
|
|
163
|
+
// The frozen alerts are only ever cleaned up by a run that actually executed.
|
|
164
|
+
test('a later successful run resolves what is genuinely gone', () => {
|
|
165
|
+
const afterOutage = reconcile(
|
|
166
|
+
input({
|
|
167
|
+
liveAlerts: [
|
|
168
|
+
{ id: 'm1', key: MODULE_KEY },
|
|
169
|
+
{ id: 'a1', key: DISK },
|
|
170
|
+
],
|
|
171
|
+
failingKeys: [],
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
expect(keysOf(afterOutage, 'resolve').sort()).toEqual([MODULE_KEY, DISK].sort());
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a later successful run keeps what is still broken', () => {
|
|
178
|
+
const afterOutage = reconcile(
|
|
179
|
+
input({
|
|
180
|
+
liveAlerts: [
|
|
181
|
+
{ id: 'm1', key: MODULE_KEY },
|
|
182
|
+
{ id: 'a1', key: DISK },
|
|
183
|
+
],
|
|
184
|
+
failingKeys: [failing(DISK)],
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
expect(keysOf(afterOutage, 'resolve')).toEqual([MODULE_KEY]);
|
|
188
|
+
expect(keysOf(afterOutage, 'refresh')).toEqual([DISK]);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alert reconciliation — turning one monitor run into alert state changes.
|
|
3
|
+
*
|
|
4
|
+
* This is the most consequential logic in the alerting system, and it is pure
|
|
5
|
+
* so that it can be tested exhaustively without a database, a bus, or an SSH
|
|
6
|
+
* connection. The caller applies the returned actions.
|
|
7
|
+
*
|
|
8
|
+
* The whole design rests on one asymmetry:
|
|
9
|
+
*
|
|
10
|
+
* a run that SUCCEEDED tells you about every key it did not report
|
|
11
|
+
* → absent means resolved (set difference)
|
|
12
|
+
*
|
|
13
|
+
* a run that FAILED TO EXECUTE tells you nothing about any key
|
|
14
|
+
* → absent means unknown, and nothing may be resolved
|
|
15
|
+
*
|
|
16
|
+
* Both produce an empty failing set. Treating them alike is not a subtle bug:
|
|
17
|
+
* it emits "✅ RESOLVED" for every outstanding problem at the exact moment a
|
|
18
|
+
* machine goes offline, and then goes quiet — the worst possible behaviour for
|
|
19
|
+
* an alerting system, which is confident false reassurance.
|
|
20
|
+
*
|
|
21
|
+
* See openspec/changes/add-alerting/design.md D5.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { AlertSeverity, MonitorRunOutcome } from '../../db/schema';
|
|
25
|
+
import type { FailingKey } from './keys';
|
|
26
|
+
|
|
27
|
+
/** An alert that has not yet resolved, as the reconciler needs to see it. */
|
|
28
|
+
export interface LiveAlert {
|
|
29
|
+
id: string;
|
|
30
|
+
key: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReconcileInput {
|
|
34
|
+
/** Every non-resolved alert currently owned by this monitor. */
|
|
35
|
+
liveAlerts: LiveAlert[];
|
|
36
|
+
outcome: MonitorRunOutcome;
|
|
37
|
+
/**
|
|
38
|
+
* The complete set of currently-failing keys. Meaningful ONLY when
|
|
39
|
+
* `outcome` is `success`; ignored otherwise, because a run that could not
|
|
40
|
+
* execute has no opinion about what is failing.
|
|
41
|
+
*/
|
|
42
|
+
failingKeys: FailingKey[];
|
|
43
|
+
/**
|
|
44
|
+
* The key meaning "this monitor could not run" — `module:<id>` for a module
|
|
45
|
+
* hook, `builtin:<check>` for a built-in check.
|
|
46
|
+
*/
|
|
47
|
+
monitorLevelKey: string;
|
|
48
|
+
/** Populated when `outcome` is `error`; becomes the monitor-level message. */
|
|
49
|
+
errorMessage?: string;
|
|
50
|
+
/** Severity carried by the monitor-level alert. */
|
|
51
|
+
monitorSeverity: AlertSeverity;
|
|
52
|
+
now: Date;
|
|
53
|
+
/** Grace window before a newly-fired alert may notify. */
|
|
54
|
+
graceMs: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ReconcileAction =
|
|
58
|
+
| {
|
|
59
|
+
type: 'create';
|
|
60
|
+
key: string;
|
|
61
|
+
severity: AlertSeverity;
|
|
62
|
+
message: string;
|
|
63
|
+
details?: string;
|
|
64
|
+
graceUntil: Date;
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
type: 'refresh';
|
|
68
|
+
alertId: string;
|
|
69
|
+
key: string;
|
|
70
|
+
severity: AlertSeverity;
|
|
71
|
+
message: string;
|
|
72
|
+
details?: string;
|
|
73
|
+
}
|
|
74
|
+
| { type: 'resolve'; alertId: string; key: string };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reconcile one monitor run against the alerts that monitor currently owns.
|
|
78
|
+
*
|
|
79
|
+
* Returns the actions to apply, in no particular order. Applying them is the
|
|
80
|
+
* caller's job — keeping that separate is what lets every branch below be
|
|
81
|
+
* asserted directly.
|
|
82
|
+
*/
|
|
83
|
+
export function reconcile(input: ReconcileInput): ReconcileAction[] {
|
|
84
|
+
return input.outcome === 'error' ? reconcileErroredRun(input) : reconcileSuccessfulRun(input);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A run that executed. The failing set is authoritative: what is present is
|
|
89
|
+
* firing, what is absent has recovered.
|
|
90
|
+
*/
|
|
91
|
+
function reconcileSuccessfulRun(input: ReconcileInput): ReconcileAction[] {
|
|
92
|
+
const actions: ReconcileAction[] = [];
|
|
93
|
+
const byKey = new Map(input.liveAlerts.map((alert) => [alert.key, alert]));
|
|
94
|
+
const reported = new Set<string>();
|
|
95
|
+
|
|
96
|
+
for (const failing of input.failingKeys) {
|
|
97
|
+
reported.add(failing.key);
|
|
98
|
+
const existing = byKey.get(failing.key);
|
|
99
|
+
if (existing) {
|
|
100
|
+
actions.push({
|
|
101
|
+
type: 'refresh',
|
|
102
|
+
alertId: existing.id,
|
|
103
|
+
key: failing.key,
|
|
104
|
+
severity: failing.severity,
|
|
105
|
+
message: failing.message,
|
|
106
|
+
details: failing.details,
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
actions.push({
|
|
110
|
+
type: 'create',
|
|
111
|
+
key: failing.key,
|
|
112
|
+
severity: failing.severity,
|
|
113
|
+
message: failing.message,
|
|
114
|
+
details: failing.details,
|
|
115
|
+
graceUntil: new Date(input.now.getTime() + input.graceMs),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Set difference: anything live that this run did NOT report has recovered.
|
|
121
|
+
// The monitor-level alert falls out of this naturally — the run executed, so
|
|
122
|
+
// "could not run" is no longer true and it is never in `failingKeys`.
|
|
123
|
+
for (const alert of input.liveAlerts) {
|
|
124
|
+
if (!reported.has(alert.key)) {
|
|
125
|
+
actions.push({ type: 'resolve', alertId: alert.id, key: alert.key });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return actions;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A run that could not execute. It reports exactly one thing — that it could
|
|
134
|
+
* not run — and says nothing whatsoever about the individual checks.
|
|
135
|
+
*
|
|
136
|
+
* Every existing alert is therefore left FROZEN: not refreshed, not resolved,
|
|
137
|
+
* not touched. They are neither confirmed nor cleared, which is the honest
|
|
138
|
+
* position. When the system comes back and a run succeeds, ordinary set
|
|
139
|
+
* difference cleans them up.
|
|
140
|
+
*/
|
|
141
|
+
function reconcileErroredRun(input: ReconcileInput): ReconcileAction[] {
|
|
142
|
+
const message = input.errorMessage ?? 'Monitor run failed';
|
|
143
|
+
const existing = input.liveAlerts.find((alert) => alert.key === input.monitorLevelKey);
|
|
144
|
+
|
|
145
|
+
if (existing) {
|
|
146
|
+
return [
|
|
147
|
+
{
|
|
148
|
+
type: 'refresh',
|
|
149
|
+
alertId: existing.id,
|
|
150
|
+
key: input.monitorLevelKey,
|
|
151
|
+
severity: input.monitorSeverity,
|
|
152
|
+
message,
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
type: 'create',
|
|
160
|
+
key: input.monitorLevelKey,
|
|
161
|
+
severity: input.monitorSeverity,
|
|
162
|
+
message,
|
|
163
|
+
graceUntil: new Date(input.now.getTime() + input.graceMs),
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
}
|