Initial commit: llamacppctl – llama.cpp Docker server control CLI
Steuert einen llama.cpp-Server als Docker-Container: --start/--check/--stop/
--change/--chat, INI-Konfiguration (builtin defaults -> [default] ->
[model.<profile>] -> CLI), SSRF-gehärtete Prompt-Eingabe (Datei/HTTPS-URL),
File-Locking für --start/--change und ein OpenAI-kompatibler HTTP-Layer.
Enthält u. a.:
- Env-Var-Expansion in hf_home (hf_home = ${HF_HOME})
- konfigurierbares Chat-Antwortbudget (max_tokens/chat_temperature,
CLI: --max-tokens/--chat-temp); temperature defer an Server-Default
- DNS-Pinning gegen DNS-Rebinding bei URL-Quellen
- dry-run als nebenwirkungsfreie Vorschau (kein Lock/Removal/Modell-Check)
- 98 Tests (pytest)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
3158d16f9b
32 changed files with 3912 additions and 0 deletions
430
build_archive.py
Normal file
430
build_archive.py
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
#!/usr/bin/env python3
|
||||
"""build_archive.py
|
||||
|
||||
Assembles ``llamacppctl-installable.tar.gz`` directly on the local machine
|
||||
from the llamacppctl project files, and verifies that all required files and
|
||||
declared dependencies are present and consistent before/after packaging.
|
||||
|
||||
This script is intentionally self-contained (standard library only, plus an
|
||||
optional import of ``tomllib``/``tomli`` purely for dependency verification)
|
||||
so it can be run directly, without first installing the llamacppctl package
|
||||
itself.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 build_archive.py
|
||||
python3 build_archive.py --project-dir /path/to/llamacppctl --output /path/to/llamacppctl-installable.tar.gz
|
||||
python3 build_archive.py --skip-tests # skip running pytest before packaging
|
||||
python3 build_archive.py --skip-pip-check # skip the pip dependency dry-run
|
||||
|
||||
Exit codes
|
||||
----------
|
||||
0 archive built and verified successfully
|
||||
1 a required file was missing, a test failed, or verification failed
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import venv
|
||||
from pathlib import Path
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Required file manifest.
|
||||
#
|
||||
# Every path here is relative to the project directory and MUST exist before
|
||||
# packaging. This is the "verifies that all dependencies are correctly
|
||||
# included" contract: the archive is only built if every one of these is
|
||||
# present, and re-opened afterwards to confirm every one of them made it
|
||||
# into the tarball unmodified.
|
||||
# --------------------------------------------------------------------------
|
||||
REQUIRED_FILES = [
|
||||
"pyproject.toml",
|
||||
"README.md",
|
||||
"requirements.txt",
|
||||
"requirements-dev.txt",
|
||||
"llama.cpp.config.example",
|
||||
"docs/SECURITY_AND_OPERATIONS.md",
|
||||
"man/llamacppctl.1",
|
||||
"src/llamacppctl/__init__.py",
|
||||
"src/llamacppctl/py.typed",
|
||||
"src/llamacppctl/cli.py",
|
||||
"src/llamacppctl/schema.py",
|
||||
"src/llamacppctl/config.py",
|
||||
"src/llamacppctl/prompt_io.py",
|
||||
"src/llamacppctl/docker_ops.py",
|
||||
"src/llamacppctl/http_ops.py",
|
||||
"src/llamacppctl/lock_ops.py",
|
||||
"src/llamacppctl/actions.py",
|
||||
"src/llamacppctl/main.py",
|
||||
"tests/conftest.py",
|
||||
"tests/test_prompt_sources.py",
|
||||
"tests/test_prompt_files.py",
|
||||
"tests/test_prompt_urls.py",
|
||||
"tests/test_config.py",
|
||||
"tests/test_cli.py",
|
||||
"tests/test_docker_ops.py",
|
||||
"tests/test_http_ops.py",
|
||||
"tests/test_lock_ops.py",
|
||||
]
|
||||
|
||||
# Top-level directories to include wholesale (in addition to REQUIRED_FILES),
|
||||
# so that any file added later without updating REQUIRED_FILES is still
|
||||
# packaged. REQUIRED_FILES remains the source of truth for *verification*.
|
||||
INCLUDE_DIRS = ["src", "tests", "docs", "man"]
|
||||
INCLUDE_FILES = [
|
||||
"pyproject.toml",
|
||||
"README.md",
|
||||
"requirements.txt",
|
||||
"requirements-dev.txt",
|
||||
"llama.cpp.config.example",
|
||||
"build_archive.py",
|
||||
]
|
||||
|
||||
EXCLUDE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".venv", "venv", ".git", "*.egg-info"}
|
||||
EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
|
||||
|
||||
ARCHIVE_ROOT_NAME = "llamacppctl" # top-level directory name inside the tarball
|
||||
|
||||
|
||||
class BuildError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[build_archive] {msg}")
|
||||
|
||||
|
||||
def verify_required_files(project_dir: Path) -> None:
|
||||
log("Verifying required files are present...")
|
||||
missing = []
|
||||
for rel in REQUIRED_FILES:
|
||||
p = project_dir / rel
|
||||
if not p.is_file():
|
||||
missing.append(rel)
|
||||
if missing:
|
||||
raise BuildError(
|
||||
"Missing required files, refusing to build archive:\n "
|
||||
+ "\n ".join(missing)
|
||||
)
|
||||
log(f" All {len(REQUIRED_FILES)} required files present.")
|
||||
|
||||
|
||||
def verify_dependencies_declared(project_dir: Path) -> list:
|
||||
"""Parses pyproject.toml and cross-checks it against requirements.txt.
|
||||
|
||||
Returns the list of runtime dependency specifiers declared in
|
||||
pyproject.toml. Raises BuildError on any mismatch.
|
||||
"""
|
||||
log("Verifying declared dependencies are consistent...")
|
||||
pyproject_path = project_dir / "pyproject.toml"
|
||||
requirements_path = project_dir / "requirements.txt"
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ModuleNotFoundError:
|
||||
try:
|
||||
import tomli as tomllib # type: ignore
|
||||
except ModuleNotFoundError:
|
||||
tomllib = None
|
||||
|
||||
if tomllib is not None:
|
||||
with open(pyproject_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
deps = data.get("project", {}).get("dependencies", [])
|
||||
else:
|
||||
# Fallback: minimal manual extraction, good enough for verification
|
||||
# purposes when neither tomllib nor tomli is available.
|
||||
log(" (tomllib/tomli unavailable, falling back to manual parse)")
|
||||
deps = []
|
||||
in_deps = False
|
||||
for line in pyproject_path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("dependencies"):
|
||||
in_deps = True
|
||||
if "[" in stripped and "]" in stripped:
|
||||
inner = stripped.split("[", 1)[1].rsplit("]", 1)[0]
|
||||
deps = [d.strip().strip('"').strip("'") for d in inner.split(",") if d.strip()]
|
||||
in_deps = False
|
||||
continue
|
||||
if in_deps:
|
||||
if "]" in stripped:
|
||||
in_deps = False
|
||||
stripped = stripped.split("]", 1)[0]
|
||||
cleaned = stripped.strip().rstrip(",").strip('"').strip("'")
|
||||
if cleaned:
|
||||
deps.append(cleaned)
|
||||
|
||||
if not deps:
|
||||
raise BuildError("No runtime dependencies found in pyproject.toml [project.dependencies]")
|
||||
|
||||
req_text = requirements_path.read_text(encoding="utf-8")
|
||||
req_names = set()
|
||||
for line in req_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# crude package-name extraction, e.g. "requests>=2.31,<3" -> "requests"
|
||||
name = line
|
||||
for sep in (">=", "<=", "==", "!=", ">", "<", "~="):
|
||||
if sep in name:
|
||||
name = name.split(sep, 1)[0]
|
||||
req_names.add(name.strip().lower())
|
||||
|
||||
missing_from_requirements = []
|
||||
for dep in deps:
|
||||
name = dep
|
||||
for sep in (">=", "<=", "==", "!=", ">", "<", "~="):
|
||||
if sep in name:
|
||||
name = name.split(sep, 1)[0]
|
||||
name = name.strip().lower()
|
||||
if name not in req_names:
|
||||
missing_from_requirements.append(dep)
|
||||
|
||||
if missing_from_requirements:
|
||||
raise BuildError(
|
||||
"pyproject.toml declares dependencies not mirrored in requirements.txt:\n "
|
||||
+ "\n ".join(missing_from_requirements)
|
||||
)
|
||||
|
||||
log(f" pyproject.toml dependencies: {deps}")
|
||||
log(" requirements.txt is consistent with pyproject.toml.")
|
||||
return deps
|
||||
|
||||
|
||||
def run_pip_dependency_check(project_dir: Path, deps: list) -> None:
|
||||
"""Creates a throwaway virtualenv and does a real `pip install` of just
|
||||
the declared dependencies (not the project itself) to confirm they are
|
||||
resolvable from PyPI. This is a stronger guarantee than a --dry-run
|
||||
against an environment that might already happen to have them cached."""
|
||||
log("Verifying dependencies are installable (throwaway virtualenv)...")
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="llamacppctl-depcheck-") as tmp:
|
||||
venv_dir = Path(tmp) / "venv"
|
||||
venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir)
|
||||
pip_bin = venv_dir / "bin" / "pip"
|
||||
if not pip_bin.exists():
|
||||
pip_bin = venv_dir / "Scripts" / "pip.exe" # Windows fallback
|
||||
|
||||
cmd = [str(pip_bin), "install", "--quiet", *deps]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise BuildError(
|
||||
"Dependency installability check failed:\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
log(" All declared dependencies installed successfully in an isolated environment.")
|
||||
|
||||
|
||||
def run_tests(project_dir: Path) -> None:
|
||||
log("Running test suite (pytest) before packaging...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "-q"],
|
||||
cwd=str(project_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(result.stdout)
|
||||
if result.returncode != 0:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
raise BuildError(f"Test suite failed (exit code {result.returncode}); refusing to package.")
|
||||
log(" Test suite passed.")
|
||||
|
||||
|
||||
def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
||||
parts = Path(tarinfo.name).parts
|
||||
for part in parts:
|
||||
if part in EXCLUDE_DIR_NAMES or part.endswith(".egg-info"):
|
||||
return None
|
||||
if Path(tarinfo.name).suffix in EXCLUDE_SUFFIXES:
|
||||
return None
|
||||
# Normalize metadata for reproducibility.
|
||||
tarinfo.uid = 0
|
||||
tarinfo.gid = 0
|
||||
tarinfo.uname = ""
|
||||
tarinfo.gname = ""
|
||||
return tarinfo
|
||||
|
||||
|
||||
def build_tarball(project_dir: Path, output_path: Path) -> None:
|
||||
log(f"Building archive: {output_path}")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(output_path, "w:gz") as tar:
|
||||
for rel in INCLUDE_FILES:
|
||||
src = project_dir / rel
|
||||
if src.is_file():
|
||||
tar.add(src, arcname=f"{ARCHIVE_ROOT_NAME}/{rel}", filter=_tar_filter)
|
||||
|
||||
for dirname in INCLUDE_DIRS:
|
||||
src_dir = project_dir / dirname
|
||||
if src_dir.is_dir():
|
||||
tar.add(src_dir, arcname=f"{ARCHIVE_ROOT_NAME}/{dirname}", filter=_tar_filter)
|
||||
|
||||
log(f" Archive written: {output_path} ({output_path.stat().st_size:,} bytes)")
|
||||
|
||||
|
||||
def verify_tarball(output_path: Path) -> None:
|
||||
log("Re-opening archive to verify contents...")
|
||||
with tarfile.open(output_path, "r:gz") as tar:
|
||||
names = set(tar.getnames())
|
||||
|
||||
missing = []
|
||||
for rel in REQUIRED_FILES:
|
||||
expected = f"{ARCHIVE_ROOT_NAME}/{rel}"
|
||||
if expected not in names:
|
||||
missing.append(expected)
|
||||
|
||||
if missing:
|
||||
raise BuildError(
|
||||
"Archive verification failed, required files missing from tarball:\n "
|
||||
+ "\n ".join(missing)
|
||||
)
|
||||
|
||||
log(f" Verified {len(REQUIRED_FILES)} required files are present in the archive.")
|
||||
log(f" Total archive members: {len(names)}")
|
||||
|
||||
|
||||
def smoke_test_install(output_path: Path) -> None:
|
||||
"""Extracts the tarball into a temp dir, installs it in a throwaway
|
||||
virtualenv, and confirms the `llamacppctl` console script works via
|
||||
--print-effective-config and --dry-run (no Docker daemon required for
|
||||
either)."""
|
||||
log("Smoke-testing installation from the built archive...")
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="llamacppctl-smoketest-") as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with tarfile.open(output_path, "r:gz") as tar:
|
||||
tar.extractall(tmp_path)
|
||||
|
||||
extracted_project = tmp_path / ARCHIVE_ROOT_NAME
|
||||
venv_dir = tmp_path / "venv"
|
||||
venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir)
|
||||
pip_bin = venv_dir / "bin" / "pip"
|
||||
py_bin = venv_dir / "bin" / "python"
|
||||
llamacppctl_bin = venv_dir / "bin" / "llamacppctl"
|
||||
|
||||
result = subprocess.run(
|
||||
[str(pip_bin), "install", "--quiet", str(extracted_project)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise BuildError(
|
||||
"Smoke-test install failed:\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
log(" Package installed successfully from the extracted archive.")
|
||||
|
||||
config_example = extracted_project / "llama.cpp.config.example"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(llamacppctl_bin),
|
||||
"--print-effective-config",
|
||||
"--config", str(config_example),
|
||||
"--profile", "qwen35",
|
||||
"--start",
|
||||
"--dry-run",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(result.stdout)
|
||||
|
||||
docker_unavailable = "docker is not available on PATH" in (result.stdout + result.stderr)
|
||||
|
||||
if result.returncode != 0 and not docker_unavailable:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
raise BuildError(
|
||||
f"Smoke test invocation failed unexpectedly (exit code {result.returncode})."
|
||||
)
|
||||
|
||||
if docker_unavailable:
|
||||
log(
|
||||
" Console script and argument/config resolution work end-to-end; "
|
||||
"exited cleanly at the expected Docker-unavailable check "
|
||||
"(no Docker daemon in this environment -- this is not a packaging defect)."
|
||||
)
|
||||
else:
|
||||
log(" --print-effective-config / --dry-run smoke test succeeded.")
|
||||
|
||||
|
||||
def main(argv: list | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
default=str(Path(__file__).resolve().parent),
|
||||
help="Path to the llamacppctl project directory (default: this script's directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default=None,
|
||||
help="Output path for the tar.gz (default: <project-dir>/../llamacppctl-installable.tar.gz)",
|
||||
)
|
||||
parser.add_argument("--skip-tests", action="store_true", help="Skip running pytest before packaging")
|
||||
parser.add_argument(
|
||||
"--skip-pip-check",
|
||||
action="store_true",
|
||||
help="Skip the throwaway-virtualenv dependency installability check",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-smoke-test",
|
||||
action="store_true",
|
||||
help="Skip extracting + installing the built archive as a final smoke test",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
project_dir = Path(args.project_dir).resolve()
|
||||
output_path = (
|
||||
Path(args.output).resolve()
|
||||
if args.output
|
||||
else project_dir.parent / "llamacppctl-installable.tar.gz"
|
||||
)
|
||||
|
||||
try:
|
||||
verify_required_files(project_dir)
|
||||
deps = verify_dependencies_declared(project_dir)
|
||||
|
||||
if not args.skip_pip_check:
|
||||
run_pip_dependency_check(project_dir, deps)
|
||||
else:
|
||||
log("Skipping pip dependency installability check (--skip-pip-check).")
|
||||
|
||||
if not args.skip_tests:
|
||||
run_tests(project_dir)
|
||||
else:
|
||||
log("Skipping test suite (--skip-tests).")
|
||||
|
||||
build_tarball(project_dir, output_path)
|
||||
verify_tarball(output_path)
|
||||
|
||||
if not args.skip_smoke_test:
|
||||
smoke_test_install(output_path)
|
||||
else:
|
||||
log("Skipping install smoke test (--skip-smoke-test).")
|
||||
|
||||
except BuildError as exc:
|
||||
print(f"\n[build_archive] BUILD FAILED: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
log("")
|
||||
log("Build and verification complete.")
|
||||
log(f"Archive: {output_path}")
|
||||
log(f"Size: {output_path.stat().st_size:,} bytes")
|
||||
log("")
|
||||
log("To install on the target machine:")
|
||||
log(f" tar xzf {output_path.name}")
|
||||
log(f" cd {ARCHIVE_ROOT_NAME}")
|
||||
log(" python3 -m venv .venv && source .venv/bin/activate")
|
||||
log(" pip install .")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue