@celilo/cli 0.5.0-alpha.5 → 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.
Files changed (33) hide show
  1. package/package.json +2 -2
  2. package/src/ansible/inventory.test.ts +10 -10
  3. package/src/ansible/validation.test.ts +25 -15
  4. package/src/api-clients/proxmox.test.ts +123 -1
  5. package/src/api-clients/proxmox.ts +292 -7
  6. package/src/cli/commands/events.test.ts +4 -4
  7. package/src/cli/commands/events.ts +2 -2
  8. package/src/cli/commands/proxmox-vm-template-build.ts +164 -0
  9. package/src/cli/commands/service-add-proxmox.ts +62 -3
  10. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  11. package/src/cli/commands/service-reconfigure.ts +110 -5
  12. package/src/cli/index.ts +2 -2
  13. package/src/config/paths.test.ts +61 -48
  14. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  15. package/src/infrastructure/property-extractor.test.ts +15 -0
  16. package/src/infrastructure/property-extractor.ts +12 -0
  17. package/src/manifest/schema.ts +7 -0
  18. package/src/manifest/validate.test.ts +53 -0
  19. package/src/services/bus-interview.test.ts +2 -2
  20. package/src/services/bus-secret-flow.test.ts +2 -2
  21. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  22. package/src/services/deploy-validation.test.ts +2 -2
  23. package/src/services/dns-provider-backfill.test.ts +2 -2
  24. package/src/services/dns-registrations.test.ts +10 -10
  25. package/src/services/module-build.test.ts +43 -38
  26. package/src/templates/generator.test.ts +62 -12
  27. package/src/templates/generator.ts +48 -50
  28. package/src/test-utils/fixtures.test.ts +1 -1
  29. package/src/test-utils/integration-guard.ts +33 -0
  30. package/src/types/infrastructure.ts +6 -0
  31. package/src/variables/computed/computed-integration.test.ts +3 -3
  32. package/src/variables/computed/computed.test.ts +5 -5
  33. package/src/variables/declarative-derivation.test.ts +6 -6
@@ -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,14 +123,71 @@ export async function handleServiceAddProxmox(
122
123
  validate: validateRequired('Storage'),
123
124
  });
124
125
 
125
- // Find storage that supports vztmpl content. We do this BEFORE prompting
126
- // for a template so the user only ever sees one storage in subsequent
127
- // 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.
128
128
  const credentials = {
129
129
  api_url: apiUrl,
130
130
  api_token_id: apiTokenId,
131
131
  api_token_secret: apiTokenSecret,
132
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.
133
191
  console.log('\nFinding storage for templates...');
134
192
  const storageListResult = await listNodeStorage(credentials, targetNode);
135
193
 
@@ -178,6 +236,7 @@ export async function handleServiceAddProxmox(
178
236
  default_target_node: targetNode,
179
237
  lxc_template: lxcTemplate,
180
238
  storage,
239
+ ...(vmTemplate ? { vm_template: vmTemplate } : {}),
181
240
  },
182
241
  });
183
242
 
@@ -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 ProxmoxProviderConfig {
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(` Template: ${currentConfig.lxc_template}`);
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
- // Update service configuration
163
- await updateServiceProviderConfig(service.id, {
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
- ` Template: ${lxcTemplate}\n\n` +
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
 
package/src/cli/index.ts CHANGED
@@ -287,7 +287,7 @@ Examples:
287
287
  celilo events tail --type deploy.completed.lunacycle # filter by type
288
288
  celilo events emit deploy.completed.lunacycle '{}' # operator-fired event
289
289
  celilo events tail --type 'config.required.*' # see pending deploy questions
290
- celilo events reply 42 '"lunacycle.net"' # answer query #42 (config)
290
+ celilo events reply 42 '"example.net"' # answer query #42 (config)
291
291
  `;
292
292
  return { success: true, message: helpText.trim() };
293
293
  }
@@ -1091,7 +1091,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1091
1091
  '',
1092
1092
  'Examples:',
1093
1093
  ' celilo hook run namecheap validate_config --debug',
1094
- ' celilo hook run namecheap container_created vps_ip=138.68.140.177',
1094
+ ' celilo hook run namecheap container_created vps_ip=192.0.2.30',
1095
1095
  ].join('\n'),
1096
1096
  };
1097
1097
  }