@celilo/cli 0.23.0 → 0.24.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 (68) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +27 -7
  3. package/package.json +6 -5
  4. package/src/cli/commands/alerts-act.ts +1 -1
  5. package/src/cli/commands/backup-create.ts +26 -11
  6. package/src/cli/commands/backup-list.test.ts +83 -0
  7. package/src/cli/commands/backup-list.ts +67 -3
  8. package/src/cli/commands/backup-prune.ts +17 -17
  9. package/src/cli/commands/backup-sweep.ts +20 -8
  10. package/src/cli/commands/firewall-interface-list.test.ts +85 -0
  11. package/src/cli/commands/firewall-interface-list.ts +123 -0
  12. package/src/cli/commands/machine-add.ts +30 -2
  13. package/src/cli/commands/module-config.test.ts +70 -3
  14. package/src/cli/commands/module-config.ts +262 -28
  15. package/src/cli/commands/module-status.ts +155 -12
  16. package/src/cli/commands/monitor.ts +116 -19
  17. package/src/cli/commands/system-migrate.ts +14 -0
  18. package/src/cli/commands/system-update.ts +4 -1
  19. package/src/cli/completion.ts +35 -9
  20. package/src/cli/index.ts +59 -2
  21. package/src/cli/tui/audit-state.ts +2 -0
  22. package/src/hooks/capability-loader.ts +130 -4
  23. package/src/hooks/load-hook-config.test.ts +169 -1
  24. package/src/hooks/load-hook-config.ts +118 -20
  25. package/src/hooks/types.ts +2 -1
  26. package/src/manifest/contracts/v1.ts +16 -0
  27. package/src/manifest/schema.ts +40 -65
  28. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  29. package/src/services/alerting/cadence-migration.test.ts +155 -0
  30. package/src/services/alerting/cadence-migration.ts +90 -0
  31. package/src/services/alerting/coverage-source.ts +8 -11
  32. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  33. package/src/services/alerting/deploy-hooks.ts +11 -5
  34. package/src/services/alerting/health-cadence.test.ts +58 -0
  35. package/src/services/alerting/health-cadence.ts +128 -0
  36. package/src/services/alerting/health-coverage.ts +18 -8
  37. package/src/services/alerting/monitors.ts +50 -15
  38. package/src/services/alerting/sweep-runner.test.ts +51 -3
  39. package/src/services/alerting/sweep-runner.ts +30 -7
  40. package/src/services/audit/backup-source.ts +24 -1
  41. package/src/services/audit/backups.test.ts +95 -10
  42. package/src/services/audit/backups.ts +40 -37
  43. package/src/services/audit/interface-classification.test.ts +220 -0
  44. package/src/services/audit/interface-classification.ts +167 -0
  45. package/src/services/audit/types.ts +2 -1
  46. package/src/services/backup-age-agreement.test.ts +118 -0
  47. package/src/services/backup-create.ts +36 -30
  48. package/src/services/backup-metadata.ts +52 -1
  49. package/src/services/backup-retention.test.ts +123 -0
  50. package/src/services/backup-retention.ts +66 -5
  51. package/src/services/backup-schedule.test.ts +166 -0
  52. package/src/services/backup-schedule.ts +105 -15
  53. package/src/services/backup-staging.ts +14 -1
  54. package/src/services/backup-sweep.test.ts +22 -3
  55. package/src/services/backup-sweep.ts +15 -5
  56. package/src/services/cadence.test.ts +97 -0
  57. package/src/services/cadence.ts +165 -0
  58. package/src/services/config-provenance.test.ts +155 -0
  59. package/src/services/config-provenance.ts +104 -0
  60. package/src/services/machine-detector.ts +23 -1
  61. package/src/services/module-config.ts +33 -0
  62. package/src/services/storage-providers/s3.test.ts +96 -13
  63. package/src/services/storage-providers/s3.ts +48 -15
  64. package/src/services/zone-detector.test.ts +34 -3
  65. package/src/services/zone-detector.ts +33 -13
  66. package/src/variables/context.ts +69 -15
  67. package/src/variables/declarative-derivation.test.ts +53 -0
  68. package/src/variables/declarative-derivation.ts +13 -2
@@ -6,7 +6,24 @@
6
6
  import { subnetContains } from '@celilo/capabilities';
7
7
  import { eq } from 'drizzle-orm';
8
8
  import { getDb } from '../db/client';
9
- import { type NetworkZone, systemConfig } from '../db/schema';
9
+ import { NETWORK_ZONES, type NetworkZone, systemConfig } from '../db/schema';
10
+
11
+ /**
12
+ * The zones a subnet can be declared for — every `NetworkZone` except
13
+ * `external`.
14
+ *
15
+ * `external` is deliberately absent and must stay absent: it has no
16
+ * `network.external.subnet` and must never be given one. It is not a segment
17
+ * celilo manages, it is whatever the ISP handed you, so it is the RESIDUAL —
18
+ * decided by `isPubliclyRoutable`, not by containment (design D1, amended).
19
+ *
20
+ * Derived from NETWORK_ZONES rather than listed by hand. The hand-written list
21
+ * that used to be here had already dropped `control-plane-vpn`, which is the
22
+ * third instance of that bug class in this repo.
23
+ */
24
+ const SUBNET_BACKED_ZONES: readonly NetworkZone[] = NETWORK_ZONES.filter(
25
+ (zone) => zone !== 'external',
26
+ );
10
27
 
11
28
  /**
12
29
  * Get system network configuration for a zone
@@ -28,25 +45,28 @@ async function getZoneSubnet(zone: NetworkZone): Promise<string | null> {
28
45
  }
29
46
 
30
47
  /**
31
- * Detect network zone from IP address
32
- * Matches IP against system network configuration
48
+ * Which zone an address belongs to, or `'unknown'` when celilo cannot say.
49
+ *
50
+ * **`'unknown'` is the honest answer, and it used to be unreachable.** This
51
+ * returned `'external'` on no-match — so on a firewall with five RFC1918 legs
52
+ * and three declared zones, private gateway addresses were each reported as
53
+ * facing the internet. `NetworkInterface.zone` was already typed
54
+ * `NetworkZone | 'unknown'` and nothing could produce it, because this claimed
55
+ * `external` instead. The slot for the honest answer existed and was
56
+ * unreachable.
33
57
  *
34
- * @param ip - IP address to check
35
- * @returns Detected network zone, or 'external' if no match
58
+ * This answers ONLY the containment question. Whether an unmatched address is
59
+ * an external edge is a different question, answered by `isPubliclyRoutable`
60
+ * see design D1 on why conflating the two was the defect.
36
61
  */
37
- export async function detectZoneFromIp(ip: string): Promise<NetworkZone> {
38
- // Check each zone's subnet
39
- const zones: NetworkZone[] = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt'];
40
-
41
- for (const zone of zones) {
62
+ export async function detectZoneFromIp(ip: string): Promise<NetworkZone | 'unknown'> {
63
+ for (const zone of SUBNET_BACKED_ZONES) {
42
64
  const subnet = await getZoneSubnet(zone);
43
65
  if (subnet && subnetContains(subnet, ip)) {
44
66
  return zone;
45
67
  }
46
68
  }
47
-
48
- // No match found - must be external
49
- return 'external';
69
+ return 'unknown';
50
70
  }
51
71
 
52
72
  /**
@@ -185,9 +185,14 @@ function resolveSelfRefsInObject(
185
185
  }
186
186
 
187
187
  /**
188
- * Build resolution context for a module
188
+ * Build the resolution context for a module, provisioning as it goes.
189
189
  *
190
- * Execution function (Rule 10.1) - performs database queries
190
+ * Execution function (Rule 10.1) - performs database queries AND writes:
191
+ * it seeds `module_configs` with defaults and zone-derived networking, and
192
+ * records the module's deployed system (allocating IPAM addresses when the
193
+ * host is a celilo-provisioned container). This is the generate/deploy-time
194
+ * entrypoint. Anything that only wants to READ the resolved configuration
195
+ * wants {@link readResolutionContext} instead.
191
196
  *
192
197
  * @param moduleId - Module to build context for
193
198
  * @param db - Database client (optional, for testing)
@@ -197,6 +202,55 @@ export async function buildResolutionContext(
197
202
  moduleId: string,
198
203
  db = getDb(),
199
204
  ): Promise<ResolutionContext> {
205
+ return assembleResolutionContext(moduleId, db, { provision: true });
206
+ }
207
+
208
+ /**
209
+ * The same resolved configuration, computed without touching the database.
210
+ *
211
+ * Every derived value is recomputed from its current upstream, exactly as a
212
+ * build would compute it, but nothing is written: no config rows are seeded,
213
+ * no addresses are allocated, no deployed system is recorded. That makes it
214
+ * safe to call from read paths that run on a cadence — health checks, hook
215
+ * invocations, capability factories — where a provisioning side effect would
216
+ * be both surprising and, in the IPAM case, harmful.
217
+ *
218
+ * This is what {@link import('../hooks/load-hook-config').loadHookConfigMap}
219
+ * uses so a hook sees the value a derive currently produces rather than only
220
+ * the ones that happen to have been stored.
221
+ *
222
+ * @param moduleId - Module to build context for
223
+ * @param db - Database client (optional, for testing)
224
+ * @returns Resolution context with all data sources
225
+ */
226
+ export async function readResolutionContext(
227
+ moduleId: string,
228
+ db = getDb(),
229
+ ): Promise<ResolutionContext> {
230
+ return assembleResolutionContext(moduleId, db, { provision: false });
231
+ }
232
+
233
+ /**
234
+ * The shared body of both entrypoints. `provision` is deliberately private
235
+ * (Rule 10.3): callers choose a named function, not a flag.
236
+ */
237
+ async function assembleResolutionContext(
238
+ moduleId: string,
239
+ db: DbClient,
240
+ { provision }: { provision: boolean },
241
+ ): Promise<ResolutionContext> {
242
+ /**
243
+ * Seed a config row — a no-op when only reading. Every seeded value is also
244
+ * assigned into `selfConfig` by the caller, so the resolved context is the
245
+ * same either way; what differs is whether it is written down.
246
+ */
247
+ const persistConfig = (
248
+ key: string,
249
+ value: string | number | boolean | unknown[] | Record<string, unknown>,
250
+ ): void => {
251
+ if (provision) upsertModuleConfig(db, moduleId, key, value);
252
+ };
253
+
200
254
  // Fetch module manifest for VM resources
201
255
  const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
202
256
 
@@ -226,12 +280,12 @@ export async function buildResolutionContext(
226
280
 
227
281
  // Store assigned values in module config
228
282
  if (assigned.hostname) {
229
- upsertModuleConfig(db, moduleId, 'hostname', assigned.hostname);
283
+ persistConfig('hostname', assigned.hostname);
230
284
  selfConfig.hostname = assigned.hostname;
231
285
  }
232
286
 
233
287
  if (assigned.zone) {
234
- upsertModuleConfig(db, moduleId, 'zone', assigned.zone);
288
+ persistConfig('zone', assigned.zone);
235
289
  selfConfig.zone = assigned.zone;
236
290
  }
237
291
  }
@@ -248,9 +302,7 @@ export async function buildResolutionContext(
248
302
  // declared shape — e.g. `default: 2222` (YAML int) round-trips as
249
303
  // `number` not the string "2222". This is the root of Defect 1.
250
304
  if (variable.default !== undefined && !selfConfig[variable.name]) {
251
- upsertModuleConfig(
252
- db,
253
- moduleId,
305
+ persistConfig(
254
306
  variable.name,
255
307
  variable.default as string | number | boolean | unknown[] | Record<string, unknown>,
256
308
  );
@@ -303,7 +355,7 @@ export async function buildResolutionContext(
303
355
  for (const { manifestKey, configKey, systemValue } of resourceMappings) {
304
356
  if (systemValue != null) {
305
357
  // Canonical system size — always wins so a resize propagates.
306
- upsertModuleConfig(db, moduleId, configKey, systemValue);
358
+ persistConfig(configKey, systemValue);
307
359
  selfConfig[configKey] = String(systemValue);
308
360
  continue;
309
361
  }
@@ -312,9 +364,7 @@ export async function buildResolutionContext(
312
364
  // Pass them through unstringified so valueJson preserves the
313
365
  // shape — see comment in the variable-defaults block above.
314
366
  if (value !== undefined && !selfConfig[configKey]) {
315
- upsertModuleConfig(
316
- db,
317
- moduleId,
367
+ persistConfig(
318
368
  configKey,
319
369
  value as string | number | boolean | unknown[] | Record<string, unknown>,
320
370
  );
@@ -333,7 +383,11 @@ export async function buildResolutionContext(
333
383
  // outputs (resolveInfrastructureVariables), not here.
334
384
  // This is the single place generate-time addresses are recorded — `target_ip`
335
385
  // no longer lives in module_configs.
336
- if (module?.manifestData) {
386
+ //
387
+ // Provisioning only. A read must never reach this: allocating an address is
388
+ // not something looking at a config should do, and by the time any hook runs
389
+ // the row is already there for `buildInfraSystemsMap` below to read.
390
+ if (provision && module?.manifestData) {
337
391
  const manifest = module.manifestData as ModuleManifest;
338
392
  const declared = getDeclaredSystems(manifest);
339
393
  const hostname = selfConfig.hostname;
@@ -593,7 +647,7 @@ export async function buildResolutionContext(
593
647
 
594
648
  // If zone from manifest but not in selfConfig, store it as first-class config
595
649
  if (zone && !selfConfig.zone) {
596
- upsertModuleConfig(db, moduleId, 'zone', zone);
650
+ persistConfig('zone', zone);
597
651
  selfConfig.zone = zone;
598
652
  }
599
653
 
@@ -625,7 +679,7 @@ export async function buildResolutionContext(
625
679
  }
626
680
  return value;
627
681
  })();
628
- upsertModuleConfig(db, moduleId, field, coerced);
682
+ persistConfig(field, coerced);
629
683
  selfConfig[field] = String(coerced);
630
684
  }
631
685
  }
@@ -685,7 +739,7 @@ export async function buildResolutionContext(
685
739
  }
686
740
 
687
741
  if (isNew || isChanged) {
688
- upsertModuleConfig(db, moduleId, key, value);
742
+ persistConfig(key, value);
689
743
  selfConfig[key] = value;
690
744
  }
691
745
  }
@@ -52,6 +52,59 @@ describe('resolveDeclarativeDerivation', () => {
52
52
  expect(result).toBe('10.0.10.0/24');
53
53
  });
54
54
 
55
+ test('resolves a system key containing hyphens', () => {
56
+ // celilo's zone names are kebab-case, so shipped manifests contain
57
+ // `$system:network.control-plane-vpn.subnet` and
58
+ // `$system:network.secure-mgmt.subnet`. While the match stopped at the
59
+ // first hyphen these looked up `network.control` / `network.secure`,
60
+ // threw, and — being optional — resolved to nothing in silence. That is
61
+ // half of the 2026-08-14 DNS outage.
62
+ const variable: VariableDeclare = {
63
+ name: 'vpn_subnet',
64
+ type: 'string',
65
+ required: false,
66
+ source: 'system',
67
+ derive_from: '$system:network.control-plane-vpn.subnet',
68
+ };
69
+
70
+ const context: ResolutionContext = {
71
+ moduleId: 'technitium',
72
+ selfConfig: {},
73
+ systemConfig: { 'network.control-plane-vpn.subnet': '10.255.255.0/24' },
74
+ systemSecrets: {},
75
+ secrets: {},
76
+ capabilities: {},
77
+ };
78
+
79
+ expect(resolveDeclarativeDerivation(variable, context)).toBe('10.255.255.0/24');
80
+ });
81
+
82
+ test('names the whole hyphenated key when it is missing', () => {
83
+ // The message has to name the key the manifest asked for. Reporting
84
+ // `network.secure` for a manifest that says `network.secure-mgmt` sends
85
+ // the reader looking for a key that was never requested.
86
+ const variable: VariableDeclare = {
87
+ name: 'secure_mgmt_subnet',
88
+ type: 'string',
89
+ required: true,
90
+ source: 'system',
91
+ derive_from: '$system:network.secure-mgmt.subnet',
92
+ };
93
+
94
+ const context: ResolutionContext = {
95
+ moduleId: 'technitium',
96
+ selfConfig: {},
97
+ systemConfig: {},
98
+ systemSecrets: {},
99
+ secrets: {},
100
+ capabilities: {},
101
+ };
102
+
103
+ expect(() => resolveDeclarativeDerivation(variable, context)).toThrow(
104
+ "Missing system config: network.secure-mgmt.subnet (required by variable 'secure_mgmt_subnet')",
105
+ );
106
+ });
107
+
55
108
  test('throws on missing system config', () => {
56
109
  const variable: VariableDeclare = {
57
110
  name: 'primary_domain',
@@ -56,8 +56,19 @@ function substituteVariables(
56
56
  ): string {
57
57
  let result = input;
58
58
 
59
- // Replace $system:key patterns (both $system:key and ${system:key} forms)
60
- result = result.replace(/\$\{?system:([a-zA-Z0-9_.]+)\}?/g, (_match, key) => {
59
+ // Replace $system:key patterns (both $system:key and ${system:key} forms).
60
+ //
61
+ // The key may contain hyphens. celilo's own zone names are kebab-case, so
62
+ // `$system:network.control-plane-vpn.subnet` and
63
+ // `$system:network.secure-mgmt.subnet` are both real keys in shipped
64
+ // manifests — and neither could ever resolve while this class excluded `-`:
65
+ // the match stopped at the first hyphen, looked up `network.control`, and
66
+ // threw. For an optional variable that throw is swallowed, so the derive
67
+ // simply produced nothing, forever, in silence. That is half of the
68
+ // 2026-08-14 DNS outage (`technitium.vpn_subnet`); the other half is that
69
+ // nothing re-derived the value at read time — see
70
+ // `hooks/load-hook-config.ts`.
71
+ result = result.replace(/\$\{?system:([a-zA-Z0-9_.-]+)\}?/g, (_match, key) => {
61
72
  const value = context.systemConfig[key];
62
73
  if (value === undefined) {
63
74
  throw new Error(`Missing system config: ${key} (required by variable '${variableName}')`);