@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,169 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
composeInterviewBody,
|
|
4
|
+
decideInterviewDelivery,
|
|
5
|
+
describeQuestion,
|
|
6
|
+
isRefusedFamily,
|
|
7
|
+
parseInterviewAnswer,
|
|
8
|
+
} from './interview-responder';
|
|
9
|
+
|
|
10
|
+
const headless = { hasTty: false, hasBidirectionalRoute: true };
|
|
11
|
+
|
|
12
|
+
describe('isRefusedFamily', () => {
|
|
13
|
+
test.each(['secret.required.namecheap.ddns_passwords', 'secret.required.forgejo.admin_token'])(
|
|
14
|
+
'%p is refused',
|
|
15
|
+
(type) => {
|
|
16
|
+
expect(isRefusedFamily(type)).toBe(true);
|
|
17
|
+
},
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
test.each([
|
|
21
|
+
'config.required.caddy.hostname',
|
|
22
|
+
'ensure.required.namecheap.managed_domain',
|
|
23
|
+
'interview.required.service:proxmox.node',
|
|
24
|
+
'aspect.required.technitium.dns',
|
|
25
|
+
])('%p is not refused', (type) => {
|
|
26
|
+
expect(isRefusedFamily(type)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('decideInterviewDelivery', () => {
|
|
31
|
+
// The refusal is checked FIRST, so a secret is declined even on a headless
|
|
32
|
+
// box with a working transport. The deploy waits for a terminal, which is
|
|
33
|
+
// correct — the alternative is leaking a credential for convenience.
|
|
34
|
+
test('a secret question is refused even when nothing else could answer it', () => {
|
|
35
|
+
const decision = decideInterviewDelivery({
|
|
36
|
+
eventType: 'secret.required.forgejo.admin_token',
|
|
37
|
+
...headless,
|
|
38
|
+
});
|
|
39
|
+
expect(decision.deliver).toBe(false);
|
|
40
|
+
expect(decision.reason).toContain('terminal');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a secret question is refused with a TTY too', () => {
|
|
44
|
+
const decision = decideInterviewDelivery({
|
|
45
|
+
eventType: 'secret.required.forgejo.admin_token',
|
|
46
|
+
hasTty: true,
|
|
47
|
+
hasBidirectionalRoute: true,
|
|
48
|
+
});
|
|
49
|
+
expect(decision.deliver).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Without this a deploy started from a laptop appears to hang while
|
|
53
|
+
// silently waiting for a text message.
|
|
54
|
+
test('a terminal wins when one is attached', () => {
|
|
55
|
+
const decision = decideInterviewDelivery({
|
|
56
|
+
eventType: 'config.required.caddy.hostname',
|
|
57
|
+
hasTty: true,
|
|
58
|
+
hasBidirectionalRoute: true,
|
|
59
|
+
});
|
|
60
|
+
expect(decision.deliver).toBe(false);
|
|
61
|
+
expect(decision.reason).toContain('terminal responder');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a non-secret question is delivered headlessly', () => {
|
|
65
|
+
expect(
|
|
66
|
+
decideInterviewDelivery({ eventType: 'config.required.caddy.hostname', ...headless }),
|
|
67
|
+
).toEqual({ deliver: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Delivering to a route that cannot reply produces a question nobody can
|
|
71
|
+
// answer, which reads to the operator as the deploy having hung.
|
|
72
|
+
test('nothing is delivered when no route can receive a reply', () => {
|
|
73
|
+
const decision = decideInterviewDelivery({
|
|
74
|
+
eventType: 'config.required.caddy.hostname',
|
|
75
|
+
hasTty: false,
|
|
76
|
+
hasBidirectionalRoute: false,
|
|
77
|
+
});
|
|
78
|
+
expect(decision.deliver).toBe(false);
|
|
79
|
+
expect(decision.reason).toContain('never be answered');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('composeInterviewBody', () => {
|
|
84
|
+
test('leads with the question, ends with a bare reply instruction', () => {
|
|
85
|
+
const body = composeInterviewBody({
|
|
86
|
+
scope: 'caddy',
|
|
87
|
+
key: 'hostname',
|
|
88
|
+
description: 'Public hostname for the site',
|
|
89
|
+
token: 'K7QM2X',
|
|
90
|
+
});
|
|
91
|
+
expect(body.split('\n')[0]).toContain('caddy needs: hostname');
|
|
92
|
+
expect(body.endsWith('reply K7QM2X <value>')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('works without a description', () => {
|
|
96
|
+
const body = composeInterviewBody({ scope: 'caddy', key: 'hostname', token: 'K7QM2X' });
|
|
97
|
+
expect(body).toContain('caddy needs: hostname');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('parseInterviewAnswer', () => {
|
|
102
|
+
test('takes everything after the token as the answer', () => {
|
|
103
|
+
expect(parseInterviewAnswer('K7QM2X www.example.com', 'K7QM2X')).toBe('www.example.com');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('is case-insensitive on the token', () => {
|
|
107
|
+
expect(parseInterviewAnswer('k7qm2x www.example.com', 'K7QM2X')).toBe('www.example.com');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// An answer may legitimately contain spaces and punctuation. Second-guessing
|
|
111
|
+
// it would corrupt exactly the values that are painful to retype.
|
|
112
|
+
test('preserves an answer containing spaces and punctuation verbatim', () => {
|
|
113
|
+
expect(parseInterviewAnswer('K7QM2X my value: with, punctuation', 'K7QM2X')).toBe(
|
|
114
|
+
'my value: with, punctuation',
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('an answer that looks like a verb is still an answer', () => {
|
|
119
|
+
expect(parseInterviewAnswer('K7QM2X resolve', 'K7QM2X')).toBe('resolve');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a token with no value is not an answer', () => {
|
|
123
|
+
expect(parseInterviewAnswer('K7QM2X', 'K7QM2X')).toBeNull();
|
|
124
|
+
expect(parseInterviewAnswer('K7QM2X ', 'K7QM2X')).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a different token does not match', () => {
|
|
128
|
+
expect(parseInterviewAnswer('ZZZZZZ value', 'K7QM2X')).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The families disagree on payload shape, and reading the wrong field means an
|
|
134
|
+
* operator gets the raw event type instead of the question.
|
|
135
|
+
*/
|
|
136
|
+
describe('describeQuestion', () => {
|
|
137
|
+
test("uses the interview family's message — it IS the question", () => {
|
|
138
|
+
const q = describeQuestion('interview.required.service.node', {
|
|
139
|
+
scope: 'service:proxmox-home-lab',
|
|
140
|
+
key: 'default_target_node',
|
|
141
|
+
message: 'Which Proxmox node should new containers land on?',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(q.scope).toBe('service:proxmox-home-lab');
|
|
145
|
+
expect(q.key).toBe('Which Proxmox node should new containers land on?');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// config.required carries `module`, not `scope`, and has no prose at all.
|
|
149
|
+
test('composes from module + key when there is no message', () => {
|
|
150
|
+
const q = describeQuestion('config.required.caddy.acme_email', {
|
|
151
|
+
module: 'caddy',
|
|
152
|
+
key: 'acme_email',
|
|
153
|
+
description: 'Address ACME registers with',
|
|
154
|
+
type: 'string',
|
|
155
|
+
required: true,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(q.scope).toBe('caddy');
|
|
159
|
+
expect(q.key).toBe('acme_email');
|
|
160
|
+
expect(q.description).toBe('Address ACME registers with');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Never render "undefined needs: value" at 3am.
|
|
164
|
+
test('falls back to the event type rather than undefined', () => {
|
|
165
|
+
const q = describeQuestion('ensure.required.forgejo.runner', {});
|
|
166
|
+
expect(q.scope).toBe('ensure.required.forgejo.runner');
|
|
167
|
+
expect(q.key).toBe('value');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answering deploy interviews over a notification transport.
|
|
3
|
+
*
|
|
4
|
+
* The payoff the whole design was shaped around: because an alert and an
|
|
5
|
+
* interview question are the same message with different reply semantics, one
|
|
6
|
+
* transport gives both. The send path, the token table, and the inbound path
|
|
7
|
+
* are shared; only what `targetId` points at differs.
|
|
8
|
+
*
|
|
9
|
+
* alert targetId -> alerts.id reply acknowledges
|
|
10
|
+
* interview targetId -> a BUS event id reply IS the answer
|
|
11
|
+
*
|
|
12
|
+
* Two rules here are safety properties rather than preferences, and both are
|
|
13
|
+
* enforced before anything is delivered.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AlertSeverity } from '../../db/schema';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Interview families a messaging transport must never carry.
|
|
20
|
+
*
|
|
21
|
+
* `headless-cli-interview` already requires that secrets never travel in a
|
|
22
|
+
* reply payload — a responder writes plaintext out-of-band to the encrypted
|
|
23
|
+
* store and replies only `{ acknowledged: true }`. A messaging transport
|
|
24
|
+
* cannot satisfy that: the operator would have to type the secret into a chat,
|
|
25
|
+
* putting it in the transport's message store, the device's history, and every
|
|
26
|
+
* other route on the policy, all before the responder ever sees it. The
|
|
27
|
+
* out-of-band write is meaningless when the inbound channel is itself the leak.
|
|
28
|
+
*
|
|
29
|
+
* So this is compliance with an existing requirement, not a new rule. See
|
|
30
|
+
* design D11.
|
|
31
|
+
*/
|
|
32
|
+
const REFUSED_PREFIXES = ['secret.required.'] as const;
|
|
33
|
+
|
|
34
|
+
export function isRefusedFamily(eventType: string): boolean {
|
|
35
|
+
return REFUSED_PREFIXES.some((prefix) => eventType.startsWith(prefix));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface InterviewDeliveryDecision {
|
|
39
|
+
deliver: boolean;
|
|
40
|
+
/** Why not, when `deliver` is false — shown to the operator. */
|
|
41
|
+
reason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface InterviewContext {
|
|
45
|
+
eventType: string;
|
|
46
|
+
/** True when a terminal responder is available to answer instead. */
|
|
47
|
+
hasTty: boolean;
|
|
48
|
+
/** True when at least one ack-capable route exists. */
|
|
49
|
+
hasBidirectionalRoute: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a notification transport should carry this interview question.
|
|
54
|
+
*
|
|
55
|
+
* Order matters: the refusal is checked FIRST, so a secret question is
|
|
56
|
+
* declined even on a headless box with a perfectly good transport. Declining
|
|
57
|
+
* it there leaves the deploy waiting for a terminal, which is correct — the
|
|
58
|
+
* alternative is leaking a credential to make a deploy convenient.
|
|
59
|
+
*/
|
|
60
|
+
export function decideInterviewDelivery(context: InterviewContext): InterviewDeliveryDecision {
|
|
61
|
+
if (isRefusedFamily(context.eventType)) {
|
|
62
|
+
return {
|
|
63
|
+
deliver: false,
|
|
64
|
+
reason:
|
|
65
|
+
'This question asks for a secret, which must not travel over a messaging transport. ' +
|
|
66
|
+
'Answer it at a terminal — see `celilo events list-pending`.',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Terminal wins when someone is sitting there (D12). Without this, a deploy
|
|
71
|
+
// started from a laptop appears to hang while silently waiting for a text.
|
|
72
|
+
if (context.hasTty) {
|
|
73
|
+
return { deliver: false, reason: 'A terminal responder is attached and will answer.' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!context.hasBidirectionalRoute) {
|
|
77
|
+
return {
|
|
78
|
+
deliver: false,
|
|
79
|
+
reason: 'No route can receive replies, so a delivered question could never be answered.',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { deliver: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The interview families do not share a payload shape, and the differences
|
|
88
|
+
* matter for what a human sees on a phone:
|
|
89
|
+
*
|
|
90
|
+
* interview.required.* { scope, key, message, ... } `message` IS the question
|
|
91
|
+
* config.required.* { module, key, type, ... } no prose; compose from module+key
|
|
92
|
+
*
|
|
93
|
+
* Reading only `scope`/`key` drops the actual question text on the family that
|
|
94
|
+
* has it, and renders the raw event type on the family that does not. Neither
|
|
95
|
+
* is something you want to read at 3am.
|
|
96
|
+
*/
|
|
97
|
+
export function describeQuestion(
|
|
98
|
+
eventType: string,
|
|
99
|
+
payload: Record<string, unknown>,
|
|
100
|
+
): { scope: string; key: string; description?: string } {
|
|
101
|
+
const key = typeof payload.key === 'string' ? payload.key : 'value';
|
|
102
|
+
const description = typeof payload.description === 'string' ? payload.description : undefined;
|
|
103
|
+
|
|
104
|
+
// The interview family's `message` is the operator-facing question — prefer
|
|
105
|
+
// it over anything we would compose ourselves.
|
|
106
|
+
if (typeof payload.message === 'string' && payload.message.length > 0) {
|
|
107
|
+
const scope = typeof payload.scope === 'string' ? payload.scope : eventType;
|
|
108
|
+
return { scope, key: payload.message, description };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const owner =
|
|
112
|
+
typeof payload.module === 'string'
|
|
113
|
+
? payload.module
|
|
114
|
+
: typeof payload.scope === 'string'
|
|
115
|
+
? payload.scope
|
|
116
|
+
: eventType;
|
|
117
|
+
return { scope: owner, key, description };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render an interview question for a phone.
|
|
122
|
+
*
|
|
123
|
+
* The question leads, because that is the notification preview. The token
|
|
124
|
+
* instruction is last and bare — someone answering a deploy prompt from a
|
|
125
|
+
* phone should not have to parse a menu.
|
|
126
|
+
*/
|
|
127
|
+
export function composeInterviewBody(input: {
|
|
128
|
+
scope: string;
|
|
129
|
+
key: string;
|
|
130
|
+
description?: string;
|
|
131
|
+
token: string;
|
|
132
|
+
}): string {
|
|
133
|
+
const lines = [`❓ ${input.scope} needs: ${input.key}`];
|
|
134
|
+
if (input.description) lines.push(input.description);
|
|
135
|
+
lines.push('', `reply ${input.token} <value>`);
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Extract the answer from an inbound reply.
|
|
141
|
+
*
|
|
142
|
+
* Everything after the token is the value, verbatim and unparsed — an answer
|
|
143
|
+
* may legitimately contain spaces, punctuation, or something that looks like a
|
|
144
|
+
* verb, and second-guessing it would corrupt exactly the values an operator
|
|
145
|
+
* cannot easily retype.
|
|
146
|
+
*/
|
|
147
|
+
export function parseInterviewAnswer(body: string, token: string): string | null {
|
|
148
|
+
const trimmed = body.trim();
|
|
149
|
+
const upper = trimmed.toUpperCase();
|
|
150
|
+
const normalisedToken = token.toUpperCase();
|
|
151
|
+
if (!upper.startsWith(normalisedToken)) return null;
|
|
152
|
+
|
|
153
|
+
const answer = trimmed.slice(token.length).trim();
|
|
154
|
+
return answer.length > 0 ? answer : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Severity an interview question is delivered at — never a page-worthy one. */
|
|
158
|
+
export const INTERVIEW_SEVERITY: AlertSeverity = 'warning';
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
builtinAlertKey,
|
|
4
|
+
failingKeysFromHealthItems,
|
|
5
|
+
moduleAlertKey,
|
|
6
|
+
moduleCheckAlertKey,
|
|
7
|
+
parentModuleKey,
|
|
8
|
+
parseAlertKey,
|
|
9
|
+
severityForItemStatus,
|
|
10
|
+
} from './keys';
|
|
11
|
+
|
|
12
|
+
describe('alert key construction', () => {
|
|
13
|
+
test('module-level key', () => {
|
|
14
|
+
expect(moduleAlertKey('caddy')).toBe('module:caddy');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('module check key', () => {
|
|
18
|
+
expect(moduleCheckAlertKey('caddy', 'cert-validity')).toBe('module:caddy/check:cert-validity');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('builtin key', () => {
|
|
22
|
+
expect(builtinAlertKey('machines_reachable', 'machine', 'iot')).toBe(
|
|
23
|
+
'builtin:machines_reachable/machine:iot',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('parseAlertKey', () => {
|
|
29
|
+
test('round-trips a module-level key', () => {
|
|
30
|
+
expect(parseAlertKey('module:caddy')).toEqual({ source: 'module', moduleId: 'caddy' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('round-trips a module check key', () => {
|
|
34
|
+
expect(parseAlertKey('module:caddy/check:cert-validity')).toEqual({
|
|
35
|
+
source: 'module',
|
|
36
|
+
moduleId: 'caddy',
|
|
37
|
+
check: 'cert-validity',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('round-trips a builtin key', () => {
|
|
42
|
+
expect(parseAlertKey('builtin:machines_reachable/machine:iot')).toEqual({
|
|
43
|
+
source: 'builtin',
|
|
44
|
+
check: 'machines_reachable',
|
|
45
|
+
targetKind: 'machine',
|
|
46
|
+
targetId: 'iot',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Check ITEM names are module-authored and unconstrained. A key must survive
|
|
51
|
+
// an author choosing punctuation, or alert identity silently corrupts.
|
|
52
|
+
test('check name containing a colon survives', () => {
|
|
53
|
+
const key = moduleCheckAlertKey('caddy', 'tls:handshake');
|
|
54
|
+
expect(parseAlertKey(key)).toEqual({
|
|
55
|
+
source: 'module',
|
|
56
|
+
moduleId: 'caddy',
|
|
57
|
+
check: 'tls:handshake',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('check name containing a slash survives', () => {
|
|
62
|
+
const key = moduleCheckAlertKey('caddy', 'disk:/var');
|
|
63
|
+
expect(parseAlertKey(key)).toEqual({
|
|
64
|
+
source: 'module',
|
|
65
|
+
moduleId: 'caddy',
|
|
66
|
+
check: 'disk:/var',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// The bare form is the built-in counterpart of the module-level key:
|
|
71
|
+
// "the check itself could not run", so it has no target.
|
|
72
|
+
test('a bare builtin key parses as the monitor-level form', () => {
|
|
73
|
+
expect(parseAlertKey('builtin:machines_reachable')).toEqual({
|
|
74
|
+
source: 'builtin',
|
|
75
|
+
check: 'machines_reachable',
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test.each([
|
|
80
|
+
['', 'empty'],
|
|
81
|
+
['caddy', 'no source prefix'],
|
|
82
|
+
['module:', 'empty module id'],
|
|
83
|
+
['module:caddy/check:', 'empty check name'],
|
|
84
|
+
['builtin:', 'empty check name'],
|
|
85
|
+
['builtin:machines_reachable/iot', 'builtin target without a kind'],
|
|
86
|
+
['unknown:thing', 'unrecognized source'],
|
|
87
|
+
])('rejects %p (%s)', (key) => {
|
|
88
|
+
expect(parseAlertKey(key)).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('parentModuleKey', () => {
|
|
93
|
+
test('an item key is owned by its module key', () => {
|
|
94
|
+
expect(parentModuleKey('module:caddy/check:cert-validity')).toBe('module:caddy');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a module-level key has no parent', () => {
|
|
98
|
+
expect(parentModuleKey('module:caddy')).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a builtin key has no module parent', () => {
|
|
102
|
+
expect(parentModuleKey('builtin:machines_reachable/machine:iot')).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('severityForItemStatus', () => {
|
|
107
|
+
test('pass produces no alert', () => {
|
|
108
|
+
expect(severityForItemStatus('pass', 'critical')).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('warn is always warning, never the monitor severity', () => {
|
|
112
|
+
expect(severityForItemStatus('warn', 'critical')).toBe('warning');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('fail takes the monitor severity', () => {
|
|
116
|
+
expect(severityForItemStatus('fail', 'critical')).toBe('critical');
|
|
117
|
+
expect(severityForItemStatus('fail', 'warning')).toBe('warning');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('failingKeysFromHealthItems', () => {
|
|
122
|
+
const items = [
|
|
123
|
+
{ name: 'disk-space', status: 'fail' as const, message: '/var 94% used' },
|
|
124
|
+
{ name: 'cert-validity', status: 'pass' as const, message: 'expires in 62d' },
|
|
125
|
+
{ name: 'upstream', status: 'warn' as const, message: 'slow', details: '900ms' },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
test('only non-passing items produce keys', () => {
|
|
129
|
+
const failing = failingKeysFromHealthItems('caddy', items, 'critical');
|
|
130
|
+
expect(failing.map((f) => f.key)).toEqual([
|
|
131
|
+
'module:caddy/check:disk-space',
|
|
132
|
+
'module:caddy/check:upstream',
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('severity follows the item status, not the monitor alone', () => {
|
|
137
|
+
const failing = failingKeysFromHealthItems('caddy', items, 'critical');
|
|
138
|
+
expect(failing.find((f) => f.key.endsWith('disk-space'))?.severity).toBe('critical');
|
|
139
|
+
expect(failing.find((f) => f.key.endsWith('upstream'))?.severity).toBe('warning');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('message and details are carried through', () => {
|
|
143
|
+
const failing = failingKeysFromHealthItems('caddy', items, 'critical');
|
|
144
|
+
const upstream = failing.find((f) => f.key.endsWith('upstream'));
|
|
145
|
+
expect(upstream?.message).toBe('slow');
|
|
146
|
+
expect(upstream?.details).toBe('900ms');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// The empty set here means "ran, found nothing wrong" — trustworthy.
|
|
150
|
+
// The dangerous empty set (could not run) never reaches this function.
|
|
151
|
+
test('all-passing produces an empty failing set', () => {
|
|
152
|
+
const allPass = [{ name: 'x', status: 'pass' as const, message: 'ok' }];
|
|
153
|
+
expect(failingKeysFromHealthItems('caddy', allPass, 'critical')).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alert keys — the stable identity every alert is tracked by.
|
|
3
|
+
*
|
|
4
|
+
* A key is derived from the monitor and the check result; it is never
|
|
5
|
+
* operator-supplied. Everything downstream depends on it being stable across
|
|
6
|
+
* firings: dedup, acknowledgement, suppression, and resolution-by-absence all
|
|
7
|
+
* key off this string.
|
|
8
|
+
*
|
|
9
|
+
* module:<id> the hook itself could not run
|
|
10
|
+
* module:<id>/check:<name> one item within a health_check result
|
|
11
|
+
* builtin:<check>/<kind>:<target> one target of a built-in audit check
|
|
12
|
+
*
|
|
13
|
+
* The module-level form is not a degenerate case. "Could not run at all" is the
|
|
14
|
+
* most common real failure — an unreachable system — and it is the key that
|
|
15
|
+
* machine- and zone-level suppression acts upon, so it must be addressable
|
|
16
|
+
* separately from the items underneath it.
|
|
17
|
+
*
|
|
18
|
+
* See openspec/changes/add-alerting/design.md D4.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { AlertSeverity } from '../../db/schema';
|
|
22
|
+
|
|
23
|
+
export interface ModuleAlertKey {
|
|
24
|
+
source: 'module';
|
|
25
|
+
moduleId: string;
|
|
26
|
+
/** Absent for the module-level key ("the hook could not run"). */
|
|
27
|
+
check?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BuiltinAlertKey {
|
|
31
|
+
source: 'builtin';
|
|
32
|
+
/** Audit check name, e.g. `machines_reachable`. */
|
|
33
|
+
check: string;
|
|
34
|
+
/**
|
|
35
|
+
* What kind of thing the target is, e.g. `machine`, `module`. Absent on the
|
|
36
|
+
* bare `builtin:<check>` form, which means "the check itself could not run"
|
|
37
|
+
* — the built-in counterpart of the module-level key.
|
|
38
|
+
*/
|
|
39
|
+
targetKind?: string;
|
|
40
|
+
targetId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ParsedAlertKey = ModuleAlertKey | BuiltinAlertKey;
|
|
44
|
+
|
|
45
|
+
const CHECK_SEPARATOR = '/check:';
|
|
46
|
+
|
|
47
|
+
/** `module:<id>` — the module-level key. */
|
|
48
|
+
export function moduleAlertKey(moduleId: string): string {
|
|
49
|
+
return `module:${moduleId}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `module:<id>/check:<name>` — one item of a health_check result. */
|
|
53
|
+
export function moduleCheckAlertKey(moduleId: string, check: string): string {
|
|
54
|
+
return `${moduleAlertKey(moduleId)}${CHECK_SEPARATOR}${check}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `builtin:<check>/<kind>:<target>` — one target of a built-in check. */
|
|
58
|
+
export function builtinAlertKey(check: string, targetKind: string, targetId: string): string {
|
|
59
|
+
return `builtin:${check}/${targetKind}:${targetId}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `builtin:<check>` — the check itself could not run.
|
|
64
|
+
*
|
|
65
|
+
* The built-in counterpart of the module-level key. Without it, a built-in
|
|
66
|
+
* check that throws would produce no failing keys at all, which the reconciler
|
|
67
|
+
* would read as "nothing is wrong" — the same false-all-clear this design
|
|
68
|
+
* exists to prevent, just on a different code path.
|
|
69
|
+
*/
|
|
70
|
+
export function builtinMonitorKey(check: string): string {
|
|
71
|
+
return `builtin:${check}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a key back into its parts, or `null` if it is not a well-formed key.
|
|
76
|
+
*
|
|
77
|
+
* Module ids and built-in check names are kebab-case by construction, so they
|
|
78
|
+
* contain neither `:` nor `/`. Check ITEM names come from module authors and
|
|
79
|
+
* are deliberately unconstrained: everything after the first `/check:` is taken
|
|
80
|
+
* verbatim, so an item may contain colons and slashes without breaking the key.
|
|
81
|
+
* That keeps a module author's naming choice from corrupting alert identity.
|
|
82
|
+
*/
|
|
83
|
+
export function parseAlertKey(key: string): ParsedAlertKey | null {
|
|
84
|
+
if (key.startsWith('module:')) {
|
|
85
|
+
const rest = key.slice('module:'.length);
|
|
86
|
+
const sepIndex = rest.indexOf(CHECK_SEPARATOR);
|
|
87
|
+
if (sepIndex === -1) {
|
|
88
|
+
return rest.length > 0 ? { source: 'module', moduleId: rest } : null;
|
|
89
|
+
}
|
|
90
|
+
const moduleId = rest.slice(0, sepIndex);
|
|
91
|
+
const check = rest.slice(sepIndex + CHECK_SEPARATOR.length);
|
|
92
|
+
if (!moduleId || !check) return null;
|
|
93
|
+
return { source: 'module', moduleId, check };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (key.startsWith('builtin:')) {
|
|
97
|
+
const rest = key.slice('builtin:'.length);
|
|
98
|
+
const slash = rest.indexOf('/');
|
|
99
|
+
if (slash === -1) {
|
|
100
|
+
// Bare `builtin:<check>` — the check itself could not run.
|
|
101
|
+
return rest.length > 0 ? { source: 'builtin', check: rest } : null;
|
|
102
|
+
}
|
|
103
|
+
const check = rest.slice(0, slash);
|
|
104
|
+
const target = rest.slice(slash + 1);
|
|
105
|
+
const colon = target.indexOf(':');
|
|
106
|
+
if (colon === -1) return null;
|
|
107
|
+
const targetKind = target.slice(0, colon);
|
|
108
|
+
const targetId = target.slice(colon + 1);
|
|
109
|
+
if (!check || !targetKind || !targetId) return null;
|
|
110
|
+
return { source: 'builtin', check, targetKind, targetId };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The module-level key that owns a given key, or `null` if the key IS a
|
|
118
|
+
* module-level key (or is not module-scoped at all).
|
|
119
|
+
*
|
|
120
|
+
* Used by suppression: `module:caddy` firing suppresses every
|
|
121
|
+
* `module:caddy/check:*` beneath it.
|
|
122
|
+
*/
|
|
123
|
+
export function parentModuleKey(key: string): string | null {
|
|
124
|
+
const parsed = parseAlertKey(key);
|
|
125
|
+
if (!parsed || parsed.source !== 'module' || !parsed.check) return null;
|
|
126
|
+
return moduleAlertKey(parsed.moduleId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Map a health_check item status to the severity its alert carries, or `null`
|
|
131
|
+
* when the item produces no alert at all.
|
|
132
|
+
*
|
|
133
|
+
* `fail` takes the monitor's configured severity; `warn` is always `warning`
|
|
134
|
+
* and never pages. That gives module authors somewhere to put "cert expires in
|
|
135
|
+
* 20 days" without anyone having to decide whether it is a 3am problem.
|
|
136
|
+
*
|
|
137
|
+
* See design D6.
|
|
138
|
+
*/
|
|
139
|
+
export function severityForItemStatus(
|
|
140
|
+
itemStatus: 'pass' | 'warn' | 'fail',
|
|
141
|
+
monitorSeverity: AlertSeverity,
|
|
142
|
+
): AlertSeverity | null {
|
|
143
|
+
if (itemStatus === 'pass') return null;
|
|
144
|
+
if (itemStatus === 'warn') return 'warning';
|
|
145
|
+
return monitorSeverity;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** One currently-failing key produced by a monitor run. */
|
|
149
|
+
export interface FailingKey {
|
|
150
|
+
key: string;
|
|
151
|
+
severity: AlertSeverity;
|
|
152
|
+
message: string;
|
|
153
|
+
details?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface HealthCheckItemLike {
|
|
157
|
+
name: string;
|
|
158
|
+
status: 'pass' | 'warn' | 'fail';
|
|
159
|
+
message: string;
|
|
160
|
+
details?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Project a SUCCESSFUL health_check result into the complete set of currently
|
|
165
|
+
* failing keys.
|
|
166
|
+
*
|
|
167
|
+
* Only call this for a run that actually executed. A run that could not execute
|
|
168
|
+
* yields an empty item list for reasons that say nothing about the checks, and
|
|
169
|
+
* feeding that here would produce an empty failing set — which the reconciler
|
|
170
|
+
* would read as "everything recovered" at the exact moment a system became
|
|
171
|
+
* unreachable. That branch belongs to the caller (design D5).
|
|
172
|
+
*/
|
|
173
|
+
export function failingKeysFromHealthItems(
|
|
174
|
+
moduleId: string,
|
|
175
|
+
items: HealthCheckItemLike[],
|
|
176
|
+
monitorSeverity: AlertSeverity,
|
|
177
|
+
): FailingKey[] {
|
|
178
|
+
const failing: FailingKey[] = [];
|
|
179
|
+
for (const item of items) {
|
|
180
|
+
const severity = severityForItemStatus(item.status, monitorSeverity);
|
|
181
|
+
if (!severity) continue;
|
|
182
|
+
failing.push({
|
|
183
|
+
key: moduleCheckAlertKey(moduleId, item.name),
|
|
184
|
+
severity,
|
|
185
|
+
message: item.message,
|
|
186
|
+
details: item.details,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return failing;
|
|
190
|
+
}
|