puls-dev 0.2.1 β†’ 0.2.3

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.
Files changed (37) hide show
  1. package/README.md +13 -5
  2. package/dist/core/config.d.ts +5 -0
  3. package/dist/providers/firebase/appcheck.d.ts +15 -0
  4. package/dist/providers/firebase/appcheck.js +109 -0
  5. package/dist/providers/firebase/appcheck.test.d.ts +1 -0
  6. package/dist/providers/firebase/appcheck.test.js +141 -0
  7. package/dist/providers/firebase/index.d.ts +2 -0
  8. package/dist/providers/firebase/index.js +2 -0
  9. package/dist/providers/gcp/api.d.ts +10 -0
  10. package/dist/providers/gcp/api.js +111 -0
  11. package/dist/providers/gcp/clouddns.d.ts +37 -0
  12. package/dist/providers/gcp/clouddns.js +284 -0
  13. package/dist/providers/gcp/clouddns.test.d.ts +1 -0
  14. package/dist/providers/gcp/clouddns.test.js +259 -0
  15. package/dist/providers/gcp/cloudrun.d.ts +31 -0
  16. package/dist/providers/gcp/cloudrun.js +240 -0
  17. package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
  18. package/dist/providers/gcp/cloudrun.test.js +281 -0
  19. package/dist/providers/gcp/cloudsql.d.ts +37 -0
  20. package/dist/providers/gcp/cloudsql.js +262 -0
  21. package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
  22. package/dist/providers/gcp/cloudsql.test.js +295 -0
  23. package/dist/providers/gcp/iam.d.ts +38 -0
  24. package/dist/providers/gcp/iam.js +309 -0
  25. package/dist/providers/gcp/iam.test.d.ts +1 -0
  26. package/dist/providers/gcp/iam.test.js +305 -0
  27. package/dist/providers/gcp/index.d.ts +19 -0
  28. package/dist/providers/gcp/index.js +19 -0
  29. package/dist/providers/gcp/pubsub.d.ts +31 -0
  30. package/dist/providers/gcp/pubsub.js +227 -0
  31. package/dist/providers/gcp/pubsub.test.d.ts +1 -0
  32. package/dist/providers/gcp/pubsub.test.js +244 -0
  33. package/dist/providers/gcp/secrets.d.ts +21 -0
  34. package/dist/providers/gcp/secrets.js +187 -0
  35. package/dist/providers/gcp/secrets.test.d.ts +1 -0
  36. package/dist/providers/gcp/secrets.test.js +264 -0
  37. 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
+ }