@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.
- package/drizzle/0009_dns_registrations.sql +13 -0
- package/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +3 -3
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/api-clients/proxmox.test.ts +211 -1
- package/src/api-clients/proxmox.ts +399 -8
- package/src/cli/command-registry.ts +83 -6
- package/src/cli/commands/backup-delete.ts +10 -7
- package/src/cli/commands/backup-import.ts +11 -8
- package/src/cli/commands/backup-restore.ts +11 -8
- package/src/cli/commands/dns.ts +57 -0
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +89 -24
- package/src/cli/commands/machine-add.ts +178 -163
- package/src/cli/commands/machine-remove.ts +10 -7
- package/src/cli/commands/module-config.test.ts +78 -0
- package/src/cli/commands/module-config.ts +18 -3
- package/src/cli/commands/module-import.ts +9 -5
- package/src/cli/commands/module-publish.ts +24 -0
- package/src/cli/commands/module-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.test.ts +37 -0
- package/src/cli/commands/module-upgrade.ts +26 -6
- package/src/cli/commands/proxmox-node-list.ts +101 -0
- package/src/cli/commands/proxmox-template-selection.ts +16 -15
- package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
- package/src/cli/commands/publish/alpha.test.ts +26 -0
- package/src/cli/commands/publish/alpha.ts +23 -0
- package/src/cli/commands/publish/types.ts +7 -2
- package/src/cli/commands/publish/workspace.ts +11 -1
- package/src/cli/commands/restore.ts +29 -0
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +283 -209
- package/src/cli/commands/service-reconfigure.test.ts +115 -0
- package/src/cli/commands/service-reconfigure.ts +252 -129
- package/src/cli/commands/service-remove.ts +19 -13
- package/src/cli/commands/service-verify.ts +9 -10
- package/src/cli/commands/storage-add-local.ts +120 -107
- package/src/cli/commands/storage-add-s3.ts +145 -131
- package/src/cli/commands/storage-remove.ts +11 -8
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-init.ts +119 -128
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +23 -0
- package/src/cli/index.ts +91 -7
- package/src/cli/service-credential.ts +54 -0
- package/src/config/paths.test.ts +61 -48
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +74 -0
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +43 -2
- package/src/hooks/run-named-hook.ts +28 -2
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +17 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-interview.ts +232 -0
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/deploy-validation.test.ts +54 -4
- package/src/services/deploy-validation.ts +27 -36
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-provider-backfill.ts +14 -2
- package/src/services/dns-registrations.test.ts +120 -0
- package/src/services/dns-registrations.ts +108 -0
- package/src/services/events-daemon.test.ts +59 -0
- package/src/services/events-daemon.ts +191 -57
- package/src/services/fleet-checks.test.ts +508 -0
- package/src/services/fleet-checks.ts +678 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +7 -6
- package/src/services/module-subscriptions.test.ts +88 -0
- package/src/services/module-subscriptions.ts +50 -1
- package/src/services/module-validator/bundled-deps.test.ts +55 -0
- package/src/services/module-validator/bundled-deps.ts +115 -0
- package/src/services/module-validator/capability-versions.test.ts +1 -1
- package/src/services/placement-reconcile.test.ts +86 -0
- package/src/services/placement-reconcile.ts +108 -0
- package/src/services/programmatic-responder.ts +34 -0
- package/src/services/terminal-responder.ts +113 -0
- package/src/templates/generator.test.ts +92 -12
- package/src/templates/generator.ts +165 -80
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- 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.
|
|
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.
|
|
55
|
+
"@celilo/capabilities": "^0.4.2",
|
|
56
56
|
"@celilo/cli-display": "^0.1.9",
|
|
57
|
-
"@celilo/event-bus": "^0.1.
|
|
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: '
|
|
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=
|
|
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: '
|
|
140
|
-
{ name: 'www', type: 'A', value: '
|
|
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:
|
|
149
|
+
expect(result).toContain('value: 192.0.2.20');
|
|
150
150
|
expect(result).toContain('- name: www');
|
|
151
|
-
expect(result).toContain('value:
|
|
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: '
|
|
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: '
|
|
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', '
|
|
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('
|
|
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(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
|
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
|
+
});
|