feat(api): /db/snapshot, /db/waveforms/recent.zip, gated /db/restore
This commit is contained in:
@@ -72,6 +72,7 @@ from sfm.cache import SFMCache, get_cache
|
|||||||
from sfm.database import SeismoDb
|
from sfm.database import SeismoDb
|
||||||
from sfm.live_cache import LiveCache as _LiveCache
|
from sfm.live_cache import LiveCache as _LiveCache
|
||||||
from sfm.waveform_store import WaveformStore
|
from sfm.waveform_store import WaveformStore
|
||||||
|
from sfm import db_snapshot
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -2808,6 +2809,70 @@ def db_unit_waveforms_zip(
|
|||||||
return StreamingResponse(buf, media_type="application/zip", headers=headers)
|
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")
|
@app.get("/db/monitor_log")
|
||||||
def db_monitor_log(
|
def db_monitor_log(
|
||||||
serial: Optional[str] = Query(None, description="Filter by unit serial"),
|
serial: Optional[str] = Query(None, description="Filter by unit serial"),
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user