puls-dev 0.2.1 → 0.2.3
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 +13 -5
- package/dist/core/config.d.ts +5 -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/package.json +5 -1
|
@@ -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 {};
|
|
@@ -0,0 +1,281 @@
|
|
|
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 { GCPCloudRunBuilder } from "./cloudrun.js";
|
|
5
|
+
import { Config } from "../../core/config.js";
|
|
6
|
+
describe("GCPCloudRunBuilder 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-east1",
|
|
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 GCPCloudRunBuilder("my-service")
|
|
77
|
+
.image("gcr.io/my-proj/my-image:v1")
|
|
78
|
+
.port(3000)
|
|
79
|
+
.cpu(2)
|
|
80
|
+
.memory(1024)
|
|
81
|
+
.minInstances(2)
|
|
82
|
+
.maxInstances(20)
|
|
83
|
+
.env({ DB_HOST: "localhost", PORT: "3000" })
|
|
84
|
+
.region("europe-west1")
|
|
85
|
+
.public(false);
|
|
86
|
+
assert.strictEqual(builder._image, "gcr.io/my-proj/my-image:v1");
|
|
87
|
+
assert.strictEqual(builder._port, 3000);
|
|
88
|
+
assert.strictEqual(builder._cpu, 2);
|
|
89
|
+
assert.strictEqual(builder._memory, 1024);
|
|
90
|
+
assert.strictEqual(builder._minInstances, 2);
|
|
91
|
+
assert.strictEqual(builder._maxInstances, 20);
|
|
92
|
+
assert.deepStrictEqual(builder._env, { DB_HOST: "localhost", PORT: "3000" });
|
|
93
|
+
assert.strictEqual(builder._region, "europe-west1");
|
|
94
|
+
assert.strictEqual(builder._public, false);
|
|
95
|
+
});
|
|
96
|
+
test("runs in dry-run mode safely and logs plans", async () => {
|
|
97
|
+
Config.set({
|
|
98
|
+
dryRun: true,
|
|
99
|
+
providers: {
|
|
100
|
+
gcp: {
|
|
101
|
+
projectId: "my-gcp-project",
|
|
102
|
+
serviceAccountPath: "/fake/sa.json",
|
|
103
|
+
region: "us-central1",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const builder = new GCPCloudRunBuilder("dry-run-service")
|
|
108
|
+
.image("gcr.io/my-proj/my-image:v1")
|
|
109
|
+
.minInstances(1)
|
|
110
|
+
.maxInstances(5);
|
|
111
|
+
const result = await builder.deploy();
|
|
112
|
+
assert.strictEqual(result.serviceId, "dry-run-service");
|
|
113
|
+
assert.strictEqual(result.url, "https://dry-run-service-dryrun.a.run.app");
|
|
114
|
+
const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
|
|
115
|
+
assert.strictEqual(writeCalls.length, 0); // No write requests in dry-run
|
|
116
|
+
});
|
|
117
|
+
test("creates a new service when it does not exist", async () => {
|
|
118
|
+
// 1. Mock GET returning 404 (discovery)
|
|
119
|
+
mockResponses["GET /services/new-service"] = {
|
|
120
|
+
status: 404,
|
|
121
|
+
body: { message: "Not found" },
|
|
122
|
+
};
|
|
123
|
+
// 2. Mock POST (create)
|
|
124
|
+
mockResponses["POST /services?serviceId=new-service"] = {
|
|
125
|
+
status: 201,
|
|
126
|
+
body: {
|
|
127
|
+
name: "projects/my-gcp-project/locations/us-east1/services/new-service",
|
|
128
|
+
uri: "https://new-service-xyz.run.app",
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
// 3. Mock POST (setIamPolicy)
|
|
132
|
+
mockResponses["POST /services/new-service:setIamPolicy"] = {
|
|
133
|
+
status: 200,
|
|
134
|
+
body: {},
|
|
135
|
+
};
|
|
136
|
+
const builder = new GCPCloudRunBuilder("new-service")
|
|
137
|
+
.image("gcr.io/my-proj/my-image:v1")
|
|
138
|
+
.port(8080)
|
|
139
|
+
.cpu("1")
|
|
140
|
+
.memory("512Mi")
|
|
141
|
+
.minInstances(0)
|
|
142
|
+
.maxInstances(10)
|
|
143
|
+
.public(true);
|
|
144
|
+
const result = await builder.deploy();
|
|
145
|
+
assert.strictEqual(result.serviceId, "new-service");
|
|
146
|
+
assert.strictEqual(result.url, "https://new-service-xyz.run.app");
|
|
147
|
+
// Assert fetch calls occurred
|
|
148
|
+
const postCalls = fetchCalls.filter((c) => c.method === "POST" && c.url.includes("serviceId="));
|
|
149
|
+
assert.strictEqual(postCalls.length, 1);
|
|
150
|
+
assert.deepStrictEqual(postCalls[0].body.template.containers[0].image, "gcr.io/my-proj/my-image:v1");
|
|
151
|
+
const iamCalls = fetchCalls.filter((c) => c.method === "POST" && c.url.includes(":setIamPolicy"));
|
|
152
|
+
assert.strictEqual(iamCalls.length, 1);
|
|
153
|
+
assert.deepStrictEqual(iamCalls[0].body.policy.bindings[0].role, "roles/run.invoker");
|
|
154
|
+
assert.deepStrictEqual(iamCalls[0].body.policy.bindings[0].members, ["allUsers"]);
|
|
155
|
+
});
|
|
156
|
+
test("updates an existing service if configuration differs", async () => {
|
|
157
|
+
// 1. Mock GET returning existing service config with different image
|
|
158
|
+
mockResponses["GET /services/existing-service"] = {
|
|
159
|
+
status: 200,
|
|
160
|
+
body: {
|
|
161
|
+
name: "projects/my-gcp-project/locations/us-east1/services/existing-service",
|
|
162
|
+
uri: "https://existing-service-xyz.run.app",
|
|
163
|
+
template: {
|
|
164
|
+
containers: [
|
|
165
|
+
{
|
|
166
|
+
image: "gcr.io/my-proj/old-image:v1",
|
|
167
|
+
ports: [{ containerPort: 8080 }],
|
|
168
|
+
resources: { limits: { cpu: "1", memory: "512Mi" } },
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
scaling: { minInstanceCount: 0, maxInstanceCount: 10 },
|
|
172
|
+
},
|
|
173
|
+
ingress: "INGRESS_TRAFFIC_ALL",
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
// 2. Mock PATCH (update)
|
|
177
|
+
mockResponses["PATCH /services/existing-service"] = {
|
|
178
|
+
status: 200,
|
|
179
|
+
body: {
|
|
180
|
+
name: "projects/my-gcp-project/locations/us-east1/services/existing-service",
|
|
181
|
+
uri: "https://existing-service-xyz.run.app",
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
// 3. Mock POST (setIamPolicy)
|
|
185
|
+
mockResponses["POST /services/existing-service:setIamPolicy"] = {
|
|
186
|
+
status: 200,
|
|
187
|
+
body: {},
|
|
188
|
+
};
|
|
189
|
+
const builder = new GCPCloudRunBuilder("existing-service")
|
|
190
|
+
.image("gcr.io/my-proj/new-image:v1") // Image changed!
|
|
191
|
+
.minInstances(0)
|
|
192
|
+
.maxInstances(10)
|
|
193
|
+
.public(true);
|
|
194
|
+
const result = await builder.deploy();
|
|
195
|
+
assert.strictEqual(result.serviceId, "existing-service");
|
|
196
|
+
assert.strictEqual(result.url, "https://existing-service-xyz.run.app");
|
|
197
|
+
// Verify PATCH was called
|
|
198
|
+
const patchCalls = fetchCalls.filter((c) => c.method === "PATCH");
|
|
199
|
+
assert.strictEqual(patchCalls.length, 1);
|
|
200
|
+
assert.deepStrictEqual(patchCalls[0].body.template.containers[0].image, "gcr.io/my-proj/new-image:v1");
|
|
201
|
+
const iamCalls = fetchCalls.filter((c) => c.method === "POST" && c.url.includes(":setIamPolicy"));
|
|
202
|
+
assert.strictEqual(iamCalls.length, 1);
|
|
203
|
+
});
|
|
204
|
+
test("skips updating an existing service if configuration is identical", async () => {
|
|
205
|
+
// 1. Mock GET returning exact same config
|
|
206
|
+
mockResponses["GET /services/identical-service"] = {
|
|
207
|
+
status: 200,
|
|
208
|
+
body: {
|
|
209
|
+
name: "projects/my-gcp-project/locations/us-east1/services/identical-service",
|
|
210
|
+
uri: "https://identical-service-xyz.run.app",
|
|
211
|
+
template: {
|
|
212
|
+
containers: [
|
|
213
|
+
{
|
|
214
|
+
image: "gcr.io/my-proj/image:v1",
|
|
215
|
+
ports: [{ containerPort: 8080 }],
|
|
216
|
+
resources: { limits: { cpu: "1", memory: "512Mi" } },
|
|
217
|
+
env: [{ name: "NODE_ENV", value: "production" }],
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
scaling: { minInstanceCount: 0, maxInstanceCount: 10 },
|
|
221
|
+
},
|
|
222
|
+
ingress: "INGRESS_TRAFFIC_ALL",
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
// 2. Mock POST (setIamPolicy)
|
|
226
|
+
mockResponses["POST /services/identical-service:setIamPolicy"] = {
|
|
227
|
+
status: 200,
|
|
228
|
+
body: {},
|
|
229
|
+
};
|
|
230
|
+
const builder = new GCPCloudRunBuilder("identical-service")
|
|
231
|
+
.image("gcr.io/my-proj/image:v1")
|
|
232
|
+
.port(8080)
|
|
233
|
+
.cpu("1")
|
|
234
|
+
.memory("512Mi")
|
|
235
|
+
.minInstances(0)
|
|
236
|
+
.maxInstances(10)
|
|
237
|
+
.env({ NODE_ENV: "production" })
|
|
238
|
+
.public(true);
|
|
239
|
+
const result = await builder.deploy();
|
|
240
|
+
assert.strictEqual(result.serviceId, "identical-service");
|
|
241
|
+
assert.strictEqual(result.url, "https://identical-service-xyz.run.app");
|
|
242
|
+
// Verify PATCH or POST for services was NOT called
|
|
243
|
+
const writeCalls = fetchCalls.filter((c) => (c.method === "PATCH" || (c.method === "POST" && c.url.includes("serviceId="))));
|
|
244
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
245
|
+
// Verify setIamPolicy was still called
|
|
246
|
+
const iamCalls = fetchCalls.filter((c) => c.method === "POST" && c.url.includes(":setIamPolicy"));
|
|
247
|
+
assert.strictEqual(iamCalls.length, 1);
|
|
248
|
+
});
|
|
249
|
+
test("destroys an existing service", async () => {
|
|
250
|
+
// 1. Mock GET returning existing service config (discovery on destroy)
|
|
251
|
+
mockResponses["GET /services/to-delete"] = {
|
|
252
|
+
status: 200,
|
|
253
|
+
body: { name: "projects/my-gcp-project/locations/us-east1/services/to-delete" },
|
|
254
|
+
};
|
|
255
|
+
// 2. Mock DELETE
|
|
256
|
+
mockResponses["DELETE /services/to-delete"] = {
|
|
257
|
+
status: 200,
|
|
258
|
+
body: {},
|
|
259
|
+
};
|
|
260
|
+
const builder = new GCPCloudRunBuilder("to-delete");
|
|
261
|
+
const result = await builder.destroy();
|
|
262
|
+
assert.deepStrictEqual(result, { destroyed: "to-delete" });
|
|
263
|
+
// Verify DELETE was called
|
|
264
|
+
const deleteCalls = fetchCalls.filter((c) => c.method === "DELETE");
|
|
265
|
+
assert.strictEqual(deleteCalls.length, 1);
|
|
266
|
+
assert.strictEqual(deleteCalls[0].url.includes("/services/to-delete"), true);
|
|
267
|
+
});
|
|
268
|
+
test("destroy does nothing when service does not exist", async () => {
|
|
269
|
+
// 1. Mock GET returning 404
|
|
270
|
+
mockResponses["GET /services/not-exist"] = {
|
|
271
|
+
status: 404,
|
|
272
|
+
body: { message: "Not found" },
|
|
273
|
+
};
|
|
274
|
+
const builder = new GCPCloudRunBuilder("not-exist");
|
|
275
|
+
const result = await builder.destroy();
|
|
276
|
+
assert.deepStrictEqual(result, { destroyed: "not-exist" });
|
|
277
|
+
// Verify DELETE was NOT called
|
|
278
|
+
const deleteCalls = fetchCalls.filter((c) => c.method === "DELETE");
|
|
279
|
+
assert.strictEqual(deleteCalls.length, 0);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
export declare class GCPCloudSQLBuilder extends BaseBuilder {
|
|
3
|
+
private _engine;
|
|
4
|
+
private _engineVersion;
|
|
5
|
+
private _tier;
|
|
6
|
+
private _storage;
|
|
7
|
+
private _username?;
|
|
8
|
+
private _password?;
|
|
9
|
+
private _dbName?;
|
|
10
|
+
private _publicAccess;
|
|
11
|
+
private _region?;
|
|
12
|
+
resolvedEndpoint: string | null;
|
|
13
|
+
resolvedPort: number | null;
|
|
14
|
+
resolvedConnectionName: string | null;
|
|
15
|
+
constructor(instanceId: string);
|
|
16
|
+
engine(e: {
|
|
17
|
+
engine: "postgres" | "mysql";
|
|
18
|
+
version: string;
|
|
19
|
+
}): this;
|
|
20
|
+
size(tier: string): this;
|
|
21
|
+
storage(gb: number): this;
|
|
22
|
+
credentials(username: string, password: string): this;
|
|
23
|
+
database(name: string): this;
|
|
24
|
+
publicAccess(enabled?: boolean): this;
|
|
25
|
+
region(reg: string): this;
|
|
26
|
+
private discoverInstance;
|
|
27
|
+
private waitForOperation;
|
|
28
|
+
deploy(): Promise<{
|
|
29
|
+
name: string;
|
|
30
|
+
endpoint: string | null;
|
|
31
|
+
port: number;
|
|
32
|
+
connectionName: string | null;
|
|
33
|
+
}>;
|
|
34
|
+
destroy(): Promise<{
|
|
35
|
+
destroyed: string;
|
|
36
|
+
}>;
|
|
37
|
+
}
|