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.
Files changed (117) hide show
  1. package/dist/core/checker.js +71 -0
  2. package/dist/core/config.d.ts +6 -0
  3. package/dist/core/config.js +11 -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 +4 -0
  7. package/dist/core/decorators.js +56 -30
  8. package/dist/core/hooks.d.ts +21 -0
  9. package/dist/core/hooks.js +116 -0
  10. package/dist/core/hooks.test.d.ts +1 -0
  11. package/dist/core/hooks.test.js +194 -0
  12. package/dist/core/multiregion.test.d.ts +1 -0
  13. package/dist/core/multiregion.test.js +87 -0
  14. package/dist/core/output.d.ts +2 -0
  15. package/dist/core/output.js +9 -2
  16. package/dist/core/parallel.test.d.ts +1 -0
  17. package/dist/core/parallel.test.js +215 -0
  18. package/dist/core/parser.d.ts +10 -0
  19. package/dist/core/parser.js +140 -0
  20. package/dist/core/parser.test.d.ts +1 -0
  21. package/dist/core/parser.test.js +117 -0
  22. package/dist/core/production.test.d.ts +1 -0
  23. package/dist/core/production.test.js +189 -0
  24. package/dist/core/provisioner.d.ts +4 -0
  25. package/dist/core/provisioner.js +123 -0
  26. package/dist/core/resource.d.ts +23 -0
  27. package/dist/core/resource.js +54 -0
  28. package/dist/core/retry.d.ts +9 -0
  29. package/dist/core/retry.js +28 -0
  30. package/dist/core/retry.test.d.ts +1 -0
  31. package/dist/core/retry.test.js +66 -0
  32. package/dist/core/secret.d.ts +41 -0
  33. package/dist/core/secret.js +105 -0
  34. package/dist/core/secret.test.d.ts +1 -0
  35. package/dist/core/secret.test.js +166 -0
  36. package/dist/core/stack.d.ts +4 -3
  37. package/dist/core/stack.js +322 -48
  38. package/dist/index.d.ts +3 -0
  39. package/dist/index.js +3 -0
  40. package/dist/providers/aws/api.js +97 -17
  41. package/dist/providers/aws/ec2.d.ts +51 -0
  42. package/dist/providers/aws/ec2.js +331 -0
  43. package/dist/providers/aws/ec2.test.d.ts +1 -0
  44. package/dist/providers/aws/ec2.test.js +281 -0
  45. package/dist/providers/aws/index.d.ts +4 -0
  46. package/dist/providers/aws/index.js +4 -0
  47. package/dist/providers/aws/route53.d.ts +1 -0
  48. package/dist/providers/aws/route53.js +15 -2
  49. package/dist/providers/aws/route53.test.js +47 -0
  50. package/dist/providers/aws/template.d.ts +34 -0
  51. package/dist/providers/aws/template.js +252 -0
  52. package/dist/providers/aws/template.test.d.ts +1 -0
  53. package/dist/providers/aws/template.test.js +208 -0
  54. package/dist/providers/do/api.d.ts +3 -1
  55. package/dist/providers/do/api.js +126 -27
  56. package/dist/providers/do/app.d.ts +26 -0
  57. package/dist/providers/do/app.js +124 -0
  58. package/dist/providers/do/app.test.d.ts +1 -0
  59. package/dist/providers/do/app.test.js +268 -0
  60. package/dist/providers/do/database.d.ts +44 -0
  61. package/dist/providers/do/database.js +208 -0
  62. package/dist/providers/do/database.test.d.ts +1 -0
  63. package/dist/providers/do/database.test.js +293 -0
  64. package/dist/providers/do/domain.d.ts +2 -0
  65. package/dist/providers/do/domain.js +30 -0
  66. package/dist/providers/do/domain.test.js +49 -0
  67. package/dist/providers/do/droplet.d.ts +9 -0
  68. package/dist/providers/do/droplet.js +146 -8
  69. package/dist/providers/do/droplet.test.js +228 -1
  70. package/dist/providers/do/firewall.d.ts +2 -1
  71. package/dist/providers/do/firewall.js +23 -9
  72. package/dist/providers/do/firewall.test.js +54 -0
  73. package/dist/providers/do/index.d.ts +11 -0
  74. package/dist/providers/do/index.js +8 -0
  75. package/dist/providers/do/spaces.d.ts +27 -0
  76. package/dist/providers/do/spaces.js +142 -0
  77. package/dist/providers/do/spaces.test.d.ts +1 -0
  78. package/dist/providers/do/spaces.test.js +180 -0
  79. package/dist/providers/do/spaces_api.d.ts +2 -0
  80. package/dist/providers/do/spaces_api.js +20 -0
  81. package/dist/providers/do/vpc.d.ts +30 -0
  82. package/dist/providers/do/vpc.js +128 -0
  83. package/dist/providers/do/vpc.test.d.ts +1 -0
  84. package/dist/providers/do/vpc.test.js +258 -0
  85. package/dist/providers/firebase/api.js +92 -29
  86. package/dist/providers/firebase/list.d.ts +2 -0
  87. package/dist/providers/firebase/list.js +25 -0
  88. package/dist/providers/gcp/api.js +88 -14
  89. package/dist/providers/gcp/clouddns.d.ts +1 -0
  90. package/dist/providers/gcp/clouddns.js +15 -2
  91. package/dist/providers/gcp/clouddns.test.js +45 -0
  92. package/dist/providers/gcp/index.d.ts +5 -1
  93. package/dist/providers/gcp/index.js +5 -1
  94. package/dist/providers/gcp/list.d.ts +2 -0
  95. package/dist/providers/gcp/list.js +55 -0
  96. package/dist/providers/gcp/secrets.js +1 -1
  97. package/dist/providers/gcp/template.d.ts +32 -0
  98. package/dist/providers/gcp/template.js +252 -0
  99. package/dist/providers/gcp/template.test.d.ts +1 -0
  100. package/dist/providers/gcp/template.test.js +227 -0
  101. package/dist/providers/gcp/vm.d.ts +48 -0
  102. package/dist/providers/gcp/vm.js +375 -0
  103. package/dist/providers/gcp/vm.test.d.ts +1 -0
  104. package/dist/providers/gcp/vm.test.js +321 -0
  105. package/dist/providers/proxmox/api.d.ts +1 -0
  106. package/dist/providers/proxmox/api.js +72 -16
  107. package/dist/providers/proxmox/index.d.ts +2 -0
  108. package/dist/providers/proxmox/index.js +2 -0
  109. package/dist/providers/proxmox/template.d.ts +44 -0
  110. package/dist/providers/proxmox/template.js +349 -0
  111. package/dist/providers/proxmox/template.test.d.ts +1 -0
  112. package/dist/providers/proxmox/template.test.js +179 -0
  113. package/dist/providers/proxmox/vm.d.ts +7 -4
  114. package/dist/providers/proxmox/vm.js +57 -102
  115. package/dist/providers/proxmox/vm.test.js +77 -0
  116. package/dist/types/inventory.d.ts +44 -1
  117. package/package.json +3 -1
@@ -0,0 +1,293 @@
1
+ import { test, describe, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert";
3
+ import { DatabaseBuilder } from "./database.js";
4
+ import { Config } from "../../core/config.js";
5
+ describe("DatabaseBuilder Unit Tests", () => {
6
+ let originalFetch;
7
+ let fetchCalls = [];
8
+ let mockResponses = {};
9
+ beforeEach(() => {
10
+ Config.set({
11
+ dryRun: false,
12
+ providers: {
13
+ do: { token: "fake-do-token", defaultRegion: "nyc3" },
14
+ },
15
+ });
16
+ originalFetch = globalThis.fetch;
17
+ fetchCalls = [];
18
+ mockResponses = {};
19
+ globalThis.fetch = async (input, init) => {
20
+ const url = String(input);
21
+ const method = init?.method ?? "GET";
22
+ const body = init?.body ? JSON.parse(init.body) : undefined;
23
+ const headers = init?.headers;
24
+ fetchCalls.push({ url, method, body, headers });
25
+ const matchKey = Object.keys(mockResponses)
26
+ .filter((key) => {
27
+ const [mMethod, mPath] = key.split(" ");
28
+ return method === mMethod && url.includes(mPath);
29
+ })
30
+ .sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
31
+ if (matchKey) {
32
+ const resp = mockResponses[matchKey];
33
+ return {
34
+ ok: resp.status >= 200 && resp.status < 300,
35
+ status: resp.status,
36
+ json: async () => resp.body,
37
+ text: async () => JSON.stringify(resp.body),
38
+ };
39
+ }
40
+ return {
41
+ ok: false,
42
+ status: 404,
43
+ json: async () => ({ message: "Not found" }),
44
+ text: async () => "Not found",
45
+ };
46
+ };
47
+ });
48
+ afterEach(() => {
49
+ globalThis.fetch = originalFetch;
50
+ });
51
+ test("gracefully handles discovery when Database Cluster does not exist", async () => {
52
+ mockResponses["GET /databases"] = {
53
+ status: 200,
54
+ body: { databases: [] },
55
+ };
56
+ const builder = new DatabaseBuilder("my-db");
57
+ const discoveryResult = await builder.discoveryPromise;
58
+ assert.strictEqual(discoveryResult, null);
59
+ assert.strictEqual(fetchCalls.length, 1);
60
+ assert.strictEqual(fetchCalls[0].method, "GET");
61
+ assert.ok(fetchCalls[0].url.includes("/databases"));
62
+ });
63
+ test("discovers Database Cluster successfully when it exists", async () => {
64
+ mockResponses["GET /databases"] = {
65
+ status: 200,
66
+ body: {
67
+ databases: [
68
+ {
69
+ id: "db-123",
70
+ name: "my-db",
71
+ status: "online",
72
+ connection: {
73
+ host: "10.0.0.5",
74
+ port: 5432,
75
+ uri: "postgresql://user:pass@10.0.0.5:5432/db",
76
+ user: "user",
77
+ password: "pass",
78
+ },
79
+ },
80
+ ],
81
+ },
82
+ };
83
+ const builder = new DatabaseBuilder("my-db");
84
+ const discoveryResult = await builder.discoveryPromise;
85
+ assert.ok(discoveryResult);
86
+ assert.strictEqual(discoveryResult.id, "db-123");
87
+ assert.strictEqual(discoveryResult.status, "online");
88
+ const host = await builder.out.host.get();
89
+ const port = await builder.out.port.get();
90
+ const uri = await builder.out.uri.get();
91
+ assert.strictEqual(host, "10.0.0.5");
92
+ assert.strictEqual(port, 5432);
93
+ assert.strictEqual(uri, "postgresql://user:pass@10.0.0.5:5432/db");
94
+ });
95
+ test("performs clean dry-run planning without making write requests", async () => {
96
+ Config.set({
97
+ dryRun: true,
98
+ providers: { do: { token: "fake-token" } },
99
+ });
100
+ mockResponses["GET /databases"] = {
101
+ status: 200,
102
+ body: { databases: [] },
103
+ };
104
+ const builder = new DatabaseBuilder("my-dry-db")
105
+ .engine("mysql")
106
+ .version("8")
107
+ .size("db-s-2vcpu-4gb")
108
+ .nodes(2)
109
+ .vpc("vpc-999")
110
+ .allowIp("1.1.1.1/32");
111
+ const result = await builder.deploy();
112
+ assert.ok(result);
113
+ assert.strictEqual(result.name, "my-dry-db");
114
+ assert.strictEqual(result.id, "PENDING");
115
+ // Discover should run, but no creations/firewall writes
116
+ const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
117
+ assert.strictEqual(writeCalls.length, 0);
118
+ const host = await builder.out.host.get();
119
+ assert.strictEqual(host, "127.0.0.1");
120
+ });
121
+ test("deploys new Database Cluster and awaits status: online", async () => {
122
+ mockResponses["GET /databases"] = {
123
+ status: 200,
124
+ body: { databases: [] },
125
+ };
126
+ mockResponses["POST /databases"] = {
127
+ status: 201,
128
+ body: { database: { id: "new-db-id", name: "my-db-new", status: "provisioning" } },
129
+ };
130
+ let pollCount = 0;
131
+ mockResponses["GET /databases/new-db-id"] = {
132
+ status: 200,
133
+ get body() {
134
+ pollCount++;
135
+ if (pollCount === 1) {
136
+ return { database: { id: "new-db-id", status: "provisioning" } };
137
+ }
138
+ return {
139
+ database: {
140
+ id: "new-db-id",
141
+ status: "online",
142
+ connection: {
143
+ host: "db-node.example.com",
144
+ port: 3306,
145
+ uri: "mysql://admin:secret@db-node.example.com:3306/db",
146
+ user: "admin",
147
+ password: "secret",
148
+ },
149
+ },
150
+ };
151
+ },
152
+ };
153
+ mockResponses["PUT /databases/new-db-id/firewall"] = {
154
+ status: 204,
155
+ body: {},
156
+ };
157
+ const builder = new DatabaseBuilder("my-db-new")
158
+ .engine("mysql")
159
+ .version("8.0")
160
+ .size("db-s-1vcpu-1gb")
161
+ .nodes(1)
162
+ .allowIp("1.2.3.4/32");
163
+ // Instantly check status
164
+ builder.waitFor = async (label, condition) => {
165
+ let done = false;
166
+ while (!done) {
167
+ done = await condition();
168
+ }
169
+ };
170
+ const result = await builder.deploy();
171
+ assert.ok(result);
172
+ assert.strictEqual(result.id, "new-db-id");
173
+ assert.strictEqual(result.status, "online");
174
+ const host = await builder.out.host.get();
175
+ assert.strictEqual(host, "db-node.example.com");
176
+ const postCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/databases"));
177
+ assert.ok(postCall);
178
+ assert.deepStrictEqual(postCall.body, {
179
+ name: "my-db-new",
180
+ engine: "mysql",
181
+ version: "8.0",
182
+ region: "nyc3",
183
+ size: "db-s-1vcpu-1gb",
184
+ num_nodes: 1,
185
+ });
186
+ const firewallCall = fetchCalls.find((c) => c.method === "PUT" && c.url.includes("/databases/new-db-id/firewall"));
187
+ assert.ok(firewallCall);
188
+ assert.deepStrictEqual(firewallCall.body, {
189
+ rules: [{ type: "ip_addr", value: "1.2.3.4/32" }],
190
+ });
191
+ });
192
+ test("deploys new Database Cluster with private VPC network assignment", async () => {
193
+ mockResponses["GET /databases"] = {
194
+ status: 200,
195
+ body: { databases: [] },
196
+ };
197
+ mockResponses["POST /databases"] = {
198
+ status: 201,
199
+ body: { database: { id: "vpc-db-id", name: "my-vpc-db", status: "provisioning" } },
200
+ };
201
+ mockResponses["GET /databases/vpc-db-id"] = {
202
+ status: 200,
203
+ body: {
204
+ database: {
205
+ id: "vpc-db-id",
206
+ status: "online",
207
+ private_connection: {
208
+ host: "db-node.private.int",
209
+ port: 5432,
210
+ uri: "postgresql://user:pwd@db-node.private.int:5432/db",
211
+ user: "user",
212
+ password: "pwd",
213
+ },
214
+ connection: {
215
+ host: "db-node.public.com",
216
+ port: 5432,
217
+ uri: "postgresql://user:pwd@db-node.public.com:5432/db",
218
+ user: "user",
219
+ password: "pwd",
220
+ },
221
+ },
222
+ },
223
+ };
224
+ const builder = new DatabaseBuilder("my-vpc-db")
225
+ .engine("pg")
226
+ .vpc("my-custom-vpc-uuid");
227
+ builder.waitFor = async (label, condition) => {
228
+ await condition();
229
+ };
230
+ const result = await builder.deploy();
231
+ assert.ok(result);
232
+ const host = await builder.out.host.get();
233
+ // Should prefer private VPC host!
234
+ assert.strictEqual(host, "db-node.private.int");
235
+ const postCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/databases"));
236
+ assert.ok(postCall);
237
+ assert.strictEqual(postCall.body.private_network_uuid, "my-custom-vpc-uuid");
238
+ });
239
+ test("updates firewall rules (trusted sources) on existing cluster", async () => {
240
+ mockResponses["GET /databases"] = {
241
+ status: 200,
242
+ body: {
243
+ databases: [
244
+ {
245
+ id: "db-123",
246
+ name: "my-db",
247
+ status: "online",
248
+ connection: {
249
+ host: "10.0.0.5",
250
+ port: 5432,
251
+ uri: "postgresql://user:pass@10.0.0.5:5432/db",
252
+ user: "user",
253
+ password: "pass",
254
+ },
255
+ },
256
+ ],
257
+ },
258
+ };
259
+ mockResponses["PUT /databases/db-123/firewall"] = {
260
+ status: 204,
261
+ body: {},
262
+ };
263
+ const builder = new DatabaseBuilder("my-db")
264
+ .allowDroplet("99999");
265
+ await builder.deploy();
266
+ const putCall = fetchCalls.find((c) => c.method === "PUT" && c.url.includes("/databases/db-123/firewall"));
267
+ assert.ok(putCall);
268
+ assert.deepStrictEqual(putCall.body, {
269
+ rules: [{ type: "droplet", value: "99999" }],
270
+ });
271
+ });
272
+ test("destroys Database Cluster successfully", async () => {
273
+ mockResponses["GET /databases"] = {
274
+ status: 200,
275
+ body: {
276
+ databases: [
277
+ { id: "db-123", name: "my-db-del" },
278
+ ],
279
+ },
280
+ };
281
+ mockResponses["DELETE /databases/db-123"] = {
282
+ status: 204,
283
+ body: {},
284
+ };
285
+ const builder = new DatabaseBuilder("my-db-del");
286
+ await builder.discoveryPromise;
287
+ const result = await builder.destroy();
288
+ assert.deepStrictEqual(result, { destroyed: "my-db-del" });
289
+ const deleteCall = fetchCalls.find((c) => c.method === "DELETE");
290
+ assert.ok(deleteCall);
291
+ assert.ok(deleteCall.url.endsWith("/databases/db-123"));
292
+ });
293
+ });
@@ -17,6 +17,8 @@ export declare class DomainBuilder extends BaseBuilder {
17
17
  constructor(domainName: string);
18
18
  private discoverDomain;
19
19
  withSSL(): this;
20
+ record(filePath: string): this;
21
+ record(name: string, type: DNSRecord["type"], value: string | DropletBuilder | Output<string>, ttl?: number, priority?: number, port?: number, weight?: number, flags?: number, tag?: string): this;
20
22
  pointer(name: string, target: DropletBuilder | Output<string> | string): this;
21
23
  cname(name: string, target: string): this;
22
24
  aaaa(name: string, target: string | Output<string>): this;
@@ -3,6 +3,7 @@ import { Output } from "../../core/output.js";
3
3
  import { DropletBuilder } from "./droplet.js";
4
4
  import { CertificateBuilder } from "./certificate.js";
5
5
  import { getDoApi } from "./api.js";
6
+ import { loadRecordsFromFile } from "../../core/parser.js";
6
7
  export class DomainBuilder extends BaseBuilder {
7
8
  domainName;
8
9
  records = [];
@@ -27,6 +28,35 @@ export class DomainBuilder extends BaseBuilder {
27
28
  this.sidecars.push(cert);
28
29
  return this;
29
30
  }
31
+ record(nameOrPath, type, value, ttl, priority, port, weight, flags, tag) {
32
+ if (arguments.length === 1 && typeof nameOrPath === "string" && (nameOrPath.endsWith(".yaml") || nameOrPath.endsWith(".yml") || nameOrPath.endsWith(".json"))) {
33
+ const loaded = loadRecordsFromFile(nameOrPath);
34
+ for (const r of loaded) {
35
+ this.records.push({
36
+ name: r.name,
37
+ type: r.type,
38
+ value: r.value,
39
+ priority: r.priority,
40
+ port: r.port,
41
+ weight: r.weight,
42
+ flags: r.flags,
43
+ tag: r.tag,
44
+ });
45
+ }
46
+ return this;
47
+ }
48
+ this.records.push({
49
+ name: nameOrPath,
50
+ type: type,
51
+ value: value,
52
+ priority,
53
+ port,
54
+ weight,
55
+ flags,
56
+ tag,
57
+ });
58
+ return this;
59
+ }
30
60
  pointer(name, target) {
31
61
  this.records.push({ type: "A", name, value: target });
32
62
  return this;
@@ -1,5 +1,7 @@
1
1
  import { test, describe, beforeEach, afterEach } from "node:test";
2
2
  import assert from "node:assert";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
3
5
  import { DomainBuilder } from "./domain.js";
4
6
  import { Config } from "../../core/config.js";
5
7
  describe("DomainBuilder Unit Tests", () => {
@@ -197,4 +199,51 @@ describe("DomainBuilder Unit Tests", () => {
197
199
  assert.ok(deleteCall);
198
200
  assert.ok(deleteCall.url.endsWith("/domains/destroy.com"));
199
201
  });
202
+ test("loads records from a configuration file (YAML) successfully", async () => {
203
+ mockResponses["GET /domains/file-do.com"] = {
204
+ status: 200,
205
+ body: { domain: { name: "file-do.com" } }
206
+ };
207
+ mockResponses["GET /domains/file-do.com/records?per_page=200"] = {
208
+ status: 200,
209
+ body: { domain_records: [] }
210
+ };
211
+ mockResponses["POST /domains/file-do.com/records"] = {
212
+ status: 201,
213
+ body: { domain_record: { id: 201 } }
214
+ };
215
+ // Mock YAML file creation
216
+ const tempYamlPath = path.resolve(process.cwd(), "temp-do-records.yaml");
217
+ const yamlContent = `
218
+ - name: www
219
+ type: CNAME
220
+ value: lb.google.com
221
+ - name: mail
222
+ type: A
223
+ value: 1.2.3.4
224
+ `;
225
+ fs.writeFileSync(tempYamlPath, yamlContent, "utf-8");
226
+ try {
227
+ const builder = new DomainBuilder("file-do.com")
228
+ .record("temp-do-records.yaml")
229
+ .record("api", "A", "10.0.0.9"); // Hybrid programmatic record!
230
+ const result = await builder.deploy();
231
+ assert.strictEqual(result.records.length, 3);
232
+ const wwwRec = result.records.find((r) => r.name === "www");
233
+ assert.ok(wwwRec);
234
+ assert.strictEqual(wwwRec.type, "CNAME");
235
+ assert.strictEqual(wwwRec.value, "lb.google.com");
236
+ const mailRec = result.records.find((r) => r.name === "mail");
237
+ assert.ok(mailRec);
238
+ assert.strictEqual(mailRec.type, "A");
239
+ assert.strictEqual(mailRec.value, "1.2.3.4");
240
+ const apiRec = result.records.find((r) => r.name === "api");
241
+ assert.ok(apiRec);
242
+ assert.strictEqual(apiRec.value, "10.0.0.9");
243
+ }
244
+ finally {
245
+ if (fs.existsSync(tempYamlPath))
246
+ fs.unlinkSync(tempYamlPath);
247
+ }
248
+ });
200
249
  });
@@ -12,6 +12,8 @@ export declare class DropletBuilder extends BaseBuilder {
12
12
  private dropletId?;
13
13
  private resolvedIp?;
14
14
  private sshKeyPath?;
15
+ private _provision;
16
+ private _forceConfigCheck;
15
17
  constructor(name: string);
16
18
  private discoverDroplet;
17
19
  getPublicIp(): Promise<string | undefined>;
@@ -20,6 +22,11 @@ export declare class DropletBuilder extends BaseBuilder {
20
22
  region(region: (typeof REGION)[keyof typeof REGION] | string): this;
21
23
  size(size: (typeof SIZE)[keyof typeof SIZE] | string): this;
22
24
  sslKey(keyPath: string): this;
25
+ vpc(uuid: string | Output<string>): this;
26
+ provision(...playbookPaths: (string | string[])[]): this;
27
+ forceConfigCheck(): this;
28
+ protected checkPort(ip: string, port: number): Promise<boolean>;
29
+ protected runProvisioner(ip: string, script: string): Promise<void>;
23
30
  private resolveOrRegisterSshKey;
24
31
  deploy(): Promise<any>;
25
32
  destroy(): Promise<any>;
@@ -33,3 +40,5 @@ export declare const DO: {
33
40
  Domain: (name: string) => DomainBuilder;
34
41
  LoadBalancer: (name: string) => LoadBalancerBuilder;
35
42
  };
43
+ export declare function parseDropletTagsForProvision(tags: string[]): Record<string, string>;
44
+ export declare function generateDropletTagForProvision(playbook: string, hash: string): string;
@@ -8,6 +8,9 @@ import { FirewallBuilder } from './firewall.js';
8
8
  import { DomainBuilder } from './domain.js';
9
9
  import { LoadBalancerBuilder } from './load_balancer.js';
10
10
  import { getDoApi } from './api.js';
11
+ import { checkPort, runProvisioner } from '../../core/provisioner.js';
12
+ import { getFileHash } from '../proxmox/hash.js';
13
+ import { resourceContextStorage } from '../../core/context.js';
11
14
  export class DropletBuilder extends BaseBuilder {
12
15
  out = {
13
16
  ip: new Output(),
@@ -21,6 +24,8 @@ export class DropletBuilder extends BaseBuilder {
21
24
  dropletId;
22
25
  resolvedIp;
23
26
  sshKeyPath;
27
+ _provision = [];
28
+ _forceConfigCheck = false;
24
29
  constructor(name) {
25
30
  super(name);
26
31
  this.discoveryPromise = this.discoverDroplet(name);
@@ -69,6 +74,25 @@ export class DropletBuilder extends BaseBuilder {
69
74
  this.sshKeyPath = keyPath.replace('~', homedir());
70
75
  return this;
71
76
  }
77
+ vpc(uuid) {
78
+ this.config.vpc_uuid = uuid;
79
+ return this;
80
+ }
81
+ provision(...playbookPaths) {
82
+ this._provision.push(...playbookPaths.flat());
83
+ return this;
84
+ }
85
+ forceConfigCheck() {
86
+ this._forceConfigCheck = true;
87
+ return this;
88
+ }
89
+ async checkPort(ip, port) {
90
+ return checkPort(ip, port);
91
+ }
92
+ async runProvisioner(ip, script) {
93
+ const keyPath = this.sshKeyPath ? this.sshKeyPath : undefined;
94
+ return runProvisioner(ip, "root", keyPath, script);
95
+ }
72
96
  async resolveOrRegisterSshKey(api) {
73
97
  const pubPath = this.sshKeyPath.replace(/\.pub$/, '') + '.pub';
74
98
  const pubKey = fs.readFileSync(pubPath, 'utf8').trim();
@@ -90,16 +114,46 @@ export class DropletBuilder extends BaseBuilder {
90
114
  : true;
91
115
  if (await this.checkProtection(hasChanges))
92
116
  return null;
117
+ // Provisioning Calculations
118
+ const appliedHashes = existing ? parseDropletTagsForProvision(existing.tags || []) : {};
119
+ const declaredPlaybooksWithHashes = this._provision.map((p) => {
120
+ const baseName = p.split("/").pop() ?? p;
121
+ const slug = baseName.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
122
+ return { path: p, slug, hash: getFileHash(p) };
123
+ });
124
+ const playbooksToRun = this._forceConfigCheck
125
+ ? declaredPlaybooksWithHashes
126
+ : declaredPlaybooksWithHashes.filter((p) => {
127
+ const appliedHash = appliedHashes[p.slug];
128
+ return !appliedHash || appliedHash !== p.hash;
129
+ });
130
+ const playbookRunRequired = playbooksToRun.length > 0;
93
131
  if (dryRun) {
94
132
  console.log(`\nšŸ” [DRY RUN] "${this.name}"...`);
95
133
  if (!existing) {
96
134
  const keyHint = this.sshKeyPath ? ` + key ${this.sshKeyPath.split('/').pop()}` : '';
97
- console.log(` šŸ“ Plan: Create droplet ${this.name} (${this.config.size} in ${this.config.region}${keyHint})`);
135
+ let vpcHint = '';
136
+ if (this.config.vpc_uuid) {
137
+ const vpcVal = this.config.vpc_uuid instanceof Output ? 'PENDING' : this.config.vpc_uuid;
138
+ vpcHint = ` in VPC ${vpcVal}`;
139
+ }
140
+ console.log(` šŸ“ Plan: Create droplet ${this.name} (${this.config.size} in ${this.config.region}${vpcHint}${keyHint})`);
141
+ if (this._provision.length > 0) {
142
+ console.log(` └─ Provision: ${this._provision.join(", ")}`);
143
+ }
98
144
  this.out.id.resolve(-1);
99
145
  this.out.ip.resolve('0.0.0.0');
100
146
  }
101
- else if (hasChanges) {
102
- console.log(` šŸ“ Plan: Resize ${this.name} → ${this.config.size}`);
147
+ else if (hasChanges || playbookRunRequired) {
148
+ if (hasChanges) {
149
+ console.log(` šŸ“ Plan: Resize ${this.name} → ${this.config.size}`);
150
+ }
151
+ if (playbookRunRequired) {
152
+ console.log(` šŸ“ [PLAN] Run ${playbooksToRun.length} playbook changes on existing droplet:`);
153
+ for (const p of playbooksToRun) {
154
+ console.log(` └─ Playbook: ${p.path} (hash: ${p.hash})`);
155
+ }
156
+ }
103
157
  }
104
158
  else {
105
159
  console.log(` āœ… ${this.name} is up to date.`);
@@ -111,12 +165,23 @@ export class DropletBuilder extends BaseBuilder {
111
165
  console.log(`\nā³ Finalizing "${this.name}"...`);
112
166
  if (!existing) {
113
167
  const sshKeyIds = this.sshKeyPath ? [await this.resolveOrRegisterSshKey(api)] : [];
168
+ let resolvedVpcUuid;
169
+ if (this.config.vpc_uuid) {
170
+ resolvedVpcUuid = this.config.vpc_uuid instanceof Output ? await this.config.vpc_uuid.get() : this.config.vpc_uuid;
171
+ }
172
+ const initialTags = [];
173
+ for (const playbook of this._provision) {
174
+ const hash = getFileHash(playbook);
175
+ initialTags.push(generateDropletTagForProvision(playbook, hash));
176
+ }
114
177
  const result = await api.post('/droplets', {
115
178
  name: this.name,
116
179
  region: this.config.region,
117
180
  size: this.config.size,
118
181
  image: this.config.image,
119
182
  ...(sshKeyIds.length && { ssh_keys: sshKeyIds }),
183
+ ...(resolvedVpcUuid && { vpc_uuid: resolvedVpcUuid }),
184
+ ...(initialTags.length && { tags: initialTags }),
120
185
  });
121
186
  this.dropletId = result.droplet.id;
122
187
  this.out.id.resolve(this.dropletId);
@@ -134,13 +199,70 @@ export class DropletBuilder extends BaseBuilder {
134
199
  this.out.ip.resolve(this.resolvedIp);
135
200
  console.log(` 🌐 Public IP: ${this.resolvedIp}`);
136
201
  }
137
- }
138
- else if (hasChanges) {
139
- console.log(`✨ Resizing ${this.name} → ${this.config.size}...`);
140
- await api.post(`/droplets/${this.dropletId}/actions`, { type: 'resize', size: this.config.size });
202
+ if (this._provision.length > 0) {
203
+ const activeIp = this.resolvedIp ?? '0.0.0.0';
204
+ if (activeIp === '0.0.0.0') {
205
+ throw new Error(`Failed to resolve IP for new droplet "${this.name}" to run playbooks`);
206
+ }
207
+ await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
208
+ for (const playbook of this._provision) {
209
+ await this.runProvisioner(activeIp, playbook);
210
+ }
211
+ }
141
212
  }
142
213
  else {
143
- console.log(`āœ… ${this.name} is up to date.`);
214
+ if (hasChanges) {
215
+ console.log(`✨ Resizing ${this.name} → ${this.config.size}...`);
216
+ await api.post(`/droplets/${this.dropletId}/actions`, { type: 'resize', size: this.config.size });
217
+ }
218
+ if (playbookRunRequired) {
219
+ console.log(` šŸ”„ Running ${playbooksToRun.length} playbook changes...`);
220
+ const activeIp = this.resolvedIp ?? '0.0.0.0';
221
+ if (activeIp === '0.0.0.0') {
222
+ throw new Error(`Failed to resolve IP for existing droplet "${this.name}" to run playbooks`);
223
+ }
224
+ await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
225
+ for (const p of playbooksToRun) {
226
+ await this.runProvisioner(activeIp, p.path);
227
+ // Update tags on DigitalOcean Droplet
228
+ const oldHash = appliedHashes[p.slug];
229
+ if (oldHash) {
230
+ const oldTag = `puls-h-${p.slug}-${oldHash}`;
231
+ try {
232
+ await api.delete(`/tags/${encodeURIComponent(oldTag)}/resources`, {
233
+ resources: [{ id: String(this.dropletId), type: 'droplet' }]
234
+ });
235
+ }
236
+ catch { }
237
+ }
238
+ const newTag = `puls-h-${p.slug}-${p.hash}`;
239
+ try {
240
+ await api.post('/tags', { name: newTag });
241
+ }
242
+ catch { }
243
+ await api.post(`/tags/${encodeURIComponent(newTag)}/resources`, {
244
+ resources: [{ id: String(this.dropletId), type: 'droplet' }]
245
+ });
246
+ appliedHashes[p.slug] = p.hash;
247
+ }
248
+ console.log(` āœ… Playbooks applied successfully and metadata updated.`);
249
+ }
250
+ if (!hasChanges && !playbookRunRequired) {
251
+ console.log(`āœ… ${this.name} is up to date.`);
252
+ }
253
+ }
254
+ const context = resourceContextStorage.getStore();
255
+ if (context && context.hosts) {
256
+ const activeIp = this.resolvedIp ?? "0.0.0.0";
257
+ if (!context.hosts.some(h => h.name === this.name)) {
258
+ context.hosts.push({
259
+ name: this.name,
260
+ ip: activeIp,
261
+ user: "root",
262
+ sshKey: this.sshKeyPath,
263
+ provider: "do"
264
+ });
265
+ }
144
266
  }
145
267
  for (const sidecar of this.sidecars)
146
268
  await sidecar.deploy();
@@ -178,3 +300,19 @@ export const DO = {
178
300
  Domain: (name) => new DomainBuilder(name),
179
301
  LoadBalancer: (name) => new LoadBalancerBuilder(name),
180
302
  };
303
+ export function parseDropletTagsForProvision(tags) {
304
+ const result = {};
305
+ for (const tag of tags) {
306
+ const match = tag.match(/^puls-h-([a-zA-Z0-9_-]+)-([a-f0-9]{12})$/);
307
+ if (match) {
308
+ const [_, playbookName, hash] = match;
309
+ result[playbookName] = hash;
310
+ }
311
+ }
312
+ return result;
313
+ }
314
+ export function generateDropletTagForProvision(playbook, hash) {
315
+ const baseName = playbook.split('/').pop() ?? playbook;
316
+ const slug = baseName.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
317
+ return `puls-h-${slug}-${hash}`;
318
+ }