puls-dev 0.3.5 → 0.3.7
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/README.md +165 -54
- package/dist/bin/install-shell.js +5 -6
- package/dist/bin/puls.js +10 -3
- package/dist/core/config.d.ts +4 -0
- package/dist/core/decorators.d.ts +4 -0
- package/dist/core/decorators.js +2 -0
- package/dist/core/parallel.test.js +4 -3
- package/dist/core/resource.d.ts +2 -1
- package/dist/core/resource.js +4 -2
- package/dist/core/stack.d.ts +4 -0
- package/dist/core/stack.js +8 -8
- package/dist/providers/aws/acm.test.d.ts +1 -0
- package/dist/providers/aws/acm.test.js +167 -0
- package/dist/providers/aws/cloudfront.test.d.ts +1 -0
- package/dist/providers/aws/cloudfront.test.js +170 -0
- package/dist/providers/aws/fargate.test.d.ts +1 -0
- package/dist/providers/aws/fargate.test.js +244 -0
- package/dist/providers/aws/rds.test.d.ts +1 -0
- package/dist/providers/aws/rds.test.js +219 -0
- package/dist/providers/aws/sqs.test.d.ts +1 -0
- package/dist/providers/aws/sqs.test.js +181 -0
- package/dist/providers/cloudflare/api.d.ts +15 -0
- package/dist/providers/cloudflare/api.js +199 -0
- package/dist/providers/cloudflare/index.d.ts +14 -0
- package/dist/providers/cloudflare/index.js +19 -0
- package/dist/providers/cloudflare/kv.d.ts +20 -0
- package/dist/providers/cloudflare/kv.js +69 -0
- package/dist/providers/cloudflare/kv.test.d.ts +1 -0
- package/dist/providers/cloudflare/kv.test.js +134 -0
- package/dist/providers/cloudflare/r2.d.ts +14 -0
- package/dist/providers/cloudflare/r2.js +57 -0
- package/dist/providers/cloudflare/r2.test.d.ts +1 -0
- package/dist/providers/cloudflare/r2.test.js +132 -0
- package/dist/providers/cloudflare/worker.d.ts +28 -0
- package/dist/providers/cloudflare/worker.js +172 -0
- package/dist/providers/cloudflare/worker.test.d.ts +1 -0
- package/dist/providers/cloudflare/worker.test.js +220 -0
- package/dist/providers/cloudflare/zone.d.ts +42 -0
- package/dist/providers/cloudflare/zone.js +280 -0
- package/dist/providers/cloudflare/zone.test.d.ts +1 -0
- package/dist/providers/cloudflare/zone.test.js +284 -0
- package/dist/providers/firebase/auth.test.d.ts +1 -0
- package/dist/providers/firebase/auth.test.js +145 -0
- package/dist/providers/firebase/hosting.test.js +7 -6
- package/dist/providers/firebase/storage.test.d.ts +1 -0
- package/dist/providers/firebase/storage.test.js +148 -0
- package/package.json +6 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { WorkerBuilder } from "./worker.js";
|
|
6
|
+
import { KVBuilder } from "./kv.js";
|
|
7
|
+
import { R2Builder } from "./r2.js";
|
|
8
|
+
import { Config } from "../../core/config.js";
|
|
9
|
+
describe("WorkerBuilder Unit Tests", () => {
|
|
10
|
+
let originalFetch;
|
|
11
|
+
let fetchCalls = [];
|
|
12
|
+
let mockResponses = {};
|
|
13
|
+
const tempScriptPath = path.resolve(process.cwd(), "temp-worker.js");
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
Config.set({
|
|
16
|
+
dryRun: false,
|
|
17
|
+
providers: {
|
|
18
|
+
cloudflare: { token: "fake-cf-token", accountId: "fake-cf-account" }
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
originalFetch = globalThis.fetch;
|
|
22
|
+
fetchCalls = [];
|
|
23
|
+
mockResponses = {};
|
|
24
|
+
fs.writeFileSync(tempScriptPath, "export default { fetch() { return new Response('hello'); } };", "utf8");
|
|
25
|
+
globalThis.fetch = async (input, init) => {
|
|
26
|
+
const url = String(input);
|
|
27
|
+
const method = init?.method ?? "GET";
|
|
28
|
+
let body;
|
|
29
|
+
if (init?.body) {
|
|
30
|
+
if (init.body instanceof FormData) {
|
|
31
|
+
body = init.body;
|
|
32
|
+
}
|
|
33
|
+
else if (typeof init.body === "string") {
|
|
34
|
+
try {
|
|
35
|
+
body = JSON.parse(init.body);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
body = init.body;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
body = init.body;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const headers = init?.headers;
|
|
46
|
+
fetchCalls.push({ url, method, body, headers });
|
|
47
|
+
const matchKey = Object.keys(mockResponses).find(key => {
|
|
48
|
+
const [mMethod, mPath] = key.split(" ");
|
|
49
|
+
return method === mMethod && url.endsWith(mPath);
|
|
50
|
+
});
|
|
51
|
+
if (matchKey) {
|
|
52
|
+
const resp = mockResponses[matchKey];
|
|
53
|
+
return {
|
|
54
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
55
|
+
status: resp.status,
|
|
56
|
+
json: async () => resp.body,
|
|
57
|
+
text: async () => JSON.stringify(resp.body),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
status: 404,
|
|
63
|
+
json: async () => ({ errors: [{ message: "Not found" }] }),
|
|
64
|
+
text: async () => "Not found",
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
globalThis.fetch = originalFetch;
|
|
70
|
+
if (fs.existsSync(tempScriptPath)) {
|
|
71
|
+
fs.unlinkSync(tempScriptPath);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
test("throws if script path is not configured", async () => {
|
|
75
|
+
const builder = new WorkerBuilder("my-worker");
|
|
76
|
+
await assert.rejects(async () => {
|
|
77
|
+
await builder.deploy();
|
|
78
|
+
}, /Worker script path is not configured/);
|
|
79
|
+
});
|
|
80
|
+
test("deploys worker script with bindings and reconciles routes", async () => {
|
|
81
|
+
// KV namespace mock response for builder dependency discovery
|
|
82
|
+
mockResponses["GET /accounts/fake-cf-account/workers/namespaces?per_page=100"] = {
|
|
83
|
+
status: 200,
|
|
84
|
+
body: {
|
|
85
|
+
result: [{ id: "kv-id-999", title: "my-kv" }]
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
// R2 mock response for discovery
|
|
89
|
+
mockResponses["GET /accounts/fake-cf-account/r2/buckets"] = {
|
|
90
|
+
status: 200,
|
|
91
|
+
body: {
|
|
92
|
+
result: { buckets: [{ name: "my-bucket" }] }
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
// Worker upload mock response
|
|
96
|
+
mockResponses["PUT /accounts/fake-cf-account/workers/scripts/my-worker"] = {
|
|
97
|
+
status: 200,
|
|
98
|
+
body: { result: { id: "my-worker" } }
|
|
99
|
+
};
|
|
100
|
+
// Route resolution: zone matching
|
|
101
|
+
mockResponses["GET /zones?name=api.example.com"] = {
|
|
102
|
+
status: 200,
|
|
103
|
+
body: { result: [] }
|
|
104
|
+
};
|
|
105
|
+
mockResponses["GET /zones?name=example.com"] = {
|
|
106
|
+
status: 200,
|
|
107
|
+
body: { result: [{ id: "zone-123", name: "example.com" }] }
|
|
108
|
+
};
|
|
109
|
+
// Get routes mock response (1 existing route to update, 1 new route to create)
|
|
110
|
+
mockResponses["GET /zones/zone-123/workers/routes"] = {
|
|
111
|
+
status: 200,
|
|
112
|
+
body: {
|
|
113
|
+
result: [
|
|
114
|
+
{ id: "route-existing", pattern: "api.example.com/*", script: "old-worker" }
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
mockResponses["PUT /zones/zone-123/workers/routes/route-existing"] = {
|
|
119
|
+
status: 200,
|
|
120
|
+
body: { result: { id: "route-existing" } }
|
|
121
|
+
};
|
|
122
|
+
mockResponses["POST /zones/zone-123/workers/routes"] = {
|
|
123
|
+
status: 200,
|
|
124
|
+
body: { result: { id: "route-new" } }
|
|
125
|
+
};
|
|
126
|
+
const kv = new KVBuilder("my-kv");
|
|
127
|
+
const r2 = new R2Builder("my-bucket");
|
|
128
|
+
// Initialize/deploy dependencies first
|
|
129
|
+
await kv.deploy();
|
|
130
|
+
await r2.deploy();
|
|
131
|
+
const worker = new WorkerBuilder("my-worker")
|
|
132
|
+
.script(tempScriptPath)
|
|
133
|
+
.kv("MY_KV", kv)
|
|
134
|
+
.r2("MY_BUCKET", r2)
|
|
135
|
+
.env("ENV_VAR", "my-env-value")
|
|
136
|
+
.route("api.example.com/*")
|
|
137
|
+
.route("example.com/*");
|
|
138
|
+
const result = await worker.deploy();
|
|
139
|
+
assert.strictEqual(result.scriptName, "my-worker");
|
|
140
|
+
assert.deepStrictEqual(result.routes, ["api.example.com/*", "example.com/*"]);
|
|
141
|
+
// Verify multipart request body contains metadata with bindings
|
|
142
|
+
const uploadCall = fetchCalls.find(c => c.method === "PUT" && c.url.endsWith("/workers/scripts/my-worker"));
|
|
143
|
+
assert.ok(uploadCall);
|
|
144
|
+
assert.ok(uploadCall.body instanceof FormData);
|
|
145
|
+
const metadataStr = uploadCall.body.get("metadata");
|
|
146
|
+
assert.ok(typeof metadataStr === "string");
|
|
147
|
+
const metadata = JSON.parse(metadataStr);
|
|
148
|
+
assert.deepStrictEqual(metadata, {
|
|
149
|
+
main_module: "index.js",
|
|
150
|
+
bindings: [
|
|
151
|
+
{ type: "kv_namespace", name: "MY_KV", namespace_id: "kv-id-999" },
|
|
152
|
+
{ type: "r2_bucket", name: "MY_BUCKET", bucket_name: "my-bucket" },
|
|
153
|
+
{ type: "plain_text", name: "ENV_VAR", text: "my-env-value" }
|
|
154
|
+
]
|
|
155
|
+
});
|
|
156
|
+
// Verify route updates & creations
|
|
157
|
+
const putRouteCall = fetchCalls.find(c => c.method === "PUT" && c.url.includes("/workers/routes/"));
|
|
158
|
+
assert.ok(putRouteCall);
|
|
159
|
+
assert.ok(putRouteCall.url.endsWith("/zones/zone-123/workers/routes/route-existing"));
|
|
160
|
+
assert.deepStrictEqual(putRouteCall.body, {
|
|
161
|
+
pattern: "api.example.com/*",
|
|
162
|
+
script: "my-worker"
|
|
163
|
+
});
|
|
164
|
+
const postRouteCall = fetchCalls.find(c => c.method === "POST" && c.url.endsWith("/zones/zone-123/workers/routes"));
|
|
165
|
+
assert.ok(postRouteCall);
|
|
166
|
+
assert.deepStrictEqual(postRouteCall.body, {
|
|
167
|
+
pattern: "example.com/*",
|
|
168
|
+
script: "my-worker"
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
test("does not call fetch during dryRun deploy", async () => {
|
|
172
|
+
Config.set({
|
|
173
|
+
dryRun: true,
|
|
174
|
+
providers: {
|
|
175
|
+
cloudflare: { token: "fake-cf-token", accountId: "fake-cf-account" }
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
const worker = new WorkerBuilder("my-worker")
|
|
179
|
+
.script(tempScriptPath)
|
|
180
|
+
.route("example.com/*");
|
|
181
|
+
const result = await worker.deploy();
|
|
182
|
+
assert.strictEqual(result.scriptName, "my-worker");
|
|
183
|
+
const puts = fetchCalls.filter(c => c.method === "PUT");
|
|
184
|
+
assert.strictEqual(puts.length, 0);
|
|
185
|
+
});
|
|
186
|
+
test("destroys routes and script successfully", async () => {
|
|
187
|
+
// Mock zone resolution for route cleanup
|
|
188
|
+
mockResponses["GET /zones?name=example.com"] = {
|
|
189
|
+
status: 200,
|
|
190
|
+
body: { result: [{ id: "zone-123", name: "example.com" }] }
|
|
191
|
+
};
|
|
192
|
+
mockResponses["GET /zones/zone-123/workers/routes"] = {
|
|
193
|
+
status: 200,
|
|
194
|
+
body: {
|
|
195
|
+
result: [
|
|
196
|
+
{ id: "route-existing", pattern: "example.com/*", script: "my-worker" }
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
mockResponses["DELETE /zones/zone-123/workers/routes/route-existing"] = {
|
|
201
|
+
status: 200,
|
|
202
|
+
body: {}
|
|
203
|
+
};
|
|
204
|
+
mockResponses["DELETE /accounts/fake-cf-account/workers/scripts/my-worker"] = {
|
|
205
|
+
status: 200,
|
|
206
|
+
body: {}
|
|
207
|
+
};
|
|
208
|
+
const worker = new WorkerBuilder("my-worker")
|
|
209
|
+
.script(tempScriptPath)
|
|
210
|
+
.route("example.com/*");
|
|
211
|
+
const result = await worker.destroy();
|
|
212
|
+
assert.deepStrictEqual(result, { destroyed: "my-worker" });
|
|
213
|
+
const deleteRouteCall = fetchCalls.find(c => c.method === "DELETE" && c.url.includes("/workers/routes/"));
|
|
214
|
+
assert.ok(deleteRouteCall);
|
|
215
|
+
assert.ok(deleteRouteCall.url.endsWith("/zones/zone-123/workers/routes/route-existing"));
|
|
216
|
+
const deleteScriptCall = fetchCalls.find(c => c.method === "DELETE" && c.url.includes("/workers/scripts/"));
|
|
217
|
+
assert.ok(deleteScriptCall);
|
|
218
|
+
assert.ok(deleteScriptCall.url.endsWith("/accounts/fake-cf-account/workers/scripts/my-worker"));
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
export interface CFDNSRecord {
|
|
4
|
+
type: "A" | "CNAME" | "TXT" | "MX" | "AAAA" | "SRV" | "CAA";
|
|
5
|
+
name: string;
|
|
6
|
+
value: string | BaseBuilder | Output<string>;
|
|
7
|
+
ttl?: number;
|
|
8
|
+
priority?: number;
|
|
9
|
+
port?: number;
|
|
10
|
+
weight?: number;
|
|
11
|
+
flags?: number;
|
|
12
|
+
tag?: string;
|
|
13
|
+
proxied?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare class ZoneBuilder extends BaseBuilder {
|
|
16
|
+
domainName: string;
|
|
17
|
+
readonly out: {
|
|
18
|
+
id: Output<string>;
|
|
19
|
+
};
|
|
20
|
+
resolvedId: string | null;
|
|
21
|
+
private records;
|
|
22
|
+
constructor(domainName: string);
|
|
23
|
+
private discoverZone;
|
|
24
|
+
record(filePath: string): this;
|
|
25
|
+
record(name: string, type: CFDNSRecord["type"], value: string | BaseBuilder | Output<string>, ttl?: number, priority?: number, port?: number, weight?: number, flags?: number, tag?: string, proxied?: boolean): this;
|
|
26
|
+
pointer(name: string, target: BaseBuilder | Output<string> | string, proxied?: boolean): this;
|
|
27
|
+
cname(name: string, target: string, proxied?: boolean): this;
|
|
28
|
+
aaaa(name: string, target: string | Output<string>, proxied?: boolean): this;
|
|
29
|
+
txt(name: string, target: string): this;
|
|
30
|
+
mx(name: string, target: string, priority?: number): this;
|
|
31
|
+
srv(name: string, target: string, port: number, priority?: number, weight?: number): this;
|
|
32
|
+
caa(name: string, tag: string, target: string, flags?: number): this;
|
|
33
|
+
deploy(): Promise<{
|
|
34
|
+
zone: string;
|
|
35
|
+
zoneId: string | null;
|
|
36
|
+
}>;
|
|
37
|
+
destroy(): Promise<{
|
|
38
|
+
destroyed: boolean;
|
|
39
|
+
} | {
|
|
40
|
+
destroyed: string;
|
|
41
|
+
}>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
import { getCloudflareApi, getCloudflareAccountId } from "./api.js";
|
|
4
|
+
import { loadRecordsFromFile } from "../../core/parser.js";
|
|
5
|
+
function getFullRecordName(name, zone) {
|
|
6
|
+
const cleanName = name.trim();
|
|
7
|
+
if (cleanName === "" || cleanName === "@")
|
|
8
|
+
return zone;
|
|
9
|
+
if (cleanName.endsWith(zone))
|
|
10
|
+
return cleanName;
|
|
11
|
+
return `${cleanName}.${zone}`;
|
|
12
|
+
}
|
|
13
|
+
export class ZoneBuilder extends BaseBuilder {
|
|
14
|
+
domainName;
|
|
15
|
+
out = {
|
|
16
|
+
id: new Output(),
|
|
17
|
+
};
|
|
18
|
+
resolvedId = null;
|
|
19
|
+
records = [];
|
|
20
|
+
constructor(domainName) {
|
|
21
|
+
super(domainName);
|
|
22
|
+
this.domainName = domainName;
|
|
23
|
+
this.discoveryPromise = this.discoverZone(domainName);
|
|
24
|
+
}
|
|
25
|
+
async discoverZone(name) {
|
|
26
|
+
try {
|
|
27
|
+
const api = getCloudflareApi();
|
|
28
|
+
const res = await api.get(`/zones?name=${name}`);
|
|
29
|
+
return (res.result ?? []).find((z) => z.name === name) ?? null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
record(nameOrPath, type, value, ttl, priority, port, weight, flags, tag, proxied) {
|
|
36
|
+
if (arguments.length === 1 &&
|
|
37
|
+
typeof nameOrPath === "string" &&
|
|
38
|
+
(nameOrPath.endsWith(".yaml") || nameOrPath.endsWith(".yml") || nameOrPath.endsWith(".json"))) {
|
|
39
|
+
const loaded = loadRecordsFromFile(nameOrPath);
|
|
40
|
+
for (const r of loaded) {
|
|
41
|
+
this.records.push({
|
|
42
|
+
name: r.name,
|
|
43
|
+
type: r.type,
|
|
44
|
+
value: r.value,
|
|
45
|
+
ttl: r.ttl,
|
|
46
|
+
priority: r.priority,
|
|
47
|
+
port: r.port,
|
|
48
|
+
weight: r.weight,
|
|
49
|
+
flags: r.flags,
|
|
50
|
+
tag: r.tag,
|
|
51
|
+
proxied: r.proxied,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
this.records.push({
|
|
57
|
+
name: nameOrPath,
|
|
58
|
+
type: type,
|
|
59
|
+
value: value,
|
|
60
|
+
ttl,
|
|
61
|
+
priority,
|
|
62
|
+
port,
|
|
63
|
+
weight,
|
|
64
|
+
flags,
|
|
65
|
+
tag,
|
|
66
|
+
proxied,
|
|
67
|
+
});
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
pointer(name, target, proxied) {
|
|
71
|
+
this.records.push({ type: "A", name, value: target, proxied });
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
cname(name, target, proxied) {
|
|
75
|
+
this.records.push({ type: "CNAME", name, value: target, proxied });
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
aaaa(name, target, proxied) {
|
|
79
|
+
this.records.push({ type: "AAAA", name, value: target, proxied });
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
txt(name, target) {
|
|
83
|
+
this.records.push({ type: "TXT", name, value: target });
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
mx(name, target, priority = 10) {
|
|
87
|
+
this.records.push({ type: "MX", name, value: target, priority });
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
srv(name, target, port, priority = 10, weight = 10) {
|
|
91
|
+
this.records.push({ type: "SRV", name, value: target, port, priority, weight });
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
caa(name, tag, target, flags = 0) {
|
|
95
|
+
this.records.push({ type: "CAA", name, value: target, tag, flags });
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
async deploy() {
|
|
99
|
+
const dryRun = this.isDryRunActive();
|
|
100
|
+
const existing = await this.discoveryPromise;
|
|
101
|
+
const api = getCloudflareApi();
|
|
102
|
+
console.log(`\n🌐 Finalizing DNS Zone for "${this.domainName}"...`);
|
|
103
|
+
if (existing) {
|
|
104
|
+
this.resolvedId = existing.id;
|
|
105
|
+
this.out.id.resolve(existing.id);
|
|
106
|
+
console.log(` ✅ Zone "${this.domainName}" exists (id=${existing.id})`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
if (dryRun) {
|
|
110
|
+
console.log(` 📝 [PLAN] Create Cloudflare Zone "${this.domainName}"`);
|
|
111
|
+
this.resolvedId = "PENDING";
|
|
112
|
+
this.out.id.resolve("PENDING");
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const accountId = getCloudflareAccountId();
|
|
116
|
+
const res = await api.post("/zones", {
|
|
117
|
+
name: this.domainName,
|
|
118
|
+
account: { id: accountId },
|
|
119
|
+
type: "full",
|
|
120
|
+
});
|
|
121
|
+
this.resolvedId = res.result.id;
|
|
122
|
+
this.out.id.resolve(this.resolvedId);
|
|
123
|
+
console.log(`🚀 Created Cloudflare Zone "${this.domainName}" (id=${this.resolvedId})`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let existingRecords = [];
|
|
127
|
+
if (existing && !dryRun) {
|
|
128
|
+
try {
|
|
129
|
+
const res = await api.get(`/zones/${this.resolvedId}/dns_records?per_page=100`);
|
|
130
|
+
existingRecords = res.result ?? [];
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
existingRecords = [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const consumedRecordIds = new Set();
|
|
137
|
+
for (const record of this.records) {
|
|
138
|
+
let data;
|
|
139
|
+
if (record.value instanceof Output) {
|
|
140
|
+
data = await record.value.get();
|
|
141
|
+
}
|
|
142
|
+
else if (record.value && typeof record.value === "object" && "out" in record.value) {
|
|
143
|
+
const out = record.value.out;
|
|
144
|
+
if (out && out.ip instanceof Output) {
|
|
145
|
+
data = await out.ip.get();
|
|
146
|
+
}
|
|
147
|
+
else if (out && out.publicIp instanceof Output) {
|
|
148
|
+
data = await out.publicIp.get();
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
data = String(record.value);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else if (record.value instanceof BaseBuilder) {
|
|
155
|
+
if (typeof record.value.getPublicIp === "function") {
|
|
156
|
+
data = await record.value.getPublicIp();
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
data = String(record.value);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
data = String(record.value);
|
|
164
|
+
}
|
|
165
|
+
const targetFullName = getFullRecordName(record.name, this.domainName);
|
|
166
|
+
const targetTtl = record.ttl ?? 3600;
|
|
167
|
+
const targetProxied = !!record.proxied;
|
|
168
|
+
// Match logic helper
|
|
169
|
+
const isMatch = (r) => {
|
|
170
|
+
if (r.type !== record.type || r.name !== targetFullName)
|
|
171
|
+
return false;
|
|
172
|
+
if (record.type === "MX") {
|
|
173
|
+
return r.content === data && (r.priority ?? 10) === (record.priority ?? 10);
|
|
174
|
+
}
|
|
175
|
+
if (record.type === "SRV") {
|
|
176
|
+
return (r.data?.target === data &&
|
|
177
|
+
r.data?.port === (record.port ?? 5060) &&
|
|
178
|
+
r.data?.priority === (record.priority ?? 10) &&
|
|
179
|
+
r.data?.weight === (record.weight ?? 10));
|
|
180
|
+
}
|
|
181
|
+
if (record.type === "CAA") {
|
|
182
|
+
return (r.data?.value === data &&
|
|
183
|
+
r.data?.tag === (record.tag ?? "issue") &&
|
|
184
|
+
r.data?.flags === (record.flags ?? 0));
|
|
185
|
+
}
|
|
186
|
+
return r.content === data && !!r.proxied === targetProxied;
|
|
187
|
+
};
|
|
188
|
+
const perfectMatch = existingRecords.find((r) => !consumedRecordIds.has(r.id) && isMatch(r));
|
|
189
|
+
if (perfectMatch) {
|
|
190
|
+
consumedRecordIds.add(perfectMatch.id);
|
|
191
|
+
console.log(` ✅ ${record.type} ${targetFullName} is up to date (→ ${data})`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const updateableMatch = existingRecords.find((r) => !consumedRecordIds.has(r.id) && r.type === record.type && r.name === targetFullName);
|
|
195
|
+
// Construct payload
|
|
196
|
+
const payload = {
|
|
197
|
+
type: record.type,
|
|
198
|
+
name: record.name,
|
|
199
|
+
ttl: targetTtl,
|
|
200
|
+
};
|
|
201
|
+
if (record.type === "MX") {
|
|
202
|
+
payload.content = data;
|
|
203
|
+
payload.priority = record.priority ?? 10;
|
|
204
|
+
}
|
|
205
|
+
else if (record.type === "SRV") {
|
|
206
|
+
payload.data = {
|
|
207
|
+
priority: record.priority ?? 10,
|
|
208
|
+
weight: record.weight ?? 10,
|
|
209
|
+
port: record.port ?? 5060,
|
|
210
|
+
target: data,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
else if (record.type === "CAA") {
|
|
214
|
+
payload.data = {
|
|
215
|
+
flags: record.flags ?? 0,
|
|
216
|
+
tag: record.tag ?? "issue",
|
|
217
|
+
value: data,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
payload.content = data;
|
|
222
|
+
payload.proxied = targetProxied;
|
|
223
|
+
}
|
|
224
|
+
if (updateableMatch) {
|
|
225
|
+
consumedRecordIds.add(updateableMatch.id);
|
|
226
|
+
if (dryRun) {
|
|
227
|
+
console.log(` 📝 [PLAN] Update ${record.type} ${targetFullName} → ${data}`);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
await api.put(`/zones/${this.resolvedId}/dns_records/${updateableMatch.id}`, payload);
|
|
231
|
+
console.log(` 🔄 Updated ${record.type} ${targetFullName} → ${data}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
if (dryRun) {
|
|
236
|
+
console.log(` 📝 [PLAN] Create ${record.type} ${targetFullName} → ${data}`);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
await api.post(`/zones/${this.resolvedId}/dns_records`, payload);
|
|
240
|
+
console.log(` 🚀 Created ${record.type} ${targetFullName} → ${data}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Purge stale
|
|
245
|
+
const declaredKeySet = new Set(this.records.map((r) => `${r.type}:${getFullRecordName(r.name, this.domainName)}`));
|
|
246
|
+
for (const r of existingRecords) {
|
|
247
|
+
if (consumedRecordIds.has(r.id))
|
|
248
|
+
continue;
|
|
249
|
+
if (declaredKeySet.has(`${r.type}:${r.name}`)) {
|
|
250
|
+
if (dryRun) {
|
|
251
|
+
console.log(` 📝 [PLAN] Delete stale ${r.type} ${r.name}`);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
await api.delete(`/zones/${this.resolvedId}/dns_records/${r.id}`);
|
|
255
|
+
console.log(` 🗑️ Deleted stale ${r.type} ${r.name}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
await this.deploySidecars();
|
|
260
|
+
return { zone: this.domainName, zoneId: this.resolvedId };
|
|
261
|
+
}
|
|
262
|
+
async destroy() {
|
|
263
|
+
const dryRun = this.isDryRunActive();
|
|
264
|
+
const existing = await this.discoveryPromise;
|
|
265
|
+
const api = getCloudflareApi();
|
|
266
|
+
console.log(`\n🗑️ Destroying Cloudflare DNS Zone "${this.domainName}"...`);
|
|
267
|
+
if (!existing) {
|
|
268
|
+
console.log(` ─ Zone "${this.domainName}" not found`);
|
|
269
|
+
return { destroyed: false };
|
|
270
|
+
}
|
|
271
|
+
if (dryRun) {
|
|
272
|
+
console.log(` 📝 [PLAN] Delete Cloudflare Zone "${this.domainName}" (id=${existing.id})`);
|
|
273
|
+
return { destroyed: this.domainName };
|
|
274
|
+
}
|
|
275
|
+
await api.delete(`/zones/${existing.id}`);
|
|
276
|
+
console.log(` 🗑️ Removed Cloudflare Zone "${this.domainName}" (id=${existing.id})`);
|
|
277
|
+
await this.destroySidecars();
|
|
278
|
+
return { destroyed: this.domainName };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|