diff --git a/.dockerignore b/.dockerignore index 930b8efd9..e98d158c4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,9 @@ # VCS -.git +.git/* +!.git/HEAD +!.git/packed-refs +!.git/refs/ +!.git/refs/** .github .github/* !.github/plugin/ diff --git a/Dockerfile b/Dockerfile index d03af8146..c144f288b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,8 @@ ARG PYTHON_SITE_PACKAGES=/usr/local/lib/python${PYTHON_VERSION}/site-packages FROM python:${PYTHON_VERSION}-slim AS builder ARG UV_VERSION +ARG PYTHON_SITE_PACKAGES +ARG HEADROOM_BUILD_VERSION="" # build-essential / g++ for any C extension wheels uv may need to build # from source. curl + ca-certificates are required by the rustup @@ -51,6 +53,85 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=cache,target=/build/target \ uv pip install --system ".[${HEADROOM_EXTRAS}]" +RUN --mount=type=bind,source=.,target=/context,readonly \ + HEADROOM_BUILD_VERSION="${HEADROOM_BUILD_VERSION}" PYTHON_SITE_PACKAGES="${PYTHON_SITE_PACKAGES}" python - <<'PY' +import hashlib +import os +from pathlib import Path + + +def git_revision(context: Path) -> str | None: + git_dir = context / ".git" + head_path = git_dir / "HEAD" + if not head_path.exists(): + return None + head = head_path.read_text(encoding="utf-8").strip() + if head.startswith("ref: "): + ref_name = head.removeprefix("ref: ").strip() + ref_path = git_dir / ref_name + if ref_path.exists(): + head = ref_path.read_text(encoding="utf-8").strip() + else: + packed_refs = git_dir / "packed-refs" + if not packed_refs.exists(): + return None + for line in packed_refs.read_text(encoding="utf-8").splitlines(): + if line.startswith("#") or not line.strip(): + continue + sha, _, name = line.partition(" ") + if name.strip() == ref_name: + head = sha + break + else: + return None + return head[:12] if len(head) >= 7 and all(c in "0123456789abcdef" for c in head.lower()) else None + + +def source_digest(root: Path) -> str: + digest = hashlib.sha256() + inputs = ( + "pyproject.toml", + "uv.lock", + "README.md", + "Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + "crates", + "headroom", + ) + for name in inputs: + path = root / name + if not path.exists(): + continue + files = [path] if path.is_file() else sorted(p for p in path.rglob("*") if p.is_file()) + for file in files: + digest.update(file.relative_to(root).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(file.read_bytes()) + digest.update(b"\0") + return digest.hexdigest()[:12] + + +build_version = os.environ["HEADROOM_BUILD_VERSION"].strip() +if not build_version: + print("no Headroom build version override provided; using installed package metadata") + raise SystemExit(0) +if build_version == "source-build": + revision = git_revision(Path("/context")) + build_version = ( + f"source-build+g{revision}" + if revision + else f"source-build+sha256.{source_digest(Path('/build'))}" + ) + +package_dir = Path(os.environ["PYTHON_SITE_PACKAGES"]) / "headroom" +(package_dir / "_build_info.py").write_text( + "BUILD_VERSION = " + repr(build_version) + "\n", + encoding="utf-8", +) +print("baked Headroom build version: " + build_version) +PY + # Build-stage smoke check: verify the extension loads end-to-end inside # the build image before we copy site-packages into the runtime image. # If this fails, the runtime image would fail Phase A0's fail-loud @@ -145,4 +226,4 @@ ENTRYPOINT ["python3", "-m", "headroom.cli", "proxy"] CMD ["--host", "0.0.0.0", "--port", "8787"] # Default published image remains python-slim runtime -FROM runtime-slim-base AS runtime \ No newline at end of file +FROM runtime-slim-base AS runtime diff --git a/docker-compose.yml b/docker-compose.yml index 3c46ca94d..527d03e50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: headroom-proxy: - build: . + build: + context: . + args: + HEADROOM_BUILD_VERSION: ${HEADROOM_BUILD_VERSION:-source-build} command: ["--host", "0.0.0.0"] environment: - HEADROOM_HOST=0.0.0.0 diff --git a/headroom/_version.py b/headroom/_version.py index cbb479b14..1c50487c1 100644 --- a/headroom/_version.py +++ b/headroom/_version.py @@ -2,10 +2,63 @@ from __future__ import annotations +import importlib +import os +import re from importlib.metadata import PackageNotFoundError, version from pathlib import Path UNKNOWN_VERSION = "unknown" +VERSION_ENV_VARS = ("HEADROOM_VERSION", "HEADROOM_BUILD_VERSION") +RELEASE_VERSION_RE = re.compile(r"^v?\d+\.\d+\.\d+$") + + +def _clean_version(value: object) -> str | None: + """Return a non-empty version string, if one is present.""" + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped or None + + +def is_release_version(value: object) -> bool: + """Return whether a value is a comparable release version.""" + cleaned = _clean_version(value) + return bool(cleaned and RELEASE_VERSION_RE.fullmatch(cleaned)) + + +def normalize_release_version(value: object) -> str | None: + """Return a comparable release version without a display prefix.""" + cleaned = _clean_version(value) + if cleaned is None or RELEASE_VERSION_RE.fullmatch(cleaned) is None: + return None + return cleaned[1:] if cleaned.startswith("v") else cleaned + + +def format_version_label(value: object) -> str: + """Return a user-facing version label without prefixing source labels.""" + cleaned = _clean_version(value) or UNKNOWN_VERSION + if is_release_version(cleaned) and not cleaned.startswith("v"): + return f"v{cleaned}" + return cleaned + + +def _env_version() -> str | None: + """Return an explicit runtime/build version override.""" + for name in VERSION_ENV_VARS: + value = _clean_version(os.environ.get(name)) + if value: + return value + return None + + +def _packaged_build_version() -> str | None: + """Return Docker/image build metadata baked into the installed package.""" + try: + build_info = importlib.import_module("headroom._build_info") + except ModuleNotFoundError: + return None + return _clean_version(getattr(build_info, "BUILD_VERSION", None)) def _source_root() -> Path | None: @@ -46,12 +99,20 @@ def _source_tree_version(root: Path) -> str | None: def get_version() -> str: """Return Headroom's runtime version.""" + env_version = _env_version() + if env_version: + return env_version + root = _source_root() if root is not None: source_version = _source_tree_version(root) if source_version: return source_version + build_version = _packaged_build_version() + if build_version: + return build_version + try: return version("headroom-ai") except PackageNotFoundError: diff --git a/headroom/cli/doctor.py b/headroom/cli/doctor.py index cd07f8cf7..3176afa3e 100644 --- a/headroom/cli/doctor.py +++ b/headroom/cli/doctor.py @@ -22,6 +22,7 @@ from typing import Any import click +from headroom._version import format_version_label, normalize_release_version from headroom.install.health import probe_json from headroom.install.paths import claude_settings_path, codex_config_path from headroom.install.state import list_manifests @@ -97,7 +98,7 @@ def check_proxy_liveness(livez: dict[str, Any] | None, base_url: str) -> CheckRe return CheckResult( name="proxy", status=PASS, - summary=f"running at {base_url} ({uptime_text}, v{version})", + summary=f"running at {base_url} ({uptime_text}, {format_version_label(version)})", ) @@ -112,14 +113,26 @@ def check_version_drift(livez: dict[str, Any] | None, installed: str) -> CheckRe status=WARN, summary=f"cannot compare versions (proxy {running}, installed {installed})", ) - if running != installed: + running_release = normalize_release_version(running) + installed_release = normalize_release_version(installed) + if running_release is None or installed_release is None: + return CheckResult( + name="version", + status=SKIP, + summary=f"source/non-release version label (proxy {running}, installed {installed})", + ) + if running_release != installed_release: return CheckResult( name="version", status=WARN, summary=f"version drift: proxy {running}, installed {installed}", hint="restart the proxy to pick up new code: headroom proxy", ) - return CheckResult(name="version", status=PASS, summary=f"proxy matches installed v{installed}") + return CheckResult( + name="version", + status=PASS, + summary=f"proxy matches installed {format_version_label(installed)}", + ) def check_claude_routing(settings_path: Path, port: int) -> CheckResult: @@ -398,7 +411,9 @@ def _render(checks: list[CheckResult], port: int, installed: str) -> None: from rich.table import Table console = Console() - console.print(f"[bold]Headroom Doctor[/bold] [dim]v{installed} · port {port}[/dim]\n") + console.print( + f"[bold]Headroom Doctor[/bold] [dim]{format_version_label(installed)} · port {port}[/dim]\n" + ) table = Table(show_header=True, header_style="bold") table.add_column("check") table.add_column("status") diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 8bdcfefb4..59194da31 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -45,6 +45,7 @@ import click from headroom import fsutil from headroom._version import __version__ as _HEADROOM_VERSION +from headroom._version import normalize_release_version as _normalize_release_version from headroom.agent_savings import ( apply_agent_savings_env_defaults, ) @@ -2531,11 +2532,12 @@ def _proxy_version(payload: dict[str, Any] | None) -> str | None: def _proxy_needs_version_restart(payload: dict[str, Any] | None) -> bool: """Return True when a running Headroom proxy uses a different package version.""" running_version = _proxy_version(payload) + running_release = _normalize_release_version(running_version) + current_release = _normalize_release_version(_HEADROOM_VERSION) return ( - running_version is not None - and running_version != "unknown" - and _HEADROOM_VERSION != "unknown" - and running_version != _HEADROOM_VERSION + running_release is not None + and current_release is not None + and running_release != current_release ) diff --git a/headroom/dashboard/templates/dashboard.html b/headroom/dashboard/templates/dashboard.html index b038e9cfd..3f16bc9c3 100644 --- a/headroom/dashboard/templates/dashboard.html +++ b/headroom/dashboard/templates/dashboard.html @@ -109,7 +109,7 @@

HEADROOM

- +
@@ -1795,7 +1795,7 @@ stats: {}, historyStats: {}, healthy: true, - version: '0.3.0', + version: 'loading', lastUpdate: 'never', viewMode: 'session', historyGranularity: 'daily', @@ -1862,6 +1862,12 @@ } }, + formatVersion(value) { + const label = String(value || 'unknown').trim(); + if (label === 'loading' || label === 'unknown') return label; + return /^\d+\.\d+\.\d+$/.test(label) ? 'v' + label : label; + }, + async fetchStats() { try { const [statsRes, healthRes] = await Promise.all([ @@ -1872,7 +1878,7 @@ this.stats = await statsRes.json(); const health = await healthRes.json(); this.healthy = health.status === 'healthy'; - this.version = health.version || '0.3.0'; + this.version = health.version || 'unknown'; this.log_full_messages = this.stats.log_full_messages || false; // Update history for sparklines diff --git a/headroom/observability/metrics.py b/headroom/observability/metrics.py index db91e7f4c..67e44672c 100644 --- a/headroom/observability/metrics.py +++ b/headroom/observability/metrics.py @@ -5,14 +5,14 @@ from __future__ import annotations import logging import os from dataclasses import dataclass, field -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version as package_version from threading import Lock from typing import Any, Literal from opentelemetry import metrics from opentelemetry.metrics import CallbackOptions, Observation +from headroom._version import get_version + logger = logging.getLogger(__name__) MetricExporter = Literal["console", "otlp_http"] @@ -28,10 +28,7 @@ _owned_metrics_config: OTelMetricsConfig | None = None def _headroom_version() -> str: - try: - return package_version("headroom-ai") - except PackageNotFoundError: - return "unknown" + return get_version() def _parse_bool(raw: str | None, default: bool = False) -> bool: diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index 4d6e6c05f..f8ac9b2ac 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -1405,7 +1405,7 @@ class HeadroomProxy( self.http_client_h1 = ( self.http_client if not _http2 else httpx.AsyncClient(http2=False, **_client_kwargs) ) - logger.info("Headroom Proxy started") + logger.info("Headroom Proxy started (version %s)", __version__) logger.info(f"Optimization: {'ENABLED' if self.config.optimize else 'DISABLED'}") self.config.mode = normalize_proxy_mode(self.config.mode) logger.info(f"Mode: {self.config.mode}") diff --git a/tests/test_cli/test_wrap_persistent.py b/tests/test_cli/test_wrap_persistent.py index 72919cafd..ba463469e 100644 --- a/tests/test_cli/test_wrap_persistent.py +++ b/tests/test_cli/test_wrap_persistent.py @@ -290,6 +290,22 @@ def test_ensure_proxy_restarts_idle_stale_ephemeral_proxy(monkeypatch) -> None: assert calls[1][0] == "start" +def test_proxy_version_restart_ignores_non_release_source_labels(monkeypatch) -> None: + monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "0.29.0") + assert wrap_cli._proxy_needs_version_restart({"version": "source-build+g6266a1d774b5"}) is False + assert ( + wrap_cli._proxy_needs_version_restart({"version": "source-build+sha.abcdef123456"}) is False + ) + assert wrap_cli._proxy_needs_version_restart({"version": "6266a1d"}) is False + assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0+gabcdef0"}) is False + + monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "source-build+sha.abcdef123456") + assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0"}) is False + + monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "0.29.1") + assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0"}) is True + + def test_ensure_proxy_restarts_ephemeral_proxy_for_openai_api_url_mismatch(monkeypatch) -> None: calls: list[object] = [] health = { diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py index 3d26f563a..5ece31a4e 100644 --- a/tests/test_cli_doctor.py +++ b/tests/test_cli_doctor.py @@ -56,6 +56,13 @@ class TestProxyLiveness: assert "v0.26.0" in result.summary assert "3d" in result.summary + def test_up_leaves_source_label_unprefixed(self): + livez = {**LIVEZ_OK, "version": "source-build+sha.abcdef123456"} + result = check_proxy_liveness(livez, "http://127.0.0.1:8787") + assert result.status == PASS + assert "source-build+sha.abcdef123456" in result.summary + assert "vsource-build" not in result.summary + class TestVersionDrift: def test_match_passes(self): @@ -74,6 +81,21 @@ class TestVersionDrift: assert check_version_drift({"version": "unknown"}, "0.26.0").status == WARN assert check_version_drift(LIVEZ_OK, "unknown").status == WARN + @pytest.mark.parametrize( + ("running", "installed"), + [ + ("source-build+g6266a1d774b5", "0.26.0"), + ("source-build+sha.abcdef123456", "0.26.0"), + ("6266a1d", "0.26.0"), + ("0.26.0+gabcdef0", "0.26.0"), + ("0.26.0", "source-build+sha.abcdef123456"), + ], + ) + def test_non_release_version_labels_skip_drift_comparison(self, running, installed): + result = check_version_drift({"version": running}, installed) + assert result.status == SKIP + assert "drift" not in result.summary + class TestClaudeRouting: def test_missing_file_warns(self, tmp_path): diff --git a/tests/test_docker_compose_persistence.py b/tests/test_docker_compose_persistence.py index 371e98d56..16f8bcd76 100644 --- a/tests/test_docker_compose_persistence.py +++ b/tests/test_docker_compose_persistence.py @@ -14,3 +14,21 @@ def test_top_level_compose_pins_headroom_state_to_named_volume() -> None: assert "- HOME=/home/nonroot" in compose assert "- HEADROOM_WORKSPACE_DIR=/home/nonroot/.headroom" in compose assert "- HEADROOM_CONFIG_DIR=/home/nonroot/.headroom/config" in compose + + +def test_top_level_compose_marks_source_build_version() -> None: + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8") + + assert "HEADROOM_BUILD_VERSION: ${HEADROOM_BUILD_VERSION:-source-build}" in compose + assert 'ARG HEADROOM_BUILD_VERSION=""' in dockerfile + assert "ARG PYTHON_SITE_PACKAGES" in dockerfile + assert "if not build_version:" in dockerfile + assert "source-build+g{revision}" in dockerfile + assert "source-build+sha256." in dockerfile + assert "_build_info.py" in dockerfile + assert "import headroom._version" not in dockerfile + assert ".git/*" in dockerignore + assert "!.git/HEAD" in dockerignore + assert "!.git/refs/**" in dockerignore diff --git a/tests/test_package_init_lazy.py b/tests/test_package_init_lazy.py index eb341fdb7..be97b9463 100644 --- a/tests/test_package_init_lazy.py +++ b/tests/test_package_init_lazy.py @@ -7,6 +7,7 @@ import os import subprocess import sys import textwrap +import types from importlib.metadata import PackageNotFoundError from pathlib import Path from unittest.mock import patch @@ -64,6 +65,66 @@ def test_version_reports_unknown_when_distribution_metadata_is_missing() -> None assert version_module.get_version() == version_module.UNKNOWN_VERSION +def test_version_prefers_explicit_build_env(monkeypatch) -> None: + monkeypatch.setenv("HEADROOM_BUILD_VERSION", "source-build") + + with patch.object(version_module, "version", return_value="9.8.7") as package_version: + assert version_module.get_version() == "source-build" + + package_version.assert_not_called() + + +def test_version_label_helpers_only_prefix_release_versions() -> None: + assert version_module.is_release_version("0.29.0") is True + assert version_module.is_release_version("v0.29.0") is True + assert version_module.normalize_release_version("v0.29.0") == "0.29.0" + assert version_module.is_release_version("source-build+g6266a1d774b5") is False + assert version_module.is_release_version("source-build+sha.abcdef123456") is False + assert version_module.is_release_version("6266a1d") is False + assert version_module.is_release_version("0.29.0+gabcdef0") is False + + assert version_module.format_version_label("0.29.0") == "v0.29.0" + assert version_module.format_version_label("v0.29.0") == "v0.29.0" + assert ( + version_module.format_version_label("source-build+sha.abcdef123456") + == "source-build+sha.abcdef123456" + ) + assert ( + version_module.format_version_label("source-build+g6266a1d774b5") + == "source-build+g6266a1d774b5" + ) + assert version_module.format_version_label("6266a1d") == "6266a1d" + assert version_module.format_version_label(None) == version_module.UNKNOWN_VERSION + + +def test_version_uses_packaged_build_metadata( + monkeypatch, +) -> None: + build_info = types.ModuleType("headroom._build_info") + build_info.BUILD_VERSION = "0.29.0+gabcdef0" + monkeypatch.setitem(sys.modules, "headroom._build_info", build_info) + + with ( + patch.object(version_module, "_source_root", return_value=None), + patch.object(version_module, "version", return_value="0.29.0") as package_version, + ): + assert version_module.get_version() == "0.29.0+gabcdef0" + + package_version.assert_not_called() + + +def test_observability_version_uses_runtime_version(monkeypatch) -> None: + from headroom.observability import metrics as metrics_module + + monkeypatch.setattr( + metrics_module, + "get_version", + lambda: "source-build+sha.abcdef123456", + ) + + assert metrics_module._headroom_version() == "source-build+sha.abcdef123456" + + def test_version_prefers_source_tree_release_history() -> None: with ( patch.object(version_module, "_source_root", return_value=Path(".")), diff --git a/tests/test_proxy_dashboard_stats_cache.py b/tests/test_proxy_dashboard_stats_cache.py index 0a2569483..5a0cc0764 100644 --- a/tests/test_proxy_dashboard_stats_cache.py +++ b/tests/test_proxy_dashboard_stats_cache.py @@ -591,6 +591,12 @@ def test_dashboard_uses_cached_stats_and_lazy_history_feed_polling() -> None: html = get_dashboard_html() assert "fetch('/stats?cached=1')" in html + assert "version: 'loading'" in html + assert 'x-text="formatVersion(version)"' in html + assert "return /^\\d+\\.\\d+\\.\\d+$/.test(label)" in html + assert "return /^\\d/.test(value)" not in html + assert "this.version = health.version || 'unknown'" in html + assert "0.3.0" not in html assert "@click=\"setViewMode('history')\"" in html assert '@click="toggleFeed()"' in html assert "this.viewMode === 'history'" in html diff --git a/tests/test_proxy_healthchecks.py b/tests/test_proxy_healthchecks.py index d6372aa63..60eccd1d1 100644 --- a/tests/test_proxy_healthchecks.py +++ b/tests/test_proxy_healthchecks.py @@ -8,7 +8,7 @@ pytest.importorskip("httpx") from fastapi.testclient import TestClient -from headroom.proxy.server import ProxyConfig, create_app +from headroom.proxy.server import ProxyConfig, __version__, create_app @pytest.fixture @@ -37,6 +37,7 @@ def test_livez_reports_process_health(client): assert data["service"] == "headroom-proxy" assert data["status"] == "healthy" assert data["alive"] is True + assert data["version"] == __version__ assert data["uptime_seconds"] >= 0 @@ -73,6 +74,7 @@ def test_health_preserves_backwards_compatible_config_payload(client): data = response.json() assert data["status"] == "healthy" assert data["ready"] is True + assert data["version"] == __version__ config = data["config"] assert config["backend"] == "anthropic" assert config["optimize"] is False