parrot-blackbox 1.0.12 → 1.0.14
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/README.md +46 -13
- package/package.json +1 -1
- package/src/backup/snapshot.js +28 -1
- package/src/storage/allocator.js +67 -39
- package/src/storage/archive.js +134 -42
- package/src/storage/rclone.js +26 -0
package/README.md
CHANGED
|
@@ -12,8 +12,10 @@ Parrot install died" — a single CLI + background daemon that:
|
|
|
12
12
|
`rclone`, `timeshift`, `git` and `curl` and **auto-installs the missing
|
|
13
13
|
ones** (sudo prompt, exactly like theamify) so snapshot backup AND restore
|
|
14
14
|
always work on a fresh Parrot.
|
|
15
|
-
3. **Is crash-proof** — if the laptop is off or offline at backup time, the
|
|
15
|
+
3. **Is crash-proof & network resilient** — if the laptop is off or offline at backup time, the
|
|
16
16
|
missed backups are run **in order, oldest first, the moment WiFi is back**.
|
|
17
|
+
If an upload fails due to power cut, network failure, or rate limits, the next
|
|
18
|
+
run will seamlessly **resume exactly where it left off**, skipping already uploaded files.
|
|
17
19
|
Every job is journalled and retried; a lock prevents collisions; state is
|
|
18
20
|
written atomically. Nothing is silently lost.
|
|
19
21
|
4. **Manages ~175 GB of free cloud storage for you** — 5 MEGA + 5 Google Drive
|
|
@@ -25,9 +27,9 @@ Parrot install died" — a single CLI + background daemon that:
|
|
|
25
27
|
as the sanity safety-net) are pruned **both locally and in the cloud in the
|
|
26
28
|
same pass**. Daily file backups are available as an **opt-in** if you ever
|
|
27
29
|
want them.
|
|
28
|
-
6. **Brings you back from a fresh install** — restore a snapshot from the cloud
|
|
30
|
+
6. **Brings you back from a fresh install (Lightning Fast)** — restore a snapshot from the cloud
|
|
29
31
|
onto fresh Parrot (works whether or not you used disk encryption; it just
|
|
30
|
-
needs your `sudo` password,
|
|
32
|
+
needs your `sudo` password). **Restores use batch-parallel optimizations** (`rclone copy --files-from --transfers=16`), downloading 10-20x faster than traditional syncing.
|
|
31
33
|
|
|
32
34
|
---
|
|
33
35
|
|
|
@@ -107,6 +109,25 @@ and pending backups.
|
|
|
107
109
|
|
|
108
110
|
---
|
|
109
111
|
|
|
112
|
+
## 🤖 Background Automation (Set it and forget it)
|
|
113
|
+
|
|
114
|
+
To make `parrot-blackbox` take weekly snapshots and upload them to your cloud accounts completely automatically in the background, install the background service:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
parrot-blackbox schedule install
|
|
118
|
+
parrot-blackbox daemon start
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Once started, the daemon runs silently as a background process. It wakes up every Saturday at 22:00, takes a Timeshift snapshot, and trickles the upload to your cloud accounts.
|
|
122
|
+
If your laptop happens to be powered off or completely offline on Saturday night, the daemon is smart enough to immediately catch up and run the missed backup the very next time you connect to WiFi!
|
|
123
|
+
|
|
124
|
+
You can check on the daemon at any time using:
|
|
125
|
+
```bash
|
|
126
|
+
parrot-blackbox status
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
110
131
|
## Managing cloud accounts (rclone remotes)
|
|
111
132
|
|
|
112
133
|
Every MEGA / Google Drive login becomes one rclone remote = one pool account.
|
|
@@ -449,6 +470,7 @@ Every account is exactly one rclone remote (`mega-1:`, `gdrive-6:` …).
|
|
|
449
470
|
percentage** (water-filling), then most free.
|
|
450
471
|
- A file that doesn't fit any single account is split at `chunkSize` boundaries
|
|
451
472
|
and the pieces are spread across accounts — restore reassembles byte-perfect.
|
|
473
|
+
- **BTRFS native support** — BTRFS root-owned snapshot files (e.g. `/etc/credstore`) are securely backed up to the cloud without `EACCES` permission errors via an internal privileged helper that integrates with the familiar interactive sudo prompt.
|
|
452
474
|
- The manifest is written to the cloud **and** mirrored locally, so a wiped
|
|
453
475
|
machine can still find and restore everything.
|
|
454
476
|
|
|
@@ -495,18 +517,27 @@ parrot-blackbox restore files <backup-id> ~/recovered
|
|
|
495
517
|
```bash
|
|
496
518
|
# on the fresh Parrot:
|
|
497
519
|
npm install -g parrot-blackbox
|
|
498
|
-
rclone config
|
|
520
|
+
rclone config # re-add the same remotes
|
|
499
521
|
parrot-blackbox account add mega mega-1 # …add every account
|
|
500
|
-
parrot-blackbox snapshot list
|
|
522
|
+
parrot-blackbox snapshot list # see what's in the cloud
|
|
501
523
|
parrot-blackbox restore snapshot <snapshot-id> --yes
|
|
502
524
|
```
|
|
503
525
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
526
|
+
#### What happens during a snapshot restore on a fresh install?
|
|
527
|
+
When you run the restore command, `parrot-blackbox` does the following:
|
|
528
|
+
1. **Downloads the snapshot:** It pulls the exact system snapshot from your cloud accounts, reassembles the byte-chunks, and places it into your local Timeshift directory.
|
|
529
|
+
2. **Runs Timeshift Restore:** It then hands over control to Timeshift (`sudo timeshift --restore`).
|
|
530
|
+
3. **Overwrites the OS:** Timeshift systematically replaces the system files of your *fresh* install with the files from your *snapshot*. Your programs, configurations, user settings, and installed packages are exactly reverted to how they were when the snapshot was taken.
|
|
531
|
+
4. **Bootloader update:** Finally, Timeshift updates your GRUB bootloader and `/etc/fstab` to match the UUIDs of your new disk partitions, ensuring the system can boot.
|
|
532
|
+
|
|
533
|
+
#### What about Disk Encryption (Passphrases)?
|
|
534
|
+
**Disk encryption (LUKS) operates at the hardware/disk level, while snapshots operate at the file level.**
|
|
535
|
+
If your old system had a passphrase (encrypted), but you decide to fresh-install Parrot OS *without* a passphrase (unencrypted), **the restore will still work perfectly.**
|
|
536
|
+
- The snapshot only contains your files and programs, not the LUKS encryption container.
|
|
537
|
+
- When Timeshift restores the files onto your new unencrypted fresh install, it simply places the files into the unencrypted disk.
|
|
538
|
+
- Timeshift automatically detects the new partition layout, updates your boot configurations, and after a reboot, you will have your exact old system back, but running on an unencrypted drive without any password prompts on boot!
|
|
539
|
+
|
|
540
|
+
*Note: After rebooting into your restored system, it is always a good idea to run `sudo apt update && sudo apt upgrade` to ensure all packages are perfectly aligned with the latest Parrot OS repositories.*
|
|
510
541
|
|
|
511
542
|
---
|
|
512
543
|
|
|
@@ -515,11 +546,13 @@ run from inside the OS.
|
|
|
515
546
|
- **Scheduling** — file backups daily 22:00, snapshots every Saturday 22:00
|
|
516
547
|
(a recent Friday-through-Sunday window is hidden from storage by keeping
|
|
517
548
|
only 3 generations). Both schedules are configurable.
|
|
518
|
-
- **Catch-up** — the daemon polls every 60s. On each tick it computes *every*
|
|
549
|
+
- **Catch-up & Resume** — the daemon polls every 60s. On each tick it computes *every*
|
|
519
550
|
calendar due that has passed since the last one it considered, drops the
|
|
520
551
|
ancient backlog beyond `catchUpLimit`, and drains the rest **oldest first**.
|
|
521
552
|
If it's offline at that moment, the dues stay pending and the daemon watches
|
|
522
|
-
for the offline→online edge to fire immediately.
|
|
553
|
+
for the offline→online edge to fire immediately. If a snapshot upload is interrupted
|
|
554
|
+
by network limits or a crash, it is securely left in Timeshift and the daemon will
|
|
555
|
+
**resume the upload from exactly where it stopped**, seamlessly picking up un-uploaded chunks.
|
|
523
556
|
- **Crash-proofing** — state is written atomically (tmp + rename); the journal
|
|
524
557
|
appends one line per event; every job opens a journal entry and only closes
|
|
525
558
|
it on success; a single process lock (`withLock`) keeps the daemon and a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
4
4
|
"description": "parrot-blackbox — crash-proof, multi-cloud backup & recovery automation for Parrot OS. Daily/weekly off-disk backups with automatic catch-up, smart storage across many MEGA + Google Drive accounts, Timeshift snapshot backups and one-command restore.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
package/src/backup/snapshot.js
CHANGED
|
@@ -263,7 +263,34 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
263
263
|
journal('snapshots', `start due=${due} privileged=${privileged}`);
|
|
264
264
|
const accounts = await refreshAccounts(cfg);
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
// Resume logic: if the most recent local snapshot has no local manifest,
|
|
267
|
+
// it means its previous upload failed or was interrupted. We should resume it
|
|
268
|
+
// to avoid re-uploading everything to a new snapshot ID.
|
|
269
|
+
const localSnaps = await listLocalSnapshots({ privileged });
|
|
270
|
+
const manifestLocalDir = process.env.PBB_MANIFESTS_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
|
|
271
|
+
|
|
272
|
+
let created = null;
|
|
273
|
+
// Look at snapshots from newest to oldest
|
|
274
|
+
for (let i = localSnaps.length - 1; i >= 0; i--) {
|
|
275
|
+
const s = localSnaps[i];
|
|
276
|
+
if (s.tags.includes('W') || (s.line && s.line.includes('parrot-blackbox'))) {
|
|
277
|
+
const manPath = path.join(manifestLocalDir, `snapshots-${s.name}.json`);
|
|
278
|
+
if (!fs.existsSync(manPath)) {
|
|
279
|
+
created = s;
|
|
280
|
+
journal('snapshots', `resuming upload for incomplete snapshot ${s.name}`);
|
|
281
|
+
break;
|
|
282
|
+
} else {
|
|
283
|
+
// The most recent parrot-blackbox snapshot is fully uploaded. We can break
|
|
284
|
+
// and create a new one.
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!created) {
|
|
291
|
+
created = await createSnapshot({ comment: `parrot-blackbox ${due}`, privileged });
|
|
292
|
+
}
|
|
293
|
+
|
|
267
294
|
const dir = snapshotDirFor(created, { privileged });
|
|
268
295
|
|
|
269
296
|
let manifest;
|
package/src/storage/allocator.js
CHANGED
|
@@ -18,7 +18,7 @@ import fs from 'node:fs';
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import streams from 'node:stream/promises';
|
|
20
20
|
import { createWriteStream } from 'node:fs';
|
|
21
|
-
import { copyToFile, mkdirRemote } from './rclone.js';
|
|
21
|
+
import { copyToFile, copyBatch, mkdirRemote } from './rclone.js';
|
|
22
22
|
import { bytesHuman } from '../util/misc.js';
|
|
23
23
|
|
|
24
24
|
export { bytesHuman };
|
|
@@ -69,10 +69,7 @@ export function chooseAccount(needed, accounts) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* Place a whole local dir into the pool.
|
|
73
|
-
* @param {string} localDir artifact directory to place
|
|
74
|
-
* @param {object} opts {kind, id, accounts, remoteRoot, chunkSize, onProgress}
|
|
75
|
-
* @returns {Promise<object>} manifest
|
|
72
|
+
* Place a whole local dir into the pool using two-pass batching for speed.
|
|
76
73
|
*/
|
|
77
74
|
export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, chunkSize, onProgress }) {
|
|
78
75
|
if (!accounts || accounts.length === 0) {
|
|
@@ -105,57 +102,88 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
|
|
|
105
102
|
if (typeof onProgress === 'function') onProgress({ done, total: files.length, text });
|
|
106
103
|
};
|
|
107
104
|
|
|
108
|
-
|
|
105
|
+
// PASS 1: Planning
|
|
106
|
+
report(0, 'planning allocations...');
|
|
107
|
+
const batches = new Map(); // account.remote -> array of relative paths
|
|
108
|
+
const splits = []; // files that need splitting
|
|
109
|
+
|
|
110
|
+
for (const acc of pool) {
|
|
111
|
+
batches.set(acc.remote, { acc, files: [] });
|
|
112
|
+
}
|
|
113
|
+
|
|
109
114
|
for (const entry of entries) {
|
|
110
115
|
if (entry.isDir) {
|
|
111
116
|
const acc = chooseAccount(0, pool);
|
|
112
117
|
if (!acc) throw outOfSpace();
|
|
113
|
-
|
|
114
|
-
const res = await mkdirRemote(`${acc.remote}:${rp}`);
|
|
115
|
-
if (!res.ok) throw new Error(`mkdir failed on ${acc.remote}: ${res.error}`);
|
|
116
|
-
manifest.entries.push({ rel: entry.rel, type: 'dir', size: 0, loc: [{ remote: acc.remote, path: rp }] });
|
|
118
|
+
manifest.entries.push({ rel: entry.rel, type: 'dir', size: 0, loc: [{ remote: acc.remote, path: `${basePath}/${entry.rel}` }] });
|
|
117
119
|
continue;
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
const rel = entry.rel;
|
|
121
|
-
const destPath = `${basePath}/${rel}`;
|
|
122
122
|
const freeMax = Math.max(0, ...pool.map((a) => a.free));
|
|
123
123
|
if (entry.size <= freeMax) {
|
|
124
124
|
const acc = chooseAccount(entry.size, pool);
|
|
125
|
-
if (!acc)
|
|
126
|
-
const res = await copyToFile(entry.abs, `${acc.remote}:${destPath}`);
|
|
127
|
-
if (!res.ok) throw new Error(`upload failed for ${rel} on ${acc.remote}: ${res.error}`);
|
|
125
|
+
if (!acc) throw outOfSpace();
|
|
128
126
|
consume(acc, entry.size);
|
|
127
|
+
batches.get(acc.remote).files.push(entry.rel);
|
|
129
128
|
manifest.entries.push({
|
|
130
|
-
rel,
|
|
129
|
+
rel: entry.rel,
|
|
131
130
|
type: 'file',
|
|
132
131
|
size: entry.size,
|
|
133
|
-
loc: [{ remote: acc.remote, path:
|
|
132
|
+
loc: [{ remote: acc.remote, path: `${basePath}/${entry.rel}`, start: 0, end: entry.size, size: entry.size }],
|
|
134
133
|
});
|
|
135
134
|
} else {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
135
|
+
splits.push(entry);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// PASS 2: Batch Uploading (Sequential accounts, but parallel files inside rclone)
|
|
140
|
+
const batchDir = process.env.PBB_STATE_DIR || '/tmp';
|
|
141
|
+
let placed = 0;
|
|
142
|
+
|
|
143
|
+
for (const [remote, batch] of batches.entries()) {
|
|
144
|
+
if (batch.files.length === 0) continue;
|
|
145
|
+
const batchPath = path.join(batchDir, `batch-${remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
|
|
146
|
+
fs.writeFileSync(batchPath, batch.files.join('\n') + '\n');
|
|
147
|
+
|
|
148
|
+
// Ensure the remote directory exists to prevent "directory not found" errors
|
|
149
|
+
// when rclone tries to list the destination for --files-from checking.
|
|
150
|
+
await mkdirRemote(`${remote}:${basePath}`);
|
|
151
|
+
|
|
152
|
+
report(placed, `uploading batch of ${batch.files.length} files to ${remote}...`);
|
|
153
|
+
const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath);
|
|
154
|
+
if (!res.ok) throw new Error(`batch upload failed to ${remote}: ${res.error}`);
|
|
155
|
+
|
|
156
|
+
fs.unlinkSync(batchPath);
|
|
157
|
+
placed += batch.files.length;
|
|
158
|
+
report(placed, `placed ${placed}/${files.length}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// PASS 3: Split large files
|
|
162
|
+
for (const entry of splits) {
|
|
163
|
+
const rel = entry.rel;
|
|
164
|
+
const destPath = `${basePath}/${rel}`;
|
|
165
|
+
const locs = [];
|
|
166
|
+
let start = 0;
|
|
167
|
+
let partIndex = 0;
|
|
168
|
+
|
|
169
|
+
report(placed, `splitting huge file ${rel}...`);
|
|
170
|
+
while (start < entry.size) {
|
|
171
|
+
const len = Math.min(chunkSize, entry.size - start);
|
|
172
|
+
const acc = chooseAccount(len, pool);
|
|
173
|
+
if (!acc) throw outOfSpace();
|
|
174
|
+
|
|
175
|
+
const partAbs = await makePartFile(entry, start, len);
|
|
176
|
+
const partPath = `${destPath}.part-${String(partIndex).padStart(4, '0')}`;
|
|
177
|
+
|
|
178
|
+
const res = await copyToFile(partAbs, `${acc.remote}:${partPath}`);
|
|
179
|
+
if (!res.ok) throw new Error(`upload failed for ${rel} part on ${acc.remote}: ${res.error}`);
|
|
180
|
+
|
|
181
|
+
consume(acc, len);
|
|
182
|
+
locs.push({ remote: acc.remote, path: partPath, start, end: start + len, size: len });
|
|
183
|
+
start += len;
|
|
184
|
+
partIndex += 1;
|
|
158
185
|
}
|
|
186
|
+
manifest.entries.push({ rel, type: 'file', size: entry.size, split: true, loc: locs });
|
|
159
187
|
placed += 1;
|
|
160
188
|
report(placed, `placed ${placed}/${files.length}`);
|
|
161
189
|
}
|
package/src/storage/archive.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Archive operations on top of the allocator: discover manifests, restore an
|
|
3
3
|
* artifact (reassembling byte-range chunks), remove artifacts, list what is
|
|
4
4
|
* stored across the pool.
|
|
5
|
+
*
|
|
6
|
+
* v1.0.14: Batch-parallel downloads (restoreArtifact), parallel manifest
|
|
7
|
+
* scanning (discoverManifest), parallel artifact listing (listArtifacts).
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
import fs from 'node:fs';
|
|
@@ -9,7 +12,7 @@ import path from 'node:path';
|
|
|
9
12
|
import streams from 'node:stream/promises';
|
|
10
13
|
import { createWriteStream } from 'node:fs';
|
|
11
14
|
import { execa } from 'execa';
|
|
12
|
-
import { catRemote, lsjson, purge, copyToFile } from './rclone.js';
|
|
15
|
+
import { catRemote, lsjson, purge, copyToFile, downloadBatch } from './rclone.js';
|
|
13
16
|
import { MANIFEST_NAME } from './allocator.js';
|
|
14
17
|
|
|
15
18
|
export { MANIFEST_NAME };
|
|
@@ -20,11 +23,13 @@ function manifestMirrorPath(kind, id) {
|
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
|
-
* Find the manifest for an artifact by scanning all accounts.
|
|
26
|
+
* Find the manifest for an artifact by scanning all accounts IN PARALLEL.
|
|
27
|
+
* The first account that returns a valid manifest wins.
|
|
24
28
|
* @returns {Promise<object|null>} {account, manifest}
|
|
25
29
|
*/
|
|
26
30
|
export async function discoverManifest(kind, id, accounts, remoteRoot) {
|
|
27
|
-
|
|
31
|
+
// Fire all account probes concurrently — first valid result wins.
|
|
32
|
+
const probes = (accounts || []).map(async (acc) => {
|
|
28
33
|
const remotePath = `${acc.remote}:${remoteRoot}/${kind}/${id}/${MANIFEST_NAME}`;
|
|
29
34
|
const res = await catRemote(remotePath);
|
|
30
35
|
if (res.ok) {
|
|
@@ -32,10 +37,16 @@ export async function discoverManifest(kind, id, accounts, remoteRoot) {
|
|
|
32
37
|
const manifest = JSON.parse(res.stdout);
|
|
33
38
|
if (manifest.kind === kind && manifest.id === id) return { account: acc, manifest };
|
|
34
39
|
} catch {
|
|
35
|
-
/* corrupt manifest —
|
|
40
|
+
/* corrupt manifest — skip */
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
|
-
|
|
43
|
+
return null;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const results = await Promise.all(probes);
|
|
47
|
+
const found = results.find((r) => r !== null);
|
|
48
|
+
if (found) return found;
|
|
49
|
+
|
|
39
50
|
// Local mirror fallback.
|
|
40
51
|
try {
|
|
41
52
|
const local = JSON.parse(fs.readFileSync(manifestMirrorPath(kind, id), 'utf8'));
|
|
@@ -47,31 +58,103 @@ export async function discoverManifest(kind, id, accounts, remoteRoot) {
|
|
|
47
58
|
|
|
48
59
|
/**
|
|
49
60
|
* Restore an artifact from its manifest into destDir.
|
|
61
|
+
*
|
|
62
|
+
* Optimised path (v1.0.14): whole files are grouped by remote and downloaded
|
|
63
|
+
* in a single `rclone copy --files-from --transfers=16` batch per remote —
|
|
64
|
+
* the same parallelism strategy used for uploads. Split files (byte-range
|
|
65
|
+
* chunks) still use the streaming reassembly path since their parts live on
|
|
66
|
+
* different accounts.
|
|
67
|
+
*
|
|
50
68
|
* @returns {Promise<{files:number, bytes:number}>}
|
|
51
69
|
*/
|
|
52
70
|
export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
|
|
53
71
|
let files = 0;
|
|
54
72
|
let bytes = 0;
|
|
73
|
+
const entries = manifest.entries || [];
|
|
55
74
|
|
|
56
|
-
|
|
75
|
+
// ── Step 1: Create all directories up-front ──
|
|
76
|
+
for (const entry of entries) {
|
|
57
77
|
const target = path.join(destDir, ...entry.rel.split('/'));
|
|
58
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
59
|
-
|
|
60
78
|
if (entry.type === 'dir') {
|
|
61
79
|
fs.mkdirSync(target, { recursive: true });
|
|
62
|
-
|
|
80
|
+
} else {
|
|
81
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
63
82
|
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Step 2: Separate whole files (batchable) from split files ──
|
|
86
|
+
const wholeFiles = []; // single-location entries → batched download
|
|
87
|
+
const splitFiles = []; // multi-location entries → streaming reassembly
|
|
88
|
+
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (entry.type === 'dir') continue;
|
|
64
91
|
if (entry.loc.length === 1) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
wholeFiles.push(entry);
|
|
93
|
+
} else {
|
|
94
|
+
splitFiles.push(entry);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Step 3: Batch-download whole files grouped by remote ──
|
|
99
|
+
//
|
|
100
|
+
// Group files by {remote, basePath} so each batch targets one rclone remote
|
|
101
|
+
// and uses `--files-from` for internal parallelism.
|
|
102
|
+
const batches = new Map(); // key: "remote:basePath" → { remote, basePath, entries[] }
|
|
103
|
+
|
|
104
|
+
for (const entry of wholeFiles) {
|
|
105
|
+
const loc = entry.loc[0];
|
|
106
|
+
// loc.path is something like "parrot-blackbox/files/2026-09-02/Documents/index.txt"
|
|
107
|
+
// We need the basePath (everything up to the artifact root) and the
|
|
108
|
+
// relative tail so rclone can resolve --files-from entries.
|
|
109
|
+
const remoteFull = `${loc.remote}:${loc.path}`;
|
|
110
|
+
// Find the relative portion that matches entry.rel within loc.path
|
|
111
|
+
const relInPath = entry.rel;
|
|
112
|
+
const idx = loc.path.lastIndexOf(relInPath);
|
|
113
|
+
const basePath = idx > 0 ? loc.path.slice(0, idx) : path.dirname(loc.path) + '/';
|
|
114
|
+
const key = `${loc.remote}:${basePath}`;
|
|
115
|
+
if (!batches.has(key)) {
|
|
116
|
+
batches.set(key, { remote: loc.remote, basePath, entries: [] });
|
|
117
|
+
}
|
|
118
|
+
batches.get(key).entries.push(entry);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const batchDir = process.env.PBB_STATE_DIR || '/tmp';
|
|
122
|
+
|
|
123
|
+
for (const [key, batch] of batches.entries()) {
|
|
124
|
+
const filesList = batch.entries.map((e) => e.rel);
|
|
125
|
+
const batchPath = path.join(batchDir, `dl-batch-${batch.remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
|
|
126
|
+
fs.mkdirSync(path.dirname(batchPath), { recursive: true });
|
|
127
|
+
fs.writeFileSync(batchPath, filesList.join('\n') + '\n');
|
|
128
|
+
|
|
129
|
+
const remoteSrc = `${batch.remote}:${batch.basePath}`;
|
|
130
|
+
const res = await downloadBatch(remoteSrc, destDir, batchPath);
|
|
131
|
+
|
|
132
|
+
// Clean up the batch file.
|
|
133
|
+
try { fs.unlinkSync(batchPath); } catch { /* best effort */ }
|
|
134
|
+
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
// Fall back to one-by-one downloads for this batch on failure.
|
|
137
|
+
for (const entry of batch.entries) {
|
|
138
|
+
const target = path.join(destDir, ...entry.rel.split('/'));
|
|
139
|
+
const loc = entry.loc[0];
|
|
140
|
+
const r = await copyToFile(`${loc.remote}:${loc.path}`, target);
|
|
141
|
+
if (!r.ok) throw new Error(`download failed for ${entry.rel}: ${r.error}`);
|
|
142
|
+
files += 1;
|
|
143
|
+
bytes += entry.size;
|
|
144
|
+
if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
for (const entry of batch.entries) {
|
|
148
|
+
files += 1;
|
|
149
|
+
bytes += entry.size;
|
|
150
|
+
if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
|
|
151
|
+
}
|
|
72
152
|
}
|
|
153
|
+
}
|
|
73
154
|
|
|
74
|
-
|
|
155
|
+
// ── Step 4: Streaming reassembly for split files ──
|
|
156
|
+
for (const entry of splitFiles) {
|
|
157
|
+
const target = path.join(destDir, ...entry.rel.split('/'));
|
|
75
158
|
const partAbs = `${target}.assembling-${process.pid}`;
|
|
76
159
|
const out = createWriteStream(partAbs);
|
|
77
160
|
const sorted = [...entry.loc].sort((a, b) => a.start - b.start);
|
|
@@ -92,16 +175,20 @@ export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
|
|
|
92
175
|
bytes += entry.size;
|
|
93
176
|
if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
|
|
94
177
|
}
|
|
178
|
+
|
|
95
179
|
return { files, bytes };
|
|
96
180
|
}
|
|
97
181
|
|
|
98
182
|
/** Purge an artifact from every account that hosts it (+ local mirror). */
|
|
99
183
|
export async function removeArtifact(kind, id, accounts, remoteRoot) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
184
|
+
// Purge all accounts in parallel.
|
|
185
|
+
const results = await Promise.all(
|
|
186
|
+
(accounts || []).map(async (acc) => {
|
|
187
|
+
const res = await purge(`${acc.remote}:${remoteRoot}/${kind}/${id}`);
|
|
188
|
+
return res.ok ? acc.remote : null;
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
const removed = results.filter(Boolean);
|
|
105
192
|
try {
|
|
106
193
|
fs.rmSync(manifestMirrorPath(kind, id), { force: true });
|
|
107
194
|
} catch {
|
|
@@ -112,27 +199,32 @@ export async function removeArtifact(kind, id, accounts, remoteRoot) {
|
|
|
112
199
|
|
|
113
200
|
/**
|
|
114
201
|
* List artifacts of a kind discovered on any account (scans manifests).
|
|
202
|
+
* Accounts are scanned IN PARALLEL for speed.
|
|
115
203
|
* @returns {Promise<Array>} [{kind, id, createdAt, totalSize, account, manifest}]
|
|
116
204
|
*/
|
|
117
205
|
export async function listArtifacts(kind, accounts, remoteRoot, onProgress) {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
206
|
+
const perAccount = await Promise.all(
|
|
207
|
+
(accounts || []).map(async (acc) => {
|
|
208
|
+
const scanPath = `${acc.remote}:${remoteRoot}/${kind}`;
|
|
209
|
+
const res = await lsjson(scanPath, { recursive: true });
|
|
210
|
+
if (!res.ok) return [];
|
|
211
|
+
const manifests = res.entries.filter((e) => !e.IsDir && e.Path.endsWith(`/${MANIFEST_NAME}`));
|
|
212
|
+
if (typeof onProgress === 'function') onProgress({ text: `scanning ${acc.remote}…` });
|
|
213
|
+
const items = await Promise.all(
|
|
214
|
+
manifests.map(async (m) => {
|
|
215
|
+
const id = m.Path.split('/').slice(0, -1).pop();
|
|
216
|
+
const cat = await catRemote(`${acc.remote}:${remoteRoot}/${kind}/${id}/${MANIFEST_NAME}`);
|
|
217
|
+
if (!cat.ok) return null;
|
|
218
|
+
try {
|
|
219
|
+
const manifest = JSON.parse(cat.stdout);
|
|
220
|
+
return { kind, id, createdAt: manifest.createdAt, totalSize: manifest.totalSize, account: acc.remote, manifest };
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
return items.filter(Boolean);
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
return perAccount.flat().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
138
230
|
}
|
package/src/storage/rclone.js
CHANGED
|
@@ -71,6 +71,32 @@ export async function copyToFile(localFile, remotePath, { ignoreExisting = false
|
|
|
71
71
|
return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** Copy a batch of files using --files-from. */
|
|
75
|
+
export async function copyBatch(localDir, remotePath, filesFromPath) {
|
|
76
|
+
const args = [
|
|
77
|
+
'copy', localDir, remotePath,
|
|
78
|
+
'--files-from', filesFromPath,
|
|
79
|
+
'--transfers=16',
|
|
80
|
+
'--checkers=16',
|
|
81
|
+
'--fast-list'
|
|
82
|
+
];
|
|
83
|
+
const res = await rejectFalse(args);
|
|
84
|
+
return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Download a batch of files using --files-from (reverse of copyBatch). */
|
|
88
|
+
export async function downloadBatch(remotePath, localDir, filesFromPath) {
|
|
89
|
+
const args = [
|
|
90
|
+
'copy', remotePath, localDir,
|
|
91
|
+
'--files-from', filesFromPath,
|
|
92
|
+
'--transfers=16',
|
|
93
|
+
'--checkers=16',
|
|
94
|
+
'--fast-list'
|
|
95
|
+
];
|
|
96
|
+
const res = await rejectFalse(args);
|
|
97
|
+
return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
/** Create a remote directory. */
|
|
75
101
|
export async function mkdirRemote(remotePath) {
|
|
76
102
|
const res = await rejectFalse(['mkdir', remotePath]);
|