@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.
- package/gemini-extension.json +12 -0
- package/lib/deployment/constants.js +3 -0
- package/lib/deployment/deployer.js +51 -2
- package/lib/deployment/helpers.js +41 -48
- package/lib/deployment/source-processor.js +0 -48
- package/lib/deployment/universal-maker.js +274 -0
- package/package.json +1 -1
- package/test/local/deployment-helpers.test.js +34 -86
- package/test/local/source-processor.test.js +0 -165
- package/test/local/universal-maker.test.js +248 -0
package/gemini-extension.json
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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 (
|
|
109
|
+
if (
|
|
110
|
+
!isFolder(files) ||
|
|
111
|
+
!(checkIfNodeJsRuntime(files) || checkIfPythonRuntime(files))
|
|
112
|
+
) {
|
|
133
113
|
// TODO: support file list deployment runtime detection
|
|
134
|
-
return
|
|
114
|
+
return deploymentAttrs;
|
|
135
115
|
}
|
|
136
116
|
|
|
137
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
}));
|
|
159
|
+
const result =
|
|
160
|
+
deploymentHelpers.createDirectSourceDeploymentContainer(input);
|
|
217
161
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
});
|