@cloudbase/manager-node 5.7.1-beta.1 → 5.8.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 (62) hide show
  1. package/lib/cloudApp/index.js +12 -3
  2. package/lib/deploy/DatabaseDeployer.js +238 -0
  3. package/lib/deploy/DeployOrchestrator.js +645 -0
  4. package/lib/deploy/FunctionDeployer.js +611 -0
  5. package/lib/deploy/GatewayDeployer.js +612 -0
  6. package/lib/deploy/StateStore.js +327 -0
  7. package/lib/deploy/StaticDeployer.js +236 -0
  8. package/lib/deploy/domain.js +41 -0
  9. package/lib/deploy/framework.js +230 -0
  10. package/lib/deploy/function/artifact.js +207 -0
  11. package/lib/deploy/function/builders/cloud.js +654 -0
  12. package/lib/deploy/function/cam-preflight.js +157 -0
  13. package/lib/deploy/function/cloud-build-service.js +191 -0
  14. package/lib/deploy/function/config-guard.js +595 -0
  15. package/lib/deploy/function/docker-preflight.js +87 -0
  16. package/lib/deploy/function/image-preflight.js +164 -0
  17. package/lib/deploy/function/local-builder.js +129 -0
  18. package/lib/deploy/function/planner.js +278 -0
  19. package/lib/deploy/function/preflight.js +329 -0
  20. package/lib/deploy/types.js +103 -0
  21. package/lib/env/index.js +0 -27
  22. package/lib/environment.js +11 -0
  23. package/lib/function/index.js +1 -1
  24. package/lib/hosting/index.js +4 -1
  25. package/lib/index.js +31 -0
  26. package/lib/projectValidator/index.js +452 -0
  27. package/lib/projectValidator/types.js +2 -0
  28. package/lib/storage/index.js +6 -7
  29. package/lib/utils/index.js +62 -5
  30. package/package.json +2 -1
  31. package/types/cloudApp/index.d.ts +4 -0
  32. package/types/cloudApp/types.d.ts +87 -6
  33. package/types/deploy/DatabaseDeployer.d.ts +70 -0
  34. package/types/deploy/DeployOrchestrator.d.ts +170 -0
  35. package/types/deploy/FunctionDeployer.d.ts +159 -0
  36. package/types/deploy/GatewayDeployer.d.ts +194 -0
  37. package/types/deploy/StateStore.d.ts +170 -0
  38. package/types/deploy/StaticDeployer.d.ts +97 -0
  39. package/types/deploy/domain.d.ts +17 -0
  40. package/types/deploy/framework.d.ts +63 -0
  41. package/types/deploy/function/artifact.d.ts +40 -0
  42. package/types/deploy/function/builders/cloud.d.ts +110 -0
  43. package/types/deploy/function/cam-preflight.d.ts +51 -0
  44. package/types/deploy/function/cloud-build-service.d.ts +10 -0
  45. package/types/deploy/function/config-guard.d.ts +41 -0
  46. package/types/deploy/function/docker-preflight.d.ts +17 -0
  47. package/types/deploy/function/image-preflight.d.ts +21 -0
  48. package/types/deploy/function/local-builder.d.ts +26 -0
  49. package/types/deploy/function/planner.d.ts +15 -0
  50. package/types/deploy/function/preflight.d.ts +9 -0
  51. package/types/deploy/types.d.ts +429 -0
  52. package/types/env/index.d.ts +1 -17
  53. package/types/env/type.d.ts +6 -260
  54. package/types/environment.d.ts +7 -0
  55. package/types/function/types.d.ts +15 -0
  56. package/types/hosting/index.d.ts +6 -0
  57. package/types/index.d.ts +18 -0
  58. package/types/interfaces/function.interface.d.ts +1 -1
  59. package/types/projectValidator/index.d.ts +38 -0
  60. package/types/projectValidator/types.d.ts +26 -0
  61. package/types/storage/index.d.ts +6 -0
  62. package/types/utils/index.d.ts +13 -0
@@ -0,0 +1,654 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TCR_LOGIN_PUSH_SCRIPT = exports.TCR_PUSH_SCRIPT_ZIP_PATH = void 0;
7
+ exports.buildImageOnCloud = buildImageOnCloud;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const error_1 = require("../../../error");
11
+ const types_1 = require("../../types");
12
+ /**
13
+ * 云端镜像构建(cloud 策略)
14
+ *
15
+ * 对应构建方案(https://iwiki.woa.com/p/4020519533)的「阶段 A:构建镜像(CloudApp custom)」:
16
+ * 源码打包 ZIP → DescribeCloudAppCosInfo拿上传凭证 → PUT 上传 COS
17
+ * → CreateCloudApp(DeployType:custom) 触发容器内 docker build + push TCR
18
+ * → 退避轮询 DescribeCloudAppVersion 到终态 → 从 Artifacts 或拼接得到 imageUri/digest。
19
+ *
20
+ * 产物只返回镜像引用(imageUri/digest/buildId/logsUrl),由编排器交回统一
21
+ * SCF 部署链,本模块不创建/更新任何函数(对应实施规划:builder 只获得 image artifact)。
22
+ *
23
+ * 安全(对应 security_rules):
24
+ * - CustomSteps 由 SDK 固定模板生成;用户可控值(如 tag、registry、namespace)先经
25
+ * 白名单校验,绝不作为任意 shell 片段拼接进 command。
26
+ * - 企业版 TCR 在构建容器内使用 STS 临时凭证换取登录 token;个人版固定密码先做
27
+ * Base64 封装,再通过 CloudApp Secrets 注入为 $SECRET_TCR_PASSWORD_B64,构建容器内
28
+ * 解码后直接送入 docker login stdin,不进入普通 Env、命令行参数或日志。
29
+ * - COS 上传必须携带服务端返回的全部 UploadHeaders。
30
+ * - tag 使用 $CLOUDBASE_VERSION_NAME 或内容摘要,绝不使用 latest。
31
+ *
32
+ * 所有外部依赖(打包、tcb API、COS 上传)以接口注入,默认由编排器提供真实实现,
33
+ * 便于测试替换(不依赖 jest.mock)。
34
+ */
35
+ /** custom 构建的固定 DeployType */
36
+ const CUSTOM_DEPLOY_TYPE = 'custom';
37
+ /** custom 构建的源码类型 */
38
+ const CUSTOM_BUILD_TYPE = 'zip';
39
+ /** 轮询默认参数:初始 5s、退避到 30s、总超时 15 分钟 */
40
+ const POLL_INITIAL_INTERVAL = 5000;
41
+ const POLL_MAX_INTERVAL = 30000;
42
+ const POLL_TOTAL_TIMEOUT = 15 * 60 * 1000;
43
+ /** 轮询临时错误最大连续重试次数,超过则判失败 */
44
+ const POLL_MAX_TRANSIENT_ERRORS = 5;
45
+ const TCR_REGISTRY_AUTH_EXIT_CODE = 42;
46
+ const TCR_REGISTRY_INVALID_EXIT_CODE = 43;
47
+ /** 个人版 TCR 自动创建目标仓库时,仓库数量已达到地域配额 */
48
+ const TCR_PERSONAL_REPOSITORY_QUOTA_EXIT_CODE = 44;
49
+ /** 仓库地址/命名空间/服务名等安全字符白名单:域名、路径、字母数字与常见分隔符 */
50
+ const SAFE_REGISTRY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/;
51
+ /** tag 白名单:字母数字、下划线、点、中划线 */
52
+ const SAFE_TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
53
+ /** Dockerfile 路径:只能是构建上下文内的安全相对路径 */
54
+ const SAFE_DOCKERFILE_PATTERN = /^[a-zA-Z0-9._\-/]+$/;
55
+ /** Docker build arg key 与环境变量同规则 */
56
+ const SAFE_BUILD_ARG_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
57
+ /** 疑似敏感构建参数名;cloud 构建会把参数写入命令/构建记录,因此直接拒绝 */
58
+ const SENSITIVE_BUILD_ARG_KEY_PATTERN = /(secret|token|password|passwd|credential|private[_-]?key|access[_-]?key)/i;
59
+ /** 校验仓库/命名空间等镜像地址片段,拒绝命令注入风险字符 */
60
+ function assertSafeRegistryToken(value, field) {
61
+ if (!SAFE_REGISTRY_PATTERN.test(value)) {
62
+ throw new error_1.CloudBaseError(`${field} 含非法字符,可能导致命令注入:${value}`, {
63
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_REPOSITORY_INVALID
64
+ });
65
+ }
66
+ }
67
+ /** 校验 tag,拒绝 latest 与非法字符 */
68
+ function assertSafeTag(tag) {
69
+ if (!SAFE_TAG_PATTERN.test(tag)) {
70
+ throw new error_1.CloudBaseError(`镜像 tag 含非法字符:${tag}`, {
71
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_TAG_INVALID
72
+ });
73
+ }
74
+ if (tag.toLowerCase() === 'latest') {
75
+ throw new error_1.CloudBaseError('镜像 tag 禁止使用 latest,请使用不可变 tag', {
76
+ code: types_1.FUNCTION_DEPLOY_ERROR.IMAGE_TAG_LATEST_FORBIDDEN
77
+ });
78
+ }
79
+ }
80
+ /** 校验 Dockerfile 路径,避免通过 shell command 逃逸构建上下文 */
81
+ function assertSafeDockerfile(dockerfile) {
82
+ const value = dockerfile.trim();
83
+ const segments = value.split('/');
84
+ if (!value ||
85
+ value.length > 512 ||
86
+ value.startsWith('/') ||
87
+ value.startsWith('\\') ||
88
+ /^[A-Za-z]:[\\/]/.test(value) ||
89
+ !SAFE_DOCKERFILE_PATTERN.test(value) ||
90
+ segments.some(segment => segment === '..')) {
91
+ throw new error_1.CloudBaseError('Dockerfile 路径不合法,需为构建上下文内的安全相对路径', {
92
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_DOCKERFILE_INVALID
93
+ });
94
+ }
95
+ }
96
+ /** 校验 cloud 构建参数;值进入 CloudApp Env,command 中只引用固定变量名 */
97
+ function assertSafeBuildArgs(buildArgs) {
98
+ for (const [key, value] of Object.entries(buildArgs)) {
99
+ if (!SAFE_BUILD_ARG_KEY_PATTERN.test(key)) {
100
+ throw new error_1.CloudBaseError(`构建参数名不合法:${key}`, {
101
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_ARG_INVALID
102
+ });
103
+ }
104
+ if (SENSITIVE_BUILD_ARG_KEY_PATTERN.test(key)) {
105
+ throw new error_1.CloudBaseError(`构建参数 ${key} 可能包含敏感信息;cloud 构建禁止通过 buildArgs 传递密钥`, { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_ARG_INVALID });
106
+ }
107
+ if (typeof value !== 'string' || /[\u0000]/.test(value)) {
108
+ throw new error_1.CloudBaseError(`构建参数 ${key} 的值必须是无 NUL 字符的字符串`, {
109
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_ARG_INVALID
110
+ });
111
+ }
112
+ }
113
+ }
114
+ /** 个人版 CCR 默认域名 */
115
+ const DEFAULT_PERSONAL_REGISTRY = 'ccr.ccs.tencentyun.com';
116
+ /** 注入到构建上下文 zip 中的推送脚本相对路径(与 CustomSteps 的 push命令一致) */
117
+ exports.TCR_PUSH_SCRIPT_ZIP_PATH = 'scripts/tcr-login-and-push.sh';
118
+ /**
119
+ * 注入到构建 zip 的 TCR/CCR 登录推送脚本(完整可运行)
120
+ *
121
+ * 对应构建方案(https://iwiki.woa.com/p/4020519533)「阶段 A」的 push 步骤:
122
+ * push-image 步骤执行 `bash./scripts/tcr-login-and-push.sh`,该脚本必须随源码
123
+ * 一同打进zip 根目录,否则云端会 `No such file or directory` 秒挂。
124
+ *
125
+ * 关键设计:
126
+ * - 地域参数化:REGION 读环境变量 $TCR_REGION(由 buildEnv 按部署地域注入),
127
+ * 避免硬编码 ap-shanghai跨地域时 AuthFailure.SignatureFailure。
128
+ * - 双仓库兼容:设置了 $TCR_INSTANCE_ID 走企业版 TCR CreateInstanceToken;
129
+ * 否则走个人版 CCR,用户名来自普通 Env,固定密码来自 CloudApp Secrets。
130
+ * - 安全红线:凭证一律经 --password-stdin 传入,绝不出现在 ps/日志;
131
+ * 不打印任何 $API_SECRET_* / token / $SECRET_TCR_PASSWORD_B64 或解码后的密码。
132
+ *
133
+ * 脚本内容为SDK 固定模板,不拼接任何用户输入(镜像地址等均由已校验的
134
+ * 环境变量在容器内组装),无命令注入面。
135
+ */
136
+ exports.TCR_LOGIN_PUSH_SCRIPT = `#!/usr/bin/env bash
137
+ # scripts/tcr-login-and-push.sh
138
+ # 由 CloudBase SDK 自动注入:企业版使用 STS 换取临时 token,个人版使用 CloudApp Secret
139
+ set -euo pipefail
140
+
141
+ : "\${TCR_REGISTRY:?TCR_REGISTRY 未设置}"
142
+ : "\${TCR_NAMESPACE:?TCR_NAMESPACE 未设置}"
143
+ : "\${CLOUDBASE_SERVICE_NAME:?平台变量缺失}"
144
+ : "\${CLOUDBASE_VERSION_NAME:?平台变量缺失}"
145
+
146
+ REGION="\${TCR_REGION:-ap-shanghai}"
147
+ IMAGE="\${TCR_REGISTRY}/\${TCR_NAMESPACE}/\${CLOUDBASE_SERVICE_NAME}:\${CLOUDBASE_VERSION_NAME}"
148
+ IS_PERSONAL_REGISTRY=0
149
+
150
+ if [ -n "\${TCR_INSTANCE_ID:-}" ]; then
151
+ : "\${API_SECRET_ID:?STS 凭证缺失}"
152
+ : "\${API_SECRET_KEY:?STS 凭证缺失}"
153
+ : "\${API_TOKEN:?STS 凭证缺失}"
154
+
155
+ # ---- 企业版 TCR:CreateInstanceToken 换登录态 ----
156
+ HOST="tcr.tencentcloudapi.com"
157
+ SERVICE="tcr"
158
+ VERSION="2019-09-24"
159
+ ACTION="CreateInstanceToken"
160
+ ALGORITHM="TC3-HMAC-SHA256"
161
+ TIMESTAMP=$(date +%s)
162
+ DATE=$(date -u -d "@\${TIMESTAMP}" +"%Y-%m-%d" 2>/dev/null || date -u -r "\${TIMESTAMP}" +"%Y-%m-%d")
163
+
164
+ PAYLOAD=$(printf '{"RegistryId":"%s","TokenType":"LongTermToken"}' "\${TCR_INSTANCE_ID}")
165
+ HASHED_PAYLOAD=$(printf '%s' "\${PAYLOAD}" | openssl dgst -sha256 -hex | awk '{print $NF}')
166
+ CANONICAL_HEADERS="content-type:application/json; charset=utf-8\\nhost:\${HOST}\\nx-tc-action:$(echo "\${ACTION}" | tr '[:upper:]' '[:lower:]')\\n"
167
+ SIGNED_HEADERS="content-type;host;x-tc-action"
168
+ CANONICAL_REQUEST="POST\\n/\\n\\n\${CANONICAL_HEADERS}\\n\${SIGNED_HEADERS}\\n\${HASHED_PAYLOAD}"
169
+
170
+ CREDENTIAL_SCOPE="\${DATE}/\${SERVICE}/tc3_request"
171
+ HASHED_CR=$(printf "\${CANONICAL_REQUEST}" | openssl dgst -sha256 -hex | awk '{print $NF}')
172
+ STRING_TO_SIGN="\${ALGORITHM}\\n\${TIMESTAMP}\\n\${CREDENTIAL_SCOPE}\\n\${HASHED_CR}"
173
+
174
+ secret_date=$(printf '%s' "\${DATE}" | openssl dgst -sha256 -hmac "TC3\${API_SECRET_KEY}" -hex | awk '{print $NF}')
175
+ secret_svc=$(printf '%s' "\${SERVICE}" | openssl dgst -sha256 -mac HMAC -macopt hexkey:"\${secret_date}" -hex | awk '{print $NF}')
176
+ secret_sign=$(printf '%s' "tc3_request" | openssl dgst -sha256 -mac HMAC -macopt hexkey:"\${secret_svc}" -hex | awk '{print $NF}')
177
+ SIGNATURE=$(printf "\${STRING_TO_SIGN}" | openssl dgst -sha256 -mac HMAC -macopt hexkey:"\${secret_sign}" -hex | awk '{print $NF}')
178
+
179
+ AUTHZ="\${ALGORITHM} Credential=\${API_SECRET_ID}/\${CREDENTIAL_SCOPE}, SignedHeaders=\${SIGNED_HEADERS}, Signature=\${SIGNATURE}"
180
+
181
+ RESP=$(curl -sS -X POST "https://\${HOST}" \\
182
+ -H "Authorization: \${AUTHZ}" \\
183
+ -H "Content-Type: application/json; charset=utf-8" \\
184
+ -H "Host: \${HOST}" \\
185
+ -H "X-TC-Action: \${ACTION}" \\
186
+ -H "X-TC-Timestamp: \${TIMESTAMP}" \\
187
+ -H "X-TC-Version: \${VERSION}" \\
188
+ -H "X-TC-Region: \${REGION}" \\
189
+ -H "X-TC-Token: \${API_TOKEN}" \\
190
+ --data "\${PAYLOAD}")
191
+
192
+ ERROR_CODE=$(printf '%s' "\${RESP}" | jq -r '.Response.Error.Code // empty')
193
+ if [ -n "\${ERROR_CODE}" ]; then
194
+ case "\${ERROR_CODE}" in
195
+ AuthFailure*|UnauthorizedOperation*|FailedOperation.NoPermission*)
196
+ echo "企业版 TCR 鉴权失败,请确认当前身份有权访问该实例并调用 CreateInstanceToken" >&2
197
+ exit ${TCR_REGISTRY_AUTH_EXIT_CODE}
198
+ ;;
199
+ ResourceNotFound*|InvalidParameterValue.RegistryId*)
200
+ echo "企业版 TCR 实例不存在或实例 ID 无效" >&2
201
+ exit ${TCR_REGISTRY_INVALID_EXIT_CODE}
202
+ ;;
203
+ *)
204
+ echo "企业版 TCR 获取登录凭证失败:\${ERROR_CODE}" >&2
205
+ exit 1
206
+ ;;
207
+ esac
208
+ fi
209
+
210
+ TOKEN_USER=$(printf '%s' "\${RESP}" | jq -er '.Response.Username')
211
+ TOKEN_PASS=$(printf '%s' "\${RESP}" | jq -er '.Response.Token')
212
+
213
+ printf '%s' "\${TOKEN_PASS}" | docker login -u "\${TOKEN_USER}" --password-stdin "\${TCR_REGISTRY}"
214
+ else
215
+ # ---- 个人版 CCR:账号 ID + 固定密码(Base64 封装后由 CloudApp Secrets 注入) ----
216
+ IS_PERSONAL_REGISTRY=1
217
+ : "\${TCR_USERNAME:?TCR_USERNAME 未设置}"
218
+ : "\${SECRET_TCR_PASSWORD_B64:?TCR_PASSWORD 未设置}"
219
+ printf '%s' "\${SECRET_TCR_PASSWORD_B64}" | base64 --decode | docker login -u "\${TCR_USERNAME}" --password-stdin "\${TCR_REGISTRY}"
220
+ fi
221
+
222
+ PUSH_LOG=$(mktemp)
223
+ trap 'rm -f "\${PUSH_LOG}"' EXIT
224
+ set +e
225
+ docker push "\${IMAGE}" 2>&1 | tee "\${PUSH_LOG}"
226
+ PUSH_STATUS=\${PIPESTATUS[0]}
227
+ set -e
228
+
229
+ if [ "\${PUSH_STATUS}" -ne 0 ]; then
230
+ if [ "\${IS_PERSONAL_REGISTRY}" -eq 1 ] && {
231
+ grep -Fq 'LimitExceeded.ErrRepoMaxLimit' "\${PUSH_LOG}" ||
232
+ grep -Fq '用户仓库已经达到最大配额' "\${PUSH_LOG}"
233
+ }; then
234
+ echo "个人版 TCR 镜像仓库数量已达到当前地域配额,无法自动创建目标仓库" >&2
235
+ exit ${TCR_PERSONAL_REPOSITORY_QUOTA_EXIT_CODE}
236
+ fi
237
+ exit 1
238
+ fi
239
+
240
+ echo "::output::tag=\${CLOUDBASE_VERSION_NAME}"
241
+ `;
242
+ function resolveTarget(config) {
243
+ const build = config.build;
244
+ const repository = (build.repository || config.name).trim();
245
+ const rawRegistryId = build.registryId;
246
+ const registryId = typeof rawRegistryId === 'string' ? rawRegistryId.trim() : rawRegistryId;
247
+ const isEnterprise = config.imageType === 'enterprise' || Boolean(registryId);
248
+ // CloudApp custom 构建与推送必须使用同一个平台版本 tag。
249
+ // 若允许自定义 tag,build-image 会构建自定义 tag,而固定推送脚本会推
250
+ // $CLOUDBASE_VERSION_NAME,最终必然找不到本地镜像。
251
+ if (build.tag) {
252
+ assertSafeTag(build.tag);
253
+ throw new error_1.CloudBaseError('cloud 策略不支持自定义 build.tag,请移除该字段并使用平台生成的 CLOUDBASE_VERSION_NAME', { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_TAG_INVALID });
254
+ }
255
+ // repository 允许两种形态:
256
+ // 1) namespace/image 或完整 registry/namespace/image:直接拆分
257
+ // 2) 仅镜像名:使用 cloudbaserc 中显式声明并经 {{env.*}} 解析后的 build.namespace
258
+ let registry = DEFAULT_PERSONAL_REGISTRY;
259
+ let hasExplicitRegistry = false;
260
+ let namespace = (build.namespace || '').trim();
261
+ let imageName = repository;
262
+ if (!isEnterprise && !namespace) {
263
+ throw new error_1.CloudBaseError('个人版 TCR 云端构建缺少 build.namespace;请在 cloudbaserc 中显式声明,' +
264
+ '推荐使用 "{{env.TCB_TCR_NAMESPACE}}"', { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_REPOSITORY_INVALID });
265
+ }
266
+ if (repository.includes('/')) {
267
+ assertSafeRegistryToken(repository, 'build.repository');
268
+ const parts = repository.split('/').filter(Boolean);
269
+ // 形如 domain/ns/name或 ns/name
270
+ let repositoryNamespace;
271
+ if (parts.length >= 3 && parts[0].includes('.')) {
272
+ registry = parts[0];
273
+ hasExplicitRegistry = true;
274
+ repositoryNamespace = parts.slice(1, -1).join('/');
275
+ }
276
+ else {
277
+ repositoryNamespace = parts.slice(0, -1).join('/');
278
+ }
279
+ if (namespace && namespace !== repositoryNamespace) {
280
+ throw new error_1.CloudBaseError('build.namespace 与 build.repository 中的命名空间不一致', { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_REPOSITORY_INVALID });
281
+ }
282
+ namespace = namespace || repositoryNamespace;
283
+ imageName = parts[parts.length - 1];
284
+ }
285
+ else {
286
+ assertSafeRegistryToken(repository, 'build.repository');
287
+ }
288
+ assertSafeRegistryToken(registry, 'TCR registry');
289
+ if (!namespace) {
290
+ throw new error_1.CloudBaseError('cloud 策略缺少 TCR 命名空间,请配置 build.namespace 或在完整 repository 中声明', { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_REPOSITORY_INVALID });
291
+ }
292
+ assertSafeRegistryToken(namespace, 'TCR namespace');
293
+ if (config.imageType === 'personal' && registryId) {
294
+ throw new error_1.CloudBaseError('cloud 策略的 personal 镜像不能配置 build.registryId;请移除该字段或改用 enterprise', { code: types_1.FUNCTION_DEPLOY_ERROR.IMAGE_TYPE_INVALID });
295
+ }
296
+ if (config.imageType === 'enterprise') {
297
+ if (rawRegistryId === undefined || rawRegistryId === null) {
298
+ throw new error_1.CloudBaseError('cloud 策略的企业版 TCR 未配置 build.registryId;如果当前没有企业版实例,请改用 imageType=personal', { code: types_1.FUNCTION_DEPLOY_ERROR.IMAGE_REGISTRY_ID_MISSING });
299
+ }
300
+ if (typeof registryId !== 'string' || !registryId) {
301
+ throw new error_1.CloudBaseError('cloud 策略的企业版 TCR build.registryId 已声明但解析结果为空;若使用 {{env.*}},请确认环境变量已赋值,当前没有企业版实例时请改用 imageType=personal', { code: types_1.FUNCTION_DEPLOY_ERROR.IMAGE_REGISTRY_ID_INVALID });
302
+ }
303
+ }
304
+ if (isEnterprise && !hasExplicitRegistry) {
305
+ throw new error_1.CloudBaseError('cloud 策略的企业版 TCR 需要明确仓库域名:请将 build.repository 配置为 ' +
306
+ 'registry/namespace/repository;registryId 无法用于推导仓库域名', { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_REPOSITORY_INVALID });
307
+ }
308
+ return {
309
+ registry,
310
+ namespace,
311
+ registryId,
312
+ serviceName: imageName
313
+ };
314
+ }
315
+ /**
316
+ * 解析并校验 cloud 构建执行参数。
317
+ * 用户值不直接拼入 shell:Dockerfile/platform/buildArgs value 均通过 CloudApp Env 注入,
318
+ * CustomSteps 只包含 SDK 固定命令和经过白名单校验的 build arg key。
319
+ */
320
+ function resolveCloudBuildOptions(config) {
321
+ const dockerfile = config.build.dockerfile || types_1.DEFAULT_DOCKERFILE;
322
+ const platform = config.build.platform || types_1.DEFAULT_IMAGE_PLATFORM;
323
+ const buildArgs = config.build.buildArgs || {};
324
+ assertSafeDockerfile(dockerfile);
325
+ if (platform !== types_1.DEFAULT_IMAGE_PLATFORM) {
326
+ throw new error_1.CloudBaseError(`当前仅支持构建 ${types_1.DEFAULT_IMAGE_PLATFORM} 镜像`, {
327
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_PLATFORM_INVALID
328
+ });
329
+ }
330
+ assertSafeBuildArgs(buildArgs);
331
+ return {
332
+ dockerfile,
333
+ platform,
334
+ buildArgs,
335
+ forceBuild: config.build.forceBuild === true
336
+ };
337
+ }
338
+ /**
339
+ * 生成固定的 CustomSteps 模板(build + push)。
340
+ *
341
+ * Dockerfile、platform 和 build arg value 通过 CloudApp Env 注入;只有通过严格
342
+ * ENV key 校验的 build arg key 会进入固定 command。forceBuild 显式映射为 --no-cache。
343
+ */
344
+ function buildCustomSteps(options) {
345
+ const buildArgs = Object.keys(options.buildArgs)
346
+ .sort()
347
+ .map(key => `--build-arg ${key}="$BUILD_ARG_${key}"`)
348
+ .join(' ');
349
+ const noCache = options.forceBuild ? ' --no-cache' : '';
350
+ const extraArgs = buildArgs ? ` ${buildArgs}` : '';
351
+ return [
352
+ {
353
+ name: 'build-image',
354
+ command: 'docker build' +
355
+ ' --platform "$CLOUDBASE_BUILD_PLATFORM"' +
356
+ ' -f "$CLOUDBASE_DOCKERFILE"' +
357
+ noCache +
358
+ extraArgs +
359
+ ' -t $TCR_REGISTRY/$TCR_NAMESPACE/$CLOUDBASE_SERVICE_NAME:$CLOUDBASE_VERSION_NAME .'
360
+ },
361
+ {
362
+ name: 'push-image',
363
+ command: 'bash ./scripts/tcr-login-and-push.sh'
364
+ }
365
+ ];
366
+ }
367
+ /**
368
+ * 组装 custom 构建的环境变量(用户可控值已校验,此处只做键值透传)
369
+ *
370
+ * - TCR_REGION:部署地域,供推送脚本换取镜像仓库登录态时签名/请求用,
371
+ * 缺省交由脚本回退默认地域;参数化后跨地域不会 SignatureFailure。
372
+ * - TCR_INSTANCE_ID:仅企业版 TCR 注入,脚本据此走 CreateInstanceToken 分支;
373
+ * 个人版 CCR 不注入,脚本走 STS 直接 docker login 分支。
374
+ */
375
+ function buildEnv(target, buildOptions, region) {
376
+ const env = [
377
+ { key: 'TCR_REGISTRY', value: target.registry },
378
+ { key: 'TCR_NAMESPACE', value: target.namespace },
379
+ { key: 'CLOUDBASE_DOCKERFILE', value: buildOptions.dockerfile },
380
+ { key: 'CLOUDBASE_BUILD_PLATFORM', value: buildOptions.platform },
381
+ ...Object.entries(buildOptions.buildArgs).map(([key, value]) => ({
382
+ key: `BUILD_ARG_${key}`,
383
+ value
384
+ }))
385
+ ];
386
+ if (region) {
387
+ env.push({ key: 'TCR_REGION', value: region });
388
+ }
389
+ if (target.registryId) {
390
+ assertSafeRegistryToken(target.registryId, 'registryId');
391
+ env.push({ key: 'TCR_INSTANCE_ID', value: target.registryId });
392
+ }
393
+ return env;
394
+ }
395
+ /**
396
+ * 组装个人版 TCR 凭证。
397
+ * username 可作为普通构建变量;password 必须进入 CloudApp Secrets。
398
+ */
399
+ function buildRegistryCredentials(config, target) {
400
+ var _a;
401
+ if (target.registryId) {
402
+ return { env: [] };
403
+ }
404
+ const credential = config.build.registryCredential;
405
+ if (!credential) {
406
+ throw new error_1.CloudBaseError('个人版 TCR 云端构建缺少 build.registryCredential;请在 cloudbaserc 中显式声明 ' +
407
+ 'username/password,推荐分别使用 "{{env.TCB_TCR_USERNAME}}" 和 ' +
408
+ '"{{env.TCB_TCR_PASSWORD}}"', { code: types_1.FUNCTION_DEPLOY_ERROR.CLOUD_REGISTRY_CREDENTIAL_MISSING });
409
+ }
410
+ const username = (_a = credential.username) === null || _a === void 0 ? void 0 : _a.trim();
411
+ const password = credential.password;
412
+ if (typeof username !== 'string' ||
413
+ !/^\d{5,20}$/.test(username) ||
414
+ typeof password !== 'string' ||
415
+ !password ||
416
+ password.length > 16 * 1024) {
417
+ throw new error_1.CloudBaseError('个人版 TCR 凭证不合法:TCB_TCR_USERNAME 需为腾讯云账号 ID,' +
418
+ 'TCB_TCR_PASSWORD 不能为空且长度不能超过 16KB', { code: types_1.FUNCTION_DEPLOY_ERROR.CLOUD_REGISTRY_CREDENTIAL_INVALID });
419
+ }
420
+ // CloudApp Secret 对原始特殊字符的处理需要与本地 dotenv 解耦验证,因此这里先做
421
+ // Base64 封装;构建容器内仅在管道中解码并直接送入 docker login stdin。
422
+ // Base64 不是加密,安全边界仍由 CloudApp Secrets 提供,编码值不得进入普通 Env 或日志。
423
+ const passwordBase64 = Buffer.from(password, 'utf8').toString('base64');
424
+ return {
425
+ env: [{ key: 'TCR_USERNAME', value: username }],
426
+ secrets: [{ name: 'TCR_PASSWORD_B64', value: passwordBase64 }]
427
+ };
428
+ }
429
+ function resolveCloudBuildContext(config, dockerfile) {
430
+ const cwd = path_1.default.resolve(config.build.cwd || config.functionPath || process.cwd());
431
+ const dockerfilePath = path_1.default.resolve(cwd, dockerfile);
432
+ let stat = null;
433
+ try {
434
+ stat = fs_1.default.statSync(dockerfilePath);
435
+ }
436
+ catch (error) {
437
+ if (!error || error.code !== 'ENOENT') {
438
+ throw error;
439
+ }
440
+ }
441
+ if ((stat === null || stat === void 0 ? void 0 : stat.isFile()) !== true) {
442
+ throw new error_1.CloudBaseError(`Dockerfile 不存在或不是普通文件:${dockerfilePath}`, {
443
+ code: types_1.FUNCTION_DEPLOY_ERROR.CLOUD_BUILD_DOCKERFILE_MISSING
444
+ });
445
+ }
446
+ return cwd;
447
+ }
448
+ /** 从构建产物或拼接规则得到最终不可变镜像地址 */
449
+ function resolveImageUri(target, versionName, artifacts) {
450
+ // 优先从 Artifacts 取平台回传的镜像信息
451
+ const imageArtifact = (artifacts || []).find(item => (item === null || item === void 0 ? void 0 : item.Type) === 'image');
452
+ if (imageArtifact === null || imageArtifact === void 0 ? void 0 : imageArtifact.ImageUri) {
453
+ return { imageUri: imageArtifact.ImageUri, imageDigest: imageArtifact.Digest };
454
+ }
455
+ // 无Artifacts 时按约定拼接:{registry}/{namespace}/{name}:{tag}
456
+ const imageUri = `${target.registry}/${target.namespace}/${target.serviceName}:${versionName}`;
457
+ return { imageUri, imageDigest: imageArtifact === null || imageArtifact === void 0 ? void 0 : imageArtifact.Digest };
458
+ }
459
+ /** 判断企业版 TCR 推送步骤是否因实例鉴权失败 */
460
+ function isRegistryAuthFailure(steps) {
461
+ return (steps || []).some(step => {
462
+ var _a;
463
+ return (step === null || step === void 0 ? void 0 : step.Name) === 'push-image' &&
464
+ ((_a = step === null || step === void 0 ? void 0 : step.Status) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'failed' &&
465
+ step.ExitCode === TCR_REGISTRY_AUTH_EXIT_CODE;
466
+ });
467
+ }
468
+ /** 判断企业版 TCR 推送步骤是否因实例 ID 无效失败 */
469
+ function isRegistryIdFailure(steps) {
470
+ return (steps || []).some(step => {
471
+ var _a;
472
+ return (step === null || step === void 0 ? void 0 : step.Name) === 'push-image' &&
473
+ ((_a = step === null || step === void 0 ? void 0 : step.Status) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'failed' &&
474
+ step.ExitCode === TCR_REGISTRY_INVALID_EXIT_CODE;
475
+ });
476
+ }
477
+ /** 判断个人版 TCR 是否因自动创建目标仓库时达到仓库数量配额而失败 */
478
+ function isPersonalRepositoryQuotaFailure(target, steps) {
479
+ if (target.registryId) {
480
+ return false;
481
+ }
482
+ return (steps || []).some(step => {
483
+ var _a;
484
+ return (step === null || step === void 0 ? void 0 : step.Name) === 'push-image' &&
485
+ ((_a = step === null || step === void 0 ? void 0 : step.Status) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'failed' &&
486
+ step.ExitCode === TCR_PERSONAL_REPOSITORY_QUOTA_EXIT_CODE;
487
+ });
488
+ }
489
+ /** 汇总失败 Step 的定位信息(脱敏:只取步骤名/状态/退出码,不含日志明文) */
490
+ function summarizeFailedSteps(steps) {
491
+ const failed = (steps || []).filter(step => typeof (step === null || step === void 0 ? void 0 : step.Status) === 'string' && step.Status.toLowerCase() === 'failed');
492
+ if (failed.length === 0) {
493
+ return '';
494
+ }
495
+ return failed
496
+ .map(step => {
497
+ const code = step.ExitCode !== undefined ? ` (exit ${step.ExitCode})` : '';
498
+ return `${step.Name}${code}`;
499
+ })
500
+ .join(', ');
501
+ }
502
+ /**
503
+ * 执行云端构建并推送镜像
504
+ *
505
+ * @param config 规范化的 cloud 部署配置
506
+ * @param service 注入的外部能力(打包/tcb API/COS 上传)
507
+ * @param options 可选项:日志回调 onLog、部署地域 region
508
+ */
509
+ async function buildImageOnCloud(config, service, options = {}) {
510
+ const { onLog, region } = options;
511
+ const target = resolveTarget(config);
512
+ const buildOptions = resolveCloudBuildOptions(config);
513
+ // 在打包和上传前完成真实消费字段校验,避免无效配置触发远端资源操作。
514
+ const registryCredentials = buildRegistryCredentials(config, target);
515
+ const serviceName = target.serviceName;
516
+ const cwd = resolveCloudBuildContext(config, buildOptions.dockerfile);
517
+ // 1. 打包构建上下文(注入 push 脚本,不污染用户源码目录)
518
+ onLog === null || onLog === void 0 ? void 0 : onLog('打包构建上下文');
519
+ const zipPath = await service.packContext(cwd, [
520
+ {
521
+ zipPath: exports.TCR_PUSH_SCRIPT_ZIP_PATH,
522
+ content: exports.TCR_LOGIN_PUSH_SCRIPT,
523
+ mode: 0o755
524
+ }
525
+ ]);
526
+ try {
527
+ // 2. 获取 COS 上传凭证
528
+ onLog === null || onLog === void 0 ? void 0 : onLog('获取 COS 上传凭证');
529
+ const cosInfo = await service.getCosInfo(serviceName);
530
+ if (!(cosInfo === null || cosInfo === void 0 ? void 0 : cosInfo.uploadUrl) || !(cosInfo === null || cosInfo === void 0 ? void 0 : cosInfo.unixTimestamp)) {
531
+ throw new error_1.CloudBaseError('获取 COS 上传凭证失败:返回缺少 UploadUrl 或 UnixTimestamp', {
532
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_CONTEXT_INVALID
533
+ });
534
+ }
535
+ // 3. 上传源码 ZIP(携带全部 UploadHeaders)
536
+ onLog === null || onLog === void 0 ? void 0 : onLog('上传源码到 COS');
537
+ await service.uploadZip(cosInfo, zipPath);
538
+ // 4. 触发 custom 构建
539
+ onLog === null || onLog === void 0 ? void 0 : onLog('触发云端构建');
540
+ const created = await service.createBuild({
541
+ serviceName,
542
+ cosTimestamp: cosInfo.unixTimestamp,
543
+ env: [
544
+ ...buildEnv(target, buildOptions, region),
545
+ ...registryCredentials.env
546
+ ],
547
+ secrets: registryCredentials.secrets,
548
+ customSteps: buildCustomSteps(buildOptions)
549
+ });
550
+ if (!(created === null || created === void 0 ? void 0 : created.versionName)) {
551
+ throw new error_1.CloudBaseError('触发云端构建失败:返回缺少 VersionName', {
552
+ code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_CONTEXT_INVALID
553
+ });
554
+ }
555
+ // 打出触发构建的 RequestId 与版本号,便于把构建问题反馈给平台后端定位
556
+ onLog === null || onLog === void 0 ? void 0 : onLog(`已触发云端构建 versionName=${created.versionName}` +
557
+ (created.requestId ? ` requestId=${created.requestId}` : ''));
558
+ // 5. 退避轮询到终态
559
+ const finalStatus = await pollUntilTerminal({
560
+ service,
561
+ serviceName,
562
+ versionName: created.versionName,
563
+ onLog
564
+ });
565
+ if (finalStatus.status === 'FAILED' || finalStatus.status === 'CANCELED') {
566
+ const failedInfo = summarizeFailedSteps(finalStatus.steps);
567
+ const detail = failedInfo ? `,失败步骤:${failedInfo}` : '';
568
+ const reqId = finalStatus.requestId
569
+ ? `,requestId=${finalStatus.requestId}`
570
+ : '';
571
+ const registryAuthFailure = isRegistryAuthFailure(finalStatus.steps);
572
+ const registryIdFailure = isRegistryIdFailure(finalStatus.steps);
573
+ const personalRepositoryQuotaFailure = isPersonalRepositoryQuotaFailure(target, finalStatus.steps);
574
+ const code = finalStatus.status === 'CANCELED'
575
+ ? types_1.FUNCTION_DEPLOY_ERROR.CLOUD_BUILD_CANCELED
576
+ : registryAuthFailure
577
+ ? types_1.FUNCTION_DEPLOY_ERROR.CLOUD_REGISTRY_AUTH_FAILED
578
+ : registryIdFailure
579
+ ? types_1.FUNCTION_DEPLOY_ERROR.IMAGE_REGISTRY_ID_INVALID
580
+ : personalRepositoryQuotaFailure
581
+ ? types_1.FUNCTION_DEPLOY_ERROR.CLOUD_PERSONAL_REGISTRY_REPOSITORY_QUOTA_EXCEEDED
582
+ : types_1.FUNCTION_DEPLOY_ERROR.CLOUD_BUILD_FAILED;
583
+ const guidance = finalStatus.status === 'CANCELED'
584
+ ? ''
585
+ : registryAuthFailure
586
+ ? ';企业版 TCR 鉴权失败,请确认实例属于当前账号/地域,且当前身份拥有 CreateInstanceToken 和仓库推送权限'
587
+ : registryIdFailure
588
+ ? ';企业版 TCR 实例不存在或实例 ID 无效,请检查 build.registryId 对应的环境变量和实例状态'
589
+ : personalRepositoryQuotaFailure
590
+ ? ';个人版 TCR 当前地域的镜像仓库数量已达到配额,目标仓库无法自动创建。请在容器镜像服务个人版中删除不再使用的仓库(删除前请备份其中镜像),或将 build.repository 改为已有仓库;如需更多仓库,请切换企业版 TCR。实际配额请以控制台为准'
591
+ : '';
592
+ throw new error_1.CloudBaseError(`云端构建未成功(${finalStatus.status})${detail}${reqId}${guidance}`, { code });
593
+ }
594
+ // 6. 得到不可变镜像地址与digest
595
+ const { imageUri, imageDigest } = resolveImageUri(target, created.versionName, finalStatus.artifacts);
596
+ return {
597
+ imageUri,
598
+ imageDigest,
599
+ buildId: created.buildId,
600
+ versionName: created.versionName
601
+ };
602
+ }
603
+ finally {
604
+ // 清理临时 zip,失败不影响主流程
605
+ try {
606
+ await service.cleanup(zipPath);
607
+ }
608
+ catch (_a) {
609
+ // 忽略清理异常
610
+ }
611
+ }
612
+ }
613
+ /**
614
+ * 退避轮询构建版本状态直到终态
615
+ *
616
+ * - 间隔从 5s 退避到 30s,总超时 15 分钟。
617
+ * - 临时网络错误连续重试,超过阈值才判失败,避免抖动误杀长构建。
618
+ */
619
+ async function pollUntilTerminal(params) {
620
+ const { service, serviceName, versionName, onLog } = params;
621
+ const start = Date.now();
622
+ let interval = POLL_INITIAL_INTERVAL;
623
+ let transientErrors = 0;
624
+ // 先等一个间隔再查,构建刚触发通常还没状态
625
+ while (Date.now() - start < POLL_TOTAL_TIMEOUT) {
626
+ await service.delay(interval);
627
+ let status;
628
+ try {
629
+ status = await service.getVersionStatus(serviceName, versionName);
630
+ transientErrors = 0;
631
+ }
632
+ catch (error) {
633
+ transientErrors += 1;
634
+ if (transientErrors > POLL_MAX_TRANSIENT_ERRORS) {
635
+ 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 });
636
+ }
637
+ // 临时错误,继续退避重试
638
+ interval = Math.min(interval * 2, POLL_MAX_INTERVAL);
639
+ continue;
640
+ }
641
+ const normalized = (status.status || '').toUpperCase();
642
+ onLog === null || onLog === void 0 ? void 0 : onLog(`构建状态:${normalized}`);
643
+ if (normalized === 'SUCCESS' || normalized === 'FAILED' || normalized === 'CANCELED') {
644
+ // 终态时打出查询接口 RequestId,便于把该次构建版本反馈给平台后端定位
645
+ if (status.requestId) {
646
+ onLog === null || onLog === void 0 ? void 0 : onLog(`构建版本查询 requestId=${status.requestId}`);
647
+ }
648
+ return Object.assign(Object.assign({}, status), { status: normalized });
649
+ }
650
+ // 仍在构建,退避
651
+ interval = Math.min(interval * 2, POLL_MAX_INTERVAL);
652
+ }
653
+ throw new error_1.CloudBaseError(`云端构建超时(超过 ${Math.round(POLL_TOTAL_TIMEOUT / 60000)} 分钟仍未完成)`, { code: types_1.FUNCTION_DEPLOY_ERROR.CLOUD_BUILD_TIMEOUT });
654
+ }