puls-dev 0.2.0 → 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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/core/config.d.ts +5 -0
  3. package/dist/providers/aws/api.d.ts +4 -0
  4. package/dist/providers/aws/api.js +4 -0
  5. package/dist/providers/aws/cloudwatch.d.ts +44 -0
  6. package/dist/providers/aws/cloudwatch.js +205 -0
  7. package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
  8. package/dist/providers/aws/cloudwatch.test.js +224 -0
  9. package/dist/providers/aws/fargate.d.ts +2 -0
  10. package/dist/providers/aws/fargate.js +6 -0
  11. package/dist/providers/aws/iam.d.ts +52 -0
  12. package/dist/providers/aws/iam.js +307 -0
  13. package/dist/providers/aws/iam.test.d.ts +1 -0
  14. package/dist/providers/aws/iam.test.js +367 -0
  15. package/dist/providers/aws/index.d.ts +7 -0
  16. package/dist/providers/aws/index.js +7 -0
  17. package/dist/providers/aws/lambda.d.ts +3 -1
  18. package/dist/providers/aws/lambda.js +11 -2
  19. package/dist/providers/aws/rds.d.ts +1 -0
  20. package/dist/providers/aws/rds.js +4 -1
  21. package/dist/providers/aws/sns.d.ts +22 -0
  22. package/dist/providers/aws/sns.js +146 -0
  23. package/dist/providers/aws/sns.test.d.ts +1 -0
  24. package/dist/providers/aws/sns.test.js +162 -0
  25. package/dist/providers/firebase/appcheck.d.ts +15 -0
  26. package/dist/providers/firebase/appcheck.js +109 -0
  27. package/dist/providers/firebase/appcheck.test.d.ts +1 -0
  28. package/dist/providers/firebase/appcheck.test.js +141 -0
  29. package/dist/providers/firebase/index.d.ts +2 -0
  30. package/dist/providers/firebase/index.js +2 -0
  31. package/dist/providers/gcp/api.d.ts +10 -0
  32. package/dist/providers/gcp/api.js +111 -0
  33. package/dist/providers/gcp/clouddns.d.ts +37 -0
  34. package/dist/providers/gcp/clouddns.js +284 -0
  35. package/dist/providers/gcp/clouddns.test.d.ts +1 -0
  36. package/dist/providers/gcp/clouddns.test.js +259 -0
  37. package/dist/providers/gcp/cloudrun.d.ts +31 -0
  38. package/dist/providers/gcp/cloudrun.js +240 -0
  39. package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
  40. package/dist/providers/gcp/cloudrun.test.js +281 -0
  41. package/dist/providers/gcp/cloudsql.d.ts +37 -0
  42. package/dist/providers/gcp/cloudsql.js +262 -0
  43. package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
  44. package/dist/providers/gcp/cloudsql.test.js +295 -0
  45. package/dist/providers/gcp/iam.d.ts +38 -0
  46. package/dist/providers/gcp/iam.js +309 -0
  47. package/dist/providers/gcp/iam.test.d.ts +1 -0
  48. package/dist/providers/gcp/iam.test.js +305 -0
  49. package/dist/providers/gcp/index.d.ts +19 -0
  50. package/dist/providers/gcp/index.js +19 -0
  51. package/dist/providers/gcp/pubsub.d.ts +31 -0
  52. package/dist/providers/gcp/pubsub.js +227 -0
  53. package/dist/providers/gcp/pubsub.test.d.ts +1 -0
  54. package/dist/providers/gcp/pubsub.test.js +244 -0
  55. package/dist/providers/gcp/secrets.d.ts +21 -0
  56. package/dist/providers/gcp/secrets.js +187 -0
  57. package/dist/providers/gcp/secrets.test.d.ts +1 -0
  58. package/dist/providers/gcp/secrets.test.js +264 -0
  59. package/dist/providers/proxmox/vm.d.ts +2 -0
  60. package/dist/providers/proxmox/vm.js +35 -3
  61. package/dist/providers/proxmox/vm.test.d.ts +1 -0
  62. package/dist/providers/proxmox/vm.test.js +155 -0
  63. package/dist/types/aws.d.ts +11 -0
  64. package/package.json +32 -2
@@ -0,0 +1,141 @@
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 { FirebaseAppCheckBuilder } from "./appcheck.js";
5
+ import { Config } from "../../core/config.js";
6
+ describe("FirebaseAppCheckBuilder Unit Tests", () => {
7
+ let originalFetch;
8
+ let fetchCalls = [];
9
+ let mockResponses = {};
10
+ beforeEach(() => {
11
+ Config.set({
12
+ dryRun: false,
13
+ providers: {
14
+ firebase: {
15
+ projectId: "my-project",
16
+ serviceAccountPath: "/fake/sa.json",
17
+ },
18
+ },
19
+ });
20
+ originalFetch = globalThis.fetch;
21
+ fetchCalls = [];
22
+ mockResponses = {};
23
+ globalThis.fetch = async (input, init) => {
24
+ const url = String(input);
25
+ const method = init?.method ?? "GET";
26
+ let body;
27
+ if (init?.body) {
28
+ if (typeof init.body === "string") {
29
+ try {
30
+ body = JSON.parse(init.body);
31
+ }
32
+ catch {
33
+ body = init.body;
34
+ }
35
+ }
36
+ else {
37
+ body = "[Binary/Buffer Body]";
38
+ }
39
+ }
40
+ const headers = init?.headers;
41
+ fetchCalls.push({ url, method, body, headers });
42
+ const matchKey = Object.keys(mockResponses)
43
+ .filter((key) => {
44
+ const [mMethod, mPath] = key.split(" ");
45
+ return method === mMethod && url.includes(mPath);
46
+ })
47
+ .sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
48
+ if (matchKey) {
49
+ const resp = mockResponses[matchKey];
50
+ return {
51
+ ok: resp.status >= 200 && resp.status < 300,
52
+ status: resp.status,
53
+ json: async () => resp.body,
54
+ text: async () => JSON.stringify(resp.body),
55
+ };
56
+ }
57
+ return {
58
+ ok: false,
59
+ status: 404,
60
+ json: async () => ({ message: `Endpoint not mocked: ${method} ${url}` }),
61
+ text: async () => `Endpoint not mocked: ${method} ${url}`,
62
+ };
63
+ };
64
+ mock.method(GoogleAuth.prototype, "getClient", async () => {
65
+ return {
66
+ getAccessToken: async () => ({ token: "fake-access-token" }),
67
+ };
68
+ });
69
+ });
70
+ afterEach(() => {
71
+ globalThis.fetch = originalFetch;
72
+ mock.restoreAll();
73
+ });
74
+ test("runs in dry-run mode safely and logs plans", async () => {
75
+ Config.set({
76
+ dryRun: true,
77
+ providers: {
78
+ firebase: {
79
+ projectId: "my-project",
80
+ serviceAccountPath: "/fake/sa.json",
81
+ },
82
+ },
83
+ });
84
+ const builder = new FirebaseAppCheckBuilder()
85
+ .enforce("firestore")
86
+ .unenforced("storage")
87
+ .off("auth");
88
+ const deployResult = await builder.deploy();
89
+ assert.strictEqual(deployResult.project, "my-project");
90
+ assert.strictEqual(fetchCalls.length, 0); // zero write calls
91
+ });
92
+ test("syncs App Check services idempotently - updates changed and skips identical", async () => {
93
+ // 1. Mock GET calls returning existing statuses:
94
+ // firestore is currently OFF (needs to be ENFORCED)
95
+ // storage is currently UNENFORCED (needs to be UNENFORCED - should skip)
96
+ mockResponses["GET /services/firestore.googleapis.com"] = {
97
+ status: 200,
98
+ body: { name: "projects/my-project/services/firestore.googleapis.com", enforcementMode: "OFF" },
99
+ };
100
+ mockResponses["GET /services/firebasestorage.googleapis.com"] = {
101
+ status: 200,
102
+ body: { name: "projects/my-project/services/firebasestorage.googleapis.com", enforcementMode: "UNENFORCED" },
103
+ };
104
+ // 2. Mock PATCH calls
105
+ mockResponses["PATCH /services/firestore.googleapis.com"] = {
106
+ status: 200,
107
+ body: { name: "projects/my-project/services/firestore.googleapis.com", enforcementMode: "ENFORCED" },
108
+ };
109
+ const builder = new FirebaseAppCheckBuilder()
110
+ .enforce("firestore")
111
+ .unenforced("storage");
112
+ const deployResult = await builder.deploy();
113
+ assert.strictEqual(deployResult.project, "my-project");
114
+ // We should have exactly 2 GET calls and 1 PATCH call
115
+ const patchCalls = fetchCalls.filter((c) => c.method === "PATCH");
116
+ assert.strictEqual(patchCalls.length, 1);
117
+ assert.strictEqual(patchCalls[0].url.includes("/services/firestore.googleapis.com"), true);
118
+ assert.strictEqual(patchCalls[0].body.enforcementMode, "ENFORCED");
119
+ });
120
+ test("destroys App Check configuration by reverting all configured services to OFF", async () => {
121
+ // Mock PATCH calls returning OFF
122
+ mockResponses["PATCH /services/firestore.googleapis.com"] = {
123
+ status: 200,
124
+ body: { name: "projects/my-project/services/firestore.googleapis.com", enforcementMode: "OFF" },
125
+ };
126
+ mockResponses["PATCH /services/firebasestorage.googleapis.com"] = {
127
+ status: 200,
128
+ body: { name: "projects/my-project/services/firebasestorage.googleapis.com", enforcementMode: "OFF" },
129
+ };
130
+ const builder = new FirebaseAppCheckBuilder()
131
+ .enforce("firestore")
132
+ .unenforced("storage");
133
+ const destroyResult = await builder.destroy();
134
+ assert.deepStrictEqual(destroyResult, { destroyed: "appcheck" });
135
+ // Verify both services were patched to OFF
136
+ const patchCalls = fetchCalls.filter((c) => c.method === "PATCH");
137
+ assert.strictEqual(patchCalls.length, 2);
138
+ assert.strictEqual(patchCalls.some((c) => c.url.includes("/services/firestore.googleapis.com") && c.body.enforcementMode === "OFF"), true);
139
+ assert.strictEqual(patchCalls.some((c) => c.url.includes("/services/firebasestorage.googleapis.com") && c.body.enforcementMode === "OFF"), true);
140
+ });
141
+ });
@@ -4,6 +4,7 @@ import { FirebaseFirestoreBuilder } from './firestore.js';
4
4
  import { FirebaseStorageBuilder } from './storage.js';
5
5
  import { FirebaseAuthBuilder } from './auth.js';
6
6
  import { FirebaseRemoteConfigBuilder } from './remoteconfig.js';
7
+ import { FirebaseAppCheckBuilder } from './appcheck.js';
7
8
  export { FUNCTIONS_RUNTIME };
8
9
  export declare const Firebase: {
9
10
  Hosting: (siteId: string) => FirebaseHostingBuilder;
@@ -12,4 +13,5 @@ export declare const Firebase: {
12
13
  Storage: (bucket?: string) => FirebaseStorageBuilder;
13
14
  Auth: () => FirebaseAuthBuilder;
14
15
  RemoteConfig: () => FirebaseRemoteConfigBuilder;
16
+ AppCheck: () => FirebaseAppCheckBuilder;
15
17
  };
@@ -4,6 +4,7 @@ import { FirebaseFirestoreBuilder } from './firestore.js';
4
4
  import { FirebaseStorageBuilder } from './storage.js';
5
5
  import { FirebaseAuthBuilder } from './auth.js';
6
6
  import { FirebaseRemoteConfigBuilder } from './remoteconfig.js';
7
+ import { FirebaseAppCheckBuilder } from './appcheck.js';
7
8
  export { FUNCTIONS_RUNTIME };
8
9
  export const Firebase = {
9
10
  Hosting: (siteId) => new FirebaseHostingBuilder(siteId),
@@ -12,4 +13,5 @@ export const Firebase = {
12
13
  Storage: (bucket) => new FirebaseStorageBuilder(bucket),
13
14
  Auth: () => new FirebaseAuthBuilder(),
14
15
  RemoteConfig: () => new FirebaseRemoteConfigBuilder(),
16
+ AppCheck: () => new FirebaseAppCheckBuilder(),
15
17
  };
@@ -0,0 +1,10 @@
1
+ export interface GCPConfig {
2
+ projectId: string;
3
+ serviceAccountPath: string;
4
+ region?: string;
5
+ }
6
+ export declare function resolveGCPConfig(): GCPConfig;
7
+ export declare function getProjectId(): string;
8
+ export declare function getRegion(): string;
9
+ export declare function getGCPToken(scopes: string[]): Promise<string>;
10
+ export declare function gcpFetch(base: string, path: string, opts?: RequestInit): Promise<any>;
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs';
2
+ import { GoogleAuth } from 'google-auth-library';
3
+ import { Config } from '../../core/config.js';
4
+ export function resolveGCPConfig() {
5
+ // 1. Check Config.providers.gcp
6
+ const gcpCfg = Config.get().providers.gcp;
7
+ if (gcpCfg?.serviceAccountPath) {
8
+ if (gcpCfg.projectId) {
9
+ return {
10
+ projectId: gcpCfg.projectId,
11
+ serviceAccountPath: gcpCfg.serviceAccountPath,
12
+ region: gcpCfg.region,
13
+ };
14
+ }
15
+ try {
16
+ const sa = JSON.parse(fs.readFileSync(gcpCfg.serviceAccountPath, 'utf8'));
17
+ return {
18
+ projectId: sa.project_id,
19
+ serviceAccountPath: gcpCfg.serviceAccountPath,
20
+ region: gcpCfg.region,
21
+ };
22
+ }
23
+ catch (e) {
24
+ // Continue to next fallback
25
+ }
26
+ }
27
+ // 2. Fallback to Config.providers.firebase
28
+ const fbCfg = Config.get().providers.firebase;
29
+ if (fbCfg?.serviceAccountPath) {
30
+ if (fbCfg.projectId) {
31
+ return {
32
+ projectId: fbCfg.projectId,
33
+ serviceAccountPath: fbCfg.serviceAccountPath,
34
+ };
35
+ }
36
+ try {
37
+ const sa = JSON.parse(fs.readFileSync(fbCfg.serviceAccountPath, 'utf8'));
38
+ return {
39
+ projectId: sa.project_id,
40
+ serviceAccountPath: fbCfg.serviceAccountPath,
41
+ };
42
+ }
43
+ catch (e) {
44
+ // Continue to next fallback
45
+ }
46
+ }
47
+ // 3. Fallback to process.env.GCP_SA
48
+ const gcpSa = process.env.GCP_SA;
49
+ if (gcpSa && fs.existsSync(gcpSa)) {
50
+ try {
51
+ const sa = JSON.parse(fs.readFileSync(gcpSa, 'utf8'));
52
+ return {
53
+ projectId: sa.project_id,
54
+ serviceAccountPath: gcpSa,
55
+ };
56
+ }
57
+ catch (e) {
58
+ // Continue to next fallback
59
+ }
60
+ }
61
+ // 4. Fallback to process.env.FIREBASE_SA
62
+ const fbSa = process.env.FIREBASE_SA;
63
+ if (fbSa && fs.existsSync(fbSa)) {
64
+ try {
65
+ const sa = JSON.parse(fs.readFileSync(fbSa, 'utf8'));
66
+ return {
67
+ projectId: sa.project_id,
68
+ serviceAccountPath: fbSa,
69
+ };
70
+ }
71
+ catch (e) {
72
+ // Continue to next fallback
73
+ }
74
+ }
75
+ throw new Error('GCP credentials not configured. Please set GCP_SA or FIREBASE_SA env var, or configure providers.gcp or providers.firebase in Config.');
76
+ }
77
+ export function getProjectId() {
78
+ return resolveGCPConfig().projectId;
79
+ }
80
+ export function getRegion() {
81
+ const gcpCfg = Config.get().providers.gcp;
82
+ return gcpCfg?.region ?? 'us-central1';
83
+ }
84
+ export async function getGCPToken(scopes) {
85
+ const { serviceAccountPath } = resolveGCPConfig();
86
+ const auth = new GoogleAuth({ keyFile: serviceAccountPath, scopes });
87
+ const client = await auth.getClient();
88
+ const token = await client.getAccessToken();
89
+ if (!token.token) {
90
+ throw new Error('Failed to retrieve GCP access token');
91
+ }
92
+ return token.token;
93
+ }
94
+ const CLOUD_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
95
+ export async function gcpFetch(base, path, opts = {}) {
96
+ const token = await getGCPToken([CLOUD_SCOPE]);
97
+ const res = await fetch(`${base}${path}`, {
98
+ ...opts,
99
+ headers: {
100
+ 'Authorization': `Bearer ${token}`,
101
+ 'Content-Type': 'application/json',
102
+ ...(opts.headers ?? {}),
103
+ },
104
+ });
105
+ if (!res.ok) {
106
+ const body = await res.text();
107
+ throw new Error(`GCP API ${opts.method ?? 'GET'} ${path} → ${res.status}: ${body}`);
108
+ }
109
+ const text = await res.text();
110
+ return text ? JSON.parse(text) : null;
111
+ }
@@ -0,0 +1,37 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { Output } from "../../core/output.js";
3
+ export interface GCPDNSRecord {
4
+ name: string;
5
+ type: string;
6
+ value: any;
7
+ ttl: number;
8
+ }
9
+ export declare class GCPCloudDNSZoneBuilder extends BaseBuilder {
10
+ zoneName: string;
11
+ readonly out: {
12
+ zone: Output<{
13
+ name: string;
14
+ id: string;
15
+ }>;
16
+ };
17
+ cleanZoneName: string;
18
+ zoneId: string;
19
+ private records;
20
+ constructor(zoneName: string);
21
+ private discoverZone;
22
+ record(name: string, type: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS" | "PTR" | "SRV" | "CAA" | "SPF", value: string, ttl?: number): this;
23
+ pointer(name: string, target: BaseBuilder | Output<string> | string): this;
24
+ deploy(): Promise<{
25
+ zone: string;
26
+ id: string;
27
+ records: {
28
+ name: string;
29
+ type: string;
30
+ ttl: number;
31
+ rrdatas: string[];
32
+ }[];
33
+ }>;
34
+ destroy(): Promise<{
35
+ destroyed: string;
36
+ }>;
37
+ }
@@ -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 {};