@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
package/README.md CHANGED
@@ -274,3 +274,27 @@ Post the configuration changes as shown above, start the Gemini CLI. You should
274
274
  This will take you to the authentication page on your browser, wherein you need to sign in using the appropriate gmail id, and accept the terms and conditions. Once the authentication is succcessful, you can come back to the Gemini CLI, and the Cloud Run MCP server will be ready to use.
275
275
 
276
276
  The Google Cloud Platform Terms of Service (available at https://cloud.google.com/terms/) and the Data Processing and Security Terms (available at https://cloud.google.com/terms/data-processing-terms) do not apply to any component of the Cloud Run MCP Server software.
277
+
278
+ # Cloud Run Skills
279
+
280
+ We introduce Cloud Run skills to enable AI agents to perform actions on Cloud Run. You can use these skills with Gemini CLI and other agentic AI tools. The skills are available at [Cloud Run Skills](https://github.com/GoogleCloudPlatform/cloud-run-mcp/blob/main/skills/cloud-run/SKILL.md).
281
+
282
+ The Cloud Run skills are based on top of gcloud cli for Cloud Run empowering agents to perform all the actions on the Cloud Run using gcloud, as can be performed by the GCP user using gcloud cli.
283
+
284
+ In order to use Cloud Run skills:
285
+
286
+ 1. Ensure you have the `gcloud` CLI installed and authenticated with `gcloud auth login` and `gcloud auth application-default login`.
287
+ 2. Set your project with `gcloud config set project [PROJECT_ID]`.
288
+ 3. Enable the skills on your agentic AI tool. For example, you can enable the skills for Gemini CLI using the following command on your terminal:
289
+
290
+ ```bash
291
+ gemini skills install https://github.com/GoogleCloudPlatform/cloud-run-mcp.git --path skills/cloud-run
292
+ ```
293
+
294
+ 4. Once the skills are enabled, you can use them to perform actions on Cloud Run. Here are some of the prompts for you to get started:
295
+
296
+ - List the Cloud Run services in the project test-gcp-project in the region us-west1.
297
+
298
+ - Deploy the folder /home/username/workspace/hello-world as Cloud Run service hello-world to the project test-gcp-project in the region us-west1.
299
+
300
+ - Describe the Cloud Run job hello-job in the project test-gcp-project in the region europe-west1.
package/SECURITY.md ADDED
@@ -0,0 +1,7 @@
1
+ # Security Policy
2
+
3
+ To report a security issue, please use [g.co/vulnz](https://g.co/vulnz).
4
+
5
+ The Google Security Team will respond within 5 working days of your report on [g.co/vulnz](https://g.co/vulnz).
6
+
7
+ We use [g.co/vulnz](https://g.co/vulnz) for our intake, and do coordination and disclosure here using GitHub Security Advisory to privately discuss and fix the issue.
@@ -0,0 +1,37 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # Default cloudbuild.yaml for Cloud Shell custom images.
16
+ # This file is used by the Cloud Build trigger that builds this image.
17
+
18
+ substitutions:
19
+ _IMAGE_TAG: '${COMMIT_SHA}'
20
+
21
+ steps:
22
+ - name: 'gcr.io/cloud-builders/docker'
23
+ args:
24
+ [
25
+ 'build',
26
+ '-t',
27
+ 'us-docker.pkg.dev/${PROJECT_ID}/container/mcp:latest',
28
+ '-t',
29
+ 'us-docker.pkg.dev/${PROJECT_ID}/container/mcp:public-image-${_IMAGE_TAG}',
30
+ '.',
31
+ ]
32
+ images:
33
+ - 'us-docker.pkg.dev/${PROJECT_ID}/container/mcp:latest'
34
+ - 'us-docker.pkg.dev/${PROJECT_ID}/container/mcp:public-image-${_IMAGE_TAG}'
35
+
36
+ options:
37
+ dynamicSubstitutions: true
@@ -0,0 +1,41 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4
+ <modelVersion>4.0.0</modelVersion>
5
+ <parent>
6
+ <groupId>org.springframework.boot</groupId>
7
+ <artifactId>spring-boot-starter-parent</artifactId>
8
+ <version>3.2.2</version>
9
+ <relativePath/> <!-- lookup parent from repository -->
10
+ </parent>
11
+ <groupId>com.example</groupId>
12
+ <artifactId>helloworld</artifactId>
13
+ <version>0.0.1-SNAPSHOT</version>
14
+ <name>helloworld</name>
15
+ <description>Demo project for Spring Boot</description>
16
+ <properties>
17
+ <java.version>17</java.version>
18
+ </properties>
19
+ <dependencies>
20
+ <dependency>
21
+ <groupId>org.springframework.boot</groupId>
22
+ <artifactId>spring-boot-starter-web</artifactId>
23
+ </dependency>
24
+
25
+ <dependency>
26
+ <groupId>org.springframework.boot</groupId>
27
+ <artifactId>spring-boot-starter-test</artifactId>
28
+ <scope>test</scope>
29
+ </dependency>
30
+ </dependencies>
31
+
32
+ <build>
33
+ <plugins>
34
+ <plugin>
35
+ <groupId>org.springframework.boot</groupId>
36
+ <artifactId>spring-boot-maven-plugin</artifactId>
37
+ </plugin>
38
+ </plugins>
39
+ </build>
40
+
41
+ </project>
@@ -0,0 +1,21 @@
1
+ package com.example.helloworld;
2
+
3
+ import org.springframework.boot.SpringApplication;
4
+ import org.springframework.boot.autoconfigure.SpringBootApplication;
5
+ import org.springframework.web.bind.annotation.GetMapping;
6
+ import org.springframework.web.bind.annotation.RestController;
7
+
8
+ @SpringBootApplication
9
+ @RestController
10
+ public class HelloworldApplication {
11
+
12
+ public static void main(String[] args) {
13
+ SpringApplication.run(HelloworldApplication.class, args);
14
+ }
15
+
16
+ @GetMapping("/")
17
+ public String hello() {
18
+ return "Hello from Spring Boot on Cloud Run!";
19
+ }
20
+
21
+ }
@@ -0,0 +1,15 @@
1
+ const express = require('express');
2
+ const app = express();
3
+ const port = process.env.PORT || 8080;
4
+
5
+ app.get('/', (req, res) => {
6
+ res.send('Hello from Node.js on Cloud Run!');
7
+ });
8
+
9
+ app.get('/health', (req, res) => {
10
+ res.status(200).send('OK');
11
+ });
12
+
13
+ app.listen(port, () => {
14
+ console.log(`Nodejs sample app listening at http://localhost:${port}`);
15
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "nodejs-sample-app",
3
+ "version": "1.0.0",
4
+ "description": "A simple Node.js sample application for Cloud Run",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "start": "node index.js"
8
+ },
9
+ "dependencies": {
10
+ "express": "^4.18.2"
11
+ }
12
+ }
@@ -0,0 +1,11 @@
1
+ from flask import Flask, request
2
+
3
+ app = Flask(__name__)
4
+
5
+ @app.route('/')
6
+ def hello():
7
+ user = request.args.get('user', 'World')
8
+ return f'Hello {user}'
9
+
10
+ if __name__ == '__main__':
11
+ app.run(host='0.0.0.0', port=8080)
@@ -0,0 +1,11 @@
1
+ from flask import Flask, request
2
+
3
+ app = Flask(__name__)
4
+
5
+ @app.route('/')
6
+ def hello_world():
7
+ user = request.args.get('user', 'World')
8
+ return f'Hello {user}'
9
+
10
+ if __name__ == '__main__':
11
+ app.run(host='0.0.0.0', port=8080)
@@ -0,0 +1,10 @@
1
+ [project]
2
+ name = "pyproject-project"
3
+ version = "0.1.0"
4
+ dependencies = [
5
+ "Flask",
6
+ "gunicorn",
7
+ ]
8
+ [build-system]
9
+ requires = ["setuptools>=61.0"]
10
+ build-backend = "setuptools.build_meta"
@@ -7,5 +7,17 @@
7
7
  "args": ["-y", "@google-cloud/cloud-run-mcp"]
8
8
  }
9
9
  },
10
+ "settings": [
11
+ {
12
+ "name": "Project ID",
13
+ "description": "Google Cloud project ID to use for Cloud Run operations.",
14
+ "envVar": "GOOGLE_CLOUD_PROJECT"
15
+ },
16
+ {
17
+ "name": "Region",
18
+ "description": "Google Cloud region where Cloud Run services will be managed.",
19
+ "envVar": "GOOGLE_CLOUD_REGION"
20
+ }
21
+ ],
10
22
  "contextFileName": "gemini-extension/GEMINI.md"
11
23
  }
package/lib/clients.js CHANGED
@@ -22,12 +22,15 @@ const CLIENT_SECRET = process.env.GOOGLE_OAUTH_CLIENT_SECRET;
22
22
  const REDIRECT_URI = process.env.GOOGLE_OAUTH_REDIRECT_URI;
23
23
  const AUTHORIZATION_HEADER = 'Authorization';
24
24
  const BEARER_PREFIX = 'Bearer';
25
+ const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
25
26
 
26
- function keyGenerator(projectId, accessToken) {
27
- return accessToken !== GCLOUD_AUTH ? projectId + accessToken : projectId;
27
+ function keyGenerator(projectId, accessToken, region = '') {
28
+ const baseKey =
29
+ accessToken !== GCLOUD_AUTH ? projectId + accessToken : projectId;
30
+ return baseKey + (region || '');
28
31
  }
29
32
 
30
- const clients = {
33
+ export const clients = {
31
34
  run: new Map(),
32
35
  builds: new Map(),
33
36
  serviceUsage: new Map(),
@@ -37,7 +40,9 @@ const clients = {
37
40
  logging: new Map(),
38
41
  billing: new Map(),
39
42
  projects: new Map(),
43
+ runV1: new Map(),
40
44
  oauth: new Map(),
45
+ secretManager: new Map(),
41
46
  };
42
47
 
43
48
  function getAuthClient(accessToken) {
@@ -275,6 +280,26 @@ export async function getProjectsClient(accessToken = GCLOUD_AUTH) {
275
280
  );
276
281
  }
277
282
 
283
+ /**
284
+ * Gets a Secret Manager Client for the specified project.
285
+ * @param {string} projectId - The Google Cloud project ID.
286
+ * @returns {Promise<import('@google-cloud/secret-manager').SecretManagerServiceClient>}
287
+ */
288
+ export async function getSecretManagerClient(
289
+ projectId,
290
+ accessToken = GCLOUD_AUTH
291
+ ) {
292
+ const key = keyGenerator(projectId, accessToken);
293
+ return getClient(
294
+ 'secretManager',
295
+ key,
296
+ async () =>
297
+ (await import('@google-cloud/secret-manager')).SecretManagerServiceClient,
298
+ { projectId },
299
+ accessToken
300
+ );
301
+ }
302
+
278
303
  /**
279
304
  * Gets an OAuth2 Client for the specified access token.
280
305
  * @param {string} accessToken - The access token.
@@ -290,3 +315,79 @@ export async function getOAuthClient(accessToken) {
290
315
 
291
316
  return clients.oauth.get(accessToken);
292
317
  }
318
+
319
+ /**
320
+ * Gets a Cloud Run v1 Client (from googleapis).
321
+ * @param {string} projectId - The Google Cloud project ID.
322
+ * @param {string} accessToken - Access token for authentication.
323
+ * @param {string} [region] - The Google Cloud region (optional).
324
+ * @returns {Promise<import('googleapis').run_v1.Run>}
325
+ */
326
+ export async function getRunV1Client(
327
+ projectId,
328
+ accessToken = GCLOUD_AUTH,
329
+ region = null
330
+ ) {
331
+ const key = keyGenerator(projectId, accessToken, region);
332
+ if (!clients.runV1.has(key)) {
333
+ const { google } = await import('googleapis');
334
+ const options = { version: 'v1' };
335
+
336
+ if (accessToken && accessToken !== GCLOUD_AUTH) {
337
+ options.auth = await getOAuthClient(accessToken);
338
+ } else {
339
+ // Use Application Default Credentials for GCLOUD_AUTH
340
+ const auth = new google.auth.GoogleAuth({
341
+ scopes: [CLOUD_PLATFORM_SCOPE],
342
+ projectId: projectId,
343
+ });
344
+ options.auth = auth;
345
+ }
346
+
347
+ if (region) {
348
+ const availableRegions = await getCloudRunRegions(projectId, accessToken);
349
+ if (!availableRegions.includes(region)) {
350
+ throw new Error(`Invalid region: ${region}.`);
351
+ }
352
+ options.rootUrl = `https://${region}-run.googleapis.com/`;
353
+ }
354
+ clients.runV1.set(key, google.run(options));
355
+ }
356
+ return clients.runV1.get(key);
357
+ }
358
+
359
+ let cachedRegions = null;
360
+
361
+ /**
362
+ * Resets the cached regions. Used for testing.
363
+ */
364
+ export function resetCachedRegions() {
365
+ cachedRegions = null;
366
+ }
367
+
368
+ /**
369
+ * Returns a list of supported Cloud Run regions by fetching them from Compute Engine API.
370
+ * This is the SDK equivalent of `gcloud compute regions list`.
371
+ * Note: This is not moved to utils as it is required by getRunV1Client. Moving it to utils
372
+ * would lead to circular dependency.
373
+ * @param {string} projectId - The project ID to fetch regions for.
374
+ * @param {string} accessToken - The access token for authentication.
375
+ * @returns {Promise<string[]>}
376
+ */
377
+ export async function getCloudRunRegions(projectId, accessToken) {
378
+ if (cachedRegions) {
379
+ return cachedRegions;
380
+ }
381
+ const runClient = await getRunClient(projectId, accessToken);
382
+ const iterable = runClient.listLocationsAsync({
383
+ name: `projects/${projectId}`,
384
+ });
385
+
386
+ const regions = [];
387
+ for await (const region of iterable) {
388
+ regions.push(region.locationId);
389
+ }
390
+
391
+ cachedRegions = regions;
392
+ return cachedRegions;
393
+ }
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ import path from 'node:path';
18
+ import fs from 'node:fs';
17
19
  import {
18
20
  getCloudBuildClient,
19
21
  getLoggingClient,
@@ -21,8 +23,16 @@ import {
21
23
  getBuildsClient,
22
24
  } from '../clients.js';
23
25
  import { callWithRetry } from './helpers.js';
24
- import { logAndProgress } from '../util/helpers.js';
26
+ import {
27
+ logAndProgress,
28
+ calculateSourceFingerprint,
29
+ sanitizeCloudRunServiceName,
30
+ } from '../util/helpers.js';
25
31
  import { checkCloudRunServiceExists } from './run.js';
32
+ import { ensureArtifactRegistryRepoExists } from './registry.js';
33
+ import { ensureStorageBucketExists, uploadToStorageBucket } from './storage.js';
34
+ import { zipFiles } from '../util/archive.js';
35
+ import { DEPLOYMENT_CONFIG } from '../deployment/constants.js';
26
36
 
27
37
  const DELAY_WAIT_FOR_BUILD_LOGS = 10000; // 10 seconds delay to allow logs to propagate
28
38
  const BUILD_LOGS_LINES_TO_FETCH = 100; // Number of log lines to fetch for build logs snippet
@@ -54,9 +64,11 @@ export async function triggerCloudBuild(
54
64
  targetImageUrl,
55
65
  hasDockerfile,
56
66
  accessToken,
57
- progressCallback
67
+ progressCallback,
68
+ ingress
58
69
  ) {
59
- const serviceName = targetImageUrl.split('/').pop().split(/[:@]/)[0];
70
+ const rawServiceName = targetImageUrl.split('/').pop().split(/[:@]/)[0];
71
+ const serviceName = sanitizeCloudRunServiceName(rawServiceName);
60
72
  const parent = `projects/${projectId}/locations/${location}`;
61
73
  const serviceFullName = `${parent}/services/${serviceName}`;
62
74
  const buildsClient = await getBuildsClient(projectId, accessToken);
@@ -78,6 +90,10 @@ export async function triggerCloudBuild(
78
90
  },
79
91
  };
80
92
 
93
+ if (ingress) {
94
+ servicePatch.ingress = ingress;
95
+ }
96
+
81
97
  try {
82
98
  if (serviceExists) {
83
99
  await logAndProgress(
@@ -123,36 +139,76 @@ export async function triggerCloudBuild(
123
139
  throw new Error(`Dry-run deployment failed: ${err.message}`);
124
140
  }
125
141
 
126
- const request = {
127
- parent: `projects/${projectId}/locations/${location}`,
128
- storageSource: {
129
- bucket: sourceBucketName,
130
- object: sourceBlobName,
131
- },
132
- imageUri: targetImageUrl,
133
- client: 'cloud-run-mcp',
134
- };
142
+ let buildId;
143
+ let completedBuild;
144
+
135
145
  if (hasDockerfile) {
136
- request.dockerBuild = {};
146
+ // Docker builds need manual control via v1 createBuild to enable BuildKit support.
147
+ // This resolves issues with advanced Dockerfile instructions like heredocs (RUN <<EOF).
148
+ const build = {
149
+ images: [targetImageUrl],
150
+ source: {
151
+ storageSource: {
152
+ bucket: sourceBucketName,
153
+ object: sourceBlobName,
154
+ },
155
+ },
156
+ steps: [
157
+ {
158
+ name: 'gcr.io/cloud-builders/docker',
159
+ args: ['build', '-t', targetImageUrl, '.'],
160
+ env: ['DOCKER_BUILDKIT=1'],
161
+ },
162
+ ],
163
+ options: {
164
+ logging: 'CLOUD_LOGGING_ONLY',
165
+ },
166
+ };
167
+
168
+ await logAndProgress(
169
+ `Initiating Cloud Build (BuildKit enabled) for gs://${sourceBucketName}/${sourceBlobName} in ${location}...`,
170
+ progressCallback
171
+ );
172
+
173
+ const [operation] = await callWithRetry(
174
+ () =>
175
+ cloudBuildClient.createBuild({
176
+ parent: `projects/${projectId}/locations/${location}`,
177
+ build,
178
+ }),
179
+ 'cloudBuild.createBuild'
180
+ );
181
+ buildId = operation.metadata.build.id;
137
182
  } else {
138
- request.buildpackBuild = {};
139
- }
183
+ // For simple buildpacks, use the high-level submitBuild API if possible or if preferred for simplicity.
184
+ const request = {
185
+ parent: `projects/${projectId}/locations/${location}`,
186
+ storageSource: {
187
+ bucket: sourceBucketName,
188
+ object: sourceBlobName,
189
+ },
190
+ imageUri: targetImageUrl,
191
+ client: 'cloud-run-mcp',
192
+ buildpackBuild: {},
193
+ };
140
194
 
141
- await logAndProgress(
142
- `Initiating Cloud Build for gs://${sourceBucketName}/${sourceBlobName} in ${location}...`,
143
- progressCallback
144
- );
195
+ await logAndProgress(
196
+ `Initiating Cloud Build (Buildpacks) for gs://${sourceBucketName}/${sourceBlobName} in ${location}...`,
197
+ progressCallback
198
+ );
199
+
200
+ const [submitBuildResponse] = await callWithRetry(
201
+ () => buildsClient.submitBuild(request),
202
+ 'builds.submitBuild'
203
+ );
204
+ const encodedBuildId = submitBuildResponse.buildOperation.name
205
+ .split('/')
206
+ .pop();
207
+ buildId = Buffer.from(encodedBuildId, 'base64').toString('ascii');
208
+ }
145
209
 
146
- const [submitBuildResponse] = await callWithRetry(
147
- () => buildsClient.submitBuild(request),
148
- 'builds.submitBuild'
149
- );
150
210
  await logAndProgress(`Cloud Build job started...`, progressCallback);
151
- const encodedBuildId = submitBuildResponse.buildOperation.name
152
- .split('/')
153
- .pop();
154
- const buildId = Buffer.from(encodedBuildId, 'base64').toString('ascii');
155
- let completedBuild;
211
+
156
212
  while (true) {
157
213
  const [getBuildOperation] = await callWithRetry(
158
214
  () =>
@@ -247,3 +303,111 @@ export async function triggerCloudBuild(
247
303
  throw new Error(`Build ${buildId} failed.${buildLogsSnippet}`);
248
304
  }
249
305
  }
306
+
307
+ /**
308
+ * Build source for services defined in resourcesConfig.source_builds
309
+ * @param {*} resourcesConfig - resourcesConfig received from `run-compose resource` call
310
+ * @param {*} accessToken - access token for authentication
311
+ * @param {*} projectId - GCP project id
312
+ * @param {*} region - GCP region
313
+ * @param {*} folderPath - Path to the folder containing the source code
314
+ * @param {*} progressCallback - Callback function to report progress
315
+ * @returns - Completed resourcesConfig object
316
+ */
317
+ export async function composeBuild(
318
+ resourcesConfig,
319
+ accessToken,
320
+ projectId,
321
+ region,
322
+ folderPath,
323
+ progressCallback
324
+ ) {
325
+ if (
326
+ !resourcesConfig.source_builds ||
327
+ Object.keys(resourcesConfig.source_builds).length === 0
328
+ ) {
329
+ return resourcesConfig;
330
+ }
331
+
332
+ // Ensure AR Repo exists once
333
+ await ensureArtifactRegistryRepoExists(
334
+ projectId,
335
+ accessToken,
336
+ region,
337
+ DEPLOYMENT_CONFIG.REPO_NAME,
338
+ 'DOCKER',
339
+ progressCallback
340
+ );
341
+
342
+ const bucketName = `${projectId}_cloudbuild`;
343
+
344
+ // Ensure Storage Bucket exists once
345
+ const bucket = await ensureStorageBucketExists(
346
+ projectId,
347
+ bucketName,
348
+ region,
349
+ accessToken,
350
+ undefined,
351
+ false,
352
+ progressCallback
353
+ );
354
+
355
+ const buildPromises = Object.entries(resourcesConfig.source_builds).map(
356
+ async ([serviceName, buildInfo]) => {
357
+ const contextDir = path.join(folderPath, buildInfo.context);
358
+ await logAndProgress(
359
+ `Building source for service ${serviceName} in ${contextDir}...`,
360
+ progressCallback
361
+ );
362
+
363
+ // Check for Dockerfile in context
364
+ const hasDockerfile = fs.existsSync(path.join(contextDir, 'Dockerfile'));
365
+
366
+ // Generate Fingerprint
367
+ const fingerprint = await calculateSourceFingerprint(contextDir);
368
+ const composeProject = resourcesConfig.project;
369
+
370
+ // Generate image URL
371
+ // Use format: {repo}/{project_name}_{container}:{tag} matching builder.py
372
+ const imageUrl = `${region}-docker.pkg.dev/${projectId}/${DEPLOYMENT_CONFIG.REPO_NAME}/${composeProject}_${serviceName}:${DEPLOYMENT_CONFIG.IMAGE_TAG}`;
373
+
374
+ // Create tar.gz archive in memory
375
+ const archiveBuffer = await zipFiles(
376
+ [contextDir],
377
+ true,
378
+ progressCallback
379
+ );
380
+ // Use format: {epoch}.{fingerprint}.tgz in source/ folder
381
+ const archiveName = `source/${Date.now()}.${fingerprint}.tgz`;
382
+
383
+ const file = await uploadToStorageBucket(
384
+ bucket,
385
+ archiveBuffer,
386
+ archiveName,
387
+ progressCallback
388
+ );
389
+
390
+ // Trigger build
391
+ const buildResult = await triggerCloudBuild(
392
+ projectId,
393
+ region,
394
+ bucket.name,
395
+ archiveName,
396
+ DEPLOYMENT_CONFIG.REPO_NAME,
397
+ imageUrl,
398
+ hasDockerfile,
399
+ accessToken,
400
+ progressCallback,
401
+ undefined
402
+ );
403
+
404
+ const builtImageUrl = buildResult.results.images[0].name;
405
+ // Update resourcesConfig with image_id
406
+ resourcesConfig.source_builds[serviceName].image_id = builtImageUrl;
407
+ }
408
+ );
409
+
410
+ await Promise.all(buildPromises);
411
+
412
+ return resourcesConfig;
413
+ }