@celilo/cli 0.5.0-alpha.8 → 0.5.0-alpha.9
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 +78 -0
- package/src/api-clients/proxmox.ts +96 -1
- package/src/cli/command-registry.ts +32 -3
- package/src/cli/commands/backup-delete.ts +10 -7
- package/src/cli/commands/backup-import.ts +11 -8
- package/src/cli/commands/backup-restore.ts +11 -8
- package/src/cli/commands/events.ts +8 -3
- package/src/cli/commands/machine-add.ts +178 -163
- package/src/cli/commands/machine-remove.ts +10 -7
- package/src/cli/commands/module-config.test.ts +78 -0
- package/src/cli/commands/module-config.ts +18 -3
- package/src/cli/commands/module-import.ts +9 -5
- package/src/cli/commands/module-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +10 -6
- package/src/cli/commands/proxmox-node-list.ts +101 -0
- package/src/cli/commands/proxmox-template-selection.ts +16 -15
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +275 -260
- package/src/cli/commands/service-reconfigure.ts +171 -153
- package/src/cli/commands/service-remove.ts +19 -13
- package/src/cli/commands/service-verify.ts +9 -10
- package/src/cli/commands/storage-add-local.ts +120 -107
- package/src/cli/commands/storage-add-s3.ts +145 -131
- package/src/cli/commands/storage-remove.ts +11 -8
- package/src/cli/commands/system-init.ts +119 -128
- package/src/cli/completion.ts +15 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/service-credential.ts +54 -0
- package/src/services/bus-interview.ts +232 -0
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +6 -1
- package/src/services/placement-reconcile.test.ts +86 -0
- package/src/services/placement-reconcile.ts +108 -0
- package/src/services/programmatic-responder.ts +34 -0
- package/src/services/terminal-responder.ts +113 -0
- package/src/templates/generator.test.ts +30 -0
- package/src/templates/generator.ts +86 -31
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Re-run the interactive interview for an existing service's provider configuration
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import * as p from '@clack/prompts';
|
|
7
6
|
import {
|
|
8
7
|
type ProxmoxCredentials,
|
|
9
8
|
type ProxmoxVmTemplate,
|
|
@@ -12,14 +11,14 @@ import {
|
|
|
12
11
|
listAvailableTemplates,
|
|
13
12
|
listNodeStorage,
|
|
14
13
|
} from '../../api-clients/proxmox';
|
|
14
|
+
import { askConfirm, askSelect, askText, withInterviewSession } from '../../services/bus-interview';
|
|
15
15
|
import {
|
|
16
16
|
getContainerServiceByServiceId,
|
|
17
17
|
getServiceCredentials,
|
|
18
18
|
updateServiceProviderConfig,
|
|
19
19
|
} from '../../services/container-service';
|
|
20
|
-
import { celiloIntro, celiloOutro
|
|
20
|
+
import { celiloIntro, celiloOutro } from '../prompts';
|
|
21
21
|
import type { CommandResult } from '../types';
|
|
22
|
-
import { validateRequired } from '../validators';
|
|
23
22
|
import {
|
|
24
23
|
runApplianceDownload,
|
|
25
24
|
selectUbuntuApplianceFromCatalog,
|
|
@@ -116,177 +115,196 @@ export async function handleServiceReconfigure(
|
|
|
116
115
|
};
|
|
117
116
|
}
|
|
118
117
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
// Every prompt below is a bus interview (ISS-0127), so reconfigure is
|
|
119
|
+
// drivable headlessly via `celilo events respond --values` / `events reply`.
|
|
120
|
+
// `withInterviewSession` runs a terminal-responder when stdin is a TTY so
|
|
121
|
+
// those questions render locally; on a non-TTY run the headless responder
|
|
122
|
+
// answers instead.
|
|
123
|
+
const scope = `service:${serviceId}`;
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
return withInterviewSession(async () => {
|
|
126
|
+
try {
|
|
127
|
+
const service = await getContainerServiceByServiceId(serviceId);
|
|
128
|
+
if (!service) {
|
|
129
|
+
return { success: false, error: `Service '${serviceId}' not found` };
|
|
130
|
+
}
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
if (service.providerName !== 'proxmox') {
|
|
133
|
+
return {
|
|
134
|
+
success: false,
|
|
135
|
+
error: `Service reconfigure is currently only supported for Proxmox services (got: ${service.providerName})`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
133
138
|
|
|
134
|
-
|
|
135
|
-
const currentConfig = service.providerConfig as unknown as ProxmoxProviderConfig;
|
|
139
|
+
celiloIntro(`Reconfigure Service: ${service.name}`);
|
|
136
140
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
console.log(` Target node: ${currentConfig.default_target_node}`);
|
|
140
|
-
console.log(` Storage: ${currentConfig.storage}`);
|
|
141
|
-
console.log(` LXC template: ${currentConfig.lxc_template}`);
|
|
142
|
-
console.log(` VM template: ${currentConfig.vm_template ?? '(none)'}`);
|
|
143
|
-
console.log('');
|
|
141
|
+
const credentials = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
|
|
142
|
+
const currentConfig = service.providerConfig as unknown as ProxmoxProviderConfig;
|
|
144
143
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
// Show current configuration
|
|
145
|
+
console.log('Current configuration:');
|
|
146
|
+
console.log(` Target node: ${currentConfig.default_target_node}`);
|
|
147
|
+
console.log(` Storage: ${currentConfig.storage}`);
|
|
148
|
+
console.log(` LXC template: ${currentConfig.lxc_template}`);
|
|
149
|
+
console.log(` VM template: ${currentConfig.vm_template ?? '(none)'}`);
|
|
150
|
+
console.log('');
|
|
152
151
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
152
|
+
// Re-prompt for configurable fields with current values as defaults
|
|
153
|
+
const targetNode = await askText({
|
|
154
|
+
scope,
|
|
155
|
+
key: 'default_target_node',
|
|
156
|
+
message: 'Default target node',
|
|
157
|
+
defaultValue: currentConfig.default_target_node,
|
|
158
|
+
placeholder: currentConfig.default_target_node,
|
|
159
|
+
required: true,
|
|
160
|
+
});
|
|
159
161
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
const storage = await askText({
|
|
163
|
+
scope,
|
|
164
|
+
key: 'storage',
|
|
165
|
+
message: 'Default storage',
|
|
166
|
+
defaultValue: currentConfig.storage,
|
|
167
|
+
placeholder: currentConfig.storage,
|
|
168
|
+
required: true,
|
|
169
|
+
});
|
|
164
170
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
);
|
|
170
|
-
if (vztmplStorage) {
|
|
171
|
-
templateStorage = vztmplStorage.storage;
|
|
172
|
-
console.log(`✓ Using storage '${templateStorage}' for templates`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
171
|
+
// Find template storage. Done before catalog selection so the user only
|
|
172
|
+
// ever sees the chosen storage in subsequent messages.
|
|
173
|
+
console.log('\nFinding storage for templates...');
|
|
174
|
+
const storageListResult = await listNodeStorage(credentials, targetNode);
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return { success: false, error: selectionResult.message };
|
|
187
|
-
}
|
|
188
|
-
const templateFilename = selectionResult.choice.template;
|
|
189
|
-
const lxcTemplate = buildTemplatePath(templateStorage, templateFilename);
|
|
176
|
+
let templateStorage = 'local';
|
|
177
|
+
if (storageListResult.success) {
|
|
178
|
+
const vztmplStorage = storageListResult.data.find(
|
|
179
|
+
(s) => s.active && s.enabled && s.content.includes('vztmpl'),
|
|
180
|
+
);
|
|
181
|
+
if (vztmplStorage) {
|
|
182
|
+
templateStorage = vztmplStorage.storage;
|
|
183
|
+
console.log(`✓ Using storage '${templateStorage}' for templates`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
190
186
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
187
|
+
// Pick a template from Proxmox's live catalog. Pre-selects the family
|
|
188
|
+
// matching the existing volid (e.g. user has 24.04 -1 cached, mirror has -2).
|
|
189
|
+
// The select + manual-entry fallback inside go through the bus interview
|
|
190
|
+
// too (ISS-0127), scoped to this service.
|
|
191
|
+
const selectionResult = await selectUbuntuApplianceFromCatalog(credentials, targetNode, {
|
|
192
|
+
scope,
|
|
193
|
+
currentTemplate: extractTemplateFilename(currentConfig.lxc_template),
|
|
194
|
+
});
|
|
195
|
+
if (selectionResult.kind === 'cancelled') {
|
|
196
|
+
return { success: false, error: 'Cancelled by user' };
|
|
197
|
+
}
|
|
198
|
+
if (selectionResult.kind === 'error') {
|
|
199
|
+
return { success: false, error: selectionResult.message };
|
|
200
|
+
}
|
|
201
|
+
const templateFilename = selectionResult.choice.template;
|
|
202
|
+
const lxcTemplate = buildTemplatePath(templateStorage, templateFilename);
|
|
194
203
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
204
|
+
// Check if new template exists in storage.
|
|
205
|
+
console.log(`\nChecking if template '${templateFilename}' exists...`);
|
|
206
|
+
const templatesResult = await listAvailableTemplates(
|
|
207
|
+
credentials,
|
|
208
|
+
targetNode,
|
|
209
|
+
templateStorage,
|
|
210
|
+
);
|
|
199
211
|
|
|
200
|
-
|
|
201
|
-
|
|
212
|
+
let templateExists = false;
|
|
213
|
+
if (templatesResult.success) {
|
|
214
|
+
templateExists = templatesResult.data.some((t) => t.volid.includes(templateFilename));
|
|
215
|
+
}
|
|
202
216
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
initialValue: true,
|
|
206
|
-
});
|
|
217
|
+
if (!templateExists) {
|
|
218
|
+
console.log(`✗ Template '${templateFilename}' not found`);
|
|
207
219
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const outcome = await runApplianceDownload({
|
|
214
|
-
credentials,
|
|
215
|
-
targetNode,
|
|
216
|
-
templateStorage,
|
|
217
|
-
templateFilename,
|
|
220
|
+
const shouldDownload = await askConfirm({
|
|
221
|
+
scope,
|
|
222
|
+
key: 'download_template',
|
|
223
|
+
message: 'Download template now?',
|
|
224
|
+
defaultValue: true,
|
|
218
225
|
});
|
|
219
226
|
|
|
220
|
-
if (!
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
};
|
|
227
|
+
if (!shouldDownload) {
|
|
228
|
+
console.log(
|
|
229
|
+
'\nTemplate not downloaded. Service will be updated but may fail verification.',
|
|
230
|
+
);
|
|
231
|
+
} else {
|
|
232
|
+
const outcome = await runApplianceDownload({
|
|
233
|
+
credentials,
|
|
234
|
+
targetNode,
|
|
235
|
+
templateStorage,
|
|
236
|
+
templateFilename,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!outcome.ready) {
|
|
240
|
+
const detail =
|
|
241
|
+
outcome.reason === 'task-failed'
|
|
242
|
+
? `pveam download exited with status: ${outcome.exitStatus ?? 'unknown'}`
|
|
243
|
+
: outcome.reason === 'started-failed'
|
|
244
|
+
? `Proxmox rejected the download request: ${outcome.startError ?? 'unknown error'}`
|
|
245
|
+
: 'Template download did not complete in time';
|
|
246
|
+
return {
|
|
247
|
+
success: false,
|
|
248
|
+
error: `${detail}\n\nTroubleshooting:\n 1. SSH into your Proxmox host and run: pveam update && pveam download ${templateStorage} ${templateFilename}\n 2. Re-try: celilo service reconfigure ${serviceId}\n 3. Check DNS and firewall settings on the Proxmox host`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
231
251
|
}
|
|
252
|
+
} else {
|
|
253
|
+
console.log(`✓ Template '${templateFilename}' found`);
|
|
232
254
|
}
|
|
233
|
-
} else {
|
|
234
|
-
console.log(`✓ Template '${templateFilename}' found`);
|
|
235
|
-
}
|
|
236
255
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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,
|
|
256
|
+
// VM template (for `requires.system.type: vm` modules). Keep the current
|
|
257
|
+
// value, swap to another detected template, build a new one, or clear it.
|
|
258
|
+
// Without this step a reconfigure would silently drop vm_template (ISS-0128).
|
|
259
|
+
let vmTemplate = currentConfig.vm_template;
|
|
260
|
+
const existingTemplates = await detectExistingVmTemplates(credentials, targetNode);
|
|
261
|
+
const vmChoice = await askSelect({
|
|
262
|
+
scope,
|
|
263
|
+
key: 'vm_template',
|
|
264
|
+
message: 'VM template for VM-type modules',
|
|
265
|
+
options: buildVmTemplateChoices(currentConfig.vm_template, existingTemplates),
|
|
255
266
|
});
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
267
|
+
if (vmChoice === VM_BUILD) {
|
|
268
|
+
vmTemplate = await buildCloudInitTemplate({
|
|
269
|
+
credentials,
|
|
270
|
+
nodeName: targetNode,
|
|
271
|
+
diskStorage: storage,
|
|
272
|
+
});
|
|
273
|
+
} else if (vmChoice === VM_CLEAR || vmChoice === VM_SKIP) {
|
|
274
|
+
vmTemplate = undefined;
|
|
275
|
+
} else if (vmChoice !== VM_KEEP) {
|
|
276
|
+
vmTemplate = vmChoice; // an existing template name
|
|
277
|
+
}
|
|
278
|
+
// VM_KEEP leaves vmTemplate at currentConfig.vm_template.
|
|
262
279
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
280
|
+
// Update service configuration. resolveReconfiguredProviderConfig preserves
|
|
281
|
+
// every existing key and carries vm_template forward (ISS-0128 drop guard).
|
|
282
|
+
const newConfig = resolveReconfiguredProviderConfig({
|
|
283
|
+
default_target_node: targetNode,
|
|
284
|
+
lxc_template: lxcTemplate,
|
|
285
|
+
storage,
|
|
286
|
+
vm_template: vmTemplate,
|
|
287
|
+
});
|
|
288
|
+
await updateServiceProviderConfig(service.id, newConfig);
|
|
272
289
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
290
|
+
celiloOutro(
|
|
291
|
+
`Service '${serviceId}' reconfigured successfully!\n\n` +
|
|
292
|
+
` Target node: ${targetNode}\n` +
|
|
293
|
+
` Storage: ${storage}\n` +
|
|
294
|
+
` LXC template: ${lxcTemplate}\n` +
|
|
295
|
+
` VM template: ${vmTemplate ?? '(none)'}\n\n` +
|
|
296
|
+
`Next steps:\n - Verify: celilo service verify ${serviceId}\n - Deploy: celilo module deploy <module-id>`,
|
|
297
|
+
);
|
|
281
298
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
299
|
+
return {
|
|
300
|
+
success: true,
|
|
301
|
+
message: `Reconfigured service: ${serviceId}`,
|
|
302
|
+
};
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
error: `Failed to reconfigure service: ${error instanceof Error ? error.message : String(error)}`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
});
|
|
292
310
|
}
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* Remove a container service
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import * as p from '@clack/prompts';
|
|
7
6
|
import { eq } from 'drizzle-orm';
|
|
8
7
|
import { getDb } from '../../db/client';
|
|
9
8
|
import { moduleInfrastructure } from '../../db/schema';
|
|
9
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
10
10
|
import {
|
|
11
11
|
getContainerServiceByServiceId,
|
|
12
12
|
removeContainerService,
|
|
@@ -60,13 +60,16 @@ export async function handleServiceRemove(
|
|
|
60
60
|
console.log('');
|
|
61
61
|
|
|
62
62
|
if (!flags.force) {
|
|
63
|
-
const confirmed = await
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
const confirmed = await withInterviewSession(() =>
|
|
64
|
+
askConfirm({
|
|
65
|
+
scope: `service:${service.serviceId}`,
|
|
66
|
+
key: 'remove_unassign_modules',
|
|
67
|
+
message: 'Remove service and unassign modules?',
|
|
68
|
+
defaultValue: false,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
67
71
|
|
|
68
|
-
if (
|
|
69
|
-
p.cancel('Operation cancelled');
|
|
72
|
+
if (!confirmed) {
|
|
70
73
|
return { success: false, error: 'Cancelled by user' };
|
|
71
74
|
}
|
|
72
75
|
}
|
|
@@ -74,13 +77,16 @@ export async function handleServiceRemove(
|
|
|
74
77
|
|
|
75
78
|
// Confirm deletion
|
|
76
79
|
if (!flags.force) {
|
|
77
|
-
const confirmed = await
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
const confirmed = await withInterviewSession(() =>
|
|
81
|
+
askConfirm({
|
|
82
|
+
scope: `service:${service.serviceId}`,
|
|
83
|
+
key: 'remove',
|
|
84
|
+
message: `Remove service '${service.serviceId}' (${service.name})?`,
|
|
85
|
+
defaultValue: false,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
81
88
|
|
|
82
|
-
if (
|
|
83
|
-
p.cancel('Operation cancelled');
|
|
89
|
+
if (!confirmed) {
|
|
84
90
|
return { success: false, error: 'Cancelled by user' };
|
|
85
91
|
}
|
|
86
92
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* Re-verify a container service connection
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import * as p from '@clack/prompts';
|
|
7
6
|
import { extractTemplateFilename } from '../../api-clients/proxmox';
|
|
7
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
8
8
|
import {
|
|
9
9
|
type ProxmoxCredentials,
|
|
10
10
|
getContainerServiceByServiceId,
|
|
@@ -91,15 +91,14 @@ export async function handleServiceVerify(
|
|
|
91
91
|
testResult.message.includes('Template') &&
|
|
92
92
|
testResult.message.includes('not found')
|
|
93
93
|
) {
|
|
94
|
-
const shouldDownload = await
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
94
|
+
const shouldDownload = await withInterviewSession(() =>
|
|
95
|
+
askConfirm({
|
|
96
|
+
scope: `service:${service.serviceId}`,
|
|
97
|
+
key: 'download_template',
|
|
98
|
+
message: 'Download missing template now?',
|
|
99
|
+
defaultValue: true,
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
103
102
|
|
|
104
103
|
if (shouldDownload) {
|
|
105
104
|
try {
|