@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
@@ -0,0 +1,517 @@
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 path from 'path';
18
+ import { promises as fsPromises } from 'fs';
19
+ import os from 'os';
20
+ import util from 'util';
21
+ import { execFile } from 'child_process';
22
+ import crypto from 'crypto';
23
+ import { logAndProgress } from '../util/helpers.js';
24
+ import { uploadDirectory } from './helpers.js';
25
+ import { TEMP_PATHS, RUN_COMPOSE } from './constants.js';
26
+ import { ensureRepositoryDownloaded } from '../util/artifacts.js';
27
+ import {
28
+ ensureStorageBucketExists,
29
+ uploadToStorageBucket,
30
+ grantBucketAccess,
31
+ } from '../cloud-api/storage.js';
32
+ import { getProjectNumber, sanitizeLabelValue } from '../util/helpers.js';
33
+ import { ensureApisEnabled } from '../cloud-api/helpers.js';
34
+ import {
35
+ getSecret,
36
+ createSecret,
37
+ addSecretVersion,
38
+ addSecretAccessorBinding,
39
+ } from '../cloud-api/secrets.js';
40
+
41
+ const execFileAsync = util.promisify(execFile);
42
+
43
+ // TODO: Move to production project
44
+ const AR_PROJECT = 'serverless-runtimes-qa';
45
+ const AR_LOCATION = 'us-central1';
46
+ const AR_REPOSITORY = 'run-compose';
47
+
48
+ const ARCH_MAPPING = {
49
+ darwin_amd64: 'darwin_amd64',
50
+ darwin_arm64: 'darwin_arm64',
51
+ linux_386: 'linux_386',
52
+ linux_aarch64: 'linux_aarch64',
53
+ linux_amd64: 'linux_amd64',
54
+ windows_386: 'windows_386',
55
+ windows_amd64: 'windows_amd64',
56
+ };
57
+
58
+ const _MAX_BUCKET_NAME_LENGTH = 63;
59
+ const _HASH_LENGTH = 8;
60
+ const SHA1 = 'sha1';
61
+ const COMPOSE_SUFFIX = 'compose';
62
+ const HEX_ENCODING = 'hex';
63
+
64
+ /**
65
+ * Gets the architecture key for the current platform.
66
+ * @returns {string|null} The architecture key or null if not supported.
67
+ */
68
+ function getRunComposeArchitectureKey() {
69
+ const platform = process.platform;
70
+ const arch = process.arch;
71
+
72
+ if (platform === 'darwin') {
73
+ if (arch === 'x64') return 'darwin_amd64';
74
+ if (arch === 'arm64') return 'darwin_arm64';
75
+ } else if (platform === 'linux') {
76
+ if (arch === 'x64') return 'linux_amd64';
77
+ if (arch === 'arm64') return 'linux_aarch64';
78
+ if (arch === 'ia32') return 'linux_386';
79
+ } else if (platform === 'win32') {
80
+ if (arch === 'x64') return 'windows_amd64';
81
+ if (arch === 'ia32') return 'windows_386';
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * Ensures run-compose binary is downloaded and returns its path.
88
+ * @param {string} accessToken - Access token for authentication.
89
+ * @param {function} progressCallback - Progress callback.
90
+ * @returns {Promise<string|null>} The path to the downloaded binary or null if it fails.
91
+ */
92
+ export async function runCompose(accessToken, progressCallback) {
93
+ const binDir = path.join(
94
+ os.homedir(),
95
+ TEMP_PATHS.BASE,
96
+ TEMP_PATHS.BIN_SUBDIR
97
+ );
98
+ const key = getRunComposeArchitectureKey();
99
+ if (!key) {
100
+ await logAndProgress(
101
+ `run-compose is not supported on ${process.platform} ${process.arch}.`,
102
+ progressCallback,
103
+ 'debug'
104
+ );
105
+ return null;
106
+ }
107
+
108
+ const arch = ARCH_MAPPING[key];
109
+ const binPath = path.join(binDir, RUN_COMPOSE.BIN);
110
+
111
+ const binPathResult = await ensureRepositoryDownloaded(
112
+ binPath,
113
+ {
114
+ project: AR_PROJECT,
115
+ location: AR_LOCATION,
116
+ repository: AR_REPOSITORY,
117
+ artifactPath: `${arch}:${RUN_COMPOSE.VERSION}:${RUN_COMPOSE.BIN}`,
118
+ displayName: 'run-compose',
119
+ },
120
+ accessToken,
121
+ progressCallback
122
+ );
123
+
124
+ if (!binPathResult) {
125
+ return null;
126
+ }
127
+
128
+ return binPathResult;
129
+ }
130
+
131
+ /**
132
+ * Gets resource configuration for a compose file using the run-compose binary.
133
+ *
134
+ * @param {string} binPath - Path to the run-compose binary.
135
+ * @param {string} composeFilePath - Path to the compose.yaml file.
136
+ * @param {string} region - The Google Cloud region.
137
+ * @param {function} progressCallback - Optional callback for progress updates.
138
+ * @returns {Promise<string>} The output from the resource command.
139
+ */
140
+ export async function resourceCompose(
141
+ binPath,
142
+ composeFilePath,
143
+ region,
144
+ progressCallback
145
+ ) {
146
+ try {
147
+ const parentDir = path.dirname(composeFilePath);
148
+ await logAndProgress(
149
+ `Running command: ${binPath} resource ${composeFilePath} --region ${region} --out .`,
150
+ progressCallback,
151
+ 'debug'
152
+ );
153
+
154
+ const { stdout, stderr } = await execFileAsync(
155
+ binPath,
156
+ ['resource', composeFilePath, '--region', region, '--out', '.'],
157
+ { cwd: parentDir }
158
+ );
159
+
160
+ if (stderr) {
161
+ await logAndProgress(
162
+ `run-compose resource stderr: ${stderr}`,
163
+ progressCallback,
164
+ 'warn'
165
+ );
166
+ }
167
+ return stdout;
168
+ } catch (error) {
169
+ const errorMsg = `Failed to get resources for compose file: ${error.message}`;
170
+ await logAndProgress(errorMsg, progressCallback, 'error');
171
+ throw new Error(errorMsg);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Translates a compose file using the run-compose binary.
177
+ *
178
+ * @param {string} binPath - Path to the run-compose binary.
179
+ * @param {string} composeFilePath - Path to the compose.yaml file.
180
+ * @param {string} region - The Google Cloud region.
181
+ * @param {string} projectNumber - The Google Cloud project number.
182
+ * @param {function} progressCallback - Optional callback for progress updates.
183
+ * @param {object} [resourcesConfig] - Optional resource configuration.
184
+ * @returns {Promise<string>} The output from the translation command.
185
+ */
186
+ export async function translateCompose(
187
+ binPath,
188
+ composeFilePath,
189
+ region,
190
+ projectNumber,
191
+ progressCallback,
192
+ resourcesConfig = null
193
+ ) {
194
+ try {
195
+ const parentDir = path.dirname(composeFilePath);
196
+ await logAndProgress(
197
+ `Translating compose file ${composeFilePath}...`,
198
+ progressCallback
199
+ );
200
+ const args = [
201
+ 'translate',
202
+ composeFilePath,
203
+ '--region',
204
+ region,
205
+ '--project-number',
206
+ projectNumber,
207
+ '--out',
208
+ '.',
209
+ ];
210
+
211
+ if (resourcesConfig) {
212
+ const configStr = JSON.stringify(resourcesConfig);
213
+ args.push('--resources-config', configStr);
214
+ }
215
+
216
+ await logAndProgress(
217
+ `Running command: "${binPath}" ${args.join(' ')}`,
218
+ progressCallback,
219
+ 'debug'
220
+ );
221
+
222
+ const { stdout, stderr } = await execFileAsync(binPath, args, {
223
+ cwd: parentDir,
224
+ });
225
+
226
+ if (stderr) {
227
+ await logAndProgress(
228
+ `run-compose translate stderr: ${stderr}`,
229
+ progressCallback,
230
+ 'warn'
231
+ );
232
+ }
233
+
234
+ await logAndProgress(
235
+ 'Compose file translated successfully.',
236
+ progressCallback
237
+ );
238
+ return stdout;
239
+ } catch (error) {
240
+ const errorMsg = `Failed to translate compose file: ${error.message}`;
241
+ await logAndProgress(errorMsg, progressCallback, 'error');
242
+ throw new Error(errorMsg);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Handle volumes defined in resourcesConfig (bind volumes and named volumes)
248
+ * @param {Object} resourcesConfig - resourcesConfig received from `run-compose resource` call
249
+ * @param {string} accessToken - access token for authentication
250
+ * @param {string} projectId - GCP project id
251
+ * @param {string} region - GCP region
252
+ * @param {string} folderPath - Path to the folder containing the source code
253
+ * @param {function} progressCallback - Callback function to report progress
254
+ * @returns {Promise<Object>} - Completed resourcesConfig object
255
+ */
256
+ export async function composeVolumes(
257
+ resourcesConfig,
258
+ accessToken,
259
+ projectId,
260
+ region,
261
+ folderPath,
262
+ progressCallback
263
+ ) {
264
+ const hasBindVolumes =
265
+ resourcesConfig.volumes && resourcesConfig.volumes.bind_mount;
266
+ const hasNamedVolumes =
267
+ resourcesConfig.volumes && resourcesConfig.volumes.named_volume;
268
+
269
+ if (hasBindVolumes || hasNamedVolumes) {
270
+ const projectNumber = await getProjectNumber(projectId, accessToken);
271
+ const projectName = resourcesConfig.project;
272
+ const sanitizedComposeProjectName = projectName
273
+ .toLowerCase()
274
+ .replace(/[^a-z0-9]/g, '-')
275
+ .replace(/-+/g, '-');
276
+
277
+ const bucketNameCandidate = `${projectNumber}-${sanitizedComposeProjectName}-${region}-${COMPOSE_SUFFIX}`;
278
+ let bucketName = bucketNameCandidate;
279
+ if (bucketNameCandidate.length > _MAX_BUCKET_NAME_LENGTH) {
280
+ const projectHash = crypto
281
+ .createHash(SHA1)
282
+ .update(sanitizedComposeProjectName)
283
+ .digest(HEX_ENCODING)
284
+ .slice(0, _HASH_LENGTH);
285
+ bucketName = `${projectNumber}-${projectHash}-${region}-${COMPOSE_SUFFIX}`;
286
+ }
287
+
288
+ resourcesConfig.volumes = resourcesConfig.volumes || {};
289
+ resourcesConfig.volumes.bucket_name = bucketName;
290
+
291
+ const computeServiceAccount = `serviceAccount:${projectNumber}-compute@developer.gserviceaccount.com`;
292
+
293
+ const labels = {
294
+ [RUN_COMPOSE.LABEL_MANAGED_BY]: RUN_COMPOSE.LABEL_MANAGED_BY_VALUE,
295
+ [RUN_COMPOSE.LABEL_PROJECT]: sanitizeLabelValue(projectName),
296
+ };
297
+
298
+ // Explicitly ensure the bucket exists in the project
299
+ const bucket = await ensureStorageBucketExists(
300
+ projectId,
301
+ bucketName,
302
+ region,
303
+ accessToken,
304
+ labels,
305
+ false,
306
+ progressCallback
307
+ );
308
+
309
+ await grantBucketAccess(
310
+ bucket,
311
+ 'roles/storage.objectAdmin',
312
+ computeServiceAccount,
313
+ progressCallback
314
+ );
315
+
316
+ if (hasBindVolumes) {
317
+ await handleBindMounts(
318
+ resourcesConfig,
319
+ bucket,
320
+ folderPath,
321
+ progressCallback
322
+ );
323
+ }
324
+
325
+ //Nothing special needs to be done for named volume.
326
+ }
327
+
328
+ return resourcesConfig;
329
+ }
330
+
331
+ /**
332
+ * Handles uploading and configuring bind mounts for Cloud Run Compose.
333
+ * @param {Object} resourcesConfig - The resource configuration to update.
334
+ * @param {string} bucket - Volume bucket.
335
+ * @param {string} folderPath - Local path to the compose project.
336
+ * @param {Function} progressCallback - Callback for progress updates.
337
+ */
338
+ async function handleBindMounts(
339
+ resourcesConfig,
340
+ bucket,
341
+ folderPath,
342
+ progressCallback
343
+ ) {
344
+ const bucketName = bucket.name;
345
+ const bindMountsData = resourcesConfig.volumes.bind_mount;
346
+
347
+ // Handle both Map (serviceName -> mounts) and potential direct mounts list
348
+ const entries = Array.isArray(bindMountsData)
349
+ ? [['direct', bindMountsData]]
350
+ : Object.entries(bindMountsData);
351
+
352
+ for (const [serviceName, mounts] of entries) {
353
+ // Ensure mounts is an array
354
+ const mountsArray = Array.isArray(mounts) ? mounts : [mounts];
355
+
356
+ for (const mount of mountsArray) {
357
+ const source = mount.source || mount.path; // handle both naming conventions
358
+ if (!source) continue;
359
+
360
+ const fullSourcePath = path.resolve(folderPath, source);
361
+
362
+ try {
363
+ await fsPromises.access(fullSourcePath);
364
+ } catch {
365
+ const errorMsg = `Bind mount source '${source}' for service '${serviceName}' does not exist.`;
366
+ await logAndProgress(errorMsg, progressCallback, 'error');
367
+ throw new Error(errorMsg);
368
+ }
369
+
370
+ let gcsPath;
371
+ const relativeSourcePath = path.relative(folderPath, fullSourcePath);
372
+
373
+ if (relativeSourcePath === '' || relativeSourcePath === '.') {
374
+ gcsPath = `bind_mounts/${serviceName}`;
375
+ } else {
376
+ const sourceBasename = path.basename(fullSourcePath);
377
+ gcsPath = `bind_mounts/${serviceName}/${sourceBasename}`;
378
+ }
379
+
380
+ mount.mount_source = gcsPath;
381
+ // Also update volume_id for backward compatibility if needed
382
+ mount.volume_id = `gs://${bucketName}/${gcsPath}`;
383
+
384
+ const stats = await fsPromises.stat(fullSourcePath);
385
+ if (stats.isDirectory()) {
386
+ await logAndProgress(
387
+ `Uploading bind mount directory ${source} for service ${serviceName} to gs://${bucketName}/${gcsPath}/...`,
388
+ progressCallback
389
+ );
390
+ await uploadDirectory(
391
+ bucket,
392
+ fullSourcePath,
393
+ gcsPath,
394
+ progressCallback
395
+ );
396
+ } else {
397
+ await logAndProgress(
398
+ `Uploading bind mount file ${source} for service ${serviceName} to gs://${bucketName}/${gcsPath}...`,
399
+ progressCallback
400
+ );
401
+ const fileBuffer = await fsPromises.readFile(fullSourcePath);
402
+ await uploadToStorageBucket(
403
+ bucket,
404
+ fileBuffer,
405
+ gcsPath,
406
+ progressCallback
407
+ );
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Handle secrets defined in resourcesConfig.
415
+ * @param {Object} resourcesConfig - resourcesConfig received from `run-compose resource` call.
416
+ * @param {string} accessToken - access token for authentication.
417
+ * @param {string} projectId - GCP project ID.
418
+ * @param {string} folderPath - Path to the folder containing the source code.
419
+ * @param {function} progressCallback - Callback function to report progress.
420
+ * @returns {Promise<Object>} - Completed resourcesConfig object.
421
+ */
422
+ export async function composeSecrets(
423
+ resourcesConfig,
424
+ accessToken,
425
+ projectId,
426
+ folderPath,
427
+ progressCallback
428
+ ) {
429
+ if (
430
+ !resourcesConfig.secrets ||
431
+ Object.keys(resourcesConfig.secrets).length === 0
432
+ ) {
433
+ return resourcesConfig;
434
+ }
435
+
436
+ // Ensure Secret Manager API is enabled
437
+ await ensureApisEnabled(
438
+ projectId,
439
+ ['secretmanager.googleapis.com'],
440
+ accessToken,
441
+ progressCallback
442
+ );
443
+
444
+ const projectNumber = await getProjectNumber(projectId, accessToken);
445
+ const computeServiceAccount = `${projectNumber}-compute@developer.gserviceaccount.com`;
446
+ const projectName = resourcesConfig.project;
447
+ const labels = {
448
+ [RUN_COMPOSE.LABEL_MANAGED_BY]: RUN_COMPOSE.LABEL_MANAGED_BY_VALUE,
449
+ [RUN_COMPOSE.LABEL_PROJECT]: sanitizeLabelValue(projectName),
450
+ };
451
+
452
+ const secretEntries = Object.entries(resourcesConfig.secrets);
453
+
454
+ for (const [secretName, secretConfig] of secretEntries) {
455
+ // Expected structure in secretConfig: { name, file, mount }
456
+ if (!secretConfig.name || !secretConfig.file || !secretConfig.mount) {
457
+ await logAndProgress(
458
+ `Secret configuration for ${secretName} is incomplete. 'name', 'file' and 'mount' are required.`,
459
+ progressCallback,
460
+ 'warn'
461
+ );
462
+ continue;
463
+ }
464
+
465
+ const fullFilePath = path.resolve(folderPath, secretConfig.file);
466
+ try {
467
+ await fsPromises.access(fullFilePath);
468
+ } catch {
469
+ const errorMsg = `Secret file not found: ${fullFilePath}`;
470
+ await logAndProgress(errorMsg, progressCallback, 'error');
471
+ throw new Error(errorMsg);
472
+ }
473
+
474
+ const secretId = secretConfig.name;
475
+
476
+ // Check if secret exists
477
+ let secret = await getSecret(projectId, secretId, accessToken);
478
+ if (!secret) {
479
+ secret = await createSecret(
480
+ projectId,
481
+ secretId,
482
+ accessToken,
483
+ labels,
484
+ progressCallback
485
+ );
486
+ }
487
+
488
+ // Add IAM policy binding
489
+ await addSecretAccessorBinding(
490
+ projectId,
491
+ secretId,
492
+ `serviceAccount:${computeServiceAccount}`,
493
+ accessToken,
494
+ progressCallback
495
+ );
496
+
497
+ // Add secret version
498
+ const secretContent = await fsPromises.readFile(fullFilePath);
499
+ const version = await addSecretVersion(
500
+ projectId,
501
+ secretId,
502
+ secretContent,
503
+ accessToken,
504
+ progressCallback
505
+ );
506
+
507
+ // Update secret_version in config
508
+ secretConfig.secret_version = version.name;
509
+
510
+ await logAndProgress(
511
+ `Secret '${secretId}' version '${version.name}' prepared for usage.`,
512
+ progressCallback
513
+ );
514
+ }
515
+
516
+ return resourcesConfig;
517
+ }
@@ -21,12 +21,14 @@ export const DEPLOYMENT_CONFIG = {
21
21
  IMAGE_TAG: 'latest',
22
22
  LABEL_CREATED_BY: 'cloud-run-mcp',
23
23
  DEFAULT_NODE_BASE_IMAGE: 'nodejs22',
24
+ DEFAULT_PYTHON_BASE_IMAGE: 'python314',
24
25
  NO_BUILD_IMAGE_TYPE: 'scratch',
25
26
  };
26
27
 
27
28
  export const TEMP_PATHS = {
28
29
  BASE: '.cloud-run-mcp',
29
30
  SUBDIR: 'source',
31
+ BIN_SUBDIR: 'bin',
30
32
  };
31
33
 
32
34
  export const REQUIRED_APIS = {
@@ -49,6 +51,15 @@ export const DEPLOYMENT_TYPES = {
49
51
 
50
52
  export const RUNTIMES = {
51
53
  NODEJS: 'nodejs',
54
+ PYTHON: 'python',
52
55
  };
53
56
 
54
57
  export const MAX_ALLOWED_DIRECT_SOURCE_SIZE_BYTES = 250 * 1024 * 1024; // 250 MiB
58
+
59
+ export const RUN_COMPOSE = {
60
+ BIN: 'run-compose',
61
+ VERSION: '1.0.0',
62
+ LABEL_MANAGED_BY: 'managed-by',
63
+ LABEL_MANAGED_BY_VALUE: 'runcompose',
64
+ LABEL_PROJECT: 'run-compose-project',
65
+ };