puls-dev 0.2.8 → 0.3.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/dist/core/checker.js +71 -0
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +12 -1
- package/dist/core/context.d.ts +14 -0
- package/dist/core/context.js +2 -0
- package/dist/core/decorators.d.ts +2 -0
- package/dist/core/decorators.js +8 -14
- package/dist/core/group.test.d.ts +1 -0
- package/dist/core/group.test.js +94 -0
- package/dist/core/parallel.test.d.ts +1 -0
- package/dist/core/parallel.test.js +215 -0
- package/dist/core/production.test.d.ts +1 -0
- package/dist/core/production.test.js +189 -0
- package/dist/core/provisioner.js +29 -11
- package/dist/core/resource.d.ts +8 -0
- package/dist/core/resource.js +45 -0
- package/dist/core/retry.d.ts +9 -0
- package/dist/core/retry.js +28 -0
- package/dist/core/retry.test.d.ts +1 -0
- package/dist/core/retry.test.js +66 -0
- package/dist/core/secret.d.ts +2 -1
- package/dist/core/secret.js +12 -2
- package/dist/core/stack.js +381 -75
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/providers/aws/api.js +97 -17
- package/dist/providers/aws/ec2.d.ts +3 -0
- package/dist/providers/aws/ec2.js +37 -3
- package/dist/providers/aws/ec2.test.js +5 -3
- package/dist/providers/aws/index.d.ts +2 -0
- package/dist/providers/aws/index.js +2 -0
- package/dist/providers/aws/secrets.js +20 -3
- package/dist/providers/aws/template.d.ts +34 -0
- package/dist/providers/aws/template.js +252 -0
- package/dist/providers/aws/template.test.d.ts +1 -0
- package/dist/providers/aws/template.test.js +208 -0
- package/dist/providers/do/api.d.ts +2 -0
- package/dist/providers/do/api.js +124 -26
- package/dist/providers/do/droplet.js +14 -0
- package/dist/providers/firebase/api.js +92 -29
- package/dist/providers/firebase/list.d.ts +2 -0
- package/dist/providers/firebase/list.js +25 -0
- package/dist/providers/gcp/api.js +88 -14
- package/dist/providers/gcp/index.d.ts +3 -1
- package/dist/providers/gcp/index.js +3 -1
- package/dist/providers/gcp/list.d.ts +2 -0
- package/dist/providers/gcp/list.js +55 -0
- package/dist/providers/gcp/secrets.js +21 -4
- package/dist/providers/gcp/template.d.ts +32 -0
- package/dist/providers/gcp/template.js +252 -0
- package/dist/providers/gcp/template.test.d.ts +1 -0
- package/dist/providers/gcp/template.test.js +227 -0
- package/dist/providers/gcp/vm.d.ts +3 -0
- package/dist/providers/gcp/vm.js +46 -3
- package/dist/providers/proxmox/api.d.ts +1 -0
- package/dist/providers/proxmox/api.js +72 -16
- package/dist/providers/proxmox/index.d.ts +3 -1
- package/dist/providers/proxmox/index.js +14 -1
- package/dist/providers/proxmox/template.d.ts +44 -0
- package/dist/providers/proxmox/template.js +350 -0
- package/dist/providers/proxmox/template.test.d.ts +1 -0
- package/dist/providers/proxmox/template.test.js +215 -0
- package/dist/providers/proxmox/vm.d.ts +3 -0
- package/dist/providers/proxmox/vm.js +43 -11
- package/dist/types/inventory.d.ts +44 -1
- package/package.json +2 -2
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
5
|
+
import { Config } from "../../core/config.js";
|
|
6
|
+
import { Output } from "../../core/output.js";
|
|
7
|
+
import { getPMClient } from "./api.js";
|
|
8
|
+
import { getFileHash, parseProvisionMetadata, mergeProvisionMetadata } from "./hash.js";
|
|
9
|
+
import { checkPort, runProvisioner } from "../../core/provisioner.js";
|
|
10
|
+
export class TemplateBuilder extends BaseBuilder {
|
|
11
|
+
out = {
|
|
12
|
+
vmid: new Output(),
|
|
13
|
+
};
|
|
14
|
+
resolvedVmid = null;
|
|
15
|
+
resolvedNode = null;
|
|
16
|
+
_baseImage;
|
|
17
|
+
_cores = 2;
|
|
18
|
+
_memory = 2048;
|
|
19
|
+
_provision = [];
|
|
20
|
+
_storage;
|
|
21
|
+
_sshKeys;
|
|
22
|
+
constructor(name) {
|
|
23
|
+
super(name);
|
|
24
|
+
this.discoveryPromise = this.discoverTemplate(name);
|
|
25
|
+
}
|
|
26
|
+
async discoverTemplate(name) {
|
|
27
|
+
try {
|
|
28
|
+
const pm = getPMClient();
|
|
29
|
+
const resources = await pm.get("/cluster/resources?type=vm");
|
|
30
|
+
const match = (resources ?? []).find((r) => r.name === name && r.template === 1) ?? null;
|
|
31
|
+
if (match) {
|
|
32
|
+
try {
|
|
33
|
+
const config = await pm.get(`/nodes/${match.node}/qemu/${match.vmid}/config`);
|
|
34
|
+
match.description = config.description ?? "";
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
match.description = "";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return match;
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e.message?.includes("not configured"))
|
|
44
|
+
return null;
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
baseImage(os) {
|
|
49
|
+
this._baseImage = os;
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
cores(n) {
|
|
53
|
+
this._cores = n;
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
memory(mb) {
|
|
57
|
+
this._memory = mb;
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
storage(pool) {
|
|
61
|
+
this._storage = pool;
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
sshKey(keys) {
|
|
65
|
+
this._sshKeys = Array.isArray(keys) ? [...keys] : keys;
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
provision(...playbookPaths) {
|
|
69
|
+
this._provision.push(...playbookPaths.flat());
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
async deploy() {
|
|
73
|
+
const dryRun = this.isDryRunActive();
|
|
74
|
+
const existing = await this.discoveryPromise;
|
|
75
|
+
const pm = getPMClient();
|
|
76
|
+
if (existing) {
|
|
77
|
+
this.resolvedVmid = existing.vmid;
|
|
78
|
+
this.resolvedNode = existing.node;
|
|
79
|
+
this.out.vmid.resolve(existing.vmid);
|
|
80
|
+
// Check if playbook hashes differ
|
|
81
|
+
const appliedHashes = parseProvisionMetadata(existing.description ?? "");
|
|
82
|
+
const declaredPlaybooksWithHashes = this._provision.map((p) => {
|
|
83
|
+
const baseName = p.split("/").pop() ?? p;
|
|
84
|
+
return { path: p, baseName, hash: getFileHash(p) };
|
|
85
|
+
});
|
|
86
|
+
const hasChanges = declaredPlaybooksWithHashes.some((p) => {
|
|
87
|
+
const appliedHash = appliedHashes[p.baseName];
|
|
88
|
+
return !appliedHash || appliedHash !== p.hash;
|
|
89
|
+
});
|
|
90
|
+
if (!hasChanges) {
|
|
91
|
+
console.log(`\n🖥️ Finalizing Proxmox Template "${this.name}"...`);
|
|
92
|
+
console.log(` ✅ Template "${this.name}" already exists and matches defined state.`);
|
|
93
|
+
return { name: this.name, vmid: this.resolvedVmid, node: this.resolvedNode };
|
|
94
|
+
}
|
|
95
|
+
console.log(`\n🖥️ Finalizing Proxmox Template "${this.name}"...`);
|
|
96
|
+
console.log(` 🔄 Template playbook hashes changed. Purging old template...`);
|
|
97
|
+
if (dryRun) {
|
|
98
|
+
console.log(` 📝 [PLAN] Would purge template "${this.name}" (vmid=${existing.vmid}) and rebuild.`);
|
|
99
|
+
return { name: this.name, vmid: "PENDING" };
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
await pm.delete(`/nodes/${existing.node}/qemu/${existing.vmid}?purge=1&destroy-unreferenced-disks=1`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.log(`\n🖥️ Finalizing Proxmox Template "${this.name}"...`);
|
|
106
|
+
if (dryRun) {
|
|
107
|
+
console.log(` 📝 [PLAN] Bake Proxmox Template "${this.name}"`);
|
|
108
|
+
if (this._baseImage)
|
|
109
|
+
console.log(` └─ Base Image: ${this._baseImage}`);
|
|
110
|
+
console.log(` └─ Cores: ${this._cores} Memory: ${this._memory} MB`);
|
|
111
|
+
if (this._provision.length > 0) {
|
|
112
|
+
console.log(` └─ Provision: ${this._provision.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
this.out.vmid.resolve(-1);
|
|
115
|
+
return { name: this.name, vmid: "PENDING" };
|
|
116
|
+
}
|
|
117
|
+
// Pick target node (cluster-aware)
|
|
118
|
+
let node;
|
|
119
|
+
try {
|
|
120
|
+
const nodesList = await pm.get("/nodes");
|
|
121
|
+
const configuredNodes = Config.get().providers.proxmox?.nodes;
|
|
122
|
+
const onlineNodes = (nodesList ?? []).filter((n) => {
|
|
123
|
+
if (n.status !== "online")
|
|
124
|
+
return false;
|
|
125
|
+
if (configuredNodes && configuredNodes.length > 0) {
|
|
126
|
+
return configuredNodes.includes(n.node);
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
});
|
|
130
|
+
if (onlineNodes.length > 0) {
|
|
131
|
+
onlineNodes.sort((a, b) => {
|
|
132
|
+
const freeA = (a.maxmem ?? 0) - (a.mem ?? 0);
|
|
133
|
+
const freeB = (b.maxmem ?? 0) - (b.mem ?? 0);
|
|
134
|
+
return freeB - freeA;
|
|
135
|
+
});
|
|
136
|
+
node = onlineNodes[0].node;
|
|
137
|
+
console.log(` 🧠 Cluster-aware node selection: picked "${node}" with the most free RAM (${Math.round((((onlineNodes[0].maxmem ?? 0) - (onlineNodes[0].mem ?? 0)) / 1024 / 1024 / 1024) * 10) / 10} GB free)`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
// Fallback
|
|
142
|
+
}
|
|
143
|
+
if (!node) {
|
|
144
|
+
const configuredNodes = Config.get().providers.proxmox?.nodes;
|
|
145
|
+
node = configuredNodes?.[0];
|
|
146
|
+
}
|
|
147
|
+
if (!node) {
|
|
148
|
+
const nodes = await pm.get("/nodes");
|
|
149
|
+
node = (nodes ?? [])[0]?.node;
|
|
150
|
+
}
|
|
151
|
+
if (!node)
|
|
152
|
+
throw new Error("No Proxmox nodes available");
|
|
153
|
+
const newVmid = await pm.get("/cluster/nextid");
|
|
154
|
+
const storage = this._storage ?? "rbd_pool";
|
|
155
|
+
// Lookup base image template
|
|
156
|
+
const resources = await pm.get("/cluster/resources?type=vm");
|
|
157
|
+
const isVmid = this._baseImage && /^\d+$/.test(this._baseImage);
|
|
158
|
+
const baseTemplate = this._baseImage
|
|
159
|
+
? (resources ?? []).find((r) => r.template === 1 &&
|
|
160
|
+
(isVmid
|
|
161
|
+
? String(r.vmid) === this._baseImage
|
|
162
|
+
: r.name?.includes(this._baseImage)))
|
|
163
|
+
: null;
|
|
164
|
+
if (this._baseImage && !baseTemplate) {
|
|
165
|
+
throw new Error(`No Proxmox base template found matching "${this._baseImage}".`);
|
|
166
|
+
}
|
|
167
|
+
if (baseTemplate) {
|
|
168
|
+
console.log(` 📋 Cloning base template "${baseTemplate.name}" (vmid=${baseTemplate.vmid}) → "${this.name}" (vmid=${newVmid})`);
|
|
169
|
+
const taskId = await pm.post(`/nodes/${baseTemplate.node || node}/qemu/${baseTemplate.vmid}/clone`, {
|
|
170
|
+
newid: newVmid,
|
|
171
|
+
name: this.name,
|
|
172
|
+
full: 1,
|
|
173
|
+
storage,
|
|
174
|
+
format: "raw",
|
|
175
|
+
target: node,
|
|
176
|
+
});
|
|
177
|
+
await this.waitForTask(baseTemplate.node || node, taskId, pm);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
console.log(` 🆕 Creating blank VM "${this.name}" (vmid=${newVmid})`);
|
|
181
|
+
await pm.post(`/nodes/${node}/qemu`, {
|
|
182
|
+
vmid: newVmid,
|
|
183
|
+
name: this.name,
|
|
184
|
+
cores: this._cores,
|
|
185
|
+
memory: this._memory,
|
|
186
|
+
net0: "virtio,bridge=vmbr1",
|
|
187
|
+
ostype: "l26",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
this.resolvedVmid = newVmid;
|
|
191
|
+
this.resolvedNode = node;
|
|
192
|
+
// Apply config
|
|
193
|
+
const configPatch = {
|
|
194
|
+
onboot: 0,
|
|
195
|
+
cores: this._cores,
|
|
196
|
+
memory: this._memory,
|
|
197
|
+
net0: "virtio,bridge=vmbr1",
|
|
198
|
+
ipconfig0: "ip=dhcp",
|
|
199
|
+
nameserver: (Config.get().providers.proxmox?.dnsServers ?? ["1.1.1.1", "8.8.8.8"]).join(" "),
|
|
200
|
+
searchdomain: Config.get().providers.proxmox?.dnsDomain ?? "",
|
|
201
|
+
ciuser: "root",
|
|
202
|
+
};
|
|
203
|
+
const pubKeys = this.resolvePublicKeys();
|
|
204
|
+
if (pubKeys.length) {
|
|
205
|
+
configPatch.sshkeys = encodeURIComponent(pubKeys.join("\n"));
|
|
206
|
+
}
|
|
207
|
+
await pm.post(`/nodes/${node}/qemu/${newVmid}/config`, configPatch);
|
|
208
|
+
// Start VM for provisioning
|
|
209
|
+
await pm.post(`/nodes/${node}/qemu/${newVmid}/status/start`);
|
|
210
|
+
console.log(`🚀 Started VM "${this.name}" (vmid=${newVmid}) for template provisioning...`);
|
|
211
|
+
// Wait for IP
|
|
212
|
+
let resolvedIp = null;
|
|
213
|
+
await this.waitFor(`VM "${this.name}" to boot and get an IP`, async () => {
|
|
214
|
+
try {
|
|
215
|
+
const ifaces = await pm.get(`/nodes/${node}/qemu/${newVmid}/agent/network-get-interfaces`);
|
|
216
|
+
const eth = (ifaces ?? []).find((i) => i.name !== "lo");
|
|
217
|
+
const addr = eth?.["ip-addresses"]?.find((a) => a["ip-address-type"] === "ipv4");
|
|
218
|
+
if (addr?.["ip-address"]) {
|
|
219
|
+
resolvedIp = addr["ip-address"];
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}, { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
228
|
+
if (!resolvedIp) {
|
|
229
|
+
throw new Error(`Failed to resolve IP for VM "${this.name}" during provisioning`);
|
|
230
|
+
}
|
|
231
|
+
// Run Playbooks
|
|
232
|
+
if (this._provision.length > 0) {
|
|
233
|
+
await this.waitFor(`SSH on ${resolvedIp} to be ready`, () => this.checkPort(resolvedIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
234
|
+
await this.waitFor(`cloud-init to finish on ${resolvedIp}`, () => this.checkCloudInit(resolvedIp), { intervalMs: 15_000, timeoutMs: 300_000 });
|
|
235
|
+
const appliedHashes = {};
|
|
236
|
+
for (const script of this._provision) {
|
|
237
|
+
await this.runProvisioner(resolvedIp, script);
|
|
238
|
+
const baseName = script.split("/").pop() ?? script;
|
|
239
|
+
appliedHashes[baseName] = getFileHash(script);
|
|
240
|
+
}
|
|
241
|
+
// Write playbook metadata into VM notes description
|
|
242
|
+
const updatedNotes = mergeProvisionMetadata("", appliedHashes);
|
|
243
|
+
await pm.post(`/nodes/${this.resolvedNode}/qemu/${this.resolvedVmid}/config`, {
|
|
244
|
+
description: updatedNotes,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// Stop the VM
|
|
248
|
+
console.log(` 🛑 Stopping VM "${this.name}" (vmid=${newVmid})...`);
|
|
249
|
+
await pm.post(`/nodes/${node}/qemu/${newVmid}/status/stop`);
|
|
250
|
+
await this.waitFor(`VM "${this.name}" to stop`, async () => {
|
|
251
|
+
const s = await pm.get(`/nodes/${node}/qemu/${newVmid}/status/current`);
|
|
252
|
+
return s?.status === "stopped";
|
|
253
|
+
}, { intervalMs: 5_000, timeoutMs: 120_000 });
|
|
254
|
+
// Convert to Template
|
|
255
|
+
console.log(` 💾 Converting VM "${this.name}" (vmid=${newVmid}) to template...`);
|
|
256
|
+
await pm.post(`/nodes/${node}/qemu/${newVmid}/template`);
|
|
257
|
+
console.log(` ✅ Template "${this.name}" (vmid=${newVmid}) baked successfully.`);
|
|
258
|
+
this.out.vmid.resolve(newVmid);
|
|
259
|
+
return { name: this.name, vmid: newVmid, node };
|
|
260
|
+
}
|
|
261
|
+
async destroy() {
|
|
262
|
+
const dryRun = this.isDryRunActive();
|
|
263
|
+
const existing = await this.discoveryPromise;
|
|
264
|
+
console.log(`\n🗑️ Destroying Proxmox Template "${this.name}"...`);
|
|
265
|
+
if (!existing) {
|
|
266
|
+
console.log(` ─ Template "${this.name}" not found`);
|
|
267
|
+
return { destroyed: false };
|
|
268
|
+
}
|
|
269
|
+
if (dryRun) {
|
|
270
|
+
console.log(` 📝 [PLAN] Delete Template "${this.name}" (vmid=${existing.vmid})`);
|
|
271
|
+
return { destroyed: this.name };
|
|
272
|
+
}
|
|
273
|
+
const pm = getPMClient();
|
|
274
|
+
await pm.delete(`/nodes/${existing.node}/qemu/${existing.vmid}?purge=1&destroy-unreferenced-disks=1`);
|
|
275
|
+
console.log(` 🗑️ Removed Template "${this.name}" (vmid=${existing.vmid})`);
|
|
276
|
+
return { destroyed: this.name };
|
|
277
|
+
}
|
|
278
|
+
async waitForTask(node, upid, pm) {
|
|
279
|
+
const encoded = encodeURIComponent(upid);
|
|
280
|
+
await this.waitFor(`clone task to complete`, async () => {
|
|
281
|
+
const status = await pm.get(`/nodes/${node}/tasks/${encoded}/status`);
|
|
282
|
+
if (status?.status !== "stopped")
|
|
283
|
+
return false;
|
|
284
|
+
if (status.exitstatus && status.exitstatus !== "OK") {
|
|
285
|
+
throw new Error(`Clone task failed: ${status.exitstatus}`);
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}, { intervalMs: 5_000, timeoutMs: 300_000 });
|
|
289
|
+
}
|
|
290
|
+
resolvePublicKeys() {
|
|
291
|
+
const input = this._sshKeys;
|
|
292
|
+
if (!input) {
|
|
293
|
+
try {
|
|
294
|
+
return [readFileSync(`${homedir()}/.ssh/id_ed25519.pub`, "utf-8").trim()];
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (Array.isArray(input))
|
|
301
|
+
return input.map((k) => k.trim()).filter(Boolean);
|
|
302
|
+
if (input.startsWith("ssh-") ||
|
|
303
|
+
input.startsWith("ecdsa-") ||
|
|
304
|
+
input.startsWith("sk-")) {
|
|
305
|
+
return [input.trim()];
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
return [readFileSync(input.replace(/^~/, homedir()), "utf-8").trim()];
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
checkCloudInit(ip) {
|
|
315
|
+
const keyPath = this.sshKeyPath();
|
|
316
|
+
return new Promise((resolve) => {
|
|
317
|
+
const proc = spawn("ssh", [
|
|
318
|
+
"-i",
|
|
319
|
+
keyPath,
|
|
320
|
+
"-o",
|
|
321
|
+
"StrictHostKeyChecking=no",
|
|
322
|
+
"-o",
|
|
323
|
+
"ConnectTimeout=10",
|
|
324
|
+
"-o",
|
|
325
|
+
"BatchMode=yes",
|
|
326
|
+
`root@${ip}`,
|
|
327
|
+
"cloud-init status",
|
|
328
|
+
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
329
|
+
let out = "";
|
|
330
|
+
proc.stdout.on("data", (d) => (out += d.toString()));
|
|
331
|
+
proc.on("close", () => resolve(out.includes("done") || out.includes("error")));
|
|
332
|
+
proc.on("error", () => resolve(false));
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
async checkPort(ip, port) {
|
|
336
|
+
return checkPort(ip, port);
|
|
337
|
+
}
|
|
338
|
+
async runProvisioner(ip, script) {
|
|
339
|
+
return runProvisioner(ip, "root", this._sshKeys, script);
|
|
340
|
+
}
|
|
341
|
+
sshKeyPath() {
|
|
342
|
+
const keyInput = Array.isArray(this._sshKeys) ? null : this._sshKeys;
|
|
343
|
+
return (keyInput &&
|
|
344
|
+
!keyInput.startsWith("ssh-") &&
|
|
345
|
+
!keyInput.startsWith("ecdsa-") &&
|
|
346
|
+
!keyInput.startsWith("sk-")
|
|
347
|
+
? keyInput.replace(/\.pub$/, "")
|
|
348
|
+
: `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { ProxmoxApiClient } from "./api.js";
|
|
4
|
+
import { TemplateBuilder } from "./template.js";
|
|
5
|
+
import { VMBuilder } from "./vm.js";
|
|
6
|
+
import { Config } from "../../core/config.js";
|
|
7
|
+
import { getFileHash, mergeProvisionMetadata } from "./hash.js";
|
|
8
|
+
import { Stack } from "../../core/stack.js";
|
|
9
|
+
describe("Proxmox TemplateBuilder Unit Tests", () => {
|
|
10
|
+
let originalGet;
|
|
11
|
+
let originalPost;
|
|
12
|
+
let originalDelete;
|
|
13
|
+
let clientCalls = [];
|
|
14
|
+
let mockGetResponses = {};
|
|
15
|
+
let mockPostResponses = {};
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
Config.set({
|
|
18
|
+
dryRun: false,
|
|
19
|
+
providers: {
|
|
20
|
+
proxmox: {
|
|
21
|
+
url: "https://pve.example.com:8006",
|
|
22
|
+
user: "root@pam",
|
|
23
|
+
tokenName: "puls",
|
|
24
|
+
tokenSecret: "secret-key",
|
|
25
|
+
verifySsl: false,
|
|
26
|
+
dnsDomain: "nolimit.int",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
clientCalls = [];
|
|
31
|
+
mockGetResponses = {};
|
|
32
|
+
mockPostResponses = {};
|
|
33
|
+
originalGet = ProxmoxApiClient.prototype.get;
|
|
34
|
+
originalPost = ProxmoxApiClient.prototype.post;
|
|
35
|
+
originalDelete = ProxmoxApiClient.prototype.delete;
|
|
36
|
+
ProxmoxApiClient.prototype.get = async function (path) {
|
|
37
|
+
clientCalls.push({ method: "GET", path });
|
|
38
|
+
if (mockGetResponses[path] !== undefined) {
|
|
39
|
+
const handler = mockGetResponses[path];
|
|
40
|
+
if (typeof handler === "function")
|
|
41
|
+
return handler();
|
|
42
|
+
return handler;
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
};
|
|
46
|
+
ProxmoxApiClient.prototype.post = async function (path, body) {
|
|
47
|
+
clientCalls.push({ method: "POST", path, body });
|
|
48
|
+
if (mockPostResponses[path] !== undefined) {
|
|
49
|
+
const handler = mockPostResponses[path];
|
|
50
|
+
if (typeof handler === "function")
|
|
51
|
+
return handler(body);
|
|
52
|
+
return handler;
|
|
53
|
+
}
|
|
54
|
+
if (path.includes("/clone")) {
|
|
55
|
+
return "UPID:pve1:00000000:00000000:00000000:qemuclone:101:root@pam:";
|
|
56
|
+
}
|
|
57
|
+
return {};
|
|
58
|
+
};
|
|
59
|
+
ProxmoxApiClient.prototype.delete = async function (path) {
|
|
60
|
+
clientCalls.push({ method: "DELETE", path });
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
ProxmoxApiClient.prototype.get = originalGet;
|
|
65
|
+
ProxmoxApiClient.prototype.post = originalPost;
|
|
66
|
+
ProxmoxApiClient.prototype.delete = originalDelete;
|
|
67
|
+
});
|
|
68
|
+
test("gracefully handles discovery when Template does not exist", async () => {
|
|
69
|
+
mockGetResponses["/cluster/resources?type=vm"] = [];
|
|
70
|
+
const builder = new TemplateBuilder("my-template");
|
|
71
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
72
|
+
assert.strictEqual(discoveryResult, null);
|
|
73
|
+
});
|
|
74
|
+
test("discovers existing Template and skips deployment if hashes match (Idempotence)", async () => {
|
|
75
|
+
const nginxHash = getFileHash("playbooks/nginx.yaml");
|
|
76
|
+
const notes = mergeProvisionMetadata("Pre-baked template notes", {
|
|
77
|
+
"nginx.yaml": nginxHash,
|
|
78
|
+
});
|
|
79
|
+
mockGetResponses["/cluster/resources?type=vm"] = [
|
|
80
|
+
{ name: "my-template", vmid: 500, node: "pve1", template: 1 },
|
|
81
|
+
];
|
|
82
|
+
mockGetResponses["/nodes/pve1/qemu/500/config"] = {
|
|
83
|
+
description: notes,
|
|
84
|
+
};
|
|
85
|
+
const builder = new TemplateBuilder("my-template")
|
|
86
|
+
.provision("playbooks/nginx.yaml");
|
|
87
|
+
const result = await builder.deploy();
|
|
88
|
+
assert.strictEqual(result.vmid, 500);
|
|
89
|
+
assert.strictEqual(result.node, "pve1");
|
|
90
|
+
// No POST/DELETE calls should be made since it already matches
|
|
91
|
+
const writes = clientCalls.filter(c => c.method === "POST" || c.method === "DELETE");
|
|
92
|
+
assert.strictEqual(writes.length, 0);
|
|
93
|
+
});
|
|
94
|
+
test("purges and rebuilds template if playbooks differ", async () => {
|
|
95
|
+
mockGetResponses["/cluster/resources?type=vm"] = [
|
|
96
|
+
{ name: "my-template", vmid: 500, node: "pve1", template: 1 },
|
|
97
|
+
];
|
|
98
|
+
// Template config notes are empty/out of date
|
|
99
|
+
mockGetResponses["/nodes/pve1/qemu/500/config"] = {
|
|
100
|
+
description: "",
|
|
101
|
+
};
|
|
102
|
+
mockGetResponses["/nodes"] = [
|
|
103
|
+
{ node: "pve1", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 12 * 1024 * 1024 * 1024 }
|
|
104
|
+
];
|
|
105
|
+
mockGetResponses["/cluster/nextid"] = 600;
|
|
106
|
+
mockGetResponses["/nodes/pve1/qemu/600/agent/network-get-interfaces"] = [
|
|
107
|
+
{
|
|
108
|
+
name: "eth0",
|
|
109
|
+
"ip-addresses": [
|
|
110
|
+
{ "ip-address-type": "ipv4", "ip-address": "10.8.10.199" }
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
];
|
|
114
|
+
const builder = new TemplateBuilder("my-template")
|
|
115
|
+
.provision("playbooks/nginx.yaml");
|
|
116
|
+
const provisionCalls = [];
|
|
117
|
+
builder.waitFor = async (label, condition) => {
|
|
118
|
+
return await condition();
|
|
119
|
+
};
|
|
120
|
+
builder.checkPort = async () => true;
|
|
121
|
+
builder.checkCloudInit = async () => true;
|
|
122
|
+
builder.runProvisioner = async (ip, script) => {
|
|
123
|
+
provisionCalls.push(script);
|
|
124
|
+
};
|
|
125
|
+
const result = await builder.deploy();
|
|
126
|
+
assert.strictEqual(result.vmid, 600);
|
|
127
|
+
// Verify it purged the old template
|
|
128
|
+
const deleteCall = clientCalls.find(c => c.method === "DELETE" && c.path === "/nodes/pve1/qemu/500?purge=1&destroy-unreferenced-disks=1");
|
|
129
|
+
assert.ok(deleteCall);
|
|
130
|
+
// Verify it created a blank VM and provisioned it
|
|
131
|
+
const createCall = clientCalls.find(c => c.method === "POST" && c.path === "/nodes/pve1/qemu");
|
|
132
|
+
assert.ok(createCall);
|
|
133
|
+
// Verify playbooks ran
|
|
134
|
+
assert.strictEqual(provisionCalls.length, 1);
|
|
135
|
+
assert.strictEqual(provisionCalls[0], "playbooks/nginx.yaml");
|
|
136
|
+
// Verify it stopped the VM and converted it to a template
|
|
137
|
+
const stopCall = clientCalls.find(c => c.method === "POST" && c.path === "/nodes/pve1/qemu/600/status/stop");
|
|
138
|
+
assert.ok(stopCall);
|
|
139
|
+
const templateCall = clientCalls.find(c => c.method === "POST" && c.path === "/nodes/pve1/qemu/600/template");
|
|
140
|
+
assert.ok(templateCall);
|
|
141
|
+
});
|
|
142
|
+
test("VM clones from Template successfully across nodes", async () => {
|
|
143
|
+
const nginxHash = getFileHash("playbooks/nginx.yaml");
|
|
144
|
+
const notes = mergeProvisionMetadata("Pre-baked template notes", {
|
|
145
|
+
"nginx.yaml": nginxHash,
|
|
146
|
+
});
|
|
147
|
+
mockGetResponses["/cluster/resources?type=vm"] = [
|
|
148
|
+
// Template exists on pve1
|
|
149
|
+
{ name: "my-game-template", vmid: 500, node: "pve1", template: 1 },
|
|
150
|
+
];
|
|
151
|
+
mockGetResponses["/nodes/pve1/qemu/500/config"] = {
|
|
152
|
+
description: notes,
|
|
153
|
+
};
|
|
154
|
+
mockGetResponses["/cluster/nextid"] = 205;
|
|
155
|
+
mockGetResponses["/nodes"] = [
|
|
156
|
+
// Target node is pve2
|
|
157
|
+
{ node: "pve2", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 12 * 1024 * 1024 * 1024 }
|
|
158
|
+
];
|
|
159
|
+
class ProxmoxStack extends Stack {
|
|
160
|
+
template = new TemplateBuilder("my-game-template")
|
|
161
|
+
.provision("playbooks/nginx.yaml");
|
|
162
|
+
server = new VMBuilder("my-prod-game-01")
|
|
163
|
+
.fromTemplate(this.template)
|
|
164
|
+
.cores(4);
|
|
165
|
+
}
|
|
166
|
+
const stack = new ProxmoxStack();
|
|
167
|
+
stack.server.waitFor = async () => true;
|
|
168
|
+
stack.server.checkPort = async () => true;
|
|
169
|
+
stack.server.checkCloudInit = async () => true;
|
|
170
|
+
const result = await stack.deploy();
|
|
171
|
+
// Verify VM cloned successfully
|
|
172
|
+
assert.strictEqual(result.template.vmid, 500);
|
|
173
|
+
assert.strictEqual(result.server.vmid, 205);
|
|
174
|
+
// Verify clone POST used the template's node (pve1) and vmid (500), but with target (pve2)
|
|
175
|
+
const cloneCall = clientCalls.find(c => c.method === "POST" && c.path === "/nodes/pve1/qemu/500/clone");
|
|
176
|
+
assert.ok(cloneCall);
|
|
177
|
+
assert.strictEqual(cloneCall.body.newid, 205);
|
|
178
|
+
assert.strictEqual(cloneCall.body.name, "my-prod-game-01");
|
|
179
|
+
assert.strictEqual(cloneCall.body.target, "pve2");
|
|
180
|
+
});
|
|
181
|
+
test("Template bakes from baseTemplate on a different node successfully", async () => {
|
|
182
|
+
mockGetResponses["/cluster/resources?type=vm"] = [
|
|
183
|
+
// Base template exists on pve1
|
|
184
|
+
{ name: "ubuntu-base", vmid: 9000, node: "pve1", template: 1 },
|
|
185
|
+
];
|
|
186
|
+
mockGetResponses["/nodes"] = [
|
|
187
|
+
// Target node pve2 has more free RAM than pve1
|
|
188
|
+
{ node: "pve1", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 20 * 1024 * 1024 * 1024 },
|
|
189
|
+
{ node: "pve2", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 5 * 1024 * 1024 * 1024 },
|
|
190
|
+
];
|
|
191
|
+
mockGetResponses["/cluster/nextid"] = 9001;
|
|
192
|
+
mockGetResponses["/nodes/pve2/qemu/9001/agent/network-get-interfaces"] = [
|
|
193
|
+
{
|
|
194
|
+
name: "eth0",
|
|
195
|
+
"ip-addresses": [
|
|
196
|
+
{ "ip-address-type": "ipv4", "ip-address": "10.8.10.199" }
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
];
|
|
200
|
+
const builder = new TemplateBuilder("my-new-template")
|
|
201
|
+
.baseImage("ubuntu-base")
|
|
202
|
+
.provision("playbooks/nginx.yaml");
|
|
203
|
+
builder.waitFor = async () => true;
|
|
204
|
+
builder.checkPort = async () => true;
|
|
205
|
+
builder.checkCloudInit = async () => true;
|
|
206
|
+
const result = await builder.deploy();
|
|
207
|
+
assert.strictEqual(result.vmid, 9001);
|
|
208
|
+
assert.strictEqual(result.node, "pve2");
|
|
209
|
+
// Verify clone POST was sent to pve1 (source node) but targeted pve2
|
|
210
|
+
const cloneCall = clientCalls.find(c => c.method === "POST" && c.path === "/nodes/pve1/qemu/9000/clone");
|
|
211
|
+
assert.ok(cloneCall);
|
|
212
|
+
assert.strictEqual(cloneCall.body.newid, 9001);
|
|
213
|
+
assert.strictEqual(cloneCall.body.target, "pve2");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BaseBuilder } from "../../core/resource.js";
|
|
2
2
|
import { Output } from "../../core/output.js";
|
|
3
3
|
import type { OSImage } from "../../types/proxmox.js";
|
|
4
|
+
import { TemplateBuilder } from "./template.js";
|
|
4
5
|
export declare class VMBuilder extends BaseBuilder {
|
|
5
6
|
readonly out: {
|
|
6
7
|
ip: Output<string>;
|
|
@@ -10,6 +11,7 @@ export declare class VMBuilder extends BaseBuilder {
|
|
|
10
11
|
resolvedNode: string | null;
|
|
11
12
|
resolvedIp: string | null;
|
|
12
13
|
private _image?;
|
|
14
|
+
private _templateSource?;
|
|
13
15
|
private _cores;
|
|
14
16
|
private _memory;
|
|
15
17
|
private _provision;
|
|
@@ -24,6 +26,7 @@ export declare class VMBuilder extends BaseBuilder {
|
|
|
24
26
|
constructor(name: string);
|
|
25
27
|
private discoverVm;
|
|
26
28
|
image(os: OSImage): this;
|
|
29
|
+
fromTemplate(template: TemplateBuilder): this;
|
|
27
30
|
cores(n: number): this;
|
|
28
31
|
memory(mb: number): this;
|
|
29
32
|
provision(...playbookPaths: (string | string[])[]): this;
|