Files
versioning/tests/test_recovery.py
T

199 lines
8.3 KiB
Python
Raw Normal View History

"""Disaster recovery, retention, GC, adopt, metrics, verified restores."""
import json
import sqlite3
import time
from datetime import datetime
import httpx
import pytest
from fastapi.testclient import TestClient
from versiond import dbsafe
from versiond.api import create_app
from versiond.config import Config, Paths
from versiond.store import BlobStore
from test_service import CONFIG, history, wait_for
from test_purge import FakeDAV, REMOTE
@pytest.fixture
def rec_env(tmp_path, monkeypatch):
monkeypatch.setenv("VERSIOND_HOME", str(tmp_path / "home"))
paths = Paths.resolve()
paths.ensure()
paths.config_file.write_text(CONFIG + "\n[upload]\nmanifest_interval_seconds = 0.2\n")
cfg = Config.load(paths)
dav = FakeDAV()
project = tmp_path / "proj"
project.mkdir()
(project / "main.py").write_text("print('hello')\n")
conn = sqlite3.connect(project / "app.db")
conn.execute("CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)")
conn.execute("INSERT INTO t(v) VALUES ('one')")
conn.commit()
conn.close()
app = create_app(cfg, remote_transport=httpx.MockTransport(dav.handler))
with TestClient(app, base_url="http://127.0.0.1:9922") as client:
client.headers["Authorization"] = f"Bearer {cfg.api_token()}"
app.state.services.uploader.offline_sleep = 0.3
yield client, dav, project, cfg
def _configure_and_fill(client, project, files=2):
r = client.put("/api/v1/config/remote", json=REMOTE)
assert r.status_code == 200, r.text
directory = r.json()["directory"]
client.post("/api/v1/roots", json={"path": str(project)})
wait_for(lambda: (lambda p: p["upload"]["versions_local"] == 0
and p["upload"]["versions_durable"] == files)(
client.get("/api/v1/progress").json()), timeout=15)
return directory
def _db_index(cfg):
return sqlite3.connect(cfg.paths.index_file, timeout=5.0)
def test_reindex_rebuilds_lost_index(rec_env):
client, dav, project, cfg = rec_env
directory = _configure_and_fill(client, project)
before = {v["id"]: v for v in history(client, project / "main.py")}
assert before
# Simulate total local loss of metadata (spool stays, like a surviving disk).
conn = _db_index(cfg)
conn.execute("DELETE FROM versions")
conn.execute("DELETE FROM files")
conn.execute("DELETE FROM blobs")
conn.commit()
conn.close()
assert history(client, project / "main.py") == []
r = client.post("/api/v1/admin/reindex")
assert r.status_code == 202, r.text
task_id = r.json()["task_id"]
status = wait_for(lambda: (lambda s: s if s["status"] == "done" else None)(
client.get(f"/api/v1/admin/reindex/{task_id}").json()), timeout=15)
assert status["versions"] >= 2 and status["files"] == 2
after = history(client, project / "main.py")
assert len(after) == len(before)
assert after[0]["durability"] == "durable"
assert client.get(f"/api/v1/versions/{after[0]['id']}/content").text == "print('hello')\n"
# Restore works end to end from the rebuilt index.
(project / "main.py").write_text("garbage\n")
vid = after[0]["id"]
out = str(project / "restored.py")
assert client.post(f"/api/v1/versions/{vid}/restore", json={"target_path": out}).status_code == 200
assert open(out).read() == "print('hello')\n"
def test_reindex_fetches_blobs_from_remote(rec_env):
"""True disaster: index AND spool gone; content streams back on demand."""
client, dav, project, cfg = rec_env
_configure_and_fill(client, project)
conn = _db_index(cfg)
conn.execute("DELETE FROM versions")
conn.execute("DELETE FROM files")
conn.execute("DELETE FROM blobs")
conn.commit()
conn.close()
for p in (cfg.paths.spool_dir).rglob("*"):
if p.is_file():
p.unlink()
task_id = client.post("/api/v1/admin/reindex").json()["task_id"]
wait_for(lambda: (lambda s: s if s["status"] == "done" else None)(
client.get(f"/api/v1/admin/reindex/{task_id}").json()), timeout=15)
after = history(client, project / "main.py")
assert after
assert client.get(f"/api/v1/versions/{after[0]['id']}/content").text == "print('hello')\n"
def test_adopt_takes_over_directory(rec_env):
client, dav, project, cfg = rec_env
directory = _configure_and_fill(client, project)
# A fresh machine against the same server adopts the existing directory.
import tempfile
from pathlib import Path
home2 = Path(tempfile.mkdtemp())
(home2 / "config").mkdir()
(home2 / "config" / "config.toml").write_text(CONFIG)
cfg2 = Config.load(Paths(config_dir=home2 / "config", data_dir=home2 / "data", cache_dir=home2 / "cache"))
app2 = create_app(cfg2, remote_transport=httpx.MockTransport(dav.handler))
with TestClient(app2, base_url="http://127.0.0.1:9922") as c2:
c2.headers["Authorization"] = f"Bearer {cfg2.api_token()}"
r = c2.post("/api/v1/config/remote/adopt",
json={**REMOTE, "password": "secret", "directory": directory})
assert r.status_code == 200, r.text
assert r.json()["directory"] == directory and r.json()["adopted"] is True
def test_retention_thins_old_keeps_five_days(rec_env):
client, dav, project, cfg = rec_env
_configure_and_fill(client, project)
for i in range(4):
(project / "main.py").write_text(f"v{i}\n")
time.sleep(0.5) # outside the 0.2s coalescing window: each is a version
wait_for(lambda: len(history(client, project / "main.py")) == 5, timeout=10)
now = time.time()
conn = _db_index(cfg)
# Age 3 versions 10 days; pin the oldest; keep the newest fresh.
vids = [v["id"] for v in reversed(history(client, project / "main.py"))]
for vid in vids[:3]:
conn.execute("UPDATE versions SET captured_at = ? WHERE id = ?", (now - 10 * 86400, vid))
conn.execute("UPDATE versions SET pinned = 1 WHERE id = ?", (vids[0],))
conn.commit()
conn.close()
dry = client.post("/api/v1/admin/retention/run", json={"dry_run": True}).json()
assert dry["dry_run"] and dry["versions_to_delete"] == 1 # 3 old minus pinned minus daily-kept
done = client.post("/api/v1/admin/retention/run", json={"dry_run": False}).json()
assert done["versions_deleted"] == 1
assert done["index_reclaimed"]["freelist_after"] == 0 # hard delete, not freelist bloat
remaining = history(client, project / "main.py")
assert len(remaining) == 4 # latest + pinned + one-per-day + fresh middle
assert remaining[0]["pinned"] == 0 # newest still the newest
assert any(v["pinned"] == 1 for v in remaining)
def test_gc_reclaims_orphans_local_and_remote(rec_env):
client, dav, project, cfg = rec_env
directory = _configure_and_fill(client, project)
(project / "main.py").write_text("v2\n")
wait_for(lambda: len(history(client, project / "main.py")) == 2, timeout=10)
conn = _db_index(cfg)
conn.execute("DELETE FROM versions WHERE id = (SELECT max(id) FROM versions)")
conn.commit()
conn.close()
dry = client.post("/api/v1/admin/gc", json={"dry_run": True}).json()
assert dry["blobs"] >= 1
done = client.post("/api/v1/admin/gc", json={"dry_run": False}).json()
assert done["blobs_removed"] >= 1 and done["errors"] == []
assert [p for p in dav.files if p.startswith(directory + "/blobs/")] != [] # referenced stay
# Remaining history still restorable.
assert client.get(f"/api/v1/versions/{history(client, project / 'main.py')[0]['id']}/content").status_code == 200
def test_metrics_and_db_restore_guard(rec_env):
client, dav, project, cfg = rec_env
_configure_and_fill(client, project)
m = client.get("/api/v1/metrics")
assert m.status_code == 200 and "versiond_files_tracked" in m.text
db_hist = history(client, project / "app.db")
assert db_hist
vid = db_hist[0]["id"]
row = _db_index(cfg).execute(
"SELECT blob_sha256 FROM versions WHERE id = ?", (vid,)).fetchone()
blob_path = None
for cand in BlobStore(cfg.paths.spool_dir)._candidates(row[0]):
if cand.exists():
blob_path = cand
assert blob_path is not None
blob_path.write_bytes(dbsafe.SQLITE_MAGIC + b"\x00" * 200) # corrupt the stored bytes
r = client.post(f"/api/v1/versions/{vid}/restore",
json={"target_path": str(project / "evil.db")})
assert r.status_code == 422 and "unrestorable" in r.text