From c9e11c348694237671c8d9f8ea3241ab617d1e43 Mon Sep 17 00:00:00 2001 From: Stephan Berbig Date: Wed, 27 May 2026 22:59:53 +0200 Subject: [PATCH] cli: add `add-backend` / `remove-backend` / `list-backends` commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- pyproject.toml | 6 +- src/neuronetz_gateway/cli/backends.py | 150 ++++++++++++++++++ src/neuronetz_gateway/cli/manage.py | 211 +++++++++++++++++++++++++- tests/unit/test_cli_backends.py | 155 +++++++++++++++++++ 4 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 src/neuronetz_gateway/cli/backends.py create mode 100644 tests/unit/test_cli_backends.py diff --git a/pyproject.toml b/pyproject.toml index 166bf8f..4e41e94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,8 +55,10 @@ src = ["src", "tests"] select = ["E", "F", "I", "B", "UP", "S", "ASYNC"] [tool.ruff.lint.per-file-ignores] -# Tests may use assert and bind to all interfaces in fixtures. -"tests/**" = ["S101", "S104"] +# Tests may use assert, bind to all interfaces in fixtures, and use obvious +# 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] python_version = "3.12" diff --git a/src/neuronetz_gateway/cli/backends.py b/src/neuronetz_gateway/cli/backends.py new file mode 100644 index 0000000..fd0a3c9 --- /dev/null +++ b/src/neuronetz_gateway/cli/backends.py @@ -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", +] diff --git a/src/neuronetz_gateway/cli/manage.py b/src/neuronetz_gateway/cli/manage.py index 4c6bfc9..a6e2be7 100644 --- a/src/neuronetz_gateway/cli/manage.py +++ b/src/neuronetz_gateway/cli/manage.py @@ -16,13 +16,14 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable +from pathlib import Path from typing import Annotated import typer from neuronetz_gateway.auth.hashing import build_hasher, hash_secret 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.repositories import ( ApiKeyRepository, @@ -283,7 +284,6 @@ def list_models( import httpx 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.router import build_backend_headers @@ -366,7 +366,6 @@ def probe_ollama( """ import httpx - from neuronetz_gateway.config import BackendSpec from neuronetz_gateway.proxy.router import build_backend_headers settings = get_settings() @@ -449,6 +448,212 @@ def probe_ollama( 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 , 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: """Console-script entry point.""" app() diff --git a/tests/unit/test_cli_backends.py b/tests/unit/test_cli_backends.py new file mode 100644 index 0000000..af33c6d --- /dev/null +++ b/tests/unit/test_cli_backends.py @@ -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"