Files
DumiFlex 670bae4388 feat(backups): pin and label backups, and Backups to keep covers manual ones (#194)
Manual backups were never pruned, so the list filled up. Pruning now keeps the newest unpinned backups of every reason, and Back up now prunes too. A pin (a .pin marker file beside the backup) exempts a backup from pruning and from the count; the backup staged for restore is never pruned either. Existing manual backups are pinned once so none vanish on update.

Back up now takes an optional short note, kept in a .note file beside the backup and shown in the list instead of Manual.
2026-10-29 13:50:03 +01:00

485 lines
18 KiB
Python

"""engine/db/backups.py — create / list / prune / delete / daily / pre-migration
/ staged restore."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta, timezone
import pytest
from engine import prefs
from engine.db import backups as bk
from engine.db import config as dbcfg
from engine.db import migrations as mig
from engine.db.connection import get_connection
@pytest.fixture
def sidecar(tmp_path, monkeypatch):
p = tmp_path / "db-config.json"
monkeypatch.setattr(dbcfg, "SIDECAR_PATH", p)
return p
@pytest.fixture
def db(tmp_path, monkeypatch):
"""A live WAL DB with one committed row, pointed at by WP_DB_PATH."""
path = tmp_path / "lib" / "wildcard-pipeline.db"
monkeypatch.setenv("WP_DB_PATH", str(path))
conn = get_connection(path)
conn.execute("CREATE TABLE t (v TEXT)")
with conn:
conn.execute("INSERT INTO t VALUES ('original')")
conn.close()
return path
def _rows(path) -> list[str]:
conn = sqlite3.connect(str(path))
try:
return [r[0] for r in conn.execute("SELECT v FROM t ORDER BY rowid")]
finally:
conn.close()
def _touch_backup(db_path, name: str) -> None:
d = bk.backups_dir(db_path)
d.mkdir(parents=True, exist_ok=True)
(d / name).write_bytes(b"x")
# ── create / list ────────────────────────────────────────────────────
def test_create_backup_is_consistent_under_wal(db):
# Hold a connection open with committed-but-uncheckpointed data in -wal:
# a plain file copy of the main file would miss the second row.
conn = get_connection(db)
with conn:
conn.execute("INSERT INTO t VALUES ('in-wal')")
try:
entry = bk.create_backup(db, "manual")
finally:
conn.close()
assert entry["reason"] == "manual"
assert entry["name"].startswith("wildcard-pipeline-")
assert entry["name"].endswith("-manual.db")
assert entry["created_at"].endswith("Z")
path = bk.backups_dir(db) / entry["name"]
assert entry["size"] == path.stat().st_size > 0
assert _rows(path) == ["original", "in-wal"]
# No temp file left behind.
assert sorted(p.name for p in bk.backups_dir(db).iterdir()) == [
bk.LEGACY_PINS_MARKER, entry["name"]]
def test_create_backup_rejects_unknown_reason(db):
with pytest.raises(ValueError):
bk.create_backup(db, "whenever")
def test_create_backup_missing_db_raises(tmp_path):
with pytest.raises(FileNotFoundError):
bk.create_backup(tmp_path / "nope.db", "manual")
assert not (tmp_path / "nope.db").exists()
def test_same_second_names_get_a_suffix(db):
a = bk.create_backup(db, "manual")
b = bk.create_backup(db, "manual")
c = bk.create_backup(db, "manual")
names = {a["name"], b["name"], c["name"]}
assert len(names) == 3
# Newest first — suffix order breaks timestamp ties.
listed = [e["name"] for e in bk.list_backups(db)]
assert listed[0] == c["name"] and listed[-1] == a["name"]
def test_list_is_newest_first_and_ignores_strangers(db):
_touch_backup(db, "wildcard-pipeline-20260101-000000-daily.db")
_touch_backup(db, "wildcard-pipeline-20260301-000000-manual.db")
_touch_backup(db, "wildcard-pipeline-20260201-120000-pre-migration.db")
_touch_backup(db, "notes.txt")
_touch_backup(db, "wildcard-pipeline-2026-bad-daily.db")
listed = bk.list_backups(db)
assert [e["name"] for e in listed] == [
"wildcard-pipeline-20260301-000000-manual.db",
"wildcard-pipeline-20260201-120000-pre-migration.db",
"wildcard-pipeline-20260101-000000-daily.db",
]
assert listed[1] == {
"name": "wildcard-pipeline-20260201-120000-pre-migration.db",
"size": 1,
"created_at": "2026-02-01T12:00:00Z",
"reason": "pre-migration",
"pinned": False,
"note": None,
}
def test_list_without_dir_is_empty(tmp_path):
assert bk.list_backups(tmp_path / "x.db") == []
# ── prune ────────────────────────────────────────────────────────────
def _pins_settled(db_path) -> None:
"""Backups dir as it is after the one-time legacy pin pass."""
d = bk.backups_dir(db_path)
d.mkdir(parents=True, exist_ok=True)
(d / bk.LEGACY_PINS_MARKER).touch()
def test_prune_keeps_newest_of_every_reason(db):
_pins_settled(db)
for day in range(1, 6):
_touch_backup(db, f"wildcard-pipeline-202601{day:02d}-000000-daily.db")
_touch_backup(db, "wildcard-pipeline-20260106-000000-manual.db")
_touch_backup(db, "wildcard-pipeline-20251201-000000-manual.db")
_touch_backup(db, "wildcard-pipeline-20251202-000000-pre-restore.db")
removed = bk.prune(db, 2)
assert sorted(removed) == [
"wildcard-pipeline-20251201-000000-manual.db",
"wildcard-pipeline-20251202-000000-pre-restore.db",
"wildcard-pipeline-20260101-000000-daily.db",
"wildcard-pipeline-20260102-000000-daily.db",
"wildcard-pipeline-20260103-000000-daily.db",
"wildcard-pipeline-20260104-000000-daily.db",
]
assert [e["name"] for e in bk.list_backups(db)] == [
"wildcard-pipeline-20260106-000000-manual.db",
"wildcard-pipeline-20260105-000000-daily.db",
]
def test_pinned_backups_are_kept_and_not_counted(db):
_pins_settled(db)
for day in range(1, 5):
_touch_backup(db, f"wildcard-pipeline-202601{day:02d}-000000-daily.db")
old = "wildcard-pipeline-20260101-000000-daily.db"
assert bk.set_pinned(db, old, True)["pinned"] is True
removed = bk.prune(db, 2)
assert sorted(removed) == ["wildcard-pipeline-20260102-000000-daily.db"]
assert [(e["name"], e["pinned"]) for e in bk.list_backups(db)] == [
("wildcard-pipeline-20260104-000000-daily.db", False),
("wildcard-pipeline-20260103-000000-daily.db", False),
(old, True),
]
# Unpinning doesn't delete on its own; the next prune does.
assert bk.set_pinned(db, old, False)["pinned"] is False
assert old in [e["name"] for e in bk.list_backups(db)]
assert bk.prune(db, 2) == [old]
def test_set_pinned_unknown_name(db):
with pytest.raises(bk.BackupNotFound):
bk.set_pinned(db, "wildcard-pipeline-20260101-000000-manual.db", True)
with pytest.raises(bk.BackupNotFound):
bk.set_pinned(db, "../wildcard-pipeline.db", True)
def test_legacy_manual_backups_are_pinned_once(db):
_touch_backup(db, "wildcard-pipeline-20251201-000000-manual.db")
_touch_backup(db, "wildcard-pipeline-20251202-000000-manual.db")
_touch_backup(db, "wildcard-pipeline-20251203-000000-daily.db")
assert {e["name"]: e["pinned"] for e in bk.list_backups(db)} == {
"wildcard-pipeline-20251201-000000-manual.db": True,
"wildcard-pipeline-20251202-000000-manual.db": True,
"wildcard-pipeline-20251203-000000-daily.db": False,
}
assert bk.prune(db, 1) == []
# Once done, a later manual backup is an ordinary one.
fresh = bk.create_backup(db, "manual")
assert fresh["pinned"] is False
assert bk.prune(db, 1) == ["wildcard-pipeline-20251203-000000-daily.db"]
def test_first_backup_writes_the_legacy_marker(db):
e = bk.create_backup(db, "manual")
assert e["pinned"] is False
assert (bk.backups_dir(db) / bk.LEGACY_PINS_MARKER).exists()
def test_prune_spares_the_backup_staged_for_restore(db, sidecar):
_pins_settled(db)
for day in range(1, 4):
_touch_backup(db, f"wildcard-pipeline-202601{day:02d}-000000-daily.db")
staged = "wildcard-pipeline-20260101-000000-daily.db"
bk.stage_restore(db, staged)
assert bk.prune(db, 1) == ["wildcard-pipeline-20260102-000000-daily.db"]
assert staged in [e["name"] for e in bk.list_backups(db)]
def test_delete_and_prune_drop_pin_markers(db):
_pins_settled(db)
name = "wildcard-pipeline-20260101-000000-daily.db"
_touch_backup(db, name)
bk.set_pinned(db, name, True)
bk.delete_backup(db, name)
d = bk.backups_dir(db)
assert not (d / (name + bk.PIN_SUFFIX)).exists()
(d / ("wildcard-pipeline-20260102-000000-daily.db" + bk.PIN_SUFFIX)).touch()
bk.prune(db, 5)
assert list(d.glob("*" + bk.PIN_SUFFIX)) == []
def test_manual_backup_note_is_cleaned_and_listed(db):
e = bk.create_backup(db, "manual", note=" before the\noutfit rework ")
assert e["note"] == "before the outfit rework"
assert bk.list_backups(db)[0]["note"] == "before the outfit rework"
long = bk.create_backup(db, "manual", note="x" * 200)
assert long["note"] == "x" * bk.NOTE_MAX_LEN
blank = bk.create_backup(db, "manual", note=" ")
assert blank["note"] is None
assert not (bk.backups_dir(db) / (blank["name"] + bk.NOTE_SUFFIX)).exists()
def test_note_goes_with_its_backup(db):
_pins_settled(db)
e = bk.create_backup(db, "manual", note="keep me")
d = bk.backups_dir(db)
bk.delete_backup(db, e["name"])
assert not (d / (e["name"] + bk.NOTE_SUFFIX)).exists()
(d / ("wildcard-pipeline-20200101-000000-manual.db" + bk.NOTE_SUFFIX)).write_text("orphan")
bk.prune(db, 5)
assert list(d.glob("*" + bk.NOTE_SUFFIX)) == []
# ── delete ───────────────────────────────────────────────────────────
def test_delete_backup(db):
e = bk.create_backup(db, "manual")
bk.delete_backup(db, e["name"])
assert bk.list_backups(db) == []
with pytest.raises(bk.BackupNotFound):
bk.delete_backup(db, e["name"])
@pytest.mark.parametrize("name", [
"../wildcard-pipeline.db",
"../../etc/passwd",
"/etc/passwd",
"wildcard-pipeline.db",
"sub/wildcard-pipeline-20260101-000000-manual.db",
"wildcard-pipeline-20260101-000000-manual.db/../../wildcard-pipeline.db",
"",
])
def test_delete_refuses_traversal_and_non_backups(db, name):
with pytest.raises(bk.BackupNotFound):
bk.delete_backup(db, name)
assert db.exists()
def test_delete_refuses_symlink_escaping_dir(db, tmp_path):
outside = tmp_path / "victim.db"
outside.write_bytes(b"v")
d = bk.backups_dir(db)
d.mkdir(parents=True, exist_ok=True)
link = d / "wildcard-pipeline-20260101-000000-manual.db"
try:
link.symlink_to(outside)
except OSError:
pytest.skip("symlinks unavailable")
with pytest.raises(bk.BackupNotFound):
bk.delete_backup(db, link.name)
assert outside.exists()
# ── daily ────────────────────────────────────────────────────────────
def test_daily_when_none_exist(db):
entry = bk.maybe_daily_backup(db)
assert entry is not None and entry["reason"] == "daily"
def test_daily_skipped_when_recent_backup_of_any_reason(db):
bk.create_backup(db, "manual")
assert bk.maybe_daily_backup(db) is None
def test_daily_runs_when_newest_older_than_24h(db):
bk.create_backup(db, "manual")
later = datetime.now(timezone.utc) + timedelta(hours=25)
assert bk.maybe_daily_backup(db, now=later) is not None
def test_daily_respects_prefs(db):
prefs.update({"backups": {"daily": False}})
assert bk.maybe_daily_backup(db) is None
prefs.update({"backups": {"daily": True, "enabled": False}})
assert bk.maybe_daily_backup(db) is None
def test_daily_skips_missing_db(tmp_path):
assert bk.maybe_daily_backup(tmp_path / "missing.db") is None
def test_daily_prunes(db):
prefs.update({"backups": {"keep": 1}})
_touch_backup(db, "wildcard-pipeline-20200101-000000-daily.db")
entry = bk.maybe_daily_backup(db)
assert [e["name"] for e in bk.list_backups(db)] == [entry["name"]]
def test_daily_defaults_to_resolved_db_path(db):
assert bk.maybe_daily_backup() is not None
assert bk.list_backups(db)
def test_scheduler_runs_check_at_start_and_is_idempotent(db):
try:
assert bk.start_daily_scheduler(period=3600) is True
assert bk.start_daily_scheduler(period=3600) is False
assert [e["reason"] for e in bk.list_backups(db)] == ["daily"]
finally:
bk.stop_daily_scheduler()
# ── pre-migration hook ───────────────────────────────────────────────
def _migrated_partway(path, monkeypatch):
"""A DB that applied migrations up to one below head."""
all_migs = mig._discover()
monkeypatch.setattr(mig, "_discover", lambda: all_migs[:-1])
conn = get_connection(path)
mig.migrate(conn)
monkeypatch.setattr(mig, "_discover", lambda: all_migs)
return conn
def test_pre_migration_backup_taken_before_upgrade(tmp_path, monkeypatch):
path = tmp_path / "up.db"
conn = _migrated_partway(path, monkeypatch)
before = mig.current_version(conn)
assert bk.list_backups(path) == [] # fresh DB: nothing to back up
mig.migrate(conn)
listed = bk.list_backups(path)
assert [e["reason"] for e in listed] == ["pre-migration"]
snap = sqlite3.connect(str(bk.backups_dir(path) / listed[0]["name"]))
try:
assert mig.current_version(snap) == before # taken BEFORE applying
finally:
snap.close()
assert mig.current_version(conn) == mig.head_version()
# Up to date: no further backup.
mig.migrate(conn)
assert len(bk.list_backups(path)) == 1
conn.close()
def test_pre_migration_skipped_when_disabled(tmp_path, monkeypatch):
prefs.update({"backups": {"enabled": False}})
path = tmp_path / "up.db"
conn = _migrated_partway(path, monkeypatch)
mig.migrate(conn)
assert bk.list_backups(path) == []
conn.close()
def test_pre_migration_backup_failure_never_blocks(tmp_path, monkeypatch):
path = tmp_path / "up.db"
conn = _migrated_partway(path, monkeypatch)
def boom(*a, **k):
raise OSError("disk full")
monkeypatch.setattr(bk, "create_backup", boom)
mig.migrate(conn)
assert mig.current_version(conn) == mig.head_version()
conn.close()
def test_in_memory_db_never_backed_up(monkeypatch):
calls = []
monkeypatch.setattr(bk, "create_backup", lambda *a, **k: calls.append(a))
conn = sqlite3.connect(":memory:")
assert bk.backup_before_migration(conn) is None
assert calls == []
# ── staged restore ───────────────────────────────────────────────────
def _write(path, value: str) -> None:
conn = get_connection(path)
with conn:
conn.execute("UPDATE t SET v = ?", (value,))
conn.close()
def test_restore_round_trip(db, sidecar):
dbcfg.save({"preference": "user"})
snap = bk.create_backup(db, "manual")
_write(db, "changed")
assert bk.stage_restore(db, snap["name"]).name == snap["name"]
assert bk.pending_restore_name(db) == snap["name"]
# Leftover WAL sidecars from the live DB must not be replayed onto the restore.
(db.parent / (db.name + "-wal")).write_bytes(b"garbage")
(db.parent / (db.name + "-shm")).write_bytes(b"garbage")
bk.execute_pending_restore()
assert _rows(db) == ["original"]
assert "pending_restore" not in dbcfg.load()
assert dbcfg.load()["preference"] == "user" # other sidecar fields kept
reasons = sorted(e["reason"] for e in bk.list_backups(db))
assert reasons == ["manual", "pre-restore"]
pre = next(e for e in bk.list_backups(db) if e["reason"] == "pre-restore")
assert _rows(bk.backups_dir(db) / pre["name"]) == ["changed"]
def test_stage_restore_unknown_name(db, sidecar):
with pytest.raises(bk.BackupNotFound):
bk.stage_restore(db, "wildcard-pipeline-20260101-000000-manual.db")
with pytest.raises(bk.BackupNotFound):
bk.stage_restore(db, "../wildcard-pipeline.db")
assert "pending_restore" not in dbcfg.load()
def test_cancel_restore(db, sidecar):
snap = bk.create_backup(db, "manual")
bk.stage_restore(db, snap["name"])
bk.cancel_restore()
assert bk.pending_restore_name(db) is None
bk.execute_pending_restore() # nothing staged → no-op
assert [e["reason"] for e in bk.list_backups(db)] == ["manual"]
def test_restore_of_vanished_backup_is_dropped(db, sidecar):
snap = bk.create_backup(db, "manual")
bk.stage_restore(db, snap["name"])
bk.delete_backup(db, snap["name"])
bk.execute_pending_restore()
assert "pending_restore" not in dbcfg.load()
assert _rows(db) == ["original"]
def test_restore_ignores_sidecar_pointing_outside_backups(db, sidecar, tmp_path):
other = tmp_path / "wildcard-pipeline-20260101-000000-manual.db"
other.write_bytes(b"not a db")
dbcfg.set_pending_restore(other)
bk.execute_pending_restore()
assert _rows(db) == ["original"]
assert "pending_restore" not in dbcfg.load()
def test_restore_never_raises(db, sidecar, monkeypatch):
snap = bk.create_backup(db, "manual")
bk.stage_restore(db, snap["name"])
monkeypatch.setattr(bk, "resolve_db_path", lambda: (_ for _ in ()).throw(RuntimeError("x")))
bk.execute_pending_restore() # logged, not raised
def test_sidecar_round_trips_pending_restore(sidecar, tmp_path):
dbcfg.save({"pending_move": {"from": "/a", "to": "/b", "mode": "copy"},
"pending_restore": {"from": str(tmp_path / "x.db")}})
cfg = dbcfg.load()
assert cfg["pending_restore"] == {"from": str(tmp_path / "x.db")}
dbcfg.clear_pending_move()
assert "pending_restore" in dbcfg.load()
dbcfg.clear_pending_restore()
assert dbcfg.load() == {}
def test_sidecar_drops_malformed_pending_restore(sidecar):
sidecar.write_text('{"pending_restore": {"from": 3}}', encoding="utf-8")
assert dbcfg.load() == {}