puls-dev 0.2.7 → 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 (80) hide show
  1. package/dist/core/config.d.ts +2 -0
  2. package/dist/core/decorators.d.ts +2 -0
  3. package/dist/core/decorators.js +48 -16
  4. package/dist/core/hooks.d.ts +21 -0
  5. package/dist/core/hooks.js +116 -0
  6. package/dist/core/hooks.test.d.ts +1 -0
  7. package/dist/core/hooks.test.js +194 -0
  8. package/dist/core/multiregion.test.d.ts +1 -0
  9. package/dist/core/multiregion.test.js +87 -0
  10. package/dist/core/output.d.ts +2 -0
  11. package/dist/core/output.js +9 -2
  12. package/dist/core/parser.d.ts +10 -0
  13. package/dist/core/parser.js +140 -0
  14. package/dist/core/parser.test.d.ts +1 -0
  15. package/dist/core/parser.test.js +117 -0
  16. package/dist/core/provisioner.d.ts +4 -0
  17. package/dist/core/provisioner.js +105 -0
  18. package/dist/core/resource.d.ts +16 -0
  19. package/dist/core/resource.js +44 -0
  20. package/dist/core/secret.d.ts +40 -0
  21. package/dist/core/secret.js +95 -0
  22. package/dist/core/secret.test.d.ts +1 -0
  23. package/dist/core/secret.test.js +166 -0
  24. package/dist/core/stack.d.ts +4 -3
  25. package/dist/core/stack.js +50 -9
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +2 -0
  28. package/dist/providers/aws/ec2.d.ts +48 -0
  29. package/dist/providers/aws/ec2.js +297 -0
  30. package/dist/providers/aws/ec2.test.d.ts +1 -0
  31. package/dist/providers/aws/ec2.test.js +279 -0
  32. package/dist/providers/aws/index.d.ts +2 -0
  33. package/dist/providers/aws/index.js +2 -0
  34. package/dist/providers/aws/route53.d.ts +1 -0
  35. package/dist/providers/aws/route53.js +15 -2
  36. package/dist/providers/aws/route53.test.js +47 -0
  37. package/dist/providers/do/api.d.ts +1 -1
  38. package/dist/providers/do/api.js +2 -1
  39. package/dist/providers/do/app.d.ts +26 -0
  40. package/dist/providers/do/app.js +124 -0
  41. package/dist/providers/do/app.test.d.ts +1 -0
  42. package/dist/providers/do/app.test.js +268 -0
  43. package/dist/providers/do/database.d.ts +44 -0
  44. package/dist/providers/do/database.js +208 -0
  45. package/dist/providers/do/database.test.d.ts +1 -0
  46. package/dist/providers/do/database.test.js +293 -0
  47. package/dist/providers/do/domain.d.ts +2 -0
  48. package/dist/providers/do/domain.js +30 -0
  49. package/dist/providers/do/domain.test.js +49 -0
  50. package/dist/providers/do/droplet.d.ts +9 -0
  51. package/dist/providers/do/droplet.js +132 -8
  52. package/dist/providers/do/droplet.test.js +228 -1
  53. package/dist/providers/do/firewall.d.ts +2 -1
  54. package/dist/providers/do/firewall.js +23 -9
  55. package/dist/providers/do/firewall.test.js +54 -0
  56. package/dist/providers/do/index.d.ts +11 -0
  57. package/dist/providers/do/index.js +8 -0
  58. package/dist/providers/do/spaces.d.ts +27 -0
  59. package/dist/providers/do/spaces.js +142 -0
  60. package/dist/providers/do/spaces.test.d.ts +1 -0
  61. package/dist/providers/do/spaces.test.js +180 -0
  62. package/dist/providers/do/spaces_api.d.ts +2 -0
  63. package/dist/providers/do/spaces_api.js +20 -0
  64. package/dist/providers/do/vpc.d.ts +30 -0
  65. package/dist/providers/do/vpc.js +128 -0
  66. package/dist/providers/do/vpc.test.d.ts +1 -0
  67. package/dist/providers/do/vpc.test.js +258 -0
  68. package/dist/providers/gcp/clouddns.d.ts +1 -0
  69. package/dist/providers/gcp/clouddns.js +15 -2
  70. package/dist/providers/gcp/clouddns.test.js +45 -0
  71. package/dist/providers/gcp/index.d.ts +3 -1
  72. package/dist/providers/gcp/index.js +3 -1
  73. package/dist/providers/gcp/vm.d.ts +45 -0
  74. package/dist/providers/gcp/vm.js +332 -0
  75. package/dist/providers/gcp/vm.test.d.ts +1 -0
  76. package/dist/providers/gcp/vm.test.js +321 -0
  77. package/dist/providers/proxmox/vm.d.ts +4 -4
  78. package/dist/providers/proxmox/vm.js +17 -93
  79. package/dist/providers/proxmox/vm.test.js +77 -0
  80. package/package.json +3 -1
@@ -0,0 +1,321 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from "node:test";
2
+ import assert from "node:assert";
3
+ import fs from "node:fs";
4
+ import { GoogleAuth } from "google-auth-library";
5
+ import { GCPVMBuilder } from "./vm.js";
6
+ import { Config } from "../../core/config.js";
7
+ import { getFileHash } from "../proxmox/hash.js";
8
+ describe("GCPVMBuilder Unit Tests", () => {
9
+ let originalFetch;
10
+ let fetchCalls = [];
11
+ let mockResponses = {};
12
+ beforeEach(() => {
13
+ Config.set({
14
+ dryRun: false,
15
+ providers: {
16
+ gcp: {
17
+ projectId: "my-gcp-project",
18
+ serviceAccountPath: "/fake/sa.json",
19
+ region: "us-central1",
20
+ },
21
+ },
22
+ });
23
+ originalFetch = globalThis.fetch;
24
+ fetchCalls = [];
25
+ mockResponses = {};
26
+ globalThis.fetch = async (input, init) => {
27
+ const url = String(input);
28
+ const method = init?.method ?? "GET";
29
+ let body;
30
+ if (init?.body) {
31
+ if (typeof init.body === "string") {
32
+ try {
33
+ body = JSON.parse(init.body);
34
+ }
35
+ catch {
36
+ body = init.body;
37
+ }
38
+ }
39
+ else {
40
+ body = "[Binary/Buffer Body]";
41
+ }
42
+ }
43
+ const headers = init?.headers;
44
+ fetchCalls.push({ url, method, body, headers });
45
+ const matchKey = Object.keys(mockResponses)
46
+ .filter((key) => {
47
+ const [mMethod, mPath] = key.split(" ");
48
+ return method === mMethod && url.includes(mPath);
49
+ })
50
+ .sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
51
+ if (matchKey) {
52
+ const resp = mockResponses[matchKey];
53
+ return {
54
+ ok: resp.status >= 200 && resp.status < 300,
55
+ status: resp.status,
56
+ json: async () => resp.body,
57
+ text: async () => JSON.stringify(resp.body),
58
+ };
59
+ }
60
+ return {
61
+ ok: false,
62
+ status: 404,
63
+ json: async () => ({ error: { message: `Endpoint not mocked: ${method} ${url}` } }),
64
+ text: async () => `Endpoint not mocked: ${method} ${url}`,
65
+ };
66
+ };
67
+ mock.method(GoogleAuth.prototype, "getClient", async () => {
68
+ return {
69
+ getAccessToken: async () => ({ token: "fake-gcp-token" }),
70
+ };
71
+ });
72
+ mock.method(fs, "readFileSync", () => {
73
+ return "ssh-rsa AAAA_FAKE_GCP_PUBLIC_KEY test@gcp.com";
74
+ });
75
+ });
76
+ afterEach(() => {
77
+ globalThis.fetch = originalFetch;
78
+ mock.restoreAll();
79
+ });
80
+ test("handles discovery when VM does not exist", async () => {
81
+ mockResponses["GET /instances/my-gcp-vm"] = {
82
+ status: 404,
83
+ body: { error: { message: "Not Found" } },
84
+ };
85
+ const builder = new GCPVMBuilder("my-gcp-vm");
86
+ const existing = await builder.discoveryPromise;
87
+ assert.strictEqual(existing, null);
88
+ const getCall = fetchCalls.find((c) => c.method === "GET" && c.url.includes("/instances/my-gcp-vm"));
89
+ assert.ok(getCall);
90
+ });
91
+ test("discovers VM successfully when it exists", async () => {
92
+ mockResponses["GET /instances/my-gcp-vm"] = {
93
+ status: 200,
94
+ body: {
95
+ id: "vm-123456",
96
+ name: "my-gcp-vm",
97
+ status: "RUNNING",
98
+ networkInterfaces: [
99
+ {
100
+ accessConfigs: [{ natIP: "34.56.78.90" }],
101
+ },
102
+ ],
103
+ metadata: {
104
+ items: [{ key: "puls-provision", value: "nginx-yaml=abc123123123" }],
105
+ },
106
+ },
107
+ };
108
+ const builder = new GCPVMBuilder("my-gcp-vm");
109
+ const existing = await builder.discoveryPromise;
110
+ assert.ok(existing);
111
+ assert.strictEqual(existing.id, "vm-123456");
112
+ const resolvedId = await builder.out.id.get();
113
+ const resolvedIp = await builder.out.ip.get();
114
+ assert.strictEqual(resolvedId, "vm-123456");
115
+ assert.strictEqual(resolvedIp, "34.56.78.90");
116
+ });
117
+ test("runs in dry-run mode safely and logs plan", async () => {
118
+ Config.set({
119
+ dryRun: true,
120
+ providers: {
121
+ gcp: { projectId: "my-gcp-project", serviceAccountPath: "/fake/sa.json" },
122
+ },
123
+ });
124
+ mockResponses["GET /instances/new-gcp-vm"] = {
125
+ status: 404,
126
+ body: { error: { message: "Not Found" } },
127
+ };
128
+ const builder = new GCPVMBuilder("new-gcp-vm")
129
+ .machineType("e2-medium")
130
+ .zone("europe-west1-b")
131
+ .provision("playbooks/nginx.yaml");
132
+ const res = await builder.deploy();
133
+ assert.deepStrictEqual(res, { name: "new-gcp-vm", id: "PENDING" });
134
+ const resolvedId = await builder.out.id.get();
135
+ const resolvedIp = await builder.out.ip.get();
136
+ assert.strictEqual(resolvedId, "PENDING");
137
+ assert.strictEqual(resolvedIp, "0.0.0.0");
138
+ // Ensure no writes were performed
139
+ const writeCalls = fetchCalls.filter((c) => c.method === "POST" || c.method === "DELETE");
140
+ assert.strictEqual(writeCalls.length, 0);
141
+ });
142
+ test("creates a new VM instance and runs playbooks successfully", async () => {
143
+ mockResponses["GET /instances/new-vm"] = {
144
+ status: 404,
145
+ body: { error: { message: "Not Found" } },
146
+ };
147
+ mockResponses["POST /instances"] = {
148
+ status: 200,
149
+ body: { id: "op-111", status: "DONE" },
150
+ };
151
+ // Subsequents GET during wait loop returns RUNNING
152
+ let getCount = 0;
153
+ globalThis.fetch = async (input, init) => {
154
+ const url = String(input);
155
+ const method = init?.method ?? "GET";
156
+ const body = init?.body ? JSON.parse(init.body) : undefined;
157
+ fetchCalls.push({ url, method, body });
158
+ if (method === "GET" && url.includes("/instances/new-vm")) {
159
+ const createCall = fetchCalls.find(c => c.method === "POST" && c.url.includes("/instances"));
160
+ if (!createCall) {
161
+ return {
162
+ ok: false,
163
+ status: 404,
164
+ json: async () => ({ error: { message: "Not Found" } }),
165
+ text: async () => JSON.stringify({ error: { message: "Not Found" } }),
166
+ };
167
+ }
168
+ getCount++;
169
+ const data = {
170
+ id: "new-vm-uuid",
171
+ name: "new-vm",
172
+ status: getCount > 1 ? "RUNNING" : "PROVISIONING",
173
+ networkInterfaces: [
174
+ {
175
+ accessConfigs: [{ natIP: "35.200.10.20" }],
176
+ },
177
+ ],
178
+ };
179
+ return {
180
+ ok: true,
181
+ status: 200,
182
+ json: async () => data,
183
+ text: async () => JSON.stringify(data),
184
+ };
185
+ }
186
+ if (method === "POST" && url.includes("/instances")) {
187
+ const opData = { id: "op-111", status: "DONE" };
188
+ return {
189
+ ok: true,
190
+ status: 200,
191
+ json: async () => opData,
192
+ text: async () => JSON.stringify(opData),
193
+ };
194
+ }
195
+ return { ok: false, status: 404 };
196
+ };
197
+ const builder = new GCPVMBuilder("new-vm")
198
+ .machineType("e2-medium")
199
+ .zone("us-central1-a")
200
+ .sshKey("~/.ssh/id_rsa.pub")
201
+ .provision("playbooks/nginx.yaml");
202
+ const provisionCalls = [];
203
+ builder.waitFor = async (label, condition) => {
204
+ return await condition();
205
+ };
206
+ builder.checkPort = async () => true;
207
+ builder.runProvisioner = async (ip, script) => {
208
+ provisionCalls.push(script);
209
+ };
210
+ const res = await builder.deploy();
211
+ assert.ok(res);
212
+ assert.strictEqual(res.id, "new-vm-uuid");
213
+ assert.strictEqual(res.ip, "35.200.10.20");
214
+ assert.strictEqual(provisionCalls.length, 1);
215
+ assert.strictEqual(provisionCalls[0], "playbooks/nginx.yaml");
216
+ const createCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/instances"));
217
+ assert.ok(createCall);
218
+ assert.strictEqual(createCall.body.name, "new-vm");
219
+ assert.strictEqual(createCall.body.machineType, "zones/us-central1-a/machineTypes/e2-medium");
220
+ const sshMetadata = createCall.body.metadata.items.find((i) => i.key === "ssh-keys");
221
+ assert.strictEqual(sshMetadata.value, "root:ssh-rsa AAAA_FAKE_GCP_PUBLIC_KEY test@gcp.com");
222
+ const provMetadata = createCall.body.metadata.items.find((i) => i.key === "puls-provision");
223
+ assert.ok(provMetadata.value.startsWith("nginx-yaml="));
224
+ });
225
+ test("skips playbook execution on existing VM if hashes match", async () => {
226
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
227
+ mockResponses["GET /instances/exist-vm"] = {
228
+ status: 200,
229
+ body: {
230
+ id: "exist-vm-id",
231
+ name: "exist-vm",
232
+ status: "RUNNING",
233
+ machineType: "zones/us-central1-a/machineTypes/e2-micro",
234
+ networkInterfaces: [
235
+ {
236
+ accessConfigs: [{ natIP: "35.200.10.30" }],
237
+ },
238
+ ],
239
+ metadata: {
240
+ items: [{ key: "puls-provision", value: `nginx-yaml=${nginxHash}` }],
241
+ },
242
+ },
243
+ };
244
+ const builder = new GCPVMBuilder("exist-vm")
245
+ .machineType("e2-micro")
246
+ .zone("us-central1-a")
247
+ .provision("playbooks/nginx.yaml");
248
+ const provisionCalls = [];
249
+ builder.runProvisioner = async (ip, script) => {
250
+ provisionCalls.push(script);
251
+ };
252
+ await builder.deploy();
253
+ // No playbooks should run
254
+ assert.strictEqual(provisionCalls.length, 0);
255
+ // No setMetadata call
256
+ const setMetaCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/setMetadata"));
257
+ assert.strictEqual(setMetaCall, undefined);
258
+ });
259
+ test("executes playbooks on existing VM if hashes differ, updating metadata", async () => {
260
+ mockResponses["GET /instances/exist-diff-vm"] = {
261
+ status: 200,
262
+ body: {
263
+ id: "exist-diff-vm-id",
264
+ name: "exist-diff-vm",
265
+ status: "RUNNING",
266
+ machineType: "zones/us-central1-a/machineTypes/e2-micro",
267
+ networkInterfaces: [
268
+ {
269
+ accessConfigs: [{ natIP: "35.200.10.40" }],
270
+ },
271
+ ],
272
+ metadata: {
273
+ fingerprint: "old-fingerprint-123",
274
+ items: [{ key: "puls-provision", value: "nginx-yaml=abc123123123" }],
275
+ },
276
+ },
277
+ };
278
+ mockResponses["POST /instances/exist-diff-vm/setMetadata"] = {
279
+ status: 200,
280
+ body: {},
281
+ };
282
+ const builder = new GCPVMBuilder("exist-diff-vm")
283
+ .machineType("e2-micro")
284
+ .zone("us-central1-a")
285
+ .provision("playbooks/nginx.yaml");
286
+ const provisionCalls = [];
287
+ builder.waitFor = async (label, condition) => {
288
+ return await condition();
289
+ };
290
+ builder.checkPort = async () => true;
291
+ builder.runProvisioner = async (ip, script) => {
292
+ provisionCalls.push(script);
293
+ };
294
+ await builder.deploy();
295
+ assert.strictEqual(provisionCalls.length, 1);
296
+ assert.strictEqual(provisionCalls[0], "playbooks/nginx.yaml");
297
+ // Verify setMetadata was dispatched with new hashes and correct fingerprint
298
+ const setMetaCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/setMetadata"));
299
+ assert.ok(setMetaCall);
300
+ assert.strictEqual(setMetaCall.body.fingerprint, "old-fingerprint-123");
301
+ const provMetadata = setMetaCall.body.items.find((i) => i.key === "puls-provision");
302
+ const expectedHash = getFileHash("playbooks/nginx.yaml");
303
+ assert.strictEqual(provMetadata.value, `nginx-yaml=${expectedHash}`);
304
+ });
305
+ test("destroys VM successfully", async () => {
306
+ mockResponses["GET /instances/delete-vm"] = {
307
+ status: 200,
308
+ body: { id: "delete-vm-id", name: "delete-vm" },
309
+ };
310
+ mockResponses["DELETE /instances/delete-vm"] = {
311
+ status: 200,
312
+ body: {},
313
+ };
314
+ const builder = new GCPVMBuilder("delete-vm");
315
+ await builder.discoveryPromise;
316
+ const res = await builder.destroy();
317
+ assert.deepStrictEqual(res, { destroyed: "delete-vm" });
318
+ const deleteCall = fetchCalls.find((c) => c.method === "DELETE" && c.url.includes("/instances/delete-vm"));
319
+ assert.ok(deleteCall);
320
+ });
321
+ });
@@ -20,6 +20,7 @@ export declare class VMBuilder extends BaseBuilder {
20
20
  private _ip?;
21
21
  private _sshKeys?;
22
22
  private _machine;
23
+ private _forceConfigCheck;
23
24
  constructor(name: string);
24
25
  private discoverVm;
25
26
  image(os: OSImage): this;
@@ -33,6 +34,7 @@ export declare class VMBuilder extends BaseBuilder {
33
34
  ip(address: string): this;
34
35
  sshKey(keys: string | readonly string[]): this;
35
36
  machine(type: "q35" | "i440fx"): this;
37
+ forceConfigCheck(): this;
36
38
  deploy(): Promise<{
37
39
  name: string;
38
40
  vmid: number | null;
@@ -55,9 +57,7 @@ export declare class VMBuilder extends BaseBuilder {
55
57
  private destroyVmByName;
56
58
  private resolvePublicKeys;
57
59
  private checkCloudInit;
58
- private checkPort;
60
+ protected checkPort(ip: string, port: number): Promise<boolean>;
61
+ protected runProvisioner(ip: string, script: string): Promise<void>;
59
62
  private sshKeyPath;
60
- private runProvisioner;
61
- private runPuppet;
62
- private runAnsible;
63
63
  }
@@ -1,12 +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";
9
8
  import { getFileHash, parseProvisionMetadata, mergeProvisionMetadata } from "./hash.js";
9
+ import { checkPort, runProvisioner } from "../../core/provisioner.js";
10
10
  export class VMBuilder extends BaseBuilder {
11
11
  out = {
12
12
  ip: new Output(),
@@ -26,6 +26,7 @@ export class VMBuilder extends BaseBuilder {
26
26
  _ip;
27
27
  _sshKeys;
28
28
  _machine = "q35";
29
+ _forceConfigCheck = false;
29
30
  constructor(name) {
30
31
  super(name);
31
32
  this.discoveryPromise = this.discoverVm(name);
@@ -96,6 +97,10 @@ export class VMBuilder extends BaseBuilder {
96
97
  this._machine = type;
97
98
  return this;
98
99
  }
100
+ forceConfigCheck() {
101
+ this._forceConfigCheck = true;
102
+ return this;
103
+ }
99
104
  async deploy() {
100
105
  const dryRun = this.isDryRunActive();
101
106
  const existing = await this.discoveryPromise;
@@ -116,10 +121,12 @@ export class VMBuilder extends BaseBuilder {
116
121
  const baseName = p.split("/").pop() ?? p;
117
122
  return { path: p, baseName, hash: getFileHash(p) };
118
123
  });
119
- const playbooksToRun = declaredPlaybooksWithHashes.filter((p) => {
120
- const appliedHash = appliedHashes[p.baseName];
121
- return !appliedHash || appliedHash !== p.hash;
122
- });
124
+ const playbooksToRun = this._forceConfigCheck
125
+ ? declaredPlaybooksWithHashes
126
+ : declaredPlaybooksWithHashes.filter((p) => {
127
+ const appliedHash = appliedHashes[p.baseName];
128
+ return !appliedHash || appliedHash !== p.hash;
129
+ });
123
130
  if (playbooksToRun.length > 0) {
124
131
  console.log(`\n🖥️ Finalizing Proxmox VM "${this.name}"...`);
125
132
  console.log(` ✅ VM "${this.name}" already exists (vmid=${existing.vmid}, node=${existing.node}, status=${existing.status})`);
@@ -480,19 +487,11 @@ export class VMBuilder extends BaseBuilder {
480
487
  proc.on("error", () => resolve(false));
481
488
  });
482
489
  }
483
- checkPort(ip, port) {
484
- return new Promise((resolve) => {
485
- const socket = createConnection({ host: ip, port, timeout: 3_000 });
486
- socket.on("connect", () => {
487
- socket.destroy();
488
- resolve(true);
489
- });
490
- socket.on("timeout", () => {
491
- socket.destroy();
492
- resolve(false);
493
- });
494
- socket.on("error", () => resolve(false));
495
- });
490
+ async checkPort(ip, port) {
491
+ return checkPort(ip, port);
492
+ }
493
+ async runProvisioner(ip, script) {
494
+ return runProvisioner(ip, "root", this._sshKeys, script);
496
495
  }
497
496
  sshKeyPath() {
498
497
  const keyInput = Array.isArray(this._sshKeys)
@@ -505,79 +504,4 @@ export class VMBuilder extends BaseBuilder {
505
504
  ? keyInput.replace(/\.pub$/, "")
506
505
  : `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
507
506
  }
508
- runProvisioner(ip, script) {
509
- const ext = script.split(".").pop()?.toLowerCase();
510
- if (ext === "sh") {
511
- throw new Error(`Shell script provisioning (.sh) is no longer supported. ` +
512
- `Please migrate "${script}" to an Ansible playbook (.yaml/.yml).`);
513
- }
514
- if (ext === "pp")
515
- return this.runPuppet(ip, script);
516
- return this.runAnsible(ip, script); // .yml / .yaml
517
- }
518
- runPuppet(ip, manifest) {
519
- console.log(` 🔧 Applying Puppet manifest: ${manifest} → ${ip}`);
520
- const keyPath = this.sshKeyPath();
521
- // Copy manifest then apply it
522
- return new Promise((resolve, reject) => {
523
- const scp = spawn("scp", [
524
- "-i",
525
- keyPath,
526
- "-o",
527
- "StrictHostKeyChecking=no",
528
- manifest,
529
- `root@${ip}:/tmp/manifest.pp`,
530
- ], { stdio: "inherit" });
531
- scp.on("close", (code) => {
532
- if (code !== 0) {
533
- reject(new Error(`scp exited with code ${code}`));
534
- return;
535
- }
536
- const puppet = spawn("ssh", [
537
- "-i",
538
- keyPath,
539
- "-o",
540
- "StrictHostKeyChecking=no",
541
- `root@${ip}`,
542
- "puppet apply /tmp/manifest.pp",
543
- ], { stdio: "inherit" });
544
- puppet.on("close", (c) => {
545
- if (c === 0) {
546
- console.log(` ✅ Provisioning complete`);
547
- resolve();
548
- }
549
- else
550
- reject(new Error(`puppet apply exited with code ${c}`));
551
- });
552
- puppet.on("error", (err) => reject(new Error(`Failed to run puppet: ${err.message}`)));
553
- });
554
- scp.on("error", (err) => reject(new Error(`Failed to run scp: ${err.message}`)));
555
- });
556
- }
557
- runAnsible(ip, playbook) {
558
- console.log(` 🔧 Running Ansible: ${playbook} → ${ip}`);
559
- const keyPath = this.sshKeyPath();
560
- return new Promise((resolve, reject) => {
561
- const proc = spawn("ansible-playbook", [
562
- playbook,
563
- "-i",
564
- `${ip},`,
565
- "-u",
566
- "root",
567
- "--private-key",
568
- keyPath,
569
- "--ssh-extra-args",
570
- "-o StrictHostKeyChecking=no -o ConnectTimeout=30",
571
- ], { stdio: "inherit" });
572
- proc.on("close", (code) => {
573
- if (code === 0) {
574
- console.log(` ✅ Provisioning complete`);
575
- resolve();
576
- }
577
- else
578
- reject(new Error(`ansible-playbook exited with code ${code}`));
579
- });
580
- proc.on("error", (err) => reject(new Error(`Failed to run ansible-playbook: ${err.message}`)));
581
- });
582
- }
583
507
  }
@@ -1,9 +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";
6
15
  import { getFileHash, parseProvisionMetadata, mergeProvisionMetadata } from "./hash.js";
16
+ import { Stack } from "../../core/stack.js";
17
+ import { ForceConfigCheck } from "../../core/decorators.js";
7
18
  describe("Proxmox VMBuilder Unit Tests", () => {
8
19
  let originalGet;
9
20
  let originalPost;
@@ -263,6 +274,72 @@ describe("Proxmox VMBuilder Unit Tests", () => {
263
274
  assert.strictEqual(updateConfigCall.body.description, expectedDescription);
264
275
  assert.ok(expectedDescription.startsWith("User notes preserved"));
265
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
+ });
266
343
  });
267
344
  describe("Proxmox Provision Hash & Metadata Utilities", () => {
268
345
  test("parseProvisionMetadata parses valid, invalid, and empty strings", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puls-dev",
3
- "version": "0.2.7",
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",