@celilo/cli 0.5.0-alpha.10 → 0.5.0-alpha.12

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.10",
3
+ "version": "0.5.0-alpha.12",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 {
@@ -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()