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,279 @@
|
|
|
1
|
+
import { test, describe, beforeEach, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { EC2VMBuilder, parseAwsTagsForProvision, mergeAwsTagsForProvision } from "./ec2.js";
|
|
7
|
+
import { getFileHash } from "../proxmox/hash.js";
|
|
8
|
+
import { Config } from "../../core/config.js";
|
|
9
|
+
import { EC2Client } from "@aws-sdk/client-ec2";
|
|
10
|
+
describe("EC2VMBuilder Unit Tests", () => {
|
|
11
|
+
let originalSend;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
Config.set({
|
|
14
|
+
dryRun: false,
|
|
15
|
+
providers: {
|
|
16
|
+
aws: { region: "us-east-1" },
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
originalSend = EC2Client.prototype.send;
|
|
20
|
+
});
|
|
21
|
+
test("gracefully handles discovery when VM does not exist", async () => {
|
|
22
|
+
EC2Client.prototype.send = (async (command) => {
|
|
23
|
+
if (command.constructor.name === "DescribeInstancesCommand") {
|
|
24
|
+
return { Reservations: [] };
|
|
25
|
+
}
|
|
26
|
+
return {};
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
const vm = new EC2VMBuilder("missing-vm");
|
|
30
|
+
const existing = await vm.discoveryPromise;
|
|
31
|
+
assert.strictEqual(existing, null);
|
|
32
|
+
assert.strictEqual(vm.resolvedInstanceId, undefined);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
EC2Client.prototype.send = originalSend;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
test("discovers VM successfully when it exists", async () => {
|
|
39
|
+
EC2Client.prototype.send = (async (command) => {
|
|
40
|
+
if (command.constructor.name === "DescribeInstancesCommand") {
|
|
41
|
+
return {
|
|
42
|
+
Reservations: [
|
|
43
|
+
{
|
|
44
|
+
Instances: [
|
|
45
|
+
{
|
|
46
|
+
InstanceId: "i-1234567890abcdef0",
|
|
47
|
+
InstanceType: "t3.micro",
|
|
48
|
+
PublicIpAddress: "54.80.12.34",
|
|
49
|
+
State: { Name: "running" },
|
|
50
|
+
Tags: [{ Key: "Name", Value: "existing-vm" }],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {};
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
const vm = new EC2VMBuilder("existing-vm");
|
|
61
|
+
const existing = await vm.discoveryPromise;
|
|
62
|
+
assert.ok(existing);
|
|
63
|
+
assert.strictEqual(existing.InstanceId, "i-1234567890abcdef0");
|
|
64
|
+
assert.strictEqual(await vm.out.id.get(), "i-1234567890abcdef0");
|
|
65
|
+
assert.strictEqual(await vm.out.ip.get(), "54.80.12.34");
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
EC2Client.prototype.send = originalSend;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
test("runs in dry-run mode safely and logs plan", async () => {
|
|
72
|
+
Config.set({ dryRun: true });
|
|
73
|
+
EC2Client.prototype.send = (async (command) => {
|
|
74
|
+
if (command.constructor.name === "DescribeInstancesCommand") {
|
|
75
|
+
return { Reservations: [] };
|
|
76
|
+
}
|
|
77
|
+
return {};
|
|
78
|
+
});
|
|
79
|
+
const originalLog = console.log;
|
|
80
|
+
let logOutput = "";
|
|
81
|
+
console.log = (...args) => {
|
|
82
|
+
logOutput += args.join(" ") + "\n";
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
const vm = new EC2VMBuilder("dryrun-vm")
|
|
86
|
+
.instanceType("t3.medium")
|
|
87
|
+
.ami("ami-test123")
|
|
88
|
+
.provision("playbooks/nginx.yaml");
|
|
89
|
+
const result = await vm.deploy();
|
|
90
|
+
assert.strictEqual(result.name, "dryrun-vm");
|
|
91
|
+
assert.strictEqual(result.id, "PENDING");
|
|
92
|
+
assert.strictEqual(await vm.out.id.get(), "PENDING");
|
|
93
|
+
assert.strictEqual(await vm.out.ip.get(), "0.0.0.0");
|
|
94
|
+
assert.ok(logOutput.includes("š [DRY RUN]"));
|
|
95
|
+
assert.ok(logOutput.includes("Plan: Create EC2 Instance \"dryrun-vm\""));
|
|
96
|
+
assert.ok(logOutput.includes("t3.medium from AMI ami-test123"));
|
|
97
|
+
assert.ok(logOutput.includes("playbooks/nginx.yaml"));
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
console.log = originalLog;
|
|
101
|
+
EC2Client.prototype.send = originalSend;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
test("creates a new VM instance and runs playbooks successfully", async () => {
|
|
105
|
+
// 1. Create a dummy playbook file to check hash
|
|
106
|
+
const dummyPlaybookDir = join(tmpdir(), `puls-ec2-test-${Date.now()}`);
|
|
107
|
+
fs.mkdirSync(dummyPlaybookDir);
|
|
108
|
+
const playbookPath = join(dummyPlaybookDir, "setup.yaml");
|
|
109
|
+
fs.writeFileSync(playbookPath, "- hosts: all\n tasks:\n - name: Hello");
|
|
110
|
+
// Mock EC2 Client state transitions:
|
|
111
|
+
// First DescribeInstancesCommand returns empty, then returns running VM.
|
|
112
|
+
let describeCount = 0;
|
|
113
|
+
EC2Client.prototype.send = (async (command) => {
|
|
114
|
+
const name = command.constructor.name;
|
|
115
|
+
if (name === "DescribeInstancesCommand") {
|
|
116
|
+
describeCount++;
|
|
117
|
+
if (describeCount === 1) {
|
|
118
|
+
return { Reservations: [] };
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
Reservations: [
|
|
122
|
+
{
|
|
123
|
+
Instances: [
|
|
124
|
+
{
|
|
125
|
+
InstanceId: "i-999",
|
|
126
|
+
InstanceType: "t3.micro",
|
|
127
|
+
PublicIpAddress: "34.200.10.20",
|
|
128
|
+
State: { Name: "running" },
|
|
129
|
+
Tags: [
|
|
130
|
+
{ Key: "Name", Value: "new-ec2-vm" },
|
|
131
|
+
{ Key: "puls-provision", Value: `setup-yaml=${getFileHash(playbookPath)}` },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (name === "RunInstancesCommand") {
|
|
140
|
+
return {
|
|
141
|
+
Instances: [
|
|
142
|
+
{
|
|
143
|
+
InstanceId: "i-999",
|
|
144
|
+
State: { Name: "pending" },
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {};
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
const vm = new EC2VMBuilder("new-ec2-vm")
|
|
153
|
+
.ami("ami-0c55b159cbfafe1f0")
|
|
154
|
+
.instanceType("t3.micro")
|
|
155
|
+
.sshPrivateKey("~/.ssh/id_rsa")
|
|
156
|
+
.provision(playbookPath);
|
|
157
|
+
// Mock SSH Port check and playbook run
|
|
158
|
+
mock.method(vm, "checkPort", async () => true);
|
|
159
|
+
const runProvisionSpy = mock.method(vm, "runProvisioner", async () => { });
|
|
160
|
+
const result = await vm.deploy();
|
|
161
|
+
assert.strictEqual(result.id, "i-999");
|
|
162
|
+
assert.strictEqual(result.ip, "34.200.10.20");
|
|
163
|
+
assert.strictEqual(await vm.out.ip.get(), "34.200.10.20");
|
|
164
|
+
assert.strictEqual(runProvisionSpy.mock.callCount(), 1);
|
|
165
|
+
assert.strictEqual(runProvisionSpy.mock.calls[0].arguments[0], "34.200.10.20");
|
|
166
|
+
assert.strictEqual(runProvisionSpy.mock.calls[0].arguments[1], playbookPath);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
// Cleanup
|
|
170
|
+
EC2Client.prototype.send = originalSend;
|
|
171
|
+
try {
|
|
172
|
+
fs.unlinkSync(playbookPath);
|
|
173
|
+
fs.rmdirSync(dummyPlaybookDir);
|
|
174
|
+
}
|
|
175
|
+
catch { }
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
test("stops, resizes, and starts the VM when instanceType changes", async () => {
|
|
179
|
+
let state = "running";
|
|
180
|
+
let type = "t3.micro";
|
|
181
|
+
EC2Client.prototype.send = (async (command) => {
|
|
182
|
+
const name = command.constructor.name;
|
|
183
|
+
if (name === "DescribeInstancesCommand") {
|
|
184
|
+
return {
|
|
185
|
+
Reservations: [
|
|
186
|
+
{
|
|
187
|
+
Instances: [
|
|
188
|
+
{
|
|
189
|
+
InstanceId: "i-123",
|
|
190
|
+
InstanceType: type,
|
|
191
|
+
PublicIpAddress: "34.20.10.5",
|
|
192
|
+
State: { Name: state },
|
|
193
|
+
Tags: [{ Key: "Name", Value: "resize-vm" }],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (name === "StopInstancesCommand") {
|
|
201
|
+
state = "stopped";
|
|
202
|
+
return {};
|
|
203
|
+
}
|
|
204
|
+
if (name === "ModifyInstanceAttributeCommand") {
|
|
205
|
+
type = command.input.InstanceType.Value;
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
if (name === "StartInstancesCommand") {
|
|
209
|
+
state = "running";
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
return {};
|
|
213
|
+
});
|
|
214
|
+
try {
|
|
215
|
+
const vm = new EC2VMBuilder("resize-vm").instanceType("t3.medium");
|
|
216
|
+
const result = await vm.deploy();
|
|
217
|
+
assert.strictEqual(result.id, "i-123");
|
|
218
|
+
assert.strictEqual(type, "t3.medium");
|
|
219
|
+
assert.strictEqual(state, "running");
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
EC2Client.prototype.send = originalSend;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
test("terminates the VM successfully on destroy", async () => {
|
|
226
|
+
let terminated = false;
|
|
227
|
+
EC2Client.prototype.send = (async (command) => {
|
|
228
|
+
const name = command.constructor.name;
|
|
229
|
+
if (name === "DescribeInstancesCommand") {
|
|
230
|
+
return {
|
|
231
|
+
Reservations: [
|
|
232
|
+
{
|
|
233
|
+
Instances: [
|
|
234
|
+
{
|
|
235
|
+
InstanceId: "i-555",
|
|
236
|
+
InstanceType: "t3.micro",
|
|
237
|
+
State: { Name: "running" },
|
|
238
|
+
Tags: [{ Key: "Name", Value: "delete-vm" }],
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (name === "TerminateInstancesCommand") {
|
|
246
|
+
terminated = true;
|
|
247
|
+
return {};
|
|
248
|
+
}
|
|
249
|
+
return {};
|
|
250
|
+
});
|
|
251
|
+
try {
|
|
252
|
+
const vm = new EC2VMBuilder("delete-vm");
|
|
253
|
+
const result = await vm.destroy();
|
|
254
|
+
assert.strictEqual(result.destroyed, "delete-vm");
|
|
255
|
+
assert.ok(terminated);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
EC2Client.prototype.send = originalSend;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
test("parseAwsTagsForProvision correctly handles missing and formatted provision tags", () => {
|
|
262
|
+
assert.deepStrictEqual(parseAwsTagsForProvision(undefined), {});
|
|
263
|
+
assert.deepStrictEqual(parseAwsTagsForProvision([]), {});
|
|
264
|
+
const tags = [
|
|
265
|
+
{ Key: "Name", Value: "my-vm" },
|
|
266
|
+
{ Key: "puls-provision", Value: "setup-yaml=h123,configure-yaml=h456" },
|
|
267
|
+
];
|
|
268
|
+
const parsed = parseAwsTagsForProvision(tags);
|
|
269
|
+
assert.deepStrictEqual(parsed, {
|
|
270
|
+
"setup-yaml": "h123",
|
|
271
|
+
"configure-yaml": "h456",
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
test("mergeAwsTagsForProvision merges record mapping back into string", () => {
|
|
275
|
+
const hashes = { "nginx-yaml": "hash-abc", "sec-yaml": "hash-xyz" };
|
|
276
|
+
const merged = mergeAwsTagsForProvision(hashes);
|
|
277
|
+
assert.strictEqual(merged, "nginx-yaml=hash-abc,sec-yaml=hash-xyz");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -10,6 +10,7 @@ import { SecretsBuilder } from "./secrets.js";
|
|
|
10
10
|
import { IAMRoleBuilder, IAMPolicyBuilder } from "./iam.js";
|
|
11
11
|
import { SNSTopicBuilder } from "./sns.js";
|
|
12
12
|
import { CloudWatchAlarmBuilder } from "./cloudwatch.js";
|
|
13
|
+
import { EC2VMBuilder } from "./ec2.js";
|
|
13
14
|
export declare const AWS: {
|
|
14
15
|
init: (opts: {
|
|
15
16
|
region: string;
|
|
@@ -27,5 +28,6 @@ export declare const AWS: {
|
|
|
27
28
|
IAMPolicy: (name: string) => IAMPolicyBuilder;
|
|
28
29
|
SNS: (name: string) => SNSTopicBuilder;
|
|
29
30
|
Alarm: (name: string) => CloudWatchAlarmBuilder;
|
|
31
|
+
EC2: (name: string) => EC2VMBuilder;
|
|
30
32
|
};
|
|
31
33
|
export * from "../../types/aws.js";
|
|
@@ -11,6 +11,7 @@ import { SecretsBuilder } from "./secrets.js";
|
|
|
11
11
|
import { IAMRoleBuilder, IAMPolicyBuilder } from "./iam.js";
|
|
12
12
|
import { SNSTopicBuilder } from "./sns.js";
|
|
13
13
|
import { CloudWatchAlarmBuilder } from "./cloudwatch.js";
|
|
14
|
+
import { EC2VMBuilder } from "./ec2.js";
|
|
14
15
|
export const AWS = {
|
|
15
16
|
init: (opts) => {
|
|
16
17
|
Config.set({
|
|
@@ -33,5 +34,6 @@ export const AWS = {
|
|
|
33
34
|
IAMPolicy: (name) => new IAMPolicyBuilder(name),
|
|
34
35
|
SNS: (name) => new SNSTopicBuilder(name),
|
|
35
36
|
Alarm: (name) => new CloudWatchAlarmBuilder(name),
|
|
37
|
+
EC2: (name) => new EC2VMBuilder(name),
|
|
36
38
|
};
|
|
37
39
|
export * from "../../types/aws.js";
|
|
@@ -21,6 +21,7 @@ export declare class Route53Builder extends BaseBuilder {
|
|
|
21
21
|
cert(): ACMCertificateBuilder | undefined;
|
|
22
22
|
withWildcardSSL(): this;
|
|
23
23
|
register(contact?: RegistrantContact): this;
|
|
24
|
+
record(filePath: string): this;
|
|
24
25
|
record(name: string, type: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS" | "PTR" | "SRV" | "CAA" | "NAPTR" | "SPF", value: string, ttl?: number): this;
|
|
25
26
|
pointer(name: string, target: BaseBuilder): this;
|
|
26
27
|
deploy(): Promise<{
|
|
@@ -4,6 +4,7 @@ import { BaseBuilder } from "../../core/resource.js";
|
|
|
4
4
|
import { Output } from "../../core/output.js";
|
|
5
5
|
import { ACMCertificateBuilder } from "./acm.js";
|
|
6
6
|
import { getR53Client, getR53DomainsClient } from "./api.js";
|
|
7
|
+
import { loadRecordsFromFile } from "../../core/parser.js";
|
|
7
8
|
export class Route53Builder extends BaseBuilder {
|
|
8
9
|
out = {
|
|
9
10
|
zone: new Output(),
|
|
@@ -59,8 +60,20 @@ export class Route53Builder extends BaseBuilder {
|
|
|
59
60
|
this._registrantContact = contact;
|
|
60
61
|
return this;
|
|
61
62
|
}
|
|
62
|
-
record(
|
|
63
|
-
|
|
63
|
+
record(nameOrPath, type, value, ttl = 300) {
|
|
64
|
+
if (arguments.length === 1 && typeof nameOrPath === "string" && (nameOrPath.endsWith(".yaml") || nameOrPath.endsWith(".yml") || nameOrPath.endsWith(".json"))) {
|
|
65
|
+
const loaded = loadRecordsFromFile(nameOrPath);
|
|
66
|
+
for (const r of loaded) {
|
|
67
|
+
this.records.push({
|
|
68
|
+
name: r.name,
|
|
69
|
+
type: r.type,
|
|
70
|
+
value: r.value,
|
|
71
|
+
ttl: r.ttl ?? 300,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
this.records.push({ name: nameOrPath, type: type, value: value, ttl });
|
|
64
77
|
return this;
|
|
65
78
|
}
|
|
66
79
|
pointer(name, target) {
|
|
@@ -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 { Route53Client } from '@aws-sdk/client-route-53';
|
|
4
6
|
import { Route53DomainsClient } from '@aws-sdk/client-route-53-domains';
|
|
5
7
|
import { Route53Builder } from './route53.js';
|
|
@@ -226,4 +228,49 @@ describe('Route53Builder Unit Tests', () => {
|
|
|
226
228
|
assert.strictEqual(pollCall.length, 2);
|
|
227
229
|
assert.strictEqual(pollCall[0].input.OperationId, 'op-registration-abc');
|
|
228
230
|
});
|
|
231
|
+
test("loads records from a configuration file (JSON) successfully", async () => {
|
|
232
|
+
mockR53Responses['ListHostedZonesByNameCommand'] = {
|
|
233
|
+
HostedZones: [{ Id: '/hostedzone/Z123', Name: 'example.com.' }]
|
|
234
|
+
};
|
|
235
|
+
mockR53Responses['ChangeResourceRecordSetsCommand'] = {};
|
|
236
|
+
// Mock JSON file creation
|
|
237
|
+
const tempJsonPath = path.resolve(process.cwd(), "temp-route53-records.json");
|
|
238
|
+
const jsonContent = JSON.stringify([
|
|
239
|
+
{ name: "www", type: "CNAME", value: "lb.com", ttl: 120 },
|
|
240
|
+
{ name: "mail", type: "A", value: "1.1.1.1" }
|
|
241
|
+
]);
|
|
242
|
+
fs.writeFileSync(tempJsonPath, jsonContent, "utf-8");
|
|
243
|
+
try {
|
|
244
|
+
const builder = new Route53Builder("example.com")
|
|
245
|
+
.record("temp-route53-records.json")
|
|
246
|
+
.record("api", "A", "2.2.2.2"); // Hybrid programmatic record!
|
|
247
|
+
await builder.deploy();
|
|
248
|
+
const changeCall = r53Calls.find(c => c.commandName === 'ChangeResourceRecordSetsCommand');
|
|
249
|
+
assert.ok(changeCall);
|
|
250
|
+
const changes = changeCall.input.ChangeBatch.Changes;
|
|
251
|
+
assert.strictEqual(changes.length, 3);
|
|
252
|
+
assert.deepStrictEqual(changes[0].ResourceRecordSet, {
|
|
253
|
+
Name: "www",
|
|
254
|
+
Type: "CNAME",
|
|
255
|
+
TTL: 120,
|
|
256
|
+
ResourceRecords: [{ Value: "lb.com" }]
|
|
257
|
+
});
|
|
258
|
+
assert.deepStrictEqual(changes[1].ResourceRecordSet, {
|
|
259
|
+
Name: "mail",
|
|
260
|
+
Type: "A",
|
|
261
|
+
TTL: 300, // default
|
|
262
|
+
ResourceRecords: [{ Value: "1.1.1.1" }]
|
|
263
|
+
});
|
|
264
|
+
assert.deepStrictEqual(changes[2].ResourceRecordSet, {
|
|
265
|
+
Name: "api",
|
|
266
|
+
Type: "A",
|
|
267
|
+
TTL: 300,
|
|
268
|
+
ResourceRecords: [{ Value: "2.2.2.2" }]
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
if (fs.existsSync(tempJsonPath))
|
|
273
|
+
fs.unlinkSync(tempJsonPath);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
229
276
|
});
|
|
@@ -6,6 +6,6 @@ export declare class DoApiClient {
|
|
|
6
6
|
get<T>(path: string): Promise<T>;
|
|
7
7
|
post<T>(path: string, body: unknown): Promise<T>;
|
|
8
8
|
put<T>(path: string, body: unknown): Promise<T>;
|
|
9
|
-
delete(path: string): Promise<void>;
|
|
9
|
+
delete(path: string, body?: unknown): Promise<void>;
|
|
10
10
|
}
|
|
11
11
|
export declare function getDoApi(): DoApiClient;
|
package/dist/providers/do/api.js
CHANGED
|
@@ -38,10 +38,11 @@ export class DoApiClient {
|
|
|
38
38
|
throw new Error(`DO API PUT ${path}: ${res.status} ${await res.text()}`);
|
|
39
39
|
return res.json();
|
|
40
40
|
}
|
|
41
|
-
async delete(path) {
|
|
41
|
+
async delete(path, body) {
|
|
42
42
|
const res = await fetch(`${DoApiClient.BASE}${path}`, {
|
|
43
43
|
method: 'DELETE',
|
|
44
44
|
headers: this.authHeaders,
|
|
45
|
+
...(body !== undefined && { body: JSON.stringify(body) }),
|
|
45
46
|
});
|
|
46
47
|
if (!res.ok && res.status !== 404) {
|
|
47
48
|
throw new Error(`DO API DELETE ${path}: ${res.status} ${await res.text()}`);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
export declare class AppPlatformBuilder extends BaseBuilder {
|
|
4
|
+
readonly out: {
|
|
5
|
+
id: Output<string>;
|
|
6
|
+
liveUrl: Output<string>;
|
|
7
|
+
};
|
|
8
|
+
private _spec;
|
|
9
|
+
constructor(appName: string);
|
|
10
|
+
spec(jsonSpec: any): this;
|
|
11
|
+
private discoverApp;
|
|
12
|
+
deploy(): Promise<{
|
|
13
|
+
name: string;
|
|
14
|
+
id: any;
|
|
15
|
+
liveUrl: any;
|
|
16
|
+
} | {
|
|
17
|
+
name: string;
|
|
18
|
+
id: string;
|
|
19
|
+
liveUrl?: undefined;
|
|
20
|
+
}>;
|
|
21
|
+
destroy(): Promise<{
|
|
22
|
+
destroyed: boolean;
|
|
23
|
+
} | {
|
|
24
|
+
destroyed: string;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { getDoApi } from "./api.js";
|
|
3
|
+
import { Output } from "../../core/output.js";
|
|
4
|
+
export class AppPlatformBuilder extends BaseBuilder {
|
|
5
|
+
out = {
|
|
6
|
+
id: new Output(),
|
|
7
|
+
liveUrl: new Output(),
|
|
8
|
+
};
|
|
9
|
+
_spec = {};
|
|
10
|
+
constructor(appName) {
|
|
11
|
+
super(appName);
|
|
12
|
+
this._spec.name = appName;
|
|
13
|
+
this.discoveryPromise = this.discoverApp(appName);
|
|
14
|
+
}
|
|
15
|
+
spec(jsonSpec) {
|
|
16
|
+
this._spec = { ...this._spec, ...jsonSpec, name: this.name };
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
async discoverApp(name) {
|
|
20
|
+
try {
|
|
21
|
+
const api = getDoApi();
|
|
22
|
+
const res = await api.get("/apps");
|
|
23
|
+
const match = (res.apps ?? []).find((a) => a.spec?.name === name);
|
|
24
|
+
if (match) {
|
|
25
|
+
this.out.id.resolve(match.id);
|
|
26
|
+
this.out.liveUrl.resolve(match.live_url);
|
|
27
|
+
}
|
|
28
|
+
return match ?? null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async deploy() {
|
|
35
|
+
const dryRun = this.isDryRunActive();
|
|
36
|
+
const existing = await this.discoveryPromise;
|
|
37
|
+
const api = getDoApi();
|
|
38
|
+
console.log(`\nš Finalizing DigitalOcean App Platform "${this.name}"...`);
|
|
39
|
+
if (existing) {
|
|
40
|
+
this.out.id.resolve(existing.id);
|
|
41
|
+
this.out.liveUrl.resolve(existing.live_url);
|
|
42
|
+
const hasSpecChange = JSON.stringify(existing.spec) !== JSON.stringify(this._spec);
|
|
43
|
+
if (hasSpecChange) {
|
|
44
|
+
if (dryRun) {
|
|
45
|
+
console.log(` š [PLAN] Update App Platform "${this.name}" with new specification`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
console.log(` š Updating App Platform "${this.name}" (id=${existing.id})...`);
|
|
49
|
+
const updateRes = await api.put(`/apps/${existing.id}`, {
|
|
50
|
+
spec: this._spec,
|
|
51
|
+
});
|
|
52
|
+
console.log(` ā
App Platform "${this.name}" updated successfully.`);
|
|
53
|
+
this.out.id.resolve(updateRes.app.id);
|
|
54
|
+
this.out.liveUrl.resolve(updateRes.app.live_url);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
console.log(` ā
App Platform "${this.name}" already exists and configuration is up to date.`);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
name: this.name,
|
|
62
|
+
id: existing.id,
|
|
63
|
+
liveUrl: existing.live_url,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (dryRun) {
|
|
67
|
+
console.log(` š [PLAN] Create DigitalOcean App Platform "${this.name}"`);
|
|
68
|
+
console.log(` āā Region: ${this._spec.region ?? "default"}`);
|
|
69
|
+
if (this._spec.services) {
|
|
70
|
+
console.log(` āā Services: ${this._spec.services.map((s) => s.name).join(", ")}`);
|
|
71
|
+
}
|
|
72
|
+
if (this._spec.static_sites) {
|
|
73
|
+
console.log(` āā Static Sites: ${this._spec.static_sites.map((s) => s.name).join(", ")}`);
|
|
74
|
+
}
|
|
75
|
+
this.out.id.resolve("PENDING");
|
|
76
|
+
this.out.liveUrl.resolve(`https://${this.name}.ondigitalocean.app`);
|
|
77
|
+
return { name: this.name, id: "PENDING" };
|
|
78
|
+
}
|
|
79
|
+
console.log(`š Creating DigitalOcean App Platform "${this.name}"...`);
|
|
80
|
+
const createRes = await api.post("/apps", {
|
|
81
|
+
spec: this._spec,
|
|
82
|
+
});
|
|
83
|
+
const app = createRes.app;
|
|
84
|
+
console.log(`š App Platform created with ID: ${app.id}`);
|
|
85
|
+
let finalApp = app;
|
|
86
|
+
// Wait for the app deployment to complete and be active
|
|
87
|
+
await this.waitFor(`App Platform "${this.name}" to finish deploying`, async () => {
|
|
88
|
+
const check = await api.get(`/apps/${app.id}`);
|
|
89
|
+
if (check.app) {
|
|
90
|
+
if (check.app.live_url) {
|
|
91
|
+
finalApp = check.app;
|
|
92
|
+
this.out.id.resolve(check.app.id);
|
|
93
|
+
this.out.liveUrl.resolve(check.app.live_url);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}, { intervalMs: 15_000, timeoutMs: 900_000 });
|
|
99
|
+
console.log(`š App Platform deployment complete ā ${finalApp.live_url}`);
|
|
100
|
+
return {
|
|
101
|
+
name: this.name,
|
|
102
|
+
id: finalApp.id,
|
|
103
|
+
liveUrl: finalApp.live_url,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async destroy() {
|
|
107
|
+
const dryRun = this.isDryRunActive();
|
|
108
|
+
const existing = await this.discoveryPromise;
|
|
109
|
+
const api = getDoApi();
|
|
110
|
+
console.log(`\nšļø Destroying DigitalOcean App Platform "${this.name}"...`);
|
|
111
|
+
if (!existing) {
|
|
112
|
+
console.log(` ā App Platform "${this.name}" not found`);
|
|
113
|
+
return { destroyed: false };
|
|
114
|
+
}
|
|
115
|
+
if (dryRun) {
|
|
116
|
+
console.log(` š [PLAN] Delete App Platform "${this.name}" (id=${existing.id})`);
|
|
117
|
+
return { destroyed: this.name };
|
|
118
|
+
}
|
|
119
|
+
console.log(` š Deleting App Platform "${this.name}" (id=${existing.id})...`);
|
|
120
|
+
await api.delete(`/apps/${existing.id}`);
|
|
121
|
+
console.log(` šļø Removed App Platform "${this.name}"`);
|
|
122
|
+
return { destroyed: this.name };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|