mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
fix(docker): report source build version
This commit is contained in:
parent
48201345be
commit
6266a1d774
9 changed files with 99 additions and 7 deletions
|
|
@ -7,6 +7,7 @@ ARG PYTHON_SITE_PACKAGES=/usr/local/lib/python${PYTHON_VERSION}/site-packages
|
|||
FROM python:${PYTHON_VERSION}-slim AS builder
|
||||
|
||||
ARG UV_VERSION
|
||||
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 +52,10 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||
--mount=type=cache,target=/build/target \
|
||||
uv pip install --system ".[${HEADROOM_EXTRAS}]"
|
||||
|
||||
RUN if [ -n "${HEADROOM_BUILD_VERSION}" ]; then \
|
||||
cd /tmp && HEADROOM_BUILD_VERSION="${HEADROOM_BUILD_VERSION}" python -c "import os; from pathlib import Path; import headroom._version as v; p = Path(v.__file__).with_name('_build_info.py'); p.write_text('BUILD_VERSION = ' + repr(os.environ['HEADROOM_BUILD_VERSION']) + '\n', encoding='utf-8'); print('baked Headroom build version: ' + os.environ['HEADROOM_BUILD_VERSION'])"; \
|
||||
fi
|
||||
|
||||
# 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 +150,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
|
||||
FROM runtime-slim-base AS runtime
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,10 +2,39 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
UNKNOWN_VERSION = "unknown"
|
||||
VERSION_ENV_VARS = ("HEADROOM_VERSION", "HEADROOM_BUILD_VERSION")
|
||||
|
||||
|
||||
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 _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 +75,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:
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
<header class="border-b border-border px-6 py-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-xl font-semibold tracking-tight">HEADROOM</h1>
|
||||
<span class="text-xs text-gray-500 font-mono" x-text="'v' + version"></span>
|
||||
<span class="text-xs text-gray-500 font-mono" x-text="formatVersion(version)"></span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
|
||||
<div class="inline-flex rounded-lg border border-border bg-surface p-1">
|
||||
|
|
@ -1767,7 +1767,7 @@
|
|||
stats: {},
|
||||
historyStats: {},
|
||||
healthy: true,
|
||||
version: '0.3.0',
|
||||
version: 'loading',
|
||||
lastUpdate: 'never',
|
||||
viewMode: 'session',
|
||||
historyGranularity: 'daily',
|
||||
|
|
@ -1834,6 +1834,11 @@
|
|||
}
|
||||
},
|
||||
|
||||
formatVersion(value) {
|
||||
if (value === 'loading' || value === 'unknown') return value;
|
||||
return /^\d/.test(value) ? 'v' + value : value;
|
||||
},
|
||||
|
||||
async fetchStats() {
|
||||
try {
|
||||
const [statsRes, healthRes] = await Promise.all([
|
||||
|
|
@ -1844,7 +1849,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
|
||||
|
|
|
|||
|
|
@ -1404,7 +1404,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}")
|
||||
|
|
|
|||
|
|
@ -14,3 +14,12 @@ 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")
|
||||
|
||||
assert "HEADROOM_BUILD_VERSION: ${HEADROOM_BUILD_VERSION:-source-build}" in compose
|
||||
assert 'ARG HEADROOM_BUILD_VERSION=""' in dockerfile
|
||||
assert "_build_info.py" in dockerfile
|
||||
|
|
|
|||
|
|
@ -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,31 @@ 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_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_version_prefers_source_tree_release_history() -> None:
|
||||
with (
|
||||
patch.object(version_module, "_source_root", return_value=Path(".")),
|
||||
|
|
|
|||
|
|
@ -591,6 +591,11 @@ 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/.test(value) ? 'v' + value : value" 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue