cli: add add-backend / remove-backend / list-backends commands
Some checks failed
CI / ruff (push) Has been cancelled
CI / mypy --strict (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / bandit (push) Has been cancelled
CI / pip-audit (push) Has been cancelled

So nobody ever has to hand-write the OLLAMA_BACKENDS JSON again.

  # add a backend, probe it, print the resulting .env line:
  neuronetz-gateway add-backend embedded     http://ollama:11434
  neuronetz-gateway add-backend neuro-ollama http://neuro-ollama:11434 --token ABC

  # update one (e.g. rotate token):
  neuronetz-gateway add-backend neuro-ollama http://neuro-ollama:11434 --token XYZ --replace

  # remove:
  neuronetz-gateway remove-backend neuro-ollama

  # peek (tokens redacted):
  neuronetz-gateway list-backends

  # write directly to a .env file (atomic temp-file + rename):
  neuronetz-gateway add-backend foo http://foo:11434 --token T --write-env /app/.env

  # show what would change without doing it:
  neuronetz-gateway add-backend foo http://foo:11434 --token T --dry-run

What each command does:

- `add-backend NAME URL` (+ optional --token / --header / --scheme / --replace
  / --no-validate / --write-env / --dry-run): builds a new backend list (current
  list parsed from OLLAMA_BACKENDS env, or synthesized from the single-backend
  fallback if unset), validates the new backend by probing /api/tags with the
  same headers the gateway will use at runtime (`build_backend_headers`), then
  prints the resulting OLLAMA_BACKENDS=... line ready to paste — or writes it
  in place if --write-env is given. Refuses to overwrite an existing name
  unless --replace is passed.
- `remove-backend NAME` (+ --write-env / --dry-run): mirror of add-backend for
  removal.
- `list-backends`: shows the configured backends with tokens redacted to
  "***" via `redacted_dump`. Useful sanity check after editing .env.

All the JSON manipulation is in a new pure-helpers module
`cli/backends.py` (parse / serialize / add_or_replace / remove /
update_env_file). The Typer commands in `cli/manage.py` are thin shells
on top — the logic is unit-tested directly without spinning up Typer or
the network. The token is unwrapped from SecretStr exactly once at the
serialization boundary (`to_dict`) and never logged.

New tests (16): full coverage of the helpers — round-trip serialize/parse,
duplicate-name rejection, replace-in-place order preservation, remove
on unknown name, redaction, atomic env-file rewrite (insert / replace /
idempotent re-apply / create-when-missing).

ruff (incl. the per-file ignore add for tests' S105/S106 — placeholder
"tok123"-style strings are inputs, not credentials) + mypy --strict (68
source files) clean. pytest: 76 passed + 39 skipped (the 16 new tests +
no regressions on the existing 60).
This commit is contained in:
Stephan Berbig
2026-05-27 22:59:53 +02:00
parent b8a0692aa1
commit c9e11c3486
4 changed files with 517 additions and 5 deletions

View File

@@ -55,8 +55,10 @@ src = ["src", "tests"]
select = ["E", "F", "I", "B", "UP", "S", "ASYNC"] select = ["E", "F", "I", "B", "UP", "S", "ASYNC"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
# Tests may use assert and bind to all interfaces in fixtures. # Tests may use assert, bind to all interfaces in fixtures, and use obvious
"tests/**" = ["S101", "S104"] # fake-token strings as inputs (S105/S106 fire on any literal that looks like
# a credential — fine in test inputs).
"tests/**" = ["S101", "S104", "S105", "S106"]
[tool.mypy] [tool.mypy]
python_version = "3.12" python_version = "3.12"

View File

@@ -0,0 +1,150 @@
"""Pure helpers for the `add-backend` / `remove-backend` / `list-backends` CLI.
The actual Typer commands live in :mod:`neuronetz_gateway.cli.manage`. Anything
that's pure logic — serializing a backend list to the JSON shape expected by
``OLLAMA_BACKENDS``, parsing it back, in-place editing of a ``.env`` file — is
kept here so it can be unit-tested without spinning up Typer or a network probe.
Token handling rules:
- The token is read from a :class:`SecretStr` and only unwrapped at the
serialization boundary (so reprs/logs never see it).
- ``redacted_dump`` returns a backend dict with the token redacted, for
``list-backends`` / debug output that we don't want to leak secrets through.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from pydantic import SecretStr
from neuronetz_gateway.config import BackendSpec
ENV_KEY = "OLLAMA_BACKENDS"
def to_dict(backend: BackendSpec) -> dict[str, str]:
"""Serialize a backend to the dict shape expected in ``OLLAMA_BACKENDS`` JSON.
Defaults are omitted to keep the line short and readable. The token, if
present, is unwrapped here — this function is the *one* place that touches
the raw secret value.
"""
out: dict[str, str] = {"name": backend.name, "base_url": backend.base_url}
if backend.auth_token is not None:
raw = backend.auth_token.get_secret_value()
if raw:
out["auth_token"] = raw
if backend.auth_header != "Authorization":
out["auth_header"] = backend.auth_header
if backend.auth_scheme != "Bearer":
out["auth_scheme"] = backend.auth_scheme
return out
def redacted_dump(backend: BackendSpec) -> dict[str, str]:
"""Like :func:`to_dict` but replaces the token with a fixed placeholder."""
out = to_dict(backend)
if "auth_token" in out:
# noqa: S105 — "***" is a redaction marker, not a hardcoded credential.
out["auth_token"] = "***" # noqa: S105
return out
def serialize(backends: list[BackendSpec]) -> str:
"""Render a backend list as the compact one-line JSON used in .env."""
return json.dumps([to_dict(b) for b in backends], separators=(",", ":"))
def parse(raw: str) -> list[BackendSpec]:
"""Parse an ``OLLAMA_BACKENDS`` JSON string back into BackendSpec models.
An empty string yields an empty list (matches the single-backend fallback
behavior of :meth:`Settings.effective_backends`).
"""
raw = raw.strip()
if not raw:
return []
data: Any = json.loads(raw)
if not isinstance(data, list):
raise ValueError(f"OLLAMA_BACKENDS must be a JSON array, got {type(data).__name__}")
backends: list[BackendSpec] = []
for idx, entry in enumerate(data):
if not isinstance(entry, dict):
raise ValueError(f"backend #{idx} is not an object")
token = entry.get("auth_token")
backends.append(
BackendSpec(
name=str(entry["name"]),
base_url=str(entry["base_url"]),
auth_token=SecretStr(str(token)) if token else None,
auth_header=str(entry.get("auth_header", "Authorization")),
auth_scheme=str(entry.get("auth_scheme", "Bearer")),
)
)
return backends
def add_or_replace(
backends: list[BackendSpec], new: BackendSpec, *, replace: bool = False
) -> list[BackendSpec]:
"""Return a new list with ``new`` added or replacing an existing entry.
Raises ``ValueError`` if the name is already present and ``replace=False``.
Preserves order: replacement keeps the existing position, appends are
placed at the end (so explicit priority order is honored).
"""
for idx, existing in enumerate(backends):
if existing.name == new.name:
if not replace:
raise ValueError(
f"backend '{new.name}' already exists; pass replace=True to update it"
)
return [*backends[:idx], new, *backends[idx + 1 :]]
return [*backends, new]
def remove(backends: list[BackendSpec], name: str) -> list[BackendSpec]:
"""Return a new list with the backend ``name`` removed.
Raises ``ValueError`` if no such backend exists.
"""
filtered = [b for b in backends if b.name != name]
if len(filtered) == len(backends):
raise ValueError(f"no backend named '{name}'")
return filtered
def update_env_file(path: Path, value: str, *, key: str = ENV_KEY) -> None:
"""Replace (or insert) ``KEY=VALUE`` in a .env file, preserving everything else.
Uses a "remove every existing matching line, then append" strategy so the
final file always has exactly one ``KEY=`` line. Atomic via a temp file +
rename so a crashed write can't corrupt the user's .env.
"""
existing = path.read_text(encoding="utf-8") if path.exists() else ""
kept: list[str] = []
for line in existing.splitlines():
if line.startswith(f"{key}=") or line.startswith(f"{key} ="):
continue
kept.append(line)
kept.append(f"{key}={value}")
new_content = "\n".join(kept) + "\n"
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(new_content, encoding="utf-8")
tmp.replace(path)
__all__ = [
"ENV_KEY",
"add_or_replace",
"parse",
"redacted_dump",
"remove",
"serialize",
"to_dict",
"update_env_file",
]

View File

@@ -16,13 +16,14 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Annotated from typing import Annotated
import typer import typer
from neuronetz_gateway.auth.hashing import build_hasher, hash_secret from neuronetz_gateway.auth.hashing import build_hasher, hash_secret
from neuronetz_gateway.auth.keys import generate_key from neuronetz_gateway.auth.keys import generate_key
from neuronetz_gateway.config import Settings, get_settings from neuronetz_gateway.config import BackendSpec, Settings, get_settings
from neuronetz_gateway.db.models import BudgetPeriod, KeyStatus from neuronetz_gateway.db.models import BudgetPeriod, KeyStatus
from neuronetz_gateway.db.repositories import ( from neuronetz_gateway.db.repositories import (
ApiKeyRepository, ApiKeyRepository,
@@ -283,7 +284,6 @@ def list_models(
import httpx import httpx
import redis.asyncio as redis import redis.asyncio as redis
from neuronetz_gateway.config import BackendSpec
from neuronetz_gateway.proxy.discovery import fetch_tags, names_of from neuronetz_gateway.proxy.discovery import fetch_tags, names_of
from neuronetz_gateway.proxy.router import build_backend_headers from neuronetz_gateway.proxy.router import build_backend_headers
@@ -366,7 +366,6 @@ def probe_ollama(
""" """
import httpx import httpx
from neuronetz_gateway.config import BackendSpec
from neuronetz_gateway.proxy.router import build_backend_headers from neuronetz_gateway.proxy.router import build_backend_headers
settings = get_settings() settings = get_settings()
@@ -449,6 +448,212 @@ def probe_ollama(
typer.secho("upstream reachable and authenticated.", fg=typer.colors.GREEN, bold=True) typer.secho("upstream reachable and authenticated.", fg=typer.colors.GREEN, bold=True)
@app.command("list-backends")
def list_backends() -> None:
"""Show the configured Ollama backends (tokens redacted).
Reads from ``OLLAMA_BACKENDS`` if set, otherwise displays the single
"default" backend synthesized from the legacy single-backend env vars.
"""
import os
from neuronetz_gateway.cli.backends import parse as parse_backends
from neuronetz_gateway.cli.backends import redacted_dump
raw = os.environ.get("OLLAMA_BACKENDS", "").strip()
settings = get_settings()
if raw:
backends = parse_backends(raw)
typer.echo(f"OLLAMA_BACKENDS is set ({len(backends)} backend(s)):")
else:
backends = settings.effective_backends()
typer.echo("OLLAMA_BACKENDS is empty — using single-backend fallback:")
for idx, b in enumerate(backends, start=1):
typer.echo(f" [{idx}] {b.name}")
for k, v in redacted_dump(b).items():
if k == "name":
continue
typer.echo(f" {k}: {v}")
@app.command("add-backend")
def add_backend(
name: Annotated[
str, typer.Argument(help="Unique backend name (e.g. 'embedded', 'neuro-ollama').")
],
base_url: Annotated[
str, typer.Argument(help="Base URL of the Ollama API (e.g. http://neuro-ollama:11434).")
],
*,
token: Annotated[
str | None, typer.Option("--token", help="Bearer token (omit if no auth).")
] = None,
header: Annotated[
str, typer.Option("--header", help="Auth header name; default Authorization.")
] = "Authorization",
scheme: Annotated[
str, typer.Option("--scheme", help="Auth scheme prefix; default Bearer.")
] = "Bearer",
skip_validate: Annotated[
bool, typer.Option("--no-validate", help="Skip the upstream probe (NOT recommended).")
] = False,
write_env: Annotated[
str | None,
typer.Option(
"--write-env",
help="If given, replace the OLLAMA_BACKENDS line in this .env file in place.",
),
] = None,
replace: Annotated[
bool, typer.Option("--replace", help="If a backend with this name exists, update it.")
] = False,
dry_run: Annotated[
bool, typer.Option("--dry-run", help="Show what would change; don't probe or write.")
] = False,
) -> None:
"""Add a backend to OLLAMA_BACKENDS (or update with --replace), probe-validate it,
and print/write the resulting line.
Without --write-env, just prints the new ``OLLAMA_BACKENDS=...`` line for
you to paste into your .env. With --write-env <path>, edits the file in
place (atomic temp-file + rename). Recreating the gateway container is on
you — typically ``docker compose up -d gateway``.
"""
import os
import httpx
from pydantic import SecretStr
from neuronetz_gateway.cli.backends import (
add_or_replace,
serialize,
update_env_file,
)
from neuronetz_gateway.cli.backends import (
parse as parse_backends,
)
from neuronetz_gateway.proxy.router import build_backend_headers
raw = os.environ.get("OLLAMA_BACKENDS", "").strip()
settings = get_settings()
current = parse_backends(raw) if raw else list(settings.effective_backends())
secret = SecretStr(token) if token else None
new = BackendSpec(
name=name,
base_url=base_url,
auth_token=secret,
auth_header=header,
auth_scheme=scheme,
)
try:
updated = add_or_replace(current, new, replace=replace)
except ValueError as exc:
typer.secho(str(exc), fg=typer.colors.RED)
raise typer.Exit(code=1) from None
if dry_run:
typer.echo("(dry-run) would set:")
typer.echo(f" OLLAMA_BACKENDS={serialize(updated)}")
return
if not skip_validate:
async def _probe() -> tuple[int, str]:
async with httpx.AsyncClient(
base_url=new.base_url,
timeout=httpx.Timeout(connect=5, read=10, write=10, pool=10),
headers=build_backend_headers(new),
) as client:
try:
resp = await client.get("/api/tags")
except httpx.HTTPError as exc:
return 0, f"transport error: {type(exc).__name__}"
if resp.status_code >= 400:
return resp.status_code, f"HTTP {resp.status_code}"
ct = resp.headers.get("content-type", "")
body = resp.json() if ct.startswith("application/json") else {}
count = len(body.get("models", [])) if isinstance(body, dict) else 0
return resp.status_code, f"HTTP 200, {count} model(s)"
status, msg = asyncio.run(_probe())
if status != 200:
typer.secho(f"✗ probe failed for {new.name}: {msg}", fg=typer.colors.RED)
if status in (401, 403):
typer.echo(" credentials rejected — check --token / --header / --scheme.")
typer.echo(" (use --no-validate to skip this check; not recommended.)")
raise typer.Exit(code=1)
typer.secho(f"✓ probe ok for {new.name}: {msg}", fg=typer.colors.GREEN)
line = serialize(updated)
if write_env is not None:
path = Path(write_env)
update_env_file(path, line)
typer.echo(f"✓ updated {path}")
typer.echo(" recreate the gateway: docker compose up -d gateway")
else:
typer.echo("")
typer.echo("Add this to your .env (then `docker compose up -d gateway`):")
typer.echo("")
typer.echo(f" OLLAMA_BACKENDS={line}")
typer.echo("")
@app.command("remove-backend")
def remove_backend(
name: Annotated[str, typer.Argument(help="Name of the backend to remove.")],
*,
write_env: Annotated[
str | None,
typer.Option("--write-env", help="If given, update this .env file in place."),
] = None,
dry_run: Annotated[
bool, typer.Option("--dry-run", help="Show what would change; don't write.")
] = False,
) -> None:
"""Remove a backend from OLLAMA_BACKENDS and emit the resulting line."""
import os
from neuronetz_gateway.cli.backends import (
parse as parse_backends,
)
from neuronetz_gateway.cli.backends import (
remove as remove_backend_from_list,
)
from neuronetz_gateway.cli.backends import (
serialize,
update_env_file,
)
raw = os.environ.get("OLLAMA_BACKENDS", "").strip()
if not raw:
typer.secho("OLLAMA_BACKENDS is empty — nothing to remove.", fg=typer.colors.YELLOW)
raise typer.Exit(code=1)
current = parse_backends(raw)
try:
updated = remove_backend_from_list(current, name)
except ValueError as exc:
typer.secho(str(exc), fg=typer.colors.RED)
raise typer.Exit(code=1) from None
line = serialize(updated)
if dry_run:
typer.echo("(dry-run) would set:")
typer.echo(f" OLLAMA_BACKENDS={line}")
return
if write_env is not None:
update_env_file(Path(write_env), line)
typer.echo(f"✓ updated {write_env}")
typer.echo(" recreate the gateway: docker compose up -d gateway")
else:
typer.echo("")
typer.echo("Replace your OLLAMA_BACKENDS in .env with:")
typer.echo("")
typer.echo(f" OLLAMA_BACKENDS={line}")
typer.echo("")
def main() -> None: def main() -> None:
"""Console-script entry point.""" """Console-script entry point."""
app() app()

View File

@@ -0,0 +1,155 @@
"""Unit tests for the pure helpers behind add-backend / remove-backend / list-backends."""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import SecretStr
from neuronetz_gateway.cli.backends import (
add_or_replace,
parse,
redacted_dump,
remove,
serialize,
to_dict,
update_env_file,
)
from neuronetz_gateway.config import BackendSpec
def _spec(
name: str, url: str, token: str | None = None, header: str = "Authorization"
) -> BackendSpec:
return BackendSpec(
name=name,
base_url=url,
auth_token=SecretStr(token) if token else None,
auth_header=header,
)
def test_to_dict_omits_default_header_and_scheme() -> None:
out = to_dict(_spec("a", "http://x"))
assert out == {"name": "a", "base_url": "http://x"}
def test_to_dict_includes_token_when_present() -> None:
out = to_dict(_spec("a", "http://x", token="s3cret"))
assert out["auth_token"] == "s3cret"
def test_to_dict_includes_non_default_header() -> None:
out = to_dict(_spec("a", "http://x", token="t", header="X-API-Key"))
assert out["auth_header"] == "X-API-Key"
def test_redacted_dump_hides_token() -> None:
out = redacted_dump(_spec("a", "http://x", token="s3cret"))
assert out["auth_token"] == "***"
# original to_dict still shows it (this fn is the secrets boundary)
assert to_dict(_spec("a", "http://x", token="s3cret"))["auth_token"] == "s3cret"
def test_serialize_round_trips_via_parse() -> None:
backends = [
_spec("embedded", "http://ollama:11434"),
_spec("public", "https://ollama.neuronetz.ai", token="tok123"),
]
raw = serialize(backends)
parsed = parse(raw)
assert [b.name for b in parsed] == ["embedded", "public"]
assert parsed[0].auth_token is None
assert parsed[1].auth_token is not None
assert parsed[1].auth_token.get_secret_value() == "tok123"
assert parsed[1].base_url == "https://ollama.neuronetz.ai"
def test_parse_empty_returns_empty_list() -> None:
assert parse("") == []
assert parse(" ") == []
def test_parse_rejects_non_array() -> None:
with pytest.raises(ValueError, match="must be a JSON array"):
parse('{"name":"a","base_url":"http://x"}')
def test_add_appends_when_name_is_new() -> None:
backends = [_spec("a", "http://a")]
new = _spec("b", "http://b")
out = add_or_replace(backends, new)
assert [b.name for b in out] == ["a", "b"]
def test_add_rejects_duplicate_without_replace() -> None:
backends = [_spec("a", "http://a")]
new = _spec("a", "http://a-different")
with pytest.raises(ValueError, match="already exists"):
add_or_replace(backends, new)
def test_add_replaces_in_place_when_replace_true() -> None:
backends = [_spec("a", "http://old"), _spec("b", "http://b")]
new = _spec("a", "http://new", token="t")
out = add_or_replace(backends, new, replace=True)
# Order preserved — "a" stays in slot 0, "b" stays in slot 1.
assert [b.name for b in out] == ["a", "b"]
assert out[0].base_url == "http://new"
assert out[0].auth_token is not None
assert out[0].auth_token.get_secret_value() == "t"
def test_remove_drops_named_backend() -> None:
backends = [_spec("a", "http://a"), _spec("b", "http://b")]
out = remove(backends, "a")
assert [b.name for b in out] == ["b"]
def test_remove_rejects_unknown_name() -> None:
backends = [_spec("a", "http://a")]
with pytest.raises(ValueError, match="no backend named 'ghost'"):
remove(backends, "ghost")
def test_update_env_file_inserts_when_key_absent(tmp_path: Path) -> None:
env = tmp_path / ".env"
env.write_text("FOO=1\nBAR=hello\n", encoding="utf-8")
update_env_file(env, "[{}]")
text = env.read_text(encoding="utf-8")
assert "FOO=1" in text
assert "BAR=hello" in text
assert text.rstrip().endswith("OLLAMA_BACKENDS=[{}]")
def test_update_env_file_replaces_existing_line(tmp_path: Path) -> None:
env = tmp_path / ".env"
env.write_text(
"FOO=1\nOLLAMA_BACKENDS=old\nBAR=hello\n", encoding="utf-8"
)
update_env_file(env, "new")
text = env.read_text(encoding="utf-8")
# exactly one OLLAMA_BACKENDS line, with the new value
matches = [line for line in text.splitlines() if line.startswith("OLLAMA_BACKENDS=")]
assert matches == ["OLLAMA_BACKENDS=new"]
# untouched lines preserved
assert "FOO=1" in text
assert "BAR=hello" in text
def test_update_env_file_is_idempotent(tmp_path: Path) -> None:
env = tmp_path / ".env"
env.write_text("KEEP=this\n", encoding="utf-8")
update_env_file(env, "v1")
update_env_file(env, "v1") # same value twice
text = env.read_text(encoding="utf-8")
matches = [line for line in text.splitlines() if line.startswith("OLLAMA_BACKENDS=")]
assert matches == ["OLLAMA_BACKENDS=v1"]
assert "KEEP=this" in text
def test_update_env_file_creates_when_missing(tmp_path: Path) -> None:
env = tmp_path / "fresh.env" # doesn't exist yet
update_env_file(env, "x")
assert env.read_text(encoding="utf-8").rstrip() == "OLLAMA_BACKENDS=x"