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.
Files changed (36) hide show
  1. package/dist/core/config.d.ts +5 -0
  2. package/dist/providers/firebase/appcheck.d.ts +15 -0
  3. package/dist/providers/firebase/appcheck.js +109 -0
  4. package/dist/providers/firebase/appcheck.test.d.ts +1 -0
  5. package/dist/providers/firebase/appcheck.test.js +141 -0
  6. package/dist/providers/firebase/index.d.ts +2 -0
  7. package/dist/providers/firebase/index.js +2 -0
  8. package/dist/providers/gcp/api.d.ts +10 -0
  9. package/dist/providers/gcp/api.js +111 -0
  10. package/dist/providers/gcp/clouddns.d.ts +37 -0
  11. package/dist/providers/gcp/clouddns.js +284 -0
  12. package/dist/providers/gcp/clouddns.test.d.ts +1 -0
  13. package/dist/providers/gcp/clouddns.test.js +259 -0
  14. package/dist/providers/gcp/cloudrun.d.ts +31 -0
  15. package/dist/providers/gcp/cloudrun.js +240 -0
  16. package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
  17. package/dist/providers/gcp/cloudrun.test.js +281 -0
  18. package/dist/providers/gcp/cloudsql.d.ts +37 -0
  19. package/dist/providers/gcp/cloudsql.js +262 -0
  20. package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
  21. package/dist/providers/gcp/cloudsql.test.js +295 -0
  22. package/dist/providers/gcp/iam.d.ts +38 -0
  23. package/dist/providers/gcp/iam.js +309 -0
  24. package/dist/providers/gcp/iam.test.d.ts +1 -0
  25. package/dist/providers/gcp/iam.test.js +305 -0
  26. package/dist/providers/gcp/index.d.ts +19 -0
  27. package/dist/providers/gcp/index.js +19 -0
  28. package/dist/providers/gcp/pubsub.d.ts +31 -0
  29. package/dist/providers/gcp/pubsub.js +227 -0
  30. package/dist/providers/gcp/pubsub.test.d.ts +1 -0
  31. package/dist/providers/gcp/pubsub.test.js +244 -0
  32. package/dist/providers/gcp/secrets.d.ts +21 -0
  33. package/dist/providers/gcp/secrets.js +187 -0
  34. package/dist/providers/gcp/secrets.test.d.ts +1 -0
  35. package/dist/providers/gcp/secrets.test.js +264 -0
  36. package/package.json +5 -1
@@ -0,0 +1,309 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { Output } from "../../core/output.js";
3
+ import { gcpFetch, getProjectId } from "./api.js";
4
+ const IAM_BASE = "https://iam.googleapis.com";
5
+ const CRM_BASE = "https://cloudresourcemanager.googleapis.com";
6
+ export class GCPServiceAccountBuilder extends BaseBuilder {
7
+ out = {
8
+ email: new Output(),
9
+ name: new Output(),
10
+ };
11
+ _displayName;
12
+ _description;
13
+ constructor(accountId) {
14
+ super(accountId);
15
+ this.discoveryPromise = this.discoverServiceAccount();
16
+ }
17
+ get email() {
18
+ const project = getProjectId();
19
+ return `${this.name}@${project}.iam.gserviceaccount.com`;
20
+ }
21
+ displayName(name) {
22
+ this._displayName = name;
23
+ return this;
24
+ }
25
+ description(desc) {
26
+ this._description = desc;
27
+ return this;
28
+ }
29
+ async discoverServiceAccount() {
30
+ try {
31
+ const project = getProjectId();
32
+ const sa = await gcpFetch(IAM_BASE, `/v1/projects/${project}/serviceAccounts/${this.email}`);
33
+ if (sa) {
34
+ this.out.email.resolve(this.email);
35
+ this.out.name.resolve(sa.name ?? `projects/${project}/serviceAccounts/${this.email}`);
36
+ }
37
+ return sa;
38
+ }
39
+ catch (e) {
40
+ if (e.message?.includes("404") ||
41
+ e.message?.includes("403") ||
42
+ e.message?.includes("credentials not configured")) {
43
+ return null;
44
+ }
45
+ throw e;
46
+ }
47
+ }
48
+ async deploy() {
49
+ const dryRun = this.isDryRunActive();
50
+ const project = getProjectId();
51
+ const existing = await this.discoveryPromise;
52
+ console.log(`\nšŸ‘¤ Finalizing GCP Service Account "${this.name}"...`);
53
+ if (dryRun) {
54
+ if (existing) {
55
+ console.log(` āœ… Service account "${this.name}" exists (email: ${this.email})`);
56
+ if (this._displayName || this._description) {
57
+ console.log(` šŸ“ [PLAN] Update service account metadata`);
58
+ }
59
+ }
60
+ else {
61
+ console.log(` šŸ“ [PLAN] Create service account "${this.name}"`);
62
+ }
63
+ this.out.email.resolve(this.email);
64
+ this.out.name.resolve(`projects/${project}/serviceAccounts/${this.email}`);
65
+ return { email: this.email, name: `projects/${project}/serviceAccounts/${this.email}` };
66
+ }
67
+ if (!existing) {
68
+ console.log(`šŸš€ Creating GCP Service Account "${this.name}"...`);
69
+ const sa = await gcpFetch(IAM_BASE, `/v1/projects/${project}/serviceAccounts`, {
70
+ method: "POST",
71
+ body: JSON.stringify({
72
+ accountId: this.name,
73
+ serviceAccount: {
74
+ displayName: this._displayName,
75
+ description: this._description,
76
+ },
77
+ }),
78
+ });
79
+ this.out.email.resolve(this.email);
80
+ this.out.name.resolve(sa.name ?? `projects/${project}/serviceAccounts/${this.email}`);
81
+ console.log(` āœ… Service account created: ${this.email}`);
82
+ }
83
+ else {
84
+ console.log(` āœ… Service account "${this.name}" exists`);
85
+ const needsUpdate = (this._displayName && existing.displayName !== this._displayName) ||
86
+ (this._description && existing.description !== this._description);
87
+ if (needsUpdate) {
88
+ console.log(`šŸ”„ Updating GCP Service Account "${this.name}" metadata...`);
89
+ await gcpFetch(IAM_BASE, `/v1/projects/${project}/serviceAccounts/${this.email}`, {
90
+ method: "PATCH",
91
+ body: JSON.stringify({
92
+ displayName: this._displayName,
93
+ description: this._description,
94
+ }),
95
+ });
96
+ console.log(` āœ… Metadata updated.`);
97
+ }
98
+ }
99
+ await this.deploySidecars();
100
+ return {
101
+ email: this.email,
102
+ name: `projects/${project}/serviceAccounts/${this.email}`,
103
+ };
104
+ }
105
+ async destroy() {
106
+ const dryRun = this.isDryRunActive();
107
+ const project = getProjectId();
108
+ console.log(`\nšŸ—‘ļø Destroying GCP Service Account "${this.name}"...`);
109
+ const existing = await this.discoverServiceAccount();
110
+ if (!existing) {
111
+ console.log(` āœ… Service account "${this.name}" does not exist - nothing to do.`);
112
+ return { destroyed: this.name };
113
+ }
114
+ if (dryRun) {
115
+ console.log(` šŸ“ [PLAN] Delete service account "${this.name}" (${this.email})`);
116
+ return { destroyed: this.name };
117
+ }
118
+ await gcpFetch(IAM_BASE, `/v1/projects/${project}/serviceAccounts/${this.email}`, {
119
+ method: "DELETE",
120
+ });
121
+ console.log(` āœ… Service account "${this.name}" deleted.`);
122
+ await this.destroySidecars();
123
+ return { destroyed: this.name };
124
+ }
125
+ }
126
+ export class GCPIAMBindingBuilder extends BaseBuilder {
127
+ _role;
128
+ _members = [];
129
+ constructor(name) {
130
+ super(name);
131
+ // Bindings are project-wide and dynamic, so discovery of project policy happens during deploy
132
+ this.discoveryPromise = Promise.resolve(null);
133
+ }
134
+ role(name) {
135
+ this._role = name;
136
+ return this;
137
+ }
138
+ member(m) {
139
+ this._members.push(m);
140
+ return this;
141
+ }
142
+ members(...m) {
143
+ this._members.push(...m);
144
+ return this;
145
+ }
146
+ async resolveMembers() {
147
+ const resolved = [];
148
+ for (const m of this._members) {
149
+ if (m instanceof GCPServiceAccountBuilder) {
150
+ resolved.push(`serviceAccount:${m.email}`);
151
+ }
152
+ else if (m instanceof Output) {
153
+ const val = await m.get();
154
+ resolved.push(val.includes(":") ? val : `serviceAccount:${val}`);
155
+ }
156
+ else {
157
+ const val = String(m);
158
+ if (val.includes(":")) {
159
+ resolved.push(val);
160
+ }
161
+ else if (val.includes("@")) {
162
+ if (val.endsWith(".gserviceaccount.com")) {
163
+ resolved.push(`serviceAccount:${val}`);
164
+ }
165
+ else {
166
+ resolved.push(`user:${val}`);
167
+ }
168
+ }
169
+ else {
170
+ const project = getProjectId();
171
+ resolved.push(`serviceAccount:${val}@${project}.iam.gserviceaccount.com`);
172
+ }
173
+ }
174
+ }
175
+ return resolved;
176
+ }
177
+ async deploy() {
178
+ const dryRun = this.isDryRunActive();
179
+ const project = getProjectId();
180
+ console.log(`\nšŸ” Finalizing GCP IAM Binding for Role "${this._role}"...`);
181
+ if (!this._role) {
182
+ throw new Error(`[GCP.IAMBinding:${this.name}] .role("...") is required`);
183
+ }
184
+ if (this._members.length === 0) {
185
+ throw new Error(`[GCP.IAMBinding:${this.name}] At least one member is required via .member()/.members()`);
186
+ }
187
+ const resolvedMembers = await this.resolveMembers();
188
+ // 1. Fetch current policy
189
+ let policy;
190
+ try {
191
+ policy = await gcpFetch(CRM_BASE, `/v1/projects/${project}:getIamPolicy`, {
192
+ method: "POST",
193
+ body: JSON.stringify({}),
194
+ });
195
+ }
196
+ catch (e) {
197
+ if (dryRun || e.message?.includes("credentials not configured")) {
198
+ policy = { bindings: [], etag: "DRYRUN_ETAG" };
199
+ }
200
+ else {
201
+ throw e;
202
+ }
203
+ }
204
+ const bindings = policy.bindings ?? [];
205
+ let binding = bindings.find((b) => b.role === this._role);
206
+ // 2. Compute members to add
207
+ const toAdd = [];
208
+ if (!binding) {
209
+ toAdd.push(...resolvedMembers);
210
+ }
211
+ else {
212
+ const existingMembers = binding.members ?? [];
213
+ for (const m of resolvedMembers) {
214
+ if (!existingMembers.includes(m)) {
215
+ toAdd.push(m);
216
+ }
217
+ }
218
+ }
219
+ if (toAdd.length === 0) {
220
+ console.log(` āœ… IAM binding for role "${this._role}" is up to date (members already bound)`);
221
+ return { role: this._role, bound: resolvedMembers };
222
+ }
223
+ if (dryRun) {
224
+ console.log(` šŸ“ [PLAN] Bind members [${toAdd.join(", ")}] to role "${this._role}"`);
225
+ return { role: this._role, bound: resolvedMembers };
226
+ }
227
+ // 3. Mutate policy safely
228
+ if (!binding) {
229
+ binding = { role: this._role, members: resolvedMembers };
230
+ bindings.push(binding);
231
+ }
232
+ else {
233
+ binding.members = [...(binding.members ?? []), ...toAdd];
234
+ }
235
+ policy.bindings = bindings;
236
+ // 4. Set updated policy with optimistic locking etag
237
+ await gcpFetch(CRM_BASE, `/v1/projects/${project}:setIamPolicy`, {
238
+ method: "POST",
239
+ body: JSON.stringify({
240
+ policy,
241
+ }),
242
+ });
243
+ console.log(`šŸš€ Successfully bound members [${toAdd.join(", ")}] to role "${this._role}"`);
244
+ await this.deploySidecars();
245
+ return {
246
+ role: this._role,
247
+ bound: resolvedMembers,
248
+ };
249
+ }
250
+ async destroy() {
251
+ const dryRun = this.isDryRunActive();
252
+ const project = getProjectId();
253
+ console.log(`\nšŸ—‘ļø Removing GCP IAM Bindings for Role "${this._role}"...`);
254
+ if (!this._role) {
255
+ return { destroyed: this.name };
256
+ }
257
+ const resolvedMembers = await this.resolveMembers();
258
+ // 1. Fetch current policy
259
+ let policy;
260
+ try {
261
+ policy = await gcpFetch(CRM_BASE, `/v1/projects/${project}:getIamPolicy`, {
262
+ method: "POST",
263
+ body: JSON.stringify({}),
264
+ });
265
+ }
266
+ catch (e) {
267
+ if (dryRun || e.message?.includes("credentials not configured")) {
268
+ return { destroyed: this.name };
269
+ }
270
+ throw e;
271
+ }
272
+ const bindings = policy.bindings ?? [];
273
+ const binding = bindings.find((b) => b.role === this._role);
274
+ if (!binding) {
275
+ console.log(` āœ… No bindings found for role "${this._role}" - nothing to do.`);
276
+ return { destroyed: this.name };
277
+ }
278
+ const existingMembers = binding.members ?? [];
279
+ const remaining = existingMembers.filter((m) => !resolvedMembers.includes(m));
280
+ const removed = existingMembers.filter((m) => resolvedMembers.includes(m));
281
+ if (removed.length === 0) {
282
+ console.log(` āœ… Bound members already removed - nothing to do.`);
283
+ return { destroyed: this.name };
284
+ }
285
+ if (dryRun) {
286
+ console.log(` šŸ“ [PLAN] Remove members [${removed.join(", ")}] from role "${this._role}"`);
287
+ return { destroyed: this.name };
288
+ }
289
+ // 2. Safe removal
290
+ if (remaining.length === 0) {
291
+ // If no members left in this role, remove the role binding block completely
292
+ policy.bindings = bindings.filter((b) => b.role !== this._role);
293
+ }
294
+ else {
295
+ binding.members = remaining;
296
+ policy.bindings = bindings;
297
+ }
298
+ // 3. Set updated policy with etag
299
+ await gcpFetch(CRM_BASE, `/v1/projects/${project}:setIamPolicy`, {
300
+ method: "POST",
301
+ body: JSON.stringify({
302
+ policy,
303
+ }),
304
+ });
305
+ console.log(` āœ… Removed members [${removed.join(", ")}] from role "${this._role}".`);
306
+ await this.destroySidecars();
307
+ return { destroyed: this.name };
308
+ }
309
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,305 @@
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 { GCPServiceAccountBuilder, GCPIAMBindingBuilder } from "./iam.js";
5
+ import { Config } from "../../core/config.js";
6
+ import { Output } from "../../core/output.js";
7
+ describe("GCP IAM Builders 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
+ describe("GCPServiceAccountBuilder Tests", () => {
77
+ test("generates email address correctly", () => {
78
+ const builder = new GCPServiceAccountBuilder("custom-sa");
79
+ assert.strictEqual(builder.email, "custom-sa@my-gcp-project.iam.gserviceaccount.com");
80
+ });
81
+ test("runs in dry-run mode safely without making API calls", 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 /serviceAccounts/custom-sa@my-gcp-project.iam.gserviceaccount.com"] = {
92
+ status: 404,
93
+ body: {},
94
+ };
95
+ const builder = new GCPServiceAccountBuilder("custom-sa")
96
+ .displayName("Custom Display Name")
97
+ .description("Custom Description");
98
+ const result = await builder.deploy();
99
+ assert.strictEqual(result.email, "custom-sa@my-gcp-project.iam.gserviceaccount.com");
100
+ const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
101
+ assert.strictEqual(writeCalls.length, 0);
102
+ });
103
+ test("creates service account when missing", async () => {
104
+ mockResponses["GET /serviceAccounts/custom-sa@my-gcp-project.iam.gserviceaccount.com"] = {
105
+ status: 404,
106
+ body: {},
107
+ };
108
+ mockResponses["POST /serviceAccounts"] = {
109
+ status: 200,
110
+ body: { name: "projects/my-gcp-project/serviceAccounts/custom-sa@my-gcp-project.iam.gserviceaccount.com" },
111
+ };
112
+ const builder = new GCPServiceAccountBuilder("custom-sa")
113
+ .displayName("My Custom Display")
114
+ .description("My Custom Description");
115
+ const result = await builder.deploy();
116
+ assert.strictEqual(result.email, "custom-sa@my-gcp-project.iam.gserviceaccount.com");
117
+ // Verify POST call
118
+ const createCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/serviceAccounts"));
119
+ assert.ok(createCall);
120
+ assert.strictEqual(createCall.body.accountId, "custom-sa");
121
+ assert.strictEqual(createCall.body.serviceAccount.displayName, "My Custom Display");
122
+ assert.strictEqual(createCall.body.serviceAccount.description, "My Custom Description");
123
+ });
124
+ test("patches existing service account if metadata differs", async () => {
125
+ mockResponses["GET /serviceAccounts/custom-sa@my-gcp-project.iam.gserviceaccount.com"] = {
126
+ status: 200,
127
+ body: {
128
+ displayName: "Old Display",
129
+ description: "Old Desc",
130
+ },
131
+ };
132
+ mockResponses["PATCH /serviceAccounts/custom-sa@my-gcp-project.iam.gserviceaccount.com"] = {
133
+ status: 200,
134
+ body: {},
135
+ };
136
+ const builder = new GCPServiceAccountBuilder("custom-sa")
137
+ .displayName("New Display")
138
+ .description("New Desc");
139
+ await builder.deploy();
140
+ const patchCall = fetchCalls.find((c) => c.method === "PATCH");
141
+ assert.ok(patchCall);
142
+ assert.strictEqual(patchCall.body.displayName, "New Display");
143
+ assert.strictEqual(patchCall.body.description, "New Desc");
144
+ });
145
+ test("skips patch if existing service account metadata is identical", async () => {
146
+ mockResponses["GET /serviceAccounts/custom-sa@my-gcp-project.iam.gserviceaccount.com"] = {
147
+ status: 200,
148
+ body: {
149
+ displayName: "Same Display",
150
+ description: "Same Desc",
151
+ },
152
+ };
153
+ const builder = new GCPServiceAccountBuilder("custom-sa")
154
+ .displayName("Same Display")
155
+ .description("Same Desc");
156
+ await builder.deploy();
157
+ const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
158
+ assert.strictEqual(writeCalls.length, 0);
159
+ });
160
+ test("destroys service account successfully", async () => {
161
+ mockResponses["GET /serviceAccounts/custom-sa@my-gcp-project.iam.gserviceaccount.com"] = {
162
+ status: 200,
163
+ body: {},
164
+ };
165
+ mockResponses["DELETE /serviceAccounts/custom-sa@my-gcp-project.iam.gserviceaccount.com"] = {
166
+ status: 200,
167
+ body: {},
168
+ };
169
+ const builder = new GCPServiceAccountBuilder("custom-sa");
170
+ const result = await builder.destroy();
171
+ assert.deepStrictEqual(result, { destroyed: "custom-sa" });
172
+ const deleteCall = fetchCalls.find((c) => c.method === "DELETE");
173
+ assert.ok(deleteCall);
174
+ });
175
+ });
176
+ describe("GCPIAMBindingBuilder Tests", () => {
177
+ test("resolves member strings, builders, outputs and custom types correctly", async () => {
178
+ const saBuilder = new GCPServiceAccountBuilder("builder-sa");
179
+ const plainString = "user:bob@gmail.com";
180
+ const plainSaEmail = "test-sa@my-gcp-project.iam.gserviceaccount.com";
181
+ const plainSaId = "other-sa"; // auto-resolves to serviceAccount:other-sa@proj...
182
+ const outputEmail = new Output();
183
+ outputEmail.resolve("output-sa"); // auto-resolves to serviceAccount:output-sa
184
+ const binding = new GCPIAMBindingBuilder("test-binding")
185
+ .role("roles/viewer")
186
+ .members(plainString, saBuilder, plainSaEmail, plainSaId, outputEmail);
187
+ const resolved = await binding.resolveMembers();
188
+ assert.deepStrictEqual(resolved, [
189
+ "user:bob@gmail.com",
190
+ "serviceAccount:builder-sa@my-gcp-project.iam.gserviceaccount.com",
191
+ "serviceAccount:test-sa@my-gcp-project.iam.gserviceaccount.com",
192
+ "serviceAccount:other-sa@my-gcp-project.iam.gserviceaccount.com",
193
+ "serviceAccount:output-sa",
194
+ ]);
195
+ });
196
+ test("appends bound members non-destructively to an existing role binding", async () => {
197
+ // 1. Mock getIamPolicy returns existing policy
198
+ mockResponses["POST /projects/my-gcp-project:getIamPolicy"] = {
199
+ status: 200,
200
+ body: {
201
+ etag: "version1",
202
+ bindings: [
203
+ {
204
+ role: "roles/storage.objectViewer",
205
+ members: ["user:alice@example.com"],
206
+ },
207
+ ],
208
+ },
209
+ };
210
+ // 2. Mock setIamPolicy
211
+ mockResponses["POST /projects/my-gcp-project:setIamPolicy"] = {
212
+ status: 200,
213
+ body: {},
214
+ };
215
+ const binding = new GCPIAMBindingBuilder("viewer-binding")
216
+ .role("roles/storage.objectViewer")
217
+ .member("user:bob@example.com");
218
+ await binding.deploy();
219
+ const setCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes(":setIamPolicy"));
220
+ assert.ok(setCall);
221
+ assert.strictEqual(setCall.body.policy.etag, "version1"); // Etag lock!
222
+ const targetBinding = setCall.body.policy.bindings.find((b) => b.role === "roles/storage.objectViewer");
223
+ assert.ok(targetBinding);
224
+ // Non-destructive: both Alice and Bob are bound!
225
+ assert.deepStrictEqual(targetBinding.members, ["user:alice@example.com", "user:bob@example.com"]);
226
+ });
227
+ test("skips deploy if all members are already bound", async () => {
228
+ mockResponses["POST /projects/my-gcp-project:getIamPolicy"] = {
229
+ status: 200,
230
+ body: {
231
+ etag: "version1",
232
+ bindings: [
233
+ {
234
+ role: "roles/storage.objectViewer",
235
+ members: ["user:alice@example.com", "user:bob@example.com"],
236
+ },
237
+ ],
238
+ },
239
+ };
240
+ const binding = new GCPIAMBindingBuilder("viewer-binding")
241
+ .role("roles/storage.objectViewer")
242
+ .members("user:alice@example.com", "user:bob@example.com");
243
+ await binding.deploy();
244
+ // No setIamPolicy should be called
245
+ const setCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes(":setIamPolicy"));
246
+ assert.strictEqual(setCall, undefined);
247
+ });
248
+ test("destroys binding cleanly by removing only our declared members", async () => {
249
+ mockResponses["POST /projects/my-gcp-project:getIamPolicy"] = {
250
+ status: 200,
251
+ body: {
252
+ etag: "version2",
253
+ bindings: [
254
+ {
255
+ role: "roles/storage.admin",
256
+ members: ["user:admin@company.com", "serviceAccount:to-prune@my-gcp-project.iam.gserviceaccount.com"],
257
+ },
258
+ ],
259
+ },
260
+ };
261
+ mockResponses["POST /projects/my-gcp-project:setIamPolicy"] = {
262
+ status: 200,
263
+ body: {},
264
+ };
265
+ const binding = new GCPIAMBindingBuilder("admin-binding")
266
+ .role("roles/storage.admin")
267
+ .member("to-prune"); // maps to serviceAccount:to-prune@my-gcp-project...
268
+ const result = await binding.destroy();
269
+ assert.deepStrictEqual(result, { destroyed: "admin-binding" });
270
+ const setCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes(":setIamPolicy"));
271
+ assert.ok(setCall);
272
+ const targetBinding = setCall.body.policy.bindings.find((b) => b.role === "roles/storage.admin");
273
+ assert.ok(targetBinding);
274
+ // Pruned our member, left the other admin!
275
+ assert.deepStrictEqual(targetBinding.members, ["user:admin@company.com"]);
276
+ });
277
+ test("removes the entire role binding block if no members remain", async () => {
278
+ mockResponses["POST /projects/my-gcp-project:getIamPolicy"] = {
279
+ status: 200,
280
+ body: {
281
+ etag: "version3",
282
+ bindings: [
283
+ {
284
+ role: "roles/pubsub.publisher",
285
+ members: ["serviceAccount:to-prune@my-gcp-project.iam.gserviceaccount.com"],
286
+ },
287
+ ],
288
+ },
289
+ };
290
+ mockResponses["POST /projects/my-gcp-project:setIamPolicy"] = {
291
+ status: 200,
292
+ body: {},
293
+ };
294
+ const binding = new GCPIAMBindingBuilder("pub-binding")
295
+ .role("roles/pubsub.publisher")
296
+ .member("to-prune");
297
+ await binding.destroy();
298
+ const setCall = fetchCalls.find((c) => c.method === "POST" && c.url.includes(":setIamPolicy"));
299
+ assert.ok(setCall);
300
+ // Entire block is deleted since no members remain
301
+ const targetBinding = setCall.body.policy.bindings.find((b) => b.role === "roles/pubsub.publisher");
302
+ assert.strictEqual(targetBinding, undefined);
303
+ });
304
+ });
305
+ });
@@ -0,0 +1,19 @@
1
+ import { GCPCloudRunBuilder } from './cloudrun.js';
2
+ import { GCPCloudSQLBuilder } from './cloudsql.js';
3
+ import { GCPSecretBuilder, resolveGCPEnvVars } from './secrets.js';
4
+ import { GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder } from './pubsub.js';
5
+ import { GCPCloudDNSZoneBuilder } from './clouddns.js';
6
+ import { GCPServiceAccountBuilder, GCPIAMBindingBuilder } from './iam.js';
7
+ export { GCPSecretBuilder, resolveGCPEnvVars, GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder, GCPCloudDNSZoneBuilder, GCPServiceAccountBuilder, GCPIAMBindingBuilder };
8
+ export declare const GCP: {
9
+ CloudRun: (serviceId: string) => GCPCloudRunBuilder;
10
+ CloudSQL: (instanceId: string) => GCPCloudSQLBuilder;
11
+ Secret: (secretId: string) => GCPSecretBuilder;
12
+ CloudDNS: (zoneName: string) => GCPCloudDNSZoneBuilder;
13
+ ServiceAccount: (saId: string) => GCPServiceAccountBuilder;
14
+ IAMBinding: (name: string) => GCPIAMBindingBuilder;
15
+ PubSub: {
16
+ Topic: (topicId: string) => GCPPubSubTopicBuilder;
17
+ Subscription: (subscriptionId: string) => GCPPubSubSubscriptionBuilder;
18
+ };
19
+ };
@@ -0,0 +1,19 @@
1
+ import { GCPCloudRunBuilder } from './cloudrun.js';
2
+ import { GCPCloudSQLBuilder } from './cloudsql.js';
3
+ import { GCPSecretBuilder, resolveGCPEnvVars } from './secrets.js';
4
+ import { GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder } from './pubsub.js';
5
+ import { GCPCloudDNSZoneBuilder } from './clouddns.js';
6
+ import { GCPServiceAccountBuilder, GCPIAMBindingBuilder } from './iam.js';
7
+ export { GCPSecretBuilder, resolveGCPEnvVars, GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder, GCPCloudDNSZoneBuilder, GCPServiceAccountBuilder, GCPIAMBindingBuilder };
8
+ export const GCP = {
9
+ CloudRun: (serviceId) => new GCPCloudRunBuilder(serviceId),
10
+ CloudSQL: (instanceId) => new GCPCloudSQLBuilder(instanceId),
11
+ Secret: (secretId) => new GCPSecretBuilder(secretId),
12
+ CloudDNS: (zoneName) => new GCPCloudDNSZoneBuilder(zoneName),
13
+ ServiceAccount: (saId) => new GCPServiceAccountBuilder(saId),
14
+ IAMBinding: (name) => new GCPIAMBindingBuilder(name),
15
+ PubSub: {
16
+ Topic: (topicId) => new GCPPubSubTopicBuilder(topicId),
17
+ Subscription: (subscriptionId) => new GCPPubSubSubscriptionBuilder(subscriptionId),
18
+ },
19
+ };