@celilo/cli 0.5.0-alpha.9 → 0.6.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.
@@ -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) {
@@ -41,7 +41,7 @@ import {
41
41
  import type { SystemUpdateResult } from '../../services/update/types';
42
42
  import { getFlag, hasFlag } from '../parser';
43
43
  import type { CommandResult } from '../types';
44
- import { fetchAndUpgrade } from './module-upgrade';
44
+ import { fetchAndUpdate } from './module-update';
45
45
 
46
46
  /**
47
47
  * Packages we manage via the global bun install. The self-update step
@@ -275,7 +275,7 @@ async function buildSnapshots(
275
275
  * - `backup` calls `createModuleBackup`. Modules without an
276
276
  * `on_backup` hook return ok (nothing to back up; not an error).
277
277
  * - `upgrade` fetches the latest version from the registry and runs
278
- * the in-place upgrade (`fetchAndUpgrade` from module-upgrade.ts).
278
+ * the in-place upgrade (`fetchAndUpdate` from module-upgrade.ts).
279
279
  * Same code path that `module update` uses for its sweep mode.
280
280
  * - `deploy` calls `deployModule`.
281
281
  * - `health` calls `runModuleHealthCheck`.
@@ -314,7 +314,7 @@ function buildOps(registry: RegistryClient, wasDeployed: Set<string>): Orchestra
314
314
  if (!latest) {
315
315
  return { ok: false, error: `${moduleId}: no non-yanked version` };
316
316
  }
317
- const result = await fetchAndUpgrade(registry, moduleId, latest.vers, db, {});
317
+ const result = await fetchAndUpdate(registry, moduleId, latest.vers, db, {});
318
318
  if (result.status === 'success') return { ok: true };
319
319
  if (result.status === 'failed') return { ok: false, error: result.error };
320
320
  // 'skipped' isn't expected here (the module IS installed), but
@@ -141,6 +141,7 @@ export async function getCompletions(words: string[], current: number): Promise<
141
141
  'remove',
142
142
  'search',
143
143
  'update',
144
+ 'upgrade',
144
145
  'verify',
145
146
  'audit',
146
147
  'backup',
package/src/cli/index.ts CHANGED
@@ -63,6 +63,7 @@ import { handleModuleShowConfig, handleModuleShowZone } from './commands/module-
63
63
  import { handleModuleStatus } from './commands/module-status';
64
64
  import { handleModuleTerraformUnlock } from './commands/module-terraform-unlock';
65
65
  import { handleModuleTypesCheck, handleModuleTypesGenerate } from './commands/module-types';
66
+ import { handleModuleUpdate } from './commands/module-update';
66
67
  import { handleModuleUpgrade } from './commands/module-upgrade';
67
68
  import { moduleVerify } from './commands/module-verify';
68
69
  import { handlePackage } from './commands/package';
@@ -1266,6 +1267,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1266
1267
  case 'remove':
1267
1268
  return handleModuleRemove(parsed.args, parsed.flags);
1268
1269
  case 'update':
1270
+ return handleModuleUpdate(parsed.args, parsed.flags);
1271
+ case 'upgrade':
1269
1272
  return handleModuleUpgrade(parsed.args, parsed.flags);
1270
1273
  case 'audit':
1271
1274
  return moduleAudit(parsed.args);
@@ -63,9 +63,13 @@ const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName:
63
63
  script: 'scripts/idp-functions.ts',
64
64
  legacyFactoryName: 'createIdp',
65
65
  },
66
- git_forge: {
67
- script: 'scripts/git-forge-functions.ts',
68
- legacyFactoryName: 'createForgejoGitForge',
66
+ source_forge: {
67
+ script: 'scripts/source-forge-functions.ts',
68
+ legacyFactoryName: 'createForgejoSourceForge',
69
+ },
70
+ registry_publish: {
71
+ script: 'scripts/registry-publish-functions.ts',
72
+ legacyFactoryName: 'default',
69
73
  },
70
74
  dhcp_server: {
71
75
  script: 'scripts/dhcp-server-functions.ts',
@@ -237,7 +237,9 @@ export type ComputedField = z.infer<typeof ComputedFieldSchema>;
237
237
  export const CapabilityProviderSchema = z.object({
238
238
  name: z.string().min(1),
239
239
  version: z.string().min(1),
240
- data: z.record(z.unknown()),
240
+ // A purely imperative capability (functions-only, no cross-module data —
241
+ // e.g. registry_publish) carries no `data` block; default to {}.
242
+ data: z.record(z.unknown()).default({}),
241
243
  /**
242
244
  * Computed fields, merged into the capability's data namespace at access
243
245
  * time. A consumer reads `$capability:<name>.<computed-field>` exactly like
@@ -0,0 +1,106 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { classifyVersionDelta, parseVersion, resolveDeployPosture } from './deploy-posture';
3
+
4
+ describe('parseVersion', () => {
5
+ test('parses major.minor.patch+build', () => {
6
+ expect(parseVersion('1.2.3+28')).toEqual({
7
+ major: 1,
8
+ minor: 2,
9
+ patch: 3,
10
+ prerelease: '',
11
+ build: '28',
12
+ });
13
+ });
14
+
15
+ test('parses a prerelease', () => {
16
+ expect(parseVersion('0.5.0-alpha.9')).toEqual({
17
+ major: 0,
18
+ minor: 5,
19
+ patch: 0,
20
+ prerelease: 'alpha.9',
21
+ build: '',
22
+ });
23
+ });
24
+
25
+ test('tolerates a bare version', () => {
26
+ expect(parseVersion('2.0.0')).toEqual({
27
+ major: 2,
28
+ minor: 0,
29
+ patch: 0,
30
+ prerelease: '',
31
+ build: '',
32
+ });
33
+ });
34
+ });
35
+
36
+ describe('classifyVersionDelta (highest-order component that changed)', () => {
37
+ test('major / minor / patch', () => {
38
+ expect(classifyVersionDelta('1.0.0', '2.0.0')).toBe('major');
39
+ expect(classifyVersionDelta('1.0.0', '1.1.0')).toBe('minor');
40
+ expect(classifyVersionDelta('1.0.0', '1.0.1')).toBe('patch');
41
+ });
42
+
43
+ test('build-metadata-only change is a revision', () => {
44
+ expect(classifyVersionDelta('1.0.1+27', '1.0.1+28')).toBe('revision');
45
+ });
46
+
47
+ test('prerelease-only change counts as patch (still fast)', () => {
48
+ expect(classifyVersionDelta('0.5.0-alpha.8', '0.5.0-alpha.9')).toBe('patch');
49
+ });
50
+
51
+ test('identical versions → none', () => {
52
+ expect(classifyVersionDelta('1.0.1+28', '1.0.1+28')).toBe('none');
53
+ });
54
+
55
+ test('highest-order wins when several components differ', () => {
56
+ expect(classifyVersionDelta('1.0.1+5', '1.1.0+1')).toBe('minor');
57
+ });
58
+ });
59
+
60
+ describe('resolveDeployPosture (precedence)', () => {
61
+ test('default by-semver: revision + patch ⇒ fast', () => {
62
+ expect(resolveDeployPosture({ installed: '1.0.1+1', next: '1.0.1+2' }).posture).toBe('fast');
63
+ expect(resolveDeployPosture({ installed: '1.0.1', next: '1.0.2' }).posture).toBe('fast');
64
+ });
65
+
66
+ test('default by-semver: minor + major ⇒ safe', () => {
67
+ expect(resolveDeployPosture({ installed: '1.0.0', next: '1.1.0' }).posture).toBe('safe');
68
+ expect(resolveDeployPosture({ installed: '1.0.0', next: '2.0.0' }).posture).toBe('safe');
69
+ });
70
+
71
+ test('per-release deploy_posture overrides the semver default', () => {
72
+ // a minor bump would default to safe; the release pins fast.
73
+ const r = resolveDeployPosture({ installed: '1.0.0', next: '1.1.0', releasePosture: 'fast' });
74
+ expect(r.posture).toBe('fast');
75
+ expect(r.reason).toContain('release deploy_posture');
76
+ });
77
+
78
+ test('module always-safe is a hard floor — beats a per-release fast', () => {
79
+ const r = resolveDeployPosture({
80
+ installed: '1.0.1',
81
+ next: '1.0.2',
82
+ releasePosture: 'fast',
83
+ modulePolicy: 'always-safe',
84
+ });
85
+ expect(r.posture).toBe('safe');
86
+ expect(r.reason).toContain('always-safe');
87
+ });
88
+
89
+ test('module always-fast forces fast on a minor bump (when no release override)', () => {
90
+ expect(
91
+ resolveDeployPosture({ installed: '1.0.0', next: '1.1.0', modulePolicy: 'always-fast' })
92
+ .posture,
93
+ ).toBe('fast');
94
+ });
95
+
96
+ test('a per-release safe still beats always-fast (release intent wins below the floor)', () => {
97
+ expect(
98
+ resolveDeployPosture({
99
+ installed: '1.0.1',
100
+ next: '1.0.2',
101
+ releasePosture: 'safe',
102
+ modulePolicy: 'always-fast',
103
+ }).posture,
104
+ ).toBe('safe');
105
+ });
106
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Deploy posture — fast vs. safe — for `celilo module upgrade` (ISS-0138,
3
+ * v2/BUILD_BUS.md).
4
+ *
5
+ * CD is CI-driven, so no operator is present to pass `--no-backup`. The posture
6
+ * (fast = skip backup + extended verify; safe = backup + full verify) is
7
+ * DERIVED, with this precedence:
8
+ * 1. per-module `upgrade_policy: always-safe` — a hard floor (always safe).
9
+ * 2. per-release `deploy_posture` stamped in the .netapp release metadata.
10
+ * 3. per-module `upgrade_policy: always-fast`.
11
+ * 4. default: the semver delta of installed → next (revision/patch = fast;
12
+ * minor/major = safe).
13
+ *
14
+ * celilo reads version NUMBERS, never changesets — the default needs only the
15
+ * versions. All functions here are pure (Rule 10).
16
+ */
17
+
18
+ export type DeployPosture = 'fast' | 'safe';
19
+ export type VersionDelta = 'none' | 'revision' | 'patch' | 'minor' | 'major';
20
+ export type UpgradePolicy = 'by-semver' | 'always-safe' | 'always-fast';
21
+
22
+ export interface ParsedVersion {
23
+ major: number;
24
+ minor: number;
25
+ patch: number;
26
+ /** prerelease tag, e.g. "alpha.2"; '' when absent. */
27
+ prerelease: string;
28
+ /** build metadata — celilo's `+N` registry revision; '' when absent. */
29
+ build: string;
30
+ }
31
+
32
+ /** Parse a celilo module version: `major.minor.patch[-prerelease][+build]`. */
33
+ export function parseVersion(version: string): ParsedVersion {
34
+ const [coreAndPre = '', build = ''] = version.split('+');
35
+ const [core = '', prerelease = ''] = coreAndPre.split('-');
36
+ const parts = core.split('.');
37
+ const num = (i: number) => Number.parseInt(parts[i] ?? '', 10) || 0;
38
+ return { major: num(0), minor: num(1), patch: num(2), prerelease, build };
39
+ }
40
+
41
+ /**
42
+ * Classify the delta between an installed version and the next one by the
43
+ * highest-order component that changed. Assumes `next` >= `installed` (the poll
44
+ * only upgrades). A prerelease-only change counts as `patch` (still fast); a
45
+ * build-metadata-only change (`+N`) is `revision`.
46
+ */
47
+ export function classifyVersionDelta(installed: string, next: string): VersionDelta {
48
+ const a = parseVersion(installed);
49
+ const b = parseVersion(next);
50
+ if (a.major !== b.major) return 'major';
51
+ if (a.minor !== b.minor) return 'minor';
52
+ if (a.patch !== b.patch) return 'patch';
53
+ if (a.prerelease !== b.prerelease) return 'patch';
54
+ if (a.build !== b.build) return 'revision';
55
+ return 'none';
56
+ }
57
+
58
+ /**
59
+ * Resolve the deploy posture for an upgrade. Precedence: `always-safe` floor →
60
+ * per-release override → `always-fast` → semver-delta default. Returns the
61
+ * posture plus a short reason for operator-visible logging.
62
+ */
63
+ export function resolveDeployPosture(opts: {
64
+ installed: string;
65
+ next: string;
66
+ releasePosture?: DeployPosture | null;
67
+ modulePolicy?: UpgradePolicy;
68
+ }): { posture: DeployPosture; reason: string } {
69
+ const policy = opts.modulePolicy ?? 'by-semver';
70
+
71
+ if (policy === 'always-safe') {
72
+ return { posture: 'safe', reason: 'module upgrade_policy=always-safe' };
73
+ }
74
+ if (opts.releasePosture) {
75
+ return {
76
+ posture: opts.releasePosture,
77
+ reason: `release deploy_posture=${opts.releasePosture}`,
78
+ };
79
+ }
80
+ if (policy === 'always-fast') {
81
+ return { posture: 'fast', reason: 'module upgrade_policy=always-fast' };
82
+ }
83
+
84
+ const delta = classifyVersionDelta(opts.installed, opts.next);
85
+ const posture: DeployPosture = delta === 'minor' || delta === 'major' ? 'safe' : 'fast';
86
+ return { posture, reason: `semver delta=${delta}` };
87
+ }
@@ -1,9 +1,12 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
- import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { defineEvents, openBus } from '@celilo/event-bus';
7
+ import { parse as parseYaml } from 'yaml';
6
8
  import { closeDb, getDb } from '../db/client';
9
+ import { ModuleManifestSchema } from '../manifest/schema';
7
10
  import { ModuleSubscriptionSchema } from '../manifest/schema';
8
11
  import type { ModuleManifest } from '../manifest/schema';
9
12
  import { setupTestDatabase as migrateDbFile } from '../test-utils/setup-test-db';
@@ -341,3 +344,30 @@ describe('resyncAllSubscriptions (ISS-0088)', () => {
341
344
  expect(subscriberNames()).toEqual(['caddy.reconcile']);
342
345
  });
343
346
  });
347
+
348
+ describe('build-bus registry-poll wiring (ISS-0139)', () => {
349
+ // The CD poll is wired as a celilo-mgmt manifest subscription so it registers
350
+ // ONLY on the management host. Guard the exact handler/pattern against drift.
351
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
352
+
353
+ it('celilo-mgmt subscribes the registry poll to timer.tick.15m', () => {
354
+ const manifestPath = join(REPO_ROOT, 'modules', 'celilo-mgmt', 'manifest.yml');
355
+ const manifest = ModuleManifestSchema.parse(parseYaml(readFileSync(manifestPath, 'utf-8')));
356
+
357
+ const poll = manifest.subscriptions?.find((s) => s.name === 'registry-poll');
358
+ expect(poll).toBeDefined();
359
+ expect(poll?.pattern).toBe('timer.tick.15m');
360
+ // A literal handler command (the CLI poll), not a hook — see manifest comment.
361
+ expect(poll?.handler).toBe('celilo module upgrade');
362
+ expect(poll?.hook).toBeUndefined();
363
+
364
+ const resolved = resolveSubscription(
365
+ // biome-ignore lint/style/noNonNullAssertion: asserted defined above
366
+ poll!,
367
+ 'celilo-mgmt',
368
+ '/modules/celilo-mgmt',
369
+ );
370
+ expect(resolved.name).toBe('celilo-mgmt.registry-poll');
371
+ expect(resolved.handler).toBe('celilo module upgrade');
372
+ });
373
+ });
@@ -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()