@celilo/cli 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +71 -2
  3. package/docs/ALERTING.md +298 -0
  4. package/docs/INDEX.md +103 -0
  5. package/drizzle/0016_trusted_sources.sql +10 -0
  6. package/drizzle/0017_alerting.sql +127 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +3 -2
  9. package/schemas/system_config.json +9 -0
  10. package/src/capabilities/well-known.ts +11 -1
  11. package/src/cli/commands/alerts-act.ts +107 -0
  12. package/src/cli/commands/alerts-list.ts +62 -0
  13. package/src/cli/commands/alerts-poll.ts +129 -0
  14. package/src/cli/commands/alerts-sweep.ts +156 -0
  15. package/src/cli/commands/module-list.ts +50 -3
  16. package/src/cli/commands/module-show.ts +11 -3
  17. package/src/cli/commands/monitor.ts +178 -0
  18. package/src/cli/commands/notify-config.ts +453 -0
  19. package/src/cli/commands/system-audit.ts +2 -0
  20. package/src/cli/commands/system-update.ts +1 -0
  21. package/src/cli/completion.ts +26 -0
  22. package/src/cli/generate-zsh-completion.ts +2 -0
  23. package/src/cli/index.ts +58 -0
  24. package/src/cli/tui/audit-state.ts +2 -0
  25. package/src/db/schema.ts +371 -2
  26. package/src/hooks/capability-loader.ts +158 -46
  27. package/src/hooks/capability-map-coverage.test.ts +101 -0
  28. package/src/manifest/schema.ts +77 -4
  29. package/src/services/alerting/ack.test.ts +212 -0
  30. package/src/services/alerting/ack.ts +119 -0
  31. package/src/services/alerting/builtin-monitors.test.ts +132 -0
  32. package/src/services/alerting/builtin-monitors.ts +84 -0
  33. package/src/services/alerting/builtin-source.ts +82 -0
  34. package/src/services/alerting/coverage-source.ts +38 -0
  35. package/src/services/alerting/deferral.test.ts +161 -0
  36. package/src/services/alerting/delivery-loop.test.ts +396 -0
  37. package/src/services/alerting/deploy-hooks.test.ts +125 -0
  38. package/src/services/alerting/deploy-hooks.ts +111 -0
  39. package/src/services/alerting/escalation.test.ts +207 -0
  40. package/src/services/alerting/escalation.ts +151 -0
  41. package/src/services/alerting/format.test.ts +193 -0
  42. package/src/services/alerting/format.ts +150 -0
  43. package/src/services/alerting/health-coverage.ts +81 -0
  44. package/src/services/alerting/inbound-poller.test.ts +298 -0
  45. package/src/services/alerting/inbound-poller.ts +236 -0
  46. package/src/services/alerting/inbound.test.ts +201 -0
  47. package/src/services/alerting/inbound.ts +112 -0
  48. package/src/services/alerting/interview-responder.test.ts +169 -0
  49. package/src/services/alerting/interview-responder.ts +158 -0
  50. package/src/services/alerting/keys.test.ts +155 -0
  51. package/src/services/alerting/keys.ts +190 -0
  52. package/src/services/alerting/monitors.ts +185 -0
  53. package/src/services/alerting/notification-responder.test.ts +290 -0
  54. package/src/services/alerting/notification-responder.ts +260 -0
  55. package/src/services/alerting/notifier.ts +219 -0
  56. package/src/services/alerting/people.ts +178 -0
  57. package/src/services/alerting/quiet-hours.test.ts +140 -0
  58. package/src/services/alerting/quiet-hours.ts +99 -0
  59. package/src/services/alerting/reconcile.test.ts +190 -0
  60. package/src/services/alerting/reconcile.ts +166 -0
  61. package/src/services/alerting/run-monitor.test.ts +185 -0
  62. package/src/services/alerting/run-monitor.ts +177 -0
  63. package/src/services/alerting/store.test.ts +222 -0
  64. package/src/services/alerting/store.ts +289 -0
  65. package/src/services/alerting/suppression.test.ts +228 -0
  66. package/src/services/alerting/suppression.ts +142 -0
  67. package/src/services/alerting/sweep-runner.test.ts +229 -0
  68. package/src/services/alerting/sweep-runner.ts +204 -0
  69. package/src/services/alerting/sweep.test.ts +61 -0
  70. package/src/services/alerting/sweep.ts +41 -0
  71. package/src/services/alerting/tokens.test.ts +152 -0
  72. package/src/services/alerting/tokens.ts +119 -0
  73. package/src/services/alerting/transport-loader.ts +48 -0
  74. package/src/services/audit/index.test.ts +1 -0
  75. package/src/services/audit/index.ts +3 -0
  76. package/src/services/audit/trusted-sources.test.ts +137 -0
  77. package/src/services/audit/trusted-sources.ts +124 -0
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/firewall-reach.ts +83 -0
  80. package/src/services/health-runner.test.ts +50 -0
  81. package/src/services/health-runner.ts +116 -82
  82. package/src/services/machine-pool.ts +2 -1
  83. package/src/services/module-deploy.ts +17 -0
  84. package/src/services/system-config-validator.test.ts +31 -1
  85. package/src/services/trusted-sources.test.ts +221 -0
  86. package/src/services/trusted-sources.ts +159 -0
  87. package/src/services/update/orchestrator.test.ts +1 -0
  88. package/src/templates/generator.ts +6 -29
@@ -8,12 +8,12 @@
8
8
  import { eq } from 'drizzle-orm';
9
9
  import { FuelGauge } from '../cli/fuel-gauge';
10
10
  import type { DbClient } from '../db/client';
11
- import { modules } from '../db/schema';
11
+ import { type ModuleState, modules } from '../db/schema';
12
12
  import { loadCapabilityFunctions } from '../hooks/capability-loader';
13
13
  import { invokeHook } from '../hooks/executor';
14
14
  import { loadHookConfigMap } from '../hooks/load-hook-config';
15
15
  import { createCapturingLogger, createConsoleLogger, createGaugeLogger } from '../hooks/logger';
16
- import type { HookResult } from '../hooks/types';
16
+ import type { HookLogger, HookResult } from '../hooks/types';
17
17
  import type { ModuleManifest } from '../manifest/schema';
18
18
  import { decryptSecret } from '../secrets/encryption';
19
19
  import { getOrCreateMasterKey } from '../secrets/master-key';
@@ -33,21 +33,108 @@ export interface HealthCheckResult {
33
33
  error?: string;
34
34
  }
35
35
 
36
+ export interface HealthCheckOptions {
37
+ debug?: boolean;
38
+ noInteractive?: boolean;
39
+ onProgress?: (msg: string) => void;
40
+ /**
41
+ * Scheduled/monitor-driven run: no FuelGauge, no stdout, and — the part
42
+ * that matters beyond cosmetics — the module's lifecycle state is NOT
43
+ * touched. See `createHealthCheckPresentation` and the state-transition
44
+ * guard below.
45
+ */
46
+ unattended?: boolean;
47
+ }
48
+
49
+ /**
50
+ * How a health-check run reports itself while it runs.
51
+ *
52
+ * The four modes differ ONLY in which logger they build and whether a
53
+ * gauge needs stopping afterwards, so they are resolved here rather than
54
+ * branching around four copies of the `invokeHook` call (Rule 10.1 —
55
+ * presentation is not execution).
56
+ */
57
+ interface HealthCheckPresentation {
58
+ logger: HookLogger;
59
+ /** Called once the hook returns; stops the gauge when there is one. */
60
+ finish(success: boolean): void;
61
+ }
62
+
63
+ const NO_OP_FINISH = (): void => {};
64
+
65
+ function createHealthCheckPresentation(
66
+ moduleId: string,
67
+ options: HealthCheckOptions,
68
+ ): HealthCheckPresentation {
69
+ if (options.debug) {
70
+ return { logger: createConsoleLogger(moduleId, 'health_check'), finish: NO_OP_FINISH };
71
+ }
72
+
73
+ // Scheduled run: capture hook output rather than letting it reach a
74
+ // terminal nobody is watching.
75
+ if (options.unattended) {
76
+ return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
77
+ }
78
+
79
+ // TUI mode: emit a progress message instead of drawing FuelGauge
80
+ // (which writes to stdout and would corrupt the alt-screen render).
81
+ // Use a capturing logger to absorb hook-level log lines that would
82
+ // otherwise leak to stdout via createConsoleLogger.
83
+ if (options.onProgress) {
84
+ options.onProgress(`Checking ${moduleId}`);
85
+ return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
86
+ }
87
+
88
+ const gauge = new FuelGauge('Testing app', { skipAnimation: options.noInteractive });
89
+ gauge.start();
90
+ return {
91
+ logger: createGaugeLogger(gauge, moduleId, 'health_check'),
92
+ finish: (success) => gauge.stop(success),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Decide whether a health-check result should move a module's lifecycle
98
+ * state, returning the new state or `null` to leave it alone.
99
+ *
100
+ * A SCHEDULED run observes only. `VERIFIED` asserts that a check was
101
+ * deliberately run and passed; letting a recurring unattended check drive it
102
+ * would silently change what the state means — from "someone verified this"
103
+ * to "it was up N minutes ago" — and would churn it on every transient
104
+ * failure, damping that the alert lifecycle already provides and module state
105
+ * does not. Continuously observed health is reported through alerts and the
106
+ * `celilo module list` health column instead.
107
+ *
108
+ * Pure on purpose: this rule is the one thing in the scheduling work that
109
+ * regresses invisibly (a wrong boolean here just makes state flicker), so it
110
+ * is testable without a database or a hook subprocess.
111
+ *
112
+ * See openspec/changes/add-alerting/design.md D15.
113
+ */
114
+ export function nextModuleState(
115
+ current: ModuleState,
116
+ status: 'healthy' | 'degraded' | 'unhealthy',
117
+ unattended: boolean,
118
+ ): ModuleState | null {
119
+ if (unattended) return null;
120
+ if (status === 'healthy' || status === 'degraded') return 'VERIFIED';
121
+ return current === 'VERIFIED' ? 'INSTALLED' : null;
122
+ }
123
+
36
124
  /**
37
125
  * Run health checks for a single module
38
126
  *
39
127
  * `onProgress` (when supplied) replaces the FuelGauge animation. Used
40
128
  * by the audit TUI to stream progress messages into its own UI rather
41
129
  * than letting them leak to stdout before the alt-screen takes over.
130
+ *
131
+ * `unattended` marks a monitor-driven run: silent, and non-mutating with
132
+ * respect to module state.
42
133
  */
43
134
  export async function runModuleHealthCheck(
44
135
  moduleId: string,
45
136
  db: DbClient,
46
- options: {
47
- debug?: boolean;
48
- noInteractive?: boolean;
49
- onProgress?: (msg: string) => void;
50
- } = {},
137
+ options: HealthCheckOptions = {},
51
138
  ): Promise<HealthCheckResult> {
52
139
  const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
53
140
  if (!module) {
@@ -83,75 +170,25 @@ export async function runModuleHealthCheck(
83
170
  // Run the hook. Logger is constructed BEFORE loadCapabilityFunctions
84
171
  // so the auto-logging wrapper (HOOK_API_V2 D6) can capture it for
85
172
  // every capability call.
86
- let hookResult: HookResult;
87
- if (options.debug) {
88
- const logger = createConsoleLogger(moduleId, 'health_check');
89
- const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
90
- hookResult = await invokeHook(
91
- module.sourcePath,
92
- 'health_check',
93
- manifest.celilo_contract,
94
- hookDef,
95
- {},
96
- configMap,
97
- secretMap,
98
- logger,
99
- {
100
- debug: true,
101
- capabilities: capabilityFunctions,
102
- requiredCapabilities,
103
- systems: getModuleSystems(moduleId, db),
104
- },
105
- );
106
- } else if (options.onProgress) {
107
- // TUI mode: emit a progress message instead of drawing FuelGauge
108
- // (which writes to stdout and would corrupt the alt-screen render).
109
- // Use a capturing logger to absorb hook-level log lines that would
110
- // otherwise leak to stdout via createConsoleLogger.
111
- options.onProgress(`Checking ${moduleId}`);
112
- const { logger } = createCapturingLogger();
113
- const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
114
- hookResult = await invokeHook(
115
- module.sourcePath,
116
- 'health_check',
117
- manifest.celilo_contract,
118
- hookDef,
119
- {},
120
- configMap,
121
- secretMap,
122
- logger,
123
- {
124
- debug: false,
125
- capabilities: capabilityFunctions,
126
- requiredCapabilities,
127
- systems: getModuleSystems(moduleId, db),
128
- },
129
- );
130
- } else {
131
- const gauge = new FuelGauge('Testing app', {
132
- skipAnimation: options.noInteractive,
133
- });
134
- gauge.start();
135
- const logger = createGaugeLogger(gauge, moduleId, 'health_check');
136
- const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
137
- hookResult = await invokeHook(
138
- module.sourcePath,
139
- 'health_check',
140
- manifest.celilo_contract,
141
- hookDef,
142
- {},
143
- configMap,
144
- secretMap,
145
- logger,
146
- {
147
- debug: false,
148
- capabilities: capabilityFunctions,
149
- requiredCapabilities,
150
- systems: getModuleSystems(moduleId, db),
151
- },
152
- );
153
- gauge.stop(hookResult.success);
154
- }
173
+ const presentation = createHealthCheckPresentation(moduleId, options);
174
+ const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, presentation.logger);
175
+ const hookResult: HookResult = await invokeHook(
176
+ module.sourcePath,
177
+ 'health_check',
178
+ manifest.celilo_contract,
179
+ hookDef,
180
+ {},
181
+ configMap,
182
+ secretMap,
183
+ presentation.logger,
184
+ {
185
+ debug: options.debug ?? false,
186
+ capabilities: capabilityFunctions,
187
+ requiredCapabilities,
188
+ systems: getModuleSystems(moduleId, db),
189
+ },
190
+ );
191
+ presentation.finish(hookResult.success);
155
192
 
156
193
  if (!hookResult.success) {
157
194
  return {
@@ -178,12 +215,9 @@ export async function runModuleHealthCheck(
178
215
  ? 'degraded'
179
216
  : 'healthy';
180
217
 
181
- // Update module state to VERIFIED if healthy or degraded
182
- if (status === 'healthy' || status === 'degraded') {
183
- db.update(modules).set({ state: 'VERIFIED' }).where(eq(modules.id, moduleId)).run();
184
- } else if (module.state === 'VERIFIED') {
185
- // Revert to INSTALLED if previously verified but now unhealthy
186
- db.update(modules).set({ state: 'INSTALLED' }).where(eq(modules.id, moduleId)).run();
218
+ const nextState = nextModuleState(module.state, status, options.unattended ?? false);
219
+ if (nextState) {
220
+ db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
187
221
  }
188
222
 
189
223
  return { moduleId, status, checks };
@@ -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 Array<'dmz' | 'app' | 'secure' | 'internal'>),
359
+ inArray(ipAllocations.zone, zones as AllocatableZone[]),
359
360
  ),
360
361
  );
361
362
 
@@ -268,14 +268,25 @@ export async function deployModule(
268
268
  await import('./celilo-events');
269
269
  const { startOperation, completeOperation, failOperation } = await import('./module-operations');
270
270
 
271
+ const { closeDeployWindows, ensureMonitorOnDeploy, openDeployWindow } = await import(
272
+ './alerting/deploy-hooks'
273
+ );
274
+
271
275
  const opId = startOperation(moduleId, 'deploy');
272
276
  emitDeployStarted({ module: moduleId, startedAt });
273
277
 
278
+ // A deploy restarts services, which fails their own health checks. Without
279
+ // this the operator is paged about the deploy they are personally running.
280
+ openDeployWindow(db, moduleId, new Date());
281
+
274
282
  let result: DeployResult;
275
283
  try {
276
284
  result = await deployModuleImpl(moduleId, db, options);
277
285
  } catch (err) {
278
286
  const error = err instanceof Error ? err.message : String(err);
287
+ // Close on the failure path too: a window left open would silence this
288
+ // module forever, which is far worse than a few noisy alerts.
289
+ closeDeployWindows(db, moduleId, new Date());
279
290
  failOperation(opId, err);
280
291
  emitDeployFailed({
281
292
  module: moduleId,
@@ -287,7 +298,13 @@ export async function deployModule(
287
298
  }
288
299
 
289
300
  const durationMs = Math.max(0, Date.now() - startedAt);
301
+ closeDeployWindows(db, moduleId, new Date());
302
+
290
303
  if (result.success) {
304
+ // A module that declares a suggested interval starts being watched on its
305
+ // first successful deploy — watching is the default, not a thing to
306
+ // remember.
307
+ ensureMonitorOnDeploy(db, moduleId);
291
308
  completeOperation(opId);
292
309
  emitDeployCompleted({ module: moduleId, startedAt, durationMs });
293
310
  } else {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import type { SystemConfigProperty, SystemConfigSchema } from './system-config-schema-types';
5
5
  import { validateKey, validateValue } from './system-config-validator';
@@ -182,4 +182,34 @@ describe('shipped system_config.json covers every placement zone', () => {
182
182
  expect(validateKey(`network.${zone}.gateway`, shipped).valid).toBe(true);
183
183
  });
184
184
  }
185
+
186
+ // The same defect one step further out: a module can declare
187
+ // `derive_from: "$system:<key>"` for a key the shipped schema never accepts.
188
+ // technitium's `vpn_subnet` derived from `$system:network.vpn.subnet` for
189
+ // months while `system config set network.vpn.subnet` rejected the key — the
190
+ // derive had no source and DNS silently lost its VPN split-horizon view.
191
+ // Enumerating the manifests keeps this from needing a hand-maintained list.
192
+ const MODULES_DIR = join(import.meta.dir, '../../../../modules');
193
+ const derivedSystemKeys = new Set<string>();
194
+ for (const entry of readdirSync(MODULES_DIR, { withFileTypes: true })) {
195
+ if (!entry.isDirectory()) continue;
196
+ const manifest = join(MODULES_DIR, entry.name, 'manifest.yml');
197
+ if (!existsSync(manifest)) continue;
198
+ // `derive_from:` only — a bare `$system:` also appears in prose descriptions.
199
+ for (const m of readFileSync(manifest, 'utf-8').matchAll(
200
+ /derive_from:\s*["']?\$system:([\w.-]+)/g,
201
+ )) {
202
+ derivedSystemKeys.add(m[1]);
203
+ }
204
+ }
205
+
206
+ it('found system-derived keys to check (the scan itself must not silently pass)', () => {
207
+ expect(derivedSystemKeys.size).toBeGreaterThan(0);
208
+ });
209
+
210
+ for (const key of [...derivedSystemKeys].sort()) {
211
+ it(`accepts ${key}, which a module derives from`, () => {
212
+ expect(validateKey(key, shipped).valid).toBe(true);
213
+ });
214
+ }
185
215
  });
@@ -0,0 +1,221 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { DbClient } from '../db/client';
6
+ import { systemConfig } from '../db/schema';
7
+ import { loadTrustedSubnets } from '../hooks/capability-loader';
8
+ import { setupTestDatabase } from '../test-utils/setup-test-db';
9
+ import {
10
+ buildTrustedSourceStore,
11
+ composeTrustedSubnets,
12
+ listAllTrustedSources,
13
+ listTrustedSourcesFor,
14
+ parseOperatorTrustedSubnets,
15
+ } from './trusted-sources';
16
+
17
+ const FW = '192.168.0.254';
18
+ const VPN = '10.255.255.0/24';
19
+ const CONTROL_PLANE = '192.168.0.0/24';
20
+
21
+ describe('trusted-source store', () => {
22
+ let dir: string;
23
+ let db: DbClient;
24
+
25
+ beforeEach(async () => {
26
+ dir = mkdtempSync(join(tmpdir(), 'ts-'));
27
+ const dbPath = join(dir, 'celilo.db');
28
+ process.env.CELILO_DB_PATH = dbPath;
29
+ db = await setupTestDatabase(dbPath);
30
+ });
31
+ afterEach(() => {
32
+ db.$client.close();
33
+ process.env.CELILO_DB_PATH = undefined;
34
+ try {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ } catch {
37
+ /* ignore */
38
+ }
39
+ });
40
+
41
+ it('stamps registeredBy from the binding, not the caller', () => {
42
+ const store = buildTrustedSourceStore(db, 'wireguard');
43
+ store.add(FW, { subnet: VPN, description: 'admin VPN clients' });
44
+
45
+ expect(store.list(FW)).toEqual([
46
+ { subnet: VPN, description: 'admin VPN clients', registeredBy: 'wireguard' },
47
+ ]);
48
+ });
49
+
50
+ it('upserts by subnet — registering twice yields one row', () => {
51
+ const store = buildTrustedSourceStore(db, 'wireguard');
52
+ store.add(FW, { subnet: VPN, description: 'first' });
53
+ store.add(FW, { subnet: VPN, description: 'second' });
54
+
55
+ expect(store.list(FW)).toHaveLength(1);
56
+ expect(store.list(FW)[0].description).toBe('second');
57
+ });
58
+
59
+ it('removes by subnet and leaves other firewalls alone', () => {
60
+ buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'vpn' });
61
+ buildTrustedSourceStore(db, 'other').add('10.0.0.1', { subnet: VPN, description: 'elsewhere' });
62
+
63
+ buildTrustedSourceStore(db, 'wireguard').remove(FW, VPN);
64
+
65
+ expect(buildTrustedSourceStore(db, 'wireguard').list(FW)).toEqual([]);
66
+ expect(buildTrustedSourceStore(db, 'other').list('10.0.0.1')).toHaveLength(1);
67
+ });
68
+
69
+ it('reads one firewall’s registrations without a module binding', () => {
70
+ // Trust registered against one firewall is not trust granted by another —
71
+ // the read is scoped, and the reader needs no identity to look.
72
+ buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'vpn' });
73
+ buildTrustedSourceStore(db, 'other').add('10.0.0.1', {
74
+ subnet: '172.16.9.0/24',
75
+ description: 'elsewhere',
76
+ });
77
+
78
+ expect(listTrustedSourcesFor(db, FW).map((s) => s.subnet)).toEqual([VPN]);
79
+ expect(listTrustedSourcesFor(db, '10.0.0.1').map((s) => s.subnet)).toEqual(['172.16.9.0/24']);
80
+ });
81
+
82
+ it('reports every registration across firewalls, with who registered it', () => {
83
+ buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'vpn' });
84
+
85
+ expect(listAllTrustedSources(db)).toEqual([
86
+ { firewallIp: FW, subnet: VPN, description: 'vpn', registeredBy: 'wireguard' },
87
+ ]);
88
+ });
89
+ });
90
+
91
+ // Caught by e2e, and it is the whole reason the render input and the reporting
92
+ // view are different calls. `on_uninstall` withdrew the VPN's trusted source,
93
+ // logged success — and the reach rules were re-rendered anyway, because the
94
+ // snapshot the loader had injected into the provider's config still listed the
95
+ // subnet, and the provider unions that snapshot with the live registry. The set
96
+ // could grow but never shrink. One value, two sources of truth.
97
+ describe('the render input excludes registrations; the reporting view includes them', () => {
98
+ let dir: string;
99
+ let db: DbClient;
100
+
101
+ beforeEach(async () => {
102
+ dir = mkdtempSync(join(tmpdir(), 'ts-render-'));
103
+ const dbPath = join(dir, 'celilo.db');
104
+ process.env.CELILO_DB_PATH = dbPath;
105
+ db = await setupTestDatabase(dbPath);
106
+ db.insert(systemConfig).values({ key: 'network.internal.subnet', value: CONTROL_PLANE }).run();
107
+ buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'admin VPN' });
108
+ });
109
+ afterEach(() => {
110
+ db.$client.close();
111
+ process.env.CELILO_DB_PATH = undefined;
112
+ try {
113
+ rmSync(dir, { recursive: true, force: true });
114
+ } catch {
115
+ /* ignore */
116
+ }
117
+ });
118
+
119
+ it('omits registrations when no firewall is named (the render input)', () => {
120
+ // The provider unions the LIVE registry itself; folding a snapshot in here
121
+ // too is what stopped a withdrawal from ever taking effect.
122
+ expect(loadTrustedSubnets(db).map((e) => e.subnet)).toEqual([CONTROL_PLANE]);
123
+ });
124
+
125
+ it('includes them when a firewall IS named (the reporting view)', () => {
126
+ const composed = loadTrustedSubnets(db, FW);
127
+ expect(composed.map((e) => e.subnet)).toEqual([CONTROL_PLANE, VPN]);
128
+ expect(composed[1].registeredBy).toBe('wireguard');
129
+ });
130
+ });
131
+
132
+ describe('composing the trusted-subnet set', () => {
133
+ // The property that makes composition safe to ship AHEAD of any module that
134
+ // registers: a fleet with no contributors renders exactly what it rendered
135
+ // when the set was derived from the control plane alone.
136
+ it('with no registrations and no override, yields the derived subnet alone', () => {
137
+ expect(
138
+ composeTrustedSubnets({
139
+ controlPlaneSubnet: CONTROL_PLANE,
140
+ registered: [],
141
+ operatorOverride: [],
142
+ }),
143
+ ).toEqual([{ subnet: CONTROL_PLANE, origin: 'derived-control-plane' }]);
144
+ });
145
+
146
+ it('with no control plane and no contributors, yields nothing', () => {
147
+ expect(
148
+ composeTrustedSubnets({
149
+ controlPlaneSubnet: undefined,
150
+ registered: [],
151
+ operatorOverride: [],
152
+ }),
153
+ ).toEqual([]);
154
+ });
155
+
156
+ it('merges all three origins', () => {
157
+ const composed = composeTrustedSubnets({
158
+ controlPlaneSubnet: CONTROL_PLANE,
159
+ registered: [{ subnet: VPN, description: 'admin VPN', registeredBy: 'wireguard' }],
160
+ operatorOverride: ['172.16.5.0/24'],
161
+ });
162
+
163
+ expect(composed.map((e) => e.subnet)).toEqual([CONTROL_PLANE, VPN, '172.16.5.0/24']);
164
+ expect(composed.map((e) => e.origin)).toEqual([
165
+ 'derived-control-plane',
166
+ 'registered',
167
+ 'operator-override',
168
+ ]);
169
+ });
170
+
171
+ it('names WHO holds zone-wide reach, so it is never anonymous', () => {
172
+ const composed = composeTrustedSubnets({
173
+ controlPlaneSubnet: CONTROL_PLANE,
174
+ registered: [{ subnet: VPN, description: 'admin VPN', registeredBy: 'wireguard' }],
175
+ operatorOverride: [],
176
+ });
177
+
178
+ expect(composed[1]).toEqual({
179
+ subnet: VPN,
180
+ origin: 'registered',
181
+ registeredBy: 'wireguard',
182
+ description: 'admin VPN',
183
+ });
184
+ });
185
+
186
+ it('deduplicates an overlapping subnet, keeping the strongest claim', () => {
187
+ const composed = composeTrustedSubnets({
188
+ controlPlaneSubnet: CONTROL_PLANE,
189
+ registered: [{ subnet: CONTROL_PLANE, description: 'dup', registeredBy: 'wireguard' }],
190
+ operatorOverride: [CONTROL_PLANE],
191
+ });
192
+
193
+ expect(composed).toEqual([{ subnet: CONTROL_PLANE, origin: 'derived-control-plane' }]);
194
+ });
195
+
196
+ it('an operator override adds to the derived set rather than replacing it', () => {
197
+ // Replacing would let an operator strand celilo's own control plane, which
198
+ // blocks the SSH every hook and converge runs over.
199
+ const composed = composeTrustedSubnets({
200
+ controlPlaneSubnet: CONTROL_PLANE,
201
+ registered: [],
202
+ operatorOverride: ['172.16.5.0/24'],
203
+ });
204
+
205
+ expect(composed.map((e) => e.subnet)).toContain(CONTROL_PLANE);
206
+ });
207
+ });
208
+
209
+ describe('parsing the operator override', () => {
210
+ it('splits, trims, and drops empties', () => {
211
+ expect(parseOperatorTrustedSubnets(' 10.1.0.0/24 , 10.2.0.0/24 ,, ')).toEqual([
212
+ '10.1.0.0/24',
213
+ '10.2.0.0/24',
214
+ ]);
215
+ });
216
+
217
+ it('an unset key contributes nothing', () => {
218
+ expect(parseOperatorTrustedSubnets(undefined)).toEqual([]);
219
+ expect(parseOperatorTrustedSubnets('')).toEqual([]);
220
+ });
221
+ });