Skip to content

Commit d74493c

Browse files
Updated the scheduled backup rention policy to all from last 24 hours, daily from last 7 days, and weekly from last 4 weeks
1 parent 247a3fd commit d74493c

4 files changed

Lines changed: 97 additions & 17 deletions

File tree

flake.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
outputs = { self, nixpkgs, raspberry-pi-nix, lnbits, spark-sidecar, ... }:
2323
let
24-
version = "0.9.1"; # Bump before each release tag to match the next tag name
24+
version = "0.9.3"; # Bump before each release tag to match the next tag name
2525
system = "aarch64-linux";
2626
in
2727
{

nixos/admin-app/app.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
package_encrypted_backup,
5151
package_plain_backup,
5252
read_json_file,
53+
select_scheduled_backups_to_keep,
5354
utc_now_iso,
5455
validate_manifest_files,
5556
write_json_file,
@@ -804,13 +805,7 @@ def _local_backup_manifest(path: Path) -> dict[str, Any] | None:
804805

805806
def _prune_scheduled_backups():
806807
now = datetime.now().astimezone()
807-
checkpoints = [
808-
("daily", now.timestamp() - 86400),
809-
("weekly", now.timestamp() - (7 * 86400)),
810-
("monthly", now.timestamp() - (30 * 86400)),
811-
]
812-
keep: set[Path] = set()
813-
candidates: list[tuple[Path, float]] = []
808+
candidates: list[tuple[Path, datetime]] = []
814809

815810
for path in RECOVERY_BACKUP_DIR.glob("lnbitsbox-recovery-*.zip"):
816811
manifest = _local_backup_manifest(path)
@@ -819,18 +814,12 @@ def _prune_scheduled_backups():
819814
created_at = parse_iso_datetime(manifest.get("created_at"))
820815
if created_at is None:
821816
continue
822-
candidates.append((path, created_at.timestamp()))
817+
candidates.append((path, created_at))
823818

824819
if not candidates:
825820
return
826821

827-
candidates.sort(key=lambda item: item[1], reverse=True)
828-
keep.add(candidates[0][0])
829-
830-
for _, threshold in checkpoints:
831-
eligible = [item for item in candidates if item[1] <= threshold]
832-
if eligible:
833-
keep.add(max(eligible, key=lambda item: item[1])[0])
822+
keep = select_scheduled_backups_to_keep(candidates, now=now)
834823

835824
for path, _ in candidates:
836825
if path in keep:

nixos/admin-app/recovery_utils.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
import secrets
66
import zipfile
7-
from datetime import datetime, timezone
7+
from datetime import datetime, timedelta, timezone
88
from pathlib import Path
99
from typing import Any
1010

@@ -217,3 +217,54 @@ def parse_iso_datetime(value: str | None) -> datetime | None:
217217
if parsed.tzinfo is None:
218218
return parsed.replace(tzinfo=timezone.utc)
219219
return parsed
220+
221+
222+
def select_scheduled_backups_to_keep(
223+
backups: list[tuple[Path, datetime]],
224+
*,
225+
now: datetime | None = None,
226+
) -> set[Path]:
227+
if not backups:
228+
return set()
229+
230+
if now is None:
231+
now = datetime.now(timezone.utc)
232+
elif now.tzinfo is None:
233+
now = now.replace(tzinfo=timezone.utc)
234+
235+
normalized = []
236+
for path, created_at in backups:
237+
if created_at.tzinfo is None:
238+
created_at = created_at.replace(tzinfo=timezone.utc)
239+
normalized.append((path, created_at.astimezone(now.tzinfo)))
240+
241+
normalized.sort(key=lambda item: item[1], reverse=True)
242+
keep: set[Path] = {normalized[0][0]}
243+
244+
hourly_cutoff = now - timedelta(hours=24)
245+
daily_cutoff = now - timedelta(days=7)
246+
weekly_cutoff = now - timedelta(weeks=4)
247+
248+
daily_buckets: dict[datetime.date, tuple[Path, datetime]] = {}
249+
weekly_buckets: dict[tuple[int, int], tuple[Path, datetime]] = {}
250+
251+
for path, created_at in normalized:
252+
if created_at >= hourly_cutoff:
253+
keep.add(path)
254+
continue
255+
if created_at >= daily_cutoff:
256+
bucket = created_at.date()
257+
existing = daily_buckets.get(bucket)
258+
if existing is None or created_at > existing[1]:
259+
daily_buckets[bucket] = (path, created_at)
260+
continue
261+
if created_at >= weekly_cutoff:
262+
iso_year, iso_week, _ = created_at.isocalendar()
263+
bucket = (iso_year, iso_week)
264+
existing = weekly_buckets.get(bucket)
265+
if existing is None or created_at > existing[1]:
266+
weekly_buckets[bucket] = (path, created_at)
267+
268+
keep.update(path for path, _ in daily_buckets.values())
269+
keep.update(path for path, _ in weekly_buckets.values())
270+
return keep

nixos/admin-app/tests/test_recovery_utils.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import io
22
import unittest
33
import zipfile
4+
from datetime import datetime, timedelta, timezone
5+
from pathlib import Path
46

57
try:
68
from recovery_utils import (
@@ -11,6 +13,7 @@
1113
load_backup_container,
1214
package_encrypted_backup,
1315
package_plain_backup,
16+
select_scheduled_backups_to_keep,
1417
validate_manifest_files,
1518
)
1619
RECOVERY_IMPORT_ERROR = None
@@ -104,6 +107,43 @@ def test_compatibility_report(self):
104107
self.assertEqual(compatibility_report("0.2.0", "0.1.49")["level"], "warn")
105108
self.assertEqual(compatibility_report("1.0.0", "0.1.49")["level"], "error")
106109

110+
def test_scheduled_backup_retention_keeps_recent_daily_and_weekly_windows(self):
111+
now = datetime(2026, 3, 17, 12, 0, tzinfo=timezone.utc)
112+
backups = [
113+
(Path("/tmp/hourly-1.zip"), now - timedelta(hours=1)),
114+
(Path("/tmp/hourly-20.zip"), now - timedelta(hours=20)),
115+
(Path("/tmp/day-2-new.zip"), now - timedelta(days=2, hours=1)),
116+
(Path("/tmp/day-2-old.zip"), now - timedelta(days=2, hours=6)),
117+
(Path("/tmp/day-6.zip"), now - timedelta(days=6, hours=2)),
118+
(Path("/tmp/week-2-new.zip"), now - timedelta(days=10, hours=1)),
119+
(Path("/tmp/week-2-old.zip"), now - timedelta(days=12)),
120+
(Path("/tmp/week-4.zip"), now - timedelta(days=24)),
121+
(Path("/tmp/older-than-window.zip"), now - timedelta(days=40)),
122+
]
123+
124+
keep = select_scheduled_backups_to_keep(backups, now=now)
125+
126+
self.assertIn(Path("/tmp/hourly-1.zip"), keep)
127+
self.assertIn(Path("/tmp/hourly-20.zip"), keep)
128+
self.assertIn(Path("/tmp/day-2-new.zip"), keep)
129+
self.assertNotIn(Path("/tmp/day-2-old.zip"), keep)
130+
self.assertIn(Path("/tmp/day-6.zip"), keep)
131+
self.assertIn(Path("/tmp/week-2-new.zip"), keep)
132+
self.assertNotIn(Path("/tmp/week-2-old.zip"), keep)
133+
self.assertIn(Path("/tmp/week-4.zip"), keep)
134+
self.assertNotIn(Path("/tmp/older-than-window.zip"), keep)
135+
136+
def test_scheduled_backup_retention_keeps_latest_even_when_outside_windows(self):
137+
now = datetime(2026, 3, 17, 12, 0, tzinfo=timezone.utc)
138+
backups = [
139+
(Path("/tmp/latest-old.zip"), now - timedelta(days=60)),
140+
(Path("/tmp/older.zip"), now - timedelta(days=90)),
141+
]
142+
143+
keep = select_scheduled_backups_to_keep(backups, now=now)
144+
145+
self.assertEqual(keep, {Path("/tmp/latest-old.zip")})
146+
107147

108148
if __name__ == "__main__":
109149
unittest.main()

0 commit comments

Comments
 (0)