154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
"""Back up thread assets to a configured destination. Verifies every file."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import sys
|
|
import tomllib
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from .media_manifest import (
|
|
_from_wire_dict,
|
|
_manifest_to_wire,
|
|
atomic_write_json,
|
|
hash_file,
|
|
inventory_digest,
|
|
payload_inventory,
|
|
)
|
|
from .models import MediaManifest
|
|
except ImportError: # pragma: no cover - script-mode import
|
|
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from media_manifest import (
|
|
_from_wire_dict,
|
|
_manifest_to_wire,
|
|
atomic_write_json,
|
|
hash_file,
|
|
inventory_digest,
|
|
payload_inventory,
|
|
)
|
|
from models import MediaManifest
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BackupResult:
|
|
"""Outcome of a single-thread backup attempt to one destination."""
|
|
state: str
|
|
destination: Path
|
|
error: str | None
|
|
|
|
|
|
def load_destination_root(vault: Path, destination_id: str) -> Path:
|
|
"""Read the configured backup destination root from ``vault/secrets/config.toml``."""
|
|
path = vault / "secrets" / "config.toml"
|
|
if not path.is_file():
|
|
raise RuntimeError(f"missing {path}")
|
|
text = path.read_text(encoding="utf-8")
|
|
try:
|
|
data = tomllib.loads(text)
|
|
backup = data.get("backup") or {}
|
|
configured_id = str(backup.get("id") or "cozy")
|
|
root = str(backup.get("root") or "").strip()
|
|
except tomllib.TOMLDecodeError:
|
|
values: dict[str, str] = {}
|
|
in_backup = False
|
|
for raw in text.splitlines():
|
|
line = raw.strip()
|
|
if line.startswith("[") and line.endswith("]"):
|
|
in_backup = line[1:-1].strip().lower() == "backup"
|
|
continue
|
|
if in_backup and "=" in line:
|
|
key, _equals, remainder = line.partition("=")
|
|
values[key.strip().lower()] = remainder.strip().strip("\"'")
|
|
configured_id = values.get("id", "cozy")
|
|
root = values.get("root", "")
|
|
if configured_id != destination_id:
|
|
raise RuntimeError(f"backup destination not configured: {destination_id}")
|
|
if not root:
|
|
raise RuntimeError("config.toml [backup] root is empty")
|
|
return Path(root)
|
|
|
|
|
|
def backup_thread(
|
|
asset_dir: Path,
|
|
*,
|
|
assets_root: Path,
|
|
destination_root: Path,
|
|
destination_id: str,
|
|
now: str,
|
|
require_destination_root: bool = False,
|
|
) -> BackupResult:
|
|
"""Mirror ``asset_dir`` into ``destination_root/<relative>`` and verify every payload by sha256."""
|
|
media_path = asset_dir / "media.json"
|
|
raw = json.loads(media_path.read_text(encoding="utf-8"))
|
|
manifest = _from_wire_dict(raw)
|
|
relative = asset_dir.relative_to(assets_root)
|
|
destination = destination_root / relative
|
|
inventory = payload_inventory(asset_dir, manifest)
|
|
digest = inventory_digest(inventory)
|
|
mirror: dict = {
|
|
"destination_id": destination_id,
|
|
"relative_path": relative.as_posix(),
|
|
"state": "error",
|
|
"inventory_digest": digest,
|
|
"last_attempted_at": now,
|
|
"completed_at": None,
|
|
"verified_at": None,
|
|
"error": None,
|
|
}
|
|
others = [
|
|
entry
|
|
for entry in raw.get("mirrors") or []
|
|
if entry.get("destination_id") != destination_id
|
|
]
|
|
try:
|
|
if require_destination_root and not destination_root.is_dir():
|
|
raise OSError("destination root unavailable")
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
for source in sorted(asset_dir.iterdir(), key=lambda path: path.name):
|
|
if source.is_file():
|
|
shutil.copy2(source, destination / source.name)
|
|
for name, expected in inventory.items():
|
|
if name == "media.json#canonical":
|
|
continue
|
|
copied = destination / name
|
|
if not copied.is_file() or hash_file(copied) != expected:
|
|
raise OSError(f"backup verification failed: {name}")
|
|
mirror["state"] = "synced"
|
|
mirror["completed_at"] = now
|
|
mirror["verified_at"] = now
|
|
except OSError as exc:
|
|
text = str(exc)
|
|
if str(destination_root) in text or str(destination) in text:
|
|
mirror["error"] = "backup copy or verification failed"
|
|
else:
|
|
mirror["error"] = text[:300]
|
|
raw["mirrors"] = others + [mirror]
|
|
media_path.write_text(
|
|
json.dumps(_manifest_to_wire_for_disk(manifest, raw), indent=2, ensure_ascii=False) + "\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
if mirror["state"] == "synced":
|
|
shutil.copy2(media_path, destination / "media.json")
|
|
if hash_file(media_path) != hash_file(destination / "media.json"):
|
|
mirror["state"] = "error"
|
|
mirror["error"] = "final manifest verification failed"
|
|
media_path.write_text(
|
|
json.dumps(_manifest_to_wire_for_disk(manifest, raw), indent=2, ensure_ascii=False) + "\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
return BackupResult("error", destination, mirror["error"])
|
|
return BackupResult(mirror["state"], destination, mirror["error"])
|
|
|
|
|
|
def _manifest_to_wire_for_disk(manifest: MediaManifest, raw: dict) -> dict:
|
|
"""Merge typed manifest with the on-disk wire shape (incl. mirrors)."""
|
|
typed_wire = _manifest_to_wire(manifest)
|
|
typed_wire["mirrors"] = list(raw.get("mirrors") or [])
|
|
return typed_wire
|