Merge pull request #432 from tseaver/feat-431-control-plane-behind-proxy

feat(ingester): serve control plane under a configurable base path
This commit is contained in:
Yiorgis Gozadinos 2026-06-11 09:38:43 +03:00 committed by GitHub
commit a64524d5ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 200 additions and 29 deletions

View file

@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Added
- `ingester.api.root_path` (and `haiku-ingester serve --root-path`) serves the control plane under a sub-path for reverse-proxying; forwarded to FastAPI/uvicorn `root_path` and reflected in the dashboard's `<base href>`.
## [0.56.0] - 2026-06-09
### Added

View file

@ -326,6 +326,32 @@ 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 `<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
# Redirect the bare prefix to the trailing-slash form so the dashboard's
# <base href> resolves correctly.
location = /ingester {
return 308 /ingester/;
}
location /ingester/ {
rewrite ^/ingester/?(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8765;
}
```
## Operating

View file

@ -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 <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):

View file

@ -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 ``<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,
*,
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 ``<base href>``
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

View file

@ -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 = '<div class="empty">loading…</div>';
try {
renderDatabase(await fetchJson("/database"));
renderDatabase(await fetchJson("database"));
dbLoaded = true;
} catch (e) {
$("db-body").innerHTML = `<div class="empty">error: ${escapeHtml(e.message)}</div>`;
@ -812,7 +812,7 @@
async function loadConfig() {
$("config-body").innerHTML = '<div class="empty">loading…</div>';
try {
const data = await fetchJson("/config");
const data = await fetchJson("config");
$("config-body").innerHTML = '<pre class="config"></pre>';
$("config-body").querySelector("pre").textContent = data.yaml;
configLoaded = true;

View file

@ -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

View file

@ -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))

View file

@ -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 <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 ---

View file

@ -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 ---

View file

@ -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():