@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
@@ -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 () => {
@@ -50,7 +53,7 @@ describe('Deployment Helpers', () => {
50
53
  assert.equal(result.hasDockerfile, true);
51
54
  });
52
55
 
53
- test('makeFileDeploymentMetadata correctly identifies Node.js project attributes', async () => {
56
+ test('makeFileDeploymentMetadata correctly identifies Node.js project', async () => {
54
57
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
55
58
  fs: fsMock,
56
59
  });
@@ -69,110 +72,75 @@ describe('Deployment Helpers', () => {
69
72
  return false;
70
73
  });
71
74
 
72
- // Mock readFileSync for package.json
73
- fsMock.readFileSync.mock.mockImplementation(() =>
74
- JSON.stringify({
75
- scripts: { start: 'node server.js' },
76
- })
77
- );
78
-
79
75
  const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
80
76
 
81
77
  assert.equal(result.hasDockerfile, false);
82
78
  assert.equal(result.deploymentAttrs.runtime, 'nodejs');
83
- assert.deepEqual(result.deploymentAttrs.cmd, ['node']);
84
- assert.deepEqual(result.deploymentAttrs.args, ['server.js']);
85
79
  });
86
80
 
87
- test('makeFileDeploymentMetadata ignores Node.js if engines.node is present', async () => {
81
+ test('canDeployWithoutBuild returns true for valid Node.js project', async () => {
88
82
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
89
83
  fs: fsMock,
90
84
  });
91
85
 
92
- const folderPath = '/absolute/path/to/node-app-engines';
93
-
94
- fsMock.statSync.mock.mockImplementation(() => ({
95
- isDirectory: () => true,
96
- }));
97
-
98
- fsMock.existsSync.mock.mockImplementation((p) => {
99
- if (p.endsWith('package.json')) return true;
100
- return false;
101
- });
102
-
103
- fsMock.readFileSync.mock.mockImplementation(() =>
104
- JSON.stringify({
105
- scripts: { start: 'node server.js' },
106
- engines: { node: '>=14' },
107
- })
108
- );
109
-
110
- const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
111
-
112
- assert.equal(result.deploymentAttrs.runtime, undefined);
113
- });
114
-
115
- test('makeFileDeploymentMetadata handles invalid package.json gracefully', async () => {
116
- const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
117
- fs: fsMock,
118
- });
119
-
120
- const folderPath = '/absolute/path/to/bad-json-app';
121
-
122
- fsMock.statSync.mock.mockImplementation(() => ({
123
- isDirectory: () => true,
124
- }));
125
-
126
- fsMock.existsSync.mock.mockImplementation((p) => {
127
- if (p.endsWith('package.json')) return true;
128
- return false;
129
- });
130
-
131
- // Mock readFileSync to return invalid JSON
132
- fsMock.readFileSync.mock.mockImplementation(() => '{ invalid json }');
133
-
134
- const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
86
+ const metadata = {
87
+ hasDockerfile: false,
88
+ deploymentAttrs: {
89
+ runtime: 'nodejs',
90
+ },
91
+ };
135
92
 
136
- // Should return empty deployment attrs
137
- assert.equal(result.deploymentAttrs.runtime, undefined);
93
+ assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), true);
138
94
  });
139
95
 
140
- test('canDeployWithoutBuild returns true for valid Node.js zip deploy', async () => {
96
+ test('canDeployWithoutBuild returns false if Dockerfile exists', async () => {
141
97
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
142
98
  fs: fsMock,
143
99
  });
144
100
 
145
101
  const metadata = {
146
- hasDockerfile: false,
102
+ hasDockerfile: true,
147
103
  deploymentAttrs: {
148
104
  runtime: 'nodejs',
149
- cmd: ['node'],
150
- args: ['index.js'],
151
- baseImage: 'base-image',
152
105
  },
153
106
  };
154
107
 
155
- assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), true);
108
+ assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), false);
156
109
  });
157
110
 
158
- test('canDeployWithoutBuild returns false if Dockerfile exists', async () => {
111
+ test('createDirectSourceDeploymentContainer creates correct object without envVars', async () => {
159
112
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
160
113
  fs: fsMock,
161
114
  });
162
115
 
163
- const metadata = {
164
- hasDockerfile: true,
116
+ const input = {
117
+ bucketName: 'test-bucket',
118
+ fileName: 'source.tar.gz',
165
119
  deploymentAttrs: {
166
- runtime: 'nodejs',
167
120
  cmd: ['node'],
168
- args: ['index.js'],
121
+ args: ['server.js'],
122
+ baseImage: 'gcr.io/google-appengine/nodejs',
169
123
  },
170
124
  };
171
125
 
172
- assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), false);
126
+ const result =
127
+ deploymentHelpers.createDirectSourceDeploymentContainer(input);
128
+
129
+ assert.deepEqual(result, {
130
+ image: DEPLOYMENT_CONFIG.NO_BUILD_IMAGE_TYPE,
131
+ baseImageUri: 'gcr.io/google-appengine/nodejs',
132
+ sourceCode: {
133
+ cloudStorageSource: {
134
+ bucket: 'test-bucket',
135
+ object: 'source.tar.gz',
136
+ },
137
+ },
138
+ command: ['node'],
139
+ args: ['server.js'],
140
+ });
173
141
  });
174
142
 
175
- test('createDirectSourceDeploymentContainer creates correct object', async () => {
143
+ test('createDirectSourceDeploymentContainer creates correct object with envVars', async () => {
176
144
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
177
145
  fs: fsMock,
178
146
  });
@@ -184,6 +152,10 @@ describe('Deployment Helpers', () => {
184
152
  cmd: ['node'],
185
153
  args: ['server.js'],
186
154
  baseImage: 'gcr.io/google-appengine/nodejs',
155
+ envVars: {
156
+ PORT: '8080',
157
+ NODE_ENV: 'production',
158
+ },
187
159
  },
188
160
  };
189
161
 
@@ -201,31 +173,106 @@ describe('Deployment Helpers', () => {
201
173
  },
202
174
  command: ['node'],
203
175
  args: ['server.js'],
176
+ env: [
177
+ { name: 'PORT', value: '8080' },
178
+ { name: 'NODE_ENV', value: 'production' },
179
+ ],
180
+ });
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
+ },
204
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'));
205
240
  });
206
241
 
207
- test('makeFileDeploymentMetadata handles invalid package.json gracefully', async () => {
242
+ test('makeFileDeploymentMetadata correctly identifies compose file in folder', async () => {
208
243
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
209
244
  fs: fsMock,
210
245
  });
211
246
 
212
- const folderPath = '/absolute/path/to/bad-json-app';
247
+ const folderPath = '/absolute/path/to/folder';
213
248
 
214
249
  fsMock.statSync.mock.mockImplementation(() => ({
215
250
  isDirectory: () => true,
216
251
  }));
217
252
 
218
- fsMock.existsSync.mock.mockImplementation((p) => {
219
- if (p.endsWith('package.json')) return true;
253
+ fsMock.existsSync.mock.mockImplementation((filePath) => {
254
+ if (filePath.endsWith('compose.yaml')) return true;
220
255
  return false;
221
256
  });
222
257
 
223
- // Mock readFileSync to return invalid JSON
224
- fsMock.readFileSync.mock.mockImplementation(() => '{ invalid json }');
225
-
226
258
  const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
227
259
 
228
- // Should return empty deployment attrs
229
- assert.equal(result.deploymentAttrs.runtime, undefined);
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);
230
277
  });
231
278
  });