@celilo/cli 0.5.0-alpha.8 → 0.5.0
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/package.json +1 -1
- package/src/api-clients/proxmox.test.ts +108 -20
- package/src/api-clients/proxmox.ts +130 -24
- package/src/cli/command-registry.ts +32 -3
- 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/events.ts +8 -3
- 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-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +10 -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 +25 -18
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +275 -260
- package/src/cli/commands/service-reconfigure.ts +171 -153
- 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-init.ts +119 -128
- package/src/cli/completion.ts +15 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/service-credential.ts +54 -0
- package/src/services/bus-interview.ts +232 -0
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +6 -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/proxmox-state-recovery.ts +26 -7
- package/src/services/terminal-responder.ts +113 -0
- package/src/templates/generator.test.ts +52 -1
- package/src/templates/generator.ts +106 -37
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import {
|
|
3
|
+
type ProxmoxClusterResource,
|
|
3
4
|
buildCloudImageVolid,
|
|
4
5
|
filterVmTemplates,
|
|
5
6
|
findNodeForVmid,
|
|
6
7
|
pollTaskUntilDone,
|
|
7
8
|
selectFreeVmid,
|
|
8
|
-
|
|
9
|
+
selectImportStorage,
|
|
10
|
+
summarizeNodeCapacities,
|
|
9
11
|
} from './proxmox';
|
|
10
12
|
|
|
11
13
|
describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current location)', () => {
|
|
@@ -36,6 +38,82 @@ describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current l
|
|
|
36
38
|
});
|
|
37
39
|
});
|
|
38
40
|
|
|
41
|
+
describe('summarizeNodeCapacities (ISS-0060 — per-node capacity reconciled from Proxmox)', () => {
|
|
42
|
+
const GB = 1024 * 1024 * 1024;
|
|
43
|
+
const resources: ProxmoxClusterResource[] = [
|
|
44
|
+
{
|
|
45
|
+
type: 'node',
|
|
46
|
+
node: 'node3',
|
|
47
|
+
status: 'online',
|
|
48
|
+
maxmem: 16 * GB,
|
|
49
|
+
mem: 4 * GB,
|
|
50
|
+
maxcpu: 8,
|
|
51
|
+
cpu: 0.25,
|
|
52
|
+
maxdisk: 200 * GB,
|
|
53
|
+
disk: 50 * GB,
|
|
54
|
+
uptime: 3600,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: 'node',
|
|
58
|
+
node: 'node2',
|
|
59
|
+
status: 'offline',
|
|
60
|
+
maxmem: 8 * GB,
|
|
61
|
+
mem: 7 * GB,
|
|
62
|
+
maxcpu: 4,
|
|
63
|
+
cpu: 0.9,
|
|
64
|
+
maxdisk: 100 * GB,
|
|
65
|
+
disk: 90 * GB,
|
|
66
|
+
uptime: 0,
|
|
67
|
+
},
|
|
68
|
+
// Guest + storage rows must be ignored.
|
|
69
|
+
{ type: 'lxc', vmid: 200, node: 'node2', status: 'running' },
|
|
70
|
+
{ type: 'storage', node: 'node3', status: 'available' },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
test('derives free RAM/CPU/disk and online state from node rows only', () => {
|
|
74
|
+
const caps = summarizeNodeCapacities(resources);
|
|
75
|
+
expect(caps).toHaveLength(2); // guest + storage rows dropped
|
|
76
|
+
// Sorted by node name → node2 first.
|
|
77
|
+
expect(caps[0]).toEqual({
|
|
78
|
+
node: 'node2',
|
|
79
|
+
online: false,
|
|
80
|
+
memTotalMb: 8192,
|
|
81
|
+
memFreeMb: 1024,
|
|
82
|
+
cpuCores: 4,
|
|
83
|
+
cpuUsedPct: 90,
|
|
84
|
+
diskTotalGb: 100,
|
|
85
|
+
diskFreeGb: 10,
|
|
86
|
+
uptimeSec: 0,
|
|
87
|
+
});
|
|
88
|
+
expect(caps[1]).toMatchObject({
|
|
89
|
+
node: 'node3',
|
|
90
|
+
online: true,
|
|
91
|
+
memFreeMb: 12288,
|
|
92
|
+
cpuUsedPct: 25,
|
|
93
|
+
diskFreeGb: 150,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('tolerates missing fields (treats absent capacity as 0, offline unless status==online)', () => {
|
|
98
|
+
const caps = summarizeNodeCapacities([{ type: 'node', node: 'bare' }]);
|
|
99
|
+
expect(caps[0]).toEqual({
|
|
100
|
+
node: 'bare',
|
|
101
|
+
online: false,
|
|
102
|
+
memTotalMb: 0,
|
|
103
|
+
memFreeMb: 0,
|
|
104
|
+
cpuCores: 0,
|
|
105
|
+
cpuUsedPct: 0,
|
|
106
|
+
diskTotalGb: 0,
|
|
107
|
+
diskFreeGb: 0,
|
|
108
|
+
uptimeSec: 0,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('empty inventory → no nodes', () => {
|
|
113
|
+
expect(summarizeNodeCapacities([])).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
39
117
|
describe('filterVmTemplates — extract VM templates from a /nodes/{node}/qemu listing', () => {
|
|
40
118
|
test('keeps only guests flagged template===1 and projects to {vmid, name}', () => {
|
|
41
119
|
const guests = [
|
|
@@ -59,38 +137,48 @@ describe('filterVmTemplates — extract VM templates from a /nodes/{node}/qemu l
|
|
|
59
137
|
});
|
|
60
138
|
});
|
|
61
139
|
|
|
62
|
-
describe('
|
|
63
|
-
test('returns the first active, enabled storage whose content includes
|
|
140
|
+
describe('selectImportStorage — pick a storage that accepts import content', () => {
|
|
141
|
+
test('returns the first active, enabled storage whose content includes import', () => {
|
|
64
142
|
const storages = [
|
|
65
143
|
{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 },
|
|
66
|
-
{ storage: 'local', content: '
|
|
144
|
+
{ storage: 'local', content: 'import,vztmpl,backup', active: 1, enabled: 1 },
|
|
145
|
+
];
|
|
146
|
+
expect(selectImportStorage(storages)).toBe('local');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('does NOT pick an iso-only storage (import-from rejects iso content)', () => {
|
|
150
|
+
// Regression guard: the cloud image must land in the import namespace, not
|
|
151
|
+
// iso/, or the VM-create `import-from=` fails with `has wrong type 'iso'`.
|
|
152
|
+
const storages = [
|
|
153
|
+
{ storage: 'iso-only', content: 'iso,vztmpl,backup', active: 1, enabled: 1 },
|
|
154
|
+
{ storage: 'data', content: 'import,images', active: 1, enabled: 1 },
|
|
67
155
|
];
|
|
68
|
-
expect(
|
|
156
|
+
expect(selectImportStorage(storages)).toBe('data');
|
|
69
157
|
});
|
|
70
158
|
|
|
71
|
-
test('skips an
|
|
159
|
+
test('skips an import-capable storage that is inactive', () => {
|
|
72
160
|
const storages = [
|
|
73
|
-
{ storage: 'cold', content: '
|
|
74
|
-
{ storage: 'local', content: '
|
|
161
|
+
{ storage: 'cold', content: 'import', active: 0, enabled: 1 },
|
|
162
|
+
{ storage: 'local', content: 'import', active: 1, enabled: 1 },
|
|
75
163
|
];
|
|
76
|
-
expect(
|
|
164
|
+
expect(selectImportStorage(storages)).toBe('local');
|
|
77
165
|
});
|
|
78
166
|
|
|
79
|
-
test('skips an
|
|
167
|
+
test('skips an import-capable storage that is disabled', () => {
|
|
80
168
|
const storages = [
|
|
81
|
-
{ storage: 'off', content: '
|
|
82
|
-
{ storage: 'local', content: '
|
|
169
|
+
{ storage: 'off', content: 'import', active: 1, enabled: 0 },
|
|
170
|
+
{ storage: 'local', content: 'import', active: 1, enabled: 1 },
|
|
83
171
|
];
|
|
84
|
-
expect(
|
|
172
|
+
expect(selectImportStorage(storages)).toBe('local');
|
|
85
173
|
});
|
|
86
174
|
|
|
87
|
-
test('returns null when no storage accepts
|
|
175
|
+
test('returns null when no storage accepts import content', () => {
|
|
88
176
|
const storages = [{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 }];
|
|
89
|
-
expect(
|
|
177
|
+
expect(selectImportStorage(storages)).toBeNull();
|
|
90
178
|
});
|
|
91
179
|
|
|
92
180
|
test('returns null for an empty storage list', () => {
|
|
93
|
-
expect(
|
|
181
|
+
expect(selectImportStorage([])).toBeNull();
|
|
94
182
|
});
|
|
95
183
|
});
|
|
96
184
|
|
|
@@ -122,15 +210,15 @@ describe('selectFreeVmid — first free VMID >= minVmid (template hygiene)', ()
|
|
|
122
210
|
});
|
|
123
211
|
});
|
|
124
212
|
|
|
125
|
-
describe('buildCloudImageVolid — volid for a cloud image on
|
|
126
|
-
test('places the filename under the storage
|
|
213
|
+
describe('buildCloudImageVolid — volid for a cloud image on import storage', () => {
|
|
214
|
+
test('places the filename under the storage import/ namespace', () => {
|
|
127
215
|
expect(buildCloudImageVolid('local', 'noble-server-cloudimg-amd64.img')).toBe(
|
|
128
|
-
'local:
|
|
216
|
+
'local:import/noble-server-cloudimg-amd64.img',
|
|
129
217
|
);
|
|
130
218
|
});
|
|
131
219
|
|
|
132
220
|
test('works with an arbitrary storage name', () => {
|
|
133
|
-
expect(buildCloudImageVolid('nas
|
|
221
|
+
expect(buildCloudImageVolid('nas', 'jammy.img')).toBe('nas:import/jammy.img');
|
|
134
222
|
});
|
|
135
223
|
});
|
|
136
224
|
|
|
@@ -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
|
*/
|
|
@@ -795,30 +890,38 @@ export async function listVmTemplates(
|
|
|
795
890
|
return { success: true, data: filterVmTemplates(result.data) };
|
|
796
891
|
}
|
|
797
892
|
|
|
798
|
-
/** Storage row shape (subset) used when picking
|
|
893
|
+
/** Storage row shape (subset) used when picking import-capable storage. */
|
|
799
894
|
type ProxmoxStorageRow = { storage: string; content: string; active: number; enabled: number };
|
|
800
895
|
|
|
801
896
|
/**
|
|
802
|
-
* Pick the first active, enabled storage that accepts `
|
|
897
|
+
* Pick the first active, enabled storage that accepts `import` content. Pure
|
|
803
898
|
* selection logic, split from the network call for testability (Rule 10).
|
|
804
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.)
|
|
805
907
|
*/
|
|
806
|
-
export function
|
|
807
|
-
const
|
|
808
|
-
return
|
|
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;
|
|
809
911
|
}
|
|
810
912
|
|
|
811
913
|
/**
|
|
812
|
-
* Find a storage on the node that accepts `
|
|
813
|
-
* images). Returns the storage name,
|
|
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.
|
|
814
917
|
*/
|
|
815
|
-
export async function
|
|
918
|
+
export async function findImportStorage(
|
|
816
919
|
credentials: ProxmoxCredentials,
|
|
817
920
|
nodeName: string,
|
|
818
921
|
): Promise<ProxmoxResult<string | null>> {
|
|
819
922
|
const result = await listNodeStorage(credentials, nodeName);
|
|
820
923
|
if (!result.success) return result;
|
|
821
|
-
return { success: true, data:
|
|
924
|
+
return { success: true, data: selectImportStorage(result.data) };
|
|
822
925
|
}
|
|
823
926
|
|
|
824
927
|
/**
|
|
@@ -858,20 +961,22 @@ export async function findFreeTemplateVmid(
|
|
|
858
961
|
}
|
|
859
962
|
|
|
860
963
|
/**
|
|
861
|
-
* Download a cloud image URL to
|
|
862
|
-
* status polling. Requires PVE
|
|
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.
|
|
863
968
|
*/
|
|
864
969
|
export async function downloadCloudImage(
|
|
865
970
|
credentials: ProxmoxCredentials,
|
|
866
971
|
nodeName: string,
|
|
867
|
-
|
|
972
|
+
importStorage: string,
|
|
868
973
|
url: string,
|
|
869
974
|
filename: string,
|
|
870
975
|
): Promise<ProxmoxResult<string>> {
|
|
871
976
|
return makeProxmoxPost<string>(
|
|
872
977
|
credentials,
|
|
873
|
-
`/nodes/${nodeName}/storage/${
|
|
874
|
-
{ url, filename, content: '
|
|
978
|
+
`/nodes/${nodeName}/storage/${importStorage}/download-url`,
|
|
979
|
+
{ url, filename, content: 'import' },
|
|
875
980
|
);
|
|
876
981
|
}
|
|
877
982
|
|
|
@@ -908,13 +1013,14 @@ export async function pollTaskUntilDone(
|
|
|
908
1013
|
}
|
|
909
1014
|
|
|
910
1015
|
/**
|
|
911
|
-
* Build the volid for a cloud image downloaded to
|
|
1016
|
+
* Build the volid for a cloud image downloaded to `import` storage. Pure path
|
|
912
1017
|
* construction, split out for testability (Rule 10) — mirrors buildTemplatePath.
|
|
913
|
-
* Proxmox's `download-url` with `content=
|
|
914
|
-
* storage's `
|
|
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).
|
|
915
1021
|
*/
|
|
916
|
-
export function buildCloudImageVolid(
|
|
917
|
-
return `${
|
|
1022
|
+
export function buildCloudImageVolid(importStorage: string, filename: string): string {
|
|
1023
|
+
return `${importStorage}:import/${filename}`;
|
|
918
1024
|
}
|
|
919
1025
|
|
|
920
1026
|
export interface CreateVmFromCloudImageParams {
|
|
@@ -922,8 +1028,8 @@ export interface CreateVmFromCloudImageParams {
|
|
|
922
1028
|
nodeName: string;
|
|
923
1029
|
vmid: number;
|
|
924
1030
|
name: string;
|
|
925
|
-
/** Volume ID of the downloaded cloud image, e.g. `local:
|
|
926
|
-
|
|
1031
|
+
/** Volume ID of the downloaded cloud image, e.g. `local:import/noble-server-cloudimg-amd64.img` */
|
|
1032
|
+
imageVolid: string;
|
|
927
1033
|
/** Storage for the imported VM disk and the cloud-init drive */
|
|
928
1034
|
diskStorage: string;
|
|
929
1035
|
}
|
|
@@ -943,7 +1049,7 @@ export interface CreateVmFromCloudImageParams {
|
|
|
943
1049
|
export async function createVmFromCloudImage(
|
|
944
1050
|
params: CreateVmFromCloudImageParams,
|
|
945
1051
|
): Promise<ProxmoxResult<string>> {
|
|
946
|
-
const { credentials, nodeName, vmid, name,
|
|
1052
|
+
const { credentials, nodeName, vmid, name, imageVolid, diskStorage } = params;
|
|
947
1053
|
return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu`, {
|
|
948
1054
|
vmid: String(vmid),
|
|
949
1055
|
name,
|
|
@@ -955,7 +1061,7 @@ export async function createVmFromCloudImage(
|
|
|
955
1061
|
serial0: 'socket',
|
|
956
1062
|
vga: 'serial0',
|
|
957
1063
|
boot: 'order=scsi0',
|
|
958
|
-
scsi0: `${diskStorage}:0,import-from=${
|
|
1064
|
+
scsi0: `${diskStorage}:0,import-from=${imageVolid}`,
|
|
959
1065
|
ide2: `${diskStorage}:cloudinit`,
|
|
960
1066
|
});
|
|
961
1067
|
}
|
|
@@ -666,8 +666,16 @@ export const COMMANDS: CommandDef[] = [
|
|
|
666
666
|
description: 'Add Proxmox container service',
|
|
667
667
|
flags: [
|
|
668
668
|
{ name: 'api-url', description: 'Proxmox API URL', takesValue: true },
|
|
669
|
-
{
|
|
670
|
-
|
|
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
|
+
},
|
|
671
679
|
{ name: 'node', description: 'Target node', takesValue: true },
|
|
672
680
|
{
|
|
673
681
|
name: 'zone',
|
|
@@ -682,7 +690,11 @@ export const COMMANDS: CommandDef[] = [
|
|
|
682
690
|
name: 'digitalocean',
|
|
683
691
|
description: 'Add DigitalOcean container service',
|
|
684
692
|
flags: [
|
|
685
|
-
{
|
|
693
|
+
{
|
|
694
|
+
name: 'api-token',
|
|
695
|
+
description: 'DigitalOcean API token (or $DIGITALOCEAN_API_TOKEN)',
|
|
696
|
+
takesValue: true,
|
|
697
|
+
},
|
|
686
698
|
{ name: 'region', description: 'Region', takesValue: true },
|
|
687
699
|
{
|
|
688
700
|
name: 'zone',
|
|
@@ -1057,6 +1069,23 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1057
1069
|
},
|
|
1058
1070
|
],
|
|
1059
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
|
+
},
|
|
1060
1089
|
{
|
|
1061
1090
|
name: 'subscribers',
|
|
1062
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
|
}
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* Restores from a backup archive, executing the module's on_restore hook.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import * as p from '@clack/prompts';
|
|
7
6
|
import { formatSize, getBackup } from '../../services/backup-metadata';
|
|
8
7
|
import { restoreModuleBackup, restoreSystemStateBackup } from '../../services/backup-restore';
|
|
9
8
|
import { getBackupStorage } from '../../services/backup-storage';
|
|
9
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
10
10
|
import { celiloIntro, celiloOutro } from '../prompts';
|
|
11
11
|
import type { CommandResult } from '../types';
|
|
12
12
|
|
|
@@ -66,13 +66,16 @@ export async function handleBackupRestore(
|
|
|
66
66
|
|
|
67
67
|
// Confirm unless --yes
|
|
68
68
|
if (!flags.yes) {
|
|
69
|
-
const confirmed = await
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
const confirmed = await withInterviewSession(() =>
|
|
70
|
+
askConfirm({
|
|
71
|
+
scope: `backup-restore:${backupId}`,
|
|
72
|
+
key: 'proceed',
|
|
73
|
+
message: 'Proceed with restore?',
|
|
74
|
+
defaultValue: false,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (!confirmed) {
|
|
76
79
|
return { success: false, error: 'Cancelled by user' };
|
|
77
80
|
}
|
|
78
81
|
}
|
|
@@ -389,7 +389,7 @@ export async function handleEventsRepair(): Promise<CommandResult> {
|
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
const INTERVIEW_FAMILIES = ['config', 'secret', 'ensure', 'aspect'] as const;
|
|
392
|
+
const INTERVIEW_FAMILIES = ['config', 'secret', 'ensure', 'aspect', 'interview'] as const;
|
|
393
393
|
type InterviewFamily = (typeof INTERVIEW_FAMILIES)[number];
|
|
394
394
|
|
|
395
395
|
/** Classify a query event type into its interview family, or null if it isn't one. */
|
|
@@ -398,6 +398,7 @@ function interviewFamily(type: string): InterviewFamily | null {
|
|
|
398
398
|
if (type.startsWith('secret.required.')) return 'secret';
|
|
399
399
|
if (type.startsWith('ensure.required.')) return 'ensure';
|
|
400
400
|
if (type.startsWith('aspect.required.')) return 'aspect';
|
|
401
|
+
if (type.startsWith('interview.required.')) return 'interview';
|
|
401
402
|
return null;
|
|
402
403
|
}
|
|
403
404
|
|
|
@@ -428,6 +429,10 @@ function interviewFamily(type: string): InterviewFamily | null {
|
|
|
428
429
|
* base-module aspect, 'false' refuses it (ISS-0027).
|
|
429
430
|
* Replies { consented }; the deploy records the
|
|
430
431
|
* approval/denial.
|
|
432
|
+
* - interview.required.<scope>.<key> → value is the answer itself, shaped per
|
|
433
|
+
* the question's kind ('"node3"', 'true',
|
|
434
|
+
* '["a","b"]'); replies { value }. The generic
|
|
435
|
+
* operator-command interview family (ISS-0127).
|
|
431
436
|
*/
|
|
432
437
|
export async function handleEventsReply(
|
|
433
438
|
args: string[],
|
|
@@ -476,7 +481,7 @@ export async function handleEventsReply(
|
|
|
476
481
|
return {
|
|
477
482
|
success: false,
|
|
478
483
|
error: `Event ${queryId} is type '${query.type}', not an interview query.
|
|
479
|
-
Expected one of: config.required.* / secret.required.* / ensure.required.* / aspect.required.*`,
|
|
484
|
+
Expected one of: config.required.* / secret.required.* / ensure.required.* / aspect.required.* / interview.required.*`,
|
|
480
485
|
};
|
|
481
486
|
}
|
|
482
487
|
|
|
@@ -496,7 +501,7 @@ export async function handleEventsReply(
|
|
|
496
501
|
});
|
|
497
502
|
}
|
|
498
503
|
|
|
499
|
-
if (family === 'config') {
|
|
504
|
+
if (family === 'config' || family === 'interview') {
|
|
500
505
|
bus.emitRaw(`${query.type}.reply`, { value }, { replyFor: queryId, emittedBy });
|
|
501
506
|
return jsonResult({ ok: true, status: 'replied', queryId, type: query.type, family, value });
|
|
502
507
|
}
|