@google-cloud/cloud-run-mcp 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +24 -0
  2. package/SECURITY.md +7 -0
  3. package/cloudbuild.yaml +37 -0
  4. package/example-sources-to-deploy/java/pom.xml +41 -0
  5. package/example-sources-to-deploy/java/src/main/java/com/example/helloworld/HelloworldApplication.java +21 -0
  6. package/example-sources-to-deploy/java/src/main/resources/application.properties +1 -0
  7. package/example-sources-to-deploy/nodejs/index.js +15 -0
  8. package/example-sources-to-deploy/nodejs/package.json +12 -0
  9. package/example-sources-to-deploy/python/pip-project/main.py +11 -0
  10. package/example-sources-to-deploy/python/pip-project/requirements.txt +2 -0
  11. package/example-sources-to-deploy/python/pyproject-project/main.py +11 -0
  12. package/example-sources-to-deploy/python/pyproject-project/pyproject.toml +10 -0
  13. package/lib/clients.js +104 -3
  14. package/lib/cloud-api/build.js +192 -28
  15. package/lib/cloud-api/run.js +62 -0
  16. package/lib/cloud-api/secrets.js +196 -0
  17. package/lib/cloud-api/storage.js +164 -16
  18. package/lib/deployment/compose.js +517 -0
  19. package/lib/deployment/constants.js +8 -0
  20. package/lib/deployment/deployer.js +283 -28
  21. package/lib/deployment/helpers.js +58 -1
  22. package/lib/deployment/universal-maker.js +21 -148
  23. package/lib/util/archive.js +3 -3
  24. package/lib/util/artifacts.js +189 -0
  25. package/lib/util/helpers.js +109 -0
  26. package/mcp-server.js +4 -0
  27. package/package.json +6 -3
  28. package/skills/cloud-run/SKILL.md +109 -0
  29. package/test/local/archive.test.js +88 -0
  30. package/test/local/clients.test.js +224 -2
  31. package/test/local/cloud-api/build.test.js +330 -25
  32. package/test/local/cloud-api/run.test.js +129 -0
  33. package/test/local/cloud-api/secrets.test.js +291 -0
  34. package/test/local/cloud-api/storage.test.js +405 -0
  35. package/test/local/compose.test.js +618 -0
  36. package/test/local/deploy.test.js +408 -0
  37. package/test/local/deployment-helpers.test.js +100 -1
  38. package/test/local/test-utils.js +2 -18
  39. package/test/local/tools.test.js +43 -1
  40. package/test/local/universal-maker.test.js +23 -114
  41. package/test/local/util.test.js +361 -0
  42. package/test/need-gcp/deployer.test.js +1 -1
  43. package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
  44. package/tools/register-tools.js +19 -3
  45. /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
  46. /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
  47. /package/example-sources-to-deploy/{main.go → golang/main.go} +0 -0
@@ -0,0 +1,408 @@
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 { test, describe, mock } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import esmock from 'esmock';
20
+ import path from 'path';
21
+
22
+ describe('Deploy Compose', () => {
23
+ const projectId = 'test-project';
24
+ const region = 'us-central1';
25
+ const accessToken = 'test-token';
26
+ const composeFilePath = '/abs/path/to/compose.yaml';
27
+ const files = [path.dirname(composeFilePath)];
28
+
29
+ test('successfully deploys a simple compose file with one source-built service', async () => {
30
+ // Setup mocks
31
+ const prepareSourceDirectoryMock = mock.fn(async () => '/tmp/temp-dir');
32
+ const getProjectNumberMock = mock.fn(async () => '123456789');
33
+ const downloadRunComposeMock = mock.fn(
34
+ async () => '/usr/local/bin/run-compose'
35
+ );
36
+ const resourceComposeMock = mock.fn(async () =>
37
+ JSON.stringify({
38
+ source_builds: {
39
+ web: { context: 'app' },
40
+ },
41
+ })
42
+ );
43
+ const translateComposeMock = mock.fn(async () =>
44
+ JSON.stringify({
45
+ services: {
46
+ web: 'web.yaml',
47
+ },
48
+ })
49
+ );
50
+ const triggerCloudBuildMock = mock.fn(async () => ({
51
+ results: {
52
+ images: [
53
+ {
54
+ name: 'us-central1-docker.pkg.dev/test-project/mcp-cloud-run-deployments/web:latest',
55
+ },
56
+ ],
57
+ },
58
+ }));
59
+ const ensureArtifactRegistryRepoExistsMock = mock.fn();
60
+ const ensureStorageBucketExistsMock = mock.fn(async () => ({}));
61
+ const uploadToStorageBucketMock = mock.fn();
62
+ const zipFilesMock = mock.fn(async () => Buffer.from('dummy zip content'));
63
+ const cleanupTempDirectoryMock = mock.fn();
64
+ const getRunV1ClientMock = mock.fn(async () => ({
65
+ namespaces: {
66
+ services: {
67
+ replaceService: mock.fn(async () => ({
68
+ data: { metadata: { name: 'web' } },
69
+ })),
70
+ create: mock.fn(),
71
+ },
72
+ },
73
+ }));
74
+ const fsMock = {
75
+ existsSync: mock.fn(() => true),
76
+ promises: {
77
+ mkdtemp: mock.fn(async () => '/tmp/random-dir'),
78
+ readFile: mock.fn(async () => 'name: web'),
79
+ },
80
+ };
81
+ const logAndProgressMock = mock.fn();
82
+
83
+ // Import with mocks
84
+ const { deployCompose } = await esmock('../../lib/deployment/deployer.js', {
85
+ '../../lib/deployment/source-processor.js': {
86
+ prepareSourceDirectory: prepareSourceDirectoryMock,
87
+ cleanupTempDirectory: cleanupTempDirectoryMock,
88
+ },
89
+ '../../lib/util/helpers.js': {
90
+ getProjectNumber: getProjectNumberMock,
91
+ logAndProgress: logAndProgressMock,
92
+ },
93
+ '../../lib/deployment/compose.js': {
94
+ runCompose: downloadRunComposeMock,
95
+ resourceCompose: resourceComposeMock,
96
+ translateCompose: translateComposeMock,
97
+ composeVolumes: mock.fn(async (resourcesConfig) => resourcesConfig),
98
+ },
99
+ '../../lib/cloud-api/build.js': {
100
+ triggerCloudBuild: triggerCloudBuildMock,
101
+ composeBuild: mock.fn(
102
+ async (resourcesConfig, token, pId, reg, fPath, cb) => {
103
+ for (const serviceName in resourcesConfig.source_builds) {
104
+ const imageUrl = `${reg}-docker.pkg.dev/${pId}/mcp-cloud-run-deployments/${serviceName}:latest`;
105
+ const buildResult = await triggerCloudBuildMock(
106
+ pId,
107
+ reg,
108
+ 'bucket',
109
+ 'blob',
110
+ 'repo',
111
+ imageUrl,
112
+ true,
113
+ token,
114
+ cb
115
+ );
116
+ resourcesConfig.source_builds[serviceName].image_id =
117
+ buildResult.results.images[0].name;
118
+ }
119
+ return resourcesConfig;
120
+ }
121
+ ),
122
+ },
123
+ '../../lib/cloud-api/registry.js': {
124
+ ensureArtifactRegistryRepoExists: ensureArtifactRegistryRepoExistsMock,
125
+ },
126
+ '../../lib/util/archive.js': {
127
+ zipFiles: zipFilesMock,
128
+ },
129
+ '../../lib/cloud-api/storage.js': {
130
+ ensureStorageBucketExists: ensureStorageBucketExistsMock,
131
+ uploadToStorageBucket: uploadToStorageBucketMock,
132
+ },
133
+ '../../lib/clients.js': {
134
+ getRunV1Client: getRunV1ClientMock,
135
+ },
136
+ fs: fsMock,
137
+ path: path,
138
+ });
139
+
140
+ const result = await deployCompose({
141
+ projectId,
142
+ region,
143
+ files,
144
+ composeFilePath,
145
+ accessToken,
146
+ progressCallback: logAndProgressMock,
147
+ });
148
+
149
+ // Validations
150
+ assert.ok(result);
151
+ assert.equal(result.service, 'web');
152
+ assert.equal(result.uri, undefined); // In the mock, status.url is not provided
153
+ assert.equal(prepareSourceDirectoryMock.mock.callCount(), 1);
154
+ assert.equal(resourceComposeMock.mock.callCount(), 1);
155
+ assert.equal(translateComposeMock.mock.callCount(), 1);
156
+ assert.equal(triggerCloudBuildMock.mock.callCount(), 1);
157
+ assert.equal(cleanupTempDirectoryMock.mock.callCount(), 1);
158
+
159
+ // Verify triggerCloudBuild was called with correctly updated image tag
160
+ const buildCall = triggerCloudBuildMock.mock.calls[0];
161
+ assert.equal(
162
+ buildCall.arguments[5],
163
+ 'us-central1-docker.pkg.dev/test-project/mcp-cloud-run-deployments/web:latest'
164
+ );
165
+ });
166
+
167
+ test('failure in prepareSourceDirectory triggers cleanup and rethrows', async () => {
168
+ const error = new Error('Preparation failed');
169
+ const prepareSourceDirectoryMock = mock.fn(async () => {
170
+ throw error;
171
+ });
172
+ const logAndProgressMock = mock.fn();
173
+ const cleanupTempDirectoryMock = mock.fn();
174
+
175
+ const { deployCompose } = await esmock('../../lib/deployment/deployer.js', {
176
+ '../../lib/deployment/source-processor.js': {
177
+ prepareSourceDirectory: prepareSourceDirectoryMock,
178
+ cleanupTempDirectory: cleanupTempDirectoryMock,
179
+ },
180
+ '../../lib/util/helpers.js': {
181
+ logAndProgress: logAndProgressMock,
182
+ },
183
+ });
184
+
185
+ await assert.rejects(
186
+ deployCompose({
187
+ projectId,
188
+ region,
189
+ files,
190
+ composeFilePath,
191
+ accessToken,
192
+ progressCallback: logAndProgressMock,
193
+ }),
194
+ error
195
+ );
196
+
197
+ assert.equal(
198
+ logAndProgressMock.mock.calls.some(
199
+ (call) => call.arguments[2] === 'error'
200
+ ),
201
+ true
202
+ );
203
+ });
204
+
205
+ test('calls composeSecrets during deployment', async () => {
206
+ const composeSecretsMock = mock.fn(
207
+ async (resourcesConfig) => resourcesConfig
208
+ );
209
+ const prepareSourceDirectoryMock = mock.fn(async () => '/tmp/temp-dir');
210
+ const downloadRunComposeMock = mock.fn(async () => '/bin/run-compose');
211
+ const resourceComposeMock = mock.fn(async () => JSON.stringify({}));
212
+ const translateComposeMock = mock.fn(async () =>
213
+ JSON.stringify({ services: {} })
214
+ );
215
+ const getProjectNumberMock = mock.fn(async () => '123456');
216
+ const cleanupTempDirectoryMock = mock.fn();
217
+
218
+ const { deployCompose } = await esmock('../../lib/deployment/deployer.js', {
219
+ '../../lib/deployment/source-processor.js': {
220
+ prepareSourceDirectory: prepareSourceDirectoryMock,
221
+ cleanupTempDirectory: cleanupTempDirectoryMock,
222
+ },
223
+ '../../lib/util/helpers.js': {
224
+ getProjectNumber: getProjectNumberMock,
225
+ logAndProgress: mock.fn(),
226
+ },
227
+ '../../lib/deployment/compose.js': {
228
+ runCompose: downloadRunComposeMock,
229
+ resourceCompose: resourceComposeMock,
230
+ translateCompose: translateComposeMock,
231
+ composeVolumes: mock.fn(async (resourcesConfig) => resourcesConfig),
232
+ composeSecrets: composeSecretsMock,
233
+ },
234
+ '../../lib/clients.js': {
235
+ getRunV1Client: mock.fn(async () => ({ namespaces: { services: {} } })),
236
+ },
237
+ fs: {
238
+ promises: {
239
+ readFile: mock.fn(async () => 'name: web'),
240
+ },
241
+ },
242
+ path: path,
243
+ });
244
+
245
+ await deployCompose({
246
+ projectId,
247
+ region,
248
+ files,
249
+ composeFilePath,
250
+ accessToken,
251
+ progressCallback: mock.fn(),
252
+ });
253
+
254
+ assert.strictEqual(composeSecretsMock.mock.callCount(), 1);
255
+ });
256
+
257
+ test('successfully deploys models and services in correct order', async () => {
258
+ const prepareSourceDirectoryMock = mock.fn(async () => '/tmp/temp-dir');
259
+ const getProjectNumberMock = mock.fn(async () => '123456');
260
+ const downloadRunComposeMock = mock.fn(async () => '/bin/run-compose');
261
+ const resourceComposeMock = mock.fn(async () => JSON.stringify({}));
262
+ const translateComposeMock = mock.fn(async () =>
263
+ JSON.stringify({
264
+ models: { 'my-model': 'model.yaml' },
265
+ services: { 'my-app': 'app.yaml' },
266
+ })
267
+ );
268
+
269
+ const deployedServices = [];
270
+ const replaceServiceMock = mock.fn(async (params) => {
271
+ // Extract service name from namespaces/{p}/services/{s}
272
+ const serviceName = params.name.split('/').pop();
273
+ deployedServices.push(serviceName);
274
+ return { data: { status: { url: `https://${serviceName}.a.run.app` } } };
275
+ });
276
+
277
+ const getRunV1ClientMock = mock.fn(async () => ({
278
+ namespaces: {
279
+ services: {
280
+ replaceService: replaceServiceMock,
281
+ },
282
+ },
283
+ }));
284
+
285
+ const { deployCompose } = await esmock('../../lib/deployment/deployer.js', {
286
+ '../../lib/deployment/source-processor.js': {
287
+ prepareSourceDirectory: prepareSourceDirectoryMock,
288
+ cleanupTempDirectory: mock.fn(),
289
+ },
290
+ '../../lib/util/helpers.js': {
291
+ getProjectNumber: getProjectNumberMock,
292
+ logAndProgress: mock.fn(),
293
+ },
294
+ '../../lib/deployment/compose.js': {
295
+ runCompose: downloadRunComposeMock,
296
+ resourceCompose: resourceComposeMock,
297
+ translateCompose: translateComposeMock,
298
+ composeVolumes: mock.fn(async (resourcesConfig) => resourcesConfig),
299
+ composeSecrets: mock.fn(async (resourcesConfig) => resourcesConfig),
300
+ },
301
+ '../../lib/clients.js': {
302
+ getRunV1Client: getRunV1ClientMock,
303
+ },
304
+ fs: {
305
+ promises: {
306
+ readFile: mock.fn(async () => 'name: dummy'),
307
+ },
308
+ },
309
+ path: path,
310
+ });
311
+
312
+ const result = await deployCompose({
313
+ projectId,
314
+ region,
315
+ files,
316
+ composeFilePath,
317
+ accessToken,
318
+ progressCallback: mock.fn(),
319
+ });
320
+
321
+ assert.ok(result);
322
+ assert.strictEqual(result.service, 'my-app');
323
+ assert.strictEqual(result.uri, 'https://my-app.a.run.app');
324
+ assert.strictEqual(deployedServices.length, 2);
325
+ // Verify order: models first
326
+ assert.strictEqual(deployedServices[0], 'my-model');
327
+ assert.strictEqual(deployedServices[1], 'my-app');
328
+ });
329
+
330
+ test('sanitizes service names with underscores', async () => {
331
+ const rawServiceName = 'ai-model-test_web';
332
+ const sanitizedName = 'ai-model-test-web';
333
+
334
+ const prepareSourceDirectoryMock = mock.fn(async () => '/tmp/temp-dir');
335
+ const getProjectNumberMock = mock.fn(async () => '123456');
336
+ const downloadRunComposeMock = mock.fn(async () => '/bin/run-compose');
337
+ const translateComposeMock = mock.fn(async () =>
338
+ JSON.stringify({
339
+ services: { [rawServiceName]: 'web.yaml' },
340
+ })
341
+ );
342
+
343
+ const replaceServiceMock = mock.fn(async (params) => {
344
+ return { data: { metadata: { name: params.name.split('/').pop() } } };
345
+ });
346
+
347
+ const { deployCompose } = await esmock('../../lib/deployment/deployer.js', {
348
+ '../../lib/deployment/source-processor.js': {
349
+ prepareSourceDirectory: prepareSourceDirectoryMock,
350
+ cleanupTempDirectory: mock.fn(),
351
+ },
352
+ '../../lib/util/helpers.js': {
353
+ getProjectNumber: getProjectNumberMock,
354
+ logAndProgress: mock.fn(),
355
+ sanitizeCloudRunServiceName: (name) =>
356
+ name
357
+ .toLowerCase()
358
+ .replace(/[^a-z0-9]/g, '-')
359
+ .replace(/^-+|-+$/g, ''),
360
+ },
361
+ '../../lib/deployment/compose.js': {
362
+ runCompose: downloadRunComposeMock,
363
+ resourceCompose: mock.fn(async () => JSON.stringify({})),
364
+ translateCompose: translateComposeMock,
365
+ composeVolumes: mock.fn(async (resourcesConfig) => resourcesConfig),
366
+ composeSecrets: mock.fn(async (resourcesConfig) => resourcesConfig),
367
+ },
368
+ '../../lib/cloud-api/build.js': {
369
+ composeBuild: mock.fn(async (rc) => rc),
370
+ },
371
+ '../../lib/clients.js': {
372
+ getRunV1Client: mock.fn(async () => ({
373
+ namespaces: { services: { replaceService: replaceServiceMock } },
374
+ })),
375
+ },
376
+ fs: {
377
+ promises: {
378
+ readFile: mock.fn(async () => 'metadata:\n name: ai-model-test_web'),
379
+ },
380
+ },
381
+ path: path,
382
+ });
383
+
384
+ await deployCompose({
385
+ projectId,
386
+ region,
387
+ files,
388
+ composeFilePath,
389
+ accessToken,
390
+ progressCallback: mock.fn(),
391
+ });
392
+
393
+ assert.strictEqual(replaceServiceMock.mock.callCount(), 1);
394
+ const call = replaceServiceMock.mock.calls[0];
395
+
396
+ // Verify name in URL was sanitized
397
+ assert.strictEqual(
398
+ call.arguments[0].name,
399
+ `namespaces/${projectId}/services/${sanitizedName}`
400
+ );
401
+
402
+ // Verify name in requestBody metadata was sanitized
403
+ assert.strictEqual(
404
+ call.arguments[0].requestBody.metadata.name,
405
+ sanitizedName
406
+ );
407
+ });
408
+ });
@@ -23,7 +23,10 @@ describe('Deployment Helpers', () => {
23
23
  const fsMock = {
24
24
  statSync: mock.fn(),
25
25
  existsSync: mock.fn(),
26
- readFileSync: mock.fn(),
26
+ promises: {
27
+ readdir: mock.fn(),
28
+ readFile: mock.fn(),
29
+ },
27
30
  };
28
31
 
29
32
  test('makeFileDeploymentMetadata correctly identifies Dockerfile in folder', async () => {
@@ -176,4 +179,100 @@ describe('Deployment Helpers', () => {
176
179
  ],
177
180
  });
178
181
  });
182
+
183
+ test('uploadDirectory recursively uploads files', async () => {
184
+ const uploadToStorageBucketMock = mock.fn(async () => ({}));
185
+ const readFileMock = mock.fn(async () => Buffer.from('content'));
186
+
187
+ // Create a setup for each call to differentiate directory depth
188
+ fsMock.promises.readdir.mock.resetCalls();
189
+ fsMock.promises.readdir.mock.mockImplementation(async (p) => {
190
+ if (p.endsWith('subdir')) {
191
+ return [{ name: 'file2.txt', isDirectory: () => false }];
192
+ }
193
+ return [
194
+ { name: 'file1.txt', isDirectory: () => false },
195
+ { name: 'subdir', isDirectory: () => true },
196
+ ];
197
+ });
198
+
199
+ const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
200
+ fs: {
201
+ ...fsMock,
202
+ promises: {
203
+ ...fsMock.promises,
204
+ readFile: readFileMock,
205
+ },
206
+ default: {
207
+ ...fsMock,
208
+ promises: {
209
+ ...fsMock.promises,
210
+ readFile: readFileMock,
211
+ },
212
+ },
213
+ },
214
+ '../../lib/cloud-api/storage.js': {
215
+ uploadToStorageBucket: uploadToStorageBucketMock,
216
+ },
217
+ });
218
+
219
+ const bucket = { name: 'test-bucket' };
220
+ const localPath = '/local/path';
221
+ const gcsPrefix = 'prefix';
222
+ const progressCallback = mock.fn();
223
+
224
+ await deploymentHelpers.uploadDirectory(
225
+ bucket,
226
+ localPath,
227
+ gcsPrefix,
228
+ progressCallback
229
+ );
230
+
231
+ assert.strictEqual(uploadToStorageBucketMock.mock.callCount(), 2);
232
+
233
+ // Verify first upload (root level)
234
+ const call1 = uploadToStorageBucketMock.mock.calls[0];
235
+ assert.ok(call1.arguments[2].endsWith('prefix/file1.txt'));
236
+
237
+ // Verify second upload (subdir level)
238
+ const call2 = uploadToStorageBucketMock.mock.calls[1];
239
+ assert.ok(call2.arguments[2].endsWith('prefix/subdir/file2.txt'));
240
+ });
241
+
242
+ test('makeFileDeploymentMetadata correctly identifies compose file in folder', async () => {
243
+ const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
244
+ fs: fsMock,
245
+ });
246
+
247
+ const folderPath = '/absolute/path/to/folder';
248
+
249
+ fsMock.statSync.mock.mockImplementation(() => ({
250
+ isDirectory: () => true,
251
+ }));
252
+
253
+ fsMock.existsSync.mock.mockImplementation((filePath) => {
254
+ if (filePath.endsWith('compose.yaml')) return true;
255
+ return false;
256
+ });
257
+
258
+ const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
259
+
260
+ assert.ok(result.composeFilePath.endsWith('compose.yaml'));
261
+ });
262
+
263
+ test('makeFileDeploymentMetadata returns null for composeFilePath if no compose file exists', async () => {
264
+ const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
265
+ fs: fsMock,
266
+ });
267
+
268
+ const files = ['Dockerfile', 'index.js'];
269
+
270
+ fsMock.statSync.mock.mockImplementation(() => ({
271
+ isDirectory: () => false,
272
+ }));
273
+
274
+ const result = deploymentHelpers.makeFileDeploymentMetadata(files);
275
+
276
+ assert.strictEqual(result.composeFilePath, null);
277
+ });
179
278
  });
@@ -1,19 +1,3 @@
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
1
  /**
18
2
  * Waits for a specific string to appear in a stream's output.
19
3
  * This is useful for synchronizing tests with asynchronous child processes,
@@ -22,11 +6,11 @@ limitations under the License.
22
6
  *
23
7
  * @param {ReadableStream} stream - The stream to listen to (e.g., process.stdout or process.stderr).
24
8
  * @param {string} str - The string to wait for in the stream's output.
25
- * @param {number} [timeoutMs=7000] - The maximum time in milliseconds to wait before rejecting.
9
+ * @param {number} [timeoutMs=10000] - The maximum time in milliseconds to wait before rejecting.
26
10
  * @returns {Promise<string>} A promise that resolves with the accumulated data
27
11
  * when the string is found, or rejects if the timeout is reached.
28
12
  */
29
- export async function waitForString(stream, str, timeoutMs = 7000) {
13
+ export async function waitForString(stream, str, timeoutMs = 10000) {
30
14
  let accumulatedData = '';
31
15
  return new Promise((resolve, reject) => {
32
16
  const timeout = setTimeout(() => {
@@ -207,6 +207,7 @@ describe('registerTools', () => {
207
207
  name: 'my-service',
208
208
  uri: 'my-uri',
209
209
  lastModifier: 'me',
210
+ updateTime: { seconds: '1735732800', nanos: 0 }, // 2025-01-01T12:00:00Z
210
211
  }),
211
212
  },
212
213
  }
@@ -223,11 +224,12 @@ describe('registerTools', () => {
223
224
  service: 'my-service',
224
225
  });
225
226
 
227
+ const expectedDate = new Date('2025-01-01T12:00:00Z').toUTCString();
226
228
  assert.deepStrictEqual(result, {
227
229
  content: [
228
230
  {
229
231
  type: 'text',
230
- text: 'Name: my-service\nRegion: my-region\nProject: my-project\nURL: my-uri\nLast deployed by: me',
232
+ text: `Name: my-service\nRegion: my-region\nProject: my-project\nURL: my-uri\nConsole URL: https://console.cloud.google.com/run/detail/my-region/my-service?project=my-project\nLast deployed by: me\nLast deployed date: ${expectedDate}`,
231
233
  },
232
234
  ],
233
235
  });
@@ -324,6 +326,46 @@ describe('registerTools', () => {
324
326
  ],
325
327
  });
326
328
  });
329
+
330
+ it('should deploy local folder with ingress', async () => {
331
+ const server = {
332
+ registerTool: mock.fn(),
333
+ };
334
+ const deployMock = mock.fn(() => Promise.resolve({ uri: 'my-uri' }));
335
+
336
+ const { registerTools } = await esmock(
337
+ '../../tools/tools.js',
338
+ {},
339
+ {
340
+ '../../lib/deployment/deployer.js': {
341
+ deploy: deployMock,
342
+ },
343
+ }
344
+ );
345
+
346
+ registerTools(server, {
347
+ gcpCredentialsAvailable: true,
348
+ ingress: 'INGRESS_TRAFFIC_INTERNAL_ONLY',
349
+ });
350
+
351
+ const handler = server.registerTool.mock.calls.find(
352
+ (call) => call.arguments[0] === 'deploy_local_folder'
353
+ ).arguments[2];
354
+ await handler(
355
+ {
356
+ project: 'my-project',
357
+ region: 'my-region',
358
+ service: 'my-service',
359
+ folderPath: '/my/folder',
360
+ },
361
+ { sendNotification: mock.fn() }
362
+ );
363
+
364
+ assert.strictEqual(
365
+ deployMock.mock.calls[0].arguments[0].ingress,
366
+ 'INGRESS_TRAFFIC_INTERNAL_ONLY'
367
+ );
368
+ });
327
369
  });
328
370
 
329
371
  describe('deploy_file_contents', () => {