@google-cloud/cloud-run-mcp 1.5.0 → 1.7.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/.c8rc.json +9 -0
- package/.github/workflows/lint-checker.yml +17 -0
- package/.github/workflows/npm-publish.yml +1 -1
- package/.kokoro/kokoro_build.sh +22 -0
- package/.kokoro/presubmit.cfg +21 -0
- package/.kokoro/run_tests.sh +31 -0
- package/CONTRIBUTING.md +24 -9
- package/README.md +52 -11
- package/REVIEWING.md +20 -0
- package/gemini-extension/GEMINI.md +1 -1
- package/lib/cloud-api/billing.js +183 -0
- package/lib/cloud-api/build.js +200 -0
- package/lib/{gcp-api-helpers.js → cloud-api/helpers.js} +81 -35
- package/lib/{gcp-projects.js → cloud-api/projects.js} +58 -28
- package/lib/cloud-api/registry.js +98 -0
- package/lib/{cloud-run-services.js → cloud-api/run.js} +103 -21
- package/lib/cloud-api/storage.js +123 -0
- package/lib/deployment/constants.js +54 -0
- package/lib/deployment/deployer.js +621 -0
- package/lib/deployment/helpers.js +194 -0
- package/lib/deployment/source-processor.js +124 -0
- package/lib/util/archive.js +104 -0
- package/lib/util/helpers.js +42 -0
- package/mcp-server.js +21 -10
- package/package.json +8 -5
- package/test/local/cloud-api/build.test.js +436 -0
- package/test/local/cloud-api/projects.test.js +18 -0
- package/test/local/deployment-helpers.test.js +231 -0
- package/test/local/mcp-server-stdio.test.js +59 -0
- package/test/local/mcp-server-streamable-http.test.js +61 -0
- package/test/local/notifications.test.js +9 -6
- package/test/local/source-processor.test.js +290 -0
- package/test/local/test-utils.js +51 -0
- package/test/local/tools.test.js +114 -70
- package/test/need-gcp/cloud-run-services.test.js +44 -44
- package/test/need-gcp/{cloud-run-deploy.test.js → deployer.test.js} +3 -56
- package/test/need-gcp/gcp-auth-check.test.js +1 -1
- package/test/need-gcp/gcp-projects.test.js +3 -15
- package/test/need-gcp/test-helpers.js +175 -0
- package/test/need-gcp/workflows/deployment-workflows.test.js +100 -0
- package/tools/register-tools.js +106 -97
- package/tools/tools.js +2 -9
- package/lib/cloud-run-deploy.js +0 -937
- package/lib/gcp-billing-accounts.js +0 -98
- /package/lib/{gcp-auth-check.js → cloud-api/auth.js} +0 -0
- /package/lib/{gcp-metadata.js → cloud-api/metadata.js} +0 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it, mock } from 'node:test';
|
|
3
|
+
import esmock from 'esmock';
|
|
4
|
+
|
|
5
|
+
describe('triggerCloudBuild', () => {
|
|
6
|
+
it('should return a successful build and log correct messages', async () => {
|
|
7
|
+
const mockBuildId = 'mock-build-id';
|
|
8
|
+
const mockSuccessResult = {
|
|
9
|
+
id: mockBuildId,
|
|
10
|
+
status: 'SUCCESS',
|
|
11
|
+
results: { images: [{ name: 'gcr.io/mock-project/mock-image' }] },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const getBuildMock = mock.fn(() => Promise.resolve([mockSuccessResult]));
|
|
15
|
+
const logAndProgressMock = mock.fn();
|
|
16
|
+
|
|
17
|
+
const { triggerCloudBuild } = await esmock(
|
|
18
|
+
'../../../lib/cloud-api/build.js',
|
|
19
|
+
{
|
|
20
|
+
'../../../lib/cloud-api/helpers.js': {
|
|
21
|
+
callWithRetry: (fn) => fn(), // Directly execute the function
|
|
22
|
+
},
|
|
23
|
+
'../../../lib/util/helpers.js': {
|
|
24
|
+
logAndProgress: logAndProgressMock,
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const context = {
|
|
30
|
+
cloudBuildClient: {
|
|
31
|
+
createBuild: mock.fn(() =>
|
|
32
|
+
Promise.resolve([
|
|
33
|
+
{
|
|
34
|
+
metadata: {
|
|
35
|
+
build: {
|
|
36
|
+
id: mockBuildId,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
])
|
|
41
|
+
),
|
|
42
|
+
getBuild: getBuildMock,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const result = await triggerCloudBuild(
|
|
47
|
+
context,
|
|
48
|
+
'mock-project',
|
|
49
|
+
'mock-location',
|
|
50
|
+
'mock-bucket',
|
|
51
|
+
'mock-blob',
|
|
52
|
+
'mock-repo',
|
|
53
|
+
'gcr.io/mock-project/mock-image',
|
|
54
|
+
true,
|
|
55
|
+
() => {}
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
assert.deepStrictEqual(result, mockSuccessResult);
|
|
59
|
+
assert.strictEqual(
|
|
60
|
+
context.cloudBuildClient.createBuild.mock.callCount(),
|
|
61
|
+
1
|
|
62
|
+
);
|
|
63
|
+
assert.strictEqual(context.cloudBuildClient.getBuild.mock.callCount(), 1);
|
|
64
|
+
|
|
65
|
+
const { calls: logCalls } = logAndProgressMock.mock;
|
|
66
|
+
assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
|
|
67
|
+
assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
|
|
68
|
+
assert.match(logCalls[2].arguments[0], /completed successfully/);
|
|
69
|
+
assert.match(logCalls[3].arguments[0], /Image built/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should throw an error for a failed build and log correct messages', async () => {
|
|
73
|
+
const mockBuildId = 'mock-build-id-failure';
|
|
74
|
+
const mockFailureResult = {
|
|
75
|
+
id: mockBuildId,
|
|
76
|
+
status: 'FAILURE',
|
|
77
|
+
logUrl: 'http://mock-log-url.com',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const getBuildMock = mock.fn(() => Promise.resolve([mockFailureResult]));
|
|
81
|
+
const logAndProgressMock = mock.fn();
|
|
82
|
+
const setTimeoutMock = mock.fn((resolve) => resolve());
|
|
83
|
+
mock.method(global, 'setTimeout', setTimeoutMock);
|
|
84
|
+
|
|
85
|
+
const { triggerCloudBuild } = await esmock(
|
|
86
|
+
'../../../lib/cloud-api/build.js',
|
|
87
|
+
{
|
|
88
|
+
'../../../lib/cloud-api/helpers.js': {
|
|
89
|
+
callWithRetry: (fn) => fn(),
|
|
90
|
+
},
|
|
91
|
+
'../../../lib/util/helpers.js': {
|
|
92
|
+
logAndProgress: logAndProgressMock,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const context = {
|
|
98
|
+
cloudBuildClient: {
|
|
99
|
+
createBuild: mock.fn(() =>
|
|
100
|
+
Promise.resolve([
|
|
101
|
+
{
|
|
102
|
+
metadata: {
|
|
103
|
+
build: {
|
|
104
|
+
id: mockBuildId,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
])
|
|
109
|
+
),
|
|
110
|
+
getBuild: getBuildMock,
|
|
111
|
+
},
|
|
112
|
+
loggingClient: {
|
|
113
|
+
getEntries: mock.fn(() =>
|
|
114
|
+
Promise.resolve([[{ data: 'log line 1' }, { data: 'log line 2' }]])
|
|
115
|
+
),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
await assert.rejects(
|
|
120
|
+
() =>
|
|
121
|
+
triggerCloudBuild(
|
|
122
|
+
context,
|
|
123
|
+
'mock-project',
|
|
124
|
+
'mock-location',
|
|
125
|
+
'mock-bucket',
|
|
126
|
+
'mock-blob',
|
|
127
|
+
'mock-repo',
|
|
128
|
+
'gcr.io/mock-project/mock-image',
|
|
129
|
+
true,
|
|
130
|
+
() => {}
|
|
131
|
+
),
|
|
132
|
+
(err) => {
|
|
133
|
+
assert.match(err.message, /Build mock-build-id-failure failed/);
|
|
134
|
+
assert.match(err.message, /log line 1/);
|
|
135
|
+
assert.match(err.message, /log line 2/);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
assert.strictEqual(
|
|
141
|
+
context.cloudBuildClient.createBuild.mock.callCount(),
|
|
142
|
+
1
|
|
143
|
+
);
|
|
144
|
+
assert.strictEqual(context.cloudBuildClient.getBuild.mock.callCount(), 1);
|
|
145
|
+
assert.strictEqual(context.loggingClient.getEntries.mock.callCount(), 1);
|
|
146
|
+
assert.strictEqual(setTimeoutMock.mock.callCount(), 1);
|
|
147
|
+
assert.strictEqual(setTimeoutMock.mock.calls[0].arguments[1], 10000);
|
|
148
|
+
|
|
149
|
+
const { calls: logCalls } = logAndProgressMock.mock;
|
|
150
|
+
assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
|
|
151
|
+
assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
|
|
152
|
+
assert.match(logCalls[2].arguments[0], /failed with status: FAILURE/);
|
|
153
|
+
assert.match(logCalls[3].arguments[0], /Build logs:/);
|
|
154
|
+
assert.match(logCalls[4].arguments[0], /Attempting to fetch last/);
|
|
155
|
+
assert.match(logCalls[5].arguments[0], /Successfully fetched snippet/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should use buildpacks when no Dockerfile is present', async () => {
|
|
159
|
+
const mockBuildId = 'mock-build-id-buildpacks';
|
|
160
|
+
const mockSuccessResult = {
|
|
161
|
+
id: mockBuildId,
|
|
162
|
+
status: 'SUCCESS',
|
|
163
|
+
results: { images: [{ name: 'gcr.io/mock-project/mock-image' }] },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const getBuildMock = mock.fn(() => Promise.resolve([mockSuccessResult]));
|
|
167
|
+
const createBuildMock = mock.fn(() =>
|
|
168
|
+
Promise.resolve([
|
|
169
|
+
{
|
|
170
|
+
metadata: {
|
|
171
|
+
build: {
|
|
172
|
+
id: mockBuildId,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
])
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const { triggerCloudBuild } = await esmock(
|
|
180
|
+
'../../../lib/cloud-api/build.js',
|
|
181
|
+
{
|
|
182
|
+
'../../../lib/cloud-api/helpers.js': {
|
|
183
|
+
callWithRetry: (fn) => fn(),
|
|
184
|
+
},
|
|
185
|
+
'../../../lib/util/helpers.js': {
|
|
186
|
+
logAndProgress: () => {},
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const context = {
|
|
192
|
+
cloudBuildClient: {
|
|
193
|
+
createBuild: createBuildMock,
|
|
194
|
+
getBuild: getBuildMock,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
await triggerCloudBuild(
|
|
199
|
+
context,
|
|
200
|
+
'mock-project',
|
|
201
|
+
'mock-location',
|
|
202
|
+
'mock-bucket',
|
|
203
|
+
'mock-blob',
|
|
204
|
+
'mock-repo',
|
|
205
|
+
'gcr.io/mock-project/mock-image',
|
|
206
|
+
false, // hasDockerfile = false
|
|
207
|
+
() => {}
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
assert.strictEqual(createBuildMock.mock.callCount(), 1);
|
|
211
|
+
const buildArg = createBuildMock.mock.calls[0].arguments[0].build;
|
|
212
|
+
const buildStep = buildArg.steps[0];
|
|
213
|
+
assert.strictEqual(buildStep.name, 'gcr.io/k8s-skaffold/pack');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should poll for build status until completion', async () => {
|
|
217
|
+
const mockBuildId = 'mock-build-id-polling';
|
|
218
|
+
const mockWorkingResult = { id: mockBuildId, status: 'WORKING' };
|
|
219
|
+
const mockSuccessResult = {
|
|
220
|
+
id: mockBuildId,
|
|
221
|
+
status: 'SUCCESS',
|
|
222
|
+
results: { images: [{ name: 'gcr.io/mock-project/mock-image' }] },
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
let getBuildCallCount = 0;
|
|
226
|
+
const getBuildMock = mock.fn(() => {
|
|
227
|
+
getBuildCallCount++;
|
|
228
|
+
if (getBuildCallCount === 1) {
|
|
229
|
+
return Promise.resolve([mockWorkingResult]);
|
|
230
|
+
}
|
|
231
|
+
return Promise.resolve([mockSuccessResult]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const logAndProgressMock = mock.fn();
|
|
235
|
+
const setTimeoutMock = mock.fn((resolve) => resolve());
|
|
236
|
+
mock.method(global, 'setTimeout', setTimeoutMock);
|
|
237
|
+
|
|
238
|
+
const { triggerCloudBuild } = await esmock(
|
|
239
|
+
'../../../lib/cloud-api/build.js',
|
|
240
|
+
{
|
|
241
|
+
'../../../lib/cloud-api/helpers.js': {
|
|
242
|
+
callWithRetry: (fn) => fn(),
|
|
243
|
+
},
|
|
244
|
+
'../../../lib/util/helpers.js': {
|
|
245
|
+
logAndProgress: logAndProgressMock,
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const context = {
|
|
251
|
+
cloudBuildClient: {
|
|
252
|
+
createBuild: mock.fn(() =>
|
|
253
|
+
Promise.resolve([
|
|
254
|
+
{
|
|
255
|
+
metadata: {
|
|
256
|
+
build: {
|
|
257
|
+
id: mockBuildId,
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
])
|
|
262
|
+
),
|
|
263
|
+
getBuild: getBuildMock,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
await triggerCloudBuild(
|
|
268
|
+
context,
|
|
269
|
+
'mock-project',
|
|
270
|
+
'mock-location',
|
|
271
|
+
'mock-bucket',
|
|
272
|
+
'mock-blob',
|
|
273
|
+
'mock-repo',
|
|
274
|
+
'gcr.io/mock-project/mock-image',
|
|
275
|
+
true,
|
|
276
|
+
() => {}
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
assert.strictEqual(getBuildMock.mock.callCount(), 2);
|
|
280
|
+
assert.strictEqual(setTimeoutMock.mock.callCount(), 1);
|
|
281
|
+
assert.strictEqual(setTimeoutMock.mock.calls[0].arguments[1], 5000);
|
|
282
|
+
const { calls: logCalls } = logAndProgressMock.mock;
|
|
283
|
+
assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
|
|
284
|
+
assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
|
|
285
|
+
assert.match(logCalls[2].arguments[0], /Build status: WORKING/);
|
|
286
|
+
assert.match(logCalls[3].arguments[0], /completed successfully/);
|
|
287
|
+
assert.match(logCalls[4].arguments[0], /Image built/);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should handle failed build when no logs are found', async () => {
|
|
291
|
+
const mockBuildId = 'mock-build-id-no-logs';
|
|
292
|
+
const mockFailureResult = {
|
|
293
|
+
id: mockBuildId,
|
|
294
|
+
status: 'FAILURE',
|
|
295
|
+
logUrl: 'http://mock-log-url.com',
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const getBuildMock = mock.fn(() => Promise.resolve([mockFailureResult]));
|
|
299
|
+
const logAndProgressMock = mock.fn();
|
|
300
|
+
|
|
301
|
+
const { triggerCloudBuild } = await esmock(
|
|
302
|
+
'../../../lib/cloud-api/build.js',
|
|
303
|
+
{
|
|
304
|
+
'../../../lib/cloud-api/helpers.js': {
|
|
305
|
+
callWithRetry: (fn) => fn(),
|
|
306
|
+
},
|
|
307
|
+
'../../../lib/util/helpers.js': {
|
|
308
|
+
logAndProgress: logAndProgressMock,
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const context = {
|
|
314
|
+
cloudBuildClient: {
|
|
315
|
+
createBuild: mock.fn(() =>
|
|
316
|
+
Promise.resolve([
|
|
317
|
+
{
|
|
318
|
+
metadata: {
|
|
319
|
+
build: {
|
|
320
|
+
id: mockBuildId,
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
])
|
|
325
|
+
),
|
|
326
|
+
getBuild: getBuildMock,
|
|
327
|
+
},
|
|
328
|
+
loggingClient: {
|
|
329
|
+
getEntries: mock.fn(() => Promise.resolve([[]])), // No log entries
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
await assert.rejects(
|
|
334
|
+
() =>
|
|
335
|
+
triggerCloudBuild(
|
|
336
|
+
context,
|
|
337
|
+
'mock-project',
|
|
338
|
+
'mock-location',
|
|
339
|
+
'mock-bucket',
|
|
340
|
+
'mock-blob',
|
|
341
|
+
'mock-repo',
|
|
342
|
+
'gcr.io/mock-project/mock-image',
|
|
343
|
+
true,
|
|
344
|
+
() => {}
|
|
345
|
+
),
|
|
346
|
+
(err) => {
|
|
347
|
+
assert.match(err.message, /Build mock-build-id-no-logs failed/);
|
|
348
|
+
assert.doesNotMatch(err.message, /Last log lines/);
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const { calls: logCalls } = logAndProgressMock.mock;
|
|
354
|
+
assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
|
|
355
|
+
assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
|
|
356
|
+
assert.match(logCalls[2].arguments[0], /failed with status: FAILURE/);
|
|
357
|
+
assert.match(logCalls[3].arguments[0], /Build logs:/);
|
|
358
|
+
assert.match(logCalls[4].arguments[0], /Attempting to fetch last/);
|
|
359
|
+
assert.match(logCalls[5].arguments[0], /No specific log entries retrieved/);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should handle error when fetching logs for a failed build', async () => {
|
|
363
|
+
const mockBuildId = 'mock-build-id-log-error';
|
|
364
|
+
const mockFailureResult = {
|
|
365
|
+
id: mockBuildId,
|
|
366
|
+
status: 'FAILURE',
|
|
367
|
+
logUrl: 'http://mock-log-url.com',
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const getBuildMock = mock.fn(() => Promise.resolve([mockFailureResult]));
|
|
371
|
+
const logAndProgressMock = mock.fn();
|
|
372
|
+
|
|
373
|
+
const { triggerCloudBuild } = await esmock(
|
|
374
|
+
'../../../lib/cloud-api/build.js',
|
|
375
|
+
{
|
|
376
|
+
'../../../lib/cloud-api/helpers.js': {
|
|
377
|
+
callWithRetry: (fn) => fn(),
|
|
378
|
+
},
|
|
379
|
+
'../../../lib/util/helpers.js': {
|
|
380
|
+
logAndProgress: logAndProgressMock,
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const context = {
|
|
386
|
+
cloudBuildClient: {
|
|
387
|
+
createBuild: mock.fn(() =>
|
|
388
|
+
Promise.resolve([
|
|
389
|
+
{
|
|
390
|
+
metadata: {
|
|
391
|
+
build: {
|
|
392
|
+
id: mockBuildId,
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
])
|
|
397
|
+
),
|
|
398
|
+
getBuild: getBuildMock,
|
|
399
|
+
},
|
|
400
|
+
loggingClient: {
|
|
401
|
+
getEntries: mock.fn(() => Promise.reject(new Error('Log fetch error'))),
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
await assert.rejects(
|
|
406
|
+
() =>
|
|
407
|
+
triggerCloudBuild(
|
|
408
|
+
context,
|
|
409
|
+
'mock-project',
|
|
410
|
+
'mock-location',
|
|
411
|
+
'mock-bucket',
|
|
412
|
+
'mock-blob',
|
|
413
|
+
'mock-repo',
|
|
414
|
+
'gcr.io/mock-project/mock-image',
|
|
415
|
+
true,
|
|
416
|
+
() => {}
|
|
417
|
+
),
|
|
418
|
+
(err) => {
|
|
419
|
+
assert.match(err.message, /Build mock-build-id-log-error failed/);
|
|
420
|
+
assert.doesNotMatch(err.message, /Last log lines/);
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
const { calls: logCalls } = logAndProgressMock.mock;
|
|
426
|
+
assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
|
|
427
|
+
assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
|
|
428
|
+
assert.match(logCalls[2].arguments[0], /failed with status: FAILURE/);
|
|
429
|
+
assert.match(logCalls[3].arguments[0], /Build logs:/);
|
|
430
|
+
assert.match(logCalls[4].arguments[0], /Attempting to fetch last/);
|
|
431
|
+
assert.match(
|
|
432
|
+
logCalls[5].arguments[0],
|
|
433
|
+
/Failed to fetch build logs snippet/
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import esmock from 'esmock';
|
|
4
|
+
|
|
5
|
+
describe('projects', () => {
|
|
6
|
+
describe('generateProjectId', () => {
|
|
7
|
+
it('should generate a project id in the correct format', async () => {
|
|
8
|
+
const { generateProjectId } = await esmock(
|
|
9
|
+
'../../../lib/cloud-api/projects.js',
|
|
10
|
+
{}
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const projectId = generateProjectId();
|
|
14
|
+
assert.ok(projectId.startsWith('mcp-'));
|
|
15
|
+
assert.strictEqual(projectId.length, 11);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
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 { DEPLOYMENT_CONFIG } from '../../lib/deployment/constants.js';
|
|
21
|
+
|
|
22
|
+
describe('Deployment Helpers', () => {
|
|
23
|
+
const fsMock = {
|
|
24
|
+
statSync: mock.fn(),
|
|
25
|
+
existsSync: mock.fn(),
|
|
26
|
+
readFileSync: mock.fn(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test('makeFileDeploymentMetadata correctly identifies Dockerfile in folder', async () => {
|
|
30
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
31
|
+
fs: fsMock,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const folderPath = '/absolute/path/to/folder';
|
|
35
|
+
|
|
36
|
+
// Mock isFolder logic
|
|
37
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
38
|
+
isDirectory: () => true,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Mock Dockerfile check
|
|
42
|
+
fsMock.existsSync.mock.mockImplementation((filePath) => {
|
|
43
|
+
if (filePath.endsWith('Dockerfile') || filePath.endsWith('dockerfile'))
|
|
44
|
+
return true;
|
|
45
|
+
return false;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
49
|
+
|
|
50
|
+
assert.equal(result.hasDockerfile, true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('makeFileDeploymentMetadata correctly identifies Node.js project attributes', async () => {
|
|
54
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
55
|
+
fs: fsMock,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const folderPath = '/absolute/path/to/node-app';
|
|
59
|
+
|
|
60
|
+
// Mock isFolder
|
|
61
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
62
|
+
isDirectory: () => true,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// Mock existsSync
|
|
66
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
67
|
+
if (p.endsWith('Dockerfile')) return false;
|
|
68
|
+
if (p.endsWith('package.json')) return true;
|
|
69
|
+
return false;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Mock readFileSync for package.json
|
|
73
|
+
fsMock.readFileSync.mock.mockImplementation(() =>
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
scripts: { start: 'node server.js' },
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
80
|
+
|
|
81
|
+
assert.equal(result.hasDockerfile, false);
|
|
82
|
+
assert.equal(result.deploymentAttrs.runtime, 'nodejs');
|
|
83
|
+
assert.deepEqual(result.deploymentAttrs.cmd, ['node']);
|
|
84
|
+
assert.deepEqual(result.deploymentAttrs.args, ['server.js']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('makeFileDeploymentMetadata ignores Node.js if engines.node is present', async () => {
|
|
88
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
89
|
+
fs: fsMock,
|
|
90
|
+
});
|
|
91
|
+
|
|
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]);
|
|
135
|
+
|
|
136
|
+
// Should return empty deployment attrs
|
|
137
|
+
assert.equal(result.deploymentAttrs.runtime, undefined);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('canDeployWithoutBuild returns true for valid Node.js zip deploy', async () => {
|
|
141
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
142
|
+
fs: fsMock,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const metadata = {
|
|
146
|
+
hasDockerfile: false,
|
|
147
|
+
deploymentAttrs: {
|
|
148
|
+
runtime: 'nodejs',
|
|
149
|
+
cmd: ['node'],
|
|
150
|
+
args: ['index.js'],
|
|
151
|
+
baseImage: 'base-image',
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('canDeployWithoutBuild returns false if Dockerfile exists', async () => {
|
|
159
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
160
|
+
fs: fsMock,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const metadata = {
|
|
164
|
+
hasDockerfile: true,
|
|
165
|
+
deploymentAttrs: {
|
|
166
|
+
runtime: 'nodejs',
|
|
167
|
+
cmd: ['node'],
|
|
168
|
+
args: ['index.js'],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('createDirectSourceDeploymentContainer creates correct object', async () => {
|
|
176
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
177
|
+
fs: fsMock,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const input = {
|
|
181
|
+
bucketName: 'test-bucket',
|
|
182
|
+
fileName: 'source.tar.gz',
|
|
183
|
+
deploymentAttrs: {
|
|
184
|
+
cmd: ['node'],
|
|
185
|
+
args: ['server.js'],
|
|
186
|
+
baseImage: 'gcr.io/google-appengine/nodejs',
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result =
|
|
191
|
+
deploymentHelpers.createDirectSourceDeploymentContainer(input);
|
|
192
|
+
|
|
193
|
+
assert.deepEqual(result, {
|
|
194
|
+
image: DEPLOYMENT_CONFIG.NO_BUILD_IMAGE_TYPE,
|
|
195
|
+
baseImageUri: 'gcr.io/google-appengine/nodejs',
|
|
196
|
+
sourceCode: {
|
|
197
|
+
cloudStorageSource: {
|
|
198
|
+
bucket: 'test-bucket',
|
|
199
|
+
object: 'source.tar.gz',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
command: ['node'],
|
|
203
|
+
args: ['server.js'],
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('makeFileDeploymentMetadata handles invalid package.json gracefully', async () => {
|
|
208
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
209
|
+
fs: fsMock,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const folderPath = '/absolute/path/to/bad-json-app';
|
|
213
|
+
|
|
214
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
215
|
+
isDirectory: () => true,
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
219
|
+
if (p.endsWith('package.json')) return true;
|
|
220
|
+
return false;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Mock readFileSync to return invalid JSON
|
|
224
|
+
fsMock.readFileSync.mock.mockImplementation(() => '{ invalid json }');
|
|
225
|
+
|
|
226
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
227
|
+
|
|
228
|
+
// Should return empty deployment attrs
|
|
229
|
+
assert.equal(result.deploymentAttrs.runtime, undefined);
|
|
230
|
+
});
|
|
231
|
+
});
|