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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/core/config.d.ts +5 -0
  3. package/dist/providers/aws/api.d.ts +4 -0
  4. package/dist/providers/aws/api.js +4 -0
  5. package/dist/providers/aws/cloudwatch.d.ts +44 -0
  6. package/dist/providers/aws/cloudwatch.js +205 -0
  7. package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
  8. package/dist/providers/aws/cloudwatch.test.js +224 -0
  9. package/dist/providers/aws/fargate.d.ts +2 -0
  10. package/dist/providers/aws/fargate.js +6 -0
  11. package/dist/providers/aws/iam.d.ts +52 -0
  12. package/dist/providers/aws/iam.js +307 -0
  13. package/dist/providers/aws/iam.test.d.ts +1 -0
  14. package/dist/providers/aws/iam.test.js +367 -0
  15. package/dist/providers/aws/index.d.ts +7 -0
  16. package/dist/providers/aws/index.js +7 -0
  17. package/dist/providers/aws/lambda.d.ts +3 -1
  18. package/dist/providers/aws/lambda.js +11 -2
  19. package/dist/providers/aws/rds.d.ts +1 -0
  20. package/dist/providers/aws/rds.js +4 -1
  21. package/dist/providers/aws/sns.d.ts +22 -0
  22. package/dist/providers/aws/sns.js +146 -0
  23. package/dist/providers/aws/sns.test.d.ts +1 -0
  24. package/dist/providers/aws/sns.test.js +162 -0
  25. package/dist/providers/firebase/appcheck.d.ts +15 -0
  26. package/dist/providers/firebase/appcheck.js +109 -0
  27. package/dist/providers/firebase/appcheck.test.d.ts +1 -0
  28. package/dist/providers/firebase/appcheck.test.js +141 -0
  29. package/dist/providers/firebase/index.d.ts +2 -0
  30. package/dist/providers/firebase/index.js +2 -0
  31. package/dist/providers/gcp/api.d.ts +10 -0
  32. package/dist/providers/gcp/api.js +111 -0
  33. package/dist/providers/gcp/clouddns.d.ts +37 -0
  34. package/dist/providers/gcp/clouddns.js +284 -0
  35. package/dist/providers/gcp/clouddns.test.d.ts +1 -0
  36. package/dist/providers/gcp/clouddns.test.js +259 -0
  37. package/dist/providers/gcp/cloudrun.d.ts +31 -0
  38. package/dist/providers/gcp/cloudrun.js +240 -0
  39. package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
  40. package/dist/providers/gcp/cloudrun.test.js +281 -0
  41. package/dist/providers/gcp/cloudsql.d.ts +37 -0
  42. package/dist/providers/gcp/cloudsql.js +262 -0
  43. package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
  44. package/dist/providers/gcp/cloudsql.test.js +295 -0
  45. package/dist/providers/gcp/iam.d.ts +38 -0
  46. package/dist/providers/gcp/iam.js +309 -0
  47. package/dist/providers/gcp/iam.test.d.ts +1 -0
  48. package/dist/providers/gcp/iam.test.js +305 -0
  49. package/dist/providers/gcp/index.d.ts +19 -0
  50. package/dist/providers/gcp/index.js +19 -0
  51. package/dist/providers/gcp/pubsub.d.ts +31 -0
  52. package/dist/providers/gcp/pubsub.js +227 -0
  53. package/dist/providers/gcp/pubsub.test.d.ts +1 -0
  54. package/dist/providers/gcp/pubsub.test.js +244 -0
  55. package/dist/providers/gcp/secrets.d.ts +21 -0
  56. package/dist/providers/gcp/secrets.js +187 -0
  57. package/dist/providers/gcp/secrets.test.d.ts +1 -0
  58. package/dist/providers/gcp/secrets.test.js +264 -0
  59. package/dist/providers/proxmox/vm.d.ts +2 -0
  60. package/dist/providers/proxmox/vm.js +35 -3
  61. package/dist/providers/proxmox/vm.test.d.ts +1 -0
  62. package/dist/providers/proxmox/vm.test.js +155 -0
  63. package/dist/types/aws.d.ts +11 -0
  64. 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: "q35",
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
+ });
@@ -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.0",
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
  },