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 <base href> 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) <noreply@anthropic.com>
This commit is contained in:
Tres Seaver 2026-06-09 23:02:43 -04:00
parent bdad908a60
commit b78f0ae9ed
No known key found for this signature in database
GPG key ID: F2A968348913D1D8
10 changed files with 190 additions and 29 deletions

View file

@ -326,6 +326,26 @@ ingester:
host: 127.0.0.1 host: 127.0.0.1
port: 8765 port: 8765
auth_token: secret # null → unauthenticated 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 `<base href>` 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 ## Operating

View file

@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from typing import Annotated, Literal 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 from haiku.rag.utils import get_default_data_dir
@ -345,10 +345,34 @@ class WorkerConfig(BaseModel):
class APIConfig(BaseModel): class APIConfig(BaseModel):
"""HTTP control plane settings for the ingester.""" """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 enabled: bool = True
host: str = "127.0.0.1" host: str = "127.0.0.1"
port: int = 8765 port: int = 8765
auth_token: str | None = None 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 <base href> 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): class _SourceBase(BaseModel):

View file

@ -1,17 +1,26 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter from fastapi import APIRouter, Request
from fastapi.responses import FileResponse from fastapi.responses import HTMLResponse
router = APIRouter(tags=["dashboard"]) router = APIRouter(tags=["dashboard"])
_INDEX_HTML_PATH = Path(__file__).resolve().parent.parent / "static" / "index.html" _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) @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, """Serve the operator dashboard. Static page that polls /health, /sources,
/stats and /jobs from the browser. Auth happens via the bearer header the /stats and /jobs from the browser. Auth happens via the bearer header the
JS attaches to its fetches the dashboard route itself is unauthenticated 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.""" so an operator can open it in a browser and paste the token on demand.
return FileResponse(_INDEX_HTML_PATH, media_type="text/html")
A ``<base href>`` 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("<head>", f'<head>\n <base href="{base_href}" />', 1)
return HTMLResponse(html)

View file

@ -34,8 +34,15 @@ def build_app(
state: APIState, state: APIState,
*, *,
auth_token: str | None = None, auth_token: str | None = None,
root_path: str = "",
) -> FastAPI: ) -> 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 ``<base href>``
are prefix-aware; empty serves at the root.
"""
from haiku.rag.ingester.api.routes import ( from haiku.rag.ingester.api.routes import (
config as config_route, config as config_route,
) )
@ -54,6 +61,7 @@ def build_app(
title="haiku-ingester", title="haiku-ingester",
description="Control plane for the haiku.rag production ingester.", description="Control plane for the haiku.rag production ingester.",
version="1", version="1",
root_path=root_path,
) )
app.state.api_state = state app.state.api_state = state
app.state.auth_token = auth_token app.state.auth_token = auth_token

View file

@ -634,13 +634,13 @@
try { try {
const [health, sources, providers, stats, active, dead, recent] = const [health, sources, providers, stats, active, dead, recent] =
await Promise.all([ await Promise.all([
fetchJson("/health"), fetchJson("health"),
fetchJson("/sources"), fetchJson("sources"),
fetchJson("/providers"), fetchJson("providers"),
fetchJson("/stats"), fetchJson("stats"),
fetchJson("/jobs?status=claimed&limit=20"), fetchJson("jobs?status=claimed&limit=20"),
fetchJson("/jobs?status=dead&limit=10"), fetchJson("jobs?status=dead&limit=10"),
fetchJson("/jobs?status=succeeded&limit=10"), fetchJson("jobs?status=succeeded&limit=10"),
]); ]);
setHealthDot(false); setHealthDot(false);
let detail = `${health.worker_count} workers · ${health.poller_count} pollers`; let detail = `${health.worker_count} workers · ${health.poller_count} pollers`;
@ -676,16 +676,16 @@
// Action handlers (called from inline onclick — kept on window). // Action handlers (called from inline onclick — kept on window).
window.cancelJob = async (id) => { window.cancelJob = async (id) => {
if (!confirm(`Cancel job ${id.slice(0, 8)}…?`)) return; 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(); refresh();
}; };
window.retryJob = async (id) => { window.retryJob = async (id) => {
try { await postJson(`/jobs/${encodeURIComponent(id)}/retry`); } catch (e) {} try { await postJson(`jobs/${encodeURIComponent(id)}/retry`); } catch (e) {}
refresh(); refresh();
}; };
window.refreshSource = async (sid) => { window.refreshSource = async (sid) => {
try { try {
await postJson(`/sources/${encodeURIComponent(sid)}/refresh`); await postJson(`sources/${encodeURIComponent(sid)}/refresh`);
} catch (e) {} } catch (e) {}
refresh(); refresh();
}; };
@ -801,7 +801,7 @@
async function loadDatabase() { async function loadDatabase() {
$("db-body").innerHTML = '<div class="empty">loading…</div>'; $("db-body").innerHTML = '<div class="empty">loading…</div>';
try { try {
renderDatabase(await fetchJson("/database")); renderDatabase(await fetchJson("database"));
dbLoaded = true; dbLoaded = true;
} catch (e) { } catch (e) {
$("db-body").innerHTML = `<div class="empty">error: ${escapeHtml(e.message)}</div>`; $("db-body").innerHTML = `<div class="empty">error: ${escapeHtml(e.message)}</div>`;
@ -812,7 +812,7 @@
async function loadConfig() { async function loadConfig() {
$("config-body").innerHTML = '<div class="empty">loading…</div>'; $("config-body").innerHTML = '<div class="empty">loading…</div>';
try { try {
const data = await fetchJson("/config"); const data = await fetchJson("config");
$("config-body").innerHTML = '<pre class="config"></pre>'; $("config-body").innerHTML = '<pre class="config"></pre>';
$("config-body").querySelector("pre").textContent = data.yaml; $("config-body").querySelector("pre").textContent = data.yaml;
configLoaded = true; configLoaded = true;

View file

@ -255,17 +255,25 @@ class IngesterApp:
) )
if ingester_cfg.api.auth_token is None: if ingester_cfg.api.auth_token is None:
logger.warning("API auth_token is unset — control plane is unauthenticated") 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( config = uvicorn.Config(
app, app,
host=ingester_cfg.api.host, host=ingester_cfg.api.host,
port=ingester_cfg.api.port, port=ingester_cfg.api.port,
root_path=ingester_cfg.api.root_path,
log_level="info", log_level="info",
access_log=_api_access_log_enabled(), access_log=_api_access_log_enabled(),
lifespan="off", lifespan="off",
) )
server = uvicorn.Server(config) server = uvicorn.Server(config)
logger.info( 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 return asyncio.create_task(server.serve()), server

View file

@ -154,6 +154,13 @@ def serve(
"--port", "--port",
help="Bind the HTTP control plane to PORT (overrides ingester.api.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( no_api: bool = typer.Option(
False, False,
"--no-api", "--no-api",
@ -167,6 +174,8 @@ def serve(
app_config.ingester.api.host = host app_config.ingester.api.host = host
if port is not None: if port is not None:
app_config.ingester.api.port = port 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) db_path = _resolve_db_path(app_config, db)
app = IngesterApp(config=app_config, db_path=db_path) app = IngesterApp(config=app_config, db_path=db_path)
asyncio.run(app.serve(api=not no_api)) asyncio.run(app.serve(api=not no_api))

View file

@ -23,8 +23,10 @@ def state(jobs, sync):
) )
def _client(state, *, auth_token: str | None = None) -> httpx.AsyncClient: def _client(
app = build_app(state, auth_token=auth_token) 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( return httpx.AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver" 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"] assert "text/html" in resp.headers["content-type"]
body = resp.text body = resp.text
assert "haiku-ingester · status" in body assert "haiku-ingester · status" in body
# The JS calls the JSON endpoints; sanity-check it's wired up. # The JS calls the JSON endpoints with base-href-relative paths (no leading
assert "/stats" in body # slash) so the page works behind a sub-path; sanity-check it's wired up.
assert "/sources" in body assert 'fetchJson("stats")' in body
assert "/jobs?status=claimed" 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. # Op badge helper is present so DELETE rows render distinctly.
assert "opBadge" in body assert "opBadge" in body
@ -662,12 +665,44 @@ async def test_dashboard_wires_database_and_config_panels(state):
body = resp.text body = resp.text
assert 'id="db-panel"' in body assert 'id="db-panel"' in body
assert 'id="config-panel"' in body assert 'id="config-panel"' in body
assert "/database" in body assert 'fetchJson("database")' in body
assert "/config" in body assert 'fetchJson("config")' in body
assert "loadDatabase" in body assert "loadDatabase" in body
assert "loadConfig" 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 <base href> 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 '<base href="/" />' 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
<base href> 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 '<base href="/ingester/" />' 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 --- # --- config ---

View file

@ -82,6 +82,25 @@ def test_serve_passes_host_and_port(monkeypatch):
assert captured["config"].ingester.api.port == 9999 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 --- # --- queue init / migrate ---

View file

@ -5,6 +5,7 @@ import yaml
from pydantic import ValidationError from pydantic import ValidationError
from haiku.rag.config import ( from haiku.rag.config import (
APIConfig,
AppConfig, AppConfig,
FSSourceConfig, FSSourceConfig,
HTTPSourceConfig, HTTPSourceConfig,
@ -21,6 +22,34 @@ def test_default_ingester_config_has_sane_values():
assert cfg.workers.retry.max_attempts == 5 assert cfg.workers.retry.max_attempts == 5
assert cfg.api.enabled is True assert cfg.api.enabled is True
assert cfg.api.port == 8765 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(): def test_discriminator_picks_fs_source():