@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
@@ -22,9 +22,15 @@ import {
22
22
  uploadToStorageBucket,
23
23
  } from '../cloud-api/storage.js';
24
24
  import { ensureArtifactRegistryRepoExists } from '../cloud-api/registry.js';
25
- import { triggerCloudBuild } from '../cloud-api/build.js';
26
- import { logAndProgress } from '../util/helpers.js';
27
- import { checkCloudRunServiceExists } from '../cloud-api/run.js';
25
+ import { triggerCloudBuild, composeBuild } from '../cloud-api/build.js';
26
+ import {
27
+ logAndProgress,
28
+ sanitizeCloudRunServiceName,
29
+ } from '../util/helpers.js';
30
+ import {
31
+ checkCloudRunServiceExists,
32
+ setServicePublicAccess,
33
+ } from '../cloud-api/run.js';
28
34
  import {
29
35
  canDeployWithoutBuild,
30
36
  createDirectSourceDeploymentContainer,
@@ -32,16 +38,29 @@ import {
32
38
  } from './helpers.js';
33
39
  import {
34
40
  prepareSourceDirectory,
35
- installDependencies,
36
41
  cleanupTempDirectory,
37
42
  } from './source-processor.js';
43
+ import { runUniversalMaker } from './universal-maker.js';
38
44
 
39
45
  import {
40
46
  DEPLOYMENT_CONFIG,
41
47
  DEPLOYMENT_TYPES,
42
48
  MAX_ALLOWED_DIRECT_SOURCE_SIZE_BYTES,
43
49
  REQUIRED_APIS,
50
+ RUNTIMES,
44
51
  } from './constants.js';
52
+ import path from 'path';
53
+ import fs from 'fs';
54
+ import yaml from 'js-yaml';
55
+ import { getRunV1Client } from '../clients.js';
56
+ import {
57
+ runCompose as downloadRunCompose,
58
+ resourceCompose,
59
+ translateCompose,
60
+ composeVolumes,
61
+ composeSecrets,
62
+ } from './compose.js';
63
+ import { getProjectNumber } from '../util/helpers.js';
45
64
 
46
65
  /**
47
66
  * Deploys or updates a Cloud Run service with the specified container image.
@@ -68,7 +87,8 @@ async function deployToCloudRun(
68
87
  deploymentType,
69
88
  accessToken,
70
89
  zippedSourceContainer,
71
- runtime
90
+ runtime,
91
+ ingress
72
92
  ) {
73
93
  const runClient = await getRunClient(projectId, accessToken);
74
94
  const parent = runClient.locationPath(projectId, location);
@@ -90,7 +110,11 @@ async function deployToCloudRun(
90
110
  'deployment-type': deploymentType,
91
111
  runtime: runtime,
92
112
  },
113
+ client: DEPLOYMENT_CONFIG.LABEL_CREATED_BY,
93
114
  };
115
+ if (ingress) {
116
+ service.ingress = ingress;
117
+ }
94
118
 
95
119
  // Conditionally set invokerIamDisabled based on the skipIamCheck flag
96
120
  if (skipIamCheck) {
@@ -248,7 +272,8 @@ async function deployWithZip(
248
272
  deploymentAttrs,
249
273
  progressCallback,
250
274
  skipIamCheck,
251
- accessToken
275
+ accessToken,
276
+ ingress
252
277
  ) {
253
278
  await logAndProgress(
254
279
  `Attempting direct source deployment...`,
@@ -259,7 +284,18 @@ async function deployWithZip(
259
284
 
260
285
  try {
261
286
  tempDir = await prepareSourceDirectory(files);
262
- await installDependencies(tempDir, deploymentAttrs);
287
+
288
+ if (
289
+ deploymentAttrs.runtime === RUNTIMES.NODEJS ||
290
+ deploymentAttrs.runtime === RUNTIMES.PYTHON
291
+ ) {
292
+ deploymentAttrs = await updateDeploymentAttrsWithUniversalMaker(
293
+ tempDir,
294
+ deploymentAttrs,
295
+ accessToken,
296
+ progressCallback
297
+ );
298
+ }
263
299
 
264
300
  const archiveBuffer = await zipFiles([tempDir], true, progressCallback);
265
301
  const archiveName = DEPLOYMENT_CONFIG.TARGZ_FILE_NAME;
@@ -280,10 +316,12 @@ async function deployWithZip(
280
316
  bucketName,
281
317
  region,
282
318
  accessToken,
319
+ undefined,
320
+ false,
283
321
  progressCallback
284
322
  );
285
323
 
286
- await uploadToStorageBucket(
324
+ const file = await uploadToStorageBucket(
287
325
  bucket,
288
326
  archiveBuffer,
289
327
  archiveName,
@@ -292,7 +330,7 @@ async function deployWithZip(
292
330
  await logAndProgress('Source code uploaded successfully', progressCallback);
293
331
 
294
332
  const container = createDirectSourceDeploymentContainer({
295
- bucketName,
333
+ bucketName: bucket.name,
296
334
  fileName: archiveName,
297
335
  deploymentAttrs,
298
336
  });
@@ -307,15 +345,57 @@ async function deployWithZip(
307
345
  DEPLOYMENT_TYPES.NO_BUILD,
308
346
  accessToken,
309
347
  container,
310
- deploymentAttrs.runtime
348
+ deploymentAttrs.runtime,
349
+ ingress
311
350
  );
312
351
 
352
+ await logAndProgress(
353
+ `Deployment completed successfully with zip deploy`,
354
+ progressCallback
355
+ );
313
356
  return service;
314
357
  } finally {
315
358
  await cleanupTempDirectory(tempDir);
316
359
  }
317
360
  }
318
361
 
362
+ /**
363
+ * Updates deployment attributes using result from Universal Maker.
364
+ *
365
+ * @async
366
+ * @param {string} tempDir - Path to the temporary source directory.
367
+ * @param {object} deploymentAttrs - The initial deployment attributes.
368
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
369
+ * @returns {Promise<object>} A promise that resolves with the updated deployment attributes.
370
+ */
371
+ async function updateDeploymentAttrsWithUniversalMaker(
372
+ tempDir,
373
+ deploymentAttrs,
374
+ accessToken,
375
+ progressCallback
376
+ ) {
377
+ const umResult = await runUniversalMaker(
378
+ tempDir,
379
+ accessToken,
380
+ progressCallback
381
+ );
382
+ if (umResult) {
383
+ await logAndProgress(
384
+ 'Updating deployment attributes using Universal Maker results.',
385
+ progressCallback
386
+ );
387
+ return {
388
+ ...deploymentAttrs,
389
+ baseImage: umResult.runtime || deploymentAttrs.baseImage,
390
+ runtime: umResult.runtime || deploymentAttrs.runtime,
391
+ cmd: umResult.command ? [umResult.command] : deploymentAttrs.cmd,
392
+ args: umResult.args || deploymentAttrs.args,
393
+ envVars: umResult.envVars || {},
394
+ };
395
+ }
396
+ return deploymentAttrs;
397
+ }
398
+
319
399
  /**
320
400
  * Handles deployment using Google Cloud Build.
321
401
  * This function zips the source code, uploads it to Google Cloud Storage,
@@ -343,7 +423,8 @@ async function deployWithBuild(
343
423
  bucketName,
344
424
  progressCallback,
345
425
  skipIamCheck,
346
- accessToken
426
+ accessToken,
427
+ ingress
347
428
  ) {
348
429
  const imageUrl = `${region}-docker.pkg.dev/${projectId}/${DEPLOYMENT_CONFIG.REPO_NAME}/${serviceName}:${DEPLOYMENT_CONFIG.IMAGE_TAG}`;
349
430
 
@@ -352,13 +433,15 @@ async function deployWithBuild(
352
433
  bucketName,
353
434
  region,
354
435
  accessToken,
436
+ undefined,
437
+ false,
355
438
  progressCallback
356
439
  );
357
440
 
358
441
  const archiveBuffer = await zipFiles(files, false, progressCallback);
359
442
  const archiveName = DEPLOYMENT_CONFIG.ZIP_FILE_NAME;
360
443
 
361
- await uploadToStorageBucket(
444
+ const file = await uploadToStorageBucket(
362
445
  bucket,
363
446
  archiveBuffer,
364
447
  archiveName,
@@ -380,13 +463,14 @@ async function deployWithBuild(
380
463
  const buildResult = await triggerCloudBuild(
381
464
  projectId,
382
465
  region,
383
- bucketName,
466
+ bucket.name,
384
467
  archiveName,
385
468
  DEPLOYMENT_CONFIG.REPO_NAME,
386
469
  imageUrl,
387
470
  hasDockerfile,
388
471
  accessToken,
389
- progressCallback
472
+ progressCallback,
473
+ ingress
390
474
  );
391
475
 
392
476
  const builtImageUrl = buildResult.results.images[0].name;
@@ -399,10 +483,16 @@ async function deployWithBuild(
399
483
  progressCallback,
400
484
  skipIamCheck,
401
485
  DEPLOYMENT_TYPES.WITH_BUILD,
402
- accessToken
486
+ accessToken,
487
+ undefined,
488
+ undefined,
489
+ ingress
403
490
  );
404
491
 
405
- await logAndProgress(`Deployment completed successfully`, progressCallback);
492
+ await logAndProgress(
493
+ `Deployment completed successfully with source build deploy`,
494
+ progressCallback
495
+ );
406
496
  return service;
407
497
  }
408
498
 
@@ -425,6 +515,7 @@ export async function deploy({
425
515
  progressCallback,
426
516
  skipIamCheck,
427
517
  accessToken,
518
+ ingress,
428
519
  }) {
429
520
  if (!projectId) {
430
521
  const errorMsg =
@@ -433,13 +524,6 @@ export async function deploy({
433
524
  throw new Error(errorMsg);
434
525
  }
435
526
 
436
- if (!serviceName) {
437
- const errorMsg =
438
- 'Error: serviceName is required in the configuration object.';
439
- await logAndProgress(errorMsg, progressCallback, 'error');
440
- throw new Error(errorMsg);
441
- }
442
-
443
527
  if (!files || !Array.isArray(files) || files.length === 0) {
444
528
  const errorMsg =
445
529
  'Error: files array is required in the configuration object.';
@@ -451,6 +535,30 @@ export async function deploy({
451
535
  }
452
536
  }
453
537
 
538
+ // Determines if we can use the direct source deployment (no Cloud Build) or compose deployment
539
+ const { hasDockerfile, composeFilePath, deploymentAttrs } =
540
+ makeFileDeploymentMetadata(files);
541
+
542
+ if (composeFilePath) {
543
+ return await deployCompose({
544
+ projectId,
545
+ serviceName,
546
+ region,
547
+ files,
548
+ composeFilePath,
549
+ progressCallback,
550
+ skipIamCheck,
551
+ accessToken,
552
+ });
553
+ }
554
+
555
+ if (!serviceName) {
556
+ const errorMsg =
557
+ 'Error: serviceName is required in the configuration object.';
558
+ await logAndProgress(errorMsg, progressCallback, 'error');
559
+ throw new Error(errorMsg);
560
+ }
561
+
454
562
  try {
455
563
  await ensureApisEnabled(
456
564
  projectId,
@@ -466,10 +574,6 @@ export async function deploy({
466
574
  await logAndProgress(`Service Name: ${serviceName}`, progressCallback);
467
575
  await logAndProgress(`Files to deploy: ${files.length}`, progressCallback);
468
576
 
469
- // Determines if we can use the direct source deployment (no Cloud Build)
470
- const { hasDockerfile, deploymentAttrs } =
471
- makeFileDeploymentMetadata(files);
472
-
473
577
  await logAndProgress(`Dockerfile: ${hasDockerfile}`, progressCallback);
474
578
 
475
579
  if (canDeployWithoutBuild({ hasDockerfile, deploymentAttrs })) {
@@ -483,7 +587,8 @@ export async function deploy({
483
587
  deploymentAttrs,
484
588
  progressCallback,
485
589
  skipIamCheck,
486
- accessToken
590
+ accessToken,
591
+ ingress
487
592
  );
488
593
  } catch (error) {
489
594
  await logAndProgress(
@@ -503,7 +608,8 @@ export async function deploy({
503
608
  bucketName,
504
609
  progressCallback,
505
610
  skipIamCheck,
506
- accessToken
611
+ accessToken,
612
+ ingress
507
613
  );
508
614
  } catch (error) {
509
615
  const deployFailedMessage = `Deployment Failed: ${error.message}`;
@@ -533,6 +639,7 @@ export async function deployImage({
533
639
  progressCallback,
534
640
  skipIamCheck,
535
641
  accessToken,
642
+ ingress,
536
643
  }) {
537
644
  if (!projectId) {
538
645
  const errorMsg =
@@ -579,7 +686,10 @@ export async function deployImage({
579
686
  progressCallback,
580
687
  skipIamCheck,
581
688
  DEPLOYMENT_TYPES.IMAGE,
582
- accessToken
689
+ accessToken,
690
+ undefined,
691
+ undefined,
692
+ ingress
583
693
  );
584
694
 
585
695
  await logAndProgress(`Deployment completed successfully`, progressCallback);
@@ -591,3 +701,197 @@ export async function deployImage({
591
701
  throw error;
592
702
  }
593
703
  }
704
+
705
+ /**
706
+ * Deploys services defined in a compose.yaml file to Cloud Run.
707
+ *
708
+ * @async
709
+ * @param {object} config - The deployment configuration.
710
+ * @param {string} config.projectId - The Google Cloud project ID.
711
+ * @param {string} [config.serviceName] - The name of the Cloud Run service.
712
+ * @param {string} [config.region] - The Google Cloud region for deployment.
713
+ * @param {Array<string>} config.files - An array of paths to include in the source bundle.
714
+ * @param {string} config.composeFilePath - The absolute path to the compose configuration file.
715
+ * @param {function(object): void} [config.progressCallback] - Optional callback for progress updates.
716
+ * @param {boolean} [config.skipIamCheck=false] - Whether to skip the IAM check (if true, will attempt to set public access).
717
+ * @param {string} [config.accessToken] - Access token for authentication.
718
+ * @returns {Promise<object>} A promise that resolves with the deployment result.
719
+ * @throws {Error} If deployment fails or required configuration is missing.
720
+ */
721
+ export async function deployCompose({
722
+ projectId,
723
+ serviceName,
724
+ region,
725
+ files,
726
+ composeFilePath,
727
+ progressCallback,
728
+ skipIamCheck,
729
+ accessToken,
730
+ }) {
731
+ let tempDir;
732
+ try {
733
+ tempDir = await prepareSourceDirectory(files, progressCallback);
734
+ const tempComposeFilePath = path.join(
735
+ tempDir,
736
+ path.basename(composeFilePath)
737
+ );
738
+ const folderPath = tempDir;
739
+ const projectNumber = await getProjectNumber(projectId, accessToken);
740
+ const binPath = await downloadRunCompose(accessToken, progressCallback);
741
+
742
+ // Get resource configuration
743
+ const resourceOutput = await resourceCompose(
744
+ binPath,
745
+ tempComposeFilePath,
746
+ region,
747
+ progressCallback
748
+ );
749
+ let resourcesConfig = JSON.parse(resourceOutput);
750
+ if (!serviceName) {
751
+ serviceName = resourcesConfig.project;
752
+ }
753
+
754
+ // Handle source builds if any
755
+ if (resourcesConfig.source_builds) {
756
+ resourcesConfig = await composeBuild(
757
+ resourcesConfig,
758
+ accessToken,
759
+ projectId,
760
+ region,
761
+ folderPath,
762
+ progressCallback
763
+ );
764
+ }
765
+
766
+ // Handle volumes (bind and named)
767
+ resourcesConfig = await composeVolumes(
768
+ resourcesConfig,
769
+ accessToken,
770
+ projectId,
771
+ region,
772
+ folderPath,
773
+ progressCallback
774
+ );
775
+
776
+ // Handle secrets
777
+ resourcesConfig = await composeSecrets(
778
+ resourcesConfig,
779
+ accessToken,
780
+ projectId,
781
+ folderPath,
782
+ progressCallback
783
+ );
784
+
785
+ const result = await translateCompose(
786
+ binPath,
787
+ tempComposeFilePath,
788
+ region,
789
+ projectNumber,
790
+ progressCallback,
791
+ resourcesConfig
792
+ );
793
+
794
+ const translation = JSON.parse(result);
795
+ const deploymentItems = [];
796
+
797
+ if (translation.models) {
798
+ for (const [name, yamlPath] of Object.entries(translation.models)) {
799
+ deploymentItems.push({ name, yamlPath, type: 'model' });
800
+ }
801
+ }
802
+ if (translation.services) {
803
+ for (const [name, yamlPath] of Object.entries(translation.services)) {
804
+ deploymentItems.push({ name, yamlPath, type: 'service' });
805
+ }
806
+ }
807
+
808
+ const deploymentResults = {};
809
+ const runV1Client = await getRunV1Client(projectId, accessToken, region);
810
+
811
+ for (const { name: rawName, yamlPath, type } of deploymentItems) {
812
+ const serviceName = sanitizeCloudRunServiceName(rawName);
813
+ const fullYamlPath = path.join(folderPath, yamlPath);
814
+ await logAndProgress(
815
+ `Deploying ${type} ${serviceName} using configuration from ${fullYamlPath}...`,
816
+ progressCallback
817
+ );
818
+
819
+ const serviceConfig = yaml.load(
820
+ await fs.promises.readFile(fullYamlPath, 'utf8')
821
+ );
822
+
823
+ // Ensure the metadata name is sanitized to match
824
+ serviceConfig.metadata = serviceConfig.metadata || {};
825
+ serviceConfig.metadata.name = serviceName;
826
+
827
+ // The name for replaceService in v1 is namespaces/{project}/services/{service}
828
+ const name = `namespaces/${projectId}/services/${serviceName}`;
829
+ const parent = `namespaces/${projectId}`;
830
+
831
+ let response;
832
+ try {
833
+ response = await runV1Client.namespaces.services.replaceService({
834
+ name,
835
+ requestBody: serviceConfig,
836
+ });
837
+ } catch (error) {
838
+ if (error.code === 404) {
839
+ await logAndProgress(
840
+ `${type.charAt(0).toUpperCase() + type.slice(1)} ${serviceName} does not exist. Creating it...`,
841
+ progressCallback
842
+ );
843
+ response = await runV1Client.namespaces.services.create({
844
+ parent,
845
+ requestBody: serviceConfig,
846
+ });
847
+ } else {
848
+ throw error;
849
+ }
850
+ }
851
+
852
+ await logAndProgress(
853
+ `${type.charAt(0).toUpperCase() + type.slice(1)} ${serviceName} deployed successfully.`,
854
+ progressCallback
855
+ );
856
+
857
+ // Set public access if skipIamCheck is enabled
858
+ try {
859
+ if (skipIamCheck) {
860
+ await setServicePublicAccess(
861
+ projectId,
862
+ region,
863
+ serviceName,
864
+ accessToken,
865
+ progressCallback
866
+ );
867
+ }
868
+ } catch (error) {
869
+ await logAndProgress(
870
+ `Failed to set public access for ${type} ${serviceName}: ${error.message}`,
871
+ progressCallback,
872
+ 'warn'
873
+ );
874
+ }
875
+ if (type === 'service') {
876
+ const deployedService = response.data || response;
877
+ const serviceUrl = deployedService.status?.url;
878
+
879
+ deploymentResults['service'] = serviceName;
880
+ deploymentResults['uri'] = serviceUrl;
881
+ }
882
+ }
883
+ await logAndProgress(
884
+ `Deployment (compose) completed successfully. Deployment results: ${JSON.stringify(deploymentResults)}`,
885
+ progressCallback
886
+ );
887
+ return deploymentResults;
888
+ } catch (error) {
889
+ const errorMsg = `Deployment (compose) failed: ${error.message}`;
890
+ await logAndProgress(errorMsg, progressCallback, 'error');
891
+ throw error;
892
+ } finally {
893
+ if (tempDir) {
894
+ await cleanupTempDirectory(tempDir);
895
+ }
896
+ }
897
+ }