puls-dev 0.2.0 → 0.2.2
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 +1 -1
- package/dist/core/config.d.ts +5 -0
- package/dist/providers/aws/api.d.ts +4 -0
- package/dist/providers/aws/api.js +4 -0
- package/dist/providers/aws/cloudwatch.d.ts +44 -0
- package/dist/providers/aws/cloudwatch.js +205 -0
- package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
- package/dist/providers/aws/cloudwatch.test.js +224 -0
- package/dist/providers/aws/fargate.d.ts +2 -0
- package/dist/providers/aws/fargate.js +6 -0
- package/dist/providers/aws/iam.d.ts +52 -0
- package/dist/providers/aws/iam.js +307 -0
- package/dist/providers/aws/iam.test.d.ts +1 -0
- package/dist/providers/aws/iam.test.js +367 -0
- package/dist/providers/aws/index.d.ts +7 -0
- package/dist/providers/aws/index.js +7 -0
- package/dist/providers/aws/lambda.d.ts +3 -1
- package/dist/providers/aws/lambda.js +11 -2
- package/dist/providers/aws/rds.d.ts +1 -0
- package/dist/providers/aws/rds.js +4 -1
- package/dist/providers/aws/sns.d.ts +22 -0
- package/dist/providers/aws/sns.js +146 -0
- package/dist/providers/aws/sns.test.d.ts +1 -0
- package/dist/providers/aws/sns.test.js +162 -0
- package/dist/providers/firebase/appcheck.d.ts +15 -0
- package/dist/providers/firebase/appcheck.js +109 -0
- package/dist/providers/firebase/appcheck.test.d.ts +1 -0
- package/dist/providers/firebase/appcheck.test.js +141 -0
- package/dist/providers/firebase/index.d.ts +2 -0
- package/dist/providers/firebase/index.js +2 -0
- package/dist/providers/gcp/api.d.ts +10 -0
- package/dist/providers/gcp/api.js +111 -0
- package/dist/providers/gcp/clouddns.d.ts +37 -0
- package/dist/providers/gcp/clouddns.js +284 -0
- package/dist/providers/gcp/clouddns.test.d.ts +1 -0
- package/dist/providers/gcp/clouddns.test.js +259 -0
- package/dist/providers/gcp/cloudrun.d.ts +31 -0
- package/dist/providers/gcp/cloudrun.js +240 -0
- package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
- package/dist/providers/gcp/cloudrun.test.js +281 -0
- package/dist/providers/gcp/cloudsql.d.ts +37 -0
- package/dist/providers/gcp/cloudsql.js +262 -0
- package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
- package/dist/providers/gcp/cloudsql.test.js +295 -0
- package/dist/providers/gcp/iam.d.ts +38 -0
- package/dist/providers/gcp/iam.js +309 -0
- package/dist/providers/gcp/iam.test.d.ts +1 -0
- package/dist/providers/gcp/iam.test.js +305 -0
- package/dist/providers/gcp/index.d.ts +19 -0
- package/dist/providers/gcp/index.js +19 -0
- package/dist/providers/gcp/pubsub.d.ts +31 -0
- package/dist/providers/gcp/pubsub.js +227 -0
- package/dist/providers/gcp/pubsub.test.d.ts +1 -0
- package/dist/providers/gcp/pubsub.test.js +244 -0
- package/dist/providers/gcp/secrets.d.ts +21 -0
- package/dist/providers/gcp/secrets.js +187 -0
- package/dist/providers/gcp/secrets.test.d.ts +1 -0
- package/dist/providers/gcp/secrets.test.js +264 -0
- package/dist/providers/proxmox/vm.d.ts +2 -0
- package/dist/providers/proxmox/vm.js +35 -3
- package/dist/providers/proxmox/vm.test.d.ts +1 -0
- package/dist/providers/proxmox/vm.test.js +155 -0
- package/dist/types/aws.d.ts +11 -0
- package/package.json +32 -2
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { GoogleAuth } from "google-auth-library";
|
|
4
|
+
import { GCPSecretBuilder } from "./secrets.js";
|
|
5
|
+
import { GCPCloudRunBuilder } from "./cloudrun.js";
|
|
6
|
+
import { Config } from "../../core/config.js";
|
|
7
|
+
describe("GCPSecretBuilder Unit Tests", () => {
|
|
8
|
+
let originalFetch;
|
|
9
|
+
let fetchCalls = [];
|
|
10
|
+
let mockResponses = {};
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
Config.set({
|
|
13
|
+
dryRun: false,
|
|
14
|
+
providers: {
|
|
15
|
+
gcp: {
|
|
16
|
+
projectId: "my-gcp-project",
|
|
17
|
+
serviceAccountPath: "/fake/sa.json",
|
|
18
|
+
region: "us-central1",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
originalFetch = globalThis.fetch;
|
|
23
|
+
fetchCalls = [];
|
|
24
|
+
mockResponses = {};
|
|
25
|
+
globalThis.fetch = async (input, init) => {
|
|
26
|
+
const url = String(input);
|
|
27
|
+
const method = init?.method ?? "GET";
|
|
28
|
+
let body;
|
|
29
|
+
if (init?.body) {
|
|
30
|
+
if (typeof init.body === "string") {
|
|
31
|
+
try {
|
|
32
|
+
body = JSON.parse(init.body);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
body = init.body;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
body = "[Binary/Buffer Body]";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const headers = init?.headers;
|
|
43
|
+
fetchCalls.push({ url, method, body, headers });
|
|
44
|
+
const matchKey = Object.keys(mockResponses)
|
|
45
|
+
.filter((key) => {
|
|
46
|
+
const [mMethod, mPath] = key.split(" ");
|
|
47
|
+
return method === mMethod && url.includes(mPath);
|
|
48
|
+
})
|
|
49
|
+
.sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
|
|
50
|
+
if (matchKey) {
|
|
51
|
+
const resp = mockResponses[matchKey];
|
|
52
|
+
return {
|
|
53
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
54
|
+
status: resp.status,
|
|
55
|
+
json: async () => resp.body,
|
|
56
|
+
text: async () => JSON.stringify(resp.body),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
status: 404,
|
|
62
|
+
json: async () => ({ message: `Endpoint not mocked: ${method} ${url}` }),
|
|
63
|
+
text: async () => `Endpoint not mocked: ${method} ${url}`,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
mock.method(GoogleAuth.prototype, "getClient", async () => {
|
|
67
|
+
return {
|
|
68
|
+
getAccessToken: async () => ({ token: "fake-gcp-token" }),
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
globalThis.fetch = originalFetch;
|
|
74
|
+
mock.restoreAll();
|
|
75
|
+
});
|
|
76
|
+
test("fluent builder api sets properties and resolves values correctly", async () => {
|
|
77
|
+
mockResponses["GET /secrets/fluent-sec"] = {
|
|
78
|
+
status: 200,
|
|
79
|
+
body: { name: "projects/my-gcp-project/secrets/fluent-sec" },
|
|
80
|
+
};
|
|
81
|
+
mockResponses["GET /secrets/fluent-sec/versions/latest:access"] = {
|
|
82
|
+
status: 200,
|
|
83
|
+
body: { payload: { data: Buffer.from("super-secret-text").toString("base64") } },
|
|
84
|
+
};
|
|
85
|
+
const builder = new GCPSecretBuilder("fluent-sec")
|
|
86
|
+
.plainText("super-secret-text");
|
|
87
|
+
assert.strictEqual(builder._value, "super-secret-text");
|
|
88
|
+
const val = await builder.awaitValue();
|
|
89
|
+
assert.strictEqual(val, "super-secret-text");
|
|
90
|
+
assert.strictEqual(builder.resolvedValue, "super-secret-text");
|
|
91
|
+
});
|
|
92
|
+
test("keyValue helper serializes object correctly", () => {
|
|
93
|
+
const builder = new GCPSecretBuilder("kv-sec")
|
|
94
|
+
.keyValue({ apiKey: "12345", dbPass: "secret" });
|
|
95
|
+
assert.strictEqual(builder._value, JSON.stringify({ apiKey: "12345", dbPass: "secret" }));
|
|
96
|
+
});
|
|
97
|
+
test("runs in dry-run mode safely and logs plans", async () => {
|
|
98
|
+
Config.set({
|
|
99
|
+
dryRun: true,
|
|
100
|
+
providers: {
|
|
101
|
+
gcp: {
|
|
102
|
+
projectId: "my-gcp-project",
|
|
103
|
+
serviceAccountPath: "/fake/sa.json",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
mockResponses["GET /secrets/dry-run-sec"] = {
|
|
108
|
+
status: 404,
|
|
109
|
+
body: { message: "Not found" },
|
|
110
|
+
};
|
|
111
|
+
const builder = new GCPSecretBuilder("dry-run-sec")
|
|
112
|
+
.plainText("my-planned-value");
|
|
113
|
+
const result = await builder.deploy();
|
|
114
|
+
assert.strictEqual(result.name, "dry-run-sec");
|
|
115
|
+
assert.strictEqual(result.value, "my-planned-value");
|
|
116
|
+
// No write calls should be sent in dry-run mode
|
|
117
|
+
const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
|
|
118
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
119
|
+
});
|
|
120
|
+
test("creates a new secret when missing", async () => {
|
|
121
|
+
// 1. Mock GET returned 404 (discovery metadata)
|
|
122
|
+
mockResponses["GET /secrets/new-sec"] = {
|
|
123
|
+
status: 404,
|
|
124
|
+
body: { message: "Not found" },
|
|
125
|
+
};
|
|
126
|
+
// 2. Mock POST (create secret container)
|
|
127
|
+
mockResponses["POST /secrets?secretId=new-sec"] = {
|
|
128
|
+
status: 200,
|
|
129
|
+
body: { name: "projects/my-gcp-project/secrets/new-sec" },
|
|
130
|
+
};
|
|
131
|
+
// 3. Mock POST (add version)
|
|
132
|
+
mockResponses["POST /secrets/new-sec:addVersion"] = {
|
|
133
|
+
status: 200,
|
|
134
|
+
body: { name: "projects/my-gcp-project/secrets/new-sec/versions/1" },
|
|
135
|
+
};
|
|
136
|
+
const builder = new GCPSecretBuilder("new-sec")
|
|
137
|
+
.plainText("my-new-secret-value");
|
|
138
|
+
const result = await builder.deploy();
|
|
139
|
+
assert.strictEqual(result.name, "new-sec");
|
|
140
|
+
assert.strictEqual(result.value, "my-new-secret-value");
|
|
141
|
+
assert.strictEqual(result.arn, "projects/my-gcp-project/secrets/new-sec");
|
|
142
|
+
// Verify correct calls were made
|
|
143
|
+
const postSecret = fetchCalls.find((c) => c.method === "POST" && c.url.includes("secretId=new-sec"));
|
|
144
|
+
assert.ok(postSecret);
|
|
145
|
+
const postVersion = fetchCalls.find((c) => c.method === "POST" && c.url.includes(":addVersion"));
|
|
146
|
+
assert.ok(postVersion);
|
|
147
|
+
const expectedBase64 = Buffer.from("my-new-secret-value").toString("base64");
|
|
148
|
+
assert.strictEqual(postVersion.body.payload.data, expectedBase64);
|
|
149
|
+
});
|
|
150
|
+
test("updates an existing secret if value differs", async () => {
|
|
151
|
+
// 1. Mock GET (discovery) returns existing secret
|
|
152
|
+
mockResponses["GET /secrets/existing-sec"] = {
|
|
153
|
+
status: 200,
|
|
154
|
+
body: { name: "projects/my-gcp-project/secrets/existing-sec" },
|
|
155
|
+
};
|
|
156
|
+
// 2. Mock GET (access latest version) returns old value
|
|
157
|
+
mockResponses["GET /secrets/existing-sec/versions/latest:access"] = {
|
|
158
|
+
status: 200,
|
|
159
|
+
body: { payload: { data: Buffer.from("old-value").toString("base64") } },
|
|
160
|
+
};
|
|
161
|
+
// 3. Mock POST (add version) for updated value
|
|
162
|
+
mockResponses["POST /secrets/existing-sec:addVersion"] = {
|
|
163
|
+
status: 200,
|
|
164
|
+
body: { name: "projects/my-gcp-project/secrets/existing-sec/versions/2" },
|
|
165
|
+
};
|
|
166
|
+
const builder = new GCPSecretBuilder("existing-sec")
|
|
167
|
+
.plainText("new-value"); // Value changed!
|
|
168
|
+
const result = await builder.deploy();
|
|
169
|
+
assert.strictEqual(result.name, "existing-sec");
|
|
170
|
+
assert.strictEqual(result.value, "new-value");
|
|
171
|
+
// Verify addVersion was called
|
|
172
|
+
const postVersion = fetchCalls.filter((c) => c.method === "POST" && c.url.includes(":addVersion"));
|
|
173
|
+
assert.strictEqual(postVersion.length, 1);
|
|
174
|
+
assert.strictEqual(postVersion[0].body.payload.data, Buffer.from("new-value").toString("base64"));
|
|
175
|
+
});
|
|
176
|
+
test("skips updating secret if value is identical", async () => {
|
|
177
|
+
// 1. Mock GET (discovery) returns existing secret
|
|
178
|
+
mockResponses["GET /secrets/identical-sec"] = {
|
|
179
|
+
status: 200,
|
|
180
|
+
body: { name: "projects/my-gcp-project/secrets/identical-sec" },
|
|
181
|
+
};
|
|
182
|
+
// 2. Mock GET (access latest version) returns same value
|
|
183
|
+
mockResponses["GET /secrets/identical-sec/versions/latest:access"] = {
|
|
184
|
+
status: 200,
|
|
185
|
+
body: { payload: { data: Buffer.from("same-value").toString("base64") } },
|
|
186
|
+
};
|
|
187
|
+
const builder = new GCPSecretBuilder("identical-sec")
|
|
188
|
+
.plainText("same-value");
|
|
189
|
+
const result = await builder.deploy();
|
|
190
|
+
assert.strictEqual(result.name, "identical-sec");
|
|
191
|
+
assert.strictEqual(result.value, "same-value");
|
|
192
|
+
// Verify NO write calls (POST or DELETE) occurred
|
|
193
|
+
const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
|
|
194
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
195
|
+
});
|
|
196
|
+
test("destroys an existing secret successfully", async () => {
|
|
197
|
+
// 1. Mock GET (discovery on destroy) returns existing secret
|
|
198
|
+
mockResponses["GET /secrets/to-delete-sec"] = {
|
|
199
|
+
status: 200,
|
|
200
|
+
body: { name: "projects/my-gcp-project/secrets/to-delete-sec" },
|
|
201
|
+
};
|
|
202
|
+
// 2. Mock DELETE
|
|
203
|
+
mockResponses["DELETE /secrets/to-delete-sec"] = {
|
|
204
|
+
status: 200,
|
|
205
|
+
body: {},
|
|
206
|
+
};
|
|
207
|
+
const builder = new GCPSecretBuilder("to-delete-sec");
|
|
208
|
+
const result = await builder.destroy();
|
|
209
|
+
assert.deepStrictEqual(result, { destroyed: "to-delete-sec" });
|
|
210
|
+
// Verify DELETE was called
|
|
211
|
+
const deleteCalls = fetchCalls.filter((c) => c.method === "DELETE");
|
|
212
|
+
assert.strictEqual(deleteCalls.length, 1);
|
|
213
|
+
assert.strictEqual(deleteCalls[0].url.includes("/secrets/to-delete-sec"), true);
|
|
214
|
+
});
|
|
215
|
+
test("injects secret into Cloud Run microservice environment variables", async () => {
|
|
216
|
+
// 1. Stateful mock for GET /secrets/db-pass: returns 404 then 200
|
|
217
|
+
let secCallCount = 0;
|
|
218
|
+
mockResponses["GET /secrets/db-pass"] = {
|
|
219
|
+
get status() {
|
|
220
|
+
secCallCount++;
|
|
221
|
+
return secCallCount === 1 ? 404 : 200;
|
|
222
|
+
},
|
|
223
|
+
get body() {
|
|
224
|
+
if (secCallCount === 1)
|
|
225
|
+
return { message: "Not found" };
|
|
226
|
+
return { name: "projects/my-gcp-project/secrets/db-pass" };
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
mockResponses["GET /secrets/db-pass/versions/latest:access"] = {
|
|
230
|
+
status: 200,
|
|
231
|
+
body: { payload: { data: Buffer.from("mypassword").toString("base64") } },
|
|
232
|
+
};
|
|
233
|
+
mockResponses["POST /secrets?secretId=db-pass"] = { status: 200, body: {} };
|
|
234
|
+
mockResponses["POST /secrets/db-pass:addVersion"] = { status: 200, body: {} };
|
|
235
|
+
// Cloud Run mocks
|
|
236
|
+
mockResponses["GET /services/web-app"] = {
|
|
237
|
+
status: 404,
|
|
238
|
+
body: { message: "Not found" },
|
|
239
|
+
};
|
|
240
|
+
mockResponses["POST /services?serviceId=web-app"] = {
|
|
241
|
+
status: 200,
|
|
242
|
+
body: { name: "projects/my-gcp-project/locations/us-central1/services/web-app", uri: "https://web-app.run.app" },
|
|
243
|
+
};
|
|
244
|
+
mockResponses["POST /services/web-app:setIamPolicy"] = { status: 200, body: {} };
|
|
245
|
+
const secret = new GCPSecretBuilder("db-pass").plainText("mypassword");
|
|
246
|
+
const app = new GCPCloudRunBuilder("web-app")
|
|
247
|
+
.image("gcr.io/my-proj/my-image:latest")
|
|
248
|
+
.env({
|
|
249
|
+
NODE_ENV: "production",
|
|
250
|
+
DATABASE_PASSWORD: secret, // Wired directly!
|
|
251
|
+
});
|
|
252
|
+
// Deploy secret first, which populates the resolvedValue
|
|
253
|
+
await secret.deploy();
|
|
254
|
+
// Deploy Cloud Run app
|
|
255
|
+
await app.deploy();
|
|
256
|
+
// Verify Cloud Run creation request body resolved the secret variable
|
|
257
|
+
const runPost = fetchCalls.find((c) => c.method === "POST" && c.url.includes("serviceId=web-app"));
|
|
258
|
+
assert.ok(runPost);
|
|
259
|
+
const envs = runPost.body.template.containers[0].env;
|
|
260
|
+
const dbPassEnv = envs.find((e) => e.name === "DATABASE_PASSWORD");
|
|
261
|
+
assert.ok(dbPassEnv);
|
|
262
|
+
assert.strictEqual(dbPassEnv.value, "mypassword"); // Successfully resolved!
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -19,6 +19,7 @@ export declare class VMBuilder extends BaseBuilder {
|
|
|
19
19
|
private _vlan?;
|
|
20
20
|
private _ip?;
|
|
21
21
|
private _sshKeys?;
|
|
22
|
+
private _machine;
|
|
22
23
|
constructor(name: string);
|
|
23
24
|
private discoverVm;
|
|
24
25
|
image(os: OSImage): this;
|
|
@@ -31,6 +32,7 @@ export declare class VMBuilder extends BaseBuilder {
|
|
|
31
32
|
vlan(tag: number): this;
|
|
32
33
|
ip(address: string): this;
|
|
33
34
|
sshKey(keys: string | readonly string[]): this;
|
|
35
|
+
machine(type: "q35" | "i440fx"): this;
|
|
34
36
|
deploy(): Promise<{
|
|
35
37
|
name: string;
|
|
36
38
|
vmid: number | null;
|
|
@@ -24,6 +24,7 @@ export class VMBuilder extends BaseBuilder {
|
|
|
24
24
|
_vlan;
|
|
25
25
|
_ip;
|
|
26
26
|
_sshKeys;
|
|
27
|
+
_machine = "q35";
|
|
27
28
|
constructor(name) {
|
|
28
29
|
super(name);
|
|
29
30
|
this.discoveryPromise = this.discoverVm(name);
|
|
@@ -80,6 +81,10 @@ export class VMBuilder extends BaseBuilder {
|
|
|
80
81
|
this._sshKeys = Array.isArray(keys) ? [...keys] : keys;
|
|
81
82
|
return this;
|
|
82
83
|
}
|
|
84
|
+
machine(type) {
|
|
85
|
+
this._machine = type;
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
83
88
|
async deploy() {
|
|
84
89
|
const dryRun = this.isDryRunActive();
|
|
85
90
|
const existing = await this.discoveryPromise;
|
|
@@ -102,7 +107,7 @@ export class VMBuilder extends BaseBuilder {
|
|
|
102
107
|
console.log(` 📝 [PLAN] Create VM "${this.name}"`);
|
|
103
108
|
if (this._image)
|
|
104
109
|
console.log(` └─ Image: ${this._image}`);
|
|
105
|
-
console.log(` └─ Cores: ${this._cores} Memory: ${this._memory} MB`);
|
|
110
|
+
console.log(` └─ Cores: ${this._cores} Memory: ${this._memory} MB Machine: ${this._machine}`);
|
|
106
111
|
if (this._vlan)
|
|
107
112
|
console.log(` └─ VLAN: ${this._vlan}`);
|
|
108
113
|
if (this._provision) {
|
|
@@ -132,8 +137,35 @@ export class VMBuilder extends BaseBuilder {
|
|
|
132
137
|
? `Check that VMID ${this._image} exists and is marked as a template.`
|
|
133
138
|
: `Create a template whose name contains "${this._image}".`));
|
|
134
139
|
}
|
|
135
|
-
// Resolve target node: explicit → configured nodes list → template's node → API discovery
|
|
140
|
+
// Resolve target node: explicit → cluster-aware (online & max free RAM) → configured nodes list → template's node → API discovery
|
|
136
141
|
let node = this._node;
|
|
142
|
+
if (!node) {
|
|
143
|
+
try {
|
|
144
|
+
const nodesList = await pm.get("/nodes");
|
|
145
|
+
const configuredNodes = Config.get().providers.proxmox?.nodes;
|
|
146
|
+
const onlineNodes = (nodesList ?? []).filter((n) => {
|
|
147
|
+
if (n.status !== "online")
|
|
148
|
+
return false;
|
|
149
|
+
if (configuredNodes && configuredNodes.length > 0) {
|
|
150
|
+
return configuredNodes.includes(n.node);
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
if (onlineNodes.length > 0) {
|
|
155
|
+
// Sort descending by free memory (maxmem - mem)
|
|
156
|
+
onlineNodes.sort((a, b) => {
|
|
157
|
+
const freeA = (a.maxmem ?? 0) - (a.mem ?? 0);
|
|
158
|
+
const freeB = (b.maxmem ?? 0) - (b.mem ?? 0);
|
|
159
|
+
return freeB - freeA;
|
|
160
|
+
});
|
|
161
|
+
node = onlineNodes[0].node;
|
|
162
|
+
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)`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
// Fallback silently to configured nodes list or discovery
|
|
167
|
+
}
|
|
168
|
+
}
|
|
137
169
|
if (!node) {
|
|
138
170
|
const configuredNodes = Config.get().providers.proxmox?.nodes;
|
|
139
171
|
node = configuredNodes?.[0] ?? template?.node;
|
|
@@ -191,7 +223,7 @@ export class VMBuilder extends BaseBuilder {
|
|
|
191
223
|
const net0 = `virtio,bridge=vmbr1${this._vlan ? `,tag=${this._vlan}` : ""}`;
|
|
192
224
|
const configPatch = {
|
|
193
225
|
onboot: 1,
|
|
194
|
-
machine:
|
|
226
|
+
machine: this._machine,
|
|
195
227
|
cores: this._cores,
|
|
196
228
|
memory: this._memory,
|
|
197
229
|
net0,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { ProxmoxApiClient } from "./api.js";
|
|
4
|
+
import { VMBuilder } from "./vm.js";
|
|
5
|
+
import { Config } from "../../core/config.js";
|
|
6
|
+
describe("Proxmox VMBuilder Unit Tests", () => {
|
|
7
|
+
let originalGet;
|
|
8
|
+
let originalPost;
|
|
9
|
+
let originalDelete;
|
|
10
|
+
let clientCalls = [];
|
|
11
|
+
let mockGetResponses = {};
|
|
12
|
+
let mockPostResponses = {};
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
Config.set({
|
|
15
|
+
dryRun: false,
|
|
16
|
+
providers: {
|
|
17
|
+
proxmox: {
|
|
18
|
+
url: "https://pve.example.com:8006",
|
|
19
|
+
user: "root@pam",
|
|
20
|
+
tokenName: "puls",
|
|
21
|
+
tokenSecret: "secret-key",
|
|
22
|
+
verifySsl: false,
|
|
23
|
+
dnsDomain: "nolimit.int",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
clientCalls = [];
|
|
28
|
+
mockGetResponses = {};
|
|
29
|
+
mockPostResponses = {};
|
|
30
|
+
originalGet = ProxmoxApiClient.prototype.get;
|
|
31
|
+
originalPost = ProxmoxApiClient.prototype.post;
|
|
32
|
+
originalDelete = ProxmoxApiClient.prototype.delete;
|
|
33
|
+
ProxmoxApiClient.prototype.get = async function (path) {
|
|
34
|
+
clientCalls.push({ method: "GET", path });
|
|
35
|
+
if (mockGetResponses[path] !== undefined) {
|
|
36
|
+
const handler = mockGetResponses[path];
|
|
37
|
+
if (typeof handler === "function")
|
|
38
|
+
return handler();
|
|
39
|
+
return handler;
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
42
|
+
};
|
|
43
|
+
ProxmoxApiClient.prototype.post = async function (path, body) {
|
|
44
|
+
clientCalls.push({ method: "POST", path, body });
|
|
45
|
+
if (mockPostResponses[path] !== undefined) {
|
|
46
|
+
const handler = mockPostResponses[path];
|
|
47
|
+
if (typeof handler === "function")
|
|
48
|
+
return handler(body);
|
|
49
|
+
return handler;
|
|
50
|
+
}
|
|
51
|
+
if (path.includes("/clone")) {
|
|
52
|
+
return "UPID:pve1:00000000:00000000:00000000:qemuclone:101:root@pam:";
|
|
53
|
+
}
|
|
54
|
+
return {};
|
|
55
|
+
};
|
|
56
|
+
ProxmoxApiClient.prototype.delete = async function (path) {
|
|
57
|
+
clientCalls.push({ method: "DELETE", path });
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
ProxmoxApiClient.prototype.get = originalGet;
|
|
62
|
+
ProxmoxApiClient.prototype.post = originalPost;
|
|
63
|
+
ProxmoxApiClient.prototype.delete = originalDelete;
|
|
64
|
+
});
|
|
65
|
+
test("gracefully handles discovery when VM does not exist", async () => {
|
|
66
|
+
mockGetResponses["/cluster/resources?type=vm"] = [];
|
|
67
|
+
const builder = new VMBuilder("my-vm");
|
|
68
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
69
|
+
assert.strictEqual(discoveryResult, null);
|
|
70
|
+
assert.ok(clientCalls.some((c) => c.path === "/cluster/resources?type=vm"));
|
|
71
|
+
});
|
|
72
|
+
test("discovers existing VM successfully", async () => {
|
|
73
|
+
mockGetResponses["/cluster/resources?type=vm"] = [
|
|
74
|
+
{ name: "my-vm", vmid: 200, node: "pve2", template: 0, status: "running" },
|
|
75
|
+
];
|
|
76
|
+
const builder = new VMBuilder("my-vm");
|
|
77
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
78
|
+
assert.ok(discoveryResult);
|
|
79
|
+
assert.strictEqual(discoveryResult.vmid, 200);
|
|
80
|
+
assert.strictEqual(discoveryResult.node, "pve2");
|
|
81
|
+
const deployResult = await builder.deploy();
|
|
82
|
+
assert.strictEqual(deployResult.vmid, 200);
|
|
83
|
+
assert.strictEqual(builder.resolvedNode, "pve2");
|
|
84
|
+
});
|
|
85
|
+
test("performs clean dry-run planning without making API writes", async () => {
|
|
86
|
+
Config.set({
|
|
87
|
+
dryRun: true,
|
|
88
|
+
providers: {
|
|
89
|
+
proxmox: {
|
|
90
|
+
url: "https://pve.example.com:8006",
|
|
91
|
+
user: "root@pam",
|
|
92
|
+
tokenName: "puls",
|
|
93
|
+
tokenSecret: "secret-key",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const builder = new VMBuilder("dryrun-vm")
|
|
98
|
+
.cores(4)
|
|
99
|
+
.memory(4096)
|
|
100
|
+
.machine("i440fx");
|
|
101
|
+
const deployResult = await builder.deploy();
|
|
102
|
+
assert.strictEqual(deployResult.vmid, "PENDING");
|
|
103
|
+
assert.ok(!clientCalls.some((c) => c.method === "POST"));
|
|
104
|
+
});
|
|
105
|
+
test("deploys new VM and performs cluster-aware node selection based on free RAM", async () => {
|
|
106
|
+
mockGetResponses["/cluster/resources?type=vm"] = [];
|
|
107
|
+
mockGetResponses["/cluster/nextid"] = 105;
|
|
108
|
+
// Simulate three nodes in the cluster with different RAM allocations and statuses
|
|
109
|
+
mockGetResponses["/nodes"] = [
|
|
110
|
+
{ node: "pve-offline", status: "offline", maxmem: 64 * 1024 * 1024 * 1024, mem: 4 * 1024 * 1024 * 1024 }, // offline
|
|
111
|
+
{ node: "pve-ram-low", status: "online", maxmem: 16 * 1024 * 1024 * 1024, mem: 14 * 1024 * 1024 * 1024 }, // 2GB free
|
|
112
|
+
{ node: "pve-ram-high", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 12 * 1024 * 1024 * 1024 }, // 20GB free
|
|
113
|
+
];
|
|
114
|
+
// Mock wait for task (normally waitForTask would poll the UPID, but in tests it mock-completes or we bypass it)
|
|
115
|
+
// In our test, since we don't have a template image configured, it creates a blank VM by POSTing to /nodes/{node}/qemu
|
|
116
|
+
const builder = new VMBuilder("my-new-vm")
|
|
117
|
+
.cores(2)
|
|
118
|
+
.memory(2048)
|
|
119
|
+
.ip("10.8.10.85")
|
|
120
|
+
.machine("i440fx");
|
|
121
|
+
const deployResult = await builder.deploy();
|
|
122
|
+
// Verify it resolved to the VMID and the most free RAM node ("pve-ram-high")
|
|
123
|
+
assert.strictEqual(deployResult.vmid, 105);
|
|
124
|
+
assert.strictEqual(builder.resolvedNode, "pve-ram-high");
|
|
125
|
+
// Verify the blank VM POST went to the correct node
|
|
126
|
+
const createCall = clientCalls.find((c) => c.method === "POST" && c.path.startsWith("/nodes/pve-ram-high/qemu"));
|
|
127
|
+
assert.ok(createCall);
|
|
128
|
+
assert.deepStrictEqual(createCall.body, {
|
|
129
|
+
vmid: 105,
|
|
130
|
+
name: "my-new-vm",
|
|
131
|
+
cores: 2,
|
|
132
|
+
memory: 2048,
|
|
133
|
+
net0: "virtio,bridge=vmbr1",
|
|
134
|
+
ostype: "l26",
|
|
135
|
+
});
|
|
136
|
+
// Verify config patch incorporates the custom machine override "i440fx"
|
|
137
|
+
const configCall = clientCalls.find((c) => c.method === "POST" && c.path === "/nodes/pve-ram-high/qemu/105/config");
|
|
138
|
+
assert.ok(configCall);
|
|
139
|
+
assert.strictEqual(configCall.body.machine, "i440fx");
|
|
140
|
+
assert.strictEqual(configCall.body.cores, 2);
|
|
141
|
+
assert.strictEqual(configCall.body.memory, 2048);
|
|
142
|
+
});
|
|
143
|
+
test("destroys an existing VM successfully", async () => {
|
|
144
|
+
mockGetResponses["/cluster/resources?type=vm"] = [
|
|
145
|
+
{ name: "my-vm", vmid: 200, node: "pve1", template: 0 },
|
|
146
|
+
];
|
|
147
|
+
const builder = new VMBuilder("my-vm");
|
|
148
|
+
await builder.discoveryPromise;
|
|
149
|
+
const destroyResult = await builder.destroy();
|
|
150
|
+
assert.deepStrictEqual(destroyResult, { destroyed: "my-vm" });
|
|
151
|
+
// In Proxmox, VM deletion is handled via BaseBuilder default or custom VMBuilder destroy.
|
|
152
|
+
// Let's verify we logged or called the delete path or returned safely.
|
|
153
|
+
assert.ok(destroyResult.destroyed);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/dist/types/aws.d.ts
CHANGED
|
@@ -53,3 +53,14 @@ export interface RegistrantContact {
|
|
|
53
53
|
COUNTRY: string;
|
|
54
54
|
}
|
|
55
55
|
export declare const DOMAIN_REGISTER: RegistrantContact;
|
|
56
|
+
export interface IAMPolicyStatement {
|
|
57
|
+
Effect: "Allow" | "Deny";
|
|
58
|
+
Action: string | string[];
|
|
59
|
+
Resource?: string | string[];
|
|
60
|
+
Principal?: Record<string, string | string[]>;
|
|
61
|
+
Condition?: Record<string, any>;
|
|
62
|
+
}
|
|
63
|
+
export interface IAMPolicyDocument {
|
|
64
|
+
Version?: string;
|
|
65
|
+
Statement: IAMPolicyStatement[];
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "puls-dev",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"./firebase": {
|
|
27
27
|
"types": "./dist/providers/firebase/index.d.ts",
|
|
28
28
|
"default": "./dist/providers/firebase/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./gcp": {
|
|
31
|
+
"types": "./dist/providers/gcp/index.d.ts",
|
|
32
|
+
"default": "./dist/providers/gcp/index.js"
|
|
29
33
|
}
|
|
30
34
|
},
|
|
31
35
|
"files": [
|
|
@@ -50,10 +54,28 @@
|
|
|
50
54
|
"author": "Bia",
|
|
51
55
|
"license": "ISC",
|
|
52
56
|
"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",
|
|
53
73
|
"@types/node": "^25.6.2",
|
|
74
|
+
"google-auth-library": "^10.6.2",
|
|
54
75
|
"ts-node": "^10.9.2",
|
|
55
76
|
"tsx": "^4.21.0",
|
|
56
|
-
"typescript": "^6.0.3"
|
|
77
|
+
"typescript": "^6.0.3",
|
|
78
|
+
"undici": "^8.3.0"
|
|
57
79
|
},
|
|
58
80
|
"dependencies": {
|
|
59
81
|
"dotenv": "^17.4.2",
|
|
@@ -63,6 +85,7 @@
|
|
|
63
85
|
"@aws-sdk/client-acm": "^3.1040.0",
|
|
64
86
|
"@aws-sdk/client-apigatewayv2": "^3.1044.0",
|
|
65
87
|
"@aws-sdk/client-cloudfront": "^3.1040.0",
|
|
88
|
+
"@aws-sdk/client-cloudwatch": "^3.1045.0",
|
|
66
89
|
"@aws-sdk/client-cloudwatch-logs": "^3.1045.0",
|
|
67
90
|
"@aws-sdk/client-ec2": "^3.1045.0",
|
|
68
91
|
"@aws-sdk/client-ecs": "^3.1045.0",
|
|
@@ -73,6 +96,7 @@
|
|
|
73
96
|
"@aws-sdk/client-route-53-domains": "^3.1041.0",
|
|
74
97
|
"@aws-sdk/client-s3": "^3.1040.0",
|
|
75
98
|
"@aws-sdk/client-secrets-manager": "^3.1045.0",
|
|
99
|
+
"@aws-sdk/client-sns": "^3.1045.0",
|
|
76
100
|
"@aws-sdk/client-sqs": "^3.1045.0",
|
|
77
101
|
"google-auth-library": "^10.6.2",
|
|
78
102
|
"undici": "^8.2.0"
|
|
@@ -87,6 +111,9 @@
|
|
|
87
111
|
"@aws-sdk/client-cloudfront": {
|
|
88
112
|
"optional": true
|
|
89
113
|
},
|
|
114
|
+
"@aws-sdk/client-cloudwatch": {
|
|
115
|
+
"optional": true
|
|
116
|
+
},
|
|
90
117
|
"@aws-sdk/client-cloudwatch-logs": {
|
|
91
118
|
"optional": true
|
|
92
119
|
},
|
|
@@ -117,6 +144,9 @@
|
|
|
117
144
|
"@aws-sdk/client-secrets-manager": {
|
|
118
145
|
"optional": true
|
|
119
146
|
},
|
|
147
|
+
"@aws-sdk/client-sns": {
|
|
148
|
+
"optional": true
|
|
149
|
+
},
|
|
120
150
|
"@aws-sdk/client-sqs": {
|
|
121
151
|
"optional": true
|
|
122
152
|
},
|