puls-dev 0.2.0 → 0.2.1

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.
@@ -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.1",
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",
@@ -50,10 +50,28 @@
50
50
  "author": "Bia",
51
51
  "license": "ISC",
52
52
  "devDependencies": {
53
+ "@aws-sdk/client-acm": "^3.1053.0",
54
+ "@aws-sdk/client-apigatewayv2": "^3.1053.0",
55
+ "@aws-sdk/client-cloudfront": "^3.1053.0",
56
+ "@aws-sdk/client-cloudwatch": "^3.1053.0",
57
+ "@aws-sdk/client-cloudwatch-logs": "^3.1053.0",
58
+ "@aws-sdk/client-ec2": "^3.1053.0",
59
+ "@aws-sdk/client-ecs": "^3.1053.0",
60
+ "@aws-sdk/client-iam": "^3.1053.0",
61
+ "@aws-sdk/client-lambda": "^3.1053.0",
62
+ "@aws-sdk/client-rds": "^3.1053.0",
63
+ "@aws-sdk/client-route-53": "^3.1053.0",
64
+ "@aws-sdk/client-route-53-domains": "^3.1053.0",
65
+ "@aws-sdk/client-s3": "^3.1053.0",
66
+ "@aws-sdk/client-secrets-manager": "^3.1053.0",
67
+ "@aws-sdk/client-sns": "^3.1053.0",
68
+ "@aws-sdk/client-sqs": "^3.1053.0",
53
69
  "@types/node": "^25.6.2",
70
+ "google-auth-library": "^10.6.2",
54
71
  "ts-node": "^10.9.2",
55
72
  "tsx": "^4.21.0",
56
- "typescript": "^6.0.3"
73
+ "typescript": "^6.0.3",
74
+ "undici": "^8.3.0"
57
75
  },
58
76
  "dependencies": {
59
77
  "dotenv": "^17.4.2",
@@ -63,6 +81,7 @@
63
81
  "@aws-sdk/client-acm": "^3.1040.0",
64
82
  "@aws-sdk/client-apigatewayv2": "^3.1044.0",
65
83
  "@aws-sdk/client-cloudfront": "^3.1040.0",
84
+ "@aws-sdk/client-cloudwatch": "^3.1045.0",
66
85
  "@aws-sdk/client-cloudwatch-logs": "^3.1045.0",
67
86
  "@aws-sdk/client-ec2": "^3.1045.0",
68
87
  "@aws-sdk/client-ecs": "^3.1045.0",
@@ -73,6 +92,7 @@
73
92
  "@aws-sdk/client-route-53-domains": "^3.1041.0",
74
93
  "@aws-sdk/client-s3": "^3.1040.0",
75
94
  "@aws-sdk/client-secrets-manager": "^3.1045.0",
95
+ "@aws-sdk/client-sns": "^3.1045.0",
76
96
  "@aws-sdk/client-sqs": "^3.1045.0",
77
97
  "google-auth-library": "^10.6.2",
78
98
  "undici": "^8.2.0"
@@ -87,6 +107,9 @@
87
107
  "@aws-sdk/client-cloudfront": {
88
108
  "optional": true
89
109
  },
110
+ "@aws-sdk/client-cloudwatch": {
111
+ "optional": true
112
+ },
90
113
  "@aws-sdk/client-cloudwatch-logs": {
91
114
  "optional": true
92
115
  },
@@ -117,6 +140,9 @@
117
140
  "@aws-sdk/client-secrets-manager": {
118
141
  "optional": true
119
142
  },
143
+ "@aws-sdk/client-sns": {
144
+ "optional": true
145
+ },
120
146
  "@aws-sdk/client-sqs": {
121
147
  "optional": true
122
148
  },