parrot-blackbox 2.0.5 → 2.0.7
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/package.json +1 -1
- package/src/backup/snapshot.js +61 -6
- package/src/cli.js +24 -15
- package/src/commands/wizard.js +4 -2
- package/src/storage/allocator.js +25 -1
- package/src/util/misc.js +50 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "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
|
@@ -259,6 +259,31 @@ export async function deleteAllSnapshots({ privileged = 'interactive', onProgres
|
|
|
259
259
|
|
|
260
260
|
return { deleted, failed };
|
|
261
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* A module-level registry of all temporary BTRFS mounts created during this
|
|
264
|
+
* process lifetime, so we can unmount them all at exit even if the snapshot
|
|
265
|
+
* object that originally held the reference was garbage-collected.
|
|
266
|
+
*/
|
|
267
|
+
const _activeTempMounts = new Set();
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Unmount and remove all temporary BTRFS mount points created by this process.
|
|
271
|
+
* Called on process exit and also by cleanupSnapshotMount.
|
|
272
|
+
*/
|
|
273
|
+
function _cleanupAllTempMounts() {
|
|
274
|
+
for (const mp of _activeTempMounts) {
|
|
275
|
+
try { sudoExecSync(['umount', mp]); } catch { /* best effort */ }
|
|
276
|
+
try { sudoExecSync(['rmdir', mp]); } catch { /* best effort */ }
|
|
277
|
+
_activeTempMounts.delete(mp);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Register a process exit handler so stale mounts are always cleaned up,
|
|
282
|
+
// even if the snapshot object holding _tempMount was discarded before cleanup.
|
|
283
|
+
process.on('exit', _cleanupAllTempMounts);
|
|
284
|
+
// Also handle abnormal exits so the mount isn't left behind after Ctrl+C
|
|
285
|
+
// (SIGINT/SIGTERM handlers in cli.js call process.exit(), which fires 'exit').
|
|
286
|
+
|
|
262
287
|
/**
|
|
263
288
|
* Resolve the on-disk directory of a snapshot.
|
|
264
289
|
*
|
|
@@ -270,6 +295,11 @@ export async function deleteAllSnapshots({ privileged = 'interactive', onProgres
|
|
|
270
295
|
* 2. Check static paths (rsync mode)
|
|
271
296
|
* 3. Mount the BTRFS root subvolume ourselves to access snapshots persistently
|
|
272
297
|
* 4. Fall back to triggering timeshift --list
|
|
298
|
+
*
|
|
299
|
+
* IMPORTANT: the mount point is registered in _activeTempMounts so that
|
|
300
|
+
* _cleanupAllTempMounts() can release it on process exit even if the snapshot
|
|
301
|
+
* object is later discarded (e.g. after a resume-upload path that builds a new
|
|
302
|
+
* snapshot object from just the name).
|
|
273
303
|
*/
|
|
274
304
|
export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {}) {
|
|
275
305
|
if (snapshot.dir && fs.existsSync(snapshot.dir)) return snapshot.dir;
|
|
@@ -301,6 +331,9 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
|
|
|
301
331
|
const mountResult = sudoExecSync(['mount', '-o', 'subvolid=5', device, mountPoint]);
|
|
302
332
|
|
|
303
333
|
if (mountResult.exitCode === 0) {
|
|
334
|
+
// Register in the global set so it is always cleaned up on exit.
|
|
335
|
+
_activeTempMounts.add(mountPoint);
|
|
336
|
+
|
|
304
337
|
// Search for the snapshot in the mounted root subvolume
|
|
305
338
|
const searchPaths = [
|
|
306
339
|
`${mountPoint}/timeshift-btrfs/snapshots/${snapshot.name}`,
|
|
@@ -311,12 +344,13 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
|
|
|
311
344
|
const found = searchPaths.find((p) => fs.existsSync(p));
|
|
312
345
|
|
|
313
346
|
if (found) {
|
|
314
|
-
// Store the mount point for cleanup
|
|
347
|
+
// Store the mount point on the snapshot object for explicit early cleanup.
|
|
315
348
|
snapshot._tempMount = mountPoint;
|
|
316
349
|
return found;
|
|
317
350
|
}
|
|
318
351
|
|
|
319
|
-
// Unmount if we didn't find anything
|
|
352
|
+
// Unmount immediately if we didn't find anything useful.
|
|
353
|
+
_activeTempMounts.delete(mountPoint);
|
|
320
354
|
sudoExecSync(['umount', mountPoint]);
|
|
321
355
|
sudoExecSync(['rmdir', mountPoint]);
|
|
322
356
|
}
|
|
@@ -332,13 +366,15 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
|
|
|
332
366
|
/** Cleanup temporary BTRFS mount if one was created */
|
|
333
367
|
export function cleanupSnapshotMount(snapshot) {
|
|
334
368
|
if (snapshot && snapshot._tempMount) {
|
|
369
|
+
const mp = snapshot._tempMount;
|
|
335
370
|
try {
|
|
336
|
-
sudoExecSync(['umount',
|
|
337
|
-
sudoExecSync(['rmdir',
|
|
338
|
-
delete snapshot._tempMount;
|
|
371
|
+
sudoExecSync(['umount', mp]);
|
|
372
|
+
sudoExecSync(['rmdir', mp]);
|
|
339
373
|
} catch {
|
|
340
374
|
/* best effort */
|
|
341
375
|
}
|
|
376
|
+
_activeTempMounts.delete(mp);
|
|
377
|
+
delete snapshot._tempMount;
|
|
342
378
|
}
|
|
343
379
|
}
|
|
344
380
|
|
|
@@ -523,11 +559,30 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
523
559
|
* `btrfs send` always receives a real subvolume, never a plain directory.
|
|
524
560
|
*/
|
|
525
561
|
async function uploadViaBtrfsSend({ snapshot, subvolPath, parentSnapshot, parentSubvolPath, accounts, cfg, due, privileged, onProgress }) {
|
|
526
|
-
const { createSendStream, estimateSendSize, isValidBtrfsStreamManifest } = await import('./btrfs-send.js');
|
|
562
|
+
const { createSendStream, estimateSendSize, isValidBtrfsStreamManifest, isSubvolumeReadOnly, setSubvolumeReadOnly } = await import('./btrfs-send.js');
|
|
527
563
|
const { planAndPlaceStream } = await import('../storage/allocator.js');
|
|
528
564
|
|
|
529
565
|
if (!subvolPath) throw new Error('no BTRFS subvolume path for snapshot upload');
|
|
530
566
|
|
|
567
|
+
// ── RO guard ──────────────────────────────────────────────────────────────
|
|
568
|
+
// btrfs send requires the subvolume to be read-only (ro=true). Timeshift
|
|
569
|
+
// always creates snapshots as RO, but when they are accessed via a
|
|
570
|
+
// subvolid=5 bind-mount the kernel may report the inner @ subvolume as
|
|
571
|
+
// rw=false (writable) depending on mount flags. Detect and fix this before
|
|
572
|
+
// handing the path to btrfs send, otherwise the send exits 1 with
|
|
573
|
+
// "subvolume … is not read-only".
|
|
574
|
+
const isRO = await isSubvolumeReadOnly(subvolPath, { privileged });
|
|
575
|
+
if (!isRO) {
|
|
576
|
+
journal('snapshots', `subvolume ${subvolPath} is not read-only — setting ro=true before send`);
|
|
577
|
+
try {
|
|
578
|
+
await setSubvolumeReadOnly(subvolPath, true, { privileged });
|
|
579
|
+
journal('snapshots', `set ${subvolPath} read-only OK`);
|
|
580
|
+
} catch (e) {
|
|
581
|
+
throw new Error(`btrfs send requires a read-only subvolume but ${subvolPath} is writable and could not be set read-only: ${e.message}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
585
|
+
|
|
531
586
|
journal('snapshots', `btrfs send subvolPath=${subvolPath} parent=${parentSubvolPath || 'null'}`);
|
|
532
587
|
|
|
533
588
|
// Estimate size for progress reporting
|
package/src/cli.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineCommand, runMain } from 'citty';
|
|
|
2
2
|
import pc from 'picocolors';
|
|
3
3
|
import * as p from '@clack/prompts';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
5
7
|
import { runSetup } from './commands/setup.js';
|
|
6
8
|
import { runWizard } from './commands/wizard.js';
|
|
7
9
|
import { runRepair } from './commands/manage.js';
|
|
@@ -17,7 +19,7 @@ import { listAccounts, addAccount, removeAccount, refreshAccounts, poolSummary }
|
|
|
17
19
|
import { listArtifacts } from './storage/archive.js';
|
|
18
20
|
import { loadConfig, loadState, saveConfig } from './core/store.js';
|
|
19
21
|
import { configFile, stateDir } from './core/paths.js';
|
|
20
|
-
import { bytesHuman } from './util/misc.js';
|
|
22
|
+
import { bytesHuman, makeProgressRenderer } from './util/misc.js';
|
|
21
23
|
import { isOnline } from './util/network.js';
|
|
22
24
|
|
|
23
25
|
const require = createRequire(import.meta.url);
|
|
@@ -386,7 +388,9 @@ const main = defineCommand({
|
|
|
386
388
|
case 'force':
|
|
387
389
|
case 'backup': {
|
|
388
390
|
// Runs every ENABLED job right now (default = the weekly snapshot).
|
|
389
|
-
const
|
|
391
|
+
const progress = makeProgressRenderer();
|
|
392
|
+
const res = await runDueJobs({ force: true, privileged: 'interactive', onProgress: progress });
|
|
393
|
+
progress.stop();
|
|
390
394
|
const report = res.report || [];
|
|
391
395
|
if (report.length === 0) console.log(pc.dim('No enabled backup jobs — run `parrot-blackbox` to set up the schedule.'));
|
|
392
396
|
for (const r of report) {
|
|
@@ -408,7 +412,9 @@ const main = defineCommand({
|
|
|
408
412
|
const [sub, ...args] = rest;
|
|
409
413
|
if (sub === 'now' || sub === 'create' || sub === 'force') {
|
|
410
414
|
try {
|
|
411
|
-
const
|
|
415
|
+
const progress = makeProgressRenderer();
|
|
416
|
+
const r = await runSnapshotNow(undefined, undefined, { onProgress: progress });
|
|
417
|
+
progress.stop();
|
|
412
418
|
console.log(`${pc.green('✔')} Snapshot ${r.snapshot} created & uploaded (${bytesHuman(r.manifest.totalSize)}).`);
|
|
413
419
|
if (r.pruned?.length) console.log(pc.dim(`Pruned: ${r.pruned.join(', ')}`));
|
|
414
420
|
} catch (e) {
|
|
@@ -667,7 +673,7 @@ const main = defineCommand({
|
|
|
667
673
|
});
|
|
668
674
|
}
|
|
669
675
|
s.stop('✔ Upload complete');
|
|
670
|
-
|
|
676
|
+
fs.writeFileSync(outPath, JSON.stringify(manifest));
|
|
671
677
|
process.exitCode = 0;
|
|
672
678
|
} catch (e) {
|
|
673
679
|
s.stop('✖ Upload failed');
|
|
@@ -677,16 +683,12 @@ const main = defineCommand({
|
|
|
677
683
|
// If we are running as root via sudo, rclone might have refreshed OAuth tokens
|
|
678
684
|
// and rewritten rclone.conf as root:root. Restore ownership to the real user.
|
|
679
685
|
if (process.getuid && process.getuid() === 0 && process.env.SUDO_UID && process.env.SUDO_GID) {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
} catch { /* best effort */ }
|
|
687
|
-
}
|
|
688
|
-
});
|
|
689
|
-
});
|
|
686
|
+
const confPath = path.join(process.env.HOME, '.config', 'rclone', 'rclone.conf');
|
|
687
|
+
if (fs.existsSync(confPath)) {
|
|
688
|
+
try {
|
|
689
|
+
fs.chownSync(confPath, parseInt(process.env.SUDO_UID, 10), parseInt(process.env.SUDO_GID, 10));
|
|
690
|
+
} catch { /* best effort */ }
|
|
691
|
+
}
|
|
690
692
|
}
|
|
691
693
|
}
|
|
692
694
|
return;
|
|
@@ -702,4 +704,11 @@ const main = defineCommand({
|
|
|
702
704
|
process.exitCode = 1;
|
|
703
705
|
}
|
|
704
706
|
},
|
|
705
|
-
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
runMain(main).then(() => {
|
|
710
|
+
// Force the process to exit cleanly regardless of any open file descriptors or
|
|
711
|
+
// kernel mounts left by subvolid=5 bind-mounts created in snapshotDirFor.
|
|
712
|
+
// process.on('exit') in snapshot.js will unmount them before we terminate.
|
|
713
|
+
process.exit(process.exitCode ?? 0);
|
|
714
|
+
});
|
package/src/commands/wizard.js
CHANGED
|
@@ -24,7 +24,7 @@ import { installService, removeService } from './service.js';
|
|
|
24
24
|
import { startDaemon, stopDaemon, daemonRunning } from '../daemon/daemon.js';
|
|
25
25
|
import { runDoctor, runStatus, runUninstallWizard } from './manage.js';
|
|
26
26
|
import { runSetup } from './setup.js';
|
|
27
|
-
import { bytesHuman } from '../util/misc.js';
|
|
27
|
+
import { bytesHuman, makeProgressRenderer } from '../util/misc.js';
|
|
28
28
|
|
|
29
29
|
const require = createRequire(import.meta.url);
|
|
30
30
|
const pkg = require('../../package.json');
|
|
@@ -163,7 +163,9 @@ async function backupNowAction() {
|
|
|
163
163
|
/** Create + upload a snapshot immediately. */
|
|
164
164
|
async function snapshotNowAction() {
|
|
165
165
|
try {
|
|
166
|
-
const
|
|
166
|
+
const progress = makeProgressRenderer();
|
|
167
|
+
const r = await runSnapshotNow(undefined, undefined, { onProgress: progress });
|
|
168
|
+
progress.stop();
|
|
167
169
|
p.log.success(`✔ Snapshot ${r.snapshot} created & uploaded (${bytesHuman(r.manifest?.totalSize ?? 0)}).`);
|
|
168
170
|
if (r.pruned?.length) p.log.message(pc.dim(`Pruned: ${r.pruned.join(', ')}`));
|
|
169
171
|
} catch (e) {
|
package/src/storage/allocator.js
CHANGED
|
@@ -269,6 +269,12 @@ export async function planAndPlaceStream(btrfsStream, { kind, id, accounts, remo
|
|
|
269
269
|
let locs = [];
|
|
270
270
|
let currentStart = 0;
|
|
271
271
|
let target = getNextAccountAndPath();
|
|
272
|
+
|
|
273
|
+
// Speed tracking
|
|
274
|
+
let speedBytesWindow = 0;
|
|
275
|
+
let speedWindowStart = Date.now();
|
|
276
|
+
let currentSpeedMBs = 0;
|
|
277
|
+
const SPEED_WINDOW_MS = 1500; // recalculate speed every 1.5 s
|
|
272
278
|
|
|
273
279
|
currentChild = spawn(process.env.PBB_RCLONE || 'rclone', ['rcat', `${target.remote}:${target.path}`]);
|
|
274
280
|
let childFailed = false;
|
|
@@ -287,6 +293,7 @@ export async function planAndPlaceStream(btrfsStream, { kind, id, accounts, remo
|
|
|
287
293
|
|
|
288
294
|
currentBytesInChunk += toWrite;
|
|
289
295
|
totalBytes += toWrite;
|
|
296
|
+
speedBytesWindow += toWrite;
|
|
290
297
|
offset += toWrite;
|
|
291
298
|
|
|
292
299
|
if (!canContinue) {
|
|
@@ -309,7 +316,24 @@ export async function planAndPlaceStream(btrfsStream, { kind, id, accounts, remo
|
|
|
309
316
|
currentChild.on('exit', (code) => { if (code !== 0) childFailed = true; });
|
|
310
317
|
}
|
|
311
318
|
}
|
|
312
|
-
|
|
319
|
+
|
|
320
|
+
// Recalculate speed on each chunk and emit progress
|
|
321
|
+
if (onProgress) {
|
|
322
|
+
const now = Date.now();
|
|
323
|
+
const elapsed = now - speedWindowStart;
|
|
324
|
+
if (elapsed >= SPEED_WINDOW_MS) {
|
|
325
|
+
currentSpeedMBs = (speedBytesWindow / (1024 * 1024)) / (elapsed / 1000);
|
|
326
|
+
speedBytesWindow = 0;
|
|
327
|
+
speedWindowStart = now;
|
|
328
|
+
}
|
|
329
|
+
onProgress({
|
|
330
|
+
done: totalBytes,
|
|
331
|
+
total: originalSize,
|
|
332
|
+
speedMBs: currentSpeedMBs,
|
|
333
|
+
remote: target.remote,
|
|
334
|
+
text: `uploading stream: ${(totalBytes / (1024 ** 2)).toFixed(1)} MB`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
313
337
|
}
|
|
314
338
|
|
|
315
339
|
if (currentChild) {
|
package/src/util/misc.js
CHANGED
|
@@ -111,4 +111,54 @@ export function shellQuote(s) {
|
|
|
111
111
|
|
|
112
112
|
export function pad2(n) {
|
|
113
113
|
return String(n).padStart(2, '0');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Returns an onProgress handler that renders a live, in-place progress bar
|
|
118
|
+
* with percentage, MB transferred, and upload speed.
|
|
119
|
+
*
|
|
120
|
+
* Expected event shape:
|
|
121
|
+
* { done: number (bytes), total: number (bytes or 0), speedMBs?: number, remote?: string }
|
|
122
|
+
*
|
|
123
|
+
* Call renderer.stop() when done to advance to the next line.
|
|
124
|
+
*
|
|
125
|
+
* When `total` is 0 (stream size unknown) we display bytes + speed only.
|
|
126
|
+
*/
|
|
127
|
+
export function makeProgressRenderer() {
|
|
128
|
+
const isTTY = process.stdout.isTTY;
|
|
129
|
+
const BAR_WIDTH = 25;
|
|
130
|
+
let lastLine = '';
|
|
131
|
+
|
|
132
|
+
function render({ done = 0, total = 0, speedMBs = 0, remote = '' } = {}) {
|
|
133
|
+
const doneMB = done / (1024 * 1024);
|
|
134
|
+
const totalMB = total / (1024 * 1024);
|
|
135
|
+
const speedStr = speedMBs > 0 ? ` ${speedMBs.toFixed(1)} MB/s` : '';
|
|
136
|
+
const destStr = remote ? ` → ${remote}` : '';
|
|
137
|
+
|
|
138
|
+
let line;
|
|
139
|
+
if (total > 0) {
|
|
140
|
+
const pct = Math.min(100, Math.round((done / total) * 100));
|
|
141
|
+
const filled = Math.round((pct / 100) * BAR_WIDTH);
|
|
142
|
+
const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
|
|
143
|
+
line = ` [${bar}] ${String(pct).padStart(3)}% ${doneMB.toFixed(1)} MB / ${totalMB.toFixed(1)} MB${speedStr}${destStr}`;
|
|
144
|
+
} else {
|
|
145
|
+
line = ` ⬆ ${doneMB.toFixed(1)} MB streamed${speedStr}${destStr}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isTTY) {
|
|
149
|
+
process.stdout.write(`\r${line}\x1b[K`);
|
|
150
|
+
} else if (line !== lastLine) {
|
|
151
|
+
// Non-TTY (piped / daemon log): only emit when something changes to
|
|
152
|
+
// avoid flooding the journal with thousands of identical lines.
|
|
153
|
+
process.stdout.write(line + '\n');
|
|
154
|
+
}
|
|
155
|
+
lastLine = line;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
render.stop = function stop() {
|
|
159
|
+
if (isTTY && lastLine) process.stdout.write('\n');
|
|
160
|
+
lastLine = '';
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return render;
|
|
114
164
|
}
|