(bugfix) Make snapshot pruning robust to archived permissions
Repair user permissions inside snapshot trees before deleting them so retention prune and incomplete cleanup can remove directories copied with restrictive rsync archive modes. Add path validation for scheduled/manual snapshot deletes and cover non-traversable nested directories in retention tests.
This commit is contained in:
@@ -154,6 +154,68 @@ class SqlRetentionTests(TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_apply_deletes_snapshot_with_non_traversable_nested_directory(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
prefix = Path(tmp) / "home"
|
||||
host = HostConfig.objects.create(
|
||||
host="web-01",
|
||||
address="web-01.example.test",
|
||||
retention_daily=0,
|
||||
retention_weekly=0,
|
||||
retention_monthly=0,
|
||||
retention_yearly=0,
|
||||
)
|
||||
old_dir = Path(tmp) / "backups" / host.host / "scheduled" / "20260518-021500Z__OLD"
|
||||
restricted_dir = old_dir / "data" / "var" / "lib" / "snapd" / "void"
|
||||
restricted_dir.mkdir(parents=True)
|
||||
restricted_dir.joinpath("state").write_text("preserved permissions\n")
|
||||
restricted_dir.chmod(0)
|
||||
new_dir = Path(tmp) / "backups" / host.host / "scheduled" / "20260519-021500Z__NEW"
|
||||
new_dir.mkdir(parents=True)
|
||||
old = self._snapshot(host, old_dir.name, path=str(old_dir))
|
||||
self._snapshot(host, new_dir.name, path=str(new_dir))
|
||||
|
||||
result = run_sql_retention_apply(
|
||||
prefix=prefix,
|
||||
host=host.host,
|
||||
kind="scheduled",
|
||||
protect_bases=False,
|
||||
yes=True,
|
||||
max_delete=1,
|
||||
acquire_lock=False,
|
||||
)
|
||||
|
||||
self.assertFalse(old_dir.exists())
|
||||
self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists())
|
||||
self.assertEqual(result["deleted"][0]["dirname"], old.dirname)
|
||||
|
||||
def test_apply_rejects_scheduled_snapshot_path_outside_host_kind_directory(self) -> None:
|
||||
host = HostConfig.objects.create(
|
||||
host="web-01",
|
||||
address="web-01.example.test",
|
||||
retention_daily=0,
|
||||
retention_weekly=0,
|
||||
retention_monthly=0,
|
||||
retention_yearly=0,
|
||||
)
|
||||
self._snapshot(
|
||||
host,
|
||||
"20260518-021500Z__OLD",
|
||||
path="/backups/web-01/manual/20260518-021500Z__OLD",
|
||||
)
|
||||
self._snapshot(host, "20260519-021500Z__NEW")
|
||||
|
||||
with self.assertRaisesRegex(ConfigError, "unexpected snapshot path"):
|
||||
run_sql_retention_apply(
|
||||
prefix=Path("/tmp/pobsync-test"),
|
||||
host=host.host,
|
||||
kind="scheduled",
|
||||
protect_bases=False,
|
||||
yes=True,
|
||||
max_delete=1,
|
||||
acquire_lock=False,
|
||||
)
|
||||
|
||||
def test_apply_respects_max_delete(self) -> None:
|
||||
host = HostConfig.objects.create(
|
||||
host="web-01",
|
||||
@@ -213,6 +275,36 @@ class SqlRetentionTests(TestCase):
|
||||
self.assertEqual(purged.action, PurgedSnapshot.Action.INCOMPLETE_CLEANUP)
|
||||
self.assertEqual(purged.reason, "manual incomplete cleanup")
|
||||
|
||||
def test_incomplete_cleanup_deletes_non_traversable_nested_directory(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
prefix = Path(tmp) / "home"
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
incomplete_dir = Path(tmp) / "backups" / host.host / ".incomplete" / "20260519-031500Z__BROKEN01"
|
||||
restricted_dir = incomplete_dir / "data" / "var" / "lib" / "snapd" / "void"
|
||||
restricted_dir.mkdir(parents=True)
|
||||
restricted_dir.joinpath("state").write_text("interrupted\n")
|
||||
restricted_dir.chmod(0)
|
||||
record = SnapshotRecord.objects.create(
|
||||
host=host,
|
||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||
dirname=incomplete_dir.name,
|
||||
path=str(incomplete_dir),
|
||||
status="failed",
|
||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
result = run_incomplete_cleanup(
|
||||
prefix=prefix,
|
||||
host=host.host,
|
||||
yes=True,
|
||||
max_delete=1,
|
||||
acquire_lock=False,
|
||||
)
|
||||
|
||||
self.assertFalse(incomplete_dir.exists())
|
||||
self.assertFalse(SnapshotRecord.objects.filter(pk=record.pk).exists())
|
||||
self.assertEqual(result["deleted"][0]["dirname"], incomplete_dir.name)
|
||||
|
||||
def test_incomplete_cleanup_respects_max_delete(self) -> None:
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
SnapshotRecord.objects.create(
|
||||
|
||||
Reference in New Issue
Block a user