@google-cloud/cloud-run-mcp 1.6.0 → 1.8.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.
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ import { getRunClient } from '../clients.js';
17
18
  import { callWithRetry, ensureApisEnabled } from '../cloud-api/helpers.js';
18
19
  import { zipFiles } from '../util/archive.js';
19
20
  import {
@@ -24,27 +25,23 @@ import { ensureArtifactRegistryRepoExists } from '../cloud-api/registry.js';
24
25
  import { triggerCloudBuild } from '../cloud-api/build.js';
25
26
  import { logAndProgress } from '../util/helpers.js';
26
27
  import { checkCloudRunServiceExists } from '../cloud-api/run.js';
28
+ import {
29
+ canDeployWithoutBuild,
30
+ createDirectSourceDeploymentContainer,
31
+ makeFileDeploymentMetadata,
32
+ } from './helpers.js';
33
+ import {
34
+ prepareSourceDirectory,
35
+ installDependencies,
36
+ cleanupTempDirectory,
37
+ } from './source-processor.js';
27
38
 
28
- // Configuration
29
- const REPO_NAME = 'mcp-cloud-run-deployments';
30
- const ZIP_FILE_NAME = 'source.zip';
31
- const IMAGE_TAG = 'latest';
32
-
33
- // APIs required for deploying from source code.
34
- const REQUIRED_APIS_FOR_SOURCE_DEPLOY = [
35
- 'iam.googleapis.com',
36
- 'storage.googleapis.com',
37
- 'cloudbuild.googleapis.com',
38
- 'artifactregistry.googleapis.com',
39
- 'run.googleapis.com',
40
- 'serviceusage.googleapis.com',
41
- ];
42
-
43
- // APIs required for deploying a container image.
44
- const REQUIRED_APIS_FOR_IMAGE_DEPLOY = [
45
- 'run.googleapis.com',
46
- 'serviceusage.googleapis.com',
47
- ];
39
+ import {
40
+ DEPLOYMENT_CONFIG,
41
+ DEPLOYMENT_TYPES,
42
+ MAX_ALLOWED_DIRECT_SOURCE_SIZE_BYTES,
43
+ REQUIRED_APIS,
44
+ } from './constants.js';
48
45
 
49
46
  /**
50
47
  * Deploys or updates a Cloud Run service with the specified container image.
@@ -62,29 +59,35 @@ const REQUIRED_APIS_FOR_IMAGE_DEPLOY = [
62
59
  * @throws {Error} If the deployment or update process fails.
63
60
  */
64
61
  async function deployToCloudRun(
65
- context,
66
62
  projectId,
67
63
  location,
68
64
  serviceId,
69
65
  imgUrl,
70
66
  progressCallback,
71
- skipIamCheck
67
+ skipIamCheck,
68
+ deploymentType,
69
+ zippedSourceContainer,
70
+ runtime
72
71
  ) {
73
- const parent = context.runClient.locationPath(projectId, location);
74
- const servicePath = context.runClient.servicePath(
75
- projectId,
76
- location,
77
- serviceId
78
- );
72
+ const runClient = await getRunClient(projectId);
73
+ const parent = runClient.locationPath(projectId, location);
74
+ const servicePath = runClient.servicePath(projectId, location, serviceId);
79
75
  const revisionName = `${serviceId}-${Date.now()}`; // Generate a unique revision name
80
76
 
77
+ const containers =
78
+ deploymentType === DEPLOYMENT_TYPES.NO_BUILD
79
+ ? [zippedSourceContainer]
80
+ : [{ image: imgUrl }];
81
+
81
82
  const service = {
82
83
  template: {
83
84
  revision: revisionName,
84
- containers: [{ image: imgUrl }],
85
+ containers,
85
86
  },
86
87
  labels: {
87
- 'created-by': 'cloud-run-mcp',
88
+ 'created-by': DEPLOYMENT_CONFIG.LABEL_CREATED_BY,
89
+ 'deployment-type': deploymentType,
90
+ runtime: runtime,
88
91
  },
89
92
  };
90
93
 
@@ -95,7 +98,6 @@ async function deployToCloudRun(
95
98
 
96
99
  try {
97
100
  const exists = await checkCloudRunServiceExists(
98
- context,
99
101
  projectId,
100
102
  location,
101
103
  serviceId,
@@ -115,7 +117,7 @@ async function deployToCloudRun(
115
117
  dryRunServiceConfig.name = servicePath;
116
118
  await callWithRetry(
117
119
  () =>
118
- context.runClient.updateService({
120
+ runClient.updateService({
119
121
  service: dryRunServiceConfig,
120
122
  validateOnly: true,
121
123
  }),
@@ -124,7 +126,7 @@ async function deployToCloudRun(
124
126
  } else {
125
127
  await callWithRetry(
126
128
  () =>
127
- context.runClient.createService({
129
+ runClient.createService({
128
130
  parent: parent,
129
131
  service: dryRunServiceConfig,
130
132
  serviceId: serviceId,
@@ -175,7 +177,7 @@ async function deployToCloudRun(
175
177
  );
176
178
  service.name = servicePath;
177
179
  [operation] = await callWithRetry(
178
- () => context.runClient.updateService({ service }),
180
+ () => runClient.updateService({ service }),
179
181
  `updateService ${serviceId}`
180
182
  );
181
183
  } else {
@@ -185,7 +187,7 @@ async function deployToCloudRun(
185
187
  );
186
188
  [operation] = await callWithRetry(
187
189
  () =>
188
- context.runClient.createService({
190
+ runClient.createService({
189
191
  parent: parent,
190
192
  service: service, // 'service' object might have invokerIamDisabled removed
191
193
  serviceId: serviceId,
@@ -213,6 +215,187 @@ async function deployToCloudRun(
213
215
  }
214
216
  }
215
217
 
218
+ /**
219
+ * Handles deployment directly from source via a zipped archive (no Cloud Build).
220
+ * This function prepares the source code, installs dependencies, zips the code,
221
+ * uploads it to Google Cloud Storage, and then deploys it to Cloud Run
222
+ * using the direct source deployment mechanism.
223
+ *
224
+ * @param {object} context - The context object containing various GCP clients.
225
+ * @param {string} projectId - The Google Cloud project ID.
226
+ * @param {string} region - The Google Cloud region for the deployment.
227
+ * @param {string} serviceName - The name of the Cloud Run service.
228
+ * @param {Array<string|{filename: string, content: Buffer|string}>} files - Files to deploy.
229
+ * @param {string} bucketName - The name of the GCS bucket to use for staging.
230
+ * @param {object} deploymentAttrs - Deployment attributes detected from the source.
231
+ * @param {string} [deploymentAttrs.runtime] - The detected runtime (e.g., 'nodejs').
232
+ * @param {string[]} [deploymentAttrs.cmd] - The command to run the application.
233
+ * @param {string[]} [deploymentAttrs.args] - The arguments for the command.
234
+ * @param {string} [deploymentAttrs.baseImage] - The base image to use for the deployment.
235
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
236
+ * @param {boolean} [skipIamCheck=false] - Whether to skip the IAM check during Cloud Run service creation/update.
237
+ * @returns {Promise<object>} A promise that resolves with the deployed Cloud Run service object.
238
+ * @throws {Error} If any part of the deployment process fails.
239
+ */
240
+ async function deployWithZip(
241
+ projectId,
242
+ region,
243
+ serviceName,
244
+ files,
245
+ bucketName,
246
+ deploymentAttrs,
247
+ progressCallback,
248
+ skipIamCheck
249
+ ) {
250
+ await logAndProgress(
251
+ `Attempting direct source deployment...`,
252
+ progressCallback
253
+ );
254
+
255
+ let tempDir;
256
+
257
+ try {
258
+ tempDir = await prepareSourceDirectory(files);
259
+ await installDependencies(tempDir, deploymentAttrs);
260
+
261
+ const archiveBuffer = await zipFiles([tempDir], true, progressCallback);
262
+ const archiveName = DEPLOYMENT_CONFIG.TARGZ_FILE_NAME;
263
+
264
+ if (archiveBuffer.length > MAX_ALLOWED_DIRECT_SOURCE_SIZE_BYTES) {
265
+ const errorMsg = `Warning: Source archive size (${(
266
+ archiveBuffer.length /
267
+ (1024 * 1024)
268
+ ).toFixed(
269
+ 2
270
+ )} MiB) exceeds the ${MAX_ALLOWED_DIRECT_SOURCE_SIZE_BYTES / (1024 * 1024)} MiB limit.`;
271
+ await logAndProgress(errorMsg, progressCallback, 'warn');
272
+ throw new Error('Archive size exceeds limit');
273
+ }
274
+
275
+ const bucket = await ensureStorageBucketExists(
276
+ projectId,
277
+ bucketName,
278
+ region,
279
+ progressCallback
280
+ );
281
+
282
+ await uploadToStorageBucket(
283
+ bucket,
284
+ archiveBuffer,
285
+ archiveName,
286
+ progressCallback
287
+ );
288
+ await logAndProgress('Source code uploaded successfully', progressCallback);
289
+
290
+ const container = createDirectSourceDeploymentContainer({
291
+ bucketName,
292
+ fileName: archiveName,
293
+ deploymentAttrs,
294
+ });
295
+
296
+ const service = await deployToCloudRun(
297
+ projectId,
298
+ region,
299
+ serviceName,
300
+ undefined,
301
+ progressCallback,
302
+ skipIamCheck,
303
+ DEPLOYMENT_TYPES.NO_BUILD,
304
+ container,
305
+ deploymentAttrs.runtime
306
+ );
307
+
308
+ return service;
309
+ } finally {
310
+ await cleanupTempDirectory(tempDir);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Handles deployment using Google Cloud Build.
316
+ * This function zips the source code, uploads it to Google Cloud Storage,
317
+ * triggers a Cloud Build job to build a container image, pushes the image
318
+ * to Artifact Registry, and then deploys the image to Cloud Run.
319
+ *
320
+ * @param {object} context - The context object containing various GCP clients.
321
+ * @param {string} projectId - The Google Cloud project ID.
322
+ * @param {string} region - The Google Cloud region for the deployment.
323
+ * @param {string} serviceName - The name of the Cloud Run service.
324
+ * @param {Array<string|{filename: string, content: Buffer|string}>} files - Files to deploy.
325
+ * @param {boolean} hasDockerfile - Whether a Dockerfile is present in the files.
326
+ * @param {string} bucketName - The name of the GCS bucket to use for staging.
327
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
328
+ * @param {boolean} [skipIamCheck=false] - Whether to skip the IAM check during Cloud Run service creation/update.
329
+ * @returns {Promise<object>} A promise that resolves with the deployed Cloud Run service object.
330
+ * @throws {Error} If any part of the build or deployment process fails.
331
+ */
332
+ async function deployWithBuild(
333
+ projectId,
334
+ region,
335
+ serviceName,
336
+ files,
337
+ hasDockerfile,
338
+ bucketName,
339
+ progressCallback,
340
+ skipIamCheck
341
+ ) {
342
+ const imageUrl = `${region}-docker.pkg.dev/${projectId}/${DEPLOYMENT_CONFIG.REPO_NAME}/${serviceName}:${DEPLOYMENT_CONFIG.IMAGE_TAG}`;
343
+
344
+ const bucket = await ensureStorageBucketExists(
345
+ projectId,
346
+ bucketName,
347
+ region,
348
+ progressCallback
349
+ );
350
+
351
+ const archiveBuffer = await zipFiles(files, false, progressCallback);
352
+ const archiveName = DEPLOYMENT_CONFIG.ZIP_FILE_NAME;
353
+
354
+ await uploadToStorageBucket(
355
+ bucket,
356
+ archiveBuffer,
357
+ archiveName,
358
+ progressCallback
359
+ );
360
+ await logAndProgress('Source code uploaded successfully', progressCallback);
361
+
362
+ await ensureArtifactRegistryRepoExists(
363
+ projectId,
364
+ region,
365
+ DEPLOYMENT_CONFIG.REPO_NAME,
366
+ 'DOCKER',
367
+ progressCallback
368
+ );
369
+
370
+ await logAndProgress(`Dockerfile: ${hasDockerfile}`, progressCallback);
371
+
372
+ const buildResult = await triggerCloudBuild(
373
+ projectId,
374
+ region,
375
+ bucketName,
376
+ archiveName,
377
+ DEPLOYMENT_CONFIG.REPO_NAME,
378
+ imageUrl,
379
+ hasDockerfile,
380
+ progressCallback
381
+ );
382
+
383
+ const builtImageUrl = buildResult.results.images[0].name;
384
+
385
+ const service = await deployToCloudRun(
386
+ projectId,
387
+ region,
388
+ serviceName,
389
+ builtImageUrl,
390
+ progressCallback,
391
+ skipIamCheck,
392
+ DEPLOYMENT_TYPES.WITH_BUILD
393
+ );
394
+
395
+ await logAndProgress(`Deployment completed successfully`, progressCallback);
396
+ return service;
397
+ }
398
+
216
399
  /**
217
400
  * Deploys a service to Google Cloud Run.
218
401
  * @param {object} config - The deployment configuration.
@@ -257,128 +440,57 @@ export async function deploy({
257
440
  }
258
441
  }
259
442
 
260
- const path = await import('path');
261
- const fs = await import('fs');
262
- const { Storage } = await import('@google-cloud/storage');
263
- const { CloudBuildClient } = await import('@google-cloud/cloudbuild');
264
- const { ArtifactRegistryClient } = await import(
265
- '@google-cloud/artifact-registry'
266
- );
267
- const { v2: CloudRunV2Module } = await import('@google-cloud/run');
268
- const { ServicesClient } = CloudRunV2Module;
269
- const { ServiceUsageClient } = await import('@google-cloud/service-usage');
270
- const { Logging } = await import('@google-cloud/logging');
271
-
272
443
  try {
273
- const context = {
274
- storage: new Storage({ projectId }),
275
- cloudBuildClient: new CloudBuildClient({ projectId }),
276
- artifactRegistryClient: new ArtifactRegistryClient({ projectId }),
277
- runClient: new ServicesClient({ projectId }),
278
- serviceUsageClient: new ServiceUsageClient({ projectId }),
279
- loggingClient: new Logging({ projectId }),
280
- };
281
-
282
444
  await ensureApisEnabled(
283
- context,
284
445
  projectId,
285
- REQUIRED_APIS_FOR_SOURCE_DEPLOY,
446
+ REQUIRED_APIS.SOURCE_DEPLOY,
286
447
  progressCallback
287
448
  );
288
449
 
289
450
  const bucketName = `${projectId}-source-bucket`;
290
- const imageUrl = `${region}-docker.pkg.dev/${projectId}/${REPO_NAME}/${serviceName}:${IMAGE_TAG}`;
291
451
 
292
452
  await logAndProgress(`Project: ${projectId}`, progressCallback);
293
453
  await logAndProgress(`Region: ${region}`, progressCallback);
294
454
  await logAndProgress(`Service Name: ${serviceName}`, progressCallback);
295
455
  await logAndProgress(`Files to deploy: ${files.length}`, progressCallback);
296
456
 
297
- let hasDockerfile = false;
298
- if (
299
- files.length === 1 &&
300
- typeof files[0] === 'string' &&
301
- fs.statSync(files[0]).isDirectory()
302
- ) {
303
- // Handle folder deployment: check for Dockerfile inside the folder
304
- const dockerfilePath = path.join(files[0], 'Dockerfile');
305
- const dockerfilePathLowerCase = path.join(files[0], 'dockerfile');
306
- if (
307
- fs.existsSync(dockerfilePath) ||
308
- fs.existsSync(dockerfilePathLowerCase)
309
- ) {
310
- hasDockerfile = true;
311
- }
312
- } else {
313
- // Handle file list deployment or file content deployment
314
- for (const file of files) {
315
- if (typeof file === 'string') {
316
- if (path.basename(file).toLowerCase() === 'dockerfile') {
317
- hasDockerfile = true;
318
- break;
319
- }
320
- } else if (typeof file === 'object' && file.filename) {
321
- if (path.basename(file.filename).toLowerCase() === 'dockerfile') {
322
- hasDockerfile = true;
323
- break;
324
- }
325
- }
326
- }
327
- }
328
- await logAndProgress(`Dockerfile: ${hasDockerfile}`, progressCallback);
329
-
330
- const bucket = await ensureStorageBucketExists(
331
- context,
332
- bucketName,
333
- region,
334
- progressCallback
335
- );
336
-
337
- const zipBuffer = await zipFiles(files, progressCallback);
338
- await uploadToStorageBucket(
339
- context,
340
- bucket,
341
- zipBuffer,
342
- ZIP_FILE_NAME,
343
- progressCallback
344
- );
345
- await logAndProgress('Source code uploaded successfully', progressCallback);
457
+ // Determines if we can use the direct source deployment (no Cloud Build)
458
+ const { hasDockerfile, deploymentAttrs } =
459
+ makeFileDeploymentMetadata(files);
346
460
 
347
- await ensureArtifactRegistryRepoExists(
348
- context,
349
- projectId,
350
- region,
351
- REPO_NAME,
352
- 'DOCKER',
353
- progressCallback
354
- );
355
-
356
- const buildResult = await triggerCloudBuild(
357
- context,
358
- projectId,
359
- region,
360
- bucketName,
361
- ZIP_FILE_NAME,
362
- REPO_NAME,
363
- imageUrl,
364
- hasDockerfile,
365
- progressCallback
366
- );
461
+ await logAndProgress(`Dockerfile: ${hasDockerfile}`, progressCallback);
367
462
 
368
- const builtImageUrl = buildResult.results.images[0].name;
463
+ if (canDeployWithoutBuild({ hasDockerfile, deploymentAttrs })) {
464
+ try {
465
+ return await deployWithZip(
466
+ projectId,
467
+ region,
468
+ serviceName,
469
+ files,
470
+ bucketName,
471
+ deploymentAttrs,
472
+ progressCallback,
473
+ skipIamCheck
474
+ );
475
+ } catch (error) {
476
+ await logAndProgress(
477
+ `Failed to deploy directly from source: ${error.message}. Retrying to deploy using Cloud Build...`,
478
+ progressCallback,
479
+ 'warn'
480
+ );
481
+ }
482
+ }
369
483
 
370
- const service = await deployToCloudRun(
371
- context,
484
+ return await deployWithBuild(
372
485
  projectId,
373
486
  region,
374
487
  serviceName,
375
- builtImageUrl,
488
+ files,
489
+ hasDockerfile,
490
+ bucketName,
376
491
  progressCallback,
377
492
  skipIamCheck
378
493
  );
379
-
380
- await logAndProgress(`Deployment Completed Successfully`, progressCallback);
381
- return service;
382
494
  } catch (error) {
383
495
  const deployFailedMessage = `Deployment Failed: ${error.message}`;
384
496
  console.error(`Deployment Failed`, error);
@@ -431,20 +543,10 @@ export async function deployImage({
431
543
  }
432
544
  }
433
545
 
434
- const { v2: CloudRunV2Module } = await import('@google-cloud/run');
435
- const { ServicesClient } = CloudRunV2Module;
436
- const { ServiceUsageClient } = await import('@google-cloud/service-usage');
437
-
438
546
  try {
439
- const context = {
440
- runClient: new ServicesClient({ projectId }),
441
- serviceUsageClient: new ServiceUsageClient({ projectId }),
442
- };
443
-
444
547
  await ensureApisEnabled(
445
- context,
446
548
  projectId,
447
- REQUIRED_APIS_FOR_IMAGE_DEPLOY,
549
+ REQUIRED_APIS.IMAGE_DEPLOY,
448
550
  progressCallback
449
551
  );
450
552
 
@@ -454,16 +556,16 @@ export async function deployImage({
454
556
  await logAndProgress(`Image URL: ${imageUrl}`, progressCallback);
455
557
 
456
558
  const service = await deployToCloudRun(
457
- context,
458
559
  projectId,
459
560
  region,
460
561
  serviceName,
461
562
  imageUrl,
462
563
  progressCallback,
463
- skipIamCheck
564
+ skipIamCheck,
565
+ DEPLOYMENT_TYPES.IMAGE
464
566
  );
465
567
 
466
- await logAndProgress(`Deployment Completed Successfully`, progressCallback);
568
+ await logAndProgress(`Deployment completed successfully`, progressCallback);
467
569
  return service;
468
570
  } catch (error) {
469
571
  const deployFailedMessage = `Deployment Failed: ${error.message}`;