Files
pobsync/src/pobsync_backend/tests/test_snapshot_discovery.py
Peter van Arkel 1d90454109 (feature) improve snapshot discovery visibility in Django
Add a discovery preflight that reports the configured backup root, host
root, and snapshot directory counts before importing anything.

Show discovery status on host detail pages so missing mounts or mismatched
host directories are visible from the UI.

Warn clearly when discovery scans zero snapshots, including whether the
host backup directory is missing or simply empty.
2026-05-19 13:21:31 +02:00

179 lines
7.8 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from io import StringIO
from pathlib import Path
from tempfile import TemporaryDirectory
from django.core.management import call_command
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,
inspect_snapshot_discovery,
parse_snapshot_datetime,
resolve_base_links,
upsert_snapshot_record,
)
class SnapshotDiscoveryTests(TestCase):
def test_parse_snapshot_datetime_prefers_metadata(self) -> None:
parsed = parse_snapshot_datetime(
"20260519-021500Z__ABCDEFGH",
{"started_at": "2026-05-20T03:16:00Z"},
"started_at",
)
self.assertEqual(parsed, datetime(2026, 5, 20, 3, 16, tzinfo=timezone.utc))
def test_parse_snapshot_datetime_falls_back_to_dirname(self) -> None:
parsed = parse_snapshot_datetime("20260519-021500Z__ABCDEFGH", {}, "started_at")
self.assertEqual(parsed, datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc))
def test_discovery_upserts_snapshot_records_idempotently(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")
snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH"
meta_dir = snapshot_dir / "meta"
meta_dir.mkdir(parents=True)
write_yaml_atomic(
meta_dir / "meta.yaml",
{
"status": "success",
"started_at": "2026-05-19T02:15:00Z",
"ended_at": "2026-05-19T02:16:00Z",
},
)
first = discover_snapshots(host=host)
second = discover_snapshots(host=host)
self.assertEqual(first["created"], 1)
self.assertEqual(first["updated"], 0)
self.assertEqual(second["created"], 0)
self.assertEqual(second["updated"], 1)
self.assertEqual(SnapshotRecord.objects.count(), 1)
record = SnapshotRecord.objects.get()
self.assertEqual(record.status, "success")
self.assertEqual(record.kind, "scheduled")
self.assertEqual(record.started_at, datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc))
def test_inspect_snapshot_discovery_reports_missing_global_config(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
result = inspect_snapshot_discovery(host=host)
self.assertFalse(result["ok"])
self.assertEqual(result["reason"], "missing_global_config")
self.assertEqual(result["total_candidates"], 0)
def test_inspect_snapshot_discovery_counts_snapshot_directories(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")
(backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH").mkdir(parents=True)
(backup_root / host.host / "manual" / "20260519-031500Z__MANUAL01").mkdir(parents=True)
result = inspect_snapshot_discovery(host=host)
self.assertTrue(result["ok"])
self.assertEqual(result["reason"], "ready")
self.assertEqual(result["total_candidates"], 2)
self.assertEqual(result["kind_counts"], {"scheduled": 1, "manual": 1, "incomplete": 0})
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"
GlobalConfig.objects.create(name="default", backup_root=str(backup_root))
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
snapshot_dir = backup_root / host.host / ".incomplete" / "20260519-021500Z__ABCDEFGH"
(snapshot_dir / "meta").mkdir(parents=True)
call_command("discover_pobsync_snapshots", host=host.host, kind="incomplete", stdout=StringIO())
self.assertEqual(SnapshotRecord.objects.count(), 1)
self.assertEqual(SnapshotRecord.objects.get().kind, "incomplete")