parrot-blackbox 2.0.3 → 2.0.4
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 +59 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
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
|
@@ -158,6 +158,8 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
|
|
|
158
158
|
if (privileged === 'interactive') {
|
|
159
159
|
await ensureSudo();
|
|
160
160
|
const res = await sudoInteractive(args);
|
|
161
|
+
// Timeshift exits 0 even when the qgroup destroy fails, so we verify the
|
|
162
|
+
// snapshot is actually gone rather than trusting the exit code alone.
|
|
161
163
|
if (res.exitCode !== 0) throw new Error(`timeshift --delete ${name} failed (exit ${res.exitCode})`);
|
|
162
164
|
return true;
|
|
163
165
|
}
|
|
@@ -169,16 +171,32 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
|
|
|
169
171
|
return true;
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
/** Run btrfs quota rescan -w / and wait for it to finish. */
|
|
175
|
+
async function btrfsQuotaRescan({ privileged = 'interactive', onProgress } = {}) {
|
|
176
|
+
onProgress?.('Running btrfs quota rescan — this may take a moment…');
|
|
177
|
+
const res = privileged === 'interactive'
|
|
178
|
+
? await sudoInteractive(['btrfs', 'quota', 'rescan', '-w', '/'])
|
|
179
|
+
: await sudoNonInteractive(['btrfs', 'quota', 'rescan', '-w', '/']);
|
|
180
|
+
if (res.exitCode !== 0) {
|
|
181
|
+
onProgress?.(`⚠ btrfs quota rescan exited ${res.exitCode} — quotas may not be enabled, continuing`);
|
|
182
|
+
} else {
|
|
183
|
+
onProgress?.('✔ btrfs quota rescan complete');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
172
187
|
/**
|
|
173
188
|
* Delete ALL local Timeshift snapshots.
|
|
174
189
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
190
|
+
* Strategy (matches the confirmed-working pattern from research):
|
|
191
|
+
* 1. Run `btrfs quota rescan -w /` upfront.
|
|
192
|
+
* 2. For each snapshot: attempt delete, then verify it is GONE from
|
|
193
|
+
* `timeshift --list`. Timeshift exits 0 even when it prints
|
|
194
|
+
* "E: Failed to remove snapshot" (qgroup destroy fails silently).
|
|
195
|
+
* Verification catches that.
|
|
196
|
+
* 3. If a snapshot is still present after the first attempt:
|
|
197
|
+
* run another rescan (the delete itself may have left a new stale entry)
|
|
198
|
+
* and retry exactly once.
|
|
199
|
+
* 4. If it still persists after retry, record it as failed and move on.
|
|
182
200
|
*
|
|
183
201
|
* @param {object} opts
|
|
184
202
|
* @param {'interactive'|'noninteractive'} opts.privileged
|
|
@@ -188,40 +206,53 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
|
|
|
188
206
|
export async function deleteAllSnapshots({ privileged = 'interactive', onProgress } = {}) {
|
|
189
207
|
if (privileged === 'interactive') await ensureSudo();
|
|
190
208
|
|
|
191
|
-
// Step 1: rescan
|
|
192
|
-
|
|
193
|
-
const rescanRes = privileged === 'interactive'
|
|
194
|
-
? await sudoInteractive(['btrfs', 'quota', 'rescan', '-w', '/'])
|
|
195
|
-
: await sudoNonInteractive(['btrfs', 'quota', 'rescan', '-w', '/']);
|
|
209
|
+
// Step 1: initial rescan to clear stale qgroup entries.
|
|
210
|
+
await btrfsQuotaRescan({ privileged, onProgress });
|
|
196
211
|
|
|
197
|
-
//
|
|
198
|
-
// (e.g. rsync-mode Timeshift). Log and continue.
|
|
199
|
-
if (rescanRes.exitCode !== 0) {
|
|
200
|
-
onProgress?.(`⚠ btrfs quota rescan exited ${rescanRes.exitCode} — continuing anyway`);
|
|
201
|
-
} else {
|
|
202
|
-
onProgress?.('✔ btrfs quota rescan complete');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Step 2: list, then delete each snapshot.
|
|
212
|
+
// Step 2: delete each snapshot, verifying it's actually gone.
|
|
206
213
|
const snapshots = await listLocalSnapshots({ privileged });
|
|
207
214
|
const deleted = [];
|
|
208
215
|
const failed = [];
|
|
209
216
|
|
|
210
217
|
for (const sn of snapshots) {
|
|
211
218
|
onProgress?.(`Deleting snapshot: ${sn.name}`);
|
|
212
|
-
|
|
213
|
-
|
|
219
|
+
let success = false;
|
|
220
|
+
|
|
221
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
222
|
+
try {
|
|
223
|
+
await deleteSnapshot(sn.name, { privileged });
|
|
224
|
+
} catch {
|
|
225
|
+
// Timeshift may have removed the subvolume but still exit non-zero.
|
|
226
|
+
// Fall through to the verify step.
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Verify the snapshot is actually gone from timeshift --list.
|
|
230
|
+
const remaining = await listLocalSnapshots({ privileged });
|
|
231
|
+
const stillPresent = remaining.some((s) => s.name === sn.name);
|
|
232
|
+
|
|
233
|
+
if (!stillPresent) {
|
|
234
|
+
success = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (attempt === 1) {
|
|
239
|
+
// The delete left a new stale qgroup entry — rescan and retry once.
|
|
240
|
+
onProgress?.(` ⚠ ${sn.name} still present after delete — rescanning qgroups and retrying…`);
|
|
241
|
+
await btrfsQuotaRescan({ privileged, onProgress });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (success) {
|
|
214
246
|
deleted.push(sn.name);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
onProgress?.(` ✖ ${sn.name}: ${e.message}`);
|
|
247
|
+
} else {
|
|
248
|
+
failed.push({ name: sn.name, error: 'still present after 2 attempts + qgroup rescan' });
|
|
249
|
+
onProgress?.(` ✖ ${sn.name}: could not delete after rescan — try deleting it individually`);
|
|
219
250
|
}
|
|
220
251
|
}
|
|
221
252
|
|
|
222
253
|
if (failed.length > 0) {
|
|
223
254
|
throw Object.assign(
|
|
224
|
-
new Error(`${failed.length} snapshot(s) could not be deleted
|
|
255
|
+
new Error(`${failed.length} snapshot(s) could not be deleted`),
|
|
225
256
|
{ deleted, failed },
|
|
226
257
|
);
|
|
227
258
|
}
|