@celilo/cli 0.5.0-alpha.5 → 0.5.0-alpha.7

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 (33) hide show
  1. package/package.json +2 -2
  2. package/src/ansible/inventory.test.ts +10 -10
  3. package/src/ansible/validation.test.ts +25 -15
  4. package/src/api-clients/proxmox.test.ts +123 -1
  5. package/src/api-clients/proxmox.ts +292 -7
  6. package/src/cli/commands/events.test.ts +4 -4
  7. package/src/cli/commands/events.ts +2 -2
  8. package/src/cli/commands/proxmox-vm-template-build.ts +164 -0
  9. package/src/cli/commands/service-add-proxmox.ts +62 -3
  10. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  11. package/src/cli/commands/service-reconfigure.ts +110 -5
  12. package/src/cli/index.ts +2 -2
  13. package/src/config/paths.test.ts +61 -48
  14. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  15. package/src/infrastructure/property-extractor.test.ts +15 -0
  16. package/src/infrastructure/property-extractor.ts +12 -0
  17. package/src/manifest/schema.ts +7 -0
  18. package/src/manifest/validate.test.ts +53 -0
  19. package/src/services/bus-interview.test.ts +2 -2
  20. package/src/services/bus-secret-flow.test.ts +2 -2
  21. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  22. package/src/services/deploy-validation.test.ts +2 -2
  23. package/src/services/dns-provider-backfill.test.ts +2 -2
  24. package/src/services/dns-registrations.test.ts +10 -10
  25. package/src/services/module-build.test.ts +43 -38
  26. package/src/templates/generator.test.ts +62 -12
  27. package/src/templates/generator.ts +48 -50
  28. package/src/test-utils/fixtures.test.ts +1 -1
  29. package/src/test-utils/integration-guard.ts +33 -0
  30. package/src/types/infrastructure.ts +6 -0
  31. package/src/variables/computed/computed-integration.test.ts +3 -3
  32. package/src/variables/computed/computed.test.ts +5 -5
  33. package/src/variables/declarative-derivation.test.ts +6 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.5.0-alpha.5",
3
+ "version": "0.5.0-alpha.7",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
- "@celilo/capabilities": "^0.4.1",
55
+ "@celilo/capabilities": "^0.4.2",
56
56
  "@celilo/cli-display": "^0.1.9",
57
57
  "@celilo/event-bus": "^0.1.6",
58
58
  "@clack/prompts": "^1.1.0",
@@ -61,7 +61,7 @@ describe('generateHostsIni', () => {
61
61
  },
62
62
  {
63
63
  hostname: 'dns-ext',
64
- ansibleHost: '188.166.157.2',
64
+ ansibleHost: '192.0.2.20',
65
65
  ansibleUser: 'root',
66
66
  groups: ['dns_external'],
67
67
  },
@@ -72,7 +72,7 @@ describe('generateHostsIni', () => {
72
72
  expect(result).toContain('[homebridge]');
73
73
  expect(result).toContain('[dns_external]');
74
74
  expect(result).toContain('iot ansible_host=192.168.0.110 ansible_user=root');
75
- expect(result).toContain('dns-ext ansible_host=188.166.157.2 ansible_user=root');
75
+ expect(result).toContain('dns-ext ansible_host=192.0.2.20 ansible_user=root');
76
76
  });
77
77
 
78
78
  test('includes SSH private key file path when provided', () => {
@@ -136,8 +136,8 @@ describe('generateHostVarsYaml', () => {
136
136
  test('generates YAML for arrays', () => {
137
137
  const vars = {
138
138
  zone_records: [
139
- { name: 'ns1', type: 'A', value: '188.166.157.2' },
140
- { name: 'www', type: 'A', value: '71.36.99.96' },
139
+ { name: 'ns1', type: 'A', value: '192.0.2.20' },
140
+ { name: 'www', type: 'A', value: '203.0.113.10' },
141
141
  ],
142
142
  };
143
143
 
@@ -146,9 +146,9 @@ describe('generateHostVarsYaml', () => {
146
146
  expect(result).toContain('zone_records:');
147
147
  expect(result).toContain('- name: ns1');
148
148
  expect(result).toContain('type: A');
149
- expect(result).toContain('value: 188.166.157.2');
149
+ expect(result).toContain('value: 192.0.2.20');
150
150
  expect(result).toContain('- name: www');
151
- expect(result).toContain('value: 71.36.99.96');
151
+ expect(result).toContain('value: 203.0.113.10');
152
152
  });
153
153
 
154
154
  test('generates YAML for nested objects', () => {
@@ -359,13 +359,13 @@ describe('Database integration', () => {
359
359
  );
360
360
 
361
361
  upsertModuleConfig(db, 'dns', 'zone_records', [
362
- { name: 'ns1', type: 'A', value: '188.166.157.2' },
362
+ { name: 'ns1', type: 'A', value: '192.0.2.20' },
363
363
  ]);
364
364
 
365
365
  const vars = buildHostVars('dns', db);
366
366
 
367
367
  expect(Array.isArray(vars.zone_records)).toBe(true);
368
- expect(vars.zone_records).toEqual([{ name: 'ns1', type: 'A', value: '188.166.157.2' }]);
368
+ expect(vars.zone_records).toEqual([{ name: 'ns1', type: 'A', value: '192.0.2.20' }]);
369
369
  });
370
370
  });
371
371
 
@@ -416,13 +416,13 @@ describe('Database integration', () => {
416
416
  );
417
417
 
418
418
  upsertModuleConfig(db, 'dns-external', 'hostname', 'dns-ext');
419
- upsertModuleConfig(db, 'dns-external', 'vps_ip', '188.166.157.2');
419
+ upsertModuleConfig(db, 'dns-external', 'vps_ip', '192.0.2.20');
420
420
 
421
421
  const host = extractInventoryHost('dns-external', db);
422
422
 
423
423
  expect(host).not.toBeNull();
424
424
  expect(host?.hostname).toBe('dns-ext');
425
- expect(host?.ansibleHost).toBe('188.166.157.2'); // VPS IP used directly
425
+ expect(host?.ansibleHost).toBe('192.0.2.20'); // VPS IP used directly
426
426
  expect(host?.ansibleUser).toBe('root');
427
427
  expect(host?.groups).toEqual(['dns-external']);
428
428
  });
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { skipIntegration } from '../test-utils/integration-guard';
2
3
  import {
3
4
  isAnsibleInventoryAvailable,
4
5
  isAnsibleLintAvailable,
@@ -54,17 +55,23 @@ describe('validateWithAnsibleLint', () => {
54
55
  });
55
56
 
56
57
  describe('validatePlaybookSyntax', () => {
57
- test('returns error when playbook does not exist', async () => {
58
- const result = await validatePlaybookSyntax('/nonexistent/playbook.yml', '/tmp');
59
- expect(result.success).toBe(false);
60
- expect(result.error).toContain('Playbook not found');
61
- });
58
+ test.skipIf(skipIntegration({ tools: ['ansible'] }))(
59
+ 'returns error when playbook does not exist',
60
+ async () => {
61
+ const result = await validatePlaybookSyntax('/nonexistent/playbook.yml', '/tmp');
62
+ expect(result.success).toBe(false);
63
+ expect(result.error).toContain('Playbook not found');
64
+ },
65
+ );
62
66
 
63
- test('returns error when inventory does not exist', async () => {
64
- const result = await validatePlaybookSyntax('/tmp/playbook.yml', '/nonexistent/inventory');
65
- expect(result.success).toBe(false);
66
- expect(result.error).toContain('not found');
67
- });
67
+ test.skipIf(skipIntegration({ tools: ['ansible'] }))(
68
+ 'returns error when inventory does not exist',
69
+ async () => {
70
+ const result = await validatePlaybookSyntax('/tmp/playbook.yml', '/nonexistent/inventory');
71
+ expect(result.success).toBe(false);
72
+ expect(result.error).toContain('not found');
73
+ },
74
+ );
68
75
 
69
76
  test('returns error when ansible-playbook not installed', async () => {
70
77
  if (!isAnsiblePlaybookAvailable()) {
@@ -76,11 +83,14 @@ describe('validatePlaybookSyntax', () => {
76
83
  });
77
84
 
78
85
  describe('validateInventory', () => {
79
- test('returns error when inventory does not exist', async () => {
80
- const result = await validateInventory('/nonexistent/inventory');
81
- expect(result.success).toBe(false);
82
- expect(result.error).toContain('Inventory not found');
83
- });
86
+ test.skipIf(skipIntegration({ tools: ['ansible'] }))(
87
+ 'returns error when inventory does not exist',
88
+ async () => {
89
+ const result = await validateInventory('/nonexistent/inventory');
90
+ expect(result.success).toBe(false);
91
+ expect(result.error).toContain('Inventory not found');
92
+ },
93
+ );
84
94
 
85
95
  test('returns error when ansible-inventory not installed', async () => {
86
96
  if (!isAnsibleInventoryAvailable()) {
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { findNodeForVmid } from './proxmox';
2
+ import {
3
+ buildCloudImageVolid,
4
+ filterVmTemplates,
5
+ findNodeForVmid,
6
+ pollTaskUntilDone,
7
+ selectFreeVmid,
8
+ selectIsoStorage,
9
+ } from './proxmox';
3
10
 
4
11
  describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current location)', () => {
5
12
  // A trimmed /cluster/resources payload: guest rows carry a vmid; node/storage
@@ -28,3 +35,118 @@ describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current l
28
35
  expect(findNodeForVmid([], 200)).toBeNull();
29
36
  });
30
37
  });
38
+
39
+ describe('filterVmTemplates — extract VM templates from a /nodes/{node}/qemu listing', () => {
40
+ test('keeps only guests flagged template===1 and projects to {vmid, name}', () => {
41
+ const guests = [
42
+ { vmid: 100, name: 'running-vm' }, // no template flag → a live VM
43
+ { vmid: 9000, name: 'ubuntu-2404-cloudinit', template: 1 },
44
+ { vmid: 201, name: 'another-vm', template: 0 }, // explicitly not a template
45
+ { vmid: 9001, name: 'ubuntu-2204-cloudinit', template: 1 },
46
+ ];
47
+ expect(filterVmTemplates(guests)).toEqual([
48
+ { vmid: 9000, name: 'ubuntu-2404-cloudinit' },
49
+ { vmid: 9001, name: 'ubuntu-2204-cloudinit' },
50
+ ]);
51
+ });
52
+
53
+ test('returns an empty array when no guests are templates', () => {
54
+ expect(filterVmTemplates([{ vmid: 100, name: 'vm', template: 0 }])).toEqual([]);
55
+ });
56
+
57
+ test('returns an empty array for an empty listing', () => {
58
+ expect(filterVmTemplates([])).toEqual([]);
59
+ });
60
+ });
61
+
62
+ describe('selectIsoStorage — pick a storage that accepts iso content', () => {
63
+ test('returns the first active, enabled storage whose content includes iso', () => {
64
+ const storages = [
65
+ { storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 },
66
+ { storage: 'local', content: 'iso,vztmpl,backup', active: 1, enabled: 1 },
67
+ ];
68
+ expect(selectIsoStorage(storages)).toBe('local');
69
+ });
70
+
71
+ test('skips an iso-capable storage that is inactive', () => {
72
+ const storages = [
73
+ { storage: 'cold', content: 'iso', active: 0, enabled: 1 },
74
+ { storage: 'local', content: 'iso', active: 1, enabled: 1 },
75
+ ];
76
+ expect(selectIsoStorage(storages)).toBe('local');
77
+ });
78
+
79
+ test('skips an iso-capable storage that is disabled', () => {
80
+ const storages = [
81
+ { storage: 'off', content: 'iso', active: 1, enabled: 0 },
82
+ { storage: 'local', content: 'iso', active: 1, enabled: 1 },
83
+ ];
84
+ expect(selectIsoStorage(storages)).toBe('local');
85
+ });
86
+
87
+ test('returns null when no storage accepts iso content', () => {
88
+ const storages = [{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 }];
89
+ expect(selectIsoStorage(storages)).toBeNull();
90
+ });
91
+
92
+ test('returns null for an empty storage list', () => {
93
+ expect(selectIsoStorage([])).toBeNull();
94
+ });
95
+ });
96
+
97
+ describe('selectFreeVmid — first free VMID >= minVmid (template hygiene)', () => {
98
+ test('returns the default minimum (9000) when nothing is in use', () => {
99
+ expect(selectFreeVmid([])).toBe(9000);
100
+ });
101
+
102
+ test('returns 9000 when only low (IPAM-range) VMIDs are used', () => {
103
+ expect(selectFreeVmid([200, 201, 202])).toBe(9000);
104
+ });
105
+
106
+ test('skips a contiguous block of used template VMIDs', () => {
107
+ expect(selectFreeVmid([9000, 9001, 9002])).toBe(9003);
108
+ });
109
+
110
+ test('finds a gap inside the used set', () => {
111
+ // 9000 used, 9001 free → returns 9001
112
+ expect(selectFreeVmid([9000, 9002, 9003])).toBe(9001);
113
+ });
114
+
115
+ test('honors a custom minVmid floor', () => {
116
+ expect(selectFreeVmid([9000], 9500)).toBe(9500);
117
+ });
118
+
119
+ test('never returns a VMID below the floor even if low ones are free', () => {
120
+ // 100/101 are free but below the 9000 floor — must stay out of the IPAM range
121
+ expect(selectFreeVmid([9000])).toBe(9001);
122
+ });
123
+ });
124
+
125
+ describe('buildCloudImageVolid — volid for a cloud image on iso storage', () => {
126
+ test('places the filename under the storage iso/ namespace', () => {
127
+ expect(buildCloudImageVolid('local', 'noble-server-cloudimg-amd64.img')).toBe(
128
+ 'local:iso/noble-server-cloudimg-amd64.img',
129
+ );
130
+ });
131
+
132
+ test('works with an arbitrary storage name', () => {
133
+ expect(buildCloudImageVolid('nas-iso', 'jammy.img')).toBe('nas-iso:iso/jammy.img');
134
+ });
135
+ });
136
+
137
+ describe('pollTaskUntilDone — synchronous-task guard (null/empty UPID)', () => {
138
+ const creds = {
139
+ api_url: 'https://proxmox.invalid:8006/api2/json',
140
+ api_token_id: 'root@pam!test',
141
+ api_token_secret: 'unused',
142
+ };
143
+
144
+ test('resolves to OK immediately for an empty UPID without hitting the network', async () => {
145
+ // An empty UPID means the API call completed synchronously (e.g. converting
146
+ // a diskless VM to a template returns null data, not a worker UPID). The
147
+ // guard must short-circuit BEFORE any task-status lookup — proxmox.invalid
148
+ // would otherwise throw a DNS/connection error, so a clean 'OK' proves it
149
+ // never tried to poll.
150
+ expect(await pollTaskUntilDone(creds, 'pve', '')).toBe('OK');
151
+ });
152
+ });
@@ -558,12 +558,11 @@ export async function listAvailableTemplates(
558
558
  }
559
559
 
560
560
  /**
561
- * Make an authenticated POST request to the Proxmox API.
562
- * Shares connection/auth handling with makeProxmoxRequest; the only differences
563
- * are the verb and the form-encoded body.
561
+ * Make an authenticated form-encoded request (POST or PUT) to the Proxmox API.
564
562
  */
565
- async function makeProxmoxPost<T>(
563
+ async function makeProxmoxFormRequest<T>(
566
564
  credentials: ProxmoxCredentials,
565
+ method: 'POST' | 'PUT',
567
566
  path: string,
568
567
  params: Record<string, string>,
569
568
  ): Promise<ProxmoxResult<T>> {
@@ -576,7 +575,7 @@ async function makeProxmoxPost<T>(
576
575
  const postData = new URLSearchParams(params).toString();
577
576
 
578
577
  if (process.env.DEBUG) {
579
- console.log(`[Proxmox] POST: ${fullUrl}`);
578
+ console.log(`[Proxmox] ${method}: ${fullUrl}`);
580
579
  console.log(`[Proxmox] Body: ${postData}`);
581
580
  }
582
581
 
@@ -587,7 +586,7 @@ async function makeProxmoxPost<T>(
587
586
  hostname: url.hostname,
588
587
  port: url.port || 443,
589
588
  path: url.pathname,
590
- method: 'POST',
589
+ method,
591
590
  headers: {
592
591
  Authorization: authHeader,
593
592
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -605,7 +604,7 @@ async function makeProxmoxPost<T>(
605
604
 
606
605
  if (statusCode < 200 || statusCode >= 300) {
607
606
  if (process.env.DEBUG || statusCode >= 400) {
608
- console.error(`[Proxmox] POST ${path} failed (${statusCode}): ${body}`);
607
+ console.error(`[Proxmox] ${method} ${path} failed (${statusCode}): ${body}`);
609
608
  }
610
609
  resolve({
611
610
  success: false,
@@ -649,6 +648,14 @@ async function makeProxmoxPost<T>(
649
648
  });
650
649
  }
651
650
 
651
+ async function makeProxmoxPost<T>(
652
+ credentials: ProxmoxCredentials,
653
+ path: string,
654
+ params: Record<string, string>,
655
+ ): Promise<ProxmoxResult<T>> {
656
+ return makeProxmoxFormRequest(credentials, 'POST', path, params);
657
+ }
658
+
652
659
  /**
653
660
  * Entry from Proxmox's appliance catalog (`pveam available`). The `template`
654
661
  * field is the canonical filename (revision included) that should be passed to
@@ -742,3 +749,281 @@ export function buildTemplatePath(storageName: string, templateFilename: string)
742
749
  export function buildProxmoxApiUrl(ipAddress: string, port = 8006): string {
743
750
  return `https://${ipAddress}:${port}/api2/json`;
744
751
  }
752
+
753
+ // ── VM template build API ─────────────────────────────────────────────────────
754
+ // Used by proxmox-vm-template-build.ts to build a cloud-init template via the
755
+ // Proxmox API (no SSH to the node required).
756
+ //
757
+ // Version requirements (the build needs the HIGHER of the two — i.e. PVE 8.0+):
758
+ // - the storage `download-url` endpoint (downloadCloudImage) → PVE 7.2+
759
+ // - `import-from=` on a disk in the VM-create call → PVE 8.0+
760
+ // On PVE 7.x the create call rejects `import-from`; the older two-step
761
+ // `qm importdisk` flow would be needed there (not implemented — 8.x is current).
762
+ // @psbanka - 2026-06: revisit only if a 7.x node must be supported.
763
+
764
+ export interface ProxmoxVmTemplate {
765
+ vmid: number;
766
+ name: string;
767
+ }
768
+
769
+ /** Raw `/nodes/{node}/qemu` guest row (only the fields we read). */
770
+ interface ProxmoxQemuGuestRow {
771
+ vmid: number;
772
+ name: string;
773
+ template?: number;
774
+ }
775
+
776
+ /**
777
+ * Extract VM templates (guests with `template === 1`) from a `/nodes/{node}/qemu`
778
+ * listing. Pure filtering logic, split from the network call for testability
779
+ * (Rule 10) — mirrors findNodeForVmid.
780
+ */
781
+ export function filterVmTemplates(guests: ProxmoxQemuGuestRow[]): ProxmoxVmTemplate[] {
782
+ return guests.filter((vm) => vm.template === 1).map((vm) => ({ vmid: vm.vmid, name: vm.name }));
783
+ }
784
+
785
+ /** List existing VM templates on a node. */
786
+ export async function listVmTemplates(
787
+ credentials: ProxmoxCredentials,
788
+ nodeName: string,
789
+ ): Promise<ProxmoxResult<ProxmoxVmTemplate[]>> {
790
+ const result = await makeProxmoxRequest<ProxmoxQemuGuestRow[]>(
791
+ credentials,
792
+ `/nodes/${nodeName}/qemu`,
793
+ );
794
+ if (!result.success) return result;
795
+ return { success: true, data: filterVmTemplates(result.data) };
796
+ }
797
+
798
+ /** Storage row shape (subset) used when picking ISO-capable storage. */
799
+ type ProxmoxStorageRow = { storage: string; content: string; active: number; enabled: number };
800
+
801
+ /**
802
+ * Pick the first active, enabled storage that accepts `iso` content. Pure
803
+ * selection logic, split from the network call for testability (Rule 10).
804
+ * Returns the storage name, or null when none qualifies.
805
+ */
806
+ export function selectIsoStorage(storages: ProxmoxStorageRow[]): string | null {
807
+ const iso = storages.find((s) => s.active && s.enabled && s.content.includes('iso'));
808
+ return iso?.storage ?? null;
809
+ }
810
+
811
+ /**
812
+ * Find a storage on the node that accepts `iso` content (for downloading cloud
813
+ * images). Returns the storage name, or null if none found.
814
+ */
815
+ export async function findIsoStorage(
816
+ credentials: ProxmoxCredentials,
817
+ nodeName: string,
818
+ ): Promise<ProxmoxResult<string | null>> {
819
+ const result = await listNodeStorage(credentials, nodeName);
820
+ if (!result.success) return result;
821
+ return { success: true, data: selectIsoStorage(result.data) };
822
+ }
823
+
824
+ /**
825
+ * Pick the first free VMID >= minVmid given the set of in-use VMIDs. Pure
826
+ * selection logic, split from the network call for testability (Rule 10).
827
+ * Template VMIDs must stay outside celilo's IPAM range (200+) so they can
828
+ * never collide with deployed systems — hence minVmid defaults to 9000.
829
+ */
830
+ export function selectFreeVmid(usedVmids: Iterable<number>, minVmid = 9000): number {
831
+ const used = new Set(usedVmids);
832
+ let candidate = minVmid;
833
+ while (used.has(candidate)) {
834
+ candidate++;
835
+ }
836
+ return candidate;
837
+ }
838
+
839
+ /**
840
+ * Find a free VMID >= minVmid (default 9000) across the cluster.
841
+ * Template VMIDs must stay outside celilo's IPAM range (200+) so they can
842
+ * never collide with deployed systems.
843
+ */
844
+ export async function findFreeTemplateVmid(
845
+ credentials: ProxmoxCredentials,
846
+ _nodeName: string,
847
+ minVmid = 9000,
848
+ ): Promise<ProxmoxResult<number>> {
849
+ const result = await makeProxmoxRequest<Array<{ vmid?: number }>>(
850
+ credentials,
851
+ '/cluster/resources',
852
+ );
853
+ if (!result.success) return result;
854
+ const usedVmids = result.data
855
+ .map((r) => r.vmid)
856
+ .filter((vmid): vmid is number => typeof vmid === 'number');
857
+ return { success: true, data: selectFreeVmid(usedVmids, minVmid) };
858
+ }
859
+
860
+ /**
861
+ * Download a cloud image URL to ISO storage on the node. Returns a UPID for
862
+ * status polling. Requires PVE 7.2+ (`download-url` endpoint).
863
+ */
864
+ export async function downloadCloudImage(
865
+ credentials: ProxmoxCredentials,
866
+ nodeName: string,
867
+ isoStorage: string,
868
+ url: string,
869
+ filename: string,
870
+ ): Promise<ProxmoxResult<string>> {
871
+ return makeProxmoxPost<string>(
872
+ credentials,
873
+ `/nodes/${nodeName}/storage/${isoStorage}/download-url`,
874
+ { url, filename, content: 'iso' },
875
+ );
876
+ }
877
+
878
+ /**
879
+ * Poll a task UPID until it reaches `stopped` status. Resolves with the
880
+ * exitstatus string (typically `'OK'` on success). Throws on timeout or a
881
+ * non-OK exitstatus.
882
+ *
883
+ * An empty/missing UPID means the API call completed synchronously (some PVE
884
+ * endpoints — e.g. converting a diskless VM to a template — return `null` data
885
+ * instead of a worker UPID). There is nothing to poll, so treat it as an
886
+ * immediate success rather than encoding the empty string and 404-ing on the
887
+ * task-status lookup.
888
+ */
889
+ export async function pollTaskUntilDone(
890
+ credentials: ProxmoxCredentials,
891
+ nodeName: string,
892
+ upid: string,
893
+ timeoutMs = 600_000,
894
+ ): Promise<string> {
895
+ if (!upid) return 'OK';
896
+ const deadline = Date.now() + timeoutMs;
897
+ while (Date.now() < deadline) {
898
+ await new Promise<void>((r) => setTimeout(r, 3_000));
899
+ const status = await checkTaskStatus(credentials, nodeName, upid);
900
+ if (!status.success) throw new Error(`Task poll failed: ${status.message}`);
901
+ if (status.data.status === 'stopped') {
902
+ const exit = status.data.exitstatus ?? 'unknown';
903
+ if (exit !== 'OK') throw new Error(`Task ${upid} failed with exitstatus: ${exit}`);
904
+ return exit;
905
+ }
906
+ }
907
+ throw new Error(`Task ${upid} timed out after ${timeoutMs / 1000}s`);
908
+ }
909
+
910
+ /**
911
+ * Build the volid for a cloud image downloaded to ISO storage. Pure path
912
+ * construction, split out for testability (Rule 10) — mirrors buildTemplatePath.
913
+ * Proxmox's `download-url` with `content=iso` stores the `.img` under the
914
+ * storage's `iso/` namespace, so the volid is `<storage>:iso/<filename>`.
915
+ */
916
+ export function buildCloudImageVolid(isoStorage: string, filename: string): string {
917
+ return `${isoStorage}:iso/${filename}`;
918
+ }
919
+
920
+ export interface CreateVmFromCloudImageParams {
921
+ credentials: ProxmoxCredentials;
922
+ nodeName: string;
923
+ vmid: number;
924
+ name: string;
925
+ /** Volume ID of the downloaded cloud image, e.g. `local:iso/noble-server-cloudimg-amd64.img` */
926
+ isoVolid: string;
927
+ /** Storage for the imported VM disk and the cloud-init drive */
928
+ diskStorage: string;
929
+ }
930
+
931
+ /**
932
+ * Create a VM and import the cloud image as its boot disk in one API call.
933
+ * Also attaches the cloud-init CDROM drive so terraform can inject credentials
934
+ * at clone time. Returns a UPID — poll with pollTaskUntilDone.
935
+ *
936
+ * `import-from=` in the disk spec requires PVE 8.0+ (see the version note at the
937
+ * top of this section). The `net0` bridge is hardcoded to `vmbr0` (the Proxmox
938
+ * default); it only governs the template's own NIC, which terraform overrides
939
+ * with `$system:network.bridge` at clone time — so it never reaches a deployed
940
+ * guest. @psbanka - 2026-06: parameterize if a non-vmbr0 node ever blocks the
941
+ * template build itself.
942
+ */
943
+ export async function createVmFromCloudImage(
944
+ params: CreateVmFromCloudImageParams,
945
+ ): Promise<ProxmoxResult<string>> {
946
+ const { credentials, nodeName, vmid, name, isoVolid, diskStorage } = params;
947
+ return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu`, {
948
+ vmid: String(vmid),
949
+ name,
950
+ memory: '2048',
951
+ cores: '2',
952
+ scsihw: 'virtio-scsi-pci',
953
+ net0: 'virtio,bridge=vmbr0',
954
+ agent: 'enabled=1',
955
+ serial0: 'socket',
956
+ vga: 'serial0',
957
+ boot: 'order=scsi0',
958
+ scsi0: `${diskStorage}:0,import-from=${isoVolid}`,
959
+ ide2: `${diskStorage}:cloudinit`,
960
+ });
961
+ }
962
+
963
+ /**
964
+ * Convert an existing VM to a template. Returns a UPID — the VM must be
965
+ * powered off. Poll with pollTaskUntilDone.
966
+ */
967
+ export async function convertVmToTemplate(
968
+ credentials: ProxmoxCredentials,
969
+ nodeName: string,
970
+ vmid: number,
971
+ ): Promise<ProxmoxResult<string>> {
972
+ return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu/${vmid}/template`, {});
973
+ }
974
+
975
+ /**
976
+ * Delete a VM (cleanup on build failure). Returns a UPID.
977
+ * Passes `purge=1` so storage volumes are also removed.
978
+ */
979
+ export async function deleteVm(
980
+ credentials: ProxmoxCredentials,
981
+ nodeName: string,
982
+ vmid: number,
983
+ ): Promise<ProxmoxResult<string>> {
984
+ return new Promise((resolve) => {
985
+ try {
986
+ const { api_url, api_token_id, api_token_secret } = credentials;
987
+ const authHeader = `PVEAPIToken=${api_token_id}=${api_token_secret}`;
988
+ const fullUrl = `${api_url}/nodes/${nodeName}/qemu/${vmid}`;
989
+ const url = new URL(fullUrl);
990
+ const agent = new https.Agent({ rejectUnauthorized: false });
991
+ const req = https.request(
992
+ {
993
+ hostname: url.hostname,
994
+ port: url.port || 443,
995
+ path: `${url.pathname}?purge=1`,
996
+ method: 'DELETE',
997
+ headers: { Authorization: authHeader },
998
+ agent,
999
+ },
1000
+ (res) => {
1001
+ let body = '';
1002
+ res.on('data', (chunk) => {
1003
+ body += chunk;
1004
+ });
1005
+ res.on('end', () => {
1006
+ const statusCode = res.statusCode || 0;
1007
+ if (statusCode < 200 || statusCode >= 300) {
1008
+ resolve({
1009
+ success: false,
1010
+ message: `Delete failed with status ${statusCode}: ${body}`,
1011
+ });
1012
+ return;
1013
+ }
1014
+ try {
1015
+ const data = JSON.parse(body) as ProxmoxApiResponse<string>;
1016
+ resolve({ success: true, data: data.data });
1017
+ } catch {
1018
+ resolve({ success: true, data: '' });
1019
+ }
1020
+ });
1021
+ },
1022
+ );
1023
+ req.on('error', (error) => resolve({ success: false, message: error.message }));
1024
+ req.end();
1025
+ } catch (error) {
1026
+ resolve({ success: false, message: error instanceof Error ? error.message : String(error) });
1027
+ }
1028
+ });
1029
+ }
@@ -165,20 +165,20 @@ describe('celilo events command handlers', () => {
165
165
  });
166
166
  setupBus.close();
167
167
 
168
- const res = await handleEventsReply([String(query.id), '"lunacycle.net"'], {});
168
+ const res = await handleEventsReply([String(query.id), '"example.net"'], {});
169
169
  expect(res.success).toBe(true);
170
170
  if (!res.success) throw new Error('expected success');
171
171
  const data = res.data as { status: string; family: string; value: unknown };
172
172
  expect(data.status).toBe('replied');
173
173
  expect(data.family).toBe('config');
174
- expect(data.value).toBe('lunacycle.net');
174
+ expect(data.value).toBe('example.net');
175
175
 
176
176
  const checkBus = openBus({ dbPath, events: defineEvents({}) });
177
177
  const replies = checkBus.recentEvents({ type: 'config.required.lunacycle.domain.reply' });
178
178
  checkBus.close();
179
179
  expect(replies).toHaveLength(1);
180
180
  expect(replies[0].replyFor).toBe(query.id);
181
- expect(replies[0].payload).toEqual({ value: 'lunacycle.net' });
181
+ expect(replies[0].payload).toEqual({ value: 'example.net' });
182
182
  expect(replies[0].emittedBy).toBe('claude-config-responder');
183
183
  });
184
184
 
@@ -229,7 +229,7 @@ describe('celilo events command handlers', () => {
229
229
  setupBus.close();
230
230
 
231
231
  // A bare word isn't valid JSON — the operator must quote strings.
232
- const res = await handleEventsReply([String(query.id), 'lunacycle.net'], {});
232
+ const res = await handleEventsReply([String(query.id), 'example.net'], {});
233
233
  expect(res.success).toBe(false);
234
234
  if (res.success) throw new Error('expected failure');
235
235
  expect(res.error).toContain('Invalid JSON value');
@@ -439,7 +439,7 @@ export async function handleEventsReply(
439
439
  return {
440
440
  success: false,
441
441
  error: `Usage: celilo events reply <query-event-id> <value-json>
442
- e.g. celilo events reply 42 '"lunacycle.net"'`,
442
+ e.g. celilo events reply 42 '"example.net"'`,
443
443
  };
444
444
  }
445
445
  const queryId = Number(idArg);
@@ -454,7 +454,7 @@ export async function handleEventsReply(
454
454
  return {
455
455
  success: false,
456
456
  error: `Invalid JSON value: ${err instanceof Error ? err.message : String(err)}
457
- Encode the answer as JSON, e.g. '"lunacycle.net"', '8080', '["a","b"]'.`,
457
+ Encode the answer as JSON, e.g. '"example.net"', '8080', '["a","b"]'.`,
458
458
  };
459
459
  }
460
460