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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Background daemon. Runs a detached process that polls the scheduler every
3
+ * `pollIntervalSeconds`, and — the catch-up magic — watches for the
4
+ * offline→online transition so any missed backups fire immediately once WiFi
5
+ * returns, in order.
6
+ *
7
+ * `daemon start` spawns a detached process; `systemctl --user` integration
8
+ * (`schedule install`) is the recommended always-on wrapper.
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import { execa } from 'execa';
13
+ import { loadConfig, journal, readJsonSafe } from '../core/store.js';
14
+ import { daemonLogFile, daemonPidFile, stateDir } from '../core/paths.js';
15
+ import { isOnline } from '../util/network.js';
16
+ import { runDueJobs } from './scheduler.js';
17
+
18
+ export function readDaemonPid() {
19
+ const rec = readJsonSafe(daemonPidFile(), {});
20
+ return rec.pid && Number.isInteger(rec.pid) ? rec.pid : null;
21
+ }
22
+
23
+ export function daemonRunning() {
24
+ const pid = readDaemonPid();
25
+ if (!pid) return false;
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export async function startDaemon() {
35
+ if (daemonRunning()) return { started: false, reason: 'already running' };
36
+ const entry = process.argv[1];
37
+ fs.mkdirSync(stateDir(), { recursive: true });
38
+ const logFd = fs.openSync(daemonLogFile(), 'a');
39
+ const child = execa(process.execPath, [entry, 'daemon', 'foreground'], {
40
+ detached: true,
41
+ stdio: ['ignore', logFd, logFd],
42
+ env: process.env,
43
+ });
44
+ child.unref();
45
+ fs.writeFileSync(daemonPidFile(), JSON.stringify({ pid: child.pid, at: Date.now() }));
46
+ journal('daemon', `started pid=${child.pid}`);
47
+ return { started: true, pid: child.pid };
48
+ }
49
+
50
+ export async function stopDaemon() {
51
+ const pid = readDaemonPid();
52
+ if (!pid) {
53
+ fs.rmSync(daemonPidFile(), { force: true });
54
+ return { stopped: false, reason: 'not running' };
55
+ }
56
+ try {
57
+ process.kill(pid, 'SIGTERM');
58
+ for (let i = 0; i < 20; i += 1) {
59
+ if (!daemonRunning()) break;
60
+ await sleep(150);
61
+ }
62
+ } catch {
63
+ /* already gone */
64
+ }
65
+ fs.rmSync(daemonPidFile(), { force: true });
66
+ journal('daemon', 'stopped');
67
+ return { stopped: true, pid };
68
+ }
69
+
70
+ /** Long-running foreground loop (used by daemon and the systemd unit). */
71
+ export async function daemonForeground() {
72
+ const ignore = () => {};
73
+ process.on('SIGTERM', () => process.exit(0));
74
+ process.on('SIGINT', () => process.exit(0));
75
+
76
+ const cfg = loadConfig();
77
+ const intervalMs = (cfg.daemon.pollIntervalSeconds || 60) * 1000;
78
+ journal('daemon', `foreground loop started (poll ${intervalMs}ms)`);
79
+
80
+ let online = await isOnline();
81
+ journal('daemon', online ? 'network: online' : 'network: offline');
82
+
83
+ for (;;) {
84
+ const sleepPromise = sleep(intervalMs);
85
+ try {
86
+ await runDueJobs({ privileged: 'noninteractive', onProgress: ignore });
87
+ } catch (e) {
88
+ journal('daemon', `run error: ${e.message}`, 'error');
89
+ }
90
+ await sleepPromise;
91
+
92
+ const nowOnline = await isOnline();
93
+ if (!online && nowOnline) {
94
+ journal('daemon', 'network came online — draining pending backups');
95
+ try {
96
+ await runDueJobs({ privileged: 'noninteractive', onProgress: ignore });
97
+ } catch (e) {
98
+ journal('daemon', `catch-up run error: ${e.message}`, 'error');
99
+ }
100
+ }
101
+ online = nowOnline;
102
+ }
103
+ }
104
+
105
+ function sleep(ms) {
106
+ return new Promise((r) => setTimeout(r, ms));
107
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The scheduler — the crash-proof heart.
3
+ *
4
+ * On every tick (daemon poll, cron run, or manual `run`) it:
5
+ * 1. advances the due window (computing every missed calendar due),
6
+ * 2. if anything is pending but the machine is offline → defer (retry later),
7
+ * 3. otherwise drains pending dues OLDEST FIRST (files before snapshots),
8
+ * 4. prunes old generations (files + snapshots, local & cloud together),
9
+ * 5. persists state atomically after every job.
10
+ * A crash anywhere leaves the journal with an unfinished entry and the pending
11
+ * list intact, so the next tick simply retries. The lock prevents the daemon
12
+ * and a manual `force` from colliding.
13
+ */
14
+
15
+ import { withLock } from '../core/lock.js';
16
+ import { loadConfig, loadState, saveState, journal } from '../core/store.js';
17
+ import { iso, clock, advancePending } from '../core/time.js';
18
+ import { isOnline } from '../util/network.js';
19
+ import { runFilesBackup } from '../backup/workspace.js';
20
+ import { runSnapshotBackup, SudoDeferredError } from '../backup/snapshot.js';
21
+
22
+ /**
23
+ * @param {object} opts
24
+ * force: bool run a file backup NOW regardless of the schedule
25
+ * privileged: 'noninteractive'|'interactive'
26
+ * onProgress: fn
27
+ * @returns {Promise<object>} {ok, deferred, report}
28
+ */
29
+ export async function runDueJobs({ force = false, privileged = 'noninteractive', onProgress } = {}) {
30
+ return withLock(async () => {
31
+ const cfg = loadConfig();
32
+ const state = loadState();
33
+ const now = clock();
34
+ const report = [];
35
+
36
+ if (force) {
37
+ // Manual force: run EVERY enabled job now (default = the weekly snapshot).
38
+ const due = iso(now);
39
+ if (cfg.jobs.files.enabled) {
40
+ try {
41
+ const r = await runFilesBackup(cfg, state, { due, onProgress });
42
+ report.push({ type: 'files', due, ok: true, size: r.sizeBytes, pruned: r.pruned });
43
+ } catch (e) {
44
+ journal('files', `force failed due=${due}: ${e.message}`, 'error');
45
+ report.push({ type: 'files', due, ok: false, error: e.message });
46
+ }
47
+ }
48
+ if (cfg.jobs.snapshots.enabled) {
49
+ try {
50
+ const r = await runSnapshotBackup(cfg, state, { due, privileged, onProgress });
51
+ report.push({ type: 'snapshots', due, ok: true, snapshot: r.snapshot, size: r.manifest?.totalSize, pruned: r.pruned });
52
+ } catch (e) {
53
+ const isSudo = e instanceof SudoDeferredError;
54
+ journal('snapshots', `force failed due=${due}: ${e.message}`, isSudo ? 'info' : 'error');
55
+ report.push({ type: 'snapshots', due, ok: isSudo, deferred: isSudo, error: e.message });
56
+ }
57
+ }
58
+ return { ok: true, deferred: false, report };
59
+ }
60
+
61
+ // 1. Advance due windows for enabled jobs.
62
+ let advanced = false;
63
+ for (const type of ['files', 'snapshots']) {
64
+ const jc = cfg.jobs[type];
65
+ const js = state.jobs[type];
66
+ if (!jc || !jc.enabled || !js) continue;
67
+ const res = advancePending(js, jc, now);
68
+ if (res.lastDue !== js.lastDue) {
69
+ js.lastDue = res.lastDue;
70
+ advanced = true;
71
+ }
72
+ js.pending = res.pending;
73
+ if (res.dropped > 0) {
74
+ journal(type, `dropped ${res.dropped} stale overdue dues (catch-up limit ${jc.catchUpLimit})`, 'warn');
75
+ }
76
+ }
77
+ if (advanced) saveState(state);
78
+
79
+ const pendingFiles = (state?.jobs?.files) ? [...state.jobs.files.pending] : [];
80
+ const pendingSnaps = (state?.jobs?.snapshots) ? [...state.jobs.snapshots.pending] : [];
81
+
82
+ if (pendingFiles.length === 0 && pendingSnaps.length === 0) {
83
+ return { ok: true, deferred: false, report };
84
+ }
85
+
86
+ // 2. Network gate — defer everything if we are offline.
87
+ if (!(await isOnline())) {
88
+ journal('daemon', `offline — deferring ${pendingFiles.length} file + ${pendingSnaps.length} snapshot due(s)`);
89
+ for (const type of ['files', 'snapshots']) {
90
+ if (state.jobs[type]) {
91
+ state.jobs[type].lastStatus = 'deferred';
92
+ state.jobs[type].lastRunAt = iso(now);
93
+ }
94
+ }
95
+ saveState(state);
96
+ return { ok: true, deferred: true, report };
97
+ }
98
+
99
+ // 3. Drain pending files first (oldest → newest), then snapshots.
100
+ for (const due of pendingFiles) {
101
+ try {
102
+ const r = await runFilesBackup(cfg, state, { due, onProgress });
103
+ report.push({ type: 'files', due, ok: true, size: r.sizeBytes, pruned: r.pruned });
104
+ } catch (e) {
105
+ journal('files', `failed due=${due}: ${e.message}`, 'error');
106
+ state.jobs.files.lastStatus = 'error';
107
+ state.jobs.files.lastError = e.message;
108
+ state.jobs.files.lastRunAt = iso(now);
109
+ saveState(state);
110
+ report.push({ type: 'files', due, ok: false, error: e.message });
111
+ break; // stop draining; the rest retry on the next tick
112
+ }
113
+ }
114
+
115
+ for (const due of pendingSnaps) {
116
+ try {
117
+ const r = await runSnapshotBackup(cfg, state, { due, privileged, onProgress });
118
+ report.push({ type: 'snapshots', due, ok: true, snapshot: r.snapshot, size: r.manifest?.totalSize, pruned: r.pruned });
119
+ } catch (e) {
120
+ const isSudo = e instanceof SudoDeferredError;
121
+ journal('snapshots', `failed due=${due}: ${e.message}`, isSudo ? 'info' : 'error');
122
+ state.jobs.snapshots.lastStatus = isSudo ? 'deferred' : 'error';
123
+ state.jobs.snapshots.lastError = e.message;
124
+ state.jobs.snapshots.lastRunAt = iso(now);
125
+ saveState(state);
126
+ // A deferred sudo job is NOT a hard failure — it retries later when the
127
+ // sudo timestamp is re-armed (e.g. by an interactive `snapshot now`).
128
+ report.push({ type: 'snapshots', due, ok: isSudo, deferred: isSudo, snapshot: null, error: e.message });
129
+ if (!isSudo) break; // hard errors stop draining; soft deferrals keep going
130
+ }
131
+ }
132
+
133
+ return { ok: true, deferred: false, report };
134
+ });
135
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Storage accounts — the gitswitch-style account manager for the multi-cloud
3
+ * pool. Each account maps to ONE rclone remote (one MEGA or Google Drive
4
+ * login). 5 MEGA + 5 Drive accounts with ~20GiB / ~15GiB each ≈ 175GiB that the
5
+ * allocator can spread backups across so we are never out of space.
6
+ */
7
+
8
+ import crypto from 'node:crypto';
9
+ import { loadConfig, saveConfig } from '../core/store.js';
10
+ import { aboutRemote, listRemotes, hasRclone } from './rclone.js';
11
+ import { bytesHuman } from '../util/misc.js';
12
+
13
+ const GiB = 1024 ** 3;
14
+
15
+ function accountId() {
16
+ return crypto.randomUUID().slice(0, 8);
17
+ }
18
+
19
+ /** Provider defaults (used only when `rclone about` cannot report a quota). */
20
+ export function defaultQuota(provider, cfg) {
21
+ const p = cfg.storage.providers[provider];
22
+ return (p?.defaultQuotaGiB ?? 20) * GiB;
23
+ }
24
+
25
+ export function providerLabel(provider) {
26
+ return provider === 'mega' ? 'MEGA' : provider === 'gdrive' ? 'Google Drive' : provider;
27
+ }
28
+
29
+ /**
30
+ * Add an account. `remote` must already exist in rclone's config (created via
31
+ * `rclone config` or by the wizard's interactive `rclone config` call).
32
+ */
33
+ export async function addAccount({ provider = 'mega', label = '', remote = '', quotaGiB = null }) {
34
+ const cfg = loadConfig();
35
+ const remotes = await listRemotes();
36
+ if (!remotes.includes(remote)) {
37
+ return { ok: false, error: `rclone remote "${remote}" not found. Create it first with: rclone config` };
38
+ }
39
+ if (cfg.storage.accounts.some((a) => a.remote === remote)) {
40
+ return { ok: false, error: `an account already uses remote "${remote}"` };
41
+ }
42
+ const account = {
43
+ id: accountId(),
44
+ provider,
45
+ label: label || remote,
46
+ remote,
47
+ quotaGiB: quotaGiB && Number(quotaGiB) > 0 ? Number(quotaGiB) : undefined,
48
+ addedAt: new Date().toISOString(),
49
+ };
50
+ cfg.storage.accounts.push(account);
51
+ saveConfig(cfg);
52
+ return { ok: true, account };
53
+ }
54
+
55
+ export function removeAccount(id) {
56
+ const cfg = loadConfig();
57
+ const before = cfg.storage.accounts.length;
58
+ cfg.storage.accounts = cfg.storage.accounts.filter((a) => a.id !== id && a.remote !== id);
59
+ const removed = cfg.storage.accounts.length !== before;
60
+ if (removed) saveConfig(cfg);
61
+ return removed;
62
+ }
63
+
64
+ export function listAccounts() {
65
+ return loadConfig().storage.accounts || [];
66
+ }
67
+
68
+ export function hasAccounts(cfg = loadConfig()) {
69
+ return (cfg.storage.accounts || []).length > 0;
70
+ }
71
+
72
+ /**
73
+ * Refresh live quota data for every account. Falls back to the configured /
74
+ * provider default quota when the backend does not report usage.
75
+ * @returns {Promise<Array>} accounts with {total, used, free} bytes
76
+ */
77
+ export async function refreshAccounts(cfg = loadConfig()) {
78
+ const accounts = (cfg.storage.accounts || []).map((a) => ({ ...a }));
79
+ const refreshed = [];
80
+ for (const acc of accounts) {
81
+ const about = await aboutRemote(acc.remote + ':');
82
+ const quota = acc.quotaGiB ? Number(acc.quotaGiB) * GiB : defaultQuota(acc.provider, cfg);
83
+ const total = about.total ?? quota;
84
+ const used = about.used ?? 0;
85
+ const free = about.free ?? Math.max(0, total - used);
86
+ refreshed.push({ ...acc, total, used, free, live: about.free != null });
87
+ }
88
+ return refreshed;
89
+ }
90
+
91
+ /** One-line storage pool summary (for status/doctor). */
92
+ export function poolSummary(accounts) {
93
+ const total = accounts.reduce((s, a) => s + (a.total || 0), 0);
94
+ const used = accounts.reduce((s, a) => s + (a.used || 0), 0);
95
+ const free = accounts.reduce((s, a) => s + (a.free || 0), 0);
96
+ return {
97
+ accounts: accounts.length,
98
+ total,
99
+ used,
100
+ free,
101
+ text: `${accounts.length} account(s) — ${bytesHuman(total)} total / ${bytesHuman(used)} used / ${bytesHuman(free)} free`,
102
+ };
103
+ }
104
+
105
+ /** Spaces requirement check for setup. */
106
+ export async function storageHealth() {
107
+ const has = await hasRclone();
108
+ if (!has) return { ok: false, reason: 'rclone is not installed' };
109
+ return { ok: true, reason: null };
110
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Smart storage allocator — the brain that keeps the multi-account pool
3
+ * (many MEGA + Google Drive logins) from ever running out of space.
4
+ *
5
+ * Strategy:
6
+ * 1. Every file of an artifact is placed WHOLE on a single account when it
7
+ * fits. The account is chosen to minimise the resulting used-percentage
8
+ * (water-filling), then most free, then oldest — spreading wear.
9
+ * 2. If a single file is bigger than any one account's free space, it is
10
+ * split into byte-range chunks distributed across several accounts
11
+ * (first-fit over free space). A manifest records the exact ranges so
12
+ * restore reassembles the file byte-perfectly.
13
+ * The manifest is written to the cloud (`<root>/<kind>/<id>/__MANIFEST__.json`)
14
+ * AND mirrored locally, so restore works even from a wiped machine.
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import streams from 'node:stream/promises';
20
+ import { createWriteStream } from 'node:fs';
21
+ import { copyToFile, mkdirRemote } from './rclone.js';
22
+ import { bytesHuman } from '../util/misc.js';
23
+
24
+ export { bytesHuman };
25
+ export const GiB = 1024 ** 3;
26
+ export const MANIFEST_NAME = '__MANIFEST__.json';
27
+
28
+ /** Walk a dir tree returning [{rel, abs, size, isDir}]. Symlinks skipped. */
29
+ export function walkFiles(localDir) {
30
+ const out = [];
31
+ const base = path.resolve(localDir);
32
+ function rec(abs, rel) {
33
+ for (const ent of fs.readdirSync(abs, { withFileTypes: true })) {
34
+ const eAbs = path.join(abs, ent.name);
35
+ const eRel = rel ? `${rel}/${ent.name}` : ent.name;
36
+ let st;
37
+ try {
38
+ st = fs.statSync(eAbs);
39
+ } catch {
40
+ continue; // dangling symlink / unreadable
41
+ }
42
+ if (ent.isSymbolicLink()) continue;
43
+ if (ent.isDirectory()) {
44
+ out.push({ rel: eRel, abs: eAbs, isDir: true, size: 0 });
45
+ rec(eAbs, eRel);
46
+ } else {
47
+ out.push({ rel: eRel, abs: eAbs, isDir: false, size: st.size });
48
+ }
49
+ }
50
+ }
51
+ rec(base, '');
52
+ return out;
53
+ }
54
+
55
+ /** Choose the account that minimises its resulting used-percentage. */
56
+ export function chooseAccount(needed, accounts) {
57
+ let best = null;
58
+ let bestScore = Infinity;
59
+ for (const acc of accounts) {
60
+ if (acc.free < needed) continue;
61
+ const quota = acc.total || 0;
62
+ const score = quota ? (acc.used + needed) / quota : needed;
63
+ if (score < bestScore || (score === bestScore && (acc.free > (best?.free ?? -1)))) {
64
+ bestScore = score;
65
+ best = acc;
66
+ }
67
+ }
68
+ return best;
69
+ }
70
+
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
76
+ */
77
+ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, chunkSize, onProgress }) {
78
+ if (!accounts || accounts.length === 0) {
79
+ throw new Error('no storage accounts configured — add one with `parrot-blackbox account add`');
80
+ }
81
+ chunkSize = chunkSize || 2 * GiB;
82
+
83
+ const entries = walkFiles(localDir).filter((e) => e.isDir || e.size > 0);
84
+ const files = entries.filter((e) => !e.isDir).sort((a, b) => b.size - a.size);
85
+ const totalSize = files.reduce((s, f) => s + f.size, 0);
86
+
87
+ const pool = accounts.map((a) => ({ ...a }));
88
+ const consume = (account, bytes) => {
89
+ const a = pool.find((x) => x.id === account.id);
90
+ if (a) a.free -= bytes;
91
+ };
92
+
93
+ const basePath = `${remoteRoot}/${kind}/${id}`;
94
+ const manifest = {
95
+ schema: 1,
96
+ kind,
97
+ id,
98
+ createdAt: new Date().toISOString(),
99
+ totalSize,
100
+ remoteRoot,
101
+ entries: [],
102
+ };
103
+
104
+ const report = (done, text) => {
105
+ if (typeof onProgress === 'function') onProgress({ done, total: files.length, text });
106
+ };
107
+
108
+ let placed = 0;
109
+ for (const entry of entries) {
110
+ if (entry.isDir) {
111
+ const acc = chooseAccount(0, pool);
112
+ if (!acc) throw outOfSpace();
113
+ const rp = `${basePath}/${entry.rel}`;
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 }] });
117
+ continue;
118
+ }
119
+
120
+ const rel = entry.rel;
121
+ const destPath = `${basePath}/${rel}`;
122
+ const freeMax = Math.max(0, ...pool.map((a) => a.free));
123
+ if (entry.size <= freeMax) {
124
+ const acc = chooseAccount(entry.size, pool);
125
+ if (!acc) { report(placed, `no space for ${rel}`); throw outOfSpace(); }
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}`);
128
+ consume(acc, entry.size);
129
+ manifest.entries.push({
130
+ rel,
131
+ type: 'file',
132
+ size: entry.size,
133
+ loc: [{ remote: acc.remote, path: destPath, start: 0, end: entry.size, size: entry.size }],
134
+ });
135
+ } else {
136
+ // Split across accounts by byte ranges.
137
+ const locs = [];
138
+ let start = 0;
139
+ let partIndex = 0;
140
+ while (start < entry.size) {
141
+ const len = Math.min(chunkSize, entry.size - start);
142
+ const acc = chooseAccount(len, pool);
143
+ if (process.env.PBB_DEBUG_ALLOC === '1') {
144
+ console.error(`[alloc] file=${entry.rel} size=${entry.size} start=${start} len=${len} pool=${pool.map((a) => `${a.remote}:free=${a.free}`).join(',')} acc=${acc ? acc.remote : 'NONE'}`);
145
+ }
146
+ if (!acc) { report(placed, `no space for part of ${rel}`); throw outOfSpace(); }
147
+ const partAbs = await makePartFile(entry, start, len);
148
+ const partPath = `${destPath}.part-${String(partIndex).padStart(4, '0')}`;
149
+ const res = await copyToFile(partAbs, `${acc.remote}:${partPath}`);
150
+ if (!res.ok) throw new Error(`upload failed for ${rel} part on ${acc.remote}: ${res.error}`);
151
+ consume(acc, len);
152
+ locs.push({ remote: acc.remote, path: partPath, start, end: start + len, size: len });
153
+ start += len;
154
+ partIndex += 1;
155
+ }
156
+ manifest.entries.push({ rel, type: 'file', size: entry.size, split: true, loc: locs });
157
+ report(placed, `split ${rel} across ${locs.length} account(s)`);
158
+ }
159
+ placed += 1;
160
+ report(placed, `placed ${placed}/${files.length}`);
161
+ }
162
+
163
+ // Manifest: cloud + local mirror.
164
+ const manifestLocalDir = process.env.PBB_MANIFESTS_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
165
+ const manifestLocalPath = path.join(manifestLocalDir, `${kind}-${id}.json`);
166
+ fs.mkdirSync(manifestLocalDir, { recursive: true });
167
+ fs.writeFileSync(manifestLocalPath, JSON.stringify(manifest, null, 2));
168
+ const accForManifest = pool.find((a) => a.free > 0) || pool[0];
169
+ if (accForManifest) {
170
+ const res = await copyToFile(manifestLocalPath, `${accForManifest.remote}:${basePath}/${MANIFEST_NAME}`);
171
+ if (res.ok) manifest.account = accForManifest.remote;
172
+ }
173
+ return manifest;
174
+ }
175
+
176
+ function outOfSpace() {
177
+ return new Error(
178
+ 'OUT OF SPACE — the pool has no account with enough free room. Add an account, raise a quota, or prune old backups.',
179
+ );
180
+ }
181
+
182
+ /** Write a byte range of a file to a temp part file; returns its path. */
183
+ async function makePartFile(file, start, len) {
184
+ const dir = process.env.PBB_CHUNK_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'chunks');
185
+ const partAbs = path.join(dir, `.part-${process.pid}-${file.rel.replace(/[^a-z0-9_.-]/gi, '_')}`);
186
+ fs.mkdirSync(path.dirname(partAbs), { recursive: true });
187
+ await streams.pipeline(fs.createReadStream(file.abs, { start, end: start + len - 1 }), createWriteStream(partAbs));
188
+ return partAbs;
189
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Archive operations on top of the allocator: discover manifests, restore an
3
+ * artifact (reassembling byte-range chunks), remove artifacts, list what is
4
+ * stored across the pool.
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import streams from 'node:stream/promises';
10
+ import { createWriteStream } from 'node:fs';
11
+ import { execa } from 'execa';
12
+ import { catRemote, lsjson, purge, copyToFile } from './rclone.js';
13
+ import { MANIFEST_NAME } from './allocator.js';
14
+
15
+ export { MANIFEST_NAME };
16
+
17
+ function manifestMirrorPath(kind, id) {
18
+ const dir = process.env.PBB_MANIFESTS_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
19
+ return path.join(dir, `${kind}-${id}.json`);
20
+ }
21
+
22
+ /**
23
+ * Find the manifest for an artifact by scanning all accounts.
24
+ * @returns {Promise<object|null>} {account, manifest}
25
+ */
26
+ export async function discoverManifest(kind, id, accounts, remoteRoot) {
27
+ for (const acc of accounts || []) {
28
+ const remotePath = `${acc.remote}:${remoteRoot}/${kind}/${id}/${MANIFEST_NAME}`;
29
+ const res = await catRemote(remotePath);
30
+ if (res.ok) {
31
+ try {
32
+ const manifest = JSON.parse(res.stdout);
33
+ if (manifest.kind === kind && manifest.id === id) return { account: acc, manifest };
34
+ } catch {
35
+ /* corrupt manifest — keep scanning */
36
+ }
37
+ }
38
+ }
39
+ // Local mirror fallback.
40
+ try {
41
+ const local = JSON.parse(fs.readFileSync(manifestMirrorPath(kind, id), 'utf8'));
42
+ return { account: null, manifest: local };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Restore an artifact from its manifest into destDir.
50
+ * @returns {Promise<{files:number, bytes:number}>}
51
+ */
52
+ export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
53
+ let files = 0;
54
+ let bytes = 0;
55
+
56
+ for (const entry of manifest.entries || []) {
57
+ const target = path.join(destDir, ...entry.rel.split('/'));
58
+ fs.mkdirSync(path.dirname(target), { recursive: true });
59
+
60
+ if (entry.type === 'dir') {
61
+ fs.mkdirSync(target, { recursive: true });
62
+ continue;
63
+ }
64
+ if (entry.loc.length === 1) {
65
+ const { remote, path: rp } = entry.loc[0];
66
+ const res = await copyToFile(`${remote}:${rp}`, target);
67
+ if (!res.ok) throw new Error(`download failed for ${entry.rel}: ${res.error}`);
68
+ files += 1;
69
+ bytes += entry.size;
70
+ if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
71
+ continue;
72
+ }
73
+
74
+ // Reassemble split file from byte-range parts.
75
+ const partAbs = `${target}.assembling-${process.pid}`;
76
+ const out = createWriteStream(partAbs);
77
+ const sorted = [...entry.loc].sort((a, b) => a.start - b.start);
78
+ for (const loc of sorted) {
79
+ const src = `${loc.remote}:${loc.path}`;
80
+ const child = execa('rclone', ['cat', src], { reject: false });
81
+ child.stdout.on('error', () => {});
82
+ await streams.pipeline(child.stdout, out, { end: false }).catch(() => {});
83
+ const res = await child; // wait for the process to exit and get its code
84
+ if (res.exitCode !== 0) throw new Error(`chunk download failed for ${entry.rel} (exit ${res.exitCode})`);
85
+ }
86
+ if (out.writableEnded === false) {
87
+ out.end();
88
+ await streams.finished(out);
89
+ }
90
+ fs.renameSync(partAbs, target);
91
+ files += 1;
92
+ bytes += entry.size;
93
+ if (typeof onProgress === 'function') onProgress({ done: files, text: `restored ${entry.rel}` });
94
+ }
95
+ return { files, bytes };
96
+ }
97
+
98
+ /** Purge an artifact from every account that hosts it (+ local mirror). */
99
+ export async function removeArtifact(kind, id, accounts, remoteRoot) {
100
+ const removed = [];
101
+ for (const acc of accounts || []) {
102
+ const res = await purge(`${acc.remote}:${remoteRoot}/${kind}/${id}`);
103
+ if (res.ok) removed.push(acc.remote);
104
+ }
105
+ try {
106
+ fs.rmSync(manifestMirrorPath(kind, id), { force: true });
107
+ } catch {
108
+ /* best effort local cleanup */
109
+ }
110
+ return removed;
111
+ }
112
+
113
+ /**
114
+ * List artifacts of a kind discovered on any account (scans manifests).
115
+ * @returns {Promise<Array>} [{kind, id, createdAt, totalSize, account, manifest}]
116
+ */
117
+ export async function listArtifacts(kind, accounts, remoteRoot, onProgress) {
118
+ const out = [];
119
+ for (const acc of accounts || []) {
120
+ const scanPath = `${acc.remote}:${remoteRoot}/${kind}`;
121
+ const res = await lsjson(scanPath, { recursive: true });
122
+ if (!res.ok) continue;
123
+ const manifests = res.entries.filter((e) => !e.IsDir && e.Path.endsWith(`/${MANIFEST_NAME}`));
124
+ if (typeof onProgress === 'function') onProgress({ text: `scanning ${acc.remote}…` });
125
+ for (const m of manifests) {
126
+ const id = m.Path.split('/').slice(0, -1).pop();
127
+ const cat = await catRemote(`${acc.remote}:${remoteRoot}/${kind}/${id}/${MANIFEST_NAME}`);
128
+ if (!cat.ok) continue;
129
+ try {
130
+ const manifest = JSON.parse(cat.stdout);
131
+ out.push({ kind, id, createdAt: manifest.createdAt, totalSize: manifest.totalSize, account: acc.remote, manifest });
132
+ } catch {
133
+ /* skip corrupt */
134
+ }
135
+ }
136
+ }
137
+ return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
138
+ }