diff --git a/README.md b/README.md index 97610ea..348b3bf 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ SQLite remains the default because it is enough for a single backup server and k ## Current Architecture The public command surface is Django-first. The old YAML/cron CLI has been retired from the `pobsync` entrypoint. +Discovered snapshots are stored in `SnapshotRecord`, including the base snapshot metadata and a nullable SQL link to the +base record when it is known. The remaining internal engine code still contains reusable backup primitives: @@ -148,7 +150,6 @@ The remaining internal engine code still contains reusable backup primitives: Next refactor targets: -- Record discovered snapshots into `SnapshotRecord`. - Surface `SnapshotRecord` data through API/admin views instead of filesystem inspection. - Move more snapshot lifecycle details into typed domain objects. - Replace remaining dictionary-shaped config at engine boundaries. diff --git a/src/pobsync_backend/admin.py b/src/pobsync_backend/admin.py index e4cb08a..3ad39f7 100644 --- a/src/pobsync_backend/admin.py +++ b/src/pobsync_backend/admin.py @@ -58,9 +58,17 @@ class BackupRunAdmin(admin.ModelAdmin): @admin.register(SnapshotRecord) class SnapshotRecordAdmin(admin.ModelAdmin): - list_display = ("host", "kind", "dirname", "status", "started_at", "discovered_at") - list_filter = ("kind", "status", "started_at", "discovered_at") - search_fields = ("host__host", "dirname", "path") + list_display = ("host", "kind", "dirname", "status", "base", "started_at", "discovered_at") + list_filter = ("kind", "status", "base_kind", "started_at", "discovered_at") + search_fields = ( + "host__host", + "dirname", + "path", + "base__dirname", + "base_path", + "base_snapshot_id", + ) + autocomplete_fields = ("base",) readonly_fields = ("discovered_at",) diff --git a/src/pobsync_backend/migrations/0005_snapshotrecord_base.py b/src/pobsync_backend/migrations/0005_snapshotrecord_base.py new file mode 100644 index 0000000..2284968 --- /dev/null +++ b/src/pobsync_backend/migrations/0005_snapshotrecord_base.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pobsync_backend", "0004_backuprun_snapshot"), + ] + + operations = [ + migrations.AddField( + model_name="snapshotrecord", + name="base", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="derived_snapshots", + to="pobsync_backend.snapshotrecord", + ), + ), + migrations.AddField( + model_name="snapshotrecord", + name="base_dirname", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="snapshotrecord", + name="base_snapshot_id", + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name="snapshotrecord", + name="base_kind", + field=models.CharField(blank=True, max_length=16), + ), + migrations.AddField( + model_name="snapshotrecord", + name="base_path", + field=models.CharField(blank=True, max_length=1024), + ), + ] diff --git a/src/pobsync_backend/models.py b/src/pobsync_backend/models.py index 4e4a70d..294a476 100644 --- a/src/pobsync_backend/models.py +++ b/src/pobsync_backend/models.py @@ -111,6 +111,17 @@ class SnapshotRecord(models.Model): kind = models.CharField(max_length=16, choices=Kind.choices) dirname = models.CharField(max_length=255) path = models.CharField(max_length=1024) + base = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="derived_snapshots", + ) + base_kind = models.CharField(max_length=16, blank=True) + base_dirname = models.CharField(max_length=255, blank=True) + base_path = models.CharField(max_length=1024, blank=True) + base_snapshot_id = models.CharField(max_length=64, blank=True) status = models.CharField(max_length=32, blank=True) started_at = models.DateTimeField(null=True, blank=True) ended_at = models.DateTimeField(null=True, blank=True) diff --git a/src/pobsync_backend/snapshot_discovery.py b/src/pobsync_backend/snapshot_discovery.py index a602ae1..a8a6f18 100644 --- a/src/pobsync_backend/snapshot_discovery.py +++ b/src/pobsync_backend/snapshot_discovery.py @@ -55,6 +55,7 @@ def discover_snapshots( created += 1 else: updated += 1 + resolve_base_links(host=host_config) return { "ok": True, @@ -66,8 +67,15 @@ def discover_snapshots( def upsert_snapshot_record(*, host: HostConfig, kind: str, snapshot_dir: Path) -> tuple[SnapshotRecord, bool]: meta = read_snapshot_meta(snapshot_dir) + base_defaults = _base_defaults_from_meta(meta) defaults = { "path": str(snapshot_dir), + **base_defaults, + "base": _resolve_base_record( + host=host, + kind=base_defaults["base_kind"], + dirname=base_defaults["base_dirname"], + ), "status": str(meta.get("status") or ""), "started_at": parse_snapshot_datetime(snapshot_dir.name, meta, "started_at"), "ended_at": parse_snapshot_datetime(snapshot_dir.name, meta, "ended_at"), @@ -81,6 +89,26 @@ def upsert_snapshot_record(*, host: HostConfig, kind: str, snapshot_dir: Path) - ) +def resolve_base_links(*, host: HostConfig | None = None) -> int: + snapshot_qs = SnapshotRecord.objects.exclude(base_dirname="").filter(base__isnull=True) + if host is not None: + snapshot_qs = snapshot_qs.filter(host=host) + + updated = 0 + for snapshot in snapshot_qs.select_related("host"): + base = _resolve_base_record( + host=snapshot.host, + kind=snapshot.base_kind, + dirname=snapshot.base_dirname, + ) + if base is None: + continue + snapshot.base = base + snapshot.save(update_fields=["base"]) + updated += 1 + return updated + + def infer_snapshot_kind(snapshot_path: Path) -> str: parent = snapshot_path.parent.name if parent == "scheduled": @@ -92,6 +120,29 @@ def infer_snapshot_kind(snapshot_path: Path) -> str: raise ValueError(f"Cannot infer snapshot kind from path: {snapshot_path}") +def _base_defaults_from_meta(meta: dict[str, Any]) -> dict[str, Any]: + base = meta.get("base") + if not isinstance(base, dict): + base = {} + + return { + "base_kind": _base_value(base.get("kind")), + "base_dirname": _base_value(base.get("dirname")), + "base_path": _base_value(base.get("path")), + "base_snapshot_id": _base_value(base.get("id")), + } + + +def _base_value(value: Any) -> str: + return value if isinstance(value, str) else "" + + +def _resolve_base_record(*, host: HostConfig, kind: str, dirname: str) -> SnapshotRecord | None: + if not kind or not dirname: + return None + return SnapshotRecord.objects.filter(host=host, kind=kind, dirname=dirname).first() + + def _parse_iso_z(value: str) -> datetime | None: try: if value.endswith("Z"): diff --git a/src/pobsync_backend/tests/test_snapshot_discovery.py b/src/pobsync_backend/tests/test_snapshot_discovery.py index 895139b..3da7e66 100644 --- a/src/pobsync_backend/tests/test_snapshot_discovery.py +++ b/src/pobsync_backend/tests/test_snapshot_discovery.py @@ -10,7 +10,12 @@ from django.test import TestCase from pobsync.util import write_yaml_atomic from pobsync_backend.models import GlobalConfig, HostConfig, SnapshotRecord -from pobsync_backend.snapshot_discovery import discover_snapshots, parse_snapshot_datetime +from pobsync_backend.snapshot_discovery import ( + discover_snapshots, + parse_snapshot_datetime, + resolve_base_links, + upsert_snapshot_record, +) class SnapshotDiscoveryTests(TestCase): @@ -58,6 +63,82 @@ class SnapshotDiscoveryTests(TestCase): self.assertEqual(record.kind, "scheduled") self.assertEqual(record.started_at, datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc)) + def test_discovery_links_snapshot_to_base_record(self) -> None: + with TemporaryDirectory() as tmp: + backup_root = Path(tmp) / "backups" + GlobalConfig.objects.create(name="default", backup_root=str(backup_root)) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + base_dir = backup_root / host.host / "scheduled" / "20260518-021500Z__BASESNAP" + child_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__CHILDSNP" + (base_dir / "meta").mkdir(parents=True) + (child_dir / "meta").mkdir(parents=True) + write_yaml_atomic( + base_dir / "meta" / "meta.yaml", + { + "id": "base-id", + "status": "success", + "started_at": "2026-05-18T02:15:00Z", + "base": None, + }, + ) + write_yaml_atomic( + child_dir / "meta" / "meta.yaml", + { + "id": "child-id", + "status": "success", + "started_at": "2026-05-19T02:15:00Z", + "base": { + "kind": "scheduled", + "dirname": base_dir.name, + "id": "base-id", + "path": str(base_dir / "data"), + }, + }, + ) + + result = discover_snapshots(host=host) + + self.assertEqual(result["created"], 2) + child = SnapshotRecord.objects.get(dirname=child_dir.name) + base = SnapshotRecord.objects.get(dirname=base_dir.name) + self.assertEqual(child.base, base) + self.assertEqual(child.base_kind, "scheduled") + self.assertEqual(child.base_dirname, base_dir.name) + self.assertEqual(child.base_snapshot_id, "base-id") + self.assertEqual(child.base_path, str(base_dir / "data")) + + def test_base_link_can_be_resolved_after_base_record_exists(self) -> None: + with TemporaryDirectory() as tmp: + backup_root = Path(tmp) / "backups" + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + base_dir = backup_root / host.host / "scheduled" / "20260518-021500Z__BASESNAP" + child_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__CHILDSNP" + (base_dir / "meta").mkdir(parents=True) + (child_dir / "meta").mkdir(parents=True) + write_yaml_atomic(base_dir / "meta" / "meta.yaml", {"status": "success"}) + write_yaml_atomic( + child_dir / "meta" / "meta.yaml", + { + "status": "success", + "base": { + "kind": "scheduled", + "dirname": base_dir.name, + "id": "base-id", + "path": str(base_dir / "data"), + }, + }, + ) + + child, _created = upsert_snapshot_record(host=host, kind="scheduled", snapshot_dir=child_dir) + upsert_snapshot_record(host=host, kind="scheduled", snapshot_dir=base_dir) + linked = resolve_base_links(host=host) + + child.refresh_from_db() + self.assertEqual(linked, 1) + self.assertIsNotNone(child.base) + self.assertEqual(child.base.dirname, base_dir.name) + self.assertEqual(child.base_dirname, base_dir.name) + def test_command_discovers_snapshots_for_host(self) -> None: with TemporaryDirectory() as tmp: backup_root = Path(tmp) / "backups"