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.
179 lines
7.8 KiB
Python
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")
|