fix: Docker uv pre-install at build time + workspace permissions (#365)

* fix: pre-install uv in Docker image + fix workspace dir permissions (#357)

Two fixes for Docker startup reliability:

1. Install uv at build time in the Dockerfile so the container works
   without internet access at runtime. The init script now skips the
   download when uv is already on PATH.

2. Use sudo mkdir/chown for the workspace directory, matching the
   pattern used for /app. Docker auto-creates bind-mount directories
   as root, leaving them unwritable by the hermeswebui user.

Fixes #357

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Docker uv pre-install as root to /usr/local/bin + tests + CHANGELOG

Dockerfile: install uv as root with UV_INSTALL_DIR=/usr/local/bin so it
lands in /usr/local/bin (system PATH) rather than /home/hermeswebuitoo/.local/bin
which the hermeswebui runtime user can't see.

tests/test_issue357.py: 15 structural tests covering Dockerfile uv build-time
install (system-wide, as root, before COPY), init script skip-if-present
logic, and workspace sudo mkdir/chown.

CHANGELOG.md: v0.50.17 entry; 915 tests (up from 900)

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nesquena-hermes
2026-04-13 12:36:11 -07:00
committed by GitHub
parent f13f753de8
commit a6484f69a8
4 changed files with 213 additions and 2 deletions

189
tests/test_issue357.py Normal file
View File

@@ -0,0 +1,189 @@
"""
Tests for GitHub issue #357: Docker container fails to start without internet access.
Structural tests — verify Dockerfile and docker_init.bash contain the expected
patterns for pre-installed uv and workspace permission fixes.
Two problems fixed:
1. uv was downloaded at container startup; fails in air-gapped / firewalled environments.
Fix: pre-install uv in the Docker image at build time (system-wide in /usr/local/bin).
2. workspace directory created with plain mkdir (as root); bind-mount dirs created by
Docker as root are unwritable by the hermeswebui user.
Fix: sudo mkdir + sudo chown for workspace directory.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
DOCKERFILE = (REPO / "Dockerfile").read_text(encoding="utf-8")
INIT_SCRIPT = (REPO / "docker_init.bash").read_text(encoding="utf-8")
# ── Dockerfile: uv pre-installed at build time ───────────────────────────────
class TestDockerfileUvPreinstall:
def test_dockerfile_installs_uv_at_build_time(self):
"""Dockerfile must install uv via RUN curl at build time (not only at runtime)."""
assert "RUN curl" in DOCKERFILE and "uv/install.sh" in DOCKERFILE, (
"Dockerfile must install uv at build time via RUN curl .../uv/install.sh"
)
def test_dockerfile_uv_installed_system_wide(self):
"""uv must be installed to a system-wide directory (/usr/local/bin) accessible
to all users, not to a user-specific ~/.local/bin that another user can't see."""
# The install command must target /usr/local/bin or use root to install globally
uv_install_line = next(
(line for line in DOCKERFILE.splitlines() if "uv/install.sh" in line),
None,
)
assert uv_install_line is not None, "Could not find uv install line in Dockerfile"
# Must either use UV_INSTALL_DIR pointing to /usr/local/bin, or run as root
# (so the default install location is accessible to hermeswebui user)
has_system_dir = "/usr/local/bin" in uv_install_line or "UV_INSTALL_DIR=/usr/local/bin" in DOCKERFILE
assert has_system_dir, (
"uv must be installed to /usr/local/bin (system-wide) so hermeswebui user "
"can find it. Installing as hermeswebuitoo puts it in /home/hermeswebuitoo/.local/bin "
"which is NOT on hermeswebui's PATH."
)
def test_dockerfile_uv_installed_before_copy(self):
"""uv installation must happen before COPY . /apptoo so it's in the image."""
uv_pos = DOCKERFILE.find("uv/install.sh")
copy_pos = DOCKERFILE.find("COPY . /apptoo")
assert uv_pos != -1, "uv install not found in Dockerfile"
assert copy_pos != -1, "COPY . /apptoo not found in Dockerfile"
assert uv_pos < copy_pos, "uv must be installed before COPY . /apptoo"
def test_dockerfile_uv_installed_as_root_or_before_user_switch(self):
"""uv must be installed as root (USER root) to reach /usr/local/bin.
If installed as hermeswebuitoo, it lands in ~hermeswebuitoo/.local/bin,
which the hermeswebui user at runtime can't see.
"""
lines = DOCKERFILE.splitlines()
uv_line_idx = next(i for i, l in enumerate(lines) if "uv/install.sh" in l)
# Find the last USER directive before the uv install line
user_before = None
for i in range(uv_line_idx - 1, -1, -1):
if lines[i].strip().startswith("USER "):
user_before = lines[i].strip().split()[1]
break
assert user_before == "root", (
f"uv install must run as USER root (found USER {user_before!r}). "
"Installing as hermeswebuitoo puts uv in /home/hermeswebuitoo/.local/bin "
"which is not accessible to the hermeswebui runtime user."
)
# ── docker_init.bash: skip uv download when already present ─────────────────
class TestInitScriptUvSkip:
def test_init_script_checks_uv_before_download(self):
"""docker_init.bash must check 'command -v uv' before attempting download."""
assert "command -v uv" in INIT_SCRIPT, (
"docker_init.bash must check 'command -v uv' to skip download "
"when uv is already pre-installed in the image (#357)"
)
def test_init_script_skips_download_if_present(self):
"""Init script must use conditional logic (if/else) around the uv download."""
# Pattern: if command -v uv ... else ... fi
assert re.search(r'if\s+command\s+-v\s+uv', INIT_SCRIPT), (
"docker_init.bash must use 'if command -v uv' guard around the download"
)
def test_init_script_curl_download_in_else_branch(self):
"""The curl download must be in the else branch (only runs if uv not found)."""
# Find the conditional block
m = re.search(
r'if\s+command\s+-v\s+uv.*?fi',
INIT_SCRIPT, re.DOTALL
)
assert m, "Could not find uv conditional block in docker_init.bash"
block = m.group(0)
# curl must appear after 'else' not in the 'then' branch
else_pos = block.find("else")
curl_pos = block.find("curl")
assert else_pos != -1, "No 'else' branch in uv conditional"
assert curl_pos != -1, "No 'curl' in uv conditional block"
assert curl_pos > else_pos, (
"curl download must be in the 'else' branch, not the 'if/then' branch"
)
def test_init_script_error_exit_on_download_failure(self):
"""Curl download must call error_exit on failure (not silently continue)."""
assert "error_exit" in INIT_SCRIPT and "Failed to install uv" in INIT_SCRIPT, (
"docker_init.bash must call error_exit if uv download fails, "
"so the container exits with a clear message instead of failing silently"
)
def test_init_script_path_includes_hermeswebui_local_bin(self):
"""PATH must include /home/hermeswebui/.local/bin for fallback runtime install."""
assert "/home/hermeswebui/.local/bin" in INIT_SCRIPT, (
"docker_init.bash must include /home/hermeswebui/.local/bin in PATH "
"for the case where uv is installed at runtime via curl"
)
# ── docker_init.bash: workspace directory permissions ────────────────────────
class TestWorkspacePermissions:
def test_workspace_uses_sudo_mkdir(self):
"""docker_init.bash must use 'sudo mkdir' for the workspace directory.
Docker auto-creates bind-mount directories as root if they don't exist,
leaving them unwritable by hermeswebui. sudo mkdir + chown fixes this.
"""
# Find the workspace section
ws_section = INIT_SCRIPT[
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE"):
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE") + 800
]
assert "sudo mkdir" in ws_section, (
"docker_init.bash must use 'sudo mkdir -p' for the workspace directory "
"to handle the case where Docker created the bind-mount dir as root (#357)"
)
def test_workspace_uses_sudo_chown(self):
"""docker_init.bash must chown the workspace to hermeswebui after mkdir."""
ws_section = INIT_SCRIPT[
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE"):
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE") + 800
]
assert "sudo chown" in ws_section and "hermeswebui" in ws_section, (
"docker_init.bash must 'sudo chown hermeswebui:hermeswebui' the workspace "
"directory after creating it, so the app user can write to it (#357)"
)
def test_workspace_mkdir_before_chown(self):
"""sudo mkdir must come before sudo chown in docker_init.bash."""
mkdir_pos = INIT_SCRIPT.find("sudo mkdir -p \"$HERMES_WEBUI_DEFAULT_WORKSPACE\"")
chown_pos = INIT_SCRIPT.find("sudo chown hermeswebui:hermeswebui \"$HERMES_WEBUI_DEFAULT_WORKSPACE\"")
assert mkdir_pos != -1, "sudo mkdir for workspace not found"
assert chown_pos != -1, "sudo chown for workspace not found"
assert mkdir_pos < chown_pos, "sudo mkdir must come before sudo chown"
def test_workspace_error_exit_on_mkdir_failure(self):
"""sudo mkdir must call error_exit on failure."""
assert 'sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit' in INIT_SCRIPT, (
"sudo mkdir for workspace must call error_exit on failure"
)
def test_workspace_error_exit_on_chown_failure(self):
"""sudo chown must call error_exit on failure."""
assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit' in INIT_SCRIPT, (
"sudo chown for workspace must call error_exit on failure"
)
def test_init_script_syntax_valid(self):
"""docker_init.bash must pass bash -n syntax check."""
import subprocess
result = subprocess.run(
["bash", "-n", str(REPO / "docker_init.bash")],
capture_output=True, text=True
)
assert result.returncode == 0, (
f"docker_init.bash failed bash -n syntax check:\n{result.stderr}"
)