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.
Files changed (66) hide show
  1. package/dist/core/checker.js +71 -0
  2. package/dist/core/config.d.ts +5 -0
  3. package/dist/core/config.js +12 -1
  4. package/dist/core/context.d.ts +14 -0
  5. package/dist/core/context.js +2 -0
  6. package/dist/core/decorators.d.ts +2 -0
  7. package/dist/core/decorators.js +8 -14
  8. package/dist/core/group.test.d.ts +1 -0
  9. package/dist/core/group.test.js +94 -0
  10. package/dist/core/parallel.test.d.ts +1 -0
  11. package/dist/core/parallel.test.js +215 -0
  12. package/dist/core/production.test.d.ts +1 -0
  13. package/dist/core/production.test.js +189 -0
  14. package/dist/core/provisioner.js +29 -11
  15. package/dist/core/resource.d.ts +8 -0
  16. package/dist/core/resource.js +45 -0
  17. package/dist/core/retry.d.ts +9 -0
  18. package/dist/core/retry.js +28 -0
  19. package/dist/core/retry.test.d.ts +1 -0
  20. package/dist/core/retry.test.js +66 -0
  21. package/dist/core/secret.d.ts +2 -1
  22. package/dist/core/secret.js +12 -2
  23. package/dist/core/stack.js +381 -75
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +1 -0
  26. package/dist/providers/aws/api.js +97 -17
  27. package/dist/providers/aws/ec2.d.ts +3 -0
  28. package/dist/providers/aws/ec2.js +37 -3
  29. package/dist/providers/aws/ec2.test.js +5 -3
  30. package/dist/providers/aws/index.d.ts +2 -0
  31. package/dist/providers/aws/index.js +2 -0
  32. package/dist/providers/aws/secrets.js +20 -3
  33. package/dist/providers/aws/template.d.ts +34 -0
  34. package/dist/providers/aws/template.js +252 -0
  35. package/dist/providers/aws/template.test.d.ts +1 -0
  36. package/dist/providers/aws/template.test.js +208 -0
  37. package/dist/providers/do/api.d.ts +2 -0
  38. package/dist/providers/do/api.js +124 -26
  39. package/dist/providers/do/droplet.js +14 -0
  40. package/dist/providers/firebase/api.js +92 -29
  41. package/dist/providers/firebase/list.d.ts +2 -0
  42. package/dist/providers/firebase/list.js +25 -0
  43. package/dist/providers/gcp/api.js +88 -14
  44. package/dist/providers/gcp/index.d.ts +3 -1
  45. package/dist/providers/gcp/index.js +3 -1
  46. package/dist/providers/gcp/list.d.ts +2 -0
  47. package/dist/providers/gcp/list.js +55 -0
  48. package/dist/providers/gcp/secrets.js +21 -4
  49. package/dist/providers/gcp/template.d.ts +32 -0
  50. package/dist/providers/gcp/template.js +252 -0
  51. package/dist/providers/gcp/template.test.d.ts +1 -0
  52. package/dist/providers/gcp/template.test.js +227 -0
  53. package/dist/providers/gcp/vm.d.ts +3 -0
  54. package/dist/providers/gcp/vm.js +46 -3
  55. package/dist/providers/proxmox/api.d.ts +1 -0
  56. package/dist/providers/proxmox/api.js +72 -16
  57. package/dist/providers/proxmox/index.d.ts +3 -1
  58. package/dist/providers/proxmox/index.js +14 -1
  59. package/dist/providers/proxmox/template.d.ts +44 -0
  60. package/dist/providers/proxmox/template.js +350 -0
  61. package/dist/providers/proxmox/template.test.d.ts +1 -0
  62. package/dist/providers/proxmox/template.test.js +215 -0
  63. package/dist/providers/proxmox/vm.d.ts +3 -0
  64. package/dist/providers/proxmox/vm.js +43 -11
  65. package/dist/types/inventory.d.ts +44 -1
  66. 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;
@@ -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
- console.log(` 📝 Plan: Create GCP VM Instance "${this.name}" (${this._machineType} in zone ${this._zone})`);
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
- console.log(` └─ Provision: ${this._provision.join(", ")}`);
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: this._image,
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 headers = {
17
- Authorization: `PVEAPIToken=${this.authToken}`,
18
- };
19
- if (body !== undefined)
20
- headers['Content-Type'] = 'application/json';
21
- const opts = { method, headers };
22
- if (body !== undefined)
23
- opts.body = JSON.stringify(body);
24
- if (this.dispatcher)
25
- opts.dispatcher = this.dispatcher;
26
- const res = await fetch(`${this.baseUrl}${path}`, opts);
27
- if (!res.ok)
28
- throw new Error(`Proxmox ${method} ${path}: ${res.status} ${await res.text()}`);
29
- const json = (await res.json());
30
- return json.data ?? null;
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: string) => VMBuilder;
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) => new VMBuilder(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
+ }