cli: add add-backend / remove-backend / list-backends commands
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:
@@ -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"
|
||||||
|
|||||||
150
src/neuronetz_gateway/cli/backends.py
Normal file
150
src/neuronetz_gateway/cli/backends.py
Normal 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",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
|
|||||||
155
tests/unit/test_cli_backends.py
Normal file
155
tests/unit/test_cli_backends.py
Normal 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"
|
||||||
Reference in New Issue
Block a user