fix(docker): make host Docker socket opt-in (#4902)

* fix(docker): make host socket compose opt-in

* fix(cookbook): gate container Docker access

* fix(docker): gate socket group setup on opt-in

* fix(cookbook): gate generated docker exec serve commands

* fix(cookbook): narrow generated docker exec forms
This commit is contained in:
Alexandre Teixeira
2026-06-30 18:54:51 +01:00
committed by GitHub
parent 1c1afe5dd1
commit f38323c3a1
14 changed files with 850 additions and 114 deletions
+20
View File
@@ -169,6 +169,26 @@ SEARXNG_INSTANCE=http://localhost:8080
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB) # ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB) # ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
# ============================================================
# Host Docker access (explicit opt-in)
# ============================================================
# Default Docker Compose does not mount /var/run/docker.sock. Existing
# Ollama, vLLM, and other OpenAI-compatible endpoints remain usable without it.
#
# Enable this only for intentional Cookbook/local Docker-daemon management.
# Raw socket access is high-trust and can grant broad control over the host
# Docker daemon. Set DOCKER_GID to the host docker group's numeric GID.
# Put these values in .env, or export them before running docker compose.
# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
# DOCKER_GID=963
# docker/host-docker.yml sets this inside the container. Keep it paired
# with the socket overlay; setting it alone is not sufficient.
# ODYSSEUS_ENABLE_HOST_DOCKER=true
#
# Host Docker access can be combined with one GPU overlay:
# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml
# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml
# ============================================================ # ============================================================
# GPU support (Docker Compose) # GPU support (Docker Compose)
# ============================================================ # ============================================================
-9
View File
@@ -28,14 +28,6 @@ services:
# land under /app/.local for the odysseus user. Persist them so a # land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines. # container recreate does not silently remove installed serve engines.
- ${APP_DATA_DIR:-./data}/local:/app/.local:z - ${APP_DATA_DIR:-./data}/local:/app/.local:z
# Docker socket — lets Cookbook launch commands like
# `docker exec ollama-rocm ollama show <tag>` reach the host's
# Docker daemon (and sibling containers like ollama-rocm /
# ollama-test). The in-container user needs to be in the
# socket's owning group — see `group_add` below; the GID
# there must match the host's `docker` group (defaults to 963
# on Debian, 999 on Ubuntu — override via env if yours differs).
- /var/run/docker.sock:/var/run/docker.sock
extra_hosts: extra_hosts:
# Lets the container reach local services on the Docker host, including # Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434. # Ollama at http://host.docker.internal:11434.
@@ -101,7 +93,6 @@ services:
- /dev/kfd - /dev/kfd
- /dev/dri - /dev/dri
group_add: group_add:
- "${DOCKER_GID:-963}"
- video - video
- ${RENDER_GID:-render} - ${RENDER_GID:-render}
-10
View File
@@ -27,16 +27,6 @@ services:
# land under /app/.local for the odysseus user. Persist them so a # land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines. # container recreate does not silently remove installed serve engines.
- ${APP_DATA_DIR:-./data}/local:/app/.local:z - ${APP_DATA_DIR:-./data}/local:/app/.local:z
# Docker socket — lets Cookbook launch commands like
# `docker exec ollama-rocm ollama show <tag>` reach the host's
# Docker daemon (and sibling containers like ollama-rocm /
# ollama-test). The in-container user needs to be in the
# socket's owning group — see `group_add` below; the GID
# there must match the host's `docker` group (defaults to 963
# on Debian, 999 on Ubuntu — override via env if yours differs).
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "${DOCKER_GID:-963}"
extra_hosts: extra_hosts:
# Lets the container reach local services on the Docker host, including # Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434. # Ollama at http://host.docker.internal:11434.
-10
View File
@@ -16,16 +16,6 @@ services:
# land under /app/.local for the odysseus user. Persist them so a # land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines. # container recreate does not silently remove installed serve engines.
- ${APP_DATA_DIR:-./data}/local:/app/.local:z - ${APP_DATA_DIR:-./data}/local:/app/.local:z
# Docker socket — lets Cookbook launch commands like
# `docker exec ollama-rocm ollama show <tag>` reach the host's
# Docker daemon (and sibling containers like ollama-rocm /
# ollama-test). The in-container user needs to be in the
# socket's owning group — see `group_add` below; the GID
# there must match the host's `docker` group (defaults to 963
# on Debian, 999 on Ubuntu — override via env if yours differs).
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "${DOCKER_GID:-963}"
extra_hosts: extra_hosts:
# Lets the container reach local services on the Docker host, including # Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434. # Ollama at http://host.docker.internal:11434.
+5 -5
View File
@@ -29,12 +29,12 @@ fi
ODY_USER="$(getent passwd "$PUID" | cut -d: -f1)" ODY_USER="$(getent passwd "$PUID" | cut -d: -f1)"
[ -z "$ODY_USER" ] && ODY_USER=odysseus [ -z "$ODY_USER" ] && ODY_USER=odysseus
# Docker-socket group plumbing. When /var/run/docker.sock is bind-mounted # Docker-socket group plumbing for the explicit host-Docker overlay. When
# (Cookbook uses docker exec to reach sibling containers), the socket is # opted in, the socket is owned by root:<host docker gid>. Add the app user
# owned by root:<host docker gid>. Add the app user to that group and later # to that group and later call gosu by username so supplementary groups are
# call gosu by username so supplementary groups are retained. # retained.
DOCKER_SOCK="${DOCKER_SOCK:-/var/run/docker.sock}" DOCKER_SOCK="${DOCKER_SOCK:-/var/run/docker.sock}"
if [ -S "$DOCKER_SOCK" ]; then if [ "${ODYSSEUS_ENABLE_HOST_DOCKER:-}" = "true" ] && [ -S "$DOCKER_SOCK" ]; then
SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK" 2>/dev/null || echo '')" SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK" 2>/dev/null || echo '')"
if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "0" ]; then if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "0" ]; then
if ! getent group "$SOCK_GID" >/dev/null 2>&1; then if ! getent group "$SOCK_GID" >/dev/null 2>&1; then
+12
View File
@@ -0,0 +1,12 @@
# High-trust host Docker access. Enable only when local Docker-daemon
# management from Cookbook is required and you accept that raw socket access
# grants broad control over the host Docker daemon.
# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
# DOCKER_GID=<numeric host Docker group id>
services:
odysseus:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
group_add: ["${DOCKER_GID:-963}"]
environment:
- ODYSSEUS_ENABLE_HOST_DOCKER=true
+27
View File
@@ -99,6 +99,33 @@ Odysseus SSH key and add the public key to the remote server's
ssh-copy-id -i data/ssh/id_ed25519.pub user@server ssh-copy-id -i data/ssh/id_ed25519.pub user@server
``` ```
**Host Docker access (explicit opt-in).** Default Docker Compose intentionally
does not mount `/var/run/docker.sock`. You can still connect Odysseus to
existing Ollama, vLLM, and other OpenAI-compatible endpoints without Docker
socket access.
Cookbook/local Docker-daemon management requires the opt-in overlay below. Raw
Docker socket access is high-trust because it can effectively grant broad
control over the host Docker daemon. Remote server Docker workflows over SSH
remain preferred.
Place these values in `.env`, or export them in the shell before running
`docker compose`:
```bash
COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
DOCKER_GID=<host docker group gid>
```
Combine host Docker access with a GPU overlay when both are intentionally
required:
```bash
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml
# or
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml
```
**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can **Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
only detect GPUs that Docker exposes to the container — if the host runtime or only detect GPUs that Docker exposes to the container — if the host runtime or
device passthrough is not configured, Cookbook sees the iGPU, another card, or device passthrough is not configured, Cookbook sees the iGPU, another card, or
+211 -54
View File
@@ -30,6 +30,13 @@ from core.platform_compat import (
which_tool, which_tool,
) )
from routes.shell_routes import TMUX_LOG_DIR from routes.shell_routes import TMUX_LOG_DIR
from src.host_docker_access import (
HOST_DOCKER_ACCESS_HINT,
HOST_DOCKER_SOCKET_PATH,
host_docker_access_enabled,
local_docker_available,
running_in_container,
)
from routes.cookbook_output import ( from routes.cookbook_output import (
error_aware_output_tail, classify_dead_download, error_aware_output_tail, classify_dead_download,
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE, HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
@@ -62,6 +69,182 @@ _HF_TOKEN_STATUS_SNIPPET = (
'fi' 'fi'
) )
_OLLAMA_SIDECAR_CONTAINERS = {"ollama-test", "ollama-rocm"}
_UNSAFE_DOCKER_EXEC_CHARS = frozenset(";&|<>$`\r\n")
_SAFE_OLLAMA_MODEL_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$")
_SAFE_OLLAMA_FILE_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def _is_generated_ollama_docker_exec_cmd(cmd: str | None) -> bool:
"""Match only the fixed Docker exec shapes generated by Cookbook."""
if not cmd or any(char in cmd for char in _UNSAFE_DOCKER_EXEC_CHARS):
return False
try:
parts = shlex.split(cmd)
except ValueError:
return False
if len(parts) < 4 or parts[:2] != ["docker", "exec"]:
return False
container, executable = parts[2:4]
if container not in _OLLAMA_SIDECAR_CONTAINERS:
return False
if container == "ollama-rocm" and executable == "ollama":
return (
len(parts) == 6
and parts[4] == "show"
and _SAFE_OLLAMA_MODEL_TOKEN_RE.fullmatch(parts[5]) is not None
)
if container != "ollama-test" or executable != "ollama-import":
return False
if len(parts) not in {7, 8}:
return False
model, name, context_size = parts[4:7]
return (
_SAFE_OLLAMA_MODEL_TOKEN_RE.fullmatch(model) is not None
and _SAFE_OLLAMA_FILE_TOKEN_RE.fullmatch(name) is not None
and re.fullmatch(r"[0-9]+", context_size) is not None
and (
len(parts) == 7
or _SAFE_OLLAMA_FILE_TOKEN_RE.fullmatch(parts[7]) is not None
)
)
def _missing_binary_message(
binary: str,
target: str,
*,
local_host_docker_blocked: bool = False,
) -> str:
if binary == "tmux":
return (
f"tmux is required for Cookbook background downloads/serves on {target}. "
"Install it with your OS package manager, or run Cookbook server setup for that server."
)
if binary == "docker":
if local_host_docker_blocked:
return HOST_DOCKER_ACCESS_HINT
return (
f"Docker is required by this Cookbook launch command on {target}, but the docker CLI was not found. "
"Install Docker and make sure this user can run `docker`, then retry."
)
return f"{binary} is required on {target}, but it was not found."
async def _remote_binary_available(
remote: str,
ssh_port: str | None,
binary: str,
*,
windows: bool = False,
) -> bool:
port = ssh_port or ""
port_args = ["-p", port] if port and port != "22" else []
if windows:
check = f'powershell -NoProfile -Command "if (Get-Command {binary} -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 127 }}"'
else:
check = f"command -v {shlex.quote(binary)} >/dev/null 2>&1"
try:
proc = await asyncio.create_subprocess_exec(
"ssh",
"-o",
"ConnectTimeout=6",
"-o",
"StrictHostKeyChecking=no",
*port_args,
remote,
check,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.communicate(), timeout=10)
return proc.returncode == 0
except Exception:
return False
async def _binary_available(
binary: str,
remote: str | None,
ssh_port: str | None,
*,
windows: bool = False,
in_container: bool | None = None,
environ=None,
socket_path: str = HOST_DOCKER_SOCKET_PATH,
) -> bool:
if remote:
return await _remote_binary_available(
remote,
ssh_port,
binary,
windows=windows,
)
cli_available = shutil.which(binary) is not None
if binary != "docker":
return cli_available
return local_docker_available(
cli_available=cli_available,
in_container=in_container,
environ=environ,
socket_path=socket_path,
)
def _local_ollama_docker_fallback_available(
*,
in_container: bool | None = None,
environ: dict[str, str] | None = None,
socket_path: str = HOST_DOCKER_SOCKET_PATH,
) -> bool:
return local_docker_available(
cli_available=shutil.which("docker") is not None,
in_container=in_container,
environ=environ,
socket_path=socket_path,
)
def _local_ollama_docker_access_blocked(
*,
in_container: bool | None = None,
environ: dict[str, str] | None = None,
socket_path: str = HOST_DOCKER_SOCKET_PATH,
) -> bool:
containerized = running_in_container() if in_container is None else in_container
if not containerized or shutil.which("docker") is None:
return False
return not _local_ollama_docker_fallback_available(
in_container=containerized,
environ=environ,
socket_path=socket_path,
)
def _append_local_ollama_download_command_lines(
lines: list[str],
ollama_cmd: str,
*,
docker_fallback_available: bool,
docker_fallback_blocked: bool,
) -> None:
lines.append('if command -v ollama >/dev/null 2>&1; then')
lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote(ollama_cmd)}')
if docker_fallback_available:
lines.append('elif command -v docker >/dev/null 2>&1; then')
lines.append(" ODYSSEUS_OLLAMA_CONTAINER=\"$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E '^(ollama-rocm|ollama-test)$' | head -1)\"")
lines.append(' if [ -n "$ODYSSEUS_OLLAMA_CONTAINER" ]; then')
lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote("docker exec ${ODYSSEUS_OLLAMA_CONTAINER} " + ollama_cmd)}')
lines.append(' fi')
elif docker_fallback_blocked:
hint = shlex.quote("ERROR: " + HOST_DOCKER_ACCESS_HINT)
lines.append('else')
lines.append(f" printf '%s\\n' {hint}; exit 127")
lines.append('fi')
lines.append('if [ -z "$ODYSSEUS_OLLAMA_PULL_CMD" ]; then echo "ERROR: Ollama not found on this server. Install Ollama or start an ollama-rocm/ollama-test container."; exit 127; fi')
def setup_cookbook_routes() -> APIRouter: def setup_cookbook_routes() -> APIRouter:
router = APIRouter(tags=["cookbook"]) router = APIRouter(tags=["cookbook"])
_cookbook_state_path = Path(COOKBOOK_STATE_FILE) _cookbook_state_path = Path(COOKBOOK_STATE_FILE)
@@ -411,43 +594,6 @@ def setup_cookbook_routes() -> APIRouter:
def _needs_binary(cmd: str, binary: str) -> bool: def _needs_binary(cmd: str, binary: str) -> bool:
return bool(re.search(rf"(^|[\s;&|()]){re.escape(binary)}($|[\s;&|()])", cmd or "")) return bool(re.search(rf"(^|[\s;&|()]){re.escape(binary)}($|[\s;&|()])", cmd or ""))
def _missing_binary_message(binary: str, target: str) -> str:
if binary == "tmux":
return (
f"tmux is required for Cookbook background downloads/serves on {target}. "
"Install it with your OS package manager, or run Cookbook server setup for that server."
)
if binary == "docker":
return (
f"Docker is required by this Cookbook launch command on {target}, but the docker CLI was not found. "
"Install Docker and make sure this user can run `docker`, then retry."
)
return f"{binary} is required on {target}, but it was not found."
async def _remote_binary_available(remote: str, ssh_port: str | None, binary: str, *, windows: bool = False) -> bool:
_port = ssh_port or ""
_pf = ["-p", _port] if _port and _port != "22" else []
if windows:
check = f"powershell -NoProfile -Command \"if (Get-Command {binary} -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 127 }}\""
else:
check = f"command -v {shlex.quote(binary)} >/dev/null 2>&1"
try:
proc = await asyncio.create_subprocess_exec(
"ssh", "-o", "ConnectTimeout=6", "-o", "StrictHostKeyChecking=no",
*_pf, remote, check,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.communicate(), timeout=10)
return proc.returncode == 0
except Exception:
return False
async def _binary_available(binary: str, remote: str | None, ssh_port: str | None, *, windows: bool = False) -> bool:
if remote:
return await _remote_binary_available(remote, ssh_port, binary, windows=windows)
return shutil.which(binary) is not None
def _launch_local_detached(session_id: str, bash_lines: list[str]) -> dict: def _launch_local_detached(session_id: str, bash_lines: list[str]) -> dict:
"""Windows-native stand-in for a LOCAL tmux session (tmux doesn't exist """Windows-native stand-in for a LOCAL tmux session (tmux doesn't exist
on Windows). Mirrors shell_routes._generate_win_detached / bg_jobs.launch: on Windows). Mirrors shell_routes._generate_win_detached / bg_jobs.launch:
@@ -576,15 +722,12 @@ def setup_cookbook_routes() -> APIRouter:
# slower-but-reliable downloader (resumes cleanly from the .incomplete files). # slower-but-reliable downloader (resumes cleanly from the .incomplete files).
# Use `python3 -m pip` not `pip` — macOS has no bare `pip` command. # Use `python3 -m pip` not `pip` — macOS has no bare `pip` command.
if is_ollama_download: if is_ollama_download:
lines.append('if command -v ollama >/dev/null 2>&1; then') _append_local_ollama_download_command_lines(
lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote(ollama_cmd)}') lines,
lines.append('elif command -v docker >/dev/null 2>&1; then') ollama_cmd,
lines.append(' ODYSSEUS_OLLAMA_CONTAINER="$(docker ps --format \'{{.Names}}\' 2>/dev/null | grep -E \'^(ollama-rocm|ollama-test)$\' | head -1)"') docker_fallback_available=_local_ollama_docker_fallback_available(),
lines.append(' if [ -n "$ODYSSEUS_OLLAMA_CONTAINER" ]; then') docker_fallback_blocked=_local_ollama_docker_access_blocked(),
lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote("docker exec ${ODYSSEUS_OLLAMA_CONTAINER} " + ollama_cmd)}') )
lines.append(' fi')
lines.append('fi')
lines.append('if [ -z "$ODYSSEUS_OLLAMA_PULL_CMD" ]; then echo "ERROR: Ollama not found on this server. Install Ollama or start an ollama-rocm/ollama-test container."; exit 127; fi')
else: else:
lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', upgrade=True)}") lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', upgrade=True)}")
if req.disable_hf_transfer: if req.disable_hf_transfer:
@@ -1322,13 +1465,18 @@ def setup_cookbook_routes() -> APIRouter:
req.gpus = _validate_gpus(req.gpus) req.gpus = _validate_gpus(req.gpus)
req.hf_token = req.hf_token or _load_stored_hf_token() req.hf_token = req.hf_token or _load_stored_hf_token()
_validate_token(req.hf_token) _validate_token(req.hf_token)
# Normalize away backslash-newline continuations (multi-line pasted # Cookbook emits two fixed Docker exec forms for its Ollama sidecars.
# serve commands) so the cleaned single-line command is what gets # Keep Docker out of the general allowlist: only these parsed shapes may
# written into the runner script and used for engine auto-detection. # proceed to the target-aware Docker availability/opt-in preflight.
# `_validate_serve_cmd` returns None for empty input; coerce to "" so the if _is_generated_ollama_docker_exec_cmd(req.cmd):
# many downstream `"engine" in req.cmd` membership checks can't hit req.cmd = req.cmd.strip()
# `TypeError: argument of type 'NoneType'` (a 500 instead of a clean 400). else:
req.cmd = _validate_serve_cmd(req.cmd) or "" # Normalize away backslash-newline continuations (multi-line pasted
# serve commands) so the cleaned single-line command is what gets
# written into the runner script and used for engine auto-detection.
# `_validate_serve_cmd` returns None for empty input; coerce to "" so
# downstream `"engine" in req.cmd` checks cannot raise TypeError.
req.cmd = _validate_serve_cmd(req.cmd) or ""
req.cmd = _normalize_llama_cpp_python_cache_types(req.cmd) or "" req.cmd = _normalize_llama_cpp_python_cache_types(req.cmd) or ""
req.cmd = _normalize_minimax_m3_vllm_cmd(req.cmd) req.cmd = _normalize_minimax_m3_vllm_cmd(req.cmd)
req.cmd = _venv_safe_local_pip_install_cmd( req.cmd = _venv_safe_local_pip_install_cmd(
@@ -1406,9 +1554,18 @@ def setup_cookbook_routes() -> APIRouter:
"session_id": session_id, "session_id": session_id,
} }
if _needs_binary(req.cmd, "docker") and not await _binary_available("docker", remote, req.ssh_port, windows=is_windows): if _needs_binary(req.cmd, "docker") and not await _binary_available("docker", remote, req.ssh_port, windows=is_windows):
local_host_docker_blocked = (
not remote
and running_in_container()
and not host_docker_access_enabled()
)
return { return {
"ok": False, "ok": False,
"error": _missing_binary_message("docker", remote or "local server"), "error": _missing_binary_message(
"docker",
remote or "local server",
local_host_docker_blocked=local_host_docker_blocked,
),
"session_id": session_id, "session_id": session_id,
} }
+13 -20
View File
@@ -16,6 +16,11 @@ from pathlib import Path
from typing import Dict, Any from typing import Dict, Any
from core.platform_compat import IS_APPLE_SILICON, which_tool from core.platform_compat import IS_APPLE_SILICON, which_tool
from core.middleware import INTERNAL_TOOL_USER from core.middleware import INTERNAL_TOOL_USER
from src.host_docker_access import (
HOST_DOCKER_ACCESS_HINT,
host_docker_access_enabled as _host_docker_access_enabled,
running_in_container as _running_in_container,
)
from src.optional_deps import prepare_optional_dependency_import from src.optional_deps import prepare_optional_dependency_import
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist # POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
@@ -103,32 +108,17 @@ logger = logging.getLogger(__name__)
PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid") PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid")
DOCKER_IN_CONTAINER_HINT = ( DOCKER_IN_CONTAINER_HINT = HOST_DOCKER_ACCESS_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"]) DockerRowStatus = namedtuple("DockerRowStatus", ["applicable", "install_hint"])
PackageUpdateStatus = namedtuple("PackageUpdateStatus", ["available", "note"]) PackageUpdateStatus = namedtuple("PackageUpdateStatus", ["available", "note"])
def _docker_row_status(*, on_remote, in_container, installed, default_hint): def _docker_row_status(
local_docker_unavailable = not on_remote and in_container and not installed *, on_remote, in_container, installed, default_hint, host_docker_access=False
):
local_docker_unavailable = not on_remote and in_container and not host_docker_access
if local_docker_unavailable: if local_docker_unavailable:
return DockerRowStatus(applicable=False, install_hint=DOCKER_IN_CONTAINER_HINT) return DockerRowStatus(applicable=False, install_hint=DOCKER_IN_CONTAINER_HINT)
return DockerRowStatus(applicable=True, install_hint=default_hint) return DockerRowStatus(applicable=True, install_hint=default_hint)
@@ -1510,6 +1500,9 @@ def setup_shell_routes() -> APIRouter:
in_container=_running_in_container() if not on_remote else False, in_container=_running_in_container() if not on_remote else False,
installed=pkg["installed"], installed=pkg["installed"],
default_hint=pkg.get("install_hint"), default_hint=pkg.get("install_hint"),
host_docker_access=(
_host_docker_access_enabled() if not on_remote else False
),
) )
pkg["applicable"] = status.applicable pkg["applicable"] = status.applicable
pkg["install_hint"] = status.install_hint pkg["install_hint"] = status.install_hint
+62
View File
@@ -0,0 +1,62 @@
"""Policy checks for explicit host Docker access from a container."""
import os
import stat
from collections.abc import Mapping
HOST_DOCKER_ENV_VAR = "ODYSSEUS_ENABLE_HOST_DOCKER"
HOST_DOCKER_SOCKET_PATH = "/var/run/docker.sock"
HOST_DOCKER_ACCESS_HINT = (
"Local Docker daemon access is disabled inside the Odysseus container; a "
"Docker CLI alone is not enough. Default Docker Compose intentionally does "
"not mount the host Docker socket. Raw socket access is high-trust and can "
"grant broad control over the host Docker daemon. If you accept that risk, "
"enable docker/host-docker.yml. Remote server Docker workflows over SSH "
"remain preferred."
)
def running_in_container(
dockerenv_path: str = "/.dockerenv",
cgroup_path: str = "/proc/1/cgroup",
) -> bool:
if os.path.exists(dockerenv_path):
return True
try:
with open(cgroup_path, "r", encoding="utf-8") as handle:
contents = handle.read()
except OSError:
return False
return any(token in contents for token in ("docker", "containerd", "kubepods"))
def host_docker_access_enabled(
socket_path: str = HOST_DOCKER_SOCKET_PATH,
*,
environ: Mapping[str, str] | None = None,
) -> bool:
env = os.environ if environ is None else environ
if env.get(HOST_DOCKER_ENV_VAR, "").strip().lower() != "true":
return False
try:
mode = os.stat(socket_path).st_mode
except OSError:
return False
return stat.S_ISSOCK(mode)
def local_docker_available(
*,
cli_available: bool,
in_container: bool | None = None,
environ: Mapping[str, str] | None = None,
socket_path: str = HOST_DOCKER_SOCKET_PATH,
) -> bool:
if not cli_available:
return False
containerized = running_in_container() if in_container is None else in_container
if not containerized:
return True
return host_docker_access_enabled(socket_path, environ=environ)
+339
View File
@@ -0,0 +1,339 @@
import socket
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from starlette.requests import Request
import routes.cookbook_routes as cookbook_routes
from routes.cookbook_helpers import ServeRequest, _validate_serve_cmd
from src.host_docker_access import HOST_DOCKER_ACCESS_HINT
def _model_serve_endpoint():
router = cookbook_routes.setup_cookbook_routes()
for route in router.routes:
if route.path == "/api/model/serve" and "POST" in route.methods:
return route.endpoint
raise AssertionError("POST /api/model/serve route not found")
def _admin_request() -> Request:
request = Request(
{
"type": "http",
"method": "POST",
"path": "/api/model/serve",
"headers": [],
"state": {},
}
)
request.state.current_user = "admin"
return request
@pytest.mark.asyncio
async def test_container_cli_only_is_rejected(monkeypatch, tmp_path):
monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker")
available = await cookbook_routes._binary_available(
"docker",
None,
None,
in_container=True,
environ={},
socket_path=str(tmp_path / "missing.sock"),
)
assert available is False
message = cookbook_routes._missing_binary_message(
"docker",
"local server",
local_host_docker_blocked=True,
)
assert message == HOST_DOCKER_ACCESS_HINT
assert "docker/host-docker.yml" in message
@pytest.mark.asyncio
async def test_container_opt_in_with_unix_socket_is_allowed(monkeypatch, tmp_path):
monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker")
socket_path = tmp_path / "docker.sock"
with socket.socket(socket.AF_UNIX) as unix_socket:
unix_socket.bind(str(socket_path))
available = await cookbook_routes._binary_available(
"docker",
None,
None,
in_container=True,
environ={"ODYSSEUS_ENABLE_HOST_DOCKER": "true"},
socket_path=str(socket_path),
)
assert available is True
@pytest.mark.asyncio
async def test_native_local_docker_still_uses_cli_presence(monkeypatch, tmp_path):
monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker")
available = await cookbook_routes._binary_available(
"docker",
None,
None,
in_container=False,
environ={},
socket_path=str(tmp_path / "missing.sock"),
)
assert available is True
@pytest.mark.asyncio
async def test_remote_docker_still_uses_ssh_probe(monkeypatch):
remote_probe = AsyncMock(return_value=True)
monkeypatch.setattr(cookbook_routes, "_remote_binary_available", remote_probe)
monkeypatch.setattr(
cookbook_routes.shutil,
"which",
lambda binary: pytest.fail("remote checks must not inspect the local CLI"),
)
available = await cookbook_routes._binary_available(
"docker",
"gpu-server",
"2222",
windows=True,
in_container=True,
environ={},
socket_path="/missing/docker.sock",
)
assert available is True
remote_probe.assert_awaited_once_with(
"gpu-server",
"2222",
"docker",
windows=True,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"cmd",
[
"docker exec ollama-test ollama-import example/model model 8192 model.gguf",
"docker exec ollama-rocm ollama show llama3",
],
)
async def test_local_container_serve_returns_host_docker_opt_in_hint(
monkeypatch,
tmp_path,
cmd,
):
async def binary_available(binary, remote, ssh_port, **kwargs):
assert remote is None
if binary == "tmux":
return True
assert cookbook_routes.shutil.which(binary) == "/usr/bin/docker"
return False
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
monkeypatch.setattr(
cookbook_routes,
"host_docker_access_enabled",
lambda: False,
)
monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker")
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmp_path)
monkeypatch.setattr(
cookbook_routes,
"load_stored_hf_token",
lambda **kwargs: "",
)
response = await _model_serve_endpoint()(
_admin_request(),
ServeRequest(
repo_id="example/model",
cmd=cmd,
),
)
assert response["ok"] is False
assert response["error"] == HOST_DOCKER_ACCESS_HINT
assert "cmd binary 'docker' is not allowed" not in response["error"]
assert "docker/host-docker.yml" in response["error"]
@pytest.mark.asyncio
async def test_local_container_serve_allows_generated_docker_exec_when_enabled(
monkeypatch,
tmp_path,
):
checked_binaries = []
launched_commands = []
async def binary_available(binary, remote, ssh_port, **kwargs):
checked_binaries.append(binary)
if binary == "docker":
assert cookbook_routes.running_in_container() is True
assert cookbook_routes.host_docker_access_enabled() is True
return True
class _Stderr:
async def read(self):
return b"mock launch stopped"
class _Process:
returncode = 1
stderr = _Stderr()
async def wait(self):
return None
async def launch(command, **kwargs):
launched_commands.append(command)
return _Process()
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
monkeypatch.setattr(
cookbook_routes,
"host_docker_access_enabled",
lambda: True,
)
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmp_path)
monkeypatch.setattr(
cookbook_routes,
"load_stored_hf_token",
lambda **kwargs: "",
)
monkeypatch.setattr(
cookbook_routes.asyncio,
"create_subprocess_shell",
launch,
)
response = await _model_serve_endpoint()(
_admin_request(),
ServeRequest(
repo_id="llama3",
cmd="docker exec ollama-rocm ollama show llama3",
),
)
assert checked_binaries == ["tmux", "docker"]
assert launched_commands
assert response["error"] == "mock launch stopped"
runner = next(tmp_path.glob("serve-*_run.sh")).read_text(encoding="utf-8")
assert "docker exec ollama-rocm ollama show llama3" in runner
@pytest.mark.parametrize(
"cmd",
[
"docker run --rm alpine",
"docker exec random-container ollama show llama3",
"docker compose up",
"docker exec ollama-rocm ollama rm llama3",
"docker exec ollama-test ollama rm llama3",
"docker exec ollama-rocm ollama pull llama3",
"docker exec ollama-test ollama show llama3",
"docker exec ollama-test sh -c 'ollama show llama3'",
"docker exec ollama-test ollama show llama3; id",
"docker exec ollama-rocm ollama show llama3 extra",
"docker exec ollama-rocm ollama show llama3?",
"docker exec ollama-test ollama-import org/model model many model.gguf",
"docker exec ollama-test ollama-import org/model model 8192 path/model.gguf",
"docker exec ollama-rocm ollama show $(id)",
"docker exec ollama-rocm ollama show llama3 | cat",
],
)
def test_arbitrary_docker_commands_stay_blocked(cmd):
assert cookbook_routes._is_generated_ollama_docker_exec_cmd(cmd) is False
with pytest.raises(HTTPException) as exc:
_validate_serve_cmd(cmd)
assert exc.value.status_code == 400
def test_generated_ollama_import_shape_is_narrowly_allowed():
assert cookbook_routes._is_generated_ollama_docker_exec_cmd(
"docker exec ollama-test ollama-import org/model model 8192 model.gguf"
)
assert cookbook_routes._is_generated_ollama_docker_exec_cmd(
"docker exec ollama-test ollama-import org/model model 8192"
)
assert not cookbook_routes._is_generated_ollama_docker_exec_cmd(
"docker exec ollama-rocm ollama-import org/model model 8192 model.gguf"
)
def test_generated_ollama_show_shape_is_narrowly_allowed():
assert cookbook_routes._is_generated_ollama_docker_exec_cmd(
"docker exec ollama-rocm ollama show llama3:latest"
)
def test_local_ollama_docker_access_blocked_in_container_cli_only(monkeypatch, tmp_path):
monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker")
assert cookbook_routes._local_ollama_docker_access_blocked(
in_container=True,
environ={},
socket_path=str(tmp_path / "missing.sock"),
) is True
def test_local_ollama_docker_access_not_blocked_for_native_cli(monkeypatch, tmp_path):
monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker")
assert cookbook_routes._local_ollama_docker_access_blocked(
in_container=False,
environ={},
socket_path=str(tmp_path / "missing.sock"),
) is False
def test_local_ollama_download_probe_omits_docker_commands_when_blocked():
lines = []
cookbook_routes._append_local_ollama_download_command_lines(
lines,
"ollama pull llama3:latest",
docker_fallback_available=False,
docker_fallback_blocked=True,
)
rendered = "\n".join(lines)
assert "command -v docker" not in rendered
assert "docker ps" not in rendered
assert "docker exec" not in rendered
assert "ODYSSEUS_OLLAMA_PULL_CMD" in rendered
assert "docker/host-docker.yml" in rendered
assert "exit 127" in rendered
def test_local_ollama_download_probe_keeps_docker_fallback_when_allowed():
lines = []
cookbook_routes._append_local_ollama_download_command_lines(
lines,
"ollama pull llama3:latest",
docker_fallback_available=True,
docker_fallback_blocked=False,
)
rendered = "\n".join(lines)
assert "docker ps" in rendered
assert "docker exec ${ODYSSEUS_OLLAMA_CONTAINER}" in rendered
assert "ODYSSEUS_OLLAMA_PULL_CMD" in rendered
+33
View File
@@ -17,6 +17,7 @@ COMPOSE_FILES = [
ROOT / "docker-compose.gpu-nvidia.yml", ROOT / "docker-compose.gpu-nvidia.yml",
ROOT / "docker-compose.gpu-amd.yml", ROOT / "docker-compose.gpu-amd.yml",
] ]
HOST_DOCKER_OVERLAY = ROOT / "docker" / "host-docker.yml"
TEST_DOCS = [ TEST_DOCS = [
ROOT / "tests" / "README.md", ROOT / "tests" / "README.md",
ROOT / "tests" / "TESTING_STANDARD.md", ROOT / "tests" / "TESTING_STANDARD.md",
@@ -54,6 +55,38 @@ def test_compose_files_forward_every_upload_limit_env_var():
assert expected <= _compose_env_names(path), path.name assert expected <= _compose_env_names(path), path.name
def test_default_compose_files_do_not_mount_host_docker_socket():
for path in COMPOSE_FILES:
text = path.read_text(encoding="utf-8")
assert "/var/run/docker.sock" not in text, path.name
def test_host_docker_overlay_mounts_socket_and_adds_docker_group():
overlay = yaml.safe_load(HOST_DOCKER_OVERLAY.read_text(encoding="utf-8"))
service = overlay["services"]["odysseus"]
assert "/var/run/docker.sock:/var/run/docker.sock" in service["volumes"]
assert "${DOCKER_GID:-963}" in service["group_add"]
assert "ODYSSEUS_ENABLE_HOST_DOCKER=true" in service["environment"]
def test_docker_entrypoint_gates_socket_group_plumbing_on_explicit_opt_in():
script = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
block_start = script.index("DOCKER_SOCK=\"${DOCKER_SOCK:-/var/run/docker.sock}\"")
block_end = script.index("\nmount_root_for()", block_start)
socket_group_block = script[block_start:block_end]
opt_in_check = socket_group_block.index(
"[ \"${ODYSSEUS_ENABLE_HOST_DOCKER:-}\" = \"true\" ]"
)
socket_check = socket_group_block.index("[ -S \"$DOCKER_SOCK\" ]")
stat_socket = socket_group_block.index("stat -c")
add_group = socket_group_block.index("groupadd -g")
add_user_group = socket_group_block.index("usermod -aG")
assert opt_in_check < socket_check < stat_socket < add_group < add_user_group
def test_docker_entrypoint_does_not_resolve_root_commands_from_app_local_path(): def test_docker_entrypoint_does_not_resolve_root_commands_from_app_local_path():
script = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8") script = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
path_export = script.index('export PATH="/app/.local/bin:$PATH"') path_export = script.index('export PATH="/app/.local/bin:$PATH"')
+69 -4
View File
@@ -20,6 +20,7 @@ ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / "docker-compose.yml" BASE = ROOT / "docker-compose.yml"
NVIDIA_OVERLAY = ROOT / "docker" / "gpu.nvidia.yml" NVIDIA_OVERLAY = ROOT / "docker" / "gpu.nvidia.yml"
AMD_OVERLAY = ROOT / "docker" / "gpu.amd.yml" AMD_OVERLAY = ROOT / "docker" / "gpu.amd.yml"
HOST_DOCKER_OVERLAY = ROOT / "docker" / "host-docker.yml"
NVIDIA_STANDALONE = ROOT / "docker-compose.gpu-nvidia.yml" NVIDIA_STANDALONE = ROOT / "docker-compose.gpu-nvidia.yml"
AMD_STANDALONE = ROOT / "docker-compose.gpu-amd.yml" AMD_STANDALONE = ROOT / "docker-compose.gpu-amd.yml"
@@ -61,6 +62,13 @@ def _merge_overlay_into_base(base: dict, overlay: dict) -> dict:
return expected return expected
def _merge_overlays_into_base(base: dict, *overlays: dict) -> dict:
merged = copy.deepcopy(base)
for overlay in overlays:
merged = _merge_overlay_into_base(merged, overlay)
return merged
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def base(): def base():
return _load(BASE) return _load(BASE)
@@ -124,9 +132,10 @@ def test_nvidia_odysseus_adds_only_overlay(base):
{"driver": "nvidia", "count": "all", "capabilities": ["gpu"]} {"driver": "nvidia", "count": "all", "capabilities": ["gpu"]}
] ]
# Base Docker socket group is preserved; no AMD-only keys leaked in. # No Docker or AMD groups are added.
assert "devices" not in svc assert "devices" not in svc
assert svc["group_add"] == base_svc["group_add"] assert "group_add" not in base_svc
assert "group_add" not in svc
def test_amd_odysseus_adds_only_overlay(base): def test_amd_odysseus_adds_only_overlay(base):
@@ -137,10 +146,66 @@ def test_amd_odysseus_adds_only_overlay(base):
# Environment is unchanged from base for AMD. # Environment is unchanged from base for AMD.
assert svc["environment"] == base_svc["environment"] assert svc["environment"] == base_svc["environment"]
# devices are new; group_add preserves the base Docker group and appends AMD groups. # Devices and GPU-only groups are added.
assert "devices" not in base_svc assert "devices" not in base_svc
assert svc["devices"] == ["/dev/kfd", "/dev/dri"] assert svc["devices"] == ["/dev/kfd", "/dev/dri"]
assert svc["group_add"] == base_svc["group_add"] + ["video", "${RENDER_GID:-render}"] assert "group_add" not in base_svc
assert svc["group_add"] == ["video", "${RENDER_GID:-render}"]
# No NVIDIA-only keys leaked in. # No NVIDIA-only keys leaked in.
assert "deploy" not in svc assert "deploy" not in svc
# --- Host Docker opt-in combinations ---------------------------------------
def test_base_has_no_host_docker_access(base):
service = base["services"][SERVICE]
assert "/var/run/docker.sock:/var/run/docker.sock" not in service["volumes"]
assert "ODYSSEUS_ENABLE_HOST_DOCKER=true" not in service["environment"]
assert "group_add" not in service
def test_base_plus_host_docker_overlay_has_explicit_access(base):
merged = _merge_overlays_into_base(base, _load(HOST_DOCKER_OVERLAY))
service = merged["services"][SERVICE]
assert "/var/run/docker.sock:/var/run/docker.sock" in service["volumes"]
assert "ODYSSEUS_ENABLE_HOST_DOCKER=true" in service["environment"]
assert service["group_add"] == ["${DOCKER_GID:-963}"]
def test_nvidia_plus_host_docker_preserves_gpu_and_docker_access(base):
merged = _merge_overlays_into_base(
base,
_load(NVIDIA_OVERLAY),
_load(HOST_DOCKER_OVERLAY),
)
service = merged["services"][SERVICE]
devices = service["deploy"]["resources"]["reservations"]["devices"]
assert devices == [
{"driver": "nvidia", "count": "all", "capabilities": ["gpu"]}
]
assert "/var/run/docker.sock:/var/run/docker.sock" in service["volumes"]
assert "ODYSSEUS_ENABLE_HOST_DOCKER=true" in service["environment"]
assert service["group_add"] == ["${DOCKER_GID:-963}"]
def test_amd_plus_host_docker_preserves_gpu_and_docker_groups(base):
merged = _merge_overlays_into_base(
base,
_load(AMD_OVERLAY),
_load(HOST_DOCKER_OVERLAY),
)
service = merged["services"][SERVICE]
assert service["devices"] == ["/dev/kfd", "/dev/dri"]
assert service["group_add"] == [
"video",
"${RENDER_GID:-render}",
"${DOCKER_GID:-963}",
]
assert "/var/run/docker.sock:/var/run/docker.sock" in service["volumes"]
assert "ODYSSEUS_ENABLE_HOST_DOCKER=true" in service["environment"]
+59 -2
View File
@@ -5,6 +5,7 @@ import importlib
import importlib.util import importlib.util
import json import json
import os import os
import socket
import sys import sys
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -13,6 +14,7 @@ import pytest
from routes.shell_routes import ( from routes.shell_routes import (
_find_line_break, _find_line_break,
_host_docker_access_enabled,
_import_optional_dependency_for_status, _import_optional_dependency_for_status,
_running_in_container, _running_in_container,
_docker_row_status, _docker_row_status,
@@ -216,13 +218,24 @@ class TestDockerRowStatus:
assert status.applicable is False assert status.applicable is False
assert status.install_hint == DOCKER_IN_CONTAINER_HINT assert status.install_hint == DOCKER_IN_CONTAINER_HINT
def test_in_container_but_present_is_applicable_with_default_hint(self): def test_in_container_cli_without_opt_in_is_not_applicable(self):
status = _docker_row_status( status = _docker_row_status(
on_remote=False, on_remote=False,
in_container=True, in_container=True,
installed=True, installed=True,
default_hint=self.DEFAULT, default_hint=self.DEFAULT,
) )
assert status.applicable is False
assert status.install_hint == DOCKER_IN_CONTAINER_HINT
def test_in_container_opt_in_with_socket_is_applicable(self):
status = _docker_row_status(
on_remote=False,
in_container=True,
installed=True,
default_hint=self.DEFAULT,
host_docker_access=True,
)
assert status.applicable is True assert status.applicable is True
assert status.install_hint == self.DEFAULT assert status.install_hint == self.DEFAULT
@@ -260,7 +273,51 @@ class TestDockerRowStatus:
lowered = DOCKER_IN_CONTAINER_HINT.lower() lowered = DOCKER_IN_CONTAINER_HINT.lower()
assert "remote" in lowered assert "remote" in lowered
assert "socket" in lowered assert "socket" in lowered
assert "host-root" in lowered or "host root" in lowered assert "high-trust" in lowered
assert "docker/host-docker.yml" in lowered
class TestHostDockerAccess:
def test_opt_in_without_socket_is_disabled(self, monkeypatch, tmp_path):
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true")
assert _host_docker_access_enabled(str(tmp_path / "missing.sock")) is False
def test_regular_file_is_not_accepted(self, monkeypatch, tmp_path):
socket_path = tmp_path / "docker.sock"
socket_path.touch()
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true")
assert _host_docker_access_enabled(str(socket_path)) is False
@pytest.mark.parametrize("flag", [None, "false"])
def test_socket_without_explicit_opt_in_is_disabled(
self,
monkeypatch,
tmp_path,
flag,
):
socket_path = tmp_path / "docker.sock"
with socket.socket(socket.AF_UNIX) as unix_socket:
unix_socket.bind(str(socket_path))
if flag is None:
monkeypatch.delenv("ODYSSEUS_ENABLE_HOST_DOCKER", raising=False)
else:
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", flag)
assert _host_docker_access_enabled(str(socket_path)) is False
def test_explicit_opt_in_with_unix_socket_is_enabled(
self,
monkeypatch,
tmp_path,
):
socket_path = tmp_path / "docker.sock"
with socket.socket(socket.AF_UNIX) as unix_socket:
unix_socket.bind(str(socket_path))
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true")
assert _host_docker_access_enabled(str(socket_path)) is True
class TestPackageProbeStatus: class TestPackageProbeStatus: