- No client-side encryption: plaintext content-addressed remote (SHA-256 names), no backup key, no cryptography dependency (server disk encryption is the trust model). - Filters back user data: images, archives, databases, PDFs accepted; 10 MiB cap; binary sniffing removed; temp names hardened (~$, #..#, .temp). - SQLite zero-error policy: backup-API snapshots + integrity_check, journal folding, locked/corrupt loud skips, verified restores. - Recovery: reindex from manifests, remote adopt, on-demand blob fetch, 5-day retention + thinning, GC, date-guarded remote purge, metrics. - Scheduler with WebDAV quota signal and 70% pressure backstop (floor kept). - 37 tests incl. live-monitor capture safety and DB safety.
104 lines
4.2 KiB
Python
104 lines
4.2 KiB
Python
"""Scheduler, quota signal and capacity backstop."""
|
|
|
|
import asyncio
|
|
import time
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from versiond import maintenance
|
|
from versiond.api import create_app
|
|
from versiond.config import Config, Paths
|
|
from versiond.remote import quota as remote_quota
|
|
|
|
from test_service import CONFIG, history, wait_for
|
|
from test_purge import FakeDAV, REMOTE
|
|
|
|
|
|
@pytest.fixture
|
|
def maint_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")
|
|
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, app.state.services
|
|
|
|
|
|
def test_quota_signal_and_fallback(maint_env):
|
|
client, dav, project, cfg, svc = maint_env
|
|
directory = client.put("/api/v1/config/remote", json=REMOTE).json()["directory"]
|
|
|
|
async def get_quota():
|
|
from versiond.remote import WebDAV
|
|
settings = svc.uploader.settings()
|
|
dav_client = WebDAV(settings, transport=svc.uploader.transport)
|
|
try:
|
|
return await remote_quota(dav_client, directory)
|
|
finally:
|
|
await dav_client.close()
|
|
|
|
used, available = asyncio.run(get_quota())
|
|
assert used is not None and used >= 0 and available == dav.capacity_bytes - used
|
|
|
|
async def refresh():
|
|
return await maintenance.refresh_usage(svc)
|
|
|
|
usage = asyncio.run(refresh())
|
|
assert usage["source"] == "webdav-quota"
|
|
assert usage["percent"] == round(100 * used / dav.capacity_bytes, 1)
|
|
assert maintenance.over_limit(svc) is False
|
|
assert client.get("/health").json()["remote_pressure"] is False
|
|
|
|
|
|
def test_pressure_triggers_pass_but_floor_holds(maint_env):
|
|
client, dav, project, cfg, svc = maint_env
|
|
directory = client.put("/api/v1/config/remote", json=REMOTE).json()["directory"]
|
|
client.post("/api/v1/roots", json={"path": str(project)})
|
|
wait_for(lambda: history(client, project / "main.py"), timeout=10)
|
|
# Tiny remote: everything ever uploaded already exceeds 70%.
|
|
dav.capacity_bytes = 1
|
|
|
|
result = asyncio.run(maintenance.run_scheduled_pass(svc, "test-pressure"))
|
|
assert result["pressure"] is True
|
|
assert result["versions_deleted"] == 0 # all young: the 5-day floor holds
|
|
assert len(history(client, project / "main.py")) == 1
|
|
assert client.get("/health").json()["remote_pressure"] is True
|
|
assert "remote-over-capacity-limit" in client.get("/health").json()["degraded_roots"]
|
|
upload = client.get("/api/v1/progress").json()["upload"]
|
|
assert upload["usage"]["source"] == "webdav-quota"
|
|
assert upload["usage"]["percent"] >= 70
|
|
|
|
|
|
def test_scheduled_pass_cleans_old_and_reports(maint_env):
|
|
import sqlite3
|
|
client, dav, project, cfg, svc = maint_env
|
|
client.put("/api/v1/config/remote", json=REMOTE)
|
|
client.post("/api/v1/roots", json={"path": str(project)})
|
|
wait_for(lambda: history(client, project / "main.py"), timeout=10)
|
|
(project / "main.py").write_text("v2\n")
|
|
wait_for(lambda: len(history(client, project / "main.py")) == 2, timeout=10)
|
|
import time as _t
|
|
_t.sleep(0.5)
|
|
(project / "main.py").write_text("v3\n")
|
|
wait_for(lambda: len(history(client, project / "main.py")) == 3, timeout=10)
|
|
old = time.time() - 10 * 86400
|
|
conn = sqlite3.connect(cfg.paths.index_file, timeout=5.0)
|
|
vids = [v["id"] for v in reversed(history(client, project / "main.py"))]
|
|
for vid in vids[:2]: # two oldest share one old day: keep daily + latest
|
|
conn.execute("UPDATE versions SET captured_at = ? WHERE id = ?", (old, vid))
|
|
conn.commit()
|
|
conn.close()
|
|
result = asyncio.run(maintenance.run_scheduled_pass(svc, "test-scheduled"))
|
|
assert result["versions_deleted"] == 1
|
|
assert len(history(client, project / "main.py")) == 2
|