From 92c2392fd64f90aedfa62e51b7cef0120fb415c8 Mon Sep 17 00:00:00 2001 From: Daniel Grzelak <59827851+pan-daniel@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:56:42 +0200 Subject: [PATCH] Clarify Docker dependency status inside containers * fix: show docker as N/A inside the container * test: cover in-container docker detection * fix: make the N/A dependency chip legible * refactor: make remote docker applicability explicit and tested --- routes/shell_routes.py | 62 ++++++++++++++++++++++----- static/js/cookbook.js | 6 ++- static/style.css | 5 ++- tests/test_shell_routes.py | 85 +++++++++++++++++++++++++++++++++++++- 4 files changed, 144 insertions(+), 14 deletions(-) diff --git a/routes/shell_routes.py b/routes/shell_routes.py index 3fec36f..fa8177b 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -9,6 +9,7 @@ import shutil import subprocess import uuid import tempfile +from collections import namedtuple from pathlib import Path from typing import Dict, Any @@ -61,6 +62,36 @@ logger = logging.getLogger(__name__) PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid") +DOCKER_IN_CONTAINER_HINT = ( + "Not available inside the Odysseus container by design. The image ships no " + "docker CLI and no host socket is mounted. Run Docker-backed launches on a " + "remote server, where docker is checked over SSH. Mounting /var/run/docker.sock " + "into the container would grant it host-root access, so only do that if you " + "accept that risk." +) + + +def _running_in_container(dockerenv_path="/.dockerenv", cgroup_path="/proc/1/cgroup"): + if os.path.exists(dockerenv_path): + return True + try: + with open(cgroup_path, "r", encoding="utf-8") as fh: + contents = fh.read() + except OSError: + return False + return any(token in contents for token in ("docker", "containerd", "kubepods")) + + +DockerRowStatus = namedtuple("DockerRowStatus", ["applicable", "install_hint"]) + + +def _docker_row_status(*, on_remote, in_container, installed, default_hint): + local_docker_unavailable = not on_remote and in_container and not installed + if local_docker_unavailable: + return DockerRowStatus(applicable=False, install_hint=DOCKER_IN_CONTAINER_HINT) + return DockerRowStatus(applicable=True, install_hint=default_hint) + + def _find_line_break(buf): """Find next line terminator in buffer. Returns (index, separator_length) or (-1, 0).""" ni = buf.find(b"\n") @@ -702,20 +733,29 @@ def setup_shell_routes() -> APIRouter: pass for pkg in packages: - if host and pkg.get("target") == "remote": + on_remote = bool(host and pkg.get("target") == "remote") + if on_remote: pkg["installed"] = bool(remote_status.get(pkg["name"], False)) - continue - if pkg.get("kind") == "system": + elif pkg.get("kind") == "system": pkg["installed"] = shutil.which(pkg["name"]) is not None - continue - try: - if pkg["name"] == "llama_cpp" and shutil.which("llama-server"): - pkg["installed"] = True - continue - importlib.import_module(pkg["name"]) + elif pkg["name"] == "llama_cpp" and shutil.which("llama-server"): pkg["installed"] = True - except ImportError: - pkg["installed"] = False + else: + try: + importlib.import_module(pkg["name"]) + pkg["installed"] = True + except ImportError: + pkg["installed"] = False + + if pkg["name"] == "docker": + status = _docker_row_status( + on_remote=on_remote, + in_container=_running_in_container() if not on_remote else False, + installed=pkg["installed"], + default_hint=pkg.get("install_hint"), + ) + pkg["applicable"] = status.applicable + pkg["install_hint"] = status.install_hint return {"packages": packages} @router.post("/api/cookbook/packages/install") diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 795bcf2..1fd172c 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -542,7 +542,11 @@ async function _fetchDependencies() { if (winBlocked) return `N/A`; if (pkg.installed && isSystemDep) return `Installed`; if (pkg.installed) return ``; - if (isSystemDep) return `Missing`; + if (isSystemDep) { + const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.'); + const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing'; + return `${depLabel}`; + } return ``; }; diff --git a/static/style.css b/static/style.css index c7907b3..5da0a7e 100644 --- a/static/style.css +++ b/static/style.css @@ -18153,7 +18153,10 @@ body.gallery-selecting .gallery-dl-btn, border: 1px solid color-mix(in srgb, var(--green, #50fa7b) 35%, transparent); } .cookbook-dep-na { - color: color-mix(in srgb, var(--fg) 35%, transparent); + background: color-mix(in srgb, var(--fg) 8%, transparent); + color: color-mix(in srgb, var(--fg) 60%, transparent); + border: 1px solid color-mix(in srgb, var(--fg) 16%, transparent); + cursor: help; } .cookbook-dep-install { background: var(--accent, var(--red)); diff --git a/tests/test_shell_routes.py b/tests/test_shell_routes.py index 4833ef3..dbe932e 100644 --- a/tests/test_shell_routes.py +++ b/tests/test_shell_routes.py @@ -7,7 +7,12 @@ import sys from pathlib import Path from types import SimpleNamespace -from routes.shell_routes import _find_line_break +from routes.shell_routes import ( + _find_line_break, + _running_in_container, + _docker_row_status, + DOCKER_IN_CONTAINER_HINT, +) def test_shell_routes_import_without_posix_pty_modules(monkeypatch): @@ -99,3 +104,81 @@ class TestFindLineBreak: def test_newline_before_cr(self): """\\n comes before \\r — should return \\n.""" assert _find_line_break(b"ab\ncd\r") == (2, 1) + + +class TestRunningInContainer: + """Detect whether the Odysseus process itself runs inside a container.""" + + def test_dockerenv_marker_present(self, tmp_path): + marker = tmp_path / ".dockerenv" + marker.write_text("") + assert _running_in_container( + dockerenv_path=str(marker), cgroup_path=str(tmp_path / "missing"), + ) is True + + def test_cgroup_names_a_container_runtime(self, tmp_path): + cgroup = tmp_path / "cgroup" + cgroup.write_text("12:devices:/docker/abcdef0123456789\n") + assert _running_in_container( + dockerenv_path=str(tmp_path / "no-marker"), cgroup_path=str(cgroup), + ) is True + + def test_bare_host_has_neither_signal(self, tmp_path): + cgroup = tmp_path / "cgroup" + cgroup.write_text("0::/user.slice/session-1.scope\n") + assert _running_in_container( + dockerenv_path=str(tmp_path / "no-marker"), cgroup_path=str(cgroup), + ) is False + + def test_missing_cgroup_file_is_not_a_container(self, tmp_path): + assert _running_in_container( + dockerenv_path=str(tmp_path / "no-marker"), + cgroup_path=str(tmp_path / "also-missing"), + ) is False + + +class TestDockerRowStatus: + """Applicability plus install hint for the docker dependency row.""" + + DEFAULT = "Install Docker on the selected server." + + def test_in_container_and_absent_is_not_applicable_with_safe_default_hint(self): + status = _docker_row_status( + on_remote=False, in_container=True, installed=False, default_hint=self.DEFAULT, + ) + assert status.applicable is False + assert status.install_hint == DOCKER_IN_CONTAINER_HINT + + def test_in_container_but_present_is_applicable_with_default_hint(self): + status = _docker_row_status( + on_remote=False, in_container=True, installed=True, default_hint=self.DEFAULT, + ) + assert status.applicable is True + assert status.install_hint == self.DEFAULT + + def test_on_host_and_absent_stays_applicable_with_default_hint(self): + status = _docker_row_status( + on_remote=False, in_container=False, installed=False, default_hint=self.DEFAULT, + ) + assert status.applicable is True + assert status.install_hint == self.DEFAULT + + def test_remote_server_is_always_applicable_even_when_absent(self): + status = _docker_row_status( + on_remote=True, in_container=False, installed=False, default_hint=self.DEFAULT, + ) + assert status.applicable is True + assert status.install_hint == self.DEFAULT + + def test_remote_server_ignores_local_container_status(self): + status = _docker_row_status( + on_remote=True, in_container=True, installed=False, default_hint=self.DEFAULT, + ) + assert status.applicable is True + assert status.install_hint == self.DEFAULT + + def test_container_hint_steers_to_remote_and_warns_on_socket(self): + lowered = DOCKER_IN_CONTAINER_HINT.lower() + assert "remote" in lowered + assert "socket" in lowered + assert "host-root" in lowered or "host root" in lowered