puls-dev 0.2.6 → 0.2.7

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Intent-driven infrastructure-as-code. Describe what you want - Puls figures out create, update, or skip.**
4
4
 
5
- [Live Documentation](https://pulsdev.io/) | Discord: **pulsdev.io** ([Join](https://discord.gg/9PcwyjADZj))
5
+ [Live Documentation](https://pulsdev.io/) | Matrix|Gitter: **pulsdev.io** ([Join](https://matrix.to/#/#pulsdevio:gitter.im))
6
6
 
7
7
  > [!IMPORTANT]
8
8
  > **Active Pre-1.0 Development**
@@ -0,0 +1,3 @@
1
+ export declare function getFileHash(filePath: string): string;
2
+ export declare function parseProvisionMetadata(description: string): Record<string, string>;
3
+ export declare function mergeProvisionMetadata(description: string, metadata: Record<string, string>): string;
@@ -0,0 +1,46 @@
1
+ import fs from "node:fs";
2
+ import crypto from "node:crypto";
3
+ export function getFileHash(filePath) {
4
+ try {
5
+ if (!fs.existsSync(filePath)) {
6
+ // Stable hash fallback for virtual playbooks or missing dry-run files
7
+ return crypto.createHash("sha256").update(filePath).digest("hex").slice(0, 12);
8
+ }
9
+ const content = fs.readFileSync(filePath);
10
+ return crypto.createHash("sha256").update(content).digest("hex").slice(0, 12);
11
+ }
12
+ catch {
13
+ return "unknown";
14
+ }
15
+ }
16
+ export function parseProvisionMetadata(description) {
17
+ if (!description)
18
+ return {};
19
+ const match = description.match(/\[puls-provision:\s*([^\]]+)\]/);
20
+ if (!match)
21
+ return {};
22
+ const record = {};
23
+ const entries = match[1].split(",");
24
+ for (const entry of entries) {
25
+ const parts = entry.trim().split("=");
26
+ if (parts.length === 2) {
27
+ const [name, hash] = parts;
28
+ if (name && hash) {
29
+ record[name.trim()] = hash.trim();
30
+ }
31
+ }
32
+ }
33
+ return record;
34
+ }
35
+ export function mergeProvisionMetadata(description, metadata) {
36
+ const metaString = Object.entries(metadata)
37
+ .map(([name, hash]) => `${name}=${hash}`)
38
+ .join(",");
39
+ const block = `[puls-provision: ${metaString}]`;
40
+ const regex = /\[puls-provision:\s*[^\]]+\]/;
41
+ if (regex.test(description)) {
42
+ return description.replace(regex, block);
43
+ }
44
+ const trimmed = description.trim();
45
+ return trimmed ? `${trimmed}\n\n${block}` : block;
46
+ }
@@ -12,7 +12,7 @@ export declare class VMBuilder extends BaseBuilder {
12
12
  private _image?;
13
13
  private _cores;
14
14
  private _memory;
15
- private _provision?;
15
+ private _provision;
16
16
  private _replace?;
17
17
  private _node?;
18
18
  private _storage?;
@@ -25,7 +25,7 @@ export declare class VMBuilder extends BaseBuilder {
25
25
  image(os: OSImage): this;
26
26
  cores(n: number): this;
27
27
  memory(mb: number): this;
28
- provision(playbookPath: string | string[]): this;
28
+ provision(...playbookPaths: (string | string[])[]): this;
29
29
  replace(oldVmName: string): this;
30
30
  node(n: string): this;
31
31
  storage(pool: string): this;
@@ -37,7 +37,7 @@ export declare class VMBuilder extends BaseBuilder {
37
37
  name: string;
38
38
  vmid: number | null;
39
39
  node: string | null;
40
- ip?: undefined;
40
+ ip: string;
41
41
  } | {
42
42
  name: string;
43
43
  vmid: string;
@@ -49,6 +49,7 @@ export declare class VMBuilder extends BaseBuilder {
49
49
  ip: string | null;
50
50
  node?: undefined;
51
51
  }>;
52
+ private resolveExistingIp;
52
53
  destroy(): Promise<any>;
53
54
  private waitForTask;
54
55
  private destroyVmByName;
@@ -6,6 +6,7 @@ import { BaseBuilder } from "../../core/resource.js";
6
6
  import { Config } from "../../core/config.js";
7
7
  import { Output } from "../../core/output.js";
8
8
  import { getPMClient } from "./api.js";
9
+ import { getFileHash, parseProvisionMetadata, mergeProvisionMetadata } from "./hash.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;
@@ -33,7 +34,17 @@ export class VMBuilder extends BaseBuilder {
33
34
  try {
34
35
  const pm = getPMClient();
35
36
  const resources = await pm.get("/cluster/resources?type=vm");
36
- return ((resources ?? []).find((r) => r.name === name && !r.template) ?? null);
37
+ const match = (resources ?? []).find((r) => r.name === name && !r.template) ?? null;
38
+ if (match) {
39
+ try {
40
+ const config = await pm.get(`/nodes/${match.node}/qemu/${match.vmid}/config`);
41
+ match.description = config.description ?? "";
42
+ }
43
+ catch {
44
+ match.description = "";
45
+ }
46
+ }
47
+ return match;
37
48
  }
38
49
  catch (e) {
39
50
  if (e.message?.includes("not configured"))
@@ -53,8 +64,8 @@ export class VMBuilder extends BaseBuilder {
53
64
  this._memory = mb;
54
65
  return this;
55
66
  }
56
- provision(playbookPath) {
57
- this._provision = playbookPath;
67
+ provision(...playbookPaths) {
68
+ this._provision.push(...playbookPaths.flat());
58
69
  return this;
59
70
  }
60
71
  replace(oldVmName) {
@@ -89,20 +100,73 @@ export class VMBuilder extends BaseBuilder {
89
100
  const dryRun = this.isDryRunActive();
90
101
  const existing = await this.discoveryPromise;
91
102
  const pm = getPMClient();
92
- console.log(`\n🖥️ Finalizing Proxmox VM "${this.name}"...`);
93
103
  if (existing) {
94
104
  this.resolvedVmid = existing.vmid;
95
105
  this.resolvedNode = existing.node;
96
106
  this.out.vmid.resolve(existing.vmid);
97
- if (this._ip)
98
- this.out.ip.resolve(this._ip.split("/")[0]);
107
+ // Resolve the IP of the existing VM
108
+ this.resolvedIp = await this.resolveExistingIp(existing.node, existing.vmid, pm);
109
+ if (this.resolvedIp) {
110
+ this.out.ip.resolve(this.resolvedIp);
111
+ }
112
+ const activeIp = this.resolvedIp ?? "0.0.0.0";
113
+ // 1. Calculate hashes and check if playbooks need to run
114
+ const appliedHashes = parseProvisionMetadata(existing.description ?? "");
115
+ const declaredPlaybooksWithHashes = this._provision.map((p) => {
116
+ const baseName = p.split("/").pop() ?? p;
117
+ return { path: p, baseName, hash: getFileHash(p) };
118
+ });
119
+ const playbooksToRun = declaredPlaybooksWithHashes.filter((p) => {
120
+ const appliedHash = appliedHashes[p.baseName];
121
+ return !appliedHash || appliedHash !== p.hash;
122
+ });
123
+ if (playbooksToRun.length > 0) {
124
+ console.log(`\n🖥️ Finalizing Proxmox VM "${this.name}"...`);
125
+ console.log(` ✅ VM "${this.name}" already exists (vmid=${existing.vmid}, node=${existing.node}, status=${existing.status})`);
126
+ if (dryRun) {
127
+ console.log(` 📝 [PLAN] Run ${playbooksToRun.length} playbook changes on existing VM:`);
128
+ for (const p of playbooksToRun) {
129
+ console.log(` └─ Playbook: ${p.path} (hash: ${p.hash})`);
130
+ }
131
+ }
132
+ else {
133
+ console.log(` 🔄 Running ${playbooksToRun.length} playbook changes → ${activeIp}`);
134
+ if (activeIp === "0.0.0.0") {
135
+ throw new Error(`Failed to resolve IP for existing VM "${this.name}" to run playbooks`);
136
+ }
137
+ // Wait for SSH
138
+ await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
139
+ // Execute each playbook
140
+ for (const p of playbooksToRun) {
141
+ await this.runProvisioner(activeIp, p.path);
142
+ appliedHashes[p.baseName] = p.hash;
143
+ }
144
+ // Update notes on Proxmox VM
145
+ const updatedNotes = mergeProvisionMetadata(existing.description ?? "", appliedHashes);
146
+ await pm.post(`/nodes/${existing.node}/qemu/${existing.vmid}/config`, {
147
+ description: updatedNotes,
148
+ });
149
+ console.log(` ✅ Playbooks applied successfully and metadata updated.`);
150
+ }
151
+ return {
152
+ name: this.name,
153
+ vmid: this.resolvedVmid,
154
+ node: this.resolvedNode,
155
+ ip: activeIp,
156
+ };
157
+ }
158
+ // No playbook changes!
159
+ console.log(`\n🖥️ Finalizing Proxmox VM "${this.name}"...`);
99
160
  console.log(` ✅ VM "${this.name}" already exists (vmid=${existing.vmid}, node=${existing.node}, status=${existing.status})`);
161
+ console.log(` ✅ Configuration and playbooks are up to date.`);
100
162
  return {
101
163
  name: this.name,
102
164
  vmid: this.resolvedVmid,
103
165
  node: this.resolvedNode,
166
+ ip: activeIp,
104
167
  };
105
168
  }
169
+ console.log(`\n🖥️ Finalizing Proxmox VM "${this.name}"...`);
106
170
  if (dryRun) {
107
171
  console.log(` 📝 [PLAN] Create VM "${this.name}"`);
108
172
  if (this._image)
@@ -110,11 +174,8 @@ export class VMBuilder extends BaseBuilder {
110
174
  console.log(` └─ Cores: ${this._cores} Memory: ${this._memory} MB Machine: ${this._machine}`);
111
175
  if (this._vlan)
112
176
  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}`);
177
+ if (this._provision.length > 0) {
178
+ console.log(` └─ Provision: ${this._provision.join(", ")}`);
118
179
  }
119
180
  if (this._replace)
120
181
  console.log(` └─ Replace: "${this._replace}" after creation`);
@@ -272,21 +333,56 @@ export class VMBuilder extends BaseBuilder {
272
333
  this.out.ip.resolve(this.resolvedIp);
273
334
  console.log(` 🌐 IP: ${this.resolvedIp}`);
274
335
  }
275
- if (this._provision) {
336
+ if (this._provision.length > 0) {
276
337
  await this.waitFor(`SSH on ${this.resolvedIp} to be ready`, () => this.checkPort(this.resolvedIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
277
338
  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) {
339
+ const appliedHashes = {};
340
+ for (const script of this._provision) {
282
341
  await this.runProvisioner(this.resolvedIp, script);
342
+ const baseName = script.split("/").pop() ?? script;
343
+ appliedHashes[baseName] = getFileHash(script);
283
344
  }
345
+ // Write metadata to new VM description
346
+ const updatedNotes = mergeProvisionMetadata("", appliedHashes);
347
+ await pm.post(`/nodes/${this.resolvedNode}/qemu/${this.resolvedVmid}/config`, {
348
+ description: updatedNotes,
349
+ });
284
350
  }
285
351
  if (this._replace) {
286
352
  await this.destroyVmByName(this._replace, pm);
287
353
  }
288
354
  return { name: this.name, vmid: this.resolvedVmid, ip: this.resolvedIp };
289
355
  }
356
+ async resolveExistingIp(node, vmid, pm) {
357
+ if (this._ip) {
358
+ return this._ip.split("/")[0];
359
+ }
360
+ // Try QEMU guest agent first
361
+ try {
362
+ const ifaces = await pm.get(`/nodes/${node}/qemu/${vmid}/agent/network-get-interfaces`);
363
+ const eth = (ifaces ?? []).find((i) => i.name !== "lo");
364
+ const addr = eth?.["ip-addresses"]?.find((a) => a["ip-address-type"] === "ipv4");
365
+ if (addr?.["ip-address"]) {
366
+ return addr["ip-address"];
367
+ }
368
+ }
369
+ catch {
370
+ // Agent might not be running or installed yet
371
+ }
372
+ // Try DNS lookup next
373
+ const domain = Config.get().providers.proxmox?.dnsDomain;
374
+ if (domain) {
375
+ try {
376
+ const { resolve4 } = await import("node:dns/promises");
377
+ const [addr] = await resolve4(`${this.name}.${domain}`);
378
+ return addr;
379
+ }
380
+ catch {
381
+ // Ignored
382
+ }
383
+ }
384
+ return null;
385
+ }
290
386
  async destroy() {
291
387
  const dryRun = this.isDryRunActive();
292
388
  const existing = await this.discoveryPromise;
@@ -3,6 +3,7 @@ import assert from "node:assert";
3
3
  import { ProxmoxApiClient } from "./api.js";
4
4
  import { VMBuilder } from "./vm.js";
5
5
  import { Config } from "../../core/config.js";
6
+ import { getFileHash, parseProvisionMetadata, mergeProvisionMetadata } from "./hash.js";
6
7
  describe("Proxmox VMBuilder Unit Tests", () => {
7
8
  let originalGet;
8
9
  let originalPost;
@@ -152,4 +153,150 @@ describe("Proxmox VMBuilder Unit Tests", () => {
152
153
  // Let's verify we logged or called the delete path or returned safely.
153
154
  assert.ok(destroyResult.destroyed);
154
155
  });
156
+ test("deploys new VM and writes playbook hashes to VM notes", async () => {
157
+ mockGetResponses["/cluster/resources?type=vm"] = [];
158
+ mockGetResponses["/cluster/nextid"] = 105;
159
+ mockGetResponses["/nodes"] = [
160
+ { node: "pve1", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 12 * 1024 * 1024 * 1024 }
161
+ ];
162
+ const builder = new VMBuilder("prov-new-vm")
163
+ .cores(2)
164
+ .memory(2048)
165
+ .ip("10.8.10.90")
166
+ .provision("playbooks/nginx.yaml", "playbooks/db.yaml");
167
+ const provisionCalls = [];
168
+ // Overrides
169
+ builder.waitFor = async (label, condition) => {
170
+ return await condition();
171
+ };
172
+ builder.checkPort = async () => true;
173
+ builder.checkCloudInit = async () => true;
174
+ builder.runProvisioner = async (ip, script) => {
175
+ provisionCalls.push({ ip, script });
176
+ };
177
+ const deployResult = await builder.deploy();
178
+ assert.strictEqual(deployResult.vmid, 105);
179
+ // Verify playbooks were executed
180
+ assert.strictEqual(provisionCalls.length, 2);
181
+ assert.strictEqual(provisionCalls[0].script, "playbooks/nginx.yaml");
182
+ assert.strictEqual(provisionCalls[1].script, "playbooks/db.yaml");
183
+ // Verify VM configuration was updated with playbooks hash
184
+ const configCall = clientCalls.find((c) => c.method === "POST" && c.path === "/nodes/pve1/qemu/105/config" && c.body?.description);
185
+ assert.ok(configCall);
186
+ const expectedDescription = mergeProvisionMetadata("", {
187
+ "nginx.yaml": getFileHash("playbooks/nginx.yaml"),
188
+ "db.yaml": getFileHash("playbooks/db.yaml"),
189
+ });
190
+ assert.strictEqual(configCall.body.description, expectedDescription);
191
+ });
192
+ test("skips playbook execution on existing VM if hashes match (Idempotence)", async () => {
193
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
194
+ const dbHash = getFileHash("playbooks/db.yaml");
195
+ const descriptionNotes = mergeProvisionMetadata("User customized notes here", {
196
+ "nginx.yaml": nginxHash,
197
+ "db.yaml": dbHash,
198
+ });
199
+ mockGetResponses["/cluster/resources?type=vm"] = [
200
+ { name: "my-existing-vm", vmid: 200, node: "pve1", template: 0, status: "running" },
201
+ ];
202
+ mockGetResponses["/nodes/pve1/qemu/200/config"] = {
203
+ description: descriptionNotes,
204
+ };
205
+ const builder = new VMBuilder("my-existing-vm")
206
+ .ip("10.8.10.95")
207
+ .provision("playbooks/nginx.yaml", "playbooks/db.yaml");
208
+ const provisionCalls = [];
209
+ // Overrides
210
+ builder.waitFor = async (label, condition) => {
211
+ return await condition();
212
+ };
213
+ builder.checkPort = async () => true;
214
+ builder.checkCloudInit = async () => true;
215
+ builder.runProvisioner = async (ip, script) => {
216
+ provisionCalls.push({ ip, script });
217
+ };
218
+ const deployResult = await builder.deploy();
219
+ assert.strictEqual(deployResult.vmid, 200);
220
+ // Verify NO playbooks were executed
221
+ assert.strictEqual(provisionCalls.length, 0);
222
+ // Verify VM configuration was NOT posted to update notes
223
+ const updateConfigCall = clientCalls.find((c) => c.method === "POST" && c.path === "/nodes/pve1/qemu/200/config");
224
+ assert.ok(!updateConfigCall);
225
+ });
226
+ test("executes only new/changed playbooks on existing VM and merges notes metadata (Incremental)", async () => {
227
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
228
+ const dbHash = getFileHash("playbooks/db.yaml");
229
+ const descriptionNotes = mergeProvisionMetadata("User notes preserved", {
230
+ "nginx.yaml": nginxHash,
231
+ });
232
+ mockGetResponses["/cluster/resources?type=vm"] = [
233
+ { name: "my-existing-vm", vmid: 200, node: "pve1", template: 0, status: "running" },
234
+ ];
235
+ mockGetResponses["/nodes/pve1/qemu/200/config"] = {
236
+ description: descriptionNotes,
237
+ };
238
+ const builder = new VMBuilder("my-existing-vm")
239
+ .ip("10.8.10.95")
240
+ .provision("playbooks/nginx.yaml", "playbooks/db.yaml");
241
+ const provisionCalls = [];
242
+ // Overrides
243
+ builder.waitFor = async (label, condition) => {
244
+ return await condition();
245
+ };
246
+ builder.checkPort = async () => true;
247
+ builder.checkCloudInit = async () => true;
248
+ builder.runProvisioner = async (ip, script) => {
249
+ provisionCalls.push({ ip, script });
250
+ };
251
+ const deployResult = await builder.deploy();
252
+ assert.strictEqual(deployResult.vmid, 200);
253
+ // Verify ONLY db.yaml was executed (nginx.yaml was skipped!)
254
+ assert.strictEqual(provisionCalls.length, 1);
255
+ assert.strictEqual(provisionCalls[0].script, "playbooks/db.yaml");
256
+ // Verify VM configuration was updated with BOTH hashes and preserved user notes
257
+ const updateConfigCall = clientCalls.find((c) => c.method === "POST" && c.path === "/nodes/pve1/qemu/200/config");
258
+ assert.ok(updateConfigCall);
259
+ const expectedDescription = mergeProvisionMetadata("User notes preserved", {
260
+ "nginx.yaml": nginxHash,
261
+ "db.yaml": dbHash,
262
+ });
263
+ assert.strictEqual(updateConfigCall.body.description, expectedDescription);
264
+ assert.ok(expectedDescription.startsWith("User notes preserved"));
265
+ });
266
+ });
267
+ describe("Proxmox Provision Hash & Metadata Utilities", () => {
268
+ test("parseProvisionMetadata parses valid, invalid, and empty strings", () => {
269
+ assert.deepStrictEqual(parseProvisionMetadata(""), {});
270
+ assert.deepStrictEqual(parseProvisionMetadata("Plain text note without tag"), {});
271
+ assert.deepStrictEqual(parseProvisionMetadata("User description\n\n[puls-provision: a=123,b=456]"), {
272
+ a: "123",
273
+ b: "456",
274
+ });
275
+ assert.deepStrictEqual(parseProvisionMetadata("[puls-provision: nginx.yaml = abc123def456 , db.yaml=789 ]"), {
276
+ "nginx.yaml": "abc123def456",
277
+ "db.yaml": "789",
278
+ });
279
+ });
280
+ test("mergeProvisionMetadata merges tags into notes without corrupting user descriptions", () => {
281
+ const meta = { "nginx.yaml": "abc", "db.yaml": "def" };
282
+ const expectedBlock = "[puls-provision: nginx.yaml=abc,db.yaml=def]";
283
+ // Case 1: Empty note
284
+ assert.strictEqual(mergeProvisionMetadata("", meta), expectedBlock);
285
+ // Case 2: Existing note without tags
286
+ assert.strictEqual(mergeProvisionMetadata("My server description", meta), `My server description\n\n${expectedBlock}`);
287
+ // Case 3: Existing note with existing tags (should replace them)
288
+ const existing = `Some description\n\n[puls-provision: nginx.yaml=old]\nMore details`;
289
+ const merged = mergeProvisionMetadata(existing, meta);
290
+ assert.ok(merged.includes(expectedBlock));
291
+ assert.ok(merged.includes("Some description"));
292
+ assert.ok(!merged.includes("nginx.yaml=old"));
293
+ });
294
+ test("getFileHash returns stable fallback hash for virtual playbooks", () => {
295
+ const hash1 = getFileHash("virtual-playbook-path-1.yaml");
296
+ const hash2 = getFileHash("virtual-playbook-path-1.yaml");
297
+ const hash3 = getFileHash("virtual-playbook-path-2.yaml");
298
+ assert.strictEqual(hash1.length, 12);
299
+ assert.strictEqual(hash1, hash2);
300
+ assert.notStrictEqual(hash1, hash3);
301
+ });
155
302
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puls-dev",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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",