@google-cloud/cloud-run-mcp 1.9.0 → 1.11.0

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 (50) hide show
  1. package/README.md +24 -0
  2. package/SECURITY.md +7 -0
  3. package/cloudbuild.yaml +37 -0
  4. package/example-sources-to-deploy/java/pom.xml +41 -0
  5. package/example-sources-to-deploy/java/src/main/java/com/example/helloworld/HelloworldApplication.java +21 -0
  6. package/example-sources-to-deploy/java/src/main/resources/application.properties +1 -0
  7. package/example-sources-to-deploy/nodejs/index.js +15 -0
  8. package/example-sources-to-deploy/nodejs/package.json +12 -0
  9. package/example-sources-to-deploy/python/pip-project/main.py +11 -0
  10. package/example-sources-to-deploy/python/pip-project/requirements.txt +2 -0
  11. package/example-sources-to-deploy/python/pyproject-project/main.py +11 -0
  12. package/example-sources-to-deploy/python/pyproject-project/pyproject.toml +10 -0
  13. package/gemini-extension.json +12 -0
  14. package/lib/clients.js +104 -3
  15. package/lib/cloud-api/build.js +192 -28
  16. package/lib/cloud-api/run.js +62 -0
  17. package/lib/cloud-api/secrets.js +196 -0
  18. package/lib/cloud-api/storage.js +164 -16
  19. package/lib/deployment/compose.js +517 -0
  20. package/lib/deployment/constants.js +11 -0
  21. package/lib/deployment/deployer.js +334 -30
  22. package/lib/deployment/helpers.js +99 -49
  23. package/lib/deployment/source-processor.js +0 -48
  24. package/lib/deployment/universal-maker.js +147 -0
  25. package/lib/util/archive.js +3 -3
  26. package/lib/util/artifacts.js +189 -0
  27. package/lib/util/helpers.js +109 -0
  28. package/mcp-server.js +4 -0
  29. package/package.json +6 -3
  30. package/skills/cloud-run/SKILL.md +109 -0
  31. package/test/local/archive.test.js +88 -0
  32. package/test/local/clients.test.js +224 -2
  33. package/test/local/cloud-api/build.test.js +330 -25
  34. package/test/local/cloud-api/run.test.js +129 -0
  35. package/test/local/cloud-api/secrets.test.js +291 -0
  36. package/test/local/cloud-api/storage.test.js +405 -0
  37. package/test/local/compose.test.js +618 -0
  38. package/test/local/deploy.test.js +408 -0
  39. package/test/local/deployment-helpers.test.js +126 -79
  40. package/test/local/source-processor.test.js +0 -165
  41. package/test/local/test-utils.js +2 -18
  42. package/test/local/tools.test.js +43 -1
  43. package/test/local/universal-maker.test.js +157 -0
  44. package/test/local/util.test.js +361 -0
  45. package/test/need-gcp/deployer.test.js +1 -1
  46. package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
  47. package/tools/register-tools.js +19 -3
  48. /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
  49. /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
  50. /package/example-sources-to-deploy/{main.go → golang/main.go} +0 -0
@@ -14,6 +14,11 @@ See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ import { getProjectsClient } from '../clients.js';
18
+ import crypto from 'node:crypto';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
17
22
  /**
18
23
  * Helper function to log a message and call the progress callback.
19
24
  * @param {string} message - The message to log.
@@ -52,3 +57,107 @@ export function extractAccessToken(authorizationHeader) {
52
57
  }
53
58
  return authorizationHeader.split(' ')[1];
54
59
  }
60
+
61
+ /**
62
+ * Gets the project number for a given project ID.
63
+ * @param {string} projectId - The Google Cloud project ID.
64
+ * @param {string} accessToken - Access token for authentication.
65
+ * @returns {Promise<string>} The project number.
66
+ */
67
+ export async function getProjectNumber(projectId, accessToken) {
68
+ const projectsClient = await getProjectsClient(accessToken);
69
+ const [project] = await projectsClient.getProject({
70
+ name: `projects/${projectId}`,
71
+ });
72
+ // project.name is in the format "projects/123456"
73
+ const parts = project.name.split('/');
74
+ return parts.length > 1 ? parts[1] : project.projectNumber;
75
+ }
76
+
77
+ /**
78
+ * Calculates a SHA256 fingerprint of a directory.
79
+ * Matches the logic in gcloud run compose builder.py.
80
+ * @param {string} dirPath - The directory to fingerprint.
81
+ * @returns {Promise<string>} The SHA256 hex digest.
82
+ */
83
+ export async function calculateSourceFingerprint(dirPath) {
84
+ const hash = crypto.createHash('sha256');
85
+
86
+ // Helper for recursive walk matching Python's deterministic order
87
+ async function walk(currentDir) {
88
+ const entries = await fs.promises.readdir(currentDir, {
89
+ withFileTypes: true,
90
+ });
91
+
92
+ // Sort entries to match deterministic order (dirs and files sorted)
93
+ entries.sort((a, b) => a.name.localeCompare(b.name));
94
+
95
+ // Handle files first (matching filenames.sort() in builder.py's walk)
96
+ for (const entry of entries) {
97
+ if (entry.isFile()) {
98
+ const filePath = path.join(currentDir, entry.name);
99
+ const relPath = path.relative(dirPath, filePath);
100
+
101
+ // Update hash with relative path
102
+ hash.update(relPath);
103
+
104
+ // Update hash with file content
105
+ const content = await fs.promises.readFile(filePath);
106
+ hash.update(content);
107
+ }
108
+ }
109
+
110
+ // Then recurse into directories
111
+ for (const entry of entries) {
112
+ if (entry.isDirectory()) {
113
+ await walk(path.join(currentDir, entry.name));
114
+ }
115
+ }
116
+ }
117
+
118
+ await walk(dirPath);
119
+ return hash.digest('hex');
120
+ }
121
+
122
+ /**
123
+ * Sanitizes a string for use as a Cloud Run service name.
124
+ * Only lowercase, digits, and hyphens are allowed.
125
+ * Must begin with a letter and cannot end with a hyphen.
126
+ * @param {string} name - The name to sanitize.
127
+ * @returns {string} The sanitized name.
128
+ */
129
+ export function sanitizeCloudRunServiceName(name) {
130
+ let sanitized = name
131
+ .toLowerCase()
132
+ .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphen
133
+ .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
134
+
135
+ // Ensure it starts with a letter if possible
136
+ if (sanitized.length > 0 && !/^[a-z]/.test(sanitized)) {
137
+ sanitized = 's-' + sanitized;
138
+ }
139
+
140
+ // Cap at 49 characters (Cloud Run limit is usually 50-63 depending on API, 49 is safe)
141
+ return sanitized.slice(0, 49).replace(/-$/, '');
142
+ }
143
+
144
+ /**
145
+ * Sanitizes a string to be a valid GCP label value.
146
+ * Label values must be:
147
+ * - <= 63 characters.
148
+ * - Contain only lowercase letters, numbers, underscores, and dashes.
149
+ * @param {string} value - The string to sanitize.
150
+ * @returns {string} A valid GCP label value string.
151
+ */
152
+ export function sanitizeLabelValue(value) {
153
+ if (value === undefined || value === null) {
154
+ return '';
155
+ }
156
+ const stringValue = String(value);
157
+ if (stringValue === '') {
158
+ return '';
159
+ }
160
+ let sanitized = stringValue.toLowerCase();
161
+ sanitized = sanitized.replace(/[^a-z0-9_-]/g, '-');
162
+ return sanitized.slice(0, 63);
163
+ }
package/mcp-server.js CHANGED
@@ -69,6 +69,8 @@ const envProjectId = process.env.GOOGLE_CLOUD_PROJECT || undefined;
69
69
  const envRegion = process.env.GOOGLE_CLOUD_REGION;
70
70
  const defaultServiceName = process.env.DEFAULT_SERVICE_NAME;
71
71
  const skipIamCheck = process.env.SKIP_IAM_CHECK !== 'false';
72
+ // Values for RUN_INGRESS_POLICY could be: "INGRESS_TRAFFIC_ALL", "INGRESS_TRAFFIC_INTERNAL_ONLY", "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
73
+ const ingress = process.env.RUN_INGRESS_POLICY || undefined;
72
74
  const enableHostValidation = process.env.ENABLE_HOST_VALIDATION === 'true';
73
75
  const allowedHosts = process.env.ALLOWED_HOSTS
74
76
  ? process.env.ALLOWED_HOSTS.split(',')
@@ -107,6 +109,7 @@ async function getServer(accessToken = GCLOUD_AUTH) {
107
109
  defaultRegion: effectiveRegion,
108
110
  defaultServiceName,
109
111
  skipIamCheck,
112
+ ingress,
110
113
  gcpCredentialsAvailable,
111
114
  accessToken,
112
115
  });
@@ -120,6 +123,7 @@ async function getServer(accessToken = GCLOUD_AUTH) {
120
123
  defaultRegion: effectiveRegion,
121
124
  defaultServiceName,
122
125
  skipIamCheck,
126
+ ingress,
123
127
  gcpCredentialsAvailable,
124
128
  accessToken,
125
129
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@google-cloud/cloud-run-mcp",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "description": "Cloud Run MCP deployment tool",
6
6
  "main": "mcp-server.js",
@@ -40,19 +40,22 @@
40
40
  },
41
41
  "homepage": "https://github.com/GoogleCloudPlatform/cloud-run-mcp#readme",
42
42
  "dependencies": {
43
+ "@dotenvx/dotenvx": "^1.52.0",
43
44
  "@google-cloud/artifact-registry": "^4.0.1",
44
45
  "@google-cloud/billing": "^5.0.1",
45
46
  "@google-cloud/cloudbuild": "^5.0.1",
46
47
  "@google-cloud/logging": "^11.2.0",
47
48
  "@google-cloud/resource-manager": "^6.0.1",
48
49
  "@google-cloud/run": "^3.1.0",
50
+ "@google-cloud/secret-manager": "^6.1.1",
49
51
  "@google-cloud/service-usage": "^4.1.0",
50
52
  "@google-cloud/storage": "^7.16.0",
51
53
  "@modelcontextprotocol/sdk": "^1.24.3",
52
- "archiver": "^7.0.1",
53
- "@dotenvx/dotenvx": "^1.52.0",
54
+ "archiver": "^8.0.0",
54
55
  "express": "^5.1.0",
55
56
  "google-proto-files": "^5.0.0",
57
+ "googleapis": "^171.4.0",
58
+ "js-yaml": "^4.1.1",
56
59
  "zod": "^3.25.76"
57
60
  },
58
61
  "devDependencies": {
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: cloud-run
3
+ description: 'Manage Cloud Run services and jobs.'
4
+ metadata:
5
+ version: 0.1.0
6
+ openclaw:
7
+ category: 'compute'
8
+ requires:
9
+ bins:
10
+ - gcloud
11
+ cliHelp: 'gcloud run --help'
12
+ ---
13
+
14
+ # run (v1)
15
+
16
+ > **PREREQUISITE:** Ensure you have the `gcloud` CLI installed and authenticated with `gcloud auth login`. Set your project with `gcloud config set project [PROJECT_ID]`.
17
+
18
+ ```bash
19
+ gcloud run <resource> <method> [flags]
20
+ ```
21
+
22
+ ## Helper Commands
23
+
24
+ | Command | Description |
25
+ | -------- | -------------------------------------------- |
26
+ | `deploy` | Create or update a Cloud Run service or job. |
27
+
28
+ ## API Resources
29
+
30
+ ### services
31
+
32
+ - `list` — List available services in the specified region.
33
+ - `describe` — Obtain details about a given service, such as its URL and configuration.
34
+ - `update` — Update Cloud Run environment variables, concurrency settings, and other configuration.
35
+ - `delete` — Delete a service and its associated revisions.
36
+ - `update-traffic` — Adjust the traffic assignments for a Cloud Run service.
37
+ - `proxy` — Proxy a service to localhost authenticating as the active account.
38
+ - `logs read` — Read logs for a Cloud Run service.
39
+ - `replace` - Create or replace a service from a YAML service specification.
40
+ - `add-iam-policy-binding` — Add IAM policy binding to a Cloud Run service (e.g., to make it public).
41
+ - `remove-iam-policy-binding` - Remove IAM policy binding of a Cloud Run service.
42
+ - `get-iam-policy` - Get the IAM policy for a Cloud Run service.
43
+ - `set-iam-policy` - Set the IAM policy for a Cloud Run service.
44
+
45
+ ### jobs
46
+
47
+ - `create` — Create a Cloud Run job.
48
+ - `execute` — Start an execution of a Cloud Run job.
49
+ - `list` — List available jobs.
50
+ - `describe` — Obtain details about a given job.
51
+ - `deploy` - Create or update a Cloud Run job
52
+ - `update` — Update a Cloud Run job configuration.
53
+ - `delete` — Delete a Cloud Run job.
54
+ - `replace` - Create or replace a Cloud Run job from a YAML job specification.
55
+ - `executions list` — List executions of a Cloud Run job.
56
+ - `logs read` — Read logs for a Cloud Run job.
57
+ - `add-iam-policy-binding` — Add IAM policy binding to a Cloud Run job.
58
+ - `remove-iam-policy-binding` - Remove IAM policy binding of a Cloud Run job.
59
+ - `get-iam-policy` - Get the IAM policy for a Cloud Run job.
60
+ - `set-iam-policy` - Set the IAM policy for a Cloud Run job.
61
+
62
+ ### domain-mappings
63
+
64
+ - `list` — List domain mappings.
65
+ - `create` — Create a new domain mapping.
66
+ - `describe` — Obtain details about a domain mapping.
67
+ - `delete` — Delete a domain mapping.
68
+
69
+ ### multi-region-services
70
+
71
+ - `list` — List multi-region services.
72
+ - `describe` — Obtain details about a multi-region service.
73
+ - `update` — Update settings for multi-region services.
74
+ - `delete` — Delete a multi-region service.
75
+ - `replace` - Create or Update multi-region service from YAML specification.
76
+
77
+ ### revisions
78
+
79
+ - `list` — List available revisions for a service.
80
+ - `describe` — Obtain details about a specific revision.
81
+ - `delete` — Delete a specific revision.
82
+
83
+ ### regions
84
+
85
+ - `list` — View available Cloud Run (fully managed) regions.
86
+
87
+ ### compose
88
+
89
+ - `up` — Deploy to Cloud Run from a compose specification.
90
+
91
+ ## Discovering Commands
92
+
93
+ Before calling any command, inspect it for help:
94
+
95
+ ```bash
96
+ # Browse resources and methods
97
+ gcloud run --help
98
+
99
+ # Inspect a specific resource methods
100
+ gcloud run services --help
101
+
102
+ # Inspect a specific resource's sub-group methods
103
+ gcloud run jobs executions --help
104
+
105
+ # Inspect a method's specific flags and arguments
106
+ gcloud run deploy --help
107
+ ```
108
+
109
+ Use the output of `--help` to discover available flags like `--image`, `--env-vars`, `--memory`, etc.
@@ -0,0 +1,88 @@
1
+ /*
2
+ Copyright 2025 Google LLC
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { test, describe, beforeEach, afterEach } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import os from 'os';
22
+ import { zipFiles } from '../../lib/util/archive.js';
23
+
24
+ describe('Archive Utility (zipFiles)', () => {
25
+ let tempDir;
26
+
27
+ beforeEach(() => {
28
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-test-'));
29
+ });
30
+
31
+ afterEach(() => {
32
+ if (tempDir && fs.existsSync(tempDir)) {
33
+ fs.rmSync(tempDir, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test('creates a valid zip archive in memory from buffer objects', async () => {
38
+ const files = [
39
+ { filename: 'file1.txt', content: 'Hello World' },
40
+ { filename: 'file2.json', content: JSON.stringify({ key: 'value' }) },
41
+ ];
42
+
43
+ const zipBuffer = await zipFiles(files, false);
44
+ assert.ok(Buffer.isBuffer(zipBuffer));
45
+ assert.ok(zipBuffer.length > 0);
46
+ });
47
+
48
+ test('creates a valid tar.gz archive in memory from buffer objects', async () => {
49
+ const files = [{ filename: 'file1.txt', content: 'Hello World Tar' }];
50
+
51
+ const tarBuffer = await zipFiles(files, true);
52
+ assert.ok(Buffer.isBuffer(tarBuffer));
53
+ assert.ok(tarBuffer.length > 0);
54
+ });
55
+
56
+ test('creates archive from actual file paths on disk', async () => {
57
+ const testFilePath = path.join(tempDir, 'sample.txt');
58
+ fs.writeFileSync(testFilePath, 'Sample file content');
59
+
60
+ const zipBuffer = await zipFiles([testFilePath], false);
61
+ assert.ok(Buffer.isBuffer(zipBuffer));
62
+ assert.ok(zipBuffer.length > 0);
63
+ });
64
+
65
+ test('creates archive from directory path on disk', async () => {
66
+ const subDir = path.join(tempDir, 'subdir');
67
+ fs.mkdirSync(subDir);
68
+ fs.writeFileSync(path.join(subDir, 'nested.txt'), 'Nested content');
69
+
70
+ const zipBuffer = await zipFiles([subDir], false);
71
+ assert.ok(Buffer.isBuffer(zipBuffer));
72
+ assert.ok(zipBuffer.length > 0);
73
+ });
74
+
75
+ test('throws error if file path does not exist', async () => {
76
+ const nonExistentPath = path.join(tempDir, 'does-not-exist.txt');
77
+ await assert.rejects(
78
+ async () => {
79
+ await zipFiles([nonExistentPath], false);
80
+ },
81
+ {
82
+ message: new RegExp(
83
+ `File or directory not found: ${nonExistentPath.replace(/\\/g, '\\\\')}`
84
+ ),
85
+ }
86
+ );
87
+ });
88
+ });
@@ -1,6 +1,14 @@
1
- import { test, describe } from 'node:test';
1
+ import { test, describe, mock, beforeEach } from 'node:test';
2
2
  import assert from 'node:assert';
3
- import { getClient } from '../../lib/clients.js';
3
+ import { google } from 'googleapis';
4
+ import {
5
+ clients,
6
+ getClient,
7
+ getRunV1Client,
8
+ getCloudRunRegions,
9
+ resetCachedRegions,
10
+ } from '../../lib/clients.js';
11
+ import { GCLOUD_AUTH } from '../../constants.js';
4
12
 
5
13
  describe('getClient Helper', () => {
6
14
  class MockClient {
@@ -174,3 +182,217 @@ describe('getClient Helper', () => {
174
182
  assert.strictEqual(client.options.extraOpt, extraOpt);
175
183
  });
176
184
  });
185
+
186
+ describe('getRunV1Client', () => {
187
+ test('creates new client instance with Application Default Credentials for GCLOUD_AUTH', async () => {
188
+ // Mock the GoogleAuth class to avoid looking up ADC
189
+ const originalGoogleAuth = google.auth.GoogleAuth;
190
+ const authInstances = [];
191
+ google.auth.GoogleAuth = class MockGoogleAuth {
192
+ constructor(options) {
193
+ this.options = options;
194
+ authInstances.push(this);
195
+ }
196
+ };
197
+
198
+ const runMock = mock.method(google, 'run', (options) => {
199
+ return { options };
200
+ });
201
+
202
+ try {
203
+ const projectId = 'test-project';
204
+ const client = await getRunV1Client(projectId, GCLOUD_AUTH);
205
+
206
+ assert.ok(client);
207
+ assert.strictEqual(authInstances.length, 1);
208
+ assert.deepEqual(authInstances[0].options.scopes, [
209
+ 'https://www.googleapis.com/auth/cloud-platform',
210
+ ]);
211
+ assert.strictEqual(runMock.mock.calls.length, 1);
212
+ assert.strictEqual(
213
+ runMock.mock.calls[0].arguments[0].auth,
214
+ authInstances[0]
215
+ );
216
+ } finally {
217
+ google.auth.GoogleAuth = originalGoogleAuth;
218
+ runMock.mock.restore();
219
+ }
220
+ });
221
+
222
+ test('creates new client instance with OAuth client when access token provided', async () => {
223
+ const runMock = mock.method(google, 'run', (options) => ({ options }));
224
+ try {
225
+ const projectId = 'test-project-oauth';
226
+ const accessToken = 'fake-access-token';
227
+ const client = await getRunV1Client(projectId, accessToken);
228
+
229
+ assert.ok(client);
230
+ assert.strictEqual(runMock.mock.calls.length, 1);
231
+ const lastCall = runMock.mock.calls[0];
232
+ assert.ok(lastCall.arguments[0].auth);
233
+ assert.strictEqual(
234
+ lastCall.arguments[0].auth.credentials.access_token,
235
+ accessToken
236
+ );
237
+ } finally {
238
+ runMock.mock.restore();
239
+ }
240
+ });
241
+
242
+ test('sets rootUrl if region is provided', async () => {
243
+ const originalGoogleAuth = google.auth.GoogleAuth;
244
+ google.auth.GoogleAuth = class MockGoogleAuth {
245
+ constructor(options) {
246
+ this.options = options;
247
+ }
248
+ };
249
+
250
+ const runMock = mock.method(google, 'run', (options) => ({ options }));
251
+
252
+ const projectId = 'test-project-region';
253
+ const region = 'us-central1';
254
+ const key = projectId; // If GCLOUD_AUTH, key is just projectId
255
+
256
+ // Mock getCloudRunRegions call by injecting into clients.run
257
+ clients.run.set(key, {
258
+ async *listLocationsAsync() {
259
+ yield { locationId: 'us-central1' };
260
+ },
261
+ });
262
+
263
+ try {
264
+ await getRunV1Client(projectId, GCLOUD_AUTH, region);
265
+
266
+ const lastCall = runMock.mock.calls[0];
267
+ assert.strictEqual(
268
+ lastCall.arguments[0].rootUrl,
269
+ 'https://us-central1-run.googleapis.com/'
270
+ );
271
+ } finally {
272
+ google.auth.GoogleAuth = originalGoogleAuth;
273
+ runMock.mock.restore();
274
+ clients.run.delete(key);
275
+ }
276
+ });
277
+
278
+ test('caches client instances by key', async () => {
279
+ const runMock = mock.method(google, 'run', (options) => ({ options }));
280
+ try {
281
+ const projectId = 'test-project-cache';
282
+ const client1 = await getRunV1Client(projectId, GCLOUD_AUTH);
283
+ const client2 = await getRunV1Client(projectId, GCLOUD_AUTH);
284
+
285
+ assert.strictEqual(client1, client2);
286
+ assert.strictEqual(runMock.mock.calls.length, 1);
287
+ } finally {
288
+ runMock.mock.restore();
289
+ }
290
+ });
291
+
292
+ test('caches client instances separately for different regions', async () => {
293
+ resetCachedRegions();
294
+ const runMock = mock.method(google, 'run', (options) => ({ options }));
295
+ try {
296
+ const projectId = 'test-project-region-cache';
297
+ const region1 = 'us-central1';
298
+ const region2 = 'europe-west1';
299
+
300
+ // Mock getCloudRunRegions for both regions
301
+ const key = projectId; // GCLOUD_AUTH key for getRunClient/getCloudRunRegions
302
+ clients.run.set(key, {
303
+ async *listLocationsAsync() {
304
+ yield { locationId: region1 };
305
+ yield { locationId: region2 };
306
+ },
307
+ });
308
+
309
+ const client1 = await getRunV1Client(projectId, GCLOUD_AUTH, region1);
310
+ const client2 = await getRunV1Client(projectId, GCLOUD_AUTH, region2);
311
+
312
+ assert.notStrictEqual(client1, client2);
313
+ assert.strictEqual(runMock.mock.calls.length, 2);
314
+ assert.strictEqual(
315
+ runMock.mock.calls[0].arguments[0].rootUrl,
316
+ `https://${region1}-run.googleapis.com/`
317
+ );
318
+ assert.strictEqual(
319
+ runMock.mock.calls[1].arguments[0].rootUrl,
320
+ `https://${region2}-run.googleapis.com/`
321
+ );
322
+ } finally {
323
+ runMock.mock.restore();
324
+ clients.run.clear();
325
+ }
326
+ });
327
+ });
328
+
329
+ describe('getCloudRunRegions', () => {
330
+ beforeEach(() => {
331
+ resetCachedRegions();
332
+ // Clear the run map to ensure isolation between tests
333
+ clients.run.clear();
334
+ });
335
+
336
+ test('returns list of regions using a mock injected into the clients cache', async () => {
337
+ const projectId = 'test-project-1';
338
+ const accessToken = 'token-1';
339
+ // Match the key generation logic
340
+ const key = projectId + accessToken;
341
+
342
+ const mockRunClient = {
343
+ async *listLocationsAsync() {
344
+ yield { locationId: 'us-central1' };
345
+ yield { locationId: 'europe-west1' };
346
+ },
347
+ };
348
+
349
+ clients.run.set(key, mockRunClient);
350
+
351
+ const regions = await getCloudRunRegions(projectId, accessToken);
352
+ assert.deepStrictEqual(regions, ['us-central1', 'europe-west1']);
353
+ });
354
+
355
+ test('caches the regions after first call', async () => {
356
+ const projectId = 'test-project-2';
357
+ const accessToken = 'token-2';
358
+ const key = projectId + accessToken;
359
+
360
+ let callCount = 0;
361
+ const mockRunClient = {
362
+ async *listLocationsAsync() {
363
+ callCount++;
364
+ yield { locationId: 'us-central1' };
365
+ },
366
+ };
367
+
368
+ clients.run.set(key, mockRunClient);
369
+
370
+ await getCloudRunRegions(projectId, accessToken);
371
+ const regions = await getCloudRunRegions(projectId, accessToken);
372
+
373
+ assert.strictEqual(callCount, 1);
374
+ assert.deepStrictEqual(regions, ['us-central1']);
375
+ });
376
+
377
+ test('using different accessToken allows isolated client mocks', async () => {
378
+ const projectId = 'test-project-3';
379
+
380
+ clients.run.set(projectId + 'token-a', {
381
+ async *listLocationsAsync() {
382
+ yield { locationId: 'region-a' };
383
+ },
384
+ });
385
+ clients.run.set(projectId + 'token-b', {
386
+ async *listLocationsAsync() {
387
+ yield { locationId: 'region-b' };
388
+ },
389
+ });
390
+
391
+ const resA = await getCloudRunRegions(projectId, 'token-a');
392
+ resetCachedRegions();
393
+ const resB = await getCloudRunRegions(projectId, 'token-b');
394
+
395
+ assert.deepStrictEqual(resA, ['region-a']);
396
+ assert.deepStrictEqual(resB, ['region-b']);
397
+ });
398
+ });