2026-05-19 05:18:01 +02:00
|
|
|
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
|
2026-05-19 11:19:22 +02:00
|
|
|
from pobsync_backend.snapshot_discovery import (
|
|
|
|
|
discover_snapshots,
|
2026-05-19 13:21:31 +02:00
|
|
|
inspect_snapshot_discovery,
|
2026-05-19 11:19:22 +02:00
|
|
|
parse_snapshot_datetime,
|
|
|
|
|
resolve_base_links,
|
|
|
|
|
upsert_snapshot_record,
|
|
|
|
|
)
|
2026-05-19 05:18:01 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
2026-05-19 13:21:31 +02:00
|
|
|
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})
|
|
|
|
|
|
2026-05-19 11:19:22 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-05-19 05:18:01 +02:00
|
|
|
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")
|