@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.11

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 (101) hide show
  1. package/drizzle/0009_dns_registrations.sql +13 -0
  2. package/drizzle/0010_dns_internal_records.sql +12 -0
  3. package/drizzle/0011_backups_name.sql +1 -0
  4. package/drizzle/meta/_journal.json +22 -1
  5. package/package.json +3 -3
  6. package/src/ansible/inventory.test.ts +10 -10
  7. package/src/ansible/validation.test.ts +25 -15
  8. package/src/api-clients/proxmox.test.ts +211 -1
  9. package/src/api-clients/proxmox.ts +399 -8
  10. package/src/cli/command-registry.ts +83 -6
  11. package/src/cli/commands/backup-delete.ts +10 -7
  12. package/src/cli/commands/backup-import.ts +11 -8
  13. package/src/cli/commands/backup-restore.ts +11 -8
  14. package/src/cli/commands/dns.ts +57 -0
  15. package/src/cli/commands/events.test.ts +4 -4
  16. package/src/cli/commands/events.ts +89 -24
  17. package/src/cli/commands/machine-add.ts +178 -163
  18. package/src/cli/commands/machine-remove.ts +10 -7
  19. package/src/cli/commands/module-config.test.ts +78 -0
  20. package/src/cli/commands/module-config.ts +18 -3
  21. package/src/cli/commands/module-import.ts +9 -5
  22. package/src/cli/commands/module-publish.ts +24 -0
  23. package/src/cli/commands/module-remove.ts +20 -9
  24. package/src/cli/commands/module-status.ts +15 -0
  25. package/src/cli/commands/module-upgrade.test.ts +37 -0
  26. package/src/cli/commands/module-upgrade.ts +26 -6
  27. package/src/cli/commands/proxmox-node-list.ts +101 -0
  28. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  29. package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
  30. package/src/cli/commands/publish/alpha.test.ts +26 -0
  31. package/src/cli/commands/publish/alpha.ts +23 -0
  32. package/src/cli/commands/publish/types.ts +7 -2
  33. package/src/cli/commands/publish/workspace.ts +11 -1
  34. package/src/cli/commands/restore.ts +29 -0
  35. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  36. package/src/cli/commands/service-add-proxmox.ts +283 -209
  37. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  38. package/src/cli/commands/service-reconfigure.ts +252 -129
  39. package/src/cli/commands/service-remove.ts +19 -13
  40. package/src/cli/commands/service-verify.ts +9 -10
  41. package/src/cli/commands/storage-add-local.ts +120 -107
  42. package/src/cli/commands/storage-add-s3.ts +145 -131
  43. package/src/cli/commands/storage-remove.ts +11 -8
  44. package/src/cli/commands/system-doctor.ts +135 -40
  45. package/src/cli/commands/system-init.ts +119 -128
  46. package/src/cli/commands/system-migrate.test.ts +40 -0
  47. package/src/cli/commands/system-migrate.ts +65 -0
  48. package/src/cli/completion.ts +23 -0
  49. package/src/cli/index.ts +91 -7
  50. package/src/cli/service-credential.ts +54 -0
  51. package/src/config/paths.test.ts +61 -48
  52. package/src/db/client.ts +15 -146
  53. package/src/db/migrate.ts +14 -6
  54. package/src/db/schema-introspection.ts +88 -0
  55. package/src/db/schema.ts +74 -0
  56. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  57. package/src/hooks/capability-loader.ts +43 -2
  58. package/src/hooks/run-named-hook.ts +28 -2
  59. package/src/hooks/types.ts +2 -1
  60. package/src/infrastructure/property-extractor.test.ts +15 -0
  61. package/src/infrastructure/property-extractor.ts +12 -0
  62. package/src/manifest/contracts/v1.ts +16 -0
  63. package/src/manifest/schema.ts +17 -0
  64. package/src/manifest/validate.test.ts +53 -0
  65. package/src/services/bus-interview.test.ts +2 -2
  66. package/src/services/bus-interview.ts +232 -0
  67. package/src/services/bus-secret-flow.test.ts +2 -2
  68. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  69. package/src/services/deploy-preflight.ts +25 -0
  70. package/src/services/deploy-validation.test.ts +54 -4
  71. package/src/services/deploy-validation.ts +27 -36
  72. package/src/services/dns-internal-records.test.ts +126 -0
  73. package/src/services/dns-internal-records.ts +119 -0
  74. package/src/services/dns-provider-backfill.test.ts +2 -2
  75. package/src/services/dns-provider-backfill.ts +14 -2
  76. package/src/services/dns-registrations.test.ts +120 -0
  77. package/src/services/dns-registrations.ts +108 -0
  78. package/src/services/events-daemon.test.ts +59 -0
  79. package/src/services/events-daemon.ts +191 -57
  80. package/src/services/fleet-checks.test.ts +508 -0
  81. package/src/services/fleet-checks.ts +678 -0
  82. package/src/services/module-build.test.ts +43 -38
  83. package/src/services/module-config.ts +12 -0
  84. package/src/services/module-deploy.ts +7 -6
  85. package/src/services/module-subscriptions.test.ts +88 -0
  86. package/src/services/module-subscriptions.ts +50 -1
  87. package/src/services/module-validator/bundled-deps.test.ts +55 -0
  88. package/src/services/module-validator/bundled-deps.ts +115 -0
  89. package/src/services/module-validator/capability-versions.test.ts +1 -1
  90. package/src/services/placement-reconcile.test.ts +86 -0
  91. package/src/services/placement-reconcile.ts +108 -0
  92. package/src/services/programmatic-responder.ts +34 -0
  93. package/src/services/terminal-responder.ts +113 -0
  94. package/src/templates/generator.test.ts +92 -12
  95. package/src/templates/generator.ts +165 -80
  96. package/src/test-utils/fixtures.test.ts +1 -1
  97. package/src/test-utils/integration-guard.ts +33 -0
  98. package/src/types/infrastructure.ts +6 -0
  99. package/src/variables/computed/computed-integration.test.ts +3 -3
  100. package/src/variables/computed/computed.test.ts +5 -5
  101. package/src/variables/declarative-derivation.test.ts +6 -6
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Reconcile a module's ACTUAL node placement from Proxmox (ISS-0060 pt 2).
3
+ *
4
+ * "The DB stores intent, Proxmox reports reality." `module status` / `list`
5
+ * must show where a container ACTUALLY lives — queried live from the Proxmox
6
+ * cluster — not a cached `__infra_target_node` that drifts (caddy recorded
7
+ * node3, ran on node2). Machine-pool systems and non-Proxmox container services
8
+ * have no Proxmox node to reconcile.
9
+ *
10
+ * Never throws: a Proxmox outage yields an 'unreachable' resolution so a status
11
+ * or list command still renders instead of erroring.
12
+ */
13
+
14
+ import type { DeployedSystem } from '@celilo/capabilities';
15
+ import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
16
+ import { getContainerService, getServiceCredentials } from './container-service';
17
+
18
+ export type PlacementResolution =
19
+ | { kind: 'node'; node: string } // reconciled: lives on <node>
20
+ | { kind: 'unreachable' } // a Proxmox container, but the API couldn't be reached
21
+ | { kind: 'absent' } // a Proxmox container with a vmid not present in the cluster
22
+ | { kind: 'uncreated' } // a container_service system not yet created (no vmid)
23
+ | { kind: 'machine' } // a machine-pool system (no Proxmox node)
24
+ | { kind: 'other' }; // a non-Proxmox container service (e.g. DigitalOcean)
25
+
26
+ /** One-line placement description for `module status`. Pure (Rule 10). */
27
+ export function formatPlacementLine(sys: DeployedSystem, res: PlacementResolution): string {
28
+ const zone = `zone ${sys.zone}`;
29
+ const vmid = sys.infrastructure.vmid;
30
+ switch (res.kind) {
31
+ case 'machine':
32
+ return `${sys.hostname} — machine (${zone})`;
33
+ case 'other':
34
+ return `${sys.hostname} — container_service, non-Proxmox (${zone})`;
35
+ case 'uncreated':
36
+ return `${sys.hostname} — not yet created (${zone})`;
37
+ case 'unreachable':
38
+ return `${sys.hostname} (vmid ${vmid}) → node unknown — Proxmox unreachable (${zone})`;
39
+ case 'absent':
40
+ return `${sys.hostname} (vmid ${vmid}) → not found in Proxmox — not created? (${zone})`;
41
+ case 'node':
42
+ return `${sys.hostname} (vmid ${vmid}) → ${res.node} (${zone})`;
43
+ }
44
+ }
45
+
46
+ /** Pure resolution of one system given the reconciled vmid→node map + service classification. */
47
+ export function resolveOnePlacement(
48
+ sys: DeployedSystem,
49
+ vmidToNode: Map<number, string>,
50
+ unreachable: Set<string>,
51
+ nonProxmox: Set<string>,
52
+ ): PlacementResolution {
53
+ const infra = sys.infrastructure;
54
+ if (infra.type !== 'container_service') return { kind: 'machine' };
55
+ if (infra.serviceId && nonProxmox.has(infra.serviceId)) return { kind: 'other' };
56
+ if (infra.vmid == null) return { kind: 'uncreated' };
57
+ if (infra.serviceId && unreachable.has(infra.serviceId)) return { kind: 'unreachable' };
58
+ const node = vmidToNode.get(infra.vmid);
59
+ return node ? { kind: 'node', node } : { kind: 'absent' };
60
+ }
61
+
62
+ /**
63
+ * Reconcile each system's real node from Proxmox. One `/cluster/resources` fetch
64
+ * per distinct Proxmox service (usually one); machine + non-Proxmox systems need
65
+ * no call. Returns each system paired with its resolution, in input order.
66
+ */
67
+ export async function reconcilePlacement(
68
+ systems: DeployedSystem[],
69
+ ): Promise<Array<{ system: DeployedSystem; resolution: PlacementResolution }>> {
70
+ const serviceIds = [
71
+ ...new Set(
72
+ systems
73
+ .filter((s) => s.infrastructure.type === 'container_service')
74
+ .map((s) => s.infrastructure.serviceId)
75
+ .filter((id): id is string => !!id),
76
+ ),
77
+ ];
78
+
79
+ const vmidToNode = new Map<number, string>();
80
+ const unreachable = new Set<string>();
81
+ const nonProxmox = new Set<string>();
82
+
83
+ for (const serviceId of serviceIds) {
84
+ const service = await getContainerService(serviceId);
85
+ if (!service || service.providerName !== 'proxmox') {
86
+ nonProxmox.add(serviceId);
87
+ continue;
88
+ }
89
+ try {
90
+ const creds = (await getServiceCredentials(serviceId)) as ProxmoxCredentials;
91
+ const result = await new ProxmoxClient(creds).clusterResources();
92
+ if (!result.success) {
93
+ unreachable.add(serviceId);
94
+ continue;
95
+ }
96
+ for (const r of result.data) {
97
+ if (typeof r.vmid === 'number' && r.node) vmidToNode.set(r.vmid, r.node);
98
+ }
99
+ } catch {
100
+ unreachable.add(serviceId);
101
+ }
102
+ }
103
+
104
+ return systems.map((system) => ({
105
+ system,
106
+ resolution: resolveOnePlacement(system, vmidToNode, unreachable, nonProxmox),
107
+ }));
108
+ }
@@ -23,6 +23,7 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
23
23
  import type {
24
24
  ConfigRequiredPayload,
25
25
  EnsureRequiredPayload,
26
+ InterviewRequiredPayload,
26
27
  SecretRequiredPayload,
27
28
  } from './bus-interview';
28
29
  import { readModuleSecretKey, writeModuleSecretKey } from './config-interview';
@@ -58,6 +59,13 @@ export interface ResponderValues {
58
59
  secretValues?: Record<string, string>;
59
60
  }
60
61
  >;
62
+ /**
63
+ * Generic interview answers keyed by `<scope>.<key>` (ISS-0127). When a
64
+ * command emits `interview.required.<scope>.<key>`, the responder replies
65
+ * with `{ value }`. The value's shape should match the payload's `kind`
66
+ * (string for text/select, string[] for multiselect, boolean for confirm).
67
+ */
68
+ interview?: Record<string, unknown>;
61
69
  }
62
70
 
63
71
  export interface ProgrammaticResponderOptions {
@@ -108,6 +116,7 @@ export interface ProgrammaticResponderHandle {
108
116
  seenConfigPayloads(): ConfigRequiredPayload[];
109
117
  seenSecretPayloads(): SecretRequiredPayload[];
110
118
  seenEnsurePayloads(): EnsureRequiredPayload[];
119
+ seenInterviewPayloads(): InterviewRequiredPayload[];
111
120
  /** Stop watching. Caller still owns the db client. */
112
121
  close(): void;
113
122
  }
@@ -126,6 +135,7 @@ export function startProgrammaticResponder(
126
135
  const seenConfig: ConfigRequiredPayload[] = [];
127
136
  const seenSecret: SecretRequiredPayload[] = [];
128
137
  const seenEnsure: EnsureRequiredPayload[] = [];
138
+ const seenInterview: InterviewRequiredPayload[] = [];
129
139
  let lastActivityAt = Date.now();
130
140
 
131
141
  const me = opts.emittedBy ?? 'programmatic';
@@ -276,6 +286,28 @@ export function startProgrammaticResponder(
276
286
  answered.push({ type: event.type, key: lookupKey });
277
287
  });
278
288
 
289
+ const interviewWatch = bus.watch('interview.required.*.*', async (event) => {
290
+ if (event.replyFor !== null) return;
291
+ lastActivityAt = Date.now();
292
+
293
+ const payload = event.payload as InterviewRequiredPayload;
294
+ if (!payload || typeof payload.scope !== 'string' || typeof payload.key !== 'string') {
295
+ missed.push({ type: event.type, key: '?', reason: 'malformed payload' });
296
+ return;
297
+ }
298
+ seenInterview.push(payload);
299
+
300
+ const lookupKey = `${payload.scope}.${payload.key}`;
301
+ const value = opts.values.interview?.[lookupKey];
302
+ if (value === undefined) {
303
+ handleMissing(event.type, lookupKey, `no interview value for "${lookupKey}"`);
304
+ return;
305
+ }
306
+
307
+ bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me });
308
+ answered.push({ type: event.type, key: lookupKey });
309
+ });
310
+
279
311
  // Liveness probe: a non-interactive caller (e.g. `module generate`
280
312
  // with no TTY) emits `responder.probe` to detect whether any
281
313
  // responder is listening before calling busInterview (which waits
@@ -298,10 +330,12 @@ export function startProgrammaticResponder(
298
330
  seenConfigPayloads: () => [...seenConfig],
299
331
  seenSecretPayloads: () => [...seenSecret],
300
332
  seenEnsurePayloads: () => [...seenEnsure],
333
+ seenInterviewPayloads: () => [...seenInterview],
301
334
  close: () => {
302
335
  configWatch.close();
303
336
  secretWatch.close();
304
337
  ensureWatch.close();
338
+ interviewWatch.close();
305
339
  probeWatch.close();
306
340
  bus.close();
307
341
  },
@@ -37,6 +37,7 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
37
37
  import type {
38
38
  ConfigRequiredPayload,
39
39
  EnsureRequiredPayload,
40
+ InterviewRequiredPayload,
40
41
  SecretRequiredPayload,
41
42
  } from './bus-interview';
42
43
  import { readModuleSecretKey, writeModuleSecretKey } from './config-interview';
@@ -283,6 +284,117 @@ export function startTerminalResponder(): TerminalResponderHandle {
283
284
  });
284
285
  });
285
286
 
287
+ // Generic interview family (ISS-0127). Non-deploy commands (e.g.
288
+ // `service reconfigure`) ask their questions here so they're driveable
289
+ // over the bus like a deploy. Renders by `kind`. Like the other watches,
290
+ // we ignore reply events (replyFor !== null) and de-dupe by event id.
291
+ const interviewWatch = bus.watch('interview.required.*.*', async (event) => {
292
+ if (event.replyFor !== null) return;
293
+ if (handled.has(event.id)) return;
294
+ handled.add(event.id);
295
+
296
+ const payload = event.payload as InterviewRequiredPayload;
297
+ if (!payload || typeof payload.scope !== 'string' || typeof payload.key !== 'string') {
298
+ log.warn(
299
+ `Terminal responder skipped malformed interview event ${event.type} (id ${event.id}): missing scope/key`,
300
+ );
301
+ return;
302
+ }
303
+
304
+ const message = payload.description
305
+ ? `${payload.message} — ${payload.description}`
306
+ : payload.message;
307
+
308
+ let value: unknown;
309
+
310
+ if (payload.kind === 'confirm') {
311
+ const answer = await p.confirm({
312
+ message,
313
+ initialValue: payload.defaultValue === 'true',
314
+ });
315
+ if (p.isCancel(answer)) {
316
+ log.warn(
317
+ `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
318
+ );
319
+ return;
320
+ }
321
+ value = answer;
322
+ } else if (payload.kind === 'select') {
323
+ const answer = await p.select({
324
+ message,
325
+ options: (payload.options ?? []).map((opt) => ({
326
+ value: opt.value,
327
+ label: opt.label,
328
+ hint: opt.hint,
329
+ })),
330
+ initialValue: payload.defaultValue,
331
+ });
332
+ if (p.isCancel(answer)) {
333
+ log.warn(
334
+ `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
335
+ );
336
+ return;
337
+ }
338
+ value = answer;
339
+ } else if (payload.kind === 'multiselect') {
340
+ const answer = await p.multiselect({
341
+ message,
342
+ options: (payload.options ?? []).map((opt) => ({
343
+ value: opt.value,
344
+ label: opt.label,
345
+ hint: opt.hint,
346
+ })),
347
+ required: payload.required,
348
+ });
349
+ if (p.isCancel(answer)) {
350
+ log.warn(
351
+ `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
352
+ );
353
+ return;
354
+ }
355
+ value = answer;
356
+ } else {
357
+ // kind === 'text'
358
+ const type = payload.type ?? 'string';
359
+ const typeHint = describeTypeHint(type);
360
+ const answer = await promptText({
361
+ message: typeHint ? `${message} (${typeHint})` : message,
362
+ defaultValue: payload.defaultValue,
363
+ placeholder: payload.placeholder,
364
+ validate: (val) => {
365
+ if (payload.required && (!val || val.trim() === '')) {
366
+ return 'This field is required';
367
+ }
368
+ if (payload.pattern && val) {
369
+ const re = new RegExp(payload.pattern);
370
+ if (!re.test(val)) return `Value must match: ${payload.pattern}`;
371
+ }
372
+ try {
373
+ coerceValue(val, type);
374
+ } catch (err) {
375
+ return err instanceof Error ? err.message : String(err);
376
+ }
377
+ },
378
+ });
379
+ if (answer === undefined) {
380
+ log.warn(
381
+ `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
382
+ );
383
+ return;
384
+ }
385
+ value = coerceValue(answer, type);
386
+ }
387
+
388
+ bus.emitRaw(
389
+ `${event.type}.reply`,
390
+ { value },
391
+ {
392
+ replyFor: event.id,
393
+ emittedBy: me,
394
+ },
395
+ );
396
+ });
397
+
286
398
  // Liveness probe: lets a non-interactive caller in another shell
287
399
  // (e.g. `module generate`) detect that a terminal-responder is
288
400
  // running here and that calling busInterview is safe.
@@ -300,6 +412,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
300
412
  configWatch.close();
301
413
  secretWatch.close();
302
414
  ensureWatch.close();
415
+ interviewWatch.close();
303
416
  probeWatch.close();
304
417
  bus.close();
305
418
  },
@@ -6,10 +6,11 @@ import { type DbClient, createDbClient } from '../db/client';
6
6
  import { capabilities } from '../db/schema';
7
7
  import { upsertModuleConfig } from '../services/module-config';
8
8
  import {
9
+ decideTargetNode,
9
10
  discoverTemplateFiles,
10
11
  generateTemplates,
11
12
  getOutputFilename,
12
- injectProxmoxLxcDns,
13
+ injectProxmoxDns,
13
14
  isTemplateFile,
14
15
  readTemplateFiles,
15
16
  targetNodeFromTfState,
@@ -636,7 +637,7 @@ resource "proxmox_lxc" "container" {
636
637
  });
637
638
  });
638
639
 
639
- describe('injectProxmoxLxcDns', () => {
640
+ describe('injectProxmoxDns', () => {
640
641
  const LXC = [
641
642
  'resource "proxmox_lxc" "caddy" {',
642
643
  ' target_node = "pve"',
@@ -648,7 +649,7 @@ resource "proxmox_lxc" "container" {
648
649
  ].join('\n');
649
650
 
650
651
  test('injects nameserver + lifecycle when a nameserver is computable', () => {
651
- const out = injectProxmoxLxcDns(LXC, true);
652
+ const out = injectProxmoxDns(LXC, true);
652
653
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
653
654
  expect(out).toContain(' lifecycle {');
654
655
  expect(out).toContain(
@@ -665,7 +666,7 @@ resource "proxmox_lxc" "container" {
665
666
  });
666
667
 
667
668
  test('injects lifecycle only (no nameserver) when none is computable', () => {
668
- const out = injectProxmoxLxcDns(LXC, false);
669
+ const out = injectProxmoxDns(LXC, false);
669
670
  expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
670
671
  expect(out).toContain(' lifecycle {');
671
672
  expect(out).toContain(
@@ -674,8 +675,8 @@ resource "proxmox_lxc" "container" {
674
675
  });
675
676
 
676
677
  test('is idempotent — already-injected content is returned unchanged', () => {
677
- const once = injectProxmoxLxcDns(LXC, true);
678
- const twice = injectProxmoxLxcDns(once, true);
678
+ const once = injectProxmoxDns(LXC, true);
679
+ const twice = injectProxmoxDns(once, true);
679
680
  expect(twice).toBe(once);
680
681
  });
681
682
 
@@ -689,27 +690,77 @@ resource "proxmox_lxc" "container" {
689
690
  ' start = true',
690
691
  '}',
691
692
  ].join('\n');
692
- const out = injectProxmoxLxcDns(stale, true);
693
+ const out = injectProxmoxDns(stale, true);
693
694
  // Exactly one nameserver attribute survives.
694
695
  expect(out.match(/nameserver\s*=/g)?.length).toBe(1);
695
696
  // The lifecycle guard is still added (ISS-0055 extended the ignore list).
696
697
  expect(out).toContain('ignore_changes = [nameserver,');
697
698
  });
698
699
 
699
- test('leaves non-proxmox_lxc resources untouched', () => {
700
- const vm = ['resource "proxmox_vm" "build" {', ' cores = 4', '}'].join('\n');
701
- expect(injectProxmoxLxcDns(vm, true)).toBe(vm);
700
+ test('injects cloud-init DNS + the VM ForceNew ignore list into proxmox_vm_qemu', () => {
701
+ const vm = [
702
+ 'resource "proxmox_vm_qemu" "builder" {',
703
+ ' target_node = "pve"',
704
+ ' clone = "ubuntu-2204-cloudinit"',
705
+ ' network {',
706
+ ' bridge = "vmbr0"',
707
+ ' }',
708
+ '}',
709
+ ].join('\n');
710
+ const out = injectProxmoxDns(vm, true);
711
+ expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
712
+ expect(out).toContain(' lifecycle {');
713
+ // The VM ForceNew list (telmate 3.x), NOT the LXC one.
714
+ expect(out).toContain(
715
+ ' ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]',
716
+ );
717
+ const lines = out.split('\n');
718
+ expect(lines[0]).toBe('resource "proxmox_vm_qemu" "builder" {');
719
+ expect(lines[1]).toBe(' nameserver = "$self:lxc_nameserver"');
720
+ expect(out).toContain(' clone = "ubuntu-2204-cloudinit"');
721
+ });
722
+
723
+ test('branches the ignore list per block in a mixed lxc + vm file', () => {
724
+ // The forgejo-runner builder pattern: one template with both a count-gated
725
+ // proxmox_lxc (light) and proxmox_vm_qemu (builder) — each gets its own list.
726
+ const mixed = [
727
+ 'resource "proxmox_lxc" "light" {',
728
+ ' target_node = "pve"',
729
+ '}',
730
+ '',
731
+ 'resource "proxmox_vm_qemu" "builder" {',
732
+ ' clone = "ubuntu-2204-cloudinit"',
733
+ '}',
734
+ ].join('\n');
735
+ const out = injectProxmoxDns(mixed, true);
736
+ expect(out).toContain(
737
+ 'ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
738
+ );
739
+ expect(out).toContain('ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]');
740
+ });
741
+
742
+ test('leaves non-Proxmox-compute resources untouched', () => {
743
+ // Not proxmox_lxc / proxmox_vm_qemu — an external-zone droplet, and the bare
744
+ // `proxmox_vm` type (which is NOT the cloud-init qemu resource we target).
745
+ const droplet = [
746
+ 'resource "digitalocean_droplet" "edge" {',
747
+ ' size = "s-1vcpu-1gb"',
748
+ '}',
749
+ ].join('\n');
750
+ const bareVm = ['resource "proxmox_vm" "x" {', ' cores = 4', '}'].join('\n');
751
+ expect(injectProxmoxDns(droplet, true)).toBe(droplet);
752
+ expect(injectProxmoxDns(bareVm, true)).toBe(bareVm);
702
753
  });
703
754
 
704
755
  test('injects into every proxmox_lxc block in a multi-resource file', () => {
705
756
  const two = `${LXC}\n\n${LXC.replace('"caddy"', '"forgejo"')}`;
706
- const out = injectProxmoxLxcDns(two, true);
757
+ const out = injectProxmoxDns(two, true);
707
758
  expect(out.match(/ignore_changes = \[nameserver,/g)?.length).toBe(2);
708
759
  });
709
760
 
710
761
  test('matches the indentation of the resource opening line', () => {
711
762
  const indented = [' resource "proxmox_lxc" "x" {', ' cores = 1', ' }'].join('\n');
712
- const out = injectProxmoxLxcDns(indented, true);
763
+ const out = injectProxmoxDns(indented, true);
713
764
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
714
765
  expect(out).toContain(' lifecycle {');
715
766
  expect(out).toContain(
@@ -744,3 +795,32 @@ describe("targetNodeFromTfState (ISS-0090 — terraform state is celilo's placem
744
795
  expect(targetNodeFromTfState({})).toBeNull();
745
796
  });
746
797
  });
798
+
799
+ describe('decideTargetNode (ISS-0090 — deploy follows reality: Proxmox > state > default)', () => {
800
+ test('Proxmox reality wins — adopts a hand-migration tf-state would miss', () => {
801
+ expect(
802
+ decideTargetNode({ proxmoxNode: 'node2', stateNode: 'node3', defaultNode: 'node3' }),
803
+ ).toEqual({ node: 'node2', source: 'proxmox' });
804
+ });
805
+
806
+ test('falls back to terraform state when Proxmox is unknown/unreachable', () => {
807
+ expect(
808
+ decideTargetNode({ proxmoxNode: null, stateNode: 'node2', defaultNode: 'node3' }),
809
+ ).toEqual({ node: 'node2', source: 'state' });
810
+ });
811
+
812
+ test('first deploy (no Proxmox, no state) → service default', () => {
813
+ expect(decideTargetNode({ proxmoxNode: null, stateNode: null, defaultNode: 'node3' })).toEqual({
814
+ node: 'node3',
815
+ source: 'default',
816
+ });
817
+ });
818
+
819
+ test('a changed default never relocates a running container (reality overrides default)', () => {
820
+ // default flipped node2→node3, but the container actually runs on node2:
821
+ // resolution stays node2, so the redeploy is an in-place update, not a move.
822
+ expect(
823
+ decideTargetNode({ proxmoxNode: 'node2', stateNode: null, defaultNode: 'node3' }),
824
+ ).toEqual({ node: 'node2', source: 'proxmox' });
825
+ });
826
+ });