puls-dev 0.2.8 → 0.3.0
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 +5 -0
- package/dist/core/config.js +12 -1
- package/dist/core/context.d.ts +14 -0
- package/dist/core/context.js +2 -0
- package/dist/core/decorators.d.ts +2 -0
- package/dist/core/decorators.js +8 -14
- package/dist/core/group.test.d.ts +1 -0
- package/dist/core/group.test.js +94 -0
- package/dist/core/parallel.test.d.ts +1 -0
- package/dist/core/parallel.test.js +215 -0
- package/dist/core/production.test.d.ts +1 -0
- package/dist/core/production.test.js +189 -0
- package/dist/core/provisioner.js +29 -11
- package/dist/core/resource.d.ts +8 -0
- package/dist/core/resource.js +45 -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 +2 -1
- package/dist/core/secret.js +12 -2
- package/dist/core/stack.js +381 -75
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/providers/aws/api.js +97 -17
- package/dist/providers/aws/ec2.d.ts +3 -0
- package/dist/providers/aws/ec2.js +37 -3
- package/dist/providers/aws/ec2.test.js +5 -3
- package/dist/providers/aws/index.d.ts +2 -0
- package/dist/providers/aws/index.js +2 -0
- package/dist/providers/aws/secrets.js +20 -3
- 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 +2 -0
- package/dist/providers/do/api.js +124 -26
- package/dist/providers/do/droplet.js +14 -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/index.d.ts +3 -1
- package/dist/providers/gcp/index.js +3 -1
- package/dist/providers/gcp/list.d.ts +2 -0
- package/dist/providers/gcp/list.js +55 -0
- package/dist/providers/gcp/secrets.js +21 -4
- 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 +3 -0
- package/dist/providers/gcp/vm.js +46 -3
- package/dist/providers/proxmox/api.d.ts +1 -0
- package/dist/providers/proxmox/api.js +72 -16
- package/dist/providers/proxmox/index.d.ts +3 -1
- package/dist/providers/proxmox/index.js +14 -1
- package/dist/providers/proxmox/template.d.ts +44 -0
- package/dist/providers/proxmox/template.js +350 -0
- package/dist/providers/proxmox/template.test.d.ts +1 -0
- package/dist/providers/proxmox/template.test.js +215 -0
- package/dist/providers/proxmox/vm.d.ts +3 -0
- package/dist/providers/proxmox/vm.js +43 -11
- package/dist/types/inventory.d.ts +44 -1
- package/package.json +2 -2
|
@@ -0,0 +1,227 @@
|
|
|
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 { GCPTemplateBuilder } from "./template.js";
|
|
6
|
+
import { GCPVMBuilder } from "./vm.js";
|
|
7
|
+
import { Config } from "../../core/config.js";
|
|
8
|
+
import { getFileHash } from "../proxmox/hash.js";
|
|
9
|
+
import { Stack } from "../../core/stack.js";
|
|
10
|
+
describe("GCPTemplateBuilder Unit Tests", () => {
|
|
11
|
+
let originalFetch;
|
|
12
|
+
let fetchCalls = [];
|
|
13
|
+
let mockResponses = {};
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
Config.set({
|
|
16
|
+
dryRun: false,
|
|
17
|
+
providers: {
|
|
18
|
+
gcp: {
|
|
19
|
+
projectId: "my-gcp-project",
|
|
20
|
+
serviceAccountPath: "/fake/sa.json",
|
|
21
|
+
region: "us-central1",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
originalFetch = globalThis.fetch;
|
|
26
|
+
fetchCalls = [];
|
|
27
|
+
mockResponses = {};
|
|
28
|
+
globalThis.fetch = async (input, init) => {
|
|
29
|
+
const url = String(input);
|
|
30
|
+
const method = init?.method ?? "GET";
|
|
31
|
+
let body;
|
|
32
|
+
if (init?.body) {
|
|
33
|
+
if (typeof init.body === "string") {
|
|
34
|
+
try {
|
|
35
|
+
body = JSON.parse(init.body);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
body = init.body;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
body = "[Binary/Buffer Body]";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const headers = init?.headers;
|
|
46
|
+
fetchCalls.push({ url, method, body, headers });
|
|
47
|
+
const matchKey = Object.keys(mockResponses)
|
|
48
|
+
.filter((key) => {
|
|
49
|
+
const [mMethod, mPath] = key.split(" ");
|
|
50
|
+
return method === mMethod && url.includes(mPath);
|
|
51
|
+
})
|
|
52
|
+
.sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
|
|
53
|
+
if (matchKey) {
|
|
54
|
+
const resp = mockResponses[matchKey];
|
|
55
|
+
return {
|
|
56
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
57
|
+
status: resp.status,
|
|
58
|
+
json: async () => resp.body,
|
|
59
|
+
text: async () => JSON.stringify(resp.body),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
status: 404,
|
|
65
|
+
json: async () => ({ error: { message: `Endpoint not mocked: ${method} ${url}` } }),
|
|
66
|
+
text: async () => `Endpoint not mocked: ${method} ${url}`,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
mock.method(GoogleAuth.prototype, "getClient", async () => {
|
|
70
|
+
return {
|
|
71
|
+
getAccessToken: async () => ({ token: "fake-gcp-token" }),
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
mock.method(fs, "readFileSync", () => {
|
|
75
|
+
return "ssh-rsa AAAA_FAKE_GCP_PUBLIC_KEY test@gcp.com";
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
globalThis.fetch = originalFetch;
|
|
80
|
+
mock.restoreAll();
|
|
81
|
+
});
|
|
82
|
+
test("gracefully handles discovery when Template does not exist", async () => {
|
|
83
|
+
mockResponses["GET /global/images/my-golden-image"] = {
|
|
84
|
+
status: 404,
|
|
85
|
+
body: { error: { message: "Not Found" } },
|
|
86
|
+
};
|
|
87
|
+
const builder = new GCPTemplateBuilder("my-golden-image");
|
|
88
|
+
const existing = await builder.discoveryPromise;
|
|
89
|
+
assert.strictEqual(existing, null);
|
|
90
|
+
});
|
|
91
|
+
test("discovers existing Template and skips deployment if hashes match (Idempotence)", async () => {
|
|
92
|
+
const nginxHash = getFileHash("playbooks/nginx.yaml");
|
|
93
|
+
mockResponses["GET /global/images/my-docker-base"] = {
|
|
94
|
+
status: 200,
|
|
95
|
+
body: {
|
|
96
|
+
name: "my-docker-base",
|
|
97
|
+
status: "READY",
|
|
98
|
+
description: `nginx-yaml=${nginxHash}`,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const builder = new GCPTemplateBuilder("my-docker-base")
|
|
102
|
+
.provision("playbooks/nginx.yaml");
|
|
103
|
+
const result = await builder.deploy();
|
|
104
|
+
assert.strictEqual(result.imageId, "projects/my-gcp-project/global/images/my-docker-base");
|
|
105
|
+
// No POST/DELETE calls since it already matches
|
|
106
|
+
const writes = fetchCalls.filter(c => c.method === "POST" || c.method === "DELETE");
|
|
107
|
+
assert.strictEqual(writes.length, 0);
|
|
108
|
+
});
|
|
109
|
+
test("purges and rebuilds template if playbooks differ", async () => {
|
|
110
|
+
// 1. Initial image discovery finds outdated hash
|
|
111
|
+
mockResponses["GET /global/images/my-docker-base"] = {
|
|
112
|
+
status: 200,
|
|
113
|
+
body: {
|
|
114
|
+
name: "my-docker-base",
|
|
115
|
+
status: "READY",
|
|
116
|
+
description: "nginx-yaml=outdated-hash",
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
// 2. Temp instance discovery transition: first 404, then RUNNING, then stopped (TERMINATED), then 404 after delete
|
|
120
|
+
let tempQueryCount = 0;
|
|
121
|
+
mockResponses["GET /instances/puls-bake-temp-my-docker-base"] = {
|
|
122
|
+
status: 200,
|
|
123
|
+
get body() {
|
|
124
|
+
tempQueryCount++;
|
|
125
|
+
if (tempQueryCount === 1) {
|
|
126
|
+
return {
|
|
127
|
+
name: "puls-bake-temp-my-docker-base",
|
|
128
|
+
status: "RUNNING",
|
|
129
|
+
networkInterfaces: [
|
|
130
|
+
{
|
|
131
|
+
accessConfigs: [{ natIP: "35.200.12.34" }],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
name: "puls-bake-temp-my-docker-base",
|
|
138
|
+
status: "TERMINATED",
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
// 3. Mock image ready after bake
|
|
143
|
+
mockResponses["GET /global/images/my-docker-base"] = {
|
|
144
|
+
status: 200,
|
|
145
|
+
body: {
|
|
146
|
+
name: "my-docker-base",
|
|
147
|
+
status: "READY",
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
// 4. Operations and deletes
|
|
151
|
+
mockResponses["DELETE /global/images/my-docker-base"] = { status: 200, body: {} };
|
|
152
|
+
mockResponses["POST /instances"] = { status: 200, body: {} };
|
|
153
|
+
mockResponses["POST /instances/puls-bake-temp-my-docker-base/stop"] = { status: 200, body: {} };
|
|
154
|
+
mockResponses["POST /global/images"] = { status: 200, body: {} };
|
|
155
|
+
mockResponses["DELETE /instances/puls-bake-temp-my-docker-base"] = { status: 200, body: {} };
|
|
156
|
+
const builder = new GCPTemplateBuilder("my-docker-base")
|
|
157
|
+
.provision("playbooks/nginx.yaml");
|
|
158
|
+
builder.waitFor = async (label, condition) => {
|
|
159
|
+
return await condition();
|
|
160
|
+
};
|
|
161
|
+
builder.checkPort = async () => true;
|
|
162
|
+
const provisionSpy = mock.method(builder, "runProvisioner", async () => { });
|
|
163
|
+
const result = await builder.deploy();
|
|
164
|
+
assert.strictEqual(result.imageId, "projects/my-gcp-project/global/images/my-docker-base");
|
|
165
|
+
// Verify delete old image called
|
|
166
|
+
assert.ok(fetchCalls.some(c => c.method === "DELETE" && c.url.includes("/global/images/my-docker-base")));
|
|
167
|
+
// Verify temp instance POST, stop POST, image bake POST, and temp instance DELETE
|
|
168
|
+
assert.ok(fetchCalls.some(c => c.method === "POST" && c.url.includes("/instances")));
|
|
169
|
+
assert.ok(fetchCalls.some(c => c.method === "POST" && c.url.includes("/stop")));
|
|
170
|
+
assert.ok(fetchCalls.some(c => c.method === "POST" && c.url.includes("/global/images")));
|
|
171
|
+
assert.ok(fetchCalls.some(c => c.method === "DELETE" && c.url.includes("/instances/puls-bake-temp-my-docker-base")));
|
|
172
|
+
// Verify provision playbook ran
|
|
173
|
+
assert.strictEqual(provisionSpy.mock.callCount(), 1);
|
|
174
|
+
assert.strictEqual(provisionSpy.mock.calls[0].arguments[0], "35.200.12.34");
|
|
175
|
+
assert.strictEqual(provisionSpy.mock.calls[0].arguments[1], "playbooks/nginx.yaml");
|
|
176
|
+
});
|
|
177
|
+
test("GCPVMBuilder clones from GCP custom image template successfully", async () => {
|
|
178
|
+
const nginxHash = getFileHash("playbooks/nginx.yaml");
|
|
179
|
+
mockResponses["GET /global/images/my-golden-gcp-image"] = {
|
|
180
|
+
status: 200,
|
|
181
|
+
body: {
|
|
182
|
+
name: "my-golden-gcp-image",
|
|
183
|
+
status: "READY",
|
|
184
|
+
description: `nginx-yaml=${nginxHash}`,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
let vmQueryCount = 0;
|
|
188
|
+
mockResponses["GET /instances/prod-server-01"] = {
|
|
189
|
+
status: 200,
|
|
190
|
+
get body() {
|
|
191
|
+
vmQueryCount++;
|
|
192
|
+
if (vmQueryCount === 1)
|
|
193
|
+
return null; // VM absent initially
|
|
194
|
+
return {
|
|
195
|
+
name: "prod-server-01",
|
|
196
|
+
status: "RUNNING",
|
|
197
|
+
networkInterfaces: [
|
|
198
|
+
{
|
|
199
|
+
accessConfigs: [{ natIP: "35.240.10.88" }],
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
mockResponses["POST /instances"] = { status: 200, body: {} };
|
|
206
|
+
class GCPStack extends Stack {
|
|
207
|
+
gcpTemplate = new GCPTemplateBuilder("my-golden-gcp-image")
|
|
208
|
+
.provision("playbooks/nginx.yaml");
|
|
209
|
+
server = new GCPVMBuilder("prod-server-01")
|
|
210
|
+
.fromTemplate(this.gcpTemplate)
|
|
211
|
+
.machineType("e2-standard-4");
|
|
212
|
+
}
|
|
213
|
+
const stack = new GCPStack();
|
|
214
|
+
stack.server.waitFor = async (label, condition) => {
|
|
215
|
+
return await condition();
|
|
216
|
+
};
|
|
217
|
+
stack.server.checkPort = async () => true;
|
|
218
|
+
const result = await stack.deploy();
|
|
219
|
+
assert.strictEqual(result.gcpTemplate.imageId, "projects/my-gcp-project/global/images/my-golden-gcp-image");
|
|
220
|
+
assert.strictEqual(result.server.ip, "35.240.10.88");
|
|
221
|
+
// Verify instance creation POST has the dynamically resolved custom image path
|
|
222
|
+
const createCall = fetchCalls.find(c => c.method === "POST" && c.url.includes("/instances") && c.body?.disks);
|
|
223
|
+
assert.ok(createCall);
|
|
224
|
+
assert.strictEqual(createCall.body.disks[0].initializeParams.sourceImage, "projects/my-gcp-project/global/images/my-golden-gcp-image");
|
|
225
|
+
assert.strictEqual(createCall.body.machineType, "zones/us-central1-a/machineTypes/e2-standard-4");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseBuilder } from "../../core/resource.js";
|
|
2
2
|
import { Output } from "../../core/output.js";
|
|
3
|
+
import { GCPTemplateBuilder } from "./template.js";
|
|
3
4
|
export declare class GCPVMBuilder extends BaseBuilder {
|
|
4
5
|
readonly out: {
|
|
5
6
|
ip: Output<string>;
|
|
@@ -7,6 +8,7 @@ export declare class GCPVMBuilder extends BaseBuilder {
|
|
|
7
8
|
};
|
|
8
9
|
private _machineType;
|
|
9
10
|
private _image;
|
|
11
|
+
private _templateSource?;
|
|
10
12
|
private _zone;
|
|
11
13
|
private _network;
|
|
12
14
|
private _sshKeys;
|
|
@@ -17,6 +19,7 @@ export declare class GCPVMBuilder extends BaseBuilder {
|
|
|
17
19
|
constructor(name: string);
|
|
18
20
|
machineType(type: string): this;
|
|
19
21
|
image(img: string): this;
|
|
22
|
+
fromTemplate(template: GCPTemplateBuilder): this;
|
|
20
23
|
zone(z: string): this;
|
|
21
24
|
network(netPath: string): this;
|
|
22
25
|
sshKey(keys: string | string[]): this;
|
package/dist/providers/gcp/vm.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Output } from "../../core/output.js";
|
|
|
5
5
|
import { gcpFetch, getProjectId } from "./api.js";
|
|
6
6
|
import { checkPort, runProvisioner } from "../../core/provisioner.js";
|
|
7
7
|
import { getFileHash } from "../proxmox/hash.js";
|
|
8
|
+
import { resourceContextStorage } from "../../core/context.js";
|
|
8
9
|
export class GCPVMBuilder extends BaseBuilder {
|
|
9
10
|
out = {
|
|
10
11
|
ip: new Output(),
|
|
@@ -12,6 +13,7 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
12
13
|
};
|
|
13
14
|
_machineType = "e2-micro";
|
|
14
15
|
_image = "projects/ubuntu-os-cloud/global/images/family/ubuntu-2204-lts";
|
|
16
|
+
_templateSource;
|
|
15
17
|
_zone = "us-central1-a";
|
|
16
18
|
_network = "global/networks/default";
|
|
17
19
|
_sshKeys = [];
|
|
@@ -31,6 +33,11 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
31
33
|
this._image = img;
|
|
32
34
|
return this;
|
|
33
35
|
}
|
|
36
|
+
fromTemplate(template) {
|
|
37
|
+
this._templateSource = template;
|
|
38
|
+
this.dependsOn(template);
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
34
41
|
zone(z) {
|
|
35
42
|
this._zone = z;
|
|
36
43
|
this.discoveryPromise = this.discoverVM();
|
|
@@ -58,6 +65,9 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
58
65
|
async runProvisioner(ip, script) {
|
|
59
66
|
const keysArray = Array.isArray(this._sshKeys) ? this._sshKeys : [this._sshKeys];
|
|
60
67
|
const keyPath = keysArray.find(k => !k.startsWith('ssh-') && !k.startsWith('ecdsa-') && !k.startsWith('sk-'));
|
|
68
|
+
if (!keyPath) {
|
|
69
|
+
throw new Error(`[GCP VM:${this.name}] No SSH private key path found. Pass a file path via .sshKey() to run provisioning.`);
|
|
70
|
+
}
|
|
61
71
|
return runProvisioner(ip, "root", keyPath, script);
|
|
62
72
|
}
|
|
63
73
|
async discoverVM() {
|
|
@@ -115,9 +125,23 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
115
125
|
if (dryRun) {
|
|
116
126
|
console.log(`\n🔍 [DRY RUN] GCP VM "${this.name}"...`);
|
|
117
127
|
if (!existing) {
|
|
118
|
-
|
|
128
|
+
const sourceLabel = this._templateSource ? `Template: ${this._templateSource.name}` : `Image: ${this._image}`;
|
|
129
|
+
console.log(` 📝 Plan: Create GCP VM Instance`);
|
|
130
|
+
const details = [
|
|
131
|
+
`Name: ${this.name}`,
|
|
132
|
+
`Machine Type: ${this._machineType}`,
|
|
133
|
+
`Zone: ${this._zone}`,
|
|
134
|
+
`Source: ${sourceLabel}`,
|
|
135
|
+
];
|
|
136
|
+
if (this._network) {
|
|
137
|
+
details.push(`Network: ${this._network}`);
|
|
138
|
+
}
|
|
119
139
|
if (this._provision.length > 0) {
|
|
120
|
-
|
|
140
|
+
details.push(`Provision: ${this._provision.join(", ")}`);
|
|
141
|
+
}
|
|
142
|
+
for (let i = 0; i < details.length; i++) {
|
|
143
|
+
const prefix = i === details.length - 1 ? " └─ " : " ├─ ";
|
|
144
|
+
console.log(`${prefix}${details[i]}`);
|
|
121
145
|
}
|
|
122
146
|
this.out.id.resolve("PENDING");
|
|
123
147
|
this.out.ip.resolve("0.0.0.0");
|
|
@@ -163,6 +187,10 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
163
187
|
initialHashes[p.slug] = p.hash;
|
|
164
188
|
}
|
|
165
189
|
const initialMetadataVal = mergeGcpMetadataForProvision(initialHashes);
|
|
190
|
+
let activeImage = this._image;
|
|
191
|
+
if (this._templateSource) {
|
|
192
|
+
activeImage = await this._templateSource.out.imageId.get();
|
|
193
|
+
}
|
|
166
194
|
const body = {
|
|
167
195
|
name: this.name,
|
|
168
196
|
machineType: `zones/${zone}/machineTypes/${this._machineType}`,
|
|
@@ -171,7 +199,7 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
171
199
|
boot: true,
|
|
172
200
|
autoDelete: true,
|
|
173
201
|
initializeParams: {
|
|
174
|
-
sourceImage:
|
|
202
|
+
sourceImage: activeImage,
|
|
175
203
|
},
|
|
176
204
|
},
|
|
177
205
|
],
|
|
@@ -261,6 +289,21 @@ export class GCPVMBuilder extends BaseBuilder {
|
|
|
261
289
|
console.log(`✅ GCP VM "${this.name}" is up to date.`);
|
|
262
290
|
}
|
|
263
291
|
}
|
|
292
|
+
const context = resourceContextStorage.getStore();
|
|
293
|
+
if (context && context.hosts) {
|
|
294
|
+
const activeIp = this.resolvedIp ?? "0.0.0.0";
|
|
295
|
+
const keysArray = Array.isArray(this._sshKeys) ? this._sshKeys : [this._sshKeys];
|
|
296
|
+
const keyPath = keysArray.find(k => !k.startsWith('ssh-') && !k.startsWith('ecdsa-') && !k.startsWith('sk-'));
|
|
297
|
+
if (!context.hosts.some(h => h.name === this.name)) {
|
|
298
|
+
context.hosts.push({
|
|
299
|
+
name: this.name,
|
|
300
|
+
ip: activeIp,
|
|
301
|
+
user: "root",
|
|
302
|
+
sshKey: keyPath,
|
|
303
|
+
provider: "gcp"
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
264
307
|
return {
|
|
265
308
|
name: this.name,
|
|
266
309
|
id: this.resolvedInstanceId,
|
|
@@ -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;
|
|
@@ -11,6 +12,7 @@ export declare const Proxmox: {
|
|
|
11
12
|
dnsServers?: string[];
|
|
12
13
|
verifySsl?: boolean;
|
|
13
14
|
}) => void;
|
|
14
|
-
VM: (name:
|
|
15
|
+
VM: <T extends string | string[]>(name: T) => T extends string[] ? VMBuilder[] & VMBuilder : VMBuilder;
|
|
16
|
+
Template: <T extends string | string[]>(name: T) => T extends string[] ? TemplateBuilder[] & TemplateBuilder : TemplateBuilder;
|
|
15
17
|
};
|
|
16
18
|
export * from "../../types/proxmox.js";
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { Config } from "../../core/config.js";
|
|
2
2
|
import { VMBuilder } from "./vm.js";
|
|
3
|
+
import { TemplateBuilder } from "./template.js";
|
|
4
|
+
import { createBuilderArray } from "../../core/resource.js";
|
|
3
5
|
export const Proxmox = {
|
|
4
6
|
init: (opts) => {
|
|
5
7
|
Config.set({
|
|
6
8
|
providers: { ...Config.get().providers, proxmox: opts },
|
|
7
9
|
});
|
|
8
10
|
},
|
|
9
|
-
VM: (name) =>
|
|
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
|
+
},
|
|
10
23
|
};
|
|
11
24
|
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
|
+
}
|