@google-cloud/cloud-run-mcp 1.10.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 (47) 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/lib/clients.js +104 -3
  14. package/lib/cloud-api/build.js +192 -28
  15. package/lib/cloud-api/run.js +62 -0
  16. package/lib/cloud-api/secrets.js +196 -0
  17. package/lib/cloud-api/storage.js +164 -16
  18. package/lib/deployment/compose.js +517 -0
  19. package/lib/deployment/constants.js +8 -0
  20. package/lib/deployment/deployer.js +283 -28
  21. package/lib/deployment/helpers.js +58 -1
  22. package/lib/deployment/universal-maker.js +21 -148
  23. package/lib/util/archive.js +3 -3
  24. package/lib/util/artifacts.js +189 -0
  25. package/lib/util/helpers.js +109 -0
  26. package/mcp-server.js +4 -0
  27. package/package.json +6 -3
  28. package/skills/cloud-run/SKILL.md +109 -0
  29. package/test/local/archive.test.js +88 -0
  30. package/test/local/clients.test.js +224 -2
  31. package/test/local/cloud-api/build.test.js +330 -25
  32. package/test/local/cloud-api/run.test.js +129 -0
  33. package/test/local/cloud-api/secrets.test.js +291 -0
  34. package/test/local/cloud-api/storage.test.js +405 -0
  35. package/test/local/compose.test.js +618 -0
  36. package/test/local/deploy.test.js +408 -0
  37. package/test/local/deployment-helpers.test.js +100 -1
  38. package/test/local/test-utils.js +2 -18
  39. package/test/local/tools.test.js +43 -1
  40. package/test/local/universal-maker.test.js +23 -114
  41. package/test/local/util.test.js +361 -0
  42. package/test/need-gcp/deployer.test.js +1 -1
  43. package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
  44. package/tools/register-tools.js +19 -3
  45. /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
  46. /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
  47. /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,
@@ -43,6 +49,18 @@ import {
43
49
  REQUIRED_APIS,
44
50
  RUNTIMES,
45
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';
46
64
 
47
65
  /**
48
66
  * Deploys or updates a Cloud Run service with the specified container image.
@@ -69,7 +87,8 @@ async function deployToCloudRun(
69
87
  deploymentType,
70
88
  accessToken,
71
89
  zippedSourceContainer,
72
- runtime
90
+ runtime,
91
+ ingress
73
92
  ) {
74
93
  const runClient = await getRunClient(projectId, accessToken);
75
94
  const parent = runClient.locationPath(projectId, location);
@@ -91,7 +110,11 @@ async function deployToCloudRun(
91
110
  'deployment-type': deploymentType,
92
111
  runtime: runtime,
93
112
  },
113
+ client: DEPLOYMENT_CONFIG.LABEL_CREATED_BY,
94
114
  };
115
+ if (ingress) {
116
+ service.ingress = ingress;
117
+ }
95
118
 
96
119
  // Conditionally set invokerIamDisabled based on the skipIamCheck flag
97
120
  if (skipIamCheck) {
@@ -249,7 +272,8 @@ async function deployWithZip(
249
272
  deploymentAttrs,
250
273
  progressCallback,
251
274
  skipIamCheck,
252
- accessToken
275
+ accessToken,
276
+ ingress
253
277
  ) {
254
278
  await logAndProgress(
255
279
  `Attempting direct source deployment...`,
@@ -292,10 +316,12 @@ async function deployWithZip(
292
316
  bucketName,
293
317
  region,
294
318
  accessToken,
319
+ undefined,
320
+ false,
295
321
  progressCallback
296
322
  );
297
323
 
298
- await uploadToStorageBucket(
324
+ const file = await uploadToStorageBucket(
299
325
  bucket,
300
326
  archiveBuffer,
301
327
  archiveName,
@@ -304,7 +330,7 @@ async function deployWithZip(
304
330
  await logAndProgress('Source code uploaded successfully', progressCallback);
305
331
 
306
332
  const container = createDirectSourceDeploymentContainer({
307
- bucketName,
333
+ bucketName: bucket.name,
308
334
  fileName: archiveName,
309
335
  deploymentAttrs,
310
336
  });
@@ -319,9 +345,14 @@ async function deployWithZip(
319
345
  DEPLOYMENT_TYPES.NO_BUILD,
320
346
  accessToken,
321
347
  container,
322
- deploymentAttrs.runtime
348
+ deploymentAttrs.runtime,
349
+ ingress
323
350
  );
324
351
 
352
+ await logAndProgress(
353
+ `Deployment completed successfully with zip deploy`,
354
+ progressCallback
355
+ );
325
356
  return service;
326
357
  } finally {
327
358
  await cleanupTempDirectory(tempDir);
@@ -392,7 +423,8 @@ async function deployWithBuild(
392
423
  bucketName,
393
424
  progressCallback,
394
425
  skipIamCheck,
395
- accessToken
426
+ accessToken,
427
+ ingress
396
428
  ) {
397
429
  const imageUrl = `${region}-docker.pkg.dev/${projectId}/${DEPLOYMENT_CONFIG.REPO_NAME}/${serviceName}:${DEPLOYMENT_CONFIG.IMAGE_TAG}`;
398
430
 
@@ -401,13 +433,15 @@ async function deployWithBuild(
401
433
  bucketName,
402
434
  region,
403
435
  accessToken,
436
+ undefined,
437
+ false,
404
438
  progressCallback
405
439
  );
406
440
 
407
441
  const archiveBuffer = await zipFiles(files, false, progressCallback);
408
442
  const archiveName = DEPLOYMENT_CONFIG.ZIP_FILE_NAME;
409
443
 
410
- await uploadToStorageBucket(
444
+ const file = await uploadToStorageBucket(
411
445
  bucket,
412
446
  archiveBuffer,
413
447
  archiveName,
@@ -429,13 +463,14 @@ async function deployWithBuild(
429
463
  const buildResult = await triggerCloudBuild(
430
464
  projectId,
431
465
  region,
432
- bucketName,
466
+ bucket.name,
433
467
  archiveName,
434
468
  DEPLOYMENT_CONFIG.REPO_NAME,
435
469
  imageUrl,
436
470
  hasDockerfile,
437
471
  accessToken,
438
- progressCallback
472
+ progressCallback,
473
+ ingress
439
474
  );
440
475
 
441
476
  const builtImageUrl = buildResult.results.images[0].name;
@@ -448,10 +483,16 @@ async function deployWithBuild(
448
483
  progressCallback,
449
484
  skipIamCheck,
450
485
  DEPLOYMENT_TYPES.WITH_BUILD,
451
- accessToken
486
+ accessToken,
487
+ undefined,
488
+ undefined,
489
+ ingress
452
490
  );
453
491
 
454
- await logAndProgress(`Deployment completed successfully`, progressCallback);
492
+ await logAndProgress(
493
+ `Deployment completed successfully with source build deploy`,
494
+ progressCallback
495
+ );
455
496
  return service;
456
497
  }
457
498
 
@@ -474,6 +515,7 @@ export async function deploy({
474
515
  progressCallback,
475
516
  skipIamCheck,
476
517
  accessToken,
518
+ ingress,
477
519
  }) {
478
520
  if (!projectId) {
479
521
  const errorMsg =
@@ -482,13 +524,6 @@ export async function deploy({
482
524
  throw new Error(errorMsg);
483
525
  }
484
526
 
485
- if (!serviceName) {
486
- const errorMsg =
487
- 'Error: serviceName is required in the configuration object.';
488
- await logAndProgress(errorMsg, progressCallback, 'error');
489
- throw new Error(errorMsg);
490
- }
491
-
492
527
  if (!files || !Array.isArray(files) || files.length === 0) {
493
528
  const errorMsg =
494
529
  'Error: files array is required in the configuration object.';
@@ -500,6 +535,30 @@ export async function deploy({
500
535
  }
501
536
  }
502
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
+
503
562
  try {
504
563
  await ensureApisEnabled(
505
564
  projectId,
@@ -515,10 +574,6 @@ export async function deploy({
515
574
  await logAndProgress(`Service Name: ${serviceName}`, progressCallback);
516
575
  await logAndProgress(`Files to deploy: ${files.length}`, progressCallback);
517
576
 
518
- // Determines if we can use the direct source deployment (no Cloud Build)
519
- const { hasDockerfile, deploymentAttrs } =
520
- makeFileDeploymentMetadata(files);
521
-
522
577
  await logAndProgress(`Dockerfile: ${hasDockerfile}`, progressCallback);
523
578
 
524
579
  if (canDeployWithoutBuild({ hasDockerfile, deploymentAttrs })) {
@@ -532,7 +587,8 @@ export async function deploy({
532
587
  deploymentAttrs,
533
588
  progressCallback,
534
589
  skipIamCheck,
535
- accessToken
590
+ accessToken,
591
+ ingress
536
592
  );
537
593
  } catch (error) {
538
594
  await logAndProgress(
@@ -552,7 +608,8 @@ export async function deploy({
552
608
  bucketName,
553
609
  progressCallback,
554
610
  skipIamCheck,
555
- accessToken
611
+ accessToken,
612
+ ingress
556
613
  );
557
614
  } catch (error) {
558
615
  const deployFailedMessage = `Deployment Failed: ${error.message}`;
@@ -582,6 +639,7 @@ export async function deployImage({
582
639
  progressCallback,
583
640
  skipIamCheck,
584
641
  accessToken,
642
+ ingress,
585
643
  }) {
586
644
  if (!projectId) {
587
645
  const errorMsg =
@@ -628,7 +686,10 @@ export async function deployImage({
628
686
  progressCallback,
629
687
  skipIamCheck,
630
688
  DEPLOYMENT_TYPES.IMAGE,
631
- accessToken
689
+ accessToken,
690
+ undefined,
691
+ undefined,
692
+ ingress
632
693
  );
633
694
 
634
695
  await logAndProgress(`Deployment completed successfully`, progressCallback);
@@ -640,3 +701,197 @@ export async function deployImage({
640
701
  throw error;
641
702
  }
642
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
+ }
@@ -15,8 +15,9 @@ limitations under the License.
15
15
  */
16
16
 
17
17
  import path from 'path';
18
- import fs from 'fs';
18
+ import fs, { promises as fsPromises } from 'fs';
19
19
  import { DEPLOYMENT_CONFIG, RUNTIMES } from './constants.js';
20
+ import { uploadToStorageBucket } from '../cloud-api/storage.js';
20
21
 
21
22
  /**
22
23
  * Checks if the input is a single folder path.
@@ -61,6 +62,30 @@ function checkIfDockerFileExists(files) {
61
62
  return false;
62
63
  }
63
64
 
65
+ /**
66
+ * Checks if a compose file exists in the provided files array.
67
+ * @param {Array<string|Object>} files - An array of file paths or objects with filename properties.
68
+ * @returns {string|null} - Returns the path/filename of the compose file if found, null otherwise.
69
+ */
70
+ function checkIfComposeFileExists(files) {
71
+ const composeFiles = [
72
+ 'compose.yaml',
73
+ 'compose.yml',
74
+ 'docker-compose.yaml',
75
+ 'docker-compose.yml',
76
+ ];
77
+ if (isFolder(files)) {
78
+ for (const name of composeFiles) {
79
+ const fullPath = path.join(files[0], name);
80
+ if (fs.existsSync(fullPath)) {
81
+ return fullPath;
82
+ }
83
+ }
84
+ }
85
+ // TODO: we do not handle file content deployment at present as run-compose binary does not support it
86
+ return null;
87
+ }
88
+
64
89
  /**
65
90
  * Checks if the provided folder appears to be a Node.js project.
66
91
  * It looks for a `package.json` file in the root of the first path in the `files` array.
@@ -132,6 +157,7 @@ function getDeploymentAttrs(files) {
132
157
  export function makeFileDeploymentMetadata(files) {
133
158
  return {
134
159
  hasDockerfile: checkIfDockerFileExists(files),
160
+ composeFilePath: checkIfComposeFileExists(files),
135
161
  deploymentAttrs: getDeploymentAttrs(files),
136
162
  };
137
163
  }
@@ -185,3 +211,34 @@ export function createDirectSourceDeploymentContainer({
185
211
 
186
212
  return container;
187
213
  }
214
+
215
+ /**
216
+ * Uploads a local directory to a GCS bucket recursively.
217
+ * @param {*} bucket - GCS bucket object
218
+ * @param {string} localPath - Local directory path
219
+ * @param {string} gcsPrefix - GCS prefix (blob name starting part)
220
+ * @param {function} progressCallback - Callback for progress
221
+ */
222
+ export async function uploadDirectory(
223
+ bucket,
224
+ localPath,
225
+ gcsPrefix,
226
+ progressCallback
227
+ ) {
228
+ const entries = await fsPromises.readdir(localPath, { withFileTypes: true });
229
+ for (const entry of entries) {
230
+ const fullPath = path.join(localPath, entry.name);
231
+ const destination = `${gcsPrefix}/${entry.name}`;
232
+ if (entry.isDirectory()) {
233
+ await uploadDirectory(bucket, fullPath, destination, progressCallback);
234
+ } else {
235
+ const buffer = await fsPromises.readFile(fullPath);
236
+ await uploadToStorageBucket(
237
+ bucket,
238
+ buffer,
239
+ destination,
240
+ progressCallback
241
+ );
242
+ }
243
+ }
244
+ }