@google-cloud/cloud-run-mcp 1.9.0 → 1.10.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.
@@ -7,5 +7,17 @@
7
7
  "args": ["-y", "@google-cloud/cloud-run-mcp"]
8
8
  }
9
9
  },
10
+ "settings": [
11
+ {
12
+ "name": "Project ID",
13
+ "description": "Google Cloud project ID to use for Cloud Run operations.",
14
+ "envVar": "GOOGLE_CLOUD_PROJECT"
15
+ },
16
+ {
17
+ "name": "Region",
18
+ "description": "Google Cloud region where Cloud Run services will be managed.",
19
+ "envVar": "GOOGLE_CLOUD_REGION"
20
+ }
21
+ ],
10
22
  "contextFileName": "gemini-extension/GEMINI.md"
11
23
  }
@@ -21,12 +21,14 @@ export const DEPLOYMENT_CONFIG = {
21
21
  IMAGE_TAG: 'latest',
22
22
  LABEL_CREATED_BY: 'cloud-run-mcp',
23
23
  DEFAULT_NODE_BASE_IMAGE: 'nodejs22',
24
+ DEFAULT_PYTHON_BASE_IMAGE: 'python314',
24
25
  NO_BUILD_IMAGE_TYPE: 'scratch',
25
26
  };
26
27
 
27
28
  export const TEMP_PATHS = {
28
29
  BASE: '.cloud-run-mcp',
29
30
  SUBDIR: 'source',
31
+ BIN_SUBDIR: 'bin',
30
32
  };
31
33
 
32
34
  export const REQUIRED_APIS = {
@@ -49,6 +51,7 @@ export const DEPLOYMENT_TYPES = {
49
51
 
50
52
  export const RUNTIMES = {
51
53
  NODEJS: 'nodejs',
54
+ PYTHON: 'python',
52
55
  };
53
56
 
54
57
  export const MAX_ALLOWED_DIRECT_SOURCE_SIZE_BYTES = 250 * 1024 * 1024; // 250 MiB
@@ -32,15 +32,16 @@ import {
32
32
  } from './helpers.js';
33
33
  import {
34
34
  prepareSourceDirectory,
35
- installDependencies,
36
35
  cleanupTempDirectory,
37
36
  } from './source-processor.js';
37
+ import { runUniversalMaker } from './universal-maker.js';
38
38
 
39
39
  import {
40
40
  DEPLOYMENT_CONFIG,
41
41
  DEPLOYMENT_TYPES,
42
42
  MAX_ALLOWED_DIRECT_SOURCE_SIZE_BYTES,
43
43
  REQUIRED_APIS,
44
+ RUNTIMES,
44
45
  } from './constants.js';
45
46
 
46
47
  /**
@@ -259,7 +260,18 @@ async function deployWithZip(
259
260
 
260
261
  try {
261
262
  tempDir = await prepareSourceDirectory(files);
262
- await installDependencies(tempDir, deploymentAttrs);
263
+
264
+ if (
265
+ deploymentAttrs.runtime === RUNTIMES.NODEJS ||
266
+ deploymentAttrs.runtime === RUNTIMES.PYTHON
267
+ ) {
268
+ deploymentAttrs = await updateDeploymentAttrsWithUniversalMaker(
269
+ tempDir,
270
+ deploymentAttrs,
271
+ accessToken,
272
+ progressCallback
273
+ );
274
+ }
263
275
 
264
276
  const archiveBuffer = await zipFiles([tempDir], true, progressCallback);
265
277
  const archiveName = DEPLOYMENT_CONFIG.TARGZ_FILE_NAME;
@@ -316,6 +328,43 @@ async function deployWithZip(
316
328
  }
317
329
  }
318
330
 
331
+ /**
332
+ * Updates deployment attributes using result from Universal Maker.
333
+ *
334
+ * @async
335
+ * @param {string} tempDir - Path to the temporary source directory.
336
+ * @param {object} deploymentAttrs - The initial deployment attributes.
337
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
338
+ * @returns {Promise<object>} A promise that resolves with the updated deployment attributes.
339
+ */
340
+ async function updateDeploymentAttrsWithUniversalMaker(
341
+ tempDir,
342
+ deploymentAttrs,
343
+ accessToken,
344
+ progressCallback
345
+ ) {
346
+ const umResult = await runUniversalMaker(
347
+ tempDir,
348
+ accessToken,
349
+ progressCallback
350
+ );
351
+ if (umResult) {
352
+ await logAndProgress(
353
+ 'Updating deployment attributes using Universal Maker results.',
354
+ progressCallback
355
+ );
356
+ return {
357
+ ...deploymentAttrs,
358
+ baseImage: umResult.runtime || deploymentAttrs.baseImage,
359
+ runtime: umResult.runtime || deploymentAttrs.runtime,
360
+ cmd: umResult.command ? [umResult.command] : deploymentAttrs.cmd,
361
+ args: umResult.args || deploymentAttrs.args,
362
+ envVars: umResult.envVars || {},
363
+ };
364
+ }
365
+ return deploymentAttrs;
366
+ }
367
+
319
368
  /**
320
369
  * Handles deployment using Google Cloud Build.
321
370
  * This function zips the source code, uploads it to Google Cloud Storage,
@@ -72,6 +72,18 @@ function checkIfNodeJsRuntime(files) {
72
72
  return fs.existsSync(packageJsonPath);
73
73
  }
74
74
 
75
+ /**
76
+ * Checks if the provided folder appears to be a Python project.
77
+ * It looks for a `requirements.txt` or `pyproject.toml` file in the root of the first path in the `files` array.
78
+ * @param {string[]} files - Array of file paths
79
+ * @returns {boolean}
80
+ */
81
+ function checkIfPythonRuntime(files) {
82
+ const requirementsTxtPath = path.join(files[0], 'requirements.txt');
83
+ const pyprojectTomlPath = path.join(files[0], 'pyproject.toml');
84
+ return fs.existsSync(requirementsTxtPath) || fs.existsSync(pyprojectTomlPath);
85
+ }
86
+
75
87
  /**
76
88
  * Returns an empty deployment attributes object.
77
89
  * This is used as a default or when attributes cannot be determined.
@@ -86,55 +98,30 @@ function getEmptyDeploymentAttrs() {
86
98
  };
87
99
  }
88
100
 
89
- /**
90
- * Extracts deployment attributes (runtime, command, args, baseImage) from a Node.js project's `package.json`.
91
- * This function is used for direct source deployments, bypassing Cloud Build.
92
- * It returns empty attributes if a `start` script is missing, invalid, or if `engines.node` is specified (indicating a need for Cloud Build).
93
- * @param {string[]} files - Array of file paths
94
- * @returns {{runtime: string|undefined, cmd: string[]|undefined, args: string[]|undefined, baseImage: string|undefined}} Deployment attributes object.
95
- */
96
- function getNodeJsDeploymentAttrs(files) {
97
- const packageJsonPath = path.join(files[0], 'package.json');
98
- let packageJson;
99
-
100
- try {
101
- packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
102
- } catch (error) {
103
- // If package.json exists but is invalid, we can't extract attributes.
104
- return getEmptyDeploymentAttrs();
105
- }
106
-
107
- const startScript = packageJson?.scripts?.start;
108
- const hasNodeEngine = packageJson?.engines?.node;
109
-
110
- if (typeof startScript !== 'string' || !startScript.trim() || hasNodeEngine) {
111
- return getEmptyDeploymentAttrs();
112
- }
113
-
114
- // Handle multiple spaces properly
115
- const parts = startScript.trim().split(/\s+/);
116
-
117
- return {
118
- runtime: RUNTIMES.NODEJS,
119
- cmd: [parts[0]],
120
- args: parts.slice(1),
121
- baseImage: DEPLOYMENT_CONFIG.DEFAULT_NODE_BASE_IMAGE, // latest default base image
122
- };
123
- }
124
-
125
101
  /**
126
102
  * Determines deployment attributes based on the file list/folder content.
127
103
  * @param {Array<string|Object>} files - Array of file paths or file objects.
128
- * @returns {{runtime: string|undefined, cmd: string[]|undefined, args: string[]|undefined, baseImage: string|undefined}} Deployment attributes object.
104
+ * @returns {{runtime: string|undefined, baseImage: string|undefined}} Deployment attributes object.
129
105
  */
130
106
  function getDeploymentAttrs(files) {
107
+ let deploymentAttrs = getEmptyDeploymentAttrs();
131
108
  // Currently only support detection of NodeJs runtime
132
- if (!isFolder(files) || !checkIfNodeJsRuntime(files)) {
109
+ if (
110
+ !isFolder(files) ||
111
+ !(checkIfNodeJsRuntime(files) || checkIfPythonRuntime(files))
112
+ ) {
133
113
  // TODO: support file list deployment runtime detection
134
- return getEmptyDeploymentAttrs();
114
+ return deploymentAttrs;
135
115
  }
136
116
 
137
- return getNodeJsDeploymentAttrs(files);
117
+ if (checkIfPythonRuntime(files)) {
118
+ deploymentAttrs.runtime = RUNTIMES.PYTHON;
119
+ deploymentAttrs.baseImage = DEPLOYMENT_CONFIG.DEFAULT_PYTHON_BASE_IMAGE;
120
+ } else {
121
+ deploymentAttrs.runtime = RUNTIMES.NODEJS;
122
+ deploymentAttrs.baseImage = DEPLOYMENT_CONFIG.DEFAULT_NODE_BASE_IMAGE;
123
+ }
124
+ return deploymentAttrs;
138
125
  }
139
126
 
140
127
  /**
@@ -157,13 +144,7 @@ export function makeFileDeploymentMetadata(files) {
157
144
  * @returns {boolean} True if zip deployment is feasible.
158
145
  */
159
146
  export function canDeployWithoutBuild({ hasDockerfile, deploymentAttrs }) {
160
- return !!(
161
- !hasDockerfile &&
162
- deploymentAttrs &&
163
- deploymentAttrs.runtime &&
164
- deploymentAttrs.cmd &&
165
- deploymentAttrs.args
166
- );
147
+ return !!(!hasDockerfile && deploymentAttrs && deploymentAttrs.runtime);
167
148
  }
168
149
 
169
150
  /**
@@ -179,7 +160,7 @@ export function createDirectSourceDeploymentContainer({
179
160
  fileName,
180
161
  deploymentAttrs,
181
162
  }) {
182
- return {
163
+ const container = {
183
164
  image: DEPLOYMENT_CONFIG.NO_BUILD_IMAGE_TYPE,
184
165
  baseImageUri: deploymentAttrs.baseImage,
185
166
  sourceCode: {
@@ -191,4 +172,16 @@ export function createDirectSourceDeploymentContainer({
191
172
  command: deploymentAttrs.cmd,
192
173
  args: deploymentAttrs.args,
193
174
  };
175
+
176
+ // Add environment variables if provided
177
+ if (deploymentAttrs.envVars && typeof deploymentAttrs.envVars === 'object') {
178
+ container.env = Object.entries(deploymentAttrs.envVars).map(
179
+ ([name, value]) => ({
180
+ name,
181
+ value: String(value),
182
+ })
183
+ );
184
+ }
185
+
186
+ return container;
194
187
  }
@@ -58,54 +58,6 @@ export async function prepareSourceDirectory(files, progressCallback) {
58
58
  return tempDir;
59
59
  }
60
60
 
61
- /**
62
- * Installs Node.js dependencies in the target directory if a package.json exists.
63
- * Skips if node_modules already exists (assumed pre-installed or copied).
64
- *
65
- * @param {string} targetDir - The directory containing the source code.
66
- */
67
- async function installNodeDependencies(targetDir) {
68
- const nodeModulesPath = path.join(targetDir, 'node_modules');
69
-
70
- if (fs.existsSync(nodeModulesPath)) {
71
- console.log(
72
- `Existing node_modules detected. Skipping dependency installation.`
73
- );
74
- return;
75
- }
76
-
77
- // Check for lock file to decide between install and ci
78
- const hasLockFile = fs.existsSync(path.join(targetDir, 'package-lock.json'));
79
- const npmCommand = hasLockFile
80
- ? 'npm ci --omit=dev'
81
- : 'npm install --omit=dev';
82
-
83
- try {
84
- await execAsync(npmCommand, { cwd: targetDir });
85
- console.log(`${npmCommand} completed successfully`);
86
- } catch (e) {
87
- console.log(`Dependency installation failed: ${e.message}`);
88
- }
89
- }
90
-
91
- /**
92
- * Generic wrapper to install dependencies for the detected runtime.
93
- *
94
- * @param {string} targetDir - The directory containing the source code.
95
- * @param {Object} deploymentAttrs - Deployment attributes containing runtime info.
96
- */
97
- export async function installDependencies(targetDir, deploymentAttrs) {
98
- switch (deploymentAttrs?.runtime) {
99
- case RUNTIMES.NODEJS:
100
- await installNodeDependencies(targetDir);
101
- break;
102
- default:
103
- console.log(
104
- `No specific dependency installation logic for runtime: ${deploymentAttrs?.runtime}`
105
- );
106
- }
107
- }
108
-
109
61
  /**
110
62
  * Cleans up the temporary directory.
111
63
  *
@@ -0,0 +1,274 @@
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 { exec } from 'child_process';
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import os from 'os';
21
+ import util from 'util';
22
+ import crypto from 'crypto';
23
+ import { logAndProgress } from '../util/helpers.js';
24
+ import { TEMP_PATHS } from './constants.js';
25
+ import { getArtifactRegistryClient } from '../clients.js';
26
+
27
+ const execAsync = util.promisify(exec);
28
+
29
+ const UNIVERSAL_MAKER_BIN = 'universal_maker';
30
+ const UM_VERSION = '1.0.0';
31
+
32
+ const AR_HOST = 'https://artifactregistry.googleapis.com';
33
+ const AR_PROJECT = 'serverless-runtimes';
34
+ const AR_LOCATION = 'us-central1';
35
+ const AR_REPOSITORY = 'universal-maker';
36
+ const AR_DOWNLOAD_PATH_PREFIX = '/download/v1/';
37
+ const AR_DOWNLOAD_URL_SUFFIX = ':download?alt=media';
38
+
39
+ const ARCH_MAPPING = {
40
+ linux_x64: 'x86-64',
41
+ darwin_arm64: 'darwin-arm64',
42
+ };
43
+
44
+ /**
45
+ * Gets the architecture key for the current platform.
46
+ * @returns {string|null} The architecture key or null if not supported.
47
+ */
48
+ function getUniversalMakerArchitectureKey() {
49
+ const platform = process.platform;
50
+ const arch = process.arch;
51
+
52
+ if (platform === 'linux' && arch === 'x64') {
53
+ return 'linux_x64';
54
+ } else if (platform === 'darwin' && arch === 'arm64') {
55
+ return 'darwin_arm64';
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Checks if the existing binary is up to date by comparing its SHA256 mash with the remote metadata.
62
+ * If the binary is out of date, it removes it.
63
+ * @param {string} binPath - Path to the local binary.
64
+ * @param {string} arch - Current architecture.
65
+ * @param {string} accessToken - Access token for authentication.
66
+ * @param {function} progressCallback - Progress callback.
67
+ * @returns {Promise<boolean>} True if the binary is up to date (or if updating fails), false otherwise.
68
+ */
69
+ async function isBinaryUpToDate(binPath, arch, accessToken, progressCallback) {
70
+ if (!fs.existsSync(binPath)) {
71
+ return false;
72
+ }
73
+
74
+ try {
75
+ await logAndProgress(
76
+ 'Checking for Universal Maker updates...',
77
+ progressCallback,
78
+ 'debug'
79
+ );
80
+
81
+ const artifactRegistryClient = await getArtifactRegistryClient(
82
+ AR_PROJECT,
83
+ accessToken
84
+ );
85
+ const resourceName = artifactRegistryClient.filePath(
86
+ AR_PROJECT,
87
+ AR_LOCATION,
88
+ AR_REPOSITORY,
89
+ `${arch}:${UM_VERSION}:${UNIVERSAL_MAKER_BIN}`
90
+ );
91
+
92
+ const [file] = await artifactRegistryClient.getFile({ name: resourceName });
93
+ const remoteSha256Base64 = file.hashes?.find(
94
+ (h) => h.type === 'SHA256'
95
+ )?.value;
96
+
97
+ if (!remoteSha256Base64) {
98
+ return false; // Cannot verify, download new binary
99
+ }
100
+
101
+ const remoteSha256Hex = Buffer.from(remoteSha256Base64, 'base64').toString(
102
+ 'hex'
103
+ );
104
+ const localFileContent = fs.readFileSync(binPath);
105
+ const localSha256Hex = crypto
106
+ .createHash('sha256')
107
+ .update(localFileContent)
108
+ .digest('hex');
109
+
110
+ if (remoteSha256Hex === localSha256Hex) {
111
+ await logAndProgress(
112
+ 'Universal Maker binary is up to date.',
113
+ progressCallback,
114
+ 'debug'
115
+ );
116
+ return true;
117
+ }
118
+
119
+ await logAndProgress(
120
+ 'Universal Maker binary is out of date. Removing existing binary.',
121
+ progressCallback,
122
+ 'debug'
123
+ );
124
+ fs.unlinkSync(binPath);
125
+ return false;
126
+ } catch (error) {
127
+ await logAndProgress(
128
+ `Could not check for Universal Maker updates: ${error.message}. Downloading the new binary.`,
129
+ progressCallback,
130
+ 'debug'
131
+ );
132
+ return false; // On error, fallback to downloading the new binary
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Downloads the universal maker binary if it doesn't exist.
138
+ * @param {string} binDir - Directory to store the binary.
139
+ * @param {string} accessToken - Access token for authentication.
140
+ * @param {function} progressCallback - Progress callback.
141
+ * @returns {Promise<string|null>} Path to the binary or null if not supported.
142
+ */
143
+ async function ensureUniversalMaker(binDir, accessToken, progressCallback) {
144
+ const key = getUniversalMakerArchitectureKey();
145
+
146
+ if (!key) {
147
+ await logAndProgress(
148
+ `Universal Maker is not supported on ${process.platform} ${process.arch}.`,
149
+ progressCallback,
150
+ 'debug'
151
+ );
152
+ return null;
153
+ }
154
+
155
+ const binPath = path.join(binDir, UNIVERSAL_MAKER_BIN);
156
+ const arch = ARCH_MAPPING[key];
157
+
158
+ if (await isBinaryUpToDate(binPath, arch, accessToken, progressCallback)) {
159
+ return binPath;
160
+ }
161
+
162
+ await logAndProgress(`Downloading Universal Maker...`, progressCallback);
163
+
164
+ try {
165
+ if (!fs.existsSync(binDir)) {
166
+ fs.mkdirSync(binDir, { recursive: true });
167
+ }
168
+
169
+ const artifactRegistryClient = await getArtifactRegistryClient(
170
+ AR_PROJECT,
171
+ accessToken
172
+ );
173
+ const resourceName = artifactRegistryClient.filePath(
174
+ AR_PROJECT,
175
+ AR_LOCATION,
176
+ AR_REPOSITORY,
177
+ `${arch}:${UM_VERSION}:${UNIVERSAL_MAKER_BIN}`
178
+ );
179
+
180
+ const url = `${AR_HOST}${AR_DOWNLOAD_PATH_PREFIX}${resourceName}${AR_DOWNLOAD_URL_SUFFIX}`;
181
+ const response = await artifactRegistryClient.auth.request({
182
+ url,
183
+ responseType: 'stream',
184
+ });
185
+
186
+ const dest = fs.createWriteStream(binPath);
187
+ await new Promise((resolve, reject) => {
188
+ response.data.pipe(dest).on('finish', resolve).on('error', reject);
189
+ });
190
+
191
+ fs.chmodSync(binPath, '755');
192
+ return binPath;
193
+ } catch (error) {
194
+ await logAndProgress(
195
+ `Failed to download Universal Maker: ${error.message}`,
196
+ progressCallback,
197
+ 'debug'
198
+ );
199
+ return null;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Runs Universal Maker on the given application directory.
205
+ * @param {string} appDir - Directory containing the application source.
206
+ * @param {function} progressCallback - Progress callback.
207
+ * @param {string} accessToken - Access token for authentication.
208
+ * @returns {Promise<object|null>} The parsed build_output.json or null.
209
+ */
210
+ export async function runUniversalMaker(appDir, accessToken, progressCallback) {
211
+ const binDir = path.join(
212
+ os.homedir(),
213
+ TEMP_PATHS.BASE,
214
+ TEMP_PATHS.BIN_SUBDIR
215
+ );
216
+ const binPath = await ensureUniversalMaker(
217
+ binDir,
218
+ accessToken,
219
+ progressCallback
220
+ );
221
+
222
+ if (!binPath) {
223
+ return null;
224
+ }
225
+
226
+ const outputDir = path.join(os.tmpdir(), `um-output-${Date.now()}`);
227
+ if (!fs.existsSync(outputDir)) {
228
+ fs.mkdirSync(outputDir, { recursive: true });
229
+ }
230
+
231
+ try {
232
+ // ./universal_maker -application_dir <source_code_path> -output_dir <output_results_path> -output_format json
233
+ await logAndProgress(
234
+ `Running Universal Maker: ${binPath} -application_dir ${appDir} -output_dir ${outputDir} -output_format json`,
235
+ progressCallback,
236
+ 'debug'
237
+ );
238
+ const command = `"${binPath}" -application_dir "${appDir}" -output_dir "${outputDir}" -output_format json`;
239
+ await execAsync(command);
240
+
241
+ const outputPath = path.join(outputDir, 'build_output.json');
242
+ if (fs.existsSync(outputPath)) {
243
+ const output = JSON.parse(fs.readFileSync(outputPath, 'utf-8'));
244
+ await logAndProgress(
245
+ 'Universal Maker completed successfully.',
246
+ progressCallback
247
+ );
248
+ return output;
249
+ } else {
250
+ await logAndProgress(
251
+ 'Universal Maker did not produce build_output.json',
252
+ progressCallback,
253
+ 'debug'
254
+ );
255
+ return null;
256
+ }
257
+ } catch (error) {
258
+ await logAndProgress(
259
+ `Universal Maker failed: ${error.message}`,
260
+ progressCallback,
261
+ 'debug'
262
+ );
263
+ return null;
264
+ } finally {
265
+ // Cleanup output dir
266
+ try {
267
+ if (fs.existsSync(outputDir)) {
268
+ fs.rmSync(outputDir, { recursive: true, force: true });
269
+ }
270
+ } catch (e) {
271
+ // ignore cleanup errors
272
+ }
273
+ }
274
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@google-cloud/cloud-run-mcp",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "description": "Cloud Run MCP deployment tool",
6
6
  "main": "mcp-server.js",
@@ -50,7 +50,7 @@ describe('Deployment Helpers', () => {
50
50
  assert.equal(result.hasDockerfile, true);
51
51
  });
52
52
 
53
- test('makeFileDeploymentMetadata correctly identifies Node.js project attributes', async () => {
53
+ test('makeFileDeploymentMetadata correctly identifies Node.js project', async () => {
54
54
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
55
55
  fs: fsMock,
56
56
  });
@@ -69,75 +69,13 @@ describe('Deployment Helpers', () => {
69
69
  return false;
70
70
  });
71
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
72
  const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
80
73
 
81
74
  assert.equal(result.hasDockerfile, false);
82
75
  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
76
  });
139
77
 
140
- test('canDeployWithoutBuild returns true for valid Node.js zip deploy', async () => {
78
+ test('canDeployWithoutBuild returns true for valid Node.js project', async () => {
141
79
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
142
80
  fs: fsMock,
143
81
  });
@@ -146,9 +84,6 @@ describe('Deployment Helpers', () => {
146
84
  hasDockerfile: false,
147
85
  deploymentAttrs: {
148
86
  runtime: 'nodejs',
149
- cmd: ['node'],
150
- args: ['index.js'],
151
- baseImage: 'base-image',
152
87
  },
153
88
  };
154
89
 
@@ -164,15 +99,13 @@ describe('Deployment Helpers', () => {
164
99
  hasDockerfile: true,
165
100
  deploymentAttrs: {
166
101
  runtime: 'nodejs',
167
- cmd: ['node'],
168
- args: ['index.js'],
169
102
  },
170
103
  };
171
104
 
172
105
  assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), false);
173
106
  });
174
107
 
175
- test('createDirectSourceDeploymentContainer creates correct object', async () => {
108
+ test('createDirectSourceDeploymentContainer creates correct object without envVars', async () => {
176
109
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
177
110
  fs: fsMock,
178
111
  });
@@ -204,28 +137,43 @@ describe('Deployment Helpers', () => {
204
137
  });
205
138
  });
206
139
 
207
- test('makeFileDeploymentMetadata handles invalid package.json gracefully', async () => {
140
+ test('createDirectSourceDeploymentContainer creates correct object with envVars', async () => {
208
141
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
209
142
  fs: fsMock,
210
143
  });
211
144
 
212
- const folderPath = '/absolute/path/to/bad-json-app';
145
+ const input = {
146
+ bucketName: 'test-bucket',
147
+ fileName: 'source.tar.gz',
148
+ deploymentAttrs: {
149
+ cmd: ['node'],
150
+ args: ['server.js'],
151
+ baseImage: 'gcr.io/google-appengine/nodejs',
152
+ envVars: {
153
+ PORT: '8080',
154
+ NODE_ENV: 'production',
155
+ },
156
+ },
157
+ };
213
158
 
214
- fsMock.statSync.mock.mockImplementation(() => ({
215
- isDirectory: () => true,
216
- }));
159
+ const result =
160
+ deploymentHelpers.createDirectSourceDeploymentContainer(input);
217
161
 
218
- fsMock.existsSync.mock.mockImplementation((p) => {
219
- if (p.endsWith('package.json')) return true;
220
- return false;
162
+ assert.deepEqual(result, {
163
+ image: DEPLOYMENT_CONFIG.NO_BUILD_IMAGE_TYPE,
164
+ baseImageUri: 'gcr.io/google-appengine/nodejs',
165
+ sourceCode: {
166
+ cloudStorageSource: {
167
+ bucket: 'test-bucket',
168
+ object: 'source.tar.gz',
169
+ },
170
+ },
171
+ command: ['node'],
172
+ args: ['server.js'],
173
+ env: [
174
+ { name: 'PORT', value: '8080' },
175
+ { name: 'NODE_ENV', value: 'production' },
176
+ ],
221
177
  });
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
178
  });
231
179
  });
@@ -75,171 +75,6 @@ describe('Source Processor', () => {
75
75
  assert.ok(cpCall, 'Should call cp -R for directories');
76
76
  });
77
77
 
78
- test('installDependencies runs npm install for Node.js', async () => {
79
- // We need to mock fs.existsSync as well
80
- const fsMock = {
81
- existsSync: mock.fn(),
82
- };
83
-
84
- const sourceProcessor = await esmock(
85
- '../../lib/deployment/source-processor.js',
86
- {
87
- 'node:fs': fsMock,
88
- 'node:fs/promises': fspMock,
89
- 'node:child_process': childProcessMock,
90
- }
91
- );
92
-
93
- const targetDir = '/temp/dir';
94
- const deploymentAttrs = {
95
- runtime: 'nodejs',
96
- cmd: ['node', 'server.js'],
97
- args: ['server.js'],
98
- baseImage: 'node:22',
99
- };
100
- const progressCallback = mock.fn();
101
-
102
- // Mock existsSync
103
- fsMock.existsSync.mock.mockImplementation((p) => {
104
- if (p.includes('node_modules')) return false; // not installed yet
105
- if (p.includes('package-lock.json')) return false; // no lock file
106
- return false;
107
- });
108
-
109
- // Mock exec for npm install
110
- childProcessMock.exec.mock.mockImplementation((cmd, opts, callback) => {
111
- if (typeof opts === 'function') callback = opts;
112
- if (callback) callback(null, 'ok', '');
113
- });
114
-
115
- await sourceProcessor.installDependencies(
116
- targetDir,
117
- deploymentAttrs,
118
- progressCallback
119
- );
120
-
121
- const calls = childProcessMock.exec.mock.calls;
122
- const npmCall = calls.find((call) =>
123
- call.arguments[0].includes('npm install')
124
- );
125
- assert.ok(npmCall, 'Should run npm install');
126
- assert.strictEqual(npmCall.arguments[1].cwd, targetDir);
127
- });
128
-
129
- test('installDependencies uses npm ci if lockfile exists', async () => {
130
- const fsMock = {
131
- existsSync: mock.fn(),
132
- };
133
-
134
- const sourceProcessor = await esmock(
135
- '../../lib/deployment/source-processor.js',
136
- {
137
- 'node:fs': fsMock,
138
- 'node:child_process': childProcessMock,
139
- }
140
- );
141
-
142
- const targetDir = '/temp/dir';
143
- const deploymentAttrs = {
144
- runtime: 'nodejs',
145
- cmd: ['node', 'server.js'],
146
- args: ['server.js'],
147
- baseImage: 'node:22',
148
- };
149
-
150
- // Mock existsSync
151
- fsMock.existsSync.mock.mockImplementation((p) => {
152
- if (p.includes('node_modules')) return false;
153
- if (p.includes('package-lock.json')) return true; // has lock file
154
- return false;
155
- });
156
-
157
- childProcessMock.exec.mock.mockImplementation((cmd, opts, callback) => {
158
- if (typeof opts === 'function') callback = opts;
159
- if (callback) callback(null, 'ok', '');
160
- });
161
-
162
- await sourceProcessor.installDependencies(
163
- targetDir,
164
- deploymentAttrs,
165
- () => {}
166
- );
167
-
168
- const calls = childProcessMock.exec.mock.calls;
169
- const npmCall = calls.find((call) => call.arguments[0].includes('npm ci'));
170
- assert.ok(npmCall, 'Should run npm ci');
171
- });
172
-
173
- test('installDependencies skips if node_modules exists', async () => {
174
- const fsMock = {
175
- existsSync: mock.fn(),
176
- };
177
-
178
- const sourceProcessor = await esmock(
179
- '../../lib/deployment/source-processor.js',
180
- {
181
- 'node:fs': fsMock,
182
- 'node:child_process': childProcessMock,
183
- }
184
- );
185
-
186
- fsMock.existsSync.mock.mockImplementation((p) => {
187
- if (p.includes('node_modules')) return true;
188
- return false;
189
- });
190
-
191
- // clear mock calls
192
- childProcessMock.exec.mock.resetCalls();
193
-
194
- await sourceProcessor.installDependencies(
195
- '/dir',
196
- {
197
- runtime: 'nodejs',
198
- cmd: ['node', 'server.js'],
199
- args: ['server.js'],
200
- baseImage: 'node:22',
201
- },
202
- () => {}
203
- );
204
-
205
- assert.equal(childProcessMock.exec.mock.callCount(), 0);
206
- });
207
-
208
- test('installDependencies logs warn on failure but does not throw', async () => {
209
- const fsMock = { existsSync: mock.fn(() => false) };
210
- const sourceProcessor = await esmock(
211
- '../../lib/deployment/source-processor.js',
212
- {
213
- 'node:fs': fsMock,
214
- 'node:child_process': childProcessMock,
215
- }
216
- );
217
-
218
- childProcessMock.exec.mock.mockImplementation((cmd, opts, callback) => {
219
- if (typeof opts === 'function') callback = opts;
220
- callback(new Error('npm failed'));
221
- });
222
-
223
- const consoleLogMock = mock.method(console, 'log');
224
-
225
- await assert.doesNotReject(async () => {
226
- await sourceProcessor.installDependencies('/dir', { runtime: 'nodejs' });
227
- });
228
-
229
- assert.equal(
230
- consoleLogMock.mock.callCount(),
231
- 1,
232
- 'console.log should be called once'
233
- );
234
- const logArgs = consoleLogMock.mock.calls[0].arguments;
235
- assert.ok(
236
- logArgs[0].includes('Dependency installation failed: npm failed'),
237
- 'console.log should contain the error message'
238
- );
239
-
240
- consoleLogMock.mock.restore();
241
- });
242
-
243
78
  test('cleanupTempDirectory removes directory', async () => {
244
79
  const fsMock = {
245
80
  existsSync: mock.fn(() => true),
@@ -0,0 +1,248 @@
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
+
21
+ describe('Universal Maker', () => {
22
+ const fsMock = {
23
+ existsSync: mock.fn(),
24
+ mkdirSync: mock.fn(),
25
+ chmodSync: mock.fn(),
26
+ readFileSync: mock.fn(),
27
+ rmSync: mock.fn(),
28
+ unlinkSync: mock.fn(),
29
+ createWriteStream: mock.fn(),
30
+ };
31
+
32
+ const cryptoMock = {
33
+ createHash: mock.fn(),
34
+ };
35
+
36
+ const childProcessMock = {
37
+ exec: mock.fn(),
38
+ };
39
+
40
+ const osMock = {
41
+ homedir: () => '/home/user',
42
+ tmpdir: () => '/tmp',
43
+ };
44
+
45
+ const helpersMock = {
46
+ logAndProgress: mock.fn(),
47
+ };
48
+
49
+ const clientsMock = {
50
+ getArtifactRegistryClient: mock.fn(),
51
+ };
52
+
53
+ test('runUniversalMaker skips download if binary exists and runs successfully', async () => {
54
+ fsMock.existsSync.mock.resetCalls();
55
+ fsMock.readFileSync.mock.resetCalls();
56
+ childProcessMock.exec.mock.resetCalls();
57
+
58
+ const localContent = 'local content';
59
+ const sha256 =
60
+ 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
61
+
62
+ cryptoMock.createHash.mock.mockImplementation(() => ({
63
+ update: mock.fn(() => ({
64
+ digest: mock.fn(() => sha256),
65
+ })),
66
+ }));
67
+
68
+ // Mock binary exists
69
+ fsMock.existsSync.mock.mockImplementation((p) => {
70
+ if (p.includes('universal_maker')) return true;
71
+ if (p.includes('build_output.json')) return true;
72
+ return false;
73
+ });
74
+
75
+ fsMock.readFileSync.mock.mockImplementation((p) => {
76
+ if (p.includes('universal_maker')) return localContent;
77
+ if (p.includes('build_output.json')) {
78
+ return JSON.stringify({
79
+ command: 'node',
80
+ args: ['index.js'],
81
+ runtime: 'nodejs20',
82
+ envVars: { DEBUG: 'true' },
83
+ });
84
+ }
85
+ return '';
86
+ });
87
+
88
+ // Mock Artifact Registry client
89
+ const artifactRegistryClientMock = {
90
+ filePath: mock.fn(
91
+ (p, l, r, f) =>
92
+ `projects/${p}/locations/${l}/repositories/${r}/files/${f}`
93
+ ),
94
+ getFile: mock.fn(() => [
95
+ {
96
+ hashes: [
97
+ {
98
+ type: 'SHA256',
99
+ value: Buffer.from(sha256, 'hex').toString('base64'),
100
+ },
101
+ ],
102
+ },
103
+ ]),
104
+ };
105
+ clientsMock.getArtifactRegistryClient.mock.mockImplementation(
106
+ () => artifactRegistryClientMock
107
+ );
108
+
109
+ const um = await esmock('../../lib/deployment/universal-maker.js', {
110
+ fs: fsMock,
111
+ child_process: childProcessMock,
112
+ os: osMock,
113
+ crypto: cryptoMock,
114
+ '../../lib/util/helpers.js': helpersMock,
115
+ '../../lib/clients.js': clientsMock,
116
+ });
117
+
118
+ // Mock exec only for binary execution
119
+ childProcessMock.exec.mock.mockImplementation((cmd, cb) => {
120
+ cb(null, { stdout: '', stderr: '' });
121
+ });
122
+
123
+ // runUniversalMaker(appDir, accessToken, progressCallback)
124
+ const result = await um.runUniversalMaker(
125
+ '/app/dir',
126
+ 'fake-token',
127
+ mock.fn()
128
+ );
129
+
130
+ assert.ok(result);
131
+ assert.equal(result.command, 'node');
132
+ assert.deepEqual(result.args, ['index.js']);
133
+ assert.equal(result.runtime, 'nodejs20');
134
+
135
+ // Verify bin check was performed
136
+ const binExistsCall = fsMock.existsSync.mock.calls.find((c) =>
137
+ c.arguments[0].includes('universal_maker')
138
+ );
139
+ assert.ok(binExistsCall);
140
+ });
141
+
142
+ test('runUniversalMaker downloads binary if missing', async () => {
143
+ fsMock.existsSync.mock.resetCalls();
144
+ fsMock.readFileSync.mock.resetCalls();
145
+ childProcessMock.exec.mock.resetCalls();
146
+
147
+ let downloaded = false;
148
+
149
+ // Mock binary missing first, then exists after "download"
150
+ fsMock.existsSync.mock.mockImplementation((p) => {
151
+ if (p.includes('universal_maker')) return downloaded;
152
+ if (p.includes('build_output.json')) return true;
153
+ return true; // for dir checks
154
+ });
155
+
156
+ // Mock Artifact Registry client for download
157
+ const artifactRegistryClientMock = {
158
+ filePath: mock.fn(
159
+ (p, l, r, f) =>
160
+ `projects/${p}/locations/${l}/repositories/${r}/files/${f}`
161
+ ),
162
+ getFile: mock.fn(() => [{ hashes: [] }]), // trigger download
163
+ auth: {
164
+ request: mock.fn(() => {
165
+ downloaded = true;
166
+ return {
167
+ data: {
168
+ pipe: (dest) => {
169
+ setTimeout(() => dest.emit('finish'), 10);
170
+ return dest;
171
+ },
172
+ },
173
+ };
174
+ }),
175
+ },
176
+ };
177
+ clientsMock.getArtifactRegistryClient.mock.mockImplementation(
178
+ () => artifactRegistryClientMock
179
+ );
180
+
181
+ const writeStreamMock = {
182
+ on: mock.fn(function (event, cb) {
183
+ if (event === 'finish') this.finishCb = cb;
184
+ return this;
185
+ }),
186
+ emit: mock.fn(function (event) {
187
+ if (event === 'finish' && this.finishCb) this.finishCb();
188
+ }),
189
+ pipeFrom: mock.fn(),
190
+ };
191
+ fsMock.createWriteStream = mock.fn(() => writeStreamMock);
192
+
193
+ fsMock.readFileSync.mock.mockImplementation(() =>
194
+ JSON.stringify({ command: 'npm', args: ['start'] })
195
+ );
196
+
197
+ const um = await esmock('../../lib/deployment/universal-maker.js', {
198
+ fs: fsMock,
199
+ child_process: childProcessMock,
200
+ os: osMock,
201
+ crypto: cryptoMock,
202
+ '../../lib/util/helpers.js': helpersMock,
203
+ '../../lib/clients.js': clientsMock,
204
+ });
205
+
206
+ // Mock exec only for binary execution
207
+ childProcessMock.exec.mock.mockImplementation((cmd, cb) => {
208
+ cb(null, { stdout: '', stderr: '' });
209
+ });
210
+
211
+ // runUniversalMaker(appDir, accessToken, progressCallback)
212
+ const result = await um.runUniversalMaker(
213
+ '/app/dir',
214
+ 'fake-token',
215
+ mock.fn()
216
+ );
217
+
218
+ assert.ok(result);
219
+ assert.equal(result.command, 'npm');
220
+ assert.ok(downloaded);
221
+ });
222
+
223
+ test('runUniversalMaker returns null if binary not supported on platform', async () => {
224
+ // Save original platform
225
+ const originalPlatform = process.platform;
226
+ Object.defineProperty(process, 'platform', { value: 'win32' });
227
+
228
+ try {
229
+ const um = await esmock('../../lib/deployment/universal-maker.js', {
230
+ fs: fsMock,
231
+ child_process: childProcessMock,
232
+ os: osMock,
233
+ crypto: cryptoMock,
234
+ '../../lib/util/helpers.js': helpersMock,
235
+ '../../lib/clients.js': clientsMock,
236
+ });
237
+
238
+ const result = await um.runUniversalMaker(
239
+ '/app/dir',
240
+ 'fake-token',
241
+ mock.fn()
242
+ );
243
+ assert.equal(result, null);
244
+ } finally {
245
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
246
+ }
247
+ });
248
+ });