From 7ed99dbc6c7cca017706634c271028e9cc1bf7d5 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Thu, 12 Feb 2026 13:52:51 -0700 Subject: [PATCH 1/4] Refactor image target metadata handling to allow multiple metadata attachments --- posit-bakery/posit_bakery/cli/ci.py | 40 ++--- posit-bakery/posit_bakery/cli/run.py | 14 +- posit-bakery/posit_bakery/config/config.py | 22 ++- posit-bakery/posit_bakery/image/goss/dgoss.py | 5 +- .../posit_bakery/image/image_metadata.py | 151 +++++++++++++++--- .../posit_bakery/image/image_target.py | 66 ++++++-- posit-bakery/posit_bakery/settings.py | 17 ++ posit-bakery/test/cli/test_ci.py | 4 +- posit-bakery/test/config/test_config.py | 13 +- .../test/features/cli/ci/merge.feature | 4 +- posit-bakery/test/image/goss/test_dgoss.py | 17 +- .../test/image/test_image_metadata.py | 33 +--- posit-bakery/test/image/test_image_target.py | 58 +++++-- 13 files changed, 306 insertions(+), 138 deletions(-) diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index d060b15c..40d501eb 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -172,44 +172,28 @@ def merge( log.info(f"Reading targets from {', '.join(f.name for f in metadata_file)}") files_ok = True - image_digests: dict[str, list[str]] = {} + loaded_targets: list[str] = [] for file in metadata_file: - if not file.is_file(): - log.error(f"Metadata file '{file}' does not exist") + try: + loaded_targets.extend(config.load_build_metadata_from_file(file)) + except Exception as e: + log.error(f"Failed to load metadata from file '{file}'") + log.error(str(e)) files_ok = False - continue - with open(file, "r") as f: - try: - data = json.load(f) - except json.JSONDecodeError as e: - log.error(f"Metadata file '{file}' is not valid JSON: {str(e)}") - files_ok = False - continue - for uid, metadata in data.items(): - image_name = metadata.get("image.name") - if not image_name: - log.error(f"Metadata file '{file}' is missing 'image.name' for image UID '{uid}'") - files_ok = False - continue - digest = metadata.get("containerimage.digest") - if not digest: - log.error(f"Metadata file '{file}' is missing 'containerimage.digest' for image UID '{uid}'") - files_ok = False - continue - image_digests.setdefault(uid, []).append(f"{image_name}@{digest}") + loaded_targets = list(set(loaded_targets)) # Deduplicate targets in case of overlap across files if not files_ok: log.error("One or more metadata files are invalid, aborting merge.") raise typer.Exit(code=1) - log.info(f"Found {len(image_digests.keys())} targets") - log.debug(json.dumps(image_digests, indent=2, sort_keys=True)) + log.info(f"Found {len(loaded_targets)} targets") + log.debug(", ".join(loaded_targets)) - for uid, sources in image_digests.items(): + for uid in loaded_targets: target = config.get_image_target_by_uid(uid) - log.info(f"Merging {len(sources)} sources for image UID '{uid}'") + log.info(f"Merging sources for image UID '{uid}'") try: - manifest = target.merge(sources=sources, dry_run=dry_run) + manifest = target.merge(dry_run=dry_run) stdout_console.print_json(manifest.model_dump_json(indent=2, exclude_unset=True, exclude_none=True)) except DockerException as e: log.error(f"Error merging sources for UID '{uid}'") diff --git a/posit-bakery/posit_bakery/cli/run.py b/posit-bakery/posit_bakery/cli/run.py index 36ce7f77..d60183d1 100644 --- a/posit-bakery/posit_bakery/cli/run.py +++ b/posit-bakery/posit_bakery/cli/run.py @@ -1,5 +1,4 @@ import logging -import platform import re from enum import Enum from pathlib import Path @@ -12,6 +11,7 @@ from posit_bakery.config.config import BakeryConfigFilter, BakerySettings from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum from posit_bakery.log import stderr_console +from posit_bakery.settings import SETTINGS from posit_bakery.util import auto_path log = logging.getLogger(__name__) @@ -75,7 +75,7 @@ def dgoss( image_platform: Annotated[ Optional[str], typer.Option( - show_default=platform.machine(), # TODO: improve output to match docker platform format + show_default=SETTINGS.get_host_architecture(), help="Filters which image build platform to run tests for, e.g. 'linux/amd64'. Image test targets " "incompatible with the given platform(s) will be skipped. Requires a compatible goss binary.", rich_help_panel=RichHelpPanelEnum.FILTERS, @@ -121,14 +121,8 @@ def dgoss( `DGOSS_BIN` environment variables if not present in the system PATH. """ # Autoselect host architecture platform if not specified. - if image_platform is None: - machine = platform.machine() - arch_map = { - "x86_64": "amd64", - "aarch64": "arm64", - } - arch = arch_map.get(machine, "amd64") - image_platform = f"linux/{arch}" + image_platform = image_platform or SETTINGS.architecture + image_platform = f"linux/{image_platform}" settings = BakerySettings( filter=BakeryConfigFilter( diff --git a/posit-bakery/posit_bakery/config/config.py b/posit-bakery/posit_bakery/config/config.py index b7ec603c..527f69de 100644 --- a/posit-bakery/posit_bakery/config/config.py +++ b/posit-bakery/posit_bakery/config/config.py @@ -35,6 +35,7 @@ from posit_bakery.image.bake.bake import BakePlan from posit_bakery.image.goss.dgoss import DGossSuite from posit_bakery.image.goss.report import GossJsonReportCollection +from posit_bakery.image.image_metadata import MetadataFile from posit_bakery.image.image_target import ImageTarget, ImageBuildStrategy, ImageTargetSettings from posit_bakery.registry_management import ghcr @@ -850,20 +851,27 @@ def _merge_sequential_build_metadata_files(self) -> dict[str, Any]: """ merged_metadata: dict[str, dict[str, Any]] = {} for target in self.targets: - if target.metadata_file is not None: - merged_metadata[target.uid] = target.metadata_file.metadata.model_dump(exclude_none=True, by_alias=True) + for build_metadata in target.build_metadata: + merged_metadata[target.uid] = build_metadata.model_dump(exclude_none=True, by_alias=True) + return merged_metadata - def load_build_metadata_from_file(self, metadata_file: Path): + def load_build_metadata_from_file(self, metadata_file: Path) -> list[str]: """Loads build metadata from a given metadata file. :param metadata_file: Path to the metadata file to load. - :return: A dictionary containing the loaded metadata. + :return: A list of targets loaded. """ - if not metadata_file.is_file(): - raise FileNotFoundError(f"Metadata file '{str(metadata_file)}' does not exist.") + metadata_file = MetadataFile.load(metadata_file) + + targets_loaded = [] for target in self.targets: - target.load_build_metadata_from_file(metadata_file) + result = target.load_build_metadata_from_file(metadata_file) + if result is not None: + targets_loaded.append(target.uid) + log.info(f"Loaded build metadata for target '{target}' from file '{metadata_file.filepath}'.") + + return targets_loaded def bake_plan_targets(self) -> str: """Generates a bake plan JSON string for the image targets defined in the config.""" diff --git a/posit-bakery/posit_bakery/image/goss/dgoss.py b/posit-bakery/posit_bakery/image/goss/dgoss.py index 42b1dd4f..f10823ab 100644 --- a/posit-bakery/posit_bakery/image/goss/dgoss.py +++ b/posit-bakery/posit_bakery/image/goss/dgoss.py @@ -158,7 +158,10 @@ def command(self) -> list[str]: if self.runtime_options: # TODO: We may want to validate this to ensure options are not duplicated. cmd.extend(self.runtime_options.split()) - cmd.append(self.image_target.ref) + if self.platform: + cmd.append(self.image_target.ref(self.platform)) + else: + cmd.append(self.image_target.ref()) cmd.extend(self.image_command.split()) return cmd diff --git a/posit-bakery/posit_bakery/image/image_metadata.py b/posit-bakery/posit_bakery/image/image_metadata.py index 1dbfee65..2d2fed5d 100644 --- a/posit-bakery/posit_bakery/image/image_metadata.py +++ b/posit-bakery/posit_bakery/image/image_metadata.py @@ -1,8 +1,12 @@ +import datetime import json +import logging from pathlib import Path from typing import Annotated, Self -from pydantic import ConfigDict, BaseModel, Field, model_validator +from pydantic import ConfigDict, BaseModel, Field, RootModel + +log = logging.getLogger(__name__) class ImageToolsInspectionPlatformMetadata(BaseModel): @@ -74,6 +78,49 @@ class BuildMetadataContainerImageDescriptor(BaseModel): ] +class BuildMetadataBuildProvenanceMaterial(BaseModel): + """Representation of a material used in the build process.""" + + model_config = ConfigDict(extra="allow") + + uri: Annotated[str | None, Field(description="The URI of the material.", default=None)] + digest: Annotated[dict[str, str] | None, Field(description="The digest of the material.", default=None)] + + +class BuildMetadataBuildProvenanceInvocation(BaseModel): + """Representation of the invocation of the build process.""" + + model_config = ConfigDict(extra="allow") + + config_source: Annotated[ + dict, + Field( + description="The configuration source of the build invocation.", alias="configSource", default_factory=dict + ), + ] + parameters: Annotated[dict, Field(description="The parameters of the build invocation.", default_factory=dict)] + environment: Annotated[dict, Field(description="The environment of the build invocation.", default_factory=dict)] + + +class BuildMetadataBuildProvenance(BaseModel): + """Representation of build provenance in build metadata.""" + + model_config = ConfigDict(extra="allow") + + builder: Annotated[dict, Field(description="The builder used to build the image.", default_factory=dict)] + build_type: Annotated[ + str | None, Field(description="The type of build performed.", alias="buildType", default=None) + ] + materials: Annotated[ + list[BuildMetadataBuildProvenanceMaterial], + Field(description="The materials used in the build process.", default_factory=list), + ] + invocation: Annotated[ + BuildMetadataBuildProvenanceInvocation | None, + Field(description="The invocation of the build process.", default=None), + ] + + class BuildMetadata(BaseModel): """Representation of build metadata produced by Docker builds.""" @@ -93,6 +140,10 @@ class BuildMetadata(BaseModel): default=None, ), ] + build_provenance: Annotated[ + BuildMetadataBuildProvenance | None, + Field(description="The build provenance of the built image.", alias="buildx.build.provenance", default=None), + ] @property def image_tags(self) -> list[str]: @@ -110,24 +161,86 @@ def image_ref(self) -> str | None: return primary_tag return None + @property + def created_at(self) -> datetime.datetime: + """Returns the creation timestamp of the built image if available.""" + if self.container_image_descriptor and self.container_image_descriptor.annotations: + dt_str = self.container_image_descriptor.annotations.get("org.opencontainers.image.created") + if dt_str: + try: + return datetime.datetime.fromisoformat(dt_str) + except ValueError: + pass + if self.build_provenance: + # If the creation timestamp is not available in the annotations, we can use the build start time from + # labels. + if self.build_provenance.invocation and self.build_provenance.invocation.parameters: + start_time_str = self.build_provenance.invocation.parameters.get("args", {}).get( + "label:org.opencontainers.image.created" + ) + if start_time_str: + try: + return datetime.datetime.fromisoformat(start_time_str) + except ValueError: + pass + log.debug("Creation timestamp not found in metadata, defaulting to current time.") + return datetime.datetime.now() + + @property + def platform(self) -> str | None: + """Returns the platform of the built image if available.""" + # First, check the platform information in the container image descriptor, as it is more likely to be accurate + # for multi-platform builds. + if self.container_image_descriptor and self.container_image_descriptor.platform: + platform = self.container_image_descriptor.platform + if platform.os and platform.architecture: + return f"{platform.os}/{platform.architecture}" + # If platform information is not available in the container image descriptor, we can check the build provenance + # invocation environment as that should match the image platform when unspecified. + if self.build_provenance and self.build_provenance.invocation and self.build_provenance.invocation.environment: + platform = self.build_provenance.invocation.environment.get("platform") + if platform: + return platform + + return None + + +class BuildMetadataMap(RootModel[dict[str, BuildMetadata]]): + """Representation of a mapping from target UIDs to build metadata.""" + class MetadataFile(BaseModel): - target_uid: Annotated[str, Field(description="The target UID associated with the metadata.")] filepath: Annotated[Path | None, Field(description="The path to the metadata file.", default=None)] - metadata: Annotated[BuildMetadata | None, Field(description="The build metadata.", default=None)] - - @model_validator(mode="after") - def validate_metadata(self) -> Self: - """Validates that metadata is provided.""" - if self.metadata is None: - if self.filepath is None or not self.filepath.is_file(): - raise ValueError("Either filepath or metadata must be provided.") - with open(self.filepath, "r") as f: - content = json.load(f) - if not isinstance(content, dict): - raise ValueError("The metadata file does not contain a valid JSON object.") - if self.target_uid in content.keys(): - content = content[self.target_uid] - self.metadata = BuildMetadata.model_validate(content, by_alias=True) - - return self + metadata_map: Annotated[BuildMetadataMap, Field(description="The build metadata.")] + + def __str__(self): + """Returns a string representation of the metadata file.""" + return self.filepath.absolute() + + def __repr__(self): + """Returns a string representation of the metadata file.""" + return f"MetadataFile(filepath={self.filepath.absolute()}, metadata_map={self.metadata_map})" + + @classmethod + def load(cls, filepath: Path) -> Self: + """Creates a MetadataFile instance from a JSON file.""" + if not filepath.is_file(): + raise FileNotFoundError(f"Metadata file '{str(filepath)}' does not exist.") + + with open(filepath, "r") as f: + data = json.load(f) + + metadata_map = BuildMetadataMap.model_validate(data) + return cls(filepath=filepath, metadata_map=metadata_map) + + @classmethod + def loads(cls, json_str: str) -> Self: + """Creates a MetadataFile instance from a JSON string.""" + data = json.loads(json_str) + + metadata_map = BuildMetadataMap.model_validate(data) + return cls(metadata_map=metadata_map) + + def get_target_metadata_by_uid(self, target_uid: str) -> BuildMetadata | None: + """Returns the build metadata associated with a given target UID.""" + return self.metadata_map.root.get(target_uid) diff --git a/posit-bakery/posit_bakery/image/image_target.py b/posit-bakery/posit_bakery/image/image_target.py index d4d5a6d8..36ef1eb8 100644 --- a/posit-bakery/posit_bakery/image/image_target.py +++ b/posit-bakery/posit_bakery/image/image_target.py @@ -14,8 +14,8 @@ from posit_bakery.config.repository import Repository from posit_bakery.config.tag import TagPattern, TagPatternFilter from posit_bakery.const import OCI_LABEL_PREFIX, POSIT_LABEL_PREFIX, REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN -from posit_bakery.error import BakeryToolRuntimeError, BakeryFileError -from posit_bakery.image.image_metadata import MetadataFile +from posit_bakery.error import BakeryToolRuntimeError, BakeryFileError, BakeryError +from posit_bakery.image.image_metadata import MetadataFile, BuildMetadata from posit_bakery.image.util import inspect_image from posit_bakery.services import RegistryContainer from posit_bakery.settings import SETTINGS @@ -67,8 +67,8 @@ class ImageTarget(BaseModel): image_version: Annotated[ImageVersion, Field(description="ImageVersion of the image target.")] image_variant: Annotated[ImageVariant | None, Field(default=None, description="ImageVariant of the image target.")] image_os: Annotated[ImageVersionOS | None, Field(default=None, description="ImageVersionOS of the image target.")] - metadata_file: Annotated[ - MetadataFile | None, Field(default=None, description="Build metadata for the image target.") + build_metadata: Annotated[ + list[BuildMetadata], Field(default_factory=list, description="Build metadata for the image target.") ] settings: Annotated[ImageTargetSettings, Field(default_factory=ImageTargetSettings)] @@ -251,12 +251,21 @@ def build_args(self) -> dict[str, str]: build_args[value.name.upper()] = value.value return build_args - @computed_field - @property - def ref(self) -> str: - """Returns a reference to the image, preferring a build metadata digest if available.""" - if self.metadata_file is not None: - return self.metadata_file.metadata.image_ref + def ref(self, platform: str = f"linux/{SETTINGS.architecture}") -> str: + """Returns a reference to the image, preferring a build metadata digest if available. + + :param platform: The platform to reference, used for selecting the appropriate build metadata in multi-platform + builds. Defaults to the host architecture. + + :return: A string reference to the image, using the build metadata digest if available, otherwise falling back + to the first tag. + """ + if self.build_metadata: + sorted_metadata = sorted(self.build_metadata, key=lambda x: x.created_at, reverse=True) + for metadata in sorted_metadata: + if metadata.platform == platform: + return metadata.image_ref + return self.tags[0] @computed_field @@ -326,9 +335,16 @@ def remove(self, prune: bool = True, force: bool = False): log.info(f"Deleting image '{tag}' from local cache.") python_on_whales.docker.image.remove(tag, prune=prune, force=force) - def load_build_metadata_from_file(self, metadata_file: Path): + def load_build_metadata_from_file(self, metadata_file: MetadataFile) -> BuildMetadata | None: """Load build metadata from a given file.""" - self.metadata_file = MetadataFile(target_uid=self.uid, filepath=metadata_file) + target_metadata = metadata_file.get_target_metadata_by_uid(self.uid) + if target_metadata is None: + log.debug(f"No build metadata found for UID '{self.uid}' in '{metadata_file.filepath}'.") + return None + + self.build_metadata.append(target_metadata) + + return target_metadata def build( self, @@ -399,12 +415,34 @@ def build( if isinstance(metadata_file, Path): log.debug(f"Loading in build metadata from file {str(metadata_file)}") - self.metadata_file = MetadataFile(target_uid=self.uid, filepath=metadata_file) + with open(metadata_file, "r") as f: + metadata = BuildMetadata.model_validate_json(f.read()) + self.build_metadata.append(metadata) return image - def merge(self, sources: list[str], dry_run: bool = False) -> Manifest: + def _get_merge_sources(self) -> list[str]: + """Get the list of source image references to use for merging. + + Sources collected will be the most recent artifact for each platform represented in the build metadata. + """ + sources = [] + sorted_metadata = sorted(self.build_metadata, key=lambda x: x.created_at, reverse=True) + collected_platforms = set() + for metadata in sorted_metadata: + if metadata.platform not in collected_platforms: + sources.append(metadata.image_ref) + collected_platforms.add(metadata.platform) + + if not sources: + raise BakeryError(f"No valid sources found in metadata for '{str(self)}', cannot perform merge.") + + return sources + + def merge(self, dry_run: bool = False) -> Manifest: """Merge multiple images into a single image, tag, and push.""" + sources = self._get_merge_sources() + # For dry-runs, `imagetools create` produces effectively the same result as the steps below. if dry_run: return python_on_whales.docker.buildx.imagetools.create( diff --git a/posit-bakery/posit_bakery/settings.py b/posit-bakery/posit_bakery/settings.py index 38956039..f5704140 100644 --- a/posit-bakery/posit_bakery/settings.py +++ b/posit-bakery/posit_bakery/settings.py @@ -1,4 +1,5 @@ import logging +import platform import tempfile from pathlib import Path @@ -13,6 +14,22 @@ def __init__(self): self.application_storage: Path = Path(typer.get_app_dir(app_name=self.app_name)).resolve() self.temporary_storage: Path = Path(tempfile.gettempdir()) self.log_level: str | int = logging.INFO + self.architecture = self.get_host_architecture() + + @staticmethod + def get_host_architecture() -> str: + """Returns the host architecture.""" + machine = platform.machine().lower() + + # Normalize common variants + if machine in ("x86_64", "amd64"): + return "amd64" + elif machine in ("aarch64", "arm64"): + return "arm64" + elif machine.startswith("arm"): + return "arm" + else: + return machine SETTINGS = Settings() diff --git a/posit-bakery/test/cli/test_ci.py b/posit-bakery/test/cli/test_ci.py index 84afdcb7..2ec5ea37 100644 --- a/posit-bakery/test/cli/test_ci.py +++ b/posit-bakery/test/cli/test_ci.py @@ -39,8 +39,8 @@ def copy_ci_testdata_to_context(bakery_command, ci_testdata, testdata_path): def patch_image_target_merge_method(mocker): calls = [] - def patched_merge_method(self, sources: list[str], dry_run: bool = False) -> Manifest: - calls.append((sources, dry_run)) + def patched_merge_method(self, dry_run: bool = False) -> Manifest: + calls.append((self._get_merge_sources(), dry_run)) return Manifest( schemaVersion=2, mediaType="application/vnd.docker.distribution.manifest.v2+json", diff --git a/posit-bakery/test/config/test_config.py b/posit-bakery/test/config/test_config.py index ea4fe671..550370ee 100644 --- a/posit-bakery/test/config/test_config.py +++ b/posit-bakery/test/config/test_config.py @@ -13,7 +13,7 @@ from posit_bakery.config.config import BakeryConfigDocument, BakeryConfig, BakeryConfigFilter, BakerySettings from posit_bakery.config.dependencies import PythonDependencyConstraint, RDependencyVersions from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum -from posit_bakery.image.image_metadata import MetadataFile +from posit_bakery.image.image_metadata import BuildMetadata from test.config.conftest import CONFIG_TESTDATA_DIR from test.helpers import ( yaml_file_testcases, @@ -1607,7 +1607,7 @@ def test__merge_sequential_build_metadata_files(self, get_config_obj): config = get_config_obj("basic") for target in config.targets: metadata_filepath = CONFIG_TESTDATA_DIR / "build_metadata" / f"{target.uid}.json" - target.metadata_file = MetadataFile(target_uid=target.uid, filepath=metadata_filepath) + target.build_metadata.append(BuildMetadata.model_validate_json(metadata_filepath.read_text())) merged_metadata = config._merge_sequential_build_metadata_files() with open(CONFIG_TESTDATA_DIR / "build_metadata" / "expected.json", "r") as f: @@ -1622,11 +1622,10 @@ def test_load_build_metadata_file(self, get_config_obj): config.load_build_metadata_from_file(metadata_filepath) for target in config.targets: - assert isinstance(target.metadata_file, MetadataFile) - assert target.metadata_file.filepath == metadata_filepath - assert target.metadata_file.metadata is not None - assert target.metadata_file.metadata.image_name is not None - assert target.metadata_file.metadata.container_image_digest is not None + assert len(target.build_metadata) == 1 + assert target.build_metadata[0] is not None + assert target.build_metadata[0].image_name is not None + assert target.build_metadata[0].container_image_digest is not None @pytest.mark.parametrize( "untagged,older_than_days,expected_deletions", diff --git a/posit-bakery/test/features/cli/ci/merge.feature b/posit-bakery/test/features/cli/ci/merge.feature index 91dcd721..c4e5257d 100644 --- a/posit-bakery/test/features/cli/ci/merge.feature +++ b/posit-bakery/test/features/cli/ci/merge.feature @@ -17,6 +17,6 @@ Feature: merge * 4 targets are found in the metadata * the merge calls include: | cripittwood.azurecr.io/posit/test-multi/tmp:latest@sha256:f5d7d95a3801d05f91db1fa7b5bba9fdb3d5babc0332c56f0cca25407c93a2f1 | | - | cripittwood.azurecr.io/posit/test-multi/tmp:latest@sha256:22adb0b3d07e78916da03c81b899d5ded4aaff8098d40a9b8cb071c8c0f3a4a2 | cripittwood.azurecr.io/posit/test-multi/tmp:latest@sha256:b0f70c272157281f3e70fcd1d890d6927a9268f4bd315e6d7cba677182bd6098 | + | cripittwood.azurecr.io/posit/test-multi/tmp:latest@sha256:b0f70c272157281f3e70fcd1d890d6927a9268f4bd315e6d7cba677182bd6098 | cripittwood.azurecr.io/posit/test-multi/tmp:latest@sha256:22adb0b3d07e78916da03c81b899d5ded4aaff8098d40a9b8cb071c8c0f3a4a2 | | cripittwood.azurecr.io/posit/test-multi/tmp:latest@sha256:f31fb59b841be3502be62d4e85696b002204a94821839ce2e8e2fa7c26eb232a | | - | cripittwood.azurecr.io/posit/test-multi/tmp:latest@sha256:e0ee4e80f5d1b04dd103d19a7db1198c0b8bd214ed040b87d74521f2dcd6ea8e | cripittwood.azurecr.io/posit/test-multi/tmp:latest@sha256:415b48b2fcc903f194b20e48cdbd2c76e2f8127c2453f8b18e5512973186dde0 | + | cripittwood.azurecr.io/posit/test-multi/tmp:latest@sha256:415b48b2fcc903f194b20e48cdbd2c76e2f8127c2453f8b18e5512973186dde0 | cripittwood.azurecr.io/posit/test-multi/tmp:latest@sha256:e0ee4e80f5d1b04dd103d19a7db1198c0b8bd214ed040b87d74521f2dcd6ea8e | diff --git a/posit-bakery/test/image/goss/test_dgoss.py b/posit-bakery/test/image/goss/test_dgoss.py index 41bf47d7..7b17ccdb 100644 --- a/posit-bakery/test/image/goss/test_dgoss.py +++ b/posit-bakery/test/image/goss/test_dgoss.py @@ -9,6 +9,7 @@ from posit_bakery.config.dependencies import PythonDependencyVersions, RDependencyVersions from posit_bakery.image import DGossSuite from posit_bakery.image.goss.dgoss import DGossCommand, find_dgoss_bin +from posit_bakery.image.image_metadata import MetadataFile from test.helpers import remove_images pytestmark = [ @@ -114,7 +115,7 @@ def test_command(self, basic_standard_image_target): "-e", "IMAGE_OS_VERSION=22.04", "--init", - basic_standard_image_target.ref, + basic_standard_image_target.ref(), *basic_standard_image_target.image_variant.get_tool_option("goss").command.split(), ] assert dgoss_command.command == expected_command @@ -161,7 +162,7 @@ def test_command_build_args_env_vars(self, basic_standard_image_target): "-e", "BUILD_ARG_R_VERSION=4.3.3", "--init", - basic_standard_image_target.ref, + basic_standard_image_target.ref(), *basic_standard_image_target.image_variant.get_tool_option("goss").command.split(), ] assert dgoss_command.command == expected_command @@ -201,7 +202,7 @@ def test_command_with_platform_option(self, basic_standard_image_target): "-e", "IMAGE_OS_VERSION=22.04", "--init", - basic_standard_image_target.ref, + basic_standard_image_target.ref("linux/arm64"), *basic_standard_image_target.image_variant.get_tool_option("goss").command.split(), ] assert dgoss_command.command == expected_command @@ -241,16 +242,18 @@ def test_command_with_runtime_options(self, basic_standard_image_target): "IMAGE_OS_VERSION=22.04", "--init", "--privileged", - basic_standard_image_target.ref, + basic_standard_image_target.ref(), *basic_standard_image_target.image_variant.get_tool_option("goss").command.split(), ] assert dgoss_command.command == expected_command def test_command_with_build_metadata(self, basic_standard_image_target): """Test that DGossCommand command returns the expected command.""" - basic_standard_image_target.load_build_metadata_from_file(DGOSS_TESTDATA_DIR / "basic_metadata.json") + basic_standard_image_target.load_build_metadata_from_file( + MetadataFile.load(DGOSS_TESTDATA_DIR / "basic_metadata.json") + ) assert ( - basic_standard_image_target.ref + basic_standard_image_target.ref() == "docker.io/posit/test-image:1.0.0@sha256:80a50319320bf34740251482b7c06bf6dddb52aa82ea4cbffa812ed2fafaa0b9" ) dgoss_command = DGossCommand.from_image_target(image_target=basic_standard_image_target) @@ -284,7 +287,7 @@ def test_command_with_build_metadata(self, basic_standard_image_target): "-e", "IMAGE_OS_VERSION=22.04", "--init", - basic_standard_image_target.ref, + basic_standard_image_target.ref(), *basic_standard_image_target.image_variant.get_tool_option("goss").command.split(), ] assert dgoss_command.command == expected_command diff --git a/posit-bakery/test/image/test_image_metadata.py b/posit-bakery/test/image/test_image_metadata.py index c2058c22..b8921d7a 100644 --- a/posit-bakery/test/image/test_image_metadata.py +++ b/posit-bakery/test/image/test_image_metadata.py @@ -54,35 +54,18 @@ def test_image_ref(self, image_testdata_path): class TestMetadataFile: def test_metadata_file_from_file(self, image_testdata_path): - metadata_filepath = image_testdata_path / "single-target.json" - metadata_file = MetadataFile(target_uid="test-multi-1-0-0-minimal-ubuntu-22-04", filepath=metadata_filepath) - assert metadata_file.metadata is not None - assert ( - metadata_file.metadata.container_image_digest - == "sha256:bcaa64b18c7dbaede0840f90ba072b85a6ca2776e27d705102c5d59e176fe647" - ) - assert ( - metadata_file.metadata.image_name - == "docker.io/posit/test-multi:1.0.0-min,docker.io/posit/test-multi:1.0.0-ubuntu-22.04-min,docker.io/posit/test-multi:min,docker.io/posit/test-multi:ubuntu-22.04-min,ghcr.io/posit-dev/test-multi:1.0.0-min,ghcr.io/posit-dev/test-multi:1.0.0-ubuntu-22.04-min,ghcr.io/posit-dev/test-multi:min,ghcr.io/posit-dev/test-multi:ubuntu-22.04-min" - ) + metadata_filepath = image_testdata_path / "multi-target.json" + metadata_file = MetadataFile.load(metadata_filepath) + assert len(metadata_file.metadata_map.root.keys()) == 4 + assert metadata_file.filepath == metadata_filepath def test_metadata_file_from_direct_data(self, image_testdata_path): - with open(image_testdata_path / "single-target.json") as f: - data = json.load(f) + with open(image_testdata_path / "multi-target.json") as f: + metadata_file = MetadataFile.loads(f.read()) - metadata = BuildMetadata.model_validate(data) - metadata_file = MetadataFile(target_uid="test-multi-1-0-0-minimal-ubuntu-22-04", metadata=metadata) - assert metadata_file.metadata is not None + assert len(metadata_file.metadata_map.root.keys()) == 4 assert metadata_file.filepath is None - assert ( - metadata_file.metadata.container_image_digest - == "sha256:bcaa64b18c7dbaede0840f90ba072b85a6ca2776e27d705102c5d59e176fe647" - ) - assert ( - metadata_file.metadata.image_name - == "docker.io/posit/test-multi:1.0.0-min,docker.io/posit/test-multi:1.0.0-ubuntu-22.04-min,docker.io/posit/test-multi:min,docker.io/posit/test-multi:ubuntu-22.04-min,ghcr.io/posit-dev/test-multi:1.0.0-min,ghcr.io/posit-dev/test-multi:1.0.0-ubuntu-22.04-min,ghcr.io/posit-dev/test-multi:min,ghcr.io/posit-dev/test-multi:ubuntu-22.04-min" - ) def test_metadata_file_no_filepath_or_metadata_value_error(self): with pytest.raises(ValueError): - MetadataFile(target_uid="test-multi-1-0-0-minimal-ubuntu-22-04") + MetadataFile() diff --git a/posit-bakery/test/image/test_image_target.py b/posit-bakery/test/image/test_image_target.py index 0b7a72b8..b7d6d93f 100644 --- a/posit-bakery/test/image/test_image_target.py +++ b/posit-bakery/test/image/test_image_target.py @@ -1,3 +1,4 @@ +import datetime import re from unittest.mock import patch, MagicMock @@ -8,7 +9,7 @@ from posit_bakery.config.dependencies import PythonDependencyVersions, RDependencyVersions from posit_bakery.config.tag import default_tag_patterns, TagPatternFilter from posit_bakery.const import OCI_LABEL_PREFIX, POSIT_LABEL_PREFIX -from posit_bakery.image.image_metadata import MetadataFile +from posit_bakery.image.image_metadata import BuildMetadata from posit_bakery.image.image_target import ImageTarget, ImageTargetSettings from posit_bakery.settings import SETTINGS from test.helpers import remove_images, SUCCESS_SUITES @@ -427,16 +428,15 @@ def test_build_args(self, get_config_obj, is_matrix, dependencies, values, expec def test_ref(self, request, target_name, expected_ref): """Test the tag_suffixes property of an ImageTarget.""" target = request.getfixturevalue(target_name) - assert target.ref.endswith(expected_ref) + assert target.ref().endswith(expected_ref) def test_ref_from_metadata(self, basic_standard_image_target): """Test the tag_suffixes property of an ImageTarget.""" - mock_metadata_file = MagicMock(spec=MetadataFile) - mock_metadata = MagicMock() - mock_metadata_file.metadata = mock_metadata - mock_metadata_file.metadata.image_ref = "test-image@sha256:1234567890abcdef" - basic_standard_image_target.metadata_file = mock_metadata_file - assert basic_standard_image_target.ref == "test-image@sha256:1234567890abcdef" + mock_metadata = MagicMock(spec=BuildMetadata) + mock_metadata.platform = f"linux/{SETTINGS.architecture}" + mock_metadata.image_ref = "test-image@sha256:1234567890abcdef" + basic_standard_image_target.build_metadata = [mock_metadata] + assert basic_standard_image_target.ref() == "test-image@sha256:1234567890abcdef" def test_labels(self, datetime_now_value, basic_standard_image_target): """Test the labels property of an ImageTarget.""" @@ -606,18 +606,32 @@ def test_build_metadata_file(self, suite, get_targets): metadata_file = SETTINGS.temporary_storage / f"{target.uid}.json" assert metadata_file.is_file() - metadata_file = MetadataFile(target_uid=target.uid, filepath=metadata_file) - assert metadata_file.metadata.image_tags.sort() == target.tags.sort() + with open(metadata_file) as f: + data = f.read() + metadata = BuildMetadata.model_validate_json(data) + assert metadata.image_tags.sort() == target.tags.sort() remove_images(target) def test_merge_dry_run(self, patch_imagetools_create, basic_standard_image_target): """Test the merge method of an ImageTarget in dry-run mode.""" - sources = ["image1:tag", "image2:tag"] - manifest = basic_standard_image_target.merge(sources=sources, dry_run=True) + # Set up fake build metadata for two platforms + basic_standard_image_target.build_metadata = [ + MagicMock(spec=BuildMetadata), + MagicMock(spec=BuildMetadata), + ] + basic_standard_image_target.build_metadata[1].created_at = datetime.datetime.now() + basic_standard_image_target.build_metadata[0].created_at = datetime.datetime.now() + basic_standard_image_target.build_metadata[0].platform = "linux/amd64" + basic_standard_image_target.build_metadata[1].platform = "linux/arm64" + basic_standard_image_target.build_metadata[0].image_ref = "image1:tag" + basic_standard_image_target.build_metadata[1].image_ref = "image2:tag" + expected_sources = ["image1:tag", "image2:tag"] + + manifest = basic_standard_image_target.merge(dry_run=True) patch_imagetools_create.assert_called_once_with( - sources=sources, + sources=expected_sources, tags=basic_standard_image_target.tags, dry_run=True, ) @@ -635,15 +649,27 @@ def test_merge( patch_docker_push, ): """Test the merge method of an ImageTarget.""" - sources = ["image1:tag", "image2:tag"] - manifest = basic_standard_image_target.merge(sources=sources) + # Set up fake build metadata for two platforms + basic_standard_image_target.build_metadata = [ + MagicMock(spec=BuildMetadata), + MagicMock(spec=BuildMetadata), + ] + basic_standard_image_target.build_metadata[1].created_at = datetime.datetime.now() + basic_standard_image_target.build_metadata[0].created_at = datetime.datetime.now() + basic_standard_image_target.build_metadata[0].platform = "linux/amd64" + basic_standard_image_target.build_metadata[1].platform = "linux/arm64" + basic_standard_image_target.build_metadata[0].image_ref = "image1:tag" + basic_standard_image_target.build_metadata[1].image_ref = "image2:tag" + expected_sources = ["image1:tag", "image2:tag"] + + manifest = basic_standard_image_target.merge() patch_registry_container.assert_called_once() registry_url = patch_registry_container.return_value.__enter__.return_value.url expected_temp_tag = f"{registry_url}/{basic_standard_image_target.uid}:latest" patch_imagetools_create.assert_called_once_with( - sources=sources, + sources=expected_sources, tags=[expected_temp_tag], dry_run=False, ) From 403ee6da2c03a693cf3335c17b9530cf618f8f89 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 13 Feb 2026 07:22:42 -0700 Subject: [PATCH 2/4] Add tests for _get_merge_sources method in ImageTarget Tests cover multiple platforms, duplicate platform handling (most recent wins), empty metadata error case, and single platform scenarios. Co-Authored-By: Claude Opus 4.6 --- posit-bakery/test/image/test_image_target.py | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/posit-bakery/test/image/test_image_target.py b/posit-bakery/test/image/test_image_target.py index b7d6d93f..b2156419 100644 --- a/posit-bakery/test/image/test_image_target.py +++ b/posit-bakery/test/image/test_image_target.py @@ -9,6 +9,7 @@ from posit_bakery.config.dependencies import PythonDependencyVersions, RDependencyVersions from posit_bakery.config.tag import default_tag_patterns, TagPatternFilter from posit_bakery.const import OCI_LABEL_PREFIX, POSIT_LABEL_PREFIX +from posit_bakery.error import BakeryError from posit_bakery.image.image_metadata import BuildMetadata from posit_bakery.image.image_target import ImageTarget, ImageTargetSettings from posit_bakery.settings import SETTINGS @@ -613,6 +614,77 @@ def test_build_metadata_file(self, suite, get_targets): remove_images(target) + def test_get_merge_sources_multiple_platforms(self, basic_standard_image_target): + """Test _get_merge_sources returns most recent source for each platform.""" + basic_standard_image_target.build_metadata = [ + MagicMock(spec=BuildMetadata), + MagicMock(spec=BuildMetadata), + ] + basic_standard_image_target.build_metadata[0].created_at = datetime.datetime.now() + basic_standard_image_target.build_metadata[1].created_at = datetime.datetime.now() + basic_standard_image_target.build_metadata[0].platform = "linux/amd64" + basic_standard_image_target.build_metadata[1].platform = "linux/arm64" + basic_standard_image_target.build_metadata[0].image_ref = "image1@sha256:amd64digest" + basic_standard_image_target.build_metadata[1].image_ref = "image2@sha256:arm64digest" + + sources = basic_standard_image_target._get_merge_sources() + + assert len(sources) == 2 + assert "image1@sha256:amd64digest" in sources + assert "image2@sha256:arm64digest" in sources + + def test_get_merge_sources_duplicate_platforms_uses_most_recent(self, basic_standard_image_target): + """Test _get_merge_sources returns only most recent source when platform appears multiple times.""" + older_time = datetime.datetime(2024, 1, 1, 12, 0, 0) + newer_time = datetime.datetime(2024, 1, 2, 12, 0, 0) + + basic_standard_image_target.build_metadata = [ + MagicMock(spec=BuildMetadata), + MagicMock(spec=BuildMetadata), + MagicMock(spec=BuildMetadata), + ] + # Older amd64 build + basic_standard_image_target.build_metadata[0].created_at = older_time + basic_standard_image_target.build_metadata[0].platform = "linux/amd64" + basic_standard_image_target.build_metadata[0].image_ref = "old-amd64@sha256:old" + # Newer amd64 build + basic_standard_image_target.build_metadata[1].created_at = newer_time + basic_standard_image_target.build_metadata[1].platform = "linux/amd64" + basic_standard_image_target.build_metadata[1].image_ref = "new-amd64@sha256:new" + # arm64 build + basic_standard_image_target.build_metadata[2].created_at = older_time + basic_standard_image_target.build_metadata[2].platform = "linux/arm64" + basic_standard_image_target.build_metadata[2].image_ref = "arm64@sha256:arm" + + sources = basic_standard_image_target._get_merge_sources() + + assert len(sources) == 2 + assert "new-amd64@sha256:new" in sources + assert "old-amd64@sha256:old" not in sources + assert "arm64@sha256:arm" in sources + + def test_get_merge_sources_empty_metadata_raises_error(self, basic_standard_image_target): + """Test _get_merge_sources raises BakeryError when no metadata exists.""" + basic_standard_image_target.build_metadata = [] + + with pytest.raises(BakeryError) as exc_info: + basic_standard_image_target._get_merge_sources() + + assert "No valid sources found in metadata" in str(exc_info.value) + + def test_get_merge_sources_single_platform(self, basic_standard_image_target): + """Test _get_merge_sources works with single platform.""" + basic_standard_image_target.build_metadata = [ + MagicMock(spec=BuildMetadata), + ] + basic_standard_image_target.build_metadata[0].created_at = datetime.datetime.now() + basic_standard_image_target.build_metadata[0].platform = "linux/amd64" + basic_standard_image_target.build_metadata[0].image_ref = "image@sha256:digest" + + sources = basic_standard_image_target._get_merge_sources() + + assert sources == ["image@sha256:digest"] + def test_merge_dry_run(self, patch_imagetools_create, basic_standard_image_target): """Test the merge method of an ImageTarget in dry-run mode.""" # Set up fake build metadata for two platforms From 98c7b5109cb30e1b2b6f952217b7ee78991c5c23 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 13 Feb 2026 07:26:42 -0700 Subject: [PATCH 3/4] Add comprehensive tests for ImageTarget.ref method Add tests for platform mismatch fallback, explicit platform selection, and most recent metadata selection. Fix incorrect docstrings in existing ref tests. Co-Authored-By: Claude Opus 4.6 --- posit-bakery/test/image/test_image_target.py | 51 +++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/posit-bakery/test/image/test_image_target.py b/posit-bakery/test/image/test_image_target.py index b2156419..337c96c8 100644 --- a/posit-bakery/test/image/test_image_target.py +++ b/posit-bakery/test/image/test_image_target.py @@ -427,18 +427,65 @@ def test_build_args(self, get_config_obj, is_matrix, dependencies, values, expec ], ) def test_ref(self, request, target_name, expected_ref): - """Test the tag_suffixes property of an ImageTarget.""" + """Test ref returns first tag when no build metadata exists.""" target = request.getfixturevalue(target_name) assert target.ref().endswith(expected_ref) def test_ref_from_metadata(self, basic_standard_image_target): - """Test the tag_suffixes property of an ImageTarget.""" + """Test ref returns image_ref from metadata when platform matches.""" mock_metadata = MagicMock(spec=BuildMetadata) mock_metadata.platform = f"linux/{SETTINGS.architecture}" mock_metadata.image_ref = "test-image@sha256:1234567890abcdef" basic_standard_image_target.build_metadata = [mock_metadata] assert basic_standard_image_target.ref() == "test-image@sha256:1234567890abcdef" + def test_ref_from_metadata_platform_mismatch(self, basic_standard_image_target): + """Test ref falls back to first tag when metadata exists but platform doesn't match.""" + mock_metadata = MagicMock(spec=BuildMetadata) + mock_metadata.platform = "linux/arm64" # Different from default + mock_metadata.image_ref = "test-image@sha256:arm64digest" + mock_metadata.created_at = datetime.datetime.now() + basic_standard_image_target.build_metadata = [mock_metadata] + # Should fall back to first tag since platform doesn't match + assert basic_standard_image_target.ref().endswith("docker.io/posit/test-image:1.0.0") + + def test_ref_from_metadata_explicit_platform(self, basic_standard_image_target): + """Test ref returns correct image_ref when explicit platform is specified.""" + mock_metadata_amd64 = MagicMock(spec=BuildMetadata) + mock_metadata_amd64.platform = "linux/amd64" + mock_metadata_amd64.image_ref = "test-image@sha256:amd64digest" + mock_metadata_amd64.created_at = datetime.datetime.now() + + mock_metadata_arm64 = MagicMock(spec=BuildMetadata) + mock_metadata_arm64.platform = "linux/arm64" + mock_metadata_arm64.image_ref = "test-image@sha256:arm64digest" + mock_metadata_arm64.created_at = datetime.datetime.now() + + basic_standard_image_target.build_metadata = [mock_metadata_amd64, mock_metadata_arm64] + + assert basic_standard_image_target.ref(platform="linux/amd64") == "test-image@sha256:amd64digest" + assert basic_standard_image_target.ref(platform="linux/arm64") == "test-image@sha256:arm64digest" + + def test_ref_from_metadata_uses_most_recent(self, basic_standard_image_target): + """Test ref returns image_ref from most recent metadata when multiple exist for same platform.""" + older_time = datetime.datetime(2024, 1, 1, 12, 0, 0) + newer_time = datetime.datetime(2024, 1, 2, 12, 0, 0) + + mock_metadata_old = MagicMock(spec=BuildMetadata) + mock_metadata_old.platform = f"linux/{SETTINGS.architecture}" + mock_metadata_old.image_ref = "test-image@sha256:olddigest" + mock_metadata_old.created_at = older_time + + mock_metadata_new = MagicMock(spec=BuildMetadata) + mock_metadata_new.platform = f"linux/{SETTINGS.architecture}" + mock_metadata_new.image_ref = "test-image@sha256:newdigest" + mock_metadata_new.created_at = newer_time + + # Add in reverse order to verify sorting + basic_standard_image_target.build_metadata = [mock_metadata_old, mock_metadata_new] + + assert basic_standard_image_target.ref() == "test-image@sha256:newdigest" + def test_labels(self, datetime_now_value, basic_standard_image_target): """Test the labels property of an ImageTarget.""" expected_labels = { From 3132fb7c380af2a16e476899f57684fa57137648 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 13 Feb 2026 07:34:23 -0700 Subject: [PATCH 4/4] Add comprehensive tests for BuildMetadata and MetadataFile Add tests for BuildMetadata.created_at property (from annotations, build provenance, and fallback to now) and platform property (from descriptor, build provenance environment, and None fallback). Add tests for MetadataFile.get_target_metadata_by_uid, __repr__, and FileNotFoundError handling. Remove unused __str__ method from MetadataFile. Co-Authored-By: Claude Opus 4.6 --- .../posit_bakery/image/image_metadata.py | 4 - .../test/image/test_image_metadata.py | 129 +++++++++++++++++- 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/posit-bakery/posit_bakery/image/image_metadata.py b/posit-bakery/posit_bakery/image/image_metadata.py index 2d2fed5d..79288a41 100644 --- a/posit-bakery/posit_bakery/image/image_metadata.py +++ b/posit-bakery/posit_bakery/image/image_metadata.py @@ -213,10 +213,6 @@ class MetadataFile(BaseModel): filepath: Annotated[Path | None, Field(description="The path to the metadata file.", default=None)] metadata_map: Annotated[BuildMetadataMap, Field(description="The build metadata.")] - def __str__(self): - """Returns a string representation of the metadata file.""" - return self.filepath.absolute() - def __repr__(self): """Returns a string representation of the metadata file.""" return f"MetadataFile(filepath={self.filepath.absolute()}, metadata_map={self.metadata_map})" diff --git a/posit-bakery/test/image/test_image_metadata.py b/posit-bakery/test/image/test_image_metadata.py index b8921d7a..46b697a2 100644 --- a/posit-bakery/test/image/test_image_metadata.py +++ b/posit-bakery/test/image/test_image_metadata.py @@ -1,3 +1,4 @@ +import datetime import json import pytest @@ -51,15 +52,105 @@ def test_image_ref(self, image_testdata_path): expected_ref = "docker.io/posit/test-multi:1.0.0-min@sha256:bcaa64b18c7dbaede0840f90ba072b85a6ca2776e27d705102c5d59e176fe647" assert metadata.image_ref == expected_ref + def test_created_at_from_annotations(self, image_testdata_path): + """Test created_at returns timestamp from container_image_descriptor.annotations.""" + with open(image_testdata_path / "multi-target.json") as f: + data = json.load(f) + + # multi-target.json has annotations with org.opencontainers.image.created + metadata = BuildMetadata.model_validate(data["test-multi-1-0-0-minimal-ubuntu-22-04"]) + expected_dt = datetime.datetime.fromisoformat("2025-11-19T16:29:33Z") + assert metadata.created_at == expected_dt + + def test_created_at_from_build_provenance(self): + """Test created_at falls back to build provenance label when annotations missing.""" + data = { + "image.name": "test:latest", + "containerimage.digest": "sha256:abc123", + "containerimage.descriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:abc123", + "size": 100, + # No annotations + }, + "buildx.build.provenance": { + "builder": {"id": ""}, + "buildType": "https://mobyproject.org/buildkit@v1", + "materials": [], + "invocation": { + "configSource": {}, + "parameters": {"args": {"label:org.opencontainers.image.created": "2024-06-15T10:30:00"}}, + "environment": {}, + }, + }, + } + metadata = BuildMetadata.model_validate(data) + expected_dt = datetime.datetime.fromisoformat("2024-06-15T10:30:00") + assert metadata.created_at == expected_dt + + def test_created_at_defaults_to_now(self): + """Test created_at defaults to current time when no timestamp available.""" + data = { + "image.name": "test:latest", + "containerimage.digest": "sha256:abc123", + } + metadata = BuildMetadata.model_validate(data) + # Should be close to now (within a few seconds) + now = datetime.datetime.now() + assert abs((metadata.created_at - now).total_seconds()) < 5 + + def test_platform_from_descriptor(self, image_testdata_path): + """Test platform returns value from container_image_descriptor.platform.""" + with open(image_testdata_path / "multi-target.json") as f: + data = json.load(f) + + # multi-target.json has platform in container_image_descriptor + metadata = BuildMetadata.model_validate(data["test-multi-1-0-0-minimal-ubuntu-22-04"]) + assert metadata.platform == "linux/amd64" + + def test_platform_from_build_provenance_environment(self): + """Test platform falls back to build provenance invocation environment.""" + data = { + "image.name": "test:latest", + "containerimage.digest": "sha256:abc123", + "containerimage.descriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:abc123", + "size": 100, + # No platform + }, + "buildx.build.provenance": { + "builder": {"id": ""}, + "buildType": "https://mobyproject.org/buildkit@v1", + "materials": [], + "invocation": { + "configSource": {}, + "parameters": {}, + "environment": {"platform": "linux/arm64"}, + }, + }, + } + metadata = BuildMetadata.model_validate(data) + assert metadata.platform == "linux/arm64" + + def test_platform_returns_none_when_unavailable(self): + """Test platform returns None when no platform information available.""" + data = { + "image.name": "test:latest", + "containerimage.digest": "sha256:abc123", + } + metadata = BuildMetadata.model_validate(data) + assert metadata.platform is None + class TestMetadataFile: - def test_metadata_file_from_file(self, image_testdata_path): + def test_metadata_file_load(self, image_testdata_path): metadata_filepath = image_testdata_path / "multi-target.json" metadata_file = MetadataFile.load(metadata_filepath) assert len(metadata_file.metadata_map.root.keys()) == 4 assert metadata_file.filepath == metadata_filepath - def test_metadata_file_from_direct_data(self, image_testdata_path): + def test_metadata_file_loads(self, image_testdata_path): with open(image_testdata_path / "multi-target.json") as f: metadata_file = MetadataFile.loads(f.read()) @@ -69,3 +160,37 @@ def test_metadata_file_from_direct_data(self, image_testdata_path): def test_metadata_file_no_filepath_or_metadata_value_error(self): with pytest.raises(ValueError): MetadataFile() + + def test_metadata_file_load_file_not_found(self, tmp_path): + """Test load raises FileNotFoundError for non-existent file.""" + non_existent_path = tmp_path / "does-not-exist.json" + with pytest.raises(FileNotFoundError) as exc_info: + MetadataFile.load(non_existent_path) + assert "does not exist" in str(exc_info.value) + + def test_get_target_metadata_by_uid_exists(self, image_testdata_path): + """Test get_target_metadata_by_uid returns metadata for existing UID.""" + metadata_file = MetadataFile.load(image_testdata_path / "multi-target.json") + metadata = metadata_file.get_target_metadata_by_uid("test-multi-1-0-0-minimal-ubuntu-22-04") + + assert metadata is not None + assert isinstance(metadata, BuildMetadata) + assert ( + metadata.container_image_digest == "sha256:f5d7d95a3801d05f91db1fa7b5bba9fdb3d5babc0332c56f0cca25407c93a2f1" + ) + + def test_get_target_metadata_by_uid_not_found(self, image_testdata_path): + """Test get_target_metadata_by_uid returns None for non-existent UID.""" + metadata_file = MetadataFile.load(image_testdata_path / "multi-target.json") + metadata = metadata_file.get_target_metadata_by_uid("non-existent-uid") + + assert metadata is None + + def test_repr(self, image_testdata_path): + """Test __repr__ returns expected string representation.""" + metadata_filepath = image_testdata_path / "multi-target.json" + metadata_file = MetadataFile.load(metadata_filepath) + + repr_str = repr(metadata_file) + assert "MetadataFile" in repr_str + assert str(metadata_filepath.absolute()) in repr_str