@cloudbase/manager-node 5.8.0 → 5.8.1
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/CHANGELOG.md +23 -0
- package/lib/hosting/index.js +21 -1
- package/lib/storage/helper.js +52 -0
- package/lib/storage/index.js +449 -5
- package/package.json +1 -1
- package/types/hosting/index.d.ts +8 -0
- package/types/storage/helper.d.ts +20 -0
- package/types/storage/index.d.ts +124 -2
- package/types/storage/type.d.ts +115 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## @cloudbase/manager-node v5.8.0 版本发布 🚀(2026-08-20)
|
|
2
|
+
|
|
3
|
+
- 新增 声明式部署编排(hosting 本地构建/gateway 域名绑定/certId 自动查询)
|
|
4
|
+
- 新增 增加 HTTP 服务路由预校验和 SSL 证书查询能力
|
|
5
|
+
- 新增 统一镜像配置校验契约
|
|
6
|
+
- 新增 完善企业版 TCR 云构建链路
|
|
7
|
+
- 新增 补强 Python 部署上下文与检查
|
|
8
|
+
- 新增 validate explicit personal TCR credentials
|
|
9
|
+
- 新增 纳入 database 迁移到声明式编排
|
|
10
|
+
- 重构 降低配置守卫圈复杂度
|
|
11
|
+
- 重构 统一 FunctionDeployer 并迁移镜像部署实现
|
|
12
|
+
- 修复 clarify local default tag
|
|
13
|
+
- 修复 improve cloud build preflight and TCR guidance
|
|
14
|
+
- 修复 移除自动删除重建授权语义
|
|
15
|
+
- 修复 python deploy lint
|
|
16
|
+
- 修复 让云端镜像构建配置真实生效
|
|
17
|
+
- 修复 处理 HTTP 协议规划器 AI 类型安全评审
|
|
18
|
+
- 修复默认 HTTP 协议被误判为需要重建
|
|
19
|
+
- 修复 修正 image 策略 imageType 字段路径导致个人版镜像误判
|
|
20
|
+
- 补充 codeSecret 统一部署契约回归
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
1
24
|
## @cloudbase/manager-node v5.7.0 版本发布 🚀(2026-08-13)
|
|
2
25
|
|
|
3
26
|
|
package/lib/hosting/index.js
CHANGED
|
@@ -131,7 +131,7 @@ class HostingService {
|
|
|
131
131
|
* @param options
|
|
132
132
|
*/
|
|
133
133
|
async uploadFiles(options) {
|
|
134
|
-
const { localPath, cloudPath, files = [], onProgress, onFileFinish, parallel = 20, ignore = ['.DS_Store'], retryCount, retryInterval } = options;
|
|
134
|
+
const { localPath, cloudPath, entryFiles, files = [], onProgress, onFileFinish, parallel = 20, ignore = ['.DS_Store'], retryCount, retryInterval, verify = false, safe = false, prune = false } = options;
|
|
135
135
|
const hosting = await this.checkStatus();
|
|
136
136
|
const { Bucket, Regoin } = hosting;
|
|
137
137
|
const storageService = await this.environment.getStorageService();
|
|
@@ -142,9 +142,29 @@ class HostingService {
|
|
|
142
142
|
// 检查路径是否存在
|
|
143
143
|
(0, utils_1.checkReadable)(resolvePath, true);
|
|
144
144
|
if ((0, utils_1.isDirectory)(resolvePath)) {
|
|
145
|
+
if (safe || verify || prune) {
|
|
146
|
+
return storageService.uploadDirectoryConsistent({
|
|
147
|
+
localPath: resolvePath,
|
|
148
|
+
cloudPath,
|
|
149
|
+
entries: entryFiles,
|
|
150
|
+
bucket: Bucket,
|
|
151
|
+
region: Regoin,
|
|
152
|
+
onProgress,
|
|
153
|
+
onFileFinish,
|
|
154
|
+
fileId: false,
|
|
155
|
+
ignore,
|
|
156
|
+
parallel,
|
|
157
|
+
retryCount,
|
|
158
|
+
retryInterval,
|
|
159
|
+
verify,
|
|
160
|
+
safe,
|
|
161
|
+
prune
|
|
162
|
+
});
|
|
163
|
+
}
|
|
145
164
|
return storageService.uploadDirectoryCustom({
|
|
146
165
|
localPath: resolvePath,
|
|
147
166
|
cloudPath,
|
|
167
|
+
entryFiles,
|
|
148
168
|
bucket: Bucket,
|
|
149
169
|
region: Regoin,
|
|
150
170
|
onProgress,
|
|
@@ -0,0 +1,52 @@
|
|
|
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.md5File = md5File;
|
|
7
|
+
exports.splitEntryUploadFiles = splitEntryUploadFiles;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
/**
|
|
12
|
+
* 流式计算文件 MD5(32 位小写十六进制),不将整个文件读入内存
|
|
13
|
+
*/
|
|
14
|
+
function md5File(filePath) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const hash = crypto_1.default.createHash('md5');
|
|
17
|
+
const stream = fs_1.default.createReadStream(filePath);
|
|
18
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
19
|
+
stream.on('error', reject);
|
|
20
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 将目录上传文件拆分为资源组和入口组,确保入口文件最后上传
|
|
25
|
+
*/
|
|
26
|
+
function splitEntryUploadFiles(files, entryFiles) {
|
|
27
|
+
// 是否有自定义入口文件
|
|
28
|
+
const hasCustomEntryFiles = Array.isArray(entryFiles);
|
|
29
|
+
// 规范化入口文件路径,去除前导斜线
|
|
30
|
+
const normalizedEntryFiles = (entryFiles || [])
|
|
31
|
+
.map(item => String(item || '').trim().replace(/\\/g, '/').replace(/^\/+/, ''))
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
const resourceFiles = [];
|
|
34
|
+
const entryUploadFiles = [];
|
|
35
|
+
files.forEach(file => {
|
|
36
|
+
const normalizedCloudPath = file.cloudPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
37
|
+
// 判断当前文件是否为入口文件
|
|
38
|
+
const isEntryFile = hasCustomEntryFiles
|
|
39
|
+
? normalizedEntryFiles.some(entryFile => normalizedCloudPath === entryFile ||
|
|
40
|
+
normalizedCloudPath.endsWith(`/${entryFile}`))
|
|
41
|
+
: path_1.default.posix.basename(normalizedCloudPath) === 'index.html';
|
|
42
|
+
if (isEntryFile) {
|
|
43
|
+
entryUploadFiles.push(file);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
resourceFiles.push(file);
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
resourceFiles,
|
|
50
|
+
entryUploadFiles
|
|
51
|
+
};
|
|
52
|
+
}
|
package/lib/storage/index.js
CHANGED
|
@@ -10,7 +10,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
10
10
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
11
|
};
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.StorageService = void 0;
|
|
13
|
+
exports.StorageService = exports.md5File = void 0;
|
|
14
14
|
const fs_1 = __importDefault(require("fs"));
|
|
15
15
|
const util_1 = __importDefault(require("util"));
|
|
16
16
|
const path_1 = __importDefault(require("path"));
|
|
@@ -24,12 +24,418 @@ const error_1 = require("../error");
|
|
|
24
24
|
const parallel_1 = require("../utils/parallel");
|
|
25
25
|
const runenv_1 = require("../utils/runenv");
|
|
26
26
|
const constant_1 = require("../constant");
|
|
27
|
+
const helper_1 = require("./helper");
|
|
27
28
|
const BIG_FILE_SIZE = 5242880; // 5MB 1024*1024*5
|
|
29
|
+
// ===== hosting deploy consistency 相关能力 =====
|
|
30
|
+
/** 默认 MD5 计算并发数 */
|
|
31
|
+
const DEFAULT_MD5_PARALLEL = 20;
|
|
32
|
+
/** 一致性发布备份前缀(用于 diff.removed 排除备份文件) */
|
|
33
|
+
const CONSISTENT_DEPLOY_BACKUP_PREFIX = '.cloudbase-backup/';
|
|
34
|
+
var helper_2 = require("./helper");
|
|
35
|
+
Object.defineProperty(exports, "md5File", { enumerable: true, get: function () { return helper_2.md5File; } });
|
|
28
36
|
class StorageService {
|
|
29
37
|
constructor(environment) {
|
|
30
38
|
this.environment = environment;
|
|
31
39
|
this.tcbService = new utils_1.CloudService(environment.cloudBaseContext, 'tcb', '2018-06-08');
|
|
32
40
|
}
|
|
41
|
+
// ==================== 一致性发布 相关能力 ====================
|
|
42
|
+
/**
|
|
43
|
+
* 获取远端 manifest(默认 Storage 桶):返回指定前缀下文件的 key / etag / size,
|
|
44
|
+
* 过滤目录占位对象。prefix 无前导斜杠,空串表示整桶(Hosting 桶请用 getRemoteManifestCustom)。
|
|
45
|
+
*/
|
|
46
|
+
async getRemoteManifest(prefix) {
|
|
47
|
+
const { bucket, region } = this.getStorageConfig();
|
|
48
|
+
return this.getRemoteManifestCustom({
|
|
49
|
+
prefix,
|
|
50
|
+
bucket,
|
|
51
|
+
region
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 获取远端 manifest,支持自定义 Bucket 和 Region。prefix 规则同 getRemoteManifest。
|
|
56
|
+
*/
|
|
57
|
+
async getRemoteManifestCustom(options) {
|
|
58
|
+
const fileList = await this.walkCloudDirCustom(options);
|
|
59
|
+
return fileList
|
|
60
|
+
.filter(file => file.Key && !file.Key.endsWith('/'))
|
|
61
|
+
.map(file => ({
|
|
62
|
+
key: file.Key,
|
|
63
|
+
etag: String(file.ETag || '').replace(/^"|"$/g, ''),
|
|
64
|
+
size: Number(file.Size) || 0
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 生成本地 manifest:计算每个文件的云端 Key、MD5 与 Size。
|
|
69
|
+
* - filePaths 须来自 walkLocalDir(localPath),目录不计入
|
|
70
|
+
* - key = cloudPath + 文件相对路径;cloudPath 为空表示桶根
|
|
71
|
+
* - MD5 以受控并发计算,避免阻塞事件循环
|
|
72
|
+
*/
|
|
73
|
+
async buildLocalManifest(options) {
|
|
74
|
+
const { localPath, cloudPath, filePaths, parallel = DEFAULT_MD5_PARALLEL } = options;
|
|
75
|
+
const resolvePath = path_1.default.resolve(localPath);
|
|
76
|
+
const resolveLocalPath = path_1.default.join(resolvePath, path_1.default.sep);
|
|
77
|
+
const controller = new parallel_1.AsyncTaskParallelController(parallel, 50);
|
|
78
|
+
controller.loadTasks(filePaths.map(filePath => async () => {
|
|
79
|
+
const stat = await fs_1.default.promises.stat(filePath);
|
|
80
|
+
if (stat.isDirectory()) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const fileKeyPath = filePath.replace(resolveLocalPath, '').replace(/\\/g, '/');
|
|
84
|
+
const key = path_1.default.join(cloudPath || '', fileKeyPath).replace(/\\/g, '/');
|
|
85
|
+
return {
|
|
86
|
+
key,
|
|
87
|
+
md5: await (0, helper_1.md5File)(filePath),
|
|
88
|
+
size: stat.size,
|
|
89
|
+
localPath: filePath
|
|
90
|
+
};
|
|
91
|
+
}));
|
|
92
|
+
const results = await controller.run();
|
|
93
|
+
const isError = (res) => Object.prototype.toString.call(res) === '[object Error]';
|
|
94
|
+
const firstError = results.find(isError);
|
|
95
|
+
if (firstError) {
|
|
96
|
+
throw new error_1.CloudBaseError(`计算本地文件摘要失败:${firstError.message}`);
|
|
97
|
+
}
|
|
98
|
+
return results.filter(res => res !== null);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 计算本地 manifest 与远端 manifest 的差异:
|
|
102
|
+
* - added:本地有、远端无;overwritten:两边都有;removed:远端有、本地无
|
|
103
|
+
* - 两份 manifest 的 key 须基于同一 cloudPath(removed 自动排除 .cloudbase-backup/)
|
|
104
|
+
*/
|
|
105
|
+
diffManifest(localManifest, remoteManifest) {
|
|
106
|
+
const localKeys = new Set(localManifest.map(item => item.key));
|
|
107
|
+
const remoteKeys = new Set(remoteManifest.map(item => item.key));
|
|
108
|
+
const added = [];
|
|
109
|
+
const overwritten = [];
|
|
110
|
+
localManifest.forEach(item => {
|
|
111
|
+
if (remoteKeys.has(item.key)) {
|
|
112
|
+
overwritten.push(item.key);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
added.push(item.key);
|
|
116
|
+
});
|
|
117
|
+
const removed = remoteManifest
|
|
118
|
+
.map(item => item.key)
|
|
119
|
+
.filter(key => !localKeys.has(key) &&
|
|
120
|
+
!key.replace(/^\/+/, '').startsWith(CONSISTENT_DEPLOY_BACKUP_PREFIX));
|
|
121
|
+
return {
|
|
122
|
+
added,
|
|
123
|
+
overwritten,
|
|
124
|
+
removed
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 校验远端 manifest 与本地 manifest 是否一致:
|
|
129
|
+
* - 远端缺失 → missing;ETag 为纯 MD5 → 按 md5 比对;分块上传 ETag → 降级按 size 比对
|
|
130
|
+
*/
|
|
131
|
+
verifyManifest(localManifest, remoteManifest) {
|
|
132
|
+
const remoteMap = new Map(remoteManifest.map(item => [item.key, item]));
|
|
133
|
+
const missing = [];
|
|
134
|
+
const mismatched = [];
|
|
135
|
+
const pureMd5EtagRe = /^[a-f0-9]{32}$/i;
|
|
136
|
+
localManifest.forEach(localItem => {
|
|
137
|
+
const remoteItem = remoteMap.get(localItem.key);
|
|
138
|
+
if (!remoteItem) {
|
|
139
|
+
missing.push(localItem.key);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const remoteEtag = String(remoteItem.etag || '').replace(/^"|"$/g, '');
|
|
143
|
+
const shouldCompareByMd5 = pureMd5EtagRe.test(remoteEtag);
|
|
144
|
+
if (shouldCompareByMd5) {
|
|
145
|
+
if (remoteEtag.toLowerCase() !== localItem.md5.toLowerCase()) {
|
|
146
|
+
mismatched.push(localItem.key);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (Number(remoteItem.size) !== Number(localItem.size)) {
|
|
151
|
+
mismatched.push(localItem.key);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
ok: missing.length === 0 && mismatched.length === 0,
|
|
156
|
+
missing,
|
|
157
|
+
mismatched
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* 将指定前缀下的远端文件服务端复制到备份前缀(不产生下载流量)。
|
|
162
|
+
* 始终跳过 .cloudbase-backup/ 下的历史备份,避免根目录发布时递归备份。
|
|
163
|
+
*/
|
|
164
|
+
async backupRemote(options) {
|
|
165
|
+
const { bucket, region, prefix, backupPrefix, parallel = 20 } = options;
|
|
166
|
+
// COS 对象 Key 不保留前导斜杠,统一规范化为 .cloudbase-backup/... 形式
|
|
167
|
+
const normalizedBackupPrefix = backupPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
168
|
+
const remoteManifest = await this.getRemoteManifestCustom({
|
|
169
|
+
bucket,
|
|
170
|
+
region,
|
|
171
|
+
prefix
|
|
172
|
+
});
|
|
173
|
+
// 过滤掉已备份的文件,避免根目录发布时递归备份旧备份,filesToBackup 存的是需要备份的文件
|
|
174
|
+
const filesToBackup = remoteManifest.filter(item => !item.key.replace(/^\/+/, '').startsWith(CONSISTENT_DEPLOY_BACKUP_PREFIX));
|
|
175
|
+
if (!filesToBackup.length) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const cos = this.getCos();
|
|
179
|
+
const putObjectCopy = util_1.default.promisify(cos.putObjectCopy).bind(cos);
|
|
180
|
+
const controller = new parallel_1.AsyncTaskParallelController(parallel, 50);
|
|
181
|
+
controller.loadTasks(
|
|
182
|
+
// 为每个文件创建一个复制任务
|
|
183
|
+
filesToBackup.map(file => async () => {
|
|
184
|
+
const destPath = `${normalizedBackupPrefix}/${file.key.replace(/^\/+/, '')}`;
|
|
185
|
+
// 拼接复制源
|
|
186
|
+
const copySource = `${bucket}.cos.${region}.myqcloud.com/${encodeURIComponent(file.key)}`;
|
|
187
|
+
const res = await putObjectCopy({
|
|
188
|
+
Bucket: bucket,
|
|
189
|
+
Region: region,
|
|
190
|
+
Key: destPath,
|
|
191
|
+
CopySource: copySource
|
|
192
|
+
});
|
|
193
|
+
if (res.statusCode !== 200) {
|
|
194
|
+
throw new error_1.CloudBaseError(`备份文件失败:${file.key}`);
|
|
195
|
+
}
|
|
196
|
+
return destPath;
|
|
197
|
+
}));
|
|
198
|
+
const results = await controller.run();
|
|
199
|
+
const isError = (res) => Object.prototype.toString.call(res) === '[object Error]';
|
|
200
|
+
const firstError = results.find(isError);
|
|
201
|
+
if (firstError) {
|
|
202
|
+
throw new error_1.CloudBaseError(`远端备份失败:${firstError.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 从备份恢复被覆盖旧文件(服务端复制回原 Key),并删除本次新增文件。
|
|
207
|
+
* 任一阶段失败都会抛错,由上层决定是否保留备份供人工恢复。
|
|
208
|
+
*/
|
|
209
|
+
async restoreFromBackup(options) {
|
|
210
|
+
const { bucket, region, backupPrefix, overwrittenKeys, addedKeys, parallel = 20 } = options;
|
|
211
|
+
// COS 对象 Key 不保留前导斜杠,与 backupRemote 的规范化规则保持一致
|
|
212
|
+
const normalizedBackupPrefix = backupPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
213
|
+
if (overwrittenKeys.length > 0) {
|
|
214
|
+
const cos = this.getCos();
|
|
215
|
+
const putObjectCopy = util_1.default.promisify(cos.putObjectCopy).bind(cos);
|
|
216
|
+
const controller = new parallel_1.AsyncTaskParallelController(parallel, 50);
|
|
217
|
+
controller.loadTasks(overwrittenKeys.map(key => async () => {
|
|
218
|
+
const sourcePath = `${normalizedBackupPrefix}/${key.replace(/^\/+/, '')}`;
|
|
219
|
+
const copySource = `${bucket}.cos.${region}.myqcloud.com/${encodeURIComponent(sourcePath)}`;
|
|
220
|
+
const res = await putObjectCopy({
|
|
221
|
+
Bucket: bucket,
|
|
222
|
+
Region: region,
|
|
223
|
+
Key: key,
|
|
224
|
+
CopySource: copySource
|
|
225
|
+
});
|
|
226
|
+
if (res.statusCode !== 200) {
|
|
227
|
+
throw new error_1.CloudBaseError(`恢复备份文件失败:${key}`);
|
|
228
|
+
}
|
|
229
|
+
return key;
|
|
230
|
+
}));
|
|
231
|
+
const restoreResults = await controller.run();
|
|
232
|
+
const isError = (res) => Object.prototype.toString.call(res) === '[object Error]';
|
|
233
|
+
const firstRestoreError = restoreResults.find(isError);
|
|
234
|
+
if (firstRestoreError) {
|
|
235
|
+
throw new error_1.CloudBaseError(`从备份恢复远端文件失败:${firstRestoreError.message}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (addedKeys.length > 0) {
|
|
239
|
+
await this.deleteFileCustom(addedKeys, bucket, region);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* 删除备份前缀下的全部对象。
|
|
244
|
+
* 备份目录以 . 开头,内部使用空 Marker 列举,不受默认排除规则影响。
|
|
245
|
+
*/
|
|
246
|
+
async cleanupBackup(options) {
|
|
247
|
+
const { bucket, region, backupPrefix } = options;
|
|
248
|
+
// 备份目录以 . 开头,会被 walkCloudDirCustom 默认 marker='/' 排除,
|
|
249
|
+
// 必须显式传 marker='' 才能列举到备份对象
|
|
250
|
+
await this.deleteDirectoryCustom({
|
|
251
|
+
cloudPath: backupPrefix,
|
|
252
|
+
bucket,
|
|
253
|
+
region,
|
|
254
|
+
marker: ''
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* 清理发布后远端残留的冗余文件。
|
|
259
|
+
* 仅删除 removedKeys 中位于 prefix 范围内的 Key,排除 .cloudbase-backup/;
|
|
260
|
+
* 单个删除失败记入 failedKeys,不影响其他文件。
|
|
261
|
+
*/
|
|
262
|
+
async pruneRemote(options) {
|
|
263
|
+
const { bucket, region, prefix, removedKeys, parallel = 20 } = options;
|
|
264
|
+
const normalizedPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
265
|
+
const prefixWithSlash = normalizedPrefix ? `${normalizedPrefix}/` : '';
|
|
266
|
+
const backupPrefix = CONSISTENT_DEPLOY_BACKUP_PREFIX;
|
|
267
|
+
const keysToDelete = Array.from(new Set(removedKeys
|
|
268
|
+
.map(key => String(key || '').replace(/^\/+/, ''))
|
|
269
|
+
.filter(key => key &&
|
|
270
|
+
!key.startsWith(backupPrefix) &&
|
|
271
|
+
(!prefixWithSlash || key.startsWith(prefixWithSlash)) // 限制在 prefix 范围内
|
|
272
|
+
)));
|
|
273
|
+
if (!keysToDelete.length) {
|
|
274
|
+
return { deletedKeys: [], failedKeys: [] };
|
|
275
|
+
}
|
|
276
|
+
const cos = this.getCos();
|
|
277
|
+
const deleteObject = util_1.default.promisify(cos.deleteObject).bind(cos);
|
|
278
|
+
const controller = new parallel_1.AsyncTaskParallelController(parallel, 50);
|
|
279
|
+
controller.loadTasks(keysToDelete.map(key => async () => {
|
|
280
|
+
try {
|
|
281
|
+
await deleteObject({
|
|
282
|
+
Bucket: bucket,
|
|
283
|
+
Region: region,
|
|
284
|
+
Key: key
|
|
285
|
+
});
|
|
286
|
+
return { key };
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return {
|
|
290
|
+
key,
|
|
291
|
+
error: (error === null || error === void 0 ? void 0 : error.message) || String(error)
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}));
|
|
295
|
+
const results = await controller.run();
|
|
296
|
+
const deletedKeys = [];
|
|
297
|
+
const failedKeys = [];
|
|
298
|
+
results.forEach(result => {
|
|
299
|
+
if (result && typeof result.error === 'string') {
|
|
300
|
+
failedKeys.push(result);
|
|
301
|
+
}
|
|
302
|
+
else if (result === null || result === void 0 ? void 0 : result.key) {
|
|
303
|
+
deletedKeys.push(result.key);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
return { deletedKeys, failedKeys };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* 一致性目录上传编排(safe、verify、prune)
|
|
310
|
+
*
|
|
311
|
+
* 执行流程(按顺序):
|
|
312
|
+
* 1. 扫描本地目录,生成 manifest(key/md5/size)
|
|
313
|
+
* 2. safe 或 prune 开启时:拉取远端快照 manifest,与本地做 diff
|
|
314
|
+
* (safe 用 diff 判定回滚范围;prune 用 diff.removed 判定待清理文件)
|
|
315
|
+
* 3. safe 开启时:将远端文件服务端复制到备份前缀 .cloudbase-backup/<时间戳>/
|
|
316
|
+
* 4. 执行目录上传(资源文件先传,入口文件 entries 最后传)
|
|
317
|
+
* 5. verify 开启时:重新拉取远端 manifest,逐文件校验 ETag/MD5 与 size
|
|
318
|
+
* 6. 上传与校验全部成功后,prune 开启时:删除 diff.removed 中仍冗余的远端文件
|
|
319
|
+
* 7. 返回 uploadedKeys / verified / backupPrefix / prune 结果
|
|
320
|
+
*
|
|
321
|
+
* 失败时的行为:
|
|
322
|
+
* - 步骤 4/5 失败且 safe 开启:自动从备份回滚(恢复被覆盖文件、删除本次新增
|
|
323
|
+
* 文件),回滚成功后抛出“已自动回滚”错误,备份保留供人工核查
|
|
324
|
+
* - 回滚失败:保留备份并在错误信息中给出备份位置,供人工恢复
|
|
325
|
+
* - 备份一律不由本编排自动清理,均需调用方确认后通过 cleanupBackup 显式删除
|
|
326
|
+
* - prune 仅在步骤 4/5 全部成功后执行,失败发布绝不会触发远端删除
|
|
327
|
+
*
|
|
328
|
+
* ⚠️ 危险参数:prune
|
|
329
|
+
* prune 会删除云端 cloudPath 前缀下、本次本地构建中不存在的全部文件。
|
|
330
|
+
* 若调用方误传了错误的 localPath/cloudPath,整目录都会被判定为“冗余”而删除。
|
|
331
|
+
* 发布成功后备份默认保留、不自动清理:prune 误删的文件可从备份前缀
|
|
332
|
+
* (.cloudbase-backup/<时间戳>/)人工恢复;调用方确认无误后应通过
|
|
333
|
+
* cleanupBackup 显式清理备份。调用方(CLI)仍须在开启 prune 前要求用户二次确认。
|
|
334
|
+
*/
|
|
335
|
+
async uploadDirectoryConsistent(options) {
|
|
336
|
+
const { localPath, cloudPath = '', bucket, region, ignore, fileId = true, parallel = 20, retryCount = 0, retryInterval = 500, onProgress, onFileFinish, verify = false, safe = false, prune = false, entries } = options;
|
|
337
|
+
const resolvePath = path_1.default.resolve(localPath);
|
|
338
|
+
const resolveLocalPath = path_1.default.join(resolvePath, path_1.default.sep);
|
|
339
|
+
const filePaths = await this.walkLocalDir(resolveLocalPath, ignore);
|
|
340
|
+
// 生成本地 manifest
|
|
341
|
+
const localManifest = await this.buildLocalManifest({
|
|
342
|
+
localPath,
|
|
343
|
+
cloudPath,
|
|
344
|
+
filePaths,
|
|
345
|
+
parallel
|
|
346
|
+
});
|
|
347
|
+
let backupPrefix;
|
|
348
|
+
let diff;
|
|
349
|
+
if (safe || prune) {
|
|
350
|
+
const remoteSnapshot = await this.getRemoteManifestCustom({
|
|
351
|
+
bucket,
|
|
352
|
+
region,
|
|
353
|
+
prefix: cloudPath
|
|
354
|
+
});
|
|
355
|
+
// 发布前快照同时用于 safe 回滚判定和 prune 待删文件判定。
|
|
356
|
+
diff = this.diffManifest(localManifest, remoteSnapshot);
|
|
357
|
+
}
|
|
358
|
+
if (safe) {
|
|
359
|
+
// COS 对象 Key 不保留前导斜杠,备份前缀使用 .cloudbase-backup/ 形式
|
|
360
|
+
backupPrefix = `${CONSISTENT_DEPLOY_BACKUP_PREFIX}${Date.now()}/`;
|
|
361
|
+
// 备份远端
|
|
362
|
+
await this.backupRemote({
|
|
363
|
+
bucket,
|
|
364
|
+
region,
|
|
365
|
+
prefix: cloudPath,
|
|
366
|
+
backupPrefix,
|
|
367
|
+
parallel
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
await this.uploadDirectoryCustom({
|
|
372
|
+
localPath,
|
|
373
|
+
cloudPath,
|
|
374
|
+
entryFiles: entries,
|
|
375
|
+
bucket,
|
|
376
|
+
region,
|
|
377
|
+
ignore,
|
|
378
|
+
fileId,
|
|
379
|
+
parallel,
|
|
380
|
+
retryCount,
|
|
381
|
+
retryInterval,
|
|
382
|
+
onProgress,
|
|
383
|
+
onFileFinish
|
|
384
|
+
});
|
|
385
|
+
// 上传完成后校验
|
|
386
|
+
if (verify) {
|
|
387
|
+
const remoteManifest = await this.getRemoteManifestCustom({
|
|
388
|
+
bucket,
|
|
389
|
+
region,
|
|
390
|
+
prefix: cloudPath
|
|
391
|
+
});
|
|
392
|
+
const verifyResult = this.verifyManifest(localManifest, remoteManifest);
|
|
393
|
+
if (!verifyResult.ok) {
|
|
394
|
+
throw new error_1.CloudBaseError(`一致性校验失败:missing=${verifyResult.missing.join(',') || '[]'}; mismatched=${verifyResult.mismatched.join(',') || '[]'}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (!safe || !backupPrefix || !diff) {
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
// 失败时回滚;备份保留供人工核查,由调用方确认后通过 cleanupBackup 显式清理
|
|
403
|
+
try {
|
|
404
|
+
await this.restoreFromBackup({
|
|
405
|
+
bucket,
|
|
406
|
+
region,
|
|
407
|
+
backupPrefix,
|
|
408
|
+
overwrittenKeys: diff.overwritten,
|
|
409
|
+
addedKeys: diff.added,
|
|
410
|
+
parallel
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
catch (rollbackError) {
|
|
414
|
+
throw new error_1.CloudBaseError(`一致性发布失败,且回滚失败,请使用备份人工恢复:${backupPrefix};原始错误:${error.message};回滚错误:${rollbackError.message}`);
|
|
415
|
+
}
|
|
416
|
+
throw new error_1.CloudBaseError(`一致性发布失败,已自动回滚,备份保留于 ${backupPrefix},确认无误后可调用 cleanupBackup 清理:${error.message}`);
|
|
417
|
+
}
|
|
418
|
+
// 上传与可选校验均成功后才允许清理远端冗余文件。
|
|
419
|
+
// 删除失败会记录在结果中,不触发已经成功发布内容的回滚。
|
|
420
|
+
const pruneResult = prune
|
|
421
|
+
? await this.pruneRemote({
|
|
422
|
+
bucket,
|
|
423
|
+
region,
|
|
424
|
+
prefix: cloudPath,
|
|
425
|
+
removedKeys: (diff === null || diff === void 0 ? void 0 : diff.removed) || [],
|
|
426
|
+
parallel
|
|
427
|
+
})
|
|
428
|
+
: undefined;
|
|
429
|
+
// 发布成功后保留备份:prune 若误删文件,仍可从备份前缀人工恢复。
|
|
430
|
+
// 备份不由本编排自动清理,调用方确认无误后通过 cleanupBackup 显式删除。
|
|
431
|
+
return {
|
|
432
|
+
uploadedKeys: localManifest.map(item => item.key),
|
|
433
|
+
verified: verify,
|
|
434
|
+
backupPrefix,
|
|
435
|
+
prune: pruneResult
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
// ==============================================================
|
|
33
439
|
/**
|
|
34
440
|
* 上传文件
|
|
35
441
|
* localPath 为文件夹时,会尝试在文件夹中寻找 cloudPath 中的文件名
|
|
@@ -178,7 +584,7 @@ class StorageService {
|
|
|
178
584
|
* @returns {Promise<void>}
|
|
179
585
|
*/
|
|
180
586
|
async uploadDirectoryCustom(options) {
|
|
181
|
-
const { localPath, cloudPath, bucket, region, onProgress, onFileFinish, ignore, fileId = true, parallel = 20, retryCount = 0, retryInterval = 500 } = options;
|
|
587
|
+
const { localPath, cloudPath, entryFiles, bucket, region, onProgress, onFileFinish, ignore, fileId = true, parallel = 20, retryCount = 0, retryInterval = 500 } = options;
|
|
182
588
|
// 此处不检查路径是否存在
|
|
183
589
|
// 绝对路径 /var/blog/xxxx
|
|
184
590
|
const resolvePath = path_1.default.resolve(localPath);
|
|
@@ -249,10 +655,26 @@ class StorageService {
|
|
|
249
655
|
localPath: stats.filePath,
|
|
250
656
|
cloudPath: stats.cloudFileKey
|
|
251
657
|
}));
|
|
658
|
+
const { resourceFiles, entryUploadFiles } = (0, helper_1.splitEntryUploadFiles)(filesForUpload, entryFiles);
|
|
252
659
|
// 调用 uploadFilesCustom(智能混合模式:并行→串行)
|
|
253
660
|
// 不再直接调用 uploadFilesWithRetry(纯并行重试,容易 socket hang up)
|
|
661
|
+
// 第一阶段先上传资源文件;这里必须 await,确保入口文件不会与资源文件混传。
|
|
662
|
+
// 如果资源组上传失败,会直接中断,第二阶段不会开始。
|
|
663
|
+
await this.uploadFilesCustom({
|
|
664
|
+
files: resourceFiles,
|
|
665
|
+
bucket,
|
|
666
|
+
region,
|
|
667
|
+
ignore: [],
|
|
668
|
+
onProgress,
|
|
669
|
+
onFileFinish,
|
|
670
|
+
fileId,
|
|
671
|
+
parallel,
|
|
672
|
+
retryCount,
|
|
673
|
+
retryInterval
|
|
674
|
+
});
|
|
675
|
+
// 第二阶段再上传入口文件(默认各级 index.html),避免入口文件先于其依赖资源对外可见。
|
|
254
676
|
return this.uploadFilesCustom({
|
|
255
|
-
files:
|
|
677
|
+
files: entryUploadFiles,
|
|
256
678
|
bucket,
|
|
257
679
|
region,
|
|
258
680
|
ignore: [],
|
|
@@ -781,7 +1203,7 @@ class StorageService {
|
|
|
781
1203
|
* @returns {Promise<void>}
|
|
782
1204
|
*/
|
|
783
1205
|
async deleteDirectoryCustom(options) {
|
|
784
|
-
const { cloudPath, bucket, region } = options;
|
|
1206
|
+
const { cloudPath, bucket, region, marker } = options;
|
|
785
1207
|
const key = this.getCloudKey(cloudPath);
|
|
786
1208
|
const cos = this.getCos();
|
|
787
1209
|
const deleteMultipleObject = util_1.default.promisify(cos.deleteMultipleObject).bind(cos);
|
|
@@ -789,7 +1211,8 @@ class StorageService {
|
|
|
789
1211
|
const files = await this.walkCloudDirCustom({
|
|
790
1212
|
bucket,
|
|
791
1213
|
region,
|
|
792
|
-
prefix: key
|
|
1214
|
+
prefix: key,
|
|
1215
|
+
marker
|
|
793
1216
|
});
|
|
794
1217
|
// 文件为空时,不能调用删除接口
|
|
795
1218
|
if (!files.length) {
|
|
@@ -2936,6 +3359,27 @@ class StorageService {
|
|
|
2936
3359
|
}
|
|
2937
3360
|
}
|
|
2938
3361
|
exports.StorageService = StorageService;
|
|
3362
|
+
__decorate([
|
|
3363
|
+
(0, utils_1.preLazy)()
|
|
3364
|
+
], StorageService.prototype, "getRemoteManifest", null);
|
|
3365
|
+
__decorate([
|
|
3366
|
+
(0, utils_1.preLazy)()
|
|
3367
|
+
], StorageService.prototype, "getRemoteManifestCustom", null);
|
|
3368
|
+
__decorate([
|
|
3369
|
+
(0, utils_1.preLazy)()
|
|
3370
|
+
], StorageService.prototype, "backupRemote", null);
|
|
3371
|
+
__decorate([
|
|
3372
|
+
(0, utils_1.preLazy)()
|
|
3373
|
+
], StorageService.prototype, "restoreFromBackup", null);
|
|
3374
|
+
__decorate([
|
|
3375
|
+
(0, utils_1.preLazy)()
|
|
3376
|
+
], StorageService.prototype, "cleanupBackup", null);
|
|
3377
|
+
__decorate([
|
|
3378
|
+
(0, utils_1.preLazy)()
|
|
3379
|
+
], StorageService.prototype, "pruneRemote", null);
|
|
3380
|
+
__decorate([
|
|
3381
|
+
(0, utils_1.preLazy)()
|
|
3382
|
+
], StorageService.prototype, "uploadDirectoryConsistent", null);
|
|
2939
3383
|
__decorate([
|
|
2940
3384
|
(0, utils_1.preLazy)()
|
|
2941
3385
|
], StorageService.prototype, "uploadFile", null);
|
package/package.json
CHANGED
package/types/hosting/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type OnFileFinish = (error: Error, res: any, fileData: any) => void;
|
|
|
11
11
|
export interface IHostingFileOptions {
|
|
12
12
|
localPath: string;
|
|
13
13
|
cloudPath?: string;
|
|
14
|
+
entryFiles?: string[];
|
|
14
15
|
parallel?: number;
|
|
15
16
|
files?: {
|
|
16
17
|
localPath: string;
|
|
@@ -21,10 +22,14 @@ export interface IHostingFileOptions {
|
|
|
21
22
|
ignore?: string | string[];
|
|
22
23
|
retryCount?: number;
|
|
23
24
|
retryInterval?: number;
|
|
25
|
+
verify?: boolean;
|
|
26
|
+
safe?: boolean;
|
|
27
|
+
prune?: boolean;
|
|
24
28
|
}
|
|
25
29
|
export interface IHostingFilesOptions {
|
|
26
30
|
localPath?: string;
|
|
27
31
|
cloudPath?: string;
|
|
32
|
+
entryFiles?: string[];
|
|
28
33
|
parallel?: number;
|
|
29
34
|
files: {
|
|
30
35
|
localPath: string;
|
|
@@ -35,6 +40,9 @@ export interface IHostingFilesOptions {
|
|
|
35
40
|
ignore?: string | string[];
|
|
36
41
|
retryCount?: number;
|
|
37
42
|
retryInterval?: number;
|
|
43
|
+
verify?: boolean;
|
|
44
|
+
safe?: boolean;
|
|
45
|
+
prune?: boolean;
|
|
38
46
|
}
|
|
39
47
|
export type IHostingOptions = IHostingFileOptions | IHostingFilesOptions;
|
|
40
48
|
export interface IHostingCloudOptions {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 流式计算文件 MD5(32 位小写十六进制),不将整个文件读入内存
|
|
3
|
+
*/
|
|
4
|
+
export declare function md5File(filePath: string): Promise<string>;
|
|
5
|
+
/**
|
|
6
|
+
* 将目录上传文件拆分为资源组和入口组,确保入口文件最后上传
|
|
7
|
+
*/
|
|
8
|
+
export declare function splitEntryUploadFiles(files: Array<{
|
|
9
|
+
localPath: string;
|
|
10
|
+
cloudPath: string;
|
|
11
|
+
}>, entryFiles?: string[]): {
|
|
12
|
+
resourceFiles: Array<{
|
|
13
|
+
localPath: string;
|
|
14
|
+
cloudPath: string;
|
|
15
|
+
}>;
|
|
16
|
+
entryUploadFiles: Array<{
|
|
17
|
+
localPath: string;
|
|
18
|
+
cloudPath: string;
|
|
19
|
+
}>;
|
|
20
|
+
};
|
package/types/storage/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Environment } from '../environment';
|
|
2
2
|
import { IUploadMetadata, IListFileInfo, IFileInfo, ITempUrlInfo, IResponseInfo } from '../interfaces';
|
|
3
|
-
import { IUploadObjectHttpOptions, IUploadObjectHttpResult, IDeleteObjectHttpOptions, IDeleteObjectHttpResult, IDeleteObjectsHttpOptions, IDeletedObjectInfo, ICopyObjectHttpOptions, ICopyObjectHttpResult, IMoveObjectHttpOptions, IMoveObjectHttpResult, IListObjectsHttpOptions, IListObjectsHttpResult, ISignObjectHttpOptions, ISignObjectHttpResult, ISignObjectsHttpOptions, ISignObjectsHttpResult, ISignUploadObjectHttpOptions, ISignUploadObjectHttpResult, IUploadObjectBySignHttpOptions, IUploadObjectBySignHttpResult, IDownloadAuthenticatedObjectHttpOptions, IDownloadObjectHttpOptions, IDownloadPublicObjectHttpOptions, IDownloadObjectBySignHttpOptions, IDownloadObjectHttpResult, IGetObjectInfoAuthenticatedHttpOptions, IGetObjectInfoPublicHttpOptions, IGetObjectInfoHttpResult } from './type';
|
|
3
|
+
import { IUploadObjectHttpOptions, IUploadObjectHttpResult, IDeleteObjectHttpOptions, IDeleteObjectHttpResult, IDeleteObjectsHttpOptions, IDeletedObjectInfo, ICopyObjectHttpOptions, ICopyObjectHttpResult, IMoveObjectHttpOptions, IMoveObjectHttpResult, IListObjectsHttpOptions, IListObjectsHttpResult, ISignObjectHttpOptions, ISignObjectHttpResult, ISignObjectsHttpOptions, ISignObjectsHttpResult, ISignUploadObjectHttpOptions, ISignUploadObjectHttpResult, IUploadObjectBySignHttpOptions, IUploadObjectBySignHttpResult, IDownloadAuthenticatedObjectHttpOptions, IDownloadObjectHttpOptions, IDownloadPublicObjectHttpOptions, IDownloadObjectBySignHttpOptions, IDownloadObjectHttpResult, IGetObjectInfoAuthenticatedHttpOptions, IGetObjectInfoPublicHttpOptions, IGetObjectInfoHttpResult, IConsistentUploadOptions, IConsistentUploadResult, ILocalManifestItem, IManifestDiffResult, IManifestVerifyResult, IPruneResult, IRemoteManifestItem } from './type';
|
|
4
4
|
export interface IProgressData {
|
|
5
5
|
loaded: number;
|
|
6
6
|
total: number;
|
|
@@ -17,6 +17,7 @@ export interface IOptions {
|
|
|
17
17
|
export interface IFileOptions extends IOptions {
|
|
18
18
|
localPath: string;
|
|
19
19
|
cloudPath?: string;
|
|
20
|
+
entryFiles?: string[];
|
|
20
21
|
parallel?: number;
|
|
21
22
|
retryCount?: number;
|
|
22
23
|
retryInterval?: number;
|
|
@@ -40,6 +41,44 @@ export interface IWalkCloudDirOptions {
|
|
|
40
41
|
region: string;
|
|
41
42
|
marker?: string;
|
|
42
43
|
}
|
|
44
|
+
export interface IGetRemoteManifestOptions extends ICustomOptions {
|
|
45
|
+
/** 云端前缀,无前导斜杠(如 'dist');空串表示整桶 */
|
|
46
|
+
prefix: string;
|
|
47
|
+
/** 列举起始 Marker;默认 '/' 会排除 . 开头的 Key,列举备份目录时需传 '' */
|
|
48
|
+
marker?: string;
|
|
49
|
+
}
|
|
50
|
+
/** 一致性发布:远端备份选项 */
|
|
51
|
+
export interface IBackupRemoteOptions extends IGetRemoteManifestOptions {
|
|
52
|
+
/** 备份目标前缀,须以 .cloudbase-backup/ 开头,如 .cloudbase-backup/1724073600000/ */
|
|
53
|
+
backupPrefix: string;
|
|
54
|
+
/** 复制并发数,默认 20 */
|
|
55
|
+
parallel?: number;
|
|
56
|
+
}
|
|
57
|
+
/** 一致性发布:从备份恢复选项 */
|
|
58
|
+
export interface IRestoreFromBackupOptions extends ICustomOptions {
|
|
59
|
+
/** 备份前缀,与 backupRemote 所传一致 */
|
|
60
|
+
backupPrefix: string;
|
|
61
|
+
/** 待恢复的覆盖文件 Key(相对桶根),来自 diffManifest().overwritten */
|
|
62
|
+
overwrittenKeys: string[];
|
|
63
|
+
/** 待删除的新增文件 Key(相对桶根),来自 diffManifest().added */
|
|
64
|
+
addedKeys: string[];
|
|
65
|
+
/** 恢复复制并发数,默认 20 */
|
|
66
|
+
parallel?: number;
|
|
67
|
+
}
|
|
68
|
+
/** 一致性发布:清理备份选项 */
|
|
69
|
+
export interface ICleanupBackupOptions extends ICustomOptions {
|
|
70
|
+
/** 需要清理的备份前缀,与 backupRemote 所传一致 */
|
|
71
|
+
backupPrefix: string;
|
|
72
|
+
}
|
|
73
|
+
/** 一致性发布:远端冗余文件清理选项 */
|
|
74
|
+
export interface IPruneRemoteOptions extends ICustomOptions {
|
|
75
|
+
/** 限定清理范围的前缀,无前导斜杠;空串表示根目录 */
|
|
76
|
+
prefix: string;
|
|
77
|
+
/** 待删除 Key(相对桶根),来自 diffManifest().removed;越界与备份 Key 会被自动过滤 */
|
|
78
|
+
removedKeys: string[];
|
|
79
|
+
/** 删除并发数,默认 20 */
|
|
80
|
+
parallel?: number;
|
|
81
|
+
}
|
|
43
82
|
export interface IRoutingRules {
|
|
44
83
|
keyPrefixEquals?: string;
|
|
45
84
|
httpErrorCodeReturnedEquals?: string;
|
|
@@ -151,10 +190,92 @@ export interface ISearchResult {
|
|
|
151
190
|
}
|
|
152
191
|
type OnProgress = (progressData: IProgressData) => void;
|
|
153
192
|
type OnFileFinish = (error: Error, res: any, fileData: any) => void;
|
|
193
|
+
export { md5File } from './helper';
|
|
154
194
|
export declare class StorageService {
|
|
155
195
|
private environment;
|
|
156
196
|
private tcbService;
|
|
157
197
|
constructor(environment: Environment);
|
|
198
|
+
/**
|
|
199
|
+
* 获取远端 manifest(默认 Storage 桶):返回指定前缀下文件的 key / etag / size,
|
|
200
|
+
* 过滤目录占位对象。prefix 无前导斜杠,空串表示整桶(Hosting 桶请用 getRemoteManifestCustom)。
|
|
201
|
+
*/
|
|
202
|
+
getRemoteManifest(prefix: string): Promise<IRemoteManifestItem[]>;
|
|
203
|
+
/**
|
|
204
|
+
* 获取远端 manifest,支持自定义 Bucket 和 Region。prefix 规则同 getRemoteManifest。
|
|
205
|
+
*/
|
|
206
|
+
getRemoteManifestCustom(options: IGetRemoteManifestOptions): Promise<IRemoteManifestItem[]>;
|
|
207
|
+
/**
|
|
208
|
+
* 生成本地 manifest:计算每个文件的云端 Key、MD5 与 Size。
|
|
209
|
+
* - filePaths 须来自 walkLocalDir(localPath),目录不计入
|
|
210
|
+
* - key = cloudPath + 文件相对路径;cloudPath 为空表示桶根
|
|
211
|
+
* - MD5 以受控并发计算,避免阻塞事件循环
|
|
212
|
+
*/
|
|
213
|
+
buildLocalManifest(options: {
|
|
214
|
+
localPath: string;
|
|
215
|
+
cloudPath?: string;
|
|
216
|
+
filePaths: string[];
|
|
217
|
+
parallel?: number;
|
|
218
|
+
}): Promise<ILocalManifestItem[]>;
|
|
219
|
+
/**
|
|
220
|
+
* 计算本地 manifest 与远端 manifest 的差异:
|
|
221
|
+
* - added:本地有、远端无;overwritten:两边都有;removed:远端有、本地无
|
|
222
|
+
* - 两份 manifest 的 key 须基于同一 cloudPath(removed 自动排除 .cloudbase-backup/)
|
|
223
|
+
*/
|
|
224
|
+
diffManifest(localManifest: ILocalManifestItem[], remoteManifest: IRemoteManifestItem[]): IManifestDiffResult;
|
|
225
|
+
/**
|
|
226
|
+
* 校验远端 manifest 与本地 manifest 是否一致:
|
|
227
|
+
* - 远端缺失 → missing;ETag 为纯 MD5 → 按 md5 比对;分块上传 ETag → 降级按 size 比对
|
|
228
|
+
*/
|
|
229
|
+
verifyManifest(localManifest: ILocalManifestItem[], remoteManifest: IRemoteManifestItem[]): IManifestVerifyResult;
|
|
230
|
+
/**
|
|
231
|
+
* 将指定前缀下的远端文件服务端复制到备份前缀(不产生下载流量)。
|
|
232
|
+
* 始终跳过 .cloudbase-backup/ 下的历史备份,避免根目录发布时递归备份。
|
|
233
|
+
*/
|
|
234
|
+
backupRemote(options: IBackupRemoteOptions): Promise<void>;
|
|
235
|
+
/**
|
|
236
|
+
* 从备份恢复被覆盖旧文件(服务端复制回原 Key),并删除本次新增文件。
|
|
237
|
+
* 任一阶段失败都会抛错,由上层决定是否保留备份供人工恢复。
|
|
238
|
+
*/
|
|
239
|
+
restoreFromBackup(options: IRestoreFromBackupOptions): Promise<void>;
|
|
240
|
+
/**
|
|
241
|
+
* 删除备份前缀下的全部对象。
|
|
242
|
+
* 备份目录以 . 开头,内部使用空 Marker 列举,不受默认排除规则影响。
|
|
243
|
+
*/
|
|
244
|
+
cleanupBackup(options: ICleanupBackupOptions): Promise<void>;
|
|
245
|
+
/**
|
|
246
|
+
* 清理发布后远端残留的冗余文件。
|
|
247
|
+
* 仅删除 removedKeys 中位于 prefix 范围内的 Key,排除 .cloudbase-backup/;
|
|
248
|
+
* 单个删除失败记入 failedKeys,不影响其他文件。
|
|
249
|
+
*/
|
|
250
|
+
pruneRemote(options: IPruneRemoteOptions): Promise<IPruneResult>;
|
|
251
|
+
/**
|
|
252
|
+
* 一致性目录上传编排(safe、verify、prune)
|
|
253
|
+
*
|
|
254
|
+
* 执行流程(按顺序):
|
|
255
|
+
* 1. 扫描本地目录,生成 manifest(key/md5/size)
|
|
256
|
+
* 2. safe 或 prune 开启时:拉取远端快照 manifest,与本地做 diff
|
|
257
|
+
* (safe 用 diff 判定回滚范围;prune 用 diff.removed 判定待清理文件)
|
|
258
|
+
* 3. safe 开启时:将远端文件服务端复制到备份前缀 .cloudbase-backup/<时间戳>/
|
|
259
|
+
* 4. 执行目录上传(资源文件先传,入口文件 entries 最后传)
|
|
260
|
+
* 5. verify 开启时:重新拉取远端 manifest,逐文件校验 ETag/MD5 与 size
|
|
261
|
+
* 6. 上传与校验全部成功后,prune 开启时:删除 diff.removed 中仍冗余的远端文件
|
|
262
|
+
* 7. 返回 uploadedKeys / verified / backupPrefix / prune 结果
|
|
263
|
+
*
|
|
264
|
+
* 失败时的行为:
|
|
265
|
+
* - 步骤 4/5 失败且 safe 开启:自动从备份回滚(恢复被覆盖文件、删除本次新增
|
|
266
|
+
* 文件),回滚成功后抛出“已自动回滚”错误,备份保留供人工核查
|
|
267
|
+
* - 回滚失败:保留备份并在错误信息中给出备份位置,供人工恢复
|
|
268
|
+
* - 备份一律不由本编排自动清理,均需调用方确认后通过 cleanupBackup 显式删除
|
|
269
|
+
* - prune 仅在步骤 4/5 全部成功后执行,失败发布绝不会触发远端删除
|
|
270
|
+
*
|
|
271
|
+
* ⚠️ 危险参数:prune
|
|
272
|
+
* prune 会删除云端 cloudPath 前缀下、本次本地构建中不存在的全部文件。
|
|
273
|
+
* 若调用方误传了错误的 localPath/cloudPath,整目录都会被判定为“冗余”而删除。
|
|
274
|
+
* 发布成功后备份默认保留、不自动清理:prune 误删的文件可从备份前缀
|
|
275
|
+
* (.cloudbase-backup/<时间戳>/)人工恢复;调用方确认无误后应通过
|
|
276
|
+
* cleanupBackup 显式清理备份。调用方(CLI)仍须在开启 prune 前要求用户二次确认。
|
|
277
|
+
*/
|
|
278
|
+
uploadDirectoryConsistent(options: IConsistentUploadOptions): Promise<IConsistentUploadResult>;
|
|
158
279
|
/**
|
|
159
280
|
* 上传文件
|
|
160
281
|
* localPath 为文件夹时,会尝试在文件夹中寻找 cloudPath 中的文件名
|
|
@@ -311,6 +432,8 @@ export declare class StorageService {
|
|
|
311
432
|
*/
|
|
312
433
|
deleteDirectoryCustom(options: {
|
|
313
434
|
cloudPath: string;
|
|
435
|
+
/** 列举起始 Marker,默认 '/' 会排除 . 开头的 Key;清理备份目录时需传 '' */
|
|
436
|
+
marker?: string;
|
|
314
437
|
} & ICustomOptions): Promise<{
|
|
315
438
|
Deleted: {
|
|
316
439
|
Key: string;
|
|
@@ -595,4 +718,3 @@ export declare class StorageService {
|
|
|
595
718
|
private inferMimeByMagicBytes;
|
|
596
719
|
private assertValidContentType;
|
|
597
720
|
}
|
|
598
|
-
export {};
|
package/types/storage/type.d.ts
CHANGED
|
@@ -288,6 +288,121 @@ export interface IUploadObjectBySignHttpOptions {
|
|
|
288
288
|
export interface IUploadObjectBySignHttpResult {
|
|
289
289
|
Key: string;
|
|
290
290
|
}
|
|
291
|
+
/** 本地 manifest 条目:待发布文件的内容摘要 */
|
|
292
|
+
export interface ILocalManifestItem {
|
|
293
|
+
/** 云端 Key */
|
|
294
|
+
key: string;
|
|
295
|
+
/** 本地文件内容 MD5(32 位小写十六进制) */
|
|
296
|
+
md5: string;
|
|
297
|
+
/** 文件大小(字节) */
|
|
298
|
+
size: number;
|
|
299
|
+
/** 本地文件绝对路径 */
|
|
300
|
+
localPath: string;
|
|
301
|
+
}
|
|
302
|
+
/** 远端 manifest 条目:远端 COS 对象信息 */
|
|
303
|
+
export interface IRemoteManifestItem {
|
|
304
|
+
/** 云端 Key */
|
|
305
|
+
key: string;
|
|
306
|
+
/** 对象 ETag(去除首尾引号后) */
|
|
307
|
+
etag: string;
|
|
308
|
+
/** 对象大小(字节) */
|
|
309
|
+
size: number;
|
|
310
|
+
}
|
|
311
|
+
/** manifest 差异结果:用于回滚判定与发布后清理 */
|
|
312
|
+
export interface IManifestDiffResult {
|
|
313
|
+
/** 本地有、远端无 —— 本次新增 */
|
|
314
|
+
added: string[];
|
|
315
|
+
/** 本地有、远端也有 —— 本次覆盖 */
|
|
316
|
+
overwritten: string[];
|
|
317
|
+
/** 远端有、本地无 —— 可作为 prune 候选 */
|
|
318
|
+
removed: string[];
|
|
319
|
+
}
|
|
320
|
+
/** manifest 校验结果:用于发布后确认远端内容是否与本地一致 */
|
|
321
|
+
export interface IManifestVerifyResult {
|
|
322
|
+
/** 是否校验通过 */
|
|
323
|
+
ok: boolean;
|
|
324
|
+
/** 本地有但远端不存在的 Key */
|
|
325
|
+
missing: string[];
|
|
326
|
+
/** 远端存在但摘要或大小不匹配的 Key */
|
|
327
|
+
mismatched: string[];
|
|
328
|
+
}
|
|
329
|
+
/** 上传进度回调(与 StorageService 上传回调语义一致) */
|
|
330
|
+
export type IConsistentUploadProgress = (progressData: {
|
|
331
|
+
loaded: number;
|
|
332
|
+
total: number;
|
|
333
|
+
speed: number;
|
|
334
|
+
percent: number;
|
|
335
|
+
}) => void;
|
|
336
|
+
/** 单文件上传完成回调(与 StorageService 的 onFileFinish 语义一致) */
|
|
337
|
+
export type IConsistentUploadFileFinish = (error: Error, res: any, fileData: any) => void;
|
|
338
|
+
/** 一致性发布上传选项 */
|
|
339
|
+
export interface IConsistentUploadOptions {
|
|
340
|
+
/** 本地待发布目录(绝对或相对路径) */
|
|
341
|
+
localPath: string;
|
|
342
|
+
/** 云端目标前缀,无前导斜杠(如 'dist');为空表示桶根 */
|
|
343
|
+
cloudPath?: string;
|
|
344
|
+
/** 目标桶 */
|
|
345
|
+
bucket: string;
|
|
346
|
+
/** 目标地域 */
|
|
347
|
+
region: string;
|
|
348
|
+
/** 忽略规则,语义与现有 uploadDirectoryCustom 的 ignore 一致 */
|
|
349
|
+
ignore?: string | string[];
|
|
350
|
+
/** 是否获取文件 fileId */
|
|
351
|
+
fileId?: boolean;
|
|
352
|
+
/** 并发数量 */
|
|
353
|
+
parallel?: number;
|
|
354
|
+
/** 重试次数 */
|
|
355
|
+
retryCount?: number;
|
|
356
|
+
/** 重试时间间隔(毫秒) */
|
|
357
|
+
retryInterval?: number;
|
|
358
|
+
/** 上传进度回调 */
|
|
359
|
+
onProgress?: IConsistentUploadProgress;
|
|
360
|
+
/** 单文件上传完成回调(error / res / fileData,与普通上传语义一致) */
|
|
361
|
+
onFileFinish?: IConsistentUploadFileFinish;
|
|
362
|
+
/** 发布后校验远端与本地产物一致 */
|
|
363
|
+
verify?: boolean;
|
|
364
|
+
/** 安全发布:发布前快照备份 + 失败自动回滚 */
|
|
365
|
+
safe?: boolean;
|
|
366
|
+
/**
|
|
367
|
+
* ⚠️ 危险参数:发布后清理远端多余文件。
|
|
368
|
+
* 会删除云端 cloudPath 下、本地构建中不存在的全部文件。
|
|
369
|
+
* 若误传错误的 localPath/cloudPath,整个目录都会被判定为冗余而删除。
|
|
370
|
+
* 发布成功后备份默认保留,prune 误删的文件可从备份前缀人工恢复;
|
|
371
|
+
* 调用方(CLI)必须经用户二次确认后才可开启。
|
|
372
|
+
*/
|
|
373
|
+
prune?: boolean;
|
|
374
|
+
/**
|
|
375
|
+
* 入口文件列表。
|
|
376
|
+
* 为相对 localPath 的相对路径,如 'index.html'、'admin/index.html'。
|
|
377
|
+
* 未指定时默认识别所有层级下的 index.html。
|
|
378
|
+
*/
|
|
379
|
+
entries?: string[];
|
|
380
|
+
}
|
|
381
|
+
/** 远端多余文件清理结果 */
|
|
382
|
+
export interface IPruneResult {
|
|
383
|
+
/** 成功删除的云端 Key */
|
|
384
|
+
deletedKeys: string[];
|
|
385
|
+
/** 删除失败的文件及原因 */
|
|
386
|
+
failedKeys: {
|
|
387
|
+
key: string;
|
|
388
|
+
error: string;
|
|
389
|
+
}[];
|
|
390
|
+
}
|
|
391
|
+
/** 一致性发布结果 */
|
|
392
|
+
export interface IConsistentUploadResult {
|
|
393
|
+
/** 本次成功上传的云端 Key */
|
|
394
|
+
uploadedKeys: string[];
|
|
395
|
+
/** 是否执行了发布后校验 */
|
|
396
|
+
verified: boolean;
|
|
397
|
+
/**
|
|
398
|
+
* 发布前备份前缀,仅 safe 开启时返回,如 .cloudbase-backup/1724073600000/。
|
|
399
|
+
* 备份一律不由编排自动清理(发布成功与回滚后均保留):prune 误删或需要
|
|
400
|
+
* 人工核查时可从中恢复;调用方确认无误后应通过 cleanupBackup 显式清理。
|
|
401
|
+
*/
|
|
402
|
+
backupPrefix?: string;
|
|
403
|
+
/** 远端多余文件清理结果,仅 prune 开启时存在 */
|
|
404
|
+
prune?: IPruneResult;
|
|
405
|
+
}
|
|
291
406
|
/** 下载方法,仅支持 GET / HEAD */
|
|
292
407
|
export type IDownloadObjectHttpMethod = 'GET' | 'HEAD';
|
|
293
408
|
/** 下载对象公共参数 */
|