@celilo/cli 0.5.0-alpha.0 → 0.5.0-alpha.10

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 +240 -0
  9. package/src/api-clients/proxmox.ts +456 -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 +166 -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 +122 -15
  95. package/src/templates/generator.ts +206 -57
  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
@@ -28,7 +28,7 @@ interface ProxmoxError {
28
28
  details?: Record<string, unknown>;
29
29
  }
30
30
 
31
- type ProxmoxResult<T> = { success: true; data: T } | ProxmoxError;
31
+ export type ProxmoxResult<T> = { success: true; data: T } | ProxmoxError;
32
32
 
33
33
  /**
34
34
  * Make an authenticated API request to Proxmox
@@ -114,6 +114,18 @@ async function makeProxmoxRequest<T>(
114
114
  });
115
115
  });
116
116
 
117
+ // Fail fast instead of hanging on an unreachable host (no implicit timeout
118
+ // on https.request). Callers treat a failed result as "couldn't reach
119
+ // Proxmox" and fall back accordingly.
120
+ req.setTimeout(15_000, () => {
121
+ req.destroy();
122
+ resolve({
123
+ success: false,
124
+ message: 'Request timed out',
125
+ details: { timeoutMs: 15_000 },
126
+ });
127
+ });
128
+
117
129
  req.end();
118
130
  } catch (error) {
119
131
  resolve({
@@ -486,6 +498,146 @@ export async function listNodeStorage(
486
498
  return makeProxmoxRequest(credentials, `/nodes/${nodeName}/storage`);
487
499
  }
488
500
 
501
+ /**
502
+ * Find which Proxmox node a given VMID currently lives on.
503
+ *
504
+ * Queries the cluster resource inventory (`/cluster/resources`), which lists
505
+ * every guest across all nodes with its current node, and matches by VMID
506
+ * (unique cluster-wide). Returns the node name, or `null` if the VMID isn't
507
+ * present — i.e. the container hasn't been created yet (a first deploy).
508
+ *
509
+ * ISS-0090: this is celilo's source of truth for WHERE a system currently is.
510
+ * A redeploy must target the node Proxmox reports here, NOT re-derive placement
511
+ * from the service's `default_target_node` (which only governs new placement) —
512
+ * otherwise a changed default tries to relocate every running container.
513
+ */
514
+ export async function getNodeForVmid(
515
+ credentials: ProxmoxCredentials,
516
+ vmid: number,
517
+ ): Promise<ProxmoxResult<string | null>> {
518
+ // makeProxmoxRequest sends only url.pathname, so a `?type=vm` filter would be
519
+ // dropped — fetch the full inventory and match by vmid client-side instead.
520
+ const result = await makeProxmoxRequest<Array<{ vmid?: number; node?: string }>>(
521
+ credentials,
522
+ '/cluster/resources',
523
+ );
524
+
525
+ if (!result.success) {
526
+ return result;
527
+ }
528
+
529
+ return { success: true, data: findNodeForVmid(result.data, vmid) };
530
+ }
531
+
532
+ /**
533
+ * Find the node a VMID lives on within a Proxmox cluster-resource list. Pure
534
+ * matching logic, split out from the network call for testability (Rule 10).
535
+ * Non-guest entries (storage/node rows) have no `vmid` and are skipped. Returns
536
+ * the node name, or `null` when the VMID isn't present.
537
+ */
538
+ export function findNodeForVmid(
539
+ resources: Array<{ vmid?: number; node?: string }>,
540
+ vmid: number,
541
+ ): string | null {
542
+ const match = resources.find((r) => typeof r.vmid === 'number' && r.vmid === vmid);
543
+ return match?.node ?? null;
544
+ }
545
+
546
+ /**
547
+ * One row from `GET /cluster/resources`. The list is heterogeneous — `type`
548
+ * discriminates node / storage / guest rows. For `type: 'node'`, `status` is
549
+ * 'online'/'offline' and the mem/cpu/disk fields describe the node's capacity;
550
+ * for guests it's 'running'/'stopped' and `vmid` is set.
551
+ */
552
+ export interface ProxmoxClusterResource {
553
+ type: string;
554
+ node?: string;
555
+ status?: string;
556
+ vmid?: number;
557
+ maxmem?: number; // bytes (node total RAM)
558
+ mem?: number; // bytes (node RAM in use)
559
+ maxcpu?: number; // cores
560
+ cpu?: number; // load fraction 0..1
561
+ maxdisk?: number; // bytes (node total disk on the relevant storage)
562
+ disk?: number; // bytes (disk in use)
563
+ uptime?: number; // seconds
564
+ }
565
+
566
+ /** Per-node capacity reconciled from Proxmox (reality, never a cached DB value). */
567
+ export interface ProxmoxNodeCapacity {
568
+ node: string;
569
+ online: boolean;
570
+ memTotalMb: number;
571
+ memFreeMb: number;
572
+ cpuCores: number;
573
+ cpuUsedPct: number; // 0..100
574
+ diskTotalGb: number;
575
+ diskFreeGb: number;
576
+ uptimeSec: number;
577
+ }
578
+
579
+ const BYTES_PER_MB = 1024 * 1024;
580
+ const BYTES_PER_GB = 1024 * 1024 * 1024;
581
+
582
+ /**
583
+ * Summarize per-node capacity from a `/cluster/resources` list. Pure (Rule 10) —
584
+ * split from the network call for unit testing. Only `type: 'node'` rows count;
585
+ * guest and storage rows are ignored. Sorted by node name for stable output.
586
+ */
587
+ export function summarizeNodeCapacities(
588
+ resources: ProxmoxClusterResource[],
589
+ ): ProxmoxNodeCapacity[] {
590
+ return resources
591
+ .filter((r): r is ProxmoxClusterResource & { node: string } => r.type === 'node' && !!r.node)
592
+ .map((r) => {
593
+ const maxmem = r.maxmem ?? 0;
594
+ const mem = r.mem ?? 0;
595
+ const maxdisk = r.maxdisk ?? 0;
596
+ const disk = r.disk ?? 0;
597
+ return {
598
+ node: r.node,
599
+ online: r.status === 'online',
600
+ memTotalMb: Math.round(maxmem / BYTES_PER_MB),
601
+ memFreeMb: Math.round((maxmem - mem) / BYTES_PER_MB),
602
+ cpuCores: r.maxcpu ?? 0,
603
+ cpuUsedPct: Math.round((r.cpu ?? 0) * 100),
604
+ diskTotalGb: Math.round(maxdisk / BYTES_PER_GB),
605
+ diskFreeGb: Math.round((maxdisk - disk) / BYTES_PER_GB),
606
+ uptimeSec: r.uptime ?? 0,
607
+ };
608
+ })
609
+ .sort((a, b) => a.node.localeCompare(b.node));
610
+ }
611
+
612
+ /**
613
+ * Cohesive Proxmox introspection client (ISS-0060). Wraps the credentials so
614
+ * callers don't thread them through every call, and centralizes the live reads
615
+ * that let celilo treat Proxmox — not a cached DB row — as the source of truth
616
+ * for where containers live and how much room each node has.
617
+ */
618
+ export class ProxmoxClient {
619
+ constructor(private readonly credentials: ProxmoxCredentials) {}
620
+
621
+ /** Raw cluster resource inventory (node + guest + storage rows). */
622
+ async clusterResources(): Promise<ProxmoxResult<ProxmoxClusterResource[]>> {
623
+ return makeProxmoxRequest<ProxmoxClusterResource[]>(this.credentials, '/cluster/resources');
624
+ }
625
+
626
+ /** Live per-node capacity (RAM/CPU/disk/online), reconciled from Proxmox. */
627
+ async nodeCapacities(): Promise<ProxmoxResult<ProxmoxNodeCapacity[]>> {
628
+ const result = await this.clusterResources();
629
+ if (!result.success) return result;
630
+ return { success: true, data: summarizeNodeCapacities(result.data) };
631
+ }
632
+
633
+ /** The node a VMID currently lives on, or null if it isn't created yet. */
634
+ async nodeForVmid(vmid: number): Promise<ProxmoxResult<string | null>> {
635
+ const result = await this.clusterResources();
636
+ if (!result.success) return result;
637
+ return { success: true, data: findNodeForVmid(result.data, vmid) };
638
+ }
639
+ }
640
+
489
641
  /**
490
642
  * List available LXC templates in storage
491
643
  */
@@ -501,12 +653,11 @@ export async function listAvailableTemplates(
501
653
  }
502
654
 
503
655
  /**
504
- * Make an authenticated POST request to the Proxmox API.
505
- * Shares connection/auth handling with makeProxmoxRequest; the only differences
506
- * are the verb and the form-encoded body.
656
+ * Make an authenticated form-encoded request (POST or PUT) to the Proxmox API.
507
657
  */
508
- async function makeProxmoxPost<T>(
658
+ async function makeProxmoxFormRequest<T>(
509
659
  credentials: ProxmoxCredentials,
660
+ method: 'POST' | 'PUT',
510
661
  path: string,
511
662
  params: Record<string, string>,
512
663
  ): Promise<ProxmoxResult<T>> {
@@ -519,7 +670,7 @@ async function makeProxmoxPost<T>(
519
670
  const postData = new URLSearchParams(params).toString();
520
671
 
521
672
  if (process.env.DEBUG) {
522
- console.log(`[Proxmox] POST: ${fullUrl}`);
673
+ console.log(`[Proxmox] ${method}: ${fullUrl}`);
523
674
  console.log(`[Proxmox] Body: ${postData}`);
524
675
  }
525
676
 
@@ -530,7 +681,7 @@ async function makeProxmoxPost<T>(
530
681
  hostname: url.hostname,
531
682
  port: url.port || 443,
532
683
  path: url.pathname,
533
- method: 'POST',
684
+ method,
534
685
  headers: {
535
686
  Authorization: authHeader,
536
687
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -548,7 +699,7 @@ async function makeProxmoxPost<T>(
548
699
 
549
700
  if (statusCode < 200 || statusCode >= 300) {
550
701
  if (process.env.DEBUG || statusCode >= 400) {
551
- console.error(`[Proxmox] POST ${path} failed (${statusCode}): ${body}`);
702
+ console.error(`[Proxmox] ${method} ${path} failed (${statusCode}): ${body}`);
552
703
  }
553
704
  resolve({
554
705
  success: false,
@@ -592,6 +743,14 @@ async function makeProxmoxPost<T>(
592
743
  });
593
744
  }
594
745
 
746
+ async function makeProxmoxPost<T>(
747
+ credentials: ProxmoxCredentials,
748
+ path: string,
749
+ params: Record<string, string>,
750
+ ): Promise<ProxmoxResult<T>> {
751
+ return makeProxmoxFormRequest(credentials, 'POST', path, params);
752
+ }
753
+
595
754
  /**
596
755
  * Entry from Proxmox's appliance catalog (`pveam available`). The `template`
597
756
  * field is the canonical filename (revision included) that should be passed to
@@ -685,3 +844,292 @@ export function buildTemplatePath(storageName: string, templateFilename: string)
685
844
  export function buildProxmoxApiUrl(ipAddress: string, port = 8006): string {
686
845
  return `https://${ipAddress}:${port}/api2/json`;
687
846
  }
847
+
848
+ // ── VM template build API ─────────────────────────────────────────────────────
849
+ // Used by proxmox-vm-template-build.ts to build a cloud-init template via the
850
+ // Proxmox API (no SSH to the node required).
851
+ //
852
+ // Version requirements (the build needs the HIGHER of the two — i.e. PVE 8.0+):
853
+ // - the storage `download-url` endpoint (downloadCloudImage) → PVE 7.2+
854
+ // - `import-from=` on a disk in the VM-create call → PVE 8.0+
855
+ // On PVE 7.x the create call rejects `import-from`; the older two-step
856
+ // `qm importdisk` flow would be needed there (not implemented — 8.x is current).
857
+ // @psbanka - 2026-06: revisit only if a 7.x node must be supported.
858
+
859
+ export interface ProxmoxVmTemplate {
860
+ vmid: number;
861
+ name: string;
862
+ }
863
+
864
+ /** Raw `/nodes/{node}/qemu` guest row (only the fields we read). */
865
+ interface ProxmoxQemuGuestRow {
866
+ vmid: number;
867
+ name: string;
868
+ template?: number;
869
+ }
870
+
871
+ /**
872
+ * Extract VM templates (guests with `template === 1`) from a `/nodes/{node}/qemu`
873
+ * listing. Pure filtering logic, split from the network call for testability
874
+ * (Rule 10) — mirrors findNodeForVmid.
875
+ */
876
+ export function filterVmTemplates(guests: ProxmoxQemuGuestRow[]): ProxmoxVmTemplate[] {
877
+ return guests.filter((vm) => vm.template === 1).map((vm) => ({ vmid: vm.vmid, name: vm.name }));
878
+ }
879
+
880
+ /** List existing VM templates on a node. */
881
+ export async function listVmTemplates(
882
+ credentials: ProxmoxCredentials,
883
+ nodeName: string,
884
+ ): Promise<ProxmoxResult<ProxmoxVmTemplate[]>> {
885
+ const result = await makeProxmoxRequest<ProxmoxQemuGuestRow[]>(
886
+ credentials,
887
+ `/nodes/${nodeName}/qemu`,
888
+ );
889
+ if (!result.success) return result;
890
+ return { success: true, data: filterVmTemplates(result.data) };
891
+ }
892
+
893
+ /** Storage row shape (subset) used when picking import-capable storage. */
894
+ type ProxmoxStorageRow = { storage: string; content: string; active: number; enabled: number };
895
+
896
+ /**
897
+ * Pick the first active, enabled storage that accepts `import` content. Pure
898
+ * selection logic, split from the network call for testability (Rule 10).
899
+ * Returns the storage name, or null when none qualifies.
900
+ *
901
+ * Must be `import`, NOT `iso`: the cloud image is consumed via the VM-create
902
+ * `import-from=` parameter, which Proxmox only accepts from a source on a
903
+ * storage whose content type is `images` or `import`. A `.img` downloaded to
904
+ * the `iso/` namespace is rejected with `has wrong type 'iso'`. The
905
+ * `download-url` endpoint accepts `content=import` on PVE 8.2+, so we download
906
+ * straight into the import namespace. (PVE 8.2+; node fleet is 9.x.)
907
+ */
908
+ export function selectImportStorage(storages: ProxmoxStorageRow[]): string | null {
909
+ const match = storages.find((s) => s.active && s.enabled && s.content.includes('import'));
910
+ return match?.storage ?? null;
911
+ }
912
+
913
+ /**
914
+ * Find a storage on the node that accepts `import` content (for downloading
915
+ * cloud images that will be imported as a VM disk). Returns the storage name,
916
+ * or null if none found.
917
+ */
918
+ export async function findImportStorage(
919
+ credentials: ProxmoxCredentials,
920
+ nodeName: string,
921
+ ): Promise<ProxmoxResult<string | null>> {
922
+ const result = await listNodeStorage(credentials, nodeName);
923
+ if (!result.success) return result;
924
+ return { success: true, data: selectImportStorage(result.data) };
925
+ }
926
+
927
+ /**
928
+ * Pick the first free VMID >= minVmid given the set of in-use VMIDs. Pure
929
+ * selection logic, split from the network call for testability (Rule 10).
930
+ * Template VMIDs must stay outside celilo's IPAM range (200+) so they can
931
+ * never collide with deployed systems — hence minVmid defaults to 9000.
932
+ */
933
+ export function selectFreeVmid(usedVmids: Iterable<number>, minVmid = 9000): number {
934
+ const used = new Set(usedVmids);
935
+ let candidate = minVmid;
936
+ while (used.has(candidate)) {
937
+ candidate++;
938
+ }
939
+ return candidate;
940
+ }
941
+
942
+ /**
943
+ * Find a free VMID >= minVmid (default 9000) across the cluster.
944
+ * Template VMIDs must stay outside celilo's IPAM range (200+) so they can
945
+ * never collide with deployed systems.
946
+ */
947
+ export async function findFreeTemplateVmid(
948
+ credentials: ProxmoxCredentials,
949
+ _nodeName: string,
950
+ minVmid = 9000,
951
+ ): Promise<ProxmoxResult<number>> {
952
+ const result = await makeProxmoxRequest<Array<{ vmid?: number }>>(
953
+ credentials,
954
+ '/cluster/resources',
955
+ );
956
+ if (!result.success) return result;
957
+ const usedVmids = result.data
958
+ .map((r) => r.vmid)
959
+ .filter((vmid): vmid is number => typeof vmid === 'number');
960
+ return { success: true, data: selectFreeVmid(usedVmids, minVmid) };
961
+ }
962
+
963
+ /**
964
+ * Download a cloud image URL to `import` storage on the node. Returns a UPID
965
+ * for status polling. Requires PVE 8.2+ (`download-url` with `content=import`).
966
+ * The image MUST land in the import namespace (not iso) so the VM-create
967
+ * `import-from=` accepts it — see selectImportStorage.
968
+ */
969
+ export async function downloadCloudImage(
970
+ credentials: ProxmoxCredentials,
971
+ nodeName: string,
972
+ importStorage: string,
973
+ url: string,
974
+ filename: string,
975
+ ): Promise<ProxmoxResult<string>> {
976
+ return makeProxmoxPost<string>(
977
+ credentials,
978
+ `/nodes/${nodeName}/storage/${importStorage}/download-url`,
979
+ { url, filename, content: 'import' },
980
+ );
981
+ }
982
+
983
+ /**
984
+ * Poll a task UPID until it reaches `stopped` status. Resolves with the
985
+ * exitstatus string (typically `'OK'` on success). Throws on timeout or a
986
+ * non-OK exitstatus.
987
+ *
988
+ * An empty/missing UPID means the API call completed synchronously (some PVE
989
+ * endpoints — e.g. converting a diskless VM to a template — return `null` data
990
+ * instead of a worker UPID). There is nothing to poll, so treat it as an
991
+ * immediate success rather than encoding the empty string and 404-ing on the
992
+ * task-status lookup.
993
+ */
994
+ export async function pollTaskUntilDone(
995
+ credentials: ProxmoxCredentials,
996
+ nodeName: string,
997
+ upid: string,
998
+ timeoutMs = 600_000,
999
+ ): Promise<string> {
1000
+ if (!upid) return 'OK';
1001
+ const deadline = Date.now() + timeoutMs;
1002
+ while (Date.now() < deadline) {
1003
+ await new Promise<void>((r) => setTimeout(r, 3_000));
1004
+ const status = await checkTaskStatus(credentials, nodeName, upid);
1005
+ if (!status.success) throw new Error(`Task poll failed: ${status.message}`);
1006
+ if (status.data.status === 'stopped') {
1007
+ const exit = status.data.exitstatus ?? 'unknown';
1008
+ if (exit !== 'OK') throw new Error(`Task ${upid} failed with exitstatus: ${exit}`);
1009
+ return exit;
1010
+ }
1011
+ }
1012
+ throw new Error(`Task ${upid} timed out after ${timeoutMs / 1000}s`);
1013
+ }
1014
+
1015
+ /**
1016
+ * Build the volid for a cloud image downloaded to `import` storage. Pure path
1017
+ * construction, split out for testability (Rule 10) — mirrors buildTemplatePath.
1018
+ * Proxmox's `download-url` with `content=import` stores the `.img` under the
1019
+ * storage's `import/` namespace, so the volid is `<storage>:import/<filename>`.
1020
+ * This is the namespace the VM-create `import-from=` accepts (iso/ is rejected).
1021
+ */
1022
+ export function buildCloudImageVolid(importStorage: string, filename: string): string {
1023
+ return `${importStorage}:import/${filename}`;
1024
+ }
1025
+
1026
+ export interface CreateVmFromCloudImageParams {
1027
+ credentials: ProxmoxCredentials;
1028
+ nodeName: string;
1029
+ vmid: number;
1030
+ name: string;
1031
+ /** Volume ID of the downloaded cloud image, e.g. `local:import/noble-server-cloudimg-amd64.img` */
1032
+ imageVolid: string;
1033
+ /** Storage for the imported VM disk and the cloud-init drive */
1034
+ diskStorage: string;
1035
+ }
1036
+
1037
+ /**
1038
+ * Create a VM and import the cloud image as its boot disk in one API call.
1039
+ * Also attaches the cloud-init CDROM drive so terraform can inject credentials
1040
+ * at clone time. Returns a UPID — poll with pollTaskUntilDone.
1041
+ *
1042
+ * `import-from=` in the disk spec requires PVE 8.0+ (see the version note at the
1043
+ * top of this section). The `net0` bridge is hardcoded to `vmbr0` (the Proxmox
1044
+ * default); it only governs the template's own NIC, which terraform overrides
1045
+ * with `$system:network.bridge` at clone time — so it never reaches a deployed
1046
+ * guest. @psbanka - 2026-06: parameterize if a non-vmbr0 node ever blocks the
1047
+ * template build itself.
1048
+ */
1049
+ export async function createVmFromCloudImage(
1050
+ params: CreateVmFromCloudImageParams,
1051
+ ): Promise<ProxmoxResult<string>> {
1052
+ const { credentials, nodeName, vmid, name, imageVolid, diskStorage } = params;
1053
+ return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu`, {
1054
+ vmid: String(vmid),
1055
+ name,
1056
+ memory: '2048',
1057
+ cores: '2',
1058
+ scsihw: 'virtio-scsi-pci',
1059
+ net0: 'virtio,bridge=vmbr0',
1060
+ agent: 'enabled=1',
1061
+ serial0: 'socket',
1062
+ vga: 'serial0',
1063
+ boot: 'order=scsi0',
1064
+ scsi0: `${diskStorage}:0,import-from=${imageVolid}`,
1065
+ ide2: `${diskStorage}:cloudinit`,
1066
+ });
1067
+ }
1068
+
1069
+ /**
1070
+ * Convert an existing VM to a template. Returns a UPID — the VM must be
1071
+ * powered off. Poll with pollTaskUntilDone.
1072
+ */
1073
+ export async function convertVmToTemplate(
1074
+ credentials: ProxmoxCredentials,
1075
+ nodeName: string,
1076
+ vmid: number,
1077
+ ): Promise<ProxmoxResult<string>> {
1078
+ return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu/${vmid}/template`, {});
1079
+ }
1080
+
1081
+ /**
1082
+ * Delete a VM (cleanup on build failure). Returns a UPID.
1083
+ * Passes `purge=1` so storage volumes are also removed.
1084
+ */
1085
+ export async function deleteVm(
1086
+ credentials: ProxmoxCredentials,
1087
+ nodeName: string,
1088
+ vmid: number,
1089
+ ): Promise<ProxmoxResult<string>> {
1090
+ return new Promise((resolve) => {
1091
+ try {
1092
+ const { api_url, api_token_id, api_token_secret } = credentials;
1093
+ const authHeader = `PVEAPIToken=${api_token_id}=${api_token_secret}`;
1094
+ const fullUrl = `${api_url}/nodes/${nodeName}/qemu/${vmid}`;
1095
+ const url = new URL(fullUrl);
1096
+ const agent = new https.Agent({ rejectUnauthorized: false });
1097
+ const req = https.request(
1098
+ {
1099
+ hostname: url.hostname,
1100
+ port: url.port || 443,
1101
+ path: `${url.pathname}?purge=1`,
1102
+ method: 'DELETE',
1103
+ headers: { Authorization: authHeader },
1104
+ agent,
1105
+ },
1106
+ (res) => {
1107
+ let body = '';
1108
+ res.on('data', (chunk) => {
1109
+ body += chunk;
1110
+ });
1111
+ res.on('end', () => {
1112
+ const statusCode = res.statusCode || 0;
1113
+ if (statusCode < 200 || statusCode >= 300) {
1114
+ resolve({
1115
+ success: false,
1116
+ message: `Delete failed with status ${statusCode}: ${body}`,
1117
+ });
1118
+ return;
1119
+ }
1120
+ try {
1121
+ const data = JSON.parse(body) as ProxmoxApiResponse<string>;
1122
+ resolve({ success: true, data: data.data });
1123
+ } catch {
1124
+ resolve({ success: true, data: '' });
1125
+ }
1126
+ });
1127
+ },
1128
+ );
1129
+ req.on('error', (error) => resolve({ success: false, message: error.message }));
1130
+ req.end();
1131
+ } catch (error) {
1132
+ resolve({ success: false, message: error instanceof Error ? error.message : String(error) });
1133
+ }
1134
+ });
1135
+ }
@@ -106,6 +106,11 @@ export const COMMANDS: CommandDef[] = [
106
106
  ],
107
107
  },
108
108
  { name: 'list-subscribers', description: 'List persistent bus subscribers' },
109
+ {
110
+ name: 'resync-subscriptions',
111
+ description:
112
+ "Rebuild subscribers from deployed modules' manifests (after a restore/migration)",
113
+ },
109
114
  {
110
115
  name: 'list-pending',
111
116
  description: 'List pending deliveries',
@@ -227,7 +232,7 @@ export const COMMANDS: CommandDef[] = [
227
232
  },
228
233
  {
229
234
  name: 'install-daemon',
230
- description: 'Write a systemd/launchd user unit for the dispatcher',
235
+ description: 'Write a systemd/launchd unit for the dispatcher',
231
236
  flags: [
232
237
  {
233
238
  name: 'celilo-path',
@@ -237,15 +242,47 @@ export const COMMANDS: CommandDef[] = [
237
242
  },
238
243
  { name: 'poll-ms', description: 'Polling interval in ms', takesValue: true },
239
244
  { name: 'concurrency', description: 'Max parallel handlers', takesValue: true },
245
+ {
246
+ name: 'system',
247
+ description:
248
+ 'System scope: /etc/systemd/system unit or /Library/LaunchDaemons plist, run as the celilo state-dir owner (management-plane shape)',
249
+ takesValue: false,
250
+ },
251
+ {
252
+ name: 'print',
253
+ description:
254
+ 'Render the unit to stdout without writing it (used by the celilo-mgmt role)',
255
+ takesValue: false,
256
+ },
240
257
  ],
241
258
  },
242
259
  {
243
260
  name: 'uninstall-daemon',
244
261
  description: 'Remove the installed supervisor unit',
262
+ flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
245
263
  },
246
264
  {
247
265
  name: 'show-daemon',
248
266
  description: 'Print the currently installed unit file',
267
+ flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
268
+ },
269
+ ],
270
+ },
271
+ {
272
+ name: 'dns',
273
+ description: 'View DNS bookkeeping (registrations ledger)',
274
+ subcommands: [
275
+ {
276
+ name: 'registrations',
277
+ description: 'List the DNS registration ledger',
278
+ flags: [
279
+ {
280
+ name: 'provider',
281
+ description: 'Only show registrations owned by one provider module',
282
+ takesValue: true,
283
+ valueHint: 'module_ids',
284
+ },
285
+ ],
249
286
  },
250
287
  ],
251
288
  },
@@ -629,8 +666,16 @@ export const COMMANDS: CommandDef[] = [
629
666
  description: 'Add Proxmox container service',
630
667
  flags: [
631
668
  { name: 'api-url', description: 'Proxmox API URL', takesValue: true },
632
- { name: 'token-id', description: 'API token ID', takesValue: true },
633
- { name: 'token-secret', description: 'API token secret', takesValue: true },
669
+ {
670
+ name: 'api-token-id',
671
+ description: 'API token ID (or $PROXMOX_API_TOKEN_ID)',
672
+ takesValue: true,
673
+ },
674
+ {
675
+ name: 'api-token-secret',
676
+ description: 'API token secret (or $PROXMOX_API_TOKEN_SECRET)',
677
+ takesValue: true,
678
+ },
634
679
  { name: 'node', description: 'Target node', takesValue: true },
635
680
  {
636
681
  name: 'zone',
@@ -645,7 +690,11 @@ export const COMMANDS: CommandDef[] = [
645
690
  name: 'digitalocean',
646
691
  description: 'Add DigitalOcean container service',
647
692
  flags: [
648
- { name: 'api-token', description: 'DigitalOcean API token', takesValue: true },
693
+ {
694
+ name: 'api-token',
695
+ description: 'DigitalOcean API token (or $DIGITALOCEAN_API_TOKEN)',
696
+ takesValue: true,
697
+ },
649
698
  { name: 'region', description: 'Region', takesValue: true },
650
699
  {
651
700
  name: 'zone',
@@ -919,14 +968,25 @@ export const COMMANDS: CommandDef[] = [
919
968
  },
920
969
  ],
921
970
  },
971
+ {
972
+ name: 'migrate',
973
+ description: 'Apply pending database migrations (idempotent; safe to re-run)',
974
+ },
922
975
  {
923
976
  name: 'doctor',
924
- description: 'Diagnose system prerequisites and @celilo/* version drift',
977
+ description:
978
+ 'Diagnose system prerequisites, @celilo/* version drift, and (on a management plane) fleet-runtime drift',
925
979
  flags: [
926
980
  {
927
981
  name: 'fix',
928
982
  description:
929
- 'Repair drift by `bun link`-ing each drifted @celilo/* package from the workspace',
983
+ 'Repair the auto-fixable findings: `bun link` drifted @celilo/* packages and resync bus subscribers',
984
+ takesValue: false,
985
+ },
986
+ {
987
+ name: 'fleet',
988
+ description:
989
+ 'Force the fleet-runtime section (dispatcher, subscribers, capability chains) even without a celilo DB',
930
990
  takesValue: false,
931
991
  },
932
992
  ],
@@ -1009,6 +1069,23 @@ export const COMMANDS: CommandDef[] = [
1009
1069
  },
1010
1070
  ],
1011
1071
  },
1072
+ {
1073
+ name: 'proxmox',
1074
+ description: 'Proxmox cluster introspection (nodes, capacity)',
1075
+ subcommands: [
1076
+ {
1077
+ name: 'node',
1078
+ description: 'Proxmox node operations',
1079
+ subcommands: [
1080
+ {
1081
+ name: 'list',
1082
+ description: 'List cluster nodes with live capacity (RAM/CPU/disk)',
1083
+ args: [{ name: 'service-id', description: 'Proxmox service (optional if only one)' }],
1084
+ },
1085
+ ],
1086
+ },
1087
+ ],
1088
+ },
1012
1089
  {
1013
1090
  name: 'subscribers',
1014
1091
  description: 'Manage build-bus subscribers (cross-machine publish-event delivery)',