s3db.js 9.2.0 → 9.2.2
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/PLUGINS.md +453 -102
- package/README.md +31 -2
- package/dist/s3db.cjs.js +1240 -565
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1240 -565
- package/dist/s3db.es.js.map +1 -1
- package/package.json +5 -5
- package/src/concerns/async-event-emitter.js +46 -0
- package/src/database.class.js +23 -0
- package/src/plugins/backup/base-backup-driver.class.js +119 -0
- package/src/plugins/backup/filesystem-backup-driver.class.js +254 -0
- package/src/plugins/backup/index.js +85 -0
- package/src/plugins/backup/multi-backup-driver.class.js +304 -0
- package/src/plugins/backup/s3-backup-driver.class.js +313 -0
- package/src/plugins/backup.plugin.js +375 -729
- package/src/plugins/backup.plugin.js.backup +1026 -0
- package/src/plugins/scheduler.plugin.js +0 -1
- package/src/resource.class.js +156 -41
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import BaseBackupDriver from './base-backup-driver.class.js';
|
|
2
|
+
import { createBackupDriver } from './index.js';
|
|
3
|
+
import tryFn from '../../concerns/try-fn.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MultiBackupDriver - Manages multiple backup destinations
|
|
7
|
+
*
|
|
8
|
+
* Configuration:
|
|
9
|
+
* - destinations: Array of driver configurations
|
|
10
|
+
* - driver: Driver type (filesystem, s3)
|
|
11
|
+
* - config: Driver-specific configuration
|
|
12
|
+
* - strategy: Backup strategy (default: 'all')
|
|
13
|
+
* - 'all': Upload to all destinations (fail if any fails)
|
|
14
|
+
* - 'any': Upload to all, succeed if at least one succeeds
|
|
15
|
+
* - 'priority': Try destinations in order, stop on first success
|
|
16
|
+
* - concurrency: Max concurrent uploads (default: 3)
|
|
17
|
+
*/
|
|
18
|
+
export default class MultiBackupDriver extends BaseBackupDriver {
|
|
19
|
+
constructor(config = {}) {
|
|
20
|
+
super({
|
|
21
|
+
destinations: [],
|
|
22
|
+
strategy: 'all', // 'all', 'any', 'priority'
|
|
23
|
+
concurrency: 3,
|
|
24
|
+
requireAll: true, // For backward compatibility
|
|
25
|
+
...config
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
this.drivers = [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getType() {
|
|
32
|
+
return 'multi';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async onSetup() {
|
|
36
|
+
if (!Array.isArray(this.config.destinations) || this.config.destinations.length === 0) {
|
|
37
|
+
throw new Error('MultiBackupDriver: destinations array is required and must not be empty');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Create and setup all driver instances
|
|
41
|
+
for (const [index, destConfig] of this.config.destinations.entries()) {
|
|
42
|
+
if (!destConfig.driver) {
|
|
43
|
+
throw new Error(`MultiBackupDriver: destination[${index}] must have a driver type`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const driver = createBackupDriver(destConfig.driver, destConfig.config || {});
|
|
48
|
+
await driver.setup(this.database);
|
|
49
|
+
this.drivers.push({
|
|
50
|
+
driver,
|
|
51
|
+
config: destConfig,
|
|
52
|
+
index
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this.log(`Setup destination ${index}: ${destConfig.driver}`);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new Error(`Failed to setup destination ${index} (${destConfig.driver}): ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Legacy support for requireAll
|
|
62
|
+
if (this.config.requireAll === false) {
|
|
63
|
+
this.config.strategy = 'any';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.log(`Initialized with ${this.drivers.length} destinations, strategy: ${this.config.strategy}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async upload(filePath, backupId, manifest) {
|
|
70
|
+
const strategy = this.config.strategy;
|
|
71
|
+
const results = [];
|
|
72
|
+
const errors = [];
|
|
73
|
+
|
|
74
|
+
if (strategy === 'priority') {
|
|
75
|
+
// Try destinations in order, stop on first success
|
|
76
|
+
for (const { driver, config, index } of this.drivers) {
|
|
77
|
+
const [ok, err, result] = await tryFn(() =>
|
|
78
|
+
driver.upload(filePath, backupId, manifest)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (ok) {
|
|
82
|
+
this.log(`Priority upload successful to destination ${index}`);
|
|
83
|
+
return [{
|
|
84
|
+
...result,
|
|
85
|
+
driver: config.driver,
|
|
86
|
+
destination: index,
|
|
87
|
+
status: 'success'
|
|
88
|
+
}];
|
|
89
|
+
} else {
|
|
90
|
+
errors.push({ destination: index, error: err.message });
|
|
91
|
+
this.log(`Priority upload failed to destination ${index}: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new Error(`All priority destinations failed: ${errors.map(e => `${e.destination}: ${e.error}`).join('; ')}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// For 'all' and 'any' strategies, upload to all destinations
|
|
99
|
+
const uploadPromises = this.drivers.map(async ({ driver, config, index }) => {
|
|
100
|
+
const [ok, err, result] = await tryFn(() =>
|
|
101
|
+
driver.upload(filePath, backupId, manifest)
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (ok) {
|
|
105
|
+
this.log(`Upload successful to destination ${index}`);
|
|
106
|
+
return {
|
|
107
|
+
...result,
|
|
108
|
+
driver: config.driver,
|
|
109
|
+
destination: index,
|
|
110
|
+
status: 'success'
|
|
111
|
+
};
|
|
112
|
+
} else {
|
|
113
|
+
this.log(`Upload failed to destination ${index}: ${err.message}`);
|
|
114
|
+
const errorResult = {
|
|
115
|
+
driver: config.driver,
|
|
116
|
+
destination: index,
|
|
117
|
+
status: 'failed',
|
|
118
|
+
error: err.message
|
|
119
|
+
};
|
|
120
|
+
errors.push(errorResult);
|
|
121
|
+
return errorResult;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Execute uploads with concurrency limit
|
|
126
|
+
const allResults = await this._executeConcurrent(uploadPromises, this.config.concurrency);
|
|
127
|
+
const successResults = allResults.filter(r => r.status === 'success');
|
|
128
|
+
const failedResults = allResults.filter(r => r.status === 'failed');
|
|
129
|
+
|
|
130
|
+
if (strategy === 'all' && failedResults.length > 0) {
|
|
131
|
+
throw new Error(`Some destinations failed: ${failedResults.map(r => `${r.destination}: ${r.error}`).join('; ')}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (strategy === 'any' && successResults.length === 0) {
|
|
135
|
+
throw new Error(`All destinations failed: ${failedResults.map(r => `${r.destination}: ${r.error}`).join('; ')}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return allResults;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async download(backupId, targetPath, metadata) {
|
|
142
|
+
// Try to download from the first available destination
|
|
143
|
+
const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
|
|
144
|
+
|
|
145
|
+
for (const destMetadata of destinations) {
|
|
146
|
+
if (destMetadata.status !== 'success') continue;
|
|
147
|
+
|
|
148
|
+
const driverInstance = this.drivers.find(d => d.index === destMetadata.destination);
|
|
149
|
+
if (!driverInstance) continue;
|
|
150
|
+
|
|
151
|
+
const [ok, err, result] = await tryFn(() =>
|
|
152
|
+
driverInstance.driver.download(backupId, targetPath, destMetadata)
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (ok) {
|
|
156
|
+
this.log(`Downloaded from destination ${destMetadata.destination}`);
|
|
157
|
+
return result;
|
|
158
|
+
} else {
|
|
159
|
+
this.log(`Download failed from destination ${destMetadata.destination}: ${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
throw new Error(`Failed to download backup from any destination`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async delete(backupId, metadata) {
|
|
167
|
+
const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
|
|
168
|
+
const errors = [];
|
|
169
|
+
let successCount = 0;
|
|
170
|
+
|
|
171
|
+
for (const destMetadata of destinations) {
|
|
172
|
+
if (destMetadata.status !== 'success') continue;
|
|
173
|
+
|
|
174
|
+
const driverInstance = this.drivers.find(d => d.index === destMetadata.destination);
|
|
175
|
+
if (!driverInstance) continue;
|
|
176
|
+
|
|
177
|
+
const [ok, err] = await tryFn(() =>
|
|
178
|
+
driverInstance.driver.delete(backupId, destMetadata)
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (ok) {
|
|
182
|
+
successCount++;
|
|
183
|
+
this.log(`Deleted from destination ${destMetadata.destination}`);
|
|
184
|
+
} else {
|
|
185
|
+
errors.push(`${destMetadata.destination}: ${err.message}`);
|
|
186
|
+
this.log(`Delete failed from destination ${destMetadata.destination}: ${err.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (successCount === 0 && errors.length > 0) {
|
|
191
|
+
throw new Error(`Failed to delete from any destination: ${errors.join('; ')}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (errors.length > 0) {
|
|
195
|
+
this.log(`Partial delete success, some errors: ${errors.join('; ')}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async list(options = {}) {
|
|
200
|
+
// Get lists from all destinations and merge/deduplicate
|
|
201
|
+
const allLists = await Promise.allSettled(
|
|
202
|
+
this.drivers.map(({ driver, index }) =>
|
|
203
|
+
driver.list(options).catch(err => {
|
|
204
|
+
this.log(`List failed for destination ${index}: ${err.message}`);
|
|
205
|
+
return [];
|
|
206
|
+
})
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const backupMap = new Map();
|
|
211
|
+
|
|
212
|
+
// Merge results from all destinations
|
|
213
|
+
allLists.forEach((result, index) => {
|
|
214
|
+
if (result.status === 'fulfilled') {
|
|
215
|
+
result.value.forEach(backup => {
|
|
216
|
+
const existing = backupMap.get(backup.id);
|
|
217
|
+
if (!existing || new Date(backup.createdAt) > new Date(existing.createdAt)) {
|
|
218
|
+
backupMap.set(backup.id, {
|
|
219
|
+
...backup,
|
|
220
|
+
destinations: existing ? [...(existing.destinations || []), { destination: index, ...backup }] : [{ destination: index, ...backup }]
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const results = Array.from(backupMap.values())
|
|
228
|
+
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
|
229
|
+
.slice(0, options.limit || 50);
|
|
230
|
+
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async verify(backupId, expectedChecksum, metadata) {
|
|
235
|
+
const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
|
|
236
|
+
|
|
237
|
+
// Verify against any successful destination
|
|
238
|
+
for (const destMetadata of destinations) {
|
|
239
|
+
if (destMetadata.status !== 'success') continue;
|
|
240
|
+
|
|
241
|
+
const driverInstance = this.drivers.find(d => d.index === destMetadata.destination);
|
|
242
|
+
if (!driverInstance) continue;
|
|
243
|
+
|
|
244
|
+
const [ok, , isValid] = await tryFn(() =>
|
|
245
|
+
driverInstance.driver.verify(backupId, expectedChecksum, destMetadata)
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
if (ok && isValid) {
|
|
249
|
+
this.log(`Verification successful from destination ${destMetadata.destination}`);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async cleanup() {
|
|
258
|
+
await Promise.all(
|
|
259
|
+
this.drivers.map(({ driver }) =>
|
|
260
|
+
tryFn(() => driver.cleanup()).catch(() => {})
|
|
261
|
+
)
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
getStorageInfo() {
|
|
266
|
+
return {
|
|
267
|
+
...super.getStorageInfo(),
|
|
268
|
+
strategy: this.config.strategy,
|
|
269
|
+
destinations: this.drivers.map(({ driver, config, index }) => ({
|
|
270
|
+
index,
|
|
271
|
+
driver: config.driver,
|
|
272
|
+
info: driver.getStorageInfo()
|
|
273
|
+
}))
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Execute promises with concurrency limit
|
|
279
|
+
* @param {Array} promises - Array of promise functions
|
|
280
|
+
* @param {number} concurrency - Max concurrent executions
|
|
281
|
+
* @returns {Array} Results in original order
|
|
282
|
+
*/
|
|
283
|
+
async _executeConcurrent(promises, concurrency) {
|
|
284
|
+
const results = new Array(promises.length);
|
|
285
|
+
const executing = [];
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < promises.length; i++) {
|
|
288
|
+
const promise = Promise.resolve(promises[i]).then(result => {
|
|
289
|
+
results[i] = result;
|
|
290
|
+
return result;
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
executing.push(promise);
|
|
294
|
+
|
|
295
|
+
if (executing.length >= concurrency) {
|
|
296
|
+
await Promise.race(executing);
|
|
297
|
+
executing.splice(executing.findIndex(p => p === promise), 1);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
await Promise.all(executing);
|
|
302
|
+
return results;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import BaseBackupDriver from './base-backup-driver.class.js';
|
|
2
|
+
import { createReadStream } from 'fs';
|
|
3
|
+
import { stat } from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import tryFn from '../../concerns/try-fn.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* S3BackupDriver - Stores backups in S3-compatible storage
|
|
10
|
+
*
|
|
11
|
+
* Configuration:
|
|
12
|
+
* - bucket: S3 bucket name (optional, uses database bucket if not specified)
|
|
13
|
+
* - path: Key prefix for backups (supports template variables)
|
|
14
|
+
* - storageClass: S3 storage class (default: STANDARD_IA)
|
|
15
|
+
* - serverSideEncryption: S3 server-side encryption (default: AES256)
|
|
16
|
+
* - client: Custom S3 client (optional, uses database client if not specified)
|
|
17
|
+
*/
|
|
18
|
+
export default class S3BackupDriver extends BaseBackupDriver {
|
|
19
|
+
constructor(config = {}) {
|
|
20
|
+
super({
|
|
21
|
+
bucket: null, // Will use database bucket if not specified
|
|
22
|
+
path: 'backups/{date}/',
|
|
23
|
+
storageClass: 'STANDARD_IA',
|
|
24
|
+
serverSideEncryption: 'AES256',
|
|
25
|
+
client: null, // Will use database client if not specified
|
|
26
|
+
...config
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getType() {
|
|
31
|
+
return 's3';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async onSetup() {
|
|
35
|
+
// Use database client if not provided
|
|
36
|
+
if (!this.config.client) {
|
|
37
|
+
this.config.client = this.database.client;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Use database bucket if not specified
|
|
41
|
+
if (!this.config.bucket) {
|
|
42
|
+
this.config.bucket = this.database.bucket;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!this.config.client) {
|
|
46
|
+
throw new Error('S3BackupDriver: client is required (either via config or database)');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!this.config.bucket) {
|
|
50
|
+
throw new Error('S3BackupDriver: bucket is required (either via config or database)');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.log(`Initialized with bucket: ${this.config.bucket}, path: ${this.config.path}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve S3 key template variables
|
|
58
|
+
* @param {string} backupId - Backup identifier
|
|
59
|
+
* @param {Object} manifest - Backup manifest
|
|
60
|
+
* @returns {string} Resolved S3 key
|
|
61
|
+
*/
|
|
62
|
+
resolveKey(backupId, manifest = {}) {
|
|
63
|
+
const now = new Date();
|
|
64
|
+
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
65
|
+
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, '-'); // HH-MM-SS
|
|
66
|
+
|
|
67
|
+
const basePath = this.config.path
|
|
68
|
+
.replace('{date}', dateStr)
|
|
69
|
+
.replace('{time}', timeStr)
|
|
70
|
+
.replace('{year}', now.getFullYear().toString())
|
|
71
|
+
.replace('{month}', (now.getMonth() + 1).toString().padStart(2, '0'))
|
|
72
|
+
.replace('{day}', now.getDate().toString().padStart(2, '0'))
|
|
73
|
+
.replace('{backupId}', backupId)
|
|
74
|
+
.replace('{type}', manifest.type || 'backup');
|
|
75
|
+
|
|
76
|
+
return path.posix.join(basePath, `${backupId}.backup`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
resolveManifestKey(backupId, manifest = {}) {
|
|
80
|
+
return this.resolveKey(backupId, manifest).replace('.backup', '.manifest.json');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async upload(filePath, backupId, manifest) {
|
|
84
|
+
const backupKey = this.resolveKey(backupId, manifest);
|
|
85
|
+
const manifestKey = this.resolveManifestKey(backupId, manifest);
|
|
86
|
+
|
|
87
|
+
// Get file size
|
|
88
|
+
const [statOk, , stats] = await tryFn(() => stat(filePath));
|
|
89
|
+
const fileSize = statOk ? stats.size : 0;
|
|
90
|
+
|
|
91
|
+
// Upload backup file
|
|
92
|
+
const [uploadOk, uploadErr] = await tryFn(async () => {
|
|
93
|
+
const fileStream = createReadStream(filePath);
|
|
94
|
+
|
|
95
|
+
return await this.config.client.uploadObject({
|
|
96
|
+
bucket: this.config.bucket,
|
|
97
|
+
key: backupKey,
|
|
98
|
+
body: fileStream,
|
|
99
|
+
contentLength: fileSize,
|
|
100
|
+
metadata: {
|
|
101
|
+
'backup-id': backupId,
|
|
102
|
+
'backup-type': manifest.type || 'backup',
|
|
103
|
+
'created-at': new Date().toISOString()
|
|
104
|
+
},
|
|
105
|
+
storageClass: this.config.storageClass,
|
|
106
|
+
serverSideEncryption: this.config.serverSideEncryption
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (!uploadOk) {
|
|
111
|
+
throw new Error(`Failed to upload backup file: ${uploadErr.message}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Upload manifest
|
|
115
|
+
const [manifestOk, manifestErr] = await tryFn(() =>
|
|
116
|
+
this.config.client.uploadObject({
|
|
117
|
+
bucket: this.config.bucket,
|
|
118
|
+
key: manifestKey,
|
|
119
|
+
body: JSON.stringify(manifest, null, 2),
|
|
120
|
+
contentType: 'application/json',
|
|
121
|
+
metadata: {
|
|
122
|
+
'backup-id': backupId,
|
|
123
|
+
'manifest-for': backupKey
|
|
124
|
+
},
|
|
125
|
+
storageClass: this.config.storageClass,
|
|
126
|
+
serverSideEncryption: this.config.serverSideEncryption
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (!manifestOk) {
|
|
131
|
+
// Clean up backup file if manifest upload fails
|
|
132
|
+
await tryFn(() => this.config.client.deleteObject({
|
|
133
|
+
bucket: this.config.bucket,
|
|
134
|
+
key: backupKey
|
|
135
|
+
}));
|
|
136
|
+
throw new Error(`Failed to upload manifest: ${manifestErr.message}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.log(`Uploaded backup ${backupId} to s3://${this.config.bucket}/${backupKey} (${fileSize} bytes)`);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
bucket: this.config.bucket,
|
|
143
|
+
key: backupKey,
|
|
144
|
+
manifestKey,
|
|
145
|
+
size: fileSize,
|
|
146
|
+
storageClass: this.config.storageClass,
|
|
147
|
+
uploadedAt: new Date().toISOString(),
|
|
148
|
+
etag: uploadOk?.ETag
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async download(backupId, targetPath, metadata) {
|
|
153
|
+
const backupKey = metadata.key || this.resolveKey(backupId, metadata);
|
|
154
|
+
|
|
155
|
+
const [downloadOk, downloadErr] = await tryFn(() =>
|
|
156
|
+
this.config.client.downloadObject({
|
|
157
|
+
bucket: this.config.bucket,
|
|
158
|
+
key: backupKey,
|
|
159
|
+
filePath: targetPath
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (!downloadOk) {
|
|
164
|
+
throw new Error(`Failed to download backup: ${downloadErr.message}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.log(`Downloaded backup ${backupId} from s3://${this.config.bucket}/${backupKey} to ${targetPath}`);
|
|
168
|
+
return targetPath;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async delete(backupId, metadata) {
|
|
172
|
+
const backupKey = metadata.key || this.resolveKey(backupId, metadata);
|
|
173
|
+
const manifestKey = metadata.manifestKey || this.resolveManifestKey(backupId, metadata);
|
|
174
|
+
|
|
175
|
+
// Delete backup file
|
|
176
|
+
const [deleteBackupOk] = await tryFn(() =>
|
|
177
|
+
this.config.client.deleteObject({
|
|
178
|
+
bucket: this.config.bucket,
|
|
179
|
+
key: backupKey
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Delete manifest
|
|
184
|
+
const [deleteManifestOk] = await tryFn(() =>
|
|
185
|
+
this.config.client.deleteObject({
|
|
186
|
+
bucket: this.config.bucket,
|
|
187
|
+
key: manifestKey
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (!deleteBackupOk && !deleteManifestOk) {
|
|
192
|
+
throw new Error(`Failed to delete backup objects for ${backupId}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this.log(`Deleted backup ${backupId} from S3`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async list(options = {}) {
|
|
199
|
+
const { limit = 50, prefix = '' } = options;
|
|
200
|
+
const searchPrefix = this.config.path.replace(/\{[^}]+\}/g, '');
|
|
201
|
+
|
|
202
|
+
const [listOk, listErr, response] = await tryFn(() =>
|
|
203
|
+
this.config.client.listObjects({
|
|
204
|
+
bucket: this.config.bucket,
|
|
205
|
+
prefix: searchPrefix,
|
|
206
|
+
maxKeys: limit * 2 // Get more to account for manifest files
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (!listOk) {
|
|
211
|
+
this.log(`Error listing S3 objects: ${listErr.message}`);
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const manifestObjects = (response.Contents || [])
|
|
216
|
+
.filter(obj => obj.Key.endsWith('.manifest.json'))
|
|
217
|
+
.filter(obj => !prefix || obj.Key.includes(prefix));
|
|
218
|
+
|
|
219
|
+
const results = [];
|
|
220
|
+
|
|
221
|
+
for (const obj of manifestObjects.slice(0, limit)) {
|
|
222
|
+
const [manifestOk, , manifestContent] = await tryFn(() =>
|
|
223
|
+
this.config.client.getObject({
|
|
224
|
+
bucket: this.config.bucket,
|
|
225
|
+
key: obj.Key
|
|
226
|
+
})
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (manifestOk) {
|
|
230
|
+
try {
|
|
231
|
+
const manifest = JSON.parse(manifestContent);
|
|
232
|
+
const backupId = path.basename(obj.Key, '.manifest.json');
|
|
233
|
+
|
|
234
|
+
results.push({
|
|
235
|
+
id: backupId,
|
|
236
|
+
bucket: this.config.bucket,
|
|
237
|
+
key: obj.Key.replace('.manifest.json', '.backup'),
|
|
238
|
+
manifestKey: obj.Key,
|
|
239
|
+
size: obj.Size,
|
|
240
|
+
lastModified: obj.LastModified,
|
|
241
|
+
storageClass: obj.StorageClass,
|
|
242
|
+
createdAt: manifest.createdAt || obj.LastModified,
|
|
243
|
+
...manifest
|
|
244
|
+
});
|
|
245
|
+
} catch (parseErr) {
|
|
246
|
+
this.log(`Failed to parse manifest ${obj.Key}: ${parseErr.message}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Sort by creation time (newest first)
|
|
252
|
+
results.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
253
|
+
|
|
254
|
+
return results;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async verify(backupId, expectedChecksum, metadata) {
|
|
258
|
+
const backupKey = metadata.key || this.resolveKey(backupId, metadata);
|
|
259
|
+
|
|
260
|
+
const [verifyOk, verifyErr] = await tryFn(async () => {
|
|
261
|
+
// Get object metadata to check ETag
|
|
262
|
+
const headResponse = await this.config.client.headObject({
|
|
263
|
+
bucket: this.config.bucket,
|
|
264
|
+
key: backupKey
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// For single-part uploads, ETag is the MD5 hash
|
|
268
|
+
// For multipart uploads, ETag has a suffix like "-2"
|
|
269
|
+
const etag = headResponse.ETag?.replace(/"/g, '');
|
|
270
|
+
|
|
271
|
+
if (etag && !etag.includes('-')) {
|
|
272
|
+
// Single-part upload, ETag is MD5
|
|
273
|
+
const expectedMd5 = crypto.createHash('md5').update(expectedChecksum).digest('hex');
|
|
274
|
+
return etag === expectedMd5;
|
|
275
|
+
} else {
|
|
276
|
+
// For multipart uploads or SHA256 comparison, download and verify
|
|
277
|
+
const [streamOk, , stream] = await tryFn(() =>
|
|
278
|
+
this.config.client.getObjectStream({
|
|
279
|
+
bucket: this.config.bucket,
|
|
280
|
+
key: backupKey
|
|
281
|
+
})
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
if (!streamOk) return false;
|
|
285
|
+
|
|
286
|
+
const hash = crypto.createHash('sha256');
|
|
287
|
+
for await (const chunk of stream) {
|
|
288
|
+
hash.update(chunk);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const actualChecksum = hash.digest('hex');
|
|
292
|
+
return actualChecksum === expectedChecksum;
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (!verifyOk) {
|
|
297
|
+
this.log(`Verification failed for ${backupId}: ${verifyErr?.message || 'checksum mismatch'}`);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
getStorageInfo() {
|
|
305
|
+
return {
|
|
306
|
+
...super.getStorageInfo(),
|
|
307
|
+
bucket: this.config.bucket,
|
|
308
|
+
path: this.config.path,
|
|
309
|
+
storageClass: this.config.storageClass,
|
|
310
|
+
serverSideEncryption: this.config.serverSideEncryption
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|