puls-dev 0.3.3 ā 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.
- package/dist/bin/puls.d.ts +1 -0
- package/dist/bin/puls.js +123 -0
- package/dist/core/config.d.ts +3 -0
- package/dist/core/context.d.ts +1 -0
- package/dist/core/decorators.d.ts +1 -0
- package/dist/core/decorators.js +23 -5
- package/dist/core/output.js +8 -1
- package/dist/core/production.test.js +1 -0
- package/dist/core/secret.d.ts +1 -0
- package/dist/core/secret.js +5 -0
- package/dist/core/stack.js +54 -90
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/providers/do/droplet.d.ts +5 -0
- package/dist/providers/do/droplet.js +18 -3
- package/dist/providers/do/droplet.test.js +1 -1
- package/dist/providers/gcp/template.d.ts +3 -0
- package/dist/providers/gcp/template.js +13 -1
- package/dist/providers/gcp/vm.d.ts +3 -0
- package/dist/providers/gcp/vm.js +14 -2
- package/dist/providers/proxmox/api.d.ts +1 -0
- package/dist/providers/proxmox/api.js +18 -3
- package/dist/providers/proxmox/base.d.ts +16 -0
- package/dist/providers/proxmox/base.js +121 -0
- package/dist/providers/proxmox/template.d.ts +3 -10
- package/dist/providers/proxmox/template.js +51 -139
- package/dist/providers/proxmox/vm.d.ts +5 -10
- package/dist/providers/proxmox/vm.js +57 -152
- package/package.json +6 -21
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
this.
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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:
|
|
422
|
-
user:
|
|
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.
|
|
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",
|