@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
|
@@ -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
|
|
@@ -543,6 +543,101 @@ export function findNodeForVmid(
|
|
|
543
543
|
return match?.node ?? null;
|
|
544
544
|
}
|
|
545
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
|
+
|
|
546
641
|
/**
|
|
547
642
|
* List available LXC templates in storage
|
|
548
643
|
*/
|
|
@@ -558,12 +653,11 @@ export async function listAvailableTemplates(
|
|
|
558
653
|
}
|
|
559
654
|
|
|
560
655
|
/**
|
|
561
|
-
* Make an authenticated
|
|
562
|
-
* Shares connection/auth handling with makeProxmoxRequest; the only differences
|
|
563
|
-
* are the verb and the form-encoded body.
|
|
656
|
+
* Make an authenticated form-encoded request (POST or PUT) to the Proxmox API.
|
|
564
657
|
*/
|
|
565
|
-
async function
|
|
658
|
+
async function makeProxmoxFormRequest<T>(
|
|
566
659
|
credentials: ProxmoxCredentials,
|
|
660
|
+
method: 'POST' | 'PUT',
|
|
567
661
|
path: string,
|
|
568
662
|
params: Record<string, string>,
|
|
569
663
|
): Promise<ProxmoxResult<T>> {
|
|
@@ -576,7 +670,7 @@ async function makeProxmoxPost<T>(
|
|
|
576
670
|
const postData = new URLSearchParams(params).toString();
|
|
577
671
|
|
|
578
672
|
if (process.env.DEBUG) {
|
|
579
|
-
console.log(`[Proxmox]
|
|
673
|
+
console.log(`[Proxmox] ${method}: ${fullUrl}`);
|
|
580
674
|
console.log(`[Proxmox] Body: ${postData}`);
|
|
581
675
|
}
|
|
582
676
|
|
|
@@ -587,7 +681,7 @@ async function makeProxmoxPost<T>(
|
|
|
587
681
|
hostname: url.hostname,
|
|
588
682
|
port: url.port || 443,
|
|
589
683
|
path: url.pathname,
|
|
590
|
-
method
|
|
684
|
+
method,
|
|
591
685
|
headers: {
|
|
592
686
|
Authorization: authHeader,
|
|
593
687
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -605,7 +699,7 @@ async function makeProxmoxPost<T>(
|
|
|
605
699
|
|
|
606
700
|
if (statusCode < 200 || statusCode >= 300) {
|
|
607
701
|
if (process.env.DEBUG || statusCode >= 400) {
|
|
608
|
-
console.error(`[Proxmox]
|
|
702
|
+
console.error(`[Proxmox] ${method} ${path} failed (${statusCode}): ${body}`);
|
|
609
703
|
}
|
|
610
704
|
resolve({
|
|
611
705
|
success: false,
|
|
@@ -649,6 +743,14 @@ async function makeProxmoxPost<T>(
|
|
|
649
743
|
});
|
|
650
744
|
}
|
|
651
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
|
+
|
|
652
754
|
/**
|
|
653
755
|
* Entry from Proxmox's appliance catalog (`pveam available`). The `template`
|
|
654
756
|
* field is the canonical filename (revision included) that should be passed to
|
|
@@ -742,3 +844,292 @@ export function buildTemplatePath(storageName: string, templateFilename: string)
|
|
|
742
844
|
export function buildProxmoxApiUrl(ipAddress: string, port = 8006): string {
|
|
743
845
|
return `https://${ipAddress}:${port}/api2/json`;
|
|
744
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
|
|
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
|
-
{
|
|
633
|
-
|
|
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
|
-
{
|
|
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:
|
|
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
|
|
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)',
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Delete a specific backup entry and its storage file.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import * as p from '@clack/prompts';
|
|
7
6
|
import { deleteBackupRecord, formatSize, getBackup } from '../../services/backup-metadata';
|
|
8
7
|
import { createStorageProvider, getBackupStorage } from '../../services/backup-storage';
|
|
8
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
9
9
|
import { celiloIntro, celiloOutro } from '../prompts';
|
|
10
10
|
import type { CommandResult } from '../types';
|
|
11
11
|
|
|
@@ -40,13 +40,16 @@ export async function handleBackupDelete(
|
|
|
40
40
|
console.log(` → ${storage?.storageId ?? '?'}: ${backup.storagePath}\n`);
|
|
41
41
|
|
|
42
42
|
if (!flags.force) {
|
|
43
|
-
const confirmed = await
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
const confirmed = await withInterviewSession(() =>
|
|
44
|
+
askConfirm({
|
|
45
|
+
scope: `backup:${backupId}`,
|
|
46
|
+
key: 'delete',
|
|
47
|
+
message: 'Delete this backup?',
|
|
48
|
+
defaultValue: false,
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
47
51
|
|
|
48
|
-
if (
|
|
49
|
-
p.cancel('Operation cancelled');
|
|
52
|
+
if (!confirmed) {
|
|
50
53
|
return { success: false, error: 'Cancelled by user' };
|
|
51
54
|
}
|
|
52
55
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, statSync } from 'node:fs';
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
|
-
import * as p from '@clack/prompts';
|
|
9
8
|
import { formatSize } from '../../services/backup-metadata';
|
|
9
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
10
10
|
import { celiloIntro, celiloOutro } from '../prompts';
|
|
11
11
|
import type { CommandResult } from '../types';
|
|
12
12
|
|
|
@@ -54,13 +54,16 @@ export async function handleBackupImport(
|
|
|
54
54
|
|
|
55
55
|
// Confirm unless --yes
|
|
56
56
|
if (!flags.yes) {
|
|
57
|
-
const confirmed = await
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
const confirmed = await withInterviewSession(() =>
|
|
58
|
+
askConfirm({
|
|
59
|
+
scope: `backup-import:${moduleId}`,
|
|
60
|
+
key: 'import',
|
|
61
|
+
message: `Import this file as a backup for ${moduleId}?`,
|
|
62
|
+
defaultValue: true,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (!confirmed) {
|
|
64
67
|
return { success: false, error: 'Cancelled by user' };
|
|
65
68
|
}
|
|
66
69
|
}
|