@cloudbase/manager-node 5.6.7 → 5.7.0-beta.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 (60) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/cloudApp/index.js +12 -3
  3. package/lib/deploy/DatabaseDeployer.js +238 -0
  4. package/lib/deploy/DeployOrchestrator.js +564 -0
  5. package/lib/deploy/FunctionDeployer.js +611 -0
  6. package/lib/deploy/GatewayDeployer.js +522 -0
  7. package/lib/deploy/StateStore.js +327 -0
  8. package/lib/deploy/StaticDeployer.js +289 -0
  9. package/lib/deploy/domain.js +39 -0
  10. package/lib/deploy/framework.js +105 -0
  11. package/lib/deploy/function/artifact.js +207 -0
  12. package/lib/deploy/function/builders/cloud.js +454 -0
  13. package/lib/deploy/function/cam-preflight.js +157 -0
  14. package/lib/deploy/function/cloud-build-service.js +170 -0
  15. package/lib/deploy/function/config-guard.js +588 -0
  16. package/lib/deploy/function/docker-preflight.js +87 -0
  17. package/lib/deploy/function/image-preflight.js +164 -0
  18. package/lib/deploy/function/local-builder.js +110 -0
  19. package/lib/deploy/function/planner.js +282 -0
  20. package/lib/deploy/function/preflight.js +264 -0
  21. package/lib/deploy/types.js +99 -0
  22. package/lib/environment.js +11 -0
  23. package/lib/hosting/index.js +4 -1
  24. package/lib/index.js +31 -0
  25. package/lib/projectValidator/index.js +375 -0
  26. package/lib/projectValidator/types.js +2 -0
  27. package/lib/storage/index.js +6 -7
  28. package/lib/utils/index.js +41 -1
  29. package/package.json +2 -1
  30. package/types/cloudApp/index.d.ts +4 -0
  31. package/types/cloudApp/types.d.ts +87 -6
  32. package/types/deploy/DatabaseDeployer.d.ts +70 -0
  33. package/types/deploy/DeployOrchestrator.d.ts +150 -0
  34. package/types/deploy/FunctionDeployer.d.ts +158 -0
  35. package/types/deploy/GatewayDeployer.d.ts +175 -0
  36. package/types/deploy/StateStore.d.ts +170 -0
  37. package/types/deploy/StaticDeployer.d.ts +104 -0
  38. package/types/deploy/domain.d.ts +17 -0
  39. package/types/deploy/framework.d.ts +23 -0
  40. package/types/deploy/function/artifact.d.ts +40 -0
  41. package/types/deploy/function/builders/cloud.d.ts +110 -0
  42. package/types/deploy/function/cam-preflight.d.ts +51 -0
  43. package/types/deploy/function/cloud-build-service.d.ts +10 -0
  44. package/types/deploy/function/config-guard.d.ts +41 -0
  45. package/types/deploy/function/docker-preflight.d.ts +17 -0
  46. package/types/deploy/function/image-preflight.d.ts +21 -0
  47. package/types/deploy/function/local-builder.d.ts +26 -0
  48. package/types/deploy/function/planner.d.ts +15 -0
  49. package/types/deploy/function/preflight.d.ts +9 -0
  50. package/types/deploy/types.d.ts +429 -0
  51. package/types/env/type.d.ts +4 -4
  52. package/types/environment.d.ts +7 -0
  53. package/types/function/types.d.ts +15 -0
  54. package/types/hosting/index.d.ts +6 -0
  55. package/types/index.d.ts +18 -0
  56. package/types/interfaces/function.interface.d.ts +1 -1
  57. package/types/projectValidator/index.d.ts +24 -0
  58. package/types/projectValidator/types.d.ts +26 -0
  59. package/types/storage/index.d.ts +6 -0
  60. package/types/utils/index.d.ts +13 -0
@@ -0,0 +1,454 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TCR_LOGIN_PUSH_SCRIPT = exports.TCR_PUSH_SCRIPT_ZIP_PATH = void 0;
4
+ exports.buildImageOnCloud = buildImageOnCloud;
5
+ const error_1 = require("../../../error");
6
+ const types_1 = require("../../types");
7
+ /**
8
+ * 云端镜像构建(cloud 策略)
9
+ *
10
+ * 对应构建方案(https://iwiki.woa.com/p/4020519533)的「阶段 A:构建镜像(CloudApp custom)」:
11
+ * 源码打包 ZIP → DescribeCloudAppCosInfo拿上传凭证 → PUT 上传 COS
12
+ * → CreateCloudApp(DeployType:custom) 触发容器内 docker build + push TCR
13
+ * → 退避轮询 DescribeCloudAppVersion 到终态 → 从 Artifacts 或拼接得到 imageUri/digest。
14
+ *
15
+ * 产物只返回镜像引用(imageUri/digest/buildId/logsUrl),由编排器交回统一
16
+ * SCF 部署链,本模块不创建/更新任何函数(对应实施规划:builder 只获得 image artifact)。
17
+ *
18
+ * 安全(对应 security_rules):
19
+ * - CustomSteps 由 SDK 固定模板生成;用户可控值(如 tag、registry、namespace)先经
20
+ * 白名单校验,绝不作为任意 shell 片段拼接进 command。
21
+ * - 企业版 TCR 在构建容器内使用 STS 临时凭证换取登录 token;个人版固定密码先做
22
+ * Base64 封装,再通过 CloudApp Secrets 注入为 $SECRET_TCR_PASSWORD_B64,构建容器内
23
+ * 解码后直接送入 docker login stdin,不进入普通 Env、命令行参数或日志。
24
+ * - COS 上传必须携带服务端返回的全部 UploadHeaders。
25
+ * - tag 使用 $CLOUDBASE_VERSION_NAME 或内容摘要,绝不使用 latest。
26
+ *
27
+ * 所有外部依赖(打包、tcb API、COS 上传)以接口注入,默认由编排器提供真实实现,
28
+ * 便于测试替换(不依赖 jest.mock)。
29
+ */
30
+ /** custom 构建的固定 DeployType */
31
+ const CUSTOM_DEPLOY_TYPE = 'custom';
32
+ /** custom 构建的源码类型 */
33
+ const CUSTOM_BUILD_TYPE = 'zip';
34
+ /** 轮询默认参数:初始 5s、退避到 30s、总超时 15 分钟 */
35
+ const POLL_INITIAL_INTERVAL = 5000;
36
+ const POLL_MAX_INTERVAL = 30000;
37
+ const POLL_TOTAL_TIMEOUT = 15 * 60 * 1000;
38
+ /** 轮询临时错误最大连续重试次数,超过则判失败 */
39
+ const POLL_MAX_TRANSIENT_ERRORS = 5;
40
+ /** 仓库地址/命名空间/服务名等安全字符白名单:域名、路径、字母数字与常见分隔符 */
41
+ const SAFE_REGISTRY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/;
42
+ /** tag 白名单:字母数字、下划线、点、中划线 */
43
+ const SAFE_TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
44
+ /** 校验仓库/命名空间等镜像地址片段,拒绝命令注入风险字符 */
45
+ function assertSafeRegistryToken(value, field) {
46
+ if (!SAFE_REGISTRY_PATTERN.test(value)) {
47
+ throw new error_1.CloudBaseError(`${field} 含非法字符,可能导致命令注入:${value}`, {
48
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_REPOSITORY_INVALID
49
+ });
50
+ }
51
+ }
52
+ /** 校验 tag,拒绝 latest 与非法字符 */
53
+ function assertSafeTag(tag) {
54
+ if (!SAFE_TAG_PATTERN.test(tag)) {
55
+ throw new error_1.CloudBaseError(`镜像 tag 含非法字符:${tag}`, {
56
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_TAG_INVALID
57
+ });
58
+ }
59
+ if (tag.toLowerCase() === 'latest') {
60
+ throw new error_1.CloudBaseError('镜像 tag 禁止使用 latest,请使用不可变 tag', {
61
+ code: types_1.FUNCTION_DEPLOY_ERROR.IMAGE_TAG_LATEST_FORBIDDEN
62
+ });
63
+ }
64
+ }
65
+ /** 个人版 CCR 默认域名 */
66
+ const DEFAULT_PERSONAL_REGISTRY = 'ccr.ccs.tencentyun.com';
67
+ /** 注入到构建上下文 zip 中的推送脚本相对路径(与 CustomSteps 的 push命令一致) */
68
+ exports.TCR_PUSH_SCRIPT_ZIP_PATH = 'scripts/tcr-login-and-push.sh';
69
+ /**
70
+ * 注入到构建 zip 的 TCR/CCR 登录推送脚本(完整可运行)
71
+ *
72
+ * 对应构建方案(https://iwiki.woa.com/p/4020519533)「阶段 A」的 push 步骤:
73
+ * push-image 步骤执行 `bash./scripts/tcr-login-and-push.sh`,该脚本必须随源码
74
+ * 一同打进zip 根目录,否则云端会 `No such file or directory` 秒挂。
75
+ *
76
+ * 关键设计:
77
+ * - 地域参数化:REGION 读环境变量 $TCR_REGION(由 buildEnv 按部署地域注入),
78
+ * 避免硬编码 ap-shanghai跨地域时 AuthFailure.SignatureFailure。
79
+ * - 双仓库兼容:设置了 $TCR_INSTANCE_ID 走企业版 TCR CreateInstanceToken;
80
+ * 否则走个人版 CCR,用户名来自普通 Env,固定密码来自 CloudApp Secrets。
81
+ * - 安全红线:凭证一律经 --password-stdin 传入,绝不出现在 ps/日志;
82
+ * 不打印任何 $API_SECRET_* / token / $SECRET_TCR_PASSWORD_B64 或解码后的密码。
83
+ *
84
+ * 脚本内容为SDK 固定模板,不拼接任何用户输入(镜像地址等均由已校验的
85
+ * 环境变量在容器内组装),无命令注入面。
86
+ */
87
+ exports.TCR_LOGIN_PUSH_SCRIPT = `#!/usr/bin/env bash
88
+ # scripts/tcr-login-and-push.sh
89
+ # 由 CloudBase SDK 自动注入:企业版使用 STS 换取临时 token,个人版使用 CloudApp Secret
90
+ set -euo pipefail
91
+
92
+ : "\${TCR_REGISTRY:?TCR_REGISTRY 未设置}"
93
+ : "\${TCR_NAMESPACE:?TCR_NAMESPACE 未设置}"
94
+ : "\${CLOUDBASE_SERVICE_NAME:?平台变量缺失}"
95
+ : "\${CLOUDBASE_VERSION_NAME:?平台变量缺失}"
96
+
97
+ REGION="\${TCR_REGION:-ap-shanghai}"
98
+ IMAGE="\${TCR_REGISTRY}/\${TCR_NAMESPACE}/\${CLOUDBASE_SERVICE_NAME}:\${CLOUDBASE_VERSION_NAME}"
99
+
100
+ if [ -n "\${TCR_INSTANCE_ID:-}" ]; then
101
+ : "\${API_SECRET_ID:?STS 凭证缺失}"
102
+ : "\${API_SECRET_KEY:?STS 凭证缺失}"
103
+ : "\${API_TOKEN:?STS 凭证缺失}"
104
+
105
+ # ---- 企业版 TCR:CreateInstanceToken 换登录态 ----
106
+ HOST="tcr.tencentcloudapi.com"
107
+ SERVICE="tcr"
108
+ VERSION="2019-09-24"
109
+ ACTION="CreateInstanceToken"
110
+ ALGORITHM="TC3-HMAC-SHA256"
111
+ TIMESTAMP=$(date +%s)
112
+ DATE=$(date -u -d "@\${TIMESTAMP}" +"%Y-%m-%d" 2>/dev/null || date -u -r "\${TIMESTAMP}" +"%Y-%m-%d")
113
+
114
+ PAYLOAD=$(printf '{"RegistryId":"%s","TokenType":"LongTermToken"}' "\${TCR_INSTANCE_ID}")
115
+ HASHED_PAYLOAD=$(printf '%s' "\${PAYLOAD}" | openssl dgst -sha256 -hex | awk '{print $NF}')
116
+ CANONICAL_HEADERS="content-type:application/json; charset=utf-8\\nhost:\${HOST}\\nx-tc-action:$(echo "\${ACTION}" | tr '[:upper:]' '[:lower:]')\\n"
117
+ SIGNED_HEADERS="content-type;host;x-tc-action"
118
+ CANONICAL_REQUEST="POST\\n/\\n\\n\${CANONICAL_HEADERS}\\n\${SIGNED_HEADERS}\\n\${HASHED_PAYLOAD}"
119
+
120
+ CREDENTIAL_SCOPE="\${DATE}/\${SERVICE}/tc3_request"
121
+ HASHED_CR=$(printf "\${CANONICAL_REQUEST}" | openssl dgst -sha256 -hex | awk '{print $NF}')
122
+ STRING_TO_SIGN="\${ALGORITHM}\\n\${TIMESTAMP}\\n\${CREDENTIAL_SCOPE}\\n\${HASHED_CR}"
123
+
124
+ secret_date=$(printf '%s' "\${DATE}" | openssl dgst -sha256 -hmac "TC3\${API_SECRET_KEY}" -hex | awk '{print $NF}')
125
+ secret_svc=$(printf '%s' "\${SERVICE}" | openssl dgst -sha256 -mac HMAC -macopt hexkey:"\${secret_date}" -hex | awk '{print $NF}')
126
+ secret_sign=$(printf '%s' "tc3_request" | openssl dgst -sha256 -mac HMAC -macopt hexkey:"\${secret_svc}" -hex | awk '{print $NF}')
127
+ SIGNATURE=$(printf "\${STRING_TO_SIGN}" | openssl dgst -sha256 -mac HMAC -macopt hexkey:"\${secret_sign}" -hex | awk '{print $NF}')
128
+
129
+ AUTHZ="\${ALGORITHM} Credential=\${API_SECRET_ID}/\${CREDENTIAL_SCOPE}, SignedHeaders=\${SIGNED_HEADERS}, Signature=\${SIGNATURE}"
130
+
131
+ RESP=$(curl -sS -X POST "https://\${HOST}" \\
132
+ -H "Authorization: \${AUTHZ}" \\
133
+ -H "Content-Type: application/json; charset=utf-8" \\
134
+ -H "Host: \${HOST}" \\
135
+ -H "X-TC-Action: \${ACTION}" \\
136
+ -H "X-TC-Timestamp: \${TIMESTAMP}" \\
137
+ -H "X-TC-Version: \${VERSION}" \\
138
+ -H "X-TC-Region: \${REGION}" \\
139
+ -H "X-TC-Token: \${API_TOKEN}" \\
140
+ --data "\${PAYLOAD}")
141
+
142
+ TOKEN_USER=$(printf '%s' "\${RESP}" | jq -er '.Response.Username')
143
+ TOKEN_PASS=$(printf '%s' "\${RESP}" | jq -er '.Response.Token')
144
+
145
+ printf '%s' "\${TOKEN_PASS}" | docker login -u "\${TOKEN_USER}" --password-stdin "\${TCR_REGISTRY}"
146
+ else
147
+ # ---- 个人版 CCR:账号 ID + 固定密码(Base64 封装后由 CloudApp Secrets 注入) ----
148
+ : "\${TCR_USERNAME:?TCR_USERNAME 未设置}"
149
+ : "\${SECRET_TCR_PASSWORD_B64:?TCR_PASSWORD 未设置}"
150
+ printf '%s' "\${SECRET_TCR_PASSWORD_B64}" | base64 --decode | docker login -u "\${TCR_USERNAME}" --password-stdin "\${TCR_REGISTRY}"
151
+ fi
152
+
153
+ docker push "\${IMAGE}"
154
+
155
+ echo "::output::tag=\${CLOUDBASE_VERSION_NAME}"
156
+ `;
157
+ function resolveTarget(config) {
158
+ const build = config.build;
159
+ const repository = (build.repository || config.name).trim();
160
+ const registryId = build.registryId;
161
+ const isEnterprise = config.imageType === 'enterprise' || Boolean(registryId);
162
+ // CloudApp custom 构建与推送必须使用同一个平台版本 tag。
163
+ // 若允许自定义 tag,build-image 会构建自定义 tag,而固定推送脚本会推
164
+ // $CLOUDBASE_VERSION_NAME,最终必然找不到本地镜像。
165
+ if (build.tag) {
166
+ assertSafeTag(build.tag);
167
+ throw new error_1.CloudBaseError('cloud 策略不支持自定义 build.tag,请移除该字段并使用平台生成的 CLOUDBASE_VERSION_NAME', { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_TAG_INVALID });
168
+ }
169
+ // repository 允许两种形态:
170
+ // 1) namespace/image 或完整 registry/namespace/image:直接拆分
171
+ // 2) 仅镜像名:使用 cloudbaserc 中显式声明并经 {{env.*}} 解析后的 build.namespace
172
+ let registry = DEFAULT_PERSONAL_REGISTRY;
173
+ let hasExplicitRegistry = false;
174
+ let namespace = (build.namespace || '').trim();
175
+ let imageName = repository;
176
+ if (!isEnterprise && !namespace) {
177
+ throw new error_1.CloudBaseError('个人版 TCR 云端构建缺少 build.namespace;请在 cloudbaserc 中显式声明,' +
178
+ '推荐使用 "{{env.TCB_TCR_NAMESPACE}}"', { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_REPOSITORY_INVALID });
179
+ }
180
+ if (repository.includes('/')) {
181
+ assertSafeRegistryToken(repository, 'build.repository');
182
+ const parts = repository.split('/').filter(Boolean);
183
+ // 形如 domain/ns/name或 ns/name
184
+ let repositoryNamespace;
185
+ if (parts.length >= 3 && parts[0].includes('.')) {
186
+ registry = parts[0];
187
+ hasExplicitRegistry = true;
188
+ repositoryNamespace = parts.slice(1, -1).join('/');
189
+ }
190
+ else {
191
+ repositoryNamespace = parts.slice(0, -1).join('/');
192
+ }
193
+ if (namespace && namespace !== repositoryNamespace) {
194
+ throw new error_1.CloudBaseError('build.namespace 与 build.repository 中的命名空间不一致', { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_REPOSITORY_INVALID });
195
+ }
196
+ namespace = namespace || repositoryNamespace;
197
+ imageName = parts[parts.length - 1];
198
+ }
199
+ else {
200
+ assertSafeRegistryToken(repository, 'build.repository');
201
+ }
202
+ assertSafeRegistryToken(registry, 'TCR registry');
203
+ if (!namespace) {
204
+ throw new error_1.CloudBaseError('cloud 策略缺少 TCR 命名空间,请配置 build.namespace 或在完整 repository 中声明', { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_REPOSITORY_INVALID });
205
+ }
206
+ assertSafeRegistryToken(namespace, 'TCR namespace');
207
+ if (config.imageType === 'personal' && registryId) {
208
+ throw new error_1.CloudBaseError('cloud 策略的 personal 镜像不能配置 build.registryId;请移除该字段或改用 enterprise', { code: types_1.FUNCTION_DEPLOY_ERROR.IMAGE_TYPE_INVALID });
209
+ }
210
+ if (config.imageType === 'enterprise' && !registryId) {
211
+ throw new error_1.CloudBaseError('cloud 策略的企业版 TCR 缺少 registryId,请配置 build.registryId', { code: types_1.FUNCTION_DEPLOY_ERROR.IMAGE_REGISTRY_ID_MISSING });
212
+ }
213
+ if (isEnterprise && !hasExplicitRegistry) {
214
+ throw new error_1.CloudBaseError('cloud 策略的企业版 TCR 需要明确仓库域名:请将 build.repository 配置为 ' +
215
+ 'registry/namespace/repository;registryId 无法用于推导仓库域名', { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_REPOSITORY_INVALID });
216
+ }
217
+ return {
218
+ registry,
219
+ namespace,
220
+ registryId,
221
+ serviceName: imageName
222
+ };
223
+ }
224
+ /**
225
+ * 生成固定的 CustomSteps 模板(build + push)
226
+ *
227
+ * 关键安全点:command 完全由 SDK 生成,用户输入只以构建环境变量方式注入
228
+ * ($TCR_REGISTRY / $TCR_NAMESPACE / $CLOUDBASE_SERVICE_NAME / $CLOUDBASE_VERSION_NAME),
229
+ * 这些变量的值已在 resolveTarget 中做白名单校验,故命令本身无注入面。
230
+ */
231
+ function buildCustomSteps() {
232
+ return [
233
+ {
234
+ name: 'build-image',
235
+ command: 'docker build -t ' +
236
+ '$TCR_REGISTRY/$TCR_NAMESPACE/$CLOUDBASE_SERVICE_NAME:$CLOUDBASE_VERSION_NAME .'
237
+ },
238
+ {
239
+ name: 'push-image',
240
+ command: 'bash ./scripts/tcr-login-and-push.sh'
241
+ }
242
+ ];
243
+ }
244
+ /**
245
+ * 组装 custom 构建的环境变量(用户可控值已校验,此处只做键值透传)
246
+ *
247
+ * - TCR_REGION:部署地域,供推送脚本换取镜像仓库登录态时签名/请求用,
248
+ * 缺省交由脚本回退默认地域;参数化后跨地域不会 SignatureFailure。
249
+ * - TCR_INSTANCE_ID:仅企业版 TCR 注入,脚本据此走 CreateInstanceToken 分支;
250
+ * 个人版 CCR 不注入,脚本走 STS 直接 docker login 分支。
251
+ */
252
+ function buildEnv(target, region) {
253
+ const env = [
254
+ { key: 'TCR_REGISTRY', value: target.registry },
255
+ { key: 'TCR_NAMESPACE', value: target.namespace }
256
+ ];
257
+ if (region) {
258
+ env.push({ key: 'TCR_REGION', value: region });
259
+ }
260
+ if (target.registryId) {
261
+ assertSafeRegistryToken(target.registryId, 'registryId');
262
+ env.push({ key: 'TCR_INSTANCE_ID', value: target.registryId });
263
+ }
264
+ return env;
265
+ }
266
+ /**
267
+ * 组装个人版 TCR 凭证。
268
+ * username 可作为普通构建变量;password 必须进入 CloudApp Secrets。
269
+ */
270
+ function buildRegistryCredentials(config, target) {
271
+ var _a;
272
+ if (target.registryId) {
273
+ return { env: [] };
274
+ }
275
+ const credential = config.build.registryCredential;
276
+ if (!credential) {
277
+ throw new error_1.CloudBaseError('个人版 TCR 云端构建缺少 build.registryCredential;请在 cloudbaserc 中显式声明 ' +
278
+ 'username/password,推荐分别使用 "{{env.TCB_TCR_USERNAME}}" 和 ' +
279
+ '"{{env.TCB_TCR_PASSWORD}}"', { code: types_1.FUNCTION_DEPLOY_ERROR.CLOUD_REGISTRY_CREDENTIAL_MISSING });
280
+ }
281
+ const username = (_a = credential.username) === null || _a === void 0 ? void 0 : _a.trim();
282
+ const password = credential.password;
283
+ if (typeof username !== 'string' ||
284
+ !/^\d{5,20}$/.test(username) ||
285
+ typeof password !== 'string' ||
286
+ !password ||
287
+ password.length > 16 * 1024) {
288
+ throw new error_1.CloudBaseError('个人版 TCR 凭证不合法:TCB_TCR_USERNAME 需为腾讯云账号 ID,' +
289
+ 'TCB_TCR_PASSWORD 不能为空且长度不能超过 16KB', { code: types_1.FUNCTION_DEPLOY_ERROR.CLOUD_REGISTRY_CREDENTIAL_INVALID });
290
+ }
291
+ // CloudApp Secret 对原始特殊字符的处理需要与本地 dotenv 解耦验证,因此这里先做
292
+ // Base64 封装;构建容器内仅在管道中解码并直接送入 docker login stdin。
293
+ // Base64 不是加密,安全边界仍由 CloudApp Secrets 提供,编码值不得进入普通 Env 或日志。
294
+ const passwordBase64 = Buffer.from(password, 'utf8').toString('base64');
295
+ return {
296
+ env: [{ key: 'TCR_USERNAME', value: username }],
297
+ secrets: [{ name: 'TCR_PASSWORD_B64', value: passwordBase64 }]
298
+ };
299
+ }
300
+ /** 从构建产物或拼接规则得到最终不可变镜像地址 */
301
+ function resolveImageUri(target, versionName, artifacts) {
302
+ // 优先从 Artifacts 取平台回传的镜像信息
303
+ const imageArtifact = (artifacts || []).find(item => (item === null || item === void 0 ? void 0 : item.Type) === 'image');
304
+ if (imageArtifact === null || imageArtifact === void 0 ? void 0 : imageArtifact.ImageUri) {
305
+ return { imageUri: imageArtifact.ImageUri, imageDigest: imageArtifact.Digest };
306
+ }
307
+ // 无Artifacts 时按约定拼接:{registry}/{namespace}/{name}:{tag}
308
+ const imageUri = `${target.registry}/${target.namespace}/${target.serviceName}:${versionName}`;
309
+ return { imageUri, imageDigest: imageArtifact === null || imageArtifact === void 0 ? void 0 : imageArtifact.Digest };
310
+ }
311
+ /** 汇总失败 Step 的定位信息(脱敏:只取步骤名/状态/退出码,不含日志明文) */
312
+ function summarizeFailedSteps(steps) {
313
+ const failed = (steps || []).filter(step => typeof (step === null || step === void 0 ? void 0 : step.Status) === 'string' && step.Status.toLowerCase() === 'failed');
314
+ if (failed.length === 0) {
315
+ return '';
316
+ }
317
+ return failed
318
+ .map(step => {
319
+ const code = step.ExitCode !== undefined ? ` (exit ${step.ExitCode})` : '';
320
+ return `${step.Name}${code}`;
321
+ })
322
+ .join(', ');
323
+ }
324
+ /**
325
+ * 执行云端构建并推送镜像
326
+ *
327
+ * @param config 规范化的 cloud 部署配置
328
+ * @param service 注入的外部能力(打包/tcb API/COS 上传)
329
+ * @param options 可选项:日志回调 onLog、部署地域 region
330
+ */
331
+ async function buildImageOnCloud(config, service, options = {}) {
332
+ const { onLog, region } = options;
333
+ const target = resolveTarget(config);
334
+ // 在打包和上传前完成真实消费字段校验,避免额外维护环境变量名清单。
335
+ const registryCredentials = buildRegistryCredentials(config, target);
336
+ const serviceName = target.serviceName;
337
+ const cwd = config.build.cwd || config.functionPath || process.cwd();
338
+ // 1. 打包构建上下文(注入 push 脚本,不污染用户源码目录)
339
+ onLog === null || onLog === void 0 ? void 0 : onLog('打包构建上下文');
340
+ const zipPath = await service.packContext(cwd, [
341
+ {
342
+ zipPath: exports.TCR_PUSH_SCRIPT_ZIP_PATH,
343
+ content: exports.TCR_LOGIN_PUSH_SCRIPT,
344
+ mode: 0o755
345
+ }
346
+ ]);
347
+ try {
348
+ // 2. 获取 COS 上传凭证
349
+ onLog === null || onLog === void 0 ? void 0 : onLog('获取 COS 上传凭证');
350
+ const cosInfo = await service.getCosInfo(serviceName);
351
+ if (!(cosInfo === null || cosInfo === void 0 ? void 0 : cosInfo.uploadUrl) || !(cosInfo === null || cosInfo === void 0 ? void 0 : cosInfo.unixTimestamp)) {
352
+ throw new error_1.CloudBaseError('获取 COS 上传凭证失败:返回缺少 UploadUrl 或 UnixTimestamp', {
353
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_CONTEXT_INVALID
354
+ });
355
+ }
356
+ // 3. 上传源码 ZIP(携带全部 UploadHeaders)
357
+ onLog === null || onLog === void 0 ? void 0 : onLog('上传源码到 COS');
358
+ await service.uploadZip(cosInfo, zipPath);
359
+ // 4. 触发 custom 构建
360
+ onLog === null || onLog === void 0 ? void 0 : onLog('触发云端构建');
361
+ const created = await service.createBuild({
362
+ serviceName,
363
+ cosTimestamp: cosInfo.unixTimestamp,
364
+ env: [...buildEnv(target, region), ...registryCredentials.env],
365
+ secrets: registryCredentials.secrets,
366
+ customSteps: buildCustomSteps()
367
+ });
368
+ if (!(created === null || created === void 0 ? void 0 : created.versionName)) {
369
+ throw new error_1.CloudBaseError('触发云端构建失败:返回缺少 VersionName', {
370
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_CONTEXT_INVALID
371
+ });
372
+ }
373
+ // 打出触发构建的 RequestId 与版本号,便于把构建问题反馈给平台后端定位
374
+ onLog === null || onLog === void 0 ? void 0 : onLog(`已触发云端构建 versionName=${created.versionName}` +
375
+ (created.requestId ? ` requestId=${created.requestId}` : ''));
376
+ // 5. 退避轮询到终态
377
+ const finalStatus = await pollUntilTerminal({
378
+ service,
379
+ serviceName,
380
+ versionName: created.versionName,
381
+ onLog
382
+ });
383
+ if (finalStatus.status === 'FAILED' || finalStatus.status === 'CANCELED') {
384
+ const failedInfo = summarizeFailedSteps(finalStatus.steps);
385
+ const detail = failedInfo ? `,失败步骤:${failedInfo}` : '';
386
+ const reqId = finalStatus.requestId
387
+ ? `,requestId=${finalStatus.requestId}`
388
+ : '';
389
+ const code = finalStatus.status === 'CANCELED'
390
+ ? types_1.FUNCTION_DEPLOY_ERROR.CLOUD_BUILD_CANCELED
391
+ : types_1.FUNCTION_DEPLOY_ERROR.CLOUD_BUILD_FAILED;
392
+ throw new error_1.CloudBaseError(`云端构建未成功(${finalStatus.status})${detail}${reqId}`, { code });
393
+ }
394
+ // 6. 得到不可变镜像地址与digest
395
+ const { imageUri, imageDigest } = resolveImageUri(target, created.versionName, finalStatus.artifacts);
396
+ return {
397
+ imageUri,
398
+ imageDigest,
399
+ buildId: created.buildId,
400
+ versionName: created.versionName
401
+ };
402
+ }
403
+ finally {
404
+ // 清理临时 zip,失败不影响主流程
405
+ try {
406
+ await service.cleanup(zipPath);
407
+ }
408
+ catch (_a) {
409
+ // 忽略清理异常
410
+ }
411
+ }
412
+ }
413
+ /**
414
+ * 退避轮询构建版本状态直到终态
415
+ *
416
+ * - 间隔从 5s 退避到 30s,总超时 15 分钟。
417
+ * - 临时网络错误连续重试,超过阈值才判失败,避免抖动误杀长构建。
418
+ */
419
+ async function pollUntilTerminal(params) {
420
+ const { service, serviceName, versionName, onLog } = params;
421
+ const start = Date.now();
422
+ let interval = POLL_INITIAL_INTERVAL;
423
+ let transientErrors = 0;
424
+ // 先等一个间隔再查,构建刚触发通常还没状态
425
+ while (Date.now() - start < POLL_TOTAL_TIMEOUT) {
426
+ await service.delay(interval);
427
+ let status;
428
+ try {
429
+ status = await service.getVersionStatus(serviceName, versionName);
430
+ transientErrors = 0;
431
+ }
432
+ catch (error) {
433
+ transientErrors += 1;
434
+ if (transientErrors > POLL_MAX_TRANSIENT_ERRORS) {
435
+ throw new error_1.CloudBaseError(`查询构建状态连续失败 ${transientErrors} 次:${(error === null || error === void 0 ? void 0 : error.message) || error}`, { code: types_1.FUNCTION_DEPLOY_ERROR.CLOUD_BUILD_FAILED });
436
+ }
437
+ // 临时错误,继续退避重试
438
+ interval = Math.min(interval * 2, POLL_MAX_INTERVAL);
439
+ continue;
440
+ }
441
+ const normalized = (status.status || '').toUpperCase();
442
+ onLog === null || onLog === void 0 ? void 0 : onLog(`构建状态:${normalized}`);
443
+ if (normalized === 'SUCCESS' || normalized === 'FAILED' || normalized === 'CANCELED') {
444
+ // 终态时打出查询接口 RequestId,便于把该次构建版本反馈给平台后端定位
445
+ if (status.requestId) {
446
+ onLog === null || onLog === void 0 ? void 0 : onLog(`构建版本查询 requestId=${status.requestId}`);
447
+ }
448
+ return Object.assign(Object.assign({}, status), { status: normalized });
449
+ }
450
+ // 仍在构建,退避
451
+ interval = Math.min(interval * 2, POLL_MAX_INTERVAL);
452
+ }
453
+ throw new error_1.CloudBaseError(`云端构建超时(超过 ${Math.round(POLL_TOTAL_TIMEOUT / 60000)} 分钟仍未完成)`, { code: types_1.FUNCTION_DEPLOY_ERROR.CLOUD_BUILD_TIMEOUT });
454
+ }
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IMAGE_PULL_GRANT_URL = exports.REQUIRED_IMAGE_PULL_POLICY = exports.SCF_PULL_IMAGE_ROLE = void 0;
4
+ exports.isCamPermissionError = isCamPermissionError;
5
+ exports.runImagePullAuthPreflight = runImagePullAuthPreflight;
6
+ const types_1 = require("../types");
7
+ /**
8
+ * 镜像拉取授权 Preflight(涉及 CAM,scope: cloud)
9
+ *
10
+ * SCF 拉取镜像由服务角色 SCF_QcsRole 代理,个人版镜像要求该角色绑定
11
+ * QcloudAccessForSCFRoleInPullImage 策略。本模块在 image 策略部署前:
12
+ * 1. 探测该角色与策略是否就绪;
13
+ * 2. 缺失且允许时,自动绑定「唯一白名单策略」补齐(绝不放大到其它策略);
14
+ * 3. 当前身份无 CAM 权限时不硬失败,降级为结构化指引 + 一键授权链接兜底。
15
+ *
16
+ * 安全边界(对应 security_rules 的 AuthZ / 最小权限):
17
+ * - 自动授权只允许绑定 REQUIRED_IMAGE_PULL_POLICY 这一条部署必需的预置策略;
18
+ * - 不代用户创建角色、不绑定任何白名单外策略;
19
+ * - 探测/授权失败一律走降级,不阻断到无法给出可操作指引。
20
+ */
21
+ /** SCF 拉取镜像使用的服务角色 */
22
+ exports.SCF_PULL_IMAGE_ROLE = 'SCF_QcsRole';
23
+ /** 拉取个人版镜像所需的唯一预置策略(自动授权白名单,仅此一条) */
24
+ exports.REQUIRED_IMAGE_PULL_POLICY = 'QcloudAccessForSCFRoleInPullImage';
25
+ /** 官方一键授权链接,作为无 CAM 权限时的兜底指引 */
26
+ exports.IMAGE_PULL_GRANT_URL = 'https://console.cloud.tencent.com/cam/role/grant' +
27
+ `?roleName=${exports.SCF_PULL_IMAGE_ROLE}&policyName=${exports.REQUIRED_IMAGE_PULL_POLICY}` +
28
+ '&principal=eyJzZXJ2aWNlIjoic2NmLnFjbG91ZC5jb20ifQ%3D%3D';
29
+ /** 判断错误是否为 CAM 权限不足 */
30
+ function isCamPermissionError(error) {
31
+ const code = typeof (error === null || error === void 0 ? void 0 : error.code) === 'string' ? error.code : '';
32
+ const message = typeof (error === null || error === void 0 ? void 0 : error.message) === 'string' ? error.message : '';
33
+ const haystack = `${code} ${message}`.toLowerCase();
34
+ return (haystack.includes('unauthorizedoperation') ||
35
+ haystack.includes('no permission') ||
36
+ haystack.includes('not authorized') ||
37
+ haystack.includes('cam_unauthorized'));
38
+ }
39
+ /** 判断错误是否为角色不存在 */
40
+ function isRoleNotFoundError(error) {
41
+ const code = typeof (error === null || error === void 0 ? void 0 : error.code) === 'string' ? error.code : '';
42
+ const message = typeof (error === null || error === void 0 ? void 0 : error.message) === 'string' ? error.message : '';
43
+ const haystack = `${code} ${message}`.toLowerCase();
44
+ return haystack.includes('notfound') || haystack.includes('not exist') || haystack.includes('rolenotexist');
45
+ }
46
+ function pass(id, summary) {
47
+ return { id, status: 'pass', summary };
48
+ }
49
+ function warn(id, summary, remediation) {
50
+ return { id, status: 'warn', summary, remediation };
51
+ }
52
+ function fail(id, summary, options = {}) {
53
+ return { id, status: 'fail', summary, remediation: options.remediation, detail: options.detail };
54
+ }
55
+ function buildReport(checks) {
56
+ return {
57
+ scope: 'cloud',
58
+ strategy: 'image',
59
+ ready: !checks.some(item => item.status === 'fail'),
60
+ checks
61
+ };
62
+ }
63
+ /** 是否需要执行镜像拉取授权检查 */
64
+ function needsImagePullAuth(config) {
65
+ var _a;
66
+ if (config.type !== 'HTTP') {
67
+ return false;
68
+ }
69
+ // image:已有个人版镜像;local:本地构建推送的也是个人版镜像,SCF 拉取同样需要该授权
70
+ if (config.buildStrategy === 'image') {
71
+ // 联合类型已按 buildStrategy 收窄为 INormalizedHttpImageConfig,可直接访问顶层与 imageConfig 字段
72
+ const imageType = ((_a = config.imageConfig) === null || _a === void 0 ? void 0 : _a.imageType) || config.imageType;
73
+ return imageType === 'personal';
74
+ }
75
+ if (config.buildStrategy === 'local' || config.buildStrategy === 'cloud') {
76
+ // local/cloud 个人版先行:构建推送的默认是个人版镜像,SCF 拉取同样需要该授权;
77
+ // 未显式指定 imageType 时视为 personal;企业版另行处理
78
+ const imageType = config.imageType;
79
+ return imageType === undefined || imageType === 'personal';
80
+ }
81
+ return false;
82
+ }
83
+ /** 无 CAM 读权限时的降级检查项:无法自查,转手动指引 */
84
+ function unverifiableCheck() {
85
+ return warn(types_1.FUNCTION_DEPLOY_ERROR.PREFLIGHT_IMAGE_PULL_AUTH_UNVERIFIABLE, `当前身份无 CAM 权限,无法自动校验或补齐 ${exports.SCF_PULL_IMAGE_ROLE} 的拉镜像授权`, `若个人版镜像拉取失败,请用具备 CAM 权限的账号访问一键授权链接完成授权:${exports.IMAGE_PULL_GRANT_URL}`);
86
+ }
87
+ /**
88
+ * 执行镜像拉取授权 Preflight
89
+ *
90
+ * @param service CAM 能力适配(无则视为无法校验,直接降级为提示)
91
+ * @param config 规范化部署配置
92
+ * @param autoGrant 是否允许自动补齐授权(默认 true)
93
+ * @returns scope 为 cloud 的检查报告,聚合全部结论
94
+ */
95
+ async function runImagePullAuthPreflight(service, config, autoGrant = true) {
96
+ if (!needsImagePullAuth(config)) {
97
+ return buildReport([]);
98
+ }
99
+ // 无 CAM 适配能力:不阻断,仅提示手动授权入口
100
+ if (!service) {
101
+ return buildReport([unverifiableCheck()]);
102
+ }
103
+ const checks = [];
104
+ let roleDetail;
105
+ try {
106
+ roleDetail = await service.getRoleWithPolicies(exports.SCF_PULL_IMAGE_ROLE);
107
+ }
108
+ catch (error) {
109
+ if (isCamPermissionError(error)) {
110
+ // 无 CAM 读权限,无法自查,降级为提示(warn,不阻断部署尝试)
111
+ return buildReport([unverifiableCheck()]);
112
+ }
113
+ if (isRoleNotFoundError(error)) {
114
+ // 角色不存在属于真实前置缺失,不自动创建角色,给出可操作指引
115
+ checks.push(fail(types_1.FUNCTION_DEPLOY_ERROR.PREFLIGHT_IMAGE_PULL_ROLE_MISSING, `缺少服务角色 ${exports.SCF_PULL_IMAGE_ROLE}`, {
116
+ remediation: `请访问一键授权链接创建角色并授予拉镜像策略:${exports.IMAGE_PULL_GRANT_URL}`
117
+ }));
118
+ return buildReport(checks);
119
+ }
120
+ // 其它未知错误同样降级为提示,避免因 CAM 波动阻断部署
121
+ return buildReport([unverifiableCheck()]);
122
+ }
123
+ const attached = roleDetail.attachedPolicyNames || [];
124
+ if (attached.includes(exports.REQUIRED_IMAGE_PULL_POLICY)) {
125
+ checks.push(pass(types_1.FUNCTION_DEPLOY_ERROR.PREFLIGHT_IMAGE_PULL_POLICY_MISSING, `${exports.SCF_PULL_IMAGE_ROLE} 已具备拉镜像策略 ${exports.REQUIRED_IMAGE_PULL_POLICY}`));
126
+ return buildReport(checks);
127
+ }
128
+ // 未绑定拉镜像策略
129
+ if (!autoGrant) {
130
+ checks.push(fail(types_1.FUNCTION_DEPLOY_ERROR.PREFLIGHT_IMAGE_PULL_POLICY_MISSING, `${exports.SCF_PULL_IMAGE_ROLE} 缺少拉镜像策略 ${exports.REQUIRED_IMAGE_PULL_POLICY}`, {
131
+ remediation: `已关闭自动授权(autoGrant=false)。请访问一键授权链接手动授予:${exports.IMAGE_PULL_GRANT_URL}`
132
+ }));
133
+ return buildReport(checks);
134
+ }
135
+ // 自动授权:只绑定唯一白名单策略
136
+ try {
137
+ await service.attachPolicyByName(exports.SCF_PULL_IMAGE_ROLE, exports.REQUIRED_IMAGE_PULL_POLICY);
138
+ checks.push(pass(types_1.FUNCTION_DEPLOY_ERROR.PREFLIGHT_IMAGE_PULL_AUTH_GRANTED, `已自动为 ${exports.SCF_PULL_IMAGE_ROLE} 绑定拉镜像策略 ${exports.REQUIRED_IMAGE_PULL_POLICY}`));
139
+ return buildReport(checks);
140
+ }
141
+ catch (error) {
142
+ if (isCamPermissionError(error)) {
143
+ // 无 CAM 写权限:不硬失败,降级为可操作指引 + 兜底链接
144
+ checks.push(fail(types_1.FUNCTION_DEPLOY_ERROR.PREFLIGHT_CAM_PERMISSION_DENIED, `当前身份无 CAM 授权权限,无法自动为 ${exports.SCF_PULL_IMAGE_ROLE} 补齐拉镜像策略`, {
145
+ remediation: `请用具备 CAM 权限的账号(如主账号)访问一键授权链接完成授权:${exports.IMAGE_PULL_GRANT_URL}`,
146
+ detail: error === null || error === void 0 ? void 0 : error.message
147
+ }));
148
+ return buildReport(checks);
149
+ }
150
+ // 其它授权错误同样给出结构化失败与指引
151
+ checks.push(fail(types_1.FUNCTION_DEPLOY_ERROR.PREFLIGHT_IMAGE_PULL_POLICY_MISSING, `自动绑定拉镜像策略 ${exports.REQUIRED_IMAGE_PULL_POLICY} 失败`, {
152
+ remediation: `请访问一键授权链接手动授予:${exports.IMAGE_PULL_GRANT_URL}`,
153
+ detail: error === null || error === void 0 ? void 0 : error.message
154
+ }));
155
+ return buildReport(checks);
156
+ }
157
+ }