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:
commit
a64524d5ba
11 changed files with 200 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue