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
package/dist/providers/gcp/vm.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { BaseBuilder } from "../../core/resource.js";
|
|
4
|
+
import { Config } from "../../core/config.js";
|
|
4
5
|
import { Output } from "../../core/output.js";
|
|
5
6
|
import { gcpFetch, getProjectId } from "./api.js";
|
|
6
7
|
import { checkPort, runProvisioner } from "../../core/provisioner.js";
|
|
@@ -17,6 +18,7 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
17
18
|
_zone = "us-central1-a";
|
|
18
19
|
_network = "global/networks/default";
|
|
19
20
|
_sshKeys = [];
|
|
21
|
+
_sshUser;
|
|
20
22
|
_provision = [];
|
|
21
23
|
_forceConfigCheck = false;
|
|
22
24
|
resolvedInstanceId;
|
|
@@ -51,6 +53,16 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
51
53
|
this._sshKeys = keys;
|
|
52
54
|
return this;
|
|
53
55
|
}
|
|
56
|
+
sshUser(user) {
|
|
57
|
+
this._sshUser = user;
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
resolveUser() {
|
|
61
|
+
return (this._sshUser ??
|
|
62
|
+
process.env.GCP_SSH_USER ??
|
|
63
|
+
Config.get().providers.gcp?.sshUser ??
|
|
64
|
+
"root");
|
|
65
|
+
}
|
|
54
66
|
provision(...playbookPaths) {
|
|
55
67
|
this._provision.push(...playbookPaths.flat());
|
|
56
68
|
return this;
|
|
@@ -68,7 +80,7 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
68
80
|
if (!keyPath) {
|
|
69
81
|
throw new Error(`[GCP VM:${this.name}] No SSH private key path found. Pass a file path via .sshKey() to run provisioning.`);
|
|
70
82
|
}
|
|
71
|
-
return runProvisioner(ip,
|
|
83
|
+
return runProvisioner(ip, this.resolveUser(), keyPath, script);
|
|
72
84
|
}
|
|
73
85
|
async discoverVM() {
|
|
74
86
|
try {
|
|
@@ -298,7 +310,7 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
298
310
|
context.hosts.push({
|
|
299
311
|
name: this.name,
|
|
300
312
|
ip: activeIp,
|
|
301
|
-
user:
|
|
313
|
+
user: this.resolveUser(),
|
|
302
314
|
sshKey: keyPath,
|
|
303
315
|
provider: "gcp"
|
|
304
316
|
});
|
|
@@ -10,4 +10,5 @@ export declare class ProxmoxApiClient {
|
|
|
10
10
|
put<T>(path: string, body?: unknown): Promise<T>;
|
|
11
11
|
delete(path: string): Promise<void>;
|
|
12
12
|
}
|
|
13
|
+
export declare function withVmidAllocation<T>(fn: () => Promise<T>): Promise<T>;
|
|
13
14
|
export declare function getPMClient(): ProxmoxApiClient;
|
|
@@ -75,9 +75,10 @@ export class ProxmoxApiClient {
|
|
|
75
75
|
return json.data ?? null;
|
|
76
76
|
}, {
|
|
77
77
|
retryable: (err) => {
|
|
78
|
-
|
|
78
|
+
// Match the 3-digit HTTP status that appears after ": " at the end of the path segment
|
|
79
|
+
const match = err.message.match(/:\s(\d{3})(?:\s|$)/);
|
|
79
80
|
const status = match ? parseInt(match[1], 10) : null;
|
|
80
|
-
return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
|
|
81
|
+
return status === 429 || (status !== null && status >= 500) || err.message.includes("ETIMEDOUT");
|
|
81
82
|
}
|
|
82
83
|
});
|
|
83
84
|
}
|
|
@@ -94,11 +95,25 @@ export class ProxmoxApiClient {
|
|
|
94
95
|
await this.request("DELETE", path);
|
|
95
96
|
}
|
|
96
97
|
}
|
|
98
|
+
let _vmidLock = Promise.resolve();
|
|
99
|
+
export async function withVmidAllocation(fn) {
|
|
100
|
+
let release;
|
|
101
|
+
const acquired = new Promise(r => { release = r; });
|
|
102
|
+
const prev = _vmidLock;
|
|
103
|
+
_vmidLock = prev.then(() => acquired);
|
|
104
|
+
await prev;
|
|
105
|
+
try {
|
|
106
|
+
return await fn();
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
release();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
97
112
|
export function getPMClient() {
|
|
98
113
|
const cfg = Config.get().providers.proxmox;
|
|
99
114
|
if (!cfg?.url || !cfg?.user || !cfg?.tokenName || !cfg?.tokenSecret) {
|
|
100
115
|
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
101
|
-
return new ProxmoxApiClient("https://
|
|
116
|
+
return new ProxmoxApiClient("https://proxmox.invalid:8006", "mock-user@pve", "mock-token-name", "mock-token-secret", false);
|
|
102
117
|
}
|
|
103
118
|
throw new Error("Proxmox not configured. Set proxmox: { url, user, tokenName, tokenSecret } in @Deploy");
|
|
104
119
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import type { ProxmoxApiClient } from "./api.js";
|
|
3
|
+
export declare abstract class ProxmoxBaseBuilder extends BaseBuilder {
|
|
4
|
+
protected _sshKeys?: string | string[];
|
|
5
|
+
protected _sshUser?: string;
|
|
6
|
+
sshKey(keys: string | readonly string[]): this;
|
|
7
|
+
sshUser(user: string): this;
|
|
8
|
+
protected resolveUser(): string;
|
|
9
|
+
protected selectBestNode(pm: ProxmoxApiClient): Promise<string | undefined>;
|
|
10
|
+
protected waitForTask(node: string, upid: string, pm: ProxmoxApiClient): Promise<void>;
|
|
11
|
+
protected resolvePublicKeys(): string[];
|
|
12
|
+
protected sshKeyPath(): string;
|
|
13
|
+
protected checkCloudInit(ip: string): Promise<boolean>;
|
|
14
|
+
protected checkPort(ip: string, port: number): Promise<boolean>;
|
|
15
|
+
protected runProvisioner(ip: string, script: string): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
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";
|
|
5
|
+
import { Config } from "../../core/config.js";
|
|
6
|
+
import { checkPort, runProvisioner } from "../../core/provisioner.js";
|
|
7
|
+
export class ProxmoxBaseBuilder extends BaseBuilder {
|
|
8
|
+
_sshKeys;
|
|
9
|
+
_sshUser;
|
|
10
|
+
sshKey(keys) {
|
|
11
|
+
this._sshKeys = Array.isArray(keys) ? [...keys] : keys;
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
sshUser(user) {
|
|
15
|
+
this._sshUser = user;
|
|
16
|
+
return this;
|
|
17
|
+
}
|
|
18
|
+
resolveUser() {
|
|
19
|
+
return (this._sshUser ??
|
|
20
|
+
process.env.PROXMOX_SSH_USER ??
|
|
21
|
+
Config.get().providers.proxmox?.sshUser ??
|
|
22
|
+
"root");
|
|
23
|
+
}
|
|
24
|
+
async selectBestNode(pm) {
|
|
25
|
+
try {
|
|
26
|
+
const nodesList = await pm.get("/nodes");
|
|
27
|
+
const configuredNodes = Config.get().providers.proxmox?.nodes;
|
|
28
|
+
const onlineNodes = (nodesList ?? []).filter((n) => {
|
|
29
|
+
if (n.status !== "online")
|
|
30
|
+
return false;
|
|
31
|
+
if (configuredNodes && configuredNodes.length > 0) {
|
|
32
|
+
return configuredNodes.includes(n.node);
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
if (onlineNodes.length > 0) {
|
|
37
|
+
onlineNodes.sort((a, b) => {
|
|
38
|
+
const freeA = (a.maxmem ?? 0) - (a.mem ?? 0);
|
|
39
|
+
const freeB = (b.maxmem ?? 0) - (b.mem ?? 0);
|
|
40
|
+
return freeB - freeA;
|
|
41
|
+
});
|
|
42
|
+
const best = onlineNodes[0];
|
|
43
|
+
const freeGb = Math.round((((best.maxmem ?? 0) - (best.mem ?? 0)) / 1024 / 1024 / 1024) * 10) / 10;
|
|
44
|
+
console.log(` 🧠 Cluster-aware node selection: picked "${best.node}" with the most free RAM (${freeGb} GB free)`);
|
|
45
|
+
return best.node;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Fallback to configured nodes or template node
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
async waitForTask(node, upid, pm) {
|
|
54
|
+
const encoded = encodeURIComponent(upid);
|
|
55
|
+
await this.waitFor(`clone task to complete`, async () => {
|
|
56
|
+
const status = await pm.get(`/nodes/${node}/tasks/${encoded}/status`);
|
|
57
|
+
if (status?.status !== "stopped")
|
|
58
|
+
return false;
|
|
59
|
+
if (status.exitstatus && status.exitstatus !== "OK") {
|
|
60
|
+
throw new Error(`Clone task failed: ${status.exitstatus}`);
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}, { intervalMs: 5_000, timeoutMs: 300_000 });
|
|
64
|
+
}
|
|
65
|
+
resolvePublicKeys() {
|
|
66
|
+
const input = this._sshKeys;
|
|
67
|
+
if (!input) {
|
|
68
|
+
try {
|
|
69
|
+
return [readFileSync(`${homedir()}/.ssh/id_ed25519.pub`, "utf-8").trim()];
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(input))
|
|
76
|
+
return input.map((k) => k.trim()).filter(Boolean);
|
|
77
|
+
if (input.startsWith("ssh-") ||
|
|
78
|
+
input.startsWith("ecdsa-") ||
|
|
79
|
+
input.startsWith("sk-")) {
|
|
80
|
+
return [input.trim()];
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return [readFileSync(input.replace(/^~/, homedir()), "utf-8").trim()];
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
sshKeyPath() {
|
|
90
|
+
const keyInput = Array.isArray(this._sshKeys) ? null : this._sshKeys;
|
|
91
|
+
return (keyInput &&
|
|
92
|
+
!keyInput.startsWith("ssh-") &&
|
|
93
|
+
!keyInput.startsWith("ecdsa-") &&
|
|
94
|
+
!keyInput.startsWith("sk-")
|
|
95
|
+
? keyInput.replace(/\.pub$/, "")
|
|
96
|
+
: `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
|
|
97
|
+
}
|
|
98
|
+
checkCloudInit(ip) {
|
|
99
|
+
const keyPath = this.sshKeyPath();
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
const proc = spawn("ssh", [
|
|
102
|
+
"-i", keyPath,
|
|
103
|
+
"-o", "StrictHostKeyChecking=no",
|
|
104
|
+
"-o", "ConnectTimeout=10",
|
|
105
|
+
"-o", "BatchMode=yes",
|
|
106
|
+
`root@${ip}`,
|
|
107
|
+
"cloud-init status",
|
|
108
|
+
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
109
|
+
let out = "";
|
|
110
|
+
proc.stdout.on("data", (d) => (out += d.toString()));
|
|
111
|
+
proc.on("close", () => resolve(out.includes("done") || out.includes("error")));
|
|
112
|
+
proc.on("error", () => resolve(false));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async checkPort(ip, port) {
|
|
116
|
+
return checkPort(ip, port);
|
|
117
|
+
}
|
|
118
|
+
async runProvisioner(ip, script) {
|
|
119
|
+
return runProvisioner(ip, this.resolveUser(), this._sshKeys, script);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ProxmoxBaseBuilder } from "./base.js";
|
|
2
2
|
import { Output } from "../../core/output.js";
|
|
3
3
|
import type { OSImage } from "../../types/proxmox.js";
|
|
4
|
-
export declare class TemplateBuilder extends
|
|
4
|
+
export declare class TemplateBuilder extends ProxmoxBaseBuilder {
|
|
5
5
|
readonly out: {
|
|
6
6
|
vmid: Output<number>;
|
|
7
7
|
};
|
|
@@ -12,14 +12,12 @@ export declare class TemplateBuilder extends BaseBuilder {
|
|
|
12
12
|
private _memory;
|
|
13
13
|
private _provision;
|
|
14
14
|
private _storage?;
|
|
15
|
-
private _sshKeys?;
|
|
16
15
|
constructor(name: string);
|
|
17
16
|
private discoverTemplate;
|
|
18
17
|
baseImage(os: OSImage): this;
|
|
19
18
|
cores(n: number): this;
|
|
20
19
|
memory(mb: number): this;
|
|
21
20
|
storage(pool: string): this;
|
|
22
|
-
sshKey(keys: string | readonly string[]): this;
|
|
23
21
|
provision(...playbookPaths: (string | string[])[]): this;
|
|
24
22
|
deploy(): Promise<{
|
|
25
23
|
name: string;
|
|
@@ -30,15 +28,10 @@ export declare class TemplateBuilder extends BaseBuilder {
|
|
|
30
28
|
vmid: string;
|
|
31
29
|
node?: undefined;
|
|
32
30
|
}>;
|
|
31
|
+
private _deploy;
|
|
33
32
|
destroy(): Promise<{
|
|
34
33
|
destroyed: boolean;
|
|
35
34
|
} | {
|
|
36
35
|
destroyed: string;
|
|
37
36
|
}>;
|
|
38
|
-
private waitForTask;
|
|
39
|
-
private resolvePublicKeys;
|
|
40
|
-
private checkCloudInit;
|
|
41
|
-
protected checkPort(ip: string, port: number): Promise<boolean>;
|
|
42
|
-
protected runProvisioner(ip: string, script: string): Promise<void>;
|
|
43
|
-
private sshKeyPath;
|
|
44
37
|
}
|
|
@@ -1,13 +1,9 @@
|
|
|
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
|
-
|
|
10
|
-
export class TemplateBuilder extends BaseBuilder {
|
|
6
|
+
export class TemplateBuilder extends ProxmoxBaseBuilder {
|
|
11
7
|
out = {
|
|
12
8
|
vmid: new Output(),
|
|
13
9
|
};
|
|
@@ -18,7 +14,6 @@ export class TemplateBuilder extends BaseBuilder {
|
|
|
18
14
|
_memory = 2048;
|
|
19
15
|
_provision = [];
|
|
20
16
|
_storage;
|
|
21
|
-
_sshKeys;
|
|
22
17
|
constructor(name) {
|
|
23
18
|
super(name);
|
|
24
19
|
this.discoveryPromise = this.discoverTemplate(name);
|
|
@@ -61,22 +56,24 @@ export class TemplateBuilder extends BaseBuilder {
|
|
|
61
56
|
this._storage = pool;
|
|
62
57
|
return this;
|
|
63
58
|
}
|
|
64
|
-
sshKey(keys) {
|
|
65
|
-
this._sshKeys = Array.isArray(keys) ? [...keys] : keys;
|
|
66
|
-
return this;
|
|
67
|
-
}
|
|
68
59
|
provision(...playbookPaths) {
|
|
69
60
|
this._provision.push(...playbookPaths.flat());
|
|
70
61
|
return this;
|
|
71
62
|
}
|
|
72
63
|
async deploy() {
|
|
64
|
+
try {
|
|
65
|
+
return await this._deploy();
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
this.out.vmid.reject(err);
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async _deploy() {
|
|
73
73
|
const dryRun = this.isDryRunActive();
|
|
74
74
|
const existing = await this.discoveryPromise;
|
|
75
75
|
const pm = getPMClient();
|
|
76
76
|
if (existing) {
|
|
77
|
-
this.resolvedVmid = existing.vmid;
|
|
78
|
-
this.resolvedNode = existing.node;
|
|
79
|
-
this.out.vmid.resolve(existing.vmid);
|
|
80
77
|
// Check if playbook hashes differ
|
|
81
78
|
const appliedHashes = parseProvisionMetadata(existing.description ?? "");
|
|
82
79
|
const declaredPlaybooksWithHashes = this._provision.map((p) => {
|
|
@@ -88,6 +85,9 @@ export class TemplateBuilder extends BaseBuilder {
|
|
|
88
85
|
return !appliedHash || appliedHash !== p.hash;
|
|
89
86
|
});
|
|
90
87
|
if (!hasChanges) {
|
|
88
|
+
this.resolvedVmid = existing.vmid;
|
|
89
|
+
this.resolvedNode = existing.node;
|
|
90
|
+
this.out.vmid.resolve(existing.vmid);
|
|
91
91
|
console.log(`\n🖥️ Finalizing Proxmox Template "${this.name}"...`);
|
|
92
92
|
console.log(` ✅ Template "${this.name}" already exists and matches defined state.`);
|
|
93
93
|
return { name: this.name, vmid: this.resolvedVmid, node: this.resolvedNode };
|
|
@@ -96,11 +96,11 @@ export class TemplateBuilder extends BaseBuilder {
|
|
|
96
96
|
console.log(` 🔄 Template playbook hashes changed. Purging old template...`);
|
|
97
97
|
if (dryRun) {
|
|
98
98
|
console.log(` 📝 [PLAN] Would purge template "${this.name}" (vmid=${existing.vmid}) and rebuild.`);
|
|
99
|
+
this.out.vmid.resolve(-1);
|
|
99
100
|
return { name: this.name, vmid: "PENDING" };
|
|
100
101
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
102
|
+
await pm.delete(`/nodes/${existing.node}/qemu/${existing.vmid}?purge=1&destroy-unreferenced-disks=1`);
|
|
103
|
+
// Fall through to rebuild
|
|
104
104
|
}
|
|
105
105
|
console.log(`\n🖥️ Finalizing Proxmox Template "${this.name}"...`);
|
|
106
106
|
if (dryRun) {
|
|
@@ -115,31 +115,7 @@ export class TemplateBuilder extends BaseBuilder {
|
|
|
115
115
|
return { name: this.name, vmid: "PENDING" };
|
|
116
116
|
}
|
|
117
117
|
// Pick target node (cluster-aware)
|
|
118
|
-
let node;
|
|
119
|
-
try {
|
|
120
|
-
const nodesList = await pm.get("/nodes");
|
|
121
|
-
const configuredNodes = Config.get().providers.proxmox?.nodes;
|
|
122
|
-
const onlineNodes = (nodesList ?? []).filter((n) => {
|
|
123
|
-
if (n.status !== "online")
|
|
124
|
-
return false;
|
|
125
|
-
if (configuredNodes && configuredNodes.length > 0) {
|
|
126
|
-
return configuredNodes.includes(n.node);
|
|
127
|
-
}
|
|
128
|
-
return true;
|
|
129
|
-
});
|
|
130
|
-
if (onlineNodes.length > 0) {
|
|
131
|
-
onlineNodes.sort((a, b) => {
|
|
132
|
-
const freeA = (a.maxmem ?? 0) - (a.mem ?? 0);
|
|
133
|
-
const freeB = (b.maxmem ?? 0) - (b.mem ?? 0);
|
|
134
|
-
return freeB - freeA;
|
|
135
|
-
});
|
|
136
|
-
node = onlineNodes[0].node;
|
|
137
|
-
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)`);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
catch (err) {
|
|
141
|
-
// Fallback
|
|
142
|
-
}
|
|
118
|
+
let node = await this.selectBestNode(pm);
|
|
143
119
|
if (!node) {
|
|
144
120
|
const configuredNodes = Config.get().providers.proxmox?.nodes;
|
|
145
121
|
node = configuredNodes?.[0];
|
|
@@ -150,8 +126,6 @@ export class TemplateBuilder extends BaseBuilder {
|
|
|
150
126
|
}
|
|
151
127
|
if (!node)
|
|
152
128
|
throw new Error("No Proxmox nodes available");
|
|
153
|
-
const newVmid = await pm.get("/cluster/nextid");
|
|
154
|
-
const storage = this._storage ?? "rbd_pool";
|
|
155
129
|
// Lookup base image template
|
|
156
130
|
const resources = await pm.get("/cluster/resources?type=vm");
|
|
157
131
|
const isVmid = this._baseImage && /^\d+$/.test(this._baseImage);
|
|
@@ -164,28 +138,38 @@ export class TemplateBuilder extends BaseBuilder {
|
|
|
164
138
|
if (this._baseImage && !baseTemplate) {
|
|
165
139
|
throw new Error(`No Proxmox base template found matching "${this._baseImage}".`);
|
|
166
140
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
vmid:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
141
|
+
const storage = this._storage ?? "rbd_pool";
|
|
142
|
+
// Allocate VMID and immediately issue the create/clone request while holding the lock
|
|
143
|
+
// to prevent parallel templates from claiming the same VMID.
|
|
144
|
+
const { newVmid, cloneTaskId, cloneNode } = await withVmidAllocation(async () => {
|
|
145
|
+
const vmid = await pm.get("/cluster/nextid");
|
|
146
|
+
if (baseTemplate) {
|
|
147
|
+
console.log(` 📋 Cloning base template "${baseTemplate.name}" (vmid=${baseTemplate.vmid}) → "${this.name}" (vmid=${vmid})`);
|
|
148
|
+
const taskId = await pm.post(`/nodes/${baseTemplate.node || node}/qemu/${baseTemplate.vmid}/clone`, {
|
|
149
|
+
newid: vmid,
|
|
150
|
+
name: this.name,
|
|
151
|
+
full: 1,
|
|
152
|
+
storage,
|
|
153
|
+
format: "raw",
|
|
154
|
+
target: node,
|
|
155
|
+
});
|
|
156
|
+
return { newVmid: vmid, cloneTaskId: taskId, cloneNode: baseTemplate.node || node };
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
console.log(` 🆕 Creating blank VM "${this.name}" (vmid=${vmid})`);
|
|
160
|
+
await pm.post(`/nodes/${node}/qemu`, {
|
|
161
|
+
vmid,
|
|
162
|
+
name: this.name,
|
|
163
|
+
cores: this._cores,
|
|
164
|
+
memory: this._memory,
|
|
165
|
+
net0: "virtio,bridge=vmbr1",
|
|
166
|
+
ostype: "l26",
|
|
167
|
+
});
|
|
168
|
+
return { newVmid: vmid, cloneTaskId: null, cloneNode: null };
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
if (cloneTaskId && cloneNode) {
|
|
172
|
+
await this.waitForTask(cloneNode, cloneTaskId, pm);
|
|
189
173
|
}
|
|
190
174
|
this.resolvedVmid = newVmid;
|
|
191
175
|
this.resolvedNode = node;
|
|
@@ -275,76 +259,4 @@ export class TemplateBuilder extends BaseBuilder {
|
|
|
275
259
|
console.log(` 🗑️ Removed Template "${this.name}" (vmid=${existing.vmid})`);
|
|
276
260
|
return { destroyed: this.name };
|
|
277
261
|
}
|
|
278
|
-
async waitForTask(node, upid, pm) {
|
|
279
|
-
const encoded = encodeURIComponent(upid);
|
|
280
|
-
await this.waitFor(`clone task to complete`, async () => {
|
|
281
|
-
const status = await pm.get(`/nodes/${node}/tasks/${encoded}/status`);
|
|
282
|
-
if (status?.status !== "stopped")
|
|
283
|
-
return false;
|
|
284
|
-
if (status.exitstatus && status.exitstatus !== "OK") {
|
|
285
|
-
throw new Error(`Clone task failed: ${status.exitstatus}`);
|
|
286
|
-
}
|
|
287
|
-
return true;
|
|
288
|
-
}, { intervalMs: 5_000, timeoutMs: 300_000 });
|
|
289
|
-
}
|
|
290
|
-
resolvePublicKeys() {
|
|
291
|
-
const input = this._sshKeys;
|
|
292
|
-
if (!input) {
|
|
293
|
-
try {
|
|
294
|
-
return [readFileSync(`${homedir()}/.ssh/id_ed25519.pub`, "utf-8").trim()];
|
|
295
|
-
}
|
|
296
|
-
catch {
|
|
297
|
-
return [];
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
if (Array.isArray(input))
|
|
301
|
-
return input.map((k) => k.trim()).filter(Boolean);
|
|
302
|
-
if (input.startsWith("ssh-") ||
|
|
303
|
-
input.startsWith("ecdsa-") ||
|
|
304
|
-
input.startsWith("sk-")) {
|
|
305
|
-
return [input.trim()];
|
|
306
|
-
}
|
|
307
|
-
try {
|
|
308
|
-
return [readFileSync(input.replace(/^~/, homedir()), "utf-8").trim()];
|
|
309
|
-
}
|
|
310
|
-
catch {
|
|
311
|
-
return [];
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
checkCloudInit(ip) {
|
|
315
|
-
const keyPath = this.sshKeyPath();
|
|
316
|
-
return new Promise((resolve) => {
|
|
317
|
-
const proc = spawn("ssh", [
|
|
318
|
-
"-i",
|
|
319
|
-
keyPath,
|
|
320
|
-
"-o",
|
|
321
|
-
"StrictHostKeyChecking=no",
|
|
322
|
-
"-o",
|
|
323
|
-
"ConnectTimeout=10",
|
|
324
|
-
"-o",
|
|
325
|
-
"BatchMode=yes",
|
|
326
|
-
`root@${ip}`,
|
|
327
|
-
"cloud-init status",
|
|
328
|
-
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
329
|
-
let out = "";
|
|
330
|
-
proc.stdout.on("data", (d) => (out += d.toString()));
|
|
331
|
-
proc.on("close", () => resolve(out.includes("done") || out.includes("error")));
|
|
332
|
-
proc.on("error", () => resolve(false));
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
async checkPort(ip, port) {
|
|
336
|
-
return checkPort(ip, port);
|
|
337
|
-
}
|
|
338
|
-
async runProvisioner(ip, script) {
|
|
339
|
-
return runProvisioner(ip, "root", this._sshKeys, script);
|
|
340
|
-
}
|
|
341
|
-
sshKeyPath() {
|
|
342
|
-
const keyInput = Array.isArray(this._sshKeys) ? null : this._sshKeys;
|
|
343
|
-
return (keyInput &&
|
|
344
|
-
!keyInput.startsWith("ssh-") &&
|
|
345
|
-
!keyInput.startsWith("ecdsa-") &&
|
|
346
|
-
!keyInput.startsWith("sk-")
|
|
347
|
-
? keyInput.replace(/\.pub$/, "")
|
|
348
|
-
: `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
|
|
349
|
-
}
|
|
350
262
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ProxmoxBaseBuilder } from "./base.js";
|
|
2
2
|
import { Output } from "../../core/output.js";
|
|
3
3
|
import type { OSImage } from "../../types/proxmox.js";
|
|
4
4
|
import { TemplateBuilder } from "./template.js";
|
|
5
|
-
export declare class VMBuilder extends
|
|
5
|
+
export declare class VMBuilder extends ProxmoxBaseBuilder {
|
|
6
6
|
readonly out: {
|
|
7
7
|
ip: Output<string>;
|
|
8
8
|
vmid: Output<number>;
|
|
@@ -20,7 +20,7 @@ export declare class VMBuilder extends BaseBuilder {
|
|
|
20
20
|
private _storage?;
|
|
21
21
|
private _vlan?;
|
|
22
22
|
private _ip?;
|
|
23
|
-
private
|
|
23
|
+
private _gateway?;
|
|
24
24
|
private _machine;
|
|
25
25
|
private _forceConfigCheck;
|
|
26
26
|
constructor(name: string);
|
|
@@ -35,7 +35,7 @@ export declare class VMBuilder extends BaseBuilder {
|
|
|
35
35
|
storage(pool: string): this;
|
|
36
36
|
vlan(tag: number): this;
|
|
37
37
|
ip(address: string): this;
|
|
38
|
-
|
|
38
|
+
gateway(gw: string): this;
|
|
39
39
|
machine(type: "q35" | "i440fx"): this;
|
|
40
40
|
forceConfigCheck(): this;
|
|
41
41
|
deploy(): Promise<{
|
|
@@ -54,14 +54,9 @@ export declare class VMBuilder extends BaseBuilder {
|
|
|
54
54
|
ip: string | null;
|
|
55
55
|
node?: undefined;
|
|
56
56
|
}>;
|
|
57
|
+
private _deploy;
|
|
57
58
|
private resolveExistingIp;
|
|
58
59
|
private registerHost;
|
|
59
60
|
destroy(): Promise<any>;
|
|
60
|
-
private waitForTask;
|
|
61
61
|
private destroyVmByName;
|
|
62
|
-
private resolvePublicKeys;
|
|
63
|
-
private checkCloudInit;
|
|
64
|
-
protected checkPort(ip: string, port: number): Promise<boolean>;
|
|
65
|
-
protected runProvisioner(ip: string, script: string): Promise<void>;
|
|
66
|
-
private sshKeyPath;
|
|
67
62
|
}
|