From c7a8637475a710cb25f97f6c616bea8b58c6e8c4 Mon Sep 17 00:00:00 2001 From: rauljua <9117159+rauljua@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:05:38 -0700 Subject: [PATCH] fix(docker): repair app cache parent ownership (#6158) * fix(docker): repair app cache parent ownership * fix(docker): avoid walking mounted model cache * test(docker): exercise nested cache ownership --------- Co-authored-by: Raul <9117159+raultcj@users.noreply.github.com> --- docker/entrypoint.sh | 11 +++- tests/test_docker_devops_hardening.py | 84 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index aec3b8eec..5ad824a5a 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -96,7 +96,16 @@ repair_bind_mount_ownership() { # Repair image-owned writable paths without walking into bind-mounted host # trees, then repair the app-owned mount roots separately. repair_app_tree_ownership -for dir in /app/data /app/logs /app/.ssh /app/.cache/huggingface /app/.local; do +# Docker creates the parent of the HuggingFace bind mount as root before this +# entrypoint runs. Repair only the parent directory itself so app-user caches +# such as /app/.cache/vllm and /app/.cache/flashinfer can be created without +# recursively walking the mounted model cache. +chown "$PUID:$PGID" /app/.cache 2>/dev/null || true +# The Hugging Face cache can contain hundreds of gigabytes and is a nested +# mount with its own ownership contract. Repair its mount root so new cache +# entries are writable, but never traverse or rewrite existing model files. +chown "$PUID:$PGID" /app/.cache/huggingface 2>/dev/null || true +for dir in /app/data /app/logs /app/.ssh /app/.local; do repair_bind_mount_ownership "$dir" done diff --git a/tests/test_docker_devops_hardening.py b/tests/test_docker_devops_hardening.py index 2c4530e9c..c5d1a9f60 100644 --- a/tests/test_docker_devops_hardening.py +++ b/tests/test_docker_devops_hardening.py @@ -1,9 +1,14 @@ """Static regressions for Docker/devops hardening contracts.""" import ast +import os import re +import shutil +import subprocess +import uuid from pathlib import Path +import pytest import yaml from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware @@ -115,6 +120,85 @@ def test_docker_entrypoint_ownership_repair_stays_inside_expected_mounts(): assert "Skipping recursive ownership repair" in script +def test_docker_entrypoint_repairs_cache_parent_without_recursive_walk(): + """Pin the hard-coded container-path contract without running entrypoint as root.""" + script = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8") + app_repair = script.index("repair_app_tree_ownership\n") + cache_parent_repair = script.index( + 'chown "$PUID:$PGID" /app/.cache 2>/dev/null || true' + ) + mounted_cache_root_repair = script.index( + 'chown "$PUID:$PGID" /app/.cache/huggingface 2>/dev/null || true' + ) + + assert app_repair < cache_parent_repair < mounted_cache_root_repair + assert 'repair_tree_ownership "/app/.cache"' not in script + assert 'repair_bind_mount_ownership "/app/.cache/huggingface"' not in script + + +@pytest.mark.skipif(shutil.which("docker") is None, reason="Docker CLI is unavailable") +def test_docker_entrypoint_cache_parent_with_nested_volume(): + """Run the real entrypoint against a disposable nested-volume layout.""" + image = os.environ.get("ODYSSEUS_DOCKER_TEST_IMAGE", "odysseus-odysseus:latest") + if subprocess.run( + ["docker", "image", "inspect", image], + capture_output=True, + text=True, + check=False, + ).returncode != 0: + pytest.skip(f"Docker test image is unavailable: {image}") + + volume = f"odysseus-cache-parent-test-{uuid.uuid4().hex}" + subprocess.run( + ["docker", "volume", "create", volume], + capture_output=True, + text=True, + check=True, + ) + try: + subprocess.run( + [ + "docker", "run", "--rm", "--pull=never", + "--entrypoint", "sh", + "-v", f"{volume}:/fixture", + image, + "-c", "mkdir -p /fixture/nested && touch /fixture/nested/sentinel", + ], + capture_output=True, + text=True, + check=True, + ) + result = subprocess.run( + [ + "docker", "run", "--rm", "--pull=never", + "-e", "PUID=23456", + "-e", "PGID=23456", + "-v", f"{volume}:/app/.cache/huggingface", + image, + "sh", "-c", + "mkdir -p /app/.cache/vllm && " + "touch /app/.cache/vllm/probe && " + "printf 'CACHE_TEST %s %s %s %s\\n' " + "\"$(stat -c %u /app/.cache)\" " + "\"$(stat -c %u /app/.cache/vllm/probe)\" " + "\"$(stat -c %u /app/.cache/huggingface)\" " + "\"$(stat -c %u /app/.cache/huggingface/nested/sentinel)\"", + ], + capture_output=True, + text=True, + check=True, + ) + finally: + subprocess.run( + ["docker", "volume", "rm", "-f", volume], + capture_output=True, + text=True, + check=False, + ) + + assert "CACHE_TEST 23456 23456 23456 0" in result.stdout + + def test_dockerignore_excludes_secrets_editor_backups(): patterns = set((ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines()) assert {