@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.11
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/drizzle/0009_dns_registrations.sql +13 -0
- package/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +3 -3
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/api-clients/proxmox.test.ts +211 -1
- package/src/api-clients/proxmox.ts +399 -8
- package/src/cli/command-registry.ts +83 -6
- 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/dns.ts +57 -0
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +89 -24
- 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-publish.ts +24 -0
- package/src/cli/commands/module-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.test.ts +37 -0
- package/src/cli/commands/module-upgrade.ts +26 -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/proxmox-vm-template-build.ts +171 -0
- package/src/cli/commands/publish/alpha.test.ts +26 -0
- package/src/cli/commands/publish/alpha.ts +23 -0
- package/src/cli/commands/publish/types.ts +7 -2
- package/src/cli/commands/publish/workspace.ts +11 -1
- package/src/cli/commands/restore.ts +29 -0
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +283 -209
- package/src/cli/commands/service-reconfigure.test.ts +115 -0
- package/src/cli/commands/service-reconfigure.ts +252 -129
- 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-doctor.ts +135 -40
- package/src/cli/commands/system-init.ts +119 -128
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +23 -0
- package/src/cli/index.ts +91 -7
- package/src/cli/service-credential.ts +54 -0
- package/src/config/paths.test.ts +61 -48
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +74 -0
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +43 -2
- package/src/hooks/run-named-hook.ts +28 -2
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +17 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-interview.ts +232 -0
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/deploy-validation.test.ts +54 -4
- package/src/services/deploy-validation.ts +27 -36
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-provider-backfill.ts +14 -2
- package/src/services/dns-registrations.test.ts +120 -0
- package/src/services/dns-registrations.ts +108 -0
- package/src/services/events-daemon.test.ts +59 -0
- package/src/services/events-daemon.ts +191 -57
- package/src/services/fleet-checks.test.ts +508 -0
- package/src/services/fleet-checks.ts +678 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +7 -6
- package/src/services/module-subscriptions.test.ts +88 -0
- package/src/services/module-subscriptions.ts +50 -1
- package/src/services/module-validator/bundled-deps.test.ts +55 -0
- package/src/services/module-validator/bundled-deps.ts +115 -0
- package/src/services/module-validator/capability-versions.test.ts +1 -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 +92 -12
- package/src/templates/generator.ts +165 -80
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- package/src/variables/declarative-derivation.test.ts +6 -6
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { eq } from 'drizzle-orm';
|
|
6
6
|
import { getDb } from '../../db/client';
|
|
7
7
|
import { capabilities, moduleConfigs, modules, secrets } from '../../db/schema';
|
|
8
|
+
import { getModuleSystems } from '../../services/deployed-systems';
|
|
9
|
+
import { formatPlacementLine, reconcilePlacement } from '../../services/placement-reconcile';
|
|
8
10
|
import { getArg, validateRequiredArgs } from '../parser';
|
|
9
11
|
import type { CommandResult } from '../types';
|
|
10
12
|
|
|
@@ -84,6 +86,19 @@ export async function handleModuleStatus(args: string[]): Promise<CommandResult>
|
|
|
84
86
|
}
|
|
85
87
|
sections.push(metadataLines.join('\n'));
|
|
86
88
|
|
|
89
|
+
// Section 1b: Placement (real node reconciled live from Proxmox — ISS-0060).
|
|
90
|
+
// Shows where each deployed system ACTUALLY lives, not the cached
|
|
91
|
+
// __infra_target_node config (which drifts). API-only modules (no deployed
|
|
92
|
+
// systems) skip this section; a Proxmox outage degrades to "node unknown".
|
|
93
|
+
const placements = await reconcilePlacement(getModuleSystems(moduleId, db));
|
|
94
|
+
if (placements.length > 0) {
|
|
95
|
+
const placementLines = ['Placement (live from Proxmox):'];
|
|
96
|
+
for (const p of placements) {
|
|
97
|
+
placementLines.push(` ${formatPlacementLine(p.system, p.resolution)}`);
|
|
98
|
+
}
|
|
99
|
+
sections.push(placementLines.join('\n'));
|
|
100
|
+
}
|
|
101
|
+
|
|
87
102
|
// Section 2: Configuration
|
|
88
103
|
if (configs.length > 0) {
|
|
89
104
|
const configLines = ['Configuration:'];
|
|
@@ -212,4 +212,41 @@ variables:
|
|
|
212
212
|
expect(manifest.name).toBe('Test Module Renamed');
|
|
213
213
|
expect(manifest.variables?.owns ?? []).toEqual([]);
|
|
214
214
|
});
|
|
215
|
+
|
|
216
|
+
// Regression for ISS-0091: `module update` used to skip event-bus
|
|
217
|
+
// subscription registration (only `module import` did it), so a
|
|
218
|
+
// refreshed module silently lost its reconcile subscriptions — found
|
|
219
|
+
// live when caddy's reconcile_routes subscription vanished after a
|
|
220
|
+
// path-based update.
|
|
221
|
+
test('registers the new manifest subscriptions on the bus (ISS-0091)', async () => {
|
|
222
|
+
process.env.EVENT_BUS_DB = join(tempDir, 'events.db');
|
|
223
|
+
try {
|
|
224
|
+
writeFileSync(
|
|
225
|
+
join(srcDir, 'manifest.yml'),
|
|
226
|
+
`celilo_contract: "1.0"
|
|
227
|
+
id: testmod
|
|
228
|
+
name: Test Module
|
|
229
|
+
version: 1.1.0
|
|
230
|
+
description: fixture with subscriptions
|
|
231
|
+
subscriptions:
|
|
232
|
+
- name: testmod-tick
|
|
233
|
+
pattern: timer.tick.15m
|
|
234
|
+
handler: "true"
|
|
235
|
+
`,
|
|
236
|
+
);
|
|
237
|
+
const result = await upgradeOne(srcDir, db, {}, { quiet: true });
|
|
238
|
+
expect(result.status).toBe('success');
|
|
239
|
+
|
|
240
|
+
const { openBus, defineEvents } = await import('@celilo/event-bus');
|
|
241
|
+
const bus = openBus({ dbPath: join(tempDir, 'events.db'), events: defineEvents({}) });
|
|
242
|
+
try {
|
|
243
|
+
// Subscriber names are scoped `<module-id>.<sub-name>` on the bus.
|
|
244
|
+
expect(bus.getSubscriberByName('testmod.testmod-tick')).not.toBeNull();
|
|
245
|
+
} finally {
|
|
246
|
+
bus.close();
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
215
252
|
});
|
|
@@ -13,7 +13,6 @@ import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
|
13
13
|
import { unlink } from 'node:fs/promises';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { join, resolve } from 'node:path';
|
|
16
|
-
import * as p from '@clack/prompts';
|
|
17
16
|
import { eq } from 'drizzle-orm';
|
|
18
17
|
import { parse as parseYaml } from 'yaml';
|
|
19
18
|
import { registerModuleCapabilities } from '../../capabilities/registration';
|
|
@@ -23,6 +22,7 @@ import { ModuleManifestSchema } from '../../manifest/schema';
|
|
|
23
22
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
24
23
|
import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
|
|
25
24
|
import { RegistryClient } from '../../registry/client';
|
|
25
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
26
26
|
import { getFlag } from '../parser';
|
|
27
27
|
import { log } from '../prompts';
|
|
28
28
|
import type { CommandResult } from '../types';
|
|
@@ -293,6 +293,22 @@ export async function upgradeOne(
|
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
// (Re-)register event-bus subscriptions from the new manifest —
|
|
297
|
+
// mirrors import.ts (ISS-0091: update used to skip this, so a
|
|
298
|
+
// refreshed module silently lost its reconcile subscriptions).
|
|
299
|
+
// registerModuleSubscriptions is idempotent; best-effort like import:
|
|
300
|
+
// a bus problem shouldn't wedge the upgrade, but must be loud.
|
|
301
|
+
try {
|
|
302
|
+
const { registerModuleSubscriptions } = await import('../../services/module-subscriptions');
|
|
303
|
+
registerModuleSubscriptions(newManifest, installedPath);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
306
|
+
log.warn(` ${moduleId}: failed to register event-bus subscriptions: ${msg}`);
|
|
307
|
+
log.warn(
|
|
308
|
+
' Module upgraded, but reactive flows on the event bus will not fire until this is fixed.',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
296
312
|
if (!opts.quiet) {
|
|
297
313
|
log.success(`Upgraded ${moduleId} (${previousVersion} → ${newVersion})`);
|
|
298
314
|
}
|
|
@@ -492,11 +508,15 @@ async function runRegistrySweep(
|
|
|
492
508
|
log.message('Each breaking update will be applied only on explicit confirmation.\n');
|
|
493
509
|
|
|
494
510
|
for (const plan of breaking) {
|
|
495
|
-
const proceed = await
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
511
|
+
const proceed = await withInterviewSession(() =>
|
|
512
|
+
askConfirm({
|
|
513
|
+
scope: `module-upgrade:${plan.moduleId}`,
|
|
514
|
+
key: 'apply_breaking',
|
|
515
|
+
message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
|
|
516
|
+
defaultValue: false,
|
|
517
|
+
}),
|
|
518
|
+
);
|
|
519
|
+
if (!proceed) {
|
|
500
520
|
skippedBreaking++;
|
|
501
521
|
continue;
|
|
502
522
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo proxmox node list` — live per-node capacity from the Proxmox cluster
|
|
3
|
+
* (ISS-0060, Phase 1 of v2/PROXMOX_CAPACITY_AND_LIFECYCLE.md).
|
|
4
|
+
*
|
|
5
|
+
* Reads reality from the Proxmox API via `ProxmoxClient`, never a cached DB
|
|
6
|
+
* value — the foundation for capacity-aware placement (ISS-0061) and
|
|
7
|
+
* reconcile-on-read (the rest of ISS-0060 / ISS-0090).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ProxmoxClient, type ProxmoxCredentials } from '../../api-clients/proxmox';
|
|
11
|
+
import { getServiceCredentials, listContainerServices } from '../../services/container-service';
|
|
12
|
+
import { celiloIntro } from '../prompts';
|
|
13
|
+
import type { CommandResult } from '../types';
|
|
14
|
+
|
|
15
|
+
function formatUptime(sec: number): string {
|
|
16
|
+
if (sec <= 0) return '—';
|
|
17
|
+
const days = Math.floor(sec / 86400);
|
|
18
|
+
const hours = Math.floor((sec % 86400) / 3600);
|
|
19
|
+
return days > 0 ? `${days}d${hours}h` : `${hours}h`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve which Proxmox service to introspect: an explicit service-id arg, else
|
|
24
|
+
* the sole Proxmox service. Returns an error message when the choice is
|
|
25
|
+
* ambiguous or the named service doesn't exist.
|
|
26
|
+
*/
|
|
27
|
+
function resolveProxmoxService(
|
|
28
|
+
services: Awaited<ReturnType<typeof listContainerServices>>,
|
|
29
|
+
requested: string | undefined,
|
|
30
|
+
): { service: (typeof services)[number] } | { error: string } {
|
|
31
|
+
const proxmox = services.filter((s) => s.providerName === 'proxmox');
|
|
32
|
+
if (proxmox.length === 0) {
|
|
33
|
+
return {
|
|
34
|
+
error: 'No Proxmox container service configured. Add one: celilo service add proxmox',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (requested) {
|
|
38
|
+
const match = proxmox.find((s) => s.serviceId === requested);
|
|
39
|
+
if (!match) {
|
|
40
|
+
return {
|
|
41
|
+
error: `No Proxmox service '${requested}'. Known: ${proxmox.map((s) => s.serviceId).join(', ')}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { service: match };
|
|
45
|
+
}
|
|
46
|
+
if (proxmox.length > 1) {
|
|
47
|
+
return {
|
|
48
|
+
error: `Multiple Proxmox services — specify one: celilo proxmox node list <service-id>\n ${proxmox
|
|
49
|
+
.map((s) => s.serviceId)
|
|
50
|
+
.join('\n ')}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { service: proxmox[0] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function handleProxmoxNodeList(
|
|
57
|
+
args: string[],
|
|
58
|
+
_flags: Record<string, boolean | string> = {},
|
|
59
|
+
): Promise<CommandResult> {
|
|
60
|
+
celiloIntro('Proxmox nodes');
|
|
61
|
+
|
|
62
|
+
const resolved = resolveProxmoxService(await listContainerServices(), args[0]);
|
|
63
|
+
if ('error' in resolved) {
|
|
64
|
+
console.log(`✗ ${resolved.error}`);
|
|
65
|
+
return { success: false, error: resolved.error };
|
|
66
|
+
}
|
|
67
|
+
const { service } = resolved;
|
|
68
|
+
|
|
69
|
+
const creds = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
|
|
70
|
+
const result = await new ProxmoxClient(creds).nodeCapacities();
|
|
71
|
+
if (!result.success) {
|
|
72
|
+
console.log(`✗ Could not reach Proxmox (${service.serviceId}): ${result.message}`);
|
|
73
|
+
return { success: false, error: result.message };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (result.data.length === 0) {
|
|
77
|
+
console.log('No nodes reported by the cluster.');
|
|
78
|
+
return { success: true, message: 'No nodes' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(`Service: ${service.serviceId} (${service.name})\n`);
|
|
82
|
+
console.log(
|
|
83
|
+
'NODE STATUS RAM free/total CPU DISK free/total UPTIME',
|
|
84
|
+
);
|
|
85
|
+
console.log(
|
|
86
|
+
'─────────────────────────────────────────────────────────────────────────────────────',
|
|
87
|
+
);
|
|
88
|
+
for (const n of result.data) {
|
|
89
|
+
const status = n.online ? 'online' : 'OFFLINE';
|
|
90
|
+
const ram = `${(n.memFreeMb / 1024).toFixed(1)}/${(n.memTotalMb / 1024).toFixed(1)} GB`;
|
|
91
|
+
const cpu = `${n.cpuCores} cores ${n.cpuUsedPct}%`;
|
|
92
|
+
const disk = `${n.diskFreeGb}/${n.diskTotalGb} GB`;
|
|
93
|
+
console.log(
|
|
94
|
+
`${n.node.padEnd(11)} ${status.padEnd(9)} ${ram.padEnd(19)} ${cpu.padEnd(15)} ${disk.padEnd(19)} ${formatUptime(
|
|
95
|
+
n.uptimeSec,
|
|
96
|
+
)}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
console.log('');
|
|
100
|
+
return { success: true, message: `${result.data.length} node(s)` };
|
|
101
|
+
}
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
* means the canonical filename always matches what `pveam download` accepts.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import * as p from '@clack/prompts';
|
|
14
13
|
import {
|
|
15
14
|
type ProxmoxAppliance,
|
|
16
15
|
type ProxmoxCredentials,
|
|
@@ -18,9 +17,8 @@ import {
|
|
|
18
17
|
downloadAppliance,
|
|
19
18
|
listAvailableAppliances,
|
|
20
19
|
} from '../../api-clients/proxmox';
|
|
20
|
+
import { askSelect, askText } from '../../services/bus-interview';
|
|
21
21
|
import { FuelGauge } from '../fuel-gauge';
|
|
22
|
-
import { promptText } from '../prompts';
|
|
23
|
-
import { validateRequired } from '../validators';
|
|
24
22
|
|
|
25
23
|
export interface UbuntuTemplateChoice {
|
|
26
24
|
/** Canonical filename, e.g. "ubuntu-24.04-standard_24.04-2_amd64.tar.zst" */
|
|
@@ -103,7 +101,7 @@ export function pickInitialTemplate(
|
|
|
103
101
|
export async function selectUbuntuApplianceFromCatalog(
|
|
104
102
|
credentials: ProxmoxCredentials,
|
|
105
103
|
nodeName: string,
|
|
106
|
-
opts: { currentTemplate?: string }
|
|
104
|
+
opts: { scope: string; currentTemplate?: string },
|
|
107
105
|
): Promise<TemplateSelectionResult> {
|
|
108
106
|
console.log('\nFetching Proxmox template catalog...');
|
|
109
107
|
const result = await listAvailableAppliances(credentials, nodeName);
|
|
@@ -115,13 +113,17 @@ export async function selectUbuntuApplianceFromCatalog(
|
|
|
115
113
|
' After saving, run `pveam download <storage> <template>` on the Proxmox host before deploying.',
|
|
116
114
|
);
|
|
117
115
|
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
// Manual fallback over the bus (ISS-0127) — drivable headlessly by
|
|
117
|
+
// answering `interview.required.<scope>.lxc_template_manual`.
|
|
118
|
+
const manual = await askText({
|
|
119
|
+
scope: opts.scope,
|
|
120
|
+
key: 'lxc_template_manual',
|
|
121
|
+
message: 'Template filename',
|
|
120
122
|
placeholder: 'ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
|
|
121
|
-
|
|
123
|
+
required: true,
|
|
122
124
|
});
|
|
123
125
|
|
|
124
|
-
if (
|
|
126
|
+
if (manual.trim() === '') {
|
|
125
127
|
return { kind: 'cancelled' };
|
|
126
128
|
}
|
|
127
129
|
|
|
@@ -140,8 +142,11 @@ export async function selectUbuntuApplianceFromCatalog(
|
|
|
140
142
|
|
|
141
143
|
const initialValue = pickInitialTemplate(options, opts.currentTemplate);
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
// Template choice over the bus (ISS-0127), keyed `lxc_template` within scope.
|
|
146
|
+
const selection = await askSelect({
|
|
147
|
+
scope: opts.scope,
|
|
148
|
+
key: 'lxc_template',
|
|
149
|
+
message: 'Default Ubuntu template',
|
|
145
150
|
options: options.map((e) => ({
|
|
146
151
|
value: e.appliance.template,
|
|
147
152
|
label: `Ubuntu ${e.major}.${String(e.minor).padStart(2, '0')}${e.isLts ? ' LTS' : ''}`,
|
|
@@ -149,13 +154,9 @@ export async function selectUbuntuApplianceFromCatalog(
|
|
|
149
154
|
? `revision ${e.appliance.version}`
|
|
150
155
|
: `revision ${e.appliance.version} · non-LTS`,
|
|
151
156
|
})),
|
|
152
|
-
initialValue,
|
|
157
|
+
defaultValue: initialValue,
|
|
153
158
|
});
|
|
154
159
|
|
|
155
|
-
if (p.isCancel(selection)) {
|
|
156
|
-
return { kind: 'cancelled' };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
160
|
return { kind: 'selected', choice: { template: selection, source: 'catalog' } };
|
|
160
161
|
}
|
|
161
162
|
|
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
findImportStorage,
|
|
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
|
+
// 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';
|
|
42
|
+
const DEFAULT_TEMPLATE_NAME = 'ubuntu-2404-cloudinit';
|
|
43
|
+
|
|
44
|
+
export interface VmTemplateBuildParams {
|
|
45
|
+
credentials: ProxmoxCredentials;
|
|
46
|
+
nodeName: string;
|
|
47
|
+
diskStorage: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* List existing VM templates on the node. Returns an empty array (not an error)
|
|
52
|
+
* if the API call fails — the caller treats failure as "no templates found".
|
|
53
|
+
*/
|
|
54
|
+
export async function detectExistingVmTemplates(
|
|
55
|
+
credentials: ProxmoxCredentials,
|
|
56
|
+
nodeName: string,
|
|
57
|
+
): Promise<ProxmoxVmTemplate[]> {
|
|
58
|
+
const result = await listVmTemplates(credentials, nodeName);
|
|
59
|
+
return result.success ? result.data : [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a cloud-init Ubuntu 24.04 VM template on the Proxmox node.
|
|
64
|
+
* Shows a FuelGauge progress indicator — takes 2–10 min depending on the
|
|
65
|
+
* operator's internet connection (image download is ~600 MB).
|
|
66
|
+
* Returns the template name (e.g. "ubuntu-2404-cloudinit").
|
|
67
|
+
* Cleans up the VM on failure so the operator isn't left with a partial guest.
|
|
68
|
+
*/
|
|
69
|
+
export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Promise<string> {
|
|
70
|
+
const { credentials, nodeName, diskStorage } = params;
|
|
71
|
+
const gauge = new FuelGauge('Building cloud-init VM template');
|
|
72
|
+
gauge.start();
|
|
73
|
+
|
|
74
|
+
let vmid: number | null = null;
|
|
75
|
+
|
|
76
|
+
try {
|
|
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}`);
|
|
84
|
+
}
|
|
85
|
+
const importStorage = importStorageResult.data;
|
|
86
|
+
if (!importStorage) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'No import-capable storage found on the node. ' +
|
|
89
|
+
"Enable 'import' content on a storage (typically 'local') in the Proxmox UI: " +
|
|
90
|
+
'Datacenter → Storage → Edit → Content.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
gauge.addOutput(`Import storage: ${importStorage}`);
|
|
94
|
+
|
|
95
|
+
// Step 2: find a free template VMID
|
|
96
|
+
gauge.addOutput('Finding a free template VMID (>= 9000)…');
|
|
97
|
+
const vmidResult = await findFreeTemplateVmid(credentials, nodeName);
|
|
98
|
+
if (!vmidResult.success) throw new Error(`VMID lookup failed: ${vmidResult.message}`);
|
|
99
|
+
vmid = vmidResult.data;
|
|
100
|
+
gauge.addOutput(`Using VMID ${vmid}`);
|
|
101
|
+
|
|
102
|
+
// Step 3: download cloud image to import storage
|
|
103
|
+
gauge.addOutput(
|
|
104
|
+
`Downloading Ubuntu 24.04 cloud image to '${importStorage}' (~600 MB, may take several minutes)…`,
|
|
105
|
+
);
|
|
106
|
+
const downloadResult = await downloadCloudImage(
|
|
107
|
+
credentials,
|
|
108
|
+
nodeName,
|
|
109
|
+
importStorage,
|
|
110
|
+
UBUNTU_2404_AMD64_URL,
|
|
111
|
+
UBUNTU_2404_AMD64_FILENAME,
|
|
112
|
+
);
|
|
113
|
+
if (!downloadResult.success) {
|
|
114
|
+
if (downloadResult.message.includes('403') || downloadResult.message.includes('not found')) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Proxmox storage download-url endpoint rejected the request. Downloading to 'import' content requires Proxmox VE 8.2+. Error: ${downloadResult.message}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
throw new Error(`Cloud image download failed: ${downloadResult.message}`);
|
|
120
|
+
}
|
|
121
|
+
gauge.addOutput('Waiting for download to complete…');
|
|
122
|
+
await pollTaskUntilDone(credentials, nodeName, downloadResult.data, 600_000);
|
|
123
|
+
gauge.addOutput('Cloud image downloaded');
|
|
124
|
+
|
|
125
|
+
// Step 4: create VM from cloud image
|
|
126
|
+
const imageVolid = buildCloudImageVolid(importStorage, UBUNTU_2404_AMD64_FILENAME);
|
|
127
|
+
gauge.addOutput(`Creating VM ${vmid} '${DEFAULT_TEMPLATE_NAME}' (importing disk)…`);
|
|
128
|
+
const createResult = await createVmFromCloudImage({
|
|
129
|
+
credentials,
|
|
130
|
+
nodeName,
|
|
131
|
+
vmid,
|
|
132
|
+
name: DEFAULT_TEMPLATE_NAME,
|
|
133
|
+
imageVolid,
|
|
134
|
+
diskStorage,
|
|
135
|
+
});
|
|
136
|
+
if (!createResult.success) {
|
|
137
|
+
throw new Error(`VM creation failed: ${createResult.message}`);
|
|
138
|
+
}
|
|
139
|
+
gauge.addOutput('Waiting for disk import to complete…');
|
|
140
|
+
await pollTaskUntilDone(credentials, nodeName, createResult.data, 120_000);
|
|
141
|
+
gauge.addOutput('VM created');
|
|
142
|
+
|
|
143
|
+
// Step 5: convert to template
|
|
144
|
+
gauge.addOutput(`Converting VM ${vmid} to a template…`);
|
|
145
|
+
const templateResult = await convertVmToTemplate(credentials, nodeName, vmid);
|
|
146
|
+
if (!templateResult.success) {
|
|
147
|
+
throw new Error(`Convert-to-template failed: ${templateResult.message}`);
|
|
148
|
+
}
|
|
149
|
+
await pollTaskUntilDone(credentials, nodeName, templateResult.data, 60_000);
|
|
150
|
+
|
|
151
|
+
gauge.stop(true);
|
|
152
|
+
return DEFAULT_TEMPLATE_NAME;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
gauge.stop(false);
|
|
155
|
+
|
|
156
|
+
// Best-effort cleanup — remove the partially-created VM so the operator
|
|
157
|
+
// isn't left with a stranded guest.
|
|
158
|
+
if (vmid !== null) {
|
|
159
|
+
try {
|
|
160
|
+
const del = await deleteVm(credentials, nodeName, vmid);
|
|
161
|
+
if (del.success) {
|
|
162
|
+
console.log(`✗ Cleaned up VM ${vmid} after build failure`);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
console.log(`⚠ Could not clean up VM ${vmid} — delete it manually in the Proxmox UI`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
isAlphaVersion,
|
|
15
15
|
parsePackageSpec,
|
|
16
16
|
pickNextAlphaN,
|
|
17
|
+
prereleaseDistTag,
|
|
17
18
|
stripAlphaSuffix,
|
|
18
19
|
} from './alpha';
|
|
19
20
|
|
|
@@ -80,6 +81,31 @@ describe('isAlphaVersion', () => {
|
|
|
80
81
|
});
|
|
81
82
|
});
|
|
82
83
|
|
|
84
|
+
describe('prereleaseDistTag (ISS-0083)', () => {
|
|
85
|
+
test('stable versions get no override → @latest', () => {
|
|
86
|
+
expect(prereleaseDistTag('0.5.0')).toBeUndefined();
|
|
87
|
+
expect(prereleaseDistTag('1.0.0')).toBeUndefined();
|
|
88
|
+
expect(prereleaseDistTag('10.20.30')).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('prerelease versions derive their dist-tag from the identifier', () => {
|
|
92
|
+
expect(prereleaseDistTag('0.5.0-alpha.0')).toBe('alpha');
|
|
93
|
+
expect(prereleaseDistTag('0.5.0-alpha.3')).toBe('alpha');
|
|
94
|
+
expect(prereleaseDistTag('1.0.0-beta.2')).toBe('beta');
|
|
95
|
+
expect(prereleaseDistTag('1.0.0-rc.1')).toBe('rc');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('prerelease without a dotted counter still yields its identifier', () => {
|
|
99
|
+
expect(prereleaseDistTag('1.0.0-alpha')).toBe('alpha');
|
|
100
|
+
expect(prereleaseDistTag('1.0.0-next')).toBe('next');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a prerelease NEVER maps to latest', () => {
|
|
104
|
+
expect(prereleaseDistTag('0.5.0-alpha.0')).not.toBe('latest');
|
|
105
|
+
expect(prereleaseDistTag('0.5.0-alpha.0')).not.toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
83
109
|
describe('pickNextAlphaN', () => {
|
|
84
110
|
test('returns 0 when no alphas exist for the target core', () => {
|
|
85
111
|
expect(pickNextAlphaN([], '0.7.14')).toBe(0);
|
|
@@ -52,6 +52,29 @@ export function isAlphaVersion(version: string): boolean {
|
|
|
52
52
|
return /-alpha\.\d+$/.test(version);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* ISS-0083: the npm dist-tag a version must publish under, derived from
|
|
57
|
+
* its semver prerelease identifier. A prerelease (e.g. `0.5.0-alpha.0`,
|
|
58
|
+
* `1.0.0-beta.2`, `1.0.0-rc.1`) MUST be tagged with its prerelease name
|
|
59
|
+
* (`alpha`/`beta`/`rc`/…), NEVER `latest` — otherwise `npm install <pkg>`
|
|
60
|
+
* (no tag) pulls a prerelease, and the prerelease tag the .deb resolves
|
|
61
|
+
* (`@celilo/cli@alpha`) goes stale. A stable version returns undefined
|
|
62
|
+
* (publish defaults to `latest`, which is correct for stable).
|
|
63
|
+
*
|
|
64
|
+
* The tag is the FIRST dot-separated token of the prerelease component:
|
|
65
|
+
* 0.5.0-alpha.0 → "alpha"
|
|
66
|
+
* 1.0.0-beta.2 → "beta"
|
|
67
|
+
* 1.0.0-rc.1 → "rc"
|
|
68
|
+
* 1.0.0 → undefined (→ latest)
|
|
69
|
+
*/
|
|
70
|
+
export function prereleaseDistTag(version: string): string | undefined {
|
|
71
|
+
const dash = version.indexOf('-');
|
|
72
|
+
if (dash === -1) return undefined;
|
|
73
|
+
const prerelease = version.slice(dash + 1);
|
|
74
|
+
const identifier = prerelease.split('.')[0];
|
|
75
|
+
return identifier || undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
/**
|
|
56
79
|
* Pure inner of `nextAlphaNumber`. Given the full list of versions
|
|
57
80
|
* for a package (whatever `npm view <name> versions --json` returned)
|
|
@@ -166,8 +166,13 @@ export interface WorkspaceItem {
|
|
|
166
166
|
* `X.Y.Z-alpha.N` for alpha mode, base-stripped for promote mode.
|
|
167
167
|
*/
|
|
168
168
|
versionToPublish: string;
|
|
169
|
-
/**
|
|
170
|
-
|
|
169
|
+
/**
|
|
170
|
+
* npm dist-tag override. `'alpha'` for alpha mode; for normal mode a
|
|
171
|
+
* prerelease version (e.g. `0.5.0-alpha.0`) derives its tag from the
|
|
172
|
+
* prerelease identifier (ISS-0083) so it never lands on `@latest`.
|
|
173
|
+
* undefined → `@latest` (only for stable versions).
|
|
174
|
+
*/
|
|
175
|
+
tag?: string;
|
|
171
176
|
/**
|
|
172
177
|
* Rewrite recipe applied to the package's package.json immediately
|
|
173
178
|
* before `bun publish`. Restored from `originalPackageJson` after.
|
|
@@ -29,7 +29,13 @@ import {
|
|
|
29
29
|
writeFileSync,
|
|
30
30
|
} from 'node:fs';
|
|
31
31
|
import { join, relative } from 'node:path';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
ALPHA_TAG,
|
|
34
|
+
alphaSkipDecision,
|
|
35
|
+
nextAlphaNumber,
|
|
36
|
+
prereleaseDistTag,
|
|
37
|
+
stripAlphaSuffix,
|
|
38
|
+
} from './alpha';
|
|
33
39
|
import { REPO_ROOT, isPublished, readPkg } from './helpers';
|
|
34
40
|
import type {
|
|
35
41
|
PackageJson,
|
|
@@ -140,6 +146,10 @@ export function planWorkspace(opts: PlanWorkspaceInput): PlanWorkspaceOutput {
|
|
|
140
146
|
workspaceVersions.set(name, versionToPublish);
|
|
141
147
|
} else {
|
|
142
148
|
versionToPublish = version;
|
|
149
|
+
// ISS-0083: a prerelease version in package.json (e.g. 0.5.0-alpha.0)
|
|
150
|
+
// must publish under its prerelease dist-tag, never `latest`. Only
|
|
151
|
+
// stable versions get `latest` (tag stays undefined).
|
|
152
|
+
tag = prereleaseDistTag(versionToPublish);
|
|
143
153
|
if (isPublished(name, versionToPublish)) {
|
|
144
154
|
skipReason = 'already published';
|
|
145
155
|
}
|
|
@@ -93,9 +93,38 @@ export async function handleRestore(
|
|
|
93
93
|
);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// Re-register event-bus subscriptions from the restored modules' manifests.
|
|
97
|
+
// The bus (events.db) is NOT in the backup envelope, so a restore/migration
|
|
98
|
+
// starts with an EMPTY subscribers table — every event-driven reconcile (caddy
|
|
99
|
+
// public_web, dns register, namecheap's 15m DDNS refresh, …) is dormant until
|
|
100
|
+
// subscriptions are re-registered (ISS-0088). Reconstruct them from durable
|
|
101
|
+
// celilo.db state rather than requiring a redeploy of every module. Best-effort:
|
|
102
|
+
// the DB was just swapped + reopened, so on an unhappy reopen, point the
|
|
103
|
+
// operator at the standalone command (which runs in a fresh process).
|
|
104
|
+
let subsResynced: { modules: number; registered: number } | undefined;
|
|
105
|
+
try {
|
|
106
|
+
const { resyncAllSubscriptions } = await import('../../services/module-subscriptions');
|
|
107
|
+
const r = resyncAllSubscriptions();
|
|
108
|
+
subsResynced = { modules: r.modules, registered: r.registered };
|
|
109
|
+
if (r.failures.length > 0) {
|
|
110
|
+
log.warn(
|
|
111
|
+
`Re-registered ${r.registered} subscription(s); ${r.failures.length} module(s) failed — run \`celilo events resync-subscriptions\` to retry.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
log.warn(
|
|
116
|
+
`Restore landed but event-bus subscriptions were not re-registered: ${err instanceof Error ? err.message : String(err)}. Event-driven reconciles stay dormant until you run \`celilo events resync-subscriptions\`.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
96
120
|
// Build the success message.
|
|
97
121
|
const lines: string[] = ['Restore complete.'];
|
|
98
122
|
if (result.systemDbApplied) lines.push(' • celilo.db swapped into place');
|
|
123
|
+
if (subsResynced) {
|
|
124
|
+
lines.push(
|
|
125
|
+
` • re-registered ${subsResynced.registered} event-bus subscription(s) from ${subsResynced.modules} deployed module(s)`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
99
128
|
if (result.masterKeyApplied) lines.push(' • master.key swapped into place');
|
|
100
129
|
if (result.sshKeyApplied) lines.push(' • fleet SSH key restored to <data-dir>/.ssh/');
|
|
101
130
|
if (result.moduleSourcesApplied && result.moduleSourcesApplied > 0) {
|