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,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
+ };
@@ -0,0 +1,31 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ export declare class GCPPubSubTopicBuilder extends BaseBuilder {
3
+ resolvedTopicName: string | null;
4
+ constructor(topicId: string);
5
+ private discoverTopic;
6
+ deploy(): Promise<{
7
+ name: string;
8
+ topicName: string | null;
9
+ }>;
10
+ destroy(): Promise<{
11
+ destroyed: string;
12
+ }>;
13
+ }
14
+ export declare class GCPPubSubSubscriptionBuilder extends BaseBuilder {
15
+ private _topic?;
16
+ private _pushEndpoint?;
17
+ private _ackDeadlineSeconds;
18
+ resolvedSubscriptionName: string | null;
19
+ constructor(subscriptionId: string);
20
+ topic(t: string | GCPPubSubTopicBuilder): this;
21
+ pushEndpoint(url: string): this;
22
+ ackDeadline(seconds: number): this;
23
+ private discoverSubscription;
24
+ deploy(): Promise<{
25
+ name: string;
26
+ subscriptionName: string | null;
27
+ }>;
28
+ destroy(): Promise<{
29
+ destroyed: string;
30
+ }>;
31
+ }
@@ -0,0 +1,227 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { gcpFetch, getProjectId } from "./api.js";
3
+ const PUBSUB_BASE = "https://pubsub.googleapis.com";
4
+ export class GCPPubSubTopicBuilder extends BaseBuilder {
5
+ resolvedTopicName = null;
6
+ constructor(topicId) {
7
+ super(topicId);
8
+ this.discoveryPromise = this.discoverTopic();
9
+ }
10
+ async discoverTopic() {
11
+ try {
12
+ const project = getProjectId();
13
+ const res = await gcpFetch(PUBSUB_BASE, `/v1/projects/${project}/topics/${this.name}`);
14
+ this.resolvedTopicName = res.name ?? null;
15
+ return res;
16
+ }
17
+ catch (e) {
18
+ if (e.message?.includes("404") ||
19
+ e.message?.includes("403") ||
20
+ e.message?.includes("credentials not configured")) {
21
+ return null;
22
+ }
23
+ throw e;
24
+ }
25
+ }
26
+ async deploy() {
27
+ const dryRun = this.isDryRunActive();
28
+ const project = getProjectId();
29
+ const topicId = this.name;
30
+ const existing = await this.discoveryPromise;
31
+ console.log(`\nšŸ“¢ Finalizing GCP Pub/Sub Topic "${topicId}"...`);
32
+ if (dryRun) {
33
+ console.log(` šŸ“ [PLAN] ${existing ? "Update" : "Create"} Pub/Sub topic "${topicId}"`);
34
+ this.resolvedTopicName = `projects/${project}/topics/${topicId}`;
35
+ return {
36
+ name: topicId,
37
+ topicName: this.resolvedTopicName,
38
+ };
39
+ }
40
+ if (!existing) {
41
+ console.log(`šŸš€ Creating GCP Pub/Sub topic "${topicId}"...`);
42
+ const topic = await gcpFetch(PUBSUB_BASE, `/v1/projects/${project}/topics/${topicId}`, {
43
+ method: "PUT",
44
+ body: JSON.stringify({}),
45
+ });
46
+ this.resolvedTopicName = topic.name ?? null;
47
+ console.log(`šŸš€ Created Pub/Sub topic "${topicId}"`);
48
+ }
49
+ else {
50
+ this.resolvedTopicName = existing.name ?? null;
51
+ console.log(` āœ… GCP Pub/Sub topic "${topicId}" already exists.`);
52
+ }
53
+ await this.deploySidecars();
54
+ return {
55
+ name: topicId,
56
+ topicName: this.resolvedTopicName,
57
+ };
58
+ }
59
+ async destroy() {
60
+ const dryRun = this.isDryRunActive();
61
+ const project = getProjectId();
62
+ const topicId = this.name;
63
+ console.log(`\nšŸ—‘ļø Destroying GCP Pub/Sub Topic "${topicId}"...`);
64
+ const existing = await this.discoverTopic();
65
+ if (!existing) {
66
+ console.log(` āœ… Topic "${topicId}" does not exist - nothing to do.`);
67
+ return { destroyed: topicId };
68
+ }
69
+ if (dryRun) {
70
+ console.log(` šŸ“ [PLAN] Delete Pub/Sub topic "${topicId}"`);
71
+ return { destroyed: topicId };
72
+ }
73
+ console.log(` šŸ”„ Deleting Pub/Sub topic "${topicId}"...`);
74
+ await gcpFetch(PUBSUB_BASE, `/v1/projects/${project}/topics/${topicId}`, {
75
+ method: "DELETE",
76
+ });
77
+ console.log(` āœ… Pub/Sub topic "${topicId}" deleted.`);
78
+ await this.destroySidecars();
79
+ return { destroyed: topicId };
80
+ }
81
+ }
82
+ export class GCPPubSubSubscriptionBuilder extends BaseBuilder {
83
+ _topic;
84
+ _pushEndpoint;
85
+ _ackDeadlineSeconds = 10;
86
+ resolvedSubscriptionName = null;
87
+ constructor(subscriptionId) {
88
+ super(subscriptionId);
89
+ this.discoveryPromise = this.discoverSubscription();
90
+ }
91
+ topic(t) {
92
+ this._topic = t;
93
+ return this;
94
+ }
95
+ pushEndpoint(url) {
96
+ this._pushEndpoint = url;
97
+ return this;
98
+ }
99
+ ackDeadline(seconds) {
100
+ this._ackDeadlineSeconds = seconds;
101
+ return this;
102
+ }
103
+ async discoverSubscription() {
104
+ try {
105
+ const project = getProjectId();
106
+ const res = await gcpFetch(PUBSUB_BASE, `/v1/projects/${project}/subscriptions/${this.name}`);
107
+ this.resolvedSubscriptionName = res.name ?? null;
108
+ return res;
109
+ }
110
+ catch (e) {
111
+ if (e.message?.includes("404") ||
112
+ e.message?.includes("403") ||
113
+ e.message?.includes("credentials not configured")) {
114
+ return null;
115
+ }
116
+ throw e;
117
+ }
118
+ }
119
+ async deploy() {
120
+ const dryRun = this.isDryRunActive();
121
+ const project = getProjectId();
122
+ const subscriptionId = this.name;
123
+ console.log(`\nšŸ“„ Finalizing GCP Pub/Sub Subscription "${subscriptionId}"...`);
124
+ if (!this._topic) {
125
+ throw new Error(`[GCP.PubSub.Subscription:${subscriptionId}] .topic(...) is required`);
126
+ }
127
+ const existing = await this.discoveryPromise;
128
+ // Resolve full topic name
129
+ let targetTopicName;
130
+ if (this._topic instanceof GCPPubSubTopicBuilder) {
131
+ targetTopicName = this._topic.resolvedTopicName ?? `projects/${project}/topics/${this._topic.name}`;
132
+ }
133
+ else {
134
+ targetTopicName = this._topic.includes("/")
135
+ ? this._topic
136
+ : `projects/${project}/topics/${this._topic}`;
137
+ }
138
+ const pushConfig = this._pushEndpoint
139
+ ? { pushEndpoint: this._pushEndpoint }
140
+ : {};
141
+ if (dryRun) {
142
+ console.log(` šŸ“ [PLAN] ${existing ? "Update" : "Create"} Pub/Sub subscription "${subscriptionId}"`);
143
+ console.log(` └─ Topic: ${targetTopicName}`);
144
+ if (this._pushEndpoint) {
145
+ console.log(` └─ Push Endpoint: ${this._pushEndpoint}`);
146
+ }
147
+ else {
148
+ console.log(` └─ Pull Subscription`);
149
+ }
150
+ this.resolvedSubscriptionName = `projects/${project}/subscriptions/${subscriptionId}`;
151
+ return {
152
+ name: subscriptionId,
153
+ subscriptionName: this.resolvedSubscriptionName,
154
+ };
155
+ }
156
+ // Determine if update is needed
157
+ let needsUpdate = !existing;
158
+ if (existing) {
159
+ const existingEndpoint = existing.pushConfig?.pushEndpoint ?? "";
160
+ const targetEndpoint = this._pushEndpoint ?? "";
161
+ const hasEndpointChange = existingEndpoint !== targetEndpoint;
162
+ const hasTopicChange = existing.topic !== targetTopicName;
163
+ const hasAckChange = (existing.ackDeadlineSeconds ?? 10) !== this._ackDeadlineSeconds;
164
+ needsUpdate = hasEndpointChange || hasTopicChange || hasAckChange;
165
+ }
166
+ const subscriptionBody = {
167
+ topic: targetTopicName,
168
+ pushConfig,
169
+ ackDeadlineSeconds: this._ackDeadlineSeconds,
170
+ };
171
+ if (!existing) {
172
+ console.log(`šŸš€ Creating GCP Pub/Sub subscription "${subscriptionId}"...`);
173
+ const sub = await gcpFetch(PUBSUB_BASE, `/v1/projects/${project}/subscriptions/${subscriptionId}`, {
174
+ method: "PUT",
175
+ body: JSON.stringify(subscriptionBody),
176
+ });
177
+ this.resolvedSubscriptionName = sub.name ?? null;
178
+ console.log(`šŸš€ Created Pub/Sub subscription "${subscriptionId}"`);
179
+ }
180
+ else if (needsUpdate) {
181
+ console.log(`šŸ”„ Updating GCP Pub/Sub subscription "${subscriptionId}"...`);
182
+ const sub = await gcpFetch(PUBSUB_BASE, `/v1/projects/${project}/subscriptions/${subscriptionId}`, {
183
+ method: "PATCH",
184
+ body: JSON.stringify({
185
+ subscription: {
186
+ name: `projects/${project}/subscriptions/${subscriptionId}`,
187
+ ...subscriptionBody,
188
+ },
189
+ updateMask: "topic,pushConfig,ackDeadlineSeconds",
190
+ }),
191
+ });
192
+ this.resolvedSubscriptionName = sub.name ?? null;
193
+ console.log(`šŸ”„ Updated Pub/Sub subscription "${subscriptionId}"`);
194
+ }
195
+ else {
196
+ this.resolvedSubscriptionName = existing.name ?? null;
197
+ console.log(` āœ… GCP Pub/Sub subscription "${subscriptionId}" is up to date.`);
198
+ }
199
+ await this.deploySidecars();
200
+ return {
201
+ name: subscriptionId,
202
+ subscriptionName: this.resolvedSubscriptionName,
203
+ };
204
+ }
205
+ async destroy() {
206
+ const dryRun = this.isDryRunActive();
207
+ const project = getProjectId();
208
+ const subscriptionId = this.name;
209
+ console.log(`\nšŸ—‘ļø Destroying GCP Pub/Sub Subscription "${subscriptionId}"...`);
210
+ const existing = await this.discoverSubscription();
211
+ if (!existing) {
212
+ console.log(` āœ… Subscription "${subscriptionId}" does not exist - nothing to do.`);
213
+ return { destroyed: subscriptionId };
214
+ }
215
+ if (dryRun) {
216
+ console.log(` šŸ“ [PLAN] Delete Pub/Sub subscription "${subscriptionId}"`);
217
+ return { destroyed: subscriptionId };
218
+ }
219
+ console.log(` šŸ”„ Deleting Pub/Sub subscription "${subscriptionId}"...`);
220
+ await gcpFetch(PUBSUB_BASE, `/v1/projects/${project}/subscriptions/${subscriptionId}`, {
221
+ method: "DELETE",
222
+ });
223
+ console.log(` āœ… Pub/Sub subscription "${subscriptionId}" deleted.`);
224
+ await this.destroySidecars();
225
+ return { destroyed: subscriptionId };
226
+ }
227
+ }
@@ -0,0 +1 @@
1
+ export {};