@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,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo person` / `route` / `escalation-policy` — who celilo can reach.
|
|
3
|
+
*
|
|
4
|
+
* One file because the three are a single concept split across three tables,
|
|
5
|
+
* and splitting the CLI too would mean three copies of the same lookup-by-name
|
|
6
|
+
* and table-printing code.
|
|
7
|
+
*
|
|
8
|
+
* Everything is addressed by name. A UUID never reaches the operator
|
|
9
|
+
* (CLAUDE.md), and a route is identified by `<person>/<transport>` rather than
|
|
10
|
+
* by an id, because that is what someone actually knows about it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
14
|
+
import { eq } from 'drizzle-orm';
|
|
15
|
+
import { getEventBusPath } from '../../config/paths';
|
|
16
|
+
import { getDb } from '../../db/client';
|
|
17
|
+
import { type AlertSeverity, modules, monitors } from '../../db/schema';
|
|
18
|
+
import { ensureInboundSubscriber, findMonitorByTarget } from '../../services/alerting/monitors';
|
|
19
|
+
import {
|
|
20
|
+
addPolicyStep,
|
|
21
|
+
createPerson,
|
|
22
|
+
createPolicy,
|
|
23
|
+
createRoute,
|
|
24
|
+
deletePerson,
|
|
25
|
+
deletePolicy,
|
|
26
|
+
deleteRoute,
|
|
27
|
+
findPerson,
|
|
28
|
+
findPolicy,
|
|
29
|
+
findRoute,
|
|
30
|
+
listPeople,
|
|
31
|
+
listPolicies,
|
|
32
|
+
listPolicySteps,
|
|
33
|
+
listRoutes,
|
|
34
|
+
} from '../../services/alerting/people';
|
|
35
|
+
import { parseClockTime } from '../../services/alerting/quiet-hours';
|
|
36
|
+
import type { CommandResult } from '../types';
|
|
37
|
+
|
|
38
|
+
const NO_SCHEMAS = defineEvents({});
|
|
39
|
+
|
|
40
|
+
function table(headers: string[], rows: string[][]): string {
|
|
41
|
+
if (rows.length === 0) return '';
|
|
42
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
43
|
+
const line = (cells: string[]) =>
|
|
44
|
+
cells
|
|
45
|
+
.map((c, i) => (i === cells.length - 1 ? c : c.padEnd(widths[i])))
|
|
46
|
+
.join(' ')
|
|
47
|
+
.trimEnd();
|
|
48
|
+
return [line(headers), ...rows.map(line)].join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const flagString = (flags: Record<string, boolean | string>, name: string): string | undefined =>
|
|
52
|
+
typeof flags[name] === 'string' ? (flags[name] as string) : undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether a module declares the `notification` capability.
|
|
56
|
+
*
|
|
57
|
+
* Read from the manifest rather than the capabilities table: a module can be
|
|
58
|
+
* imported but not yet deployed, and configuring a route ahead of deploying
|
|
59
|
+
* the transport is a reasonable order to work in.
|
|
60
|
+
*/
|
|
61
|
+
function providesNotification(db: ReturnType<typeof getDb>, moduleId: string): boolean {
|
|
62
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
63
|
+
if (!module) return false;
|
|
64
|
+
const manifest = module.manifestData as {
|
|
65
|
+
provides?: { capabilities?: { name?: string }[] };
|
|
66
|
+
};
|
|
67
|
+
return Boolean(manifest.provides?.capabilities?.some((c) => c.name === 'notification'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── celilo person ───────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function personAdd(args: string[], flags: Record<string, boolean | string>): CommandResult {
|
|
73
|
+
const name = args[0];
|
|
74
|
+
if (!name) {
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
error: 'Usage: celilo person add <name> --timezone <IANA> [--quiet-hours 22:00-07:00]',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const db = getDb();
|
|
82
|
+
if (findPerson(db, name)) return { success: false, error: `Person "${name}" already exists.` };
|
|
83
|
+
|
|
84
|
+
const timezone = flagString(flags, 'timezone');
|
|
85
|
+
if (!timezone) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
error: 'A --timezone is required (e.g. America/Los_Angeles) — quiet hours are local to it.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let quietStart: string | null = null;
|
|
93
|
+
let quietEnd: string | null = null;
|
|
94
|
+
const quiet = flagString(flags, 'quiet-hours');
|
|
95
|
+
if (quiet) {
|
|
96
|
+
const [start, end] = quiet.split('-');
|
|
97
|
+
if (!start || !end || parseClockTime(start) === null || parseClockTime(end) === null) {
|
|
98
|
+
return {
|
|
99
|
+
success: false,
|
|
100
|
+
error: `Invalid --quiet-hours "${quiet}". Use HH:MM-HH:MM, e.g. 22:00-07:00.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
quietStart = start;
|
|
104
|
+
quietEnd = end;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
createPerson(db, { name, timezone, quietHoursStart: quietStart, quietHoursEnd: quietEnd });
|
|
108
|
+
const quietNote = quiet ? `, quiet ${quiet}` : ', always reachable';
|
|
109
|
+
return { success: true, message: `Added ${name} (${timezone}${quietNote})` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function personList(): CommandResult {
|
|
113
|
+
const people = listPeople(getDb());
|
|
114
|
+
if (people.length === 0) {
|
|
115
|
+
console.log('\nNobody configured.\n');
|
|
116
|
+
console.log(
|
|
117
|
+
' celilo person add peter --timezone America/Los_Angeles --quiet-hours 22:00-07:00\n',
|
|
118
|
+
);
|
|
119
|
+
return { success: true, message: 'No people configured' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log('');
|
|
123
|
+
console.log(
|
|
124
|
+
table(
|
|
125
|
+
['NAME', 'TIMEZONE', 'QUIET HOURS'],
|
|
126
|
+
people.map((p) => [
|
|
127
|
+
p.name,
|
|
128
|
+
p.timezone,
|
|
129
|
+
p.quietHoursStart && p.quietHoursEnd
|
|
130
|
+
? `${p.quietHoursStart}-${p.quietHoursEnd}`
|
|
131
|
+
: 'always reachable',
|
|
132
|
+
]),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
console.log('');
|
|
136
|
+
return { success: true, message: `${people.length} person(s)` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function personRemove(args: string[]): CommandResult {
|
|
140
|
+
const name = args[0];
|
|
141
|
+
if (!name) return { success: false, error: 'Usage: celilo person remove <name>' };
|
|
142
|
+
|
|
143
|
+
const db = getDb();
|
|
144
|
+
const person = findPerson(db, name);
|
|
145
|
+
if (!person) return { success: false, error: `No person named "${name}".` };
|
|
146
|
+
|
|
147
|
+
// Routes cascade, and so do the escalation steps pointing at them — a step
|
|
148
|
+
// aimed at a deleted route would otherwise skip silently at page time.
|
|
149
|
+
deletePerson(db, person.id);
|
|
150
|
+
return { success: true, message: `Removed ${name} and their routes` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function handlePerson(
|
|
154
|
+
subcommand: string | undefined,
|
|
155
|
+
args: string[],
|
|
156
|
+
flags: Record<string, boolean | string> = {},
|
|
157
|
+
): Promise<CommandResult> {
|
|
158
|
+
switch (subcommand) {
|
|
159
|
+
case undefined:
|
|
160
|
+
case 'list':
|
|
161
|
+
return personList();
|
|
162
|
+
case 'add':
|
|
163
|
+
return personAdd(args, flags);
|
|
164
|
+
case 'remove':
|
|
165
|
+
return personRemove(args);
|
|
166
|
+
default:
|
|
167
|
+
return {
|
|
168
|
+
success: false,
|
|
169
|
+
error: `Unknown person subcommand: ${subcommand}\n\nUse: list, add, remove`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── celilo route ────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
function routeAdd(args: string[], flags: Record<string, boolean | string>): CommandResult {
|
|
177
|
+
const [personName, transport] = args;
|
|
178
|
+
const address = flagString(flags, 'address');
|
|
179
|
+
if (!personName || !transport || !address) {
|
|
180
|
+
return {
|
|
181
|
+
success: false,
|
|
182
|
+
error:
|
|
183
|
+
'Usage: celilo route add <person> <transport-module> --address <addr> [--severity-floor warning|critical]',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const db = getDb();
|
|
188
|
+
const person = findPerson(db, personName);
|
|
189
|
+
if (!person) return { success: false, error: `No person named "${personName}".` };
|
|
190
|
+
|
|
191
|
+
// Check the transport BEFORE inserting. The routes table has a foreign key
|
|
192
|
+
// to modules, so a typo would otherwise surface as a raw
|
|
193
|
+
// SQLITE_CONSTRAINT_FOREIGNKEY stack trace — which tells an operator
|
|
194
|
+
// nothing about what they got wrong.
|
|
195
|
+
const module = db.select().from(modules).where(eq(modules.id, transport)).get();
|
|
196
|
+
if (!module) {
|
|
197
|
+
const available = db.select({ id: modules.id }).from(modules).all();
|
|
198
|
+
const providers = available.filter((m) => providesNotification(db, m.id)).map((m) => m.id);
|
|
199
|
+
const hint = providers.length
|
|
200
|
+
? `Available notification transports: ${providers.join(', ')}`
|
|
201
|
+
: 'No module providing the `notification` capability is installed yet.';
|
|
202
|
+
return { success: false, error: `No module "${transport}" is installed.\n\n${hint}` };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A route to a module that cannot send is a route that silently never pages.
|
|
206
|
+
if (!providesNotification(db, transport)) {
|
|
207
|
+
return {
|
|
208
|
+
success: false,
|
|
209
|
+
error: `Module "${transport}" does not provide the \`notification\` capability, so it cannot page anyone.`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (findRoute(db, person.id, transport)) {
|
|
214
|
+
return { success: false, error: `${personName} already has a ${transport} route.` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const floor = flagString(flags, 'severity-floor') ?? 'warning';
|
|
218
|
+
if (floor !== 'warning' && floor !== 'critical') {
|
|
219
|
+
return { success: false, error: `--severity-floor must be "warning" or "critical".` };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A transport that can receive is one whose module is deployed AND provides
|
|
223
|
+
// a receive path. Until the inbound poller lands we record the operator's
|
|
224
|
+
// intent; a route that cannot ack simply never stops escalation.
|
|
225
|
+
const canAck = flags['can-ack'] === true;
|
|
226
|
+
|
|
227
|
+
createRoute(db, {
|
|
228
|
+
personId: person.id,
|
|
229
|
+
transportModuleId: transport,
|
|
230
|
+
address,
|
|
231
|
+
severityFloor: floor as AlertSeverity,
|
|
232
|
+
canAck,
|
|
233
|
+
});
|
|
234
|
+
// A route that can receive is what makes inbound polling worth doing, so
|
|
235
|
+
// that is when the subscriber is registered.
|
|
236
|
+
if (canAck) {
|
|
237
|
+
const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
238
|
+
try {
|
|
239
|
+
ensureInboundSubscriber(bus);
|
|
240
|
+
} finally {
|
|
241
|
+
bus.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
success: true,
|
|
247
|
+
message: `Reaching ${personName} via ${transport} at ${address} (floor: ${floor})`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function routeList(): CommandResult {
|
|
252
|
+
const db = getDb();
|
|
253
|
+
const routes = listRoutes(db);
|
|
254
|
+
if (routes.length === 0) {
|
|
255
|
+
console.log('\nNo routes configured — nothing can be paged.\n');
|
|
256
|
+
console.log(' celilo route add peter signal --address +15551234567 --can-ack\n');
|
|
257
|
+
return { success: true, message: 'No routes configured' };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const people = new Map(listPeople(db).map((p) => [p.id, p.name]));
|
|
261
|
+
console.log('');
|
|
262
|
+
console.log(
|
|
263
|
+
table(
|
|
264
|
+
['PERSON', 'TRANSPORT', 'ADDRESS', 'FLOOR', 'ACK', 'STATE'],
|
|
265
|
+
routes.map((r) => [
|
|
266
|
+
people.get(r.personId) ?? '(unknown)',
|
|
267
|
+
r.transportModuleId,
|
|
268
|
+
r.address,
|
|
269
|
+
r.severityFloor,
|
|
270
|
+
r.canAck ? 'yes' : 'no',
|
|
271
|
+
r.enabled ? 'enabled' : 'disabled',
|
|
272
|
+
]),
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
console.log('');
|
|
276
|
+
return { success: true, message: `${routes.length} route(s)` };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function routeRemove(args: string[]): CommandResult {
|
|
280
|
+
const [personName, transport] = args;
|
|
281
|
+
if (!personName || !transport) {
|
|
282
|
+
return { success: false, error: 'Usage: celilo route remove <person> <transport-module>' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const db = getDb();
|
|
286
|
+
const person = findPerson(db, personName);
|
|
287
|
+
if (!person) return { success: false, error: `No person named "${personName}".` };
|
|
288
|
+
const route = findRoute(db, person.id, transport);
|
|
289
|
+
if (!route) return { success: false, error: `${personName} has no ${transport} route.` };
|
|
290
|
+
|
|
291
|
+
deleteRoute(db, route.id);
|
|
292
|
+
return { success: true, message: `Removed ${personName}'s ${transport} route` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function handleRoute(
|
|
296
|
+
subcommand: string | undefined,
|
|
297
|
+
args: string[],
|
|
298
|
+
flags: Record<string, boolean | string> = {},
|
|
299
|
+
): Promise<CommandResult> {
|
|
300
|
+
switch (subcommand) {
|
|
301
|
+
case undefined:
|
|
302
|
+
case 'list':
|
|
303
|
+
return routeList();
|
|
304
|
+
case 'add':
|
|
305
|
+
return routeAdd(args, flags);
|
|
306
|
+
case 'remove':
|
|
307
|
+
return routeRemove(args);
|
|
308
|
+
default:
|
|
309
|
+
return {
|
|
310
|
+
success: false,
|
|
311
|
+
error: `Unknown route subcommand: ${subcommand}\n\nUse: list, add, remove`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── celilo escalation-policy ────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
function policyAdd(args: string[]): CommandResult {
|
|
319
|
+
const name = args[0];
|
|
320
|
+
if (!name) return { success: false, error: 'Usage: celilo escalation-policy add <name>' };
|
|
321
|
+
|
|
322
|
+
const db = getDb();
|
|
323
|
+
if (findPolicy(db, name)) return { success: false, error: `Policy "${name}" already exists.` };
|
|
324
|
+
|
|
325
|
+
createPolicy(db, name);
|
|
326
|
+
return {
|
|
327
|
+
success: true,
|
|
328
|
+
message: `Created policy "${name}" — add steps with: celilo escalation-policy step ${name} <person> <transport> --after 0m`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function policyStep(args: string[], flags: Record<string, boolean | string>): CommandResult {
|
|
333
|
+
const [policyName, personName, transport] = args;
|
|
334
|
+
if (!policyName || !personName || !transport) {
|
|
335
|
+
return {
|
|
336
|
+
success: false,
|
|
337
|
+
error:
|
|
338
|
+
'Usage: celilo escalation-policy step <policy> <person> <transport-module> --after <minutes>',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const db = getDb();
|
|
343
|
+
const policy = findPolicy(db, policyName);
|
|
344
|
+
if (!policy) return { success: false, error: `No policy named "${policyName}".` };
|
|
345
|
+
const person = findPerson(db, personName);
|
|
346
|
+
if (!person) return { success: false, error: `No person named "${personName}".` };
|
|
347
|
+
const route = findRoute(db, person.id, transport);
|
|
348
|
+
if (!route) {
|
|
349
|
+
return {
|
|
350
|
+
success: false,
|
|
351
|
+
error: `${personName} has no ${transport} route. Add one first:\n celilo route add ${personName} ${transport} --address <addr>`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const after = flagString(flags, 'after') ?? '0';
|
|
356
|
+
const delayMinutes = Number.parseInt(after.replace(/m$/, ''), 10);
|
|
357
|
+
if (!Number.isFinite(delayMinutes) || delayMinutes < 0) {
|
|
358
|
+
return { success: false, error: `Invalid --after "${after}". Use minutes, e.g. 0, 10, 30.` };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const step = addPolicyStep(db, policy.id, route.id, delayMinutes);
|
|
362
|
+
return {
|
|
363
|
+
success: true,
|
|
364
|
+
message: `Step ${step.stepIndex}: ${personName} via ${transport} after ${delayMinutes}m`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function policyList(): CommandResult {
|
|
369
|
+
const db = getDb();
|
|
370
|
+
const policies = listPolicies(db);
|
|
371
|
+
if (policies.length === 0) {
|
|
372
|
+
console.log('\nNo escalation policies.\n');
|
|
373
|
+
console.log(' celilo escalation-policy add default\n');
|
|
374
|
+
return { success: true, message: 'No policies configured' };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const people = new Map(listPeople(db).map((p) => [p.id, p.name]));
|
|
378
|
+
const routes = new Map(listRoutes(db).map((r) => [r.id, r]));
|
|
379
|
+
|
|
380
|
+
console.log('');
|
|
381
|
+
for (const policy of policies) {
|
|
382
|
+
const steps = listPolicySteps(db, policy.id);
|
|
383
|
+
console.log(`${policy.name}${policy.bypassQuietHours ? ' (bypasses quiet hours)' : ''}`);
|
|
384
|
+
if (steps.length === 0) {
|
|
385
|
+
console.log(' (no steps — nothing will be paged)');
|
|
386
|
+
}
|
|
387
|
+
for (const step of steps) {
|
|
388
|
+
const route = routes.get(step.routeId);
|
|
389
|
+
const who = route ? (people.get(route.personId) ?? '(unknown)') : '(deleted route)';
|
|
390
|
+
const via = route ? route.transportModuleId : '?';
|
|
391
|
+
console.log(` ${step.stepIndex}. ${who} via ${via} after ${step.delayMinutes}m`);
|
|
392
|
+
}
|
|
393
|
+
console.log('');
|
|
394
|
+
}
|
|
395
|
+
return { success: true, message: `${policies.length} polic(ies)` };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function policyRemove(args: string[]): CommandResult {
|
|
399
|
+
const name = args[0];
|
|
400
|
+
if (!name) return { success: false, error: 'Usage: celilo escalation-policy remove <name>' };
|
|
401
|
+
|
|
402
|
+
const db = getDb();
|
|
403
|
+
const policy = findPolicy(db, name);
|
|
404
|
+
if (!policy) return { success: false, error: `No policy named "${name}".` };
|
|
405
|
+
|
|
406
|
+
deletePolicy(db, policy.id);
|
|
407
|
+
return { success: true, message: `Removed policy "${name}"` };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function policyAssign(args: string[]): CommandResult {
|
|
411
|
+
const [policyName, target] = args;
|
|
412
|
+
if (!policyName || !target) {
|
|
413
|
+
return { success: false, error: 'Usage: celilo escalation-policy assign <policy> <monitor>' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const db = getDb();
|
|
417
|
+
const policy = findPolicy(db, policyName);
|
|
418
|
+
if (!policy) return { success: false, error: `No policy named "${policyName}".` };
|
|
419
|
+
const monitor = findMonitorByTarget(db, target);
|
|
420
|
+
if (!monitor) return { success: false, error: `No monitor for "${target}".` };
|
|
421
|
+
|
|
422
|
+
db.update(monitors)
|
|
423
|
+
.set({ escalationPolicyId: policy.id })
|
|
424
|
+
.where(eq(monitors.id, monitor.id))
|
|
425
|
+
.run();
|
|
426
|
+
|
|
427
|
+
return { success: true, message: `${target} now escalates via "${policyName}"` };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function handleEscalationPolicy(
|
|
431
|
+
subcommand: string | undefined,
|
|
432
|
+
args: string[],
|
|
433
|
+
flags: Record<string, boolean | string> = {},
|
|
434
|
+
): Promise<CommandResult> {
|
|
435
|
+
switch (subcommand) {
|
|
436
|
+
case undefined:
|
|
437
|
+
case 'list':
|
|
438
|
+
return policyList();
|
|
439
|
+
case 'add':
|
|
440
|
+
return policyAdd(args);
|
|
441
|
+
case 'step':
|
|
442
|
+
return policyStep(args, flags);
|
|
443
|
+
case 'assign':
|
|
444
|
+
return policyAssign(args);
|
|
445
|
+
case 'remove':
|
|
446
|
+
return policyRemove(args);
|
|
447
|
+
default:
|
|
448
|
+
return {
|
|
449
|
+
success: false,
|
|
450
|
+
error: `Unknown escalation-policy subcommand: ${subcommand}\n\nUse: list, add, step, assign, remove`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
}
|
|
@@ -50,6 +50,7 @@ import type { ServiceCredentialsResult } from '../../services/audit/services-cre
|
|
|
50
50
|
import type { ServiceReachableResult } from '../../services/audit/services-reachable';
|
|
51
51
|
import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
|
|
52
52
|
import { getServiceCredentials, listContainerServices } from '../../services/container-service';
|
|
53
|
+
import { collectFirewallReach } from '../../services/firewall-reach';
|
|
53
54
|
import { runAllHealthChecks } from '../../services/health-runner';
|
|
54
55
|
import { listMachines } from '../../services/machine-pool';
|
|
55
56
|
import { parseStoredConfigValue } from '../../services/module-config';
|
|
@@ -436,6 +437,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
436
437
|
secretsDecryptable: { results: secretResults },
|
|
437
438
|
servicesReachable: { results: serviceReachableResults },
|
|
438
439
|
machinesReachable: { results: machineReachableResults },
|
|
440
|
+
trustedSources: { firewalls: collectFirewallReach(db) },
|
|
439
441
|
};
|
|
440
442
|
}
|
|
441
443
|
|
package/src/cli/completion.ts
CHANGED
|
@@ -37,16 +37,21 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
37
37
|
'commands',
|
|
38
38
|
'dns',
|
|
39
39
|
'completion',
|
|
40
|
+
'alerts',
|
|
41
|
+
'escalation-policy',
|
|
40
42
|
'events',
|
|
41
43
|
'help',
|
|
42
44
|
'hook',
|
|
43
45
|
'ipam',
|
|
44
46
|
'machine',
|
|
45
47
|
'module',
|
|
48
|
+
'monitor',
|
|
46
49
|
'package',
|
|
50
|
+
'person',
|
|
47
51
|
'proxmox',
|
|
48
52
|
'publish',
|
|
49
53
|
'registry',
|
|
54
|
+
'route',
|
|
50
55
|
'restore',
|
|
51
56
|
'service',
|
|
52
57
|
'status',
|
|
@@ -449,6 +454,27 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
449
454
|
return filterSuggestions(subcommands, args[1] || '');
|
|
450
455
|
}
|
|
451
456
|
|
|
457
|
+
// Person / route / escalation-policy subcommands
|
|
458
|
+
if (command === 'person' && currentIndex === 1) {
|
|
459
|
+
return filterSuggestions(['list', 'add', 'remove'], args[1] || '');
|
|
460
|
+
}
|
|
461
|
+
if (command === 'route' && currentIndex === 1) {
|
|
462
|
+
return filterSuggestions(['list', 'add', 'remove'], args[1] || '');
|
|
463
|
+
}
|
|
464
|
+
if (command === 'escalation-policy' && currentIndex === 1) {
|
|
465
|
+
return filterSuggestions(['list', 'add', 'step', 'assign', 'remove'], args[1] || '');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Monitor subcommands
|
|
469
|
+
if (command === 'monitor' && currentIndex === 1) {
|
|
470
|
+
return filterSuggestions(['list', 'add', 'run', 'enable', 'disable'], args[1] || '');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Alerts subcommands
|
|
474
|
+
if (command === 'alerts' && currentIndex === 1) {
|
|
475
|
+
return filterSuggestions(['list', 'ack', 'silence', 'resolve', 'sweep', 'poll'], args[1] || '');
|
|
476
|
+
}
|
|
477
|
+
|
|
452
478
|
// Storage subcommands
|
|
453
479
|
if (command === 'storage' && currentIndex === 1) {
|
|
454
480
|
const subcommands = ['add', 'list', 'remove', 'verify', 'set-default'];
|
|
@@ -357,6 +357,8 @@ _celilo_system_config_keys() {
|
|
|
357
357
|
'network.internal.gateway:Internal gateway IP'
|
|
358
358
|
'network.secure-mgmt.subnet:Control-plane subnet (celilo-mgr own network)'
|
|
359
359
|
'network.secure-mgmt.gateway:Control-plane gateway IP'
|
|
360
|
+
'network.vpn.subnet:VPN client subnet (WireGuard remote access)'
|
|
361
|
+
'firewall.trusted_subnets:Extra subnets that reach every managed zone (comma-separated CIDRs)'
|
|
360
362
|
'dns.primary:Primary DNS server'
|
|
361
363
|
'dns.fallback:Fallback DNS servers'
|
|
362
364
|
'routing.internal_gateway:Internal gateway IP'
|
package/src/cli/index.ts
CHANGED
|
@@ -178,6 +178,11 @@ Usage:
|
|
|
178
178
|
Commands:
|
|
179
179
|
status Show system and module status
|
|
180
180
|
audit Top-level alias for 'system audit'
|
|
181
|
+
alerts View alerts raised by monitors
|
|
182
|
+
monitor Manage what celilo watches (health checks on a schedule)
|
|
183
|
+
person Manage people celilo can reach
|
|
184
|
+
route Manage how each person is reached (transport + address)
|
|
185
|
+
escalation-policy Manage who gets paged, and in what order
|
|
181
186
|
events SQLite event-bus operations (status, tail, run dispatcher, etc.)
|
|
182
187
|
capability View registered module capabilities
|
|
183
188
|
dns View DNS bookkeeping (registrations ledger)
|
|
@@ -1708,6 +1713,59 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1708
1713
|
}
|
|
1709
1714
|
}
|
|
1710
1715
|
|
|
1716
|
+
if (parsed.command === 'person') {
|
|
1717
|
+
const { handlePerson } = await import('./commands/notify-config');
|
|
1718
|
+
return handlePerson(parsed.subcommand, parsed.args, parsed.flags);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
if (parsed.command === 'route') {
|
|
1722
|
+
const { handleRoute } = await import('./commands/notify-config');
|
|
1723
|
+
return handleRoute(parsed.subcommand, parsed.args, parsed.flags);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
if (parsed.command === 'escalation-policy') {
|
|
1727
|
+
const { handleEscalationPolicy } = await import('./commands/notify-config');
|
|
1728
|
+
return handleEscalationPolicy(parsed.subcommand, parsed.args, parsed.flags);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (parsed.command === 'monitor') {
|
|
1732
|
+
const { handleMonitor } = await import('./commands/monitor');
|
|
1733
|
+
return handleMonitor(parsed.subcommand, parsed.args, parsed.flags);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
if (parsed.command === 'alerts') {
|
|
1737
|
+
if (parsed.subcommand === 'ack') {
|
|
1738
|
+
const { handleAlertsAck } = await import('./commands/alerts-act');
|
|
1739
|
+
return handleAlertsAck(parsed.args, parsed.flags);
|
|
1740
|
+
}
|
|
1741
|
+
if (parsed.subcommand === 'silence') {
|
|
1742
|
+
const { handleAlertsSilence } = await import('./commands/alerts-act');
|
|
1743
|
+
return handleAlertsSilence(parsed.args, parsed.flags);
|
|
1744
|
+
}
|
|
1745
|
+
if (parsed.subcommand === 'resolve') {
|
|
1746
|
+
const { handleAlertsResolve } = await import('./commands/alerts-act');
|
|
1747
|
+
return handleAlertsResolve(parsed.args);
|
|
1748
|
+
}
|
|
1749
|
+
if (parsed.subcommand === 'poll') {
|
|
1750
|
+
const { handleAlertsPoll } = await import('./commands/alerts-poll');
|
|
1751
|
+
return handleAlertsPoll(parsed.flags);
|
|
1752
|
+
}
|
|
1753
|
+
if (parsed.subcommand === 'sweep') {
|
|
1754
|
+
const { handleAlertsSweep } = await import('./commands/alerts-sweep');
|
|
1755
|
+
return handleAlertsSweep();
|
|
1756
|
+
}
|
|
1757
|
+
if (!parsed.subcommand || parsed.subcommand === 'list') {
|
|
1758
|
+
const alertsFlagError = checkFlags('alerts', 'list', parsed.flags, parsed.args);
|
|
1759
|
+
if (alertsFlagError) return alertsFlagError;
|
|
1760
|
+
const { handleAlertsList } = await import('./commands/alerts-list');
|
|
1761
|
+
return handleAlertsList(parsed.args, parsed.flags);
|
|
1762
|
+
}
|
|
1763
|
+
return {
|
|
1764
|
+
success: false,
|
|
1765
|
+
error: `Unknown alerts subcommand: ${parsed.subcommand}\n\nUse: list, ack, silence, resolve, sweep, poll`,
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1711
1769
|
if (parsed.command === 'storage') {
|
|
1712
1770
|
if (parsed.flags.help || parsed.flags.h) {
|
|
1713
1771
|
return displayStorageHelp();
|
|
@@ -83,6 +83,7 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
|
|
|
83
83
|
'secrets_decryptable',
|
|
84
84
|
'services_reachable',
|
|
85
85
|
'machines_reachable',
|
|
86
|
+
'trusted_sources',
|
|
86
87
|
];
|
|
87
88
|
|
|
88
89
|
export const CATEGORY_LABELS: Record<DriftCategory, string> = {
|
|
@@ -100,6 +101,7 @@ export const CATEGORY_LABELS: Record<DriftCategory, string> = {
|
|
|
100
101
|
secrets_decryptable: 'Secrets',
|
|
101
102
|
services_reachable: 'Service reachability',
|
|
102
103
|
machines_reachable: 'Machine reachability',
|
|
104
|
+
trusted_sources: 'Trusted networks',
|
|
103
105
|
};
|
|
104
106
|
|
|
105
107
|
/** Total lines we keep across the command log before evicting oldest. */
|