puls-dev 0.2.8 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/core/checker.js +71 -0
  2. package/dist/core/config.d.ts +4 -0
  3. package/dist/core/config.js +11 -1
  4. package/dist/core/context.d.ts +14 -0
  5. package/dist/core/context.js +2 -0
  6. package/dist/core/decorators.d.ts +2 -0
  7. package/dist/core/decorators.js +8 -14
  8. package/dist/core/parallel.test.d.ts +1 -0
  9. package/dist/core/parallel.test.js +215 -0
  10. package/dist/core/production.test.d.ts +1 -0
  11. package/dist/core/production.test.js +189 -0
  12. package/dist/core/provisioner.js +29 -11
  13. package/dist/core/resource.d.ts +7 -0
  14. package/dist/core/resource.js +10 -0
  15. package/dist/core/retry.d.ts +9 -0
  16. package/dist/core/retry.js +28 -0
  17. package/dist/core/retry.test.d.ts +1 -0
  18. package/dist/core/retry.test.js +66 -0
  19. package/dist/core/secret.d.ts +2 -1
  20. package/dist/core/secret.js +12 -2
  21. package/dist/core/stack.js +308 -75
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/providers/aws/api.js +97 -17
  25. package/dist/providers/aws/ec2.d.ts +3 -0
  26. package/dist/providers/aws/ec2.js +37 -3
  27. package/dist/providers/aws/ec2.test.js +5 -3
  28. package/dist/providers/aws/index.d.ts +2 -0
  29. package/dist/providers/aws/index.js +2 -0
  30. package/dist/providers/aws/template.d.ts +34 -0
  31. package/dist/providers/aws/template.js +252 -0
  32. package/dist/providers/aws/template.test.d.ts +1 -0
  33. package/dist/providers/aws/template.test.js +208 -0
  34. package/dist/providers/do/api.d.ts +2 -0
  35. package/dist/providers/do/api.js +124 -26
  36. package/dist/providers/do/droplet.js +14 -0
  37. package/dist/providers/firebase/api.js +92 -29
  38. package/dist/providers/firebase/list.d.ts +2 -0
  39. package/dist/providers/firebase/list.js +25 -0
  40. package/dist/providers/gcp/api.js +88 -14
  41. package/dist/providers/gcp/index.d.ts +3 -1
  42. package/dist/providers/gcp/index.js +3 -1
  43. package/dist/providers/gcp/list.d.ts +2 -0
  44. package/dist/providers/gcp/list.js +55 -0
  45. package/dist/providers/gcp/secrets.js +1 -1
  46. package/dist/providers/gcp/template.d.ts +32 -0
  47. package/dist/providers/gcp/template.js +252 -0
  48. package/dist/providers/gcp/template.test.d.ts +1 -0
  49. package/dist/providers/gcp/template.test.js +227 -0
  50. package/dist/providers/gcp/vm.d.ts +3 -0
  51. package/dist/providers/gcp/vm.js +46 -3
  52. package/dist/providers/proxmox/api.d.ts +1 -0
  53. package/dist/providers/proxmox/api.js +72 -16
  54. package/dist/providers/proxmox/index.d.ts +2 -0
  55. package/dist/providers/proxmox/index.js +2 -0
  56. package/dist/providers/proxmox/template.d.ts +44 -0
  57. package/dist/providers/proxmox/template.js +349 -0
  58. package/dist/providers/proxmox/template.test.d.ts +1 -0
  59. package/dist/providers/proxmox/template.test.js +179 -0
  60. package/dist/providers/proxmox/vm.d.ts +3 -0
  61. package/dist/providers/proxmox/vm.js +40 -9
  62. package/dist/types/inventory.d.ts +44 -1
  63. package/package.json +1 -1
@@ -0,0 +1,349 @@
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/${node}/qemu/${baseTemplate.vmid}/clone`, {
170
+ newid: newVmid,
171
+ name: this.name,
172
+ full: 1,
173
+ storage,
174
+ format: "raw",
175
+ });
176
+ await this.waitForTask(node, taskId, pm);
177
+ }
178
+ else {
179
+ console.log(` 🆕 Creating blank VM "${this.name}" (vmid=${newVmid})`);
180
+ await pm.post(`/nodes/${node}/qemu`, {
181
+ vmid: newVmid,
182
+ name: this.name,
183
+ cores: this._cores,
184
+ memory: this._memory,
185
+ net0: "virtio,bridge=vmbr1",
186
+ ostype: "l26",
187
+ });
188
+ }
189
+ this.resolvedVmid = newVmid;
190
+ this.resolvedNode = node;
191
+ // Apply config
192
+ const configPatch = {
193
+ onboot: 0,
194
+ cores: this._cores,
195
+ memory: this._memory,
196
+ net0: "virtio,bridge=vmbr1",
197
+ ipconfig0: "ip=dhcp",
198
+ nameserver: (Config.get().providers.proxmox?.dnsServers ?? ["1.1.1.1", "8.8.8.8"]).join(" "),
199
+ searchdomain: Config.get().providers.proxmox?.dnsDomain ?? "",
200
+ ciuser: "root",
201
+ };
202
+ const pubKeys = this.resolvePublicKeys();
203
+ if (pubKeys.length) {
204
+ configPatch.sshkeys = encodeURIComponent(pubKeys.join("\n"));
205
+ }
206
+ await pm.post(`/nodes/${node}/qemu/${newVmid}/config`, configPatch);
207
+ // Start VM for provisioning
208
+ await pm.post(`/nodes/${node}/qemu/${newVmid}/status/start`);
209
+ console.log(`🚀 Started VM "${this.name}" (vmid=${newVmid}) for template provisioning...`);
210
+ // Wait for IP
211
+ let resolvedIp = null;
212
+ await this.waitFor(`VM "${this.name}" to boot and get an IP`, async () => {
213
+ try {
214
+ const ifaces = await pm.get(`/nodes/${node}/qemu/${newVmid}/agent/network-get-interfaces`);
215
+ const eth = (ifaces ?? []).find((i) => i.name !== "lo");
216
+ const addr = eth?.["ip-addresses"]?.find((a) => a["ip-address-type"] === "ipv4");
217
+ if (addr?.["ip-address"]) {
218
+ resolvedIp = addr["ip-address"];
219
+ return true;
220
+ }
221
+ return false;
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ }, { intervalMs: 10_000, timeoutMs: 300_000 });
227
+ if (!resolvedIp) {
228
+ throw new Error(`Failed to resolve IP for VM "${this.name}" during provisioning`);
229
+ }
230
+ // Run Playbooks
231
+ if (this._provision.length > 0) {
232
+ await this.waitFor(`SSH on ${resolvedIp} to be ready`, () => this.checkPort(resolvedIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
233
+ await this.waitFor(`cloud-init to finish on ${resolvedIp}`, () => this.checkCloudInit(resolvedIp), { intervalMs: 15_000, timeoutMs: 300_000 });
234
+ const appliedHashes = {};
235
+ for (const script of this._provision) {
236
+ await this.runProvisioner(resolvedIp, script);
237
+ const baseName = script.split("/").pop() ?? script;
238
+ appliedHashes[baseName] = getFileHash(script);
239
+ }
240
+ // Write playbook metadata into VM notes description
241
+ const updatedNotes = mergeProvisionMetadata("", appliedHashes);
242
+ await pm.post(`/nodes/${this.resolvedNode}/qemu/${this.resolvedVmid}/config`, {
243
+ description: updatedNotes,
244
+ });
245
+ }
246
+ // Stop the VM
247
+ console.log(` 🛑 Stopping VM "${this.name}" (vmid=${newVmid})...`);
248
+ await pm.post(`/nodes/${node}/qemu/${newVmid}/status/stop`);
249
+ await this.waitFor(`VM "${this.name}" to stop`, async () => {
250
+ const s = await pm.get(`/nodes/${node}/qemu/${newVmid}/status/current`);
251
+ return s?.status === "stopped";
252
+ }, { intervalMs: 5_000, timeoutMs: 120_000 });
253
+ // Convert to Template
254
+ console.log(` 💾 Converting VM "${this.name}" (vmid=${newVmid}) to template...`);
255
+ await pm.post(`/nodes/${node}/qemu/${newVmid}/template`);
256
+ console.log(` ✅ Template "${this.name}" (vmid=${newVmid}) baked successfully.`);
257
+ this.out.vmid.resolve(newVmid);
258
+ return { name: this.name, vmid: newVmid, node };
259
+ }
260
+ async destroy() {
261
+ const dryRun = this.isDryRunActive();
262
+ const existing = await this.discoveryPromise;
263
+ console.log(`\n🗑️ Destroying Proxmox Template "${this.name}"...`);
264
+ if (!existing) {
265
+ console.log(` ─ Template "${this.name}" not found`);
266
+ return { destroyed: false };
267
+ }
268
+ if (dryRun) {
269
+ console.log(` 📝 [PLAN] Delete Template "${this.name}" (vmid=${existing.vmid})`);
270
+ return { destroyed: this.name };
271
+ }
272
+ const pm = getPMClient();
273
+ await pm.delete(`/nodes/${existing.node}/qemu/${existing.vmid}?purge=1&destroy-unreferenced-disks=1`);
274
+ console.log(` 🗑️ Removed Template "${this.name}" (vmid=${existing.vmid})`);
275
+ return { destroyed: this.name };
276
+ }
277
+ async waitForTask(node, upid, pm) {
278
+ const encoded = encodeURIComponent(upid);
279
+ await this.waitFor(`clone task to complete`, async () => {
280
+ const status = await pm.get(`/nodes/${node}/tasks/${encoded}/status`);
281
+ if (status?.status !== "stopped")
282
+ return false;
283
+ if (status.exitstatus && status.exitstatus !== "OK") {
284
+ throw new Error(`Clone task failed: ${status.exitstatus}`);
285
+ }
286
+ return true;
287
+ }, { intervalMs: 5_000, timeoutMs: 300_000 });
288
+ }
289
+ resolvePublicKeys() {
290
+ const input = this._sshKeys;
291
+ if (!input) {
292
+ try {
293
+ return [readFileSync(`${homedir()}/.ssh/id_ed25519.pub`, "utf-8").trim()];
294
+ }
295
+ catch {
296
+ return [];
297
+ }
298
+ }
299
+ if (Array.isArray(input))
300
+ return input.map((k) => k.trim()).filter(Boolean);
301
+ if (input.startsWith("ssh-") ||
302
+ input.startsWith("ecdsa-") ||
303
+ input.startsWith("sk-")) {
304
+ return [input.trim()];
305
+ }
306
+ try {
307
+ return [readFileSync(input.replace(/^~/, homedir()), "utf-8").trim()];
308
+ }
309
+ catch {
310
+ return [];
311
+ }
312
+ }
313
+ checkCloudInit(ip) {
314
+ const keyPath = this.sshKeyPath();
315
+ return new Promise((resolve) => {
316
+ const proc = spawn("ssh", [
317
+ "-i",
318
+ keyPath,
319
+ "-o",
320
+ "StrictHostKeyChecking=no",
321
+ "-o",
322
+ "ConnectTimeout=10",
323
+ "-o",
324
+ "BatchMode=yes",
325
+ `root@${ip}`,
326
+ "cloud-init status",
327
+ ], { stdio: ["ignore", "pipe", "ignore"] });
328
+ let out = "";
329
+ proc.stdout.on("data", (d) => (out += d.toString()));
330
+ proc.on("close", () => resolve(out.includes("done") || out.includes("error")));
331
+ proc.on("error", () => resolve(false));
332
+ });
333
+ }
334
+ async checkPort(ip, port) {
335
+ return checkPort(ip, port);
336
+ }
337
+ async runProvisioner(ip, script) {
338
+ return runProvisioner(ip, "root", this._sshKeys, script);
339
+ }
340
+ sshKeyPath() {
341
+ const keyInput = Array.isArray(this._sshKeys) ? null : this._sshKeys;
342
+ return (keyInput &&
343
+ !keyInput.startsWith("ssh-") &&
344
+ !keyInput.startsWith("ecdsa-") &&
345
+ !keyInput.startsWith("sk-")
346
+ ? keyInput.replace(/\.pub$/, "")
347
+ : `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
348
+ }
349
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,179 @@
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", 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
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
+ { node: "pve1", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 12 * 1024 * 1024 * 1024 }
157
+ ];
158
+ class ProxmoxStack extends Stack {
159
+ template = new TemplateBuilder("my-game-template")
160
+ .provision("playbooks/nginx.yaml");
161
+ server = new VMBuilder("my-prod-game-01")
162
+ .fromTemplate(this.template)
163
+ .cores(4);
164
+ }
165
+ const stack = new ProxmoxStack();
166
+ stack.server.waitFor = async () => true;
167
+ stack.server.checkPort = async () => true;
168
+ stack.server.checkCloudInit = async () => true;
169
+ const result = await stack.deploy();
170
+ // Verify VM cloned successfully
171
+ assert.strictEqual(result.template.vmid, 500);
172
+ assert.strictEqual(result.server.vmid, 205);
173
+ // Verify clone POST used the template's VMID
174
+ const cloneCall = clientCalls.find(c => c.method === "POST" && c.path === "/nodes/pve1/qemu/500/clone");
175
+ assert.ok(cloneCall);
176
+ assert.strictEqual(cloneCall.body.newid, 205);
177
+ assert.strictEqual(cloneCall.body.name, "my-prod-game-01");
178
+ });
179
+ });
@@ -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;
@@ -7,6 +7,7 @@ import { Output } from "../../core/output.js";
7
7
  import { getPMClient } from "./api.js";
8
8
  import { getFileHash, parseProvisionMetadata, mergeProvisionMetadata } from "./hash.js";
9
9
  import { checkPort, runProvisioner } from "../../core/provisioner.js";
10
+ import { resourceContextStorage } from "../../core/context.js";
10
11
  export class VMBuilder extends BaseBuilder {
11
12
  out = {
12
13
  ip: new Output(),
@@ -16,6 +17,7 @@ export class VMBuilder extends BaseBuilder {
16
17
  resolvedNode = null;
17
18
  resolvedIp = null;
18
19
  _image;
20
+ _templateSource;
19
21
  _cores = 2;
20
22
  _memory = 2048;
21
23
  _provision = [];
@@ -41,7 +43,8 @@ export class VMBuilder extends BaseBuilder {
41
43
  const config = await pm.get(`/nodes/${match.node}/qemu/${match.vmid}/config`);
42
44
  match.description = config.description ?? "";
43
45
  }
44
- catch {
46
+ catch (err) {
47
+ console.warn(` ⚠️ Could not fetch VM config for ${match.vmid}: ${err.message}`);
45
48
  match.description = "";
46
49
  }
47
50
  }
@@ -57,6 +60,11 @@ export class VMBuilder extends BaseBuilder {
57
60
  this._image = os;
58
61
  return this;
59
62
  }
63
+ fromTemplate(template) {
64
+ this._templateSource = template;
65
+ this.dependsOn(template);
66
+ return this;
67
+ }
60
68
  cores(n) {
61
69
  this._cores = n;
62
70
  return this;
@@ -178,6 +186,8 @@ export class VMBuilder extends BaseBuilder {
178
186
  console.log(` 📝 [PLAN] Create VM "${this.name}"`);
179
187
  if (this._image)
180
188
  console.log(` └─ Image: ${this._image}`);
189
+ if (this._templateSource)
190
+ console.log(` └─ Template: ${this._templateSource.name}`);
181
191
  console.log(` └─ Cores: ${this._cores} Memory: ${this._memory} MB Machine: ${this._machine}`);
182
192
  if (this._vlan)
183
193
  console.log(` └─ VLAN: ${this._vlan}`);
@@ -191,19 +201,27 @@ export class VMBuilder extends BaseBuilder {
191
201
  return { name: this.name, vmid: "PENDING" };
192
202
  }
193
203
  // Find the template - match by VMID (numeric string) or name substring
204
+ let sourceVmid;
205
+ if (this._templateSource) {
206
+ const v = await this._templateSource.out.vmid.get();
207
+ sourceVmid = String(v);
208
+ }
209
+ else if (this._image) {
210
+ sourceVmid = String(this._image);
211
+ }
194
212
  const resources = await pm.get("/cluster/resources?type=vm");
195
- const isVmid = this._image && /^\d+$/.test(this._image);
196
- const template = this._image
213
+ const isVmid = sourceVmid && /^\d+$/.test(sourceVmid);
214
+ const template = sourceVmid
197
215
  ? (resources ?? []).find((r) => r.template === 1 &&
198
216
  (isVmid
199
- ? String(r.vmid) === this._image
200
- : r.name?.includes(this._image)))
217
+ ? String(r.vmid) === sourceVmid
218
+ : r.name?.includes(sourceVmid)))
201
219
  : null;
202
- if (this._image && !template) {
203
- throw new Error(`No Proxmox template found matching "${this._image}". ` +
220
+ if (sourceVmid && !template) {
221
+ throw new Error(`No Proxmox template found matching "${sourceVmid}". ` +
204
222
  (isVmid
205
- ? `Check that VMID ${this._image} exists and is marked as a template.`
206
- : `Create a template whose name contains "${this._image}".`));
223
+ ? `Check that VMID ${sourceVmid} exists and is marked as a template.`
224
+ : `Create a template whose name contains "${sourceVmid}".`));
207
225
  }
208
226
  // Resolve target node: explicit → cluster-aware (online & max free RAM) → configured nodes list → template's node → API discovery
209
227
  let node = this._node;
@@ -358,6 +376,19 @@ export class VMBuilder extends BaseBuilder {
358
376
  if (this._replace) {
359
377
  await this.destroyVmByName(this._replace, pm);
360
378
  }
379
+ const context = resourceContextStorage.getStore();
380
+ if (context && context.hosts) {
381
+ const activeIp = this.resolvedIp ?? "0.0.0.0";
382
+ if (!context.hosts.some(h => h.name === this.name)) {
383
+ context.hosts.push({
384
+ name: this.name,
385
+ ip: activeIp,
386
+ user: "root",
387
+ sshKey: this.sshKeyPath(),
388
+ provider: "proxmox"
389
+ });
390
+ }
391
+ }
361
392
  return { name: this.name, vmid: this.resolvedVmid, ip: this.resolvedIp };
362
393
  }
363
394
  async resolveExistingIp(node, vmid, pm) {