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:
parent
24202feee6
commit
f83f36fdfb
6 changed files with 268 additions and 4 deletions
16
README.md
16
README.md
|
|
@ -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
98
scripts/smoke.sh
Executable 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 ]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue