From b56eb8c6927ebb7812f4daf672adccbd706cc997 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 2 Jul 2026 06:53:24 +0000 Subject: [PATCH] feat(api): /db/snapshot, /db/waveforms/recent.zip, gated /db/restore --- sfm/server.py | 65 +++++++++++++++++++++++++++++ tests/test_db_snapshot_endpoints.py | 53 +++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/test_db_snapshot_endpoints.py diff --git a/sfm/server.py b/sfm/server.py index ed42775..e4632de 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -72,6 +72,7 @@ from sfm.cache import SFMCache, get_cache from sfm.database import SeismoDb from sfm.live_cache import LiveCache as _LiveCache from sfm.waveform_store import WaveformStore +from sfm import db_snapshot logging.basicConfig( level=logging.INFO, @@ -2808,6 +2809,70 @@ def db_unit_waveforms_zip( return StreamingResponse(buf, media_type="application/zip", headers=headers) +# ── Full-snapshot bundle endpoints (see sfm/db_snapshot.py) ─────────────────── +# SFM_DB_RESTORE_ENABLED gates /db/restore (dormant → 404). Set true on dev only. +@app.get("/db/snapshot") +def db_snapshot_download() -> FileResponse: + """WAL-safe copy of seismo_relay.db, for the terra-view full-snapshot bundle.""" + import os + import tempfile + from starlette.background import BackgroundTask + fd, tmp = tempfile.mkstemp(suffix=".db") + os.close(fd) + db_snapshot.snapshot_db(_get_db().db_path, Path(tmp)) + return FileResponse( + tmp, media_type="application/x-sqlite3", filename="seismo_relay.db", + background=BackgroundTask(os.unlink, tmp), + ) + + +@app.get("/db/waveforms/recent.zip") +def db_waveforms_recent_zip(n: int = Query(200, ge=1, le=2000)) -> FileResponse: + """Zip of the n most-recent events' waveform + sidecar files.""" + import os + import tempfile + from starlette.background import BackgroundTask + fd, tmp = tempfile.mkstemp(suffix=".zip") + os.close(fd) + stats = db_snapshot.build_recent_waveforms_zip(_get_db(), _get_store(), n, Path(tmp)) + return FileResponse( + tmp, media_type="application/zip", filename="waveforms.zip", + headers={"X-Event-Count": str(stats["events"]), "X-File-Count": str(stats["files"])}, + background=BackgroundTask(os.unlink, tmp), + ) + + +@app.post("/db/restore") +async def db_restore( + db: UploadFile = File(...), + waveforms: Optional[UploadFile] = File(None), +) -> dict: + """Destructive: swap seismo_relay.db + unpack waveforms. Dormant (404) unless + SFM_DB_RESTORE_ENABLED is set.""" + import os + import shutil as _shutil + import tempfile + if not db_snapshot.sfm_db_restore_enabled(): + raise HTTPException(status_code=404, detail="Not found") + + def _save(upload) -> Path: + fd, tmp = tempfile.mkstemp() + with os.fdopen(fd, "wb") as out: + _shutil.copyfileobj(upload.file, out) + return Path(tmp) + + db_tmp = _save(db) + wf_tmp = _save(waveforms) if waveforms is not None else None + try: + return db_snapshot.restore_db_and_waveforms( + _get_db().db_path, _get_store().root, db_tmp, wf_tmp, + ) + finally: + db_tmp.unlink(missing_ok=True) + if wf_tmp is not None: + wf_tmp.unlink(missing_ok=True) + + @app.get("/db/monitor_log") def db_monitor_log( serial: Optional[str] = Query(None, description="Filter by unit serial"), diff --git a/tests/test_db_snapshot_endpoints.py b/tests/test_db_snapshot_endpoints.py new file mode 100644 index 0000000..e5dd44b --- /dev/null +++ b/tests/test_db_snapshot_endpoints.py @@ -0,0 +1,53 @@ +""" +test_db_snapshot_endpoints.py — HTTP smoke tests for the /db/snapshot, +/db/waveforms/recent.zip and /db/restore routes. Skipped if the FastAPI +TestClient stack (httpx) is not installed. + +Run: + python -m pytest tests/test_db_snapshot_endpoints.py -v +""" +from __future__ import annotations +import os +import sys +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import pytest + +pytest.importorskip("httpx") # starlette TestClient needs httpx + + +def _client(tmp_path, monkeypatch): + import sfm.server as srv + from sfm.database import SeismoDb + from sfm.waveform_store import WaveformStore + from starlette.testclient import TestClient + db = SeismoDb(tmp_path / "seismo_relay.db") + store = WaveformStore(tmp_path / "waveforms") + monkeypatch.setattr(srv, "_db", db, raising=False) + monkeypatch.setattr(srv, "_store", store, raising=False) + return TestClient(srv.app) + + +def test_snapshot_endpoint_returns_sqlite(tmp_path, monkeypatch): + client = _client(tmp_path, monkeypatch) + r = client.get("/db/snapshot") + assert r.status_code == 200 + assert r.content[:16] == b"SQLite format 3\x00" + + +def test_recent_waveforms_endpoint_ok(tmp_path, monkeypatch): + client = _client(tmp_path, monkeypatch) + r = client.get("/db/waveforms/recent.zip", params={"n": 5}) + assert r.status_code == 200 + assert r.headers["content-type"] == "application/zip" + + +def test_restore_dormant_without_env(tmp_path, monkeypatch): + monkeypatch.delenv("SFM_DB_RESTORE_ENABLED", raising=False) + client = _client(tmp_path, monkeypatch) + r = client.post( + "/db/restore", + files={"db": ("x.db", b"SQLite format 3\x00", "application/octet-stream")}, + ) + assert r.status_code == 404