diff --git a/backend/util/version.py b/backend/util/version.py index f8bb8053..c2def4f2 100755 --- a/backend/util/version.py +++ b/backend/util/version.py @@ -44,6 +44,81 @@ def get_version() -> str: return base_version +# ----- what is actually published ------------------------------------------- +GHCR_IMAGE = "chodeus/chub" +_GHCR_ACCEPT = ",".join( + ( + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + ) +) + + +def _image_tag() -> str: + """Rolling tag this container tracks: :full when the extensions are baked in.""" + return "full" if os.getenv("CHUB_IMAGE_FLAVOR") == "full" else "latest" + + +def _published_build(logger, tag: str | None = None) -> int | None: + """BUILD_NUMBER of the newest published image, or None if unknown. + + Every failure returns None, so an unreachable registry never raises a badge. + """ + tag = tag or _image_tag() + try: + tok = requests.get( + f"https://ghcr.io/token?scope=repository:{GHCR_IMAGE}:pull&service=ghcr.io", + timeout=5, + ) + if not tok.ok: + logger.debug(f"GHCR token failed: {tok.status_code}") + return None + head = { + "Authorization": f"Bearer {tok.json()['token']}", + "Accept": _GHCR_ACCEPT, + } + man = requests.get( + f"https://ghcr.io/v2/{GHCR_IMAGE}/manifests/{tag}", headers=head, timeout=5 + ) + if not man.ok: + logger.debug(f"GHCR manifest {tag} failed: {man.status_code}") + return None + doc = man.json() + if "manifests" in doc: # multi-arch index; BUILD_NUMBER is per-build, not per-arch + children = [ + m + for m in doc["manifests"] + if m.get("platform", {}).get("architecture") not in (None, "unknown") + ] + if not children: + return None + man = requests.get( + f"https://ghcr.io/v2/{GHCR_IMAGE}/manifests/{children[0]['digest']}", + headers=head, + timeout=5, + ) + if not man.ok: + logger.debug(f"GHCR child manifest failed: {man.status_code}") + return None + doc = man.json() + blob = requests.get( + f"https://ghcr.io/v2/{GHCR_IMAGE}/blobs/{doc['config']['digest']}", + headers=head, + timeout=5, + ) + if not blob.ok: + logger.debug(f"GHCR config blob failed: {blob.status_code}") + return None + for entry in blob.json().get("config", {}).get("Env", []): + if entry.startswith("BUILD_NUMBER="): + return int(entry.split("=", 1)[1]) + return None + except Exception as exc: + logger.debug(f"Exception reading the published image: {exc}") + return None + + def _check_remote_version(local_version, branch, logger): raw_url = f"https://raw.githubusercontent.com/chodeus/chub/{branch}/.release-please-manifest.json" @@ -59,22 +134,8 @@ def _check_remote_version(local_version, branch, logger): logger.debug(f"Exception fetching manifest: {e}") return None, None, False - api_url = ( - f"https://api.github.com/repos/chodeus/chub/commits?sha={branch}&per_page=1" - ) - try: - resp = requests.get(api_url, timeout=5) - if not resp.ok: - logger.debug(f"Could not fetch commit count: {resp.status_code}") - return remote_version_str, None, False - link = resp.headers.get("Link") - if not link: - build_count = 1 - else: - match = re.search(r"&page=(\d+)>; rel=\"last\"", link) - build_count = int(match.group(1)) if match else 1 - except Exception as e: - logger.debug(f"Exception fetching build count: {e}") + build_count = _published_build(logger) + if build_count is None: return remote_version_str, None, False remote_full = f"{remote_version_str}.{branch}{build_count}" diff --git a/tests/test_version.py b/tests/test_version.py index f606123b..4bc3782f 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,9 +1,21 @@ """Tests for backend/util/version.py — manifest-driven versioning.""" +import json import subprocess from unittest.mock import MagicMock, patch +from urllib.parse import urlparse -from backend.util.version import _read_base_version, check_for_update, get_version +import pytest + +from backend.util.version import ( + GHCR_IMAGE, + _check_remote_version, + _image_tag, + _published_build, + _read_base_version, + check_for_update, + get_version, +) def test_read_base_version_returns_string(): @@ -69,3 +81,119 @@ def test_check_for_update_handles_network_failure(): assert result["update_available"] is False assert result["checked"] is False assert result["branch"] == "develop" + + +# ----- update check compares against what is PUBLISHED, not commits on main ---- + + +class _Resp: + def __init__(self, payload=None, ok=True, status=200, text=None): + self._payload, self.ok, self.status_code = payload, ok, status + self.text = text if text is not None else json.dumps(payload or {}) + + def json(self): + return self._payload + + +def _host(url): + """Parsed host — a substring test would match an attacker-controlled path.""" + return urlparse(url).netloc + + +def _ghcr(build_number="1550", **over): + """URL -> response for a healthy GHCR read; `over` replaces one leg.""" + routes = { + "token": _Resp({"token": "t"}), + "manifests/full": _Resp({"manifests": [ + {"digest": "sha256:child", "platform": {"architecture": "amd64"}}]}), + "manifests/latest": _Resp({"manifests": [ + {"digest": "sha256:child", "platform": {"architecture": "amd64"}}]}), + "manifests/sha256:child": _Resp({"config": {"digest": "sha256:cfg"}}), + "blobs/": _Resp({"config": {"Env": ["PATH=/usr/bin", f"BUILD_NUMBER={build_number}"]}}), + } + routes.update(over) + + def get(url, **kwargs): + for key, resp in routes.items(): + if key in url: + return resp + raise AssertionError(f"unexpected URL {url}") + + return get + + +@pytest.mark.parametrize( + "flavour,tag", [("full", "full"), ("lean", "latest"), (None, "latest")] +) +def test_published_build_reads_the_env_of_the_tag_its_flavour_tracks( + monkeypatch, flavour, tag +): + """A :full instance compared against :latest would read the wrong build, so + the requested tag is asserted — not just the number that comes back.""" + if flavour is None: + monkeypatch.delenv("CHUB_IMAGE_FLAVOR", raising=False) + else: + monkeypatch.setenv("CHUB_IMAGE_FLAVOR", flavour) + seen, routed = [], _ghcr("1556") + + def get(url, **kwargs): + seen.append(urlparse(url).path) + return routed(url, **kwargs) + + monkeypatch.setattr("backend.util.version.requests.get", get) + + assert _published_build(MagicMock()) == 1556 + assert f"/v2/{GHCR_IMAGE}/manifests/{tag}" in seen + + +@pytest.mark.parametrize("leg", ["token", "manifests/latest", "blobs/"]) +def test_published_build_is_none_when_the_registry_fails(leg, monkeypatch): + """Unknown must never raise an update badge.""" + monkeypatch.setattr( + "backend.util.version.requests.get", _ghcr(**{leg: _Resp(ok=False, status=503)}) + ) + assert _published_build(MagicMock()) is None + + +def test_image_tag_follows_the_flavour(monkeypatch): + monkeypatch.setenv("CHUB_IMAGE_FLAVOR", "full") + assert _image_tag() == "full" + monkeypatch.setenv("CHUB_IMAGE_FLAVOR", "lean") + assert _image_tag() == "latest" + monkeypatch.delenv("CHUB_IMAGE_FLAVOR", raising=False) + assert _image_tag() == "latest" + + +def test_commits_that_publish_no_image_do_not_raise_an_update(monkeypatch): + """The bug this replaced: a docs/CI-only commit bumps the commit count on main + but publishes nothing, so every user was told an update was ready that did not + exist. The comparison must read the registry and never the commits API.""" + seen = [] + + def get(url, **kwargs): + seen.append(_host(url)) + if _host(url) == "raw.githubusercontent.com": + return _Resp(text=json.dumps({".": "2.48.0"})) + return _ghcr("1556")(url, **kwargs) + + monkeypatch.setattr("backend.util.version.requests.get", get) + _remote, build, update = _check_remote_version( + "2.48.0.main1556", "main", MagicMock() + ) + + assert build == 1556 + assert update is False, "running the newest published image is not an update" + assert "api.github.com" not in seen, "must not count commits" + + +def test_a_newer_published_image_is_an_update(monkeypatch): + def get(url, **kwargs): + if _host(url) == "raw.githubusercontent.com": + return _Resp(text=json.dumps({".": "2.48.0"})) + return _ghcr("1560")(url, **kwargs) + + monkeypatch.setattr("backend.util.version.requests.get", get) + _remote, build, update = _check_remote_version( + "2.48.0.main1556", "main", MagicMock() + ) + assert (build, update) == (1560, True)