@celilo/cli 0.14.0 → 0.14.2
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 +1 -1
- package/package.json +1 -1
- package/src/capabilities/well-known.ts +11 -1
- package/src/cli/commands/alerts-sweep.ts +31 -1
- package/src/cli/commands/module-show.ts +11 -3
- package/src/db/schema.ts +13 -2
- package/src/manifest/schema.ts +18 -4
- package/src/services/alerting/sweep-runner.test.ts +90 -0
- package/src/services/alerting/sweep-runner.ts +50 -4
- package/src/services/machine-pool.ts +2 -1
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -39,7 +39,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
39
39
|
- **celilo-mgmt** — the celilo management server itself, deployed as a module (replaces install.sh + `system init`; ships daemon, runs migrations, self-registers). **provides:** `celilo_event_bus`, `celilo_module_deploy_worker`. **requires:** `cross_module_read`. See `openspec/specs/management-as-module/spec.md`.
|
|
40
40
|
- **celilo-registry** — module registry server (Cargo sparse protocol); stores `.netapp` files, serves index + search/download API. On install it provisions a confidential introspection OIDC client via `idp.create_oidc_client` (SECURE_MODULE_PUBLISH.md §5[D-A]) and converges its issuer + introspection endpoint + creds onto the box for RFC 7662 token verification. **provides:** `registry_publish`. **requires:** `public_web`, `dns_registrar`, `idp`.
|
|
41
41
|
- **celilo-apt-repo** — Debian apt repository (reprepro + Bun HTTP server) serving the celilo `.deb` at apt.celilo.computer. **provides:** `apt_publish`. **requires:** `public_web`, `dns_registrar`.
|
|
42
|
-
- **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public). Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:**
|
|
42
|
+
- **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public). Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
|
|
43
43
|
- **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
|
|
44
44
|
|
|
45
45
|
## Git forge & CI pipeline
|
package/package.json
CHANGED
|
@@ -19,10 +19,20 @@
|
|
|
19
19
|
* instead. Modules placed here inherit that reach, so placement is a privilege
|
|
20
20
|
* decision. celilo-mgmt may equally run in `internal`.
|
|
21
21
|
*
|
|
22
|
+
* Semi-trusted LAN:
|
|
23
|
+
* - internal: Behind the firewall (NAT outbound, port-forward inbound), shielded
|
|
24
|
+
* from the uncontrolled outside internet. Not firewall-segmented.
|
|
25
|
+
*
|
|
22
26
|
* External Zone (Cloud/VPS):
|
|
23
27
|
* - external: Services hosted outside home network (no VLAN, e.g., VPS on internet)
|
|
28
|
+
*
|
|
29
|
+
* Re-exported from db/schema rather than redeclared: this file used to carry its
|
|
30
|
+
* own hand-written copy of the union, and a second hand-maintained copy is what
|
|
31
|
+
* let `secure-mgmt` go missing in other places (see NETWORK_ZONES' comment).
|
|
24
32
|
*/
|
|
25
|
-
|
|
33
|
+
import type { NetworkZone } from '../db/schema';
|
|
34
|
+
|
|
35
|
+
export type { NetworkZone };
|
|
26
36
|
|
|
27
37
|
export interface WellKnownCapability {
|
|
28
38
|
canonical_hostname: string;
|
|
@@ -152,5 +152,35 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
|
|
|
152
152
|
`${report.unsuppressed} unsuppressed`,
|
|
153
153
|
`${report.notified} notified`,
|
|
154
154
|
];
|
|
155
|
-
|
|
155
|
+
// Only shown when non-zero: a quiet sweep should stay quiet. But a delivery
|
|
156
|
+
// that failed, was deferred, or was declined must never render as `0 notified`
|
|
157
|
+
// and nothing else — that is indistinguishable from "nothing needed sending",
|
|
158
|
+
// which is exactly how a transport that has stopped paging looks like a quiet
|
|
159
|
+
// night (#450).
|
|
160
|
+
if (report.deferred > 0) parts.push(`${report.deferred} deferred`);
|
|
161
|
+
if (report.deferredDelivered > 0) parts.push(`${report.deferredDelivered} deferred-delivered`);
|
|
162
|
+
if (report.failed > 0) parts.push(`${report.failed} FAILED`);
|
|
163
|
+
if (report.noPolicy > 0) parts.push(`${report.noPolicy} no-policy`);
|
|
164
|
+
|
|
165
|
+
const lines = [`alert sweep: ${parts.join(', ')}`];
|
|
166
|
+
|
|
167
|
+
// The reason escalation declined is the single most useful fact when someone
|
|
168
|
+
// asks "why was I not paged", so name it rather than aggregating it away.
|
|
169
|
+
const skipped = Object.entries(report.skipped).sort(([, a], [, b]) => b - a);
|
|
170
|
+
if (skipped.length > 0) {
|
|
171
|
+
lines.push(` not delivered: ${skipped.map(([r, n]) => `${r}×${n}`).join(', ')}`);
|
|
172
|
+
}
|
|
173
|
+
// The error itself, not just a count: the transport is loaded lazily inside
|
|
174
|
+
// the send, so a capability that will not load produces no other record
|
|
175
|
+
// anywhere — nothing ever reaches the transport's own logs.
|
|
176
|
+
for (const failure of report.failures) {
|
|
177
|
+
lines.push(` FAILED ${failure}`);
|
|
178
|
+
}
|
|
179
|
+
if (report.noPolicy > 0) {
|
|
180
|
+
lines.push(
|
|
181
|
+
` ${report.noPolicy} live alert(s) have no escalation policy — assign one with:\n celilo escalation-policy assign <policy> <monitor>`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { success: true, message: lines.join('\n') };
|
|
156
186
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { eq } from 'drizzle-orm';
|
|
7
7
|
import { getDb } from '../../db/client';
|
|
8
|
-
import { modules } from '../../db/schema';
|
|
8
|
+
import { type NetworkZone, modules } from '../../db/schema';
|
|
9
9
|
import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
|
|
10
10
|
import { buildResolutionContext } from '../../variables/context';
|
|
11
11
|
import { getArg, validateRequiredArgs } from '../parser';
|
|
@@ -161,14 +161,22 @@ export async function handleModuleShowZone(args: string[]): Promise<CommandResul
|
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
// Keyed by NetworkZone, not string: a new zone added to NETWORK_ZONES becomes a
|
|
165
|
+
// compile error here rather than silently rendering as "Unknown zone". Both
|
|
166
|
+
// `internal` and `secure-mgmt` were missing from the previous string-keyed map.
|
|
167
|
+
const zoneDescriptions: Record<NetworkZone, string> = {
|
|
168
|
+
internal: 'Internal (Semi-trusted network behind the firewall)',
|
|
165
169
|
dmz: 'DMZ (Public-facing services)',
|
|
166
170
|
app: 'Application (Internal services)',
|
|
167
171
|
secure: 'Secure (Authentication/Database)',
|
|
172
|
+
'secure-mgmt': "Secure-Mgmt (celilo's own control plane)",
|
|
168
173
|
external: 'External (VPS/Cloud)',
|
|
169
174
|
};
|
|
170
175
|
|
|
171
|
-
|
|
176
|
+
// Cast at the lookup, not the declaration: `zone` comes from config and may be
|
|
177
|
+
// any string, so the runtime fallback stays — but the map above still has to
|
|
178
|
+
// cover every NetworkZone.
|
|
179
|
+
const description = zoneDescriptions[zone as NetworkZone] ?? 'Unknown zone';
|
|
172
180
|
|
|
173
181
|
const lines = [`Module: ${moduleId}`, `Zone: ${zone} - ${description}`, ''];
|
|
174
182
|
|
package/src/db/schema.ts
CHANGED
|
@@ -198,7 +198,7 @@ export const ipAllocations = sqliteTable('ip_allocations', {
|
|
|
198
198
|
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
199
199
|
vmid: integer('vmid').notNull().unique(),
|
|
200
200
|
containerIp: text('container_ip').notNull().unique(), // CIDR format (e.g., "10.0.10.10/24")
|
|
201
|
-
zone: text('zone').$type<
|
|
201
|
+
zone: text('zone').$type<AllocatableZone>().notNull(),
|
|
202
202
|
allocatedAt: integer('allocated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
203
203
|
});
|
|
204
204
|
|
|
@@ -211,7 +211,7 @@ export const ipReservations = sqliteTable('ip_reservations', {
|
|
|
211
211
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
212
212
|
ipStart: text('ip_start').notNull(), // Single IP or range start
|
|
213
213
|
ipEnd: text('ip_end'), // NULL for single IP, end IP for range
|
|
214
|
-
zone: text('zone').$type<
|
|
214
|
+
zone: text('zone').$type<AllocatableZone>().notNull(),
|
|
215
215
|
reason: text('reason').notNull(),
|
|
216
216
|
reservedAt: integer('reserved_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
217
217
|
});
|
|
@@ -275,6 +275,17 @@ export const NETWORK_ZONES = [
|
|
|
275
275
|
*/
|
|
276
276
|
export type NetworkZone = (typeof NETWORK_ZONES)[number];
|
|
277
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Zones an IP allocation or reservation can name: every NetworkZone except
|
|
280
|
+
* `external`, whose systems are addressed by the provider, not by our IPAM.
|
|
281
|
+
*
|
|
282
|
+
* Derived rather than hand-written for the same reason as NetworkZone above —
|
|
283
|
+
* the previous hand-written union was copied into two column definitions and a
|
|
284
|
+
* cast in machine-pool.ts, and the cast had already drifted (it was missing
|
|
285
|
+
* `secure-mgmt`, and its comment claimed the only difference was `external`).
|
|
286
|
+
*/
|
|
287
|
+
export type AllocatableZone = Exclude<NetworkZone, 'external'>;
|
|
288
|
+
|
|
278
289
|
/**
|
|
279
290
|
* Container services table
|
|
280
291
|
* Stores container service providers (Proxmox, Digital Ocean, etc.)
|
package/src/manifest/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { NETWORK_ZONES } from '../db/schema';
|
|
2
3
|
import { SUPPORTED_CONTRACT_VERSIONS } from './contracts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -309,9 +310,19 @@ export function parseIntervalMinutes(value: string): number | null {
|
|
|
309
310
|
* operator creates a monitor by hand.
|
|
310
311
|
*/
|
|
311
312
|
export const HealthCheckHookSchema = LifecycleHookSchema.extend({
|
|
313
|
+
// `.regex` duplicates the well-formedness half of the `superRefine` below on
|
|
314
|
+
// purpose: only `.regex` survives the export to JSON Schema, and that export is
|
|
315
|
+
// what validates `modules/*/manifest.yml` in the editor. The 5-minute floor
|
|
316
|
+
// cannot be expressed in JSON Schema at all, so it stays a refinement — which
|
|
317
|
+
// is why both exist rather than one. Keeping them in sync is the point of
|
|
318
|
+
// sharing DURATION_PATTERN.
|
|
312
319
|
interval: z
|
|
313
320
|
.string()
|
|
321
|
+
.regex(DURATION_PATTERN)
|
|
314
322
|
.optional()
|
|
323
|
+
.describe(
|
|
324
|
+
'Suggested monitoring cadence, e.g. "15m", "1h", "1d". Must be 5m or longer — the monitor sweep runs on a 5-minute grid.',
|
|
325
|
+
)
|
|
315
326
|
.superRefine((value, ctx) => {
|
|
316
327
|
if (value === undefined) return;
|
|
317
328
|
const minutes = parseIntervalMinutes(value);
|
|
@@ -379,9 +390,10 @@ export const SystemResourceSchema = z.object({
|
|
|
379
390
|
'Proxmox provisioning type: lxc (default) or vm (qemu, for Docker / kernel-module workloads). ' +
|
|
380
391
|
'Modules declare this explicitly; celilo never infers it. Moot for machine-pool / external infra.',
|
|
381
392
|
),
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
393
|
+
// Derived from NETWORK_ZONES, not re-listed: a hand-written copy here would be
|
|
394
|
+
// a fourth place a new zone has to be remembered, and the copies in
|
|
395
|
+
// module-show and machine-pool had both already drifted.
|
|
396
|
+
zone: z.enum(NETWORK_ZONES).describe('Required security zone for this module'),
|
|
385
397
|
});
|
|
386
398
|
|
|
387
399
|
/**
|
|
@@ -594,7 +606,9 @@ export const ModuleManifestSchema = z
|
|
|
594
606
|
container_created: LifecycleHookSchema.optional(),
|
|
595
607
|
on_install: LifecycleHookSchema.optional(),
|
|
596
608
|
on_uninstall: LifecycleHookSchema.optional(),
|
|
597
|
-
health_check: HealthCheckHookSchema.optional()
|
|
609
|
+
health_check: HealthCheckHookSchema.optional().describe(
|
|
610
|
+
"Health check hook. `interval` is the module's SUGGESTED monitoring cadence; the operator's monitor row is the effective schedule and always wins.",
|
|
611
|
+
),
|
|
598
612
|
validate_config: LifecycleHookSchema.optional(),
|
|
599
613
|
on_backup: LifecycleHookSchema.optional(),
|
|
600
614
|
on_backup_analyze: LifecycleHookSchema.optional(),
|
|
@@ -226,4 +226,94 @@ describe('runSweep', () => {
|
|
|
226
226
|
expect(db.select().from(monitors).get()?.lastRunAt).toEqual(NOW);
|
|
227
227
|
expect(monitor.lastRunAt).toBeNull();
|
|
228
228
|
});
|
|
229
|
+
|
|
230
|
+
// A delivery that never happened must be distinguishable from one that was
|
|
231
|
+
// never needed. Both used to render as `notified: 0` and nothing else, which
|
|
232
|
+
// is how a firing-but-undelivered alert became undebuggable (#450).
|
|
233
|
+
describe('undelivered alerts are accounted for, not silently dropped', () => {
|
|
234
|
+
test('an alert with no escalation policy is counted, not skipped in silence', async () => {
|
|
235
|
+
// `notifyDepsFor` returning null IS "nobody is configured to be told" —
|
|
236
|
+
// the default in every other test here, which is why this went unnoticed.
|
|
237
|
+
const report = await runSweep(db, currentMonitors(), deps());
|
|
238
|
+
|
|
239
|
+
expect(liveAlerts()).toHaveLength(1);
|
|
240
|
+
expect(report.notified).toBe(0);
|
|
241
|
+
expect(report.noPolicy).toBe(1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('escalation declining to notify records WHICH reason', async () => {
|
|
245
|
+
// A fresh alert is inside its grace window, so escalation declines with
|
|
246
|
+
// `within_grace` — a real skip reason reached through the real code path
|
|
247
|
+
// rather than a stubbed outcome.
|
|
248
|
+
const notifyDeps = {
|
|
249
|
+
steps: [{ stepIndex: 0, routeId: 'route-1', delayMinutes: 0 }],
|
|
250
|
+
routes: new Map([['route-1', { id: 'route-1', severityFloor: 'warning', enabled: true }]]),
|
|
251
|
+
routeDetails: new Map([
|
|
252
|
+
[
|
|
253
|
+
'route-1',
|
|
254
|
+
{ id: 'route-1', personId: 'p1', address: '+15550000000', canAck: false } as never,
|
|
255
|
+
],
|
|
256
|
+
]),
|
|
257
|
+
quietHoursByPerson: new Map(),
|
|
258
|
+
bypassQuietHours: false,
|
|
259
|
+
transportFor: () => {
|
|
260
|
+
throw new Error('transport must not be reached for a skipped delivery');
|
|
261
|
+
},
|
|
262
|
+
mintToken: () => 'tok',
|
|
263
|
+
now: NOW,
|
|
264
|
+
} as never;
|
|
265
|
+
|
|
266
|
+
const report = await runSweep(
|
|
267
|
+
db,
|
|
268
|
+
currentMonitors(),
|
|
269
|
+
deps({ notifyDepsFor: () => notifyDeps }),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
expect(report.notified).toBe(0);
|
|
273
|
+
expect(report.noPolicy).toBe(0);
|
|
274
|
+
expect(report.skipped.within_grace).toBe(1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('a transport that cannot be loaded records the error, not just a count', async () => {
|
|
278
|
+
// The transport is resolved lazily INSIDE the send, so a capability that
|
|
279
|
+
// will not load never reaches the transport's own logs. If the sweep does
|
|
280
|
+
// not carry the message, it exists nowhere.
|
|
281
|
+
const notifyDeps = (now: Date) =>
|
|
282
|
+
({
|
|
283
|
+
steps: [{ stepIndex: 0, routeId: 'route-1', delayMinutes: 0 }],
|
|
284
|
+
routes: new Map([
|
|
285
|
+
['route-1', { id: 'route-1', severityFloor: 'warning', enabled: true }],
|
|
286
|
+
]),
|
|
287
|
+
routeDetails: new Map([
|
|
288
|
+
[
|
|
289
|
+
'route-1',
|
|
290
|
+
{ id: 'route-1', personId: 'p1', address: '+15550000000', canAck: false } as never,
|
|
291
|
+
],
|
|
292
|
+
]),
|
|
293
|
+
quietHoursByPerson: new Map(),
|
|
294
|
+
bypassQuietHours: false,
|
|
295
|
+
transportFor: () => {
|
|
296
|
+
throw new Error('does not provide the notification capability');
|
|
297
|
+
},
|
|
298
|
+
mintToken: () => 'tok',
|
|
299
|
+
now,
|
|
300
|
+
}) as never;
|
|
301
|
+
|
|
302
|
+
// First sweep creates the alert; the second is past the grace window, so
|
|
303
|
+
// escalation actually reaches the transport.
|
|
304
|
+
await runSweep(db, currentMonitors(), deps());
|
|
305
|
+
const at = later(20);
|
|
306
|
+
const report = await runSweep(
|
|
307
|
+
db,
|
|
308
|
+
currentMonitors(),
|
|
309
|
+
deps({ notifyDepsFor: () => notifyDeps(at) }, failing, at),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(report.notified).toBe(0);
|
|
313
|
+
expect(report.failed).toBe(1);
|
|
314
|
+
expect(report.failures).toHaveLength(1);
|
|
315
|
+
expect(report.failures[0]).toContain(PORT_CHECK);
|
|
316
|
+
expect(report.failures[0]).toContain('does not provide the notification capability');
|
|
317
|
+
});
|
|
318
|
+
});
|
|
229
319
|
});
|
|
@@ -61,6 +61,34 @@ export interface SweepReport {
|
|
|
61
61
|
/** Messages held over quiet hours and delivered now that the window ended. */
|
|
62
62
|
deferredDelivered: number;
|
|
63
63
|
failed: number;
|
|
64
|
+
/**
|
|
65
|
+
* Live alerts nobody is configured to be told about — no escalation policy on
|
|
66
|
+
* the monitor, so `notifyDepsFor` returns null.
|
|
67
|
+
*
|
|
68
|
+
* Counted rather than skipped in silence: "nothing needed sending" and "an
|
|
69
|
+
* alert is firing and no policy points at anyone" are opposite situations that
|
|
70
|
+
* previously rendered identically as `0 notified`.
|
|
71
|
+
*/
|
|
72
|
+
noPolicy: number;
|
|
73
|
+
/**
|
|
74
|
+
* Deliveries escalation declined, keyed by its reason (`within_grace`,
|
|
75
|
+
* `no_eligible_route`, …).
|
|
76
|
+
*
|
|
77
|
+
* `notifyAlert` returns the reason precisely so the caller can record it — its
|
|
78
|
+
* own contract says a silent skip is indistinguishable from a bug. Dropping it
|
|
79
|
+
* here is what made a firing-but-undelivered alert undebuggable (#450).
|
|
80
|
+
*/
|
|
81
|
+
skipped: Record<string, number>;
|
|
82
|
+
/**
|
|
83
|
+
* Why each failed delivery failed, as `<alert key>: <error>`.
|
|
84
|
+
*
|
|
85
|
+
* A count alone does not answer the only question that matters after a page
|
|
86
|
+
* did not arrive. The transport is loaded lazily *inside* `notifyAlert`'s try
|
|
87
|
+
* block, so "the capability would not load" and "Signal rejected the message"
|
|
88
|
+
* both surface here and nowhere else — there is no daemon-side log for the
|
|
89
|
+
* former, because nothing ever reached the daemon.
|
|
90
|
+
*/
|
|
91
|
+
failures: string[];
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
/**
|
|
@@ -85,6 +113,9 @@ export async function runSweep(
|
|
|
85
113
|
deferred: 0,
|
|
86
114
|
deferredDelivered: 0,
|
|
87
115
|
failed: 0,
|
|
116
|
+
noPolicy: 0,
|
|
117
|
+
skipped: {},
|
|
118
|
+
failures: [],
|
|
88
119
|
};
|
|
89
120
|
|
|
90
121
|
// 1. Run due monitors.
|
|
@@ -162,24 +193,36 @@ export async function runSweep(
|
|
|
162
193
|
try {
|
|
163
194
|
const outcome = await deliverDeferred(alert, route, notifyDeps);
|
|
164
195
|
if (outcome.result === 'sent') report.deferredDelivered++;
|
|
165
|
-
else if (outcome.result === 'failed')
|
|
166
|
-
|
|
196
|
+
else if (outcome.result === 'failed') {
|
|
197
|
+
report.failed++;
|
|
198
|
+
report.failures.push(`${alert.key} (deferred): ${outcome.error}`);
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
167
201
|
report.failed++;
|
|
202
|
+
report.failures.push(
|
|
203
|
+
`${alert.key} (deferred): ${error instanceof Error ? error.message : String(error)}`,
|
|
204
|
+
);
|
|
168
205
|
}
|
|
169
206
|
}
|
|
170
207
|
|
|
171
208
|
// 5. Notify. Re-read: the steps above changed state under us.
|
|
172
209
|
for (const alert of loadAllLiveAlerts(db)) {
|
|
173
210
|
const notifyDeps = deps.notifyDepsFor(alert);
|
|
174
|
-
if (!notifyDeps)
|
|
211
|
+
if (!notifyDeps) {
|
|
212
|
+
report.noPolicy++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
175
215
|
|
|
176
216
|
let outcome: NotifyOutcome;
|
|
177
217
|
try {
|
|
178
218
|
outcome = await notifyAlert(alert, notifyDeps);
|
|
179
|
-
} catch {
|
|
219
|
+
} catch (error) {
|
|
180
220
|
// notifyAlert already converts transport errors into a `failed` outcome;
|
|
181
221
|
// reaching here means something above the transport broke.
|
|
182
222
|
report.failed++;
|
|
223
|
+
report.failures.push(
|
|
224
|
+
`${alert.key}: ${error instanceof Error ? error.message : String(error)}`,
|
|
225
|
+
);
|
|
183
226
|
continue;
|
|
184
227
|
}
|
|
185
228
|
|
|
@@ -197,6 +240,9 @@ export async function runSweep(
|
|
|
197
240
|
report.deferred++;
|
|
198
241
|
} else if (outcome.result === 'failed') {
|
|
199
242
|
report.failed++;
|
|
243
|
+
report.failures.push(`${alert.key}: ${outcome.error}`);
|
|
244
|
+
} else if (outcome.result === 'skipped') {
|
|
245
|
+
report.skipped[outcome.reason] = (report.skipped[outcome.reason] ?? 0) + 1;
|
|
200
246
|
}
|
|
201
247
|
}
|
|
202
248
|
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { and, eq, inArray } from 'drizzle-orm';
|
|
3
3
|
import { getDb } from '../db/client';
|
|
4
4
|
import {
|
|
5
|
+
type AllocatableZone,
|
|
5
6
|
type NetworkZone,
|
|
6
7
|
containerServices,
|
|
7
8
|
ipAllocations,
|
|
@@ -355,7 +356,7 @@ export async function getContainerSystemsByZone(
|
|
|
355
356
|
eq(moduleInfrastructure.infrastructureType, 'container_service'),
|
|
356
357
|
// ip_allocations.zone is narrower than NetworkZone (no 'external');
|
|
357
358
|
// safe to cast — any 'external' input would just match zero rows.
|
|
358
|
-
inArray(ipAllocations.zone, zones as
|
|
359
|
+
inArray(ipAllocations.zone, zones as AllocatableZone[]),
|
|
359
360
|
),
|
|
360
361
|
);
|
|
361
362
|
|