mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(cli/update): let install ownership win over bare /.dockerenv so venv installs self-update (#2830)
## Description `headroom update` refuses to self-update for any install that happens to run inside a container, including a plain `pip install` into a venv, because `detect_install_method` checks `_in_docker()` before the pipx / uv-tool / venv / user-site branches. The guidance it prints does not apply: there is no Headroom image in the picture, the container is the environment and Headroom was pip-installed into a venv inside it. ```console $ headroom update --check Update available: 0.32.0 -> 0.34.0 Running inside a container - pull a newer Headroom image instead of self-updating. ``` `_in_docker()` is purely environmental (`/.dockerenv` exists, or `HEADROOM_IN_DOCKER` is set), with no reference to how the package was installed, so `/.dockerenv` alone shadows a venv that clearly owns the install. This hits devcontainers, GitHub Codespaces, docker/LXC self-hosting, and dev images. The fix splits the check by intent. An EXPLICIT `HEADROOM_IN_DOCKER` (which the official image can set) is a deliberate opt-out and still refuses up front, even over a venv, so the real-image behavior is preserved. The bare `/.dockerenv` heuristic now runs after ownership detection, so a venv / pipx / uv / user-site install self-updates and only a container whose own system interpreter owns the install still gets the pull-a-new-image guidance. Fixes #2816 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/cli/update.py` (`detect_install_method`): replaced the up-front `_in_docker()` refusal with an explicit `os.environ.get("HEADROOM_IN_DOCKER")` refusal (the official image opt-out), and added the bare `_in_docker()` refusal after the pipx / uv-tool / venv / user-site branches so ownership wins over environment. Updated the resolution-order docstring. - `tests/test_update_helpers.py`: added `test_venv_inside_bare_dockerenv_still_self_updates` (the fix), `test_explicit_headroom_in_docker_still_refuses_over_venv` (image opt-out preserved), and `test_bare_dockerenv_without_owner_refuses` (system-interpreter container still refuses). - `tests/test_cli_update.py` (`test_detect_docker`): updated to drive the bare-`/.dockerenv`-no-owner path deterministically (mock ownership to absent), since a real venv underneath now correctly wins. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text # Fail-before (source fix stashed, new test kept): tests/test_update_helpers.py::test_venv_inside_bare_dockerenv_still_self_updates FAILED assert method.kind == "pip" AssertionError: assert 'docker' == 'pip' # Pass-after (fix applied), all update suites: tests/test_update_helpers.py tests/test_cli_update.py tests/test_update_check.py 95 passed # uvx ruff@0.15.17 check -> All checks passed! # uvx mypy@1.20.2 headroom/cli/update.py -> Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.17 and mypy 1.20.2 via uvx. - Exact command / steps: read `detect_install_method` to confirm `_in_docker()` (line 354) preceded the pipx (377) / uv-tool (385) / venv (392) branches, reproduced the issue's environment in a test (bare `/.dockerenv` via `_in_docker` monkeypatched True, `HEADROOM_IN_DOCKER` unset, a venv layout under `sys.prefix`), fail-before with `git stash push headroom/cli/update.py` and `python -m pytest tests/test_update_helpers.py -k venv_inside_bare_dockerenv` (the venv is refused with `kind == "docker"`), then pass-after with `git stash pop` and rerunning the full update suites (95 passed). - Observed result: a venv/pip install inside a bare `/.dockerenv` container now resolves to `kind="pip"`, `can_self_update=True`, `argv=[sys.executable, "-m", "pip", "install", "-U", ...]`, matching the manual command the issue reporter confirmed works. An explicit `HEADROOM_IN_DOCKER=1` still resolves to `kind="docker"` even over a venv, and a container whose system interpreter owns the install still resolves to `kind="docker"`. - Not tested: an end-to-end `headroom update` run inside a real devcontainer against live PyPI (no container in this environment). The resolution is a pure classification function verified directly, and the actual upgrade command it builds is the existing, already-tested venv path. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md`: it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes The official image opt-out is preserved by design: the issue notes `_in_docker()` already honors `HEADROOM_IN_DOCKER`, so the image can keep refusing self-update by setting it, which this PR routes to the explicit up-front check that wins even over a venv. Only the bare `/.dockerenv` auto-detection was demoted below ownership.
This commit is contained in:
parent
de9e0523da
commit
7092b53c46
3 changed files with 90 additions and 4 deletions
|
|
@ -328,13 +328,14 @@ def detect_install_method(extras: str | None = None) -> InstallMethod:
|
||||||
|
|
||||||
1. git checkout → refuse (`git pull`)
|
1. git checkout → refuse (`git pull`)
|
||||||
2. editable install → refuse (reinstall from source)
|
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`
|
4. pipx → `pipx upgrade`
|
||||||
5. uv tool → `uv tool upgrade`
|
5. uv tool → `uv tool upgrade`
|
||||||
6. venv / virtualenv / conda → `sys.executable -m pip install -U`
|
6. venv / virtualenv / conda → `sys.executable -m pip install -U`
|
||||||
7. user-site (`pip --user`) → `sys.executable -m pip install -U --user`
|
7. user-site (`pip --user`) → `sys.executable -m pip install -U --user`
|
||||||
8. externally-managed system Python (PEP 668) → refuse with guidance
|
8. bare /.dockerenv (system interpreter) → refuse (pull a new image)
|
||||||
9. writable global Python → `sys.executable -m pip install -U` (last resort)
|
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():
|
if _is_source_checkout():
|
||||||
return InstallMethod(
|
return InstallMethod(
|
||||||
|
|
@ -351,7 +352,13 @@ def detect_install_method(extras: str | None = None) -> InstallMethod:
|
||||||
"reinstall with `pip install -U --force-reinstall .`."
|
"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(
|
return InstallMethod(
|
||||||
kind="docker",
|
kind="docker",
|
||||||
can_self_update=False,
|
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)],
|
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():
|
if _is_externally_managed():
|
||||||
return InstallMethod(
|
return InstallMethod(
|
||||||
kind="system",
|
kind="system",
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,16 @@ def test_detect_editable(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_detect_docker(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_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()
|
m = up.detect_install_method()
|
||||||
assert m.kind == "docker" and m.can_self_update is False
|
assert m.kind == "docker" and m.can_self_update is False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,64 @@ def test_update_externally_managed_refuses_via_command(monkeypatch):
|
||||||
assert "PEP 668" in res.output
|
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
|
# update_check helpers / branches
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue