@celilo/cli 0.26.1 → 1.0.0

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 (48) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +4 -2
  3. package/drizzle/0025_port_forward_owner.sql +29 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +3 -3
  6. package/src/__integration__/container-services-cli.integration.test.ts +0 -4
  7. package/src/ansible/dependencies.test.ts +233 -289
  8. package/src/ansible/dependencies.ts +151 -83
  9. package/src/cli/commands/alerts-sweep.ts +14 -3
  10. package/src/cli/commands/machine-add.ts +0 -1
  11. package/src/cli/commands/machine-list.ts +10 -4
  12. package/src/cli/commands/machine-remove.ts +13 -7
  13. package/src/cli/commands/machine-status.ts +9 -11
  14. package/src/cli/commands/module-remove.ts +26 -23
  15. package/src/cli/commands/system-audit.ts +5 -1
  16. package/src/cli/commands/system-update.ts +10 -2
  17. package/src/db/schema.ts +30 -10
  18. package/src/hooks/capability-loader.ts +65 -13
  19. package/src/hooks/define-hook.test.ts +4 -6
  20. package/src/hooks/executor.ts +2 -1
  21. package/src/hooks/types.ts +9 -17
  22. package/src/infrastructure/property-extractor.test.ts +0 -2
  23. package/src/manifest/contracts/index.ts +20 -0
  24. package/src/manifest/contracts/v1.ts +33 -1
  25. package/src/manifest/schema.ts +48 -58
  26. package/src/services/alerting/sweep-runner.test.ts +5 -1
  27. package/src/services/alerting/sweep-runner.ts +14 -8
  28. package/src/services/aspect-runner.test.ts +0 -1
  29. package/src/services/audit/undeployed-modules.ts +18 -1
  30. package/src/services/consumer-cleanup.test.ts +347 -0
  31. package/src/services/consumer-cleanup.ts +244 -0
  32. package/src/services/infrastructure-selector.test.ts +0 -7
  33. package/src/services/infrastructure-selector.ts +24 -25
  34. package/src/services/infrastructure-variable-resolver.test.ts +0 -6
  35. package/src/services/infrastructure-variable-resolver.ts +0 -3
  36. package/src/services/machine-pool.test.ts +53 -85
  37. package/src/services/machine-pool.ts +68 -84
  38. package/src/services/module-deploy.ts +17 -39
  39. package/src/services/module-validator/index.test.ts +9 -0
  40. package/src/services/port-forwards.test.ts +93 -40
  41. package/src/services/port-forwards.ts +74 -48
  42. package/src/services/ssh-key-manager.test.ts +0 -10
  43. package/src/services/trusted-sources.test.ts +52 -13
  44. package/src/services/trusted-sources.ts +25 -15
  45. package/src/test-utils/cli-context.ts +15 -2
  46. package/src/types/infrastructure.ts +11 -1
  47. package/src/services/web-route-cleanup.test.ts +0 -250
  48. package/src/services/web-route-cleanup.ts +0 -144
@@ -113,6 +113,16 @@ export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFacto
113
113
  script: 'scripts/control-plane-vpn-functions.ts',
114
114
  legacyFactoryName: 'default',
115
115
  },
116
+ // MODULE-provided, unlike its sibling public_web above, which the framework
117
+ // implements (createPublicWeb) against celilo's `web_routes` table. The
118
+ // asymmetry is deliberate: a private route stored in `web_routes` would be
119
+ // picked up by the PUBLIC caddy, which derives its served hostnames from
120
+ // every row of that table — so the provider keeps its own routes and celilo
121
+ // core holds no private-ingress business at all (celilo#846).
122
+ private_web: {
123
+ script: 'scripts/private-web-functions.ts',
124
+ legacyFactoryName: 'default',
125
+ },
116
126
  };
117
127
 
118
128
  /**
@@ -247,7 +257,7 @@ export async function loadCapabilityFunctions(
247
257
  // buildFirewallChain handles the per-layer wrap with auto-logging
248
258
  // internally, so we don't need to wrap the result here.
249
259
  if (capName === 'firewall' && allProviders.length > 1) {
250
- const firewallInterface = await buildFirewallChain(
260
+ const { chain, self } = await buildFirewallChain(
251
261
  allProviders,
252
262
  moduleInfo,
253
263
  masterKey,
@@ -256,8 +266,15 @@ export async function loadCapabilityFunctions(
256
266
  debugLog,
257
267
  consumingModuleId,
258
268
  );
259
- if (firewallInterface) {
260
- result[capName] = firewallInterface;
269
+ if (chain) {
270
+ result[capName] = chain;
271
+ }
272
+ // The chain hands a CONSUMER the innermost layer, which is not this
273
+ // provider's own layer when it sits further out. `on_consumer_removed`
274
+ // must converge THIS firewall, so it gets its own layer by name.
275
+ if (self) {
276
+ result.firewall_registry = self;
277
+ debugLog(`firewall_registry: provider view injected for ${consumingModuleId}`);
261
278
  }
262
279
  continue;
263
280
  }
@@ -332,6 +349,11 @@ export async function loadCapabilityFunctions(
332
349
  secrets: providerSecrets,
333
350
  systems: getModuleSystems(capability.moduleId, db),
334
351
  logger,
352
+ // WHO IS CALLING, so a provider can scope per-consumer state to them.
353
+ // `createPublicWeb` has always had this; compiled factories did not,
354
+ // which made a module-provided capability structurally unable to
355
+ // offer `unregisterRoutes()`-style methods.
356
+ consumerModuleId: consumingModuleId,
335
357
  });
336
358
  // Stamp here too, not only on the legacy path: a consumer that cannot
337
359
  // get what it needs must be able to name WHICH provider could not give
@@ -354,7 +376,7 @@ export async function loadCapabilityFunctions(
354
376
  providerSecrets,
355
377
  // Single firewall provider (no upstream chain) still needs the injected
356
378
  // port-forward store — the chain path isn't taken when there's one provider.
357
- capName === 'firewall' ? buildPortForwardStore(db) : undefined,
379
+ capName === 'firewall' ? buildPortForwardStore(db, consumingModuleId) : undefined,
358
380
  capName === 'firewall' ? loadFirewallZones(db) : undefined,
359
381
  // Bound to the CONSUMING module so a trusted-source registration is
360
382
  // attributable — reach into every tier must never be anonymous.
@@ -367,6 +389,12 @@ export async function loadCapabilityFunctions(
367
389
  wrapWithLogging(capabilityInterface as object, logger, capName),
368
390
  );
369
391
  debugLog(`${capName}: loaded via legacy factory`);
392
+ // Sole firewall provider running its OWN hook: the interface just built
393
+ // IS its layer, so hand it back under the provider-view name too.
394
+ if (capName === 'firewall' && consumingModuleId === capability.moduleId) {
395
+ result.firewall_registry = result[capName];
396
+ debugLog(`firewall_registry: provider view injected for ${consumingModuleId}`);
397
+ }
370
398
  }
371
399
  } catch (error) {
372
400
  debugLog(
@@ -972,6 +1000,20 @@ function parseInterfaceBaseline(raw: unknown): string[] | undefined {
972
1000
  return names.length > 0 ? names : undefined;
973
1001
  }
974
1002
 
1003
+ /**
1004
+ * What a firewall build hands back.
1005
+ *
1006
+ * `chain` is the layer a CONSUMER talks to (the innermost). `self` is the layer
1007
+ * belonging to the module currently running a hook, and is null unless that
1008
+ * module is itself one of the providers — it is what `on_consumer_removed`
1009
+ * converges, and it differs from `chain` whenever the provider sits further out
1010
+ * than the innermost layer.
1011
+ */
1012
+ interface FirewallChain {
1013
+ chain: unknown;
1014
+ self: unknown | null;
1015
+ }
1016
+
975
1017
  async function buildFirewallChain(
976
1018
  allProviders: Array<{
977
1019
  id: number;
@@ -986,11 +1028,12 @@ async function buildFirewallChain(
986
1028
  logger: HookLogger,
987
1029
  debugLog: (msg: string) => void,
988
1030
  consumingModuleId: string,
989
- ): Promise<unknown> {
1031
+ ): Promise<FirewallChain> {
990
1032
  // The shared-core port-forward registry, injected into every firewall provider
991
1033
  // in the chain so exposeService/converge reconcile against the one canonical
992
- // store (openspec/changes/unified-management-no-ssh/proposal.md).
993
- const store = buildPortForwardStore(db);
1034
+ // store (openspec/changes/unified-management-no-ssh/proposal.md). Bound to the
1035
+ // CONSUMING module so every forward it declares is attributable (D2).
1036
+ const store = buildPortForwardStore(db, consumingModuleId);
994
1037
  // Trusted-source registry, bound to the CONSUMING module so a registration is
995
1038
  // attributable. Only the layers that render their own ruleset receive it.
996
1039
  const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId);
@@ -1006,17 +1049,17 @@ async function buildFirewallChain(
1006
1049
 
1007
1050
  if (!hasExternal) {
1008
1051
  debugLog('firewall chain: no provider with external interface found');
1009
- return null;
1052
+ return { chain: null, self: null };
1010
1053
  }
1011
1054
 
1012
1055
  // Build the leaf (external) firewall first
1013
1056
  const leafModule = db.select().from(modules).where(eq(modules.id, hasExternal.moduleId)).get();
1014
- if (!leafModule) return null;
1057
+ if (!leafModule) return { chain: null, self: null };
1015
1058
 
1016
1059
  const leafModulePath = join(leafModule.sourcePath, moduleInfo.script);
1017
1060
  if (!existsSync(leafModulePath)) {
1018
1061
  debugLog(`firewall chain: leaf module not found at ${leafModulePath}`);
1019
- return null;
1062
+ return { chain: null, self: null };
1020
1063
  }
1021
1064
 
1022
1065
  const leafConfig = await loadModuleConfig(hasExternal.moduleId, db);
@@ -1025,7 +1068,7 @@ async function buildFirewallChain(
1025
1068
  const leafExported =
1026
1069
  typeof leafMod.default === 'function' ? leafMod.default : leafMod[moduleInfo.legacyFactoryName];
1027
1070
 
1028
- if (typeof leafExported !== 'function') return null;
1071
+ if (typeof leafExported !== 'function') return { chain: null, self: null };
1029
1072
 
1030
1073
  // Branded compiled factory (Phase 8 path): call with the canonical
1031
1074
  // context. wrapWithLogging is applied internally so the leaf interface
@@ -1037,6 +1080,7 @@ async function buildFirewallChain(
1037
1080
  secrets: leafSecrets,
1038
1081
  systems: getModuleSystems(hasExternal.moduleId, db),
1039
1082
  logger,
1083
+ consumerModuleId: consumingModuleId,
1040
1084
  });
1041
1085
  leafFirewall = stampProvider(leafFirewall, hasExternal.moduleId);
1042
1086
  debugLog(
@@ -1065,8 +1109,15 @@ async function buildFirewallChain(
1065
1109
  // doesn't fit defineCapabilityFunction's single-context shape.
1066
1110
  const downstream = allProviders.filter((p) => p.moduleId !== hasExternal.moduleId);
1067
1111
 
1112
+ // Each layer as its OWN provider sees it, so `on_consumer_removed` converges
1113
+ // the firewall that declares the hook rather than whichever layer a consumer
1114
+ // happens to talk to.
1115
+ const selfLayer = (id: string, iface: unknown): unknown | null =>
1116
+ id === consumingModuleId ? iface : null;
1117
+ let self = selfLayer(hasExternal.moduleId, leafFirewall);
1118
+
1068
1119
  if (downstream.length === 0) {
1069
- return leafFirewall;
1120
+ return { chain: leafFirewall, self };
1070
1121
  }
1071
1122
 
1072
1123
  let currentUpstream = leafFirewall;
@@ -1141,9 +1192,10 @@ async function buildFirewallChain(
1141
1192
  wrapWithLogging(downstreamFirewall as object, logger, 'firewall'),
1142
1193
  provider.moduleId,
1143
1194
  );
1195
+ self = self ?? selfLayer(provider.moduleId, currentUpstream);
1144
1196
  }
1145
1197
 
1146
- return currentUpstream;
1198
+ return { chain: currentUpstream, self };
1147
1199
  }
1148
1200
 
1149
1201
  /**
@@ -46,6 +46,7 @@ function makeContext(overrides: Partial<HookContext> = {}): HookContext {
46
46
  secrets: {},
47
47
  systems: [],
48
48
  logger: makeLogger(),
49
+ consumerModuleId: 'test-consumer',
49
50
  debug: false,
50
51
  screenshotDir: '',
51
52
  capabilities: {},
@@ -69,9 +70,6 @@ const fakePublicWeb: PublicWebCapability = {
69
70
  async upload_static_assets() {
70
71
  return { success: true, filesUploaded: 0, contentHash: 'fake' };
71
72
  },
72
- async unregister_routes() {
73
- return undefined;
74
- },
75
73
  async getServerIp() {
76
74
  return '10.0.10.10';
77
75
  },
@@ -297,9 +295,6 @@ describe('defineCapabilityFunction', () => {
297
295
  async upload_static_assets() {
298
296
  return { success: true, filesUploaded: 0, contentHash: 'x' };
299
297
  },
300
- async unregister_routes() {
301
- return undefined;
302
- },
303
298
  async getServerIp() {
304
299
  return '10.0.10.10';
305
300
  },
@@ -350,6 +345,7 @@ describe('defineCapabilityFunction', () => {
350
345
  secrets: { token: 'abc' },
351
346
  systems: [],
352
347
  logger: makeLogger(),
348
+ consumerModuleId: 'test-consumer',
353
349
  });
354
350
 
355
351
  expect(typeof methods.create_oidc_client).toBe('function');
@@ -376,6 +372,7 @@ describe('defineCapabilityFunction', () => {
376
372
  secrets: {},
377
373
  systems: [],
378
374
  logger: makeLogger(),
375
+ consumerModuleId: 'test-consumer',
379
376
  });
380
377
 
381
378
  const result = await methods.registerHost({ fqdn: 'www.example.com' });
@@ -404,6 +401,7 @@ describe('defineCapabilityFunction', () => {
404
401
  secrets: {},
405
402
  systems: [],
406
403
  logger: makeLogger(),
404
+ consumerModuleId: 'test-consumer',
407
405
  });
408
406
 
409
407
  const result = await methods.exposeService({
@@ -28,6 +28,7 @@ import {
28
28
  } from '@celilo/capabilities';
29
29
  import {
30
30
  type ContractHookSignature,
31
+ contractHookSignature,
31
32
  resolveContract,
32
33
  supportedContractVersions,
33
34
  } from '../manifest/contracts';
@@ -435,7 +436,7 @@ export async function invokeHook(
435
436
  };
436
437
  }
437
438
 
438
- const signature = contract.hooks[hookName];
439
+ const signature = contractHookSignature(contract.hooks, hookName);
439
440
  if (!signature) {
440
441
  return {
441
442
  success: false,
@@ -104,25 +104,17 @@ export interface HookResult {
104
104
  }
105
105
 
106
106
  /**
107
- * Supported lifecycle hook names.
107
+ * Supported lifecycle hook names — re-exported from `@celilo/capabilities`,
108
+ * which owns the one list (celilo#821).
108
109
  *
109
- * Must stay in sync with the canonical hook list in
110
- * `apps/celilo/src/manifest/contracts/v1.ts` and with the schema's
111
- * `hooks` block in `apps/celilo/src/manifest/schema.ts`.
110
+ * This was a second hand-maintained copy, and it had already drifted: it was
111
+ * missing `reconcile_routes`, so caddy's public_web reconcile hook was a hook
112
+ * the celilo side of the codebase did not believe in.
112
113
  */
113
- export type HookName =
114
- | 'container_created'
115
- | 'on_install'
116
- | 'on_uninstall'
117
- | 'health_check'
118
- | 'validate_config'
119
- | 'on_backup'
120
- | 'on_backup_analyze'
121
- | 'on_restore'
122
- | 'on_system_event'
123
- | 'refresh_registrations'
124
- | 'reassert_dhcp_dns'
125
- | 'reconcile_clients';
114
+ export type { HookName } from '@celilo/capabilities';
115
+ export { HOOK_NAMES } from '@celilo/capabilities';
116
+
117
+ import type { HookName } from '@celilo/capabilities';
126
118
 
127
119
  /**
128
120
  * Hook manifest section - maps hook names to definitions
@@ -26,7 +26,6 @@ describe('extractMachineProperties', () => {
26
26
  zone: 'external',
27
27
  role: 'host',
28
28
  interfaces: [],
29
- assignedModuleIds: [],
30
29
  createdAt: new Date(),
31
30
  updatedAt: new Date(),
32
31
  };
@@ -51,7 +50,6 @@ describe('extractMachineProperties', () => {
51
50
  zone: 'internal',
52
51
  role: 'host',
53
52
  interfaces: [],
54
- assignedModuleIds: [],
55
53
  createdAt: new Date(),
56
54
  updatedAt: new Date(),
57
55
  };
@@ -15,6 +15,26 @@ import type { ContractHookSignature, ContractHooks } from './v1';
15
15
 
16
16
  export type { ContractHooks, ContractHookSignature };
17
17
 
18
+ /**
19
+ * Look up a hook signature by an UNTRUSTED name.
20
+ *
21
+ * `ContractHooks` is keyed by `HookName` (celilo#821), which is what makes a
22
+ * missing or unknown entry a compile error. The executor's `hookName` is a
23
+ * plain `string` on purpose — it comes from a module manifest, and deciding
24
+ * whether it names a real hook is precisely what the caller is asking. This
25
+ * helper is the one place that crossing is expressed, so every other use of
26
+ * `ContractHooks` stays exact.
27
+ *
28
+ * Returns `undefined` for a name the contract does not define; the caller
29
+ * turns that into "Hook 'x' is not part of celilo_contract 1.0".
30
+ */
31
+ export function contractHookSignature(
32
+ hooks: ContractHooks,
33
+ name: string,
34
+ ): ContractHookSignature | undefined {
35
+ return (hooks as Record<string, ContractHookSignature | undefined>)[name];
36
+ }
37
+
18
38
  /**
19
39
  * The shape of a registered contract.
20
40
  */
@@ -22,6 +22,8 @@
22
22
  * change and requires a v2.0 contract.
23
23
  */
24
24
 
25
+ import type { HookName } from '@celilo/capabilities';
26
+
25
27
  /**
26
28
  * Per-input/output metadata.
27
29
  *
@@ -47,8 +49,21 @@ export interface ContractHookSignature {
47
49
  * Full contract for a version: a map of canonical hook name → signature.
48
50
  * Only hook names listed here are valid; declaring a hook not in this map is
49
51
  * a manifest validation error.
52
+ *
53
+ * Keyed by `HookName` rather than `string` (celilo#821). That single change is
54
+ * what turns this table from a fourth hand-maintained list into a derivation:
55
+ * a name added to `HOOK_NAMES` without an entry here is a type error, and an
56
+ * entry here for a name that is not a hook is one too. It was `Record<string,
57
+ * …>`, which is why the drift went unnoticed for months.
58
+ *
59
+ * `on_upstream_publish` is named explicitly because it is NOT a `HookName` — an
60
+ * array of build-bus match rules rather than an invokable hook. It has a
61
+ * signature here because the executor passes its payload as env vars and the
62
+ * contract is where that is written down.
50
63
  */
51
- export type ContractHooks = Record<string, ContractHookSignature>;
64
+ export type ContractHooks = Record<HookName, ContractHookSignature> & {
65
+ on_upstream_publish: ContractHookSignature;
66
+ };
52
67
 
53
68
  /**
54
69
  * Contract v1.0 — current canonical hook signatures.
@@ -70,6 +85,23 @@ export const V1_HOOKS: ContractHooks = {
70
85
  inputs: {},
71
86
  outputs: {},
72
87
  },
88
+ /**
89
+ * A module that CONSUMED one of this module's capabilities is being removed.
90
+ * The provider withdraws whatever it minted on that consumer's behalf
91
+ * (openspec/changes/consumer-removal-cleanup, D1).
92
+ *
93
+ * `consumer` is the only input and there are no outputs. Deliberately NOT
94
+ * accompanied by the list of capabilities the consumer used: a provider that
95
+ * cannot answer "what do I hold for this module" without being told has a
96
+ * different defect — the consumer's id was never recorded at the point of the
97
+ * call — and telling it at removal time only papers over that.
98
+ */
99
+ on_consumer_removed: {
100
+ inputs: {
101
+ consumer: { required: true },
102
+ },
103
+ outputs: {},
104
+ },
73
105
  health_check: {
74
106
  inputs: {},
75
107
  outputs: {},
@@ -1,3 +1,4 @@
1
+ import type { HookName } from '@celilo/capabilities';
1
2
  import { z } from 'zod';
2
3
  import { NETWORK_ZONES } from '../db/schema';
3
4
  import {
@@ -332,6 +333,52 @@ export const UpstreamPublishHookSchema = z.object({
332
333
  timeout: z.number().positive().optional(),
333
334
  });
334
335
 
336
+ /**
337
+ * The manifest schema for every invokable lifecycle hook, derived from the one
338
+ * list (celilo#821).
339
+ *
340
+ * `satisfies Record<HookName, …>` is doing the enforcement, in both directions:
341
+ * a name in `HOOK_NAMES` with no entry here is a type error, and an entry here
342
+ * that is not a `HookName` is a type error too. That is why this is a literal
343
+ * rather than something built with `Object.fromEntries` — a computed object
344
+ * would widen the keys to `string` and take `ModuleManifest['hooks']` down with
345
+ * it, trading one silent drift for another.
346
+ *
347
+ * Per-hook prose lives in `contracts/v1.ts`, which documents the same names.
348
+ * Two copies of that commentary is how they came to disagree.
349
+ */
350
+ const LIFECYCLE_HOOK_SCHEMAS = {
351
+ container_created: LifecycleHookSchema.optional(),
352
+ on_install: LifecycleHookSchema.optional(),
353
+ on_uninstall: LifecycleHookSchema.optional(),
354
+ on_consumer_removed: LifecycleHookSchema.optional(),
355
+ health_check: HealthCheckHookSchema.optional().describe(
356
+ "Health check hook. `interval` is the module's SUGGESTED monitoring cadence; the operator's monitor row is the effective schedule and always wins.",
357
+ ),
358
+ validate_config: LifecycleHookSchema.optional(),
359
+ on_backup: LifecycleHookSchema.optional(),
360
+ on_backup_analyze: LifecycleHookSchema.optional(),
361
+ on_restore: LifecycleHookSchema.optional(),
362
+ on_system_event: LifecycleHookSchema.optional(),
363
+ reconcile_routes: LifecycleHookSchema.optional(),
364
+ refresh_registrations: LifecycleHookSchema.optional(),
365
+ reassert_dhcp_dns: LifecycleHookSchema.optional(),
366
+ reconcile_clients: LifecycleHookSchema.optional(),
367
+ } satisfies Record<HookName, z.ZodTypeAny>;
368
+
369
+ /**
370
+ * What `manifest.hooks` accepts: every lifecycle hook, plus
371
+ * `on_upstream_publish`, which is deliberately not a `HookName` — an ARRAY of
372
+ * build-bus match rules dispatched by the receiver daemon rather than a hook
373
+ * `runNamedHook` can invoke. It is spread in here rather than living in
374
+ * `LIFECYCLE_HOOK_SCHEMAS` so the `satisfies` above stays exact.
375
+ * See [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
376
+ */
377
+ const HOOK_SCHEMAS = {
378
+ ...LIFECYCLE_HOOK_SCHEMAS,
379
+ on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
380
+ };
381
+
335
382
  /**
336
383
  * Machine resource recommendations
337
384
  * Module declares recommended machine resources (CPU, memory, disk, storage)
@@ -626,64 +673,7 @@ export const ModuleManifestSchema = z
626
673
  })
627
674
  .optional(),
628
675
 
629
- hooks: z
630
- .object({
631
- container_created: LifecycleHookSchema.optional(),
632
- on_install: LifecycleHookSchema.optional(),
633
- on_uninstall: LifecycleHookSchema.optional(),
634
- health_check: HealthCheckHookSchema.optional().describe(
635
- "Health check hook. `interval` is the module's SUGGESTED monitoring cadence; the operator's monitor row is the effective schedule and always wins.",
636
- ),
637
- validate_config: LifecycleHookSchema.optional(),
638
- on_backup: LifecycleHookSchema.optional(),
639
- on_backup_analyze: LifecycleHookSchema.optional(),
640
- on_restore: LifecycleHookSchema.optional(),
641
- /**
642
- * Per-system lifecycle hook. A dns_internal provider declares this
643
- * to (de)register a single host's A records when celilo's bridge
644
- * delivers a system.created/destroyed event. Inputs (hostname,
645
- * target_ip, op) come from the contract — see contracts/v1.ts and
646
- * [[openspec/specs/internal-dns-split-horizon/spec.md]] D5.
647
- */
648
- on_system_event: LifecycleHookSchema.optional(),
649
- /**
650
- * Reconcile the provider's running config from a celilo registry on a
651
- * change event. The caddy `public_web` provider declares this to
652
- * re-render its Caddyfile from web_routes when a consumer registers or
653
- * unregisters a route (ISS-0035). See
654
- * [[openspec/specs/public-web-provider-reconcile/spec.md]].
655
- */
656
- reconcile_routes: LifecycleHookSchema.optional(),
657
- /**
658
- * Periodic re-assertion of a dns_registrar provider's registered
659
- * records. The framework injects the provider's dns_registrations
660
- * ledger rows as the `registrations` input; the hook re-sends each
661
- * one to the underlying DNS API and fails loudly if any cannot be
662
- * re-asserted. Providers subscribe it to a `timer.tick.*` event.
663
- * Part of the dns_registrar capability contract — see
664
- * designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B3).
665
- */
666
- refresh_registrations: LifecycleHookSchema.optional(),
667
- /**
668
- * Periodic re-assertion of the resolver a dns_internal provider
669
- * hands out over DHCP. Some routers regenerate that value from
670
- * their own upstream list on a timer, silently undoing what
671
- * on_install set. The hook reads the device before writing, so a
672
- * quiet minute costs one query. Subscribe it to `timer.tick.1m` —
673
- * the tick interval IS the worst-case window in which a renewing
674
- * client can be handed the wrong resolver. See celilo#739.
675
- */
676
- reassert_dhcp_dns: LifecycleHookSchema.optional(),
677
- reconcile_clients: LifecycleHookSchema.optional(),
678
- /**
679
- * Build-bus upstream publish hooks. Array (a module can react
680
- * to multiple upstream packages with different actions). See
681
- * [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
682
- */
683
- on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
684
- })
685
- .strict()
686
- .optional(),
676
+ hooks: z.object(HOOK_SCHEMAS).strict().optional(),
687
677
 
688
678
  build: z
689
679
  .object({
@@ -329,7 +329,11 @@ describe('runSweep', () => {
329
329
 
330
330
  expect(report.notified).toBe(0);
331
331
  expect(report.noPolicy).toEqual([]);
332
- expect(report.skipped.within_grace).toBe(1);
332
+ // The alert is named, not just counted — an operator asking "why was I
333
+ // not paged" is asking about a specific alert (#450).
334
+ expect(report.skipped).toEqual([
335
+ { alertKey: 'module:homebridge/check:port', reason: 'within_grace' },
336
+ ]);
333
337
  });
334
338
 
335
339
  test('a transport that cannot be loaded records the error, not just a count', async () => {
@@ -78,14 +78,20 @@ export interface SweepReport {
78
78
  */
79
79
  noPolicy: { alertKey: string; monitor: string }[];
80
80
  /**
81
- * Deliveries escalation declined, keyed by its reason (`within_grace`,
82
- * `no_eligible_route`, …).
81
+ * Deliveries escalation declined the reason AND the alert it applies to.
83
82
  *
84
- * `notifyAlert` returns the reason precisely so the caller can record it its
85
- * own contract says a silent skip is indistinguishable from a bug. Dropping it
86
- * here is what made a firing-but-undelivered alert undebuggable (#450).
83
+ * `notifyAlert` returns the reason precisely so the caller can record it: its
84
+ * own contract says a silent skip is indistinguishable from a bug, and
85
+ * dropping it here is what made a firing-but-undelivered alert undebuggable
86
+ * (#450).
87
+ *
88
+ * The alert key is carried too, because a bare `within_grace×2` still does not
89
+ * answer "why was I not paged" for the alert the operator is actually looking
90
+ * at — they cannot tell which of their live alerts each count refers to. Same
91
+ * reasoning `noPolicy` already applies, and the same failure it was fixing.
92
+ * Counts are derived at render time so there is one source for both.
87
93
  */
88
- skipped: Record<string, number>;
94
+ skipped: { alertKey: string; reason: string }[];
89
95
  /**
90
96
  * Why each failed delivery failed, as `<alert key>: <error>`.
91
97
  *
@@ -121,7 +127,7 @@ export async function runSweep(
121
127
  deferredDelivered: 0,
122
128
  failed: 0,
123
129
  noPolicy: [],
124
- skipped: {},
130
+ skipped: [],
125
131
  failures: [],
126
132
  };
127
133
 
@@ -282,7 +288,7 @@ export async function runSweep(
282
288
  report.failed++;
283
289
  report.failures.push(`${alert.key}: ${outcome.error}`);
284
290
  } else if (outcome.result === 'skipped') {
285
- report.skipped[outcome.reason] = (report.skipped[outcome.reason] ?? 0) + 1;
291
+ report.skipped.push({ alertKey: alert.key, reason: outcome.reason });
286
292
  }
287
293
  }
288
294
 
@@ -54,7 +54,6 @@ async function seedMachine(opts: {
54
54
  sshUser: 'root',
55
55
  sshKey: 'ssh-key-placeholder',
56
56
  hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 5, arch: 'amd64' },
57
- assignedModuleIds: [],
58
57
  earmarkedModule: null,
59
58
  });
60
59
  if (opts.apiOnly) {
@@ -19,6 +19,17 @@ export interface UndeployedModule {
19
19
  id: string;
20
20
  /** Lifecycle state from the modules table (IMPORTED, VALIDATED, …). */
21
21
  state: string;
22
+ /**
23
+ * Why it is in ERROR, verbatim from the modules table.
24
+ *
25
+ * Surfaced because the headline used to be the hardcoded "previous deploy
26
+ * failed", and that became a FALSE statement once a provider could be marked
27
+ * ERROR for failing to withdraw a removed consumer's state
28
+ * (openspec/changes/consumer-removal-cleanup, D6) — no deploy was involved,
29
+ * and the operator reading the finding has no other way to learn which
30
+ * consumer it was.
31
+ */
32
+ errorMessage?: string | null;
22
33
  }
23
34
 
24
35
  export interface UndeployedModulesAuditDeps {
@@ -40,11 +51,17 @@ export async function auditUndeployedModules(
40
51
  if (TRANSIENT_STATES.has(m.state)) continue;
41
52
 
42
53
  if (m.state === 'ERROR') {
54
+ const reason = m.errorMessage?.trim();
43
55
  findings.push({
44
56
  category: 'undeployed_modules',
45
57
  severity: 'blocked',
46
58
  code: 'module_in_error_state',
47
- message: `${m.id}: previous deploy failed (state: ERROR)`,
59
+ // The recorded reason when there is one. A module reaches ERROR from
60
+ // more than one place now, and naming the wrong cause sends the
61
+ // operator to the wrong evidence.
62
+ message: reason
63
+ ? `${m.id}: ${reason} (state: ERROR)`
64
+ : `${m.id}: previous deploy failed (state: ERROR)`,
48
65
  details:
49
66
  'Investigate the prior failure (check `celilo module status` and the' +
50
67
  ' module logs) before retrying — re-deploying without diagnosing the' +