puls-dev 0.2.6 → 0.2.8

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 (83) hide show
  1. package/README.md +1 -1
  2. package/dist/core/config.d.ts +2 -0
  3. package/dist/core/decorators.d.ts +2 -0
  4. package/dist/core/decorators.js +48 -16
  5. package/dist/core/hooks.d.ts +21 -0
  6. package/dist/core/hooks.js +116 -0
  7. package/dist/core/hooks.test.d.ts +1 -0
  8. package/dist/core/hooks.test.js +194 -0
  9. package/dist/core/multiregion.test.d.ts +1 -0
  10. package/dist/core/multiregion.test.js +87 -0
  11. package/dist/core/output.d.ts +2 -0
  12. package/dist/core/output.js +9 -2
  13. package/dist/core/parser.d.ts +10 -0
  14. package/dist/core/parser.js +140 -0
  15. package/dist/core/parser.test.d.ts +1 -0
  16. package/dist/core/parser.test.js +117 -0
  17. package/dist/core/provisioner.d.ts +4 -0
  18. package/dist/core/provisioner.js +105 -0
  19. package/dist/core/resource.d.ts +16 -0
  20. package/dist/core/resource.js +44 -0
  21. package/dist/core/secret.d.ts +40 -0
  22. package/dist/core/secret.js +95 -0
  23. package/dist/core/secret.test.d.ts +1 -0
  24. package/dist/core/secret.test.js +166 -0
  25. package/dist/core/stack.d.ts +4 -3
  26. package/dist/core/stack.js +50 -9
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +2 -0
  29. package/dist/providers/aws/ec2.d.ts +48 -0
  30. package/dist/providers/aws/ec2.js +297 -0
  31. package/dist/providers/aws/ec2.test.d.ts +1 -0
  32. package/dist/providers/aws/ec2.test.js +279 -0
  33. package/dist/providers/aws/index.d.ts +2 -0
  34. package/dist/providers/aws/index.js +2 -0
  35. package/dist/providers/aws/route53.d.ts +1 -0
  36. package/dist/providers/aws/route53.js +15 -2
  37. package/dist/providers/aws/route53.test.js +47 -0
  38. package/dist/providers/do/api.d.ts +1 -1
  39. package/dist/providers/do/api.js +2 -1
  40. package/dist/providers/do/app.d.ts +26 -0
  41. package/dist/providers/do/app.js +124 -0
  42. package/dist/providers/do/app.test.d.ts +1 -0
  43. package/dist/providers/do/app.test.js +268 -0
  44. package/dist/providers/do/database.d.ts +44 -0
  45. package/dist/providers/do/database.js +208 -0
  46. package/dist/providers/do/database.test.d.ts +1 -0
  47. package/dist/providers/do/database.test.js +293 -0
  48. package/dist/providers/do/domain.d.ts +2 -0
  49. package/dist/providers/do/domain.js +30 -0
  50. package/dist/providers/do/domain.test.js +49 -0
  51. package/dist/providers/do/droplet.d.ts +9 -0
  52. package/dist/providers/do/droplet.js +132 -8
  53. package/dist/providers/do/droplet.test.js +228 -1
  54. package/dist/providers/do/firewall.d.ts +2 -1
  55. package/dist/providers/do/firewall.js +23 -9
  56. package/dist/providers/do/firewall.test.js +54 -0
  57. package/dist/providers/do/index.d.ts +11 -0
  58. package/dist/providers/do/index.js +8 -0
  59. package/dist/providers/do/spaces.d.ts +27 -0
  60. package/dist/providers/do/spaces.js +142 -0
  61. package/dist/providers/do/spaces.test.d.ts +1 -0
  62. package/dist/providers/do/spaces.test.js +180 -0
  63. package/dist/providers/do/spaces_api.d.ts +2 -0
  64. package/dist/providers/do/spaces_api.js +20 -0
  65. package/dist/providers/do/vpc.d.ts +30 -0
  66. package/dist/providers/do/vpc.js +128 -0
  67. package/dist/providers/do/vpc.test.d.ts +1 -0
  68. package/dist/providers/do/vpc.test.js +258 -0
  69. package/dist/providers/gcp/clouddns.d.ts +1 -0
  70. package/dist/providers/gcp/clouddns.js +15 -2
  71. package/dist/providers/gcp/clouddns.test.js +45 -0
  72. package/dist/providers/gcp/index.d.ts +3 -1
  73. package/dist/providers/gcp/index.js +3 -1
  74. package/dist/providers/gcp/vm.d.ts +45 -0
  75. package/dist/providers/gcp/vm.js +332 -0
  76. package/dist/providers/gcp/vm.test.d.ts +1 -0
  77. package/dist/providers/gcp/vm.test.js +321 -0
  78. package/dist/providers/proxmox/hash.d.ts +3 -0
  79. package/dist/providers/proxmox/hash.js +46 -0
  80. package/dist/providers/proxmox/vm.d.ts +8 -7
  81. package/dist/providers/proxmox/vm.js +126 -106
  82. package/dist/providers/proxmox/vm.test.js +224 -0
  83. package/package.json +3 -1
@@ -1,11 +1,12 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { spawn } from "node:child_process";
4
- import { createConnection } from "node:net";
5
4
  import { BaseBuilder } from "../../core/resource.js";
6
5
  import { Config } from "../../core/config.js";
7
6
  import { Output } from "../../core/output.js";
8
7
  import { getPMClient } from "./api.js";
8
+ import { getFileHash, parseProvisionMetadata, mergeProvisionMetadata } from "./hash.js";
9
+ import { checkPort, runProvisioner } from "../../core/provisioner.js";
9
10
  export class VMBuilder extends BaseBuilder {
10
11
  out = {
11
12
  ip: new Output(),
@@ -17,7 +18,7 @@ export class VMBuilder extends BaseBuilder {
17
18
  _image;
18
19
  _cores = 2;
19
20
  _memory = 2048;
20
- _provision;
21
+ _provision = [];
21
22
  _replace;
22
23
  _node;
23
24
  _storage;
@@ -25,6 +26,7 @@ export class VMBuilder extends BaseBuilder {
25
26
  _ip;
26
27
  _sshKeys;
27
28
  _machine = "q35";
29
+ _forceConfigCheck = false;
28
30
  constructor(name) {
29
31
  super(name);
30
32
  this.discoveryPromise = this.discoverVm(name);
@@ -33,7 +35,17 @@ export class VMBuilder extends BaseBuilder {
33
35
  try {
34
36
  const pm = getPMClient();
35
37
  const resources = await pm.get("/cluster/resources?type=vm");
36
- return ((resources ?? []).find((r) => r.name === name && !r.template) ?? null);
38
+ const match = (resources ?? []).find((r) => r.name === name && !r.template) ?? null;
39
+ if (match) {
40
+ try {
41
+ const config = await pm.get(`/nodes/${match.node}/qemu/${match.vmid}/config`);
42
+ match.description = config.description ?? "";
43
+ }
44
+ catch {
45
+ match.description = "";
46
+ }
47
+ }
48
+ return match;
37
49
  }
38
50
  catch (e) {
39
51
  if (e.message?.includes("not configured"))
@@ -53,8 +65,8 @@ export class VMBuilder extends BaseBuilder {
53
65
  this._memory = mb;
54
66
  return this;
55
67
  }
56
- provision(playbookPath) {
57
- this._provision = playbookPath;
68
+ provision(...playbookPaths) {
69
+ this._provision.push(...playbookPaths.flat());
58
70
  return this;
59
71
  }
60
72
  replace(oldVmName) {
@@ -85,24 +97,83 @@ export class VMBuilder extends BaseBuilder {
85
97
  this._machine = type;
86
98
  return this;
87
99
  }
100
+ forceConfigCheck() {
101
+ this._forceConfigCheck = true;
102
+ return this;
103
+ }
88
104
  async deploy() {
89
105
  const dryRun = this.isDryRunActive();
90
106
  const existing = await this.discoveryPromise;
91
107
  const pm = getPMClient();
92
- console.log(`\n🖥️ Finalizing Proxmox VM "${this.name}"...`);
93
108
  if (existing) {
94
109
  this.resolvedVmid = existing.vmid;
95
110
  this.resolvedNode = existing.node;
96
111
  this.out.vmid.resolve(existing.vmid);
97
- if (this._ip)
98
- this.out.ip.resolve(this._ip.split("/")[0]);
112
+ // Resolve the IP of the existing VM
113
+ this.resolvedIp = await this.resolveExistingIp(existing.node, existing.vmid, pm);
114
+ if (this.resolvedIp) {
115
+ this.out.ip.resolve(this.resolvedIp);
116
+ }
117
+ const activeIp = this.resolvedIp ?? "0.0.0.0";
118
+ // 1. Calculate hashes and check if playbooks need to run
119
+ const appliedHashes = parseProvisionMetadata(existing.description ?? "");
120
+ const declaredPlaybooksWithHashes = this._provision.map((p) => {
121
+ const baseName = p.split("/").pop() ?? p;
122
+ return { path: p, baseName, hash: getFileHash(p) };
123
+ });
124
+ const playbooksToRun = this._forceConfigCheck
125
+ ? declaredPlaybooksWithHashes
126
+ : declaredPlaybooksWithHashes.filter((p) => {
127
+ const appliedHash = appliedHashes[p.baseName];
128
+ return !appliedHash || appliedHash !== p.hash;
129
+ });
130
+ if (playbooksToRun.length > 0) {
131
+ console.log(`\n🖥️ Finalizing Proxmox VM "${this.name}"...`);
132
+ console.log(` ✅ VM "${this.name}" already exists (vmid=${existing.vmid}, node=${existing.node}, status=${existing.status})`);
133
+ if (dryRun) {
134
+ console.log(` 📝 [PLAN] Run ${playbooksToRun.length} playbook changes on existing VM:`);
135
+ for (const p of playbooksToRun) {
136
+ console.log(` └─ Playbook: ${p.path} (hash: ${p.hash})`);
137
+ }
138
+ }
139
+ else {
140
+ console.log(` 🔄 Running ${playbooksToRun.length} playbook changes → ${activeIp}`);
141
+ if (activeIp === "0.0.0.0") {
142
+ throw new Error(`Failed to resolve IP for existing VM "${this.name}" to run playbooks`);
143
+ }
144
+ // Wait for SSH
145
+ await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
146
+ // Execute each playbook
147
+ for (const p of playbooksToRun) {
148
+ await this.runProvisioner(activeIp, p.path);
149
+ appliedHashes[p.baseName] = p.hash;
150
+ }
151
+ // Update notes on Proxmox VM
152
+ const updatedNotes = mergeProvisionMetadata(existing.description ?? "", appliedHashes);
153
+ await pm.post(`/nodes/${existing.node}/qemu/${existing.vmid}/config`, {
154
+ description: updatedNotes,
155
+ });
156
+ console.log(` ✅ Playbooks applied successfully and metadata updated.`);
157
+ }
158
+ return {
159
+ name: this.name,
160
+ vmid: this.resolvedVmid,
161
+ node: this.resolvedNode,
162
+ ip: activeIp,
163
+ };
164
+ }
165
+ // No playbook changes!
166
+ console.log(`\n🖥️ Finalizing Proxmox VM "${this.name}"...`);
99
167
  console.log(` ✅ VM "${this.name}" already exists (vmid=${existing.vmid}, node=${existing.node}, status=${existing.status})`);
168
+ console.log(` ✅ Configuration and playbooks are up to date.`);
100
169
  return {
101
170
  name: this.name,
102
171
  vmid: this.resolvedVmid,
103
172
  node: this.resolvedNode,
173
+ ip: activeIp,
104
174
  };
105
175
  }
176
+ console.log(`\n🖥️ Finalizing Proxmox VM "${this.name}"...`);
106
177
  if (dryRun) {
107
178
  console.log(` 📝 [PLAN] Create VM "${this.name}"`);
108
179
  if (this._image)
@@ -110,11 +181,8 @@ export class VMBuilder extends BaseBuilder {
110
181
  console.log(` └─ Cores: ${this._cores} Memory: ${this._memory} MB Machine: ${this._machine}`);
111
182
  if (this._vlan)
112
183
  console.log(` └─ VLAN: ${this._vlan}`);
113
- if (this._provision) {
114
- const p = Array.isArray(this._provision)
115
- ? this._provision.join(", ")
116
- : this._provision;
117
- console.log(` └─ Provision: ${p}`);
184
+ if (this._provision.length > 0) {
185
+ console.log(` └─ Provision: ${this._provision.join(", ")}`);
118
186
  }
119
187
  if (this._replace)
120
188
  console.log(` └─ Replace: "${this._replace}" after creation`);
@@ -272,21 +340,56 @@ export class VMBuilder extends BaseBuilder {
272
340
  this.out.ip.resolve(this.resolvedIp);
273
341
  console.log(` 🌐 IP: ${this.resolvedIp}`);
274
342
  }
275
- if (this._provision) {
343
+ if (this._provision.length > 0) {
276
344
  await this.waitFor(`SSH on ${this.resolvedIp} to be ready`, () => this.checkPort(this.resolvedIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
277
345
  await this.waitFor(`cloud-init to finish on ${this.resolvedIp}`, () => this.checkCloudInit(this.resolvedIp), { intervalMs: 15_000, timeoutMs: 300_000 });
278
- const scripts = Array.isArray(this._provision)
279
- ? this._provision
280
- : [this._provision];
281
- for (const script of scripts) {
346
+ const appliedHashes = {};
347
+ for (const script of this._provision) {
282
348
  await this.runProvisioner(this.resolvedIp, script);
349
+ const baseName = script.split("/").pop() ?? script;
350
+ appliedHashes[baseName] = getFileHash(script);
283
351
  }
352
+ // Write metadata to new VM description
353
+ const updatedNotes = mergeProvisionMetadata("", appliedHashes);
354
+ await pm.post(`/nodes/${this.resolvedNode}/qemu/${this.resolvedVmid}/config`, {
355
+ description: updatedNotes,
356
+ });
284
357
  }
285
358
  if (this._replace) {
286
359
  await this.destroyVmByName(this._replace, pm);
287
360
  }
288
361
  return { name: this.name, vmid: this.resolvedVmid, ip: this.resolvedIp };
289
362
  }
363
+ async resolveExistingIp(node, vmid, pm) {
364
+ if (this._ip) {
365
+ return this._ip.split("/")[0];
366
+ }
367
+ // Try QEMU guest agent first
368
+ try {
369
+ const ifaces = await pm.get(`/nodes/${node}/qemu/${vmid}/agent/network-get-interfaces`);
370
+ const eth = (ifaces ?? []).find((i) => i.name !== "lo");
371
+ const addr = eth?.["ip-addresses"]?.find((a) => a["ip-address-type"] === "ipv4");
372
+ if (addr?.["ip-address"]) {
373
+ return addr["ip-address"];
374
+ }
375
+ }
376
+ catch {
377
+ // Agent might not be running or installed yet
378
+ }
379
+ // Try DNS lookup next
380
+ const domain = Config.get().providers.proxmox?.dnsDomain;
381
+ if (domain) {
382
+ try {
383
+ const { resolve4 } = await import("node:dns/promises");
384
+ const [addr] = await resolve4(`${this.name}.${domain}`);
385
+ return addr;
386
+ }
387
+ catch {
388
+ // Ignored
389
+ }
390
+ }
391
+ return null;
392
+ }
290
393
  async destroy() {
291
394
  const dryRun = this.isDryRunActive();
292
395
  const existing = await this.discoveryPromise;
@@ -384,19 +487,11 @@ export class VMBuilder extends BaseBuilder {
384
487
  proc.on("error", () => resolve(false));
385
488
  });
386
489
  }
387
- checkPort(ip, port) {
388
- return new Promise((resolve) => {
389
- const socket = createConnection({ host: ip, port, timeout: 3_000 });
390
- socket.on("connect", () => {
391
- socket.destroy();
392
- resolve(true);
393
- });
394
- socket.on("timeout", () => {
395
- socket.destroy();
396
- resolve(false);
397
- });
398
- socket.on("error", () => resolve(false));
399
- });
490
+ async checkPort(ip, port) {
491
+ return checkPort(ip, port);
492
+ }
493
+ async runProvisioner(ip, script) {
494
+ return runProvisioner(ip, "root", this._sshKeys, script);
400
495
  }
401
496
  sshKeyPath() {
402
497
  const keyInput = Array.isArray(this._sshKeys)
@@ -409,79 +504,4 @@ export class VMBuilder extends BaseBuilder {
409
504
  ? keyInput.replace(/\.pub$/, "")
410
505
  : `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
411
506
  }
412
- runProvisioner(ip, script) {
413
- const ext = script.split(".").pop()?.toLowerCase();
414
- if (ext === "sh") {
415
- throw new Error(`Shell script provisioning (.sh) is no longer supported. ` +
416
- `Please migrate "${script}" to an Ansible playbook (.yaml/.yml).`);
417
- }
418
- if (ext === "pp")
419
- return this.runPuppet(ip, script);
420
- return this.runAnsible(ip, script); // .yml / .yaml
421
- }
422
- runPuppet(ip, manifest) {
423
- console.log(` 🔧 Applying Puppet manifest: ${manifest} → ${ip}`);
424
- const keyPath = this.sshKeyPath();
425
- // Copy manifest then apply it
426
- return new Promise((resolve, reject) => {
427
- const scp = spawn("scp", [
428
- "-i",
429
- keyPath,
430
- "-o",
431
- "StrictHostKeyChecking=no",
432
- manifest,
433
- `root@${ip}:/tmp/manifest.pp`,
434
- ], { stdio: "inherit" });
435
- scp.on("close", (code) => {
436
- if (code !== 0) {
437
- reject(new Error(`scp exited with code ${code}`));
438
- return;
439
- }
440
- const puppet = spawn("ssh", [
441
- "-i",
442
- keyPath,
443
- "-o",
444
- "StrictHostKeyChecking=no",
445
- `root@${ip}`,
446
- "puppet apply /tmp/manifest.pp",
447
- ], { stdio: "inherit" });
448
- puppet.on("close", (c) => {
449
- if (c === 0) {
450
- console.log(` ✅ Provisioning complete`);
451
- resolve();
452
- }
453
- else
454
- reject(new Error(`puppet apply exited with code ${c}`));
455
- });
456
- puppet.on("error", (err) => reject(new Error(`Failed to run puppet: ${err.message}`)));
457
- });
458
- scp.on("error", (err) => reject(new Error(`Failed to run scp: ${err.message}`)));
459
- });
460
- }
461
- runAnsible(ip, playbook) {
462
- console.log(` 🔧 Running Ansible: ${playbook} → ${ip}`);
463
- const keyPath = this.sshKeyPath();
464
- return new Promise((resolve, reject) => {
465
- const proc = spawn("ansible-playbook", [
466
- playbook,
467
- "-i",
468
- `${ip},`,
469
- "-u",
470
- "root",
471
- "--private-key",
472
- keyPath,
473
- "--ssh-extra-args",
474
- "-o StrictHostKeyChecking=no -o ConnectTimeout=30",
475
- ], { stdio: "inherit" });
476
- proc.on("close", (code) => {
477
- if (code === 0) {
478
- console.log(` ✅ Provisioning complete`);
479
- resolve();
480
- }
481
- else
482
- reject(new Error(`ansible-playbook exited with code ${code}`));
483
- });
484
- proc.on("error", (err) => reject(new Error(`Failed to run ansible-playbook: ${err.message}`)));
485
- });
486
- }
487
507
  }
@@ -1,8 +1,20 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
1
10
  import { test, describe, beforeEach, afterEach } from "node:test";
2
11
  import assert from "node:assert";
3
12
  import { ProxmoxApiClient } from "./api.js";
4
13
  import { VMBuilder } from "./vm.js";
5
14
  import { Config } from "../../core/config.js";
15
+ import { getFileHash, parseProvisionMetadata, mergeProvisionMetadata } from "./hash.js";
16
+ import { Stack } from "../../core/stack.js";
17
+ import { ForceConfigCheck } from "../../core/decorators.js";
6
18
  describe("Proxmox VMBuilder Unit Tests", () => {
7
19
  let originalGet;
8
20
  let originalPost;
@@ -152,4 +164,216 @@ describe("Proxmox VMBuilder Unit Tests", () => {
152
164
  // Let's verify we logged or called the delete path or returned safely.
153
165
  assert.ok(destroyResult.destroyed);
154
166
  });
167
+ test("deploys new VM and writes playbook hashes to VM notes", async () => {
168
+ mockGetResponses["/cluster/resources?type=vm"] = [];
169
+ mockGetResponses["/cluster/nextid"] = 105;
170
+ mockGetResponses["/nodes"] = [
171
+ { node: "pve1", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 12 * 1024 * 1024 * 1024 }
172
+ ];
173
+ const builder = new VMBuilder("prov-new-vm")
174
+ .cores(2)
175
+ .memory(2048)
176
+ .ip("10.8.10.90")
177
+ .provision("playbooks/nginx.yaml", "playbooks/db.yaml");
178
+ const provisionCalls = [];
179
+ // Overrides
180
+ builder.waitFor = async (label, condition) => {
181
+ return await condition();
182
+ };
183
+ builder.checkPort = async () => true;
184
+ builder.checkCloudInit = async () => true;
185
+ builder.runProvisioner = async (ip, script) => {
186
+ provisionCalls.push({ ip, script });
187
+ };
188
+ const deployResult = await builder.deploy();
189
+ assert.strictEqual(deployResult.vmid, 105);
190
+ // Verify playbooks were executed
191
+ assert.strictEqual(provisionCalls.length, 2);
192
+ assert.strictEqual(provisionCalls[0].script, "playbooks/nginx.yaml");
193
+ assert.strictEqual(provisionCalls[1].script, "playbooks/db.yaml");
194
+ // Verify VM configuration was updated with playbooks hash
195
+ const configCall = clientCalls.find((c) => c.method === "POST" && c.path === "/nodes/pve1/qemu/105/config" && c.body?.description);
196
+ assert.ok(configCall);
197
+ const expectedDescription = mergeProvisionMetadata("", {
198
+ "nginx.yaml": getFileHash("playbooks/nginx.yaml"),
199
+ "db.yaml": getFileHash("playbooks/db.yaml"),
200
+ });
201
+ assert.strictEqual(configCall.body.description, expectedDescription);
202
+ });
203
+ test("skips playbook execution on existing VM if hashes match (Idempotence)", async () => {
204
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
205
+ const dbHash = getFileHash("playbooks/db.yaml");
206
+ const descriptionNotes = mergeProvisionMetadata("User customized notes here", {
207
+ "nginx.yaml": nginxHash,
208
+ "db.yaml": dbHash,
209
+ });
210
+ mockGetResponses["/cluster/resources?type=vm"] = [
211
+ { name: "my-existing-vm", vmid: 200, node: "pve1", template: 0, status: "running" },
212
+ ];
213
+ mockGetResponses["/nodes/pve1/qemu/200/config"] = {
214
+ description: descriptionNotes,
215
+ };
216
+ const builder = new VMBuilder("my-existing-vm")
217
+ .ip("10.8.10.95")
218
+ .provision("playbooks/nginx.yaml", "playbooks/db.yaml");
219
+ const provisionCalls = [];
220
+ // Overrides
221
+ builder.waitFor = async (label, condition) => {
222
+ return await condition();
223
+ };
224
+ builder.checkPort = async () => true;
225
+ builder.checkCloudInit = async () => true;
226
+ builder.runProvisioner = async (ip, script) => {
227
+ provisionCalls.push({ ip, script });
228
+ };
229
+ const deployResult = await builder.deploy();
230
+ assert.strictEqual(deployResult.vmid, 200);
231
+ // Verify NO playbooks were executed
232
+ assert.strictEqual(provisionCalls.length, 0);
233
+ // Verify VM configuration was NOT posted to update notes
234
+ const updateConfigCall = clientCalls.find((c) => c.method === "POST" && c.path === "/nodes/pve1/qemu/200/config");
235
+ assert.ok(!updateConfigCall);
236
+ });
237
+ test("executes only new/changed playbooks on existing VM and merges notes metadata (Incremental)", async () => {
238
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
239
+ const dbHash = getFileHash("playbooks/db.yaml");
240
+ const descriptionNotes = mergeProvisionMetadata("User notes preserved", {
241
+ "nginx.yaml": nginxHash,
242
+ });
243
+ mockGetResponses["/cluster/resources?type=vm"] = [
244
+ { name: "my-existing-vm", vmid: 200, node: "pve1", template: 0, status: "running" },
245
+ ];
246
+ mockGetResponses["/nodes/pve1/qemu/200/config"] = {
247
+ description: descriptionNotes,
248
+ };
249
+ const builder = new VMBuilder("my-existing-vm")
250
+ .ip("10.8.10.95")
251
+ .provision("playbooks/nginx.yaml", "playbooks/db.yaml");
252
+ const provisionCalls = [];
253
+ // Overrides
254
+ builder.waitFor = async (label, condition) => {
255
+ return await condition();
256
+ };
257
+ builder.checkPort = async () => true;
258
+ builder.checkCloudInit = async () => true;
259
+ builder.runProvisioner = async (ip, script) => {
260
+ provisionCalls.push({ ip, script });
261
+ };
262
+ const deployResult = await builder.deploy();
263
+ assert.strictEqual(deployResult.vmid, 200);
264
+ // Verify ONLY db.yaml was executed (nginx.yaml was skipped!)
265
+ assert.strictEqual(provisionCalls.length, 1);
266
+ assert.strictEqual(provisionCalls[0].script, "playbooks/db.yaml");
267
+ // Verify VM configuration was updated with BOTH hashes and preserved user notes
268
+ const updateConfigCall = clientCalls.find((c) => c.method === "POST" && c.path === "/nodes/pve1/qemu/200/config");
269
+ assert.ok(updateConfigCall);
270
+ const expectedDescription = mergeProvisionMetadata("User notes preserved", {
271
+ "nginx.yaml": nginxHash,
272
+ "db.yaml": dbHash,
273
+ });
274
+ assert.strictEqual(updateConfigCall.body.description, expectedDescription);
275
+ assert.ok(expectedDescription.startsWith("User notes preserved"));
276
+ });
277
+ test("forceConfigCheck() builder method forces playbook execution even if hashes match", async () => {
278
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
279
+ const descriptionNotes = mergeProvisionMetadata("User notes", {
280
+ "nginx.yaml": nginxHash,
281
+ });
282
+ mockGetResponses["/cluster/resources?type=vm"] = [
283
+ { name: "force-vm", vmid: 200, node: "pve1", template: 0, status: "running" },
284
+ ];
285
+ mockGetResponses["/nodes/pve1/qemu/200/config"] = {
286
+ description: descriptionNotes,
287
+ };
288
+ const builder = new VMBuilder("force-vm")
289
+ .ip("10.8.10.95")
290
+ .provision("playbooks/nginx.yaml")
291
+ .forceConfigCheck();
292
+ const provisionCalls = [];
293
+ // Overrides
294
+ builder.waitFor = async (label, condition) => {
295
+ return await condition();
296
+ };
297
+ builder.checkPort = async () => true;
298
+ builder.checkCloudInit = async () => true;
299
+ builder.runProvisioner = async (ip, script) => {
300
+ provisionCalls.push({ ip, script });
301
+ };
302
+ const deployResult = await builder.deploy();
303
+ assert.strictEqual(deployResult.vmid, 200);
304
+ // Verify playbook WAS executed because of forceConfigCheck()
305
+ assert.strictEqual(provisionCalls.length, 1);
306
+ assert.strictEqual(provisionCalls[0].script, "playbooks/nginx.yaml");
307
+ });
308
+ test("ForceConfigCheck decorator forces playbook execution even if hashes match", async () => {
309
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
310
+ const descriptionNotes = mergeProvisionMetadata("User notes", {
311
+ "nginx.yaml": nginxHash,
312
+ });
313
+ mockGetResponses["/cluster/resources?type=vm"] = [
314
+ { name: "force-dec-vm", vmid: 200, node: "pve1", template: 0, status: "running" },
315
+ ];
316
+ mockGetResponses["/nodes/pve1/qemu/200/config"] = {
317
+ description: descriptionNotes,
318
+ };
319
+ const provisionCalls = [];
320
+ class TestStack extends Stack {
321
+ server = new VMBuilder("force-dec-vm")
322
+ .ip("10.8.10.95")
323
+ .provision("playbooks/nginx.yaml");
324
+ }
325
+ __decorate([
326
+ ForceConfigCheck,
327
+ __metadata("design:type", Object)
328
+ ], TestStack.prototype, "server", void 0);
329
+ const stack = new TestStack();
330
+ stack.server.waitFor = async (label, condition) => {
331
+ return await condition();
332
+ };
333
+ stack.server.checkPort = async () => true;
334
+ stack.server.checkCloudInit = async () => true;
335
+ stack.server.runProvisioner = async (ip, script) => {
336
+ provisionCalls.push({ ip, script });
337
+ };
338
+ await stack.deploy();
339
+ // Verify playbook WAS executed because of @ForceConfigCheck decorator
340
+ assert.strictEqual(provisionCalls.length, 1);
341
+ assert.strictEqual(provisionCalls[0].script, "playbooks/nginx.yaml");
342
+ });
343
+ });
344
+ describe("Proxmox Provision Hash & Metadata Utilities", () => {
345
+ test("parseProvisionMetadata parses valid, invalid, and empty strings", () => {
346
+ assert.deepStrictEqual(parseProvisionMetadata(""), {});
347
+ assert.deepStrictEqual(parseProvisionMetadata("Plain text note without tag"), {});
348
+ assert.deepStrictEqual(parseProvisionMetadata("User description\n\n[puls-provision: a=123,b=456]"), {
349
+ a: "123",
350
+ b: "456",
351
+ });
352
+ assert.deepStrictEqual(parseProvisionMetadata("[puls-provision: nginx.yaml = abc123def456 , db.yaml=789 ]"), {
353
+ "nginx.yaml": "abc123def456",
354
+ "db.yaml": "789",
355
+ });
356
+ });
357
+ test("mergeProvisionMetadata merges tags into notes without corrupting user descriptions", () => {
358
+ const meta = { "nginx.yaml": "abc", "db.yaml": "def" };
359
+ const expectedBlock = "[puls-provision: nginx.yaml=abc,db.yaml=def]";
360
+ // Case 1: Empty note
361
+ assert.strictEqual(mergeProvisionMetadata("", meta), expectedBlock);
362
+ // Case 2: Existing note without tags
363
+ assert.strictEqual(mergeProvisionMetadata("My server description", meta), `My server description\n\n${expectedBlock}`);
364
+ // Case 3: Existing note with existing tags (should replace them)
365
+ const existing = `Some description\n\n[puls-provision: nginx.yaml=old]\nMore details`;
366
+ const merged = mergeProvisionMetadata(existing, meta);
367
+ assert.ok(merged.includes(expectedBlock));
368
+ assert.ok(merged.includes("Some description"));
369
+ assert.ok(!merged.includes("nginx.yaml=old"));
370
+ });
371
+ test("getFileHash returns stable fallback hash for virtual playbooks", () => {
372
+ const hash1 = getFileHash("virtual-playbook-path-1.yaml");
373
+ const hash2 = getFileHash("virtual-playbook-path-1.yaml");
374
+ const hash3 = getFileHash("virtual-playbook-path-2.yaml");
375
+ assert.strictEqual(hash1.length, 12);
376
+ assert.strictEqual(hash1, hash2);
377
+ assert.notStrictEqual(hash1, hash3);
378
+ });
155
379
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puls-dev",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Intent-driven infrastructure-as-code with eager discovery and no state files.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -70,6 +70,7 @@
70
70
  "@aws-sdk/client-secrets-manager": "^3.1053.0",
71
71
  "@aws-sdk/client-sns": "^3.1053.0",
72
72
  "@aws-sdk/client-sqs": "^3.1053.0",
73
+ "@aws-sdk/client-ssm": "^3.1053.0",
73
74
  "@types/node": "^25.6.2",
74
75
  "google-auth-library": "^10.6.2",
75
76
  "ts-node": "^10.9.2",
@@ -94,6 +95,7 @@
94
95
  "@aws-sdk/client-secrets-manager": "^3.1053.0",
95
96
  "@aws-sdk/client-sns": "^3.1053.0",
96
97
  "@aws-sdk/client-sqs": "^3.1053.0",
98
+ "@aws-sdk/client-ssm": "^3.1053.0",
97
99
  "dotenv": "^17.4.2",
98
100
  "google-auth-library": "^10.6.2",
99
101
  "reflect-metadata": "^0.2.2",