@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,13 @@
1
+ CREATE TABLE `dns_registrations` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `provider_module_id` text NOT NULL,
4
+ `consumer_module_id` text NOT NULL,
5
+ `fqdn` text NOT NULL,
6
+ `ip` text,
7
+ `registered_at` integer DEFAULT (unixepoch()) NOT NULL,
8
+ `refreshed_at` integer,
9
+ FOREIGN KEY (`provider_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade,
10
+ FOREIGN KEY (`consumer_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE UNIQUE INDEX `dns_registrations_provider_fqdn_idx` ON `dns_registrations` (`provider_module_id`,`fqdn`);
@@ -0,0 +1,12 @@
1
+ CREATE TABLE `dns_internal_records` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `provider_module_id` text NOT NULL,
4
+ `consumer_module_id` text NOT NULL,
5
+ `host` text NOT NULL,
6
+ `ip` text NOT NULL,
7
+ `registered_at` integer DEFAULT (unixepoch()) NOT NULL,
8
+ FOREIGN KEY (`provider_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade,
9
+ FOREIGN KEY (`consumer_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
10
+ );
11
+ --> statement-breakpoint
12
+ CREATE UNIQUE INDEX `dns_internal_records_provider_host_idx` ON `dns_internal_records` (`provider_module_id`,`host`);
@@ -0,0 +1 @@
1
+ ALTER TABLE `backups` ADD `name` text;
@@ -64,6 +64,27 @@
64
64
  "when": 1781064000000,
65
65
  "tag": "0008_aspect_consent",
66
66
  "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "6",
71
+ "when": 1781280898000,
72
+ "tag": "0009_dns_registrations",
73
+ "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "6",
78
+ "when": 1781481600000,
79
+ "tag": "0010_dns_internal_records",
80
+ "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "6",
85
+ "when": 1781481660000,
86
+ "tag": "0011_backups_name",
87
+ "breakpoints": true
67
88
  }
68
89
  ]
69
- }
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.5.0-alpha.1",
3
+ "version": "0.5.0-alpha.11",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,9 +52,9 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
- "@celilo/capabilities": "^0.3.0",
55
+ "@celilo/capabilities": "^0.4.2",
56
56
  "@celilo/cli-display": "^0.1.9",
57
- "@celilo/event-bus": "^0.1.5",
57
+ "@celilo/event-bus": "^0.1.7",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
60
60
  "drizzle-orm": "^0.36.4",
@@ -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,14 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { findNodeForVmid } from './proxmox';
2
+ import {
3
+ type ProxmoxClusterResource,
4
+ buildCloudImageVolid,
5
+ filterVmTemplates,
6
+ findNodeForVmid,
7
+ pollTaskUntilDone,
8
+ selectFreeVmid,
9
+ selectImportStorage,
10
+ summarizeNodeCapacities,
11
+ } from './proxmox';
3
12
 
4
13
  describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current location)', () => {
5
14
  // A trimmed /cluster/resources payload: guest rows carry a vmid; node/storage
@@ -28,3 +37,204 @@ describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current l
28
37
  expect(findNodeForVmid([], 200)).toBeNull();
29
38
  });
30
39
  });
40
+
41
+ describe('summarizeNodeCapacities (ISS-0060 — per-node capacity reconciled from Proxmox)', () => {
42
+ const GB = 1024 * 1024 * 1024;
43
+ const resources: ProxmoxClusterResource[] = [
44
+ {
45
+ type: 'node',
46
+ node: 'node3',
47
+ status: 'online',
48
+ maxmem: 16 * GB,
49
+ mem: 4 * GB,
50
+ maxcpu: 8,
51
+ cpu: 0.25,
52
+ maxdisk: 200 * GB,
53
+ disk: 50 * GB,
54
+ uptime: 3600,
55
+ },
56
+ {
57
+ type: 'node',
58
+ node: 'node2',
59
+ status: 'offline',
60
+ maxmem: 8 * GB,
61
+ mem: 7 * GB,
62
+ maxcpu: 4,
63
+ cpu: 0.9,
64
+ maxdisk: 100 * GB,
65
+ disk: 90 * GB,
66
+ uptime: 0,
67
+ },
68
+ // Guest + storage rows must be ignored.
69
+ { type: 'lxc', vmid: 200, node: 'node2', status: 'running' },
70
+ { type: 'storage', node: 'node3', status: 'available' },
71
+ ];
72
+
73
+ test('derives free RAM/CPU/disk and online state from node rows only', () => {
74
+ const caps = summarizeNodeCapacities(resources);
75
+ expect(caps).toHaveLength(2); // guest + storage rows dropped
76
+ // Sorted by node name → node2 first.
77
+ expect(caps[0]).toEqual({
78
+ node: 'node2',
79
+ online: false,
80
+ memTotalMb: 8192,
81
+ memFreeMb: 1024,
82
+ cpuCores: 4,
83
+ cpuUsedPct: 90,
84
+ diskTotalGb: 100,
85
+ diskFreeGb: 10,
86
+ uptimeSec: 0,
87
+ });
88
+ expect(caps[1]).toMatchObject({
89
+ node: 'node3',
90
+ online: true,
91
+ memFreeMb: 12288,
92
+ cpuUsedPct: 25,
93
+ diskFreeGb: 150,
94
+ });
95
+ });
96
+
97
+ test('tolerates missing fields (treats absent capacity as 0, offline unless status==online)', () => {
98
+ const caps = summarizeNodeCapacities([{ type: 'node', node: 'bare' }]);
99
+ expect(caps[0]).toEqual({
100
+ node: 'bare',
101
+ online: false,
102
+ memTotalMb: 0,
103
+ memFreeMb: 0,
104
+ cpuCores: 0,
105
+ cpuUsedPct: 0,
106
+ diskTotalGb: 0,
107
+ diskFreeGb: 0,
108
+ uptimeSec: 0,
109
+ });
110
+ });
111
+
112
+ test('empty inventory → no nodes', () => {
113
+ expect(summarizeNodeCapacities([])).toEqual([]);
114
+ });
115
+ });
116
+
117
+ describe('filterVmTemplates — extract VM templates from a /nodes/{node}/qemu listing', () => {
118
+ test('keeps only guests flagged template===1 and projects to {vmid, name}', () => {
119
+ const guests = [
120
+ { vmid: 100, name: 'running-vm' }, // no template flag → a live VM
121
+ { vmid: 9000, name: 'ubuntu-2404-cloudinit', template: 1 },
122
+ { vmid: 201, name: 'another-vm', template: 0 }, // explicitly not a template
123
+ { vmid: 9001, name: 'ubuntu-2204-cloudinit', template: 1 },
124
+ ];
125
+ expect(filterVmTemplates(guests)).toEqual([
126
+ { vmid: 9000, name: 'ubuntu-2404-cloudinit' },
127
+ { vmid: 9001, name: 'ubuntu-2204-cloudinit' },
128
+ ]);
129
+ });
130
+
131
+ test('returns an empty array when no guests are templates', () => {
132
+ expect(filterVmTemplates([{ vmid: 100, name: 'vm', template: 0 }])).toEqual([]);
133
+ });
134
+
135
+ test('returns an empty array for an empty listing', () => {
136
+ expect(filterVmTemplates([])).toEqual([]);
137
+ });
138
+ });
139
+
140
+ describe('selectImportStorage — pick a storage that accepts import content', () => {
141
+ test('returns the first active, enabled storage whose content includes import', () => {
142
+ const storages = [
143
+ { storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 },
144
+ { storage: 'local', content: 'import,vztmpl,backup', active: 1, enabled: 1 },
145
+ ];
146
+ expect(selectImportStorage(storages)).toBe('local');
147
+ });
148
+
149
+ test('does NOT pick an iso-only storage (import-from rejects iso content)', () => {
150
+ // Regression guard: the cloud image must land in the import namespace, not
151
+ // iso/, or the VM-create `import-from=` fails with `has wrong type 'iso'`.
152
+ const storages = [
153
+ { storage: 'iso-only', content: 'iso,vztmpl,backup', active: 1, enabled: 1 },
154
+ { storage: 'data', content: 'import,images', active: 1, enabled: 1 },
155
+ ];
156
+ expect(selectImportStorage(storages)).toBe('data');
157
+ });
158
+
159
+ test('skips an import-capable storage that is inactive', () => {
160
+ const storages = [
161
+ { storage: 'cold', content: 'import', active: 0, enabled: 1 },
162
+ { storage: 'local', content: 'import', active: 1, enabled: 1 },
163
+ ];
164
+ expect(selectImportStorage(storages)).toBe('local');
165
+ });
166
+
167
+ test('skips an import-capable storage that is disabled', () => {
168
+ const storages = [
169
+ { storage: 'off', content: 'import', active: 1, enabled: 0 },
170
+ { storage: 'local', content: 'import', active: 1, enabled: 1 },
171
+ ];
172
+ expect(selectImportStorage(storages)).toBe('local');
173
+ });
174
+
175
+ test('returns null when no storage accepts import content', () => {
176
+ const storages = [{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 }];
177
+ expect(selectImportStorage(storages)).toBeNull();
178
+ });
179
+
180
+ test('returns null for an empty storage list', () => {
181
+ expect(selectImportStorage([])).toBeNull();
182
+ });
183
+ });
184
+
185
+ describe('selectFreeVmid — first free VMID >= minVmid (template hygiene)', () => {
186
+ test('returns the default minimum (9000) when nothing is in use', () => {
187
+ expect(selectFreeVmid([])).toBe(9000);
188
+ });
189
+
190
+ test('returns 9000 when only low (IPAM-range) VMIDs are used', () => {
191
+ expect(selectFreeVmid([200, 201, 202])).toBe(9000);
192
+ });
193
+
194
+ test('skips a contiguous block of used template VMIDs', () => {
195
+ expect(selectFreeVmid([9000, 9001, 9002])).toBe(9003);
196
+ });
197
+
198
+ test('finds a gap inside the used set', () => {
199
+ // 9000 used, 9001 free → returns 9001
200
+ expect(selectFreeVmid([9000, 9002, 9003])).toBe(9001);
201
+ });
202
+
203
+ test('honors a custom minVmid floor', () => {
204
+ expect(selectFreeVmid([9000], 9500)).toBe(9500);
205
+ });
206
+
207
+ test('never returns a VMID below the floor even if low ones are free', () => {
208
+ // 100/101 are free but below the 9000 floor — must stay out of the IPAM range
209
+ expect(selectFreeVmid([9000])).toBe(9001);
210
+ });
211
+ });
212
+
213
+ describe('buildCloudImageVolid — volid for a cloud image on import storage', () => {
214
+ test('places the filename under the storage import/ namespace', () => {
215
+ expect(buildCloudImageVolid('local', 'noble-server-cloudimg-amd64.img')).toBe(
216
+ 'local:import/noble-server-cloudimg-amd64.img',
217
+ );
218
+ });
219
+
220
+ test('works with an arbitrary storage name', () => {
221
+ expect(buildCloudImageVolid('nas', 'jammy.img')).toBe('nas:import/jammy.img');
222
+ });
223
+ });
224
+
225
+ describe('pollTaskUntilDone — synchronous-task guard (null/empty UPID)', () => {
226
+ const creds = {
227
+ api_url: 'https://proxmox.invalid:8006/api2/json',
228
+ api_token_id: 'root@pam!test',
229
+ api_token_secret: 'unused',
230
+ };
231
+
232
+ test('resolves to OK immediately for an empty UPID without hitting the network', async () => {
233
+ // An empty UPID means the API call completed synchronously (e.g. converting
234
+ // a diskless VM to a template returns null data, not a worker UPID). The
235
+ // guard must short-circuit BEFORE any task-status lookup — proxmox.invalid
236
+ // would otherwise throw a DNS/connection error, so a clean 'OK' proves it
237
+ // never tried to poll.
238
+ expect(await pollTaskUntilDone(creds, 'pve', '')).toBe('OK');
239
+ });
240
+ });