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.
- package/README.md +1 -1
- package/dist/core/config.d.ts +5 -0
- package/dist/providers/aws/api.d.ts +4 -0
- package/dist/providers/aws/api.js +4 -0
- package/dist/providers/aws/cloudwatch.d.ts +44 -0
- package/dist/providers/aws/cloudwatch.js +205 -0
- package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
- package/dist/providers/aws/cloudwatch.test.js +224 -0
- package/dist/providers/aws/fargate.d.ts +2 -0
- package/dist/providers/aws/fargate.js +6 -0
- package/dist/providers/aws/iam.d.ts +52 -0
- package/dist/providers/aws/iam.js +307 -0
- package/dist/providers/aws/iam.test.d.ts +1 -0
- package/dist/providers/aws/iam.test.js +367 -0
- package/dist/providers/aws/index.d.ts +7 -0
- package/dist/providers/aws/index.js +7 -0
- package/dist/providers/aws/lambda.d.ts +3 -1
- package/dist/providers/aws/lambda.js +11 -2
- package/dist/providers/aws/rds.d.ts +1 -0
- package/dist/providers/aws/rds.js +4 -1
- package/dist/providers/aws/sns.d.ts +22 -0
- package/dist/providers/aws/sns.js +146 -0
- package/dist/providers/aws/sns.test.d.ts +1 -0
- package/dist/providers/aws/sns.test.js +162 -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/dist/providers/proxmox/vm.d.ts +2 -0
- package/dist/providers/proxmox/vm.js +35 -3
- package/dist/providers/proxmox/vm.test.d.ts +1 -0
- package/dist/providers/proxmox/vm.test.js +155 -0
- package/dist/types/aws.d.ts +11 -0
- 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 {};
|