@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.
- package/README.md +24 -0
- package/SECURITY.md +7 -0
- package/cloudbuild.yaml +37 -0
- package/example-sources-to-deploy/java/pom.xml +41 -0
- package/example-sources-to-deploy/java/src/main/java/com/example/helloworld/HelloworldApplication.java +21 -0
- package/example-sources-to-deploy/java/src/main/resources/application.properties +1 -0
- package/example-sources-to-deploy/nodejs/index.js +15 -0
- package/example-sources-to-deploy/nodejs/package.json +12 -0
- package/example-sources-to-deploy/python/pip-project/main.py +11 -0
- package/example-sources-to-deploy/python/pip-project/requirements.txt +2 -0
- package/example-sources-to-deploy/python/pyproject-project/main.py +11 -0
- package/example-sources-to-deploy/python/pyproject-project/pyproject.toml +10 -0
- package/gemini-extension.json +12 -0
- package/lib/clients.js +104 -3
- package/lib/cloud-api/build.js +192 -28
- package/lib/cloud-api/run.js +62 -0
- package/lib/cloud-api/secrets.js +196 -0
- package/lib/cloud-api/storage.js +164 -16
- package/lib/deployment/compose.js +517 -0
- package/lib/deployment/constants.js +11 -0
- package/lib/deployment/deployer.js +334 -30
- package/lib/deployment/helpers.js +99 -49
- package/lib/deployment/source-processor.js +0 -48
- package/lib/deployment/universal-maker.js +147 -0
- package/lib/util/archive.js +3 -3
- package/lib/util/artifacts.js +189 -0
- package/lib/util/helpers.js +109 -0
- package/mcp-server.js +4 -0
- package/package.json +6 -3
- package/skills/cloud-run/SKILL.md +109 -0
- package/test/local/archive.test.js +88 -0
- package/test/local/clients.test.js +224 -2
- package/test/local/cloud-api/build.test.js +330 -25
- package/test/local/cloud-api/run.test.js +129 -0
- package/test/local/cloud-api/secrets.test.js +291 -0
- package/test/local/cloud-api/storage.test.js +405 -0
- package/test/local/compose.test.js +618 -0
- package/test/local/deploy.test.js +408 -0
- package/test/local/deployment-helpers.test.js +126 -79
- package/test/local/source-processor.test.js +0 -165
- package/test/local/test-utils.js +2 -18
- package/test/local/tools.test.js +43 -1
- package/test/local/universal-maker.test.js +157 -0
- package/test/local/util.test.js +361 -0
- package/test/need-gcp/deployer.test.js +1 -1
- package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
- package/tools/register-tools.js +19 -3
- /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
- /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
- /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
|
-
|
|
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
|
|
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('
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
137
|
-
assert.equal(result.deploymentAttrs.runtime, undefined);
|
|
93
|
+
assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), true);
|
|
138
94
|
});
|
|
139
95
|
|
|
140
|
-
test('canDeployWithoutBuild returns
|
|
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:
|
|
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),
|
|
108
|
+
assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), false);
|
|
156
109
|
});
|
|
157
110
|
|
|
158
|
-
test('
|
|
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
|
|
164
|
-
|
|
116
|
+
const input = {
|
|
117
|
+
bucketName: 'test-bucket',
|
|
118
|
+
fileName: 'source.tar.gz',
|
|
165
119
|
deploymentAttrs: {
|
|
166
|
-
runtime: 'nodejs',
|
|
167
120
|
cmd: ['node'],
|
|
168
|
-
args: ['
|
|
121
|
+
args: ['server.js'],
|
|
122
|
+
baseImage: 'gcr.io/google-appengine/nodejs',
|
|
169
123
|
},
|
|
170
124
|
};
|
|
171
125
|
|
|
172
|
-
|
|
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
|
|
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/
|
|
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((
|
|
219
|
-
if (
|
|
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
|
-
|
|
229
|
-
|
|
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
|
});
|