parrot-blackbox 1.0.0
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/LICENSE +21 -0
- package/README.md +260 -0
- package/bin/parrot-blackbox.js +2 -0
- package/package.json +58 -0
- package/src/backup/git-exclude.js +122 -0
- package/src/backup/restore.js +93 -0
- package/src/backup/retention.js +22 -0
- package/src/backup/snapshot.js +214 -0
- package/src/backup/workspace.js +106 -0
- package/src/cli.js +426 -0
- package/src/commands/manage.js +128 -0
- package/src/commands/service.js +122 -0
- package/src/commands/setup.js +214 -0
- package/src/core/lock.js +67 -0
- package/src/core/paths.js +95 -0
- package/src/core/store.js +173 -0
- package/src/core/time.js +142 -0
- package/src/daemon/daemon.js +107 -0
- package/src/daemon/scheduler.js +135 -0
- package/src/storage/accounts.js +110 -0
- package/src/storage/allocator.js +189 -0
- package/src/storage/archive.js +138 -0
- package/src/storage/rclone.js +106 -0
- package/src/util/misc.js +114 -0
- package/src/util/network.js +26 -0
- package/src/util/sudo.js +56 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeshift integration — weekly system snapshots that are ALSO shipped into
|
|
3
|
+
* the cloud pool, so a dead SSD can never take the recovery ability with it.
|
|
4
|
+
*
|
|
5
|
+
* Snapshot pruning is the "remove both at the same time" guarantee: snapshots
|
|
6
|
+
* beyond `keep` are deleted from local disk (timeshift --delete) and from the
|
|
7
|
+
* cloud pool in the same pass.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { execa } from 'execa';
|
|
13
|
+
import { loadConfig, loadState, saveState, journal, hasCommandSync } from '../core/store.js';
|
|
14
|
+
import { timeshiftDir } from '../core/paths.js';
|
|
15
|
+
import { iso, clock } from '../core/time.js';
|
|
16
|
+
import { refreshAccounts } from '../storage/accounts.js';
|
|
17
|
+
import { planAndPlace } from '../storage/allocator.js';
|
|
18
|
+
import { listArtifacts, removeArtifact } from '../storage/archive.js';
|
|
19
|
+
import { planPrune } from './retention.js';
|
|
20
|
+
import { sudoInteractive, sudoNonInteractive } from '../util/sudo.js';
|
|
21
|
+
|
|
22
|
+
export class SudoDeferredError extends Error {
|
|
23
|
+
constructor() {
|
|
24
|
+
super('sudo authentication required — run `parrot-blackbox snapshot now` once to re-arm the sudo timestamp');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse `timeshift --list` output into snapshots.
|
|
30
|
+
* @returns {Array<{name:string, date:string, time:string, tags:string, dir:?string}>}
|
|
31
|
+
*/
|
|
32
|
+
export function parseTimeshiftList(stdout) {
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const line of String(stdout).split('\n')) {
|
|
35
|
+
const m = /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+([A-Z])\s+(\S+)(?:\s+(.*))?$/.exec(line);
|
|
36
|
+
if (!m) continue;
|
|
37
|
+
const [, date, time, tags, dirOrSize, detail] = m;
|
|
38
|
+
const name = `${date}_${time.replace(/:/g, '-')}`;
|
|
39
|
+
const dir = findPathInLine(line);
|
|
40
|
+
out.push({ name, date, time, tags, dir, line: `${date} ${time} ${tags} ${dirOrSize}${detail ? ` ${detail}` : ''}` });
|
|
41
|
+
}
|
|
42
|
+
return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Extract the snapshot directory path from a list line if present. */
|
|
46
|
+
function findPathInLine(line) {
|
|
47
|
+
const dirs = line.match(/\/[^\s]+\/[A-Za-z0-9._-]+/g) || [];
|
|
48
|
+
return dirs.find((d) => /snapshots|timeshift/i.test(d)) ||
|
|
49
|
+
dirs.find((d) => /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/.test(d)) ||
|
|
50
|
+
null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `timeshift --list` works WITHOUT root — run it directly (no sudo hang risk). */
|
|
54
|
+
async function runTimeshiftList() {
|
|
55
|
+
try {
|
|
56
|
+
const res = await execa('timeshift', ['--list'], { reject: false });
|
|
57
|
+
return (res.exitCode === 0 ? res.stdout : res.stdout || res.stderr) || '';
|
|
58
|
+
} catch {
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function listLocalSnapshots({ privileged = 'noninteractive' } = {}) {
|
|
64
|
+
const text = await runTimeshiftList();
|
|
65
|
+
return parseTimeshiftList(text);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Create a snapshot; returns the created snapshot record. */
|
|
69
|
+
export async function createSnapshot({ comment, privileged = 'noninteractive' } = {}) {
|
|
70
|
+
const before = new Set((await listLocalSnapshots({ privileged })).map((s) => s.name));
|
|
71
|
+
const args = ['timeshift', '--create', '--comments', comment || 'parrot-blackbox', '--tags', 'W'];
|
|
72
|
+
|
|
73
|
+
if (privileged === 'interactive') {
|
|
74
|
+
const res = await sudoInteractive(args);
|
|
75
|
+
if (res.exitCode !== 0) throw new Error(`timeshift --create failed (exit ${res.exitCode})`);
|
|
76
|
+
} else {
|
|
77
|
+
const res = await sudoNonInteractive(args);
|
|
78
|
+
if (res.exitCode !== 0) {
|
|
79
|
+
if (/password|authentication|sudo/i.test(res.stderr || '')) throw new SudoDeferredError();
|
|
80
|
+
throw new Error(`timeshift --create failed (exit ${res.exitCode}): ${res.stderr?.trim()}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Find which snapshot appeared.
|
|
85
|
+
const after = await listLocalSnapshots({ privileged });
|
|
86
|
+
const created = after.find((s) => !before.has(s.name)) || after[after.length - 1];
|
|
87
|
+
if (!created) throw new Error('timeshift reported success but no snapshot was found');
|
|
88
|
+
return created;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Delete a single snapshot from local disk. */
|
|
92
|
+
export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {}) {
|
|
93
|
+
const args = ['timeshift', '--delete', '--snapshot', name];
|
|
94
|
+
if (privileged === 'interactive') {
|
|
95
|
+
const res = await sudoInteractive(args);
|
|
96
|
+
if (res.exitCode !== 0) throw new Error(`timeshift --delete ${name} failed (exit ${res.exitCode})`);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
const res = await sudoNonInteractive(args);
|
|
100
|
+
if (res.exitCode !== 0) {
|
|
101
|
+
if (/password|authentication|sudo/i.test(res.stderr || '')) throw new SudoDeferredError();
|
|
102
|
+
throw new Error(`timeshift --delete ${name} failed (exit ${res.exitCode})`);
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
/** Resolve the on-disk directory of a snapshot. */
|
|
107
|
+
export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {}) {
|
|
108
|
+
if (snapshot.dir && fs.existsSync(snapshot.dir)) return snapshot.dir;
|
|
109
|
+
const base = timeshiftDir();
|
|
110
|
+
const candidates = [
|
|
111
|
+
path.join(base, 'snapshots', snapshot.name),
|
|
112
|
+
path.join(base, snapshot.name),
|
|
113
|
+
];
|
|
114
|
+
return candidates.find((c) => fs.existsSync(c)) || candidates[0];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run one snapshot generation: create → upload to the pool → prune old ones
|
|
119
|
+
* BOTH locally and in the cloud.
|
|
120
|
+
* Assumes the caller holds the scheduler lock.
|
|
121
|
+
*/
|
|
122
|
+
export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninteractive', onProgress } = {}) {
|
|
123
|
+
journal('snapshots', `start due=${due} privileged=${privileged}`);
|
|
124
|
+
const accounts = await refreshAccounts(cfg);
|
|
125
|
+
|
|
126
|
+
const created = await createSnapshot({ comment: `parrot-blackbox ${due}`, privileged });
|
|
127
|
+
const dir = snapshotDirFor(created, { privileged });
|
|
128
|
+
|
|
129
|
+
let manifest;
|
|
130
|
+
try {
|
|
131
|
+
manifest = await planAndPlace(dir, {
|
|
132
|
+
kind: 'snapshots',
|
|
133
|
+
id: created.name,
|
|
134
|
+
accounts,
|
|
135
|
+
remoteRoot: cfg.storage.remoteRoot,
|
|
136
|
+
chunkSize: cfg.storage.chunkSize,
|
|
137
|
+
onProgress,
|
|
138
|
+
});
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// The local snapshot exists and is safe; the cloud upload failed.
|
|
141
|
+
journal('snapshots', `upload failed for ${created.name}: ${e.message}`, 'error');
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
manifest.due = due;
|
|
145
|
+
manifest.snapshot = created.name;
|
|
146
|
+
|
|
147
|
+
// Prune OLD snapshots — local + cloud in the same pass.
|
|
148
|
+
const pruned = await pruneSnapshots(cfg, accounts, { privileged });
|
|
149
|
+
|
|
150
|
+
const j = state.jobs.snapshots;
|
|
151
|
+
j.lastCompletedDue = due;
|
|
152
|
+
j.lastRunAt = iso(clock());
|
|
153
|
+
j.lastStatus = 'ok';
|
|
154
|
+
j.lastError = null;
|
|
155
|
+
j.pending = (j.pending || []).filter((d) => d !== due);
|
|
156
|
+
j.completed = [...(j.completed || []), { due, at: iso(clock()) }].slice(-(cfg.jobs.snapshots.keep * 2));
|
|
157
|
+
state.manifests[`snapshots-${created.name}`] = {
|
|
158
|
+
kind: 'snapshots',
|
|
159
|
+
id: created.name,
|
|
160
|
+
due,
|
|
161
|
+
createdAt: manifest.createdAt,
|
|
162
|
+
totalSize: manifest.totalSize,
|
|
163
|
+
};
|
|
164
|
+
saveState(state);
|
|
165
|
+
journal('snapshots', `done due=${due} snapshot=${created.name} bytes=${manifest.totalSize}`);
|
|
166
|
+
|
|
167
|
+
return { due, snapshot: created.name, manifest, pruned };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Enforce the snapshot retention limit across local disk AND cloud.
|
|
172
|
+
* Returns the list of snapshot names pruned.
|
|
173
|
+
*/
|
|
174
|
+
export async function pruneSnapshots(cfg, accounts, { privileged = 'noninteractive' } = {}) {
|
|
175
|
+
const local = await listLocalSnapshots({ privileged }).catch(() => []);
|
|
176
|
+
const cloud = await listArtifacts('snapshots', accounts, cfg.storage.remoteRoot);
|
|
177
|
+
|
|
178
|
+
const localNames = local.map((s) => s.name);
|
|
179
|
+
const cloudNames = cloud.map((c) => c.id);
|
|
180
|
+
const union = [...new Set([...localNames, ...cloudNames])];
|
|
181
|
+
|
|
182
|
+
const { prune } = planPrune(union, cfg.jobs.snapshots.keep);
|
|
183
|
+
const pruned = [];
|
|
184
|
+
for (const name of prune) {
|
|
185
|
+
// Cloud first, then local — if one fails the other still gets cleaned.
|
|
186
|
+
try {
|
|
187
|
+
await removeArtifact('snapshots', name, accounts, cfg.storage.remoteRoot);
|
|
188
|
+
journal('snapshots', `pruned cloud=${name}`, 'warn');
|
|
189
|
+
} catch (e) {
|
|
190
|
+
journal('snapshots', `cloud prune failed for ${name}: ${e.message}`, 'error');
|
|
191
|
+
}
|
|
192
|
+
if (localNames.includes(name)) {
|
|
193
|
+
try {
|
|
194
|
+
await deleteSnapshot(name, { privileged });
|
|
195
|
+
journal('snapshots', `pruned local=${name}`, 'warn');
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (e instanceof SudoDeferredError) throw e;
|
|
198
|
+
journal('snapshots', `local prune failed for ${name}: ${e.message}`, 'error');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
pruned.push(name);
|
|
202
|
+
}
|
|
203
|
+
return pruned;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Manual `snapshot now` — works with an interactive sudo prompt. */
|
|
207
|
+
export async function runSnapshotNow(cfg = loadConfig(), state = loadState(), opts = {}) {
|
|
208
|
+
const due = iso(clock());
|
|
209
|
+
return runSnapshotBackup(cfg, state, { due, privileged: 'interactive', ...opts });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function timeshiftAvailable() {
|
|
213
|
+
return hasCommandSync('timeshift');
|
|
214
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daily workspace backup — user data (Desktop / Documents / Pictures /
|
|
3
|
+
* custom sources) with git-tracked files excluded, shipped through the smart
|
|
4
|
+
* storage pool, then pruned so only the latest `keep` generations survive.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { loadConfig, loadState, saveState, journal } from '../core/store.js';
|
|
10
|
+
import { stagingDir } from '../core/paths.js';
|
|
11
|
+
import { iso, clock, dueDay } from '../core/time.js';
|
|
12
|
+
import { refreshAccounts } from '../storage/accounts.js';
|
|
13
|
+
import { planAndPlace } from '../storage/allocator.js';
|
|
14
|
+
import { listArtifacts, removeArtifact } from '../storage/archive.js';
|
|
15
|
+
import { collectFiles, stageFiles, sumFiles } from './git-exclude.js';
|
|
16
|
+
import { planPrune } from './retention.js';
|
|
17
|
+
|
|
18
|
+
/** Stage the collected sources into a fresh bundle dir. */
|
|
19
|
+
export function buildBundle(cfg, due, { home = process.env.HOME } = {}) {
|
|
20
|
+
const col = collectFiles(cfg.jobs.files.sources, {
|
|
21
|
+
exclude: cfg.jobs.files.exclude,
|
|
22
|
+
home,
|
|
23
|
+
});
|
|
24
|
+
const dir = path.join(stagingDir(), `files-${dueDay(due)}`);
|
|
25
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
stageFiles(col.files, dir);
|
|
28
|
+
return {
|
|
29
|
+
dir,
|
|
30
|
+
files: col.files,
|
|
31
|
+
sizeBytes: sumFiles(col.files),
|
|
32
|
+
skippedRepos: col.skippedRepos,
|
|
33
|
+
missing: col.missing,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Run one file-backup generation for `due`.
|
|
38
|
+
* Assumes the caller holds the scheduler lock.
|
|
39
|
+
* @returns {Promise<object>} summary
|
|
40
|
+
*/
|
|
41
|
+
export async function runFilesBackup(cfg, state, { due, onProgress } = {}) {
|
|
42
|
+
const now = clock();
|
|
43
|
+
journal('files', `start due=${due}`);
|
|
44
|
+
const bundle = buildBundle(cfg, due);
|
|
45
|
+
const accounts = await refreshAccounts(cfg);
|
|
46
|
+
|
|
47
|
+
let manifest;
|
|
48
|
+
try {
|
|
49
|
+
manifest = await planAndPlace(bundle.dir, {
|
|
50
|
+
kind: 'files',
|
|
51
|
+
id: due,
|
|
52
|
+
accounts,
|
|
53
|
+
remoteRoot: cfg.storage.remoteRoot,
|
|
54
|
+
chunkSize: cfg.storage.chunkSize,
|
|
55
|
+
onProgress,
|
|
56
|
+
});
|
|
57
|
+
} finally {
|
|
58
|
+
fs.rmSync(bundle.dir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Retention: keep the newest `keep` generations, drop the rest (cloud).
|
|
62
|
+
let pruned = [];
|
|
63
|
+
try {
|
|
64
|
+
const cloud = await listArtifacts('files', accounts, cfg.storage.remoteRoot);
|
|
65
|
+
const { prune } = planPrune(cloud.map((a) => a.id), cfg.jobs.files.keep);
|
|
66
|
+
for (const id of prune) {
|
|
67
|
+
const removed = await removeArtifact('files', id, accounts, cfg.storage.remoteRoot);
|
|
68
|
+
journal('files', `pruned=${id} from=${removed.join(',')}`, 'warn');
|
|
69
|
+
pruned.push(id);
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
journal('files', `prune failed: ${e.message}`, 'warn');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const j = state.jobs.files;
|
|
76
|
+
j.lastCompletedDue = due;
|
|
77
|
+
j.lastRunAt = iso(now);
|
|
78
|
+
j.lastStatus = 'ok';
|
|
79
|
+
j.lastError = null;
|
|
80
|
+
j.pending = (j.pending || []).filter((d) => d !== due);
|
|
81
|
+
j.completed = [...(j.completed || []), { due, at: iso(now) }].slice(-(cfg.jobs.files.keep * 2));
|
|
82
|
+
state.manifests[`files-${manifest.id}`] = {
|
|
83
|
+
kind: 'files',
|
|
84
|
+
id: manifest.id,
|
|
85
|
+
createdAt: manifest.createdAt,
|
|
86
|
+
totalSize: manifest.totalSize,
|
|
87
|
+
entryCount: manifest.entries?.length || 0,
|
|
88
|
+
};
|
|
89
|
+
saveState(state);
|
|
90
|
+
journal('files', `done due=${due} bytes=${manifest.totalSize}`);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
due,
|
|
94
|
+
manifest,
|
|
95
|
+
sizeBytes: manifest.totalSize,
|
|
96
|
+
skippedRepos: bundle.skippedRepos,
|
|
97
|
+
missing: bundle.missing,
|
|
98
|
+
pruned,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Alias used by the CLI `force` command. */
|
|
103
|
+
export async function runForceBackup(cfg = loadConfig(), state = loadState(), opts = {}) {
|
|
104
|
+
const due = iso(clock());
|
|
105
|
+
return runFilesBackup(cfg, state, { due, ...opts });
|
|
106
|
+
}
|