@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.5.0-alpha.9",
3
+ "version": "0.5.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@ import {
6
6
  findNodeForVmid,
7
7
  pollTaskUntilDone,
8
8
  selectFreeVmid,
9
- selectIsoStorage,
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('selectIsoStorage — pick a storage that accepts iso content', () => {
141
- test('returns the first active, enabled storage whose content includes iso', () => {
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: 'iso,vztmpl,backup', active: 1, enabled: 1 },
144
+ { storage: 'local', content: 'import,vztmpl,backup', active: 1, enabled: 1 },
145
145
  ];
146
- expect(selectIsoStorage(storages)).toBe('local');
146
+ expect(selectImportStorage(storages)).toBe('local');
147
147
  });
148
148
 
149
- test('skips an iso-capable storage that is inactive', () => {
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: 'cold', content: 'iso', active: 0, enabled: 1 },
152
- { storage: 'local', content: 'iso', active: 1, enabled: 1 },
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(selectIsoStorage(storages)).toBe('local');
156
+ expect(selectImportStorage(storages)).toBe('data');
155
157
  });
156
158
 
157
- test('skips an iso-capable storage that is disabled', () => {
159
+ test('skips an import-capable storage that is inactive', () => {
158
160
  const storages = [
159
- { storage: 'off', content: 'iso', active: 1, enabled: 0 },
160
- { storage: 'local', content: 'iso', active: 1, enabled: 1 },
161
+ { storage: 'cold', content: 'import', active: 0, enabled: 1 },
162
+ { storage: 'local', content: 'import', active: 1, enabled: 1 },
161
163
  ];
162
- expect(selectIsoStorage(storages)).toBe('local');
164
+ expect(selectImportStorage(storages)).toBe('local');
163
165
  });
164
166
 
165
- test('returns null when no storage accepts iso content', () => {
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(selectIsoStorage(storages)).toBeNull();
177
+ expect(selectImportStorage(storages)).toBeNull();
168
178
  });
169
179
 
170
180
  test('returns null for an empty storage list', () => {
171
- expect(selectIsoStorage([])).toBeNull();
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 iso storage', () => {
204
- test('places the filename under the storage iso/ namespace', () => {
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:iso/noble-server-cloudimg-amd64.img',
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-iso', 'jammy.img')).toBe('nas-iso:iso/jammy.img');
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 ISO-capable storage. */
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 `iso` content. Pure
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 selectIsoStorage(storages: ProxmoxStorageRow[]): string | null {
902
- const iso = storages.find((s) => s.active && s.enabled && s.content.includes('iso'));
903
- return iso?.storage ?? null;
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 `iso` content (for downloading cloud
908
- * images). Returns the storage name, or null if none found.
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 findIsoStorage(
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: selectIsoStorage(result.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 ISO storage on the node. Returns a UPID for
957
- * status polling. Requires PVE 7.2+ (`download-url` endpoint).
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
- isoStorage: string,
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/${isoStorage}/download-url`,
969
- { url, filename, content: 'iso' },
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 ISO storage. Pure path
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=iso` stores the `.img` under the
1009
- * storage's `iso/` namespace, so the volid is `<storage>:iso/<filename>`.
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(isoStorage: string, filename: string): string {
1012
- return `${isoStorage}:iso/${filename}`;
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:iso/noble-server-cloudimg-amd64.img` */
1021
- isoVolid: string;
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, isoVolid, diskStorage } = params;
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=${isoVolid}`,
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
- findIsoStorage,
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
- const UBUNTU_2404_AMD64_FILENAME = 'noble-server-cloudimg-amd64.img';
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 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
+ // 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 isoStorage = isoStorageResult.data;
79
- if (!isoStorage) {
85
+ const importStorage = importStorageResult.data;
86
+ if (!importStorage) {
80
87
  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: " +
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(`ISO storage: ${isoStorage}`);
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 ISO storage
102
+ // Step 3: download cloud image to import storage
96
103
  gauge.addOutput(
97
- `Downloading Ubuntu 24.04 cloud image to '${isoStorage}' (~600 MB, may take several minutes)…`,
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
- isoStorage,
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. This endpoint requires Proxmox VE 7.2+. Error: ${downloadResult.message}`,
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 isoVolid = buildCloudImageVolid(isoStorage, UBUNTU_2404_AMD64_FILENAME);
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
- isoVolid,
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 resource
86
- const lxcResource = state.resources?.find((r) => r.type === 'proxmox_lxc');
87
- if (!lxcResource || !lxcResource.instances || lxcResource.instances.length === 0) {
88
- throw new Error('No proxmox_lxc resource found in Terraform state');
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 = lxcResource.instances[0].attributes;
108
+ const attributes = guest.instances[0].attributes;
92
109
  if (!attributes) {
93
- throw new Error('No attributes found in proxmox_lxc resource');
110
+ throw new Error(`No attributes found in ${guest.type} resource`);
94
111
  }
95
112
 
96
113
  const vmid = attributes.vmid;
97
- const containerIp = attributes.network?.[0]?.ip;
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('returns null when there is no proxmox_lxc resource (fresh/empty state)', () => {
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 proxmox_lxc resource is deployed on from a parsed terraform
220
- * state object. Pure logic (Rule 10), split from the file read for testability.
221
- * Prefers the explicit `target_node` attribute; falls back to parsing the
222
- * resource id (`<node>/lxc/<vmid>`). Returns null when there's no proxmox_lxc
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 lxc = state.resources?.find((r) => r.type === 'proxmox_lxc');
232
- const attrs = lxc?.instances?.[0]?.attributes;
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()