From 1b1fb7507d02d59149bfc154771deea8ffcc5903 Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Tue, 27 Jan 2026 13:32:29 +0100 Subject: [PATCH] Reuse and extend standardized OCI annotations Signed-off-by: Tobias Wolf On-behalf-of: SAP --- src/gardenlinux/oci/image_manifest.py | 156 +++++++++++++++++--------- src/gardenlinux/oci/layer.py | 14 ++- src/gardenlinux/oci/manifest.py | 41 +++++-- tests/oci/test_image_manifest.py | 14 ++- 4 files changed, 153 insertions(+), 72 deletions(-) diff --git a/src/gardenlinux/oci/image_manifest.py b/src/gardenlinux/oci/image_manifest.py index 12bd6d2e..86a2986e 100644 --- a/src/gardenlinux/oci/image_manifest.py +++ b/src/gardenlinux/oci/image_manifest.py @@ -6,9 +6,11 @@ from pathlib import Path from typing import Any, Dict -from oras.oci import Layer +from oras.defaults import annotation_title as ANNOTATION_TITLE +from ..constants import GL_DISTRIBUTION_NAME, GL_REPOSITORY_URL from ..features import CName +from .layer import Layer from .manifest import Manifest from .platform import NewPlatform from .schemas import EmptyManifestMetadata @@ -27,6 +29,31 @@ class ImageManifest(Manifest): Apache License, Version 2.0 """ + ANNOTATION_ARCH_KEY = "org.opencontainers.image.architecture" + """ + OCI image manifest architecture annotation + """ + ANNOTATION_CNAME_KEY = "cname" + """ + OCI image manifest GardenLinux canonical name annotation + """ + ANNOTATION_DESCRIPTION_KEY = "org.opencontainers.image.description" + """ + OCI image manifest description annotation + """ + ANNOTATION_FEATURE_SET_KEY = "feature_set" + """ + OCI image manifest GardenLinux feature set annotation + """ + ANNOTATION_SOURCE_REPO_KEY = "org.opencontainers.image.source" + """ + OCI image manifest GardenLinux source repository URL annotation + """ + ANNOTATION_TITLE_KEY = ANNOTATION_TITLE + """ + OCI image manifest title annotation + """ + @property def arch(self) -> str: """ @@ -36,12 +63,12 @@ def arch(self) -> str: :since: 0.7.0 """ - if "architecture" not in self.get("annotations", {}): + if ImageManifest.ANNOTATION_ARCH_KEY not in self.get("annotations", {}): raise RuntimeError( - "Unexpected manifest with missing config annotation 'architecture' found" + f"Unexpected manifest with missing config annotation '{ImageManifest.ANNOTATION_ARCH_KEY}' found" ) - return self["annotations"]["architecture"] # type: ignore[no-any-return] + return self["annotations"][ImageManifest.ANNOTATION_ARCH_KEY] # type: ignore[no-any-return] @arch.setter def arch(self, value: str) -> None: @@ -54,7 +81,7 @@ def arch(self, value: str) -> None: """ self._ensure_annotations_dict() - self["annotations"]["architecture"] = value + self["annotations"][ImageManifest.ANNOTATION_ARCH_KEY] = value @property def cname(self) -> str: @@ -65,12 +92,12 @@ def cname(self) -> str: :since: 0.7.0 """ - if "cname" not in self.get("annotations", {}): + if ImageManifest.ANNOTATION_CNAME_KEY not in self.get("annotations", {}): raise RuntimeError( - "Unexpected manifest with missing config annotation 'cname' found" + f"Unexpected manifest with missing config annotation '{ImageManifest.ANNOTATION_CNAME_KEY}' found" ) - return self["annotations"]["cname"] # type: ignore[no-any-return] + return self["annotations"][ImageManifest.ANNOTATION_CNAME_KEY] # type: ignore[no-any-return] @cname.setter def cname(self, value: str) -> None: @@ -83,7 +110,7 @@ def cname(self, value: str) -> None: """ self._ensure_annotations_dict() - self["annotations"]["cname"] = value + self["annotations"][ImageManifest.ANNOTATION_CNAME_KEY] = value @property def feature_set(self) -> str: @@ -94,12 +121,12 @@ def feature_set(self) -> str: :since: 0.7.0 """ - if "feature_set" not in self.get("annotations", {}): + if ImageManifest.ANNOTATION_FEATURE_SET_KEY not in self.get("annotations", {}): raise RuntimeError( - "Unexpected manifest with missing config annotation 'feature_set' found" + f"Unexpected manifest with missing config annotation '{ImageManifest.ANNOTATION_FEATURE_SET_KEY}' found" ) - return self["annotations"]["feature_set"] # type: ignore[no-any-return] + return self["annotations"][ImageManifest.ANNOTATION_FEATURE_SET_KEY] # type: ignore[no-any-return] @feature_set.setter def feature_set(self, value: str) -> None: @@ -112,7 +139,7 @@ def feature_set(self, value: str) -> None: """ self._ensure_annotations_dict() - self["annotations"]["feature_set"] = value + self["annotations"][ImageManifest.ANNOTATION_FEATURE_SET_KEY] = value @property def flavor(self) -> str: @@ -126,56 +153,75 @@ def flavor(self) -> str: return CName(self.cname).flavor @property - def layers_as_dict(self) -> Dict[str, Any]: + def _final_dict(self) -> Dict[str, Any]: """ - Returns the OCI image manifest layers as a dictionary. + Returns the final parsed and extended OCI manifest dictionary - :return: (dict) OCI image manifest layers with title as key - :since: 0.7.0 + :return: (dict) OCI manifest dictionary + :since: 1.0.0 """ - layers = {} + manifest_dict = Manifest(self)._final_dict + manifest_annotations = manifest_dict["annotations"] - for layer in self["layers"]: - if "org.opencontainers.image.title" not in layer.get("annotations", {}): - raise RuntimeError( - "Unexpected layer with missing annotation 'org.opencontainers.image.title' found" - ) + if ImageManifest.ANNOTATION_TITLE_KEY not in manifest_annotations: + manifest_annotations[ImageManifest.ANNOTATION_TITLE_KEY] = ( + GL_DISTRIBUTION_NAME + ) - layers[layer["annotations"]["org.opencontainers.image.title"]] = layer + manifest_description = manifest_annotations[ImageManifest.ANNOTATION_TITLE_KEY] - return layers + if ImageManifest.ANNOTATION_SOURCE_REPO_KEY not in manifest_annotations: + manifest_annotations[ImageManifest.ANNOTATION_SOURCE_REPO_KEY] = ( + GL_REPOSITORY_URL + ) - @property - def version(self) -> str: - """ - Returns the GardenLinux version of the OCI image manifest. + if ImageManifest.ANNOTATION_ARCH_KEY in manifest_annotations: + manifest_annotations["architecture"] = self.arch + manifest_description += f" ({self.arch})" - :return: (str) OCI image GardenLinux version - :since: 0.7.0 - """ + if ImageManifest.ANNOTATION_VERSION_KEY in manifest_annotations: + manifest_annotations["org.opencontainers.image.version"] = self.version + manifest_description += " " + self.version - if "version" not in self.get("annotations", {}): - raise RuntimeError( - "Unexpected manifest with missing config annotation 'version' found" + if ImageManifest.ANNOTATION_COMMIT_KEY in manifest_annotations: + manifest_annotations["org.opencontainers.image.revision"] = self.commit + manifest_description += f" ({self.commit})" + + if ImageManifest.ANNOTATION_FEATURE_SET_KEY in manifest_annotations: + manifest_description += ( + " - " + manifest_annotations[ImageManifest.ANNOTATION_FEATURE_SET_KEY] + ) + + if ImageManifest.ANNOTATION_DESCRIPTION_KEY not in manifest_annotations: + manifest_annotations[ImageManifest.ANNOTATION_DESCRIPTION_KEY] = ( + manifest_description ) - return self["annotations"]["version"] # type: ignore[no-any-return] + return manifest_dict - @version.setter - def version(self, value: str) -> None: + @property + def layers_as_dict(self) -> Dict[str, Any]: """ - Sets the GardenLinux version of the OCI image manifest. - - :param value: OCI image GardenLinux version + Returns the OCI image manifest layers as a dictionary. - :since: 0.7.0 + :return: (dict) OCI image manifest layers with title as key + :since: 0.7.0 """ - self._ensure_annotations_dict() - self["annotations"]["version"] = value + layers = {} + + for layer in self["layers"]: + if ImageManifest.ANNOTATION_TITLE_KEY not in layer.get("annotations", {}): + raise RuntimeError( + f"Unexpected layer with missing annotation '{ImageManifest.ANNOTATION_TITLE_KEY}' found" + ) + + layers[layer["annotations"][ImageManifest.ANNOTATION_TITLE_KEY]] = layer - def append_layer(self, layer: Layer) -> None: + return layers + + def append_layer(self, layer: Layer | Dict[str, Any]) -> None: """ Appends the given OCI image manifest layer to the manifest @@ -184,30 +230,30 @@ def append_layer(self, layer: Layer) -> None: :since: 0.7.0 """ - if not isinstance(layer, Layer): - raise RuntimeError("Unexpected layer type given") - - layer_dict = layer.dict + if isinstance(layer, Layer): + layer_dict = layer.dict + else: + layer_dict = layer - if "org.opencontainers.image.title" not in layer_dict.get("annotations", {}): + if ImageManifest.ANNOTATION_TITLE_KEY not in layer_dict.get("annotations", {}): raise RuntimeError( - "Unexpected layer with missing annotation 'org.opencontainers.image.title' found" + f"Unexpected layer with missing annotation '{ImageManifest.ANNOTATION_TITLE_KEY}' found" ) - image_title = layer_dict["annotations"]["org.opencontainers.image.title"] + image_title = layer_dict["annotations"][ImageManifest.ANNOTATION_TITLE_KEY] existing_layer_index = 0 for existing_layer in self["layers"]: - if "org.opencontainers.image.title" not in existing_layer.get( + if ImageManifest.ANNOTATION_TITLE_KEY not in existing_layer.get( "annotations", {} ): raise RuntimeError( - "Unexpected layer with missing annotation 'org.opencontainers.image.title' found" + f"Unexpected layer with missing annotation '{ImageManifest.ANNOTATION_TITLE_KEY}' found" ) if ( image_title - == existing_layer["annotations"]["org.opencontainers.image.title"] + == existing_layer["annotations"][ImageManifest.ANNOTATION_TITLE_KEY] ): break diff --git a/src/gardenlinux/oci/layer.py b/src/gardenlinux/oci/layer.py index c01e01e1..a755dc4d 100644 --- a/src/gardenlinux/oci/layer.py +++ b/src/gardenlinux/oci/layer.py @@ -5,7 +5,6 @@ from pathlib import Path from typing import Any, Dict, Iterator, Optional -from oras.defaults import annotation_title as ANNOTATION_TITLE from oras.oci import Layer as _Layer from ..constants import GL_MEDIA_TYPE_LOOKUP, GL_MEDIA_TYPES @@ -26,6 +25,15 @@ class Layer(_Layer, Mapping): # type: ignore[misc, type-arg] Apache License, Version 2.0 """ + ANNOTATION_ARCH_KEY = "io.gardenlinux.image.layer.architecture" + """ + OCI image layer architecture annotation + """ + ANNOTATION_TITLE_KEY = "org.opencontainers.image.title" + """ + OCI image layer title annotation + """ + def __init__( self, blob_path: PathLike[str] | str, @@ -48,7 +56,7 @@ def __init__( _Layer.__init__(self, blob_path, media_type, is_dir) self._annotations = { - ANNOTATION_TITLE: blob_path.name, # type: ignore[attr-defined] + Layer.ANNOTATION_TITLE_KEY: blob_path.name, # type: ignore[attr-defined] } @property @@ -157,7 +165,7 @@ def generate_metadata_from_file_name( return { "file_name": file_name.name, # type: ignore[attr-defined] "media_type": media_type, - "annotations": {"io.gardenlinux.image.layer.architecture": arch}, + "annotations": {Layer.ANNOTATION_ARCH_KEY: arch}, } @staticmethod diff --git a/src/gardenlinux/oci/manifest.py b/src/gardenlinux/oci/manifest.py index 8a877bf1..3aa52afa 100644 --- a/src/gardenlinux/oci/manifest.py +++ b/src/gardenlinux/oci/manifest.py @@ -22,6 +22,15 @@ class Manifest(dict): # type: ignore[type-arg] Apache License, Version 2.0 """ + ANNOTATION_COMMIT_KEY = "commit" + """ + OCI image manifest GardenLinux commit hash annotation + """ + ANNOTATION_VERSION_KEY = "version" + """ + OCI image manifest GardenLinux version annotation + """ + def __init__(self, *args: Any, **kwargs: Any): """ Constructor __init__(Manifest) @@ -46,12 +55,12 @@ def commit(self) -> str: :since: 0.7.0 """ - if "commit" not in self.get("annotations", {}): + if Manifest.ANNOTATION_COMMIT_KEY not in self.get("annotations", {}): raise RuntimeError( - "Unexpected manifest with missing config annotation 'commit' found" + f"Unexpected manifest with missing config annotation '{Manifest.ANNOTATION_COMMIT_KEY}' found" ) - return self["annotations"]["commit"] # type: ignore[no-any-return] + return self["annotations"][Manifest.ANNOTATION_COMMIT_KEY] # type: ignore[no-any-return] @commit.setter def commit(self, value: str) -> None: @@ -64,7 +73,7 @@ def commit(self, value: str) -> None: """ self._ensure_annotations_dict() - self["annotations"]["commit"] = value + self["annotations"][Manifest.ANNOTATION_COMMIT_KEY] = value @property def config_json(self) -> bytes: @@ -89,6 +98,20 @@ def digest(self) -> str: digest = sha256(self.json).hexdigest() return f"sha256:{digest}" + @property + def _final_dict(self) -> Dict[str, Any]: + """ + Returns the final parsed and extended OCI manifest dictionary + + :return: (dict) OCI manifest dictionary + :since: 1.0.0 + """ + + self._ensure_annotations_dict() + manifest_dict = self.copy() + + return manifest_dict + @property def json(self) -> bytes: """ @@ -98,7 +121,7 @@ def json(self) -> bytes: :since: 0.7.0 """ - return json.dumps(self).encode("utf-8") + return json.dumps(self._final_dict).encode("utf-8") @property def size(self) -> int: @@ -120,12 +143,12 @@ def version(self) -> str: :since: 0.7.0 """ - if "version" not in self.get("annotations", {}): + if Manifest.ANNOTATION_VERSION_KEY not in self.get("annotations", {}): raise RuntimeError( - "Unexpected manifest with missing config annotation 'version' found" + f"Unexpected manifest with missing config annotation '{Manifest.ANNOTATION_VERSION_KEY}' found" ) - return self["annotations"]["version"] # type: ignore[no-any-return] + return self["annotations"][Manifest.ANNOTATION_VERSION_KEY] # type: ignore[no-any-return] @version.setter def version(self, value: str) -> None: @@ -138,7 +161,7 @@ def version(self, value: str) -> None: """ self._ensure_annotations_dict() - self["annotations"]["version"] = value + self["annotations"][Manifest.ANNOTATION_VERSION_KEY] = value def config_from_dict( self, config: Dict[str, Any], annotations: Dict[str, Any] diff --git a/tests/oci/test_image_manifest.py b/tests/oci/test_image_manifest.py index 55cdc483..4ac89691 100644 --- a/tests/oci/test_image_manifest.py +++ b/tests/oci/test_image_manifest.py @@ -8,7 +8,7 @@ def test_ImageManifest_arch() -> None: # Arrange empty_manifest = ImageManifest() - manifest = ImageManifest(annotations={"architecture": "amd64"}) + manifest = ImageManifest(annotations={ImageManifest.ANNOTATION_ARCH_KEY: "amd64"}) # Assert with pytest.raises(RuntimeError): @@ -25,7 +25,7 @@ def test_ImageManifest_cname() -> None: cname = "container-amd64-today-local" empty_manifest = ImageManifest() - manifest = ImageManifest(annotations={"cname": cname}) + manifest = ImageManifest(annotations={ImageManifest.ANNOTATION_CNAME_KEY: cname}) # Assert with pytest.raises(RuntimeError): @@ -42,7 +42,9 @@ def test_ImageManifest_feature_set() -> None: feature_set = "container" empty_manifest = ImageManifest() - manifest = ImageManifest(annotations={"feature_set": feature_set}) + manifest = ImageManifest( + annotations={ImageManifest.ANNOTATION_FEATURE_SET_KEY: feature_set} + ) # Assert with pytest.raises(RuntimeError): @@ -60,7 +62,7 @@ def test_ImageManifest_flavor() -> None: cname = f"{flavor}-amd64-today-local" empty_manifest = ImageManifest() - manifest = ImageManifest(annotations={"cname": cname}) + manifest = ImageManifest(annotations={ImageManifest.ANNOTATION_CNAME_KEY: cname}) # Assert with pytest.raises(RuntimeError): @@ -98,7 +100,9 @@ def test_ImageManifest_version() -> None: version = "today" empty_manifest = ImageManifest() - manifest = ImageManifest(annotations={"version": version}) + manifest = ImageManifest( + annotations={ImageManifest.ANNOTATION_VERSION_KEY: version} + ) # Assert with pytest.raises(RuntimeError):