@google-cloud/cloud-run-mcp 1.10.0 → 1.12.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.
Files changed (52) hide show
  1. package/.github/workflows/npm-publish.yml +10 -6
  2. package/README.md +26 -0
  3. package/SECURITY.md +7 -0
  4. package/cloudbuild.yaml +37 -0
  5. package/example-sources-to-deploy/java/pom.xml +41 -0
  6. package/example-sources-to-deploy/java/src/main/java/com/example/helloworld/HelloworldApplication.java +21 -0
  7. package/example-sources-to-deploy/java/src/main/resources/application.properties +1 -0
  8. package/example-sources-to-deploy/nodejs/index.js +15 -0
  9. package/example-sources-to-deploy/nodejs/package.json +12 -0
  10. package/example-sources-to-deploy/python/pip-project/main.py +11 -0
  11. package/example-sources-to-deploy/python/pip-project/requirements.txt +2 -0
  12. package/example-sources-to-deploy/python/pyproject-project/main.py +11 -0
  13. package/example-sources-to-deploy/python/pyproject-project/pyproject.toml +10 -0
  14. package/lib/clients.js +142 -12
  15. package/lib/cloud-api/build.js +192 -28
  16. package/lib/cloud-api/run.js +62 -0
  17. package/lib/cloud-api/secrets.js +196 -0
  18. package/lib/cloud-api/storage.js +164 -16
  19. package/lib/deployment/compose.js +517 -0
  20. package/lib/deployment/constants.js +8 -0
  21. package/lib/deployment/deployer.js +283 -28
  22. package/lib/deployment/helpers.js +58 -1
  23. package/lib/deployment/universal-maker.js +21 -148
  24. package/lib/middleware/oauth.js +26 -15
  25. package/lib/util/archive.js +3 -3
  26. package/lib/util/artifacts.js +189 -0
  27. package/lib/util/helpers.js +109 -0
  28. package/mcp-server.js +83 -20
  29. package/package.json +7 -4
  30. package/skills/cloud-run/SKILL.md +109 -0
  31. package/test/local/archive.test.js +88 -0
  32. package/test/local/clients.test.js +307 -2
  33. package/test/local/cloud-api/build.test.js +330 -25
  34. package/test/local/cloud-api/run.test.js +129 -0
  35. package/test/local/cloud-api/secrets.test.js +291 -0
  36. package/test/local/cloud-api/storage.test.js +405 -0
  37. package/test/local/compose.test.js +618 -0
  38. package/test/local/deploy.test.js +408 -0
  39. package/test/local/deployment-helpers.test.js +100 -1
  40. package/test/local/mcp-server-streamable-http.test.js +132 -12
  41. package/test/local/middleware/oauth.test.js +106 -5
  42. package/test/local/test-utils.js +2 -18
  43. package/test/local/tools.test.js +43 -1
  44. package/test/local/universal-maker.test.js +23 -114
  45. package/test/local/util.test.js +361 -0
  46. package/test/need-gcp/deployer.test.js +1 -1
  47. package/test/need-gcp/test-helpers.js +5 -1
  48. package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
  49. package/tools/register-tools.js +98 -78
  50. /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
  51. /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
  52. /package/example-sources-to-deploy/{main.go → golang/main.go} +0 -0
@@ -19,22 +19,18 @@ import fs from 'fs';
19
19
  import path from 'path';
20
20
  import os from 'os';
21
21
  import util from 'util';
22
- import crypto from 'crypto';
23
22
  import { logAndProgress } from '../util/helpers.js';
24
23
  import { TEMP_PATHS } from './constants.js';
25
- import { getArtifactRegistryClient } from '../clients.js';
24
+ import { ensureRepositoryDownloaded } from '../util/artifacts.js';
26
25
 
27
26
  const execAsync = util.promisify(exec);
28
27
 
29
28
  const UNIVERSAL_MAKER_BIN = 'universal_maker';
30
29
  const UM_VERSION = '1.0.0';
31
30
 
32
- const AR_HOST = 'https://artifactregistry.googleapis.com';
33
31
  const AR_PROJECT = 'serverless-runtimes';
34
32
  const AR_LOCATION = 'us-central1';
35
33
  const AR_REPOSITORY = 'universal-maker';
36
- const AR_DOWNLOAD_PATH_PREFIX = '/download/v1/';
37
- const AR_DOWNLOAD_URL_SUFFIX = ':download?alt=media';
38
34
 
39
35
  const ARCH_MAPPING = {
40
36
  linux_x64: 'x86-64',
@@ -58,91 +54,19 @@ function getUniversalMakerArchitectureKey() {
58
54
  }
59
55
 
60
56
  /**
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.
57
+ * Runs Universal Maker on the given application directory.
58
+ * @param {string} appDir - Directory containing the application source.
66
59
  * @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
60
  * @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.
61
+ * @returns {Promise<object|null>} The parsed build_output.json or null.
142
62
  */
143
- async function ensureUniversalMaker(binDir, accessToken, progressCallback) {
63
+ export async function runUniversalMaker(appDir, accessToken, progressCallback) {
64
+ const binDir = path.join(
65
+ os.homedir(),
66
+ TEMP_PATHS.BASE,
67
+ TEMP_PATHS.BIN_SUBDIR
68
+ );
144
69
  const key = getUniversalMakerArchitectureKey();
145
-
146
70
  if (!key) {
147
71
  await logAndProgress(
148
72
  `Universal Maker is not supported on ${process.platform} ${process.arch}.`,
@@ -152,74 +76,23 @@ async function ensureUniversalMaker(binDir, accessToken, progressCallback) {
152
76
  return null;
153
77
  }
154
78
 
155
- const binPath = path.join(binDir, UNIVERSAL_MAKER_BIN);
156
79
  const arch = ARCH_MAPPING[key];
80
+ const binPath = path.join(binDir, UNIVERSAL_MAKER_BIN);
157
81
 
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,
82
+ const binPathResult = await ensureRepositoryDownloaded(
83
+ binPath,
84
+ {
85
+ project: AR_PROJECT,
86
+ location: AR_LOCATION,
87
+ repository: AR_REPOSITORY,
88
+ artifactPath: `${arch}:${UM_VERSION}:${UNIVERSAL_MAKER_BIN}`,
89
+ displayName: 'Universal Maker',
90
+ },
218
91
  accessToken,
219
92
  progressCallback
220
93
  );
221
94
 
222
- if (!binPath) {
95
+ if (!binPathResult) {
223
96
  return null;
224
97
  }
225
98
 
@@ -1,8 +1,6 @@
1
- import { getOAuthClient } from '../clients.js';
1
+ import { clients, getOAuthClient } from '../clients.js';
2
2
  import { extractAccessToken } from '../util/helpers.js';
3
3
 
4
- const TOOLS_CALL_METHOD = 'tools/call';
5
-
6
4
  /**
7
5
  * Verifies the validity of an access token and checks the audience.
8
6
  * @param {string} accessToken - The access token to verify.
@@ -16,13 +14,14 @@ async function verifyAccessToken(accessToken, audience) {
16
14
  const tokenInfo = await client.getTokenInfo(accessToken);
17
15
 
18
16
  //An expired token will not have audience
19
- if (audience && tokenInfo.aud !== audience) {
20
- throw new Error(`Invalid audience: expected ${audience}`);
17
+ if (!audience || tokenInfo.aud !== audience) {
18
+ throw new Error('Invalid token audience');
21
19
  }
22
20
 
23
21
  console.log('Access token verified successfully.');
24
22
  return tokenInfo;
25
23
  } catch (error) {
24
+ clients.oauth.delete(accessToken);
26
25
  console.error('Error verifying access token:', error);
27
26
  throw error;
28
27
  }
@@ -43,11 +42,19 @@ async function ensureOAuthTokenInEnv(req, res) {
43
42
  console.log('No authorization header found in request');
44
43
  throw new Error('No authorization header');
45
44
  }
45
+ const accessToken = extractAccessToken(req.headers.authorization);
46
+ if (!accessToken) {
47
+ console.log('No access token found in authorization header');
48
+ throw new Error('No access token found in authorization header');
49
+ }
50
+ if (!audience) {
51
+ console.log('GOOGLE_OAUTH_AUDIENCE environment variable is missing');
52
+ throw new Error(
53
+ 'GOOGLE_OAUTH_AUDIENCE environment variable is required when OAuth is enabled.'
54
+ );
55
+ }
46
56
  console.log('Authorization header found.. Verifying token');
47
- await verifyAccessToken(
48
- extractAccessToken(req.headers.authorization),
49
- audience
50
- );
57
+ await verifyAccessToken(accessToken, audience);
51
58
  console.log('Token verified');
52
59
  } catch (error) {
53
60
  console.error('Authentication failed: ', error);
@@ -64,11 +71,8 @@ async function ensureOAuthTokenInEnv(req, res) {
64
71
  * @param {import('express').NextFunction} next
65
72
  */
66
73
  export const oauthMiddleware = async (req, res, next) => {
67
- //If OAUTH_ENABLED is not true or the request is not a tools/call, skip the middleware
68
- if (
69
- process.env.OAUTH_ENABLED !== 'true' ||
70
- req.body.method !== TOOLS_CALL_METHOD
71
- ) {
74
+ //If OAUTH_ENABLED is not true, skip the middleware
75
+ if (process.env.OAUTH_ENABLED !== 'true') {
72
76
  return next();
73
77
  }
74
78
 
@@ -79,12 +83,19 @@ export const oauthMiddleware = async (req, res, next) => {
79
83
  console.error('OAuth Middleware Error:', error);
80
84
  // ensureOAuthTokenInEnv throws an error if auth fails.
81
85
  // We catch it and send a 401 response.
86
+ if (process.env.OAUTH_PROTECTED_RESOURCE) {
87
+ const metadataUrl = new URL(
88
+ '/.well-known/oauth-protected-resource',
89
+ process.env.OAUTH_PROTECTED_RESOURCE
90
+ ).href;
91
+ res.set('WWW-Authenticate', `Bearer resource_metadata="${metadataUrl}"`);
92
+ }
82
93
  res.status(401).json({
83
94
  jsonrpc: '2.0',
84
95
  error: {
85
96
  code: -32001, // Custom auth error code or standard -32000
86
97
  message: 'Authentication failed',
87
- data: error.message,
98
+ data: 'Invalid or missing access token',
88
99
  },
89
100
  id: null,
90
101
  });
@@ -31,7 +31,7 @@ import { logAndProgress } from './helpers.js';
31
31
  export async function zipFiles(files, useTarGz, progressCallback) {
32
32
  const path = await import('path');
33
33
  const fs = await import('fs');
34
- const archiver = (await import('archiver')).default;
34
+ const { TarArchive, ZipArchive } = await import('archiver');
35
35
 
36
36
  return new Promise((resolve, reject) => {
37
37
  logAndProgress('Creating in-memory archive...', progressCallback);
@@ -39,11 +39,11 @@ export async function zipFiles(files, useTarGz, progressCallback) {
39
39
 
40
40
  let archive;
41
41
  if (useTarGz) {
42
- archive = archiver('tar', {
42
+ archive = new TarArchive({
43
43
  gzip: { level: 9 },
44
44
  });
45
45
  } else {
46
- archive = archiver('zip', {
46
+ archive = new ZipArchive({
47
47
  zlib: { level: 9 },
48
48
  });
49
49
  }
@@ -0,0 +1,189 @@
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 fs from 'fs';
18
+ import path from 'path';
19
+ import crypto from 'crypto';
20
+ import { logAndProgress } from './helpers.js';
21
+ import { getArtifactRegistryClient } from '../clients.js';
22
+
23
+ const AR_HOST = 'https://artifactregistry.googleapis.com';
24
+ const AR_DOWNLOAD_PATH_PREFIX = '/download/v1/';
25
+ const AR_DOWNLOAD_URL_SUFFIX = ':download?alt=media';
26
+
27
+ /**
28
+ * Checks if the existing binary is up to date by comparing its SHA256 hash with the remote metadata.
29
+ * If the binary is out of date, it removes it.
30
+ * @param {string} binPath - Path to the local binary.
31
+ * @param {object} artifactParams - Parameters for the remote artifact.
32
+ * @param {string} artifactParams.project - Artifact Registry project.
33
+ * @param {string} artifactParams.location - Artifact Registry location.
34
+ * @param {string} artifactParams.repository - Artifact Registry repository.
35
+ * @param {string} artifactParams.artifactPath - Path to the artifact within the repository.
36
+ * @param {string} artifactParams.displayName - A friendly name for logging.
37
+ * @param {string} accessToken - Access token for authentication.
38
+ * @param {function} progressCallback - Progress callback.
39
+ * @returns {Promise<boolean>} True if the binary is up to date, false otherwise.
40
+ */
41
+ export async function isBinaryUpToDate(
42
+ binPath,
43
+ artifactParams,
44
+ accessToken,
45
+ progressCallback
46
+ ) {
47
+ const { project, location, repository, artifactPath, displayName } =
48
+ artifactParams;
49
+
50
+ if (!fs.existsSync(binPath)) {
51
+ return false;
52
+ }
53
+
54
+ try {
55
+ await logAndProgress(
56
+ `Checking for ${displayName} updates...`,
57
+ progressCallback,
58
+ 'debug'
59
+ );
60
+
61
+ const artifactRegistryClient = await getArtifactRegistryClient(
62
+ project,
63
+ accessToken
64
+ );
65
+ const resourceName = artifactRegistryClient.filePath(
66
+ project,
67
+ location,
68
+ repository,
69
+ artifactPath
70
+ );
71
+
72
+ const [file] = await artifactRegistryClient.getFile({ name: resourceName });
73
+ const remoteSha256Base64 = file.hashes?.find(
74
+ (h) => h.type === 'SHA256'
75
+ )?.value;
76
+
77
+ if (!remoteSha256Base64) {
78
+ return false; // Cannot verify, download new binary
79
+ }
80
+
81
+ const remoteSha256Hex = Buffer.from(remoteSha256Base64, 'base64').toString(
82
+ 'hex'
83
+ );
84
+ const localFileContent = fs.readFileSync(binPath);
85
+ const localSha256Hex = crypto
86
+ .createHash('sha256')
87
+ .update(localFileContent)
88
+ .digest('hex');
89
+
90
+ if (remoteSha256Hex === localSha256Hex) {
91
+ await logAndProgress(
92
+ `${displayName} binary is up to date.`,
93
+ progressCallback,
94
+ 'debug'
95
+ );
96
+ return true;
97
+ }
98
+
99
+ await logAndProgress(
100
+ `${displayName} binary is out of date. Removing existing binary.`,
101
+ progressCallback,
102
+ 'debug'
103
+ );
104
+ fs.unlinkSync(binPath);
105
+ return false;
106
+ } catch (error) {
107
+ await logAndProgress(
108
+ `Could not check for ${displayName} updates: ${error.message}. Downloading the new binary.`,
109
+ progressCallback,
110
+ 'debug'
111
+ );
112
+ return false; // On error, fallback to downloading the new binary
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Downloads a binary from Artifact Registry if it doesn't exist or is out of date.
118
+ * @param {string} binPath - Path where the binary should be stored.
119
+ * @param {object} artifactParams - Parameters for the remote artifact.
120
+ * @param {string} artifactParams.project - Artifact Registry project.
121
+ * @param {string} artifactParams.location - Artifact Registry location.
122
+ * @param {string} artifactParams.repository - Artifact Registry repository.
123
+ * @param {string} artifactParams.artifactPath - Path to the artifact within the repository.
124
+ * @param {string} artifactParams.displayName - A friendly name for logging.
125
+ * @param {string} accessToken - Access token for authentication.
126
+ * @param {function} progressCallback - Progress callback.
127
+ * @returns {Promise<string|null>} Path to the binary or null if it fails.
128
+ */
129
+ export async function ensureRepositoryDownloaded(
130
+ binPath,
131
+ artifactParams,
132
+ accessToken,
133
+ progressCallback
134
+ ) {
135
+ const { project, location, repository, artifactPath, displayName } =
136
+ artifactParams;
137
+ const binDir = path.dirname(binPath);
138
+
139
+ if (
140
+ await isBinaryUpToDate(
141
+ binPath,
142
+ artifactParams,
143
+ accessToken,
144
+ progressCallback
145
+ )
146
+ ) {
147
+ return binPath;
148
+ }
149
+
150
+ await logAndProgress(`Downloading ${displayName}...`, progressCallback);
151
+
152
+ try {
153
+ if (!fs.existsSync(binDir)) {
154
+ fs.mkdirSync(binDir, { recursive: true });
155
+ }
156
+
157
+ const artifactRegistryClient = await getArtifactRegistryClient(
158
+ project,
159
+ accessToken
160
+ );
161
+ const resourceName = artifactRegistryClient.filePath(
162
+ project,
163
+ location,
164
+ repository,
165
+ artifactPath
166
+ );
167
+
168
+ const url = `${AR_HOST}${AR_DOWNLOAD_PATH_PREFIX}${resourceName}${AR_DOWNLOAD_URL_SUFFIX}`;
169
+ const response = await artifactRegistryClient.auth.request({
170
+ url,
171
+ responseType: 'stream',
172
+ });
173
+
174
+ const dest = fs.createWriteStream(binPath);
175
+ await new Promise((resolve, reject) => {
176
+ response.data.pipe(dest).on('finish', resolve).on('error', reject);
177
+ });
178
+
179
+ fs.chmodSync(binPath, '755');
180
+ return binPath;
181
+ } catch (error) {
182
+ await logAndProgress(
183
+ `Failed to download ${displayName}: ${error.message}`,
184
+ progressCallback,
185
+ 'debug'
186
+ );
187
+ return null;
188
+ }
189
+ }
@@ -14,6 +14,11 @@ See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ import { getProjectsClient } from '../clients.js';
18
+ import crypto from 'node:crypto';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
17
22
  /**
18
23
  * Helper function to log a message and call the progress callback.
19
24
  * @param {string} message - The message to log.
@@ -52,3 +57,107 @@ export function extractAccessToken(authorizationHeader) {
52
57
  }
53
58
  return authorizationHeader.split(' ')[1];
54
59
  }
60
+
61
+ /**
62
+ * Gets the project number for a given project ID.
63
+ * @param {string} projectId - The Google Cloud project ID.
64
+ * @param {string} accessToken - Access token for authentication.
65
+ * @returns {Promise<string>} The project number.
66
+ */
67
+ export async function getProjectNumber(projectId, accessToken) {
68
+ const projectsClient = await getProjectsClient(accessToken);
69
+ const [project] = await projectsClient.getProject({
70
+ name: `projects/${projectId}`,
71
+ });
72
+ // project.name is in the format "projects/123456"
73
+ const parts = project.name.split('/');
74
+ return parts.length > 1 ? parts[1] : project.projectNumber;
75
+ }
76
+
77
+ /**
78
+ * Calculates a SHA256 fingerprint of a directory.
79
+ * Matches the logic in gcloud run compose builder.py.
80
+ * @param {string} dirPath - The directory to fingerprint.
81
+ * @returns {Promise<string>} The SHA256 hex digest.
82
+ */
83
+ export async function calculateSourceFingerprint(dirPath) {
84
+ const hash = crypto.createHash('sha256');
85
+
86
+ // Helper for recursive walk matching Python's deterministic order
87
+ async function walk(currentDir) {
88
+ const entries = await fs.promises.readdir(currentDir, {
89
+ withFileTypes: true,
90
+ });
91
+
92
+ // Sort entries to match deterministic order (dirs and files sorted)
93
+ entries.sort((a, b) => a.name.localeCompare(b.name));
94
+
95
+ // Handle files first (matching filenames.sort() in builder.py's walk)
96
+ for (const entry of entries) {
97
+ if (entry.isFile()) {
98
+ const filePath = path.join(currentDir, entry.name);
99
+ const relPath = path.relative(dirPath, filePath);
100
+
101
+ // Update hash with relative path
102
+ hash.update(relPath);
103
+
104
+ // Update hash with file content
105
+ const content = await fs.promises.readFile(filePath);
106
+ hash.update(content);
107
+ }
108
+ }
109
+
110
+ // Then recurse into directories
111
+ for (const entry of entries) {
112
+ if (entry.isDirectory()) {
113
+ await walk(path.join(currentDir, entry.name));
114
+ }
115
+ }
116
+ }
117
+
118
+ await walk(dirPath);
119
+ return hash.digest('hex');
120
+ }
121
+
122
+ /**
123
+ * Sanitizes a string for use as a Cloud Run service name.
124
+ * Only lowercase, digits, and hyphens are allowed.
125
+ * Must begin with a letter and cannot end with a hyphen.
126
+ * @param {string} name - The name to sanitize.
127
+ * @returns {string} The sanitized name.
128
+ */
129
+ export function sanitizeCloudRunServiceName(name) {
130
+ let sanitized = name
131
+ .toLowerCase()
132
+ .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphen
133
+ .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
134
+
135
+ // Ensure it starts with a letter if possible
136
+ if (sanitized.length > 0 && !/^[a-z]/.test(sanitized)) {
137
+ sanitized = 's-' + sanitized;
138
+ }
139
+
140
+ // Cap at 49 characters (Cloud Run limit is usually 50-63 depending on API, 49 is safe)
141
+ return sanitized.slice(0, 49).replace(/-$/, '');
142
+ }
143
+
144
+ /**
145
+ * Sanitizes a string to be a valid GCP label value.
146
+ * Label values must be:
147
+ * - <= 63 characters.
148
+ * - Contain only lowercase letters, numbers, underscores, and dashes.
149
+ * @param {string} value - The string to sanitize.
150
+ * @returns {string} A valid GCP label value string.
151
+ */
152
+ export function sanitizeLabelValue(value) {
153
+ if (value === undefined || value === null) {
154
+ return '';
155
+ }
156
+ const stringValue = String(value);
157
+ if (stringValue === '') {
158
+ return '';
159
+ }
160
+ let sanitized = stringValue.toLowerCase();
161
+ sanitized = sanitized.replace(/[^a-z0-9_-]/g, '-');
162
+ return sanitized.slice(0, 63);
163
+ }