diff --git a/README.md b/README.md index 380a8b8..90ee121 100644 --- a/README.md +++ b/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 diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..7052d84 --- /dev/null +++ b/scripts/smoke.sh @@ -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 (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 ] diff --git a/tests/test_actions.py b/tests/test_actions.py index 400df48..3683444 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 4d9c7e7..051035c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index 5054745..1b162dc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_prompt_urls.py b/tests/test_prompt_urls.py index 5cc800e..bc07d37 100644 --- a/tests/test_prompt_urls.py +++ b/tests/test_prompt_urls.py @@ -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(