Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 77 additions & 16 deletions backend/util/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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}"
Expand Down
130 changes: 129 additions & 1 deletion tests/test_version.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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)
Loading