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,295 @@
|
|
|
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 { GCPCloudSQLBuilder } from "./cloudsql.js";
|
|
5
|
+
import { Config } from "../../core/config.js";
|
|
6
|
+
describe("GCPCloudSQLBuilder Unit Tests", () => {
|
|
7
|
+
let originalFetch;
|
|
8
|
+
let fetchCalls = [];
|
|
9
|
+
let mockResponses = {};
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
Config.set({
|
|
12
|
+
dryRun: false,
|
|
13
|
+
providers: {
|
|
14
|
+
gcp: {
|
|
15
|
+
projectId: "my-gcp-project",
|
|
16
|
+
serviceAccountPath: "/fake/sa.json",
|
|
17
|
+
region: "us-central1",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
originalFetch = globalThis.fetch;
|
|
22
|
+
fetchCalls = [];
|
|
23
|
+
mockResponses = {};
|
|
24
|
+
globalThis.fetch = async (input, init) => {
|
|
25
|
+
const url = String(input);
|
|
26
|
+
const method = init?.method ?? "GET";
|
|
27
|
+
let body;
|
|
28
|
+
if (init?.body) {
|
|
29
|
+
if (typeof init.body === "string") {
|
|
30
|
+
try {
|
|
31
|
+
body = JSON.parse(init.body);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
body = init.body;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
body = "[Binary/Buffer Body]";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const headers = init?.headers;
|
|
42
|
+
fetchCalls.push({ url, method, body, headers });
|
|
43
|
+
const matchKey = Object.keys(mockResponses)
|
|
44
|
+
.filter((key) => {
|
|
45
|
+
const [mMethod, mPath] = key.split(" ");
|
|
46
|
+
return method === mMethod && url.includes(mPath);
|
|
47
|
+
})
|
|
48
|
+
.sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
|
|
49
|
+
if (matchKey) {
|
|
50
|
+
const resp = mockResponses[matchKey];
|
|
51
|
+
return {
|
|
52
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
53
|
+
status: resp.status,
|
|
54
|
+
json: async () => resp.body,
|
|
55
|
+
text: async () => JSON.stringify(resp.body),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
status: 404,
|
|
61
|
+
json: async () => ({ message: `Endpoint not mocked: ${method} ${url}` }),
|
|
62
|
+
text: async () => `Endpoint not mocked: ${method} ${url}`,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
mock.method(GoogleAuth.prototype, "getClient", async () => {
|
|
66
|
+
return {
|
|
67
|
+
getAccessToken: async () => ({ token: "fake-gcp-token" }),
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
globalThis.fetch = originalFetch;
|
|
73
|
+
mock.restoreAll();
|
|
74
|
+
});
|
|
75
|
+
test("fluent builder api sets properties correctly", () => {
|
|
76
|
+
const builder = new GCPCloudSQLBuilder("my-db")
|
|
77
|
+
.engine({ engine: "postgres", version: "15" })
|
|
78
|
+
.size("db-custom-2-7680")
|
|
79
|
+
.storage(50)
|
|
80
|
+
.credentials("db_user", "db_pass")
|
|
81
|
+
.database("my_app_db")
|
|
82
|
+
.publicAccess(true)
|
|
83
|
+
.region("us-east4");
|
|
84
|
+
assert.strictEqual(builder._engine, "postgres");
|
|
85
|
+
assert.strictEqual(builder._engineVersion, "15");
|
|
86
|
+
assert.strictEqual(builder._tier, "db-custom-2-7680");
|
|
87
|
+
assert.strictEqual(builder._storage, 50);
|
|
88
|
+
assert.strictEqual(builder._username, "db_user");
|
|
89
|
+
assert.strictEqual(builder._password, "db_pass");
|
|
90
|
+
assert.strictEqual(builder._dbName, "my_app_db");
|
|
91
|
+
assert.strictEqual(builder._publicAccess, true);
|
|
92
|
+
assert.strictEqual(builder._region, "us-east4");
|
|
93
|
+
});
|
|
94
|
+
test("runs in dry-run mode safely and logs plans", async () => {
|
|
95
|
+
Config.set({
|
|
96
|
+
dryRun: true,
|
|
97
|
+
providers: {
|
|
98
|
+
gcp: {
|
|
99
|
+
projectId: "my-gcp-project",
|
|
100
|
+
serviceAccountPath: "/fake/sa.json",
|
|
101
|
+
region: "us-central1",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const builder = new GCPCloudSQLBuilder("dry-run-db")
|
|
106
|
+
.credentials("root", "secretpwd")
|
|
107
|
+
.engine({ engine: "mysql", version: "8.0" })
|
|
108
|
+
.size("db-f1-micro")
|
|
109
|
+
.storage(15);
|
|
110
|
+
const result = await builder.deploy();
|
|
111
|
+
assert.strictEqual(result.name, "dry-run-db");
|
|
112
|
+
assert.strictEqual(result.endpoint, "127.0.0.1");
|
|
113
|
+
assert.strictEqual(result.port, 3306);
|
|
114
|
+
assert.strictEqual(result.connectionName, "my-gcp-project:us-central1:dry-run-db");
|
|
115
|
+
// No write calls should be sent in dry-run mode
|
|
116
|
+
const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
|
|
117
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
118
|
+
});
|
|
119
|
+
test("creates a new instance, database, and custom user when missing", async () => {
|
|
120
|
+
// 1. Stateful mock for GET /instances/new-db: returns 404 on discovery, 200 after creation
|
|
121
|
+
mockResponses["GET /instances/new-db"] = {
|
|
122
|
+
get status() {
|
|
123
|
+
const getCalls = fetchCalls.filter((c) => c.method === "GET" && c.url.includes("/instances/new-db"));
|
|
124
|
+
return getCalls.length <= 1 ? 404 : 200;
|
|
125
|
+
},
|
|
126
|
+
get body() {
|
|
127
|
+
const getCalls = fetchCalls.filter((c) => c.method === "GET" && c.url.includes("/instances/new-db"));
|
|
128
|
+
if (getCalls.length <= 1)
|
|
129
|
+
return { message: "Not found" };
|
|
130
|
+
return {
|
|
131
|
+
name: "new-db",
|
|
132
|
+
connectionName: "my-gcp-project:us-central1:new-db",
|
|
133
|
+
ipAddresses: [{ type: "PRIMARY", ipAddress: "35.200.10.20" }],
|
|
134
|
+
settings: {
|
|
135
|
+
tier: "db-f1-micro",
|
|
136
|
+
dataDiskSizeGb: "20",
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
// 2. Mock POST (create instance)
|
|
142
|
+
mockResponses["POST /instances"] = {
|
|
143
|
+
status: 200,
|
|
144
|
+
body: { name: "op-create-123", status: "PENDING" },
|
|
145
|
+
};
|
|
146
|
+
// 3. Mock GET (poll operation status until DONE)
|
|
147
|
+
mockResponses["GET /operations/op-create-123"] = {
|
|
148
|
+
status: 200,
|
|
149
|
+
body: { status: "DONE" },
|
|
150
|
+
};
|
|
151
|
+
// 5. Mock POST (create database)
|
|
152
|
+
mockResponses["POST /instances/new-db/databases"] = {
|
|
153
|
+
status: 201,
|
|
154
|
+
body: {},
|
|
155
|
+
};
|
|
156
|
+
// 6. Mock POST (create custom user)
|
|
157
|
+
mockResponses["POST /instances/new-db/users"] = {
|
|
158
|
+
status: 201,
|
|
159
|
+
body: {},
|
|
160
|
+
};
|
|
161
|
+
const builder = new GCPCloudSQLBuilder("new-db")
|
|
162
|
+
.engine({ engine: "postgres", version: "16" })
|
|
163
|
+
.size("db-f1-micro")
|
|
164
|
+
.storage(20)
|
|
165
|
+
.credentials("custom_admin", "securepwd")
|
|
166
|
+
.database("custom_db")
|
|
167
|
+
.publicAccess(false);
|
|
168
|
+
const result = await builder.deploy();
|
|
169
|
+
assert.strictEqual(result.name, "new-db");
|
|
170
|
+
assert.strictEqual(result.endpoint, "35.200.10.20");
|
|
171
|
+
assert.strictEqual(result.port, 5432);
|
|
172
|
+
assert.strictEqual(result.connectionName, "my-gcp-project:us-central1:new-db");
|
|
173
|
+
// Verify correct calls were made
|
|
174
|
+
const postInstances = fetchCalls.find((c) => c.method === "POST" && c.url.endsWith("/instances"));
|
|
175
|
+
assert.ok(postInstances);
|
|
176
|
+
assert.strictEqual(postInstances.body.name, "new-db");
|
|
177
|
+
assert.strictEqual(postInstances.body.databaseVersion, "POSTGRES_16");
|
|
178
|
+
assert.strictEqual(postInstances.body.rootPassword, "securepwd");
|
|
179
|
+
assert.strictEqual(postInstances.body.settings.dataDiskSizeGb, "20");
|
|
180
|
+
const postDB = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/databases"));
|
|
181
|
+
assert.ok(postDB);
|
|
182
|
+
assert.strictEqual(postDB.body.name, "custom_db");
|
|
183
|
+
const postUser = fetchCalls.find((c) => c.method === "POST" && c.url.includes("/users"));
|
|
184
|
+
assert.ok(postUser);
|
|
185
|
+
assert.strictEqual(postUser.body.name, "custom_admin");
|
|
186
|
+
assert.strictEqual(postUser.body.password, "securepwd");
|
|
187
|
+
});
|
|
188
|
+
test("patches instance when configuration storage or tier differs", async () => {
|
|
189
|
+
// 1. Mock GET (discovery) returns existing instance with 10GB and different tier
|
|
190
|
+
mockResponses["GET /instances/patch-db"] = {
|
|
191
|
+
status: 200,
|
|
192
|
+
body: {
|
|
193
|
+
name: "patch-db",
|
|
194
|
+
connectionName: "my-gcp-project:us-central1:patch-db",
|
|
195
|
+
ipAddresses: [{ type: "PRIMARY", ipAddress: "35.200.10.20" }],
|
|
196
|
+
settings: {
|
|
197
|
+
tier: "db-f1-micro",
|
|
198
|
+
dataDiskSizeGb: "10",
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
// 2. Mock PATCH (update settings)
|
|
203
|
+
mockResponses["PATCH /instances/patch-db"] = {
|
|
204
|
+
status: 200,
|
|
205
|
+
body: { name: "op-patch-123", status: "PENDING" },
|
|
206
|
+
};
|
|
207
|
+
// 3. Mock GET (poll operation status until DONE)
|
|
208
|
+
mockResponses["GET /operations/op-patch-123"] = {
|
|
209
|
+
status: 200,
|
|
210
|
+
body: { status: "DONE" },
|
|
211
|
+
};
|
|
212
|
+
const builder = new GCPCloudSQLBuilder("patch-db")
|
|
213
|
+
.engine({ engine: "postgres", version: "16" })
|
|
214
|
+
.size("db-custom-2-7680") // changed tier!
|
|
215
|
+
.storage(30) // increased storage!
|
|
216
|
+
.credentials("postgres", "securepwd");
|
|
217
|
+
const result = await builder.deploy();
|
|
218
|
+
assert.strictEqual(result.name, "patch-db");
|
|
219
|
+
// Verify PATCH was called
|
|
220
|
+
const patchCalls = fetchCalls.filter((c) => c.method === "PATCH");
|
|
221
|
+
assert.strictEqual(patchCalls.length, 1);
|
|
222
|
+
assert.strictEqual(patchCalls[0].body.settings.tier, "db-custom-2-7680");
|
|
223
|
+
assert.strictEqual(patchCalls[0].body.settings.dataDiskSizeGb, "30");
|
|
224
|
+
});
|
|
225
|
+
test("skips patch if instance configuration is identical", async () => {
|
|
226
|
+
// 1. Mock GET (discovery) returns identical settings
|
|
227
|
+
mockResponses["GET /instances/ident-db"] = {
|
|
228
|
+
status: 200,
|
|
229
|
+
body: {
|
|
230
|
+
name: "ident-db",
|
|
231
|
+
connectionName: "my-gcp-project:us-central1:ident-db",
|
|
232
|
+
ipAddresses: [{ type: "PRIMARY", ipAddress: "35.200.10.20" }],
|
|
233
|
+
settings: {
|
|
234
|
+
tier: "db-f1-micro",
|
|
235
|
+
dataDiskSizeGb: "10",
|
|
236
|
+
ipConfiguration: {
|
|
237
|
+
authorizedNetworks: [],
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
const builder = new GCPCloudSQLBuilder("ident-db")
|
|
243
|
+
.engine({ engine: "postgres", version: "16" })
|
|
244
|
+
.size("db-f1-micro")
|
|
245
|
+
.storage(10)
|
|
246
|
+
.credentials("postgres", "securepwd")
|
|
247
|
+
.publicAccess(false);
|
|
248
|
+
const result = await builder.deploy();
|
|
249
|
+
assert.strictEqual(result.name, "ident-db");
|
|
250
|
+
// Assert NO write calls for instances (POST or PATCH) occurred
|
|
251
|
+
const writeCalls = fetchCalls.filter((c) => c.method === "PATCH" ||
|
|
252
|
+
(c.method === "POST" && c.url.endsWith("/instances")));
|
|
253
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
254
|
+
});
|
|
255
|
+
test("destroys an existing instance successfully", async () => {
|
|
256
|
+
// 1. Mock GET (discovery on destroy) returns existing instance
|
|
257
|
+
mockResponses["GET /instances/to-delete-db"] = {
|
|
258
|
+
status: 200,
|
|
259
|
+
body: {
|
|
260
|
+
name: "to-delete-db",
|
|
261
|
+
connectionName: "my-gcp-project:us-central1:to-delete-db",
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
// 2. Mock DELETE
|
|
265
|
+
mockResponses["DELETE /instances/to-delete-db"] = {
|
|
266
|
+
status: 200,
|
|
267
|
+
body: { name: "op-delete-123", status: "PENDING" },
|
|
268
|
+
};
|
|
269
|
+
// 3. Mock GET (poll operation status until DONE)
|
|
270
|
+
mockResponses["GET /operations/op-delete-123"] = {
|
|
271
|
+
status: 200,
|
|
272
|
+
body: { status: "DONE" },
|
|
273
|
+
};
|
|
274
|
+
const builder = new GCPCloudSQLBuilder("to-delete-db");
|
|
275
|
+
const result = await builder.destroy();
|
|
276
|
+
assert.deepStrictEqual(result, { destroyed: "to-delete-db" });
|
|
277
|
+
// Verify DELETE was called
|
|
278
|
+
const deleteCalls = fetchCalls.filter((c) => c.method === "DELETE");
|
|
279
|
+
assert.strictEqual(deleteCalls.length, 1);
|
|
280
|
+
assert.strictEqual(deleteCalls[0].url.includes("/instances/to-delete-db"), true);
|
|
281
|
+
});
|
|
282
|
+
test("destroy does nothing when instance does not exist", async () => {
|
|
283
|
+
// Mock GET returned 404
|
|
284
|
+
mockResponses["GET /instances/not-exist-db"] = {
|
|
285
|
+
status: 404,
|
|
286
|
+
body: { message: "Not found" },
|
|
287
|
+
};
|
|
288
|
+
const builder = new GCPCloudSQLBuilder("not-exist-db");
|
|
289
|
+
const result = await builder.destroy();
|
|
290
|
+
assert.deepStrictEqual(result, { destroyed: "not-exist-db" });
|
|
291
|
+
// Verify no DELETE was called
|
|
292
|
+
const deleteCalls = fetchCalls.filter((c) => c.method === "DELETE");
|
|
293
|
+
assert.strictEqual(deleteCalls.length, 0);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
export declare class GCPServiceAccountBuilder extends BaseBuilder {
|
|
4
|
+
readonly out: {
|
|
5
|
+
email: Output<string>;
|
|
6
|
+
name: Output<string>;
|
|
7
|
+
};
|
|
8
|
+
private _displayName?;
|
|
9
|
+
private _description?;
|
|
10
|
+
constructor(accountId: string);
|
|
11
|
+
get email(): string;
|
|
12
|
+
displayName(name: string): this;
|
|
13
|
+
description(desc: string): this;
|
|
14
|
+
private discoverServiceAccount;
|
|
15
|
+
deploy(): Promise<{
|
|
16
|
+
email: string;
|
|
17
|
+
name: string;
|
|
18
|
+
}>;
|
|
19
|
+
destroy(): Promise<{
|
|
20
|
+
destroyed: string;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
export declare class GCPIAMBindingBuilder extends BaseBuilder {
|
|
24
|
+
private _role;
|
|
25
|
+
private _members;
|
|
26
|
+
constructor(name: string);
|
|
27
|
+
role(name: string): this;
|
|
28
|
+
member(m: string | GCPServiceAccountBuilder | Output<string>): this;
|
|
29
|
+
members(...m: (string | GCPServiceAccountBuilder | Output<string>)[]): this;
|
|
30
|
+
private resolveMembers;
|
|
31
|
+
deploy(): Promise<{
|
|
32
|
+
role: string;
|
|
33
|
+
bound: string[];
|
|
34
|
+
}>;
|
|
35
|
+
destroy(): Promise<{
|
|
36
|
+
destroyed: string;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
@@ -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 {};
|