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.
- package/dist/core/checker.js +71 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +11 -1
- package/dist/core/context.d.ts +14 -0
- package/dist/core/context.js +2 -0
- package/dist/core/decorators.d.ts +4 -0
- package/dist/core/decorators.js +56 -30
- 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/parallel.test.d.ts +1 -0
- package/dist/core/parallel.test.js +215 -0
- 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/production.test.d.ts +1 -0
- package/dist/core/production.test.js +189 -0
- package/dist/core/provisioner.d.ts +4 -0
- package/dist/core/provisioner.js +123 -0
- package/dist/core/resource.d.ts +23 -0
- package/dist/core/resource.js +54 -0
- package/dist/core/retry.d.ts +9 -0
- package/dist/core/retry.js +28 -0
- package/dist/core/retry.test.d.ts +1 -0
- package/dist/core/retry.test.js +66 -0
- package/dist/core/secret.d.ts +41 -0
- package/dist/core/secret.js +105 -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 +322 -48
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/providers/aws/api.js +97 -17
- package/dist/providers/aws/ec2.d.ts +51 -0
- package/dist/providers/aws/ec2.js +331 -0
- package/dist/providers/aws/ec2.test.d.ts +1 -0
- package/dist/providers/aws/ec2.test.js +281 -0
- package/dist/providers/aws/index.d.ts +4 -0
- package/dist/providers/aws/index.js +4 -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/aws/template.d.ts +34 -0
- package/dist/providers/aws/template.js +252 -0
- package/dist/providers/aws/template.test.d.ts +1 -0
- package/dist/providers/aws/template.test.js +208 -0
- package/dist/providers/do/api.d.ts +3 -1
- package/dist/providers/do/api.js +126 -27
- 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 +146 -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/firebase/api.js +92 -29
- package/dist/providers/firebase/list.d.ts +2 -0
- package/dist/providers/firebase/list.js +25 -0
- package/dist/providers/gcp/api.js +88 -14
- package/dist/providers/gcp/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 +5 -1
- package/dist/providers/gcp/index.js +5 -1
- package/dist/providers/gcp/list.d.ts +2 -0
- package/dist/providers/gcp/list.js +55 -0
- package/dist/providers/gcp/secrets.js +1 -1
- package/dist/providers/gcp/template.d.ts +32 -0
- package/dist/providers/gcp/template.js +252 -0
- package/dist/providers/gcp/template.test.d.ts +1 -0
- package/dist/providers/gcp/template.test.js +227 -0
- package/dist/providers/gcp/vm.d.ts +48 -0
- package/dist/providers/gcp/vm.js +375 -0
- package/dist/providers/gcp/vm.test.d.ts +1 -0
- package/dist/providers/gcp/vm.test.js +321 -0
- package/dist/providers/proxmox/api.d.ts +1 -0
- package/dist/providers/proxmox/api.js +72 -16
- package/dist/providers/proxmox/index.d.ts +2 -0
- package/dist/providers/proxmox/index.js +2 -0
- package/dist/providers/proxmox/template.d.ts +44 -0
- package/dist/providers/proxmox/template.js +349 -0
- package/dist/providers/proxmox/template.test.d.ts +1 -0
- package/dist/providers/proxmox/template.test.js +179 -0
- package/dist/providers/proxmox/vm.d.ts +7 -4
- package/dist/providers/proxmox/vm.js +57 -102
- package/dist/providers/proxmox/vm.test.js +77 -0
- package/dist/types/inventory.d.ts +44 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
+
}
|