puls-dev 0.2.9 → 0.3.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.
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.js +1 -0
- package/dist/core/group.test.d.ts +1 -0
- package/dist/core/group.test.js +94 -0
- package/dist/core/resource.d.ts +7 -0
- package/dist/core/resource.js +35 -0
- package/dist/core/stack.js +77 -4
- package/dist/providers/aws/secrets.js +20 -3
- package/dist/providers/gcp/secrets.js +20 -3
- package/dist/providers/proxmox/index.d.ts +3 -2
- package/dist/providers/proxmox/index.js +13 -2
- package/dist/providers/proxmox/template.js +3 -2
- package/dist/providers/proxmox/template.test.js +43 -4
- package/dist/providers/proxmox/vm.js +3 -2
- package/package.json +1 -1
package/dist/core/config.d.ts
CHANGED
package/dist/core/config.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { test, describe } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { BaseBuilder, createBuilderArray } from "./resource.js";
|
|
4
|
+
import { Stack } from "./stack.js";
|
|
5
|
+
import { Config } from "./config.js";
|
|
6
|
+
class MockResourceBuilder extends BaseBuilder {
|
|
7
|
+
val = 0;
|
|
8
|
+
strVal = "";
|
|
9
|
+
deployCount = 0;
|
|
10
|
+
destroyCount = 0;
|
|
11
|
+
constructor(name) {
|
|
12
|
+
super(name);
|
|
13
|
+
}
|
|
14
|
+
setVal(n) {
|
|
15
|
+
// Overloaded to support array assignment in proxy test
|
|
16
|
+
if (typeof n === "number") {
|
|
17
|
+
this.val = n;
|
|
18
|
+
}
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
setStr(s) {
|
|
22
|
+
if (typeof s === "string") {
|
|
23
|
+
this.strVal = s;
|
|
24
|
+
}
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
async deploy() {
|
|
28
|
+
this.deployCount++;
|
|
29
|
+
return { name: this.name, val: this.val, strVal: this.strVal };
|
|
30
|
+
}
|
|
31
|
+
async destroy() {
|
|
32
|
+
this.destroyCount++;
|
|
33
|
+
return { destroyedName: this.name };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
describe("Resource Builder Groups Unit Tests", () => {
|
|
37
|
+
test("createBuilderArray proxies and chains methods", () => {
|
|
38
|
+
const b1 = new MockResourceBuilder("r1");
|
|
39
|
+
const b2 = new MockResourceBuilder("r2");
|
|
40
|
+
const group = createBuilderArray([b1, b2]);
|
|
41
|
+
// Test method chaining and uniform distribution
|
|
42
|
+
group.setVal(42).setStr("hello");
|
|
43
|
+
assert.strictEqual(b1.val, 42);
|
|
44
|
+
assert.strictEqual(b2.val, 42);
|
|
45
|
+
assert.strictEqual(b1.strVal, "hello");
|
|
46
|
+
assert.strictEqual(b2.strVal, "hello");
|
|
47
|
+
});
|
|
48
|
+
test("createBuilderArray distributes array arguments by index", () => {
|
|
49
|
+
const b1 = new MockResourceBuilder("r1");
|
|
50
|
+
const b2 = new MockResourceBuilder("r2");
|
|
51
|
+
const group = createBuilderArray([b1, b2]);
|
|
52
|
+
// Test index-based distribution
|
|
53
|
+
group.setVal([10, 20]).setStr(["first", "second"]);
|
|
54
|
+
// In MockResourceBuilder, calling setVal with [10, 20] gets distributed by proxy:
|
|
55
|
+
// b1 gets setVal(10) -> val = 10
|
|
56
|
+
// b2 gets setVal(20) -> val = 20
|
|
57
|
+
assert.strictEqual(b1.val, 10);
|
|
58
|
+
assert.strictEqual(b2.val, 20);
|
|
59
|
+
assert.strictEqual(b1.strVal, "first");
|
|
60
|
+
assert.strictEqual(b2.strVal, "second");
|
|
61
|
+
});
|
|
62
|
+
test("Stack deploy gathers array builders and structures outputs as array", async () => {
|
|
63
|
+
Config.set({ dryRun: false });
|
|
64
|
+
const b1 = new MockResourceBuilder("s1");
|
|
65
|
+
const b2 = new MockResourceBuilder("s2");
|
|
66
|
+
class ArrayStack extends Stack {
|
|
67
|
+
servers = createBuilderArray([b1, b2]).setVal([100, 200]);
|
|
68
|
+
}
|
|
69
|
+
const stack = new ArrayStack();
|
|
70
|
+
const result = await stack.deploy();
|
|
71
|
+
assert.strictEqual(b1.deployCount, 1);
|
|
72
|
+
assert.strictEqual(b2.deployCount, 1);
|
|
73
|
+
assert.ok(Array.isArray(result.servers));
|
|
74
|
+
assert.strictEqual(result.servers.length, 2);
|
|
75
|
+
assert.deepStrictEqual(result.servers[0], { name: "s1", val: 100, strVal: "" });
|
|
76
|
+
assert.deepStrictEqual(result.servers[1], { name: "s2", val: 200, strVal: "" });
|
|
77
|
+
});
|
|
78
|
+
test("Stack destroy gathers array builders and structures outputs as array", async () => {
|
|
79
|
+
Config.set({ dryRun: false });
|
|
80
|
+
const b1 = new MockResourceBuilder("s1");
|
|
81
|
+
const b2 = new MockResourceBuilder("s2");
|
|
82
|
+
class ArrayStack extends Stack {
|
|
83
|
+
servers = createBuilderArray([b1, b2]);
|
|
84
|
+
}
|
|
85
|
+
const stack = new ArrayStack();
|
|
86
|
+
const result = await stack.destroy();
|
|
87
|
+
assert.strictEqual(b1.destroyCount, 1);
|
|
88
|
+
assert.strictEqual(b2.destroyCount, 1);
|
|
89
|
+
assert.ok(Array.isArray(result.servers));
|
|
90
|
+
assert.strictEqual(result.servers.length, 2);
|
|
91
|
+
assert.deepStrictEqual(result.servers[0], { destroyedName: "s1" });
|
|
92
|
+
assert.deepStrictEqual(result.servers[1], { destroyedName: "s2" });
|
|
93
|
+
});
|
|
94
|
+
});
|
package/dist/core/resource.d.ts
CHANGED
|
@@ -41,3 +41,10 @@ export declare abstract class BaseBuilder {
|
|
|
41
41
|
destroy(): Promise<any>;
|
|
42
42
|
abstract deploy(): Promise<any>;
|
|
43
43
|
}
|
|
44
|
+
export type DistributeArgs<Args extends any[]> = {
|
|
45
|
+
[K in keyof Args]: Args[K] | Args[K][];
|
|
46
|
+
};
|
|
47
|
+
export type BuilderGroup<B extends BaseBuilder> = B[] & {
|
|
48
|
+
[K in keyof B]: B[K] extends (...args: infer Args) => any ? (...args: DistributeArgs<Args>) => BuilderGroup<B> : B[K];
|
|
49
|
+
};
|
|
50
|
+
export declare function createBuilderArray<T extends BaseBuilder>(builders: T[]): BuilderGroup<T>;
|
package/dist/core/resource.js
CHANGED
|
@@ -129,3 +129,38 @@ export class BaseBuilder {
|
|
|
129
129
|
return { destroyed: this.name };
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
+
export function createBuilderArray(builders) {
|
|
133
|
+
return new Proxy(builders, {
|
|
134
|
+
get(target, prop, receiver) {
|
|
135
|
+
if (prop in target) {
|
|
136
|
+
const val = Reflect.get(target, prop, receiver);
|
|
137
|
+
if (typeof val === "function") {
|
|
138
|
+
return val.bind(target);
|
|
139
|
+
}
|
|
140
|
+
return val;
|
|
141
|
+
}
|
|
142
|
+
if (builders.length > 0) {
|
|
143
|
+
const first = builders[0];
|
|
144
|
+
const val = Reflect.get(first, prop);
|
|
145
|
+
if (typeof val === "function") {
|
|
146
|
+
return function (...args) {
|
|
147
|
+
builders.forEach((b, idx) => {
|
|
148
|
+
const method = Reflect.get(b, prop);
|
|
149
|
+
if (typeof method === "function") {
|
|
150
|
+
const mappedArgs = args.map((arg) => {
|
|
151
|
+
if (Array.isArray(arg) && arg.length === builders.length) {
|
|
152
|
+
return arg[idx];
|
|
153
|
+
}
|
|
154
|
+
return arg;
|
|
155
|
+
});
|
|
156
|
+
method.apply(b, mappedArgs);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
return receiver;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
package/dist/core/stack.js
CHANGED
|
@@ -170,6 +170,20 @@ export class Stack {
|
|
|
170
170
|
val.forceConfigCheck();
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
|
+
else if (Array.isArray(val)) {
|
|
174
|
+
const isProtected = Reflect.getMetadata("protected", this, prop);
|
|
175
|
+
const forceConfigCheck = Reflect.getMetadata("forceConfigCheck", this, prop);
|
|
176
|
+
for (const item of val) {
|
|
177
|
+
if (item instanceof BaseBuilder) {
|
|
178
|
+
resources.push({ prop, resource: item });
|
|
179
|
+
if (isProtected)
|
|
180
|
+
item.protect();
|
|
181
|
+
if (forceConfigCheck && typeof item.forceConfigCheck === "function") {
|
|
182
|
+
item.forceConfigCheck();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
173
187
|
}
|
|
174
188
|
// 2. Schedule execution
|
|
175
189
|
if (isParallel) {
|
|
@@ -203,7 +217,19 @@ export class Stack {
|
|
|
203
217
|
res = await resource.deploy();
|
|
204
218
|
await resource._runAfterDeploy(res);
|
|
205
219
|
}
|
|
206
|
-
|
|
220
|
+
const propVal = this[prop];
|
|
221
|
+
if (Array.isArray(propVal)) {
|
|
222
|
+
const idx = propVal.indexOf(resource);
|
|
223
|
+
if (idx !== -1) {
|
|
224
|
+
if (!outputs[prop]) {
|
|
225
|
+
outputs[prop] = [];
|
|
226
|
+
}
|
|
227
|
+
outputs[prop][idx] = res;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
outputs[prop] = res;
|
|
232
|
+
}
|
|
207
233
|
return res;
|
|
208
234
|
}
|
|
209
235
|
catch (err) {
|
|
@@ -234,7 +260,19 @@ export class Stack {
|
|
|
234
260
|
res = await resource.deploy();
|
|
235
261
|
await resource._runAfterDeploy(res);
|
|
236
262
|
}
|
|
237
|
-
|
|
263
|
+
const propVal = this[prop];
|
|
264
|
+
if (Array.isArray(propVal)) {
|
|
265
|
+
const idx = propVal.indexOf(resource);
|
|
266
|
+
if (idx !== -1) {
|
|
267
|
+
if (!outputs[prop]) {
|
|
268
|
+
outputs[prop] = [];
|
|
269
|
+
}
|
|
270
|
+
outputs[prop][idx] = res;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
outputs[prop] = res;
|
|
275
|
+
}
|
|
238
276
|
}
|
|
239
277
|
catch (err) {
|
|
240
278
|
controller.abort();
|
|
@@ -322,6 +360,17 @@ export class Stack {
|
|
|
322
360
|
}
|
|
323
361
|
resources.push({ prop, resource: val });
|
|
324
362
|
}
|
|
363
|
+
else if (Array.isArray(val)) {
|
|
364
|
+
if (Reflect.getMetadata("protected", this, prop)) {
|
|
365
|
+
console.log(` 🔒 Skipping protected resource "${prop}"`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
for (const item of val) {
|
|
369
|
+
if (item instanceof BaseBuilder) {
|
|
370
|
+
resources.push({ prop, resource: item });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
325
374
|
}
|
|
326
375
|
// 2. Schedule execution
|
|
327
376
|
if (isParallel) {
|
|
@@ -348,7 +397,19 @@ export class Stack {
|
|
|
348
397
|
await resource._runBeforeDestroy();
|
|
349
398
|
const res = await resource.destroy();
|
|
350
399
|
await resource._runAfterDestroy(res);
|
|
351
|
-
|
|
400
|
+
const propVal = this[prop];
|
|
401
|
+
if (Array.isArray(propVal)) {
|
|
402
|
+
const idx = propVal.indexOf(resource);
|
|
403
|
+
if (idx !== -1) {
|
|
404
|
+
if (!outputs[prop]) {
|
|
405
|
+
outputs[prop] = [];
|
|
406
|
+
}
|
|
407
|
+
outputs[prop][idx] = res;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
outputs[prop] = res;
|
|
412
|
+
}
|
|
352
413
|
return res;
|
|
353
414
|
}
|
|
354
415
|
catch (err) {
|
|
@@ -370,7 +431,19 @@ export class Stack {
|
|
|
370
431
|
await resource._runBeforeDestroy();
|
|
371
432
|
const res = await resource.destroy();
|
|
372
433
|
await resource._runAfterDestroy(res);
|
|
373
|
-
|
|
434
|
+
const propVal = this[prop];
|
|
435
|
+
if (Array.isArray(propVal)) {
|
|
436
|
+
const idx = propVal.indexOf(resource);
|
|
437
|
+
if (idx !== -1) {
|
|
438
|
+
if (!outputs[prop]) {
|
|
439
|
+
outputs[prop] = [];
|
|
440
|
+
}
|
|
441
|
+
outputs[prop][idx] = res;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
outputs[prop] = res;
|
|
446
|
+
}
|
|
374
447
|
}
|
|
375
448
|
catch (err) {
|
|
376
449
|
controller.abort();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { GetSecretValueCommand, CreateSecretCommand, PutSecretValueCommand, DeleteSecretCommand, } from "@aws-sdk/client-secrets-manager";
|
|
2
2
|
import { BaseBuilder } from "../../core/resource.js";
|
|
3
3
|
import { getSecretsClient } from "./api.js";
|
|
4
|
+
import { resolvedSecrets } from "../../core/secret.js";
|
|
4
5
|
export class SecretsBuilder extends BaseBuilder {
|
|
5
6
|
_value;
|
|
6
7
|
_description;
|
|
@@ -16,6 +17,9 @@ export class SecretsBuilder extends BaseBuilder {
|
|
|
16
17
|
try {
|
|
17
18
|
const result = await getSecretsClient().send(new GetSecretValueCommand({ SecretId: secretId }));
|
|
18
19
|
this.resolvedValue = result.SecretString ?? null;
|
|
20
|
+
if (this.resolvedValue && this.resolvedValue.length >= 3) {
|
|
21
|
+
resolvedSecrets.add(this.resolvedValue);
|
|
22
|
+
}
|
|
19
23
|
this.resolvedArn = result.ARN ?? null;
|
|
20
24
|
return result;
|
|
21
25
|
}
|
|
@@ -38,10 +42,17 @@ export class SecretsBuilder extends BaseBuilder {
|
|
|
38
42
|
}
|
|
39
43
|
plainText(v) {
|
|
40
44
|
this._value = v;
|
|
45
|
+
if (v && v.length >= 3) {
|
|
46
|
+
resolvedSecrets.add(v);
|
|
47
|
+
}
|
|
41
48
|
return this;
|
|
42
49
|
}
|
|
43
50
|
keyValue(obj) {
|
|
44
|
-
|
|
51
|
+
const v = JSON.stringify(obj);
|
|
52
|
+
this._value = v;
|
|
53
|
+
if (v && v.length >= 3) {
|
|
54
|
+
resolvedSecrets.add(v);
|
|
55
|
+
}
|
|
45
56
|
return this;
|
|
46
57
|
}
|
|
47
58
|
description(d) {
|
|
@@ -61,7 +72,7 @@ export class SecretsBuilder extends BaseBuilder {
|
|
|
61
72
|
if (existing) {
|
|
62
73
|
console.log(` ✅ Secret "${this.name}" exists`);
|
|
63
74
|
if (this.resolvedValue !== null)
|
|
64
|
-
console.log(` 💬 Value:
|
|
75
|
+
console.log(` 💬 Value: ********`);
|
|
65
76
|
if (this._value)
|
|
66
77
|
console.log(` 📝 [PLAN] Update secret value`);
|
|
67
78
|
}
|
|
@@ -88,18 +99,24 @@ export class SecretsBuilder extends BaseBuilder {
|
|
|
88
99
|
}));
|
|
89
100
|
this.resolvedArn = result.ARN ?? null;
|
|
90
101
|
this.resolvedValue = this._value;
|
|
102
|
+
if (this._value && this._value.length >= 3) {
|
|
103
|
+
resolvedSecrets.add(this._value);
|
|
104
|
+
}
|
|
91
105
|
console.log(`🚀 Created secret "${this.name}"`);
|
|
92
106
|
}
|
|
93
107
|
else {
|
|
94
108
|
console.log(` ✅ Secret "${this.name}" exists`);
|
|
95
109
|
if (this.resolvedValue !== null)
|
|
96
|
-
console.log(` 💬 Value:
|
|
110
|
+
console.log(` 💬 Value: ********`);
|
|
97
111
|
if (this._value && this._value !== this.resolvedValue) {
|
|
98
112
|
await client.send(new PutSecretValueCommand({
|
|
99
113
|
SecretId: this.name,
|
|
100
114
|
SecretString: this._value,
|
|
101
115
|
}));
|
|
102
116
|
this.resolvedValue = this._value;
|
|
117
|
+
if (this._value && this._value.length >= 3) {
|
|
118
|
+
resolvedSecrets.add(this._value);
|
|
119
|
+
}
|
|
103
120
|
console.log(` ✅ Updated secret value`);
|
|
104
121
|
}
|
|
105
122
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseBuilder } from "../../core/resource.js";
|
|
2
2
|
import { gcpFetch, getProjectId } from "./api.js";
|
|
3
|
+
import { resolvedSecrets } from "../../core/secret.js";
|
|
3
4
|
const SECRET_BASE = "https://secretmanager.googleapis.com";
|
|
4
5
|
export class GCPSecretBuilder extends BaseBuilder {
|
|
5
6
|
_value;
|
|
@@ -20,6 +21,9 @@ export class GCPSecretBuilder extends BaseBuilder {
|
|
|
20
21
|
const payload = await gcpFetch(SECRET_BASE, `/v1/projects/${project}/secrets/${secretId}/versions/latest:access`);
|
|
21
22
|
if (payload.payload?.data) {
|
|
22
23
|
this.resolvedValue = Buffer.from(payload.payload.data, "base64").toString("utf8");
|
|
24
|
+
if (this.resolvedValue && this.resolvedValue.length >= 3) {
|
|
25
|
+
resolvedSecrets.add(this.resolvedValue);
|
|
26
|
+
}
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
catch (err) {
|
|
@@ -42,10 +46,17 @@ export class GCPSecretBuilder extends BaseBuilder {
|
|
|
42
46
|
}
|
|
43
47
|
plainText(v) {
|
|
44
48
|
this._value = v;
|
|
49
|
+
if (v && v.length >= 3) {
|
|
50
|
+
resolvedSecrets.add(v);
|
|
51
|
+
}
|
|
45
52
|
return this;
|
|
46
53
|
}
|
|
47
54
|
keyValue(obj) {
|
|
48
|
-
|
|
55
|
+
const v = JSON.stringify(obj);
|
|
56
|
+
this._value = v;
|
|
57
|
+
if (v && v.length >= 3) {
|
|
58
|
+
resolvedSecrets.add(v);
|
|
59
|
+
}
|
|
49
60
|
return this;
|
|
50
61
|
}
|
|
51
62
|
async deploy() {
|
|
@@ -58,7 +69,7 @@ export class GCPSecretBuilder extends BaseBuilder {
|
|
|
58
69
|
if (existing) {
|
|
59
70
|
console.log(` ✅ Secret "${secretId}" exists`);
|
|
60
71
|
if (this.resolvedValue !== null) {
|
|
61
|
-
console.log(` 💬 Value:
|
|
72
|
+
console.log(` 💬 Value: ********`);
|
|
62
73
|
}
|
|
63
74
|
if (this._value) {
|
|
64
75
|
console.log(` 📝 [PLAN] Update secret value`);
|
|
@@ -101,12 +112,15 @@ export class GCPSecretBuilder extends BaseBuilder {
|
|
|
101
112
|
}),
|
|
102
113
|
});
|
|
103
114
|
this.resolvedValue = this._value;
|
|
115
|
+
if (this._value && this._value.length >= 3) {
|
|
116
|
+
resolvedSecrets.add(this._value);
|
|
117
|
+
}
|
|
104
118
|
console.log(`🚀 Created secret "${secretId}"`);
|
|
105
119
|
}
|
|
106
120
|
else {
|
|
107
121
|
console.log(` ✅ Secret "${secretId}" exists`);
|
|
108
122
|
if (this.resolvedValue !== null) {
|
|
109
|
-
console.log(` 💬 Value:
|
|
123
|
+
console.log(` 💬 Value: ********`);
|
|
110
124
|
}
|
|
111
125
|
if (this._value && this._value !== this.resolvedValue) {
|
|
112
126
|
const base64Data = Buffer.from(this._value, "utf8").toString("base64");
|
|
@@ -119,6 +133,9 @@ export class GCPSecretBuilder extends BaseBuilder {
|
|
|
119
133
|
}),
|
|
120
134
|
});
|
|
121
135
|
this.resolvedValue = this._value;
|
|
136
|
+
if (this._value && this._value.length >= 3) {
|
|
137
|
+
resolvedSecrets.add(this._value);
|
|
138
|
+
}
|
|
122
139
|
console.log(` ✅ Updated secret value`);
|
|
123
140
|
}
|
|
124
141
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { VMBuilder } from "./vm.js";
|
|
2
2
|
import { TemplateBuilder } from "./template.js";
|
|
3
|
+
import { BuilderGroup } from "../../core/resource.js";
|
|
3
4
|
export declare const Proxmox: {
|
|
4
5
|
init: (opts: {
|
|
5
6
|
url: string;
|
|
@@ -12,7 +13,7 @@ export declare const Proxmox: {
|
|
|
12
13
|
dnsServers?: string[];
|
|
13
14
|
verifySsl?: boolean;
|
|
14
15
|
}) => void;
|
|
15
|
-
VM: (name:
|
|
16
|
-
Template: (name:
|
|
16
|
+
VM: <T extends string | string[]>(name: T) => T extends string[] ? BuilderGroup<VMBuilder> : VMBuilder;
|
|
17
|
+
Template: <T extends string | string[]>(name: T) => T extends string[] ? BuilderGroup<TemplateBuilder> : TemplateBuilder;
|
|
17
18
|
};
|
|
18
19
|
export * from "../../types/proxmox.js";
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { Config } from "../../core/config.js";
|
|
2
2
|
import { VMBuilder } from "./vm.js";
|
|
3
3
|
import { TemplateBuilder } from "./template.js";
|
|
4
|
+
import { createBuilderArray } from "../../core/resource.js";
|
|
4
5
|
export const Proxmox = {
|
|
5
6
|
init: (opts) => {
|
|
6
7
|
Config.set({
|
|
7
8
|
providers: { ...Config.get().providers, proxmox: opts },
|
|
8
9
|
});
|
|
9
10
|
},
|
|
10
|
-
VM: (name) =>
|
|
11
|
-
|
|
11
|
+
VM: (name) => {
|
|
12
|
+
if (Array.isArray(name)) {
|
|
13
|
+
return createBuilderArray(name.map((n) => new VMBuilder(n)));
|
|
14
|
+
}
|
|
15
|
+
return new VMBuilder(name);
|
|
16
|
+
},
|
|
17
|
+
Template: (name) => {
|
|
18
|
+
if (Array.isArray(name)) {
|
|
19
|
+
return createBuilderArray(name.map((n) => new TemplateBuilder(n)));
|
|
20
|
+
}
|
|
21
|
+
return new TemplateBuilder(name);
|
|
22
|
+
},
|
|
12
23
|
};
|
|
13
24
|
export * from "../../types/proxmox.js";
|
|
@@ -166,14 +166,15 @@ export class TemplateBuilder extends BaseBuilder {
|
|
|
166
166
|
}
|
|
167
167
|
if (baseTemplate) {
|
|
168
168
|
console.log(` 📋 Cloning base template "${baseTemplate.name}" (vmid=${baseTemplate.vmid}) → "${this.name}" (vmid=${newVmid})`);
|
|
169
|
-
const taskId = await pm.post(`/nodes/${node}/qemu/${baseTemplate.vmid}/clone`, {
|
|
169
|
+
const taskId = await pm.post(`/nodes/${baseTemplate.node || node}/qemu/${baseTemplate.vmid}/clone`, {
|
|
170
170
|
newid: newVmid,
|
|
171
171
|
name: this.name,
|
|
172
172
|
full: 1,
|
|
173
173
|
storage,
|
|
174
174
|
format: "raw",
|
|
175
|
+
target: node,
|
|
175
176
|
});
|
|
176
|
-
await this.waitForTask(node, taskId, pm);
|
|
177
|
+
await this.waitForTask(baseTemplate.node || node, taskId, pm);
|
|
177
178
|
}
|
|
178
179
|
else {
|
|
179
180
|
console.log(` 🆕 Creating blank VM "${this.name}" (vmid=${newVmid})`);
|
|
@@ -139,13 +139,13 @@ describe("Proxmox TemplateBuilder Unit Tests", () => {
|
|
|
139
139
|
const templateCall = clientCalls.find(c => c.method === "POST" && c.path === "/nodes/pve1/qemu/600/template");
|
|
140
140
|
assert.ok(templateCall);
|
|
141
141
|
});
|
|
142
|
-
test("VM clones from Template successfully", async () => {
|
|
142
|
+
test("VM clones from Template successfully across nodes", async () => {
|
|
143
143
|
const nginxHash = getFileHash("playbooks/nginx.yaml");
|
|
144
144
|
const notes = mergeProvisionMetadata("Pre-baked template notes", {
|
|
145
145
|
"nginx.yaml": nginxHash,
|
|
146
146
|
});
|
|
147
147
|
mockGetResponses["/cluster/resources?type=vm"] = [
|
|
148
|
-
// Template exists
|
|
148
|
+
// Template exists on pve1
|
|
149
149
|
{ name: "my-game-template", vmid: 500, node: "pve1", template: 1 },
|
|
150
150
|
];
|
|
151
151
|
mockGetResponses["/nodes/pve1/qemu/500/config"] = {
|
|
@@ -153,7 +153,8 @@ describe("Proxmox TemplateBuilder Unit Tests", () => {
|
|
|
153
153
|
};
|
|
154
154
|
mockGetResponses["/cluster/nextid"] = 205;
|
|
155
155
|
mockGetResponses["/nodes"] = [
|
|
156
|
-
|
|
156
|
+
// Target node is pve2
|
|
157
|
+
{ node: "pve2", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 12 * 1024 * 1024 * 1024 }
|
|
157
158
|
];
|
|
158
159
|
class ProxmoxStack extends Stack {
|
|
159
160
|
template = new TemplateBuilder("my-game-template")
|
|
@@ -170,10 +171,48 @@ describe("Proxmox TemplateBuilder Unit Tests", () => {
|
|
|
170
171
|
// Verify VM cloned successfully
|
|
171
172
|
assert.strictEqual(result.template.vmid, 500);
|
|
172
173
|
assert.strictEqual(result.server.vmid, 205);
|
|
173
|
-
// Verify clone POST used the template's
|
|
174
|
+
// Verify clone POST used the template's node (pve1) and vmid (500), but with target (pve2)
|
|
174
175
|
const cloneCall = clientCalls.find(c => c.method === "POST" && c.path === "/nodes/pve1/qemu/500/clone");
|
|
175
176
|
assert.ok(cloneCall);
|
|
176
177
|
assert.strictEqual(cloneCall.body.newid, 205);
|
|
177
178
|
assert.strictEqual(cloneCall.body.name, "my-prod-game-01");
|
|
179
|
+
assert.strictEqual(cloneCall.body.target, "pve2");
|
|
180
|
+
});
|
|
181
|
+
test("Template bakes from baseTemplate on a different node successfully", async () => {
|
|
182
|
+
mockGetResponses["/cluster/resources?type=vm"] = [
|
|
183
|
+
// Base template exists on pve1
|
|
184
|
+
{ name: "ubuntu-base", vmid: 9000, node: "pve1", template: 1 },
|
|
185
|
+
];
|
|
186
|
+
mockGetResponses["/nodes"] = [
|
|
187
|
+
// Target node pve2 has more free RAM than pve1
|
|
188
|
+
{ node: "pve1", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 20 * 1024 * 1024 * 1024 },
|
|
189
|
+
{ node: "pve2", status: "online", maxmem: 32 * 1024 * 1024 * 1024, mem: 5 * 1024 * 1024 * 1024 },
|
|
190
|
+
];
|
|
191
|
+
mockGetResponses["/cluster/nextid"] = 9001;
|
|
192
|
+
mockGetResponses["/nodes/pve2/qemu/9001/agent/network-get-interfaces"] = [
|
|
193
|
+
{
|
|
194
|
+
name: "eth0",
|
|
195
|
+
"ip-addresses": [
|
|
196
|
+
{ "ip-address-type": "ipv4", "ip-address": "10.8.10.199" }
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
];
|
|
200
|
+
const builder = new TemplateBuilder("my-new-template")
|
|
201
|
+
.baseImage("ubuntu-base")
|
|
202
|
+
.provision("playbooks/nginx.yaml");
|
|
203
|
+
builder.waitFor = async (label, condition) => {
|
|
204
|
+
return await condition();
|
|
205
|
+
};
|
|
206
|
+
builder.checkPort = async () => true;
|
|
207
|
+
builder.checkCloudInit = async () => true;
|
|
208
|
+
builder.runProvisioner = async () => { };
|
|
209
|
+
const result = await builder.deploy();
|
|
210
|
+
assert.strictEqual(result.vmid, 9001);
|
|
211
|
+
assert.strictEqual(result.node, "pve2");
|
|
212
|
+
// Verify clone POST was sent to pve1 (source node) but targeted pve2
|
|
213
|
+
const cloneCall = clientCalls.find(c => c.method === "POST" && c.path === "/nodes/pve1/qemu/9000/clone");
|
|
214
|
+
assert.ok(cloneCall);
|
|
215
|
+
assert.strictEqual(cloneCall.body.newid, 9001);
|
|
216
|
+
assert.strictEqual(cloneCall.body.target, "pve2");
|
|
178
217
|
});
|
|
179
218
|
});
|
|
@@ -266,15 +266,16 @@ export class VMBuilder extends BaseBuilder {
|
|
|
266
266
|
const storage = this._storage ?? "rbd_pool";
|
|
267
267
|
if (template) {
|
|
268
268
|
console.log(` 📋 Cloning template "${template.name}" (vmid=${template.vmid}) → "${this.name}" (vmid=${newVmid})`);
|
|
269
|
-
const taskId = await pm.post(`/nodes/${node}/qemu/${template.vmid}/clone`, {
|
|
269
|
+
const taskId = await pm.post(`/nodes/${template.node || node}/qemu/${template.vmid}/clone`, {
|
|
270
270
|
newid: newVmid,
|
|
271
271
|
name: this.name,
|
|
272
272
|
full: 1,
|
|
273
273
|
storage,
|
|
274
274
|
format: "raw",
|
|
275
|
+
target: node,
|
|
275
276
|
});
|
|
276
277
|
// Clone is async - wait for the Proxmox task to finish before configuring
|
|
277
|
-
await this.waitForTask(node, taskId, pm);
|
|
278
|
+
await this.waitForTask(template.node || node, taskId, pm);
|
|
278
279
|
}
|
|
279
280
|
else {
|
|
280
281
|
console.log(` 🆕 Creating blank VM "${this.name}" (vmid=${newVmid})`);
|