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,259 @@
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 { GCPCloudDNSZoneBuilder } from "./clouddns.js";
5
+ import { Config } from "../../core/config.js";
6
+ import { Output } from "../../core/output.js";
7
+ describe("GCPCloudDNSZoneBuilder 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
+ test("initializes names and normalizes zone ID correctly", () => {
77
+ const builder = new GCPCloudDNSZoneBuilder("My-Awesome-Domain.com.");
78
+ assert.strictEqual(builder.cleanZoneName, "my-awesome-domain.com.");
79
+ assert.strictEqual(builder.zoneId, "my-awesome-domain-com");
80
+ });
81
+ test("runs in dry-run mode safely and logs plans without modifying resources", 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 /managedZones/dryrun-com"] = {
92
+ status: 404,
93
+ body: { error: { message: "Not found" } },
94
+ };
95
+ const builder = new GCPCloudDNSZoneBuilder("dryrun.com")
96
+ .record("www", "A", "1.2.3.4")
97
+ .record("api", "CNAME", "api-backend.com")
98
+ .record("@", "TXT", "v=spf1 include:_spf.google.com ~all");
99
+ const result = await builder.deploy();
100
+ assert.strictEqual(result.zone, "dryrun.com");
101
+ assert.strictEqual(result.id, "dryrun-com");
102
+ assert.strictEqual(result.records.length, 3);
103
+ // Verify dry-run outputs are correct and no write calls are sent
104
+ const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
105
+ assert.strictEqual(writeCalls.length, 0);
106
+ const wwwRec = result.records.find((r) => r.name === "www.dryrun.com.");
107
+ assert.ok(wwwRec);
108
+ assert.strictEqual(wwwRec.type, "A");
109
+ assert.deepStrictEqual(wwwRec.rrdatas, ["1.2.3.4"]);
110
+ const txtRec = result.records.find((r) => r.name === "dryrun.com.");
111
+ assert.ok(txtRec);
112
+ assert.strictEqual(txtRec.type, "TXT");
113
+ assert.deepStrictEqual(txtRec.rrdatas, ['"v=spf1 include:_spf.google.com ~all"']); // Auto-quoted!
114
+ });
115
+ test("creates a new managed zone when missing and submits records", async () => {
116
+ // 1. Zone doesn't exist yet
117
+ mockResponses["GET /managedZones/new-zone-com"] = {
118
+ status: 404,
119
+ body: { error: { message: "Not found" } },
120
+ };
121
+ // 2. Mock Managed Zone Creation POST
122
+ mockResponses["POST /managedZones"] = {
123
+ status: 200,
124
+ body: { name: "new-zone-com", dnsName: "new-zone.com." },
125
+ };
126
+ // 3. rrsets GET returns empty
127
+ mockResponses["GET /managedZones/new-zone-com/rrsets"] = {
128
+ status: 200,
129
+ body: { rrsets: [] },
130
+ };
131
+ // 4. changes POST
132
+ mockResponses["POST /managedZones/new-zone-com/changes"] = {
133
+ status: 200,
134
+ body: { status: "pending" },
135
+ };
136
+ const builder = new GCPCloudDNSZoneBuilder("new-zone.com")
137
+ .record("www", "CNAME", "lb.google.com")
138
+ .record("db", "A", "10.0.0.5", 60);
139
+ const result = await builder.deploy();
140
+ assert.strictEqual(result.id, "new-zone-com");
141
+ // Verify Managed Zone creation POST
142
+ const createZoneCall = fetchCalls.find((c) => c.method === "POST" && c.url.endsWith("/managedZones"));
143
+ assert.ok(createZoneCall);
144
+ assert.strictEqual(createZoneCall.body.name, "new-zone-com");
145
+ assert.strictEqual(createZoneCall.body.dnsName, "new-zone.com.");
146
+ // Verify record changes POST
147
+ const changeCall = fetchCalls.find((c) => c.method === "POST" && c.url.endsWith("/changes"));
148
+ assert.ok(changeCall);
149
+ assert.strictEqual(changeCall.body.additions.length, 2);
150
+ assert.strictEqual(changeCall.body.deletions.length, 0);
151
+ const cnameAdd = changeCall.body.additions.find((a) => a.type === "CNAME");
152
+ assert.ok(cnameAdd);
153
+ assert.strictEqual(cnameAdd.name, "www.new-zone.com.");
154
+ assert.deepStrictEqual(cnameAdd.rrdatas, ["lb.google.com."]); // Trailing dot auto-appended!
155
+ });
156
+ test("skips identical record sets, and transactionally updates out-of-date records", async () => {
157
+ // 1. Zone exists
158
+ mockResponses["GET /managedZones/sync-zone-com"] = {
159
+ status: 200,
160
+ body: { name: "sync-zone-com" },
161
+ };
162
+ // 2. rrsets GET returns existing records
163
+ mockResponses["GET /managedZones/sync-zone-com/rrsets"] = {
164
+ status: 200,
165
+ body: {
166
+ rrsets: [
167
+ // Identical
168
+ { name: "www.sync-zone.com.", type: "CNAME", ttl: 300, rrdatas: ["lb.google.com."] },
169
+ // Differing TTL
170
+ { name: "db.sync-zone.com.", type: "A", ttl: 300, rrdatas: ["10.0.0.5"] },
171
+ // Differing Value
172
+ { name: "mail.sync-zone.com.", type: "A", ttl: 300, rrdatas: ["1.1.1.1"] },
173
+ ],
174
+ },
175
+ };
176
+ mockResponses["POST /managedZones/sync-zone-com/changes"] = {
177
+ status: 200,
178
+ body: {},
179
+ };
180
+ const builder = new GCPCloudDNSZoneBuilder("sync-zone.com")
181
+ .record("www", "CNAME", "lb.google.com", 300) // Identical
182
+ .record("db", "A", "10.0.0.5", 60) // Changed TTL (300 -> 60)
183
+ .record("mail", "A", "2.2.2.2", 300); // Changed Value (1.1.1.1 -> 2.2.2.2)
184
+ await builder.deploy();
185
+ const changeCall = fetchCalls.find((c) => c.method === "POST" && c.url.endsWith("/changes"));
186
+ assert.ok(changeCall);
187
+ // additions should have db (new TTL) and mail (new value)
188
+ assert.strictEqual(changeCall.body.additions.length, 2);
189
+ // deletions should have old db and old mail
190
+ assert.strictEqual(changeCall.body.deletions.length, 2);
191
+ const oldMail = changeCall.body.deletions.find((d) => d.name === "mail.sync-zone.com.");
192
+ assert.ok(oldMail);
193
+ assert.deepStrictEqual(oldMail.rrdatas, ["1.1.1.1"]);
194
+ const newMail = changeCall.body.additions.find((a) => a.name === "mail.sync-zone.com.");
195
+ assert.ok(newMail);
196
+ assert.deepStrictEqual(newMail.rrdatas, ["2.2.2.2"]);
197
+ });
198
+ test("resolves pointers to other builders, converting to CNAME and stripping protocols", async () => {
199
+ mockResponses["GET /managedZones/pointers-com"] = {
200
+ status: 200,
201
+ body: { name: "pointers-com" },
202
+ };
203
+ mockResponses["GET /managedZones/pointers-com/rrsets"] = { status: 200, body: { rrsets: [] } };
204
+ mockResponses["POST /managedZones/pointers-com/changes"] = { status: 200, body: {} };
205
+ // Let's mock a target builder that resolves to a URL (e.g. Cloud Run microservice)
206
+ const mockCloudRun = {
207
+ name: "frontend-srv",
208
+ url: "https://frontend-srv-xyz.a.run.app",
209
+ };
210
+ // Let's mock another target that is an Output resolving to an IP
211
+ const ipOutput = new Output();
212
+ ipOutput.resolve("123.45.67.89");
213
+ const builder = new GCPCloudDNSZoneBuilder("pointers.com")
214
+ .pointer("app", mockCloudRun)
215
+ .pointer("api", ipOutput);
216
+ const result = await builder.deploy();
217
+ const appRec = result.records.find((r) => r.name === "app.pointers.com.");
218
+ assert.ok(appRec);
219
+ // Dynamic CNAME conversion from HTTP URL target!
220
+ assert.strictEqual(appRec.type, "CNAME");
221
+ assert.deepStrictEqual(appRec.rrdatas, ["frontend-srv-xyz.a.run.app."]); // Stripped https://, appended trailing dot!
222
+ const apiRec = result.records.find((r) => r.name === "api.pointers.com.");
223
+ assert.ok(apiRec);
224
+ // Standard A record for plain IP output!
225
+ assert.strictEqual(apiRec.type, "A");
226
+ assert.deepStrictEqual(apiRec.rrdatas, ["123.45.67.89"]);
227
+ });
228
+ test("destroys zone successfully, removing non-default records first", async () => {
229
+ mockResponses["GET /managedZones/to-delete-com"] = {
230
+ status: 200,
231
+ body: { name: "to-delete-com" },
232
+ };
233
+ mockResponses["GET /managedZones/to-delete-com/rrsets"] = {
234
+ status: 200,
235
+ body: {
236
+ rrsets: [
237
+ { name: "to-delete.com.", type: "NS", rrdatas: ["ns-cloud-a1.google.com."] }, // apex NS (default)
238
+ { name: "to-delete.com.", type: "SOA", rrdatas: ["ns-cloud-a1.google.com. host.google.com."] }, // apex SOA (default)
239
+ { name: "www.to-delete.com.", type: "A", rrdatas: ["1.2.3.4"] }, // non-default
240
+ { name: "api.to-delete.com.", type: "CNAME", rrdatas: ["lb.google.com."] }, // non-default
241
+ ],
242
+ },
243
+ };
244
+ mockResponses["POST /managedZones/to-delete-com/changes"] = { status: 200, body: {} };
245
+ mockResponses["DELETE /managedZones/to-delete-com"] = { status: 200, body: {} };
246
+ const builder = new GCPCloudDNSZoneBuilder("to-delete.com");
247
+ await builder.destroy();
248
+ // Verify non-default records deletion POST
249
+ const changeCall = fetchCalls.find((c) => c.method === "POST" && c.url.endsWith("/changes"));
250
+ assert.ok(changeCall);
251
+ assert.strictEqual(changeCall.body.deletions.length, 2); // only www and api
252
+ const deletedNames = changeCall.body.deletions.map((d) => d.name);
253
+ assert.ok(deletedNames.includes("www.to-delete.com."));
254
+ assert.ok(deletedNames.includes("api.to-delete.com."));
255
+ // Verify Zone DELETE
256
+ const deleteCall = fetchCalls.find((c) => c.method === "DELETE" && c.url.endsWith("/managedZones/to-delete-com"));
257
+ assert.ok(deleteCall);
258
+ });
259
+ });
@@ -0,0 +1,31 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { GCPSecretBuilder } from "./secrets.js";
3
+ export declare class GCPCloudRunBuilder extends BaseBuilder {
4
+ private _image?;
5
+ private _port;
6
+ private _cpu;
7
+ private _memory;
8
+ private _minInstances?;
9
+ private _maxInstances?;
10
+ private _env;
11
+ private _region?;
12
+ private _public;
13
+ constructor(serviceId: string);
14
+ image(img: string): this;
15
+ port(p: number): this;
16
+ cpu(c: string | number): this;
17
+ memory(m: string | number): this;
18
+ minInstances(n: number): this;
19
+ maxInstances(n: number): this;
20
+ env(vars: Record<string, string | GCPSecretBuilder>): this;
21
+ region(reg: string): this;
22
+ public(enabled?: boolean): this;
23
+ private discoverService;
24
+ deploy(): Promise<{
25
+ serviceId: string;
26
+ url: any;
27
+ }>;
28
+ destroy(): Promise<{
29
+ destroyed: string;
30
+ }>;
31
+ }
@@ -0,0 +1,240 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { gcpFetch, getProjectId, getRegion } from "./api.js";
3
+ import { resolveGCPEnvVars } from "./secrets.js";
4
+ const RUN_BASE = "https://run.googleapis.com";
5
+ function formatCpu(cpu) {
6
+ return typeof cpu === "number" ? String(cpu) : cpu;
7
+ }
8
+ function formatMemory(memory) {
9
+ return typeof memory === "number" ? `${memory}Mi` : memory;
10
+ }
11
+ export class GCPCloudRunBuilder extends BaseBuilder {
12
+ _image;
13
+ _port = 8080;
14
+ _cpu = "1";
15
+ _memory = "512Mi";
16
+ _minInstances;
17
+ _maxInstances;
18
+ _env = {};
19
+ _region;
20
+ _public = true;
21
+ constructor(serviceId) {
22
+ super(serviceId);
23
+ this.discoveryPromise = this.discoverService();
24
+ }
25
+ image(img) {
26
+ this._image = img;
27
+ return this;
28
+ }
29
+ port(p) {
30
+ this._port = p;
31
+ return this;
32
+ }
33
+ cpu(c) {
34
+ this._cpu = c;
35
+ return this;
36
+ }
37
+ memory(m) {
38
+ this._memory = m;
39
+ return this;
40
+ }
41
+ minInstances(n) {
42
+ this._minInstances = n;
43
+ return this;
44
+ }
45
+ maxInstances(n) {
46
+ this._maxInstances = n;
47
+ return this;
48
+ }
49
+ env(vars) {
50
+ this._env = { ...this._env, ...vars };
51
+ return this;
52
+ }
53
+ region(reg) {
54
+ this._region = reg;
55
+ // Re-trigger discovery with the custom region
56
+ this.discoveryPromise = this.discoverService();
57
+ return this;
58
+ }
59
+ public(enabled = true) {
60
+ this._public = enabled;
61
+ return this;
62
+ }
63
+ async discoverService() {
64
+ try {
65
+ const project = getProjectId();
66
+ const location = this._region ?? getRegion();
67
+ return await gcpFetch(RUN_BASE, `/v2/projects/${project}/locations/${location}/services/${this.name}`);
68
+ }
69
+ catch (e) {
70
+ if (e.message?.includes("404") ||
71
+ e.message?.includes("403") ||
72
+ e.message?.includes("credentials not configured")) {
73
+ return null;
74
+ }
75
+ throw e;
76
+ }
77
+ }
78
+ async deploy() {
79
+ const dryRun = this.isDryRunActive();
80
+ const project = getProjectId();
81
+ const location = this._region ?? getRegion();
82
+ const serviceId = this.name;
83
+ console.log(`\n⚔ Finalizing GCP Cloud Run Service "${serviceId}"...`);
84
+ if (!this._image) {
85
+ throw new Error(`[GCP.CloudRun:${serviceId}] .image("...") is required`);
86
+ }
87
+ const existing = await this.discoveryPromise;
88
+ const targetCpu = formatCpu(this._cpu);
89
+ const targetMemory = formatMemory(this._memory);
90
+ const targetMinInstances = this._minInstances ?? 0;
91
+ const targetMaxInstances = this._maxInstances ?? 100;
92
+ const targetIngress = this._public ? "INGRESS_TRAFFIC_ALL" : "INGRESS_TRAFFIC_INTERNAL_ONLY";
93
+ const resolvedEnv = await resolveGCPEnvVars(this._env, dryRun);
94
+ const targetEnv = Object.entries(resolvedEnv).map(([name, value]) => ({
95
+ name,
96
+ value,
97
+ }));
98
+ if (dryRun) {
99
+ console.log(` šŸ“ [PLAN] ${existing ? "Update" : "Create"} Cloud Run service "${serviceId}" in ${location}`);
100
+ console.log(` └─ Image: ${this._image}`);
101
+ console.log(` └─ Port: ${this._port}`);
102
+ console.log(` └─ CPU: ${targetCpu} | Memory: ${targetMemory}`);
103
+ console.log(` └─ Scaling: min ${targetMinInstances}, max ${targetMaxInstances}`);
104
+ console.log(` └─ Public Access: ${this._public ? "enabled" : "disabled"}`);
105
+ if (targetEnv.length > 0) {
106
+ console.log(` └─ Env vars: ${targetEnv.map((e) => e.name).join(", ")}`);
107
+ }
108
+ return {
109
+ serviceId,
110
+ url: `https://${serviceId}-dryrun.a.run.app`,
111
+ };
112
+ }
113
+ // Determine if update is needed
114
+ let needsUpdate = !existing;
115
+ if (existing) {
116
+ const existingContainer = existing.template?.containers?.[0];
117
+ const existingLimits = existingContainer?.resources?.limits ?? {};
118
+ const existingPorts = existingContainer?.ports ?? [];
119
+ const existingEnv = existingContainer?.env ?? [];
120
+ const existingScaling = existing.template?.scaling ?? {};
121
+ // Env vars match
122
+ const sortedExistingEnv = [...existingEnv].sort((a, b) => a.name.localeCompare(b.name));
123
+ const sortedTargetEnv = [...targetEnv].sort((a, b) => a.name.localeCompare(b.name));
124
+ const envsMatch = JSON.stringify(sortedExistingEnv) === JSON.stringify(sortedTargetEnv);
125
+ needsUpdate =
126
+ existingContainer?.image !== this._image ||
127
+ existingPorts[0]?.containerPort !== this._port ||
128
+ existingLimits.cpu !== targetCpu ||
129
+ existingLimits.memory !== targetMemory ||
130
+ (existingScaling.minInstanceCount ?? 0) !== targetMinInstances ||
131
+ (existingScaling.maxInstanceCount ?? 100) !== targetMaxInstances ||
132
+ existing.ingress !== targetIngress ||
133
+ !envsMatch;
134
+ }
135
+ const serviceBody = {
136
+ template: {
137
+ containers: [
138
+ {
139
+ image: this._image,
140
+ ports: [{ containerPort: this._port }],
141
+ resources: {
142
+ limits: {
143
+ cpu: targetCpu,
144
+ memory: targetMemory,
145
+ },
146
+ },
147
+ env: targetEnv,
148
+ },
149
+ ],
150
+ scaling: {
151
+ minInstanceCount: targetMinInstances,
152
+ maxInstanceCount: targetMaxInstances,
153
+ },
154
+ },
155
+ ingress: targetIngress,
156
+ };
157
+ let resultService;
158
+ if (!existing) {
159
+ console.log(`šŸš€ Creating GCP Cloud Run service "${serviceId}"...`);
160
+ resultService = await gcpFetch(RUN_BASE, `/v2/projects/${project}/locations/${location}/services?serviceId=${serviceId}`, {
161
+ method: "POST",
162
+ body: JSON.stringify(serviceBody),
163
+ });
164
+ }
165
+ else if (needsUpdate) {
166
+ console.log(`šŸ”„ Updating GCP Cloud Run service "${serviceId}"...`);
167
+ resultService = await gcpFetch(RUN_BASE, `/v2/projects/${project}/locations/${location}/services/${serviceId}`, {
168
+ method: "PATCH",
169
+ body: JSON.stringify({
170
+ name: `projects/${project}/locations/${location}/services/${serviceId}`,
171
+ ...serviceBody,
172
+ }),
173
+ });
174
+ }
175
+ else {
176
+ console.log(`āœ… GCP Cloud Run service "${serviceId}" is up to date.`);
177
+ resultService = existing;
178
+ }
179
+ // Apply IAM Invoker Policy
180
+ if (this._public) {
181
+ console.log(` šŸ”“ Making service public (binding roles/run.invoker to allUsers)...`);
182
+ await gcpFetch(RUN_BASE, `/v2/projects/${project}/locations/${location}/services/${serviceId}:setIamPolicy`, {
183
+ method: "POST",
184
+ body: JSON.stringify({
185
+ policy: {
186
+ bindings: [
187
+ {
188
+ role: "roles/run.invoker",
189
+ members: ["allUsers"],
190
+ },
191
+ ],
192
+ },
193
+ }),
194
+ });
195
+ console.log(` āœ… Public IAM policy applied.`);
196
+ }
197
+ else {
198
+ console.log(` šŸ”’ Restricting service to private (clearing allUsers invoker binding)...`);
199
+ await gcpFetch(RUN_BASE, `/v2/projects/${project}/locations/${location}/services/${serviceId}:setIamPolicy`, {
200
+ method: "POST",
201
+ body: JSON.stringify({
202
+ policy: {
203
+ bindings: [],
204
+ },
205
+ }),
206
+ });
207
+ console.log(` āœ… Private IAM policy applied.`);
208
+ }
209
+ await this.deploySidecars();
210
+ const url = resultService.uri ?? `https://${serviceId}.a.run.app`;
211
+ console.log(`šŸš€ Service live → ${url}`);
212
+ return {
213
+ serviceId,
214
+ url,
215
+ };
216
+ }
217
+ async destroy() {
218
+ const dryRun = this.isDryRunActive();
219
+ const project = getProjectId();
220
+ const location = this._region ?? getRegion();
221
+ const serviceId = this.name;
222
+ console.log(`\nšŸ—‘ļø Destroying GCP Cloud Run Service "${serviceId}"...`);
223
+ const existing = await this.discoverService();
224
+ if (!existing) {
225
+ console.log(` āœ… Service "${serviceId}" does not exist - nothing to do.`);
226
+ return { destroyed: serviceId };
227
+ }
228
+ if (dryRun) {
229
+ console.log(` šŸ“ [PLAN] Delete Cloud Run service "${serviceId}" in ${location}`);
230
+ return { destroyed: serviceId };
231
+ }
232
+ console.log(` šŸ”„ Deleting Cloud Run service "${serviceId}"...`);
233
+ await gcpFetch(RUN_BASE, `/v2/projects/${project}/locations/${location}/services/${serviceId}`, {
234
+ method: "DELETE",
235
+ });
236
+ console.log(` āœ… Service "${serviceId}" deleted.`);
237
+ await this.destroySidecars();
238
+ return { destroyed: serviceId };
239
+ }
240
+ }
@@ -0,0 +1 @@
1
+ export {};