mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
fix(monitor): read an OCI image saved from a Docker-format manifest
An image published with a Docker-format manifest is saved for Proxmox under another digest than the one its registry serves. The App tab reads the installed version through the digest the registry served the image under, and decides an update by comparing the platform manifest that digest resolves to with the one the tag points at.
This commit is contained in:
@@ -4347,6 +4347,7 @@ def check_app(
|
|||||||
"error": result.get("error"),
|
"error": result.get("error"),
|
||||||
"checked_at": _now_iso(),
|
"checked_at": _now_iso(),
|
||||||
"installed_digest": result.get("installed_digest"),
|
"installed_digest": result.get("installed_digest"),
|
||||||
|
"installed_registry_digest": result.get("installed_registry_digest"),
|
||||||
"latest_digest": result.get("latest_digest"),
|
"latest_digest": result.get("latest_digest"),
|
||||||
"image_created": result.get("image_created"),
|
"image_created": result.get("image_created"),
|
||||||
"latest_image_created": result.get("latest_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 {}
|
contract = template.get("container_contract") or {}
|
||||||
image = contract.get("image") or {}
|
image = contract.get("image") or {}
|
||||||
observed_image = (record.get("observed") or {}).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
|
# A stack member carries only its own image contract; the presentation
|
||||||
# belongs to the stack it is part of, which records its title, site,
|
# belongs to the stack it is part of, which records its title, site,
|
||||||
# category and the endpoint the stack is reached on.
|
# 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
|
# The exact image this container was created from. Its digest is what
|
||||||
# an update is decided on; the version label is only for reading.
|
# an update is decided on; the version label is only for reading.
|
||||||
"installed_digest": str(observed_image.get("manifest_digest") or "").strip() or None,
|
"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,
|
"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}"}
|
return {**result, "error": f"OCI engine unavailable: {exc}"}
|
||||||
|
|
||||||
known = known or {}
|
known = known or {}
|
||||||
|
# Deciding an update needs the digest the registry gave the installed image.
|
||||||
if (known.get("installed_digest") == installed_digest
|
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"))):
|
and (known.get("installed_version") or known.get("image_created"))):
|
||||||
result["installed_version"] = known.get("installed_version")
|
result["installed_version"] = known.get("installed_version")
|
||||||
result["image_created"] = known.get("image_created")
|
result["image_created"] = known.get("image_created")
|
||||||
|
result["installed_registry_digest"] = known.get("installed_registry_digest")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
installed = _oci_resolve(
|
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["installed_version"] = installed.get("version")
|
||||||
result["image_created"] = installed.get("created")
|
result["image_created"] = installed.get("created")
|
||||||
|
result["installed_registry_digest"] = installed.get("manifest_digest")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {**result, "error": f"could not read the installed image: {exc}"}
|
return {**result, "error": f"could not read the installed image: {exc}"}
|
||||||
if not result.get("installed_version"):
|
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
|
# 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
|
# 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.
|
# 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"),
|
result.update(latest_digest=latest_digest, latest_version=latest.get("version"),
|
||||||
latest_image_created=latest.get("created"), update_available=replaced)
|
latest_image_created=latest.get("created"), update_available=replaced)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user