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,180 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
5
|
+
import { SpacesBuilder } from "./spaces.js";
|
|
6
|
+
import { Config } from "../../core/config.js";
|
|
7
|
+
describe("SpacesBuilder Unit Tests", () => {
|
|
8
|
+
let originalS3Send;
|
|
9
|
+
let s3Calls = [];
|
|
10
|
+
let mockS3Responses = {};
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
Config.set({
|
|
13
|
+
dryRun: false,
|
|
14
|
+
providers: {
|
|
15
|
+
do: {
|
|
16
|
+
token: "fake-do-token",
|
|
17
|
+
defaultRegion: "nyc3",
|
|
18
|
+
spacesAccessKey: "fake-spaces-key",
|
|
19
|
+
spacesSecretKey: "fake-spaces-secret",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
s3Calls = [];
|
|
24
|
+
mockS3Responses = {};
|
|
25
|
+
originalS3Send = S3Client.prototype.send;
|
|
26
|
+
S3Client.prototype.send = async function (command) {
|
|
27
|
+
const commandName = command.constructor.name;
|
|
28
|
+
const input = command.input;
|
|
29
|
+
s3Calls.push({ commandName, input });
|
|
30
|
+
if (mockS3Responses[commandName] !== undefined) {
|
|
31
|
+
const handler = mockS3Responses[commandName];
|
|
32
|
+
if (typeof handler === "function")
|
|
33
|
+
return handler(input);
|
|
34
|
+
if (handler instanceof Error)
|
|
35
|
+
throw handler;
|
|
36
|
+
return handler;
|
|
37
|
+
}
|
|
38
|
+
return {};
|
|
39
|
+
};
|
|
40
|
+
mock.method(fs, "readFileSync", () => {
|
|
41
|
+
return Buffer.from("fake-file-content");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
S3Client.prototype.send = originalS3Send;
|
|
46
|
+
mock.restoreAll();
|
|
47
|
+
});
|
|
48
|
+
test("gracefully handles discovery when Space does not exist", async () => {
|
|
49
|
+
mockS3Responses["HeadBucketCommand"] = () => {
|
|
50
|
+
const err = new Error("Not Found");
|
|
51
|
+
err.name = "NotFound";
|
|
52
|
+
err.$metadata = { httpStatusCode: 404 };
|
|
53
|
+
throw err;
|
|
54
|
+
};
|
|
55
|
+
const builder = new SpacesBuilder("my-space");
|
|
56
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
57
|
+
assert.strictEqual(discoveryResult, false);
|
|
58
|
+
assert.strictEqual(s3Calls.length, 1);
|
|
59
|
+
assert.strictEqual(s3Calls[0].commandName, "HeadBucketCommand");
|
|
60
|
+
assert.strictEqual(s3Calls[0].input.Bucket, "my-space");
|
|
61
|
+
});
|
|
62
|
+
test("discovers Space successfully when it exists", async () => {
|
|
63
|
+
mockS3Responses["HeadBucketCommand"] = () => ({});
|
|
64
|
+
const builder = new SpacesBuilder("my-space");
|
|
65
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
66
|
+
assert.strictEqual(discoveryResult, true);
|
|
67
|
+
assert.strictEqual(s3Calls.length, 1);
|
|
68
|
+
});
|
|
69
|
+
test("performs clean dry-run planning without making write requests", async () => {
|
|
70
|
+
Config.set({
|
|
71
|
+
dryRun: true,
|
|
72
|
+
providers: {
|
|
73
|
+
do: {
|
|
74
|
+
token: "fake-do-token",
|
|
75
|
+
spacesAccessKey: "fake-key",
|
|
76
|
+
spacesSecretKey: "fake-secret",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
mockS3Responses["HeadBucketCommand"] = () => {
|
|
81
|
+
const err = new Error("Not Found");
|
|
82
|
+
err.name = "NotFound";
|
|
83
|
+
err.$metadata = { httpStatusCode: 404 };
|
|
84
|
+
throw err;
|
|
85
|
+
};
|
|
86
|
+
const builder = new SpacesBuilder("my-dry-space")
|
|
87
|
+
.region("ams3")
|
|
88
|
+
.acl("public-read")
|
|
89
|
+
.cors([
|
|
90
|
+
{
|
|
91
|
+
AllowedMethods: ["GET", "PUT"],
|
|
92
|
+
AllowedOrigins: ["*"],
|
|
93
|
+
},
|
|
94
|
+
])
|
|
95
|
+
.upload("dist/index.js");
|
|
96
|
+
const result = await builder.deploy();
|
|
97
|
+
assert.ok(result);
|
|
98
|
+
assert.strictEqual(result.name, "my-dry-space");
|
|
99
|
+
assert.strictEqual(result.region, "ams3");
|
|
100
|
+
// HeadBucketCommand should run for discovery, but no creation/write commands
|
|
101
|
+
assert.ok(s3Calls.some((c) => c.commandName === "HeadBucketCommand"));
|
|
102
|
+
const writeCalls = s3Calls.filter((c) => c.commandName !== "HeadBucketCommand");
|
|
103
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
104
|
+
});
|
|
105
|
+
test("deploys new Space, applying ACL and CORS rules successfully", async () => {
|
|
106
|
+
mockS3Responses["HeadBucketCommand"] = () => {
|
|
107
|
+
const err = new Error("Not Found");
|
|
108
|
+
err.name = "NotFound";
|
|
109
|
+
err.$metadata = { httpStatusCode: 404 };
|
|
110
|
+
throw err;
|
|
111
|
+
};
|
|
112
|
+
mockS3Responses["CreateBucketCommand"] = () => ({});
|
|
113
|
+
mockS3Responses["PutBucketAclCommand"] = () => ({});
|
|
114
|
+
mockS3Responses["PutBucketCorsCommand"] = () => ({});
|
|
115
|
+
const builder = new SpacesBuilder("my-new-space")
|
|
116
|
+
.region("sgp1")
|
|
117
|
+
.acl("public-read")
|
|
118
|
+
.cors([
|
|
119
|
+
{
|
|
120
|
+
AllowedHeaders: ["*"],
|
|
121
|
+
AllowedMethods: ["GET", "HEAD"],
|
|
122
|
+
AllowedOrigins: ["https://example.com"],
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
const result = await builder.deploy();
|
|
126
|
+
assert.ok(result);
|
|
127
|
+
assert.strictEqual(result.name, "my-new-space");
|
|
128
|
+
assert.strictEqual(result.region, "sgp1");
|
|
129
|
+
// Verify commands sent to S3
|
|
130
|
+
const createCall = s3Calls.find((c) => c.commandName === "CreateBucketCommand");
|
|
131
|
+
assert.ok(createCall);
|
|
132
|
+
assert.strictEqual(createCall.input.Bucket, "my-new-space");
|
|
133
|
+
const aclCall = s3Calls.find((c) => c.commandName === "PutBucketAclCommand");
|
|
134
|
+
assert.ok(aclCall);
|
|
135
|
+
assert.strictEqual(aclCall.input.Bucket, "my-new-space");
|
|
136
|
+
assert.strictEqual(aclCall.input.ACL, "public-read");
|
|
137
|
+
const corsCall = s3Calls.find((c) => c.commandName === "PutBucketCorsCommand");
|
|
138
|
+
assert.ok(corsCall);
|
|
139
|
+
assert.strictEqual(corsCall.input.Bucket, "my-new-space");
|
|
140
|
+
assert.deepStrictEqual(corsCall.input.CORSConfiguration.CORSRules, [
|
|
141
|
+
{
|
|
142
|
+
AllowedHeaders: ["*"],
|
|
143
|
+
AllowedMethods: ["GET", "HEAD"],
|
|
144
|
+
AllowedOrigins: ["https://example.com"],
|
|
145
|
+
},
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
test("deploys new Space and handles file upload with correct content type", async () => {
|
|
149
|
+
mockS3Responses["HeadBucketCommand"] = () => {
|
|
150
|
+
const err = new Error("Not Found");
|
|
151
|
+
err.name = "NotFound";
|
|
152
|
+
err.$metadata = { httpStatusCode: 404 };
|
|
153
|
+
throw err;
|
|
154
|
+
};
|
|
155
|
+
mockS3Responses["CreateBucketCommand"] = () => ({});
|
|
156
|
+
mockS3Responses["PutBucketAclCommand"] = () => ({});
|
|
157
|
+
mockS3Responses["PutObjectCommand"] = () => ({});
|
|
158
|
+
const builder = new SpacesBuilder("my-upload-space")
|
|
159
|
+
.upload("dist/app.json");
|
|
160
|
+
const result = await builder.deploy();
|
|
161
|
+
assert.ok(result);
|
|
162
|
+
const putCall = s3Calls.find((c) => c.commandName === "PutObjectCommand");
|
|
163
|
+
assert.ok(putCall);
|
|
164
|
+
assert.strictEqual(putCall.input.Bucket, "my-upload-space");
|
|
165
|
+
assert.strictEqual(putCall.input.Key, "app.json");
|
|
166
|
+
assert.strictEqual(putCall.input.ContentType, "application/json");
|
|
167
|
+
assert.strictEqual(putCall.input.ACL, "private");
|
|
168
|
+
});
|
|
169
|
+
test("destroys Space successfully", async () => {
|
|
170
|
+
mockS3Responses["HeadBucketCommand"] = () => ({});
|
|
171
|
+
mockS3Responses["DeleteBucketCommand"] = () => ({});
|
|
172
|
+
const builder = new SpacesBuilder("my-delete-space");
|
|
173
|
+
await builder.discoveryPromise;
|
|
174
|
+
const result = await builder.destroy();
|
|
175
|
+
assert.deepStrictEqual(result, { destroyed: "my-delete-space" });
|
|
176
|
+
const deleteCall = s3Calls.find((c) => c.commandName === "DeleteBucketCommand");
|
|
177
|
+
assert.ok(deleteCall);
|
|
178
|
+
assert.strictEqual(deleteCall.input.Bucket, "my-delete-space");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import { Config } from "../../core/config.js";
|
|
3
|
+
export function getSpacesS3Client(region = "nyc3") {
|
|
4
|
+
const doConfig = Config.get().providers.do;
|
|
5
|
+
const accessKey = doConfig?.spacesAccessKey ?? process.env.SPACES_ACCESS_KEY_ID;
|
|
6
|
+
const secretKey = doConfig?.spacesSecretKey ?? process.env.SPACES_SECRET_ACCESS_KEY;
|
|
7
|
+
if (!accessKey || !secretKey) {
|
|
8
|
+
throw new Error("DigitalOcean Spaces credentials not found. " +
|
|
9
|
+
"Please set SPACES_ACCESS_KEY_ID and SPACES_SECRET_ACCESS_KEY env vars, " +
|
|
10
|
+
"or configure 'spacesAccessKey' and 'spacesSecretKey' in your DO provider options.");
|
|
11
|
+
}
|
|
12
|
+
return new S3Client({
|
|
13
|
+
endpoint: `https://${region}.digitaloceanspaces.com`,
|
|
14
|
+
region: region, // S3Client requires region, even if endpoint is custom
|
|
15
|
+
credentials: {
|
|
16
|
+
accessKeyId: accessKey,
|
|
17
|
+
secretAccessKey: secretKey,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
export declare class VPCBuilder extends BaseBuilder {
|
|
4
|
+
readonly out: {
|
|
5
|
+
id: Output<string>;
|
|
6
|
+
ipRange: Output<string>;
|
|
7
|
+
};
|
|
8
|
+
private _region;
|
|
9
|
+
private _ipRange?;
|
|
10
|
+
private _description?;
|
|
11
|
+
constructor(name: string);
|
|
12
|
+
region(r: string): this;
|
|
13
|
+
ipRange(cidr: string): this;
|
|
14
|
+
description(text: string): this;
|
|
15
|
+
private discoverVpc;
|
|
16
|
+
deploy(): Promise<{
|
|
17
|
+
name: string;
|
|
18
|
+
id: any;
|
|
19
|
+
ipRange: any;
|
|
20
|
+
} | {
|
|
21
|
+
name: string;
|
|
22
|
+
id: string;
|
|
23
|
+
ipRange?: undefined;
|
|
24
|
+
}>;
|
|
25
|
+
destroy(): Promise<{
|
|
26
|
+
destroyed: boolean;
|
|
27
|
+
} | {
|
|
28
|
+
destroyed: string;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { getDoApi } from "./api.js";
|
|
3
|
+
import { Output } from "../../core/output.js";
|
|
4
|
+
export class VPCBuilder extends BaseBuilder {
|
|
5
|
+
out = {
|
|
6
|
+
id: new Output(),
|
|
7
|
+
ipRange: new Output(),
|
|
8
|
+
};
|
|
9
|
+
_region = "nyc3";
|
|
10
|
+
_ipRange;
|
|
11
|
+
_description;
|
|
12
|
+
constructor(name) {
|
|
13
|
+
super(name);
|
|
14
|
+
this.discoveryPromise = this.discoverVpc(name);
|
|
15
|
+
}
|
|
16
|
+
region(r) {
|
|
17
|
+
this._region = r;
|
|
18
|
+
this.discoveryPromise = this.discoverVpc(this.name);
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
ipRange(cidr) {
|
|
22
|
+
this._ipRange = cidr;
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
description(text) {
|
|
26
|
+
this._description = text;
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
async discoverVpc(name) {
|
|
30
|
+
try {
|
|
31
|
+
const api = getDoApi();
|
|
32
|
+
const res = await api.get("/vpcs?per_page=200");
|
|
33
|
+
const match = (res.vpcs ?? []).find((vpc) => vpc.name === name);
|
|
34
|
+
if (match) {
|
|
35
|
+
this.out.id.resolve(match.id);
|
|
36
|
+
this.out.ipRange.resolve(match.ip_range);
|
|
37
|
+
}
|
|
38
|
+
return match ?? null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async deploy() {
|
|
45
|
+
const dryRun = this.isDryRunActive();
|
|
46
|
+
const existing = await this.discoveryPromise;
|
|
47
|
+
const api = getDoApi();
|
|
48
|
+
console.log(`\n🌐 Finalizing DigitalOcean VPC "${this.name}"...`);
|
|
49
|
+
if (existing) {
|
|
50
|
+
console.log(` ✅ VPC "${this.name}" already exists (id=${existing.id}, ipRange=${existing.ip_range}).`);
|
|
51
|
+
this.out.id.resolve(existing.id);
|
|
52
|
+
this.out.ipRange.resolve(existing.ip_range);
|
|
53
|
+
const hasDescriptionChange = this._description !== undefined && existing.description !== this._description;
|
|
54
|
+
if (hasDescriptionChange) {
|
|
55
|
+
if (dryRun) {
|
|
56
|
+
console.log(` 📝 [PLAN] Update VPC description → "${this._description}"`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
await api.put(`/vpcs/${existing.id}`, {
|
|
60
|
+
name: this.name,
|
|
61
|
+
description: this._description,
|
|
62
|
+
});
|
|
63
|
+
console.log(` ✅ VPC description updated.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
name: this.name,
|
|
68
|
+
id: existing.id,
|
|
69
|
+
ipRange: existing.ip_range,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (dryRun) {
|
|
73
|
+
console.log(` 📝 [PLAN] Create DigitalOcean VPC "${this.name}" (${this._region})`);
|
|
74
|
+
if (this._ipRange) {
|
|
75
|
+
console.log(` └─ Custom IP Range: ${this._ipRange}`);
|
|
76
|
+
}
|
|
77
|
+
if (this._description) {
|
|
78
|
+
console.log(` └─ Description: ${this._description}`);
|
|
79
|
+
}
|
|
80
|
+
this.out.id.resolve("PENDING");
|
|
81
|
+
this.out.ipRange.resolve(this._ipRange || "10.10.10.0/20");
|
|
82
|
+
return { name: this.name, id: "PENDING" };
|
|
83
|
+
}
|
|
84
|
+
console.log(`🚀 Creating DigitalOcean VPC "${this.name}"...`);
|
|
85
|
+
const body = {
|
|
86
|
+
name: this.name,
|
|
87
|
+
region: this._region,
|
|
88
|
+
};
|
|
89
|
+
if (this._ipRange) {
|
|
90
|
+
body.ip_range = this._ipRange;
|
|
91
|
+
}
|
|
92
|
+
if (this._description) {
|
|
93
|
+
body.description = this._description;
|
|
94
|
+
}
|
|
95
|
+
const createRes = await api.post("/vpcs", body);
|
|
96
|
+
const vpc = createRes.vpc;
|
|
97
|
+
console.log(`🚀 VPC created with ID: ${vpc.id}`);
|
|
98
|
+
this.out.id.resolve(vpc.id);
|
|
99
|
+
this.out.ipRange.resolve(vpc.ip_range);
|
|
100
|
+
return {
|
|
101
|
+
name: this.name,
|
|
102
|
+
id: vpc.id,
|
|
103
|
+
ipRange: vpc.ip_range,
|
|
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 VPC "${this.name}"...`);
|
|
111
|
+
if (!existing) {
|
|
112
|
+
console.log(` ─ VPC "${this.name}" not found`);
|
|
113
|
+
return { destroyed: false };
|
|
114
|
+
}
|
|
115
|
+
if (existing.default) {
|
|
116
|
+
console.log(` ⏭️ [SKIP] Cannot delete the default VPC "${this.name}"`);
|
|
117
|
+
return { destroyed: false };
|
|
118
|
+
}
|
|
119
|
+
if (dryRun) {
|
|
120
|
+
console.log(` 📝 [PLAN] Delete VPC "${this.name}" (id=${existing.id})`);
|
|
121
|
+
return { destroyed: this.name };
|
|
122
|
+
}
|
|
123
|
+
console.log(` 🔄 Deleting VPC "${this.name}" (id=${existing.id})...`);
|
|
124
|
+
await api.delete(`/vpcs/${existing.id}`);
|
|
125
|
+
console.log(` 🗑️ Removed VPC "${this.name}"`);
|
|
126
|
+
return { destroyed: this.name };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { VPCBuilder } from './vpc.js';
|
|
4
|
+
import { Config } from '../../core/config.js';
|
|
5
|
+
describe('VPCBuilder 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 VPC does not exist', async () => {
|
|
52
|
+
mockResponses['GET /vpcs'] = {
|
|
53
|
+
status: 200,
|
|
54
|
+
body: { vpcs: [] }
|
|
55
|
+
};
|
|
56
|
+
const builder = new VPCBuilder('my-custom-vpc');
|
|
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('/vpcs'));
|
|
62
|
+
});
|
|
63
|
+
test('discovers VPC successfully when it exists', async () => {
|
|
64
|
+
mockResponses['GET /vpcs'] = {
|
|
65
|
+
status: 200,
|
|
66
|
+
body: {
|
|
67
|
+
vpcs: [
|
|
68
|
+
{
|
|
69
|
+
id: 'vpc-uuid-111',
|
|
70
|
+
name: 'my-custom-vpc',
|
|
71
|
+
ip_range: '10.10.0.0/16',
|
|
72
|
+
default: false
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const builder = new VPCBuilder('my-custom-vpc');
|
|
78
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
79
|
+
assert.ok(discoveryResult);
|
|
80
|
+
assert.strictEqual(discoveryResult.id, 'vpc-uuid-111');
|
|
81
|
+
const resolvedId = await builder.out.id.get();
|
|
82
|
+
const resolvedIpRange = await builder.out.ipRange.get();
|
|
83
|
+
assert.strictEqual(resolvedId, 'vpc-uuid-111');
|
|
84
|
+
assert.strictEqual(resolvedIpRange, '10.10.0.0/16');
|
|
85
|
+
});
|
|
86
|
+
test('dry-run resolves pending outputs and plans creation when VPC does not exist', async () => {
|
|
87
|
+
Config.set({
|
|
88
|
+
dryRun: true,
|
|
89
|
+
providers: {
|
|
90
|
+
do: { token: 'fake-do-token' }
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
mockResponses['GET /vpcs'] = {
|
|
94
|
+
status: 200,
|
|
95
|
+
body: { vpcs: [] }
|
|
96
|
+
};
|
|
97
|
+
const builder = new VPCBuilder('my-custom-vpc')
|
|
98
|
+
.region('sfo3')
|
|
99
|
+
.ipRange('10.20.30.0/24')
|
|
100
|
+
.description('Temporary Test VPC');
|
|
101
|
+
const result = await builder.deploy();
|
|
102
|
+
assert.deepStrictEqual(result, { name: 'my-custom-vpc', id: 'PENDING' });
|
|
103
|
+
const resolvedId = await builder.out.id.get();
|
|
104
|
+
const resolvedIpRange = await builder.out.ipRange.get();
|
|
105
|
+
assert.strictEqual(resolvedId, 'PENDING');
|
|
106
|
+
assert.strictEqual(resolvedIpRange, '10.20.30.0/24');
|
|
107
|
+
// No POST writes should be triggered during dry-run
|
|
108
|
+
const writeCalls = fetchCalls.filter(c => c.method === 'POST' || c.method === 'PUT');
|
|
109
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
110
|
+
});
|
|
111
|
+
test('deploys and creates a new VPC successfully', async () => {
|
|
112
|
+
mockResponses['GET /vpcs'] = {
|
|
113
|
+
status: 200,
|
|
114
|
+
body: { vpcs: [] }
|
|
115
|
+
};
|
|
116
|
+
mockResponses['POST /vpcs'] = {
|
|
117
|
+
status: 201,
|
|
118
|
+
body: {
|
|
119
|
+
vpc: {
|
|
120
|
+
id: 'created-vpc-uuid',
|
|
121
|
+
name: 'new-vpc',
|
|
122
|
+
region: 'nyc3',
|
|
123
|
+
ip_range: '10.50.0.0/16',
|
|
124
|
+
description: 'A brand new VPC'
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const builder = new VPCBuilder('new-vpc')
|
|
129
|
+
.region('nyc3')
|
|
130
|
+
.ipRange('10.50.0.0/16')
|
|
131
|
+
.description('A brand new VPC');
|
|
132
|
+
const result = await builder.deploy();
|
|
133
|
+
assert.deepStrictEqual(result, {
|
|
134
|
+
name: 'new-vpc',
|
|
135
|
+
id: 'created-vpc-uuid',
|
|
136
|
+
ipRange: '10.50.0.0/16'
|
|
137
|
+
});
|
|
138
|
+
const resolvedId = await builder.out.id.get();
|
|
139
|
+
const resolvedIpRange = await builder.out.ipRange.get();
|
|
140
|
+
assert.strictEqual(resolvedId, 'created-vpc-uuid');
|
|
141
|
+
assert.strictEqual(resolvedIpRange, '10.50.0.0/16');
|
|
142
|
+
const postCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/vpcs'));
|
|
143
|
+
assert.ok(postCall);
|
|
144
|
+
assert.deepStrictEqual(postCall.body, {
|
|
145
|
+
name: 'new-vpc',
|
|
146
|
+
region: 'nyc3',
|
|
147
|
+
ip_range: '10.50.0.0/16',
|
|
148
|
+
description: 'A brand new VPC'
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
test('updates VPC description if it changes', async () => {
|
|
152
|
+
mockResponses['GET /vpcs'] = {
|
|
153
|
+
status: 200,
|
|
154
|
+
body: {
|
|
155
|
+
vpcs: [
|
|
156
|
+
{
|
|
157
|
+
id: 'vpc-uuid-222',
|
|
158
|
+
name: 'existing-vpc',
|
|
159
|
+
ip_range: '10.10.0.0/16',
|
|
160
|
+
description: 'Old Description',
|
|
161
|
+
default: false
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
mockResponses['PUT /vpcs/vpc-uuid-222'] = {
|
|
167
|
+
status: 200,
|
|
168
|
+
body: {
|
|
169
|
+
vpc: {
|
|
170
|
+
id: 'vpc-uuid-222',
|
|
171
|
+
name: 'existing-vpc',
|
|
172
|
+
ip_range: '10.10.0.0/16',
|
|
173
|
+
description: 'New Description',
|
|
174
|
+
default: false
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const builder = new VPCBuilder('existing-vpc')
|
|
179
|
+
.description('New Description');
|
|
180
|
+
const result = await builder.deploy();
|
|
181
|
+
assert.strictEqual(result.id, 'vpc-uuid-222');
|
|
182
|
+
const putCall = fetchCalls.find(c => c.method === 'PUT' && c.url.includes('/vpcs/vpc-uuid-222'));
|
|
183
|
+
assert.ok(putCall);
|
|
184
|
+
assert.deepStrictEqual(putCall.body, {
|
|
185
|
+
name: 'existing-vpc',
|
|
186
|
+
description: 'New Description'
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
test('skips update if description is unchanged', async () => {
|
|
190
|
+
mockResponses['GET /vpcs'] = {
|
|
191
|
+
status: 200,
|
|
192
|
+
body: {
|
|
193
|
+
vpcs: [
|
|
194
|
+
{
|
|
195
|
+
id: 'vpc-uuid-222',
|
|
196
|
+
name: 'existing-vpc',
|
|
197
|
+
ip_range: '10.10.0.0/16',
|
|
198
|
+
description: 'Same Description',
|
|
199
|
+
default: false
|
|
200
|
+
}
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const builder = new VPCBuilder('existing-vpc')
|
|
205
|
+
.description('Same Description');
|
|
206
|
+
await builder.deploy();
|
|
207
|
+
const putCalls = fetchCalls.filter(c => c.method === 'PUT');
|
|
208
|
+
assert.strictEqual(putCalls.length, 0);
|
|
209
|
+
});
|
|
210
|
+
test('deletes custom VPC successfully during destroy', async () => {
|
|
211
|
+
mockResponses['GET /vpcs'] = {
|
|
212
|
+
status: 200,
|
|
213
|
+
body: {
|
|
214
|
+
vpcs: [
|
|
215
|
+
{
|
|
216
|
+
id: 'vpc-uuid-delete',
|
|
217
|
+
name: 'custom-vpc-to-delete',
|
|
218
|
+
ip_range: '10.10.0.0/16',
|
|
219
|
+
default: false
|
|
220
|
+
}
|
|
221
|
+
]
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
mockResponses['DELETE /vpcs/vpc-uuid-delete'] = {
|
|
225
|
+
status: 204,
|
|
226
|
+
body: {}
|
|
227
|
+
};
|
|
228
|
+
const builder = new VPCBuilder('custom-vpc-to-delete');
|
|
229
|
+
await builder.discoveryPromise;
|
|
230
|
+
const result = await builder.destroy();
|
|
231
|
+
assert.deepStrictEqual(result, { destroyed: 'custom-vpc-to-delete' });
|
|
232
|
+
const deleteCall = fetchCalls.find(c => c.method === 'DELETE');
|
|
233
|
+
assert.ok(deleteCall);
|
|
234
|
+
assert.ok(deleteCall.url.endsWith('/vpcs/vpc-uuid-delete'));
|
|
235
|
+
});
|
|
236
|
+
test('skips deletion of default VPC networks during destroy', async () => {
|
|
237
|
+
mockResponses['GET /vpcs'] = {
|
|
238
|
+
status: 200,
|
|
239
|
+
body: {
|
|
240
|
+
vpcs: [
|
|
241
|
+
{
|
|
242
|
+
id: 'default-vpc-uuid',
|
|
243
|
+
name: 'default-nyc3',
|
|
244
|
+
ip_range: '10.10.0.0/16',
|
|
245
|
+
default: true
|
|
246
|
+
}
|
|
247
|
+
]
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const builder = new VPCBuilder('default-nyc3');
|
|
251
|
+
await builder.discoveryPromise;
|
|
252
|
+
const result = await builder.destroy();
|
|
253
|
+
assert.deepStrictEqual(result, { destroyed: false });
|
|
254
|
+
// No DELETE requests should be triggered
|
|
255
|
+
const deleteCalls = fetchCalls.filter(c => c.method === 'DELETE');
|
|
256
|
+
assert.strictEqual(deleteCalls.length, 0);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -19,6 +19,7 @@ export declare class GCPCloudDNSZoneBuilder extends BaseBuilder {
|
|
|
19
19
|
private records;
|
|
20
20
|
constructor(zoneName: string);
|
|
21
21
|
private discoverZone;
|
|
22
|
+
record(filePath: string): this;
|
|
22
23
|
record(name: string, type: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS" | "PTR" | "SRV" | "CAA" | "SPF", value: string, ttl?: number): this;
|
|
23
24
|
pointer(name: string, target: BaseBuilder | Output<string> | string): this;
|
|
24
25
|
deploy(): Promise<{
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BaseBuilder } from "../../core/resource.js";
|
|
2
2
|
import { Output } from "../../core/output.js";
|
|
3
3
|
import { gcpFetch, getProjectId } from "./api.js";
|
|
4
|
+
import { loadRecordsFromFile } from "../../core/parser.js";
|
|
4
5
|
const DNS_BASE = "https://dns.googleapis.com";
|
|
5
6
|
function cleanZoneId(domain) {
|
|
6
7
|
return domain
|
|
@@ -57,8 +58,20 @@ export class GCPCloudDNSZoneBuilder extends BaseBuilder {
|
|
|
57
58
|
throw e;
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
|
-
record(
|
|
61
|
-
|
|
61
|
+
record(nameOrPath, type, value, ttl = 300) {
|
|
62
|
+
if (arguments.length === 1 && typeof nameOrPath === "string" && (nameOrPath.endsWith(".yaml") || nameOrPath.endsWith(".yml") || nameOrPath.endsWith(".json"))) {
|
|
63
|
+
const loaded = loadRecordsFromFile(nameOrPath);
|
|
64
|
+
for (const r of loaded) {
|
|
65
|
+
this.records.push({
|
|
66
|
+
name: r.name,
|
|
67
|
+
type: r.type,
|
|
68
|
+
value: r.value,
|
|
69
|
+
ttl: r.ttl ?? 300,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
this.records.push({ name: nameOrPath, type: type, value: value, ttl });
|
|
62
75
|
return this;
|
|
63
76
|
}
|
|
64
77
|
pointer(name, target) {
|