From b78f0ae9ed12c7e6e94417e386796ea6908ccc9f Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Tue, 9 Jun 2026 23:02:43 -0400 Subject: [PATCH] feat(ingester): serve control plane under a configurable base path Add `ingester.api.root_path` so the HTTP control plane (dashboard + API) can be reverse-proxied behind a sub-path (e.g. /ingester/) on a shared origin, instead of needing nginx sub_filter URL-rewriting. - APIConfig.root_path: normalized ('', or single leading slash, no trailing slash) via a field_validator; validate_assignment so CLI overrides normalize the same way as config-file values. - Forwarded to FastAPI(root_path=) and uvicorn.Config(root_path=) so OpenAPI/docs links are prefix-aware. - Dashboard route injects a matching root_path; all dashboard fetches are now base-relative, so they resolve under the prefix while staying identical at the root. - `serve --root-path` CLI flag. - Docs: "Behind a reverse proxy" section with an nginx example. Closes #431 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ingester.md | 20 ++++++++ haiku_rag_slim/haiku/rag/config/models.py | 26 +++++++++- .../rag/ingester/api/routes/dashboard.py | 19 +++++-- .../haiku/rag/ingester/api/server.py | 10 +++- .../haiku/rag/ingester/api/static/index.html | 24 ++++----- haiku_rag_slim/haiku/rag/ingester/app.py | 12 ++++- haiku_rag_slim/haiku/rag/ingester/cli.py | 9 ++++ tests/ingester/test_api.py | 51 ++++++++++++++++--- tests/ingester/test_cli.py | 19 +++++++ tests/ingester/test_config.py | 29 +++++++++++ 10 files changed, 190 insertions(+), 29 deletions(-) diff --git a/docs/ingester.md b/docs/ingester.md index b99492dc..e06629cc 100644 --- a/docs/ingester.md +++ b/docs/ingester.md @@ -326,6 +326,26 @@ ingester: host: 127.0.0.1 port: 8765 auth_token: secret # null → unauthenticated + root_path: "" # e.g. /ingester behind a proxy +``` + +### Behind a reverse proxy + +To serve the control plane under a sub-path (so a reverse proxy can front +it alongside other services on one origin, e.g. `https://host/ingester/`), +set `ingester.api.root_path` (or `serve --root-path /ingester`). It is +forwarded to FastAPI/uvicorn as `root_path` — OpenAPI/`/docs` links become +prefix-aware — and the dashboard is served with a matching `` so +its JSON fetches resolve under the prefix. The value is normalized to a +single leading slash with no trailing slash (`ingester`, `/ingester/` and +`/` become `/ingester`, `/ingester` and `""`). Strip the prefix at the proxy +before forwarding; for example, with nginx: + +```nginx +location /ingester/ { + rewrite ^/ingester/?(.*)$ /$1 break; + proxy_pass http://127.0.0.1:8765; +} ``` ## Operating diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index 5263eca0..b32c6f8d 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Annotated, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from haiku.rag.utils import get_default_data_dir @@ -345,10 +345,34 @@ class WorkerConfig(BaseModel): class APIConfig(BaseModel): """HTTP control plane settings for the ingester.""" + # Validate on assignment so CLI overrides (e.g. --root-path) run the same + # normalization as values parsed from the config file. + model_config = ConfigDict(validate_assignment=True) + enabled: bool = True host: str = "127.0.0.1" port: int = 8765 auth_token: str | None = None + root_path: str = Field( + default="", + description=( + "Base path the control plane is served under when reverse-proxied " + "behind a sub-path (e.g. '/ingester'). Empty serves at the root. " + "Forwarded to FastAPI/uvicorn as root_path and used to set the " + "dashboard's so its fetches are prefix-aware." + ), + ) + + @field_validator("root_path") + @classmethod + def _normalize_root_path(cls, value: str) -> str: + """Normalize to '' (root) or a single leading-slash, no-trailing-slash + prefix, so 'ingester', '/ingester/' and '/' become '/ingester', + '/ingester' and ''.""" + trimmed = value.strip().rstrip("/") + if trimmed and not trimmed.startswith("/"): + trimmed = "/" + trimmed + return trimmed class _SourceBase(BaseModel): diff --git a/haiku_rag_slim/haiku/rag/ingester/api/routes/dashboard.py b/haiku_rag_slim/haiku/rag/ingester/api/routes/dashboard.py index 1cf87ddb..4b7ce76c 100644 --- a/haiku_rag_slim/haiku/rag/ingester/api/routes/dashboard.py +++ b/haiku_rag_slim/haiku/rag/ingester/api/routes/dashboard.py @@ -1,17 +1,26 @@ from pathlib import Path -from fastapi import APIRouter -from fastapi.responses import FileResponse +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse router = APIRouter(tags=["dashboard"]) _INDEX_HTML_PATH = Path(__file__).resolve().parent.parent / "static" / "index.html" +_INDEX_HTML = _INDEX_HTML_PATH.read_text(encoding="utf-8") @router.get("/", include_in_schema=False) -async def dashboard() -> FileResponse: +async def dashboard(request: Request) -> HTMLResponse: """Serve the operator dashboard. Static page that polls /health, /sources, /stats and /jobs from the browser. Auth happens via the bearer header the JS attaches to its fetches — the dashboard route itself is unauthenticated - so an operator can open it in a browser and paste the token on demand.""" - return FileResponse(_INDEX_HTML_PATH, media_type="text/html") + so an operator can open it in a browser and paste the token on demand. + + A ```` matching the server's ``root_path`` is injected so the + page's relative fetches resolve correctly whether the control plane is + served at the root or reverse-proxied under a sub-path (e.g. ``/ingester/``). + """ + root_path = request.scope.get("root_path", "") + base_href = f"{root_path}/" if root_path else "/" + html = _INDEX_HTML.replace("", f'\n ', 1) + return HTMLResponse(html) diff --git a/haiku_rag_slim/haiku/rag/ingester/api/server.py b/haiku_rag_slim/haiku/rag/ingester/api/server.py index 5985f691..bafa7b0e 100644 --- a/haiku_rag_slim/haiku/rag/ingester/api/server.py +++ b/haiku_rag_slim/haiku/rag/ingester/api/server.py @@ -34,8 +34,15 @@ def build_app( state: APIState, *, auth_token: str | None = None, + root_path: str = "", ) -> FastAPI: - """Construct the ingester's FastAPI control plane.""" + """Construct the ingester's FastAPI control plane. + + ``root_path`` serves the control plane under a base path when it is + reverse-proxied behind a sub-path (e.g. ``/ingester``). It is forwarded to + FastAPI so generated URLs (OpenAPI/docs) and the dashboard's ```` + are prefix-aware; empty serves at the root. + """ from haiku.rag.ingester.api.routes import ( config as config_route, ) @@ -54,6 +61,7 @@ def build_app( title="haiku-ingester", description="Control plane for the haiku.rag production ingester.", version="1", + root_path=root_path, ) app.state.api_state = state app.state.auth_token = auth_token diff --git a/haiku_rag_slim/haiku/rag/ingester/api/static/index.html b/haiku_rag_slim/haiku/rag/ingester/api/static/index.html index 42fcae7b..039bd005 100644 --- a/haiku_rag_slim/haiku/rag/ingester/api/static/index.html +++ b/haiku_rag_slim/haiku/rag/ingester/api/static/index.html @@ -634,13 +634,13 @@ try { const [health, sources, providers, stats, active, dead, recent] = await Promise.all([ - fetchJson("/health"), - fetchJson("/sources"), - fetchJson("/providers"), - fetchJson("/stats"), - fetchJson("/jobs?status=claimed&limit=20"), - fetchJson("/jobs?status=dead&limit=10"), - fetchJson("/jobs?status=succeeded&limit=10"), + fetchJson("health"), + fetchJson("sources"), + fetchJson("providers"), + fetchJson("stats"), + fetchJson("jobs?status=claimed&limit=20"), + fetchJson("jobs?status=dead&limit=10"), + fetchJson("jobs?status=succeeded&limit=10"), ]); setHealthDot(false); let detail = `${health.worker_count} workers · ${health.poller_count} pollers`; @@ -676,16 +676,16 @@ // Action handlers (called from inline onclick — kept on window). window.cancelJob = async (id) => { if (!confirm(`Cancel job ${id.slice(0, 8)}…?`)) return; - try { await deleteJson(`/jobs/${encodeURIComponent(id)}`); } catch (e) {} + try { await deleteJson(`jobs/${encodeURIComponent(id)}`); } catch (e) {} refresh(); }; window.retryJob = async (id) => { - try { await postJson(`/jobs/${encodeURIComponent(id)}/retry`); } catch (e) {} + try { await postJson(`jobs/${encodeURIComponent(id)}/retry`); } catch (e) {} refresh(); }; window.refreshSource = async (sid) => { try { - await postJson(`/sources/${encodeURIComponent(sid)}/refresh`); + await postJson(`sources/${encodeURIComponent(sid)}/refresh`); } catch (e) {} refresh(); }; @@ -801,7 +801,7 @@ async function loadDatabase() { $("db-body").innerHTML = '
loading…
'; try { - renderDatabase(await fetchJson("/database")); + renderDatabase(await fetchJson("database")); dbLoaded = true; } catch (e) { $("db-body").innerHTML = `
error: ${escapeHtml(e.message)}
`; @@ -812,7 +812,7 @@ async function loadConfig() { $("config-body").innerHTML = '
loading…
'; try { - const data = await fetchJson("/config"); + const data = await fetchJson("config"); $("config-body").innerHTML = '
';
           $("config-body").querySelector("pre").textContent = data.yaml;
           configLoaded = true;
diff --git a/haiku_rag_slim/haiku/rag/ingester/app.py b/haiku_rag_slim/haiku/rag/ingester/app.py
index 2e0d283f..a22f82c3 100644
--- a/haiku_rag_slim/haiku/rag/ingester/app.py
+++ b/haiku_rag_slim/haiku/rag/ingester/app.py
@@ -255,17 +255,25 @@ class IngesterApp:
         )
         if ingester_cfg.api.auth_token is None:
             logger.warning("API auth_token is unset — control plane is unauthenticated")
-        app = build_app(state, auth_token=ingester_cfg.api.auth_token)
+        app = build_app(
+            state,
+            auth_token=ingester_cfg.api.auth_token,
+            root_path=ingester_cfg.api.root_path,
+        )
         config = uvicorn.Config(
             app,
             host=ingester_cfg.api.host,
             port=ingester_cfg.api.port,
+            root_path=ingester_cfg.api.root_path,
             log_level="info",
             access_log=_api_access_log_enabled(),
             lifespan="off",
         )
         server = uvicorn.Server(config)
         logger.info(
-            "API listening on %s:%d", ingester_cfg.api.host, ingester_cfg.api.port
+            "API listening on %s:%d%s",
+            ingester_cfg.api.host,
+            ingester_cfg.api.port,
+            ingester_cfg.api.root_path,
         )
         return asyncio.create_task(server.serve()), server
diff --git a/haiku_rag_slim/haiku/rag/ingester/cli.py b/haiku_rag_slim/haiku/rag/ingester/cli.py
index 147d5f95..82f758f1 100644
--- a/haiku_rag_slim/haiku/rag/ingester/cli.py
+++ b/haiku_rag_slim/haiku/rag/ingester/cli.py
@@ -154,6 +154,13 @@ def serve(
         "--port",
         help="Bind the HTTP control plane to PORT (overrides ingester.api.port).",
     ),
+    root_path: str | None = typer.Option(
+        None,
+        "--root-path",
+        help="Serve the control plane under a base path so it can be reverse-"
+        "proxied behind a sub-path, e.g. /ingester (overrides "
+        "ingester.api.root_path).",
+    ),
     no_api: bool = typer.Option(
         False,
         "--no-api",
@@ -167,6 +174,8 @@ def serve(
         app_config.ingester.api.host = host
     if port is not None:
         app_config.ingester.api.port = port
+    if root_path is not None:
+        app_config.ingester.api.root_path = root_path
     db_path = _resolve_db_path(app_config, db)
     app = IngesterApp(config=app_config, db_path=db_path)
     asyncio.run(app.serve(api=not no_api))
diff --git a/tests/ingester/test_api.py b/tests/ingester/test_api.py
index cecd24d7..fdf5b001 100644
--- a/tests/ingester/test_api.py
+++ b/tests/ingester/test_api.py
@@ -23,8 +23,10 @@ def state(jobs, sync):
     )
 
 
-def _client(state, *, auth_token: str | None = None) -> httpx.AsyncClient:
-    app = build_app(state, auth_token=auth_token)
+def _client(
+    state, *, auth_token: str | None = None, root_path: str = ""
+) -> httpx.AsyncClient:
+    app = build_app(state, auth_token=auth_token, root_path=root_path)
     return httpx.AsyncClient(
         transport=ASGITransport(app=app), base_url="http://testserver"
     )
@@ -627,10 +629,11 @@ async def test_dashboard_served_unauthenticated(state):
     assert "text/html" in resp.headers["content-type"]
     body = resp.text
     assert "haiku-ingester · status" in body
-    # The JS calls the JSON endpoints; sanity-check it's wired up.
-    assert "/stats" in body
-    assert "/sources" in body
-    assert "/jobs?status=claimed" in body
+    # The JS calls the JSON endpoints with base-href-relative paths (no leading
+    # slash) so the page works behind a sub-path; sanity-check it's wired up.
+    assert 'fetchJson("stats")' in body
+    assert 'fetchJson("sources")' in body
+    assert 'fetchJson("jobs?status=claimed&limit=20")' in body
     # Op badge helper is present so DELETE rows render distinctly.
     assert "opBadge" in body
 
@@ -662,12 +665,44 @@ async def test_dashboard_wires_database_and_config_panels(state):
     body = resp.text
     assert 'id="db-panel"' in body
     assert 'id="config-panel"' in body
-    assert "/database" in body
-    assert "/config" in body
+    assert 'fetchJson("database")' in body
+    assert 'fetchJson("config")' in body
     assert "loadDatabase" in body
     assert "loadConfig" in body
 
 
+@pytest.mark.asyncio
+async def test_dashboard_base_href_defaults_to_root(state):
+    """With no root_path the dashboard's  is the origin root, so its
+    relative fetches resolve at the top level exactly as before."""
+    async with _client(state) as client:
+        resp = await client.get("/")
+
+    assert resp.status_code == 200
+    assert '' in resp.text
+
+
+@pytest.mark.asyncio
+async def test_dashboard_base_href_reflects_root_path(state):
+    """When served under a root_path (reverse-proxied sub-path), the injected
+     carries the prefix so the relative fetches hit /ingester/..."""
+    async with _client(state, root_path="/ingester") as client:
+        resp = await client.get("/")
+
+    assert resp.status_code == 200
+    assert '' in resp.text
+
+
+@pytest.mark.asyncio
+async def test_api_routes_unchanged_under_root_path(state):
+    """root_path only affects URL generation/base-href; the routes themselves
+    still answer at their declared paths (the proxy strips the prefix)."""
+    async with _client(state, root_path="/ingester") as client:
+        resp = await client.get("/health")
+
+    assert resp.status_code == 200
+
+
 # --- config ---
 
 
diff --git a/tests/ingester/test_cli.py b/tests/ingester/test_cli.py
index 093d9b6c..1e69cdad 100644
--- a/tests/ingester/test_cli.py
+++ b/tests/ingester/test_cli.py
@@ -82,6 +82,25 @@ def test_serve_passes_host_and_port(monkeypatch):
     assert captured["config"].ingester.api.port == 9999
 
 
+def test_serve_passes_root_path(monkeypatch):
+    fake = AsyncMock()
+    captured = {}
+
+    def _capture(**kwargs):
+        captured.update(kwargs)
+        return fake
+
+    monkeypatch.setattr("haiku.rag.ingester.cli.IngesterApp", _capture)
+
+    result = runner.invoke(
+        cli, ["serve", "--db", "x.lancedb", "--root-path", "/ingester/"]
+    )
+
+    assert result.exit_code == 0
+    # Assignment is normalized (trailing slash stripped) via validate_assignment.
+    assert captured["config"].ingester.api.root_path == "/ingester"
+
+
 # --- queue init / migrate ---
 
 
diff --git a/tests/ingester/test_config.py b/tests/ingester/test_config.py
index 0510a53d..370ec377 100644
--- a/tests/ingester/test_config.py
+++ b/tests/ingester/test_config.py
@@ -5,6 +5,7 @@ import yaml
 from pydantic import ValidationError
 
 from haiku.rag.config import (
+    APIConfig,
     AppConfig,
     FSSourceConfig,
     HTTPSourceConfig,
@@ -21,6 +22,34 @@ def test_default_ingester_config_has_sane_values():
     assert cfg.workers.retry.max_attempts == 5
     assert cfg.api.enabled is True
     assert cfg.api.port == 8765
+    assert cfg.api.root_path == ""
+
+
+@pytest.mark.parametrize(
+    "raw, expected",
+    [
+        ("", ""),
+        ("/", ""),
+        ("ingester", "/ingester"),
+        ("/ingester", "/ingester"),
+        ("/ingester/", "/ingester"),
+        ("  /ingester/  ", "/ingester"),
+    ],
+)
+def test_api_root_path_normalized(raw, expected):
+    cfg = APIConfig(root_path=raw)
+
+    assert cfg.root_path == expected
+
+
+def test_api_root_path_normalized_on_assignment():
+    """validate_assignment ensures CLI overrides (--root-path) get the same
+    normalization as values parsed from the config file."""
+    cfg = APIConfig()
+
+    cfg.root_path = "ingester/"
+
+    assert cfg.root_path == "/ingester"
 
 
 def test_discriminator_picks_fs_source():