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:
@@ -5,6 +5,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [v0.50.17] Docker: pre-install uv at build time + fix workspace permissions (fixes #357)
|
||||||
|
|
||||||
|
- **Docker containers no longer need internet access at startup** (`Dockerfile`): `uv` is now installed at image build time via `RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh` (run as root, so `uv` lands in `/usr/local/bin` — accessible to all users). The init script skips the download if `uv` is already on PATH (`command -v uv`), and falls back to downloading with a proper `error_exit` if it isn't. This fixes startup failures in air-gapped, firewalled, or isolated Docker networks where `github.com` is unreachable at runtime.
|
||||||
|
- **Fix applied during review**: the original PR installed `uv` as the `hermeswebuitoo` user (to `~hermeswebuitoo/.local/bin`), which is not on the `hermeswebui` runtime user's `PATH`. Changed to install as `root` with `UV_INSTALL_DIR=/usr/local/bin` so `uv` is in the system PATH for all users.
|
||||||
|
- **Workspace directory now writable by the hermeswebui user** (`docker_init.bash`): The init script now uses `sudo mkdir -p` and `sudo chown hermeswebui:hermeswebui` for `HERMES_WEBUI_DEFAULT_WORKSPACE`. Docker auto-creates bind-mount directories as `root` if they don't exist on the host, making them unwritable by the app user. The `sudo chown` corrects ownership after creation.
|
||||||
|
- 15 new structural tests in `tests/test_issue357.py`; 915 tests total (up from 900)
|
||||||
|
|
||||||
## [v0.50.16] Fix CSRF check failing behind reverse proxy on non-standard ports (PR #360)
|
## [v0.50.16] Fix CSRF check failing behind reverse proxy on non-standard ports (PR #360)
|
||||||
|
|
||||||
- **CSRF no longer rejects POST requests from reverse-proxied deployments on non-standard ports** (`api/routes.py`, fixes #355): When serving behind Nginx Proxy Manager or similar on a port like `:8000`, browsers send `Origin: https://app.example.com:8000` while the proxy forwards `Host: app.example.com` (port stripped). The old string comparison failed this as cross-origin. Two changes fix it:
|
- **CSRF no longer rejects POST requests from reverse-proxied deployments on non-standard ports** (`api/routes.py`, fixes #355): When serving behind Nginx Proxy Manager or similar on a port like `:8000`, browsers send `Origin: https://app.example.com:8000` while the proxy forwards `Host: app.example.com` (port stripped). The old string comparison failed this as cross-origin. Two changes fix it:
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ RUN touch /.within_container
|
|||||||
RUN rm -rf /var/lib/apt/lists/* /etc/apt/apt.conf.d/01proxy \
|
RUN rm -rf /var/lib/apt/lists/* /etc/apt/apt.conf.d/01proxy \
|
||||||
&& apt-get clean
|
&& apt-get clean
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Pre-install uv system-wide so the container doesn't need internet access at runtime.
|
||||||
|
# Installing as root places uv in /usr/local/bin, available to all users.
|
||||||
|
# The init script will skip the download when uv is already on PATH.
|
||||||
|
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
|
|
||||||
USER hermeswebuitoo
|
USER hermeswebuitoo
|
||||||
|
|
||||||
COPY . /apptoo
|
COPY . /apptoo
|
||||||
|
|||||||
@@ -187,7 +187,10 @@ rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_STATE_DIR"
|
|||||||
echo ""; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: Default workspace directory shown on first launch"
|
echo ""; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: Default workspace directory shown on first launch"
|
||||||
if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then echo "HERMES_WEBUI_DEFAULT_WORKSPACE not set, setting to /workspace"; export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi;
|
if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then echo "HERMES_WEBUI_DEFAULT_WORKSPACE not set, setting to /workspace"; export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi;
|
||||||
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||||
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then mkdir -p $HERMES_WEBUI_DEFAULT_WORKSPACE || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi
|
# Use sudo for mkdir/chown — Docker may auto-create bind-mount directories as root,
|
||||||
|
# leaving them unwritable by the hermeswebui user (#357).
|
||||||
|
sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||||
|
sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to set owner of $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||||
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi
|
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi
|
||||||
it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it || error_exit "Failed to verify default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it || error_exit "Failed to verify default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||||
rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||||
@@ -195,8 +198,13 @@ rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_DEFAULT_WOR
|
|||||||
echo ""; echo "==================="
|
echo ""; echo "==================="
|
||||||
echo ""; echo "== Installing uv and creating a new virtual environment for hermes-webui"
|
echo ""; echo "== Installing uv and creating a new virtual environment for hermes-webui"
|
||||||
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
export PATH="/home/hermeswebui/.local/bin/:$PATH"
|
export PATH="/home/hermeswebui/.local/bin/:$PATH"
|
||||||
|
if command -v uv &>/dev/null; then
|
||||||
|
echo "-- uv already installed ($(uv --version)), skipping download"
|
||||||
|
else
|
||||||
|
echo "-- uv not found, downloading..."
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh || error_exit "Failed to install uv — check network connectivity"
|
||||||
|
fi
|
||||||
export UV_PROJECT_ENVIRONMENT=venv
|
export UV_PROJECT_ENVIRONMENT=venv
|
||||||
|
|
||||||
export UV_CACHE_DIR=/uv_cache
|
export UV_CACHE_DIR=/uv_cache
|
||||||
|
|||||||
189
tests/test_issue357.py
Normal file
189
tests/test_issue357.py
Normal 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}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user