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:
@@ -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 <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:
|
||||
"""Console-script entry point."""
|
||||
app()
|
||||
|
||||
Reference in New Issue
Block a user