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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue