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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parrot-blackbox",
3
- "version": "2.0.3",
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",
@@ -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
- * Runs `sudo btrfs quota rescan -w /` first to re-sync qgroup accounting —
176
- * without this, a stale qgroup entry can cause `timeshift --delete` to fail
177
- * with "Failed to destroy qgroup" even though the subvolume itself was removed.
178
- *
179
- * After the rescan, each snapshot is deleted in a loop. If a delete still
180
- * fails the function records the error and carries on so the rest can be
181
- * cleaned up; it throws at the end if any deletions failed.
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 qgroups so stale entries don't block the deletes.
192
- onProgress?.('Running btrfs quota rescan — this may take a moment…');
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
- // A non-zero exit here is not fatal — it just means quotas may not be enabled
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
- try {
213
- await deleteSnapshot(sn.name, { privileged });
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
- } catch (e) {
216
- // qgroup bookkeeping might still fail on the first pass caller can retry.
217
- failed.push({ name: sn.name, error: e.message });
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 — try running again after a fresh qgroup rescan`),
255
+ new Error(`${failed.length} snapshot(s) could not be deleted`),
225
256
  { deleted, failed },
226
257
  );
227
258
  }