@celilo/cli 0.5.0-alpha.9 → 0.5.0
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 +1 -1
- package/src/api-clients/proxmox.test.ts +30 -20
- package/src/api-clients/proxmox.ts +34 -23
- package/src/cli/commands/proxmox-vm-template-build.ts +25 -18
- package/src/services/proxmox-state-recovery.ts +26 -7
- package/src/templates/generator.test.ts +22 -1
- package/src/templates/generator.ts +22 -8
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
findNodeForVmid,
|
|
7
7
|
pollTaskUntilDone,
|
|
8
8
|
selectFreeVmid,
|
|
9
|
-
|
|
9
|
+
selectImportStorage,
|
|
10
10
|
summarizeNodeCapacities,
|
|
11
11
|
} from './proxmox';
|
|
12
12
|
|
|
@@ -137,38 +137,48 @@ describe('filterVmTemplates — extract VM templates from a /nodes/{node}/qemu l
|
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
describe('
|
|
141
|
-
test('returns the first active, enabled storage whose content includes
|
|
140
|
+
describe('selectImportStorage — pick a storage that accepts import content', () => {
|
|
141
|
+
test('returns the first active, enabled storage whose content includes import', () => {
|
|
142
142
|
const storages = [
|
|
143
143
|
{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 },
|
|
144
|
-
{ storage: 'local', content: '
|
|
144
|
+
{ storage: 'local', content: 'import,vztmpl,backup', active: 1, enabled: 1 },
|
|
145
145
|
];
|
|
146
|
-
expect(
|
|
146
|
+
expect(selectImportStorage(storages)).toBe('local');
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
test('
|
|
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'`.
|
|
150
152
|
const storages = [
|
|
151
|
-
{ storage: '
|
|
152
|
-
{ storage: '
|
|
153
|
+
{ storage: 'iso-only', content: 'iso,vztmpl,backup', active: 1, enabled: 1 },
|
|
154
|
+
{ storage: 'data', content: 'import,images', active: 1, enabled: 1 },
|
|
153
155
|
];
|
|
154
|
-
expect(
|
|
156
|
+
expect(selectImportStorage(storages)).toBe('data');
|
|
155
157
|
});
|
|
156
158
|
|
|
157
|
-
test('skips an
|
|
159
|
+
test('skips an import-capable storage that is inactive', () => {
|
|
158
160
|
const storages = [
|
|
159
|
-
{ storage: '
|
|
160
|
-
{ storage: 'local', content: '
|
|
161
|
+
{ storage: 'cold', content: 'import', active: 0, enabled: 1 },
|
|
162
|
+
{ storage: 'local', content: 'import', active: 1, enabled: 1 },
|
|
161
163
|
];
|
|
162
|
-
expect(
|
|
164
|
+
expect(selectImportStorage(storages)).toBe('local');
|
|
163
165
|
});
|
|
164
166
|
|
|
165
|
-
test('
|
|
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', () => {
|
|
166
176
|
const storages = [{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 }];
|
|
167
|
-
expect(
|
|
177
|
+
expect(selectImportStorage(storages)).toBeNull();
|
|
168
178
|
});
|
|
169
179
|
|
|
170
180
|
test('returns null for an empty storage list', () => {
|
|
171
|
-
expect(
|
|
181
|
+
expect(selectImportStorage([])).toBeNull();
|
|
172
182
|
});
|
|
173
183
|
});
|
|
174
184
|
|
|
@@ -200,15 +210,15 @@ describe('selectFreeVmid — first free VMID >= minVmid (template hygiene)', ()
|
|
|
200
210
|
});
|
|
201
211
|
});
|
|
202
212
|
|
|
203
|
-
describe('buildCloudImageVolid — volid for a cloud image on
|
|
204
|
-
test('places the filename under the storage
|
|
213
|
+
describe('buildCloudImageVolid — volid for a cloud image on import storage', () => {
|
|
214
|
+
test('places the filename under the storage import/ namespace', () => {
|
|
205
215
|
expect(buildCloudImageVolid('local', 'noble-server-cloudimg-amd64.img')).toBe(
|
|
206
|
-
'local:
|
|
216
|
+
'local:import/noble-server-cloudimg-amd64.img',
|
|
207
217
|
);
|
|
208
218
|
});
|
|
209
219
|
|
|
210
220
|
test('works with an arbitrary storage name', () => {
|
|
211
|
-
expect(buildCloudImageVolid('nas
|
|
221
|
+
expect(buildCloudImageVolid('nas', 'jammy.img')).toBe('nas:import/jammy.img');
|
|
212
222
|
});
|
|
213
223
|
});
|
|
214
224
|
|
|
@@ -890,30 +890,38 @@ export async function listVmTemplates(
|
|
|
890
890
|
return { success: true, data: filterVmTemplates(result.data) };
|
|
891
891
|
}
|
|
892
892
|
|
|
893
|
-
/** Storage row shape (subset) used when picking
|
|
893
|
+
/** Storage row shape (subset) used when picking import-capable storage. */
|
|
894
894
|
type ProxmoxStorageRow = { storage: string; content: string; active: number; enabled: number };
|
|
895
895
|
|
|
896
896
|
/**
|
|
897
|
-
* Pick the first active, enabled storage that accepts `
|
|
897
|
+
* Pick the first active, enabled storage that accepts `import` content. Pure
|
|
898
898
|
* selection logic, split from the network call for testability (Rule 10).
|
|
899
899
|
* Returns the storage name, or null when none qualifies.
|
|
900
|
+
*
|
|
901
|
+
* Must be `import`, NOT `iso`: the cloud image is consumed via the VM-create
|
|
902
|
+
* `import-from=` parameter, which Proxmox only accepts from a source on a
|
|
903
|
+
* storage whose content type is `images` or `import`. A `.img` downloaded to
|
|
904
|
+
* the `iso/` namespace is rejected with `has wrong type 'iso'`. The
|
|
905
|
+
* `download-url` endpoint accepts `content=import` on PVE 8.2+, so we download
|
|
906
|
+
* straight into the import namespace. (PVE 8.2+; node fleet is 9.x.)
|
|
900
907
|
*/
|
|
901
|
-
export function
|
|
902
|
-
const
|
|
903
|
-
return
|
|
908
|
+
export function selectImportStorage(storages: ProxmoxStorageRow[]): string | null {
|
|
909
|
+
const match = storages.find((s) => s.active && s.enabled && s.content.includes('import'));
|
|
910
|
+
return match?.storage ?? null;
|
|
904
911
|
}
|
|
905
912
|
|
|
906
913
|
/**
|
|
907
|
-
* Find a storage on the node that accepts `
|
|
908
|
-
* images). Returns the storage name,
|
|
914
|
+
* Find a storage on the node that accepts `import` content (for downloading
|
|
915
|
+
* cloud images that will be imported as a VM disk). Returns the storage name,
|
|
916
|
+
* or null if none found.
|
|
909
917
|
*/
|
|
910
|
-
export async function
|
|
918
|
+
export async function findImportStorage(
|
|
911
919
|
credentials: ProxmoxCredentials,
|
|
912
920
|
nodeName: string,
|
|
913
921
|
): Promise<ProxmoxResult<string | null>> {
|
|
914
922
|
const result = await listNodeStorage(credentials, nodeName);
|
|
915
923
|
if (!result.success) return result;
|
|
916
|
-
return { success: true, data:
|
|
924
|
+
return { success: true, data: selectImportStorage(result.data) };
|
|
917
925
|
}
|
|
918
926
|
|
|
919
927
|
/**
|
|
@@ -953,20 +961,22 @@ export async function findFreeTemplateVmid(
|
|
|
953
961
|
}
|
|
954
962
|
|
|
955
963
|
/**
|
|
956
|
-
* Download a cloud image URL to
|
|
957
|
-
* status polling. Requires PVE
|
|
964
|
+
* Download a cloud image URL to `import` storage on the node. Returns a UPID
|
|
965
|
+
* for status polling. Requires PVE 8.2+ (`download-url` with `content=import`).
|
|
966
|
+
* The image MUST land in the import namespace (not iso) so the VM-create
|
|
967
|
+
* `import-from=` accepts it — see selectImportStorage.
|
|
958
968
|
*/
|
|
959
969
|
export async function downloadCloudImage(
|
|
960
970
|
credentials: ProxmoxCredentials,
|
|
961
971
|
nodeName: string,
|
|
962
|
-
|
|
972
|
+
importStorage: string,
|
|
963
973
|
url: string,
|
|
964
974
|
filename: string,
|
|
965
975
|
): Promise<ProxmoxResult<string>> {
|
|
966
976
|
return makeProxmoxPost<string>(
|
|
967
977
|
credentials,
|
|
968
|
-
`/nodes/${nodeName}/storage/${
|
|
969
|
-
{ url, filename, content: '
|
|
978
|
+
`/nodes/${nodeName}/storage/${importStorage}/download-url`,
|
|
979
|
+
{ url, filename, content: 'import' },
|
|
970
980
|
);
|
|
971
981
|
}
|
|
972
982
|
|
|
@@ -1003,13 +1013,14 @@ export async function pollTaskUntilDone(
|
|
|
1003
1013
|
}
|
|
1004
1014
|
|
|
1005
1015
|
/**
|
|
1006
|
-
* Build the volid for a cloud image downloaded to
|
|
1016
|
+
* Build the volid for a cloud image downloaded to `import` storage. Pure path
|
|
1007
1017
|
* construction, split out for testability (Rule 10) — mirrors buildTemplatePath.
|
|
1008
|
-
* Proxmox's `download-url` with `content=
|
|
1009
|
-
* storage's `
|
|
1018
|
+
* Proxmox's `download-url` with `content=import` stores the `.img` under the
|
|
1019
|
+
* storage's `import/` namespace, so the volid is `<storage>:import/<filename>`.
|
|
1020
|
+
* This is the namespace the VM-create `import-from=` accepts (iso/ is rejected).
|
|
1010
1021
|
*/
|
|
1011
|
-
export function buildCloudImageVolid(
|
|
1012
|
-
return `${
|
|
1022
|
+
export function buildCloudImageVolid(importStorage: string, filename: string): string {
|
|
1023
|
+
return `${importStorage}:import/${filename}`;
|
|
1013
1024
|
}
|
|
1014
1025
|
|
|
1015
1026
|
export interface CreateVmFromCloudImageParams {
|
|
@@ -1017,8 +1028,8 @@ export interface CreateVmFromCloudImageParams {
|
|
|
1017
1028
|
nodeName: string;
|
|
1018
1029
|
vmid: number;
|
|
1019
1030
|
name: string;
|
|
1020
|
-
/** Volume ID of the downloaded cloud image, e.g. `local:
|
|
1021
|
-
|
|
1031
|
+
/** Volume ID of the downloaded cloud image, e.g. `local:import/noble-server-cloudimg-amd64.img` */
|
|
1032
|
+
imageVolid: string;
|
|
1022
1033
|
/** Storage for the imported VM disk and the cloud-init drive */
|
|
1023
1034
|
diskStorage: string;
|
|
1024
1035
|
}
|
|
@@ -1038,7 +1049,7 @@ export interface CreateVmFromCloudImageParams {
|
|
|
1038
1049
|
export async function createVmFromCloudImage(
|
|
1039
1050
|
params: CreateVmFromCloudImageParams,
|
|
1040
1051
|
): Promise<ProxmoxResult<string>> {
|
|
1041
|
-
const { credentials, nodeName, vmid, name,
|
|
1052
|
+
const { credentials, nodeName, vmid, name, imageVolid, diskStorage } = params;
|
|
1042
1053
|
return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu`, {
|
|
1043
1054
|
vmid: String(vmid),
|
|
1044
1055
|
name,
|
|
@@ -1050,7 +1061,7 @@ export async function createVmFromCloudImage(
|
|
|
1050
1061
|
serial0: 'socket',
|
|
1051
1062
|
vga: 'serial0',
|
|
1052
1063
|
boot: 'order=scsi0',
|
|
1053
|
-
scsi0: `${diskStorage}:0,import-from=${
|
|
1064
|
+
scsi0: `${diskStorage}:0,import-from=${imageVolid}`,
|
|
1054
1065
|
ide2: `${diskStorage}:cloudinit`,
|
|
1055
1066
|
});
|
|
1056
1067
|
}
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
deleteVm,
|
|
26
26
|
downloadCloudImage,
|
|
27
27
|
findFreeTemplateVmid,
|
|
28
|
-
|
|
28
|
+
findImportStorage,
|
|
29
29
|
listVmTemplates,
|
|
30
30
|
pollTaskUntilDone,
|
|
31
31
|
} from '../../api-clients/proxmox';
|
|
@@ -33,7 +33,12 @@ import { FuelGauge } from '../fuel-gauge';
|
|
|
33
33
|
|
|
34
34
|
const UBUNTU_2404_AMD64_URL =
|
|
35
35
|
'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img';
|
|
36
|
-
|
|
36
|
+
// Stored under the storage's import/ namespace, so the filename must end in an
|
|
37
|
+
// extension PVE accepts for `import` content (UPLOAD_IMPORT_EXT_RE_1 =
|
|
38
|
+
// ova|qcow2|raw|vmdk) — a `.img` is rejected with "invalid filename or wrong
|
|
39
|
+
// extension". Ubuntu's cloud image is qcow2 format despite the upstream `.img`
|
|
40
|
+
// URL, so we download it under a `.qcow2` name. The URL above is unchanged.
|
|
41
|
+
const UBUNTU_2404_AMD64_FILENAME = 'noble-server-cloudimg-amd64.qcow2';
|
|
37
42
|
const DEFAULT_TEMPLATE_NAME = 'ubuntu-2404-cloudinit';
|
|
38
43
|
|
|
39
44
|
export interface VmTemplateBuildParams {
|
|
@@ -69,21 +74,23 @@ export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Pro
|
|
|
69
74
|
let vmid: number | null = null;
|
|
70
75
|
|
|
71
76
|
try {
|
|
72
|
-
// Step 1: find
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
// Step 1: find import-capable storage. The cloud image is consumed via the
|
|
78
|
+
// VM-create `import-from=`, which requires an `import`/`images` source — a
|
|
79
|
+
// `.img` in the `iso/` namespace is rejected (`has wrong type 'iso'`).
|
|
80
|
+
gauge.addOutput('Finding import-capable storage on the node…');
|
|
81
|
+
const importStorageResult = await findImportStorage(credentials, nodeName);
|
|
82
|
+
if (!importStorageResult.success) {
|
|
83
|
+
throw new Error(`Could not list node storage: ${importStorageResult.message}`);
|
|
77
84
|
}
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
85
|
+
const importStorage = importStorageResult.data;
|
|
86
|
+
if (!importStorage) {
|
|
80
87
|
throw new Error(
|
|
81
|
-
'No
|
|
82
|
-
"Enable '
|
|
88
|
+
'No import-capable storage found on the node. ' +
|
|
89
|
+
"Enable 'import' content on a storage (typically 'local') in the Proxmox UI: " +
|
|
83
90
|
'Datacenter → Storage → Edit → Content.',
|
|
84
91
|
);
|
|
85
92
|
}
|
|
86
|
-
gauge.addOutput(`
|
|
93
|
+
gauge.addOutput(`Import storage: ${importStorage}`);
|
|
87
94
|
|
|
88
95
|
// Step 2: find a free template VMID
|
|
89
96
|
gauge.addOutput('Finding a free template VMID (>= 9000)…');
|
|
@@ -92,21 +99,21 @@ export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Pro
|
|
|
92
99
|
vmid = vmidResult.data;
|
|
93
100
|
gauge.addOutput(`Using VMID ${vmid}`);
|
|
94
101
|
|
|
95
|
-
// Step 3: download cloud image to
|
|
102
|
+
// Step 3: download cloud image to import storage
|
|
96
103
|
gauge.addOutput(
|
|
97
|
-
`Downloading Ubuntu 24.04 cloud image to '${
|
|
104
|
+
`Downloading Ubuntu 24.04 cloud image to '${importStorage}' (~600 MB, may take several minutes)…`,
|
|
98
105
|
);
|
|
99
106
|
const downloadResult = await downloadCloudImage(
|
|
100
107
|
credentials,
|
|
101
108
|
nodeName,
|
|
102
|
-
|
|
109
|
+
importStorage,
|
|
103
110
|
UBUNTU_2404_AMD64_URL,
|
|
104
111
|
UBUNTU_2404_AMD64_FILENAME,
|
|
105
112
|
);
|
|
106
113
|
if (!downloadResult.success) {
|
|
107
114
|
if (downloadResult.message.includes('403') || downloadResult.message.includes('not found')) {
|
|
108
115
|
throw new Error(
|
|
109
|
-
`Proxmox storage download-url endpoint rejected the request.
|
|
116
|
+
`Proxmox storage download-url endpoint rejected the request. Downloading to 'import' content requires Proxmox VE 8.2+. Error: ${downloadResult.message}`,
|
|
110
117
|
);
|
|
111
118
|
}
|
|
112
119
|
throw new Error(`Cloud image download failed: ${downloadResult.message}`);
|
|
@@ -116,14 +123,14 @@ export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Pro
|
|
|
116
123
|
gauge.addOutput('Cloud image downloaded');
|
|
117
124
|
|
|
118
125
|
// Step 4: create VM from cloud image
|
|
119
|
-
const
|
|
126
|
+
const imageVolid = buildCloudImageVolid(importStorage, UBUNTU_2404_AMD64_FILENAME);
|
|
120
127
|
gauge.addOutput(`Creating VM ${vmid} '${DEFAULT_TEMPLATE_NAME}' (importing disk)…`);
|
|
121
128
|
const createResult = await createVmFromCloudImage({
|
|
122
129
|
credentials,
|
|
123
130
|
nodeName,
|
|
124
131
|
vmid,
|
|
125
132
|
name: DEFAULT_TEMPLATE_NAME,
|
|
126
|
-
|
|
133
|
+
imageVolid,
|
|
127
134
|
diskStorage,
|
|
128
135
|
});
|
|
129
136
|
if (!createResult.success) {
|
|
@@ -23,14 +23,29 @@ interface TerraformState {
|
|
|
23
23
|
instances?: Array<{
|
|
24
24
|
attributes?: {
|
|
25
25
|
vmid?: number;
|
|
26
|
+
// proxmox_lxc carries the IP here ("10.0.10.12/24" or "dhcp").
|
|
26
27
|
network?: Array<{
|
|
27
28
|
ip?: string;
|
|
28
29
|
}>;
|
|
30
|
+
// proxmox_vm_qemu carries the cloud-init IP here ("ip=10.0.10.12/24,gw=…").
|
|
31
|
+
ipconfig0?: string;
|
|
29
32
|
};
|
|
30
33
|
}>;
|
|
31
34
|
}>;
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Extract the CIDR from a qemu cloud-init `ipconfig0` string
|
|
39
|
+
* ("ip=10.0.10.12/24,gw=10.0.10.1" → "10.0.10.12/24"). Returns undefined for a
|
|
40
|
+
* DHCP config or anything without an explicit `ip=`.
|
|
41
|
+
*/
|
|
42
|
+
function extractIpFromIpconfig(ipconfig0: string | undefined): string | undefined {
|
|
43
|
+
if (!ipconfig0) return undefined;
|
|
44
|
+
const match = ipconfig0.match(/(?:^|,)ip=([^,]+)/);
|
|
45
|
+
const value = match?.[1];
|
|
46
|
+
return value && value !== 'dhcp' ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
/**
|
|
35
50
|
* Ensure module_configs has vmid and target_ip from Terraform state
|
|
36
51
|
* This recovers from state drift scenarios where container was deleted/recreated
|
|
@@ -82,19 +97,23 @@ export async function ensureProxmoxConfigFromState(
|
|
|
82
97
|
);
|
|
83
98
|
}
|
|
84
99
|
|
|
85
|
-
// Find proxmox_lxc
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
// Find the provisioned guest: an LXC (proxmox_lxc) or a VM (proxmox_vm_qemu).
|
|
101
|
+
const guest = state.resources?.find(
|
|
102
|
+
(r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu',
|
|
103
|
+
);
|
|
104
|
+
if (!guest || !guest.instances || guest.instances.length === 0) {
|
|
105
|
+
throw new Error('No proxmox_lxc or proxmox_vm_qemu resource found in Terraform state');
|
|
89
106
|
}
|
|
90
107
|
|
|
91
|
-
const attributes =
|
|
108
|
+
const attributes = guest.instances[0].attributes;
|
|
92
109
|
if (!attributes) {
|
|
93
|
-
throw new Error(
|
|
110
|
+
throw new Error(`No attributes found in ${guest.type} resource`);
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
const vmid = attributes.vmid;
|
|
97
|
-
|
|
114
|
+
// LXC exposes the IP at network[0].ip; a qemu VM exposes it via the cloud-init
|
|
115
|
+
// ipconfig0 ("ip=<cidr>,gw=<gw>"). Use whichever the guest type provides.
|
|
116
|
+
const containerIp = attributes.network?.[0]?.ip ?? extractIpFromIpconfig(attributes.ipconfig0);
|
|
98
117
|
|
|
99
118
|
if (!vmid) {
|
|
100
119
|
throw new Error('vmid not found in Terraform state');
|
|
@@ -790,7 +790,28 @@ describe("targetNodeFromTfState (ISS-0090 — terraform state is celilo's placem
|
|
|
790
790
|
expect(targetNodeFromTfState(state)).toBe('node3');
|
|
791
791
|
});
|
|
792
792
|
|
|
793
|
-
test('
|
|
793
|
+
test('reads the node from a proxmox_vm_qemu target_node attribute (type:vm)', () => {
|
|
794
|
+
const state = {
|
|
795
|
+
resources: [
|
|
796
|
+
{
|
|
797
|
+
type: 'proxmox_vm_qemu',
|
|
798
|
+
instances: [{ attributes: { target_node: 'node3', id: 'node3/qemu/208' } }],
|
|
799
|
+
},
|
|
800
|
+
],
|
|
801
|
+
};
|
|
802
|
+
expect(targetNodeFromTfState(state)).toBe('node3');
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test('parses the qemu resource id when target_node is absent (type:vm)', () => {
|
|
806
|
+
const state = {
|
|
807
|
+
resources: [
|
|
808
|
+
{ type: 'proxmox_vm_qemu', instances: [{ attributes: { id: 'node3/qemu/208' } }] },
|
|
809
|
+
],
|
|
810
|
+
};
|
|
811
|
+
expect(targetNodeFromTfState(state)).toBe('node3');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test('returns null when there is no proxmox_lxc/proxmox_vm_qemu resource (fresh/empty state)', () => {
|
|
794
815
|
expect(targetNodeFromTfState({ resources: [] })).toBeNull();
|
|
795
816
|
expect(targetNodeFromTfState({})).toBeNull();
|
|
796
817
|
});
|
|
@@ -216,10 +216,11 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
|
-
* Extract the node a
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* resource id (`<node>/lxc/<vmid>`
|
|
219
|
+
* Extract the node a Proxmox guest is deployed on from a parsed terraform state
|
|
220
|
+
* object — an LXC (proxmox_lxc) or a VM (proxmox_vm_qemu). Pure logic (Rule 10),
|
|
221
|
+
* split from the file read for testability. Prefers the explicit `target_node`
|
|
222
|
+
* attribute; falls back to parsing the resource id (`<node>/lxc/<vmid>` for an
|
|
223
|
+
* LXC, `<node>/qemu/<vmid>` for a VM). Returns null when there's no such
|
|
223
224
|
* resource (e.g. an empty/fresh state).
|
|
224
225
|
*/
|
|
225
226
|
export function targetNodeFromTfState(state: {
|
|
@@ -228,15 +229,17 @@ export function targetNodeFromTfState(state: {
|
|
|
228
229
|
instances?: Array<{ attributes?: { target_node?: string; id?: string } }>;
|
|
229
230
|
}>;
|
|
230
231
|
}): string | null {
|
|
231
|
-
const
|
|
232
|
-
|
|
232
|
+
const guest = state.resources?.find(
|
|
233
|
+
(r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu',
|
|
234
|
+
);
|
|
235
|
+
const attrs = guest?.instances?.[0]?.attributes;
|
|
233
236
|
if (!attrs) {
|
|
234
237
|
return null;
|
|
235
238
|
}
|
|
236
239
|
if (attrs.target_node) {
|
|
237
240
|
return attrs.target_node;
|
|
238
241
|
}
|
|
239
|
-
// id format: "<node>/lxc/<vmid>"
|
|
242
|
+
// id format: "<node>/lxc/<vmid>" (LXC) or "<node>/qemu/<vmid>" (VM)
|
|
240
243
|
return attrs.id?.split('/')[0] || null;
|
|
241
244
|
}
|
|
242
245
|
|
|
@@ -745,6 +748,10 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
745
748
|
default_target_node: string;
|
|
746
749
|
lxc_template: string;
|
|
747
750
|
storage: string;
|
|
751
|
+
// Optional: only set once a cloud-init VM template has been built/selected
|
|
752
|
+
// for the service (see service reconfigure). Required by `type: vm` modules;
|
|
753
|
+
// absent for services that only deploy LXCs.
|
|
754
|
+
vm_template?: string;
|
|
748
755
|
};
|
|
749
756
|
|
|
750
757
|
// ISS-0090: deploy follows REALITY. Resolve the node from Proxmox (it sees
|
|
@@ -774,6 +781,13 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
774
781
|
// any stale __infra_target_node a prior generate left behind.
|
|
775
782
|
upsertModuleConfig(db, moduleId, '__infra_lxc_template', providerConfig.lxc_template);
|
|
776
783
|
upsertModuleConfig(db, moduleId, '__infra_storage', providerConfig.storage);
|
|
784
|
+
// vm_template is only present once a VM template exists for the service.
|
|
785
|
+
// Persist it when set so `type: vm` modules can resolve $self:vm_template;
|
|
786
|
+
// a `type: vm` deploy against a service without one then fails loudly at
|
|
787
|
+
// variable resolution (the intended "no VM template configured" error).
|
|
788
|
+
if (providerConfig.vm_template) {
|
|
789
|
+
upsertModuleConfig(db, moduleId, '__infra_vm_template', providerConfig.vm_template);
|
|
790
|
+
}
|
|
777
791
|
deleteModuleConfig(db, moduleId, '__infra_target_node');
|
|
778
792
|
|
|
779
793
|
log.success(`Infrastructure resolved: target_node=${decision.node} (${decision.source})`);
|
|
@@ -816,7 +830,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
816
830
|
|
|
817
831
|
// lxc_template / storage are provider config (intent, not drift-prone) — read
|
|
818
832
|
// them back from the __infra_* rows persisted above.
|
|
819
|
-
const infraKeys = ['lxc_template', 'storage'];
|
|
833
|
+
const infraKeys = ['lxc_template', 'storage', 'vm_template'];
|
|
820
834
|
for (const key of infraKeys) {
|
|
821
835
|
const infraConfig = db
|
|
822
836
|
.select()
|