@google-cloud/cloud-run-mcp 1.10.0 → 1.12.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 (52) hide show
  1. package/.github/workflows/npm-publish.yml +10 -6
  2. package/README.md +26 -0
  3. package/SECURITY.md +7 -0
  4. package/cloudbuild.yaml +37 -0
  5. package/example-sources-to-deploy/java/pom.xml +41 -0
  6. package/example-sources-to-deploy/java/src/main/java/com/example/helloworld/HelloworldApplication.java +21 -0
  7. package/example-sources-to-deploy/java/src/main/resources/application.properties +1 -0
  8. package/example-sources-to-deploy/nodejs/index.js +15 -0
  9. package/example-sources-to-deploy/nodejs/package.json +12 -0
  10. package/example-sources-to-deploy/python/pip-project/main.py +11 -0
  11. package/example-sources-to-deploy/python/pip-project/requirements.txt +2 -0
  12. package/example-sources-to-deploy/python/pyproject-project/main.py +11 -0
  13. package/example-sources-to-deploy/python/pyproject-project/pyproject.toml +10 -0
  14. package/lib/clients.js +142 -12
  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 +8 -0
  21. package/lib/deployment/deployer.js +283 -28
  22. package/lib/deployment/helpers.js +58 -1
  23. package/lib/deployment/universal-maker.js +21 -148
  24. package/lib/middleware/oauth.js +26 -15
  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 +83 -20
  29. package/package.json +7 -4
  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 +307 -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 +100 -1
  40. package/test/local/mcp-server-streamable-http.test.js +132 -12
  41. package/test/local/middleware/oauth.test.js +106 -5
  42. package/test/local/test-utils.js +2 -18
  43. package/test/local/tools.test.js +43 -1
  44. package/test/local/universal-maker.test.js +23 -114
  45. package/test/local/util.test.js +361 -0
  46. package/test/need-gcp/deployer.test.js +1 -1
  47. package/test/need-gcp/test-helpers.js +5 -1
  48. package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
  49. package/tools/register-tools.js +98 -78
  50. /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
  51. /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
  52. /package/example-sources-to-deploy/{main.go → golang/main.go} +0 -0
@@ -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
+ }
@@ -23,6 +23,9 @@ import {
23
23
  import { callWithRetry, ensureApisEnabled } from './helpers.js';
24
24
  import { logAndProgress } from '../util/helpers.js';
25
25
 
26
+ const INVOKER_ROLE = 'roles/run.invoker';
27
+ const PUBLIC_MEMBER = 'allUsers';
28
+
26
29
  async function listCloudRunLocations(projectId, accessToken) {
27
30
  const listLocationsRequest = {
28
31
  name: `projects/${projectId}`,
@@ -255,3 +258,62 @@ export async function checkCloudRunServiceExists(
255
258
  throw error;
256
259
  }
257
260
  }
261
+
262
+ /**
263
+ * Sets public access (allUsers) for a Cloud Run service by assigning the 'roles/run.invoker' role.
264
+ *
265
+ * @async
266
+ * @param {string} projectId - The Google Cloud project ID.
267
+ * @param {string} location - The Google Cloud region.
268
+ * @param {string} serviceId - The ID of the Cloud Run service.
269
+ * @param {string} accessToken - Access token for authentication.
270
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
271
+ * @returns {Promise<void>} A promise that resolves when the IAM policy is successfully set.
272
+ * @throws {Error} If there's an error setting the IAM policy.
273
+ */
274
+ export async function setServicePublicAccess(
275
+ projectId,
276
+ location,
277
+ serviceId,
278
+ accessToken,
279
+ progressCallback
280
+ ) {
281
+ const runClient = await getRunClient(projectId, accessToken);
282
+ const resource = runClient.servicePath(projectId, location, serviceId);
283
+
284
+ try {
285
+ await logAndProgress(
286
+ `Setting public access for service ${serviceId}...`,
287
+ progressCallback
288
+ );
289
+ // For Cloud Run v2, we should use getIamPolicy then merge or just set.
290
+ // Given this is usually for fresh deployments or simple public services,
291
+ // we set it directly.
292
+ await callWithRetry(async () => {
293
+ const [policy] = await runClient.getIamPolicy({ resource });
294
+
295
+ // Find existing invoker binding or create a new one
296
+ let invokerBinding = policy.bindings.find((b) => b.role === INVOKER_ROLE);
297
+ if (!invokerBinding) {
298
+ invokerBinding = { role: INVOKER_ROLE, members: [] };
299
+ policy.bindings.push(invokerBinding);
300
+ }
301
+
302
+ // Add public member if not present
303
+ if (!invokerBinding.members.includes(PUBLIC_MEMBER)) {
304
+ invokerBinding.members.push(PUBLIC_MEMBER);
305
+ }
306
+
307
+ // Set the updated policy
308
+ await runClient.setIamPolicy({ resource, policy });
309
+ }, 'run.setIamPolicy');
310
+
311
+ await logAndProgress(
312
+ `Public access set successfully for service ${serviceId}.`,
313
+ progressCallback
314
+ );
315
+ } catch (error) {
316
+ console.error(`Error setting public access for ${serviceId}:`, error);
317
+ throw error;
318
+ }
319
+ }
@@ -0,0 +1,196 @@
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
+ import { getSecretManagerClient } from '../clients.js';
18
+ import { logAndProgress } from '../util/helpers.js';
19
+
20
+ const SECRET_ACCESSOR_ROLE = 'roles/secretmanager.secretAccessor';
21
+
22
+ /**
23
+ * Retrieves secret metadata.
24
+ * @param {string} projectId - GCP project ID.
25
+ * @param {string} secretName - Name of the secret.
26
+ * @param {string} accessToken - OAuth2 access token.
27
+ * @returns {Promise<Object|null>} Secret data or null if not found.
28
+ */
29
+ export async function getSecret(projectId, secretName, accessToken) {
30
+ const secretManager = await getSecretManagerClient(projectId, accessToken);
31
+ try {
32
+ const [res] = await secretManager.getSecret({
33
+ name: `projects/${projectId}/secrets/${secretName}`,
34
+ });
35
+ return res;
36
+ } catch (err) {
37
+ if (err.code === 5) {
38
+ // NOT_FOUND in gRPC
39
+ return null;
40
+ }
41
+ throw err;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Creates a new secret in Secret Manager.
47
+ * @param {string} projectId - GCP project ID.
48
+ * @param {string} secretName - Name of the secret to create.
49
+ * @param {string} accessToken - OAuth2 access token.
50
+ * @param {Object.<string, string>} [labels] - Optional labels to apply to the secret.
51
+ * @param {Function} progressCallback - Progress reporting callback.
52
+ * @returns {Promise<Object>} The created secret.
53
+ */
54
+ export async function createSecret(
55
+ projectId,
56
+ secretName,
57
+ accessToken,
58
+ labels,
59
+ progressCallback
60
+ ) {
61
+ const secretManager = await getSecretManagerClient(projectId, accessToken);
62
+ await logAndProgress(`Creating secret '${secretName}'...`, progressCallback);
63
+ const secret = {
64
+ replication: {
65
+ automatic: {},
66
+ },
67
+ };
68
+ if (labels) {
69
+ secret.labels = labels;
70
+ }
71
+ const [res] = await secretManager.createSecret({
72
+ parent: `projects/${projectId}`,
73
+ secretId: secretName,
74
+ secret: secret,
75
+ });
76
+ return res;
77
+ }
78
+
79
+ /**
80
+ * Adds a new version to an existing secret.
81
+ * @param {string} projectId - GCP project ID.
82
+ * @param {string} secretName - Name of the secret.
83
+ * @param {Buffer|string} content - Secret content.
84
+ * @param {string} accessToken - OAuth2 access token.
85
+ * @param {Function} progressCallback - Progress reporting callback.
86
+ * @returns {Promise<Object>} Information about the created version.
87
+ */
88
+ export async function addSecretVersion(
89
+ projectId,
90
+ secretName,
91
+ content,
92
+ accessToken,
93
+ progressCallback
94
+ ) {
95
+ const secretManager = await getSecretManagerClient(projectId, accessToken);
96
+ await logAndProgress(
97
+ `Adding new version to secret '${secretName}'...`,
98
+ progressCallback
99
+ );
100
+
101
+ const payload = typeof content === 'string' ? Buffer.from(content) : content;
102
+
103
+ const [res] = await secretManager.addSecretVersion({
104
+ parent: `projects/${projectId}/secrets/${secretName}`,
105
+ payload: {
106
+ data: payload,
107
+ },
108
+ });
109
+ return res;
110
+ }
111
+
112
+ /**
113
+ * Gets the IAM policy for a secret.
114
+ * @param {string} projectId - GCP project ID.
115
+ * @param {string} secretName - Name of the secret.
116
+ * @param {string} accessToken - OAuth2 access token.
117
+ * @returns {Promise<Object>} The IAM policy.
118
+ */
119
+ export async function getSecretIamPolicy(projectId, secretName, accessToken) {
120
+ const secretManager = await getSecretManagerClient(projectId, accessToken);
121
+ const [res] = await secretManager.getIamPolicy({
122
+ resource: `projects/${projectId}/secrets/${secretName}`,
123
+ });
124
+ return res;
125
+ }
126
+
127
+ /**
128
+ * Sets the IAM policy for a secret.
129
+ * @param {string} projectId - GCP project ID.
130
+ * @param {string} secretName - Name of the secret.
131
+ * @param {Object} policy - The IAM policy to set.
132
+ * @param {string} accessToken - OAuth2 access token.
133
+ * @param {Function} progressCallback - Progress reporting callback.
134
+ * @returns {Promise<Object>} Result of the policy update.
135
+ */
136
+ export async function setSecretIamPolicy(
137
+ projectId,
138
+ secretName,
139
+ policy,
140
+ accessToken,
141
+ progressCallback
142
+ ) {
143
+ const secretManager = await getSecretManagerClient(projectId, accessToken);
144
+ await logAndProgress(
145
+ `Updating IAM policy for secret '${secretName}'...`,
146
+ progressCallback
147
+ );
148
+ const [res] = await secretManager.setIamPolicy({
149
+ resource: `projects/${projectId}/secrets/${secretName}`,
150
+ policy: policy,
151
+ });
152
+ return res;
153
+ }
154
+
155
+ /**
156
+ * Adds a role binding to a secret's IAM policy.
157
+ * @param {string} projectId - GCP project ID.
158
+ * @param {string} secretName - Name of the secret.
159
+ * @param {string} member - Member to add (e.g. 'serviceAccount:...').
160
+ * @param {string} accessToken - OAuth2 access token.
161
+ * @param {Function} progressCallback - Progress reporting callback.
162
+ * @returns {Promise<Object>} Final IAM policy.
163
+ */
164
+ export async function addSecretAccessorBinding(
165
+ projectId,
166
+ secretName,
167
+ member,
168
+ accessToken,
169
+ progressCallback
170
+ ) {
171
+ const policy = await getSecretIamPolicy(projectId, secretName, accessToken);
172
+ const role = SECRET_ACCESSOR_ROLE;
173
+
174
+ policy.bindings = policy.bindings || [];
175
+ const binding = policy.bindings.find((b) => b.role === role);
176
+
177
+ if (binding) {
178
+ if (binding.members.includes(member)) {
179
+ return policy;
180
+ }
181
+ binding.members.push(member);
182
+ } else {
183
+ policy.bindings.push({
184
+ role: role,
185
+ members: [member],
186
+ });
187
+ }
188
+
189
+ return setSecretIamPolicy(
190
+ projectId,
191
+ secretName,
192
+ policy,
193
+ accessToken,
194
+ progressCallback
195
+ );
196
+ }