Drop the pre-Django YAML import/export management commands and remove the file-based config loader fallback from the backup and retention engines. Keep the runtime config bridge backed by Django models, and add tests that ensure engine operations require an explicit Django config source.
113 lines
3.2 KiB
Python
113 lines
3.2 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, List
|
|
|
|
from ..config.source import ConfigSource
|
|
from ..errors import ConfigError
|
|
from ..retention import Snapshot, apply_base_protection, build_retention_plan
|
|
from ..snapshot_meta import iter_snapshot_dirs, read_snapshot_meta, resolve_host_root
|
|
from ..util import sanitize_host
|
|
|
|
|
|
def _parse_snapshot_dt(dirname: str, meta: dict) -> datetime:
|
|
ts = meta.get("started_at")
|
|
if isinstance(ts, str) and ts.endswith("Z"):
|
|
try:
|
|
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
pass
|
|
|
|
# fallback: dirname YYYYMMDD-HHMMSSZ__ID
|
|
try:
|
|
prefix = dirname.split("__", 1)[0]
|
|
return datetime.strptime(prefix, "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
|
except Exception:
|
|
return datetime.fromtimestamp(0, tz=timezone.utc)
|
|
|
|
|
|
def run_retention_plan(
|
|
prefix: Path,
|
|
host: str,
|
|
kind: str,
|
|
protect_bases: bool,
|
|
config_source: ConfigSource | None = None,
|
|
) -> dict[str, Any]:
|
|
host = sanitize_host(host)
|
|
|
|
if kind not in {"scheduled", "manual", "all"}:
|
|
raise ConfigError("kind must be scheduled, manual, or all")
|
|
|
|
if config_source is None:
|
|
raise ConfigError("A Django config source is required.")
|
|
cfg = config_source.effective_config_for_host(host)
|
|
|
|
retention = cfg.get("retention")
|
|
if not isinstance(retention, dict):
|
|
raise ConfigError("No retention config found")
|
|
|
|
backup_root = cfg.get("backup_root")
|
|
if not isinstance(backup_root, str) or not backup_root.startswith("/"):
|
|
raise ConfigError("Invalid backup_root in config")
|
|
|
|
host_root = resolve_host_root(backup_root, host)
|
|
|
|
kinds: List[str]
|
|
if kind == "all":
|
|
kinds = ["scheduled", "manual"]
|
|
else:
|
|
kinds = [kind]
|
|
|
|
snapshots: List[Snapshot] = []
|
|
|
|
for kk in kinds:
|
|
for d in iter_snapshot_dirs(host_root, kk):
|
|
meta = read_snapshot_meta(d)
|
|
dt = _parse_snapshot_dt(d.name, meta)
|
|
|
|
snapshots.append(
|
|
Snapshot(
|
|
kind=kk,
|
|
dirname=d.name,
|
|
path=str(d),
|
|
dt=dt,
|
|
status=meta.get("status"),
|
|
base=meta.get("base"),
|
|
)
|
|
)
|
|
|
|
plan = build_retention_plan(
|
|
snapshots=snapshots,
|
|
retention=retention,
|
|
now=datetime.now(timezone.utc),
|
|
)
|
|
|
|
keep = set(plan.keep)
|
|
reasons = dict(plan.reasons)
|
|
|
|
if protect_bases:
|
|
keep, reasons = apply_base_protection(snapshots=snapshots, keep=keep, reasons=reasons)
|
|
|
|
delete = [s for s in snapshots if s.dirname not in keep]
|
|
|
|
return {
|
|
"ok": True,
|
|
"host": host,
|
|
"kind": kind,
|
|
"protect_bases": bool(protect_bases),
|
|
"retention": retention,
|
|
"keep": sorted(keep),
|
|
"delete": [
|
|
{
|
|
"dirname": s.dirname,
|
|
"kind": s.kind,
|
|
"path": s.path,
|
|
"dt": s.dt.isoformat(),
|
|
"status": s.status,
|
|
}
|
|
for s in delete
|
|
],
|
|
"reasons": reasons,
|
|
}
|