Files
safishamsiandClaude Opus 4.8 77391fde49 cache_server: hosted shared-cache server + CLI, docs, reference deploy
Builds the server side of reagraph.remote_cache: the shared analysis-cache tier
("the real moat") whose client already shipped. A repeat analysis of the same
artifact -- already run by someone else, on another machine -- is now served
from a shared store without re-running the tool.

reagraph/cache_server.py: a pure-stdlib HTTP service (ThreadingHTTPServer) that
is a thin layer over the SAME reagraph.cache CacheBackend the local cache uses,
speaking the exact wire contract documented on remote_cache.UrllibTransport:
  GET /{key} -> bytes|404, PUT /{key} -> 201, DELETE /{key} -> 204,
  GET /keys -> {"keys":[...]}, GET /health -> ok.
Safety posture: keys must be sha256 hex (^[0-9a-f]{64}$) -- this is also the
path-traversal defense (a request segment is never turned into a filesystem
path; only a validated key reaches the backend, which shards it). Public read /
token-gated write, tokens from the environment ONLY (never argv -- it leaks via
ps), constant-time compared. Upload size cap (413) and cache-entry-shape
validation (422). Every verb is wrapped: an unexpected error is a 500, never a
crashed serving thread.

make_server/serve/main public API; `python -m reagraph.cache_server` and a new
`reagraph serve-cache` CLI subcommand (host/port/dir/max-value-mb/
no-validate-payload; tokens via env). Exported from reagraph.__init__.

Tests (20 new, 158 total): unit tests over a real loopback socket (round-trip,
404 miss, bad-key 400 before any backend call, /keys, delete, /health, size-cap
413, payload 422, read/write auth, 500-stays-alive) and a conformance suite
driving the REAL RemoteCacheBackend / TieredBackend / Cache against the real
server (cross-machine round-trip, read-only push is a no-op, tiered local-warm
on a remote hit, fail-open on a dead port). Verified live via the CLI end to end.

docs/CACHE_SERVER.md (operator + client guide, trust/privacy model) and deploy/
(reference Dockerfile, .dockerignore, compose.yaml, fly.toml, README) for a
hosted instance. README/roadmap updated: the shared cache is in the tree.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-10-17 18:42:42 +01:00

354 lines
14 KiB
Python

"""Hosted shared-cache server: the other side of ``reagraph.remote_cache``.
``reagraph.cache`` makes a local build pay for each (artifact, scope) analysis
only once. ``reagraph.remote_cache`` lifts that one tier up with a
``RemoteCacheBackend`` that pulls byte-for-byte cache entries from a *shared*
store so a repeat analysis -- already run by someone else, on another machine --
is served without re-running the tool. This module is that shared store: a tiny
stdlib HTTP server speaking exactly the wire contract documented on
``reagraph.remote_cache.UrllibTransport``.
THE WIRE CONTRACT (must match the client byte-for-byte)
-------------------------------------------------------
* ``GET {base}/{key}`` -> 200 + raw bytes (application/octet-stream), 404 miss.
* ``PUT {base}/{key}`` -> 2xx; request body is the raw bytes to store.
* ``DELETE {base}/{key}`` -> 2xx (404 tolerated by the client as already-gone).
* ``GET {base}/keys`` -> 200 + JSON ``{"keys": [...]}``.
* Auth, when configured, is ``Authorization: Bearer <token>``.
SAFETY POSTURE
--------------
* A storage key MUST be a sha256 hex digest (``^[0-9a-f]{64}$``) -- exactly what
``Cache.key_for`` emits. Anything else is a 400. This is also the whole
path-traversal defence: the server NEVER builds a filesystem path from the
request; it only ever hands a validated 64-hex key to the backend, which
shards it safely. No file is ever opened by raw request path.
* Tokens gate reads and writes independently (constant-time compared). A split
posture -- public read, token-gated write -- is the common hosted shape.
* Every handler wraps its work: an unexpected error is a 500, logged to stderr;
it never crashes the serving thread.
Pure stdlib.
"""
from __future__ import annotations
import argparse
import hmac
import json
import os
import re
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .cache import LocalCacheBackend
# A storage key is a sha256 hex digest -- precisely what Cache.key_for() emits.
# Validating against this is both correctness (reject junk) and the path-traversal
# defence (we never touch the filesystem with an unvalidated request segment).
_KEY_RE = re.compile(r"^[0-9a-f]{64}$")
# Default upload cap: a cache fragment is JSON node-link data, not a blob store.
DEFAULT_MAX_VALUE_BYTES = 5 * 1024 * 1024
# Env var serve() reads for the on-disk store directory (falls back to the
# LocalCacheBackend default when unset).
ENV_DIR = "REAGRAPH_CACHE_SERVER_DIR"
# Tokens are sourced from the environment ONLY (never a CLI arg: argv leaks in ps).
ENV_READ_TOKEN = "REAGRAPH_CACHE_READ_TOKEN"
ENV_WRITE_TOKEN = "REAGRAPH_CACHE_WRITE_TOKEN"
def _log(msg: str) -> None:
"""Server-side diagnostics on stderr (stdout stays clean for piping)."""
print(f"[reagraph.cache_server] {msg}", file=sys.stderr)
class _CacheHandler(BaseHTTPRequestHandler):
"""One request handler for the shared cache. Settings are injected as class
attributes by :func:`make_server` (a per-server subclass), so the handler
closes over its backend/tokens without any module-level global state."""
# -- injected by make_server (defaults make the class usable standalone) --
backend = None
read_token = None
write_token = None
max_value_bytes = DEFAULT_MAX_VALUE_BYTES
validate_payload = True
# Keep the default per-request stderr spew out of the test output.
def log_message(self, format, *args): # noqa: A002 - signature is fixed by base
return
# -- small response helpers (always set Content-Length) -----------------
def _send_json(self, code: int, obj) -> None:
body = json.dumps(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_bytes(self, code: int, body: bytes) -> None:
self.send_response(code)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_empty(self, code: int) -> None:
self.send_response(code)
self.send_header("Content-Length", "0")
self.end_headers()
# -- auth ---------------------------------------------------------------
def _authorized(self, required_token) -> bool:
"""True if this request may proceed for the given required token.
A ``None`` token means that class of request is public. Otherwise the
``Authorization: Bearer <token>`` header must match, compared in constant
time to avoid leaking the token through timing."""
if required_token is None:
return True
presented = self.headers.get("Authorization", "")
expected = f"Bearer {required_token}"
return hmac.compare_digest(presented, expected)
# -- request parsing ----------------------------------------------------
def _route(self):
"""Return (kind, key). kind is 'health', 'keys', or 'key'."""
path = urllib.parse.urlsplit(self.path).path
segments = [s for s in path.split("/") if s]
if not segments or (len(segments) == 1 and segments[0] == "health"):
return "health", None
if len(segments) == 1 and segments[0] == "keys":
return "keys", None
# Treat the last path segment as the (url-encoded) key. We validate it
# before it ever reaches the backend; we never build a path from it.
return "key", urllib.parse.unquote(segments[-1])
def _read_body(self):
"""Read exactly Content-Length bytes, enforcing the size cap. Returns the
body bytes, or None if the cap was exceeded (a 413 has been sent)."""
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
if length > self.max_value_bytes:
self._send_json(413, {"error": "payload too large"})
return None
body = self.rfile.read(length) if length > 0 else b""
# Guard the actual read too, in case Content-Length lied low.
if len(body) > self.max_value_bytes:
self._send_json(413, {"error": "payload too large"})
return None
return body
# -- HTTP verbs ---------------------------------------------------------
def do_GET(self):
try:
kind, key = self._route()
if kind == "health":
self._send_json(200, {"service": "reagraph-cache", "ok": True})
return
# Reads (keys + entries, but not health) honour the read token.
if not self._authorized(self.read_token):
self._send_json(401, {"error": "unauthorized"})
return
if kind == "keys":
self._send_json(200, {"keys": list(self.backend.keys())})
return
if not _KEY_RE.match(key):
self._send_json(400, {"error": "bad key"})
return
value = self.backend.get(key)
if value is None:
self._send_json(404, {"error": "not found"})
return
self._send_bytes(200, value)
except Exception as exc: # never crash the serving thread
self._fail(exc)
def do_PUT(self):
try:
kind, key = self._route()
if kind != "key":
self._send_json(400, {"error": "bad key"})
return
if not self._authorized(self.write_token):
self._send_json(401, {"error": "unauthorized"})
return
if not _KEY_RE.match(key):
self._send_json(400, {"error": "bad key"})
return
body = self._read_body()
if body is None:
return # 413 already sent
if self.validate_payload and not self._looks_like_cache_entry(body):
self._send_json(422, {"error": "unexpected payload shape"})
return
self.backend.put(key, body)
self._send_json(201, {"stored": key})
except Exception as exc:
self._fail(exc)
def do_DELETE(self):
try:
kind, key = self._route()
if kind != "key":
self._send_json(400, {"error": "bad key"})
return
if not self._authorized(self.write_token):
self._send_json(401, {"error": "unauthorized"})
return
if not _KEY_RE.match(key):
self._send_json(400, {"error": "bad key"})
return
self.backend.delete(key) # idempotent; backend tolerates a miss
self._send_empty(204)
except Exception as exc:
self._fail(exc)
# -- helpers ------------------------------------------------------------
@staticmethod
def _looks_like_cache_entry(body: bytes) -> bool:
"""A stored value must be the reagraph cache entry shape: JSON object with
a "fragment" key (see reagraph.cache.Cache.put)."""
try:
payload = json.loads(body)
except (ValueError, TypeError):
return False
return isinstance(payload, dict) and "fragment" in payload
def _fail(self, exc: Exception) -> None:
"""Last-ditch 500 for an unexpected error: log, respond, stay alive."""
_log(f"{self.command} {self.path} failed: {exc!r}")
try:
self._send_json(500, {"error": "internal"})
except Exception: # the connection is probably gone; swallow it
pass
def make_server(
backend=None,
*,
host: str = "127.0.0.1",
port: int = 0,
read_token=None,
write_token=None,
max_value_bytes: int = DEFAULT_MAX_VALUE_BYTES,
validate_payload: bool = True,
) -> ThreadingHTTPServer:
"""Build (and bind) a threading HTTP cache server, ready to ``serve_forever``.
``port=0`` picks a free ephemeral port; read it back from
``server.server_address[1]``. ``backend`` defaults to a ``LocalCacheBackend``.
The returned server is bound but NOT yet serving -- the caller runs it (on a
thread in tests, or ``serve_forever`` in :func:`serve`)."""
if backend is None:
backend = LocalCacheBackend()
# Per-server subclass so settings live on the class, not a module global.
class Handler(_CacheHandler):
pass
Handler.backend = backend
Handler.read_token = read_token
Handler.write_token = write_token
Handler.max_value_bytes = max_value_bytes
Handler.validate_payload = validate_payload
server = ThreadingHTTPServer((host, port), Handler)
server.daemon_threads = True
return server
def serve(
host: str = "127.0.0.1",
port: int = 8787,
directory=None,
read_token=None,
write_token=None,
max_value_bytes: int = DEFAULT_MAX_VALUE_BYTES,
validate_payload: bool = True,
) -> None:
"""Build a LocalCacheBackend-backed server and serve_forever().
The store directory comes from ``directory``, else the ``REAGRAPH_CACHE_SERVER_DIR``
env var, else the LocalCacheBackend default. Prints a one-line startup banner
to stderr describing the posture."""
if directory is None:
directory = os.environ.get(ENV_DIR)
backend = LocalCacheBackend(directory)
server = make_server(
backend,
host=host,
port=port,
read_token=read_token,
write_token=write_token,
max_value_bytes=max_value_bytes,
validate_payload=validate_payload,
)
bound_host, bound_port = server.server_address[0], server.server_address[1]
read_posture = "read token required" if read_token else "public read"
write_posture = "writes: token required" if write_token else "writes: open"
_log(
f"serving on http://{bound_host}:{bound_port} "
f"dir={getattr(backend, 'dir', '?')} {read_posture} {write_posture}"
)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
def main(argv=None) -> int:
"""CLI entry point. Tokens are sourced from the environment ONLY, never from a
flag: a token on argv would leak to anyone who can read the process list
(``ps``). Set ``REAGRAPH_CACHE_READ_TOKEN`` / ``REAGRAPH_CACHE_WRITE_TOKEN``."""
parser = argparse.ArgumentParser(
prog="python -m reagraph.cache_server",
description="Hosted shared cache for REAgraph analysis fragments.",
)
parser.add_argument("--host", default="127.0.0.1", help="bind host (default 127.0.0.1)")
parser.add_argument("--port", type=int, default=8787, help="bind port (default 8787)")
parser.add_argument(
"--dir",
default=None,
help=f"store directory (default ${ENV_DIR} or the LocalCacheBackend default)",
)
parser.add_argument(
"--max-value-mb",
type=float,
default=5.0,
help="reject uploads larger than this many MiB (default 5)",
)
parser.add_argument(
"--no-validate-payload",
action="store_true",
help="accept any bytes (skip the reagraph cache-entry shape check)",
)
args = parser.parse_args(argv)
# Tokens: environment only (never a CLI arg -- argv leaks via `ps`).
read_token = os.environ.get(ENV_READ_TOKEN) or None
write_token = os.environ.get(ENV_WRITE_TOKEN) or None
serve(
host=args.host,
port=args.port,
directory=args.dir,
read_token=read_token,
write_token=write_token,
max_value_bytes=int(args.max_value_mb * 1024 * 1024),
validate_payload=not args.no_validate_payload,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())