puls-dev 0.2.7 → 0.2.8

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 (80) hide show
  1. package/dist/core/config.d.ts +2 -0
  2. package/dist/core/decorators.d.ts +2 -0
  3. package/dist/core/decorators.js +48 -16
  4. package/dist/core/hooks.d.ts +21 -0
  5. package/dist/core/hooks.js +116 -0
  6. package/dist/core/hooks.test.d.ts +1 -0
  7. package/dist/core/hooks.test.js +194 -0
  8. package/dist/core/multiregion.test.d.ts +1 -0
  9. package/dist/core/multiregion.test.js +87 -0
  10. package/dist/core/output.d.ts +2 -0
  11. package/dist/core/output.js +9 -2
  12. package/dist/core/parser.d.ts +10 -0
  13. package/dist/core/parser.js +140 -0
  14. package/dist/core/parser.test.d.ts +1 -0
  15. package/dist/core/parser.test.js +117 -0
  16. package/dist/core/provisioner.d.ts +4 -0
  17. package/dist/core/provisioner.js +105 -0
  18. package/dist/core/resource.d.ts +16 -0
  19. package/dist/core/resource.js +44 -0
  20. package/dist/core/secret.d.ts +40 -0
  21. package/dist/core/secret.js +95 -0
  22. package/dist/core/secret.test.d.ts +1 -0
  23. package/dist/core/secret.test.js +166 -0
  24. package/dist/core/stack.d.ts +4 -3
  25. package/dist/core/stack.js +50 -9
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +2 -0
  28. package/dist/providers/aws/ec2.d.ts +48 -0
  29. package/dist/providers/aws/ec2.js +297 -0
  30. package/dist/providers/aws/ec2.test.d.ts +1 -0
  31. package/dist/providers/aws/ec2.test.js +279 -0
  32. package/dist/providers/aws/index.d.ts +2 -0
  33. package/dist/providers/aws/index.js +2 -0
  34. package/dist/providers/aws/route53.d.ts +1 -0
  35. package/dist/providers/aws/route53.js +15 -2
  36. package/dist/providers/aws/route53.test.js +47 -0
  37. package/dist/providers/do/api.d.ts +1 -1
  38. package/dist/providers/do/api.js +2 -1
  39. package/dist/providers/do/app.d.ts +26 -0
  40. package/dist/providers/do/app.js +124 -0
  41. package/dist/providers/do/app.test.d.ts +1 -0
  42. package/dist/providers/do/app.test.js +268 -0
  43. package/dist/providers/do/database.d.ts +44 -0
  44. package/dist/providers/do/database.js +208 -0
  45. package/dist/providers/do/database.test.d.ts +1 -0
  46. package/dist/providers/do/database.test.js +293 -0
  47. package/dist/providers/do/domain.d.ts +2 -0
  48. package/dist/providers/do/domain.js +30 -0
  49. package/dist/providers/do/domain.test.js +49 -0
  50. package/dist/providers/do/droplet.d.ts +9 -0
  51. package/dist/providers/do/droplet.js +132 -8
  52. package/dist/providers/do/droplet.test.js +228 -1
  53. package/dist/providers/do/firewall.d.ts +2 -1
  54. package/dist/providers/do/firewall.js +23 -9
  55. package/dist/providers/do/firewall.test.js +54 -0
  56. package/dist/providers/do/index.d.ts +11 -0
  57. package/dist/providers/do/index.js +8 -0
  58. package/dist/providers/do/spaces.d.ts +27 -0
  59. package/dist/providers/do/spaces.js +142 -0
  60. package/dist/providers/do/spaces.test.d.ts +1 -0
  61. package/dist/providers/do/spaces.test.js +180 -0
  62. package/dist/providers/do/spaces_api.d.ts +2 -0
  63. package/dist/providers/do/spaces_api.js +20 -0
  64. package/dist/providers/do/vpc.d.ts +30 -0
  65. package/dist/providers/do/vpc.js +128 -0
  66. package/dist/providers/do/vpc.test.d.ts +1 -0
  67. package/dist/providers/do/vpc.test.js +258 -0
  68. package/dist/providers/gcp/clouddns.d.ts +1 -0
  69. package/dist/providers/gcp/clouddns.js +15 -2
  70. package/dist/providers/gcp/clouddns.test.js +45 -0
  71. package/dist/providers/gcp/index.d.ts +3 -1
  72. package/dist/providers/gcp/index.js +3 -1
  73. package/dist/providers/gcp/vm.d.ts +45 -0
  74. package/dist/providers/gcp/vm.js +332 -0
  75. package/dist/providers/gcp/vm.test.d.ts +1 -0
  76. package/dist/providers/gcp/vm.test.js +321 -0
  77. package/dist/providers/proxmox/vm.d.ts +4 -4
  78. package/dist/providers/proxmox/vm.js +17 -93
  79. package/dist/providers/proxmox/vm.test.js +77 -0
  80. 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,8 @@ 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';
11
13
  export class DropletBuilder extends BaseBuilder {
12
14
  out = {
13
15
  ip: new Output(),
@@ -21,6 +23,8 @@ export class DropletBuilder extends BaseBuilder {
21
23
  dropletId;
22
24
  resolvedIp;
23
25
  sshKeyPath;
26
+ _provision = [];
27
+ _forceConfigCheck = false;
24
28
  constructor(name) {
25
29
  super(name);
26
30
  this.discoveryPromise = this.discoverDroplet(name);
@@ -69,6 +73,25 @@ export class DropletBuilder extends BaseBuilder {
69
73
  this.sshKeyPath = keyPath.replace('~', homedir());
70
74
  return this;
71
75
  }
76
+ vpc(uuid) {
77
+ this.config.vpc_uuid = uuid;
78
+ return this;
79
+ }
80
+ provision(...playbookPaths) {
81
+ this._provision.push(...playbookPaths.flat());
82
+ return this;
83
+ }
84
+ forceConfigCheck() {
85
+ this._forceConfigCheck = true;
86
+ return this;
87
+ }
88
+ async checkPort(ip, port) {
89
+ return checkPort(ip, port);
90
+ }
91
+ async runProvisioner(ip, script) {
92
+ const keyPath = this.sshKeyPath ? this.sshKeyPath : undefined;
93
+ return runProvisioner(ip, "root", keyPath, script);
94
+ }
72
95
  async resolveOrRegisterSshKey(api) {
73
96
  const pubPath = this.sshKeyPath.replace(/\.pub$/, '') + '.pub';
74
97
  const pubKey = fs.readFileSync(pubPath, 'utf8').trim();
@@ -90,16 +113,46 @@ export class DropletBuilder extends BaseBuilder {
90
113
  : true;
91
114
  if (await this.checkProtection(hasChanges))
92
115
  return null;
116
+ // Provisioning Calculations
117
+ const appliedHashes = existing ? parseDropletTagsForProvision(existing.tags || []) : {};
118
+ const declaredPlaybooksWithHashes = this._provision.map((p) => {
119
+ const baseName = p.split("/").pop() ?? p;
120
+ const slug = baseName.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
121
+ return { path: p, slug, hash: getFileHash(p) };
122
+ });
123
+ const playbooksToRun = this._forceConfigCheck
124
+ ? declaredPlaybooksWithHashes
125
+ : declaredPlaybooksWithHashes.filter((p) => {
126
+ const appliedHash = appliedHashes[p.slug];
127
+ return !appliedHash || appliedHash !== p.hash;
128
+ });
129
+ const playbookRunRequired = playbooksToRun.length > 0;
93
130
  if (dryRun) {
94
131
  console.log(`\nšŸ” [DRY RUN] "${this.name}"...`);
95
132
  if (!existing) {
96
133
  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})`);
134
+ let vpcHint = '';
135
+ if (this.config.vpc_uuid) {
136
+ const vpcVal = this.config.vpc_uuid instanceof Output ? 'PENDING' : this.config.vpc_uuid;
137
+ vpcHint = ` in VPC ${vpcVal}`;
138
+ }
139
+ console.log(` šŸ“ Plan: Create droplet ${this.name} (${this.config.size} in ${this.config.region}${vpcHint}${keyHint})`);
140
+ if (this._provision.length > 0) {
141
+ console.log(` └─ Provision: ${this._provision.join(", ")}`);
142
+ }
98
143
  this.out.id.resolve(-1);
99
144
  this.out.ip.resolve('0.0.0.0');
100
145
  }
101
- else if (hasChanges) {
102
- console.log(` šŸ“ Plan: Resize ${this.name} → ${this.config.size}`);
146
+ else if (hasChanges || playbookRunRequired) {
147
+ if (hasChanges) {
148
+ console.log(` šŸ“ Plan: Resize ${this.name} → ${this.config.size}`);
149
+ }
150
+ if (playbookRunRequired) {
151
+ console.log(` šŸ“ [PLAN] Run ${playbooksToRun.length} playbook changes on existing droplet:`);
152
+ for (const p of playbooksToRun) {
153
+ console.log(` └─ Playbook: ${p.path} (hash: ${p.hash})`);
154
+ }
155
+ }
103
156
  }
104
157
  else {
105
158
  console.log(` āœ… ${this.name} is up to date.`);
@@ -111,12 +164,23 @@ export class DropletBuilder extends BaseBuilder {
111
164
  console.log(`\nā³ Finalizing "${this.name}"...`);
112
165
  if (!existing) {
113
166
  const sshKeyIds = this.sshKeyPath ? [await this.resolveOrRegisterSshKey(api)] : [];
167
+ let resolvedVpcUuid;
168
+ if (this.config.vpc_uuid) {
169
+ resolvedVpcUuid = this.config.vpc_uuid instanceof Output ? await this.config.vpc_uuid.get() : this.config.vpc_uuid;
170
+ }
171
+ const initialTags = [];
172
+ for (const playbook of this._provision) {
173
+ const hash = getFileHash(playbook);
174
+ initialTags.push(generateDropletTagForProvision(playbook, hash));
175
+ }
114
176
  const result = await api.post('/droplets', {
115
177
  name: this.name,
116
178
  region: this.config.region,
117
179
  size: this.config.size,
118
180
  image: this.config.image,
119
181
  ...(sshKeyIds.length && { ssh_keys: sshKeyIds }),
182
+ ...(resolvedVpcUuid && { vpc_uuid: resolvedVpcUuid }),
183
+ ...(initialTags.length && { tags: initialTags }),
120
184
  });
121
185
  this.dropletId = result.droplet.id;
122
186
  this.out.id.resolve(this.dropletId);
@@ -134,13 +198,57 @@ export class DropletBuilder extends BaseBuilder {
134
198
  this.out.ip.resolve(this.resolvedIp);
135
199
  console.log(` 🌐 Public IP: ${this.resolvedIp}`);
136
200
  }
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 });
201
+ if (this._provision.length > 0) {
202
+ const activeIp = this.resolvedIp ?? '0.0.0.0';
203
+ if (activeIp === '0.0.0.0') {
204
+ throw new Error(`Failed to resolve IP for new droplet "${this.name}" to run playbooks`);
205
+ }
206
+ await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
207
+ for (const playbook of this._provision) {
208
+ await this.runProvisioner(activeIp, playbook);
209
+ }
210
+ }
141
211
  }
142
212
  else {
143
- console.log(`āœ… ${this.name} is up to date.`);
213
+ if (hasChanges) {
214
+ console.log(`✨ Resizing ${this.name} → ${this.config.size}...`);
215
+ await api.post(`/droplets/${this.dropletId}/actions`, { type: 'resize', size: this.config.size });
216
+ }
217
+ if (playbookRunRequired) {
218
+ console.log(` šŸ”„ Running ${playbooksToRun.length} playbook changes...`);
219
+ const activeIp = this.resolvedIp ?? '0.0.0.0';
220
+ if (activeIp === '0.0.0.0') {
221
+ throw new Error(`Failed to resolve IP for existing droplet "${this.name}" to run playbooks`);
222
+ }
223
+ await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
224
+ for (const p of playbooksToRun) {
225
+ await this.runProvisioner(activeIp, p.path);
226
+ // Update tags on DigitalOcean Droplet
227
+ const oldHash = appliedHashes[p.slug];
228
+ if (oldHash) {
229
+ const oldTag = `puls-h-${p.slug}-${oldHash}`;
230
+ try {
231
+ await api.delete(`/tags/${encodeURIComponent(oldTag)}/resources`, {
232
+ resources: [{ id: String(this.dropletId), type: 'droplet' }]
233
+ });
234
+ }
235
+ catch { }
236
+ }
237
+ const newTag = `puls-h-${p.slug}-${p.hash}`;
238
+ try {
239
+ await api.post('/tags', { name: newTag });
240
+ }
241
+ catch { }
242
+ await api.post(`/tags/${encodeURIComponent(newTag)}/resources`, {
243
+ resources: [{ id: String(this.dropletId), type: 'droplet' }]
244
+ });
245
+ appliedHashes[p.slug] = p.hash;
246
+ }
247
+ console.log(` āœ… Playbooks applied successfully and metadata updated.`);
248
+ }
249
+ if (!hasChanges && !playbookRunRequired) {
250
+ console.log(`āœ… ${this.name} is up to date.`);
251
+ }
144
252
  }
145
253
  for (const sidecar of this.sidecars)
146
254
  await sidecar.deploy();
@@ -178,3 +286,19 @@ export const DO = {
178
286
  Domain: (name) => new DomainBuilder(name),
179
287
  LoadBalancer: (name) => new LoadBalancerBuilder(name),
180
288
  };
289
+ export function parseDropletTagsForProvision(tags) {
290
+ const result = {};
291
+ for (const tag of tags) {
292
+ const match = tag.match(/^puls-h-([a-zA-Z0-9_-]+)-([a-f0-9]{12})$/);
293
+ if (match) {
294
+ const [_, playbookName, hash] = match;
295
+ result[playbookName] = hash;
296
+ }
297
+ }
298
+ return result;
299
+ }
300
+ export function generateDropletTagForProvision(playbook, hash) {
301
+ const baseName = playbook.split('/').pop() ?? playbook;
302
+ const slug = baseName.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
303
+ return `puls-h-${slug}-${hash}`;
304
+ }