mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-15 11:16:50 +00:00
An LXC running its workload in Docker could not answer two questions it already had the data for: which application is in there, and whether a newer version exists. **Which version is available.** The Updates tab resolved that number only for docker.io, and only when a version tag happened to share the digest of the tag in use. On ghcr.io, lscr.io or quay.io the row said "New image available" with no number at all. The image a pull would install carries its own version label, so it is now read from the registry by digest — over the same protocol and Bearer challenge the digest comparison already uses, and through the same label lookup the installed version uses, now shared as _docker_version_from_labels. The question this answers is the one the tab asks: what do I get if I re-pull this tag. Not "what is the newest upstream release", which is a different number whenever a tag is pinned or the publisher tags releases differently from images. Docker Hub keeps priority on docker.io: its tag API is not a pull and does not spend the anonymous pull-rate budget, and official images carry no labels for the registry path to read. The digest still decides whether an update exists; this only names it, and declines to name it when the answer would be a guess — no build for this platform, no labels, an unreadable manifest, a moving tag, a rebuild of the same version, or two sides whose versions came from different label keys. Each refusal is recorded in available_version_source. Attestation manifests are skipped explicitly: they advertise unknown/unknown and their config blob is a provenance document, not an image. Every document is fetched by digest and verified against it, the config read is bounded, and it is cached per digest, which never changes content. The CDN redirect is followed by hand, dropping Authorization: urllib re-sends it to the redirect target and signed-URL storage rejects a second auth mechanism. **Which application it is.** The probe already read "1.37.2" out of a Vaultwarden container and get_suggestions discarded it, so the panel answered "No new applications were detected" about an application whose version it had just measured. Containerised applications are now offered for registration like any other, with their name, logo, published ports and installed version. What they do not get is an update path of their own, because they do not have one: updating Vaultwarden means pulling and recreating its image. A new update_via=docker marker records that delegation, so one release stays one badge, one notification and one button. The marker is validated rather than inferred from installed_via, since docker_exec with an upstream is a legitimate registration someone may already rely on; combining it with an upstream is rejected instead of silently stripped, because registering an app that checks GitHub behind a delegation promising it will not is worse than an error message. Three failure modes the delegation had to be defended against: detector auto-healing would have migrated the app onto a leftover /root/.<app> marker and quietly un-delegated it; saving replaces the whole record, so the editor carries the marker explicitly rather than dropping it on the first port edit; and the release-age hold gates on a publish date a delegated app never has, which deferred the whole schedule forever. Their version is resolved server-side through the container the detector declares — not through the app's name or image, since Immich's compose service and image are both immich-server while the application is immich. The annotation happens on the way out of both endpoints rather than into their caches: the App tab's cache is invalidated by events, not by time, and the Docker inventory it reads is built asynchronously, so annotating before storing froze a response taken before the first scan. The rows carry that name too. display_name was already computed and already used by the bulk-update section; the image row, the update notification and the CT badge now use it as well. A delegated app's pending update counts in the badge only while its image is not already being counted, so registering just the application does not leave the container looking up to date, and registering both does not count twice. Catalog: four detectors verified on real containers, following the rules in the file. vaultwarden and immich gain docker fallbacks for installs where the native marker does not exist. netalertx is new — note its repository is netalertx/NetAlertX; the Docker Hub namespace 404s. technitiumdns is new and uses Technitium's own update endpoint rather than GitHub releases: its marker reads 15.4 while the release tag is v15.4.0, and _version_tuple compares (15,4,0) > (15,4) as an update that would never clear. Verified live against ghcr.io (Immich 3.1.0), docker.io (Vaultwarden 1.37.2) and lscr.io (Radarr 6.3.0.10514-ls314), plus postgres:16, which correctly reports no version because official images carry no labels. Exercised end to end on Proxmox VE 9.2.4 with NetAlertX reporting 26.6.3 -> 26.9.0. 31 new unit tests cover the resolution rules, every refusal, the delegation contract and the container-to-image pairing.
341 lines
15 KiB
Python
341 lines
15 KiB
Python
"""Resolving the version a Docker image pull would install.
|
|
|
|
The registry answers the question the Updates tab actually asks: not "what is
|
|
the newest upstream release" but "what do I get if I re-pull this tag". These
|
|
tests pin the rules that keep that number honest — and the cases where the
|
|
honest answer is no number at all.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
|
if str(SCRIPTS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
|
|
import lxc_apps
|
|
|
|
|
|
def _blob(payload: dict) -> tuple[bytes, str]:
|
|
body = json.dumps(payload).encode("utf-8")
|
|
return body, "sha256:" + hashlib.sha256(body).hexdigest()
|
|
|
|
|
|
def _image_index(platform_digest: str) -> dict:
|
|
"""An index shaped like the real ones: platforms plus attestations."""
|
|
return {
|
|
"mediaType": "application/vnd.oci.image.index.v1+json",
|
|
"manifests": [
|
|
{"digest": platform_digest,
|
|
"platform": {"os": "linux", "architecture": "amd64"}},
|
|
{"digest": "sha256:" + "b" * 64,
|
|
"platform": {"os": "linux", "architecture": "arm64", "variant": "v8"}},
|
|
{"digest": "sha256:" + "c" * 64,
|
|
"platform": {"os": "unknown", "architecture": "unknown"},
|
|
"annotations": {"vnd.docker.reference.type": "attestation-manifest"}},
|
|
],
|
|
}
|
|
|
|
|
|
def _manifest(config_digest: str) -> dict:
|
|
return {
|
|
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
"config": {
|
|
"mediaType": "application/vnd.oci.image.config.v1+json",
|
|
"digest": config_digest,
|
|
},
|
|
}
|
|
|
|
|
|
class RemoteImageVersionTests(unittest.TestCase):
|
|
def setUp(self):
|
|
with lxc_apps._docker_remote_config_lock:
|
|
lxc_apps._docker_remote_config_cache.clear()
|
|
|
|
def _wire(self, remote_version="3.1.0", labels=None, config_media_type=None):
|
|
"""Serve a three-hop index -> manifest -> config walk from memory."""
|
|
config_payload = {"config": {"Labels": labels if labels is not None else {
|
|
"org.opencontainers.image.version": remote_version,
|
|
}}}
|
|
config_body, config_digest = _blob(config_payload)
|
|
manifest = _manifest(config_digest)
|
|
if config_media_type is not None:
|
|
manifest["config"]["mediaType"] = config_media_type
|
|
manifest_body, manifest_digest = _blob(manifest)
|
|
index_body, index_digest = _blob(_image_index(manifest_digest))
|
|
documents = {
|
|
index_digest: index_body,
|
|
manifest_digest: manifest_body,
|
|
config_digest: config_body,
|
|
}
|
|
calls = []
|
|
|
|
def fake_request(url, headers, method="HEAD", token=None, max_bytes=0,
|
|
follow_redirect=True):
|
|
calls.append(url)
|
|
digest = url.rsplit("/", 1)[-1]
|
|
if digest in documents:
|
|
return {}, documents[digest], "token", None
|
|
return None, None, token, f"registry HTTP 404 for {digest}"
|
|
|
|
return index_digest, calls, mock.patch.object(lxc_apps, "_registry_request", fake_request)
|
|
|
|
def _item(self, index_digest, **overrides):
|
|
item = {
|
|
"api_host": "ghcr.io",
|
|
"repository": "immich-app/immich-server",
|
|
"reference": "ghcr.io/immich-app/immich-server:release",
|
|
"remote_digest": index_digest,
|
|
"installed_version": "3.0.1",
|
|
"installed_version_source": "image_label:org.opencontainers.image.version",
|
|
"platform": {"os": "linux", "architecture": "amd64", "variant": ""},
|
|
}
|
|
item.update(overrides)
|
|
return item
|
|
|
|
def test_resolves_version_from_the_remote_image_config(self):
|
|
index_digest, calls, patch = self._wire()
|
|
with patch:
|
|
version, source = lxc_apps._docker_available_version_from_registry(
|
|
self._item(index_digest))
|
|
self.assertEqual(version, "3.1.0")
|
|
self.assertEqual(source, "remote_image_label:org.opencontainers.image.version")
|
|
self.assertEqual(len(calls), 3, "index, platform manifest and config blob")
|
|
|
|
def test_attestation_manifest_is_never_selected(self):
|
|
"""Its config is a provenance document, not an image."""
|
|
digest = lxc_apps._select_platform_manifest(
|
|
_image_index("sha256:" + "a" * 64),
|
|
{"os": "linux", "architecture": "amd64"},
|
|
)
|
|
self.assertEqual(digest, "sha256:" + "a" * 64)
|
|
|
|
def test_arm64_v8_matches_bare_arm64(self):
|
|
digest = lxc_apps._select_platform_manifest(
|
|
_image_index("sha256:" + "a" * 64),
|
|
{"os": "linux", "architecture": "arm64", "variant": ""},
|
|
)
|
|
self.assertEqual(digest, "sha256:" + "b" * 64)
|
|
|
|
def test_missing_platform_yields_no_version(self):
|
|
"""No build for this host means a pull would fail; claim nothing."""
|
|
index_digest, _calls, patch = self._wire()
|
|
with patch:
|
|
version, source = lxc_apps._docker_available_version_from_registry(
|
|
self._item(index_digest, platform={"os": "linux", "architecture": "riscv64"}))
|
|
self.assertIsNone(version)
|
|
self.assertEqual(source, "remote_platform_missing")
|
|
|
|
def test_same_version_rebuilt_reports_no_number(self):
|
|
index_digest, _calls, patch = self._wire(remote_version="3.0.1")
|
|
with patch:
|
|
version, source = lxc_apps._docker_available_version_from_registry(
|
|
self._item(index_digest))
|
|
self.assertIsNone(version)
|
|
self.assertEqual(source, "version_not_comparable")
|
|
|
|
def test_lower_remote_version_is_rejected(self):
|
|
index_digest, _calls, patch = self._wire(remote_version="2.9.0")
|
|
with patch:
|
|
version, _source = lxc_apps._docker_available_version_from_registry(
|
|
self._item(index_digest))
|
|
self.assertIsNone(version)
|
|
|
|
def test_linuxserver_build_suffix_is_an_update(self):
|
|
"""LinuxServer bumps the -lsNNN build; the app version may not move.
|
|
|
|
Label shape taken from the live lscr.io/linuxserver/radarr image.
|
|
"""
|
|
index_digest, _calls, patch = self._wire(
|
|
labels={"org.opencontainers.image.version": "6.3.0.10514-ls314",
|
|
"build_version": "Linuxserver.io version:- 6.3.0.10514-ls314"})
|
|
with patch:
|
|
version, source = lxc_apps._docker_available_version_from_registry(
|
|
self._item(index_digest, installed_version="6.3.0.10514-ls313"))
|
|
self.assertEqual(version, "6.3.0.10514-ls314")
|
|
self.assertEqual(source, "remote_image_label:org.opencontainers.image.version")
|
|
|
|
def test_label_key_must_match_the_installed_side(self):
|
|
"""Two publishers' label schemes are not a comparison."""
|
|
index_digest, _calls, patch = self._wire()
|
|
with patch:
|
|
version, source = lxc_apps._docker_available_version_from_registry(
|
|
self._item(index_digest, installed_version_source="image_label:Version"))
|
|
self.assertIsNone(version)
|
|
self.assertEqual(source, "version_source_mismatch")
|
|
|
|
def test_config_without_labels_reports_no_version(self):
|
|
index_digest, _calls, patch = self._wire(labels={})
|
|
with patch:
|
|
version, source = lxc_apps._docker_available_version_from_registry(
|
|
self._item(index_digest))
|
|
self.assertIsNone(version)
|
|
self.assertEqual(source, "remote_no_labels")
|
|
|
|
def test_moving_tag_label_is_rejected(self):
|
|
index_digest, _calls, patch = self._wire(
|
|
labels={"org.opencontainers.image.version": "main"})
|
|
with patch:
|
|
version, source = lxc_apps._docker_available_version_from_registry(
|
|
self._item(index_digest))
|
|
self.assertIsNone(version)
|
|
self.assertEqual(source, "remote_no_labels")
|
|
|
|
def test_non_image_artifact_is_unsupported(self):
|
|
index_digest, _calls, patch = self._wire(
|
|
config_media_type="application/vnd.cncf.helm.config.v1+json")
|
|
with patch:
|
|
version, source = lxc_apps._docker_available_version_from_registry(
|
|
self._item(index_digest))
|
|
self.assertIsNone(version)
|
|
self.assertEqual(source, "remote_unsupported_manifest")
|
|
|
|
def test_tampered_document_is_discarded(self):
|
|
"""Documents are content-addressed; a mismatch is not trusted."""
|
|
def fake_request(url, headers, method="HEAD", token=None, max_bytes=0,
|
|
follow_redirect=True):
|
|
return {}, b'{"mediaType": "application/vnd.oci.image.manifest.v1+json"}', "t", None
|
|
|
|
with mock.patch.object(lxc_apps, "_registry_request", fake_request):
|
|
version, source = lxc_apps._docker_available_version_from_registry(
|
|
self._item("sha256:" + "f" * 64))
|
|
self.assertIsNone(version)
|
|
self.assertEqual(source, "remote_fetch_error")
|
|
|
|
def test_registry_error_leaves_the_digest_verdict_alone(self):
|
|
def fake_request(url, headers, method="HEAD", token=None, max_bytes=0,
|
|
follow_redirect=True):
|
|
return None, None, None, "registry network error: timed out"
|
|
|
|
item = self._item("sha256:" + "e" * 64, update_available=True)
|
|
with mock.patch.object(lxc_apps, "_registry_request", fake_request):
|
|
version, source = lxc_apps._docker_available_version_from_registry(item)
|
|
self.assertIsNone(version)
|
|
self.assertEqual(source, "remote_fetch_error")
|
|
self.assertIs(item["update_available"], True, "the digest verdict stands on its own")
|
|
|
|
def test_second_image_with_the_same_digest_is_served_from_cache(self):
|
|
index_digest, calls, patch = self._wire()
|
|
with patch:
|
|
lxc_apps._docker_available_version_from_registry(self._item(index_digest))
|
|
lxc_apps._docker_available_version_from_registry(self._item(index_digest))
|
|
self.assertEqual(len(calls), 3, "the second lookup must not hit the registry")
|
|
|
|
|
|
class RegistryRedirectTests(unittest.TestCase):
|
|
def test_authorization_is_dropped_on_the_cdn_hop(self):
|
|
"""Signed storage URLs reject a second auth mechanism."""
|
|
seen = []
|
|
|
|
def fake_open(url, headers, method, max_bytes):
|
|
seen.append((url, dict(headers)))
|
|
if "blobs" in url:
|
|
return {"Location": "https://cdn.example/blob"}, None, 307, "https://cdn.example/blob", None
|
|
return {}, b"{}", 200, None, None
|
|
|
|
with mock.patch.object(lxc_apps, "_registry_open", fake_open):
|
|
lxc_apps._registry_request(
|
|
"https://ghcr.io/v2/x/y/blobs/sha256:abc",
|
|
{"User-Agent": "ProxMenux-Monitor", "Authorization": "Bearer secret"},
|
|
method="GET", max_bytes=1024,
|
|
)
|
|
self.assertEqual(len(seen), 2)
|
|
self.assertIn("Authorization", seen[0][1])
|
|
self.assertNotIn("Authorization", seen[1][1])
|
|
|
|
def test_digest_request_keeps_its_contract(self):
|
|
def fake_open(url, headers, method, max_bytes):
|
|
return {"Docker-Content-Digest": "sha256:deadbeef"}, None, 200, None, None
|
|
|
|
with mock.patch.object(lxc_apps, "_registry_open", fake_open):
|
|
digest, error = lxc_apps._registry_digest_request("https://ghcr.io/v2/x/y/manifests/latest", {})
|
|
self.assertEqual(digest, "sha256:deadbeef")
|
|
self.assertIsNone(error)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
class ContainerIdentityTests(unittest.TestCase):
|
|
"""A container's catalog identity comes from a detector that declares it."""
|
|
|
|
HINTS = {
|
|
"immich": {
|
|
"installed_via": "file",
|
|
"file_path": "/root/.immich",
|
|
"logo": "https://example.invalid/immich.webp",
|
|
"alt_detectors": [
|
|
{"installed_via": "docker_label", "container_name": "immich_server",
|
|
"label": "org.opencontainers.image.version"},
|
|
],
|
|
},
|
|
"netalertx": {
|
|
"installed_via": "docker_label",
|
|
"container_name": "netalertx",
|
|
"label": "org.opencontainers.image.version",
|
|
},
|
|
"postgresql": {"installed_via": "dpkg", "package": "postgresql"},
|
|
}
|
|
|
|
def test_index_covers_primary_and_alternate_detectors(self):
|
|
with mock.patch.object(lxc_apps, "_fetch_tracking_hints", lambda: self.HINTS):
|
|
index = lxc_apps._docker_container_slug_index()
|
|
self.assertEqual(index.get("immich_server"), "immich")
|
|
self.assertEqual(index.get("netalertx"), "netalertx")
|
|
|
|
def test_unclaimed_container_names_stay_out(self):
|
|
"""A container merely called postgres is not an application claim."""
|
|
with mock.patch.object(lxc_apps, "_fetch_tracking_hints", lambda: self.HINTS):
|
|
index = lxc_apps._docker_container_slug_index()
|
|
self.assertNotIn("postgres", index)
|
|
|
|
def test_declared_container_resolves_a_name_heuristics_cannot(self):
|
|
with mock.patch.object(lxc_apps, "_fetch_tracking_hints", lambda: self.HINTS), \
|
|
mock.patch.object(lxc_apps, "_catalog_lookup", lambda slug: {"name": "Immich"} if slug == "immich" else None):
|
|
meta = lxc_apps._docker_service_catalog_meta(
|
|
"immich-server", "immich_server",
|
|
"ghcr.io/immich-app/immich-server:release",
|
|
)
|
|
self.assertEqual(meta["slug"], "immich")
|
|
self.assertEqual(meta["name"], "Immich")
|
|
self.assertEqual(meta["logo_url"], "https://example.invalid/immich.webp")
|
|
|
|
|
|
class UpdateNotificationWordingTests(unittest.TestCase):
|
|
def test_alert_leads_with_the_application_name(self):
|
|
payload = lxc_apps._docker_stack_notification_payload(
|
|
102,
|
|
{"state": {}},
|
|
{"images": [{
|
|
"reference": "vaultwarden/server:latest",
|
|
"display_name": "Vaultwarden",
|
|
"installed_version": "1.37.2",
|
|
"available_version": "1.38.0",
|
|
"update_available": True,
|
|
"remote_digest": "sha256:" + "a" * 64,
|
|
}]},
|
|
"vaultwarden",
|
|
)
|
|
self.assertIsNotNone(payload)
|
|
self.assertIn("• Vaultwarden (vaultwarden/server:latest): 1.37.2 → 1.38.0", payload["details"])
|
|
|
|
def test_unnamed_image_keeps_its_reference(self):
|
|
payload = lxc_apps._docker_stack_notification_payload(
|
|
102,
|
|
{"state": {}},
|
|
{"images": [{
|
|
"reference": "valkey/valkey:8-bookworm",
|
|
"installed_version": None,
|
|
"available_version": None,
|
|
"update_available": True,
|
|
"remote_digest": "sha256:" + "b" * 64,
|
|
}]},
|
|
"immich",
|
|
)
|
|
self.assertIn("• valkey/valkey:8-bookworm: new registry digest", payload["details"])
|