@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.
- 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 +240 -0
- package/src/api-clients/proxmox.ts +456 -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 +166 -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 +122 -15
- package/src/templates/generator.ts +206 -57
- 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
|
|
@@ -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
|
|
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
|
|
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]
|
|
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
|
|
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]
|
|
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
|
|
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)',
|