@google-cloud/cloud-run-mcp 1.0.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.
@@ -0,0 +1,934 @@
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
+ // Configuration
18
+ const REPO_NAME = 'mcp-cloud-run-deployments';
19
+ const ZIP_FILE_NAME = 'source.zip';
20
+ const IMAGE_TAG = 'latest';
21
+
22
+ // APIs required for deploying from source code.
23
+ const REQUIRED_APIS_FOR_SOURCE_DEPLOY = [
24
+ 'iam.googleapis.com',
25
+ 'storage.googleapis.com',
26
+ 'cloudbuild.googleapis.com',
27
+ 'artifactregistry.googleapis.com',
28
+ 'run.googleapis.com',
29
+ ];
30
+
31
+ // APIs required for deploying a container image.
32
+ const REQUIRED_APIS_FOR_IMAGE_DEPLOY = ['run.googleapis.com'];
33
+
34
+ const DELAY_WAIT_FOR_BUILD_LOGS = 10000; // 10 seconds delay to allow logs to propagate
35
+ const BUILD_LOGS_LINES_TO_FETCH = 100; // Number of log lines to fetch for build logs snippet
36
+
37
+ // Initialize Clients
38
+ let storage;
39
+ let cloudBuildClient;
40
+ let artifactRegistryClient;
41
+ let runClient;
42
+
43
+ /**
44
+ * Helper function to log a message and call the progress callback.
45
+ * @param {string} message - The message to log.
46
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
47
+ * @param {'debug' | 'info' | 'warn' | 'error'} [severity='info'] - The severity level of the message.
48
+ */
49
+ function logAndProgress(message, progressCallback, severity = 'info') {
50
+ switch (severity) {
51
+ case 'error':
52
+ console.error(message);
53
+ break;
54
+ case 'warn':
55
+ case 'info':
56
+ case 'debug':
57
+ default:
58
+ console.log(message);
59
+ break;
60
+ }
61
+ if (progressCallback) {
62
+ progressCallback({ level: severity, data: message });
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Ensures that the specified Google Cloud APIs are enabled for the given project.
68
+ * If an API is not enabled, it attempts to enable it.
69
+ * Throws an error if an API cannot be enabled.
70
+ *
71
+ * @async
72
+ * @param {string} projectId - The Google Cloud project ID.
73
+ * @param {string[]} apis - An array of API identifiers to check and enable (e.g., 'run.googleapis.com').
74
+ * @param {function(string, string=): void} progressCallback - A function to call with progress updates.
75
+ * The first argument is the message, the optional second argument is the type ('error', 'warning', 'info').
76
+ * @throws {Error} If an API fails to enable or if there's an issue checking its status.
77
+ * @returns {Promise<void>} A promise that resolves when all specified APIs are enabled.
78
+ */
79
+ async function ensureApisEnabled(projectId, apis, progressCallback) {
80
+ const { ServiceUsageClient } = await import('@google-cloud/service-usage');
81
+ const serviceUsageClient = new ServiceUsageClient({ projectId });
82
+ logAndProgress('Checking and enabling required APIs...', progressCallback);
83
+
84
+ for (const api of apis) {
85
+ const serviceName = `projects/${projectId}/services/${api}`;
86
+ try {
87
+ const [service] = await serviceUsageClient.getService({
88
+ name: serviceName,
89
+ });
90
+ if (service.state !== 'ENABLED') {
91
+ logAndProgress(
92
+ `API [${api}] is not enabled. Enabling...`,
93
+ progressCallback
94
+ );
95
+ const [operation] = await serviceUsageClient.enableService({
96
+ name: serviceName,
97
+ });
98
+ await operation.promise();
99
+ }
100
+ } catch (error) {
101
+ const errorMessage = `Failed to ensure API [${api}] is enabled. Please check manually.`;
102
+ console.error(errorMessage, error);
103
+ logAndProgress(errorMessage, progressCallback, 'error');
104
+ throw new Error(errorMessage);
105
+ }
106
+ }
107
+ logAndProgress('All required APIs are enabled.', progressCallback);
108
+ }
109
+
110
+ /**
111
+ * Checks if a Cloud Run service already exists.
112
+ *
113
+ * @async
114
+ * @param {string} projectId - The Google Cloud project ID.
115
+ * @param {string} location - The Google Cloud region where the service is located.
116
+ * @param {string} serviceId - The ID of the Cloud Run service.
117
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
118
+ * @returns {Promise<boolean>} A promise that resolves to true if the service exists, false otherwise.
119
+ * @throws {Error} If there's an error checking the service (other than not found).
120
+ */
121
+ async function checkCloudRunServiceExists(
122
+ projectId,
123
+ location,
124
+ serviceId,
125
+ progressCallback
126
+ ) {
127
+ const parent = runClient.locationPath(projectId, location);
128
+ const servicePath = runClient.servicePath(projectId, location, serviceId);
129
+ try {
130
+ await runClient.getService({ name: servicePath });
131
+ logAndProgress(
132
+ `Cloud Run service ${serviceId} already exists.`,
133
+ progressCallback
134
+ );
135
+ return true;
136
+ } catch (error) {
137
+ if (error.code === 5) {
138
+ logAndProgress(
139
+ `Cloud Run service ${serviceId} does not exist.`,
140
+ progressCallback
141
+ );
142
+ return false;
143
+ }
144
+ const errorMessage = `Error checking Cloud Run service ${serviceId}: ${error.message}`;
145
+ console.error(`Error checking Cloud Run service ${serviceId}:`, error);
146
+ logAndProgress(errorMessage, progressCallback, 'error');
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Deploys or updates a Cloud Run service with the specified container image.
153
+ * If the service exists, it's updated; otherwise, a new service is created.
154
+ * The service is configured to be publicly accessible.
155
+ *
156
+ * @async
157
+ * @param {string} projectId - The Google Cloud project ID.
158
+ * @param {string} location - The Google Cloud region for the deployment.
159
+ * @param {string} serviceId - The ID for the Cloud Run service.
160
+ * @param {string} imgUrl - The URL of the container image to deploy.
161
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
162
+ * @returns {Promise<object>} A promise that resolves with the Cloud Run service object upon successful deployment or update.
163
+ * @throws {Error} If the deployment or update process fails.
164
+ */
165
+ async function deployToCloudRun(
166
+ projectId,
167
+ location,
168
+ serviceId,
169
+ imgUrl,
170
+ progressCallback,
171
+ skipIamCheck
172
+ ) {
173
+ const parent = runClient.locationPath(projectId, location);
174
+ const servicePath = runClient.servicePath(projectId, location, serviceId);
175
+ const revisionName = `${serviceId}-${Date.now()}`; // Generate a unique revision name
176
+
177
+ const service = {
178
+ template: {
179
+ revision: revisionName,
180
+ containers: [{ image: imgUrl }],
181
+ },
182
+ labels: {
183
+ 'created-by': 'cloud-run-mcp',
184
+ },
185
+ };
186
+
187
+ // Conditionally set invokerIamDisabled based on the skipIamCheck flag
188
+ if (skipIamCheck) {
189
+ service.invokerIamDisabled = true;
190
+ }
191
+
192
+ try {
193
+ const exists = await checkCloudRunServiceExists(
194
+ projectId,
195
+ location,
196
+ serviceId,
197
+ progressCallback
198
+ );
199
+
200
+ // Always perform a dry run first for general validation
201
+ try {
202
+ logAndProgress(
203
+ `Performing dry run for service ${serviceId}...`,
204
+ progressCallback,
205
+ 'debug'
206
+ );
207
+ const dryRunServiceConfig = JSON.parse(JSON.stringify(service)); // Deep copy for dry run
208
+
209
+ if (exists) {
210
+ dryRunServiceConfig.name = servicePath;
211
+ await runClient.updateService({
212
+ service: dryRunServiceConfig,
213
+ validateOnly: true,
214
+ });
215
+ } else {
216
+ await runClient.createService({
217
+ parent: parent,
218
+ service: dryRunServiceConfig,
219
+ serviceId: serviceId,
220
+ validateOnly: true,
221
+ });
222
+ }
223
+ logAndProgress(
224
+ `Dry run successful for ${serviceId} with current configuration.`,
225
+ progressCallback,
226
+ 'debug'
227
+ );
228
+ } catch (dryRunError) {
229
+ logAndProgress(
230
+ `Dry run for ${serviceId} failed: ${dryRunError.message}`,
231
+ progressCallback,
232
+ 'warn'
233
+ );
234
+
235
+ // Check if the error is related to invokerIamDisabled (this is a heuristic)
236
+ if (
237
+ skipIamCheck &&
238
+ dryRunError.message &&
239
+ (dryRunError.message.toLowerCase().includes('invokeriamdisabled') ||
240
+ dryRunError.message.toLowerCase().includes('iam policy violation') ||
241
+ dryRunError.code === 3) /* INVALID_ARGUMENT */
242
+ ) {
243
+ logAndProgress(
244
+ `Dry run suggests 'invokerIamDisabled' is not allowed or invalid. Attempting deployment without it.`,
245
+ progressCallback,
246
+ 'warn'
247
+ );
248
+ delete service.invokerIamDisabled; // Modify the main service object for actual deployment
249
+ } else {
250
+ // For any other validation errors, rethrow to stop the deployment
251
+ const errorMessage = `Dry run validation failed for service ${serviceId}: ${dryRunError.message}`;
252
+ logAndProgress(errorMessage, progressCallback, 'error');
253
+ throw new Error(errorMessage);
254
+ }
255
+ }
256
+
257
+ let operation;
258
+ if (exists) {
259
+ logAndProgress(
260
+ `Updating existing service ${serviceId}...`,
261
+ progressCallback
262
+ );
263
+ service.name = servicePath;
264
+ [operation] = await runClient.updateService({ service });
265
+ } else {
266
+ logAndProgress(`Creating new service ${serviceId}...`, progressCallback);
267
+ [operation] = await runClient.createService({
268
+ parent: parent,
269
+ service: service, // 'service' object might have invokerIamDisabled removed
270
+ serviceId: serviceId,
271
+ });
272
+ }
273
+
274
+ logAndProgress(`Deploying ${serviceId} to Cloud Run...`, progressCallback);
275
+ const [response] = await operation.promise();
276
+
277
+ logAndProgress(
278
+ `Service deployed/updated successfully: ${response.uri}`,
279
+ progressCallback
280
+ );
281
+ return response;
282
+ } catch (error) {
283
+ const errorMessage = `Error deploying/updating service ${serviceId}: ${error.message}`;
284
+ console.error(`Error deploying/updating service ${serviceId}:`, error);
285
+ logAndProgress(errorMessage, progressCallback, 'error');
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Ensures that a Google Cloud Storage bucket exists.
292
+ * If the bucket does not exist, it attempts to create it in the specified location.
293
+ *
294
+ * @async
295
+ * @param {string} bucketName - The name of the storage bucket.
296
+ * @param {string} [location='us'] - The location to create the bucket in if it doesn't exist. Defaults to 'us'.
297
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
298
+ * @returns {Promise<import('@google-cloud/storage').Bucket>} A promise that resolves with the GCS Bucket object.
299
+ * @throws {Error} If there's an error checking or creating the bucket.
300
+ */
301
+ async function ensureStorageBucketExists(
302
+ bucketName,
303
+ location = 'us',
304
+ progressCallback
305
+ ) {
306
+ const bucket = storage.bucket(bucketName);
307
+ try {
308
+ const [exists] = await bucket.exists();
309
+ if (exists) {
310
+ logAndProgress(`Bucket ${bucketName} already exists.`, progressCallback);
311
+ return bucket;
312
+ } else {
313
+ logAndProgress(
314
+ `Bucket ${bucketName} does not exist. Creating in location ${location}...`,
315
+ progressCallback
316
+ );
317
+ try {
318
+ const [createdBucket] = await storage.createBucket(bucketName, {
319
+ location: location,
320
+ });
321
+ logAndProgress(
322
+ `Storage bucket ${createdBucket.name} created successfully in ${location}.`,
323
+ progressCallback
324
+ );
325
+ return createdBucket;
326
+ } catch (createError) {
327
+ const errorMessage = `Failed to create storage bucket ${bucketName}. Error details: ${createError.message}`;
328
+ console.error(
329
+ `Failed to create storage bucket ${bucketName}. Error details:`,
330
+ createError
331
+ );
332
+ logAndProgress(errorMessage, progressCallback, 'error');
333
+ throw createError;
334
+ }
335
+ }
336
+ } catch (error) {
337
+ const errorMessage = `Error checking/creating bucket ${bucketName}: ${error.message}`;
338
+ console.error(`Error checking/creating bucket ${bucketName}:`, error);
339
+ logAndProgress(errorMessage, progressCallback, 'error');
340
+ throw error;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Creates a zip archive in memory from a list of file paths and/or file objects.
346
+ * File objects should have `filename` (string) and `content` (Buffer or string) properties.
347
+ *
348
+ * @param {Array<string|{filename: string, content: Buffer|string}>} files - An array of items to zip.
349
+ * Each item can be a string representing a file/directory path, or an object
350
+ * with `filename` and `content` properties for in-memory files.
351
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
352
+ * @returns {Promise<Buffer>} A promise that resolves with a Buffer containing the zip data.
353
+ * @throws {Error} If an input file path is not found, an input item has an invalid format, or an archiver error occurs.
354
+ */
355
+ async function zipFiles(files, progressCallback) {
356
+ const path = await import('path');
357
+ const fs = await import('fs');
358
+ const archiver = (await import('archiver')).default;
359
+
360
+ return new Promise((resolve, reject) => {
361
+ logAndProgress('Creating in-memory zip archive...', progressCallback);
362
+ const chunks = [];
363
+ const archive = archiver('zip', {
364
+ zlib: { level: 9 },
365
+ });
366
+
367
+ archive.on('data', (chunk) => chunks.push(chunk));
368
+ archive.on('end', () => {
369
+ logAndProgress(
370
+ `Files zipped successfully. Total size: ${archive.pointer()} bytes`,
371
+ progressCallback
372
+ );
373
+ resolve(Buffer.concat(chunks));
374
+ });
375
+
376
+ archive.on('warning', (err) => {
377
+ const warningMessage = `Archiver warning: ${err}`;
378
+ logAndProgress(warningMessage, progressCallback, 'warn');
379
+ if (err.code !== 'ENOENT') {
380
+ // ENOENT is often just a warning, others might be more critical for zip
381
+ reject(err);
382
+ }
383
+ });
384
+
385
+ archive.on('error', (err) => {
386
+ const errorMessage = `Archiver error: ${err.message}`;
387
+ console.error(errorMessage, err);
388
+ logAndProgress(errorMessage, progressCallback, 'error');
389
+ reject(err);
390
+ });
391
+
392
+ files.forEach((file) => {
393
+ if (typeof file === 'object' && 'filename' in file && 'content' in file) {
394
+ archive.append(file.content, { name: file.filename });
395
+ } else if (typeof file === 'string') {
396
+ let pathInput = file;
397
+
398
+ // This is a "hack" to better support WSL on Windows. AI agents tend to send path that start with '/c' in that case. Re-write it to '/mnt/c'
399
+ if (pathInput.startsWith('/c')) {
400
+ pathInput = `/mnt${pathInput}`;
401
+ }
402
+ const filePath = path.resolve(pathInput);
403
+ if (!fs.existsSync(filePath)) {
404
+ throw new Error(`File or directory not found: ${filePath}`);
405
+ }
406
+
407
+ const stats = fs.statSync(filePath);
408
+ if (stats.isDirectory()) {
409
+ archive.directory(filePath, false);
410
+ } else {
411
+ archive.file(filePath, { name: path.basename(filePath) });
412
+ }
413
+ } else {
414
+ throw new Error(`Invalid file format: ${JSON.stringify(file)}`);
415
+ }
416
+ });
417
+
418
+ archive.finalize();
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Uploads a buffer to a specified Google Cloud Storage bucket and blob name.
424
+ *
425
+ * @async
426
+ * @param {import('@google-cloud/storage').Bucket} bucket - The Google Cloud Storage bucket object.
427
+ * @param {Buffer} buffer - The buffer containing the data to upload.
428
+ * @param {string} destinationBlobName - The name for the blob in the bucket.
429
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
430
+ * @returns {Promise<import('@google-cloud/storage').File>} A promise that resolves with the GCS File object representing the uploaded blob.
431
+ * @throws {Error} If the upload fails.
432
+ */
433
+ async function uploadToStorageBucket(
434
+ bucket,
435
+ buffer,
436
+ destinationBlobName,
437
+ progressCallback
438
+ ) {
439
+ try {
440
+ logAndProgress(
441
+ `Uploading buffer to gs://${bucket.name}/${destinationBlobName}...`,
442
+ progressCallback
443
+ );
444
+ await bucket.file(destinationBlobName).save(buffer);
445
+ logAndProgress(
446
+ `File ${destinationBlobName} uploaded successfully to gs://${bucket.name}/${destinationBlobName}.`,
447
+ progressCallback
448
+ );
449
+ return bucket.file(destinationBlobName);
450
+ } catch (error) {
451
+ const errorMessage = `Error uploading buffer: ${error.message}`;
452
+ console.error(`Error uploading buffer:`, error);
453
+ logAndProgress(errorMessage, progressCallback, 'error');
454
+ throw error;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Ensures that an Artifact Registry repository exists.
460
+ * If the repository does not exist, it attempts to create it with the specified format.
461
+ *
462
+ * @async
463
+ * @param {string} projectId - The Google Cloud project ID.
464
+ * @param {string} location - The Google Cloud region for the repository.
465
+ * @param {string} repositoryId - The ID for the Artifact Registry repository.
466
+ * @param {string} [format='DOCKER'] - The format of the repository (e.g., 'DOCKER', 'NPM'). Defaults to 'DOCKER'.
467
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
468
+ * @returns {Promise<object>} A promise that resolves with the Artifact Registry repository object.
469
+ * @throws {Error} If there's an error checking or creating the repository.
470
+ */
471
+ async function ensureArtifactRegistryRepoExists(
472
+ projectId,
473
+ location,
474
+ repositoryId,
475
+ format = 'DOCKER',
476
+ progressCallback
477
+ ) {
478
+ const parent = `projects/${projectId}/locations/${location}`;
479
+ const repoPath = artifactRegistryClient.repositoryPath(
480
+ projectId,
481
+ location,
482
+ repositoryId
483
+ );
484
+
485
+ try {
486
+ const [repository] = await artifactRegistryClient.getRepository({
487
+ name: repoPath,
488
+ });
489
+ logAndProgress(
490
+ `Repository ${repositoryId} already exists in ${location}.`,
491
+ progressCallback
492
+ );
493
+ return repository;
494
+ } catch (error) {
495
+ if (error.code === 5) {
496
+ logAndProgress(
497
+ `Repository ${repositoryId} does not exist in ${location}. Creating...`,
498
+ progressCallback
499
+ );
500
+ const repositoryToCreate = {
501
+ format: format,
502
+ };
503
+ try {
504
+ const [operation] = await artifactRegistryClient.createRepository({
505
+ parent: parent,
506
+ repository: repositoryToCreate,
507
+ repositoryId: repositoryId,
508
+ });
509
+ logAndProgress(
510
+ `Creating Artifact Registry repository ${repositoryId}...`,
511
+ progressCallback
512
+ );
513
+ const [result] = await operation.promise();
514
+ logAndProgress(
515
+ `Artifact Registry repository ${result.name} created successfully.`,
516
+ progressCallback
517
+ );
518
+ return result;
519
+ } catch (createError) {
520
+ const errorMessage = `Failed to create Artifact Registry repository ${repositoryId}. Error details: ${createError.message}`;
521
+ console.error(
522
+ `Failed to create Artifact Registry repository ${repositoryId}. Error details:`,
523
+ createError
524
+ );
525
+ logAndProgress(errorMessage, progressCallback, 'error');
526
+ throw createError;
527
+ }
528
+ } else {
529
+ const errorMessage = `Error checking/creating repository ${repositoryId}: ${error.message}`;
530
+ console.error(
531
+ `Error checking/creating repository ${repositoryId}:`,
532
+ error
533
+ );
534
+ logAndProgress(errorMessage, progressCallback, 'error');
535
+ throw error;
536
+ }
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Triggers a Google Cloud Build job to build a container image from source code in a GCS bucket.
542
+ * It uses either a Dockerfile found in the source or Google Cloud Buildpacks if no Dockerfile is present.
543
+ * Waits for the build to complete and returns the build result.
544
+ *
545
+ * @async
546
+ * @param {string} projectId - The Google Cloud project ID.
547
+ * @param {string} location - The Google Cloud region for the build.
548
+ * @param {string} sourceBucketName - The GCS bucket name where the source code (zip) is stored.
549
+ * @param {string} sourceBlobName - The GCS blob name (the zip file) for the source code.
550
+ * @param {string} targetRepoName - The name of the target Artifact Registry repository (used for context, not directly in build steps).
551
+ * @param {string} targetImageUrl - The full Artifact Registry URL for the image to be built (e.g., `location-docker.pkg.dev/project/repo/image:tag`).
552
+ * @param {boolean} hasDockerfile - Indicates whether a Dockerfile is present in the source to guide the build process.
553
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
554
+ * @returns {Promise<object>} A promise that resolves with the completed Cloud Build object.
555
+ * @throws {Error} If the Cloud Build job fails, times out, or encounters an error during initiation or execution.
556
+ */
557
+ async function triggerCloudBuild(
558
+ projectId,
559
+ location,
560
+ sourceBucketName,
561
+ sourceBlobName,
562
+ targetRepoName,
563
+ targetImageUrl,
564
+ hasDockerfile,
565
+ progressCallback
566
+ ) {
567
+ let buildSteps;
568
+
569
+ if (hasDockerfile) {
570
+ buildSteps = [
571
+ {
572
+ name: 'gcr.io/cloud-builders/docker',
573
+ args: ['build', '-t', targetImageUrl, '.'],
574
+ dir: '/workspace',
575
+ },
576
+ ];
577
+ } else {
578
+ buildSteps = [
579
+ {
580
+ name: 'gcr.io/k8s-skaffold/pack',
581
+ entrypoint: 'pack',
582
+ args: [
583
+ 'build',
584
+ targetImageUrl,
585
+ '--builder',
586
+ 'gcr.io/buildpacks/builder:latest',
587
+ ],
588
+ dir: '/workspace',
589
+ },
590
+ ];
591
+ }
592
+
593
+ const build = {
594
+ source: {
595
+ storageSource: {
596
+ bucket: sourceBucketName,
597
+ object: sourceBlobName,
598
+ },
599
+ },
600
+ steps: buildSteps,
601
+ images: [targetImageUrl],
602
+ };
603
+
604
+ logAndProgress(
605
+ `Initiating Cloud Build for gs://${sourceBucketName}/${sourceBlobName} in ${location}...`,
606
+ progressCallback
607
+ );
608
+ const [operation] = await cloudBuildClient.createBuild({
609
+ projectId: projectId,
610
+ build: build,
611
+ });
612
+
613
+ logAndProgress(`Cloud Build job started...`, progressCallback);
614
+ const buildId = operation.metadata.build.id;
615
+ let completedBuild;
616
+ while (true) {
617
+ const [getBuildOperation] = await cloudBuildClient.getBuild({
618
+ projectId: projectId,
619
+ id: buildId,
620
+ });
621
+ if (
622
+ ['SUCCESS', 'FAILURE', 'INTERNAL_ERROR', 'TIMEOUT', 'CANCELLED'].includes(
623
+ getBuildOperation.status
624
+ )
625
+ ) {
626
+ completedBuild = getBuildOperation;
627
+ break;
628
+ }
629
+ logAndProgress(
630
+ `Build status: ${getBuildOperation.status}. Waiting...`,
631
+ progressCallback,
632
+ 'debug'
633
+ );
634
+ await new Promise((resolve) => setTimeout(resolve, 5000));
635
+ }
636
+
637
+ if (completedBuild.status === 'SUCCESS') {
638
+ logAndProgress(
639
+ `Cloud Build job ${buildId} completed successfully.`,
640
+ progressCallback
641
+ );
642
+ logAndProgress(
643
+ `Image built: ${completedBuild.results.images[0].name}`,
644
+ progressCallback
645
+ );
646
+ return completedBuild;
647
+ } else {
648
+ const failureMessage = `Cloud Build job ${buildId} failed with status: ${completedBuild.status}`;
649
+ logAndProgress(failureMessage, progressCallback, 'error');
650
+ const logsMessage = `Build logs: ${completedBuild.logUrl}`;
651
+ logAndProgress(logsMessage, progressCallback); // Log URL is info, failure is error
652
+
653
+ let buildLogsSnippet = `\n\nRefer to Log URL for full details: ${completedBuild.logUrl}`; // Default snippet
654
+ try {
655
+ const { Logging } = await import('@google-cloud/logging');
656
+ const loggingClient = new Logging({ projectId });
657
+
658
+ const logFilter = `resource.type="build" AND resource.labels.build_id="${buildId}"`;
659
+ logAndProgress(
660
+ `Attempting to fetch last ${BUILD_LOGS_LINES_TO_FETCH} log lines for build ${buildId}...`,
661
+ progressCallback,
662
+ 'debug'
663
+ );
664
+
665
+ // Wait for a short period to allow logs to propagate
666
+ await new Promise((resolve) =>
667
+ setTimeout(resolve, DELAY_WAIT_FOR_BUILD_LOGS)
668
+ );
669
+
670
+ // Fetch the most recent N log entries
671
+ const [entries] = await loggingClient.getEntries({
672
+ filter: logFilter,
673
+ orderBy: 'timestamp desc', // Get latest logs first
674
+ pageSize: BUILD_LOGS_LINES_TO_FETCH,
675
+ });
676
+
677
+ if (entries && entries.length > 0) {
678
+ // Entries are newest first, reverse for chronological order of the snippet
679
+ const logLines = entries.reverse().map((entry) => entry.data || '');
680
+ if (logLines.length > 0) {
681
+ buildLogsSnippet = `\n\nLast ${logLines.length} log lines from build ${buildId}:\n${logLines.join('\n')}`;
682
+ logAndProgress(
683
+ `Successfully fetched snippet of build logs for ${buildId}.`,
684
+ progressCallback,
685
+ 'info'
686
+ );
687
+ }
688
+ } else {
689
+ logAndProgress(
690
+ `No specific log entries retrieved for build ${buildId}. ${buildLogsSnippet}`,
691
+ progressCallback,
692
+ 'warn'
693
+ );
694
+ }
695
+ } catch (logError) {
696
+ console.error(`Error fetching build logs for ${buildId}:`, logError);
697
+ logAndProgress(
698
+ `Failed to fetch build logs snippet: ${logError.message}. ${buildLogsSnippet}`,
699
+ progressCallback,
700
+ 'warn'
701
+ );
702
+ // buildLogsSnippet already contains the Log URL as a fallback
703
+ }
704
+ throw new Error(`Build ${buildId} failed.${buildLogsSnippet}`);
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Deploys a service to Google Cloud Run.
710
+ * @param {object} config - The deployment configuration.
711
+ * @param {string} config.projectId - The Google Cloud project ID.
712
+ * @param {string} [config.serviceName='app'] - The name of the Cloud Run service. Defaults to 'app'.
713
+ * @param {string} [config.region='europe-west1'] - The Google Cloud region for deployment. Defaults to 'europe-west1'.
714
+ * @param {Array<string|{filename: string, content: Buffer|string}>} config.files - An array of file paths or file objects (with `filename` and `content`) to deploy.
715
+ * @param {function(object): void} [config.progressCallback] - Optional callback for progress updates. Receives an object with `level` and `data` properties.
716
+ * @returns {Promise<object>} A promise that resolves with the deployed Cloud Run service object.
717
+ * @throws {Error} If deployment fails or required configuration is missing.
718
+ */
719
+ export async function deploy({
720
+ projectId,
721
+ serviceName,
722
+ region,
723
+ files,
724
+ progressCallback,
725
+ skipIamCheck,
726
+ }) {
727
+ if (!projectId) {
728
+ const errorMsg =
729
+ 'Error: projectId is required in the configuration object.';
730
+ logAndProgress(errorMsg, progressCallback, 'error');
731
+ process.exit(1);
732
+ }
733
+
734
+ if (!files || !Array.isArray(files) || files.length === 0) {
735
+ const errorMsg =
736
+ 'Error: files array is required in the configuration object.';
737
+ logAndProgress(errorMsg, progressCallback, 'error');
738
+ if (typeof process !== 'undefined' && process.exit) {
739
+ process.exit(1);
740
+ } else {
741
+ throw new Error(errorMsg);
742
+ }
743
+ }
744
+
745
+ const path = await import('path');
746
+ const fs = await import('fs');
747
+ const { Storage } = await import('@google-cloud/storage');
748
+ const { CloudBuildClient } = await import('@google-cloud/cloudbuild');
749
+ const { ArtifactRegistryClient } = await import(
750
+ '@google-cloud/artifact-registry'
751
+ );
752
+ const { v2: CloudRunV2Module } = await import('@google-cloud/run');
753
+ const { ServicesClient } = CloudRunV2Module;
754
+
755
+ try {
756
+ await ensureApisEnabled(
757
+ projectId,
758
+ REQUIRED_APIS_FOR_SOURCE_DEPLOY,
759
+ progressCallback
760
+ );
761
+
762
+ storage = new Storage({ projectId });
763
+ cloudBuildClient = new CloudBuildClient({ projectId });
764
+ artifactRegistryClient = new ArtifactRegistryClient({ projectId });
765
+ runClient = new ServicesClient({ projectId });
766
+
767
+ const bucketName = `${projectId}-source-bucket`;
768
+ const imageUrl = `${region}-docker.pkg.dev/${projectId}/${REPO_NAME}/${serviceName}:${IMAGE_TAG}`;
769
+
770
+ logAndProgress(`Project: ${projectId}`, progressCallback);
771
+ logAndProgress(`Region: ${region}`, progressCallback);
772
+ logAndProgress(`Service Name: ${serviceName}`, progressCallback);
773
+ logAndProgress(`Files to deploy: ${files.length}`, progressCallback);
774
+
775
+ let hasDockerfile = false;
776
+ if (
777
+ files.length === 1 &&
778
+ typeof files[0] === 'string' &&
779
+ fs.statSync(files[0]).isDirectory()
780
+ ) {
781
+ // Handle folder deployment: check for Dockerfile inside the folder
782
+ const dockerfilePath = path.join(files[0], 'Dockerfile');
783
+ const dockerfilePathLowerCase = path.join(files[0], 'dockerfile');
784
+ if (
785
+ fs.existsSync(dockerfilePath) ||
786
+ fs.existsSync(dockerfilePathLowerCase)
787
+ ) {
788
+ hasDockerfile = true;
789
+ }
790
+ } else {
791
+ // Handle file list deployment or file content deployment
792
+ for (const file of files) {
793
+ if (typeof file === 'string') {
794
+ if (path.basename(file).toLowerCase() === 'dockerfile') {
795
+ hasDockerfile = true;
796
+ break;
797
+ }
798
+ } else if (typeof file === 'object' && file.filename) {
799
+ if (path.basename(file.filename).toLowerCase() === 'dockerfile') {
800
+ hasDockerfile = true;
801
+ break;
802
+ }
803
+ }
804
+ }
805
+ }
806
+ logAndProgress(`Dockerfile: ${hasDockerfile}`, progressCallback);
807
+
808
+ const bucket = await ensureStorageBucketExists(
809
+ bucketName,
810
+ region,
811
+ progressCallback
812
+ );
813
+
814
+ const zipBuffer = await zipFiles(files, progressCallback);
815
+ await uploadToStorageBucket(
816
+ bucket,
817
+ zipBuffer,
818
+ ZIP_FILE_NAME,
819
+ progressCallback
820
+ );
821
+ logAndProgress('Source code uploaded successfully', progressCallback);
822
+
823
+ await ensureArtifactRegistryRepoExists(
824
+ projectId,
825
+ region,
826
+ REPO_NAME,
827
+ 'DOCKER',
828
+ progressCallback
829
+ );
830
+
831
+ const buildResult = await triggerCloudBuild(
832
+ projectId,
833
+ region,
834
+ bucketName,
835
+ ZIP_FILE_NAME,
836
+ REPO_NAME,
837
+ imageUrl,
838
+ hasDockerfile,
839
+ progressCallback
840
+ );
841
+
842
+ const builtImageUrl = buildResult.results.images[0].name;
843
+
844
+ const service = await deployToCloudRun(
845
+ projectId,
846
+ region,
847
+ serviceName,
848
+ builtImageUrl,
849
+ progressCallback,
850
+ skipIamCheck
851
+ );
852
+
853
+ logAndProgress(`Deployment Completed Successfully`, progressCallback);
854
+ return service;
855
+ } catch (error) {
856
+ const deployFailedMessage = `Deployment Failed: ${error.message}`;
857
+ console.error(`Deployment Failed`, error);
858
+ logAndProgress(deployFailedMessage, progressCallback, 'error');
859
+ throw error;
860
+ }
861
+ }
862
+
863
+ /**
864
+ * Deploys a container image to Google Cloud Run.
865
+ * @param {object} config - The deployment configuration.
866
+ * @param {string} config.projectId - The Google Cloud project ID.
867
+ * @param {string} [config.serviceName='app'] - The name of the Cloud Run service. Defaults to 'app'.
868
+ * @param {string} [config.region='europe-west1'] - The Google Cloud region for deployment. Defaults to 'europe-west1'.
869
+ * @param {string} config.imageUrl - The URL of the container image to deploy.
870
+ * @param {function(object): void} [config.progressCallback] - Optional callback for progress updates. Receives an object with `level` and `data` properties.
871
+ * @param {boolean} [config.skipIamCheck=false] - Whether to skip the IAM check.
872
+ * @returns {Promise<object>} A promise that resolves with the deployed Cloud Run service object.
873
+ * @throws {Error} If deployment fails or required configuration is missing.
874
+ */
875
+ export async function deployImage({
876
+ projectId,
877
+ serviceName,
878
+ region,
879
+ imageUrl,
880
+ progressCallback,
881
+ skipIamCheck,
882
+ }) {
883
+ if (!projectId) {
884
+ const errorMsg =
885
+ 'Error: projectId is required in the configuration object.';
886
+ logAndProgress(errorMsg, progressCallback, 'error');
887
+ process.exit(1);
888
+ }
889
+
890
+ if (!imageUrl) {
891
+ const errorMsg = 'Error: imageUrl is required in the configuration object.';
892
+ logAndProgress(errorMsg, progressCallback, 'error');
893
+ if (typeof process !== 'undefined' && process.exit) {
894
+ process.exit(1);
895
+ } else {
896
+ throw new Error(errorMsg);
897
+ }
898
+ }
899
+
900
+ const { v2: CloudRunV2Module } = await import('@google-cloud/run');
901
+ const { ServicesClient } = CloudRunV2Module;
902
+
903
+ try {
904
+ await ensureApisEnabled(
905
+ projectId,
906
+ REQUIRED_APIS_FOR_IMAGE_DEPLOY,
907
+ progressCallback
908
+ );
909
+
910
+ runClient = new ServicesClient({ projectId });
911
+
912
+ logAndProgress(`Project: ${projectId}`, progressCallback);
913
+ logAndProgress(`Region: ${region}`, progressCallback);
914
+ logAndProgress(`Service Name: ${serviceName}`, progressCallback);
915
+ logAndProgress(`Image URL: ${imageUrl}`, progressCallback);
916
+
917
+ const service = await deployToCloudRun(
918
+ projectId,
919
+ region,
920
+ serviceName,
921
+ imageUrl,
922
+ progressCallback,
923
+ skipIamCheck
924
+ );
925
+
926
+ logAndProgress(`Deployment Completed Successfully`, progressCallback);
927
+ return service;
928
+ } catch (error) {
929
+ const deployFailedMessage = `Deployment Failed: ${error.message}`;
930
+ console.error(`Deployment Failed`, error);
931
+ logAndProgress(deployFailedMessage, progressCallback, 'error');
932
+ throw error;
933
+ }
934
+ }