diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index ff82d334..802aee3e 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -4347,6 +4347,7 @@ def check_app( "error": result.get("error"), "checked_at": _now_iso(), "installed_digest": result.get("installed_digest"), + "installed_registry_digest": result.get("installed_registry_digest"), "latest_digest": result.get("latest_digest"), "image_created": result.get("image_created"), "latest_image_created": result.get("latest_image_created"), @@ -5598,6 +5599,7 @@ def _oci_instance_meta(vmid) -> Optional[dict]: contract = template.get("container_contract") or {} image = contract.get("image") or {} observed_image = (record.get("observed") or {}).get("image") or {} + registry_digest = str((record.get("observed") or {}).get("resolved_registry_digest") or "").strip() # A stack member carries only its own image contract; the presentation # belongs to the stack it is part of, which records its title, site, # category and the endpoint the stack is reached on. @@ -5702,6 +5704,9 @@ def _oci_instance_meta(vmid) -> Optional[dict]: # The exact image this container was created from. Its digest is what # an update is decided on; the version label is only for reading. "installed_digest": str(observed_image.get("manifest_digest") or "").strip() or None, + # The digest the registry served that image under. An image published + # with a Docker-format manifest is saved under another one. + "registry_digest": registry_digest if re.fullmatch(r"sha256:[0-9a-f]{64}", registry_digest) else None, "architecture": str(observed_image.get("architecture") or "").strip() or None, } @@ -5789,16 +5794,21 @@ def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool = return {**result, "error": f"OCI engine unavailable: {exc}"} known = known or {} + # Deciding an update needs the digest the registry gave the installed image. if (known.get("installed_digest") == installed_digest + and (known.get("installed_registry_digest") or not with_latest) and (known.get("installed_version") or known.get("image_created"))): result["installed_version"] = known.get("installed_version") result["image_created"] = known.get("image_created") + result["installed_registry_digest"] = known.get("installed_registry_digest") else: try: installed = _oci_resolve( - module, f"{_oci_repository(reference)}@{installed_digest}", architecture) + module, f"{_oci_repository(reference)}@{meta.get('registry_digest') or installed_digest}", + architecture) result["installed_version"] = installed.get("version") result["image_created"] = installed.get("created") + result["installed_registry_digest"] = installed.get("manifest_digest") except Exception as exc: return {**result, "error": f"could not read the installed image: {exc}"} if not result.get("installed_version"): @@ -5815,7 +5825,7 @@ def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool = # The image decides. An application whose version did not move can still # have a new image — a rebuild on a patched base — and that is an update # for a container whose application only changes when its image does. - replaced = bool(latest_digest) and latest_digest != installed_digest + replaced = bool(latest_digest) and latest_digest != (result.get("installed_registry_digest") or installed_digest) result.update(latest_digest=latest_digest, latest_version=latest.get("version"), latest_image_created=latest.get("created"), update_available=replaced) return result diff --git a/AppImage/scripts/tests/test_oci_docker_manifest_versions.py b/AppImage/scripts/tests/test_oci_docker_manifest_versions.py new file mode 100644 index 00000000..f53e1b72 --- /dev/null +++ b/AppImage/scripts/tests/test_oci_docker_manifest_versions.py @@ -0,0 +1,76 @@ +"""An image published with a Docker-format manifest is saved for Proxmox +under another digest than the one its registry serves. Its installed version +and its updates are read through the digest the registry knows.""" +import sys +from pathlib import Path +import unittest +from unittest.mock import patch + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) +import lxc_apps + +ARCHIVE = "sha256:" + "a1" * 32 +INDEX = "sha256:" + "b2" * 32 +PLATFORM = "sha256:" + "c3" * 32 +NEWER = "sha256:" + "d4" * 32 + + +class Registry: + """A registry that knows the index and the platform manifest, never the archive.""" + + def __init__(self, tag=PLATFORM): + self.tag, self.asked = tag, [] + + def resolve_candidate(self, reference, architecture): + self.asked.append(reference) + if reference.endswith("@" + ARCHIVE): + raise RuntimeError("manifest unknown") + digest = self.tag if "@" not in reference else PLATFORM + return {"manifest_digest": digest, "version": "12.1" if digest == PLATFORM else "12.2", + "created": "2026-09-15T01:13:55Z"} + + +class DockerManifestVersionTests(unittest.TestCase): + def versions(self, registry, registry_digest=INDEX, known=None): + meta = {"image_reference": "jellyfin/jellyfin:latest", "installed_digest": ARCHIVE, + "registry_digest": registry_digest, "architecture": "amd64", "repository": None} + with patch.object(lxc_apps, "_oci_operation_running", return_value=False), \ + patch.object(lxc_apps, "_oci_instance_meta", return_value=meta), \ + patch.object(lxc_apps, "_oci_state_module", return_value=registry), \ + patch.object(lxc_apps.time, "sleep"): + return lxc_apps._oci_image_versions(105, known=known) + + def test_the_installed_image_is_read_by_the_digest_the_registry_served(self): + registry = Registry() + result = self.versions(registry) + self.assertNotIn("error", result) + self.assertEqual(registry.asked[0], "jellyfin/jellyfin@" + INDEX) + self.assertEqual((result["installed_version"], result["installed_registry_digest"]), ("12.1", PLATFORM)) + self.assertFalse(result["update_available"]) + self.assertEqual(result["installed_digest"], ARCHIVE) + + def test_a_new_image_under_the_tag_is_an_update(self): + result = self.versions(Registry(tag=NEWER)) + self.assertTrue(result["update_available"]) + self.assertEqual((result["latest_digest"], result["latest_version"]), (NEWER, "12.2")) + + def test_a_previous_answer_is_reused_with_its_registry_digest(self): + registry = Registry() + known = {"installed_digest": ARCHIVE, "installed_registry_digest": PLATFORM, "installed_version": "12.1", + "image_created": "2026-09-15T01:13:55Z"} + result = self.versions(registry, known=known) + self.assertEqual(registry.asked, ["jellyfin/jellyfin:latest"]) + self.assertFalse(result["update_available"]) + # An answer saved without the registry digest is asked again. + registry = Registry() + self.versions(registry, known={key: value for key, value in known.items() if key != "installed_registry_digest"}) + self.assertEqual(registry.asked[0], "jellyfin/jellyfin@" + INDEX) + + def test_a_record_without_the_registry_digest_is_read_by_its_own(self): + result = self.versions(Registry(), registry_digest=None) + self.assertIn("could not read the installed image", result["error"]) + + +if __name__ == "__main__": + unittest.main()