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 # Changelog
## [Unreleased] ## [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 ## [0.56.0] - 2026-06-09
### Added ### Added

View file

@ -326,6 +326,32 @@ 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
# 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 ## 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():