puls-dev 0.2.1 β 0.2.2
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 +5 -0
- package/dist/providers/firebase/appcheck.d.ts +15 -0
- package/dist/providers/firebase/appcheck.js +109 -0
- package/dist/providers/firebase/appcheck.test.d.ts +1 -0
- package/dist/providers/firebase/appcheck.test.js +141 -0
- package/dist/providers/firebase/index.d.ts +2 -0
- package/dist/providers/firebase/index.js +2 -0
- package/dist/providers/gcp/api.d.ts +10 -0
- package/dist/providers/gcp/api.js +111 -0
- package/dist/providers/gcp/clouddns.d.ts +37 -0
- package/dist/providers/gcp/clouddns.js +284 -0
- package/dist/providers/gcp/clouddns.test.d.ts +1 -0
- package/dist/providers/gcp/clouddns.test.js +259 -0
- package/dist/providers/gcp/cloudrun.d.ts +31 -0
- package/dist/providers/gcp/cloudrun.js +240 -0
- package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
- package/dist/providers/gcp/cloudrun.test.js +281 -0
- package/dist/providers/gcp/cloudsql.d.ts +37 -0
- package/dist/providers/gcp/cloudsql.js +262 -0
- package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
- package/dist/providers/gcp/cloudsql.test.js +295 -0
- package/dist/providers/gcp/iam.d.ts +38 -0
- package/dist/providers/gcp/iam.js +309 -0
- package/dist/providers/gcp/iam.test.d.ts +1 -0
- package/dist/providers/gcp/iam.test.js +305 -0
- package/dist/providers/gcp/index.d.ts +19 -0
- package/dist/providers/gcp/index.js +19 -0
- package/dist/providers/gcp/pubsub.d.ts +31 -0
- package/dist/providers/gcp/pubsub.js +227 -0
- package/dist/providers/gcp/pubsub.test.d.ts +1 -0
- package/dist/providers/gcp/pubsub.test.js +244 -0
- package/dist/providers/gcp/secrets.d.ts +21 -0
- package/dist/providers/gcp/secrets.js +187 -0
- package/dist/providers/gcp/secrets.test.d.ts +1 -0
- package/dist/providers/gcp/secrets.test.js +264 -0
- package/package.json +5 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
import { gcpFetch, getProjectId } from "./api.js";
|
|
4
|
+
const DNS_BASE = "https://dns.googleapis.com";
|
|
5
|
+
function cleanZoneId(domain) {
|
|
6
|
+
return domain
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[^a-z0-9]/g, "-")
|
|
9
|
+
.replace(/-+/g, "-")
|
|
10
|
+
.replace(/^-|-$/g, "");
|
|
11
|
+
}
|
|
12
|
+
function formatRecordName(name, zoneDnsName) {
|
|
13
|
+
const cleanZone = zoneDnsName.endsWith(".") ? zoneDnsName : `${zoneDnsName}.`;
|
|
14
|
+
if (!name || name === "@") {
|
|
15
|
+
return cleanZone;
|
|
16
|
+
}
|
|
17
|
+
if (name.endsWith(cleanZone)) {
|
|
18
|
+
return name;
|
|
19
|
+
}
|
|
20
|
+
if (name.endsWith(cleanZone.slice(0, -1))) {
|
|
21
|
+
return `${name}.`;
|
|
22
|
+
}
|
|
23
|
+
const cleanName = name.endsWith(".") ? name.slice(0, -1) : name;
|
|
24
|
+
return `${cleanName}.${cleanZone}`;
|
|
25
|
+
}
|
|
26
|
+
export class GCPCloudDNSZoneBuilder extends BaseBuilder {
|
|
27
|
+
zoneName;
|
|
28
|
+
out = {
|
|
29
|
+
zone: new Output(),
|
|
30
|
+
};
|
|
31
|
+
cleanZoneName;
|
|
32
|
+
zoneId;
|
|
33
|
+
records = [];
|
|
34
|
+
constructor(zoneName) {
|
|
35
|
+
super(zoneName);
|
|
36
|
+
this.zoneName = zoneName;
|
|
37
|
+
const clean = zoneName.toLowerCase();
|
|
38
|
+
this.cleanZoneName = clean.endsWith(".") ? clean : `${clean}.`;
|
|
39
|
+
this.zoneId = cleanZoneId(zoneName);
|
|
40
|
+
this.discoveryPromise = this.discoverZone();
|
|
41
|
+
}
|
|
42
|
+
async discoverZone() {
|
|
43
|
+
try {
|
|
44
|
+
const project = getProjectId();
|
|
45
|
+
const zone = await gcpFetch(DNS_BASE, `/dns/v1/projects/${project}/managedZones/${this.zoneId}`);
|
|
46
|
+
if (zone) {
|
|
47
|
+
this.out.zone.resolve({ name: this.zoneName, id: this.zoneId });
|
|
48
|
+
}
|
|
49
|
+
return zone;
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
if (e.message?.includes("404") ||
|
|
53
|
+
e.message?.includes("403") ||
|
|
54
|
+
e.message?.includes("credentials not configured")) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
record(name, type, value, ttl = 300) {
|
|
61
|
+
this.records.push({ name, type, value, ttl });
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
pointer(name, target) {
|
|
65
|
+
this.records.push({ name, type: "A", value: target, ttl: 300 });
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
async deploy() {
|
|
69
|
+
const dryRun = this.isDryRunActive();
|
|
70
|
+
const project = getProjectId();
|
|
71
|
+
const existing = await this.discoveryPromise;
|
|
72
|
+
console.log(`\nπΊοΈ Finalizing GCP Cloud DNS Zone "${this.zoneId}"...`);
|
|
73
|
+
if (!existing) {
|
|
74
|
+
if (dryRun) {
|
|
75
|
+
console.log(` π [PLAN] Create managed zone "${this.zoneId}" (dnsName: ${this.cleanZoneName})`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
await gcpFetch(DNS_BASE, `/dns/v1/projects/${project}/managedZones`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
name: this.zoneId,
|
|
82
|
+
dnsName: this.cleanZoneName,
|
|
83
|
+
description: "Managed by Puls",
|
|
84
|
+
visibility: "public",
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
console.log(`π Created managed zone "${this.zoneId}" (dnsName: ${this.cleanZoneName})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
console.log(` β
Managed zone "${this.zoneId}" exists`);
|
|
92
|
+
}
|
|
93
|
+
this.out.zone.resolve({ name: this.zoneName, id: this.zoneId });
|
|
94
|
+
// 1. Resolve values of all records
|
|
95
|
+
const resolvedRecords = [];
|
|
96
|
+
for (const r of this.records) {
|
|
97
|
+
let data;
|
|
98
|
+
let type = r.type;
|
|
99
|
+
if (r.value instanceof Output) {
|
|
100
|
+
data = await r.value.get();
|
|
101
|
+
}
|
|
102
|
+
else if (typeof r.value === "object" && r.value !== null) {
|
|
103
|
+
const targetObj = r.value;
|
|
104
|
+
let targetVal = null;
|
|
105
|
+
if ("url" in targetObj && typeof targetObj.url === "string") {
|
|
106
|
+
targetVal = targetObj.url;
|
|
107
|
+
}
|
|
108
|
+
else if ("resolvedUrl" in targetObj && typeof targetObj.resolvedUrl === "string") {
|
|
109
|
+
targetVal = targetObj.resolvedUrl;
|
|
110
|
+
}
|
|
111
|
+
else if (typeof targetObj.getPublicIp === "function") {
|
|
112
|
+
targetVal = await targetObj.getPublicIp();
|
|
113
|
+
}
|
|
114
|
+
else if ("resolvedIp" in targetObj && typeof targetObj.resolvedIp === "string") {
|
|
115
|
+
targetVal = targetObj.resolvedIp;
|
|
116
|
+
}
|
|
117
|
+
else if ("ip" in targetObj && typeof targetObj.ip === "string") {
|
|
118
|
+
targetVal = targetObj.ip;
|
|
119
|
+
}
|
|
120
|
+
else if (typeof targetObj.deploy === "function") {
|
|
121
|
+
const deployRes = await targetObj.deploy();
|
|
122
|
+
if (deployRes && typeof deployRes === "object") {
|
|
123
|
+
targetVal = deployRes.url ?? deployRes.ip ?? deployRes.publicIp ?? null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
data = targetVal ?? `[alias: ${targetObj.name ?? "unknown"}]`;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
data = r.value;
|
|
130
|
+
}
|
|
131
|
+
// Convert HTTP/HTTPS target urls for CNAME conversion
|
|
132
|
+
if (data.startsWith("http://") || data.startsWith("https://")) {
|
|
133
|
+
data = data.replace(/^https?:\/\//, "").split("/")[0].split(":")[0];
|
|
134
|
+
if (type === "A") {
|
|
135
|
+
type = "CNAME";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Automatically append trailing dot to CNAME, MX, NS targets if they don't have one and are not IPs
|
|
139
|
+
const isIp = /^[0-9.]+$/.test(data) || data.includes(":");
|
|
140
|
+
if ((type === "CNAME" || type === "MX" || type === "NS") && !isIp && !data.endsWith(".")) {
|
|
141
|
+
data = `${data}.`;
|
|
142
|
+
}
|
|
143
|
+
// Auto-quote TXT/SPF values
|
|
144
|
+
if ((type === "TXT" || type === "SPF") && !data.startsWith('"')) {
|
|
145
|
+
data = `"${data}"`;
|
|
146
|
+
}
|
|
147
|
+
resolvedRecords.push({
|
|
148
|
+
name: formatRecordName(r.name, this.cleanZoneName),
|
|
149
|
+
type,
|
|
150
|
+
value: data,
|
|
151
|
+
ttl: r.ttl ?? 300,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// 2. Group resolved records by name:type to form ResourceRecordSets
|
|
155
|
+
const declaredRrsetsMap = new Map();
|
|
156
|
+
for (const r of resolvedRecords) {
|
|
157
|
+
const key = `${r.name}:${r.type}`;
|
|
158
|
+
const existingRrset = declaredRrsetsMap.get(key);
|
|
159
|
+
if (existingRrset) {
|
|
160
|
+
if (!existingRrset.rrdatas.includes(r.value)) {
|
|
161
|
+
existingRrset.rrdatas.push(r.value);
|
|
162
|
+
}
|
|
163
|
+
// Keep the minimum TTL or default
|
|
164
|
+
existingRrset.ttl = Math.min(existingRrset.ttl, r.ttl);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
declaredRrsetsMap.set(key, {
|
|
168
|
+
name: r.name,
|
|
169
|
+
type: r.type,
|
|
170
|
+
ttl: r.ttl,
|
|
171
|
+
rrdatas: [r.value],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Sort rrdatas of declared sets to enable stable comparison
|
|
176
|
+
for (const rrset of declaredRrsetsMap.values()) {
|
|
177
|
+
rrset.rrdatas.sort();
|
|
178
|
+
}
|
|
179
|
+
// 3. Fetch existing rrsets from the zone
|
|
180
|
+
let existingRrsets = [];
|
|
181
|
+
if (existing) {
|
|
182
|
+
try {
|
|
183
|
+
const res = await gcpFetch(DNS_BASE, `/dns/v1/projects/${project}/managedZones/${this.zoneId}/rrsets`);
|
|
184
|
+
existingRrsets = res.rrsets ?? [];
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
existingRrsets = [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const existingRrsetsMap = new Map();
|
|
191
|
+
for (const r of existingRrsets) {
|
|
192
|
+
existingRrsetsMap.set(`${r.name}:${r.type}`, r);
|
|
193
|
+
}
|
|
194
|
+
const additions = [];
|
|
195
|
+
const deletions = [];
|
|
196
|
+
// 4. Compute additions and deletions transactionally
|
|
197
|
+
for (const [key, dec] of declaredRrsetsMap.entries()) {
|
|
198
|
+
const ext = existingRrsetsMap.get(key);
|
|
199
|
+
if (!ext) {
|
|
200
|
+
// Brand new record set
|
|
201
|
+
additions.push(dec);
|
|
202
|
+
console.log(` π [PLAN] Create ${dec.type} record: ${dec.name} β ${JSON.stringify(dec.rrdatas)} (TTL: ${dec.ttl})`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
const sortedExtRrdatas = [...(ext.rrdatas ?? [])].sort();
|
|
206
|
+
const rrdatasMatch = JSON.stringify(sortedExtRrdatas) === JSON.stringify(dec.rrdatas);
|
|
207
|
+
const ttlMatch = ext.ttl === dec.ttl;
|
|
208
|
+
if (rrdatasMatch && ttlMatch) {
|
|
209
|
+
console.log(` β
${dec.type} record ${dec.name} is up to date`);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// Transactional modification: delete old and add new
|
|
213
|
+
deletions.push(ext);
|
|
214
|
+
additions.push(dec);
|
|
215
|
+
console.log(` π [PLAN] Update ${dec.type} record: ${dec.name} β ${JSON.stringify(dec.rrdatas)} (was ${JSON.stringify(ext.rrdatas)})`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// 5. Submit transactional change if any additions or deletions
|
|
220
|
+
if ((additions.length > 0 || deletions.length > 0) && !dryRun) {
|
|
221
|
+
await gcpFetch(DNS_BASE, `/dns/v1/projects/${project}/managedZones/${this.zoneId}/changes`, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
body: JSON.stringify({
|
|
224
|
+
additions,
|
|
225
|
+
deletions,
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
console.log(` π Submitted transactional record updates to zone "${this.zoneId}"`);
|
|
229
|
+
}
|
|
230
|
+
await this.deploySidecars();
|
|
231
|
+
return {
|
|
232
|
+
zone: this.zoneName,
|
|
233
|
+
id: this.zoneId,
|
|
234
|
+
records: Array.from(declaredRrsetsMap.values()),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
async destroy() {
|
|
238
|
+
const dryRun = this.isDryRunActive();
|
|
239
|
+
const project = getProjectId();
|
|
240
|
+
const zoneId = this.zoneId;
|
|
241
|
+
console.log(`\nποΈ Destroying GCP Cloud DNS Zone "${zoneId}"...`);
|
|
242
|
+
const existing = await this.discoverZone();
|
|
243
|
+
if (!existing) {
|
|
244
|
+
console.log(` β
Zone "${zoneId}" does not exist - nothing to do.`);
|
|
245
|
+
return { destroyed: zoneId };
|
|
246
|
+
}
|
|
247
|
+
if (dryRun) {
|
|
248
|
+
console.log(` π [PLAN] Delete managed zone "${zoneId}"`);
|
|
249
|
+
return { destroyed: zoneId };
|
|
250
|
+
}
|
|
251
|
+
// 1. Fetch existing rrsets to clear non-default records
|
|
252
|
+
let existingRrsets = [];
|
|
253
|
+
try {
|
|
254
|
+
const res = await gcpFetch(DNS_BASE, `/dns/v1/projects/${project}/managedZones/${zoneId}/rrsets`);
|
|
255
|
+
existingRrsets = res.rrsets ?? [];
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// If fetching fails, we'll try to delete anyway
|
|
259
|
+
}
|
|
260
|
+
// 2. Filter out default apex NS and SOA records
|
|
261
|
+
const deletableRrsets = existingRrsets.filter((r) => {
|
|
262
|
+
const isApex = r.name === this.cleanZoneName;
|
|
263
|
+
const isDefaultType = r.type === "NS" || r.type === "SOA";
|
|
264
|
+
return !(isApex && isDefaultType);
|
|
265
|
+
});
|
|
266
|
+
// 3. Delete non-default records transactionally
|
|
267
|
+
if (deletableRrsets.length > 0) {
|
|
268
|
+
console.log(` π Deleting ${deletableRrsets.length} non-default records...`);
|
|
269
|
+
await gcpFetch(DNS_BASE, `/dns/v1/projects/${project}/managedZones/${zoneId}/changes`, {
|
|
270
|
+
method: "POST",
|
|
271
|
+
body: JSON.stringify({
|
|
272
|
+
deletions: deletableRrsets,
|
|
273
|
+
}),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// 4. Delete the managed zone
|
|
277
|
+
await gcpFetch(DNS_BASE, `/dns/v1/projects/${project}/managedZones/${zoneId}`, {
|
|
278
|
+
method: "DELETE",
|
|
279
|
+
});
|
|
280
|
+
console.log(` β
Managed zone "${zoneId}" deleted.`);
|
|
281
|
+
await this.destroySidecars();
|
|
282
|
+
return { destroyed: zoneId };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { GoogleAuth } from "google-auth-library";
|
|
4
|
+
import { GCPCloudDNSZoneBuilder } from "./clouddns.js";
|
|
5
|
+
import { Config } from "../../core/config.js";
|
|
6
|
+
import { Output } from "../../core/output.js";
|
|
7
|
+
describe("GCPCloudDNSZoneBuilder Unit Tests", () => {
|
|
8
|
+
let originalFetch;
|
|
9
|
+
let fetchCalls = [];
|
|
10
|
+
let mockResponses = {};
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
Config.set({
|
|
13
|
+
dryRun: false,
|
|
14
|
+
providers: {
|
|
15
|
+
gcp: {
|
|
16
|
+
projectId: "my-gcp-project",
|
|
17
|
+
serviceAccountPath: "/fake/sa.json",
|
|
18
|
+
region: "us-central1",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
originalFetch = globalThis.fetch;
|
|
23
|
+
fetchCalls = [];
|
|
24
|
+
mockResponses = {};
|
|
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 (typeof init.body === "string") {
|
|
31
|
+
try {
|
|
32
|
+
body = JSON.parse(init.body);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
body = init.body;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
body = "[Binary/Buffer Body]";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const headers = init?.headers;
|
|
43
|
+
fetchCalls.push({ url, method, body, headers });
|
|
44
|
+
const matchKey = Object.keys(mockResponses)
|
|
45
|
+
.filter((key) => {
|
|
46
|
+
const [mMethod, mPath] = key.split(" ");
|
|
47
|
+
return method === mMethod && url.includes(mPath);
|
|
48
|
+
})
|
|
49
|
+
.sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
|
|
50
|
+
if (matchKey) {
|
|
51
|
+
const resp = mockResponses[matchKey];
|
|
52
|
+
return {
|
|
53
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
54
|
+
status: resp.status,
|
|
55
|
+
json: async () => resp.body,
|
|
56
|
+
text: async () => JSON.stringify(resp.body),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
status: 404,
|
|
62
|
+
json: async () => ({ error: { message: `Endpoint not mocked: ${method} ${url}` } }),
|
|
63
|
+
text: async () => `Endpoint not mocked: ${method} ${url}`,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
mock.method(GoogleAuth.prototype, "getClient", async () => {
|
|
67
|
+
return {
|
|
68
|
+
getAccessToken: async () => ({ token: "fake-gcp-token" }),
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
globalThis.fetch = originalFetch;
|
|
74
|
+
mock.restoreAll();
|
|
75
|
+
});
|
|
76
|
+
test("initializes names and normalizes zone ID correctly", () => {
|
|
77
|
+
const builder = new GCPCloudDNSZoneBuilder("My-Awesome-Domain.com.");
|
|
78
|
+
assert.strictEqual(builder.cleanZoneName, "my-awesome-domain.com.");
|
|
79
|
+
assert.strictEqual(builder.zoneId, "my-awesome-domain-com");
|
|
80
|
+
});
|
|
81
|
+
test("runs in dry-run mode safely and logs plans without modifying resources", async () => {
|
|
82
|
+
Config.set({
|
|
83
|
+
dryRun: true,
|
|
84
|
+
providers: {
|
|
85
|
+
gcp: {
|
|
86
|
+
projectId: "my-gcp-project",
|
|
87
|
+
serviceAccountPath: "/fake/sa.json",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
mockResponses["GET /managedZones/dryrun-com"] = {
|
|
92
|
+
status: 404,
|
|
93
|
+
body: { error: { message: "Not found" } },
|
|
94
|
+
};
|
|
95
|
+
const builder = new GCPCloudDNSZoneBuilder("dryrun.com")
|
|
96
|
+
.record("www", "A", "1.2.3.4")
|
|
97
|
+
.record("api", "CNAME", "api-backend.com")
|
|
98
|
+
.record("@", "TXT", "v=spf1 include:_spf.google.com ~all");
|
|
99
|
+
const result = await builder.deploy();
|
|
100
|
+
assert.strictEqual(result.zone, "dryrun.com");
|
|
101
|
+
assert.strictEqual(result.id, "dryrun-com");
|
|
102
|
+
assert.strictEqual(result.records.length, 3);
|
|
103
|
+
// Verify dry-run outputs are correct and no write calls are sent
|
|
104
|
+
const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
|
|
105
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
106
|
+
const wwwRec = result.records.find((r) => r.name === "www.dryrun.com.");
|
|
107
|
+
assert.ok(wwwRec);
|
|
108
|
+
assert.strictEqual(wwwRec.type, "A");
|
|
109
|
+
assert.deepStrictEqual(wwwRec.rrdatas, ["1.2.3.4"]);
|
|
110
|
+
const txtRec = result.records.find((r) => r.name === "dryrun.com.");
|
|
111
|
+
assert.ok(txtRec);
|
|
112
|
+
assert.strictEqual(txtRec.type, "TXT");
|
|
113
|
+
assert.deepStrictEqual(txtRec.rrdatas, ['"v=spf1 include:_spf.google.com ~all"']); // Auto-quoted!
|
|
114
|
+
});
|
|
115
|
+
test("creates a new managed zone when missing and submits records", async () => {
|
|
116
|
+
// 1. Zone doesn't exist yet
|
|
117
|
+
mockResponses["GET /managedZones/new-zone-com"] = {
|
|
118
|
+
status: 404,
|
|
119
|
+
body: { error: { message: "Not found" } },
|
|
120
|
+
};
|
|
121
|
+
// 2. Mock Managed Zone Creation POST
|
|
122
|
+
mockResponses["POST /managedZones"] = {
|
|
123
|
+
status: 200,
|
|
124
|
+
body: { name: "new-zone-com", dnsName: "new-zone.com." },
|
|
125
|
+
};
|
|
126
|
+
// 3. rrsets GET returns empty
|
|
127
|
+
mockResponses["GET /managedZones/new-zone-com/rrsets"] = {
|
|
128
|
+
status: 200,
|
|
129
|
+
body: { rrsets: [] },
|
|
130
|
+
};
|
|
131
|
+
// 4. changes POST
|
|
132
|
+
mockResponses["POST /managedZones/new-zone-com/changes"] = {
|
|
133
|
+
status: 200,
|
|
134
|
+
body: { status: "pending" },
|
|
135
|
+
};
|
|
136
|
+
const builder = new GCPCloudDNSZoneBuilder("new-zone.com")
|
|
137
|
+
.record("www", "CNAME", "lb.google.com")
|
|
138
|
+
.record("db", "A", "10.0.0.5", 60);
|
|
139
|
+
const result = await builder.deploy();
|
|
140
|
+
assert.strictEqual(result.id, "new-zone-com");
|
|
141
|
+
// Verify Managed Zone creation POST
|
|
142
|
+
const createZoneCall = fetchCalls.find((c) => c.method === "POST" && c.url.endsWith("/managedZones"));
|
|
143
|
+
assert.ok(createZoneCall);
|
|
144
|
+
assert.strictEqual(createZoneCall.body.name, "new-zone-com");
|
|
145
|
+
assert.strictEqual(createZoneCall.body.dnsName, "new-zone.com.");
|
|
146
|
+
// Verify record changes POST
|
|
147
|
+
const changeCall = fetchCalls.find((c) => c.method === "POST" && c.url.endsWith("/changes"));
|
|
148
|
+
assert.ok(changeCall);
|
|
149
|
+
assert.strictEqual(changeCall.body.additions.length, 2);
|
|
150
|
+
assert.strictEqual(changeCall.body.deletions.length, 0);
|
|
151
|
+
const cnameAdd = changeCall.body.additions.find((a) => a.type === "CNAME");
|
|
152
|
+
assert.ok(cnameAdd);
|
|
153
|
+
assert.strictEqual(cnameAdd.name, "www.new-zone.com.");
|
|
154
|
+
assert.deepStrictEqual(cnameAdd.rrdatas, ["lb.google.com."]); // Trailing dot auto-appended!
|
|
155
|
+
});
|
|
156
|
+
test("skips identical record sets, and transactionally updates out-of-date records", async () => {
|
|
157
|
+
// 1. Zone exists
|
|
158
|
+
mockResponses["GET /managedZones/sync-zone-com"] = {
|
|
159
|
+
status: 200,
|
|
160
|
+
body: { name: "sync-zone-com" },
|
|
161
|
+
};
|
|
162
|
+
// 2. rrsets GET returns existing records
|
|
163
|
+
mockResponses["GET /managedZones/sync-zone-com/rrsets"] = {
|
|
164
|
+
status: 200,
|
|
165
|
+
body: {
|
|
166
|
+
rrsets: [
|
|
167
|
+
// Identical
|
|
168
|
+
{ name: "www.sync-zone.com.", type: "CNAME", ttl: 300, rrdatas: ["lb.google.com."] },
|
|
169
|
+
// Differing TTL
|
|
170
|
+
{ name: "db.sync-zone.com.", type: "A", ttl: 300, rrdatas: ["10.0.0.5"] },
|
|
171
|
+
// Differing Value
|
|
172
|
+
{ name: "mail.sync-zone.com.", type: "A", ttl: 300, rrdatas: ["1.1.1.1"] },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
mockResponses["POST /managedZones/sync-zone-com/changes"] = {
|
|
177
|
+
status: 200,
|
|
178
|
+
body: {},
|
|
179
|
+
};
|
|
180
|
+
const builder = new GCPCloudDNSZoneBuilder("sync-zone.com")
|
|
181
|
+
.record("www", "CNAME", "lb.google.com", 300) // Identical
|
|
182
|
+
.record("db", "A", "10.0.0.5", 60) // Changed TTL (300 -> 60)
|
|
183
|
+
.record("mail", "A", "2.2.2.2", 300); // Changed Value (1.1.1.1 -> 2.2.2.2)
|
|
184
|
+
await builder.deploy();
|
|
185
|
+
const changeCall = fetchCalls.find((c) => c.method === "POST" && c.url.endsWith("/changes"));
|
|
186
|
+
assert.ok(changeCall);
|
|
187
|
+
// additions should have db (new TTL) and mail (new value)
|
|
188
|
+
assert.strictEqual(changeCall.body.additions.length, 2);
|
|
189
|
+
// deletions should have old db and old mail
|
|
190
|
+
assert.strictEqual(changeCall.body.deletions.length, 2);
|
|
191
|
+
const oldMail = changeCall.body.deletions.find((d) => d.name === "mail.sync-zone.com.");
|
|
192
|
+
assert.ok(oldMail);
|
|
193
|
+
assert.deepStrictEqual(oldMail.rrdatas, ["1.1.1.1"]);
|
|
194
|
+
const newMail = changeCall.body.additions.find((a) => a.name === "mail.sync-zone.com.");
|
|
195
|
+
assert.ok(newMail);
|
|
196
|
+
assert.deepStrictEqual(newMail.rrdatas, ["2.2.2.2"]);
|
|
197
|
+
});
|
|
198
|
+
test("resolves pointers to other builders, converting to CNAME and stripping protocols", async () => {
|
|
199
|
+
mockResponses["GET /managedZones/pointers-com"] = {
|
|
200
|
+
status: 200,
|
|
201
|
+
body: { name: "pointers-com" },
|
|
202
|
+
};
|
|
203
|
+
mockResponses["GET /managedZones/pointers-com/rrsets"] = { status: 200, body: { rrsets: [] } };
|
|
204
|
+
mockResponses["POST /managedZones/pointers-com/changes"] = { status: 200, body: {} };
|
|
205
|
+
// Let's mock a target builder that resolves to a URL (e.g. Cloud Run microservice)
|
|
206
|
+
const mockCloudRun = {
|
|
207
|
+
name: "frontend-srv",
|
|
208
|
+
url: "https://frontend-srv-xyz.a.run.app",
|
|
209
|
+
};
|
|
210
|
+
// Let's mock another target that is an Output resolving to an IP
|
|
211
|
+
const ipOutput = new Output();
|
|
212
|
+
ipOutput.resolve("123.45.67.89");
|
|
213
|
+
const builder = new GCPCloudDNSZoneBuilder("pointers.com")
|
|
214
|
+
.pointer("app", mockCloudRun)
|
|
215
|
+
.pointer("api", ipOutput);
|
|
216
|
+
const result = await builder.deploy();
|
|
217
|
+
const appRec = result.records.find((r) => r.name === "app.pointers.com.");
|
|
218
|
+
assert.ok(appRec);
|
|
219
|
+
// Dynamic CNAME conversion from HTTP URL target!
|
|
220
|
+
assert.strictEqual(appRec.type, "CNAME");
|
|
221
|
+
assert.deepStrictEqual(appRec.rrdatas, ["frontend-srv-xyz.a.run.app."]); // Stripped https://, appended trailing dot!
|
|
222
|
+
const apiRec = result.records.find((r) => r.name === "api.pointers.com.");
|
|
223
|
+
assert.ok(apiRec);
|
|
224
|
+
// Standard A record for plain IP output!
|
|
225
|
+
assert.strictEqual(apiRec.type, "A");
|
|
226
|
+
assert.deepStrictEqual(apiRec.rrdatas, ["123.45.67.89"]);
|
|
227
|
+
});
|
|
228
|
+
test("destroys zone successfully, removing non-default records first", async () => {
|
|
229
|
+
mockResponses["GET /managedZones/to-delete-com"] = {
|
|
230
|
+
status: 200,
|
|
231
|
+
body: { name: "to-delete-com" },
|
|
232
|
+
};
|
|
233
|
+
mockResponses["GET /managedZones/to-delete-com/rrsets"] = {
|
|
234
|
+
status: 200,
|
|
235
|
+
body: {
|
|
236
|
+
rrsets: [
|
|
237
|
+
{ name: "to-delete.com.", type: "NS", rrdatas: ["ns-cloud-a1.google.com."] }, // apex NS (default)
|
|
238
|
+
{ name: "to-delete.com.", type: "SOA", rrdatas: ["ns-cloud-a1.google.com. host.google.com."] }, // apex SOA (default)
|
|
239
|
+
{ name: "www.to-delete.com.", type: "A", rrdatas: ["1.2.3.4"] }, // non-default
|
|
240
|
+
{ name: "api.to-delete.com.", type: "CNAME", rrdatas: ["lb.google.com."] }, // non-default
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
mockResponses["POST /managedZones/to-delete-com/changes"] = { status: 200, body: {} };
|
|
245
|
+
mockResponses["DELETE /managedZones/to-delete-com"] = { status: 200, body: {} };
|
|
246
|
+
const builder = new GCPCloudDNSZoneBuilder("to-delete.com");
|
|
247
|
+
await builder.destroy();
|
|
248
|
+
// Verify non-default records deletion POST
|
|
249
|
+
const changeCall = fetchCalls.find((c) => c.method === "POST" && c.url.endsWith("/changes"));
|
|
250
|
+
assert.ok(changeCall);
|
|
251
|
+
assert.strictEqual(changeCall.body.deletions.length, 2); // only www and api
|
|
252
|
+
const deletedNames = changeCall.body.deletions.map((d) => d.name);
|
|
253
|
+
assert.ok(deletedNames.includes("www.to-delete.com."));
|
|
254
|
+
assert.ok(deletedNames.includes("api.to-delete.com."));
|
|
255
|
+
// Verify Zone DELETE
|
|
256
|
+
const deleteCall = fetchCalls.find((c) => c.method === "DELETE" && c.url.endsWith("/managedZones/to-delete-com"));
|
|
257
|
+
assert.ok(deleteCall);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { GCPSecretBuilder } from "./secrets.js";
|
|
3
|
+
export declare class GCPCloudRunBuilder extends BaseBuilder {
|
|
4
|
+
private _image?;
|
|
5
|
+
private _port;
|
|
6
|
+
private _cpu;
|
|
7
|
+
private _memory;
|
|
8
|
+
private _minInstances?;
|
|
9
|
+
private _maxInstances?;
|
|
10
|
+
private _env;
|
|
11
|
+
private _region?;
|
|
12
|
+
private _public;
|
|
13
|
+
constructor(serviceId: string);
|
|
14
|
+
image(img: string): this;
|
|
15
|
+
port(p: number): this;
|
|
16
|
+
cpu(c: string | number): this;
|
|
17
|
+
memory(m: string | number): this;
|
|
18
|
+
minInstances(n: number): this;
|
|
19
|
+
maxInstances(n: number): this;
|
|
20
|
+
env(vars: Record<string, string | GCPSecretBuilder>): this;
|
|
21
|
+
region(reg: string): this;
|
|
22
|
+
public(enabled?: boolean): this;
|
|
23
|
+
private discoverService;
|
|
24
|
+
deploy(): Promise<{
|
|
25
|
+
serviceId: string;
|
|
26
|
+
url: any;
|
|
27
|
+
}>;
|
|
28
|
+
destroy(): Promise<{
|
|
29
|
+
destroyed: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|