@cloudbase/manager-node 5.7.0-beta.1 → 5.7.0-beta.3
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/lib/deploy/DeployOrchestrator.js +110 -42
- package/lib/deploy/GatewayDeployer.js +100 -10
- package/lib/deploy/StaticDeployer.js +0 -50
- package/lib/deploy/domain.js +5 -3
- package/lib/deploy/function/builders/cloud.js +94 -12
- package/lib/deploy/function/config-guard.js +5 -1
- package/lib/deploy/function/local-builder.js +21 -2
- package/lib/projectValidator/index.js +67 -2
- package/lib/utils/index.js +26 -9
- package/package.json +1 -1
- package/types/deploy/DeployOrchestrator.d.ts +18 -0
- package/types/deploy/GatewayDeployer.d.ts +19 -0
- package/types/deploy/StaticDeployer.d.ts +0 -7
- package/types/deploy/domain.d.ts +2 -2
- package/types/projectValidator/index.d.ts +14 -0
|
@@ -12,6 +12,7 @@ const StaticDeployer_1 = require("./StaticDeployer");
|
|
|
12
12
|
const GatewayDeployer_1 = require("./GatewayDeployer");
|
|
13
13
|
const StateStore_1 = require("./StateStore");
|
|
14
14
|
const framework_1 = require("./framework");
|
|
15
|
+
const parallel_1 = require("../utils/parallel");
|
|
15
16
|
/** 部署顺序(按依赖:database 最先,函数依赖新 Schema;gateway 最后,依赖函数/hosting) */
|
|
16
17
|
const DEPLOY_ORDER = ['database', 'functions', 'app', 'hosting', 'gateway'];
|
|
17
18
|
/**
|
|
@@ -35,10 +36,16 @@ class DeployOrchestrator {
|
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
37
38
|
* 执行部署
|
|
39
|
+
*
|
|
40
|
+
* 编排模型:plan 按「连续相同资源类型」切成 segments,segment 之间严格按
|
|
41
|
+
* DEPLOY_ORDER 串行;segment 内部按 concurrency 并行执行。失败中断策略:
|
|
42
|
+
* - 默认 fail-fast:任一资源失败即中断后续(return)
|
|
43
|
+
* - continueOnError:非 database 资源失败仍继续,最后汇总失败数
|
|
44
|
+
* - database 失败始终强制中断(无论是否 continueOnError)
|
|
38
45
|
*/
|
|
39
46
|
async deploy(options) {
|
|
40
47
|
var _a, _b, _c, _d, _e, _f, _g;
|
|
41
|
-
const { config, envId, dryRun, cwd = process.cwd(), log } = options;
|
|
48
|
+
const { config, envId, dryRun, cwd = process.cwd(), log, concurrency = 1, continueOnError = false } = options;
|
|
42
49
|
// 1. 轻量 inline 守卫(validateProject 未接入时的兜底)
|
|
43
50
|
this.guardConfig(config);
|
|
44
51
|
const plan = await this.buildPlan(config, options);
|
|
@@ -47,55 +54,89 @@ class DeployOrchestrator {
|
|
|
47
54
|
return { plan, results: [] };
|
|
48
55
|
}
|
|
49
56
|
const results = [];
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
// 按连续相同 type 切段:段间串行(依赖顺序),段内并行
|
|
58
|
+
const segments = this.segmentPlan(plan);
|
|
59
|
+
const limit = Math.max(1, Math.min(Number(concurrency) || 1, 20));
|
|
60
|
+
for (const segment of segments) {
|
|
61
|
+
// 段内预判定(顺序执行,保留交互/冲突中断语义)
|
|
62
|
+
const toExecute = [];
|
|
63
|
+
let segmentFailed = false;
|
|
64
|
+
let segmentDbFailed = false;
|
|
65
|
+
for (const item of segment) {
|
|
66
|
+
// skip:产物/配置未变更,静默跳过(不执行、不询问)
|
|
67
|
+
if (item.status === 'skip') {
|
|
68
|
+
(_b = log === null || log === void 0 ? void 0 : log.info) === null || _b === void 0 ? void 0 : _b.call(log, `[${item.type}] ${item.name}: ${item.action}`);
|
|
69
|
+
results.push({ type: item.type, name: item.name, ok: true, reason: 'skip' });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
(_c = log === null || log === void 0 ? void 0 : log.info) === null || _c === void 0 ? void 0 : _c.call(log, `[${item.type}] ${item.name}: ${item.action}`);
|
|
73
|
+
// conflict:数据库迁移冲突 → 中断整个部署(后续资源可能依赖新 Schema)
|
|
74
|
+
if (item.status === 'conflict') {
|
|
75
|
+
const message = '检测到冲突,已中断部署';
|
|
76
|
+
(_d = log === null || log === void 0 ? void 0 : log.error) === null || _d === void 0 ? void 0 : _d.call(log, `[${item.type}] ${item.name} ${message}`);
|
|
77
|
+
results.push({ type: item.type, name: item.name, ok: false, error: message });
|
|
78
|
+
if (item.type === 'database') {
|
|
79
|
+
return { plan, results };
|
|
80
|
+
}
|
|
81
|
+
segmentFailed = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// 覆盖确认:仅函数已存在(update)时触发,对齐 tcb fn deploy 的 FunctionConflictChecker 语义
|
|
85
|
+
// yes=true 直接放行;注入 confirmUpdate 按回调决定;两者都无 → 保守跳过(no-confirm)
|
|
86
|
+
if (item.type === 'functions' && item.status === 'update') {
|
|
87
|
+
let confirmed = false;
|
|
88
|
+
if (options.yes) {
|
|
89
|
+
confirmed = true;
|
|
90
|
+
}
|
|
91
|
+
else if (options.confirmUpdate) {
|
|
92
|
+
confirmed = await options.confirmUpdate(item);
|
|
93
|
+
}
|
|
94
|
+
if (!confirmed) {
|
|
95
|
+
const reason = options.confirmUpdate
|
|
96
|
+
? 'skipped-by-user'
|
|
97
|
+
: 'no-confirm';
|
|
98
|
+
(_e = log === null || log === void 0 ? void 0 : log.warn) === null || _e === void 0 ? void 0 : _e.call(log, `[${item.type}] ${item.name} 跳过覆盖更新(${reason})`);
|
|
99
|
+
results.push({ type: item.type, name: item.name, ok: false, reason });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
toExecute.push(item);
|
|
57
104
|
}
|
|
58
|
-
(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const message = '检测到冲突,已中断部署';
|
|
62
|
-
(_d = log === null || log === void 0 ? void 0 : log.error) === null || _d === void 0 ? void 0 : _d.call(log, `[${item.type}] ${item.name} ${message}`);
|
|
63
|
-
results.push({ type: item.type, name: item.name, ok: false, error: message });
|
|
64
|
-
if (item.type === 'database') {
|
|
105
|
+
if (toExecute.length === 0) {
|
|
106
|
+
// 段内无实际执行项:若本段已因 conflict 标记失败且 fail-fast,则中断
|
|
107
|
+
if (segmentFailed && !continueOnError) {
|
|
65
108
|
return { plan, results };
|
|
66
109
|
}
|
|
67
110
|
continue;
|
|
68
111
|
}
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
112
|
+
// 段内并行执行
|
|
113
|
+
const controller = new parallel_1.AsyncTaskParallelController(limit);
|
|
114
|
+
const tasks = toExecute.map((item) => async () => {
|
|
115
|
+
var _a;
|
|
116
|
+
try {
|
|
117
|
+
const stepResult = await this.executeStep({ item, config, envId, cwd });
|
|
118
|
+
results.push({ type: item.type, name: item.name, ok: true, url: stepResult });
|
|
75
119
|
}
|
|
76
|
-
|
|
77
|
-
|
|
120
|
+
catch (e) {
|
|
121
|
+
const message = (e === null || e === void 0 ? void 0 : e.message) || String(e);
|
|
122
|
+
(_a = log === null || log === void 0 ? void 0 : log.error) === null || _a === void 0 ? void 0 : _a.call(log, `[${item.type}] ${item.name} 部署失败: ${message}`);
|
|
123
|
+
results.push({ type: item.type, name: item.name, ok: false, error: message });
|
|
124
|
+
segmentFailed = true;
|
|
125
|
+
if (item.type === 'database') {
|
|
126
|
+
segmentDbFailed = true;
|
|
127
|
+
}
|
|
78
128
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
129
|
+
});
|
|
130
|
+
controller.loadTasks(tasks);
|
|
131
|
+
await controller.run();
|
|
132
|
+
// 段内失败中断决策
|
|
133
|
+
if (segmentFailed) {
|
|
134
|
+
if (segmentDbFailed) {
|
|
135
|
+
(_f = log === null || log === void 0 ? void 0 : log.error) === null || _f === void 0 ? void 0 : _f.call(log, '数据库迁移失败,中断部署');
|
|
136
|
+
return { plan, results };
|
|
86
137
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const stepResult = await this.executeStep({ item, config, envId, cwd });
|
|
90
|
-
results.push({ type: item.type, name: item.name, ok: true, url: stepResult });
|
|
91
|
-
}
|
|
92
|
-
catch (e) {
|
|
93
|
-
const message = (e === null || e === void 0 ? void 0 : e.message) || String(e);
|
|
94
|
-
(_f = log === null || log === void 0 ? void 0 : log.error) === null || _f === void 0 ? void 0 : _f.call(log, `[${item.type}] ${item.name} 部署失败: ${message}`);
|
|
95
|
-
results.push({ type: item.type, name: item.name, ok: false, error: message });
|
|
96
|
-
// database 失败中断整个部署:后续资源可能依赖新 Schema
|
|
97
|
-
if (item.type === 'database') {
|
|
98
|
-
(_g = log === null || log === void 0 ? void 0 : log.error) === null || _g === void 0 ? void 0 : _g.call(log, '数据库迁移失败,中断部署');
|
|
138
|
+
if (!continueOnError) {
|
|
139
|
+
(_g = log === null || log === void 0 ? void 0 : log.error) === null || _g === void 0 ? void 0 : _g.call(log, '存在资源部署失败,中断部署');
|
|
99
140
|
return { plan, results };
|
|
100
141
|
}
|
|
101
142
|
}
|
|
@@ -106,6 +147,33 @@ class DeployOrchestrator {
|
|
|
106
147
|
}
|
|
107
148
|
return { plan, results };
|
|
108
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* 将扁平 plan 按「连续相同资源类型」切成有序 segments
|
|
152
|
+
*
|
|
153
|
+
* segment 之间保留 DEPLOY_ORDER 依赖顺序(严格串行);segment 内部为同类型
|
|
154
|
+
* 多实例,可并行执行。例如 [db, fnA, fnB, hostingX, hostingY, gateway]
|
|
155
|
+
* → [[db], [fnA, fnB], [hostingX, hostingY], [gateway]]
|
|
156
|
+
*/
|
|
157
|
+
segmentPlan(plan) {
|
|
158
|
+
const segments = [];
|
|
159
|
+
let current = [];
|
|
160
|
+
let currentType;
|
|
161
|
+
for (const item of plan) {
|
|
162
|
+
if (currentType === undefined || item.type === currentType) {
|
|
163
|
+
current.push(item);
|
|
164
|
+
currentType = item.type;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
segments.push(current);
|
|
168
|
+
current = [item];
|
|
169
|
+
currentType = item.type;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (current.length > 0) {
|
|
173
|
+
segments.push(current);
|
|
174
|
+
}
|
|
175
|
+
return segments;
|
|
176
|
+
}
|
|
109
177
|
/**
|
|
110
178
|
* 部署成功后写回本地 state 快照
|
|
111
179
|
*
|
|
@@ -72,13 +72,39 @@ class GatewayDeployer {
|
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
if (toCreate.length > 0) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
Domain:
|
|
79
|
-
|
|
75
|
+
try {
|
|
76
|
+
await envService.createHttpServiceRoute({
|
|
77
|
+
EnvId: envId,
|
|
78
|
+
Domain: {
|
|
79
|
+
Domain: domain,
|
|
80
|
+
Routes: toCreate
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
// 路由已存在(getExistingRoutes 因域名格式差异漏查,导致误判 create):
|
|
86
|
+
// 幂等降级为「逐个创建,已存在的跳过」,避免整批失败
|
|
87
|
+
if (!this.isAlreadyExistsError(e)) {
|
|
88
|
+
throw e;
|
|
80
89
|
}
|
|
81
|
-
|
|
90
|
+
for (const route of toCreate) {
|
|
91
|
+
try {
|
|
92
|
+
await envService.createHttpServiceRoute({
|
|
93
|
+
EnvId: envId,
|
|
94
|
+
Domain: {
|
|
95
|
+
Domain: domain,
|
|
96
|
+
Routes: [route]
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (e2) {
|
|
101
|
+
// 该条已存在 → 跳过(已收敛);否则(环境/资源不存在等真实失败)必须抛出,避免静默吞掉
|
|
102
|
+
if (!this.isAlreadyExistsError(e2)) {
|
|
103
|
+
throw e2;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
82
108
|
}
|
|
83
109
|
deployedDomains.push(domain);
|
|
84
110
|
for (let i = 0; i < parsedRoutes.length; i++) {
|
|
@@ -197,24 +223,80 @@ class GatewayDeployer {
|
|
|
197
223
|
}
|
|
198
224
|
/**
|
|
199
225
|
* 查询指定域名下已存在的路由(幂等收敛用),返回 Map<path, 云端路由详情>
|
|
226
|
+
*
|
|
227
|
+
* 匹配策略(优先级从高到低):
|
|
228
|
+
* 1. 用 Filters 精确查询目标域名:DescribeHTTPServiceRoute 不带 Filters 时返回的 Domains
|
|
229
|
+
* 列表可能不完整(如 lowcode 环境 TotalCount=67 但 Domains 只返回部分,HTTPSERVICE 的
|
|
230
|
+
* .app.tcloudbase.com 域名缺失),导致云端已有路由查不到被误判为 create
|
|
231
|
+
* 2. 降级:不带 Filters 全量查询,按域名归一化匹配(去协议/尾斜杠/转小写)
|
|
232
|
+
* 若目标域名找不到则返回空(保留 domain 维度,避免把其他域名同 path 误判为已存在)
|
|
200
233
|
*/
|
|
201
234
|
async getExistingRoutes(envId, domain) {
|
|
235
|
+
var _a;
|
|
236
|
+
const normalizedTarget = this.normalizeDomain(domain);
|
|
237
|
+
// 1. Filters 精确查询目标域名(deleteCustomDomain 同款用法)
|
|
202
238
|
try {
|
|
203
239
|
const res = await this.environment.getEnvService().describeHttpServiceRoute({
|
|
204
|
-
EnvId: envId
|
|
240
|
+
EnvId: envId,
|
|
241
|
+
Filters: [{ Name: 'Domain', Values: [domain] }]
|
|
205
242
|
});
|
|
206
|
-
const targetDomain = (res.Domains || []).find(d => d.Domain ===
|
|
243
|
+
const targetDomain = (res.Domains || []).find((d) => this.normalizeDomain(d.Domain) === normalizedTarget);
|
|
207
244
|
const map = new Map();
|
|
208
245
|
for (const route of (targetDomain === null || targetDomain === void 0 ? void 0 : targetDomain.Routes) || []) {
|
|
209
246
|
map.set(route.Path, route);
|
|
210
247
|
}
|
|
248
|
+
if (map.size > 0) {
|
|
249
|
+
return map;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (_b) {
|
|
253
|
+
// Filters 查询失败(环境不支持)→ 走降级
|
|
254
|
+
}
|
|
255
|
+
// 2. 降级:全量查询 + 归一化匹配(严格保留域名维度)
|
|
256
|
+
try {
|
|
257
|
+
const res = await this.environment.getEnvService().describeHttpServiceRoute({
|
|
258
|
+
EnvId: envId
|
|
259
|
+
});
|
|
260
|
+
const targetDomain = (res.Domains || []).find((d) => this.normalizeDomain(d.Domain) === normalizedTarget);
|
|
261
|
+
const map = new Map();
|
|
262
|
+
if ((_a = targetDomain === null || targetDomain === void 0 ? void 0 : targetDomain.Routes) === null || _a === void 0 ? void 0 : _a.length) {
|
|
263
|
+
for (const route of targetDomain.Routes) {
|
|
264
|
+
map.set(route.Path, route);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// 不再按 path 跨域兜底归并:
|
|
268
|
+
// 路由唯一键是 (domain, path),不同域名同 path 合法;
|
|
269
|
+
// 若目标域名缺失,返回空并交由 create + AlreadyExists 幂等降级收敛,避免误判 skip/update。
|
|
211
270
|
return map;
|
|
212
271
|
}
|
|
213
|
-
catch (
|
|
272
|
+
catch (_c) {
|
|
214
273
|
// 查询失败(首次部署/环境未初始化)视为无已存在路由
|
|
215
274
|
return new Map();
|
|
216
275
|
}
|
|
217
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* 域名归一化:去协议、去尾斜杠、转小写(用于幂等收敛的域名匹配)
|
|
279
|
+
*/
|
|
280
|
+
normalizeDomain(domain) {
|
|
281
|
+
if (!domain)
|
|
282
|
+
return '';
|
|
283
|
+
return domain.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase();
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* 判断错误是否属于「路由已存在/已占用」类(幂等降级专用)
|
|
287
|
+
*
|
|
288
|
+
* 只匹配明确的重复/占用语义(code 含 AlreadyExists/Duplicate/ResourceInUse/Conflict,
|
|
289
|
+
* 或 message 含 already exists),并显式排除否定语义(not exists/not found 等),
|
|
290
|
+
* 避免把「环境/资源不存在」等真实失败误判为「已存在」而被静默跳过
|
|
291
|
+
*/
|
|
292
|
+
isAlreadyExistsError(e) {
|
|
293
|
+
const msg = String((e === null || e === void 0 ? void 0 : e.message) || '') + String((e === null || e === void 0 ? void 0 : e.code) || '');
|
|
294
|
+
// 否定语义优先:资源/环境不存在 → 不是「已存在」,必须抛出让调用方感知真实失败
|
|
295
|
+
if (/not\s*exists?|doesn't?\s*exist|does\s+not\s+exist|not\s*found/i.test(msg)) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
return /already\s*exists?|already[_\s-]*exists|AlreadyExists|ALREADY_EXISTS|RESOURCE_ALREADY_EXISTS|Duplicate|ResourceInUse|ResourceOccupied|Conflict|已存在/i.test(msg);
|
|
299
|
+
}
|
|
218
300
|
/**
|
|
219
301
|
* 判断期望路由与云端已存在路由是否一致
|
|
220
302
|
*
|
|
@@ -395,8 +477,16 @@ class GatewayDeployer {
|
|
|
395
477
|
`或为该路由显式配置 pathRewrite.prefix/staticStorePrefix 以引用已部署的托管实例`);
|
|
396
478
|
}
|
|
397
479
|
// 未显式配置时自动生成 Prefix = hosting 部署路径
|
|
480
|
+
// 透传会覆盖路径重写:用户显式开启 enablePathTransmission=true 时与自动重写冲突,
|
|
481
|
+
// 不再静默强制关闭(避免反向改写云端用户显式配置),明确报错让用户二选一
|
|
398
482
|
if (hosting.deployPath) {
|
|
399
|
-
|
|
483
|
+
if (route.enablePathTransmission === true) {
|
|
484
|
+
throw new error_1.CloudBaseError(`网关路由 ${route.path} 目标为 hosting:${hostingName} 且未显式配置 pathRewrite:` +
|
|
485
|
+
`部署将自动生成 PathRewrite.Prefix=${hosting.deployPath},` +
|
|
486
|
+
`但 enablePathTransmission=true 会覆盖路径重写导致请求打不到托管部署路径。` +
|
|
487
|
+
`请删除 enablePathTransmission,或显式配置 pathRewrite.prefix`);
|
|
488
|
+
}
|
|
489
|
+
return Object.assign(Object.assign({}, parsed), { EnablePathTransmission: false, PathRewrite: { Prefix: hosting.deployPath } });
|
|
400
490
|
}
|
|
401
491
|
return parsed;
|
|
402
492
|
}
|
|
@@ -79,28 +79,6 @@ class StaticDeployer {
|
|
|
79
79
|
ignore: config.ignore || ['.DS_Store']
|
|
80
80
|
};
|
|
81
81
|
await this.environment.getHostingService().uploadFiles(hostingOptions);
|
|
82
|
-
// SPA 回退:
|
|
83
|
-
// - true:404 时返回 index.html(OriginalHttpStatus=Disabled 保留 200,Charity404=Disabled 防公益页面覆盖)
|
|
84
|
-
// - false:显式取消,重置 ErrorDocument 为空(静态托管首页 IndexDocument 保留)
|
|
85
|
-
// - undefined:不关心,保持云端现状
|
|
86
|
-
// 注意:putBucketWebsite 是整体覆盖,设置前先读取现有配置并保留 RoutingRules + AutoAddressing,
|
|
87
|
-
// 否则会清空用户之前在控制台配置的路由规则(如路径前缀重写/错误码重定向)和忽略 .html 扩展名能力
|
|
88
|
-
if (config.spaFallback === true) {
|
|
89
|
-
try {
|
|
90
|
-
await this.environment.getHostingService().setWebsiteDocument(Object.assign({ indexDocument: 'index.html', errorDocument: 'index.html', originalHttpStatus: 'Disabled', charity404: 'Disabled' }, (await this.preserveWebsiteConfig())));
|
|
91
|
-
}
|
|
92
|
-
catch (e) {
|
|
93
|
-
throw new error_1.CloudBaseError(`[${name}] 设置 SPA 回退失败(spaFallback: true):${(e === null || e === void 0 ? void 0 : e.message) || String(e)}`);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
else if (config.spaFallback === false) {
|
|
97
|
-
try {
|
|
98
|
-
await this.environment.getHostingService().setWebsiteDocument(Object.assign({ indexDocument: 'index.html', errorDocument: '' }, (await this.preserveWebsiteConfig())));
|
|
99
|
-
}
|
|
100
|
-
catch (e) {
|
|
101
|
-
throw new error_1.CloudBaseError(`[${name}] 取消 SPA 回退失败(spaFallback: false):${(e === null || e === void 0 ? void 0 : e.message) || String(e)}`);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
82
|
const url = await this.resolveHostingUrl(cloudPath);
|
|
105
83
|
return { name, url, wroteRecord: false };
|
|
106
84
|
}
|
|
@@ -188,34 +166,6 @@ class StaticDeployer {
|
|
|
188
166
|
const url = await this.resolveAppUrl(serviceName, config.deployPath || `/${serviceName}`);
|
|
189
167
|
return { name: serviceName, url, wroteRecord: true };
|
|
190
168
|
}
|
|
191
|
-
/**
|
|
192
|
-
* 读取现有静态网站配置并保留 RoutingRules + AutoAddressing
|
|
193
|
-
* (putBucketWebsite 为整体覆盖,设置 SPA 回退前必须带回已有配置,否则会被清空)
|
|
194
|
-
*/
|
|
195
|
-
async preserveWebsiteConfig() {
|
|
196
|
-
var _a;
|
|
197
|
-
try {
|
|
198
|
-
const res = await this.environment.getHostingService().getWebsiteConfig();
|
|
199
|
-
const website = (res === null || res === void 0 ? void 0 : res.WebsiteConfiguration) || {};
|
|
200
|
-
const rules = website.RoutingRules || [];
|
|
201
|
-
return {
|
|
202
|
-
routingRules: rules.map((r) => {
|
|
203
|
-
var _a, _b, _c, _d;
|
|
204
|
-
return ({
|
|
205
|
-
keyPrefixEquals: (_a = r.Condition) === null || _a === void 0 ? void 0 : _a.KeyPrefixEquals,
|
|
206
|
-
httpErrorCodeReturnedEquals: (_b = r.Condition) === null || _b === void 0 ? void 0 : _b.HttpErrorCodeReturnedEquals,
|
|
207
|
-
replaceKeyWith: (_c = r.Redirect) === null || _c === void 0 ? void 0 : _c.ReplaceKeyWith,
|
|
208
|
-
replaceKeyPrefixWith: (_d = r.Redirect) === null || _d === void 0 ? void 0 : _d.ReplaceKeyPrefixWith
|
|
209
|
-
});
|
|
210
|
-
}),
|
|
211
|
-
autoAddressing: (_a = website.AutoAddressing) === null || _a === void 0 ? void 0 : _a.Status
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
catch (_b) {
|
|
215
|
-
// 查询失败(未开启静态网站/首次部署)视为无已有配置
|
|
216
|
-
return { routingRules: [], autoAddressing: undefined };
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
169
|
/**
|
|
220
170
|
* 解析 hosting 访问地址:https://{CdnDomain}{deployPath}
|
|
221
171
|
*/
|
package/lib/deploy/domain.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.resolveAccessServiceDomain = resolveAccessServiceDomain;
|
|
4
4
|
exports.getGatewayBaseUrl = getGatewayBaseUrl;
|
|
5
|
+
/** 环境 HTTP 访问服务默认域名后缀(如 `xxx-1326375956.ap-shanghai.${ACCESS_DOMAIN_SUFFIX}`) */
|
|
6
|
+
const ACCESS_DOMAIN_SUFFIX = 'app.tcloudbase.com';
|
|
5
7
|
/**
|
|
6
|
-
* 构造环境 HTTP 访问服务默认域名 `${envId}-${AppId}.${region}
|
|
8
|
+
* 构造环境 HTTP 访问服务默认域名 `${envId}-${AppId}.${region}.${ACCESS_DOMAIN_SUFFIX}`
|
|
7
9
|
*
|
|
8
10
|
* 关键:用 `UserInfo.AppId`(腾讯云 appid)而非 `UserInfo.Uin`(账号 uin)。
|
|
9
|
-
* 控制台默认域名格式是 `${envId}-${AppId}.${region}
|
|
11
|
+
* 控制台默认域名格式是 `${envId}-${AppId}.${region}.${ACCESS_DOMAIN_SUFFIX}`
|
|
10
12
|
* (如 `xxx-1326375956.ap-shanghai.app.tcloudbase.com`),与 cookie 中的 `appid` 字段对应。
|
|
11
13
|
* AppId/Region 缺失或查询失败时返回 undefined
|
|
12
14
|
*/
|
|
@@ -17,7 +19,7 @@ async function resolveAccessServiceDomain(environment, envId) {
|
|
|
17
19
|
const appId = (_a = EnvInfo === null || EnvInfo === void 0 ? void 0 : EnvInfo.UserInfo) === null || _a === void 0 ? void 0 : _a.AppId;
|
|
18
20
|
const region = EnvInfo === null || EnvInfo === void 0 ? void 0 : EnvInfo.Region;
|
|
19
21
|
if (appId && region) {
|
|
20
|
-
return `${envId}-${appId}.${region}
|
|
22
|
+
return `${envId}-${appId}.${region}.${ACCESS_DOMAIN_SUFFIX}`;
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
catch (_b) {
|
|
@@ -41,6 +41,12 @@ const POLL_MAX_TRANSIENT_ERRORS = 5;
|
|
|
41
41
|
const SAFE_REGISTRY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/;
|
|
42
42
|
/** tag 白名单:字母数字、下划线、点、中划线 */
|
|
43
43
|
const SAFE_TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
44
|
+
/** Dockerfile 路径:只能是构建上下文内的安全相对路径 */
|
|
45
|
+
const SAFE_DOCKERFILE_PATTERN = /^[a-zA-Z0-9._\-/]+$/;
|
|
46
|
+
/** Docker build arg key 与环境变量同规则 */
|
|
47
|
+
const SAFE_BUILD_ARG_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
48
|
+
/** 疑似敏感构建参数名;cloud 构建会把参数写入命令/构建记录,因此直接拒绝 */
|
|
49
|
+
const SENSITIVE_BUILD_ARG_KEY_PATTERN = /(secret|token|password|passwd|credential|private[_-]?key|access[_-]?key)/i;
|
|
44
50
|
/** 校验仓库/命名空间等镜像地址片段,拒绝命令注入风险字符 */
|
|
45
51
|
function assertSafeRegistryToken(value, field) {
|
|
46
52
|
if (!SAFE_REGISTRY_PATTERN.test(value)) {
|
|
@@ -62,6 +68,40 @@ function assertSafeTag(tag) {
|
|
|
62
68
|
});
|
|
63
69
|
}
|
|
64
70
|
}
|
|
71
|
+
/** 校验 Dockerfile 路径,避免通过 shell command 逃逸构建上下文 */
|
|
72
|
+
function assertSafeDockerfile(dockerfile) {
|
|
73
|
+
const value = dockerfile.trim();
|
|
74
|
+
const segments = value.split('/');
|
|
75
|
+
if (!value ||
|
|
76
|
+
value.length > 512 ||
|
|
77
|
+
value.startsWith('/') ||
|
|
78
|
+
value.startsWith('\\') ||
|
|
79
|
+
/^[A-Za-z]:[\\/]/.test(value) ||
|
|
80
|
+
!SAFE_DOCKERFILE_PATTERN.test(value) ||
|
|
81
|
+
segments.some(segment => segment === '..')) {
|
|
82
|
+
throw new error_1.CloudBaseError('Dockerfile 路径不合法,需为构建上下文内的安全相对路径', {
|
|
83
|
+
code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_DOCKERFILE_INVALID
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** 校验 cloud 构建参数;值进入 CloudApp Env,command 中只引用固定变量名 */
|
|
88
|
+
function assertSafeBuildArgs(buildArgs) {
|
|
89
|
+
for (const [key, value] of Object.entries(buildArgs)) {
|
|
90
|
+
if (!SAFE_BUILD_ARG_KEY_PATTERN.test(key)) {
|
|
91
|
+
throw new error_1.CloudBaseError(`构建参数名不合法:${key}`, {
|
|
92
|
+
code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_ARG_INVALID
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (SENSITIVE_BUILD_ARG_KEY_PATTERN.test(key)) {
|
|
96
|
+
throw new error_1.CloudBaseError(`构建参数 ${key} 可能包含敏感信息;cloud 构建禁止通过 buildArgs 传递密钥`, { code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_ARG_INVALID });
|
|
97
|
+
}
|
|
98
|
+
if (typeof value !== 'string' || /[\u0000]/.test(value)) {
|
|
99
|
+
throw new error_1.CloudBaseError(`构建参数 ${key} 的值必须是无 NUL 字符的字符串`, {
|
|
100
|
+
code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_ARG_INVALID
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
65
105
|
/** 个人版 CCR 默认域名 */
|
|
66
106
|
const DEFAULT_PERSONAL_REGISTRY = 'ccr.ccs.tencentyun.com';
|
|
67
107
|
/** 注入到构建上下文 zip 中的推送脚本相对路径(与 CustomSteps 的 push命令一致) */
|
|
@@ -222,18 +262,50 @@ function resolveTarget(config) {
|
|
|
222
262
|
};
|
|
223
263
|
}
|
|
224
264
|
/**
|
|
225
|
-
*
|
|
265
|
+
* 解析并校验 cloud 构建执行参数。
|
|
266
|
+
* 用户值不直接拼入 shell:Dockerfile/platform/buildArgs value 均通过 CloudApp Env 注入,
|
|
267
|
+
* CustomSteps 只包含 SDK 固定命令和经过白名单校验的 build arg key。
|
|
268
|
+
*/
|
|
269
|
+
function resolveCloudBuildOptions(config) {
|
|
270
|
+
const dockerfile = config.build.dockerfile || types_1.DEFAULT_DOCKERFILE;
|
|
271
|
+
const platform = config.build.platform || types_1.DEFAULT_IMAGE_PLATFORM;
|
|
272
|
+
const buildArgs = config.build.buildArgs || {};
|
|
273
|
+
assertSafeDockerfile(dockerfile);
|
|
274
|
+
if (platform !== types_1.DEFAULT_IMAGE_PLATFORM) {
|
|
275
|
+
throw new error_1.CloudBaseError(`当前仅支持构建 ${types_1.DEFAULT_IMAGE_PLATFORM} 镜像`, {
|
|
276
|
+
code: types_1.FUNCTION_DEPLOY_ERROR.BUILD_PLATFORM_INVALID
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
assertSafeBuildArgs(buildArgs);
|
|
280
|
+
return {
|
|
281
|
+
dockerfile,
|
|
282
|
+
platform,
|
|
283
|
+
buildArgs,
|
|
284
|
+
forceBuild: config.build.forceBuild === true
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* 生成固定的 CustomSteps 模板(build + push)。
|
|
226
289
|
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* 这些变量的值已在 resolveTarget 中做白名单校验,故命令本身无注入面。
|
|
290
|
+
* Dockerfile、platform 和 build arg value 通过 CloudApp Env 注入;只有通过严格
|
|
291
|
+
* ENV key 校验的 build arg key 会进入固定 command。forceBuild 显式映射为 --no-cache。
|
|
230
292
|
*/
|
|
231
|
-
function buildCustomSteps() {
|
|
293
|
+
function buildCustomSteps(options) {
|
|
294
|
+
const buildArgs = Object.keys(options.buildArgs)
|
|
295
|
+
.sort()
|
|
296
|
+
.map(key => `--build-arg ${key}="$BUILD_ARG_${key}"`)
|
|
297
|
+
.join(' ');
|
|
298
|
+
const noCache = options.forceBuild ? ' --no-cache' : '';
|
|
299
|
+
const extraArgs = buildArgs ? ` ${buildArgs}` : '';
|
|
232
300
|
return [
|
|
233
301
|
{
|
|
234
302
|
name: 'build-image',
|
|
235
|
-
command: 'docker build
|
|
236
|
-
'$
|
|
303
|
+
command: 'docker build' +
|
|
304
|
+
' --platform "$CLOUDBASE_BUILD_PLATFORM"' +
|
|
305
|
+
' -f "$CLOUDBASE_DOCKERFILE"' +
|
|
306
|
+
noCache +
|
|
307
|
+
extraArgs +
|
|
308
|
+
' -t $TCR_REGISTRY/$TCR_NAMESPACE/$CLOUDBASE_SERVICE_NAME:$CLOUDBASE_VERSION_NAME .'
|
|
237
309
|
},
|
|
238
310
|
{
|
|
239
311
|
name: 'push-image',
|
|
@@ -249,10 +321,16 @@ function buildCustomSteps() {
|
|
|
249
321
|
* - TCR_INSTANCE_ID:仅企业版 TCR 注入,脚本据此走 CreateInstanceToken 分支;
|
|
250
322
|
* 个人版 CCR 不注入,脚本走 STS 直接 docker login 分支。
|
|
251
323
|
*/
|
|
252
|
-
function buildEnv(target, region) {
|
|
324
|
+
function buildEnv(target, buildOptions, region) {
|
|
253
325
|
const env = [
|
|
254
326
|
{ key: 'TCR_REGISTRY', value: target.registry },
|
|
255
|
-
{ key: 'TCR_NAMESPACE', value: target.namespace }
|
|
327
|
+
{ key: 'TCR_NAMESPACE', value: target.namespace },
|
|
328
|
+
{ key: 'CLOUDBASE_DOCKERFILE', value: buildOptions.dockerfile },
|
|
329
|
+
{ key: 'CLOUDBASE_BUILD_PLATFORM', value: buildOptions.platform },
|
|
330
|
+
...Object.entries(buildOptions.buildArgs).map(([key, value]) => ({
|
|
331
|
+
key: `BUILD_ARG_${key}`,
|
|
332
|
+
value
|
|
333
|
+
}))
|
|
256
334
|
];
|
|
257
335
|
if (region) {
|
|
258
336
|
env.push({ key: 'TCR_REGION', value: region });
|
|
@@ -331,7 +409,8 @@ function summarizeFailedSteps(steps) {
|
|
|
331
409
|
async function buildImageOnCloud(config, service, options = {}) {
|
|
332
410
|
const { onLog, region } = options;
|
|
333
411
|
const target = resolveTarget(config);
|
|
334
|
-
|
|
412
|
+
const buildOptions = resolveCloudBuildOptions(config);
|
|
413
|
+
// 在打包和上传前完成真实消费字段校验,避免无效配置触发远端资源操作。
|
|
335
414
|
const registryCredentials = buildRegistryCredentials(config, target);
|
|
336
415
|
const serviceName = target.serviceName;
|
|
337
416
|
const cwd = config.build.cwd || config.functionPath || process.cwd();
|
|
@@ -361,9 +440,12 @@ async function buildImageOnCloud(config, service, options = {}) {
|
|
|
361
440
|
const created = await service.createBuild({
|
|
362
441
|
serviceName,
|
|
363
442
|
cosTimestamp: cosInfo.unixTimestamp,
|
|
364
|
-
env: [
|
|
443
|
+
env: [
|
|
444
|
+
...buildEnv(target, buildOptions, region),
|
|
445
|
+
...registryCredentials.env
|
|
446
|
+
],
|
|
365
447
|
secrets: registryCredentials.secrets,
|
|
366
|
-
customSteps: buildCustomSteps()
|
|
448
|
+
customSteps: buildCustomSteps(buildOptions)
|
|
367
449
|
});
|
|
368
450
|
if (!(created === null || created === void 0 ? void 0 : created.versionName)) {
|
|
369
451
|
throw new error_1.CloudBaseError('触发云端构建失败:返回缺少 VersionName', {
|
|
@@ -449,7 +449,11 @@ function checkBuildTarget(raw, checks) {
|
|
|
449
449
|
checks.push(fail(types_1.FUNCTION_DEPLOY_ERROR.BUILD_ARG_INVALID, `构建参数 ${key} 的值必须是字符串`));
|
|
450
450
|
}
|
|
451
451
|
if (SENSITIVE_KEY_PATTERN.test(key)) {
|
|
452
|
-
|
|
452
|
+
const message = `构建参数 ${key} 可能包含敏感信息`;
|
|
453
|
+
const remediation = '构建参数会写入镜像构建记录,请改用运行时环境变量或密钥管理';
|
|
454
|
+
checks.push(raw.buildStrategy === 'cloud'
|
|
455
|
+
? fail(types_1.FUNCTION_DEPLOY_ERROR.BUILD_ARG_INVALID, message, remediation)
|
|
456
|
+
: warn(types_1.FUNCTION_DEPLOY_ERROR.BUILD_ARG_INVALID, message, remediation));
|
|
453
457
|
}
|
|
454
458
|
});
|
|
455
459
|
}
|
|
@@ -64,6 +64,22 @@ function parseDigest(output) {
|
|
|
64
64
|
const match = output.match(/Digest:\s*(sha256:[0-9a-f]{64})/i);
|
|
65
65
|
return match ? match[1] : undefined;
|
|
66
66
|
}
|
|
67
|
+
/** 生成脱敏后的命令摘要,避免 buildArgs 值进入部署日志 */
|
|
68
|
+
function formatBuildCommandForLog(args) {
|
|
69
|
+
const displayArgs = [];
|
|
70
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
71
|
+
const arg = args[index];
|
|
72
|
+
displayArgs.push(arg);
|
|
73
|
+
if (arg === '--build-arg' && index + 1 < args.length) {
|
|
74
|
+
const buildArg = args[index + 1];
|
|
75
|
+
const separatorIndex = buildArg.indexOf('=');
|
|
76
|
+
const key = separatorIndex >= 0 ? buildArg.slice(0, separatorIndex) : buildArg;
|
|
77
|
+
displayArgs.push(`${key}=<redacted>`);
|
|
78
|
+
index += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return `docker ${displayArgs.join(' ')}`;
|
|
82
|
+
}
|
|
67
83
|
/**
|
|
68
84
|
* 本地构建镜像并推送到目标仓库
|
|
69
85
|
*
|
|
@@ -90,16 +106,19 @@ async function buildAndPushImage(config, onLog, deps = defaultDeps) {
|
|
|
90
106
|
const platform = config.build.platform || types_1.DEFAULT_IMAGE_PLATFORM;
|
|
91
107
|
// 组装 buildx 参数:构建目标平台镜像并直接推送
|
|
92
108
|
const args = ['buildx', 'build', '--platform', platform, '-f', dockerfilePath, '-t', imageUri];
|
|
109
|
+
if (config.build.forceBuild) {
|
|
110
|
+
args.push('--no-cache');
|
|
111
|
+
}
|
|
93
112
|
// 追加构建参数(键值均作为独立参数,无 shell 拼接)
|
|
94
113
|
const buildArgs = config.build.buildArgs || {};
|
|
95
114
|
for (const key of Object.keys(buildArgs)) {
|
|
96
115
|
args.push('--build-arg', `${key}=${buildArgs[key]}`);
|
|
97
116
|
}
|
|
98
117
|
args.push('--push', context);
|
|
99
|
-
onLog === null || onLog === void 0 ? void 0 : onLog(`执行
|
|
118
|
+
onLog === null || onLog === void 0 ? void 0 : onLog(`执行 ${formatBuildCommandForLog(args)}`);
|
|
100
119
|
const build = await deps.runCommand('docker', args);
|
|
101
120
|
if (!build.ok) {
|
|
102
|
-
throw new error_1.CloudBaseError(
|
|
121
|
+
throw new error_1.CloudBaseError('镜像构建或推送失败,请查看 Docker 构建输出定位具体原因', {
|
|
103
122
|
code: types_1.FUNCTION_DEPLOY_ERROR.LOCAL_PUSH_FAILED
|
|
104
123
|
});
|
|
105
124
|
}
|
|
@@ -42,7 +42,7 @@ class ProjectValidator {
|
|
|
42
42
|
const resourceIssues = this.validateResources(cwd, loaded.config);
|
|
43
43
|
errors.push(...resourceIssues.errors);
|
|
44
44
|
warnings.push(...resourceIssues.warnings);
|
|
45
|
-
this.validateConsistency(loaded.config, errors);
|
|
45
|
+
this.validateConsistency(loaded.config, errors, warnings);
|
|
46
46
|
}
|
|
47
47
|
const summary = this.getSummary(loaded.config);
|
|
48
48
|
return {
|
|
@@ -277,11 +277,76 @@ class ProjectValidator {
|
|
|
277
277
|
}
|
|
278
278
|
return { errors, warnings };
|
|
279
279
|
}
|
|
280
|
-
validateConsistency(config, errors) {
|
|
280
|
+
validateConsistency(config, errors, warnings = []) {
|
|
281
281
|
this.validateUniqueNames('functions', config.functions || [], errors);
|
|
282
282
|
this.validateUniqueNames('hosting', config.hosting || [], errors);
|
|
283
283
|
this.validateUniqueField('hosting', config.hosting || [], { field: 'deployPath', errors });
|
|
284
284
|
this.validateGatewayTargets(config, errors);
|
|
285
|
+
this.validateGatewayPathRewriteConflicts(config, warnings);
|
|
286
|
+
this.validateHostingSubPathBaseRisks(config, warnings);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* 检测 hosting 路由中 enablePathTransmission=true 与自动生成 pathRewrite 的冲突
|
|
290
|
+
*
|
|
291
|
+
* hosting 目标未显式配置 pathRewrite 时,部署会自动生成 PathRewrite.Prefix = hosting.deployPath。
|
|
292
|
+
* 若同时显式开启 enablePathTransmission=true,路径透传会覆盖路径重写,导致请求打到错误的托管路径(404)。
|
|
293
|
+
*/
|
|
294
|
+
validateGatewayPathRewriteConflicts(config, warnings) {
|
|
295
|
+
var _a;
|
|
296
|
+
const hostingMap = new Map((config.hosting || []).map((h) => [h.name, h]));
|
|
297
|
+
(((_a = config.gateway) === null || _a === void 0 ? void 0 : _a.routes) || []).forEach((route, index) => {
|
|
298
|
+
var _a, _b, _c;
|
|
299
|
+
if (!((_a = route.target) === null || _a === void 0 ? void 0 : _a.startsWith('hosting:')))
|
|
300
|
+
return;
|
|
301
|
+
// 用户显式配置了 pathRewrite → 无自动重写,无冲突
|
|
302
|
+
if (((_b = route.pathRewrite) === null || _b === void 0 ? void 0 : _b.prefix) || ((_c = route.pathRewrite) === null || _c === void 0 ? void 0 : _c.staticStorePrefix))
|
|
303
|
+
return;
|
|
304
|
+
// 未显式开启透传 → 无冲突
|
|
305
|
+
if (route.enablePathTransmission !== true)
|
|
306
|
+
return;
|
|
307
|
+
const hostingName = route.target.slice('hosting:'.length);
|
|
308
|
+
const hosting = hostingMap.get(hostingName);
|
|
309
|
+
// hosting 无 deployPath → 不会自动生成 PathRewrite,不冲突
|
|
310
|
+
if (!(hosting === null || hosting === void 0 ? void 0 : hosting.deployPath))
|
|
311
|
+
return;
|
|
312
|
+
warnings.push({
|
|
313
|
+
level: 'warning',
|
|
314
|
+
resource: `gateway.routes[${index}]`,
|
|
315
|
+
field: 'enablePathTransmission',
|
|
316
|
+
message: `hosting 路由 ${route.target} 未显式配置 pathRewrite,部署将自动生成 ` +
|
|
317
|
+
`PathRewrite.Prefix=${hosting.deployPath},但 enablePathTransmission=true 会覆盖路径重写,` +
|
|
318
|
+
`导致请求打不到托管实际部署路径`,
|
|
319
|
+
fix: '删除 enablePathTransmission,让自动生成的 pathRewrite 生效;' +
|
|
320
|
+
'或显式配置 pathRewrite.prefix 指向托管部署路径',
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* 子路径部署(deployPath !== '/')下,前端构建产物若仍使用根路径 base(如 Vite 默认 '/'),
|
|
326
|
+
* 会导致资源请求落到 /assets/* 而非 /{deployPath}/assets/*,出现 404/白屏。
|
|
327
|
+
*
|
|
328
|
+
* 这里在“可能存在构建步骤”的 hosting 配置上给出防坑告警。
|
|
329
|
+
*/
|
|
330
|
+
validateHostingSubPathBaseRisks(config, warnings) {
|
|
331
|
+
;
|
|
332
|
+
(config.hosting || []).forEach((hosting, index) => {
|
|
333
|
+
const deployPath = typeof hosting.deployPath === 'string' ? hosting.deployPath.trim() : '';
|
|
334
|
+
if (!deployPath || deployPath === '/')
|
|
335
|
+
return;
|
|
336
|
+
const framework = typeof hosting.framework === 'string' ? hosting.framework.trim().toLowerCase() : '';
|
|
337
|
+
const hasBuildStep = (typeof hosting.buildCommand === 'string' && hosting.buildCommand.trim().length > 0) ||
|
|
338
|
+
(!!framework && framework !== 'static' && framework !== 'custom');
|
|
339
|
+
if (!hasBuildStep)
|
|
340
|
+
return;
|
|
341
|
+
warnings.push({
|
|
342
|
+
level: 'warning',
|
|
343
|
+
resource: `hosting[${index}]`,
|
|
344
|
+
field: 'deployPath',
|
|
345
|
+
message: `静态托管部署在子路径 ${deployPath},请确认前端构建 base 与子路径匹配,` +
|
|
346
|
+
`否则资源可能请求到根路径(如 /assets/*)导致 404/白屏`,
|
|
347
|
+
fix: "建议将前端 base 配置为 './' 或与 deployPath 对齐(例如 '/serviceName/')",
|
|
348
|
+
});
|
|
349
|
+
});
|
|
285
350
|
}
|
|
286
351
|
validateUniqueNames(resource, items, errors) {
|
|
287
352
|
const names = new Map();
|
package/lib/utils/index.js
CHANGED
|
@@ -58,6 +58,17 @@ async function compressToZip(option) {
|
|
|
58
58
|
fs_extra_1.default.mkdirpSync(extraTmpDir);
|
|
59
59
|
}
|
|
60
60
|
return new Promise((resolve, reject) => {
|
|
61
|
+
// 幂等清理:archiver 的 error 事件与 output.close 事件在不同代码路径上,
|
|
62
|
+
// 但 extraEntries 写入异常分支调用 archive.abort() 后的异步收尾流程
|
|
63
|
+
// 仍可能间接触发 archive.error,导致 cleanupExtra 被多次调用。
|
|
64
|
+
// 用 done 标志保证清理和 resolve/reject 仅生效一次。
|
|
65
|
+
let done = false;
|
|
66
|
+
const settle = (fn) => {
|
|
67
|
+
if (done)
|
|
68
|
+
return;
|
|
69
|
+
done = true;
|
|
70
|
+
fn();
|
|
71
|
+
};
|
|
61
72
|
const cleanupExtra = () => {
|
|
62
73
|
if (extraTmpDir) {
|
|
63
74
|
try {
|
|
@@ -71,15 +82,19 @@ async function compressToZip(option) {
|
|
|
71
82
|
const output = fs_extra_1.default.createWriteStream(outputPath);
|
|
72
83
|
const archive = (0, archiver_1.default)('zip');
|
|
73
84
|
output.on('close', function () {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
85
|
+
settle(() => {
|
|
86
|
+
cleanupExtra();
|
|
87
|
+
resolve({
|
|
88
|
+
zipPath: outputPath,
|
|
89
|
+
size: Math.ceil(archive.pointer() / 1024)
|
|
90
|
+
});
|
|
78
91
|
});
|
|
79
92
|
});
|
|
80
93
|
archive.on('error', function (err) {
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
settle(() => {
|
|
95
|
+
cleanupExtra();
|
|
96
|
+
reject(err);
|
|
97
|
+
});
|
|
83
98
|
});
|
|
84
99
|
archive.pipe(output);
|
|
85
100
|
// append files from a glob pattern
|
|
@@ -102,9 +117,11 @@ async function compressToZip(option) {
|
|
|
102
117
|
});
|
|
103
118
|
}
|
|
104
119
|
catch (error) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
settle(() => {
|
|
121
|
+
cleanupExtra();
|
|
122
|
+
archive.abort();
|
|
123
|
+
reject(error);
|
|
124
|
+
});
|
|
108
125
|
return;
|
|
109
126
|
}
|
|
110
127
|
}
|
package/package.json
CHANGED
|
@@ -20,6 +20,10 @@ export interface IDeployOptions {
|
|
|
20
20
|
confirmUpdate?: (item: IDeployPlanItem) => Promise<boolean>;
|
|
21
21
|
/** 强制云端对比(漂移检测):忽略本地 state 快照的 skip 判定,强制重新对比/部署 */
|
|
22
22
|
refresh?: boolean;
|
|
23
|
+
/** 同类型资源最大并行数,默认 1(串行)。仅作用于同类型连续实例之间,跨类型依赖顺序不变 */
|
|
24
|
+
concurrency?: number;
|
|
25
|
+
/** 失败后继续部署其余资源(database 失败仍强制中断) */
|
|
26
|
+
continueOnError?: boolean;
|
|
23
27
|
log?: {
|
|
24
28
|
info?: (msg: string) => void;
|
|
25
29
|
success?: (msg: string) => void;
|
|
@@ -85,8 +89,22 @@ export declare class DeployOrchestrator {
|
|
|
85
89
|
deployPlan(options: IDeployOptions): Promise<IDeployPlanItem[]>;
|
|
86
90
|
/**
|
|
87
91
|
* 执行部署
|
|
92
|
+
*
|
|
93
|
+
* 编排模型:plan 按「连续相同资源类型」切成 segments,segment 之间严格按
|
|
94
|
+
* DEPLOY_ORDER 串行;segment 内部按 concurrency 并行执行。失败中断策略:
|
|
95
|
+
* - 默认 fail-fast:任一资源失败即中断后续(return)
|
|
96
|
+
* - continueOnError:非 database 资源失败仍继续,最后汇总失败数
|
|
97
|
+
* - database 失败始终强制中断(无论是否 continueOnError)
|
|
88
98
|
*/
|
|
89
99
|
deploy(options: IDeployOptions): Promise<IDeployResult>;
|
|
100
|
+
/**
|
|
101
|
+
* 将扁平 plan 按「连续相同资源类型」切成有序 segments
|
|
102
|
+
*
|
|
103
|
+
* segment 之间保留 DEPLOY_ORDER 依赖顺序(严格串行);segment 内部为同类型
|
|
104
|
+
* 多实例,可并行执行。例如 [db, fnA, fnB, hostingX, hostingY, gateway]
|
|
105
|
+
* → [[db], [fnA, fnB], [hostingX, hostingY], [gateway]]
|
|
106
|
+
*/
|
|
107
|
+
private segmentPlan;
|
|
90
108
|
/**
|
|
91
109
|
* 部署成功后写回本地 state 快照
|
|
92
110
|
*
|
|
@@ -104,8 +104,27 @@ export declare class GatewayDeployer {
|
|
|
104
104
|
}>>;
|
|
105
105
|
/**
|
|
106
106
|
* 查询指定域名下已存在的路由(幂等收敛用),返回 Map<path, 云端路由详情>
|
|
107
|
+
*
|
|
108
|
+
* 匹配策略(优先级从高到低):
|
|
109
|
+
* 1. 用 Filters 精确查询目标域名:DescribeHTTPServiceRoute 不带 Filters 时返回的 Domains
|
|
110
|
+
* 列表可能不完整(如 lowcode 环境 TotalCount=67 但 Domains 只返回部分,HTTPSERVICE 的
|
|
111
|
+
* .app.tcloudbase.com 域名缺失),导致云端已有路由查不到被误判为 create
|
|
112
|
+
* 2. 降级:不带 Filters 全量查询,按域名归一化匹配(去协议/尾斜杠/转小写)
|
|
113
|
+
* 若目标域名找不到则返回空(保留 domain 维度,避免把其他域名同 path 误判为已存在)
|
|
107
114
|
*/
|
|
108
115
|
private getExistingRoutes;
|
|
116
|
+
/**
|
|
117
|
+
* 域名归一化:去协议、去尾斜杠、转小写(用于幂等收敛的域名匹配)
|
|
118
|
+
*/
|
|
119
|
+
private normalizeDomain;
|
|
120
|
+
/**
|
|
121
|
+
* 判断错误是否属于「路由已存在/已占用」类(幂等降级专用)
|
|
122
|
+
*
|
|
123
|
+
* 只匹配明确的重复/占用语义(code 含 AlreadyExists/Duplicate/ResourceInUse/Conflict,
|
|
124
|
+
* 或 message 含 already exists),并显式排除否定语义(not exists/not found 等),
|
|
125
|
+
* 避免把「环境/资源不存在」等真实失败误判为「已存在」而被静默跳过
|
|
126
|
+
*/
|
|
127
|
+
private isAlreadyExistsError;
|
|
109
128
|
/**
|
|
110
129
|
* 判断期望路由与云端已存在路由是否一致
|
|
111
130
|
*
|
|
@@ -28,8 +28,6 @@ export interface IHostingDeployConfig {
|
|
|
28
28
|
ignore?: string[];
|
|
29
29
|
/** 构建时环境变量(注入本地构建进程,非敏感) */
|
|
30
30
|
envVariables?: Record<string, string | number | boolean>;
|
|
31
|
-
/** SPA 回退:true 时 404 返回 index.html */
|
|
32
|
-
spaFallback?: boolean;
|
|
33
31
|
}
|
|
34
32
|
export interface IStaticDeployResult {
|
|
35
33
|
name: string;
|
|
@@ -80,11 +78,6 @@ export declare class StaticDeployer {
|
|
|
80
78
|
* app 云端构建:上传源码 ZIP → createApp 云端构建 → 写版本记录
|
|
81
79
|
*/
|
|
82
80
|
private deployAppCloud;
|
|
83
|
-
/**
|
|
84
|
-
* 读取现有静态网站配置并保留 RoutingRules + AutoAddressing
|
|
85
|
-
* (putBucketWebsite 为整体覆盖,设置 SPA 回退前必须带回已有配置,否则会被清空)
|
|
86
|
-
*/
|
|
87
|
-
private preserveWebsiteConfig;
|
|
88
81
|
/**
|
|
89
82
|
* 解析 hosting 访问地址:https://{CdnDomain}{deployPath}
|
|
90
83
|
*/
|
package/types/deploy/domain.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Environment } from '../environment';
|
|
2
2
|
/**
|
|
3
|
-
* 构造环境 HTTP 访问服务默认域名 `${envId}-${AppId}.${region}
|
|
3
|
+
* 构造环境 HTTP 访问服务默认域名 `${envId}-${AppId}.${region}.${ACCESS_DOMAIN_SUFFIX}`
|
|
4
4
|
*
|
|
5
5
|
* 关键:用 `UserInfo.AppId`(腾讯云 appid)而非 `UserInfo.Uin`(账号 uin)。
|
|
6
|
-
* 控制台默认域名格式是 `${envId}-${AppId}.${region}
|
|
6
|
+
* 控制台默认域名格式是 `${envId}-${AppId}.${region}.${ACCESS_DOMAIN_SUFFIX}`
|
|
7
7
|
* (如 `xxx-1326375956.ap-shanghai.app.tcloudbase.com`),与 cookie 中的 `appid` 字段对应。
|
|
8
8
|
* AppId/Region 缺失或查询失败时返回 undefined
|
|
9
9
|
*/
|
|
@@ -13,6 +13,20 @@ export declare class ProjectValidator {
|
|
|
13
13
|
private isPersonalCloudFunction;
|
|
14
14
|
private validateResources;
|
|
15
15
|
private validateConsistency;
|
|
16
|
+
/**
|
|
17
|
+
* 检测 hosting 路由中 enablePathTransmission=true 与自动生成 pathRewrite 的冲突
|
|
18
|
+
*
|
|
19
|
+
* hosting 目标未显式配置 pathRewrite 时,部署会自动生成 PathRewrite.Prefix = hosting.deployPath。
|
|
20
|
+
* 若同时显式开启 enablePathTransmission=true,路径透传会覆盖路径重写,导致请求打到错误的托管路径(404)。
|
|
21
|
+
*/
|
|
22
|
+
private validateGatewayPathRewriteConflicts;
|
|
23
|
+
/**
|
|
24
|
+
* 子路径部署(deployPath !== '/')下,前端构建产物若仍使用根路径 base(如 Vite 默认 '/'),
|
|
25
|
+
* 会导致资源请求落到 /assets/* 而非 /{deployPath}/assets/*,出现 404/白屏。
|
|
26
|
+
*
|
|
27
|
+
* 这里在“可能存在构建步骤”的 hosting 配置上给出防坑告警。
|
|
28
|
+
*/
|
|
29
|
+
private validateHostingSubPathBaseRisks;
|
|
16
30
|
private validateUniqueNames;
|
|
17
31
|
private validateUniqueField;
|
|
18
32
|
private validateGatewayTargets;
|