puls-dev 0.3.2 → 0.3.4

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.
@@ -1,14 +1,10 @@
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";
1
+ import { ProxmoxBaseBuilder } from "./base.js";
5
2
  import { Config } from "../../core/config.js";
6
3
  import { Output } from "../../core/output.js";
7
- import { getPMClient } from "./api.js";
4
+ import { getPMClient, withVmidAllocation } from "./api.js";
8
5
  import { getFileHash, parseProvisionMetadata, mergeProvisionMetadata } from "./hash.js";
9
- import { checkPort, runProvisioner } from "../../core/provisioner.js";
10
6
  import { resourceContextStorage } from "../../core/context.js";
11
- export class VMBuilder extends BaseBuilder {
7
+ export class VMBuilder extends ProxmoxBaseBuilder {
12
8
  out = {
13
9
  ip: new Output(),
14
10
  vmid: new Output(),
@@ -26,7 +22,7 @@ export class VMBuilder extends BaseBuilder {
26
22
  _storage;
27
23
  _vlan;
28
24
  _ip;
29
- _sshKeys;
25
+ _gateway;
30
26
  _machine = "q35";
31
27
  _forceConfigCheck = false;
32
28
  constructor(name) {
@@ -97,8 +93,8 @@ export class VMBuilder extends BaseBuilder {
97
93
  this._ip = address;
98
94
  return this;
99
95
  }
100
- sshKey(keys) {
101
- this._sshKeys = Array.isArray(keys) ? [...keys] : keys;
96
+ gateway(gw) {
97
+ this._gateway = gw;
102
98
  return this;
103
99
  }
104
100
  machine(type) {
@@ -110,6 +106,16 @@ export class VMBuilder extends BaseBuilder {
110
106
  return this;
111
107
  }
112
108
  async deploy() {
109
+ try {
110
+ return await this._deploy();
111
+ }
112
+ catch (err) {
113
+ this.out.vmid.reject(err);
114
+ this.out.ip.reject(err);
115
+ throw err;
116
+ }
117
+ }
118
+ async _deploy() {
113
119
  const dryRun = this.isDryRunActive();
114
120
  const existing = await this.discoveryPromise;
115
121
  const pm = getPMClient();
@@ -121,8 +127,8 @@ export class VMBuilder extends BaseBuilder {
121
127
  this.resolvedIp = await this.resolveExistingIp(existing.node, existing.vmid, pm);
122
128
  if (this.resolvedIp) {
123
129
  this.out.ip.resolve(this.resolvedIp);
130
+ this.registerHost();
124
131
  }
125
- this.registerHost();
126
132
  const activeIp = this.resolvedIp ?? "0.0.0.0";
127
133
  // 1. Calculate hashes and check if playbooks need to run
128
134
  const appliedHashes = parseProvisionMetadata(existing.description ?? "");
@@ -146,10 +152,10 @@ export class VMBuilder extends BaseBuilder {
146
152
  }
147
153
  }
148
154
  else {
149
- console.log(` šŸ”„ Running ${playbooksToRun.length} playbook changes → ${activeIp}`);
150
155
  if (activeIp === "0.0.0.0") {
151
156
  throw new Error(`Failed to resolve IP for existing VM "${this.name}" to run playbooks`);
152
157
  }
158
+ console.log(` šŸ”„ Running ${playbooksToRun.length} playbook changes → ${activeIp}`);
153
159
  // Wait for SSH
154
160
  await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
155
161
  // Execute each playbook
@@ -171,7 +177,7 @@ export class VMBuilder extends BaseBuilder {
171
177
  ip: activeIp,
172
178
  };
173
179
  }
174
- // No playbook changes!
180
+ // No playbook changes
175
181
  console.log(`\nšŸ–„ļø Finalizing Proxmox VM "${this.name}"...`);
176
182
  console.log(` āœ… VM "${this.name}" already exists (vmid=${existing.vmid}, node=${existing.node}, status=${existing.status})`);
177
183
  console.log(` āœ… Configuration and playbooks are up to date.`);
@@ -225,34 +231,7 @@ export class VMBuilder extends BaseBuilder {
225
231
  : `Create a template whose name contains "${sourceVmid}".`));
226
232
  }
227
233
  // Resolve target node: explicit → cluster-aware (online & max free RAM) → configured nodes list → template's node → API discovery
228
- let node = this._node;
229
- if (!node) {
230
- try {
231
- const nodesList = await pm.get("/nodes");
232
- const configuredNodes = Config.get().providers.proxmox?.nodes;
233
- const onlineNodes = (nodesList ?? []).filter((n) => {
234
- if (n.status !== "online")
235
- return false;
236
- if (configuredNodes && configuredNodes.length > 0) {
237
- return configuredNodes.includes(n.node);
238
- }
239
- return true;
240
- });
241
- if (onlineNodes.length > 0) {
242
- // Sort descending by free memory (maxmem - mem)
243
- onlineNodes.sort((a, b) => {
244
- const freeA = (a.maxmem ?? 0) - (a.mem ?? 0);
245
- const freeB = (b.maxmem ?? 0) - (b.mem ?? 0);
246
- return freeB - freeA;
247
- });
248
- node = onlineNodes[0].node;
249
- 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)`);
250
- }
251
- }
252
- catch (err) {
253
- // Fallback silently to configured nodes list or discovery
254
- }
255
- }
234
+ let node = this._node ?? await this.selectBestNode(pm);
256
235
  if (!node) {
257
236
  const configuredNodes = Config.get().providers.proxmox?.nodes;
258
237
  node = configuredNodes?.[0] ?? template?.node;
@@ -263,31 +242,38 @@ export class VMBuilder extends BaseBuilder {
263
242
  }
264
243
  if (!node)
265
244
  throw new Error("No Proxmox nodes available");
266
- const newVmid = await pm.get("/cluster/nextid");
267
245
  const storage = this._storage ?? "rbd_pool";
268
- if (template) {
269
- console.log(` šŸ“‹ Cloning template "${template.name}" (vmid=${template.vmid}) → "${this.name}" (vmid=${newVmid})`);
270
- const taskId = await pm.post(`/nodes/${template.node || node}/qemu/${template.vmid}/clone`, {
271
- newid: newVmid,
272
- name: this.name,
273
- full: 1,
274
- storage,
275
- format: "raw",
276
- target: node,
277
- });
278
- // Clone is async - wait for the Proxmox task to finish before configuring
279
- await this.waitForTask(template.node || node, taskId, pm);
280
- }
281
- else {
282
- console.log(` šŸ†• Creating blank VM "${this.name}" (vmid=${newVmid})`);
283
- await pm.post(`/nodes/${node}/qemu`, {
284
- vmid: newVmid,
285
- name: this.name,
286
- cores: this._cores,
287
- memory: this._memory,
288
- net0: `virtio,bridge=vmbr1${this._vlan ? `,tag=${this._vlan}` : ""}`,
289
- ostype: "l26",
290
- });
246
+ // Allocate VMID and immediately issue the create/clone request while holding the lock
247
+ // to prevent parallel VMs from claiming the same VMID.
248
+ const { newVmid, cloneTaskId, cloneNode } = await withVmidAllocation(async () => {
249
+ const vmid = await pm.get("/cluster/nextid");
250
+ if (template) {
251
+ console.log(` šŸ“‹ Cloning template "${template.name}" (vmid=${template.vmid}) → "${this.name}" (vmid=${vmid})`);
252
+ const taskId = await pm.post(`/nodes/${template.node || node}/qemu/${template.vmid}/clone`, {
253
+ newid: vmid,
254
+ name: this.name,
255
+ full: 1,
256
+ storage,
257
+ format: "raw",
258
+ target: node,
259
+ });
260
+ return { newVmid: vmid, cloneTaskId: taskId, cloneNode: template.node || node };
261
+ }
262
+ else {
263
+ console.log(` šŸ†• Creating blank VM "${this.name}" (vmid=${vmid})`);
264
+ await pm.post(`/nodes/${node}/qemu`, {
265
+ vmid,
266
+ name: this.name,
267
+ cores: this._cores,
268
+ memory: this._memory,
269
+ net0: `virtio,bridge=vmbr1${this._vlan ? `,tag=${this._vlan}` : ""}`,
270
+ ostype: "l26",
271
+ });
272
+ return { newVmid: vmid, cloneTaskId: null, cloneNode: null };
273
+ }
274
+ });
275
+ if (cloneTaskId && cloneNode) {
276
+ await this.waitForTask(cloneNode, cloneTaskId, pm);
291
277
  }
292
278
  this.resolvedVmid = newVmid;
293
279
  this.resolvedNode = node;
@@ -318,7 +304,7 @@ export class VMBuilder extends BaseBuilder {
318
304
  ipconfig0: this._ip
319
305
  ? (() => {
320
306
  const [addr, prefix = "24"] = this._ip.split("/");
321
- const gw = addr.split(".").slice(0, 3).join(".") + ".1";
307
+ const gw = this._gateway ?? (addr.split(".").slice(0, 3).join(".") + ".1");
322
308
  return `gw=${gw},ip=${addr}/${prefix}`;
323
309
  })()
324
310
  : "ip=dhcp",
@@ -356,8 +342,9 @@ export class VMBuilder extends BaseBuilder {
356
342
  return false;
357
343
  }
358
344
  }, { intervalMs: 10_000, timeoutMs: 300_000 });
359
- if (this.resolvedIp)
345
+ if (this.resolvedIp) {
360
346
  this.out.ip.resolve(this.resolvedIp);
347
+ }
361
348
  console.log(` 🌐 IP: ${this.resolvedIp}`);
362
349
  }
363
350
  this.registerHost();
@@ -413,13 +400,12 @@ export class VMBuilder extends BaseBuilder {
413
400
  }
414
401
  registerHost() {
415
402
  const context = resourceContextStorage.getStore();
416
- if (context && context.hosts) {
417
- const activeIp = this.resolvedIp ?? "0.0.0.0";
403
+ if (context && context.hosts && this.resolvedIp) {
418
404
  if (!context.hosts.some((h) => h.name === this.name)) {
419
405
  context.hosts.push({
420
406
  name: this.name,
421
- ip: activeIp,
422
- user: "root",
407
+ ip: this.resolvedIp,
408
+ user: this.resolveUser(),
423
409
  sshKey: this.sshKeyPath(),
424
410
  provider: "proxmox",
425
411
  });
@@ -442,19 +428,6 @@ export class VMBuilder extends BaseBuilder {
442
428
  await this.destroyVmByName(this.name, pm);
443
429
  return { destroyed: this.name };
444
430
  }
445
- // Poll a Proxmox task UPID until it exits, then throw if it failed
446
- async waitForTask(node, upid, pm) {
447
- const encoded = encodeURIComponent(upid);
448
- await this.waitFor(`clone task to complete`, async () => {
449
- const status = await pm.get(`/nodes/${node}/tasks/${encoded}/status`);
450
- if (status?.status !== "stopped")
451
- return false;
452
- if (status.exitstatus && status.exitstatus !== "OK") {
453
- throw new Error(`Clone task failed: ${status.exitstatus}`);
454
- }
455
- return true;
456
- }, { intervalMs: 5_000, timeoutMs: 300_000 });
457
- }
458
431
  async destroyVmByName(name, pm) {
459
432
  const resources = await pm.get("/cluster/resources?type=vm");
460
433
  const vm = (resources ?? []).find((r) => r.name === name && !r.template);
@@ -472,72 +445,4 @@ export class VMBuilder extends BaseBuilder {
472
445
  await pm.delete(`/nodes/${vm.node}/qemu/${vm.vmid}?purge=1&destroy-unreferenced-disks=1`);
473
446
  console.log(` šŸ—‘ļø Removed VM "${name}" (vmid=${vm.vmid})`);
474
447
  }
475
- resolvePublicKeys() {
476
- const input = this._sshKeys;
477
- if (!input) {
478
- // Default: read ~/.ssh/id_rsa.pub if it exists
479
- try {
480
- return [
481
- readFileSync(`${homedir()}/.ssh/id_ed25519.pub`, "utf-8").trim(),
482
- ];
483
- }
484
- catch {
485
- return [];
486
- }
487
- }
488
- if (Array.isArray(input))
489
- return input.map((k) => k.trim()).filter(Boolean);
490
- // Single string: key literal (starts with ssh-) or file path
491
- if (input.startsWith("ssh-") ||
492
- input.startsWith("ecdsa-") ||
493
- input.startsWith("sk-")) {
494
- return [input.trim()];
495
- }
496
- try {
497
- return [
498
- readFileSync(input.replace(/^~/, homedir()), "utf-8").trim(),
499
- ];
500
- }
501
- catch {
502
- return [];
503
- }
504
- }
505
- checkCloudInit(ip) {
506
- const keyPath = this.sshKeyPath();
507
- return new Promise((resolve) => {
508
- const proc = spawn("ssh", [
509
- "-i",
510
- keyPath,
511
- "-o",
512
- "StrictHostKeyChecking=no",
513
- "-o",
514
- "ConnectTimeout=10",
515
- "-o",
516
- "BatchMode=yes",
517
- `root@${ip}`,
518
- "cloud-init status",
519
- ], { stdio: ["ignore", "pipe", "ignore"] });
520
- let out = "";
521
- proc.stdout.on("data", (d) => (out += d.toString()));
522
- proc.on("close", () => resolve(out.includes("done") || out.includes("error")));
523
- proc.on("error", () => resolve(false));
524
- });
525
- }
526
- async checkPort(ip, port) {
527
- return checkPort(ip, port);
528
- }
529
- async runProvisioner(ip, script) {
530
- return runProvisioner(ip, "root", this._sshKeys, script);
531
- }
532
- sshKeyPath() {
533
- const keyInput = Array.isArray(this._sshKeys)
534
- ? null
535
- : this._sshKeys;
536
- return (keyInput &&
537
- !keyInput.startsWith("ssh-") &&
538
- !keyInput.startsWith("ecdsa-") &&
539
- !keyInput.startsWith("sk-")
540
- ? keyInput.replace(/\.pub$/, "")
541
- : `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
542
- }
543
448
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puls-dev",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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",
@@ -37,8 +37,12 @@
37
37
  "README.md",
38
38
  "LICENSE"
39
39
  ],
40
+ "bin": {
41
+ "puls": "dist/bin/puls.js"
42
+ },
40
43
  "scripts": {
41
44
  "build": "tsc",
45
+ "postbuild": "node -e \"const fs=require('fs'),f='dist/bin/puls.js',c=fs.readFileSync(f,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);\" && chmod +x dist/bin/puls.js",
42
46
  "prepublishOnly": "npm run build",
43
47
  "test": "tsx --test \"src/**/*.test.ts\""
44
48
  },
@@ -54,29 +58,10 @@
54
58
  "author": "Bia",
55
59
  "license": "ISC",
56
60
  "devDependencies": {
57
- "@aws-sdk/client-acm": "^3.1053.0",
58
- "@aws-sdk/client-apigatewayv2": "^3.1053.0",
59
- "@aws-sdk/client-cloudfront": "^3.1053.0",
60
- "@aws-sdk/client-cloudwatch": "^3.1053.0",
61
- "@aws-sdk/client-cloudwatch-logs": "^3.1053.0",
62
- "@aws-sdk/client-ec2": "^3.1053.0",
63
- "@aws-sdk/client-ecs": "^3.1053.0",
64
- "@aws-sdk/client-iam": "^3.1053.0",
65
- "@aws-sdk/client-lambda": "^3.1053.0",
66
- "@aws-sdk/client-rds": "^3.1053.0",
67
- "@aws-sdk/client-route-53": "^3.1053.0",
68
- "@aws-sdk/client-route-53-domains": "^3.1053.0",
69
- "@aws-sdk/client-s3": "^3.1053.0",
70
- "@aws-sdk/client-secrets-manager": "^3.1053.0",
71
- "@aws-sdk/client-sns": "^3.1053.0",
72
- "@aws-sdk/client-sqs": "^3.1053.0",
73
- "@aws-sdk/client-ssm": "^3.1053.0",
74
61
  "@types/node": "^25.6.2",
75
- "google-auth-library": "^10.6.2",
76
62
  "ts-node": "^10.9.2",
77
63
  "tsx": "^4.21.0",
78
- "typescript": "^6.0.3",
79
- "undici": "^8.3.0"
64
+ "typescript": "^6.0.3"
80
65
  },
81
66
  "dependencies": {
82
67
  "@aws-sdk/client-acm": "^3.1053.0",