@celilo/cli 4.0.0 → 5.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 (119) hide show
  1. package/AGENTS.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0034_private_routes.sql +41 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +5 -4
  6. package/src/api/sessions.test.ts +1 -1
  7. package/src/capabilities/registration.test.ts +11 -11
  8. package/src/capabilities/secrets.test.ts +18 -18
  9. package/src/capabilities/validation.test.ts +24 -24
  10. package/src/cli/cli.test.ts +3 -3
  11. package/src/cli/command-tree-parser.test.ts +18 -0
  12. package/src/cli/command-tree-parser.ts +12 -0
  13. package/src/cli/commands/api.ts +67 -14
  14. package/src/cli/commands/events.ts +5 -2
  15. package/src/cli/commands/machine-reclassify.ts +79 -0
  16. package/src/cli/commands/module-changeset.test.ts +1 -1
  17. package/src/cli/commands/module-generate.test.ts +2 -2
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -2
  19. package/src/cli/commands/module-update.test.ts +14 -14
  20. package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
  21. package/src/cli/commands/module-upgrade.test.ts +3 -3
  22. package/src/cli/commands/module-upgrade.ts +12 -0
  23. package/src/cli/commands/module-version.test.ts +1 -1
  24. package/src/cli/commands/system-audit.ts +22 -0
  25. package/src/cli/commands/system-reboot.ts +126 -0
  26. package/src/cli/commands/system-update.ts +5 -0
  27. package/src/cli/completion.ts +3 -2
  28. package/src/cli/index.ts +41 -2
  29. package/src/cli/restore-migration-failure.test.ts +1 -1
  30. package/src/cli/stdout-pipe-flush.test.ts +35 -0
  31. package/src/cli/tui/audit-state.ts +4 -0
  32. package/src/db/private-routes.test.ts +159 -0
  33. package/src/db/schema.ts +49 -0
  34. package/src/hooks/broker.ts +31 -0
  35. package/src/hooks/capability-loader.ts +185 -12
  36. package/src/hooks/executor.test.ts +13 -13
  37. package/src/hooks/executor.ts +20 -2
  38. package/src/hooks/hook-state-dir.test.ts +2 -2
  39. package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
  40. package/src/hooks/test-fixtures/store-backed.ts +5 -3
  41. package/src/hooks/timeout-names-provider.test.ts +87 -0
  42. package/src/manifest/contracts/index.ts +28 -7
  43. package/src/manifest/contracts/v2.ts +32 -0
  44. package/src/manifest/icon-schema.test.ts +1 -1
  45. package/src/manifest/schema.ts +13 -2
  46. package/src/manifest/template-validator.test.ts +1 -1
  47. package/src/manifest/validate-privileged.test.ts +1 -1
  48. package/src/manifest/validate.test.ts +68 -29
  49. package/src/manifest/validate.ts +14 -1
  50. package/src/module/import.test.ts +7 -7
  51. package/src/module/packaging/build.test.ts +1 -1
  52. package/src/packaging/nfpm-depends.test.ts +42 -0
  53. package/src/policy/capability-shape-baseline.ts +7 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +5 -5
  55. package/src/policy/module-business-baseline.ts +33 -4
  56. package/src/services/api-access.test.ts +44 -0
  57. package/src/services/aspect-approvals.test.ts +2 -2
  58. package/src/services/aspect-reconcile.test.ts +2 -2
  59. package/src/services/aspect-runner.test.ts +4 -4
  60. package/src/services/audit/backups.test.ts +1 -1
  61. package/src/services/audit/capability-abi.test.ts +1 -1
  62. package/src/services/audit/index.test.ts +7 -0
  63. package/src/services/audit/index.ts +15 -0
  64. package/src/services/audit/machine-interface-zones.test.ts +30 -0
  65. package/src/services/audit/machine-interface-zones.ts +43 -0
  66. package/src/services/audit/module-configs.test.ts +1 -1
  67. package/src/services/audit/reboot-pending.test.ts +63 -0
  68. package/src/services/audit/reboot-pending.ts +85 -0
  69. package/src/services/audit/recurrence-gate.test.ts +21 -0
  70. package/src/services/audit/server-bun-pin.test.ts +44 -0
  71. package/src/services/audit/server-bun-pin.ts +95 -0
  72. package/src/services/audit/types.ts +2 -0
  73. package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
  74. package/src/services/bus-interview-park.test.ts +1 -1
  75. package/src/services/bus-secret-flow.test.ts +1 -1
  76. package/src/services/capability-compat.test.ts +22 -0
  77. package/src/services/capability-compat.ts +15 -2
  78. package/src/services/capability-table-rows.test.ts +20 -2
  79. package/src/services/celilo-events.test.ts +119 -6
  80. package/src/services/celilo-events.ts +156 -16
  81. package/src/services/celilo-mgmt-hooks.test.ts +6 -3
  82. package/src/services/cross-module-read.test.ts +2 -2
  83. package/src/services/deploy-preflight.ts +12 -3
  84. package/src/services/deploy-validation.test.ts +86 -7
  85. package/src/services/deploy-validation.ts +19 -0
  86. package/src/services/dns-provider-backfill.test.ts +2 -2
  87. package/src/services/dns-registrations.test.ts +1 -1
  88. package/src/services/fleet-checks.test.ts +2 -2
  89. package/src/services/fleet-checks.ts +40 -10
  90. package/src/services/health-runner.test.ts +1 -1
  91. package/src/services/infrastructure-variable-resolver.test.ts +11 -11
  92. package/src/services/instance-ops.test.ts +2 -2
  93. package/src/services/machine-interface-zones.test.ts +73 -0
  94. package/src/services/machine-interface-zones.ts +101 -0
  95. package/src/services/module-deploy.dns-repoint.test.ts +1 -1
  96. package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
  97. package/src/services/module-deploy.ts +31 -10
  98. package/src/services/module-pause-quiescence.test.ts +1 -1
  99. package/src/services/module-pause.test.ts +1 -1
  100. package/src/services/module-subscriptions.test.ts +2 -2
  101. package/src/services/module-types-generator.test.ts +1 -1
  102. package/src/services/module-validator/contract-version.test.ts +3 -3
  103. package/src/services/network-ensure.test.ts +1 -1
  104. package/src/services/provider-arrival.test.ts +62 -46
  105. package/src/services/provider-arrival.ts +41 -10
  106. package/src/services/proxmox-reconcile.test.ts +2 -2
  107. package/src/services/remove-guard.test.ts +1 -1
  108. package/src/services/system-reboot.test.ts +231 -0
  109. package/src/services/system-reboot.ts +357 -0
  110. package/src/services/update/dep-graph.test.ts +1 -1
  111. package/src/services/update/orchestrator.test.ts +165 -1
  112. package/src/services/update/orchestrator.ts +43 -12
  113. package/src/services/update/types.ts +10 -1
  114. package/src/templates/generator.test.ts +15 -4
  115. package/src/templates/generator.ts +12 -4
  116. package/src/variables/capability-self-ref.test.ts +2 -2
  117. package/src/variables/computed/computed-integration.test.ts +1 -1
  118. package/src/variables/context.ts +14 -1
  119. package/src/variables/declarative-derivation.test.ts +19 -19
@@ -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
+ });
@@ -28,7 +28,7 @@ function insertModule(id: string, version: string) {
28
28
  id,
29
29
  name: id,
30
30
  version,
31
- manifestData: { id, name: id, version, celilo_contract: '1.0' },
31
+ manifestData: { id, name: id, version, celilo_contract: '2.0' },
32
32
  sourcePath: `/tmp/${id}`,
33
33
  })
34
34
  .run();
@@ -160,7 +160,7 @@ describe('aspect-approvals', () => {
160
160
  id: 'knot-unbound-internal-v2',
161
161
  name: 'knot',
162
162
  version: '2.0.0',
163
- manifestData: { id: 'knot', name: 'knot', version: '2.0.0', celilo_contract: '1.0' },
163
+ manifestData: { id: 'knot', name: 'knot', version: '2.0.0', celilo_contract: '2.0' },
164
164
  sourcePath: '/tmp/knot',
165
165
  })
166
166
  .run();
@@ -62,7 +62,7 @@ function seedProvider(opts: {
62
62
  id: opts.moduleId,
63
63
  name: opts.moduleId,
64
64
  version: '1.0.0',
65
- celilo_contract: '1.0',
65
+ celilo_contract: '2.0',
66
66
  base_module_aspect: aspect,
67
67
  },
68
68
  sourcePath: `/tmp/${opts.moduleId}`,
@@ -108,7 +108,7 @@ function seedTargetSystem(opts: {
108
108
  id: opts.moduleId,
109
109
  name: opts.moduleId,
110
110
  version: '1.0.0',
111
- celilo_contract: '1.0',
111
+ celilo_contract: '2.0',
112
112
  },
113
113
  sourcePath: `/tmp/${opts.moduleId}`,
114
114
  })
@@ -85,7 +85,7 @@ function seedLxc(opts: {
85
85
  id: opts.moduleId,
86
86
  name: opts.moduleId,
87
87
  version: '1.0.0',
88
- celilo_contract: '1.0',
88
+ celilo_contract: '2.0',
89
89
  },
90
90
  sourcePath: `/tmp/${opts.moduleId}`,
91
91
  })
@@ -320,7 +320,7 @@ describe('aspect-runner', () => {
320
320
  id: 'knot-unbound-internal',
321
321
  name: 'knot-unbound-internal',
322
322
  version: '1.0.0',
323
- celilo_contract: '1.0',
323
+ celilo_contract: '2.0',
324
324
  },
325
325
  sourcePath: '/tmp/knot',
326
326
  })
@@ -386,7 +386,7 @@ describe('aspect-runner', () => {
386
386
 
387
387
  describe('maybeRunAspectForTrigger', () => {
388
388
  const manifestWithAspect: ModuleManifest = {
389
- celilo_contract: '1.0',
389
+ celilo_contract: '2.0',
390
390
  id: 'knot-unbound-internal',
391
391
  name: 'Knot Unbound Internal',
392
392
  version: '1.0.0',
@@ -413,7 +413,7 @@ describe('aspect-runner', () => {
413
413
  id: opts.id,
414
414
  name: opts.id,
415
415
  version: opts.version,
416
- celilo_contract: '1.0',
416
+ celilo_contract: '2.0',
417
417
  },
418
418
  sourcePath: `/tmp/${opts.id}`,
419
419
  })
@@ -20,7 +20,7 @@ function makeModule(
20
20
  id,
21
21
  name: id,
22
22
  version: '1.0.0',
23
- celilo_contract: '1.0',
23
+ celilo_contract: '2.0',
24
24
  hooks: opts.hasBackupHook ? { on_backup: { script: './backup.ts' } } : {},
25
25
  backup: opts.schedule ? { schedule: opts.schedule } : undefined,
26
26
  } as unknown as ModuleManifest;
@@ -14,7 +14,7 @@ function makeModule(
14
14
  id,
15
15
  name: id,
16
16
  version: '1.0.0',
17
- celilo_contract: '1.0',
17
+ celilo_contract: '2.0',
18
18
  provides: opts.provides
19
19
  ? {
20
20
  capabilities: opts.provides.map((p) => ({ ...p, data: {}, functions: [] })),
@@ -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
+ }
@@ -21,7 +21,7 @@ function makeModule(
21
21
  id,
22
22
  name: id,
23
23
  version: '1.0.0',
24
- celilo_contract: '1.0',
24
+ celilo_contract: '2.0',
25
25
  variables: { owns: variables, imports: [] },
26
26
  } as unknown as ModuleManifest;
27
27
  // Default state INSTALLED so existing tests continue to expect
@@ -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',
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { auditServerBunPin, readPinnedBunVersion } from './server-bun-pin';
6
+
7
+ const PIN = '/etc/celilo/mise.pin.toml';
8
+
9
+ describe('server bun pin audit', () => {
10
+ test('silent when the running bun is the pinned bun', () => {
11
+ expect(
12
+ auditServerBunPin({ pinnedVersion: '1.4.2', runningVersion: '1.4.2', pinPath: PIN }),
13
+ ).toEqual([]);
14
+ });
15
+
16
+ test('reports drift naming both versions — the celilo#1379 shape', () => {
17
+ const [finding] = auditServerBunPin({
18
+ pinnedVersion: '1.4.2',
19
+ runningVersion: '1.3.3',
20
+ pinPath: PIN,
21
+ });
22
+ expect(finding?.code).toBe('server_bun_pin_drift');
23
+ expect(finding?.severity).toBe('drift');
24
+ expect(finding?.message).toContain('1.3.3');
25
+ expect(finding?.message).toContain('1.4.2');
26
+ });
27
+
28
+ test('an unreadable pin is unmeasured, never green', () => {
29
+ const [finding] = auditServerBunPin({
30
+ pinnedVersion: null,
31
+ runningVersion: '1.4.2',
32
+ pinPath: PIN,
33
+ });
34
+ expect(finding?.severity).toBe('unmeasured');
35
+ });
36
+
37
+ test('reads the bun line out of the shipped pin file', () => {
38
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-pin-'));
39
+ const path = join(dir, 'mise.pin.toml');
40
+ writeFileSync(path, '# comment\n[tools]\nbun = "1.4.2"\nterraform = "1.10.0"\n');
41
+ expect(readPinnedBunVersion(path)).toBe('1.4.2');
42
+ expect(readPinnedBunVersion(join(dir, 'absent.toml'))).toBeNull();
43
+ });
44
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Server bun-pin drift (celilo#1379).
3
+ *
4
+ * The deb pins the bun celilo was built and tested against, and the CLI runs
5
+ * on whatever bun mise resolves at /var/celilo. Those can disagree: for three
6
+ * months celilo-mgr ran on bun 1.3.3 while the shipped pin said 1.4.2,
7
+ * because the postinst seeded the pin file once and an upgrade never
8
+ * rewrote it. 1.3.3's `Bun.YAML.parse` reads a bare `on:` key as `true`, so
9
+ * production was running a parser no gate had ever exercised.
10
+ *
11
+ * The postinst now rewrites the pin on every configure, which closes the way
12
+ * that drift arose. This check is what makes the drift VISIBLE if it arises
13
+ * some other way — an operator's `mise use`, a hand-edited config, a
14
+ * dispatcher still holding an old runtime.
15
+ *
16
+ * It compares the pin the deb shipped against the bun THIS process is
17
+ * running on. Not `mise which bun`, which answers what mise would resolve
18
+ * now rather than what is actually executing (measured 2026-09-10: mise
19
+ * named 1.4.2 and ran 1.3.3 on the same box).
20
+ */
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import type { DriftFinding } from './types';
24
+
25
+ export interface ServerBunPinAuditDeps {
26
+ /** The bun version the installed deb pins, or `null` when unreadable. */
27
+ pinnedVersion: string | null;
28
+ /** The bun this process is running on, or `null` when not under bun. */
29
+ runningVersion: string | null;
30
+ /** Where `pinnedVersion` was read from — named in the finding. */
31
+ pinPath: string;
32
+ }
33
+
34
+ export function auditServerBunPin(deps: ServerBunPinAuditDeps): DriftFinding[] {
35
+ const { pinnedVersion, runningVersion, pinPath } = deps;
36
+
37
+ if (pinnedVersion === null || runningVersion === null) {
38
+ return [
39
+ {
40
+ category: 'server_bun_pin',
41
+ severity: 'unmeasured',
42
+ code: 'server_bun_pin_unmeasured',
43
+ subject: 'system',
44
+ message:
45
+ pinnedVersion === null
46
+ ? `No bun pin could be read from ${pinPath}, so the running bun was compared against nothing.`
47
+ : 'This process is not running under bun, so the running bun version is unknown.',
48
+ remediation:
49
+ pinnedVersion === null
50
+ ? `Confirm the celilo deb is installed and ${pinPath} exists (\`sudo apt install --reinstall celilo\`).`
51
+ : 'Run `celilo system audit` from the deb-installed celilo (/usr/local/bin/celilo).',
52
+ actionable: false,
53
+ },
54
+ ];
55
+ }
56
+
57
+ if (pinnedVersion === runningVersion) return [];
58
+
59
+ return [
60
+ {
61
+ category: 'server_bun_pin',
62
+ severity: 'drift',
63
+ code: 'server_bun_pin_drift',
64
+ subject: 'system',
65
+ message: `celilo is running on bun ${runningVersion}, but the installed deb pins bun ${pinnedVersion} (${pinPath}).`,
66
+ details:
67
+ 'celilo is tested against the pinned bun only. A different runtime is not a version-number mismatch — bun 1.3.3 and 1.4.2 parse the same YAML differently, which is how a deploy shipped on an untested parser (celilo#1379).',
68
+ remediation:
69
+ 'Reconcile the runtime: `sudo apt install --reinstall celilo` rewrites /var/celilo/mise.local.toml from the deb pin, then `celilo events restart-daemon` puts the dispatcher on it.',
70
+ actionable: false,
71
+ },
72
+ ];
73
+ }
74
+
75
+ /** The pin the deb ships. Read at audit time — never a copy in celilo's code. */
76
+ export const DEB_PIN_PATH = '/etc/celilo/mise.pin.toml';
77
+
78
+ export function readPinnedBunVersion(path: string = DEB_PIN_PATH): string | null {
79
+ try {
80
+ // One `bun = "x.y.z"` line under [tools]. A TOML parser for one key would
81
+ // be a dependency to read a file this file's own format gate keeps simple.
82
+ const match = readFileSync(path, 'utf-8').match(/^\s*bun\s*=\s*"([^"]+)"/m);
83
+ return match?.[1] ?? null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function collectServerBunPinDeps(pinPath: string = DEB_PIN_PATH): ServerBunPinAuditDeps {
90
+ return {
91
+ pinnedVersion: readPinnedBunVersion(pinPath),
92
+ runningVersion: process.versions.bun ?? null,
93
+ pinPath,
94
+ };
95
+ }
@@ -27,6 +27,8 @@ export type DriftCategory =
27
27
  | 'schema'
28
28
  | 'capability_abi'
29
29
  | 'browser_pin'
30
+ | 'server_bun_pin'
31
+ | 'reboot_pending'
30
32
  | 'terraform_plan'
31
33
  | 'module_versions'
32
34
  | 'module_configs'