@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
@@ -100,12 +100,16 @@ export class CommandTreeParser {
100
100
 
101
101
  // Track whether we're in a Commands/Subcommands section
102
102
  let inCommandSection = false;
103
+ // Column where the last parsed entry's description starts; anything indented
104
+ // that far is wrapped prose, not a subcommand.
105
+ let descriptionColumn: number | null = null;
103
106
 
104
107
  for (const line of lines) {
105
108
  // Check if we're entering a Commands section
106
109
  // Match " Commands:" or "│ Commands:"
107
110
  if (/^[│\s]*(Commands|Subcommands):\s*$/i.test(line)) {
108
111
  inCommandSection = true;
112
+ descriptionColumn = null;
109
113
  continue;
110
114
  }
111
115
 
@@ -134,8 +138,16 @@ export class CommandTreeParser {
134
138
  // " command-name Description"
135
139
  const match = line.match(/^[│\s]*([a-z][a-z0-9-]*)(?:\s{2,}|\s+[<a-z])/);
136
140
 
141
+ // A wrapped description's continuation lines are indented to the previous
142
+ // entry's description column and match the pattern above (celilo#1409).
143
+ const indent = line.search(/[^│\s]/);
144
+ if (descriptionColumn !== null && indent >= descriptionColumn) {
145
+ continue;
146
+ }
147
+
137
148
  if (match) {
138
149
  const cmdName = match[1];
150
+ descriptionColumn = match[0].length;
139
151
 
140
152
  // Skip examples (lines starting with "celilo")
141
153
  if (cmdName === 'celilo') {
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
9
9
  import { homedir } from 'node:os';
10
10
  import { dirname, join } from 'node:path';
11
11
  import {
12
+ getPrincipalByName,
12
13
  grantPrincipal,
13
14
  listPrincipals,
14
15
  renderAuthorizedKeys,
@@ -22,7 +23,25 @@ function errMsg(error: unknown): string {
22
23
  return error instanceof Error ? error.message : String(error);
23
24
  }
24
25
 
25
- /** `celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>` */
26
+ function grantList(value: unknown): string[] {
27
+ return typeof value === 'string'
28
+ ? value
29
+ .split(',')
30
+ .map((g) => g.trim())
31
+ .filter(Boolean)
32
+ : [];
33
+ }
34
+
35
+ /**
36
+ * `celilo api grant <principal> [--key <pubkey|path>] --can|--add|--remove <grant[,...]>`
37
+ *
38
+ * `--can` REPLACES the grant set; `--add`/`--remove` amend it. Replace-only was
39
+ * the whole surface, so adding one permission meant re-supplying the full list
40
+ * plus the key, and a typo in either silently narrowed the principal or changed
41
+ * its key (celilo#1404). So: `--key` is required only when CREATING a principal,
42
+ * and a `--can` that drops grants an existing principal holds is refused unless
43
+ * `--force` says the narrowing is intended.
44
+ */
26
45
  export async function handleApiGrant(
27
46
  args: string[],
28
47
  flags: Record<string, boolean | string> = {},
@@ -32,37 +51,71 @@ export async function handleApiGrant(
32
51
  if (!name) {
33
52
  return {
34
53
  success: false,
35
- error: 'Usage: celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
54
+ error:
55
+ 'Usage: celilo api grant <principal> [--key <pubkey|path>] --can|--add|--remove <grant[,grant...]>',
36
56
  };
37
57
  }
38
58
 
39
- const keyArg = typeof flags.key === 'string' ? flags.key : '';
40
- if (!keyArg) {
59
+ const replace = grantList(flags.can);
60
+ const added = grantList(flags.add);
61
+ const removed = grantList(flags.remove);
62
+ if (replace.length > 0 && (added.length > 0 || removed.length > 0)) {
41
63
  return {
42
64
  success: false,
43
- error: '--key <public-key-or-path> is required (e.g. ~/.ssh/id_ed25519.pub)',
65
+ error: '--can replaces the grant set; use it OR --add/--remove, not both.',
66
+ };
67
+ }
68
+ if (replace.length === 0 && added.length === 0 && removed.length === 0) {
69
+ return {
70
+ success: false,
71
+ error:
72
+ 'One of --can <grants> (replace), --add <grants>, or --remove <grants> is required (e.g. --add module:journal).',
44
73
  };
45
74
  }
46
75
 
47
- const canArg = typeof flags.can === 'string' ? flags.can : '';
48
- if (!canArg) {
76
+ const existing = await getPrincipalByName(name);
77
+ if (!existing && (added.length > 0 || removed.length > 0)) {
49
78
  return {
50
79
  success: false,
51
- error: '--can <grant[,grant...]> is required (e.g. --can module:deploy,service:*)',
80
+ error: `No API principal named "${name}" — --add/--remove amend an existing one. Create it with --key and --can.`,
52
81
  };
53
82
  }
54
83
 
55
- const publicKey = existsSync(keyArg) ? readFileSync(keyArg, 'utf8').trim() : keyArg.trim();
56
- const grants = canArg
57
- .split(',')
58
- .map((g) => g.trim())
59
- .filter(Boolean);
84
+ const keyArg = typeof flags.key === 'string' ? flags.key : '';
85
+ if (!keyArg && !existing) {
86
+ return {
87
+ success: false,
88
+ error: '--key <public-key-or-path> is required (e.g. ~/.ssh/id_ed25519.pub)',
89
+ };
90
+ }
91
+ // Keep the enrolled key when none is supplied, so amending grants can never
92
+ // rotate the key by omission.
93
+ const publicKey = keyArg
94
+ ? existsSync(keyArg)
95
+ ? readFileSync(keyArg, 'utf8').trim()
96
+ : keyArg.trim()
97
+ : (existing?.publicKey ?? '');
98
+
99
+ const current = existing?.grants ?? [];
100
+ let grants: string[];
101
+ if (replace.length > 0) {
102
+ const dropped = current.filter((g) => !replace.includes(g));
103
+ if (dropped.length > 0 && flags.force !== true) {
104
+ return {
105
+ success: false,
106
+ error: `Refusing to narrow "${name}": --can would drop ${dropped.join(', ')}. Use --add/--remove to amend, or --force to replace anyway.`,
107
+ };
108
+ }
109
+ grants = replace;
110
+ } else {
111
+ grants = [...new Set([...current, ...added])].filter((g) => !removed.includes(g));
112
+ }
60
113
 
61
114
  const { principal, created } = await grantPrincipal({ name, publicKey, grants });
62
115
 
63
116
  return {
64
117
  success: true,
65
- message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ')}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
118
+ message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ') || '(none)'}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
66
119
  };
67
120
  } catch (error) {
68
121
  return { success: false, error: `Failed to grant API access: ${errMsg(error)}` };
@@ -6,7 +6,7 @@
6
6
  * second tool.
7
7
  *
8
8
  * Subcommands:
9
- * status bus.health() as JSON
9
+ * status bus.healthConfirmed() as JSON
10
10
  * tail recent events
11
11
  * list-subscribers persistent subscribers
12
12
  * list-pending pending deliveries (subscriber fan-out — NOT questions)
@@ -80,7 +80,10 @@ function jsonResult(data: unknown): CommandResult {
80
80
  export async function handleEventsStatus(): Promise<CommandResult> {
81
81
  const bus = openCliBus();
82
82
  try {
83
- return jsonResult(bus.health());
83
+ // Confirmed: the ansible task and the fleet check both read this verdict and
84
+ // act on it, so it must not say `wedged` about a dispatcher the reconcile path
85
+ // calls `lagging` (celilo#1399). One threshold, one answer.
86
+ return jsonResult(await bus.healthConfirmed());
84
87
  } finally {
85
88
  bus.close();
86
89
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Machine Reclassify Command
3
+ *
4
+ * Re-runs zone detection over a machine's STORED interface labels and reports
5
+ * old → new. Dry-run by default: an operator sees every relabel before any of
6
+ * them is written. `--apply` writes.
7
+ *
8
+ * See services/machine-interface-zones.ts for why stale labels happen.
9
+ */
10
+
11
+ import { eq } from 'drizzle-orm';
12
+ import { getDb } from '../../db/client';
13
+ import { machines } from '../../db/schema';
14
+ import { applyReclassification, diffInterfaceZones } from '../../services/machine-interface-zones';
15
+ import { getMachineByHostname, getMachineByIp } from '../../services/machine-pool';
16
+ import { detectZoneFromIp } from '../../services/zone-detector';
17
+ import { celiloIntro, celiloOutro } from '../prompts';
18
+ import type { CommandResult } from '../types';
19
+
20
+ const USAGE =
21
+ 'Usage:\n celilo machine reclassify <hostname|ip> # show what would change\n celilo machine reclassify <hostname|ip> --apply # write the new labels';
22
+
23
+ export async function handleMachineReclassify(
24
+ args: string[],
25
+ flags: Record<string, boolean | string> = {},
26
+ ): Promise<CommandResult> {
27
+ try {
28
+ celiloIntro('Re-classify Machine Interfaces');
29
+
30
+ const identifier = args[0];
31
+ if (!identifier) {
32
+ return { success: false, error: `Machine hostname or IP is required\n\n${USAGE}` };
33
+ }
34
+
35
+ const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(identifier);
36
+ const machine = isIp
37
+ ? await getMachineByIp(identifier)
38
+ : await getMachineByHostname(identifier);
39
+
40
+ if (!machine) {
41
+ return { success: false, error: `Machine not found: ${identifier}` };
42
+ }
43
+
44
+ const interfaces = machine.interfaces ?? [];
45
+ const changes = await diffInterfaceZones(interfaces, detectZoneFromIp);
46
+
47
+ if (changes.length === 0) {
48
+ celiloOutro(
49
+ `${machine.hostname}: all ${interfaces.length} interface zone label(s) already match the current zone model`,
50
+ );
51
+ return { success: true, message: `${machine.hostname}: no re-classification needed` };
52
+ }
53
+
54
+ const lines = changes.map((c) => ` ${c.name} (${c.ipAddress}): ${c.from} → ${c.to}`);
55
+ const summary = `${machine.hostname}: ${changes.length} interface(s) would be re-classified\n${lines.join('\n')}`;
56
+
57
+ if (!flags.apply) {
58
+ celiloOutro(`${summary}\n\nRe-run with --apply to write these labels.`);
59
+ return { success: true, message: summary };
60
+ }
61
+
62
+ getDb()
63
+ .update(machines)
64
+ .set({ interfaces: applyReclassification(interfaces, changes) })
65
+ .where(eq(machines.id, machine.id))
66
+ .run();
67
+
68
+ celiloOutro(`${summary}\n\nWritten.`);
69
+ return {
70
+ success: true,
71
+ message: `${machine.hostname}: re-classified ${changes.length} interface(s)`,
72
+ };
73
+ } catch (error) {
74
+ return {
75
+ success: false,
76
+ error: `Failed to re-classify machine interfaces: ${error instanceof Error ? error.message : String(error)}`,
77
+ };
78
+ }
79
+ }
@@ -10,7 +10,7 @@ beforeEach(async () => {
10
10
  dir = await mkdtemp(join(tmpdir(), 'celilo-cs-'));
11
11
  await writeFile(
12
12
  join(dir, 'manifest.yml'),
13
- 'celilo_contract: "1.0"\nid: lunacycle\nversion: 1.0.0\n',
13
+ 'celilo_contract: "2.0"\nid: lunacycle\nversion: 1.0.0\n',
14
14
  );
15
15
  });
16
16
  afterEach(async () => {
@@ -9,7 +9,7 @@ const DECLARED = join('ansible', 'roles', 'fixture', 'files', 'binary');
9
9
 
10
10
  function manifestWithArtifacts(artifacts: string[]): ModuleManifest {
11
11
  return {
12
- celilo_contract: '1.0',
12
+ celilo_contract: '2.0',
13
13
  id: 'fixture',
14
14
  name: 'Fixture',
15
15
  version: '1.0.0',
@@ -42,7 +42,7 @@ describe('missingBuildOutputs', () => {
42
42
  test('reports nothing for a module with no build block', () => {
43
43
  expect(
44
44
  missingBuildOutputs(payload, {
45
- celilo_contract: '1.0',
45
+ celilo_contract: '2.0',
46
46
  id: 'fixture',
47
47
  name: 'Fixture',
48
48
  version: '1.0.0',
@@ -32,7 +32,7 @@ function insertAspectModule(id: string, version: string): void {
32
32
  id,
33
33
  name: id,
34
34
  version,
35
- celilo_contract: '1.0',
35
+ celilo_contract: '2.0',
36
36
  base_module_aspect: aspect,
37
37
  },
38
38
  sourcePath: `/tmp/${id}`,
@@ -102,7 +102,7 @@ describe('handleAspectApprovalAfterImport (non-interactive)', () => {
102
102
  id: 'caddy',
103
103
  name: 'caddy',
104
104
  version: '1.0.0',
105
- manifestData: { id: 'caddy', name: 'caddy', version: '1.0.0', celilo_contract: '1.0' },
105
+ manifestData: { id: 'caddy', name: 'caddy', version: '1.0.0', celilo_contract: '2.0' },
106
106
  sourcePath: '/tmp/caddy',
107
107
  })
108
108
  .run();
@@ -95,7 +95,7 @@ describe('updateOne — displayVersion and quiet', () => {
95
95
  mkdirSync(srcDir, { recursive: true });
96
96
  writeFileSync(
97
97
  join(srcDir, 'manifest.yml'),
98
- `celilo_contract: "1.0"
98
+ `celilo_contract: "2.0"
99
99
  id: testmod
100
100
  name: Test Module
101
101
  version: 1.0.0
@@ -117,7 +117,7 @@ description: fixture
117
117
  version: '1.0.0+5',
118
118
  updatedAt: new Date('2026-04-25T16:26:31Z'),
119
119
  manifestData: {
120
- celilo_contract: '1.0',
120
+ celilo_contract: '2.0',
121
121
  id: 'testmod',
122
122
  name: 'Test Module',
123
123
  version: '1.0.0',
@@ -195,7 +195,7 @@ description: fixture
195
195
  // row's manifestData should reflect the removal.
196
196
  writeFileSync(
197
197
  join(srcDir, 'manifest.yml'),
198
- `celilo_contract: "1.0"
198
+ `celilo_contract: "2.0"
199
199
  id: testmod
200
200
  name: Test Module Renamed
201
201
  version: 2.0.0
@@ -210,7 +210,7 @@ variables:
210
210
  db.update(modules)
211
211
  .set({
212
212
  manifestData: {
213
- celilo_contract: '1.0',
213
+ celilo_contract: '2.0',
214
214
  id: 'testmod',
215
215
  name: 'Test Module',
216
216
  version: '1.0.0',
@@ -249,7 +249,7 @@ variables:
249
249
  try {
250
250
  writeFileSync(
251
251
  join(srcDir, 'manifest.yml'),
252
- `celilo_contract: "1.0"
252
+ `celilo_contract: "2.0"
253
253
  id: testmod
254
254
  name: Test Module
255
255
  version: 1.1.0
@@ -302,7 +302,7 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
302
302
  sourcePath: join(tempDir, 'installed'),
303
303
  version: '1.0.2+9',
304
304
  manifestData: {
305
- celilo_contract: '1.0',
305
+ celilo_contract: '2.0',
306
306
  id: 'iptables',
307
307
  name: 'iptables',
308
308
  version: '1.0.2',
@@ -376,7 +376,7 @@ describe("updateOne — pointed at the module's own installed path", () => {
376
376
  mkdirSync(installedDir, { recursive: true });
377
377
  writeFileSync(
378
378
  join(installedDir, 'manifest.yml'),
379
- `celilo_contract: "1.0"
379
+ `celilo_contract: "2.0"
380
380
  id: selfmod
381
381
  name: Self Module
382
382
  version: 1.0.0
@@ -392,7 +392,7 @@ description: fixture
392
392
  sourcePath: installedDir,
393
393
  version: '1.0.0',
394
394
  manifestData: {
395
- celilo_contract: '1.0',
395
+ celilo_contract: '2.0',
396
396
  id: 'selfmod',
397
397
  name: 'Self Module',
398
398
  version: '1.0.0',
@@ -480,7 +480,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
480
480
  mkdirSync(join(installedDir, 'generated'), { recursive: true });
481
481
  writeFileSync(
482
482
  join(installedDir, 'manifest.yml'),
483
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
483
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
484
484
  );
485
485
  writeFileSync(join(installedDir, 'scripts', 'on_install.ts'), 'export const OLD = 1;\n');
486
486
  // `derived` — celilo's own output, must survive an update either way.
@@ -490,7 +490,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
490
490
  mkdirSync(join(srcDir, 'scripts'), { recursive: true });
491
491
  writeFileSync(
492
492
  join(srcDir, 'manifest.yml'),
493
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture\n',
493
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture\n',
494
494
  );
495
495
  writeFileSync(join(srcDir, 'scripts', 'on_install.ts'), 'export const NEW = 2;\n');
496
496
  // cpSync refuses a FIFO with ENOTSUP. Sorts last, so the real files copy first.
@@ -504,7 +504,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
504
504
  sourcePath: installedDir,
505
505
  version: '1.0.0',
506
506
  manifestData: {
507
- celilo_contract: '1.0',
507
+ celilo_contract: '2.0',
508
508
  id: 'testmod',
509
509
  name: 'Test Module',
510
510
  version: '1.0.0',
@@ -588,7 +588,7 @@ describe('updateOne — a .netapp package source', () => {
588
588
  mkdirSync(installedDir, { recursive: true });
589
589
  writeFileSync(
590
590
  join(installedDir, 'manifest.yml'),
591
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
591
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
592
592
  );
593
593
 
594
594
  db = getDb();
@@ -599,7 +599,7 @@ describe('updateOne — a .netapp package source', () => {
599
599
  sourcePath: installedDir,
600
600
  version: '1.0.0+5',
601
601
  manifestData: {
602
- celilo_contract: '1.0',
602
+ celilo_contract: '2.0',
603
603
  id: 'testmod',
604
604
  name: 'Test Module',
605
605
  version: '1.0.0',
@@ -626,7 +626,7 @@ describe('updateOne — a .netapp package source', () => {
626
626
  mkdirSync(srcDir, { recursive: true });
627
627
  writeFileSync(
628
628
  join(srcDir, 'manifest.yml'),
629
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture v2\n',
629
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture v2\n',
630
630
  );
631
631
  const pkgPath = join(tempDir, 'testmod-2.0.0.netapp');
632
632
  await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
@@ -45,7 +45,7 @@ async function packageWithManifest(manifestYaml: string): Promise<ArrayBuffer> {
45
45
  }
46
46
 
47
47
  const MANIFEST = (publicWebVersion: string) => `
48
- celilo_contract: "1.0"
48
+ celilo_contract: "2.0"
49
49
  id: celilo-website
50
50
  name: Celilo Website
51
51
  version: 2.0.0
@@ -152,3 +152,28 @@ describe('handleModuleUpgrade — the capability gate (celilo#1361)', () => {
152
152
  }
153
153
  });
154
154
  });
155
+
156
+ describe('handleModuleUpgrade — provider AHEAD is terminal (celilo#1414)', () => {
157
+ test('fails, does not defer, when every blocker is major_mismatch_lower', async () => {
158
+ // Consumer requires public_web@3.1.0; the deployed caddy serves 4.0.0.
159
+ // No retry can fix that — the consumer's source has to move.
160
+ seedFleet('4.0.0');
161
+ downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('3.1.0')));
162
+
163
+ const result = await handleModuleUpgrade(['celilo-website'], {});
164
+
165
+ expect(result.success).toBe(false);
166
+ if (!result.success) {
167
+ expect(result.deferred).toBeUndefined();
168
+ expect(result.error).not.toContain('retries automatically');
169
+ expect(result.error).toContain('public_web');
170
+ }
171
+ // Still no state change: a terminal blocker must not record the version.
172
+ const row = getDb()
173
+ .select()
174
+ .from(modules)
175
+ .all()
176
+ .find((m) => m.id === 'celilo-website');
177
+ expect(row?.version).toBe('1.0.5');
178
+ });
179
+ });
@@ -180,7 +180,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
180
180
  mkdirSync(installedDir, { recursive: true });
181
181
  writeFileSync(
182
182
  join(installedDir, 'manifest.yml'),
183
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
183
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
184
184
  );
185
185
 
186
186
  db = getDb();
@@ -193,7 +193,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
193
193
  state: 'VERIFIED',
194
194
  updatedAt: FROZEN,
195
195
  manifestData: {
196
- celilo_contract: '1.0',
196
+ celilo_contract: '2.0',
197
197
  id: 'testmod',
198
198
  name: 'Test Module',
199
199
  version: '1.0.0',
@@ -208,7 +208,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
208
208
  mkdirSync(srcDir, { recursive: true });
209
209
  writeFileSync(
210
210
  join(srcDir, 'manifest.yml'),
211
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture v6\n',
211
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture v6\n',
212
212
  );
213
213
  const pkgPath = join(tempDir, 'testmod-1.0.0.netapp');
214
214
  await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
@@ -194,6 +194,18 @@ export async function upgradeOneModule(
194
194
  const blockers = capabilityBlockersForManifest(db, targetManifestCheck.manifest);
195
195
  if (blockers.length > 0) {
196
196
  const why = blockers.map((b) => b.message).join('; ');
197
+ // A provider AHEAD of what the consumer requires is TERMINAL (celilo#1414):
198
+ // no retry can fix it, the consumer's source must move to the provider's
199
+ // current major and republish. Only a provider BEHIND (major_mismatch_higher,
200
+ // the celilo#1361 case) is transient. Deferring the terminal case retried it
201
+ // forever, reported success, and left four consumers silently stuck.
202
+ if (blockers.every((b) => b.reason === 'major_mismatch_lower')) {
203
+ return {
204
+ success: false,
205
+ error: `${moduleId} upgrade to ${targetVersion} cannot proceed: the deployed providers are AHEAD of its capability requirements: ${why}. This will not resolve on retry — update ${moduleId}'s source to the provider's current major and republish it.`,
206
+ details: blockers,
207
+ };
208
+ }
197
209
  return {
198
210
  success: false,
199
211
  deferred: true,
@@ -8,7 +8,7 @@ let dir: string;
8
8
 
9
9
  const MANIFEST = (kind: string | null) =>
10
10
  [
11
- 'celilo_contract: "1.0"',
11
+ 'celilo_contract: "2.0"',
12
12
  'id: lunacycle',
13
13
  'name: LunaCycle',
14
14
  'version: 1.0.1 # keep this comment',
@@ -26,6 +26,7 @@ import { testProxmoxConnection } from '../../api-clients/proxmox';
26
26
  import { getDb } from '../../db/client';
27
27
  import {
28
28
  capabilitySecrets,
29
+ machines,
29
30
  moduleConfigs as moduleConfigsTbl,
30
31
  modules,
31
32
  secrets,
@@ -52,6 +53,7 @@ import {
52
53
  } from '../../services/audit/public-dns-source';
53
54
  import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
54
55
  import type { SecretCheckResult } from '../../services/audit/secrets-decryptable';
56
+ import { collectServerBunPinDeps } from '../../services/audit/server-bun-pin';
55
57
  import type { ServiceCredentialsResult } from '../../services/audit/services-credentials';
56
58
  import type { ServiceReachableResult } from '../../services/audit/services-reachable';
57
59
  import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
@@ -63,6 +65,7 @@ import { collectJailExemptions } from '../../services/jail-exemptions';
63
65
  import { probeMachines } from '../../services/machine-probe';
64
66
  import { parseStoredConfigValue } from '../../services/module-config';
65
67
  import { createPublicDnsProbe, loadPublicDnsProbeSettings } from '../../services/public-dns-probe';
68
+ import { collectRebootPendingDeps } from '../../services/system-reboot';
66
69
  import { buildTerraformEnvForModule } from '../../services/terraform-env';
67
70
  import { hasFlag } from '../parser';
68
71
  import type { CommandResult } from '../types';
@@ -368,6 +371,22 @@ async function buildAuditDeps(
368
371
  probeDiskUsage(db),
369
372
  ]);
370
373
 
374
+ // Stale interface zone labels. Reads only what is already stored — the
375
+ // detector matches an address against the declared subnets, so this costs
376
+ // no SSH and cannot be confused by an unreachable machine.
377
+ const { diffInterfaceZones } = await import('../../services/machine-interface-zones');
378
+ const { detectZoneFromIp } = await import('../../services/zone-detector');
379
+ const machineZoneLabels = await Promise.all(
380
+ db
381
+ .select()
382
+ .from(machines)
383
+ .all()
384
+ .map(async (m) => ({
385
+ hostname: m.hostname,
386
+ changes: await diffInterfaceZones(m.interfaces, detectZoneFromIp),
387
+ })),
388
+ );
389
+
371
390
  const migrationsFolder = findMigrationsFolderSafe();
372
391
 
373
392
  return {
@@ -388,6 +407,8 @@ async function buildAuditDeps(
388
407
  })),
389
408
  },
390
409
  browserPin: collectBrowserPinDeps(deployedModules),
410
+ serverBunPin: collectServerBunPinDeps(),
411
+ rebootPending: collectRebootPendingDeps(),
391
412
  terraformPlan: {
392
413
  modules: terraformModules,
393
414
  run: realTerraformPlan,
@@ -429,6 +450,7 @@ async function buildAuditDeps(
429
450
  secretsDecryptable: { results: secretResults },
430
451
  servicesReachable: { results: serviceReachableResults },
431
452
  machinesReachable: { results: machineReachableResults },
453
+ machineInterfaceZones: { machines: machineZoneLabels },
432
454
  // The only check here whose vantage point is OUTSIDE the fleet. Its
433
455
  // undetermined counters are read but not written from this path: an
434
456
  // operator-run audit is not a run of a schedule, so counting it towards