@celilo/cli 0.5.0-alpha.6 → 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.
- package/package.json +2 -2
- package/src/api-clients/proxmox.test.ts +123 -1
- package/src/api-clients/proxmox.ts +292 -7
- package/src/cli/commands/proxmox-vm-template-build.ts +164 -0
- package/src/cli/commands/service-add-proxmox.ts +61 -11
- package/src/cli/commands/service-reconfigure.test.ts +115 -0
- package/src/cli/commands/service-reconfigure.ts +110 -5
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.7",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
55
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.6",
|
|
58
58
|
"@clack/prompts": "^1.1.0",
|
|
59
59
|
"ajv": "^8.18.0",
|
|
60
60
|
"drizzle-orm": "^0.36.4",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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]
|
|
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
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a cloud-init Ubuntu VM template on Proxmox via the API (no SSH needed).
|
|
3
|
+
* Called from service-add-proxmox when the operator opts in.
|
|
4
|
+
*
|
|
5
|
+
* Steps:
|
|
6
|
+
* 1. Find ISO-capable storage on the target node.
|
|
7
|
+
* 2. Find a free template VMID (>= 9000, outside celilo's IPAM range).
|
|
8
|
+
* 3. Download Ubuntu 24.04 cloud image to ISO storage. (~2–5 min)
|
|
9
|
+
* 4. Create a VM with the cloud image imported as its boot disk + a cloud-init
|
|
10
|
+
* CDROM drive. (~30s)
|
|
11
|
+
* 5. Convert the VM to a template. (~5s)
|
|
12
|
+
*
|
|
13
|
+
* The finished template is a blank cloud-init base — no credentials, IP, or SSH
|
|
14
|
+
* keys baked in. celilo's terraform sets those at clone time (ipconfig0, sshkeys,
|
|
15
|
+
* ciuser, nameserver). Requires PVE 8.0+ (the storage download-url endpoint is
|
|
16
|
+
* 7.2+, but importing the disk via `import-from` in the VM-create call is 8.0+,
|
|
17
|
+
* so the build as a whole needs 8.0+).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ProxmoxCredentials, ProxmoxVmTemplate } from '../../api-clients/proxmox';
|
|
21
|
+
import {
|
|
22
|
+
buildCloudImageVolid,
|
|
23
|
+
convertVmToTemplate,
|
|
24
|
+
createVmFromCloudImage,
|
|
25
|
+
deleteVm,
|
|
26
|
+
downloadCloudImage,
|
|
27
|
+
findFreeTemplateVmid,
|
|
28
|
+
findIsoStorage,
|
|
29
|
+
listVmTemplates,
|
|
30
|
+
pollTaskUntilDone,
|
|
31
|
+
} from '../../api-clients/proxmox';
|
|
32
|
+
import { FuelGauge } from '../fuel-gauge';
|
|
33
|
+
|
|
34
|
+
const UBUNTU_2404_AMD64_URL =
|
|
35
|
+
'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img';
|
|
36
|
+
const UBUNTU_2404_AMD64_FILENAME = 'noble-server-cloudimg-amd64.img';
|
|
37
|
+
const DEFAULT_TEMPLATE_NAME = 'ubuntu-2404-cloudinit';
|
|
38
|
+
|
|
39
|
+
export interface VmTemplateBuildParams {
|
|
40
|
+
credentials: ProxmoxCredentials;
|
|
41
|
+
nodeName: string;
|
|
42
|
+
diskStorage: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* List existing VM templates on the node. Returns an empty array (not an error)
|
|
47
|
+
* if the API call fails — the caller treats failure as "no templates found".
|
|
48
|
+
*/
|
|
49
|
+
export async function detectExistingVmTemplates(
|
|
50
|
+
credentials: ProxmoxCredentials,
|
|
51
|
+
nodeName: string,
|
|
52
|
+
): Promise<ProxmoxVmTemplate[]> {
|
|
53
|
+
const result = await listVmTemplates(credentials, nodeName);
|
|
54
|
+
return result.success ? result.data : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a cloud-init Ubuntu 24.04 VM template on the Proxmox node.
|
|
59
|
+
* Shows a FuelGauge progress indicator — takes 2–10 min depending on the
|
|
60
|
+
* operator's internet connection (image download is ~600 MB).
|
|
61
|
+
* Returns the template name (e.g. "ubuntu-2404-cloudinit").
|
|
62
|
+
* Cleans up the VM on failure so the operator isn't left with a partial guest.
|
|
63
|
+
*/
|
|
64
|
+
export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Promise<string> {
|
|
65
|
+
const { credentials, nodeName, diskStorage } = params;
|
|
66
|
+
const gauge = new FuelGauge('Building cloud-init VM template');
|
|
67
|
+
gauge.start();
|
|
68
|
+
|
|
69
|
+
let vmid: number | null = null;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// Step 1: find ISO storage
|
|
73
|
+
gauge.addOutput('Finding ISO-capable storage on the node…');
|
|
74
|
+
const isoStorageResult = await findIsoStorage(credentials, nodeName);
|
|
75
|
+
if (!isoStorageResult.success) {
|
|
76
|
+
throw new Error(`Could not list node storage: ${isoStorageResult.message}`);
|
|
77
|
+
}
|
|
78
|
+
const isoStorage = isoStorageResult.data;
|
|
79
|
+
if (!isoStorage) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'No ISO-capable storage found on the node. ' +
|
|
82
|
+
"Enable 'iso' content on a storage (typically 'local') in the Proxmox UI: " +
|
|
83
|
+
'Datacenter → Storage → Edit → Content.',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
gauge.addOutput(`ISO storage: ${isoStorage}`);
|
|
87
|
+
|
|
88
|
+
// Step 2: find a free template VMID
|
|
89
|
+
gauge.addOutput('Finding a free template VMID (>= 9000)…');
|
|
90
|
+
const vmidResult = await findFreeTemplateVmid(credentials, nodeName);
|
|
91
|
+
if (!vmidResult.success) throw new Error(`VMID lookup failed: ${vmidResult.message}`);
|
|
92
|
+
vmid = vmidResult.data;
|
|
93
|
+
gauge.addOutput(`Using VMID ${vmid}`);
|
|
94
|
+
|
|
95
|
+
// Step 3: download cloud image to ISO storage
|
|
96
|
+
gauge.addOutput(
|
|
97
|
+
`Downloading Ubuntu 24.04 cloud image to '${isoStorage}' (~600 MB, may take several minutes)…`,
|
|
98
|
+
);
|
|
99
|
+
const downloadResult = await downloadCloudImage(
|
|
100
|
+
credentials,
|
|
101
|
+
nodeName,
|
|
102
|
+
isoStorage,
|
|
103
|
+
UBUNTU_2404_AMD64_URL,
|
|
104
|
+
UBUNTU_2404_AMD64_FILENAME,
|
|
105
|
+
);
|
|
106
|
+
if (!downloadResult.success) {
|
|
107
|
+
if (downloadResult.message.includes('403') || downloadResult.message.includes('not found')) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Proxmox storage download-url endpoint rejected the request. This endpoint requires Proxmox VE 7.2+. Error: ${downloadResult.message}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`Cloud image download failed: ${downloadResult.message}`);
|
|
113
|
+
}
|
|
114
|
+
gauge.addOutput('Waiting for download to complete…');
|
|
115
|
+
await pollTaskUntilDone(credentials, nodeName, downloadResult.data, 600_000);
|
|
116
|
+
gauge.addOutput('Cloud image downloaded');
|
|
117
|
+
|
|
118
|
+
// Step 4: create VM from cloud image
|
|
119
|
+
const isoVolid = buildCloudImageVolid(isoStorage, UBUNTU_2404_AMD64_FILENAME);
|
|
120
|
+
gauge.addOutput(`Creating VM ${vmid} '${DEFAULT_TEMPLATE_NAME}' (importing disk)…`);
|
|
121
|
+
const createResult = await createVmFromCloudImage({
|
|
122
|
+
credentials,
|
|
123
|
+
nodeName,
|
|
124
|
+
vmid,
|
|
125
|
+
name: DEFAULT_TEMPLATE_NAME,
|
|
126
|
+
isoVolid,
|
|
127
|
+
diskStorage,
|
|
128
|
+
});
|
|
129
|
+
if (!createResult.success) {
|
|
130
|
+
throw new Error(`VM creation failed: ${createResult.message}`);
|
|
131
|
+
}
|
|
132
|
+
gauge.addOutput('Waiting for disk import to complete…');
|
|
133
|
+
await pollTaskUntilDone(credentials, nodeName, createResult.data, 120_000);
|
|
134
|
+
gauge.addOutput('VM created');
|
|
135
|
+
|
|
136
|
+
// Step 5: convert to template
|
|
137
|
+
gauge.addOutput(`Converting VM ${vmid} to a template…`);
|
|
138
|
+
const templateResult = await convertVmToTemplate(credentials, nodeName, vmid);
|
|
139
|
+
if (!templateResult.success) {
|
|
140
|
+
throw new Error(`Convert-to-template failed: ${templateResult.message}`);
|
|
141
|
+
}
|
|
142
|
+
await pollTaskUntilDone(credentials, nodeName, templateResult.data, 60_000);
|
|
143
|
+
|
|
144
|
+
gauge.stop(true);
|
|
145
|
+
return DEFAULT_TEMPLATE_NAME;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
gauge.stop(false);
|
|
148
|
+
|
|
149
|
+
// Best-effort cleanup — remove the partially-created VM so the operator
|
|
150
|
+
// isn't left with a stranded guest.
|
|
151
|
+
if (vmid !== null) {
|
|
152
|
+
try {
|
|
153
|
+
const del = await deleteVm(credentials, nodeName, vmid);
|
|
154
|
+
if (del.success) {
|
|
155
|
+
console.log(`✗ Cleaned up VM ${vmid} after build failure`);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
console.log(`⚠ Could not clean up VM ${vmid} — delete it manually in the Proxmox UI`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
runApplianceDownload,
|
|
24
24
|
selectUbuntuApplianceFromCatalog,
|
|
25
25
|
} from './proxmox-template-selection';
|
|
26
|
+
import { buildCloudInitTemplate, detectExistingVmTemplates } from './proxmox-vm-template-build';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Handle service add proxmox command
|
|
@@ -122,22 +123,71 @@ export async function handleServiceAddProxmox(
|
|
|
122
123
|
validate: validateRequired('Storage'),
|
|
123
124
|
});
|
|
124
125
|
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
const vmTemplateInput = await promptText({
|
|
128
|
-
message: 'VM template name (optional — for VM-type modules):',
|
|
129
|
-
placeholder: 'e.g., ubuntu-2404-cloud-init-9000',
|
|
130
|
-
});
|
|
131
|
-
const vmTemplate = vmTemplateInput?.trim() || undefined;
|
|
132
|
-
|
|
133
|
-
// Find storage that supports vztmpl content. We do this BEFORE prompting
|
|
134
|
-
// for a template so the user only ever sees one storage in subsequent
|
|
135
|
-
// messages, and so the saved volid uses the right storage.
|
|
126
|
+
// Build credentials here — needed for VM template detection below and
|
|
127
|
+
// for LXC template catalog lookup further down.
|
|
136
128
|
const credentials = {
|
|
137
129
|
api_url: apiUrl,
|
|
138
130
|
api_token_id: apiTokenId,
|
|
139
131
|
api_token_secret: apiTokenSecret,
|
|
140
132
|
};
|
|
133
|
+
|
|
134
|
+
// VM template for `requires.system.type: vm` modules.
|
|
135
|
+
// Auto-detect existing templates on the node; offer to build if none found.
|
|
136
|
+
// See v2/PROXMOX_VM_TEMPLATE.md.
|
|
137
|
+
let vmTemplate: string | undefined;
|
|
138
|
+
const existingTemplates = await detectExistingVmTemplates(credentials, targetNode);
|
|
139
|
+
if (existingTemplates.length > 0) {
|
|
140
|
+
const templateChoice = await p.select({
|
|
141
|
+
message: 'VM template for VM-type modules:',
|
|
142
|
+
options: [
|
|
143
|
+
...existingTemplates.map((t) => ({
|
|
144
|
+
value: t.name,
|
|
145
|
+
label: `${t.name} (VMID ${t.vmid})`,
|
|
146
|
+
})),
|
|
147
|
+
{ value: '__build__', label: 'Build a new Ubuntu 24.04 cloud-init template now' },
|
|
148
|
+
{ value: '__skip__', label: 'Skip — I only need LXC modules' },
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
if (p.isCancel(templateChoice)) {
|
|
152
|
+
p.cancel('Operation cancelled');
|
|
153
|
+
return { success: false, error: 'Cancelled by user' };
|
|
154
|
+
}
|
|
155
|
+
if (templateChoice === '__build__') {
|
|
156
|
+
vmTemplate = await buildCloudInitTemplate({
|
|
157
|
+
credentials,
|
|
158
|
+
nodeName: targetNode,
|
|
159
|
+
diskStorage: storage,
|
|
160
|
+
});
|
|
161
|
+
} else if (templateChoice !== '__skip__') {
|
|
162
|
+
vmTemplate = templateChoice;
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
const shouldBuild = await p.confirm({
|
|
166
|
+
message: 'No VM templates found. Build a Ubuntu 24.04 cloud-init template now? (~2–10 min)',
|
|
167
|
+
initialValue: false,
|
|
168
|
+
});
|
|
169
|
+
if (p.isCancel(shouldBuild)) {
|
|
170
|
+
p.cancel('Operation cancelled');
|
|
171
|
+
return { success: false, error: 'Cancelled by user' };
|
|
172
|
+
}
|
|
173
|
+
if (shouldBuild) {
|
|
174
|
+
vmTemplate = await buildCloudInitTemplate({
|
|
175
|
+
credentials,
|
|
176
|
+
nodeName: targetNode,
|
|
177
|
+
diskStorage: storage,
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
const manualEntry = await promptText({
|
|
181
|
+
message: 'VM template name (optional — leave blank to skip):',
|
|
182
|
+
placeholder: 'e.g., ubuntu-2404-cloudinit',
|
|
183
|
+
});
|
|
184
|
+
vmTemplate = manualEntry?.trim() || undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Find storage that supports vztmpl content. We do this BEFORE prompting
|
|
189
|
+
// for a template so the user only ever sees one storage in subsequent
|
|
190
|
+
// messages, and so the saved volid uses the right storage.
|
|
141
191
|
console.log('\nFinding storage for templates...');
|
|
142
192
|
const storageListResult = await listNodeStorage(credentials, targetNode);
|
|
143
193
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
type ProxmoxProviderConfig,
|
|
4
|
+
buildVmTemplateChoices,
|
|
5
|
+
resolveReconfiguredProviderConfig,
|
|
6
|
+
} from './service-reconfigure';
|
|
7
|
+
|
|
8
|
+
describe('buildVmTemplateChoices — VM-template reconfigure menu', () => {
|
|
9
|
+
const templates = [
|
|
10
|
+
{ vmid: 9000, name: 'ubuntu-2404-cloudinit' },
|
|
11
|
+
{ vmid: 9001, name: 'ubuntu-2204-cloudinit' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
test('current set + detected templates: keep first, others (current deduped), build, clear', () => {
|
|
15
|
+
const choices = buildVmTemplateChoices('ubuntu-2404-cloudinit', templates);
|
|
16
|
+
expect(choices.map((c) => c.value)).toEqual([
|
|
17
|
+
'__keep__',
|
|
18
|
+
'ubuntu-2204-cloudinit', // the other detected template — current is not re-listed
|
|
19
|
+
'__build__',
|
|
20
|
+
'__clear__',
|
|
21
|
+
]);
|
|
22
|
+
expect(choices[0].label).toBe('Keep current (ubuntu-2404-cloudinit)');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('current unset: no keep/clear; detected templates, build, then skip', () => {
|
|
26
|
+
const choices = buildVmTemplateChoices(undefined, templates);
|
|
27
|
+
expect(choices.map((c) => c.value)).toEqual([
|
|
28
|
+
'ubuntu-2404-cloudinit',
|
|
29
|
+
'ubuntu-2204-cloudinit',
|
|
30
|
+
'__build__',
|
|
31
|
+
'__skip__',
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('current set, no templates detected: keep, build, clear', () => {
|
|
36
|
+
const choices = buildVmTemplateChoices('ubuntu-2404-cloudinit', []);
|
|
37
|
+
expect(choices.map((c) => c.value)).toEqual(['__keep__', '__build__', '__clear__']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('current unset, no templates detected: build, skip', () => {
|
|
41
|
+
const choices = buildVmTemplateChoices(undefined, []);
|
|
42
|
+
expect(choices.map((c) => c.value)).toEqual(['__build__', '__skip__']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a detected template carries its VMID in the label', () => {
|
|
46
|
+
const choices = buildVmTemplateChoices(undefined, [{ vmid: 9002, name: 'debian-12' }]);
|
|
47
|
+
expect(choices[0]).toEqual({ value: 'debian-12', label: 'debian-12 (VMID 9002)' });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('resolveReconfiguredProviderConfig — drop-bug guard (ISS-0128)', () => {
|
|
52
|
+
const current: ProxmoxProviderConfig = {
|
|
53
|
+
default_target_node: 'pve',
|
|
54
|
+
lxc_template: 'local:vztmpl/ubuntu-24.04.tar.zst',
|
|
55
|
+
storage: 'local-lvm',
|
|
56
|
+
vm_template: 'ubuntu-2404-cloudinit',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
test('keeping the VM template (edit echoes current) preserves it while applying other edits', () => {
|
|
60
|
+
const result = resolveReconfiguredProviderConfig({
|
|
61
|
+
default_target_node: 'pve2',
|
|
62
|
+
lxc_template: 'local:vztmpl/ubuntu-24.04-2.tar.zst',
|
|
63
|
+
storage: 'fast-lvm',
|
|
64
|
+
vm_template: 'ubuntu-2404-cloudinit', // VM_KEEP resolves to the current value
|
|
65
|
+
});
|
|
66
|
+
expect(result).toEqual({
|
|
67
|
+
default_target_node: 'pve2',
|
|
68
|
+
lxc_template: 'local:vztmpl/ubuntu-24.04-2.tar.zst',
|
|
69
|
+
storage: 'fast-lvm',
|
|
70
|
+
vm_template: 'ubuntu-2404-cloudinit',
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the pre-fix behavior would have dropped vm_template — guard against regressing', () => {
|
|
75
|
+
// Reconfiguring only the LXC fields must NOT lose the VM template: the
|
|
76
|
+
// handler threads currentConfig.vm_template through as the edit value.
|
|
77
|
+
const result = resolveReconfiguredProviderConfig({
|
|
78
|
+
default_target_node: current.default_target_node,
|
|
79
|
+
lxc_template: 'local:vztmpl/new.tar.zst',
|
|
80
|
+
storage: current.storage,
|
|
81
|
+
vm_template: current.vm_template,
|
|
82
|
+
});
|
|
83
|
+
expect(result.vm_template).toBe('ubuntu-2404-cloudinit');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('clearing (undefined edit) removes the field entirely, not sets it undefined', () => {
|
|
87
|
+
const result = resolveReconfiguredProviderConfig({
|
|
88
|
+
default_target_node: 'pve',
|
|
89
|
+
lxc_template: current.lxc_template,
|
|
90
|
+
storage: current.storage,
|
|
91
|
+
vm_template: undefined,
|
|
92
|
+
});
|
|
93
|
+
expect('vm_template' in result).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('setting a new template overwrites the old', () => {
|
|
97
|
+
const result = resolveReconfiguredProviderConfig({
|
|
98
|
+
default_target_node: 'pve',
|
|
99
|
+
lxc_template: current.lxc_template,
|
|
100
|
+
storage: current.storage,
|
|
101
|
+
vm_template: 'debian-12-cloudinit',
|
|
102
|
+
});
|
|
103
|
+
expect(result.vm_template).toBe('debian-12-cloudinit');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('no VM template edit leaves it absent', () => {
|
|
107
|
+
const result = resolveReconfiguredProviderConfig({
|
|
108
|
+
default_target_node: 'pve',
|
|
109
|
+
lxc_template: 'local:vztmpl/x.tar.zst',
|
|
110
|
+
storage: 'local-lvm',
|
|
111
|
+
vm_template: undefined,
|
|
112
|
+
});
|
|
113
|
+
expect('vm_template' in result).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import * as p from '@clack/prompts';
|
|
7
7
|
import {
|
|
8
8
|
type ProxmoxCredentials,
|
|
9
|
+
type ProxmoxVmTemplate,
|
|
9
10
|
buildTemplatePath,
|
|
10
11
|
extractTemplateFilename,
|
|
11
12
|
listAvailableTemplates,
|
|
@@ -23,11 +24,84 @@ import {
|
|
|
23
24
|
runApplianceDownload,
|
|
24
25
|
selectUbuntuApplianceFromCatalog,
|
|
25
26
|
} from './proxmox-template-selection';
|
|
27
|
+
import { buildCloudInitTemplate, detectExistingVmTemplates } from './proxmox-vm-template-build';
|
|
26
28
|
|
|
27
|
-
interface
|
|
29
|
+
// A `type` (not `interface`) so it carries an implicit index signature and is
|
|
30
|
+
// assignable to the loose `Record<string, unknown>` providerConfig column.
|
|
31
|
+
export type ProxmoxProviderConfig = {
|
|
28
32
|
default_target_node: string;
|
|
29
33
|
lxc_template: string;
|
|
30
34
|
storage: string;
|
|
35
|
+
// VM template to clone for `requires.system.type: vm` modules. Optional —
|
|
36
|
+
// a service may only ever host LXC modules. MUST be carried through a
|
|
37
|
+
// reconfigure (ISS-0128): updateServiceProviderConfig replaces the whole
|
|
38
|
+
// column, so dropping it here silently deletes the operator's template.
|
|
39
|
+
vm_template?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Sentinel choices for the VM-template reconfigure step (non-template values
|
|
43
|
+
// returned by the select). Any other value is an existing template name.
|
|
44
|
+
const VM_KEEP = '__keep__';
|
|
45
|
+
const VM_BUILD = '__build__';
|
|
46
|
+
const VM_CLEAR = '__clear__';
|
|
47
|
+
const VM_SKIP = '__skip__';
|
|
48
|
+
|
|
49
|
+
export interface VmTemplateChoice {
|
|
50
|
+
value: string;
|
|
51
|
+
label: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the select options for the VM-template reconfigure step, given the
|
|
56
|
+
* current value and the templates detected on the node. Pure — no I/O — so the
|
|
57
|
+
* menu is unit-testable and a headless caller (ISS-0127) can resolve a choice
|
|
58
|
+
* without a prompt.
|
|
59
|
+
*/
|
|
60
|
+
export function buildVmTemplateChoices(
|
|
61
|
+
current: string | undefined,
|
|
62
|
+
existing: ProxmoxVmTemplate[],
|
|
63
|
+
): VmTemplateChoice[] {
|
|
64
|
+
const choices: VmTemplateChoice[] = [];
|
|
65
|
+
if (current) {
|
|
66
|
+
choices.push({ value: VM_KEEP, label: `Keep current (${current})` });
|
|
67
|
+
}
|
|
68
|
+
for (const template of existing) {
|
|
69
|
+
if (template.name === current) continue;
|
|
70
|
+
choices.push({ value: template.name, label: `${template.name} (VMID ${template.vmid})` });
|
|
71
|
+
}
|
|
72
|
+
choices.push({ value: VM_BUILD, label: 'Build a new Ubuntu 24.04 cloud-init template now' });
|
|
73
|
+
choices.push(
|
|
74
|
+
current
|
|
75
|
+
? { value: VM_CLEAR, label: 'Clear — remove the VM template' }
|
|
76
|
+
: { value: VM_SKIP, label: 'Skip — no VM template' },
|
|
77
|
+
);
|
|
78
|
+
return choices;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the full providerConfig to persist from the reconfigure interview's
|
|
83
|
+
* resolved values. The caller seeds every field from the current config (via the
|
|
84
|
+
* prompt defaults and the VM-template choice), so the result is a function of
|
|
85
|
+
* `edits` alone. vm_template is included only when set — a falsy value clears it
|
|
86
|
+
* rather than persisting an undefined key. This is the guard for the
|
|
87
|
+
* wholesale-replace drop bug (ISS-0128): vm_template is a first-class edit, not
|
|
88
|
+
* an afterthought that gets omitted from the written object.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveReconfiguredProviderConfig(edits: {
|
|
91
|
+
default_target_node: string;
|
|
92
|
+
lxc_template: string;
|
|
93
|
+
storage: string;
|
|
94
|
+
vm_template: string | undefined;
|
|
95
|
+
}): ProxmoxProviderConfig {
|
|
96
|
+
const config: ProxmoxProviderConfig = {
|
|
97
|
+
default_target_node: edits.default_target_node,
|
|
98
|
+
lxc_template: edits.lxc_template,
|
|
99
|
+
storage: edits.storage,
|
|
100
|
+
};
|
|
101
|
+
if (edits.vm_template) {
|
|
102
|
+
config.vm_template = edits.vm_template;
|
|
103
|
+
}
|
|
104
|
+
return config;
|
|
31
105
|
}
|
|
32
106
|
|
|
33
107
|
export async function handleServiceReconfigure(
|
|
@@ -64,7 +138,8 @@ export async function handleServiceReconfigure(
|
|
|
64
138
|
console.log('Current configuration:');
|
|
65
139
|
console.log(` Target node: ${currentConfig.default_target_node}`);
|
|
66
140
|
console.log(` Storage: ${currentConfig.storage}`);
|
|
67
|
-
console.log(`
|
|
141
|
+
console.log(` LXC template: ${currentConfig.lxc_template}`);
|
|
142
|
+
console.log(` VM template: ${currentConfig.vm_template ?? '(none)'}`);
|
|
68
143
|
console.log('');
|
|
69
144
|
|
|
70
145
|
// Re-prompt for configurable fields with current values as defaults
|
|
@@ -159,18 +234,48 @@ export async function handleServiceReconfigure(
|
|
|
159
234
|
console.log(`✓ Template '${templateFilename}' found`);
|
|
160
235
|
}
|
|
161
236
|
|
|
162
|
-
//
|
|
163
|
-
|
|
237
|
+
// VM template (for `requires.system.type: vm` modules). Keep the current
|
|
238
|
+
// value, swap to another detected template, build a new one, or clear it.
|
|
239
|
+
// Without this step a reconfigure would silently drop vm_template (ISS-0128).
|
|
240
|
+
let vmTemplate = currentConfig.vm_template;
|
|
241
|
+
const existingTemplates = await detectExistingVmTemplates(credentials, targetNode);
|
|
242
|
+
const vmChoice = await p.select({
|
|
243
|
+
message: 'VM template for VM-type modules:',
|
|
244
|
+
options: buildVmTemplateChoices(currentConfig.vm_template, existingTemplates),
|
|
245
|
+
});
|
|
246
|
+
if (p.isCancel(vmChoice)) {
|
|
247
|
+
p.cancel('Operation cancelled');
|
|
248
|
+
return { success: false, error: 'Cancelled by user' };
|
|
249
|
+
}
|
|
250
|
+
if (vmChoice === VM_BUILD) {
|
|
251
|
+
vmTemplate = await buildCloudInitTemplate({
|
|
252
|
+
credentials,
|
|
253
|
+
nodeName: targetNode,
|
|
254
|
+
diskStorage: storage,
|
|
255
|
+
});
|
|
256
|
+
} else if (vmChoice === VM_CLEAR || vmChoice === VM_SKIP) {
|
|
257
|
+
vmTemplate = undefined;
|
|
258
|
+
} else if (vmChoice !== VM_KEEP) {
|
|
259
|
+
vmTemplate = vmChoice; // an existing template name
|
|
260
|
+
}
|
|
261
|
+
// VM_KEEP leaves vmTemplate at currentConfig.vm_template.
|
|
262
|
+
|
|
263
|
+
// Update service configuration. resolveReconfiguredProviderConfig preserves
|
|
264
|
+
// every existing key and carries vm_template forward (ISS-0128 drop guard).
|
|
265
|
+
const newConfig = resolveReconfiguredProviderConfig({
|
|
164
266
|
default_target_node: targetNode,
|
|
165
267
|
lxc_template: lxcTemplate,
|
|
166
268
|
storage,
|
|
269
|
+
vm_template: vmTemplate,
|
|
167
270
|
});
|
|
271
|
+
await updateServiceProviderConfig(service.id, newConfig);
|
|
168
272
|
|
|
169
273
|
celiloOutro(
|
|
170
274
|
`Service '${serviceId}' reconfigured successfully!\n\n` +
|
|
171
275
|
` Target node: ${targetNode}\n` +
|
|
172
276
|
` Storage: ${storage}\n` +
|
|
173
|
-
`
|
|
277
|
+
` LXC template: ${lxcTemplate}\n` +
|
|
278
|
+
` VM template: ${vmTemplate ?? '(none)'}\n\n` +
|
|
174
279
|
`Next steps:\n - Verify: celilo service verify ${serviceId}\n - Deploy: celilo module deploy <module-id>`,
|
|
175
280
|
);
|
|
176
281
|
|