@celilo/cli 3.1.0 → 4.1.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 (57) hide show
  1. package/CELILO_SUBSYSTEMS.md +1 -0
  2. package/package.json +5 -4
  3. package/src/cli/command-tree-parser.test.ts +18 -0
  4. package/src/cli/command-tree-parser.ts +12 -0
  5. package/src/cli/commands/api.ts +67 -14
  6. package/src/cli/commands/events.ts +5 -2
  7. package/src/cli/commands/machine-reclassify.ts +79 -0
  8. package/src/cli/commands/publish/workspace.ts +9 -0
  9. package/src/cli/commands/system-audit.ts +22 -0
  10. package/src/cli/commands/system-reboot.ts +126 -0
  11. package/src/cli/commands/system-update.ts +5 -0
  12. package/src/cli/completion.ts +3 -2
  13. package/src/cli/index.ts +19 -0
  14. package/src/cli/tui/audit-state.ts +4 -0
  15. package/src/hooks/broker.ts +31 -0
  16. package/src/hooks/capability-loader.ts +87 -44
  17. package/src/hooks/executor.ts +20 -2
  18. package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
  19. package/src/hooks/timeout-names-provider.test.ts +87 -0
  20. package/src/policy/module-business-baseline.ts +0 -6
  21. package/src/services/api-access.test.ts +44 -0
  22. package/src/services/audit/index.test.ts +7 -0
  23. package/src/services/audit/index.ts +15 -0
  24. package/src/services/audit/machine-interface-zones.test.ts +30 -0
  25. package/src/services/audit/machine-interface-zones.ts +43 -0
  26. package/src/services/audit/reboot-pending.test.ts +63 -0
  27. package/src/services/audit/reboot-pending.ts +85 -0
  28. package/src/services/audit/recurrence-gate.test.ts +21 -0
  29. package/src/services/audit/server-bun-pin.test.ts +44 -0
  30. package/src/services/audit/server-bun-pin.ts +95 -0
  31. package/src/services/audit/types.ts +2 -0
  32. package/src/services/capability-compat.test.ts +22 -0
  33. package/src/services/capability-compat.ts +15 -2
  34. package/src/services/celilo-events.test.ts +39 -0
  35. package/src/services/celilo-events.ts +41 -4
  36. package/src/services/deploy-preflight.ts +12 -3
  37. package/src/services/deploy-validation.test.ts +81 -2
  38. package/src/services/deploy-validation.ts +19 -0
  39. package/src/services/fleet-checks.ts +40 -10
  40. package/src/services/machine-interface-zones.test.ts +73 -0
  41. package/src/services/machine-interface-zones.ts +101 -0
  42. package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
  43. package/src/services/module-deploy.ts +48 -13
  44. package/src/services/provider-arrival.test.ts +62 -46
  45. package/src/services/provider-arrival.ts +41 -10
  46. package/src/services/provider-converge.test.ts +16 -0
  47. package/src/services/provider-converge.ts +106 -4
  48. package/src/services/system-reboot.test.ts +231 -0
  49. package/src/services/system-reboot.ts +357 -0
  50. package/src/services/update/orchestrator.test.ts +164 -0
  51. package/src/services/update/orchestrator.ts +43 -12
  52. package/src/services/update/types.ts +10 -1
  53. package/src/templates/generator.test.ts +15 -4
  54. package/src/templates/generator.ts +12 -4
  55. package/src/variables/context.ts +14 -1
  56. package/src/services/static-content-converge.test.ts +0 -516
  57. package/src/services/static-content-converge.ts +0 -390
package/src/cli/index.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  import { handleMachineAdd } from './commands/machine-add';
66
66
  import { handleMachineEarmark } from './commands/machine-earmark';
67
67
  import { handleMachineList } from './commands/machine-list';
68
+ import { handleMachineReclassify } from './commands/machine-reclassify';
68
69
  import { handleMachineRemove } from './commands/machine-remove';
69
70
  import { handleMachineStatus } from './commands/machine-status';
70
71
  import { moduleAudit } from './commands/module-audit';
@@ -132,6 +133,7 @@ import { handleSystemDoctor } from './commands/system-doctor';
132
133
  import { handleSystemEnsureFleetKey } from './commands/system-ensure-fleet-key';
133
134
  import { handleSystemInit } from './commands/system-init';
134
135
  import { handleSystemMigrate } from './commands/system-migrate';
136
+ import { handleSystemReboot } from './commands/system-reboot';
135
137
  import { handleSystemSecretGet } from './commands/system-secret-get';
136
138
  import { handleSystemSecretSet } from './commands/system-secret-set';
137
139
  import { handleSystemUpdate } from './commands/system-update';
@@ -1097,6 +1099,10 @@ Examples:
1097
1099
  celilo machine earmark 10.0.10.10 caddy
1098
1100
  celilo machine earmark pi-01 --clear
1099
1101
 
1102
+ # Re-classify stored interface zone labels after a zone-model change
1103
+ celilo machine reclassify celilo-mgr
1104
+ celilo machine reclassify celilo-mgr --apply
1105
+
1100
1106
  # Remove machine (by hostname or IP)
1101
1107
  celilo machine remove pi-01
1102
1108
  celilo machine remove 10.0.10.10 --force
@@ -1214,6 +1220,11 @@ Subcommands:
1214
1220
 
1215
1221
  doctor [--fix] Diagnose system prerequisites and @celilo/* version drift
1216
1222
 
1223
+ reboot [--allow-reboot] [--verify] Reboot the management server. Refuses while a module
1224
+ operation is in flight and names it. The process asking
1225
+ dies in the reboot, so --verify is what confirms the host
1226
+ came back: boot id changed, dispatcher live, CLI answering.
1227
+
1217
1228
  Runbook:
1218
1229
  https://celilo.computer/docs/system-update
1219
1230
  (offline summary in apps/celilo/docs/INDEX.md)
@@ -2269,6 +2280,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2269
2280
  return handleMachineEarmark(parsed.args, parsed.flags);
2270
2281
  }
2271
2282
 
2283
+ if (parsed.subcommand === 'reclassify') {
2284
+ return handleMachineReclassify(parsed.args, parsed.flags);
2285
+ }
2286
+
2272
2287
  return {
2273
2288
  success: false,
2274
2289
  error: `Unknown machine subcommand: ${parsed.subcommand}\n\nRun "celilo machine --help" for usage`,
@@ -2369,6 +2384,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2369
2384
  return handleSystemMigrate(parsed.args, parsed.flags);
2370
2385
  }
2371
2386
 
2387
+ if (parsed.subcommand === 'reboot') {
2388
+ return handleSystemReboot(parsed.args, parsed.flags);
2389
+ }
2390
+
2372
2391
  return {
2373
2392
  success: false,
2374
2393
  error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,
@@ -73,6 +73,8 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
73
73
  'schema',
74
74
  'capability_abi',
75
75
  'browser_pin',
76
+ 'server_bun_pin',
77
+ 'reboot_pending',
76
78
  'terraform_plan',
77
79
  'module_versions',
78
80
  'module_configs',
@@ -96,6 +98,8 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
96
98
  ];
97
99
 
98
100
  export const CATEGORY_LABELS: Record<DriftCategory, string> = {
101
+ server_bun_pin: 'Server bun pin',
102
+ reboot_pending: 'Pending reboot',
99
103
  module_integrity: 'Module integrity',
100
104
  detect_without_converge: 'Drift without converge',
101
105
  jail_exemptions: 'Hook jail exemptions',
@@ -105,6 +105,18 @@ export interface Broker {
105
105
  outcome(): HookOutcome | undefined;
106
106
  /** Every protocol-level complaint, for the error message when one matters. */
107
107
  faults(): string[];
108
+ /**
109
+ * The capability calls that are still in flight, newest last, each rendered
110
+ * as `provider_module:capability.method (Ns)`.
111
+ *
112
+ * A hook that dies at its total timeout has usually died WAITING on one of
113
+ * these, and the bare "Hook total timeout exceeded" names only the hook —
114
+ * never what it was waiting for. Measured 2026-09-21 (celilo#1403):
115
+ * bna-yard-sale's on_install died at 120s blocked on caddy's 75s route
116
+ * reconcile, and the log's only tell was a `publishStaticSite` push line
117
+ * with no matching completion. The operator had to infer the provider.
118
+ */
119
+ pendingCalls(): string[];
108
120
  /** Refuse further calls. Idempotent. Called on kill and again on cleanup. */
109
121
  stop(): void;
110
122
  /** Close the socket and remove its directory. */
@@ -184,6 +196,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
184
196
  let outcome: HookOutcome | undefined;
185
197
  let stopped = false;
186
198
  const faults: string[] = [];
199
+ /** In-flight capability calls by frame id, so a timeout can name them. */
200
+ const pending = new Map<string, { capability: string; method: string; startedAt: number }>();
187
201
  let connection: Socket | undefined;
188
202
  // Already resolved: no connection means nothing left to deliver.
189
203
  let connectionClosed: Promise<void> = Promise.resolve();
@@ -300,6 +314,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
300
314
  return;
301
315
  }
302
316
 
317
+ pending.set(id, { capability: capabilityName, method, startedAt: Date.now() });
303
318
  try {
304
319
  const value = await (fn as (...a: unknown[]) => unknown).apply(capability, args);
305
320
  options.onActivity();
@@ -307,6 +322,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
307
322
  } catch (error) {
308
323
  options.onActivity();
309
324
  send({ type: 'throw', id, error: serializeError(error) });
325
+ } finally {
326
+ pending.delete(id);
310
327
  }
311
328
  }
312
329
 
@@ -384,6 +401,20 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
384
401
  ]),
385
402
  outcome: () => outcome,
386
403
  faults: () => [...faults],
404
+ pendingCalls: () => {
405
+ const now = Date.now();
406
+ return [...pending.values()]
407
+ .sort((a, b) => a.startedAt - b.startedAt)
408
+ .map((call) => {
409
+ // `providerModuleId` is stamped onto the capability's data half by
410
+ // the loader, so the message can name the MODULE the hook is waiting
411
+ // on and not only the capability it asked through.
412
+ const view = options.capabilities[call.capability] as Record<string, unknown> | undefined;
413
+ const provider = typeof view?.providerModuleId === 'string' ? view.providerModuleId : '';
414
+ const waited = Math.round((now - call.startedAt) / 1000);
415
+ return `${provider ? `${provider}:` : ''}${call.capability}.${call.method} (${waited}s)`;
416
+ });
417
+ },
387
418
  stop: () => {
388
419
  stopped = true;
389
420
  },
@@ -80,6 +80,57 @@ import { loadHookConfigMap } from './load-hook-config';
80
80
  * upstream-chain wiring takes a second factory argument (`upstreamFirewall`)
81
81
  * that doesn't fit the `defineCapabilityFunction` shape.
82
82
  */
83
+ /**
84
+ * Make the public_web provider re-render and converge, and insist it happened.
85
+ *
86
+ * Both triggers that change what the provider must serve come through here: a
87
+ * route registered or withdrawn, and a static site published. They used to be
88
+ * two different mechanisms — an event for routes, a direct core-side content
89
+ * converge for publishes — which is how a publish could converge content
90
+ * against a config the provider had not re-rendered. One path now
91
+ * (providers-converge-declared-state, design D6).
92
+ *
93
+ * Authoritative (ISS-0081): a change the provider never reconciled is a
94
+ * FAILURE, not a warning. These used to be logger.warn while the call returned
95
+ * success anyway, so a consumer could report "ready" while caddy never learned
96
+ * the hostname — no site block, no cert, TLS internal_error for clients.
97
+ */
98
+ async function requireProviderReconcile(consumingModuleId: string, what: string): Promise<void> {
99
+ const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
100
+
101
+ if (reconcile.noDispatcher) {
102
+ throw new Error(
103
+ `public_web ${what} for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
104
+ );
105
+ }
106
+ // The handler's own words, when it left any. A crash is retried, so its
107
+ // delivery sits in `pending` with the error already recorded while the
108
+ // deadline runs out — which is how celilo#1401 reported a TIMEOUT for a
109
+ // caddy crash, for days, with `0 ok, 0 failed of 1` and the cause nowhere.
110
+ const because = reconcile.lastError ? ` The provider reported: ${reconcile.lastError}` : '';
111
+
112
+ if (reconcile.timedOut) {
113
+ throw new Error(
114
+ `public_web reconcile for ${consumingModuleId} (${what}) did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) — caddy did not confirm the change is live.${because}`,
115
+ );
116
+ }
117
+ if (reconcile.failed > 0) {
118
+ throw new Error(
119
+ `public_web reconcile for ${consumingModuleId} (${what}): ${reconcile.failed} delivery(ies) failed — caddy could not apply the change, so the hostname is not served as declared.${because}`,
120
+ );
121
+ }
122
+ // ISS-0087: a change WAS persisted and the dispatcher IS alive, yet ZERO
123
+ // providers reconciled it (no subscriber consumed routes_changed). That
124
+ // would otherwise report success for something nobody applied. `events === 0`
125
+ // means nothing changed (benign); `events > 0 && succeeded === 0` means it
126
+ // changed and nobody served it — a failure.
127
+ if (reconcile.events > 0 && reconcile.succeeded === 0) {
128
+ throw new Error(
129
+ `public_web ${what} for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the change is persisted yet never served. Ensure a public_web provider (caddy) is deployed and subscribed.`,
130
+ );
131
+ }
132
+ }
133
+
83
134
  export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName: string }> =
84
135
  {
85
136
  dns_registrar: {
@@ -646,19 +697,26 @@ export async function loadCapabilityFunctions(
646
697
  caddyModuleId: provider.moduleId,
647
698
  dnsManagedDomains,
648
699
  dnsRegistrarModuleId,
649
- // Design D10: the bytes move through the provider's Ansible converge,
650
- // not a hand-built ssh tar pipe. Core implements it over
651
- // executeAnsible against the provider's generated project (task 4.3);
652
- // awaited with everything else, so a publish returns only once the
653
- // host matches. A failure here fails the deploy loudly — a publish
700
+ // The bytes move through the provider's Ansible converge, not a
701
+ // hand-built ssh tar pipe (design D10). What changed in
702
+ // providers-converge-declared-state is WHO decides what to converge:
703
+ // core used to plan the whole fleet's release set here and throw when
704
+ // any one module had no built site (celilo#1383). Now the publish
705
+ // makes the PROVIDER re-render from the declared rows — the same path
706
+ // a route change takes — so content and config converge together and a
707
+ // broken consumer costs only its own site.
708
+ //
709
+ // The field keeps its name: it is passed into `createPublicWeb` inside
710
+ // the CONSUMER's bundled copy of @celilo/capabilities, and a module
711
+ // running an older bundle calls `deps.convergeStaticContent()` by that
712
+ // name. Renaming it here would leave that call undefined on every
713
+ // module that has not reinstalled.
714
+ //
715
+ // Awaited, so a publish returns only once the host matches: a publish
654
716
  // that reports ready while the host never received the bytes is the
655
717
  // exact "served but silently unreachable" anti-pattern.
656
718
  convergeStaticContent: async () => {
657
- const { convergeStaticContent } = await import('../services/static-content-converge');
658
- const result = await convergeStaticContent(db, provider.moduleId, consumingModuleId);
659
- if (!result.success) {
660
- throw new Error(result.error ?? 'static-content converge failed');
661
- }
719
+ await requireProviderReconcile(consumingModuleId, 'static publish');
662
720
  },
663
721
  // ISS-0035: register_route/unregister_routes emit this coarse signal
664
722
  // instead of SSHing caddy; the caddy provider's reconcile_routes
@@ -667,37 +725,7 @@ export async function loadCapabilityFunctions(
667
725
  // the consuming module's health_check runs right after and would
668
726
  // otherwise race the async reconcile.
669
727
  onRoutesChanged: async () => {
670
- const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
671
- // Authoritative (ISS-0081): a route the provider (caddy) never
672
- // reconciled is a deploy FAILURE, not a warning. These used to be
673
- // logger.warn while register_route returned success anyway, so a
674
- // consumer could report "ready" while caddy never learned the
675
- // hostname — no site block, no cert, TLS internal_error for clients.
676
- if (reconcile.noDispatcher) {
677
- throw new Error(
678
- `public_web route for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
679
- );
680
- }
681
- if (reconcile.timedOut) {
682
- throw new Error(
683
- `public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) — caddy did not confirm the route is live.`,
684
- );
685
- }
686
- if (reconcile.failed > 0) {
687
- throw new Error(
688
- `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
689
- );
690
- }
691
- // ISS-0087: a route WAS registered and the dispatcher IS alive, yet ZERO
692
- // providers reconciled it (no subscriber consumed routes_changed). That
693
- // registers a route nobody applied and would otherwise report success.
694
- // `events === 0` means no routes changed (benign); `events > 0 &&
695
- // succeeded === 0` means the route changed but nobody served it — a failure.
696
- if (reconcile.events > 0 && reconcile.succeeded === 0) {
697
- throw new Error(
698
- `public_web route for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the route is persisted yet never served (no site block, no cert). Ensure a public_web provider (caddy) is deployed and subscribed.`,
699
- );
700
- }
728
+ await requireProviderReconcile(consumingModuleId, 'route change');
701
729
  },
702
730
  });
703
731
  debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
@@ -734,6 +762,22 @@ export async function loadCapabilityFunctions(
734
762
  const { resolveModuleStateWebRoot, resolveModuleWebRoot } = await import(
735
763
  '../module/web-root'
736
764
  );
765
+
766
+ // Pause preserves state (module-pause spec), so a paused module's
767
+ // site is not re-converged. Leaving it out of the provider's site
768
+ // list is exactly that: the role converges only the slugs it is
769
+ // given and prunes nothing else, so the release already on the box
770
+ // stays, and the module's routes still render into the config.
771
+ //
772
+ // This is where d04a2bb7's paused filter went when core's fleet-wide
773
+ // plan was deleted. It moved rather than vanished, and it moved to
774
+ // the per-module lookup, which is the only place that can answer for
775
+ // one module without walking the rest.
776
+ const { isModulePaused } = await import('../services/module-pause');
777
+ if (isModulePaused(db, moduleId)) {
778
+ return { unavailable: `module '${moduleId}' is paused — its site is left as it is` };
779
+ }
780
+
737
781
  const sourceDir = resolveModuleWebRoot(moduleId, db);
738
782
  if (!sourceDir) {
739
783
  return { unavailable: `module '${moduleId}' is not installed` };
@@ -749,9 +793,8 @@ export async function loadCapabilityFunctions(
749
793
  return { sourceDir, ...overlayDir };
750
794
  },
751
795
  converge: async (artifacts) => {
752
- const { convergeProviderConfig } = await import('../services/provider-converge');
753
- const { resolveStaticContentRetention } = await import(
754
- '../services/static-content-converge'
796
+ const { convergeProviderConfig, resolveStaticContentRetention } = await import(
797
+ '../services/provider-converge'
755
798
  );
756
799
  const result = await convergeProviderConfig(db, providerModuleId, {
757
800
  ...artifacts,
@@ -511,7 +511,14 @@ export async function executeHookScript(
511
511
  setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
512
512
  };
513
513
 
514
- const totalTimer = setTimeout(() => kill('total'), timeoutMs);
514
+ // Snapshotted inside the timer rather than read at throw time: the child's
515
+ // death rejects its in-flight calls, so by the time we build the message
516
+ // the map that knows the answer has drained itself.
517
+ let pendingCallsAtKill: string[] = [];
518
+ const totalTimer = setTimeout(() => {
519
+ pendingCallsAtKill = broker.pendingCalls();
520
+ kill('total');
521
+ }, timeoutMs);
515
522
  // Debug runs get no idle timer, same as before: an operator stepping
516
523
  // through a browser hook is silent for minutes on purpose.
517
524
  const idleTimer = context.debug
@@ -536,7 +543,18 @@ export async function executeHookScript(
536
543
  // nothing keeps running past this line. That is the difference from the
537
544
  // `Promise.race` this replaces (celilo#1003).
538
545
  if (killedFor === 'total') {
539
- throw new Error(`Hook total timeout exceeded (${Math.round(timeoutMs / 1000)}s)`);
546
+ // Name what it was WAITING ON, not just that it waited. A hook whose
547
+ // budget is consumed by one slow provider used to die as a bare
548
+ // "Hook total timeout exceeded", so the operator had to infer the
549
+ // provider from a capability push line with no matching completion
550
+ // (celilo#1403: bna-yard-sale's 120s on_install blocked on caddy's 75s
551
+ // route reconcile). Captured before `close()`, while the calls are
552
+ // still in flight — `stop()` refuses new calls but clears nothing.
553
+ const waitingOn = pendingCallsAtKill;
554
+ const waitingSuffix = waitingOn.length > 0 ? `, waiting on ${waitingOn.join(', ')}` : '';
555
+ throw new Error(
556
+ `Hook total timeout exceeded (${Math.round(timeoutMs / 1000)}s)${waitingSuffix}`,
557
+ );
540
558
  }
541
559
  if (killedFor === 'idle') {
542
560
  throw new Error(
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Test fixture: a hook that blocks inside a capability call it never gets an
3
+ * answer to, and is killed at its own total timeout (celilo#1403).
4
+ *
5
+ * The real shape is a consumer's `on_install` calling
6
+ * `public_web.publishStaticSite`, which blocks on the provider's route
7
+ * reconcile. This fixture reproduces the boundary condition — a call in flight
8
+ * when the kill lands — without a provider.
9
+ *
10
+ * It logs BEFORE the call so the run is not mistaken for an idle timeout, then
11
+ * awaits forever.
12
+ */
13
+
14
+ import { defineHook } from '@celilo/capabilities';
15
+
16
+ interface SlowCapability {
17
+ neverReturns(request: Record<string, unknown>): Promise<unknown>;
18
+ }
19
+
20
+ export default defineHook({
21
+ hook: 'container_created',
22
+ requires: [],
23
+ handler: async (ctx) => {
24
+ const slow = (ctx.capabilities as unknown as Record<string, SlowCapability>).slow_provider;
25
+ ctx.logger.info('calling the provider');
26
+ await slow.neverReturns({});
27
+ },
28
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * A hook killed at its TOTAL timeout must name what it was waiting on.
3
+ *
4
+ * Measured 2026-09-21 (celilo#1403): bna-yard-sale's `on_install` called
5
+ * `public_web.publishStaticSite`, which blocks on caddy's route reconcile —
6
+ * 70-75s across the fleet's sites. The consumer died at its 120s budget with:
7
+ *
8
+ * [bna-yard-sale:on_install] -> public_web.publishStaticSite
9
+ * [bna-yard-sale:on_install] OK dns_registrar.registerHost
10
+ * [bna-yard-sale:on_install] OK dns_internal.registerRecord
11
+ * [bna-yard-sale:on_install] FAIL Hook total timeout exceeded (120s)
12
+ *
13
+ * Both DNS calls completed; the push line had no matching completion, and that
14
+ * ABSENCE was the only evidence of which provider held it. The error named the
15
+ * hook and never the cause, so the operator had to infer it from a gap in the
16
+ * log — the same class of missing signal the greenwave/axon ISP-router hooks
17
+ * already carry a comment about.
18
+ */
19
+ import { describe, expect, test } from 'bun:test';
20
+ import { mkdtempSync, rmSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { executeHookScript } from './executor';
24
+ import { createCapturingLogger } from './logger';
25
+ import { configStore, secretStore } from './test-fixtures/store-backed';
26
+ import type { HookContext } from './types';
27
+
28
+ const FIXTURES = join(__dirname, 'test-fixtures');
29
+
30
+ /** A capability whose one method never settles — the provider that holds the hook. */
31
+ function hangingCapabilities(): Record<string, unknown> {
32
+ return {
33
+ slow_provider: {
34
+ providerModuleId: 'caddy',
35
+ version: '1.0.0',
36
+ neverReturns: () => new Promise(() => {}),
37
+ },
38
+ };
39
+ }
40
+
41
+ async function runUntilTotalTimeout(): Promise<Error> {
42
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-timeout-'));
43
+ try {
44
+ const context: HookContext = {
45
+ config: configStore(),
46
+ secrets: secretStore(),
47
+ systems: [],
48
+ logger: createCapturingLogger().logger,
49
+ debug: false,
50
+ screenshotDir: dir,
51
+ stateDir: dir,
52
+ capabilities: hangingCapabilities(),
53
+ };
54
+ try {
55
+ await executeHookScript(join(FIXTURES, 'slow-capability-hook.ts'), context, {
56
+ // Total well under idle, so the kill is unambiguously the TOTAL one:
57
+ // a hook blocked in a capability call emits nothing, so a shorter idle
58
+ // timer would win the race and prove a different thing.
59
+ timeoutMs: 3_000,
60
+ idleTimeoutMs: 60_000,
61
+ });
62
+ } catch (error) {
63
+ return error as Error;
64
+ }
65
+ throw new Error('the hook returned; it was supposed to be killed at its total timeout');
66
+ } finally {
67
+ rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ }
70
+
71
+ describe('a total-timeout error names the provider it was waiting on (celilo#1403)', () => {
72
+ test('the run reached the total timeout, not the idle one', async () => {
73
+ const error = await runUntilTotalTimeout();
74
+ // Anchor the subject: an idle kill would exercise none of this.
75
+ expect(error.message).toContain('Hook total timeout exceeded');
76
+ expect(error.message).not.toContain('idle');
77
+ }, 20_000);
78
+
79
+ test('the message names the provider, the capability and the method', async () => {
80
+ const error = await runUntilTotalTimeout();
81
+ expect(error.message).toContain('waiting on');
82
+ // The provider MODULE is the operationally useful half — it is what the
83
+ // operator goes and looks at. `caddy` comes from `providerModuleId`.
84
+ expect(error.message).toContain('caddy');
85
+ expect(error.message).toContain('slow_provider.neverReturns');
86
+ }, 20_000);
87
+ });
@@ -514,10 +514,4 @@ export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
514
514
  count: 3,
515
515
  why: "X8 — the Caddyfile generator knows caddy's on-disk asset layout (#940)",
516
516
  },
517
- {
518
- file: 'apps/celilo/src/services/static-content-converge.ts',
519
- literal: '/srv/www',
520
- count: 1,
521
- why: "D10 (capability-owned-tables stage 4): the converge's slug-collision error names the on-disk release path an operator must fix. The role under modules/caddy owns the real path handling; this is message text, not path logic.",
522
- },
523
517
  ];
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { closeDb } from '@/db/client';
6
+ import { COMMANDS, flattenLeaves, isReadOnlyPath, opOf, readOnlyGrants } from '@celilo/core';
6
7
  import {
7
8
  getPrincipalByName,
8
9
  grantPrincipal,
@@ -136,3 +137,46 @@ describe('api-access (DB-backed)', () => {
136
137
  expect(await listPrincipals()).toHaveLength(0);
137
138
  });
138
139
  });
140
+
141
+ /**
142
+ * The recurrence gate for celilo#1404: every command classified read-only must
143
+ * actually be reachable by the read-only principal.
144
+ *
145
+ * The check is cross-instrument on purpose: `readOnlyGrants` decides what the RO
146
+ * principal is granted, and `grantsAllow` is the SERVER's own matcher deciding
147
+ * what that grant admits. What it catches is the two disagreeing about the shape
148
+ * of an op — which is how a derived list still leaves a read command denied.
149
+ * Verified by mutation (Rule 7.6): rendering the op as `module.journal` instead of
150
+ * `module:journal` turns this red.
151
+ *
152
+ * What it does NOT catch is the classifier itself calling a read a write — both
153
+ * sides read the same derivation, so they move together. That direction is
154
+ * covered in read-only-classifier.test.ts by a hand-written list of ops that must
155
+ * never be granted.
156
+ */
157
+ describe('read-only principal reachability (celilo#1404)', () => {
158
+ const grants = readOnlyGrants(COMMANDS);
159
+ const readLeaves = flattenLeaves(COMMANDS).filter((l) => isReadOnlyPath(l.path));
160
+
161
+ test('the registry has read-only leaves at all', () => {
162
+ // Otherwise the sweep below is vacuously green.
163
+ expect(readLeaves.length).toBeGreaterThan(20);
164
+ });
165
+
166
+ test('the server admits every read-only command for the RO principal', () => {
167
+ const denied = readLeaves
168
+ .map((l) => ({ path: l.path.join(' '), op: opOf(l.path) }))
169
+ .filter((l) => !grantsAllow(grants, l.op.split(':')[0], l.op.split(':')[1]));
170
+ expect(denied).toEqual([]);
171
+ });
172
+
173
+ test('module journal — the op the live server denied with a bare exit 126', () => {
174
+ expect(grantsAllow(grants, 'module', 'journal')).toBe(true);
175
+ expect(grantsAllow(grants, 'module', 'where')).toBe(true);
176
+ });
177
+
178
+ test('and still denies a write op', () => {
179
+ expect(grantsAllow(grants, 'module', 'deploy')).toBe(false);
180
+ expect(grantsAllow(grants, 'api', 'grant')).toBe(false);
181
+ });
182
+ });
@@ -20,6 +20,12 @@ const emptyDeps = {
20
20
  db: fakeDb,
21
21
  },
22
22
  browserPin: { consumers: [], provisioned: null },
23
+ serverBunPin: {
24
+ pinnedVersion: '1.4.2',
25
+ runningVersion: '1.4.2',
26
+ pinPath: '/etc/celilo/mise.pin.toml',
27
+ },
28
+ rebootPending: { verdict: { pending: false, unmeasured: false, reasons: [] }, marker: null },
23
29
  capabilityAbi: { modules: [] },
24
30
  terraformPlan: {
25
31
  modules: [],
@@ -42,6 +48,7 @@ const emptyDeps = {
42
48
  secretsDecryptable: { results: [] },
43
49
  servicesReachable: { results: [] },
44
50
  machinesReachable: { results: [] },
51
+ machineInterfaceZones: { machines: [] },
45
52
  diskSpace: { results: [] },
46
53
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
47
54
  trustedSources: { firewalls: [], unreachableFirewalls: [] },
@@ -25,13 +25,19 @@ import {
25
25
  import { type DiskSpaceAuditDeps, auditDiskSpace } from './disk-space';
26
26
  import { type HealthAuditDeps, auditHealth } from './health';
27
27
  import { type JailExemptionsAuditDeps, auditJailExemptions } from './jail-exemptions';
28
+ import {
29
+ type MachineInterfaceZonesAuditDeps,
30
+ auditMachineInterfaceZones,
31
+ } from './machine-interface-zones';
28
32
  import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
29
33
  import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
30
34
  import { type ModuleIntegrityAuditDeps, auditModuleIntegrity } from './module-integrity';
31
35
  import { type ModuleVersionsAuditDeps, auditModuleVersions } from './module-versions';
32
36
  import { type PublicDnsAuditDeps, auditPublicDns } from './public-dns';
37
+ import { type RebootPendingAuditDeps, auditRebootPending } from './reboot-pending';
33
38
  import { type SchemaAuditDeps, auditSchema } from './schema';
34
39
  import { type SecretsDecryptableAuditDeps, auditSecretsDecryptable } from './secrets-decryptable';
40
+ import { type ServerBunPinAuditDeps, auditServerBunPin } from './server-bun-pin';
35
41
  import {
36
42
  type ServicesCredentialsAuditDeps,
37
43
  auditServicesCredentials,
@@ -57,6 +63,8 @@ export interface AuditDeps {
57
63
  schema: SchemaAuditDeps;
58
64
  capabilityAbi: CapabilityAbiAuditDeps;
59
65
  browserPin: BrowserPinAuditDeps;
66
+ serverBunPin: ServerBunPinAuditDeps;
67
+ rebootPending: RebootPendingAuditDeps;
60
68
  terraformPlan: TerraformPlanAuditDeps;
61
69
  moduleVersions: ModuleVersionsAuditDeps;
62
70
  moduleConfigs: ModuleConfigsAuditDeps;
@@ -72,6 +80,7 @@ export interface AuditDeps {
72
80
  secretsDecryptable: SecretsDecryptableAuditDeps;
73
81
  servicesReachable: ServicesReachableAuditDeps;
74
82
  machinesReachable: MachinesReachableAuditDeps;
83
+ machineInterfaceZones: MachineInterfaceZonesAuditDeps;
75
84
  publicDns: PublicDnsAuditDeps;
76
85
  diskSpace: DiskSpaceAuditDeps;
77
86
  transportReads: TransportReadsAuditDeps;
@@ -115,6 +124,8 @@ export async function runAudit(
115
124
  wrap('schema', auditSchema(deps.schema)),
116
125
  wrap('capability_abi', auditCapabilityAbi(deps.capabilityAbi)),
117
126
  wrap('browser_pin', auditBrowserPin(deps.browserPin)),
127
+ wrap('server_bun_pin', Promise.resolve(auditServerBunPin(deps.serverBunPin))),
128
+ wrap('reboot_pending', Promise.resolve(auditRebootPending(deps.rebootPending))),
118
129
  wrap('terraform_plan', auditTerraformPlan(deps.terraformPlan)),
119
130
  wrap('module_versions', auditModuleVersions(deps.moduleVersions)),
120
131
  wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
@@ -136,6 +147,10 @@ export async function runAudit(
136
147
  wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
137
148
  wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
138
149
  wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
150
+ wrap(
151
+ 'interface_classification',
152
+ Promise.resolve(auditMachineInterfaceZones(deps.machineInterfaceZones)),
153
+ ),
139
154
  wrap('public_dns', auditPublicDns(deps.publicDns)),
140
155
  wrap('disk_space', Promise.resolve(auditDiskSpace(deps.diskSpace))),
141
156
  wrap('transport_reads', auditTransportReads(deps.transportReads)),
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { auditMachineInterfaceZones } from './machine-interface-zones';
3
+
4
+ describe('auditMachineInterfaceZones', () => {
5
+ test('reports a machine whose stored labels predate the zone model', () => {
6
+ const findings = auditMachineInterfaceZones({
7
+ machines: [
8
+ {
9
+ hostname: 'celilo-mgr',
10
+ changes: [
11
+ { name: 'eth0', ipAddress: '10.0.120.10', from: 'external', to: 'secure-mgmt' },
12
+ ],
13
+ },
14
+ ],
15
+ });
16
+
17
+ expect(findings).toHaveLength(1);
18
+ expect(findings[0].severity).toBe('drift');
19
+ expect(findings[0].code).toBe('stale_interface_zone_labels');
20
+ expect(findings[0].subject).toBe('celilo-mgr');
21
+ expect(findings[0].message).toContain('external → secure-mgmt');
22
+ expect(findings[0].remediation).toContain('celilo machine reclassify celilo-mgr --apply');
23
+ });
24
+
25
+ test('reports nothing for a machine whose labels agree', () => {
26
+ expect(auditMachineInterfaceZones({ machines: [{ hostname: 'pi-01', changes: [] }] })).toEqual(
27
+ [],
28
+ );
29
+ });
30
+ });