From e58e4a185ddae91fa1d850bfb56e84e3916361f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Jim=C3=A9nez?= <61767851+juanp-ctrl@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:23:29 -0500 Subject: [PATCH] Expose Cookbook user-install CLIs in Docker (#887) Ensure pip --user console scripts like vLLM are visible to Docker runtime and dependency probes by adding the user install bin directory to PATH. --- docker/entrypoint.sh | 4 ++++ routes/shell_routes.py | 47 ++++++++++++++++++++++++++++++++++++++ tests/test_shell_routes.py | 23 +++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a378ff2..dede92f 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -76,6 +76,10 @@ done # nvcc" even when the GPU itself is fully visible to the container. export VLLM_USE_FLASHINFER_SAMPLER="${VLLM_USE_FLASHINFER_SAMPLER:-0}" +# Make Cookbook-installed Python CLIs visible after `pip install --user`. +# vLLM and helper scripts land here because /app is the non-root user's HOME. +export PATH="/app/.local/bin:$PATH" + # Drop root and run the actual app. `gosu` is preferred over `su` / # `sudo` because it cleans up the process tree (no extra shell layer) # so signals (SIGTERM from `docker stop`) reach uvicorn directly. diff --git a/routes/shell_routes.py b/routes/shell_routes.py index da8c9a3..d0cd4b2 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -183,13 +183,41 @@ def _package_status_note(name: str, probe: dict) -> str: return "" +def _prepend_user_install_bins_to_path() -> None: + """Make pip --user console scripts visible to dependency probes. + + Docker Cookbook installs vLLM with `python -m pip install --user`, which + drops the `vllm` CLI in /app/.local/bin. The running app process does not + inherit that PATH update, so `shutil.which("vllm")` can report missing even + after a successful install. + """ + try: + import site + + candidates = [os.path.join(site.USER_BASE, "bin")] + except Exception: + candidates = [] + candidates.append(os.path.expanduser("~/.local/bin")) + + parts = os.environ.get("PATH", "").split(os.pathsep) if os.environ.get("PATH") else [] + changed = False + for path in reversed([p for p in candidates if p]): + if path not in parts: + parts.insert(0, path) + changed = True + if changed: + os.environ["PATH"] = os.pathsep.join(parts) + + def _package_probe_script(names: list[str]) -> str: names_lit = ",".join(repr(n) for n in names) return f""" import importlib.util import importlib.metadata as md import json +import os import shutil +import site names=[{names_lit}] dist_names={{ @@ -204,6 +232,24 @@ bin_names={{ 'llama_cpp':['llama-server'], }} +def add_user_install_bins_to_path(): + candidates = [] + try: + candidates.append(os.path.join(site.USER_BASE, 'bin')) + except Exception: + pass + candidates.append(os.path.expanduser('~/.local/bin')) + parts = os.environ.get('PATH', '').split(os.pathsep) if os.environ.get('PATH') else [] + changed = False + for path in reversed([p for p in candidates if p]): + if path not in parts: + parts.insert(0, path) + changed = True + if changed: + os.environ['PATH'] = os.pathsep.join(parts) + +add_user_install_bins_to_path() + def mod_status(n): spec = importlib.util.find_spec(n) loader = getattr(spec, 'loader', None) if spec else None @@ -793,6 +839,7 @@ def setup_shell_routes() -> APIRouter: _require_admin(request) _reject_cross_site(request) import importlib, importlib.metadata as importlib_metadata, shlex, json as _json + _prepend_user_install_bins_to_path() if ssh_port and str(ssh_port).strip() not in ("", "22"): _port = str(ssh_port).strip() if not _SSH_PORT_RE.match(_port) or not (1 <= int(_port) <= 65535): diff --git a/tests/test_shell_routes.py b/tests/test_shell_routes.py index 31142df..c6ffb3c 100644 --- a/tests/test_shell_routes.py +++ b/tests/test_shell_routes.py @@ -3,6 +3,7 @@ import builtins import importlib.util import json +import os import sys from pathlib import Path from types import SimpleNamespace @@ -14,7 +15,9 @@ from routes.shell_routes import ( _running_in_container, _docker_row_status, _package_installed_from_probe, + _package_probe_script, _package_status_note, + _prepend_user_install_bins_to_path, _reject_cross_site, _ssh_base_argv, _venv_activate_prefix, @@ -247,6 +250,26 @@ class TestPackageProbeStatus: assert _package_installed_from_probe("diffusers", missing_torch) is False assert _package_installed_from_probe("diffusers", ready) is True + def test_local_user_install_bin_is_added_to_path(self, monkeypatch, tmp_path): + user_base = tmp_path / "user-base" + monkeypatch.setattr("site.USER_BASE", str(user_base)) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("PATH", "/usr/bin") + + _prepend_user_install_bins_to_path() + + parts = os.environ["PATH"].split(os.pathsep) + assert str(user_base / "bin") in parts + assert str(tmp_path / "home" / ".local" / "bin") in parts + + def test_remote_package_probe_checks_user_install_bin(self): + script = _package_probe_script(["vllm"]) + + assert "site.USER_BASE" in script + assert "os.path.expanduser('~/.local/bin')" in script + assert "add_user_install_bins_to_path()" in script + assert "shutil.which(b)" in script + class TestSshBaseArgv: def test_basic_host_no_port(self):