@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.
Files changed (101) hide show
  1. package/drizzle/0009_dns_registrations.sql +13 -0
  2. package/drizzle/0010_dns_internal_records.sql +12 -0
  3. package/drizzle/0011_backups_name.sql +1 -0
  4. package/drizzle/meta/_journal.json +22 -1
  5. package/package.json +3 -3
  6. package/src/ansible/inventory.test.ts +10 -10
  7. package/src/ansible/validation.test.ts +25 -15
  8. package/src/api-clients/proxmox.test.ts +211 -1
  9. package/src/api-clients/proxmox.ts +399 -8
  10. package/src/cli/command-registry.ts +83 -6
  11. package/src/cli/commands/backup-delete.ts +10 -7
  12. package/src/cli/commands/backup-import.ts +11 -8
  13. package/src/cli/commands/backup-restore.ts +11 -8
  14. package/src/cli/commands/dns.ts +57 -0
  15. package/src/cli/commands/events.test.ts +4 -4
  16. package/src/cli/commands/events.ts +89 -24
  17. package/src/cli/commands/machine-add.ts +178 -163
  18. package/src/cli/commands/machine-remove.ts +10 -7
  19. package/src/cli/commands/module-config.test.ts +78 -0
  20. package/src/cli/commands/module-config.ts +18 -3
  21. package/src/cli/commands/module-import.ts +9 -5
  22. package/src/cli/commands/module-publish.ts +24 -0
  23. package/src/cli/commands/module-remove.ts +20 -9
  24. package/src/cli/commands/module-status.ts +15 -0
  25. package/src/cli/commands/module-upgrade.test.ts +37 -0
  26. package/src/cli/commands/module-upgrade.ts +26 -6
  27. package/src/cli/commands/proxmox-node-list.ts +101 -0
  28. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  29. package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
  30. package/src/cli/commands/publish/alpha.test.ts +26 -0
  31. package/src/cli/commands/publish/alpha.ts +23 -0
  32. package/src/cli/commands/publish/types.ts +7 -2
  33. package/src/cli/commands/publish/workspace.ts +11 -1
  34. package/src/cli/commands/restore.ts +29 -0
  35. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  36. package/src/cli/commands/service-add-proxmox.ts +283 -209
  37. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  38. package/src/cli/commands/service-reconfigure.ts +252 -129
  39. package/src/cli/commands/service-remove.ts +19 -13
  40. package/src/cli/commands/service-verify.ts +9 -10
  41. package/src/cli/commands/storage-add-local.ts +120 -107
  42. package/src/cli/commands/storage-add-s3.ts +145 -131
  43. package/src/cli/commands/storage-remove.ts +11 -8
  44. package/src/cli/commands/system-doctor.ts +135 -40
  45. package/src/cli/commands/system-init.ts +119 -128
  46. package/src/cli/commands/system-migrate.test.ts +40 -0
  47. package/src/cli/commands/system-migrate.ts +65 -0
  48. package/src/cli/completion.ts +23 -0
  49. package/src/cli/index.ts +91 -7
  50. package/src/cli/service-credential.ts +54 -0
  51. package/src/config/paths.test.ts +61 -48
  52. package/src/db/client.ts +15 -146
  53. package/src/db/migrate.ts +14 -6
  54. package/src/db/schema-introspection.ts +88 -0
  55. package/src/db/schema.ts +74 -0
  56. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  57. package/src/hooks/capability-loader.ts +43 -2
  58. package/src/hooks/run-named-hook.ts +28 -2
  59. package/src/hooks/types.ts +2 -1
  60. package/src/infrastructure/property-extractor.test.ts +15 -0
  61. package/src/infrastructure/property-extractor.ts +12 -0
  62. package/src/manifest/contracts/v1.ts +16 -0
  63. package/src/manifest/schema.ts +17 -0
  64. package/src/manifest/validate.test.ts +53 -0
  65. package/src/services/bus-interview.test.ts +2 -2
  66. package/src/services/bus-interview.ts +232 -0
  67. package/src/services/bus-secret-flow.test.ts +2 -2
  68. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  69. package/src/services/deploy-preflight.ts +25 -0
  70. package/src/services/deploy-validation.test.ts +54 -4
  71. package/src/services/deploy-validation.ts +27 -36
  72. package/src/services/dns-internal-records.test.ts +126 -0
  73. package/src/services/dns-internal-records.ts +119 -0
  74. package/src/services/dns-provider-backfill.test.ts +2 -2
  75. package/src/services/dns-provider-backfill.ts +14 -2
  76. package/src/services/dns-registrations.test.ts +120 -0
  77. package/src/services/dns-registrations.ts +108 -0
  78. package/src/services/events-daemon.test.ts +59 -0
  79. package/src/services/events-daemon.ts +191 -57
  80. package/src/services/fleet-checks.test.ts +508 -0
  81. package/src/services/fleet-checks.ts +678 -0
  82. package/src/services/module-build.test.ts +43 -38
  83. package/src/services/module-config.ts +12 -0
  84. package/src/services/module-deploy.ts +7 -6
  85. package/src/services/module-subscriptions.test.ts +88 -0
  86. package/src/services/module-subscriptions.ts +50 -1
  87. package/src/services/module-validator/bundled-deps.test.ts +55 -0
  88. package/src/services/module-validator/bundled-deps.ts +115 -0
  89. package/src/services/module-validator/capability-versions.test.ts +1 -1
  90. package/src/services/placement-reconcile.test.ts +86 -0
  91. package/src/services/placement-reconcile.ts +108 -0
  92. package/src/services/programmatic-responder.ts +34 -0
  93. package/src/services/terminal-responder.ts +113 -0
  94. package/src/templates/generator.test.ts +92 -12
  95. package/src/templates/generator.ts +165 -80
  96. package/src/test-utils/fixtures.test.ts +1 -1
  97. package/src/test-utils/integration-guard.ts +33 -0
  98. package/src/types/infrastructure.ts +6 -0
  99. package/src/variables/computed/computed-integration.test.ts +3 -3
  100. package/src/variables/computed/computed.test.ts +5 -5
  101. 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 p.confirm({
496
- message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
497
- initialValue: false,
498
- });
499
- if (p.isCancel(proceed) || !proceed) {
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
- const manual = await promptText({
119
- message: 'Template filename:',
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
- validate: validateRequired('Template filename'),
123
+ required: true,
122
124
  });
123
125
 
124
- if (typeof manual !== 'string' || manual.trim() === '') {
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
- const selection = await p.select<string>({
144
- message: 'Default Ubuntu template:',
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
- /** npm dist-tag override (only `'alpha'` for alpha mode; undefined → `@latest`). */
170
- tag?: 'alpha';
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 { ALPHA_TAG, alphaSkipDecision, nextAlphaNumber, stripAlphaSuffix } from './alpha';
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) {