mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
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:
committed by
GitHub
parent
1c1afe5dd1
commit
f38323c3a1
@@ -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)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
+207
-50
@@ -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,12 +1465,17 @@ 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)
|
||||||
|
# Cookbook emits two fixed Docker exec forms for its Ollama sidecars.
|
||||||
|
# Keep Docker out of the general allowlist: only these parsed shapes may
|
||||||
|
# proceed to the target-aware Docker availability/opt-in preflight.
|
||||||
|
if _is_generated_ollama_docker_exec_cmd(req.cmd):
|
||||||
|
req.cmd = req.cmd.strip()
|
||||||
|
else:
|
||||||
# Normalize away backslash-newline continuations (multi-line pasted
|
# Normalize away backslash-newline continuations (multi-line pasted
|
||||||
# serve commands) so the cleaned single-line command is what gets
|
# serve commands) so the cleaned single-line command is what gets
|
||||||
# written into the runner script and used for engine auto-detection.
|
# written into the runner script and used for engine auto-detection.
|
||||||
# `_validate_serve_cmd` returns None for empty input; coerce to "" so the
|
# `_validate_serve_cmd` returns None for empty input; coerce to "" so
|
||||||
# many downstream `"engine" in req.cmd` membership checks can't hit
|
# downstream `"engine" in req.cmd` checks cannot raise TypeError.
|
||||||
# `TypeError: argument of type 'NoneType'` (a 500 instead of a clean 400).
|
|
||||||
req.cmd = _validate_serve_cmd(req.cmd) or ""
|
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)
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"')
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user