feat: harden and extend the CLI (security, UX, robustness, tests)
Sieben Verbesserungen; die Dateien überschneiden sich thematisch, daher ein
Commit (jeder Commit bleibt grün: 115 Tests).
- #1 --check ist scriptbar: Exit 0 wenn Container läuft und erreichbar,
sonst 5 (check_exit_code / CheckResult).
- #2 --force implementiert: Bypass eines belegten Locks mit Warnung
(_container_lock) und stop_container(force=…) schluckt Inkonsistenzen.
- #3 stille Trunkierung behoben: chat_completion_text liefert ChatReply
(content + finish_reason); bei finish_reason=length Hinweis auf stderr,
--chat gibt Exit 1 bei leerem Content zurück.
- #4 keine vermeidbare Downtime: --change validiert den Modellpfad VOR dem
Entfernen des laufenden Containers.
- #5 Netzwerk dicht: Port-Publish standardmäßig nur auf 127.0.0.1
(--expose/expose für alle Interfaces), optionaler --api-key/api_key
(Server --api-key + Bearer-Token auf allen Requests).
- #6 --stream: Chat-Reply token-weise via SSE auf stdout (stream_chat).
- #7 tests/test_actions.py: Orchestrierungs-Ebene (dry-run-Nebenwirkungen,
Lock, validate-before-remove, chat/stream/exit-codes).
Doku aktualisiert (Manpage, README, llama.cpp.config.example).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:57:47 +02:00
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
|
|
|
|
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>
2026-07-06 17:08:10 +02:00
|
|
|
import pytest # noqa: E402
|
|
|
|
|
|
|
|
|
|
from llamacppctl import actions, http_ops # noqa: E402
|
|
|
|
|
from llamacppctl.docker_ops import ContainerInfo # noqa: E402
|
feat: harden and extend the CLI (security, UX, robustness, tests)
Sieben Verbesserungen; die Dateien überschneiden sich thematisch, daher ein
Commit (jeder Commit bleibt grün: 115 Tests).
- #1 --check ist scriptbar: Exit 0 wenn Container läuft und erreichbar,
sonst 5 (check_exit_code / CheckResult).
- #2 --force implementiert: Bypass eines belegten Locks mit Warnung
(_container_lock) und stop_container(force=…) schluckt Inkonsistenzen.
- #3 stille Trunkierung behoben: chat_completion_text liefert ChatReply
(content + finish_reason); bei finish_reason=length Hinweis auf stderr,
--chat gibt Exit 1 bei leerem Content zurück.
- #4 keine vermeidbare Downtime: --change validiert den Modellpfad VOR dem
Entfernen des laufenden Containers.
- #5 Netzwerk dicht: Port-Publish standardmäßig nur auf 127.0.0.1
(--expose/expose für alle Interfaces), optionaler --api-key/api_key
(Server --api-key + Bearer-Token auf allen Requests).
- #6 --stream: Chat-Reply token-weise via SSE auf stdout (stream_chat).
- #7 tests/test_actions.py: Orchestrierungs-Ebene (dry-run-Nebenwirkungen,
Lock, validate-before-remove, chat/stream/exit-codes).
Doku aktualisiert (Manpage, README, llama.cpp.config.example).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:57:47 +02:00
|
|
|
from llamacppctl.http_ops import HttpError # noqa: E402
|
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>
2026-07-06 17:08:10 +02:00
|
|
|
from llamacppctl.lock_ops import FileLock, LockError # noqa: E402
|
feat: harden and extend the CLI (security, UX, robustness, tests)
Sieben Verbesserungen; die Dateien überschneiden sich thematisch, daher ein
Commit (jeder Commit bleibt grün: 115 Tests).
- #1 --check ist scriptbar: Exit 0 wenn Container läuft und erreichbar,
sonst 5 (check_exit_code / CheckResult).
- #2 --force implementiert: Bypass eines belegten Locks mit Warnung
(_container_lock) und stop_container(force=…) schluckt Inkonsistenzen.
- #3 stille Trunkierung behoben: chat_completion_text liefert ChatReply
(content + finish_reason); bei finish_reason=length Hinweis auf stderr,
--chat gibt Exit 1 bei leerem Content zurück.
- #4 keine vermeidbare Downtime: --change validiert den Modellpfad VOR dem
Entfernen des laufenden Containers.
- #5 Netzwerk dicht: Port-Publish standardmäßig nur auf 127.0.0.1
(--expose/expose für alle Interfaces), optionaler --api-key/api_key
(Server --api-key + Bearer-Token auf allen Requests).
- #6 --stream: Chat-Reply token-weise via SSE auf stdout (stream_chat).
- #7 tests/test_actions.py: Orchestrierungs-Ebene (dry-run-Nebenwirkungen,
Lock, validate-before-remove, chat/stream/exit-codes).
Doku aktualisiert (Manpage, README, llama.cpp.config.example).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:57:47 +02:00
|
|
|
from llamacppctl.schema import ChatReply, CheckResult # noqa: E402
|
|
|
|
|
from tests.test_docker_ops import make_cfg # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cfg(tmp_path):
|
|
|
|
|
return make_cfg(lock_file=tmp_path / "test.lock")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- dry-run must be a pure preview (no side effects) ---------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_do_start_dry_run_does_not_start(tmp_path, monkeypatch, capsys):
|
|
|
|
|
cfg = _cfg(tmp_path)
|
|
|
|
|
called = {"start": 0}
|
|
|
|
|
monkeypatch.setattr(actions, "start_llama_container", lambda c: called.__setitem__("start", 1))
|
|
|
|
|
rc = actions.do_start(cfg, SimpleNamespace(), SimpleNamespace(dry_run=True))
|
|
|
|
|
assert rc == actions.EXIT_OK
|
|
|
|
|
assert called["start"] == 0
|
|
|
|
|
assert "docker run" in capsys.readouterr().out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_do_change_dry_run_does_not_remove(tmp_path, monkeypatch):
|
|
|
|
|
cfg = _cfg(tmp_path)
|
|
|
|
|
called = {"remove": 0}
|
|
|
|
|
monkeypatch.setattr(actions, "remove_container", lambda n: called.__setitem__("remove", 1))
|
|
|
|
|
rc = actions.do_change(cfg, SimpleNamespace(), SimpleNamespace(dry_run=True))
|
|
|
|
|
assert rc == actions.EXIT_OK
|
|
|
|
|
assert called["remove"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- --change must validate BEFORE removing the running container ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_do_change_validates_before_remove(tmp_path, monkeypatch):
|
|
|
|
|
cfg = _cfg(tmp_path)
|
|
|
|
|
called = {"remove": 0, "start": 0}
|
|
|
|
|
monkeypatch.setattr(actions, "remove_container", lambda n: called.__setitem__("remove", 1))
|
|
|
|
|
monkeypatch.setattr(actions, "start_llama_container", lambda c: called.__setitem__("start", 1))
|
|
|
|
|
|
|
|
|
|
def missing(_cfg):
|
|
|
|
|
raise FileNotFoundError("model file not found: /x")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(actions, "validate_model_path", missing)
|
|
|
|
|
rc = actions.do_change(cfg, SimpleNamespace(), SimpleNamespace(dry_run=False, force=False))
|
|
|
|
|
assert rc == actions.EXIT_MODEL_PATH_MISSING
|
|
|
|
|
assert called["remove"] == 0 # container was NOT touched
|
|
|
|
|
assert called["start"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- do_chat -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_do_chat_prints_content(tmp_path, monkeypatch, capsys):
|
|
|
|
|
cfg = _cfg(tmp_path)
|
|
|
|
|
monkeypatch.setattr(actions, "chat_completion_text", lambda c, p: ChatReply("Hallo", "stop"))
|
|
|
|
|
prompt = SimpleNamespace(user_prompt="hi", max_tokens=2048)
|
|
|
|
|
rc = actions.do_chat(cfg, prompt, SimpleNamespace(stream=False))
|
|
|
|
|
assert rc == actions.EXIT_OK
|
|
|
|
|
assert capsys.readouterr().out.strip() == "Hallo"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_do_chat_truncated_empty_warns_and_fails(tmp_path, monkeypatch, capsys):
|
|
|
|
|
cfg = _cfg(tmp_path)
|
|
|
|
|
monkeypatch.setattr(actions, "chat_completion_text", lambda c, p: ChatReply("", "length"))
|
|
|
|
|
prompt = SimpleNamespace(user_prompt="hi", max_tokens=128)
|
|
|
|
|
rc = actions.do_chat(cfg, prompt, SimpleNamespace(stream=False))
|
|
|
|
|
assert rc == actions.EXIT_GENERAL
|
|
|
|
|
err = capsys.readouterr().err
|
|
|
|
|
assert "max_tokens=128" in err and "abgeschnitten" in err
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_do_chat_http_error_returns_not_ready(tmp_path, monkeypatch):
|
|
|
|
|
cfg = _cfg(tmp_path)
|
|
|
|
|
|
|
|
|
|
def boom(c, p):
|
|
|
|
|
raise HttpError("down")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(actions, "chat_completion_text", boom)
|
|
|
|
|
prompt = SimpleNamespace(user_prompt="hi", max_tokens=2048)
|
|
|
|
|
rc = actions.do_chat(cfg, prompt, SimpleNamespace(stream=False))
|
|
|
|
|
assert rc == actions.EXIT_HTTP_NOT_READY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_do_chat_stream(tmp_path, monkeypatch, capsys):
|
|
|
|
|
cfg = _cfg(tmp_path)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
actions,
|
|
|
|
|
"stream_chat",
|
|
|
|
|
lambda c, p: iter([("content", "Hal"), ("content", "lo"), ("finish", "stop")]),
|
|
|
|
|
)
|
|
|
|
|
prompt = SimpleNamespace(user_prompt="hi", max_tokens=2048)
|
|
|
|
|
rc = actions.do_chat(cfg, prompt, SimpleNamespace(stream=True))
|
|
|
|
|
assert rc == actions.EXIT_OK
|
|
|
|
|
assert capsys.readouterr().out.strip() == "Hallo"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- exit-code mapping & force stop --------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_check_exit_code():
|
|
|
|
|
ok = CheckResult(True, True, True, True, True, "u", "")
|
|
|
|
|
down = CheckResult(True, False, False, False, False, "u", "")
|
|
|
|
|
missing = CheckResult(False, False, False, False, False, "u", "")
|
|
|
|
|
assert actions.check_exit_code(ok) == actions.EXIT_OK
|
|
|
|
|
assert actions.check_exit_code(down) == actions.EXIT_HTTP_NOT_READY
|
|
|
|
|
assert actions.check_exit_code(missing) == actions.EXIT_HTTP_NOT_READY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_do_stop_passes_force(tmp_path, monkeypatch):
|
|
|
|
|
cfg = _cfg(tmp_path)
|
|
|
|
|
seen = {}
|
|
|
|
|
|
|
|
|
|
def fake_stop(name, remove=True, force=False):
|
|
|
|
|
seen["force"] = force
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(actions, "stop_container", fake_stop)
|
|
|
|
|
actions.do_stop(cfg, SimpleNamespace(force=True))
|
|
|
|
|
assert seen["force"] is True
|
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>
2026-07-06 17:08:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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
|