2026-10-10 03:41:36 +02:00
|
|
|
"""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
|
2026-10-10 04:03:51 +02:00
|
|
|
assert done["index_reclaimed"]["freelist_after"] == 0 # hard delete, not freelist bloat
|
2026-10-10 03:41:36 +02:00
|
|
|
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
|