diff --git a/headroom/cli/update.py b/headroom/cli/update.py index 9b907f126..93796d246 100644 --- a/headroom/cli/update.py +++ b/headroom/cli/update.py @@ -328,13 +328,14 @@ def detect_install_method(extras: str | None = None) -> InstallMethod: 1. git checkout → refuse (`git pull`) 2. editable install → refuse (reinstall from source) - 3. Docker → refuse (pull a new image) + 3. explicit HEADROOM_IN_DOCKER → refuse (official image opt-out) 4. pipx → `pipx upgrade` 5. uv tool → `uv tool upgrade` 6. venv / virtualenv / conda → `sys.executable -m pip install -U` 7. user-site (`pip --user`) → `sys.executable -m pip install -U --user` - 8. externally-managed system Python (PEP 668) → refuse with guidance - 9. writable global Python → `sys.executable -m pip install -U` (last resort) + 8. bare /.dockerenv (system interpreter) → refuse (pull a new image) + 9. externally-managed system Python (PEP 668) → refuse with guidance + 10. writable global Python → `sys.executable -m pip install -U` (last resort) """ if _is_source_checkout(): return InstallMethod( @@ -351,7 +352,13 @@ def detect_install_method(extras: str | None = None) -> InstallMethod: "reinstall with `pip install -U --force-reinstall .`." ), ) - if _in_docker(): + # An EXPLICIT HEADROOM_IN_DOCKER (set by the official image) is a deliberate + # "pull a newer image" opt-out and wins up front, even over a venv. The bare + # /.dockerenv heuristic is handled far lower, after ownership detection, so a + # pip / pipx / uv install inside a devcontainer, Codespace, or docker dev + # image is not shadowed by the mere fact that the environment is a container + # (#2816). + if os.environ.get("HEADROOM_IN_DOCKER", "").strip(): return InstallMethod( kind="docker", can_self_update=False, @@ -404,6 +411,18 @@ def detect_install_method(extras: str | None = None) -> InstallMethod: argv=[sys.executable, "-m", "pip", "install", "-U", "--user", _spec(extras)], ) + # Bare /.dockerenv with no venv / pipx / uv / user-site owner: the install + # belongs to the container's own interpreter, where "pull a newer image" is + # the only real route. An explicit HEADROOM_IN_DOCKER already returned above. + if _in_docker(): + return InstallMethod( + kind="docker", + can_self_update=False, + guidance=( + "Running inside a container — pull a newer Headroom image instead of self-updating." + ), + ) + if _is_externally_managed(): return InstallMethod( kind="system", diff --git a/tests/test_cli_update.py b/tests/test_cli_update.py index 372649461..8f3ea107f 100644 --- a/tests/test_cli_update.py +++ b/tests/test_cli_update.py @@ -40,7 +40,16 @@ def test_detect_editable(monkeypatch): def test_detect_docker(monkeypatch): + # A bare /.dockerenv container whose system interpreter owns the install + # (no venv / pipx / uv / user-site) still refuses. When an install method + # owns it, ownership wins over the container environment (#2816) -- see + # test_update_helpers.test_venv_inside_bare_dockerenv_still_self_updates. + monkeypatch.delenv("HEADROOM_IN_DOCKER", raising=False) monkeypatch.setattr(up, "_in_docker", lambda: True) + monkeypatch.setattr(up, "_in_virtualenv", lambda: False) + monkeypatch.setattr(up, "_is_user_site_install", lambda loc: False) + monkeypatch.setattr(up.sys, "prefix", "/usr") + monkeypatch.setattr(up.sys, "executable", "/usr/bin/python3") m = up.detect_install_method() assert m.kind == "docker" and m.can_self_update is False diff --git a/tests/test_update_helpers.py b/tests/test_update_helpers.py index bb313b2d1..b037bb99c 100644 --- a/tests/test_update_helpers.py +++ b/tests/test_update_helpers.py @@ -267,6 +267,64 @@ def test_update_externally_managed_refuses_via_command(monkeypatch): assert "PEP 668" in res.output +def test_venv_inside_bare_dockerenv_still_self_updates(monkeypatch): + """A venv/pip install inside a container (bare /.dockerenv, no explicit + HEADROOM_IN_DOCKER) must self-update, not be refused with image guidance (#2816). + """ + monkeypatch.setattr(up, "_is_source_checkout", lambda: False) + monkeypatch.setattr(up, "_is_editable_install", lambda: False) + monkeypatch.delenv("HEADROOM_IN_DOCKER", raising=False) + # Deterministic, pipx/uv-free venv layout (mirrors the issue's environment). + monkeypatch.setenv("PIPX_HOME", "") + monkeypatch.setenv("UV_TOOL_DIR", "") + monkeypatch.setattr(up.sys, "executable", "/config/.headroom-venv/bin/python") + monkeypatch.setattr(up.sys, "prefix", "/config/.headroom-venv") + monkeypatch.setattr( + up, "_package_location", lambda: "/config/.headroom-venv/lib/python3.12/headroom" + ) + # The container is real (/.dockerenv), but a venv owns the install. + monkeypatch.setattr(up, "_in_docker", lambda: True) + monkeypatch.setattr(up, "_in_virtualenv", lambda: True) + + method = up.detect_install_method() + assert method.kind == "pip" + assert method.can_self_update is True + assert method.argv[:4] == [up.sys.executable, "-m", "pip", "install"] + + +def test_explicit_headroom_in_docker_still_refuses_over_venv(monkeypatch): + """The official image's explicit HEADROOM_IN_DOCKER opt-out wins up front, + even when a venv owns the install.""" + monkeypatch.setattr(up, "_is_source_checkout", lambda: False) + monkeypatch.setattr(up, "_is_editable_install", lambda: False) + monkeypatch.setenv("HEADROOM_IN_DOCKER", "1") + monkeypatch.setattr(up, "_in_virtualenv", lambda: True) + + method = up.detect_install_method() + assert method.kind == "docker" + assert method.can_self_update is False + + +def test_bare_dockerenv_without_owner_refuses(monkeypatch): + """A container whose system interpreter owns the install (bare /.dockerenv, no + venv/pipx/uv/user-site) still refuses with the pull-a-new-image guidance.""" + monkeypatch.setattr(up, "_is_source_checkout", lambda: False) + monkeypatch.setattr(up, "_is_editable_install", lambda: False) + monkeypatch.delenv("HEADROOM_IN_DOCKER", raising=False) + monkeypatch.setenv("PIPX_HOME", "") + monkeypatch.setenv("UV_TOOL_DIR", "") + monkeypatch.setattr(up.sys, "executable", "/usr/bin/python3") + monkeypatch.setattr(up.sys, "prefix", "/usr") + monkeypatch.setattr(up, "_package_location", lambda: "/usr/lib/python3.12/headroom") + monkeypatch.setattr(up, "_in_docker", lambda: True) + monkeypatch.setattr(up, "_in_virtualenv", lambda: False) + monkeypatch.setattr(up, "_is_user_site_install", lambda loc: False) + + method = up.detect_install_method() + assert method.kind == "docker" + assert method.can_self_update is False + + # --------------------------------------------------------------------------- # # update_check helpers / branches # --------------------------------------------------------------------------- #