puls-dev 0.2.7 → 0.2.9
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/core/checker.js +71 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +11 -1
- package/dist/core/context.d.ts +14 -0
- package/dist/core/context.js +2 -0
- package/dist/core/decorators.d.ts +4 -0
- package/dist/core/decorators.js +56 -30
- package/dist/core/hooks.d.ts +21 -0
- package/dist/core/hooks.js +116 -0
- package/dist/core/hooks.test.d.ts +1 -0
- package/dist/core/hooks.test.js +194 -0
- package/dist/core/multiregion.test.d.ts +1 -0
- package/dist/core/multiregion.test.js +87 -0
- package/dist/core/output.d.ts +2 -0
- package/dist/core/output.js +9 -2
- package/dist/core/parallel.test.d.ts +1 -0
- package/dist/core/parallel.test.js +215 -0
- package/dist/core/parser.d.ts +10 -0
- package/dist/core/parser.js +140 -0
- package/dist/core/parser.test.d.ts +1 -0
- package/dist/core/parser.test.js +117 -0
- package/dist/core/production.test.d.ts +1 -0
- package/dist/core/production.test.js +189 -0
- package/dist/core/provisioner.d.ts +4 -0
- package/dist/core/provisioner.js +123 -0
- package/dist/core/resource.d.ts +23 -0
- package/dist/core/resource.js +54 -0
- package/dist/core/retry.d.ts +9 -0
- package/dist/core/retry.js +28 -0
- package/dist/core/retry.test.d.ts +1 -0
- package/dist/core/retry.test.js +66 -0
- package/dist/core/secret.d.ts +41 -0
- package/dist/core/secret.js +105 -0
- package/dist/core/secret.test.d.ts +1 -0
- package/dist/core/secret.test.js +166 -0
- package/dist/core/stack.d.ts +4 -3
- package/dist/core/stack.js +322 -48
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/providers/aws/api.js +97 -17
- package/dist/providers/aws/ec2.d.ts +51 -0
- package/dist/providers/aws/ec2.js +331 -0
- package/dist/providers/aws/ec2.test.d.ts +1 -0
- package/dist/providers/aws/ec2.test.js +281 -0
- package/dist/providers/aws/index.d.ts +4 -0
- package/dist/providers/aws/index.js +4 -0
- package/dist/providers/aws/route53.d.ts +1 -0
- package/dist/providers/aws/route53.js +15 -2
- package/dist/providers/aws/route53.test.js +47 -0
- package/dist/providers/aws/template.d.ts +34 -0
- package/dist/providers/aws/template.js +252 -0
- package/dist/providers/aws/template.test.d.ts +1 -0
- package/dist/providers/aws/template.test.js +208 -0
- package/dist/providers/do/api.d.ts +3 -1
- package/dist/providers/do/api.js +126 -27
- package/dist/providers/do/app.d.ts +26 -0
- package/dist/providers/do/app.js +124 -0
- package/dist/providers/do/app.test.d.ts +1 -0
- package/dist/providers/do/app.test.js +268 -0
- package/dist/providers/do/database.d.ts +44 -0
- package/dist/providers/do/database.js +208 -0
- package/dist/providers/do/database.test.d.ts +1 -0
- package/dist/providers/do/database.test.js +293 -0
- package/dist/providers/do/domain.d.ts +2 -0
- package/dist/providers/do/domain.js +30 -0
- package/dist/providers/do/domain.test.js +49 -0
- package/dist/providers/do/droplet.d.ts +9 -0
- package/dist/providers/do/droplet.js +146 -8
- package/dist/providers/do/droplet.test.js +228 -1
- package/dist/providers/do/firewall.d.ts +2 -1
- package/dist/providers/do/firewall.js +23 -9
- package/dist/providers/do/firewall.test.js +54 -0
- package/dist/providers/do/index.d.ts +11 -0
- package/dist/providers/do/index.js +8 -0
- package/dist/providers/do/spaces.d.ts +27 -0
- package/dist/providers/do/spaces.js +142 -0
- package/dist/providers/do/spaces.test.d.ts +1 -0
- package/dist/providers/do/spaces.test.js +180 -0
- package/dist/providers/do/spaces_api.d.ts +2 -0
- package/dist/providers/do/spaces_api.js +20 -0
- package/dist/providers/do/vpc.d.ts +30 -0
- package/dist/providers/do/vpc.js +128 -0
- package/dist/providers/do/vpc.test.d.ts +1 -0
- package/dist/providers/do/vpc.test.js +258 -0
- package/dist/providers/firebase/api.js +92 -29
- package/dist/providers/firebase/list.d.ts +2 -0
- package/dist/providers/firebase/list.js +25 -0
- package/dist/providers/gcp/api.js +88 -14
- package/dist/providers/gcp/clouddns.d.ts +1 -0
- package/dist/providers/gcp/clouddns.js +15 -2
- package/dist/providers/gcp/clouddns.test.js +45 -0
- package/dist/providers/gcp/index.d.ts +5 -1
- package/dist/providers/gcp/index.js +5 -1
- package/dist/providers/gcp/list.d.ts +2 -0
- package/dist/providers/gcp/list.js +55 -0
- package/dist/providers/gcp/secrets.js +1 -1
- package/dist/providers/gcp/template.d.ts +32 -0
- package/dist/providers/gcp/template.js +252 -0
- package/dist/providers/gcp/template.test.d.ts +1 -0
- package/dist/providers/gcp/template.test.js +227 -0
- package/dist/providers/gcp/vm.d.ts +48 -0
- package/dist/providers/gcp/vm.js +375 -0
- package/dist/providers/gcp/vm.test.d.ts +1 -0
- package/dist/providers/gcp/vm.test.js +321 -0
- package/dist/providers/proxmox/api.d.ts +1 -0
- package/dist/providers/proxmox/api.js +72 -16
- package/dist/providers/proxmox/index.d.ts +2 -0
- package/dist/providers/proxmox/index.js +2 -0
- package/dist/providers/proxmox/template.d.ts +44 -0
- package/dist/providers/proxmox/template.js +349 -0
- package/dist/providers/proxmox/template.test.d.ts +1 -0
- package/dist/providers/proxmox/template.test.js +179 -0
- package/dist/providers/proxmox/vm.d.ts +7 -4
- package/dist/providers/proxmox/vm.js +57 -102
- package/dist/providers/proxmox/vm.test.js +77 -0
- package/dist/types/inventory.d.ts +44 -1
- package/package.json +3 -1
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { GoogleAuth } from "google-auth-library";
|
|
5
|
+
import { GCPVMBuilder } from "./vm.js";
|
|
6
|
+
import { Config } from "../../core/config.js";
|
|
7
|
+
import { getFileHash } from "../proxmox/hash.js";
|
|
8
|
+
describe("GCPVMBuilder Unit Tests", () => {
|
|
9
|
+
let originalFetch;
|
|
10
|
+
let fetchCalls = [];
|
|
11
|
+
let mockResponses = {};
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
Config.set({
|
|
14
|
+
dryRun: false,
|
|
15
|
+
providers: {
|
|
16
|
+
gcp: {
|
|
17
|
+
projectId: "my-gcp-project",
|
|
18
|
+
serviceAccountPath: "/fake/sa.json",
|
|
19
|
+
region: "us-central1",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
originalFetch = globalThis.fetch;
|
|
24
|
+
fetchCalls = [];
|
|
25
|
+
mockResponses = {};
|
|
26
|
+
globalThis.fetch = async (input, init) => {
|
|
27
|
+
const url = String(input);
|
|
28
|
+
const method = init?.method ?? "GET";
|
|
29
|
+
let body;
|
|
30
|
+
if (init?.body) {
|
|
31
|
+
if (typeof init.body === "string") {
|
|
32
|
+
try {
|
|
33
|
+
body = JSON.parse(init.body);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
body = init.body;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
body = "[Binary/Buffer Body]";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const headers = init?.headers;
|
|
44
|
+
fetchCalls.push({ url, method, body, headers });
|
|
45
|
+
const matchKey = Object.keys(mockResponses)
|
|
46
|
+
.filter((key) => {
|
|
47
|
+
const [mMethod, mPath] = key.split(" ");
|
|
48
|
+
return method === mMethod && url.includes(mPath);
|
|
49
|
+
})
|
|
50
|
+
.sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
|
|
51
|
+
if (matchKey) {
|
|
52
|
+
const resp = mockResponses[matchKey];
|
|
53
|
+
return {
|
|
54
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
55
|
+
status: resp.status,
|
|
56
|
+
json: async () => resp.body,
|
|
57
|
+
text: async () => JSON.stringify(resp.body),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
status: 404,
|
|
63
|
+
json: async () => ({ error: { message: `Endpoint not mocked: ${method} ${url}` } }),
|
|
64
|
+
text: async () => `Endpoint not mocked: ${method} ${url}`,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
mock.method(GoogleAuth.prototype, "getClient", async () => {
|
|
68
|
+
return {
|
|
69
|
+
getAccessToken: async () => ({ token: "fake-gcp-token" }),
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
mock.method(fs, "readFileSync", () => {
|
|
73
|
+
return "ssh-rsa AAAA_FAKE_GCP_PUBLIC_KEY test@gcp.com";
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
globalThis.fetch = originalFetch;
|
|
78
|
+
mock.restoreAll();
|
|
79
|
+
});
|
|
80
|
+
test("handles discovery when VM does not exist", async () => {
|
|
81
|
+
mockResponses["GET /instances/my-gcp-vm"] = {
|
|
82
|
+
status: 404,
|
|
83
|
+
body: { error: { message: "Not Found" } },
|
|
84
|
+
};
|
|
85
|
+
const builder = new GCPVMBuilder("my-gcp-vm");
|
|
86
|
+
const existing = await builder.discoveryPromise;
|
|
87
|
+
assert.strictEqual(existing, null);
|
|
88
|
+
const getCall = fetchCalls.find((c) => c.method === "GET" && c.url.includes("/instances/my-gcp-vm"));
|
|
89
|
+
assert.ok(getCall);
|
|
90
|
+
});
|
|
91
|
+
test("discovers VM successfully when it exists", async () => {
|
|
92
|
+
mockResponses["GET /instances/my-gcp-vm"] = {
|
|
93
|
+
status: 200,
|
|
94
|
+
body: {
|
|
95
|
+
id: "vm-123456",
|
|
96
|
+
name: "my-gcp-vm",
|
|
97
|
+
status: "RUNNING",
|
|
98
|
+
networkInterfaces: [
|
|
99
|
+
{
|
|
100
|
+
accessConfigs: [{ natIP: "34.56.78.90" }],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
metadata: {
|
|
104
|
+
items: [{ key: "puls-provision", value: "nginx-yaml=abc123123123" }],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
const builder = new GCPVMBuilder("my-gcp-vm");
|
|
109
|
+
const existing = await builder.discoveryPromise;
|
|
110
|
+
assert.ok(existing);
|
|
111
|
+
assert.strictEqual(existing.id, "vm-123456");
|
|
112
|
+
const resolvedId = await builder.out.id.get();
|
|
113
|
+
const resolvedIp = await builder.out.ip.get();
|
|
114
|
+
assert.strictEqual(resolvedId, "vm-123456");
|
|
115
|
+
assert.strictEqual(resolvedIp, "34.56.78.90");
|
|
116
|
+
});
|
|
117
|
+
test("runs in dry-run mode safely and logs plan", async () => {
|
|
118
|
+
Config.set({
|
|
119
|
+
dryRun: true,
|
|
120
|
+
providers: {
|
|
121
|
+
gcp: { projectId: "my-gcp-project", serviceAccountPath: "/fake/sa.json" },
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
mockResponses["GET /instances/new-gcp-vm"] = {
|
|
125
|
+
status: 404,
|
|
126
|
+
body: { error: { message: "Not Found" } },
|
|
127
|
+
};
|
|
128
|
+
const builder = new GCPVMBuilder("new-gcp-vm")
|
|
129
|
+
.machineType("e2-medium")
|
|
130
|
+
.zone("europe-west1-b")
|
|
131
|
+
.provision("playbooks/nginx.yaml");
|
|
132
|
+
const res = await builder.deploy();
|
|
133
|
+
assert.deepStrictEqual(res, { name: "new-gcp-vm", id: "PENDING" });
|
|
134
|
+
const resolvedId = await builder.out.id.get();
|
|
135
|
+
const resolvedIp = await builder.out.ip.get();
|
|
136
|
+
assert.strictEqual(resolvedId, "PENDING");
|
|
137
|
+
assert.strictEqual(resolvedIp, "0.0.0.0");
|
|
138
|
+
// Ensure no writes were performed
|
|
139
|
+
const writeCalls = fetchCalls.filter((c) => c.method === "POST" || c.method === "DELETE");
|
|
140
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
141
|
+
});
|
|
142
|
+
test("creates a new VM instance and runs playbooks successfully", async () => {
|
|
143
|
+
mockResponses["GET /instances/new-vm"] = {
|
|
144
|
+
status: 404,
|
|
145
|
+
body: { error: { message: "Not Found" } },
|
|
146
|
+
};
|
|
147
|
+
mockResponses["POST /instances"] = {
|
|
148
|
+
status: 200,
|
|
149
|
+
body: { id: "op-111", status: "DONE" },
|
|
150
|
+
};
|
|
151
|
+
// Subsequents GET during wait loop returns RUNNING
|
|
152
|
+
let getCount = 0;
|
|
153
|
+
globalThis.fetch = async (input, init) => {
|
|
154
|
+
const url = String(input);
|
|
155
|
+
const method = init?.method ?? "GET";
|
|
156
|
+
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
157
|
+
fetchCalls.push({ url, method, body });
|
|
158
|
+
if (method === "GET" && url.includes("/instances/new-vm")) {
|
|
159
|
+
const createCall = fetchCalls.find(c => c.method === "POST" && c.url.includes("/instances"));
|
|
160
|
+
if (!createCall) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
status: 404,
|
|
164
|
+
json: async () => ({ error: { message: "Not Found" } }),
|
|
165
|
+
text: async () => JSON.stringify({ error: { message: "Not Found" } }),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
getCount++;
|
|
169
|
+
const data = {
|
|
170
|
+
id: "new-vm-uuid",
|
|
171
|
+
name: "new-vm",
|
|
172
|
+
status: getCount > 1 ? "RUNNING" : "PROVISIONING",
|
|
173
|
+
networkInterfaces: [
|
|
174
|
+
{
|
|
175
|
+
accessConfigs: [{ natIP: "35.200.10.20" }],
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
status: 200,
|
|
182
|
+
json: async () => data,
|
|
183
|
+
text: async () => JSON.stringify(data),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (method === "POST" && url.includes("/instances")) {
|
|
187
|
+
const opData = { id: "op-111", status: "DONE" };
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
status: 200,
|
|
191
|
+
json: async () => opData,
|
|
192
|
+
text: async () => JSON.stringify(opData),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { ok: false, status: 404 };
|
|
196
|
+
};
|
|
197
|
+
const builder = new GCPVMBuilder("new-vm")
|
|
198
|
+
.machineType("e2-medium")
|
|
199
|
+
.zone("us-central1-a")
|
|
200
|
+
.sshKey("~/.ssh/id_rsa.pub")
|
|
201
|
+
.provision("playbooks/nginx.yaml");
|
|
202
|
+
const provisionCalls = [];
|
|
203
|
+
builder.waitFor = async (label, condition) => {
|
|
204
|
+
return await condition();
|
|
205
|
+
};
|
|
206
|
+
builder.checkPort = async () => true;
|
|
207
|
+
builder.runProvisioner = async (ip, script) => {
|
|
208
|
+
provisionCalls.push(script);
|
|
209
|
+
};
|
|
210
|
+
const res = await builder.deploy();
|
|
211
|
+
assert.ok(res);
|
|
212
|
+
assert.strictEqual(res.id, "new-vm-uuid");
|
|
213
|
+
assert.strictEqual(res.ip, "35.200.10.20");
|
|
214
|
+
assert.strictEqual(provisionCalls.length, 1);
|
|
215
|
+
assert.strictEqual(provisionCalls[0], "playbooks/nginx.yaml");
|
|
216
|
+
const createCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/instances"));
|
|
217
|
+
assert.ok(createCall);
|
|
218
|
+
assert.strictEqual(createCall.body.name, "new-vm");
|
|
219
|
+
assert.strictEqual(createCall.body.machineType, "zones/us-central1-a/machineTypes/e2-medium");
|
|
220
|
+
const sshMetadata = createCall.body.metadata.items.find((i) => i.key === "ssh-keys");
|
|
221
|
+
assert.strictEqual(sshMetadata.value, "root:ssh-rsa AAAA_FAKE_GCP_PUBLIC_KEY test@gcp.com");
|
|
222
|
+
const provMetadata = createCall.body.metadata.items.find((i) => i.key === "puls-provision");
|
|
223
|
+
assert.ok(provMetadata.value.startsWith("nginx-yaml="));
|
|
224
|
+
});
|
|
225
|
+
test("skips playbook execution on existing VM if hashes match", async () => {
|
|
226
|
+
const nginxHash = getFileHash("playbooks/nginx.yaml");
|
|
227
|
+
mockResponses["GET /instances/exist-vm"] = {
|
|
228
|
+
status: 200,
|
|
229
|
+
body: {
|
|
230
|
+
id: "exist-vm-id",
|
|
231
|
+
name: "exist-vm",
|
|
232
|
+
status: "RUNNING",
|
|
233
|
+
machineType: "zones/us-central1-a/machineTypes/e2-micro",
|
|
234
|
+
networkInterfaces: [
|
|
235
|
+
{
|
|
236
|
+
accessConfigs: [{ natIP: "35.200.10.30" }],
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
metadata: {
|
|
240
|
+
items: [{ key: "puls-provision", value: `nginx-yaml=${nginxHash}` }],
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
const builder = new GCPVMBuilder("exist-vm")
|
|
245
|
+
.machineType("e2-micro")
|
|
246
|
+
.zone("us-central1-a")
|
|
247
|
+
.provision("playbooks/nginx.yaml");
|
|
248
|
+
const provisionCalls = [];
|
|
249
|
+
builder.runProvisioner = async (ip, script) => {
|
|
250
|
+
provisionCalls.push(script);
|
|
251
|
+
};
|
|
252
|
+
await builder.deploy();
|
|
253
|
+
// No playbooks should run
|
|
254
|
+
assert.strictEqual(provisionCalls.length, 0);
|
|
255
|
+
// No setMetadata call
|
|
256
|
+
const setMetaCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/setMetadata"));
|
|
257
|
+
assert.strictEqual(setMetaCall, undefined);
|
|
258
|
+
});
|
|
259
|
+
test("executes playbooks on existing VM if hashes differ, updating metadata", async () => {
|
|
260
|
+
mockResponses["GET /instances/exist-diff-vm"] = {
|
|
261
|
+
status: 200,
|
|
262
|
+
body: {
|
|
263
|
+
id: "exist-diff-vm-id",
|
|
264
|
+
name: "exist-diff-vm",
|
|
265
|
+
status: "RUNNING",
|
|
266
|
+
machineType: "zones/us-central1-a/machineTypes/e2-micro",
|
|
267
|
+
networkInterfaces: [
|
|
268
|
+
{
|
|
269
|
+
accessConfigs: [{ natIP: "35.200.10.40" }],
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
metadata: {
|
|
273
|
+
fingerprint: "old-fingerprint-123",
|
|
274
|
+
items: [{ key: "puls-provision", value: "nginx-yaml=abc123123123" }],
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
mockResponses["POST /instances/exist-diff-vm/setMetadata"] = {
|
|
279
|
+
status: 200,
|
|
280
|
+
body: {},
|
|
281
|
+
};
|
|
282
|
+
const builder = new GCPVMBuilder("exist-diff-vm")
|
|
283
|
+
.machineType("e2-micro")
|
|
284
|
+
.zone("us-central1-a")
|
|
285
|
+
.provision("playbooks/nginx.yaml");
|
|
286
|
+
const provisionCalls = [];
|
|
287
|
+
builder.waitFor = async (label, condition) => {
|
|
288
|
+
return await condition();
|
|
289
|
+
};
|
|
290
|
+
builder.checkPort = async () => true;
|
|
291
|
+
builder.runProvisioner = async (ip, script) => {
|
|
292
|
+
provisionCalls.push(script);
|
|
293
|
+
};
|
|
294
|
+
await builder.deploy();
|
|
295
|
+
assert.strictEqual(provisionCalls.length, 1);
|
|
296
|
+
assert.strictEqual(provisionCalls[0], "playbooks/nginx.yaml");
|
|
297
|
+
// Verify setMetadata was dispatched with new hashes and correct fingerprint
|
|
298
|
+
const setMetaCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/setMetadata"));
|
|
299
|
+
assert.ok(setMetaCall);
|
|
300
|
+
assert.strictEqual(setMetaCall.body.fingerprint, "old-fingerprint-123");
|
|
301
|
+
const provMetadata = setMetaCall.body.items.find((i) => i.key === "puls-provision");
|
|
302
|
+
const expectedHash = getFileHash("playbooks/nginx.yaml");
|
|
303
|
+
assert.strictEqual(provMetadata.value, `nginx-yaml=${expectedHash}`);
|
|
304
|
+
});
|
|
305
|
+
test("destroys VM successfully", async () => {
|
|
306
|
+
mockResponses["GET /instances/delete-vm"] = {
|
|
307
|
+
status: 200,
|
|
308
|
+
body: { id: "delete-vm-id", name: "delete-vm" },
|
|
309
|
+
};
|
|
310
|
+
mockResponses["DELETE /instances/delete-vm"] = {
|
|
311
|
+
status: 200,
|
|
312
|
+
body: {},
|
|
313
|
+
};
|
|
314
|
+
const builder = new GCPVMBuilder("delete-vm");
|
|
315
|
+
await builder.discoveryPromise;
|
|
316
|
+
const res = await builder.destroy();
|
|
317
|
+
assert.deepStrictEqual(res, { destroyed: "delete-vm" });
|
|
318
|
+
const deleteCall = fetchCalls.find((c) => c.method === "DELETE" && c.url.includes("/instances/delete-vm"));
|
|
319
|
+
assert.ok(deleteCall);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -3,6 +3,7 @@ export declare class ProxmoxApiClient {
|
|
|
3
3
|
private authToken;
|
|
4
4
|
private dispatcher?;
|
|
5
5
|
constructor(url: string, user: string, tokenName: string, tokenSecret: string, verifySsl?: boolean);
|
|
6
|
+
private createProxmoxOfflineMock;
|
|
6
7
|
private request;
|
|
7
8
|
get<T>(path: string): Promise<T>;
|
|
8
9
|
post<T>(path: string, body?: unknown): Promise<T>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Agent } from "undici";
|
|
2
2
|
import { Config } from "../../core/config.js";
|
|
3
|
+
import { withRetry } from "../../core/retry.js";
|
|
4
|
+
import { resourceContextStorage } from "../../core/context.js";
|
|
3
5
|
export class ProxmoxApiClient {
|
|
4
6
|
baseUrl;
|
|
5
7
|
authToken;
|
|
@@ -12,22 +14,72 @@ export class ProxmoxApiClient {
|
|
|
12
14
|
this.dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
|
13
15
|
}
|
|
14
16
|
}
|
|
17
|
+
createProxmoxOfflineMock(method, path, body) {
|
|
18
|
+
if (path.includes("/nodes") && !path.includes("/qemu")) {
|
|
19
|
+
return [{ node: "mock-node", status: "online" }];
|
|
20
|
+
}
|
|
21
|
+
if (path.includes("/cluster/resources")) {
|
|
22
|
+
return [
|
|
23
|
+
{ type: "node", node: "mock-node", status: "online" },
|
|
24
|
+
{ type: "storage", node: "mock-node", storage: "rbd_pool", shared: 1 }
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
if (path.includes("/qemu")) {
|
|
28
|
+
if (path.includes("/status/current")) {
|
|
29
|
+
return { status: "running", qmpstatus: "running" };
|
|
30
|
+
}
|
|
31
|
+
if (path.includes("/config")) {
|
|
32
|
+
return { ipconfig0: "ip=10.8.10.50/24,gw=10.8.10.1" };
|
|
33
|
+
}
|
|
34
|
+
return { vmid: 100, upid: "UPID:mock-node:00001234:00005678:60000000:qmcreate:100:mock-user@pve:" };
|
|
35
|
+
}
|
|
36
|
+
return new Proxy({}, {
|
|
37
|
+
get(target, prop) {
|
|
38
|
+
if (prop === "then")
|
|
39
|
+
return undefined;
|
|
40
|
+
if (prop === "node")
|
|
41
|
+
return "mock-node";
|
|
42
|
+
if (prop === "vmid")
|
|
43
|
+
return 100;
|
|
44
|
+
if (prop === "status")
|
|
45
|
+
return "running";
|
|
46
|
+
if (prop.endsWith("s"))
|
|
47
|
+
return [];
|
|
48
|
+
return `mock-pm-${prop.toLowerCase()}`;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
15
52
|
async request(method, path, body) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
const context = resourceContextStorage.getStore();
|
|
54
|
+
const abortSignal = context?.abortSignal;
|
|
55
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
56
|
+
return Promise.resolve(this.createProxmoxOfflineMock(method, path, body));
|
|
57
|
+
}
|
|
58
|
+
return withRetry(async () => {
|
|
59
|
+
const headers = {
|
|
60
|
+
Authorization: `PVEAPIToken=${this.authToken}`,
|
|
61
|
+
};
|
|
62
|
+
if (body !== undefined)
|
|
63
|
+
headers['Content-Type'] = 'application/json';
|
|
64
|
+
const opts = { method, headers };
|
|
65
|
+
if (body !== undefined)
|
|
66
|
+
opts.body = JSON.stringify(body);
|
|
67
|
+
if (this.dispatcher)
|
|
68
|
+
opts.dispatcher = this.dispatcher;
|
|
69
|
+
if (abortSignal)
|
|
70
|
+
opts.signal = abortSignal;
|
|
71
|
+
const res = await fetch(`${this.baseUrl}${path}`, opts);
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
throw new Error(`Proxmox ${method} ${path}: ${res.status} ${await res.text()}`);
|
|
74
|
+
const json = (await res.json());
|
|
75
|
+
return json.data ?? null;
|
|
76
|
+
}, {
|
|
77
|
+
retryable: (err) => {
|
|
78
|
+
const match = err.message.match(/: (\d+)/);
|
|
79
|
+
const status = match ? parseInt(match[1], 10) : null;
|
|
80
|
+
return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
31
83
|
}
|
|
32
84
|
async get(path) {
|
|
33
85
|
return this.request("GET", path);
|
|
@@ -44,7 +96,11 @@ export class ProxmoxApiClient {
|
|
|
44
96
|
}
|
|
45
97
|
export function getPMClient() {
|
|
46
98
|
const cfg = Config.get().providers.proxmox;
|
|
47
|
-
if (!cfg?.url || !cfg?.user || !cfg?.tokenName || !cfg?.tokenSecret)
|
|
99
|
+
if (!cfg?.url || !cfg?.user || !cfg?.tokenName || !cfg?.tokenSecret) {
|
|
100
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
101
|
+
return new ProxmoxApiClient("https://10.8.4.39:8006", "mock-user@pve", "mock-token-name", "mock-token-secret", false);
|
|
102
|
+
}
|
|
48
103
|
throw new Error("Proxmox not configured. Set proxmox: { url, user, tokenName, tokenSecret } in @Deploy");
|
|
104
|
+
}
|
|
49
105
|
return new ProxmoxApiClient(cfg.url, cfg.user, cfg.tokenName, cfg.tokenSecret, cfg.verifySsl ?? true);
|
|
50
106
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { VMBuilder } from "./vm.js";
|
|
2
|
+
import { TemplateBuilder } from "./template.js";
|
|
2
3
|
export declare const Proxmox: {
|
|
3
4
|
init: (opts: {
|
|
4
5
|
url: string;
|
|
@@ -12,5 +13,6 @@ export declare const Proxmox: {
|
|
|
12
13
|
verifySsl?: boolean;
|
|
13
14
|
}) => void;
|
|
14
15
|
VM: (name: string) => VMBuilder;
|
|
16
|
+
Template: (name: string) => TemplateBuilder;
|
|
15
17
|
};
|
|
16
18
|
export * from "../../types/proxmox.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Config } from "../../core/config.js";
|
|
2
2
|
import { VMBuilder } from "./vm.js";
|
|
3
|
+
import { TemplateBuilder } from "./template.js";
|
|
3
4
|
export const Proxmox = {
|
|
4
5
|
init: (opts) => {
|
|
5
6
|
Config.set({
|
|
@@ -7,5 +8,6 @@ export const Proxmox = {
|
|
|
7
8
|
});
|
|
8
9
|
},
|
|
9
10
|
VM: (name) => new VMBuilder(name),
|
|
11
|
+
Template: (name) => new TemplateBuilder(name),
|
|
10
12
|
};
|
|
11
13
|
export * from "../../types/proxmox.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
import type { OSImage } from "../../types/proxmox.js";
|
|
4
|
+
export declare class TemplateBuilder extends BaseBuilder {
|
|
5
|
+
readonly out: {
|
|
6
|
+
vmid: Output<number>;
|
|
7
|
+
};
|
|
8
|
+
resolvedVmid: number | null;
|
|
9
|
+
resolvedNode: string | null;
|
|
10
|
+
private _baseImage?;
|
|
11
|
+
private _cores;
|
|
12
|
+
private _memory;
|
|
13
|
+
private _provision;
|
|
14
|
+
private _storage?;
|
|
15
|
+
private _sshKeys?;
|
|
16
|
+
constructor(name: string);
|
|
17
|
+
private discoverTemplate;
|
|
18
|
+
baseImage(os: OSImage): this;
|
|
19
|
+
cores(n: number): this;
|
|
20
|
+
memory(mb: number): this;
|
|
21
|
+
storage(pool: string): this;
|
|
22
|
+
sshKey(keys: string | readonly string[]): this;
|
|
23
|
+
provision(...playbookPaths: (string | string[])[]): this;
|
|
24
|
+
deploy(): Promise<{
|
|
25
|
+
name: string;
|
|
26
|
+
vmid: number | null;
|
|
27
|
+
node: string | null;
|
|
28
|
+
} | {
|
|
29
|
+
name: string;
|
|
30
|
+
vmid: string;
|
|
31
|
+
node?: undefined;
|
|
32
|
+
}>;
|
|
33
|
+
destroy(): Promise<{
|
|
34
|
+
destroyed: boolean;
|
|
35
|
+
} | {
|
|
36
|
+
destroyed: string;
|
|
37
|
+
}>;
|
|
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
|
+
}
|