stage-topo-b topology-export: /federation/topology endpoint + CORS cache
This commit is contained in:
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.lines import discovery, federation, network_view, pulse, translog
|
||||
from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog
|
||||
from psyc.result import Err
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ _PUBLIC_NETWORK_TTL = 60.0
|
||||
_EXPLORE_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None, "domain": None}
|
||||
_EXPLORE_TTL = 60.0
|
||||
|
||||
# Sanitized docker topology cache. The build call hits the docker-socket-proxy
|
||||
# sidecar; polled peer admin pages mustn't re-trigger that on every poke.
|
||||
_TOPOLOGY_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
|
||||
_TOPOLOGY_TTL = 60.0
|
||||
|
||||
|
||||
# Headers we slap on every public endpoint so other psyc nodes' explore
|
||||
# pages can fetch them cross-origin from the browser.
|
||||
@@ -77,6 +82,16 @@ def _cached_public_network() -> Dict[str, Any]:
|
||||
return _PUBLIC_NETWORK_CACHE["payload"]
|
||||
|
||||
|
||||
def _cached_topology() -> Dict[str, Any]:
|
||||
"""Cached sanitized docker topology — same poll-load pattern as the feed."""
|
||||
now = time.time()
|
||||
if _TOPOLOGY_CACHE["payload"] is None or (now - _TOPOLOGY_CACHE["ts"]) > _TOPOLOGY_TTL:
|
||||
export = topology_export.build_export()
|
||||
_TOPOLOGY_CACHE["payload"] = export.model_dump(mode="json")
|
||||
_TOPOLOGY_CACHE["ts"] = now
|
||||
return _TOPOLOGY_CACHE["payload"]
|
||||
|
||||
|
||||
def _cached_explore(domain: Optional[str]) -> Dict[str, Any]:
|
||||
"""Cached explore payload. Re-uses the cache when the host domain matches.
|
||||
|
||||
@@ -257,6 +272,17 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
||||
"""
|
||||
return _public_json(_cached_public_network())
|
||||
|
||||
@app.get("/federation/topology")
|
||||
def federation_topology_public() -> JSONResponse:
|
||||
"""Sanitized docker topology — public, for peer-side display.
|
||||
|
||||
Whitelist-only: container names + images + state + network names. No
|
||||
env vars, no volume mounts, no IPs/MACs/gateways, no labels. CORS open
|
||||
so a peer's `/admin/federation/network` page can fetch it from the
|
||||
browser and render every node's containers alongside its own.
|
||||
"""
|
||||
return _public_json(_cached_topology())
|
||||
|
||||
# ---------- public vouches + transparency log --------------------
|
||||
|
||||
@app.get("/federation/vouches")
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
"""Topology export — whitelist sanitization unit tests.
|
||||
"""Topology export — whitelist sanitization + endpoint contract.
|
||||
|
||||
The big invariant: nothing from docker_view.topology() escapes that isn't
|
||||
in the Pydantic schema. We assert via model_fields introspection AND via a
|
||||
JSON-dump scan over a fixture that contains every dangerous field.
|
||||
|
||||
The /federation/topology endpoint contract lives in the sibling tests added
|
||||
alongside it; this module covers the builder + the sanitizer in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from psyc import db
|
||||
from psyc.cockpit import docker_view
|
||||
from psyc.cockpit import docker_view, federation_routes
|
||||
from psyc.lines import federation, topology_export
|
||||
from psyc.lines.topology_export import (
|
||||
TopologyContainer,
|
||||
@@ -50,6 +51,14 @@ def fed_dir(tmp_path, monkeypatch):
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_topology_cache():
|
||||
if hasattr(federation_routes, "_TOPOLOGY_CACHE"):
|
||||
federation_routes._TOPOLOGY_CACHE["payload"] = None
|
||||
federation_routes._TOPOLOGY_CACHE["ts"] = 0.0
|
||||
yield
|
||||
|
||||
|
||||
# ---------- fixture data: hostile docker_view output --------------------
|
||||
|
||||
# This payload has every leaky field docker_view *could* surface, plus
|
||||
@@ -243,3 +252,62 @@ def test_build_export_caps_at_max_containers(fresh_db, fed_dir, monkeypatch):
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: fake)
|
||||
out = build_export()
|
||||
assert out.container_count == topology_export.MAX_CONTAINERS
|
||||
|
||||
|
||||
# ---------- HTTP endpoint -----------------------------------------------
|
||||
|
||||
def _mk_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret")
|
||||
from pathlib import Path as _Path
|
||||
here = _Path(__file__).resolve().parent.parent / "src" / "psyc" / "cockpit" / "templates"
|
||||
templates = Jinja2Templates(directory=str(here))
|
||||
federation_routes.register(app, templates)
|
||||
return app
|
||||
|
||||
|
||||
def test_federation_topology_endpoint_returns_json_with_cors(fresh_db, fed_dir, monkeypatch):
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY)
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/topology")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("access-control-allow-origin") == "*"
|
||||
data = r.json()
|
||||
# Schema check.
|
||||
for key in ("node_fingerprint", "generated_at", "host_name",
|
||||
"container_count", "network_count", "containers", "networks"):
|
||||
assert key in data
|
||||
assert data["container_count"] == 2
|
||||
assert len(data["containers"]) == 2
|
||||
# No leaks in the wire response either.
|
||||
blob = r.text
|
||||
for forbidden in _FORBIDDEN_STRINGS:
|
||||
assert forbidden not in blob, f"leak via endpoint: {forbidden!r}"
|
||||
|
||||
|
||||
def test_federation_topology_endpoint_resilient_when_docker_unavailable(fresh_db, fed_dir, monkeypatch):
|
||||
def boom():
|
||||
raise docker_view.DockerProxyError("proxy down")
|
||||
monkeypatch.setattr(docker_view, "topology", boom)
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/topology")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["container_count"] == 0
|
||||
assert data["containers"] == []
|
||||
|
||||
|
||||
def test_federation_topology_cache_short_circuits_repeated_calls(fresh_db, fed_dir, monkeypatch):
|
||||
"""Within TTL, a second hit must not re-call docker_view."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def counted():
|
||||
calls["n"] += 1
|
||||
return _LEAKY_TOPOLOGY
|
||||
|
||||
monkeypatch.setattr(docker_view, "topology", counted)
|
||||
client = TestClient(_mk_app())
|
||||
r1 = client.get("/federation/topology")
|
||||
r2 = client.get("/federation/topology")
|
||||
assert r1.status_code == 200 and r2.status_code == 200
|
||||
assert calls["n"] == 1, "cache should suppress the second docker_view call"
|
||||
|
||||
Reference in New Issue
Block a user