@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,10 +3,12 @@ import type { DbClient } from '../../db/client';
3
3
  import type { ModuleManifest } from '../../manifest/schema';
4
4
  import type { AuditDeps } from '../audit';
5
5
  import { unusedPublicDnsProbe } from '../audit/public-dns';
6
+ import type { DriftFinding } from '../audit/types';
6
7
  import { buildModuleGraph } from './dep-graph';
7
8
  import {
8
9
  type ModuleSnapshot,
9
10
  type OrchestratorOps,
11
+ blockersOutOfReach,
10
12
  consumerSkipReason,
11
13
  runSystemUpdate,
12
14
  } from './orchestrator';
@@ -22,7 +24,7 @@ function makeManifest(
22
24
  id,
23
25
  name: id,
24
26
  version: '1.0.0',
25
- celilo_contract: '1.0',
27
+ celilo_contract: '2.0',
26
28
  provides: opts.provides
27
29
  ? {
28
30
  capabilities: opts.provides.map((name) => ({
@@ -73,6 +75,12 @@ const cleanAudit: AuditDeps = {
73
75
  schema: { journal: () => null, applied: () => [], db: fakeDb },
74
76
  capabilityAbi: { modules: [] },
75
77
  browserPin: { consumers: [], provisioned: null },
78
+ serverBunPin: {
79
+ pinnedVersion: '1.4.2',
80
+ runningVersion: '1.4.2',
81
+ pinPath: '/etc/celilo/mise.pin.toml',
82
+ },
83
+ rebootPending: { verdict: { pending: false, unmeasured: false, reasons: [] }, marker: null },
76
84
  terraformPlan: { modules: [], run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
77
85
  moduleVersions: { installed: [], fetcher: async () => ({ latest: null }) },
78
86
  moduleConfigs: { modules: [] },
@@ -88,6 +96,7 @@ const cleanAudit: AuditDeps = {
88
96
  secretsDecryptable: { results: [] },
89
97
  servicesReachable: { results: [] },
90
98
  machinesReachable: { results: [] },
99
+ machineInterfaceZones: { machines: [] },
91
100
  diskSpace: { results: [] },
92
101
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
93
102
  trustedSources: { firewalls: [], unreachableFirewalls: [] },
@@ -162,6 +171,8 @@ describe('runSystemUpdate', () => {
162
171
  expect(result.audit.verdict).toBe('BLOCKED');
163
172
  expect(result.modules).toEqual([]);
164
173
  expect(upgradeCalls).toBe(0);
174
+ // Not `no-network` — the network is fine; the audit is what refused.
175
+ expect(result.selfUpdate).toEqual({ performed: false, reason: 'audit-blocked' });
165
176
  });
166
177
 
167
178
  test('happy path: clean audit + one drifting module → backup, upgrade, deploy, health, done', async () => {
@@ -471,3 +482,156 @@ describe('runSystemUpdate', () => {
471
482
  expect([...seenIds]).toEqual(['shared-id']);
472
483
  });
473
484
  });
485
+
486
+ describe('blockersOutOfReach', () => {
487
+ const abiBlocker = (subject: string): DriftFinding => ({
488
+ category: 'capability_abi',
489
+ severity: 'blocked',
490
+ code: 'capability_abi_provider_mismatch',
491
+ message: `${subject} provides public_web@3.1.0 but framework runtime expects 4.0.0`,
492
+ subject,
493
+ });
494
+
495
+ test('an unscoped run cannot reach any blocker', () => {
496
+ expect(blockersOutOfReach([abiBlocker('caddy')], undefined)).toHaveLength(1);
497
+ });
498
+
499
+ test('--module X reaches a blocker about X', () => {
500
+ expect(blockersOutOfReach([abiBlocker('caddy')], 'caddy')).toEqual([]);
501
+ });
502
+
503
+ test('--module Y does not reach a blocker about X', () => {
504
+ const out = blockersOutOfReach([abiBlocker('caddy')], 'celilo-mgmt');
505
+ expect(out.map((f) => f.subject)).toEqual(['caddy']);
506
+ });
507
+
508
+ test('--module X never reaches a system-wide blocker', () => {
509
+ const systemBlocker: DriftFinding = {
510
+ category: 'schema',
511
+ severity: 'blocked',
512
+ code: 'schema_pending_migrations',
513
+ message: '1 pending migration',
514
+ subject: 'system',
515
+ };
516
+ const out = blockersOutOfReach([abiBlocker('caddy'), systemBlocker], 'caddy');
517
+ expect(out.map((f) => f.subject)).toEqual(['system']);
518
+ });
519
+
520
+ test('non-blocking findings are never returned', () => {
521
+ const drift: DriftFinding = {
522
+ category: 'module_versions',
523
+ severity: 'drift',
524
+ code: 'module_version_drift',
525
+ message: 'caddy 1.0.0 → 1.1.0',
526
+ subject: 'caddy',
527
+ };
528
+ expect(blockersOutOfReach([drift], undefined)).toEqual([]);
529
+ });
530
+ });
531
+
532
+ describe('runSystemUpdate with a module-scoped BLOCKED audit', () => {
533
+ // caddy is deployed and provides public_web@3.1.0 while the runtime
534
+ // contract is at 4.0.0 — the celilo#1381 shape. Upgrading caddy is
535
+ // exactly what the blocking finding asks for.
536
+ const abiBlockedAudit: AuditDeps = {
537
+ ...cleanAudit,
538
+ capabilityAbi: {
539
+ modules: [
540
+ {
541
+ id: 'caddy',
542
+ state: 'VERIFIED',
543
+ manifest: makeManifest('caddy', { provides: ['public_web'] }),
544
+ },
545
+ ],
546
+ contractVersions: { public_web: '2.0.0' },
547
+ },
548
+ };
549
+
550
+ function run(onlyModule: string | undefined, ops: OrchestratorOps) {
551
+ return runSystemUpdate({
552
+ audit: abiBlockedAudit,
553
+ graph: buildModuleGraph([
554
+ makeManifest('caddy', { provides: ['public_web'] }),
555
+ makeManifest('celilo-mgmt'),
556
+ ]),
557
+ snapshots: new Map([
558
+ ['caddy', makeSnapshot('caddy', { installed: '1.0.0', latest: '1.1.0' })],
559
+ ['celilo-mgmt', makeSnapshot('celilo-mgmt', { installed: '1.0.0', latest: '1.1.0' })],
560
+ ]),
561
+ ops,
562
+ selfUpdate: {
563
+ installedVersion: '0.1.5',
564
+ fetcher: async () => '0.1.5',
565
+ updater: async () => ({ ok: true, stderr: '' }),
566
+ },
567
+ progress: capturingProgress(),
568
+ now: () => new Date('2026-04-25T00:00:00Z'),
569
+ idGen: () => 'fixed-id',
570
+ onlyModule,
571
+ });
572
+ }
573
+
574
+ test('--module caddy upgrades caddy, with backup and health still run', async () => {
575
+ const calls: string[] = [];
576
+ const ops = makeOps({
577
+ backup: async (id) => {
578
+ calls.push(`backup:${id}`);
579
+ return { ok: true };
580
+ },
581
+ upgrade: async (id) => {
582
+ calls.push(`upgrade:${id}`);
583
+ return { ok: true };
584
+ },
585
+ deploy: async (id) => {
586
+ calls.push(`deploy:${id}`);
587
+ return { ok: true };
588
+ },
589
+ health: async (id) => {
590
+ calls.push(`health:${id}`);
591
+ return { status: 'healthy' };
592
+ },
593
+ });
594
+
595
+ const result = await run('caddy', ops);
596
+
597
+ expect(result.audit.verdict).toBe('BLOCKED');
598
+ expect(result.modules.map((m) => m.moduleId)).toEqual(['caddy']);
599
+ expect(result.modules[0]?.step).toBe('done');
600
+ expect(calls).toEqual(['backup:caddy', 'upgrade:caddy', 'deploy:caddy', 'health:caddy']);
601
+ });
602
+
603
+ test('--module celilo-mgmt refuses, naming the caddy finding, and never says no-network', async () => {
604
+ let upgrades = 0;
605
+ const ops = makeOps({
606
+ upgrade: async () => {
607
+ upgrades++;
608
+ return { ok: true };
609
+ },
610
+ });
611
+
612
+ const result = await run('celilo-mgmt', ops);
613
+
614
+ expect(result.ok).toBe(false);
615
+ expect(result.modules).toEqual([]);
616
+ expect(upgrades).toBe(0);
617
+ expect(result.selfUpdate).toEqual({ performed: false, reason: 'audit-blocked' });
618
+ const blocked = result.audit.findings.filter((f) => f.severity === 'blocked');
619
+ expect(blocked.map((f) => f.subject)).toEqual(['caddy']);
620
+ });
621
+
622
+ test('an unscoped run still refuses', async () => {
623
+ let upgrades = 0;
624
+ const ops = makeOps({
625
+ upgrade: async () => {
626
+ upgrades++;
627
+ return { ok: true };
628
+ },
629
+ });
630
+
631
+ const result = await run(undefined, ops);
632
+
633
+ expect(result.ok).toBe(false);
634
+ expect(result.modules).toEqual([]);
635
+ expect(upgrades).toBe(0);
636
+ });
637
+ });
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { runAudit } from '../audit';
22
22
  import type { AuditDeps, SystemAuditReport } from '../audit';
23
+ import type { DriftFinding } from '../audit/types';
23
24
  import {
24
25
  type ModuleGraph,
25
26
  type ModuleId,
@@ -133,6 +134,29 @@ export function consumerSkipReason(
133
134
  return null; // compatible; consumer can proceed against still-installed provider
134
135
  }
135
136
 
137
+ /**
138
+ * The blocking findings a run cannot clear by doing its own work.
139
+ *
140
+ * `system update` used to refuse on ANY blocked finding, which made the
141
+ * command unable to fix the condition that stopped it: when a provider is
142
+ * behind a capability major, upgrading that provider is the remediation,
143
+ * and `--module <provider>` was refused too (celilo#1381).
144
+ *
145
+ * A finding is within reach only when the run is scoped to one module
146
+ * (`--module`) and the finding is about that same module — that module's
147
+ * upgrade is what the finding asks for. Findings about `system` or about
148
+ * any other module are never within reach: nothing this run does touches
149
+ * them. An unscoped run therefore still refuses on every blocker, as before.
150
+ */
151
+ export function blockersOutOfReach(
152
+ findings: DriftFinding[],
153
+ onlyModule: string | undefined,
154
+ ): DriftFinding[] {
155
+ const blocked = findings.filter((f) => f.severity === 'blocked');
156
+ if (!onlyModule) return blocked;
157
+ return blocked.filter((f) => f.subject !== onlyModule);
158
+ }
159
+
136
160
  /**
137
161
  * Drive a single module through the state machine. Stops at the first
138
162
  * failed step.
@@ -218,20 +242,27 @@ export async function runSystemUpdate(deps: RunUpdateDeps): Promise<SystemUpdate
218
242
 
219
243
  deps.progress.emit({ kind: 'plan', modules: deps.snapshots.size });
220
244
 
221
- // Audit first; bail if BLOCKED.
245
+ // Audit first; bail if BLOCKED and the selected module can't clear it.
222
246
  const audit: SystemAuditReport = await runAudit(deps.audit);
223
247
  if (audit.verdict === 'BLOCKED') {
224
- return {
225
- version: 1,
226
- updateId,
227
- startedAt,
228
- finishedAt: now().toISOString(),
229
- audit,
230
- selfUpdate: { performed: false, reason: 'no-network' },
231
- backupsCreated: false,
232
- modules: [],
233
- ok: false,
234
- };
248
+ const stillBlocking = blockersOutOfReach(audit.findings, deps.onlyModule);
249
+ if (stillBlocking.length > 0) {
250
+ deps.progress.emit({
251
+ kind: 'self-update-skipped',
252
+ reason: `audit BLOCKED: ${stillBlocking.map((f) => `${f.subject}: ${f.message}`).join('; ')}`,
253
+ });
254
+ return {
255
+ version: 1,
256
+ updateId,
257
+ startedAt,
258
+ finishedAt: now().toISOString(),
259
+ audit,
260
+ selfUpdate: { performed: false, reason: 'audit-blocked' },
261
+ backupsCreated: false,
262
+ modules: [],
263
+ ok: false,
264
+ };
265
+ }
235
266
  }
236
267
 
237
268
  // Self-update.
@@ -43,7 +43,16 @@ export interface ModuleUpdateState {
43
43
  }
44
44
 
45
45
  export type SelfUpdateResult =
46
- | { performed: false; reason: 'already-current' | 'dev-mode' | 'no-network' }
46
+ | {
47
+ performed: false;
48
+ /**
49
+ * `audit-blocked` means the run refused before the self-update was
50
+ * attempted. It exists because that refusal used to report
51
+ * `no-network` (celilo#1381), sending operators to debug a network
52
+ * that was fine.
53
+ */
54
+ reason: 'already-current' | 'dev-mode' | 'no-network' | 'audit-blocked';
55
+ }
47
56
  | { performed: true; from: string; to: string };
48
57
 
49
58
  export interface SystemUpdateResult {
@@ -656,7 +656,7 @@ resource "proxmox_lxc" "container" {
656
656
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
657
657
  expect(out).toContain(' lifecycle {');
658
658
  expect(out).toContain(
659
- ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
659
+ ' ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
660
660
  );
661
661
  // Injected immediately after the opening line, before author attributes.
662
662
  const lines = out.split('\n');
@@ -673,10 +673,21 @@ resource "proxmox_lxc" "container" {
673
673
  expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
674
674
  expect(out).toContain(' lifecycle {');
675
675
  expect(out).toContain(
676
- ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
676
+ ' ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
677
677
  );
678
678
  });
679
679
 
680
+ // celilo#1402: `ostemplate` and `rootfs[0].storage` are ForceNew and do
681
+ // NOT round-trip through `terraform import` — Proxmox reports neither. Drop
682
+ // them from the ignore list and every import of an existing container
683
+ // computes a REPLACE, which the post-import guard refuses, leaving the
684
+ // container redeployable by no route at all.
685
+ test('ignores the ForceNew attributes an import cannot recover', () => {
686
+ const out = injectProxmoxDns(LXC, true);
687
+ expect(out).toContain('ostemplate');
688
+ expect(out).toContain('rootfs[0].storage');
689
+ });
690
+
680
691
  test('is idempotent — already-injected content is returned unchanged', () => {
681
692
  const once = injectProxmoxDns(LXC, true);
682
693
  const twice = injectProxmoxDns(once, true);
@@ -737,7 +748,7 @@ resource "proxmox_lxc" "container" {
737
748
  ].join('\n');
738
749
  const out = injectProxmoxDns(mixed, true);
739
750
  expect(out).toContain(
740
- 'ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
751
+ 'ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
741
752
  );
742
753
  expect(out).toContain('ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]');
743
754
  });
@@ -767,7 +778,7 @@ resource "proxmox_lxc" "container" {
767
778
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
768
779
  expect(out).toContain(' lifecycle {');
769
780
  expect(out).toContain(
770
- ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
781
+ ' ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
771
782
  );
772
783
  });
773
784
  });
@@ -199,9 +199,17 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
199
199
  // in-place UPDATE, never a destructive REPLACE. A non-existent attribute
200
200
  // here fails `terraform validate`, so each list is exactly the schema
201
201
  // attributes the resource actually has.
202
- // LXC (ISS-0055/0089): the create-time MAC + rootfs volume, plus
203
- // `ssh_public_keys` (ForceNew — a fleet-key rotation must not REPLACE the
204
- // container). `rootfs.size` is NOT ignored, so a disk grow still applies.
202
+ // LXC (ISS-0055/0089): the create-time MAC, plus `ssh_public_keys`
203
+ // (ForceNew — a fleet-key rotation must not REPLACE the container), plus
204
+ // the two ForceNew attributes an IMPORT cannot recover: `ostemplate`
205
+ // (Proxmox never reports the source template) and `rootfs[0].storage`
206
+ // (import records the volume, not the config's storage name). Without
207
+ // them every import of an existing container computes a REPLACE, the
208
+ // post-import guard refuses it, and the container becomes redeployable
209
+ // by no route at all (celilo#1402). `rootfs[0].volume` was listed here
210
+ // and terraform reported it "Redundant ... has no effect" — it is
211
+ // computed, never configured — so it is gone.
212
+ // `rootfs.size` is NOT ignored, so a disk grow still applies.
205
213
  // We do NOT ignore `target_node` — a node change is real (migration,
206
214
  // ISS-0090), never silently dropped.
207
215
  // VM (telmate 3.x proxmox_vm_qemu): `clone` (ForceNew clone source),
@@ -212,7 +220,7 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
212
220
  const ignore =
213
221
  resourceType === 'proxmox_vm_qemu'
214
222
  ? 'nameserver, network[0].macaddr, sshkeys, clone'
215
- : 'nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys';
223
+ : 'nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys';
216
224
  injected.push(`${inner} ignore_changes = [${ignore}]`);
217
225
  injected.push(`${inner}}`);
218
226
  return injected.join('\n');
@@ -261,7 +261,7 @@ describe('Capability data $self: variable resolution', () => {
261
261
  id: 'lunacycle',
262
262
  name: 'LunaCycle',
263
263
  version: '1.0.0',
264
- celilo_contract: '1.0',
264
+ celilo_contract: '2.0',
265
265
  variables: {
266
266
  owns: [
267
267
  {
@@ -388,7 +388,7 @@ describe('Capability data $self: variable resolution', () => {
388
388
  id: 'lunacycle',
389
389
  name: 'LunaCycle',
390
390
  version: '1.0.0',
391
- celilo_contract: '1.0',
391
+ celilo_contract: '2.0',
392
392
  variables: {
393
393
  owns: [
394
394
  {
@@ -29,7 +29,7 @@ let db: DbClient;
29
29
  /** Fresh manifest per call — never share a mutable manifest across tests. */
30
30
  function namecheapManifest(computedValue = 'keys(secret.ddns_passwords)'): ModuleManifest {
31
31
  return {
32
- celilo_contract: '1.0',
32
+ celilo_contract: '2.0',
33
33
  id: 'namecheap',
34
34
  name: 'Namecheap',
35
35
  version: '4.1.0',
@@ -423,8 +423,21 @@ async function assembleResolutionContext(
423
423
  const deployedZone = machineRow.zone ?? zone;
424
424
  const machineIdentity = resolveMachineIdentityAddress(machineRow, deployedZone);
425
425
  if (!machineIdentity) {
426
+ // Distinguish "no such interface" from "the interface is there under
427
+ // a stale zone label" — the second is what a zone-model change
428
+ // produces, and its remediation is a re-classification, not new
429
+ // hardware (celilo#1382).
430
+ const { diffInterfaceZones, explainMissingZoneInterface } = await import(
431
+ '../services/machine-interface-zones'
432
+ );
433
+ const { detectZoneFromIp } = await import('../services/zone-detector');
434
+ const changes = await diffInterfaceZones(machineRow.interfaces, detectZoneFromIp);
426
435
  throw new Error(
427
- `Machine '${infraSelection.machineId}' for module '${moduleId}' has no non-loopback interface in zone '${deployedZone}'`,
436
+ `Machine '${machineRow.hostname}' for module '${moduleId}' ${explainMissingZoneInterface(
437
+ machineRow.interfaces,
438
+ deployedZone,
439
+ changes,
440
+ )}`,
428
441
  );
429
442
  }
430
443
  upsertDeployedSystem(db, moduleId, {
@@ -441,7 +441,7 @@ describe('applyDeclarativeDerivations', () => {
441
441
  describe('basic derivations', () => {
442
442
  test('applies single derivation', () => {
443
443
  const manifest: ModuleManifest = {
444
- celilo_contract: '1.0',
444
+ celilo_contract: '2.0',
445
445
  id: 'test-module',
446
446
  name: 'Test Module',
447
447
  version: '1.0.0',
@@ -477,7 +477,7 @@ describe('applyDeclarativeDerivations', () => {
477
477
 
478
478
  test('applies multiple derivations in order', () => {
479
479
  const manifest: ModuleManifest = {
480
- celilo_contract: '1.0',
480
+ celilo_contract: '2.0',
481
481
  id: 'test-module',
482
482
  name: 'Test Module',
483
483
  version: '1.0.0',
@@ -523,7 +523,7 @@ describe('applyDeclarativeDerivations', () => {
523
523
  describe('user config precedence', () => {
524
524
  test('does not overwrite user-provided values', () => {
525
525
  const manifest: ModuleManifest = {
526
- celilo_contract: '1.0',
526
+ celilo_contract: '2.0',
527
527
  id: 'test-module',
528
528
  name: 'Test Module',
529
529
  version: '1.0.0',
@@ -562,7 +562,7 @@ describe('applyDeclarativeDerivations', () => {
562
562
  describe('dependency resolution', () => {
563
563
  test('resolves dependencies in declaration order', () => {
564
564
  const manifest: ModuleManifest = {
565
- celilo_contract: '1.0',
565
+ celilo_contract: '2.0',
566
566
  id: 'test-module',
567
567
  name: 'Test Module',
568
568
  version: '1.0.0',
@@ -619,7 +619,7 @@ describe('applyDeclarativeDerivations', () => {
619
619
  describe('error handling', () => {
620
620
  test('throws error for required variable with missing dependency', () => {
621
621
  const manifest: ModuleManifest = {
622
- celilo_contract: '1.0',
622
+ celilo_contract: '2.0',
623
623
  id: 'test-module',
624
624
  name: 'Test Module',
625
625
  version: '1.0.0',
@@ -655,7 +655,7 @@ describe('applyDeclarativeDerivations', () => {
655
655
 
656
656
  test('silently skips optional variable with missing dependency', () => {
657
657
  const manifest: ModuleManifest = {
658
- celilo_contract: '1.0',
658
+ celilo_contract: '2.0',
659
659
  id: 'test-module',
660
660
  name: 'Test Module',
661
661
  version: '1.0.0',
@@ -693,7 +693,7 @@ describe('applyDeclarativeDerivations', () => {
693
693
 
694
694
  test('derives scalar numeric values from capability data', () => {
695
695
  const manifest: ModuleManifest = {
696
- celilo_contract: '1.0',
696
+ celilo_contract: '2.0',
697
697
  id: 'test-module',
698
698
  name: 'Test Module',
699
699
  version: '1.0.0',
@@ -731,7 +731,7 @@ describe('applyDeclarativeDerivations', () => {
731
731
 
732
732
  test('continues to skip structured derivations', () => {
733
733
  const manifest: ModuleManifest = {
734
- celilo_contract: '1.0',
734
+ celilo_contract: '2.0',
735
735
  id: 'test-module',
736
736
  name: 'Test Module',
737
737
  version: '1.0.0',
@@ -776,7 +776,7 @@ describe('applyDeclarativeDerivations', () => {
776
776
  // context. The key must remain unset rather than being poisoned with the raw template.
777
777
  test('does not set selfConfig when capability value still contains $self: ref', () => {
778
778
  const manifest: ModuleManifest = {
779
- celilo_contract: '1.0',
779
+ celilo_contract: '2.0',
780
780
  id: 'lunacycle',
781
781
  name: 'Lunacycle',
782
782
  version: '1.0.0',
@@ -816,7 +816,7 @@ describe('applyDeclarativeDerivations', () => {
816
816
 
817
817
  test('uses existing selfConfig value when capability returns unresolved $self: ref', () => {
818
818
  const manifest: ModuleManifest = {
819
- celilo_contract: '1.0',
819
+ celilo_contract: '2.0',
820
820
  id: 'lunacycle',
821
821
  name: 'Lunacycle',
822
822
  version: '1.0.0',
@@ -860,7 +860,7 @@ describe('applyDeclarativeDerivations', () => {
860
860
  // all 5 resolution passes unchanged and must be skipped.
861
861
  test('does not set selfConfig when capability value contains $secret: ref', () => {
862
862
  const manifest: ModuleManifest = {
863
- celilo_contract: '1.0',
863
+ celilo_contract: '2.0',
864
864
  id: 'consumer',
865
865
  name: 'Consumer',
866
866
  version: '1.0.0',
@@ -903,7 +903,7 @@ describe('applyDeclarativeDerivations', () => {
903
903
  // end-to-end, not just resolveDeclarativeDerivation in isolation.
904
904
  test('resolves $system: ref inside capability value', () => {
905
905
  const manifest: ModuleManifest = {
906
- celilo_contract: '1.0',
906
+ celilo_contract: '2.0',
907
907
  id: 'caddy',
908
908
  name: 'Caddy',
909
909
  version: '1.0.0',
@@ -946,7 +946,7 @@ describe('applyDeclarativeDerivations', () => {
946
946
  // on first pass (the other isn't set yet). Both should remain unset.
947
947
  test('silently skips both optional variables in a circular reference', () => {
948
948
  const manifest: ModuleManifest = {
949
- celilo_contract: '1.0',
949
+ celilo_contract: '2.0',
950
950
  id: 'test-module',
951
951
  name: 'Test Module',
952
952
  version: '1.0.0',
@@ -990,7 +990,7 @@ describe('applyDeclarativeDerivations', () => {
990
990
 
991
991
  test('throws when either circular variable is required', () => {
992
992
  const manifest: ModuleManifest = {
993
- celilo_contract: '1.0',
993
+ celilo_contract: '2.0',
994
994
  id: 'test-module',
995
995
  name: 'Test Module',
996
996
  version: '1.0.0',
@@ -1040,7 +1040,7 @@ describe('applyDeclarativeDerivations', () => {
1040
1040
  // Equivalent to the {var} syntax; reads from selfConfig.
1041
1041
  test('resolves $self: against selfConfig like {var}', () => {
1042
1042
  const manifest: ModuleManifest = {
1043
- celilo_contract: '1.0',
1043
+ celilo_contract: '2.0',
1044
1044
  id: 'test-module',
1045
1045
  name: 'Test Module',
1046
1046
  version: '1.0.0',
@@ -1079,7 +1079,7 @@ describe('applyDeclarativeDerivations', () => {
1079
1079
  // auth_url derives from "https://auth.$self:domain" where domain
1080
1080
  // is another self-owned variable set by the user.
1081
1081
  const manifest: ModuleManifest = {
1082
- celilo_contract: '1.0',
1082
+ celilo_contract: '2.0',
1083
1083
  id: 'authentik',
1084
1084
  name: 'Authentik',
1085
1085
  version: '1.0.0',
@@ -1121,7 +1121,7 @@ describe('applyDeclarativeDerivations', () => {
1121
1121
 
1122
1122
  test('throws when $self: references an undeclared variable', () => {
1123
1123
  const manifest: ModuleManifest = {
1124
- celilo_contract: '1.0',
1124
+ celilo_contract: '2.0',
1125
1125
  id: 'test',
1126
1126
  name: 'Test',
1127
1127
  version: '1.0.0',
@@ -1162,7 +1162,7 @@ describe('applyDeclarativeDerivations', () => {
1162
1162
  describe('skips', () => {
1163
1163
  test('skips variables without derive_from', () => {
1164
1164
  const manifest: ModuleManifest = {
1165
- celilo_contract: '1.0',
1165
+ celilo_contract: '2.0',
1166
1166
  id: 'test-module',
1167
1167
  name: 'Test Module',
1168
1168
  version: '1.0.0',
@@ -1199,7 +1199,7 @@ describe('applyDeclarativeDerivations', () => {
1199
1199
 
1200
1200
  test('handles manifest with no variables', () => {
1201
1201
  const manifest: ModuleManifest = {
1202
- celilo_contract: '1.0',
1202
+ celilo_contract: '2.0',
1203
1203
  id: 'test-module',
1204
1204
  name: 'Test Module',
1205
1205
  version: '1.0.0',