mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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.
353 lines
13 KiB
Python
353 lines
13 KiB
Python
"""Tests for the `headroom update` command + install-method detection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from headroom.cli import update as up
|
|
from headroom.cli.main import main
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_env(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HEADROOM_WORKSPACE_DIR", str(tmp_path))
|
|
monkeypatch.delenv("PIPX_HOME", raising=False)
|
|
monkeypatch.delenv("UV_TOOL_DIR", raising=False)
|
|
monkeypatch.delenv("CONDA_PREFIX", raising=False)
|
|
# Default: not a checkout / editable / docker / managed install.
|
|
monkeypatch.setattr(up, "_is_source_checkout", lambda: False)
|
|
monkeypatch.setattr(up, "_is_editable_install", lambda: False)
|
|
monkeypatch.setattr(up, "_in_docker", lambda: False)
|
|
monkeypatch.setattr(up, "_is_externally_managed", lambda: False)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# detect_install_method
|
|
# --------------------------------------------------------------------------- #
|
|
def test_detect_checkout(monkeypatch):
|
|
monkeypatch.setattr(up, "_is_source_checkout", lambda: True)
|
|
m = up.detect_install_method()
|
|
assert m.kind == "checkout" and m.can_self_update is False and "git pull" in m.guidance
|
|
|
|
|
|
def test_detect_editable(monkeypatch):
|
|
monkeypatch.setattr(up, "_is_editable_install", lambda: True)
|
|
m = up.detect_install_method()
|
|
assert m.kind == "editable" and m.can_self_update is False
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_detect_pipx_by_path(monkeypatch):
|
|
monkeypatch.setattr(up.sys, "prefix", "/home/u/.local/pipx/venvs/headroom-ai")
|
|
m = up.detect_install_method()
|
|
assert m.kind == "pipx" and m.argv == ["pipx", "upgrade", "headroom-ai"]
|
|
|
|
|
|
def test_detect_pipx_windows_path(monkeypatch):
|
|
monkeypatch.setattr(up.sys, "prefix", r"C:\\Users\\u\\pipx\\venvs\\headroom-ai")
|
|
m = up.detect_install_method()
|
|
assert m.kind == "pipx"
|
|
|
|
|
|
def test_detect_uv_tool(monkeypatch):
|
|
monkeypatch.setattr(up.sys, "prefix", "/home/u/.local/share/uv/tools/headroom-ai")
|
|
m = up.detect_install_method()
|
|
assert m.kind == "uv-tool" and m.argv == ["uv", "tool", "upgrade", "headroom-ai"]
|
|
|
|
|
|
def test_detect_venv_uses_current_interpreter(monkeypatch):
|
|
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
|
|
m = up.detect_install_method()
|
|
assert m.kind == "pip"
|
|
assert m.argv[:4] == [sys.executable, "-m", "pip", "install"]
|
|
assert "-U" in m.argv and "headroom-ai" in m.argv
|
|
|
|
|
|
def test_detect_venv_with_extras(monkeypatch):
|
|
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
|
|
m = up.detect_install_method(extras="all")
|
|
assert "headroom-ai[all]" in m.argv
|
|
|
|
|
|
def test_detect_user_site(monkeypatch):
|
|
monkeypatch.setattr(up, "_in_virtualenv", lambda: False)
|
|
monkeypatch.setattr(up, "_package_location", lambda: "/home/u/.local/site")
|
|
monkeypatch.setattr(up, "_is_user_site_install", lambda loc: True)
|
|
m = up.detect_install_method()
|
|
assert m.kind == "pip-user" and "--user" in m.argv
|
|
|
|
|
|
def test_detect_externally_managed_refuses(monkeypatch):
|
|
monkeypatch.setattr(up, "_in_virtualenv", lambda: False)
|
|
monkeypatch.setattr(up, "_is_user_site_install", lambda loc: False)
|
|
monkeypatch.setattr(up, "_is_externally_managed", lambda: True)
|
|
m = up.detect_install_method()
|
|
assert m.kind == "system" and m.can_self_update is False
|
|
assert "PEP 668" in m.guidance
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# `headroom update` command
|
|
# --------------------------------------------------------------------------- #
|
|
def test_update_already_current(monkeypatch):
|
|
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
|
|
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.26.0")
|
|
res = CliRunner().invoke(main, ["update"])
|
|
assert res.exit_code == 0
|
|
assert "up to date" in res.output
|
|
|
|
|
|
def test_update_check_reports_command_without_running(monkeypatch):
|
|
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
|
|
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0")
|
|
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
|
|
|
|
def _no_run(*a, **k):
|
|
raise AssertionError("subprocess.run must not be called with --check")
|
|
|
|
monkeypatch.setattr(up.subprocess, "run", _no_run)
|
|
res = CliRunner().invoke(main, ["update", "--check"])
|
|
assert res.exit_code == 0
|
|
assert "Update available: 0.26.0 → 0.27.0" in res.output
|
|
assert "pip" in res.output and "install" in res.output
|
|
|
|
|
|
def test_update_runs_upgrade_with_yes(monkeypatch):
|
|
calls = {}
|
|
|
|
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
|
|
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0")
|
|
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
|
|
monkeypatch.setattr(up.sys, "platform", "linux")
|
|
monkeypatch.setattr(up, "_find_core_pyd", lambda: None) # Skip integrity checks
|
|
|
|
class _Result:
|
|
returncode = 0
|
|
|
|
def _run(argv, *a, **k):
|
|
calls["argv"] = argv
|
|
return _Result()
|
|
|
|
monkeypatch.setattr(up.subprocess, "run", _run)
|
|
res = CliRunner().invoke(main, ["update", "--yes"])
|
|
assert res.exit_code == 0
|
|
assert calls["argv"][:4] == [sys.executable, "-m", "pip", "install"]
|
|
assert "upgraded to 0.27.0" in res.output
|
|
|
|
|
|
def test_update_windows_pip_handoff_uses_popen(monkeypatch):
|
|
calls = {}
|
|
|
|
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
|
|
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0")
|
|
monkeypatch.setattr(up.sys, "platform", "win32")
|
|
|
|
def _detect(extras=None):
|
|
calls["extras"] = extras
|
|
return up.InstallMethod(
|
|
kind="pip",
|
|
can_self_update=True,
|
|
argv=[
|
|
r"C:\Python313\python.exe",
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"-U",
|
|
"headroom-ai[foo&calc]",
|
|
],
|
|
)
|
|
|
|
monkeypatch.setattr(up, "detect_install_method", _detect)
|
|
|
|
def _run(*a, **k):
|
|
raise AssertionError("Windows pip handoff must not call subprocess.run")
|
|
|
|
def _popen(argv, *a, **k):
|
|
calls["argv"] = argv
|
|
return object()
|
|
|
|
monkeypatch.setattr(up.subprocess, "run", _run)
|
|
monkeypatch.setattr(up.subprocess, "Popen", _popen)
|
|
res = CliRunner().invoke(main, ["update", "--yes", "--extras", "foo&calc"])
|
|
assert res.exit_code == 0
|
|
assert calls["extras"] == "foo&calc"
|
|
assert calls["argv"][:2] == [sys.executable, "-c"]
|
|
assert "subprocess.run" in calls["argv"][2]
|
|
assert calls["argv"][3:] == [
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"-U",
|
|
"headroom-ai[foo&calc]",
|
|
]
|
|
assert "headroom.exe" in res.output
|
|
|
|
|
|
def test_update_windows_non_pip_path_stays_synchronous(monkeypatch):
|
|
calls = {}
|
|
|
|
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
|
|
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0")
|
|
monkeypatch.setattr(up.sys, "platform", "win32")
|
|
monkeypatch.setattr(
|
|
up,
|
|
"detect_install_method",
|
|
lambda extras=None: up.InstallMethod(
|
|
kind="pipx",
|
|
can_self_update=True,
|
|
argv=["pipx", "upgrade", "headroom-ai"],
|
|
),
|
|
)
|
|
|
|
def _popen(*a, **k):
|
|
raise AssertionError("pipx must stay on the synchronous path")
|
|
|
|
monkeypatch.setattr(up, "safe_update", lambda argv: calls.setdefault("safe_update", argv) and 0)
|
|
monkeypatch.setattr(up.subprocess, "Popen", _popen)
|
|
res = CliRunner().invoke(main, ["update", "--yes"])
|
|
assert res.exit_code == 0
|
|
assert calls["safe_update"] == ["pipx", "upgrade", "headroom-ai"]
|
|
|
|
|
|
def test_update_refuses_in_checkout(monkeypatch):
|
|
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
|
|
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0")
|
|
monkeypatch.setattr(up, "_is_source_checkout", lambda: True)
|
|
|
|
def _no_run(*a, **k):
|
|
raise AssertionError("must not upgrade a checkout")
|
|
|
|
monkeypatch.setattr(up.subprocess, "run", _no_run)
|
|
res = CliRunner().invoke(main, ["update", "--yes"])
|
|
assert res.exit_code == 0
|
|
assert "git pull" in res.output
|
|
|
|
|
|
def test_update_network_failure(monkeypatch):
|
|
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
|
|
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: None)
|
|
res = CliRunner().invoke(main, ["update"])
|
|
assert res.exit_code != 0
|
|
assert "Could not reach PyPI" in res.output
|
|
|
|
|
|
def test_update_upgrade_failure_surfaces_command(monkeypatch):
|
|
monkeypatch.setattr(up, "installed_version", lambda: "0.26.0")
|
|
monkeypatch.setattr(up, "fetch_latest_version", lambda **k: "0.27.0")
|
|
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
|
|
monkeypatch.setattr(up.sys, "platform", "linux")
|
|
monkeypatch.setattr(up, "_find_core_pyd", lambda: None) # Skip file operations in test
|
|
|
|
class _Result:
|
|
returncode = 1
|
|
|
|
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
|
|
res = CliRunner().invoke(main, ["update", "--yes"])
|
|
assert res.exit_code != 0
|
|
assert "Upgrade failed" in res.output
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# safe_update (Windows-specific backup/restore protection)
|
|
# --------------------------------------------------------------------------- #
|
|
def test_safe_update_success(monkeypatch):
|
|
"""Test safe_update returns 0 when the command succeeds."""
|
|
|
|
class _Result:
|
|
returncode = 0
|
|
|
|
monkeypatch.setattr(up, "_find_core_pyd", lambda: None)
|
|
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
|
|
|
|
result = up.safe_update([sys.executable, "-m", "pip", "install", "-U", "headroom-ai"])
|
|
assert result == 0
|
|
|
|
|
|
def test_safe_update_handles_missing_pyd(monkeypatch):
|
|
"""Test safe_update when _core.pyd doesn't exist."""
|
|
|
|
class _Result:
|
|
returncode = 0
|
|
|
|
monkeypatch.setattr(up, "_find_core_pyd", lambda: None)
|
|
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
|
|
|
|
result = up.safe_update([sys.executable, "-m", "pip", "install", "-U", "headroom-ai"])
|
|
assert result == 0
|
|
|
|
|
|
def test_safe_update_passes_through_failure(monkeypatch):
|
|
"""Test safe_update returns error code when pip fails."""
|
|
|
|
class _Result:
|
|
returncode = 1
|
|
|
|
monkeypatch.setattr(up, "_find_core_pyd", lambda: None)
|
|
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
|
|
|
|
result = up.safe_update([sys.executable, "-m", "pip", "install", "-U", "headroom-ai"])
|
|
assert result == 1
|
|
|
|
|
|
def test_safe_update_warns_but_no_backup_when_locked(monkeypatch, tmp_path):
|
|
"""When the .pyd is locked, safe_update warns but does not make a backup."""
|
|
fake_pyd = tmp_path / "_core.pyd"
|
|
fake_pyd.write_bytes(b"fake pyd content")
|
|
|
|
monkeypatch.setattr(up.sys, "platform", "win32")
|
|
monkeypatch.setattr(up, "_find_core_pyd", lambda: fake_pyd)
|
|
monkeypatch.setattr(up, "_is_pyd_locked", lambda p: True) # locked!
|
|
|
|
class _Result:
|
|
returncode = 1 # pip fails (expected — file was locked)
|
|
|
|
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
|
|
|
|
result = up.safe_update([sys.executable, "-m", "pip", "install", "-U", "headroom-ai"])
|
|
|
|
assert result == 1
|
|
# Backup should never be created — file is locked, pip fails without corruption
|
|
assert not (tmp_path / "_core.pyd.bak").exists()
|
|
|
|
|
|
def test_safe_update_backup_and_restore_on_integrity_failure(monkeypatch, tmp_path):
|
|
"""Test safe_update backs up and restores _core.pyd if integrity check fails."""
|
|
# Create a fake .pyd file
|
|
fake_pyd = tmp_path / "_core.pyd"
|
|
fake_pyd.write_bytes(b"fake pyd content")
|
|
|
|
# Mock Windows and _core.pyd detection
|
|
monkeypatch.setattr(up.sys, "platform", "win32")
|
|
monkeypatch.setattr(up, "_find_core_pyd", lambda: fake_pyd)
|
|
monkeypatch.setattr(up, "_is_pyd_locked", lambda p: False)
|
|
|
|
class _Result:
|
|
returncode = 0
|
|
|
|
# Simulate pip success but integrity test failure
|
|
monkeypatch.setattr(up.subprocess, "run", lambda *a, **k: _Result())
|
|
monkeypatch.setattr(up, "_test_core_integrity", lambda: False)
|
|
|
|
result = up.safe_update([sys.executable, "-m", "pip", "install", "-U", "headroom-ai"])
|
|
|
|
# Should return error due to integrity failure
|
|
assert result == 1
|
|
# Backup should be cleaned up
|
|
assert not (tmp_path / "_core.pyd.bak").exists()
|