(bugfix) Prune snapshots with preserved read-only permissions
Make SQL retention delete the snapshot root when records point at the snapshot data directory, matching how backup metadata is stored on disk. Before removing a snapshot tree, temporarily add user write permission to directories inside that snapshot so rsync-preserved source permissions do not block cleanup. Add a regression test for pruning snapshots whose data directory mirrors a read-only remote root.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import stat
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -89,17 +90,17 @@ def run_sql_retention_apply(
|
||||
if snap_kind not in {"scheduled", "manual"}:
|
||||
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}")
|
||||
|
||||
path = Path(snap_path)
|
||||
path = _snapshot_delete_path(path=Path(snap_path), dirname=dirname)
|
||||
if not path.exists():
|
||||
actions.append(f"skip missing {snap_kind}/{dirname}")
|
||||
continue
|
||||
if not path.is_dir():
|
||||
raise ConfigError(f"Refusing to delete non-directory path: {snap_path}")
|
||||
raise ConfigError(f"Refusing to delete non-directory path: {path}")
|
||||
|
||||
shutil.rmtree(path)
|
||||
_remove_snapshot_tree(path)
|
||||
SnapshotRecord.objects.filter(host__host=host, kind=snap_kind, dirname=dirname).delete()
|
||||
actions.append(f"deleted {snap_kind} {dirname}")
|
||||
deleted.append({"dirname": dirname, "kind": snap_kind, "path": snap_path})
|
||||
deleted.append({"dirname": dirname, "kind": snap_kind, "path": str(path)})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -180,3 +181,22 @@ def _snapshot_to_delete_item(snapshot: Snapshot) -> dict[str, Any]:
|
||||
"dt": snapshot.dt.isoformat(),
|
||||
"status": snapshot.status,
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
|
||||
if path.name == "data" and path.parent.name == dirname:
|
||||
return path.parent
|
||||
return path
|
||||
|
||||
|
||||
def _remove_snapshot_tree(path: Path) -> None:
|
||||
_make_directories_user_writable(path)
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
def _make_directories_user_writable(path: Path) -> None:
|
||||
for directory in [path, *[child for child in path.rglob("*") if child.is_dir() and not child.is_symlink()]]:
|
||||
mode = directory.stat().st_mode
|
||||
if mode & stat.S_IWUSR:
|
||||
continue
|
||||
directory.chmod(mode | stat.S_IWUSR)
|
||||
|
||||
Reference in New Issue
Block a user