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.
- package/dist/core/config.d.ts +2 -0
- package/dist/core/decorators.d.ts +2 -0
- package/dist/core/decorators.js +48 -16
- package/dist/core/hooks.d.ts +21 -0
- package/dist/core/hooks.js +116 -0
- package/dist/core/hooks.test.d.ts +1 -0
- package/dist/core/hooks.test.js +194 -0
- package/dist/core/multiregion.test.d.ts +1 -0
- package/dist/core/multiregion.test.js +87 -0
- package/dist/core/output.d.ts +2 -0
- package/dist/core/output.js +9 -2
- package/dist/core/parser.d.ts +10 -0
- package/dist/core/parser.js +140 -0
- package/dist/core/parser.test.d.ts +1 -0
- package/dist/core/parser.test.js +117 -0
- package/dist/core/provisioner.d.ts +4 -0
- package/dist/core/provisioner.js +105 -0
- package/dist/core/resource.d.ts +16 -0
- package/dist/core/resource.js +44 -0
- package/dist/core/secret.d.ts +40 -0
- package/dist/core/secret.js +95 -0
- package/dist/core/secret.test.d.ts +1 -0
- package/dist/core/secret.test.js +166 -0
- package/dist/core/stack.d.ts +4 -3
- package/dist/core/stack.js +50 -9
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/providers/aws/ec2.d.ts +48 -0
- package/dist/providers/aws/ec2.js +297 -0
- package/dist/providers/aws/ec2.test.d.ts +1 -0
- package/dist/providers/aws/ec2.test.js +279 -0
- package/dist/providers/aws/index.d.ts +2 -0
- package/dist/providers/aws/index.js +2 -0
- package/dist/providers/aws/route53.d.ts +1 -0
- package/dist/providers/aws/route53.js +15 -2
- package/dist/providers/aws/route53.test.js +47 -0
- package/dist/providers/do/api.d.ts +1 -1
- package/dist/providers/do/api.js +2 -1
- package/dist/providers/do/app.d.ts +26 -0
- package/dist/providers/do/app.js +124 -0
- package/dist/providers/do/app.test.d.ts +1 -0
- package/dist/providers/do/app.test.js +268 -0
- package/dist/providers/do/database.d.ts +44 -0
- package/dist/providers/do/database.js +208 -0
- package/dist/providers/do/database.test.d.ts +1 -0
- package/dist/providers/do/database.test.js +293 -0
- package/dist/providers/do/domain.d.ts +2 -0
- package/dist/providers/do/domain.js +30 -0
- package/dist/providers/do/domain.test.js +49 -0
- package/dist/providers/do/droplet.d.ts +9 -0
- package/dist/providers/do/droplet.js +132 -8
- package/dist/providers/do/droplet.test.js +228 -1
- package/dist/providers/do/firewall.d.ts +2 -1
- package/dist/providers/do/firewall.js +23 -9
- package/dist/providers/do/firewall.test.js +54 -0
- package/dist/providers/do/index.d.ts +11 -0
- package/dist/providers/do/index.js +8 -0
- package/dist/providers/do/spaces.d.ts +27 -0
- package/dist/providers/do/spaces.js +142 -0
- package/dist/providers/do/spaces.test.d.ts +1 -0
- package/dist/providers/do/spaces.test.js +180 -0
- package/dist/providers/do/spaces_api.d.ts +2 -0
- package/dist/providers/do/spaces_api.js +20 -0
- package/dist/providers/do/vpc.d.ts +30 -0
- package/dist/providers/do/vpc.js +128 -0
- package/dist/providers/do/vpc.test.d.ts +1 -0
- package/dist/providers/do/vpc.test.js +258 -0
- package/dist/providers/gcp/clouddns.d.ts +1 -0
- package/dist/providers/gcp/clouddns.js +15 -2
- package/dist/providers/gcp/clouddns.test.js +45 -0
- package/dist/providers/gcp/index.d.ts +3 -1
- package/dist/providers/gcp/index.js +3 -1
- package/dist/providers/gcp/vm.d.ts +45 -0
- package/dist/providers/gcp/vm.js +332 -0
- package/dist/providers/gcp/vm.test.d.ts +1 -0
- package/dist/providers/gcp/vm.test.js +321 -0
- package/dist/providers/proxmox/vm.d.ts +4 -4
- package/dist/providers/proxmox/vm.js +17 -93
- package/dist/providers/proxmox/vm.test.js +77 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
+
}
|