test: close coverage gaps + opt-in integration smoke

Unit-Tests (kein Docker nötig), 130 Tests gesamt:
- config: ${ENV}- und ~-Expansion in hf_home
- prompt_io: _pin_dns (DNS-Rebinding wird abgewiesen, andere Hosts unberührt,
  Resolver wird wiederhergestellt)
- actions: do_check (running/healthy, missing), do_start non-dry-run
  (Happy-Path + Readiness-Fehler mit --logs), _container_lock Force-Bypass
- cli: --max-tokens <= 0 abgelehnt, --expose/--no-expose

scripts/smoke.sh: opt-in End-to-End-Test gegen echten Docker + llama.cpp-Server
(--api-key-Round-Trip inkl. 401/200, start/check/chat/stream/stop, eigener
Container/Port, Cleanup-Trap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-07-06 17:08:10 +02:00
commit f83f36fdfb
6 changed files with 268 additions and 4 deletions

View file

@ -152,9 +152,19 @@ pytest -q
```
Die Test-Suite deckt die Prompt-Eingabeschicht (Datei- und URL-Quellen inkl.
SSRF-Abwehr mit gemockter DNS-Auflösung), die Konfigurationsauflösung, die
CLI-Validierung sowie Docker-/HTTP-Operationen (mit gemocktem `subprocess`
bzw. `requests`) ab.
SSRF-Abwehr mit gemockter DNS-Auflösung und DNS-Pinning), die
Konfigurationsauflösung (inkl. `${ENV}`-Expansion), die CLI-Validierung, die
Action-Orchestrierung (`do_start`/`do_change`/`do_check`/`do_chat`, Locking,
dry-run) sowie Docker-/HTTP-Operationen (mit gemocktem `subprocess` bzw.
`requests`) ab.
Für einen End-to-End-Test gegen einen **echten** Docker + llama.cpp-Server (u. a.
`--api-key`-Round-Trip, Streaming, `--check`-Exit-Codes) gibt es einen opt-in
Rauchtest — er startet einen separaten Container und räumt danach auf:
```bash
SMOKE_GPU=1 scripts/smoke.sh
```
## Lizenz

98
scripts/smoke.sh Executable file
View file

@ -0,0 +1,98 @@
#!/usr/bin/env bash
#
# Opt-in Integrations-Rauchtest gegen einen ECHTEN Docker + llama.cpp-Server.
# Läuft NICHT in der normalen pytest-Suite (braucht Docker, GPU-Speicher und ein
# echtes GGUF-Modell). Startet einen separaten Container (eigener Name/Port), um
# eine ggf. laufende Produktion nicht anzufassen — räumt am Ende auf.
#
# Voraussetzungen:
# - Docker-Daemon erreichbar
# - freier GPU-Speicher für das in der Config referenzierte Modell
# (ein 21-GB-Modell passt NICHT neben einen bereits laufenden 256k-Server;
# ggf. SMOKE_GPU auf eine freie Karte setzen oder Produktion vorher stoppen)
#
# Konfiguration über Environment-Variablen (alle optional):
# CLI Pfad zum CLI (Default: ./.venv/bin/llamacppctl)
# SMOKE_CONFIG Config-Datei (Default: llama.cpp.config)
# SMOKE_PROFILE --profile <name> (Default: keins)
# SMOKE_CONTAINER Container-Name (Default: llamacppctl_smoke)
# SMOKE_PORT Host-Port (Default: 8099)
# SMOKE_GPU GPU-Device (Default: aus Config)
# SMOKE_CTX ctx_size für den Test (Default: 8192, klein = schnell/wenig VRAM)
# SMOKE_MAX_TOKENS Chat-Budget (Default: 4000; Reasoning-Modelle brauchen viel)
# SMOKE_API_KEY API-Key (Default: zufällig)
#
# Beispiel:
# SMOKE_GPU=1 SMOKE_CTX=8192 scripts/smoke.sh
#
set -uo pipefail
CLI=${CLI:-./.venv/bin/llamacppctl}
CONFIG=${SMOKE_CONFIG:-llama.cpp.config}
CONTAINER=${SMOKE_CONTAINER:-llamacppctl_smoke}
PORT=${SMOKE_PORT:-8099}
CTX=${SMOKE_CTX:-8192}
MAX_TOKENS=${SMOKE_MAX_TOKENS:-4000}
API_KEY=${SMOKE_API_KEY:-smoke-$$-$RANDOM}
base=(--config "$CONFIG" --container-name "$CONTAINER" --host-port "$PORT")
[ -n "${SMOKE_PROFILE:-}" ] && base+=(--profile "$SMOKE_PROFILE")
[ -n "${SMOKE_GPU:-}" ] && base+=(--gpu "$SMOKE_GPU")
pass=0
fail=0
ok() { echo " PASS: $1"; pass=$((pass + 1)); }
bad() { echo " FAIL: $1"; fail=$((fail + 1)); }
cleanup() {
echo "== cleanup =="
"$CLI" --stop "${base[@]}" >/dev/null 2>&1 || docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
}
trap cleanup EXIT
command -v docker >/dev/null 2>&1 || { echo "docker fehlt auf PATH"; exit 2; }
docker version >/dev/null 2>&1 || { echo "docker-Daemon nicht erreichbar"; exit 2; }
echo "== dry-run =="
if "$CLI" --start "${base[@]}" --api-key "$API_KEY" --ctx-size "$CTX" --dry-run | grep -q "docker run"; then
ok "dry-run zeigt docker-run-Kommando (kein Start)"
else
bad "dry-run"
fi
echo "== start (api-key gesetzt, ctx=$CTX) =="
if "$CLI" --start "${base[@]}" --api-key "$API_KEY" --ctx-size "$CTX"; then
ok "start + readiness"
else
bad "start (Modell/GPU prüfen)"; exit 1
fi
echo "== --check Exit-Code (läuft -> 0) =="
rc=0; "$CLI" --check "${base[@]}" >/dev/null 2>&1 || rc=$?
[ "$rc" -eq 0 ] && ok "--check == 0" || bad "--check == $rc (erwartet 0)"
echo "== API-Key erzwungen: ohne Key -> 401 =="
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/v1/models")
[ "$code" = "401" ] && ok "unauth -> 401" || bad "unauth -> $code (erwartet 401)"
echo "== API-Key: mit Key -> 200 =="
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $API_KEY" "http://127.0.0.1:$PORT/v1/models")
[ "$code" = "200" ] && ok "auth -> 200" || bad "auth -> $code (erwartet 200)"
echo "== chat (Tool sendet Bearer-Key) =="
out=$("$CLI" --chat "${base[@]}" --api-key "$API_KEY" --max-tokens "$MAX_TOKENS" -p "Sag in genau einem Satz Hallo." 2>/dev/null)
[ -n "$out" ] && ok "chat liefert Text: ${out:0:60}..." || bad "chat leer"
echo "== stream =="
sout=$("$CLI" --chat "${base[@]}" --api-key "$API_KEY" --stream --max-tokens "$MAX_TOKENS" -p "Zähle bis drei." 2>/dev/null)
[ -n "$sout" ] && ok "stream liefert Text" || bad "stream leer"
echo "== stop -> --check == 5 =="
rc=0; "$CLI" --stop "${base[@]}" >/dev/null 2>&1 || rc=$?
[ "$rc" -eq 0 ] && ok "stop" || bad "stop == $rc"
rc=0; "$CLI" --check "${base[@]}" >/dev/null 2>&1 || rc=$?
[ "$rc" -eq 5 ] && ok "--check nach stop == 5" || bad "--check nach stop == $rc (erwartet 5)"
echo
echo "== Ergebnis: $pass PASS, $fail FAIL =="
[ "$fail" -eq 0 ]

View file

@ -4,8 +4,12 @@ from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from llamacppctl import actions # noqa: E402
import pytest # noqa: E402
from llamacppctl import actions, http_ops # noqa: E402
from llamacppctl.docker_ops import ContainerInfo # noqa: E402
from llamacppctl.http_ops import HttpError # noqa: E402
from llamacppctl.lock_ops import FileLock, LockError # noqa: E402
from llamacppctl.schema import ChatReply, CheckResult # noqa: E402
from tests.test_docker_ops import make_cfg # noqa: E402
@ -125,3 +129,87 @@ def test_do_stop_passes_force(tmp_path, monkeypatch):
monkeypatch.setattr(actions, "stop_container", fake_stop)
actions.do_stop(cfg, SimpleNamespace(force=True))
assert seen["force"] is True
# --- do_check ------------------------------------------------------------
def test_do_check_running_and_healthy(tmp_path, monkeypatch):
cfg = _cfg(tmp_path)
monkeypatch.setattr(
actions, "inspect_container", lambda n: ContainerInfo(n, "running", "healthy")
)
monkeypatch.setattr(http_ops, "check_health", lambda c: True)
monkeypatch.setattr(http_ops, "check_models", lambda c: True)
monkeypatch.setattr(actions, "chat_completion_text", lambda c, p: ChatReply("ok", "stop"))
result = actions.do_check(cfg, SimpleNamespace(user_prompt="ping", max_tokens=8), SimpleNamespace())
assert result.container_running is True
assert result.http_ok is True
assert result.chat_ok is True
assert actions.check_exit_code(result) == actions.EXIT_OK
def test_do_check_missing_container(tmp_path, monkeypatch):
cfg = _cfg(tmp_path)
monkeypatch.setattr(
actions, "inspect_container", lambda n: ContainerInfo(n, "missing", "none")
)
result = actions.do_check(cfg, SimpleNamespace(user_prompt=None, max_tokens=8), SimpleNamespace())
assert result.container_exists is False
assert result.container_running is False
assert result.http_ok is False
assert actions.check_exit_code(result) == actions.EXIT_HTTP_NOT_READY
# --- do_start (non-dry-run) ----------------------------------------------
def _wire_start(monkeypatch, ready: bool):
monkeypatch.setattr(actions, "validate_model_path", lambda c: None)
monkeypatch.setattr(actions, "start_llama_container", lambda c: "abc123def456")
monkeypatch.setattr(actions, "wait_until_ready", lambda c, p: ready)
monkeypatch.setattr(actions, "container_logs", lambda n, t=100: "log tail")
def test_do_start_happy_path(tmp_path, monkeypatch, capsys):
cfg = _cfg(tmp_path)
_wire_start(monkeypatch, ready=True)
monkeypatch.setattr(actions, "chat_completion_text", lambda c, p: ChatReply("Servus", "stop"))
prompt = SimpleNamespace(user_prompt="hi", max_tokens=2048)
rc = actions.do_start(cfg, prompt, SimpleNamespace(dry_run=False, force=False, logs=False))
out = capsys.readouterr().out
assert rc == actions.EXIT_OK
assert "Model ready." in out
assert "Servus" in out
def test_do_start_readiness_failure(tmp_path, monkeypatch, capsys):
cfg = _cfg(tmp_path)
_wire_start(monkeypatch, ready=False)
prompt = SimpleNamespace(user_prompt=None, max_tokens=2048)
rc = actions.do_start(cfg, prompt, SimpleNamespace(dry_run=False, force=False, logs=True, log_lines=50))
err = capsys.readouterr().err
assert rc == actions.EXIT_HTTP_NOT_READY
assert "did not become ready" in err
assert "log tail" in err # --logs printed the tail
# --- _container_lock force bypass ----------------------------------------
def test_container_lock_busy_raises_without_force(tmp_path):
cfg = _cfg(tmp_path)
with FileLock(cfg.lock_file): # hold the lock
with pytest.raises(LockError):
with actions._container_lock(cfg, SimpleNamespace(force=False)):
pass
def test_container_lock_force_bypasses_busy(tmp_path, capsys):
cfg = _cfg(tmp_path)
entered = False
with FileLock(cfg.lock_file): # hold the lock
with actions._container_lock(cfg, SimpleNamespace(force=True)):
entered = True
assert entered is True
assert "busy lock" in capsys.readouterr().err

View file

@ -39,6 +39,22 @@ def test_chat_requires_prompt_source():
parse_error(["--chat"])
def test_max_tokens_nonpositive_rejected():
parse_error(["--start", "--max-tokens", "0"])
parse_error(["--start", "--max-tokens", "-5"])
def test_max_tokens_positive_ok():
args = parse(["--start", "--max-tokens", "4096"])
assert args.max_tokens == 4096
def test_expose_and_no_expose_flags():
assert parse(["--start", "--expose"]).expose is True
assert parse(["--start", "--no-expose"]).expose is False
assert parse(["--start"]).expose is None # unset => config decides
def test_chat_with_prompt_is_valid():
args = parse(["--chat", "-p", "hello"])
assert args.chat is True

View file

@ -181,6 +181,22 @@ def test_lock_file_derived_from_container_name(tmp_path):
assert str(server_cfg.lock_file) == "/tmp/llamacppctl.test_default.lock"
def test_hf_home_expands_env_var(tmp_path, monkeypatch):
monkeypatch.setenv("LLAMACPPCTL_TEST_HOME", "/data/models")
cfg = CONFIG_BASIC.replace("hf_home = /srv/models", "hf_home = ${LLAMACPPCTL_TEST_HOME}/sub")
cfg_path = _write_config(tmp_path, cfg)
server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)]))
assert str(server_cfg.hf_home) == "/data/models/sub"
def test_hf_home_expands_tilde(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", "/home/tester")
cfg = CONFIG_BASIC.replace("hf_home = /srv/models", "hf_home = ~/models")
cfg_path = _write_config(tmp_path, cfg)
server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)]))
assert str(server_cfg.hf_home) == "/home/tester/models"
def test_builtin_defaults_has_container_name():
defaults = builtin_defaults()
assert defaults["container_name"] == "va_llm"

View file

@ -5,6 +5,7 @@ import pytest
from llamacppctl.prompt_io import (
PromptSourceError,
_pin_dns,
load_text_url,
validate_url_target,
)
@ -175,6 +176,41 @@ def test_reject_redirect_by_default(monkeypatch, policy):
load_text_url("https://example.org/x.txt", policy)
def test_pin_dns_allows_validated_ip(monkeypatch):
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"host.example": ["1.2.3.4"]})
)
with _pin_dns("host.example", ["1.2.3.4"]):
assert socket.getaddrinfo("host.example", 443) # not filtered away
def test_pin_dns_rejects_rebound_ip(monkeypatch):
# Simulate DNS rebinding: after validation to 1.2.3.4, DNS now returns a
# private IP; the pin must refuse it during the actual connect.
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"host.example": ["10.0.0.9"]})
)
with _pin_dns("host.example", ["1.2.3.4"]):
with pytest.raises(socket.gaierror):
socket.getaddrinfo("host.example", 443)
def test_pin_dns_does_not_touch_other_hosts(monkeypatch):
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"other.example": ["9.9.9.9"]})
)
with _pin_dns("host.example", ["1.2.3.4"]):
assert socket.getaddrinfo("other.example", 443) # unrelated host passes
def test_pin_dns_restores_resolver(monkeypatch):
sentinel = _fake_getaddrinfo_factory({"host.example": ["1.2.3.4"]})
monkeypatch.setattr(socket, "getaddrinfo", sentinel)
with _pin_dns("host.example", ["1.2.3.4"]):
pass
assert socket.getaddrinfo is sentinel # restored after the context
def test_revalidate_redirect_target(monkeypatch, policy):
strict_policy = replace(policy, follow_redirects=True)
monkeypatch.setattr(