@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.
- 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/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 +8 -0
- package/lib/deployment/deployer.js +283 -28
- package/lib/deployment/helpers.js +58 -1
- package/lib/deployment/universal-maker.js +21 -148
- 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 +100 -1
- package/test/local/test-utils.js +2 -18
- package/test/local/tools.test.js +43 -1
- package/test/local/universal-maker.test.js +23 -114
- 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,618 @@
|
|
|
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, beforeEach } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import esmock from 'esmock';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
|
|
22
|
+
describe('Compose Deployment', () => {
|
|
23
|
+
const osMock = {
|
|
24
|
+
homedir: () => '/home/user',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const helpersMock = {
|
|
28
|
+
logAndProgress: mock.fn(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const artifactsMock = {
|
|
32
|
+
ensureRepositoryDownloaded: mock.fn(),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const childProcessMock = {
|
|
36
|
+
execFile: mock.fn(),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
test('runCompose successfully ensures download', async () => {
|
|
40
|
+
artifactsMock.ensureRepositoryDownloaded.mock.resetCalls();
|
|
41
|
+
artifactsMock.ensureRepositoryDownloaded.mock.mockImplementation(
|
|
42
|
+
() => '/home/user/.cloud-run-mcp/bin/run-compose'
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const compose = await esmock('../../lib/deployment/compose.js', {
|
|
46
|
+
os: osMock,
|
|
47
|
+
'../../lib/util/helpers.js': helpersMock,
|
|
48
|
+
'../../lib/util/artifacts.js': artifactsMock,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const result = await compose.runCompose('fake-token', mock.fn());
|
|
53
|
+
|
|
54
|
+
assert.strictEqual(result, '/home/user/.cloud-run-mcp/bin/run-compose');
|
|
55
|
+
assert.strictEqual(
|
|
56
|
+
artifactsMock.ensureRepositoryDownloaded.mock.callCount(),
|
|
57
|
+
1
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Verify parameters passed to ensureRepositoryDownloaded
|
|
61
|
+
const call = artifactsMock.ensureRepositoryDownloaded.mock.calls[0];
|
|
62
|
+
assert.strictEqual(
|
|
63
|
+
call.arguments[0],
|
|
64
|
+
'/home/user/.cloud-run-mcp/bin/run-compose'
|
|
65
|
+
);
|
|
66
|
+
assert.strictEqual(call.arguments[1].project, 'serverless-runtimes-qa');
|
|
67
|
+
assert.strictEqual(call.arguments[1].location, 'us-central1');
|
|
68
|
+
assert.strictEqual(call.arguments[1].repository, 'run-compose');
|
|
69
|
+
assert.strictEqual(call.arguments[2], 'fake-token');
|
|
70
|
+
} finally {
|
|
71
|
+
// No need to restore env since we didn't use it
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('runCompose returns null if download fails', async () => {
|
|
76
|
+
artifactsMock.ensureRepositoryDownloaded.mock.resetCalls();
|
|
77
|
+
artifactsMock.ensureRepositoryDownloaded.mock.mockImplementation(
|
|
78
|
+
() => null
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const compose = await esmock('../../lib/deployment/compose.js', {
|
|
82
|
+
os: osMock,
|
|
83
|
+
'../../lib/util/helpers.js': helpersMock,
|
|
84
|
+
'../../lib/util/artifacts.js': artifactsMock,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const result = await compose.runCompose('fake-token', mock.fn());
|
|
88
|
+
|
|
89
|
+
assert.strictEqual(result, null);
|
|
90
|
+
assert.strictEqual(
|
|
91
|
+
artifactsMock.ensureRepositoryDownloaded.mock.callCount(),
|
|
92
|
+
1
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('runCompose returns null if platform not supported', async () => {
|
|
97
|
+
// Save original platform
|
|
98
|
+
const originalPlatform = process.platform;
|
|
99
|
+
Object.defineProperty(process, 'platform', {
|
|
100
|
+
value: 'win32',
|
|
101
|
+
configurable: true,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const compose = await esmock('../../lib/deployment/compose.js', {
|
|
106
|
+
os: osMock,
|
|
107
|
+
'../../lib/util/helpers.js': helpersMock,
|
|
108
|
+
'../../lib/util/artifacts.js': artifactsMock,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const result = await compose.runCompose('fake-token', mock.fn());
|
|
112
|
+
assert.strictEqual(result, null);
|
|
113
|
+
} finally {
|
|
114
|
+
Object.defineProperty(process, 'platform', {
|
|
115
|
+
value: originalPlatform,
|
|
116
|
+
configurable: true,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('resourceCompose returns stdout on success', async () => {
|
|
122
|
+
childProcessMock.execFile.mock.resetCalls();
|
|
123
|
+
childProcessMock.execFile.mock.mockImplementation(
|
|
124
|
+
(file, args, opts, cb) => {
|
|
125
|
+
if (typeof opts === 'function') {
|
|
126
|
+
cb = opts;
|
|
127
|
+
}
|
|
128
|
+
cb(null, { stdout: '{"resources": []}', stderr: '' });
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const compose = await esmock('../../lib/deployment/compose.js', {
|
|
133
|
+
child_process: childProcessMock,
|
|
134
|
+
'../../lib/util/helpers.js': helpersMock,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const result = await compose.resourceCompose(
|
|
138
|
+
'/bin/run-compose',
|
|
139
|
+
'/path/to/compose.yaml',
|
|
140
|
+
'us-central1',
|
|
141
|
+
mock.fn()
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
assert.strictEqual(result, '{"resources": []}');
|
|
145
|
+
assert.strictEqual(childProcessMock.execFile.mock.callCount(), 1);
|
|
146
|
+
const call = childProcessMock.execFile.mock.calls[0];
|
|
147
|
+
assert.strictEqual(call.arguments[0], '/bin/run-compose');
|
|
148
|
+
assert.deepEqual(call.arguments[1], [
|
|
149
|
+
'resource',
|
|
150
|
+
'/path/to/compose.yaml',
|
|
151
|
+
'--region',
|
|
152
|
+
'us-central1',
|
|
153
|
+
'--out',
|
|
154
|
+
'.',
|
|
155
|
+
]);
|
|
156
|
+
assert.strictEqual(call.arguments[2].cwd, '/path/to');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('resourceCompose logs warning on stderr but returns stdout', async () => {
|
|
160
|
+
childProcessMock.execFile.mock.resetCalls();
|
|
161
|
+
helpersMock.logAndProgress.mock.resetCalls();
|
|
162
|
+
childProcessMock.execFile.mock.mockImplementation(
|
|
163
|
+
(file, args, opts, cb) => {
|
|
164
|
+
if (typeof opts === 'function') {
|
|
165
|
+
cb = opts;
|
|
166
|
+
}
|
|
167
|
+
cb(null, { stdout: 'output', stderr: 'some warning' });
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const compose = await esmock('../../lib/deployment/compose.js', {
|
|
172
|
+
child_process: childProcessMock,
|
|
173
|
+
'../../lib/util/helpers.js': helpersMock,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const result = await compose.resourceCompose(
|
|
177
|
+
'/bin/run-compose',
|
|
178
|
+
'/path/to/compose.yaml',
|
|
179
|
+
'us-central1',
|
|
180
|
+
mock.fn()
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
assert.strictEqual(result, 'output');
|
|
184
|
+
// Verify logAndProgress was called with warn for stderr
|
|
185
|
+
const warnCall = helpersMock.logAndProgress.mock.calls.find(
|
|
186
|
+
(c) => c.arguments[2] === 'warn'
|
|
187
|
+
);
|
|
188
|
+
assert.ok(warnCall);
|
|
189
|
+
assert.ok(warnCall.arguments[0].includes('some warning'));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('resourceCompose throws error if execFile fails', async () => {
|
|
193
|
+
childProcessMock.execFile.mock.resetCalls();
|
|
194
|
+
childProcessMock.execFile.mock.mockImplementation(
|
|
195
|
+
(file, args, opts, cb) => {
|
|
196
|
+
if (typeof opts === 'function') {
|
|
197
|
+
cb = opts;
|
|
198
|
+
}
|
|
199
|
+
cb(new Error('execFile failed'), { stdout: '', stderr: '' });
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const compose = await esmock('../../lib/deployment/compose.js', {
|
|
204
|
+
child_process: childProcessMock,
|
|
205
|
+
'../../lib/util/helpers.js': helpersMock,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await assert.rejects(
|
|
209
|
+
compose.resourceCompose(
|
|
210
|
+
'/bin/run-compose',
|
|
211
|
+
'/path/to/compose.yaml',
|
|
212
|
+
'us-central1',
|
|
213
|
+
mock.fn()
|
|
214
|
+
),
|
|
215
|
+
{
|
|
216
|
+
message: /Failed to get resources for compose file: execFile failed/,
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('translateCompose returns stdout on success', async () => {
|
|
222
|
+
childProcessMock.execFile.mock.resetCalls();
|
|
223
|
+
childProcessMock.execFile.mock.mockImplementation(
|
|
224
|
+
(file, args, opts, cb) => {
|
|
225
|
+
if (typeof opts === 'function') {
|
|
226
|
+
cb = opts;
|
|
227
|
+
}
|
|
228
|
+
cb(null, { stdout: 'translated output', stderr: '' });
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const compose = await esmock('../../lib/deployment/compose.js', {
|
|
233
|
+
child_process: childProcessMock,
|
|
234
|
+
'../../lib/util/helpers.js': helpersMock,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const result = await compose.translateCompose(
|
|
238
|
+
'/bin/run-compose',
|
|
239
|
+
'/path/to/compose.yaml',
|
|
240
|
+
'us-central1',
|
|
241
|
+
'123456789',
|
|
242
|
+
mock.fn()
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
assert.strictEqual(result, 'translated output');
|
|
246
|
+
assert.strictEqual(childProcessMock.execFile.mock.callCount(), 1);
|
|
247
|
+
const call = childProcessMock.execFile.mock.calls[0];
|
|
248
|
+
assert.strictEqual(call.arguments[0], '/bin/run-compose');
|
|
249
|
+
assert.strictEqual(call.arguments[1][0], 'translate');
|
|
250
|
+
assert.strictEqual(call.arguments[1][1], '/path/to/compose.yaml');
|
|
251
|
+
assert.strictEqual(call.arguments[1][7], '.'); // Currently it's '.'
|
|
252
|
+
assert.strictEqual(call.arguments[2].cwd, '/path/to');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('translateCompose handles resourcesConfig', async () => {
|
|
256
|
+
childProcessMock.execFile.mock.resetCalls();
|
|
257
|
+
childProcessMock.execFile.mock.mockImplementation(
|
|
258
|
+
(file, args, opts, cb) => {
|
|
259
|
+
if (typeof opts === 'function') {
|
|
260
|
+
cb = opts;
|
|
261
|
+
}
|
|
262
|
+
cb(null, { stdout: 'translated output with config', stderr: '' });
|
|
263
|
+
}
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const compose = await esmock('../../lib/deployment/compose.js', {
|
|
267
|
+
child_process: childProcessMock,
|
|
268
|
+
'../../lib/util/helpers.js': helpersMock,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const resourcesConfig = { some: 'config' };
|
|
272
|
+
const result = await compose.translateCompose(
|
|
273
|
+
'/bin/run-compose',
|
|
274
|
+
'/path/to/compose.yaml',
|
|
275
|
+
'us-central1',
|
|
276
|
+
'123456789',
|
|
277
|
+
mock.fn(),
|
|
278
|
+
resourcesConfig
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
assert.strictEqual(result, 'translated output with config');
|
|
282
|
+
const call = childProcessMock.execFile.mock.calls[0];
|
|
283
|
+
const args = call.arguments[1];
|
|
284
|
+
assert.ok(args.includes('--resources-config'));
|
|
285
|
+
assert.ok(args.includes(JSON.stringify(resourcesConfig)));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('translateCompose throws error if execFile fails', async () => {
|
|
289
|
+
childProcessMock.execFile.mock.resetCalls();
|
|
290
|
+
childProcessMock.execFile.mock.mockImplementation(
|
|
291
|
+
(file, args, opts, cb) => {
|
|
292
|
+
if (typeof opts === 'function') {
|
|
293
|
+
cb = opts;
|
|
294
|
+
}
|
|
295
|
+
cb(new Error('translation failed'), { stdout: '', stderr: '' });
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const compose = await esmock('../../lib/deployment/compose.js', {
|
|
300
|
+
child_process: childProcessMock,
|
|
301
|
+
'../../lib/util/helpers.js': helpersMock,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await assert.rejects(
|
|
305
|
+
compose.translateCompose(
|
|
306
|
+
'/bin/run-compose',
|
|
307
|
+
'/path/to/compose.yaml',
|
|
308
|
+
'us-central1',
|
|
309
|
+
'123456789',
|
|
310
|
+
mock.fn()
|
|
311
|
+
),
|
|
312
|
+
{
|
|
313
|
+
message: /Failed to translate compose file: translation failed/,
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('composeVolumes', () => {
|
|
319
|
+
const getProjectNumberMock = mock.fn(async () => '987654321');
|
|
320
|
+
const ensureStorageBucketExistsMock = mock.fn(async () => ({
|
|
321
|
+
name: 'mock-bucket',
|
|
322
|
+
}));
|
|
323
|
+
const uploadToStorageBucketMock = mock.fn(async () => ({}));
|
|
324
|
+
const uploadDirectoryMock = mock.fn(async () => {});
|
|
325
|
+
const grantBucketAccessMock = mock.fn(async () => {});
|
|
326
|
+
const fsMock = {};
|
|
327
|
+
const fsPromisesMock = {
|
|
328
|
+
access: mock.fn(async () => {}),
|
|
329
|
+
stat: mock.fn(async () => ({ isDirectory: () => true })),
|
|
330
|
+
readFile: mock.fn(async () => Buffer.from('file-content')),
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
beforeEach(() => {
|
|
334
|
+
getProjectNumberMock.mock.resetCalls();
|
|
335
|
+
ensureStorageBucketExistsMock.mock.resetCalls();
|
|
336
|
+
uploadToStorageBucketMock.mock.resetCalls();
|
|
337
|
+
uploadDirectoryMock.mock.resetCalls();
|
|
338
|
+
grantBucketAccessMock.mock.resetCalls();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const setupCompose = async () => {
|
|
342
|
+
return await esmock('../../lib/deployment/compose.js', {
|
|
343
|
+
'../../lib/util/helpers.js': {
|
|
344
|
+
...helpersMock,
|
|
345
|
+
getProjectNumber: getProjectNumberMock,
|
|
346
|
+
},
|
|
347
|
+
'../../lib/cloud-api/storage.js': {
|
|
348
|
+
ensureStorageBucketExists: ensureStorageBucketExistsMock,
|
|
349
|
+
uploadToStorageBucket: uploadToStorageBucketMock,
|
|
350
|
+
grantBucketAccess: grantBucketAccessMock,
|
|
351
|
+
},
|
|
352
|
+
'../../lib/deployment/helpers.js': {
|
|
353
|
+
uploadDirectory: uploadDirectoryMock,
|
|
354
|
+
},
|
|
355
|
+
fs: {
|
|
356
|
+
...fsMock,
|
|
357
|
+
promises: fsPromisesMock,
|
|
358
|
+
default: {
|
|
359
|
+
...fsMock,
|
|
360
|
+
promises: fsPromisesMock,
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
path: {
|
|
364
|
+
...path,
|
|
365
|
+
resolve: (...args) => path.resolve(...args),
|
|
366
|
+
relative: (from, to) => path.relative(from, to),
|
|
367
|
+
basename: (p) => path.basename(p),
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
test('should return resourcesConfig unchanged if no volumes present', async () => {
|
|
373
|
+
const compose = await setupCompose();
|
|
374
|
+
const resourcesConfig = { project: 'test-project' };
|
|
375
|
+
const result = await compose.composeVolumes(
|
|
376
|
+
resourcesConfig,
|
|
377
|
+
'token',
|
|
378
|
+
'project-id',
|
|
379
|
+
'us-central1',
|
|
380
|
+
'/f-path',
|
|
381
|
+
mock.fn()
|
|
382
|
+
);
|
|
383
|
+
assert.deepEqual(result, resourcesConfig);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('should handle bind mounts (directories)', async () => {
|
|
387
|
+
const compose = await setupCompose();
|
|
388
|
+
const resourcesConfig = {
|
|
389
|
+
project: 'my-project',
|
|
390
|
+
volumes: {
|
|
391
|
+
bind_mount: {
|
|
392
|
+
web: [{ source: './data', target: '/app/data' }],
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
fsPromisesMock.access.mock.mockImplementation(async () => {});
|
|
398
|
+
fsPromisesMock.stat.mock.mockImplementation(async () => ({
|
|
399
|
+
isDirectory: () => true,
|
|
400
|
+
}));
|
|
401
|
+
|
|
402
|
+
const result = await compose.composeVolumes(
|
|
403
|
+
resourcesConfig,
|
|
404
|
+
'token',
|
|
405
|
+
'project-id',
|
|
406
|
+
'us-central1',
|
|
407
|
+
'/app-dir',
|
|
408
|
+
mock.fn()
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
assert.ok(result.volumes.bucket_name);
|
|
412
|
+
assert.ok(
|
|
413
|
+
result.volumes.bind_mount.web[0].mount_source.includes(
|
|
414
|
+
'bind_mounts/web/data'
|
|
415
|
+
)
|
|
416
|
+
);
|
|
417
|
+
assert.strictEqual(uploadDirectoryMock.mock.callCount(), 1);
|
|
418
|
+
// Called once in composeVolumes
|
|
419
|
+
assert.strictEqual(ensureStorageBucketExistsMock.mock.callCount(), 1);
|
|
420
|
+
const call = ensureStorageBucketExistsMock.mock.calls[0];
|
|
421
|
+
assert.deepStrictEqual(call.arguments[4], {
|
|
422
|
+
'managed-by': 'runcompose',
|
|
423
|
+
'run-compose-project': 'my-project',
|
|
424
|
+
});
|
|
425
|
+
assert.strictEqual(grantBucketAccessMock.mock.callCount(), 1);
|
|
426
|
+
// Called once in composeVolumes (reused in handleBindMounts)
|
|
427
|
+
assert.strictEqual(getProjectNumberMock.mock.callCount(), 1);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('should handle long project names with hashing', async () => {
|
|
431
|
+
const compose = await setupCompose();
|
|
432
|
+
const longProjectName =
|
|
433
|
+
'very-long-project-name-that-exceeds-the-maximum-limit-for-bucket-names';
|
|
434
|
+
const resourcesConfig = {
|
|
435
|
+
project: longProjectName,
|
|
436
|
+
volumes: {
|
|
437
|
+
bind_mount: {
|
|
438
|
+
web: [{ source: './data' }],
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const result = await compose.composeVolumes(
|
|
444
|
+
resourcesConfig,
|
|
445
|
+
'token',
|
|
446
|
+
'project-id',
|
|
447
|
+
'us-central1',
|
|
448
|
+
'/app-dir',
|
|
449
|
+
mock.fn()
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const bucketName = result.volumes.bucket_name;
|
|
453
|
+
assert.ok(bucketName.length <= 63);
|
|
454
|
+
// Verify hashing (the first digits are project number, then hash)
|
|
455
|
+
assert.ok(bucketName.startsWith('987654321-'));
|
|
456
|
+
assert.ok(bucketName.endsWith('-us-central1-compose'));
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('should handle named volumes', async () => {
|
|
460
|
+
const compose = await setupCompose();
|
|
461
|
+
ensureStorageBucketExistsMock.mock.resetCalls();
|
|
462
|
+
const resourcesConfig = {
|
|
463
|
+
project: 'my-project',
|
|
464
|
+
volumes: {
|
|
465
|
+
named_volume: {
|
|
466
|
+
'my-vol': { name: 'my-vol' },
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
await compose.composeVolumes(
|
|
472
|
+
resourcesConfig,
|
|
473
|
+
'token',
|
|
474
|
+
'project-id',
|
|
475
|
+
'us-central1',
|
|
476
|
+
'/app-dir',
|
|
477
|
+
mock.fn()
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
// Should call ensureStorageBucketExists for the default bucketName
|
|
481
|
+
const calls = ensureStorageBucketExistsMock.mock.calls;
|
|
482
|
+
assert.strictEqual(calls.length, 1);
|
|
483
|
+
// Bucket name is constructed from project number, sanitized name, region, and suffix
|
|
484
|
+
const bucketName = calls[0].arguments[1];
|
|
485
|
+
assert.ok(
|
|
486
|
+
bucketName.startsWith('987654321-my-project-us-central1-compose')
|
|
487
|
+
);
|
|
488
|
+
assert.deepStrictEqual(calls[0].arguments[4], {
|
|
489
|
+
'managed-by': 'runcompose',
|
|
490
|
+
'run-compose-project': 'my-project',
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
describe('composeSecrets', () => {
|
|
496
|
+
const getProjectNumberMock = mock.fn(async () => '123456789');
|
|
497
|
+
const ensureApisEnabledMock = mock.fn(async () => {});
|
|
498
|
+
const getSecretMock = mock.fn();
|
|
499
|
+
const createSecretMock = mock.fn();
|
|
500
|
+
const addSecretAccessorBindingMock = mock.fn();
|
|
501
|
+
const addSecretVersionMock = mock.fn();
|
|
502
|
+
const fsPromisesMock = {
|
|
503
|
+
access: mock.fn(async () => {}),
|
|
504
|
+
readFile: mock.fn(async () => Buffer.from('secret-data')),
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const setupCompose = async () => {
|
|
508
|
+
return await esmock('../../lib/deployment/compose.js', {
|
|
509
|
+
'../../lib/util/helpers.js': {
|
|
510
|
+
...helpersMock,
|
|
511
|
+
getProjectNumber: getProjectNumberMock,
|
|
512
|
+
},
|
|
513
|
+
'../../lib/cloud-api/helpers.js': {
|
|
514
|
+
ensureApisEnabled: ensureApisEnabledMock,
|
|
515
|
+
},
|
|
516
|
+
'../../lib/cloud-api/secrets.js': {
|
|
517
|
+
getSecret: getSecretMock,
|
|
518
|
+
createSecret: createSecretMock,
|
|
519
|
+
addSecretVersion: addSecretVersionMock,
|
|
520
|
+
addSecretAccessorBinding: addSecretAccessorBindingMock,
|
|
521
|
+
},
|
|
522
|
+
fs: {
|
|
523
|
+
promises: fsPromisesMock,
|
|
524
|
+
default: {
|
|
525
|
+
promises: fsPromisesMock,
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
test('should return resourcesConfig unchanged if no secrets present', async () => {
|
|
532
|
+
const compose = await setupCompose();
|
|
533
|
+
const resourcesConfig = { project: 'test-project' };
|
|
534
|
+
const result = await compose.composeSecrets(
|
|
535
|
+
resourcesConfig,
|
|
536
|
+
'token',
|
|
537
|
+
'project-id',
|
|
538
|
+
'/f-path',
|
|
539
|
+
mock.fn()
|
|
540
|
+
);
|
|
541
|
+
assert.deepEqual(result, resourcesConfig);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('should provision secrets and update resourcesConfig', async () => {
|
|
545
|
+
const compose = await setupCompose();
|
|
546
|
+
const resourcesConfig = {
|
|
547
|
+
project: 'my-project',
|
|
548
|
+
secrets: {
|
|
549
|
+
'my-secret': {
|
|
550
|
+
name: 'target-secret-name',
|
|
551
|
+
file: './my_secret.txt',
|
|
552
|
+
mount: 'my-secret',
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
getSecretMock.mock.mockImplementation(async () => null); // Secret doesn't exist
|
|
558
|
+
createSecretMock.mock.mockImplementation(async () => ({
|
|
559
|
+
name: 'target-secret-name',
|
|
560
|
+
}));
|
|
561
|
+
addSecretVersionMock.mock.mockImplementation(async () => ({
|
|
562
|
+
name: 'projects/p/secrets/s/versions/1',
|
|
563
|
+
}));
|
|
564
|
+
addSecretAccessorBindingMock.mock.mockImplementation(async () => ({}));
|
|
565
|
+
|
|
566
|
+
const result = await compose.composeSecrets(
|
|
567
|
+
resourcesConfig,
|
|
568
|
+
'token',
|
|
569
|
+
'project-id',
|
|
570
|
+
'/app-dir',
|
|
571
|
+
mock.fn()
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
assert.strictEqual(
|
|
575
|
+
result.secrets['my-secret'].secret_version,
|
|
576
|
+
'projects/p/secrets/s/versions/1'
|
|
577
|
+
);
|
|
578
|
+
assert.strictEqual(ensureApisEnabledMock.mock.callCount(), 1);
|
|
579
|
+
assert.strictEqual(createSecretMock.mock.callCount(), 1);
|
|
580
|
+
const call = createSecretMock.mock.calls[0];
|
|
581
|
+
assert.deepStrictEqual(call.arguments[3], {
|
|
582
|
+
'managed-by': 'runcompose',
|
|
583
|
+
'run-compose-project': 'my-project',
|
|
584
|
+
});
|
|
585
|
+
assert.strictEqual(addSecretVersionMock.mock.callCount(), 1);
|
|
586
|
+
assert.strictEqual(addSecretAccessorBindingMock.mock.callCount(), 1);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test('should skip provisioning if secret config is incomplete', async () => {
|
|
590
|
+
const compose = await setupCompose();
|
|
591
|
+
const resourcesConfig = {
|
|
592
|
+
project: 'my-project',
|
|
593
|
+
secrets: {
|
|
594
|
+
'bad-secret': {
|
|
595
|
+
name: 'only-name',
|
|
596
|
+
// missing file and mount
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
ensureApisEnabledMock.mock.resetCalls();
|
|
602
|
+
|
|
603
|
+
const result = await compose.composeSecrets(
|
|
604
|
+
resourcesConfig,
|
|
605
|
+
'token',
|
|
606
|
+
'project-id',
|
|
607
|
+
'/app-dir',
|
|
608
|
+
mock.fn()
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
assert.strictEqual(
|
|
612
|
+
result.secrets['bad-secret'].secret_version,
|
|
613
|
+
undefined
|
|
614
|
+
);
|
|
615
|
+
assert.strictEqual(ensureApisEnabledMock.mock.callCount(), 1);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
});
|