@celilo/cli 4.0.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 (51) 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/system-audit.ts +22 -0
  9. package/src/cli/commands/system-reboot.ts +126 -0
  10. package/src/cli/commands/system-update.ts +5 -0
  11. package/src/cli/completion.ts +3 -2
  12. package/src/cli/index.ts +19 -0
  13. package/src/cli/tui/audit-state.ts +4 -0
  14. package/src/hooks/broker.ts +31 -0
  15. package/src/hooks/capability-loader.ts +8 -2
  16. package/src/hooks/executor.ts +20 -2
  17. package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
  18. package/src/hooks/timeout-names-provider.test.ts +87 -0
  19. package/src/services/api-access.test.ts +44 -0
  20. package/src/services/audit/index.test.ts +7 -0
  21. package/src/services/audit/index.ts +15 -0
  22. package/src/services/audit/machine-interface-zones.test.ts +30 -0
  23. package/src/services/audit/machine-interface-zones.ts +43 -0
  24. package/src/services/audit/reboot-pending.test.ts +63 -0
  25. package/src/services/audit/reboot-pending.ts +85 -0
  26. package/src/services/audit/recurrence-gate.test.ts +21 -0
  27. package/src/services/audit/server-bun-pin.test.ts +44 -0
  28. package/src/services/audit/server-bun-pin.ts +95 -0
  29. package/src/services/audit/types.ts +2 -0
  30. package/src/services/capability-compat.test.ts +22 -0
  31. package/src/services/capability-compat.ts +15 -2
  32. package/src/services/celilo-events.test.ts +39 -0
  33. package/src/services/celilo-events.ts +41 -4
  34. package/src/services/deploy-preflight.ts +12 -3
  35. package/src/services/deploy-validation.test.ts +81 -2
  36. package/src/services/deploy-validation.ts +19 -0
  37. package/src/services/fleet-checks.ts +40 -10
  38. package/src/services/machine-interface-zones.test.ts +73 -0
  39. package/src/services/machine-interface-zones.ts +101 -0
  40. package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
  41. package/src/services/module-deploy.ts +31 -10
  42. package/src/services/provider-arrival.test.ts +62 -46
  43. package/src/services/provider-arrival.ts +41 -10
  44. package/src/services/system-reboot.test.ts +231 -0
  45. package/src/services/system-reboot.ts +357 -0
  46. package/src/services/update/orchestrator.test.ts +164 -0
  47. package/src/services/update/orchestrator.ts +43 -12
  48. package/src/services/update/types.ts +10 -1
  49. package/src/templates/generator.test.ts +15 -4
  50. package/src/templates/generator.ts +12 -4
  51. package/src/variables/context.ts +14 -1
@@ -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
  },
@@ -103,14 +103,20 @@ async function requireProviderReconcile(consumingModuleId: string, what: string)
103
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
104
  );
105
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
+
106
112
  if (reconcile.timedOut) {
107
113
  throw new Error(
108
- `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.`,
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}`,
109
115
  );
110
116
  }
111
117
  if (reconcile.failed > 0) {
112
118
  throw new Error(
113
- `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.`,
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}`,
114
120
  );
115
121
  }
116
122
  // ISS-0087: a change WAS persisted and the dispatcher IS alive, yet ZERO
@@ -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
+ });
@@ -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
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Stored machine interface zone labels that no longer match the zone model.
3
+ *
4
+ * A label written at `machine add` is never revisited, so a zone-model change
5
+ * leaves every earlier machine describing a topology that no longer exists.
6
+ * Nothing said so until this check: celilo#1382 surfaced only as an upgrade
7
+ * failing with "no non-loopback interface in zone 'secure-mgmt'", on a machine
8
+ * that had one.
9
+ *
10
+ * `drift`, not `blocked`: a stale label breaks a deploy only when something
11
+ * resolves by zone, and the remediation is one command.
12
+ *
13
+ * Pure over a pre-computed diff — see services/machine-interface-zones.ts.
14
+ */
15
+
16
+ import type { ZoneReclassification } from '../machine-interface-zones';
17
+ import type { DriftFinding } from './types';
18
+
19
+ export interface MachineInterfaceZonesAuditDeps {
20
+ /** One entry per machine, carrying only the labels that disagree. */
21
+ machines: Array<{ hostname: string; changes: ZoneReclassification[] }>;
22
+ }
23
+
24
+ export function auditMachineInterfaceZones(deps: MachineInterfaceZonesAuditDeps): DriftFinding[] {
25
+ return deps.machines
26
+ .filter((m) => m.changes.length > 0)
27
+ .map(({ hostname, changes }) => {
28
+ const named = changes
29
+ .map((c) => `${c.name} (${c.ipAddress}): ${c.from} → ${c.to}`)
30
+ .join(', ');
31
+ return {
32
+ category: 'interface_classification',
33
+ severity: 'drift',
34
+ code: 'stale_interface_zone_labels',
35
+ subject: hostname,
36
+ message: `${hostname}: ${changes.length} stored interface zone label(s) predate the current zone model — ${named}`,
37
+ details:
38
+ 'Interface zones are decided once, at `machine add`, and never re-checked. When a zone is added or a subnet is declared for one that had none, every machine added earlier keeps the old label. Anything resolving a machine by zone then reports the interface as absent rather than as mislabelled.',
39
+ remediation: `celilo machine reclassify ${hostname} --apply`,
40
+ actionable: true,
41
+ };
42
+ });
43
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { auditRebootPending } from './reboot-pending';
3
+
4
+ const CLEAN = { pending: false, unmeasured: false, reasons: [] };
5
+ const NOW = 1_700_000_000_000;
6
+
7
+ describe('auditRebootPending', () => {
8
+ it('reports a pending reboot as drift, carrying the reason', () => {
9
+ const [finding] = auditRebootPending({
10
+ verdict: {
11
+ pending: true,
12
+ unmeasured: false,
13
+ reasons: ['the running kernel is 6.8.0-1063-raspi'],
14
+ },
15
+ marker: null,
16
+ now: () => NOW,
17
+ });
18
+
19
+ expect(finding.severity).toBe('drift');
20
+ expect(finding.code).toBe('reboot_pending');
21
+ expect(finding.message).toContain('6.8.0-1063-raspi');
22
+ expect(finding.remediation).toContain('celilo system reboot');
23
+ });
24
+
25
+ it('reports an unverified reboot as BLOCKED — a control plane may not have come back', () => {
26
+ const [finding] = auditRebootPending({
27
+ verdict: CLEAN,
28
+ marker: { bootIdBefore: 'a', requestedAt: NOW - 3 * 60 * 60 * 1000, reason: 'kernel' },
29
+ now: () => NOW,
30
+ });
31
+
32
+ expect(finding.severity).toBe('blocked');
33
+ expect(finding.code).toBe('reboot_unverified');
34
+ expect(finding.message).toContain('3h ago');
35
+ });
36
+
37
+ it('is silent on a host that is current and has no reboot outstanding', () => {
38
+ expect(auditRebootPending({ verdict: CLEAN, marker: null, now: () => NOW })).toEqual([]);
39
+ });
40
+
41
+ it('reports UNMEASURED rather than clean when the host could not be asked', () => {
42
+ const [finding] = auditRebootPending({
43
+ verdict: { pending: false, unmeasured: true, reasons: [] },
44
+ marker: null,
45
+ now: () => NOW,
46
+ });
47
+
48
+ expect(finding.severity).toBe('unmeasured');
49
+ expect(finding.actionable).toBe(false);
50
+ });
51
+
52
+ it('still reports the unverified reboot when the host itself is unmeasurable', () => {
53
+ // The two facts are independent: not being able to read the kernel does
54
+ // not make an outstanding reboot request go away.
55
+ const findings = auditRebootPending({
56
+ verdict: { pending: false, unmeasured: true, reasons: [] },
57
+ marker: { bootIdBefore: 'a', requestedAt: NOW, reason: '' },
58
+ now: () => NOW,
59
+ });
60
+
61
+ expect(findings.map((f) => f.code)).toEqual(['reboot_unverified', 'reboot_pending_unmeasured']);
62
+ });
63
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Pending reboot on the management server (celilo#1378).
3
+ *
4
+ * `apt-upgrade` printed needrestart's pending-kernel block and that was the
5
+ * only place the condition was ever visible — so it was visible exactly when
6
+ * an operator happened to run an upgrade, and invisible the rest of the time.
7
+ * celilo-mgr sat on a kernel it was not running for days that way.
8
+ *
9
+ * Two findings, and they are different facts:
10
+ *
11
+ * reboot_pending — the host needs a reboot and has not had one.
12
+ * reboot_unverified — celilo ASKED for a reboot and nothing has confirmed
13
+ * the host came back. That is the more serious of the
14
+ * two: it is the shape of a control plane that went
15
+ * down and did not return, or returned without its
16
+ * dispatcher.
17
+ */
18
+
19
+ import type { RebootMarker, RebootPendingVerdict } from '../system-reboot';
20
+ import type { DriftFinding } from './types';
21
+
22
+ export interface RebootPendingAuditDeps {
23
+ verdict: RebootPendingVerdict;
24
+ /** A reboot celilo requested that `verifyReturn` has not yet cleared. */
25
+ marker: RebootMarker | null;
26
+ /** Defaults to `Date.now()`. */
27
+ now?: () => number;
28
+ }
29
+
30
+ const HOUR = 60 * 60 * 1000;
31
+
32
+ export function auditRebootPending(deps: RebootPendingAuditDeps): DriftFinding[] {
33
+ const findings: DriftFinding[] = [];
34
+ const now = (deps.now ?? Date.now)();
35
+
36
+ if (deps.marker !== null) {
37
+ const ageHours = Math.round((now - deps.marker.requestedAt) / HOUR);
38
+ findings.push({
39
+ category: 'reboot_pending',
40
+ severity: 'blocked',
41
+ code: 'reboot_unverified',
42
+ subject: 'system',
43
+ message: `celilo requested a reboot ${ageHours}h ago and nothing has confirmed the host came back.`,
44
+ details:
45
+ 'The process that asks for a reboot goes down with the host, so the return is verified by a second call. An uncleared marker means that call never succeeded — either the host did not reboot, or it came back without its dispatcher.',
46
+ remediation:
47
+ 'Run `celilo system reboot --verify`. It passes only once the boot id has changed and the dispatcher and CLI answer, and it names which of those is missing.',
48
+ actionable: true,
49
+ });
50
+ }
51
+
52
+ if (deps.verdict.unmeasured) {
53
+ findings.push({
54
+ category: 'reboot_pending',
55
+ severity: 'unmeasured',
56
+ code: 'reboot_pending_unmeasured',
57
+ subject: 'system',
58
+ message:
59
+ 'Neither the reboot-required marker nor a kernel pair could be read, so nothing was compared.',
60
+ details:
61
+ 'An absent /var/run/reboot-required is not evidence of a current kernel — a host without that path simply never writes one.',
62
+ remediation:
63
+ 'Run `celilo system audit` on the management server itself (Debian/Ubuntu), where /var/run/reboot-required and `uname -r` are readable.',
64
+ actionable: false,
65
+ });
66
+ return findings;
67
+ }
68
+
69
+ if (deps.verdict.pending) {
70
+ findings.push({
71
+ category: 'reboot_pending',
72
+ severity: 'drift',
73
+ code: 'reboot_pending',
74
+ subject: 'system',
75
+ message: `The management server needs a reboot: ${deps.verdict.reasons.join('; ')}.`,
76
+ details:
77
+ 'A kernel or service the host has installed is not the one it is running. Until it reboots, an upgrade that has been applied on disk has not taken effect.',
78
+ remediation:
79
+ 'Reboot through celilo: `celilo system reboot --allow-reboot`. It refuses while a module operation is in flight, and `--verify` confirms the host came back.',
80
+ actionable: true,
81
+ });
82
+ }
83
+
84
+ return findings;
85
+ }
@@ -38,7 +38,9 @@ import { auditInterfaceClassification } from './interface-classification';
38
38
  import { auditModuleIntegrity } from './module-integrity';
39
39
  import { auditModuleVersions } from './module-versions';
40
40
  import { auditPublicDns } from './public-dns';
41
+ import { auditRebootPending } from './reboot-pending';
41
42
  import { auditSchema } from './schema';
43
+ import { auditServerBunPin } from './server-bun-pin';
42
44
  import { auditTerraformPlan } from './terraform-plan';
43
45
  import { auditTrustedSources } from './trusted-sources';
44
46
  import type { DriftCategory, DriftFinding, DriftSeverity } from './types';
@@ -68,6 +70,25 @@ const GATE: Record<DriftCategory, GateEntry> = {
68
70
  because:
69
71
  'compares capability versions claimed in installed module manifests against the framework registry and deployed providers, all read from celilo own DB and local manifests — the subject is celilo itself and is always reachable',
70
72
  },
73
+ reboot_pending: {
74
+ probe: () =>
75
+ auditRebootPending({
76
+ verdict: { pending: false, unmeasured: true, reasons: [] },
77
+ marker: null,
78
+ }),
79
+ because:
80
+ 'probed: a host with no /var/run/reboot-required and no readable kernel pair was not measured, and an absent marker is not a current kernel',
81
+ },
82
+ server_bun_pin: {
83
+ probe: () =>
84
+ auditServerBunPin({
85
+ pinnedVersion: null,
86
+ runningVersion: '1.4.2',
87
+ pinPath: '/etc/celilo/mise.pin.toml',
88
+ }),
89
+ because:
90
+ 'probed: the deb pin file can be absent (a non-deb install), and then nothing was compared',
91
+ },
71
92
  browser_pin: {
72
93
  because:
73
94
  'compares a bundled client playwright revision against the locally provisioned browser binary, both on this machine — always reachable',