diff --git a/posit-bakery/posit_bakery/config/image/dev_version/base.py b/posit-bakery/posit_bakery/config/image/dev_version/base.py index 024b59357..b34e2a03d 100644 --- a/posit-bakery/posit_bakery/config/image/dev_version/base.py +++ b/posit-bakery/posit_bakery/config/image/dev_version/base.py @@ -6,6 +6,7 @@ from pydantic import Field, field_validator, model_validator from posit_bakery.config.image.build_os import DEFAULT_PLATFORMS +from posit_bakery.config.image.os_validators import OSValidatorMixin from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum from posit_bakery.config.image.version import ImageVersion from posit_bakery.config.image.version_os import ImageVersionOS @@ -15,7 +16,7 @@ log = logging.getLogger(__name__) -class BaseImageDevelopmentVersion(BakeryYAMLModel, abc.ABC): +class BaseImageDevelopmentVersion(OSValidatorMixin, BakeryYAMLModel, abc.ABC): """Base class for tool options in the bakery configuration.""" parent: Annotated[BakeryYAMLModel | None, Field(exclude=True, default=None, description="Parent Image object.")] @@ -76,85 +77,6 @@ def deduplicate_registries(cls, registries: list[Registry | BaseRegistry]) -> li ) return sorted(list(unique_registries), key=lambda r: r.base_url) - @field_validator("os", mode="after") - @classmethod - def check_os_not_empty(cls, os: list[ImageVersionOS]) -> list[ImageVersionOS]: - """Ensures that the os list is not empty. - - :param os: List of ImageVersionOS objects to check. - :param info: ValidationInfo containing the data being validated. - - :return: The unmodified list of ImageVersionOS objects. - """ - # Check that name is defined since it will already propagate a validation error if not. - if not os: - log.warning( - f"No OSes defined for image development version. At least one OS should be " - "defined for complete tagging and labeling of images." - ) - return os - - @field_validator("os", mode="after") - @classmethod - def deduplicate_os(cls, os: list[ImageVersionOS]) -> list[ImageVersionOS]: - """Ensures that the os list is unique and warns on duplicates. - - :param os: List of ImageVersionOS objects to deduplicate. - :param info: ValidationInfo containing the data being validated. - - :return: A list of unique ImageVersionOS objects. - """ - unique_oses = set(os) - for unique_os in unique_oses: - if os.count(unique_os) > 1: - log.warning(f"Duplicate OS defined in config for image development version: {unique_os.name}") - - return sorted(list(unique_oses), key=lambda o: o.name) - - @field_validator("os", mode="after") - @classmethod - def make_single_os_primary(cls, os: list[ImageVersionOS]) -> list[ImageVersionOS]: - """Ensures that at most one OS is marked as primary. - - :param os: List of ImageVersionOS objects to check. - :param info: ValidationInfo containing the data being validated. - - :return: The list of ImageVersionOS objects with at most one primary OS. - """ - # If there's only one OS, mark it as primary by default. - if len(os) == 1: - # Skip warning if name already propagates an error. - if not os[0].primary: - os[0].primary = True - - return os - - @field_validator("os", mode="after") - @classmethod - def max_one_primary_os(cls, os: list[ImageVersionOS]) -> list[ImageVersionOS]: - """Ensures that at most one OS is marked as primary. - - :param os: List of ImageVersionOS objects to check. - :param info: ValidationInfo containing the data being validated. - - :return: The list of ImageVersionOS objects with at most one primary OS. - - :raises ValueError: If more than one OS is marked as primary. - """ - primary_os_count = sum(1 for o in os if o.primary) - if primary_os_count > 1: - raise ValueError( - f"Only one OS can be marked as primary for image development version. " - f"Found {primary_os_count} OSes marked primary." - ) - elif primary_os_count == 0: - log.warning( - f"No OS marked as primary for image development version. " - "At least one OS should be marked as primary for complete tagging and labeling of images." - ) - - return os - @model_validator(mode="after") def extra_registries_or_override_registries(self) -> Self: """Ensures that only one of extraRegistries or overrideRegistries is defined. diff --git a/posit-bakery/posit_bakery/config/image/dev_version/channel.py b/posit-bakery/posit_bakery/config/image/dev_version/channel.py index 9e9019a6a..926e1e341 100644 --- a/posit-bakery/posit_bakery/config/image/dev_version/channel.py +++ b/posit-bakery/posit_bakery/config/image/dev_version/channel.py @@ -99,7 +99,7 @@ def get_version(self) -> str: result = get_product_artifact_by_channel( self.product, self.channel, - _os.buildOS, + _os.artifact_build_os, release_branch=self.release_branch or "latest", ) return result.version @@ -114,7 +114,7 @@ def get_url_by_os(self, generalize_architecture: bool = False) -> dict[str, str] result = get_product_artifact_by_channel( self.product, self.channel, - _os.buildOS, + _os.artifact_build_os, version_override=self.version_override, release_branch=self.release_branch or "latest", ) @@ -147,7 +147,7 @@ def _resolve_os_urls(self) -> list[ImageVersionOS]: result = get_product_artifact_by_channel( self.product, self.channel, - os_version.buildOS, + os_version.artifact_build_os, version_override=self.version_override, release_branch=self.release_branch or "latest", ) diff --git a/posit-bakery/posit_bakery/config/image/matrix.py b/posit-bakery/posit_bakery/config/image/matrix.py index 3bb315b6d..d137a5658 100644 --- a/posit-bakery/posit_bakery/config/image/matrix.py +++ b/posit-bakery/posit_bakery/config/image/matrix.py @@ -21,6 +21,7 @@ from posit_bakery.config.dependencies.version import DependencyVersion, extract_versions, strip_patch from posit_bakery.config.image.build_os import TargetPlatform, DEFAULT_PLATFORMS +from posit_bakery.config.image.os_validators import OSValidatorMixin from posit_bakery.config.registry import BaseRegistry, Registry from posit_bakery.config.shared import BakeryPathMixin, BakeryYAMLModel from posit_bakery.config.templating import jinja2_env @@ -62,7 +63,7 @@ def generate_default_name_pattern(data: dict[str, Any]) -> str: return pattern -class ImageMatrix(BakeryPathMixin, BakeryYAMLModel): +class ImageMatrix(OSValidatorMixin, BakeryPathMixin, BakeryYAMLModel): """Model representing a matrix of a image value combinations to build.""" parent: Annotated[ @@ -167,91 +168,6 @@ def deduplicate_registries( ) return sorted(list(unique_registries), key=lambda r: r.base_url) - @field_validator("os", mode="after") - @classmethod - def check_os_not_empty(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: - """Ensures that the os list is not empty. - - :param os: List of ImageVersionOS objects to check. - :param info: ValidationInfo containing the data being validated. - - :return: The unmodified list of ImageVersionOS objects. - """ - # Check that name is defined since it will already propagate a validation error if not. - if info.data.get("namePattern") and not os: - log.warning( - f"No OSes defined for image matrix with name pattern '{info.data['namePattern']}'. At least one OS " - "should be defined for complete tagging and labeling of images." - ) - - return os - - @field_validator("os", mode="after") - @classmethod - def deduplicate_os(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: - """Ensures that the os list is unique and warns on duplicates. - - :param os: List of ImageVersionOS objects to deduplicate. - :param info: ValidationInfo containing the data being validated. - - :return: A list of unique ImageVersionOS objects. - """ - unique_oses = set(os) - for unique_os in unique_oses: - if info.data.get("namePattern") and os.count(unique_os) > 1: - log.warning( - "Duplicate OS defined in config for image matrix with name pattern " - f"'{info.data['namePattern']}': {unique_os.name}" - ) - - return sorted(list(unique_oses), key=lambda o: o.name) - - @field_validator("os", mode="after") - @classmethod - def make_single_os_primary(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: - """Ensures that at most one OS is marked as primary. - - :param os: List of ImageVersionOS objects to check. - :param info: ValidationInfo containing the data being validated. - - :return: The list of ImageVersionOS objects with at most one primary OS. - """ - # If there's only one OS, mark it as primary by default. - if len(os) == 1: - # Skip warning if name already propagates an error. - if info.data.get("namePattern") and not os[0].primary: - log.info( - "Only one OS, {os[0].name}, defined for image matrix with name pattern " - f"{info.data['namePattern']}. Marking it as primary OS." - ) - os[0].primary = True - return os - - @field_validator("os", mode="after") - @classmethod - def max_one_primary_os(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: - """Ensures that at most one OS is marked as primary. - - :param os: List of ImageVersionOS objects to check. - :param info: ValidationInfo containing the data being validated. - - :return: The list of ImageVersionOS objects with at most one primary OS. - - :raises ValueError: If more than one OS is marked as primary. - """ - primary_os_count = sum(1 for o in os if o.primary) - if info.data.get("namePattern") and primary_os_count > 1: - raise ValueError( - f"Only one OS can be marked as primary for image matrix with name pattern " - f"'{info.data['namePattern']}'. Found {primary_os_count} OSes marked primary." - ) - elif info.data.get("namePattern") and primary_os_count == 0: - log.warning( - f"No OS marked as primary for image matrix with name pattern '{info.data['namePattern']}'. " - "At least one OS should be marked as primary for complete tagging and labeling of images." - ) - return os - @field_validator("dependencyConstraints", mode="after") @classmethod def check_duplicate_dependency_constraints( diff --git a/posit-bakery/posit_bakery/config/image/os_validators.py b/posit-bakery/posit_bakery/config/image/os_validators.py new file mode 100644 index 000000000..09f16ccf9 --- /dev/null +++ b/posit-bakery/posit_bakery/config/image/os_validators.py @@ -0,0 +1,128 @@ +import logging + +from pydantic import field_validator +from pydantic_core.core_schema import ValidationInfo + +from posit_bakery.config.image.version_os import ImageVersionOS + +log = logging.getLogger(__name__) + + +class OSValidatorMixin: + @field_validator("os", mode="after") + @classmethod + def check_os_not_empty(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: + """Ensures that the os list is not empty. + + :param os: List of ImageVersionOS objects to check. + :param info: ValidationInfo containing the data being validated. + + :return: The unmodified list of ImageVersionOS objects. + """ + if not (info.data.get("name") or info.data.get("namePattern") or info.data.get("sourceType")): + return os + if not os: + log.warning( + "No OSes defined for the image configuration. At least one OS should be " + "defined for complete tagging and labeling of images." + ) + return os + + @field_validator("os", mode="after") + @classmethod + def deduplicate_os(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: + """Ensures that the os list is unique and warns on duplicates. + + :param os: List of ImageVersionOS objects to deduplicate. + :param info: ValidationInfo containing the data being validated. + + :return: A list of unique ImageVersionOS objects. + """ + unique_oses = set(os) + for unique_os in unique_oses: + dupes = [o for o in os if o == unique_os] + if len(dupes) > 1: + artifact_os_values = {o.artifactOs for o in dupes} + if len(artifact_os_values) > 1: + raise ValueError( + f"Conflicting artifactOs values for OS '{unique_os.name}': " + f"{sorted(str(v) for v in artifact_os_values)}. " + "Each OS entry must have a consistent artifactOs." + ) + log.warning(f"Duplicate OS defined in the image configuration: {unique_os.name}") + + return sorted(list(unique_oses), key=lambda o: o.name) + + @field_validator("os", mode="after") + @classmethod + def make_single_os_primary(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: + """Ensures that a single OS entry is automatically marked as primary. + + :param os: List of ImageVersionOS objects to check. + :param info: ValidationInfo containing the data being validated. + + :return: The list of ImageVersionOS objects with the single OS marked primary. + """ + # If there's only one OS, mark it as primary by default. + if len(os) == 1: + if not os[0].primary: + log.info(f"Only one OS defined; marking '{os[0].name}' as primary.") + os[0].primary = True + return os + + @field_validator("os", mode="after") + @classmethod + def max_one_primary_os(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: + """Ensures that at most one OS is marked as primary. + + :param os: List of ImageVersionOS objects to check. + :param info: ValidationInfo containing the data being validated. + + :return: The list of ImageVersionOS objects with at most one primary OS. + + :raises ValueError: If more than one OS is marked as primary. + """ + if not (info.data.get("name") or info.data.get("namePattern") or info.data.get("sourceType")): + return os + primary_os_count = sum(1 for o in os if o.primary) + if primary_os_count > 1: + raise ValueError( + f"Only one OS can be marked as primary for the image configuration. " + f"Found {primary_os_count} OSes marked primary." + ) + elif primary_os_count == 0: + log.warning( + "No OS marked as primary for the image configuration. " + "At least one OS should be marked as primary for complete tagging and labeling of images." + ) + return os + + # Must run after make_single_os_primary: Pydantic v2 runs field validators in + # declaration order within the class. Reordering this mixin will silently break + # the auto-promote invariant for single-OS scratch configs. + # + # Intentionally unguarded: an untaggable OS entry is always a config error + # regardless of whether the parent model's name/namePattern/sourceType is valid. + @field_validator("os", mode="after") + @classmethod + def error_untaggable_os(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: + """Ensures every non-primary OS has a tagDisplayName. + + A non-primary OS with no tagDisplayName produces images that cannot be + reached by any tag — they are untaggable. + + :param os: List of ImageVersionOS objects to check. + :param info: ValidationInfo containing the data being validated. + + :return: The unmodified list of ImageVersionOS objects. + + :raises ValueError: If a non-primary OS has an empty tagDisplayName. + """ + for o in os: + if not o.tagDisplayName and not o.primary: + raise ValueError( + f"OS entry '{o.name}' has an empty tagDisplayName but is not the primary OS. " + "A non-primary OS with no tagDisplayName produces images that cannot be reached " + "by any tag. Set it as primary or remove it from the image configuration." + ) + return os diff --git a/posit-bakery/posit_bakery/config/image/version.py b/posit-bakery/posit_bakery/config/image/version.py index feede17d9..7d3e0e36b 100644 --- a/posit-bakery/posit_bakery/config/image/version.py +++ b/posit-bakery/posit_bakery/config/image/version.py @@ -11,6 +11,7 @@ from pydantic_core.core_schema import ValidationInfo from posit_bakery.config.dependencies import DependencyVersionsField +from posit_bakery.config.image.os_validators import OSValidatorMixin from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum from posit_bakery.config.registry import BaseRegistry from posit_bakery.config.registry import Registry @@ -27,7 +28,7 @@ log = logging.getLogger(__name__) -class ImageVersion(BakeryPathMixin, BakeryYAMLModel): +class ImageVersion(OSValidatorMixin, BakeryPathMixin, BakeryYAMLModel): """Model representing a version of an image.""" parent: Annotated[ @@ -174,87 +175,6 @@ def deduplicate_registries( ) return sorted(list(unique_registries), key=lambda r: r.base_url) - @field_validator("os", mode="after") - @classmethod - def check_os_not_empty(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: - """Ensures that the os list is not empty. - - :param os: List of ImageVersionOS objects to check. - :param info: ValidationInfo containing the data being validated. - - :return: The unmodified list of ImageVersionOS objects. - """ - # Check that name is defined since it will already propagate a validation error if not. - if info.data.get("name") and not os: - log.warning( - f"No OSes defined for image version '{info.data['name']}'. At least one OS should be defined for " - f"complete tagging and labeling of images." - ) - return os - - @field_validator("os", mode="after") - @classmethod - def deduplicate_os(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: - """Ensures that the os list is unique and warns on duplicates. - - :param os: List of ImageVersionOS objects to deduplicate. - :param info: ValidationInfo containing the data being validated. - - :return: A list of unique ImageVersionOS objects. - """ - unique_oses = set(os) - for unique_os in unique_oses: - if info.data.get("name") and os.count(unique_os) > 1: - log.warning(f"Duplicate OS defined in config for image version '{info.data['name']}': {unique_os.name}") - - return sorted(list(unique_oses), key=lambda o: o.name) - - @field_validator("os", mode="after") - @classmethod - def make_single_os_primary(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: - """Ensures that at most one OS is marked as primary. - - :param os: List of ImageVersionOS objects to check. - :param info: ValidationInfo containing the data being validated. - - :return: The list of ImageVersionOS objects with at most one primary OS. - """ - # If there's only one OS, mark it as primary by default. - if len(os) == 1: - # Skip warning if name already propagates an error. - if info.data.get("name") and not os[0].primary: - log.info( - f"Only one OS, {os[0].name}, defined for image version {info.data['name']}. Marking it as primary " - f"OS." - ) - os[0].primary = True - return os - - @field_validator("os", mode="after") - @classmethod - def max_one_primary_os(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ImageVersionOS]: - """Ensures that at most one OS is marked as primary. - - :param os: List of ImageVersionOS objects to check. - :param info: ValidationInfo containing the data being validated. - - :return: The list of ImageVersionOS objects with at most one primary OS. - - :raises ValueError: If more than one OS is marked as primary. - """ - primary_os_count = sum(1 for o in os if o.primary) - if primary_os_count > 1: - raise ValueError( - f"Only one OS can be marked as primary for image version '{info.data['name']}'. " - f"Found {primary_os_count} OSes marked primary." - ) - elif info.data.get("name") and primary_os_count == 0: - log.warning( - f"No OS marked as primary for image version '{info.data['name']}'. " - "At least one OS should be marked as primary for complete tagging and labeling of images." - ) - return os - @field_validator("dependencies", mode="after") @classmethod def check_duplicate_dependencies( diff --git a/posit-bakery/posit_bakery/config/image/version_os.py b/posit-bakery/posit_bakery/config/image/version_os.py index 9e4d63d08..e2894879e 100644 --- a/posit-bakery/posit_bakery/config/image/version_os.py +++ b/posit-bakery/posit_bakery/config/image/version_os.py @@ -5,12 +5,49 @@ from pydantic import BaseModel, Field, field_validator, field_serializer from pydantic_core.core_schema import ValidationInfo -from posit_bakery.config.shared import BakeryYAMLModel, ExtensionField, TagDisplayNameField +from posit_bakery.config.shared import BakeryYAMLModel +from posit_bakery.const import ( + REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN, + REGEX_OS_EXTENSION_PATTERN, + REGEX_OS_TAG_DISPLAY_NAME_PATTERN, +) from .build_os import BuildOS, SUPPORTED_OS, ALTERNATE_NAMES, TargetPlatform, DEFAULT_PLATFORMS from .posit_product.const import URL_WITH_ENV_VARS_REGEX_PATTERN +_OSLESS_NAMES = frozenset({"scratch"}) + log = logging.getLogger(__name__) +_NAME_VERSION_PATTERN = re.compile(r"(?P[\D]+)(?P[\d.]*)") + + +def _resolve_name_to_build_os(name: str) -> BuildOS: + name = name.lower().strip() + match = _NAME_VERSION_PATTERN.match(name) + if not match: + log.warning(f"Could not identify '{name}' as a supported OS.") + return SUPPORTED_OS["unknown"] + match_dict = match.groupdict() + match_dict["name"] = match_dict.get("name", "").strip().rstrip("-") + + if match_dict["name"] in ALTERNATE_NAMES: + match_dict["name"] = ALTERNATE_NAMES[match_dict["name"]] + + if not match_dict.get("version"): + if match_dict["name"] in SUPPORTED_OS: + if isinstance(SUPPORTED_OS.get(match_dict["name"]), BuildOS): + return SUPPORTED_OS[match_dict["name"]] + else: + # Convert to int before max() to get numeric ordering (9 < 10), not lexical ("9" > "10") + latest = str(max([int(x) for x in SUPPORTED_OS[match_dict["name"]].keys()])) + return SUPPORTED_OS[match_dict["name"]][latest] + else: + match_dict["version"] = match_dict.get("version", "").split(".")[0] + if match_dict["name"] in SUPPORTED_OS and match_dict["version"] in SUPPORTED_OS.get(match_dict["name"]): + return SUPPORTED_OS[match_dict["name"]][match_dict["version"]] + + return SUPPORTED_OS["unknown"] + class ImageVersionOS(BakeryYAMLModel): """Model representing a supported operating system for an image version.""" @@ -33,16 +70,30 @@ class ImageVersionOS(BakeryYAMLModel): Field(default=DEFAULT_PLATFORMS, description="List of platforms to build for this image."), ] extension: Annotated[ - ExtensionField, + str, Field( + default_factory=lambda data: ( + "" + if data.get("name", "").lower().strip() in _OSLESS_NAMES + else re.sub(r"[^a-zA-Z0-9_-]", "", data.get("name", "").lower()) + ), + pattern=REGEX_OS_EXTENSION_PATTERN, + validate_default=True, description="File extension used in the Containerfile filename in the pattern " "Containerfile.. for this OS. Set to an empty string if no extension is needed.", examples=["ubuntu2204", "debian12"], ), ] tagDisplayName: Annotated[ - TagDisplayNameField, + str, Field( + default_factory=lambda data: ( + "" + if data.get("name", "").lower().strip() in _OSLESS_NAMES + else re.sub(REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN, "-", data.get("name", "").lower()) + ), + pattern=REGEX_OS_TAG_DISPLAY_NAME_PATTERN, + validate_default=True, description="The name used in image tags for this OS. This is used to create the tag " "in the format :--.", examples=["ubuntu-22.04", "debian-12"], @@ -66,6 +117,14 @@ class ImageVersionOS(BakeryYAMLModel): pattern=URL_WITH_ENV_VARS_REGEX_PATTERN, ), ] + artifactOs: Annotated[ + str | None, + Field( + default=None, + description="OS name for artifact download URL resolution when this OS cannot " + "resolve artifacts directly (e.g. scratch).", + ), + ] def __hash__(self): """Unique hash for an ImageVersionOS object.""" @@ -80,54 +139,34 @@ def __eq__(self, other): return hash(self) == hash(other) return False + @field_validator("artifactOs", mode="after") + @classmethod + def validate_artifact_os(cls, value: str | None) -> str | None: + if value is None: + return None + resolved = _resolve_name_to_build_os(value) + if resolved == SUPPORTED_OS["unknown"]: + raise ValueError(f"artifactOs '{value}' is not a recognized OS name") + return value + @field_validator("buildOS", mode="after") @classmethod def populate_build_os(cls, value: BuildOS, info: ValidationInfo) -> BuildOS: - """Populates the build_os field based on the name field. If the OS cannot be determined, it defaults to unknown.""" - # If the buildOS is already set to a known value, return it directly. + """Populates the buildOS field from the name field.""" if isinstance(value, BuildOS) and value != SUPPORTED_OS["unknown"]: return value - - name_pattern = re.compile(r"(?P[\D]+)(?P[\d.]*)") name = info.data.get("name") if name is None: return SUPPORTED_OS["unknown"] - - name = name.lower().strip() - match = name_pattern.match(name) - if not match: - log.warning(f"Could not identify '{name}' as a supported OS.") - return SUPPORTED_OS["unknown"] - match_dict = match.groupdict() - match_dict["name"] = match_dict.get("name", "").strip() - - # Handle possible alternate names for some OSes. - if match_dict["name"] in ALTERNATE_NAMES: - match_dict["name"] = ALTERNATE_NAMES[match_dict["name"]] - - # Ideally, a name and version should be in the name field. If not, we try to infer an unversioned OS - # (such as scratch) or default to the latest version of a known OS. - if not match_dict.get("version"): - if match_dict["name"] in SUPPORTED_OS: - # If only the name is provided and it's an unversioned OS, use it. - if isinstance(SUPPORTED_OS.get(match_dict["name"]), BuildOS): - return SUPPORTED_OS[match_dict["name"]] - # Otherwise, use the latest version of the matching OS name if possible. - else: - # This line converts each version of the OS from a string to an int, finds the max, then converts it - # back to a string. This ensures that we get the latest version numerically, not lexically. - latest = str(max([int(x) for x in SUPPORTED_OS[match_dict["name"]].keys()])) - return SUPPORTED_OS[match_dict["name"]][latest] - # Otherwise, assume a two-part name and version in the name field. - else: - match_dict["version"] = match_dict.get("version", "").split(".")[0] - # Check if the name and version are in the supported OS list. - if match_dict["name"] in SUPPORTED_OS and match_dict["version"] in SUPPORTED_OS.get(match_dict["name"]): - return SUPPORTED_OS[match_dict["name"]][match_dict["version"]] - - return SUPPORTED_OS["unknown"] + return _resolve_name_to_build_os(name) @field_serializer("platforms") def serialize_platforms(self, platforms: list[TargetPlatform]) -> list[str]: """Serialize the platforms field to a list of strings for YAML output.""" return [platform.value for platform in platforms] + + @property + def artifact_build_os(self) -> BuildOS: + if self.artifactOs is not None: + return _resolve_name_to_build_os(self.artifactOs) + return self.buildOS diff --git a/posit-bakery/posit_bakery/const.py b/posit-bakery/posit_bakery/const.py index d8a3bd098..81320d9aa 100644 --- a/posit-bakery/posit_bakery/const.py +++ b/posit-bakery/posit_bakery/const.py @@ -24,6 +24,10 @@ class GetTagsOutputFormat(str, Enum): UID = "uid" +# Extension allows letters, digits, underscores, and hyphens (e.g. "ubuntu2204"). +# Tag display names additionally allow dots (e.g. "ubuntu-22.04"). +REGEX_OS_EXTENSION_PATTERN = r"^[a-zA-Z0-9_-]*$" +REGEX_OS_TAG_DISPLAY_NAME_PATTERN = r"^[a-zA-Z0-9_.-]*$" REGEX_FULL_IMAGE_TAG_PATTERN = ( r"^(?P[\w.\-_]+((?::\d+|)(?=/[a-z0-9._-]+/[a-z0-9._-]+))|)" r"(?:/|)(?P[a-z0-9.\-_]+(?:/[a-z0-9.\-_]+|))(:(?P[\w.\-_]{1,127})|)$" diff --git a/posit-bakery/posit_bakery/image/image_target.py b/posit-bakery/posit_bakery/image/image_target.py index 9009e5d9a..98beb4995 100644 --- a/posit-bakery/posit_bakery/image/image_target.py +++ b/posit-bakery/posit_bakery/image/image_target.py @@ -695,7 +695,7 @@ def build( cache_from=cache_from, cache_to=cache_to, metadata_file=metadata_file, - platforms=platforms or self.image_os.platforms, + platforms=build_platforms, target=self.build_target, secrets=[s.as_cli_option() for s in self.resolved_build_secrets], progress=False if SETTINGS.log_level >= logging.ERROR else "auto", diff --git a/posit-bakery/test/cli/testdata/ci/matrix/multiversion/default.json b/posit-bakery/test/cli/testdata/ci/matrix/multiversion/default.json new file mode 100644 index 000000000..699c5c863 --- /dev/null +++ b/posit-bakery/test/cli/testdata/ci/matrix/multiversion/default.json @@ -0,0 +1 @@ +[{"image": "test-image", "version": "2.0.0", "dev": false, "platform": "linux/amd64"}, {"image": "test-image", "version": "1.0.0", "dev": false, "platform": "linux/amd64"}] diff --git a/posit-bakery/test/cli/testdata/get/tags/barebones/default.json b/posit-bakery/test/cli/testdata/get/tags/barebones/default.json index a16069f47..b947d1e9b 100644 --- a/posit-bakery/test/cli/testdata/get/tags/barebones/default.json +++ b/posit-bakery/test/cli/testdata/get/tags/barebones/default.json @@ -3,9 +3,7 @@ "1.0.0": { "Scratch": [ "ghcr.io/posit-dev/scratch:1.0.0", - "ghcr.io/posit-dev/scratch:1.0.0-scratch", - "ghcr.io/posit-dev/scratch:latest", - "ghcr.io/posit-dev/scratch:scratch" + "ghcr.io/posit-dev/scratch:latest" ] } } diff --git a/posit-bakery/test/cli/testdata/get/tags/barebones/uid.json b/posit-bakery/test/cli/testdata/get/tags/barebones/uid.json index 755f6f7b5..315441c46 100644 --- a/posit-bakery/test/cli/testdata/get/tags/barebones/uid.json +++ b/posit-bakery/test/cli/testdata/get/tags/barebones/uid.json @@ -1,8 +1,6 @@ { "scratch-1-0-0-scratch": [ "ghcr.io/posit-dev/scratch:1.0.0", - "ghcr.io/posit-dev/scratch:1.0.0-scratch", - "ghcr.io/posit-dev/scratch:latest", - "ghcr.io/posit-dev/scratch:scratch" + "ghcr.io/posit-dev/scratch:latest" ] } diff --git a/posit-bakery/test/config/image/dev_version/test_channel.py b/posit-bakery/test/config/image/dev_version/test_channel.py index b39b5e8f5..48f73db77 100644 --- a/posit-bakery/test/config/image/dev_version/test_channel.py +++ b/posit-bakery/test/config/image/dev_version/test_channel.py @@ -104,8 +104,8 @@ def test_check_os_not_empty(self, caplog): assert "WARNING" in caplog.text assert ( - "No OSes defined for image development version. At least one OS should be defined " - "for complete tagging and labeling of images." in caplog.text + "No OSes defined for the image configuration. At least one OS should be " + "defined for complete tagging and labeling of images." in caplog.text ) def test_deduplicate_os(self, caplog): @@ -129,7 +129,7 @@ def test_deduplicate_os(self, caplog): assert len(i.os) == 1 assert i.os[0].name == "Ubuntu 22.04" assert "WARNING" in caplog.text - assert "Duplicate OS defined in config for image development version: Ubuntu 22.04" in caplog.text + assert "Duplicate OS defined in the image configuration: Ubuntu 22.04" in caplog.text def test_make_single_os_primary(self, caplog): """Test that if only one OS is defined, it is automatically made primary.""" @@ -150,7 +150,7 @@ def test_max_one_primary_os(self): """Test that an error is raised if multiple primary OSes are defined.""" with pytest.raises( ValidationError, - match="Only one OS can be marked as primary for image development version. Found 2 OSes marked primary.", + match="Only one OS can be marked as primary for the image configuration. Found 2 OSes marked primary.", ): with patch("posit_bakery.config.image.dev_version.channel.get_product_artifact_by_channel") as mock_get: mock_get.return_value = ReleaseChannelResult( @@ -181,8 +181,8 @@ def test_no_primary_os_warning(self, caplog): assert "WARNING" in caplog.text assert ( - "No OS marked as primary for image development version. At least one OS should be " - "marked as primary for complete tagging and labeling of images." in caplog.text + "No OS marked as primary for the image configuration. " + "At least one OS should be marked as primary for complete tagging and labeling of images." in caplog.text ) def test_extra_registries_or_override_registries(self): @@ -1033,6 +1033,51 @@ def test_workbench_daily( assert dev_version.get_url_by_os()[_os.name] == expected_session_url +class TestArtifactOsForScratch: + def test_resolve_os_urls_uses_artifact_os_for_scratch(self): + from posit_bakery.config.image.build_os import SUPPORTED_OS + from posit_bakery.config.image.posit_product.main import ReleaseChannelResult + + expected_url = "https://cdn.posit.co/connect/daily/ubuntu2404/amd64/rstudio-connect_2026.05.0_amd64.deb" + with patch("posit_bakery.config.image.dev_version.channel.get_product_artifact_by_channel") as mock_get: + mock_get.return_value = ReleaseChannelResult( + version="2026.05.0", + download_url=expected_url, + ) + version = ImageDevelopmentVersionFromProductChannel( + sourceType="stream", + product="connect", + channel="daily", + os=[{"name": "scratch", "artifactOs": "ubuntu-24.04"}], + ) + resolved = version._resolve_os_urls() + + for call in mock_get.call_args_list: + assert call.args[2] == SUPPORTED_OS["ubuntu"]["24"] + assert len(resolved) == 1 + assert resolved[0].artifactDownloadURL == expected_url + + def test_get_version_uses_artifact_os_for_scratch(self): + from posit_bakery.config.image.build_os import SUPPORTED_OS + from posit_bakery.config.image.posit_product.main import ReleaseChannelResult + + with patch("posit_bakery.config.image.dev_version.channel.get_product_artifact_by_channel") as mock_get: + mock_get.return_value = ReleaseChannelResult( + version="2026.05.0", + download_url="https://cdn.posit.co/connect/daily/ubuntu2404/amd64/rstudio-connect_2026.05.0_amd64.deb", + ) + version = ImageDevelopmentVersionFromProductChannel( + sourceType="stream", + product="connect", + channel="daily", + os=[{"name": "scratch", "artifactOs": "ubuntu-24.04", "primary": True}], + ) + result = version.get_version() + + assert result == "2026.05.0" + assert mock_get.call_args.args[2] == SUPPORTED_OS["ubuntu"]["24"] + + class TestResolveOsUrls: def test_sets_artifact_download_url(self): with patch("posit_bakery.config.image.dev_version.channel.get_product_artifact_by_channel") as mock_get: diff --git a/posit-bakery/test/config/image/test_matrix.py b/posit-bakery/test/config/image/test_matrix.py index c1679b73d..a10d2c55c 100644 --- a/posit-bakery/test/config/image/test_matrix.py +++ b/posit-bakery/test/config/image/test_matrix.py @@ -214,7 +214,7 @@ def test_check_os_not_empty(self, caplog): ImageMatrix(values={"go_version": ["1.24", "1.25"]}, os=[]) assert "WARNING" in caplog.text assert ( - "No OSes defined for image matrix with name pattern 'go_version{{ Values.go_version }}'. At least one OS should be " + "No OSes defined for the image configuration. At least one OS should be " "defined for complete tagging and labeling of images." in caplog.text ) @@ -233,9 +233,7 @@ def test_deduplicate_os(self, caplog): assert len(matrix.os) == 1 assert matrix.os[0].name == "Ubuntu 22.04" assert "WARNING" in caplog.text - assert ( - "Duplicate OS defined in config for image matrix with name pattern 'go_version{{ Values.go_version }}': Ubuntu 22.04" - ) in caplog.text + assert ("Duplicate OS defined in the image configuration: Ubuntu 22.04") in caplog.text def test_make_single_os_primary(self, caplog): """Test that if only one OS is defined, it is automatically made primary.""" @@ -249,7 +247,7 @@ def test_max_one_primary_os(self): """Test that an error is raised if multiple primary OSes are defined.""" with pytest.raises( ValidationError, - match="Only one OS can be marked as primary for image matrix with name pattern 'go_version{{ Values.go_version }}'. Found 2 OSes marked primary.", + match="Only one OS can be marked as primary for the image configuration. Found 2 OSes marked primary.", ): ImageMatrix( values={"go_version": ["1.24", "1.25"]}, @@ -264,8 +262,8 @@ def test_no_primary_os_warning(self, caplog): ImageMatrix(values={"go_version": ["1.24", "1.25"]}, os=[{"name": "Ubuntu 22.04"}, {"name": "Ubuntu 24.04"}]) assert "WARNING" in caplog.text assert ( - "No OS marked as primary for image matrix with name pattern 'go_version{{ Values.go_version }}'. At least one OS should be marked as primary for " - "complete tagging and labeling of images." in caplog.text + "No OS marked as primary for the image configuration. " + "At least one OS should be marked as primary for complete tagging and labeling of images." in caplog.text ) def test_check_duplicate_dependencies(self): diff --git a/posit-bakery/test/config/image/test_version.py b/posit-bakery/test/config/image/test_version.py index 1723f2008..ba5e0a55b 100644 --- a/posit-bakery/test/config/image/test_version.py +++ b/posit-bakery/test/config/image/test_version.py @@ -91,8 +91,8 @@ def test_check_os_not_empty(self, caplog): ImageVersion(name="1.0.0", os=[]) assert "WARNING" in caplog.text assert ( - "No OSes defined for image version '1.0.0'. At least one OS should be defined for complete tagging and " - "labeling of images." in caplog.text + "No OSes defined for the image configuration. At least one OS should be " + "defined for complete tagging and labeling of images." in caplog.text ) def test_deduplicate_os(self, caplog): @@ -110,7 +110,21 @@ def test_deduplicate_os(self, caplog): assert len(i.os) == 1 assert i.os[0].name == "Ubuntu 22.04" assert "WARNING" in caplog.text - assert "Duplicate OS defined in config for image version '1.0.0': Ubuntu 22.04" in caplog.text + assert "Duplicate OS defined in the image configuration: Ubuntu 22.04" in caplog.text + + def test_conflicting_artifact_os_raises(self): + """Duplicate OS entries with different artifactOs values are a config error.""" + mock_parent = MagicMock(spec=Image) + mock_parent.path = Path("/tmp/path") + with pytest.raises(ValidationError, match="Conflicting artifactOs values for OS 'scratch'"): + ImageVersion( + parent=mock_parent, + name="1.0.0", + os=[ + {"name": "scratch", "artifactOs": "ubuntu-22.04", "primary": True}, + {"name": "scratch", "artifactOs": "ubuntu-24.04"}, + ], + ) def test_make_single_os_primary(self, caplog): """Test that if only one OS is defined, it is automatically made primary.""" @@ -124,7 +138,7 @@ def test_max_one_primary_os(self): """Test that an error is raised if multiple primary OSes are defined.""" with pytest.raises( ValidationError, - match="Only one OS can be marked as primary for image version '1.0.0'. Found 2 OSes marked primary.", + match="Only one OS can be marked as primary for the image configuration. Found 2 OSes marked primary.", ): ImageVersion( name="1.0.0", @@ -139,8 +153,8 @@ def test_no_primary_os_warning(self, caplog): ImageVersion(name="1.0.0", os=[{"name": "Ubuntu 22.04"}, {"name": "Ubuntu 24.04"}]) assert "WARNING" in caplog.text assert ( - "No OS marked as primary for image version '1.0.0'. At least one OS should be marked as primary for " - "complete tagging and labeling of images." in caplog.text + "No OS marked as primary for the image configuration. " + "At least one OS should be marked as primary for complete tagging and labeling of images." in caplog.text ) def test_check_duplicate_dependencies(self): @@ -712,3 +726,29 @@ def test_parsed_version_unparseable_returns_none_with_warning(self, caplog): assert v.parsed_version is None warnings = [r for r in caplog.records if "Unparseable version string" in r.message] assert len(warnings) == 1 + + def test_non_primary_scratch_os_raises(self): + with pytest.raises(ValidationError, match="empty tagDisplayName"): + ImageVersion( + name="2026.05.0", + os=[ + {"name": "scratch", "primary": False}, + {"name": "Ubuntu 22.04", "primary": True}, + ], + ) + + def test_single_scratch_os_auto_promoted_to_primary(self): + v = ImageVersion(name="2026.05.0", os=[{"name": "scratch"}]) + assert v.os[0].primary + assert v.os[0].tagDisplayName == "" + + def test_explicit_primary_scratch_in_multi_os_passes(self): + v = ImageVersion( + name="2026.05.0", + os=[ + {"name": "scratch", "primary": True}, + {"name": "Ubuntu 22.04", "primary": False}, + ], + ) + scratch_entry = next(o for o in v.os if o.name.lower() == "scratch") + assert scratch_entry.primary diff --git a/posit-bakery/test/config/image/test_version_os.py b/posit-bakery/test/config/image/test_version_os.py index d9ba4e10b..cdb21fafe 100644 --- a/posit-bakery/test/config/image/test_version_os.py +++ b/posit-bakery/test/config/image/test_version_os.py @@ -122,6 +122,17 @@ def test_equality(self): assert os1 != os3 assert os2 != os3 + @pytest.mark.parametrize("name", ["scratch", "Scratch", "SCRATCH", " scratch "]) + def test_scratch_derives_empty_extension_and_tag(self, name): + i = ImageVersionOS(name=name) + assert i.extension == "" + assert i.tagDisplayName == "" + + def test_empty_extension_accepted_explicitly(self): + i = ImageVersionOS(name="Ubuntu 22.04", extension="", tagDisplayName="") + assert i.extension == "" + assert i.tagDisplayName == "" + @pytest.mark.parametrize( "input_name,expected_build_os", [ @@ -146,6 +157,8 @@ def test_equality(self): ("Rocky Linux 10", SUPPORTED_OS["rocky"]["10"]), ("Rocky 9", SUPPORTED_OS["rocky"]["9"]), ("Rocky", SUPPORTED_OS["rocky"]["10"]), + ("Scratch", SUPPORTED_OS["scratch"]), + ("scratch", SUPPORTED_OS["scratch"]), ], ) def test_populate_build_os(self, input_name, expected_build_os): @@ -153,3 +166,29 @@ def test_populate_build_os(self, input_name, expected_build_os): os = ImageVersionOS(name=input_name) assert os.buildOS == expected_build_os + + +class TestImageVersionOSArtifactOs: + def test_artifact_os_accepted_for_known_os(self): + i = ImageVersionOS(name="scratch", artifactOs="ubuntu-24.04") + assert i.artifactOs == "ubuntu-24.04" + + def test_artifact_os_rejected_for_unknown_os(self): + with pytest.raises(ValidationError, match="not a recognized OS name"): + ImageVersionOS(name="scratch", artifactOs="not-a-real-os") + + def test_artifact_os_defaults_to_none(self): + i = ImageVersionOS(name="scratch") + assert i.artifactOs is None + + def test_artifact_build_os_returns_resolved_os_when_set(self): + i = ImageVersionOS(name="scratch", artifactOs="ubuntu-24.04") + assert i.artifact_build_os == SUPPORTED_OS["ubuntu"]["24"] + + def test_artifact_build_os_falls_back_to_build_os_when_unset(self): + i = ImageVersionOS(name="Ubuntu 22.04") + assert i.artifact_build_os == SUPPORTED_OS["ubuntu"]["22"] + + def test_artifact_build_os_for_scratch_without_artifact_os(self): + i = ImageVersionOS(name="scratch") + assert i.artifact_build_os == SUPPORTED_OS["scratch"] diff --git a/posit-bakery/test/config/templating/test_render.py b/posit-bakery/test/config/templating/test_render.py index 7c8a0ceb2..4fdd0caff 100644 --- a/posit-bakery/test/config/templating/test_render.py +++ b/posit-bakery/test/config/templating/test_render.py @@ -67,7 +67,7 @@ def test_regexReplace_filter(self): env = jinja2_env() assert env.from_string("{{ 'hello world' | regexReplace('world', 'there') }}").render() == "hello there" assert env.from_string("{{ 'foo-bar-baz' | regexReplace('-', '_') }}").render() == "foo_bar_baz" - assert env.from_string(r"{{ '123-456-789' | regexReplace('\d', 'X') }}").render() == "XXX-XXX-XXX" + assert env.from_string(r"{{ '123-456-789' | regexReplace('\\d', 'X') }}").render() == "XXX-XXX-XXX" def test_render_template(): diff --git a/posit-bakery/test/config/test_config.py b/posit-bakery/test/config/test_config.py index d31a7d8b2..bee1a8e49 100644 --- a/posit-bakery/test/config/test_config.py +++ b/posit-bakery/test/config/test_config.py @@ -858,14 +858,14 @@ def test_create_version(self, get_tmpcontext): ) assert expected_yaml in (context / "bakery.yaml").read_text() assert (context / image.name / new_version).is_dir() - assert (context / image.name / new_version / f"Containerfile.{previous_os.extension}").is_file() + containerfile_name = f"Containerfile.{previous_os.extension}" if previous_os.extension else "Containerfile" + assert (context / image.name / new_version / containerfile_name).is_file() expected_containerfile = textwrap.dedent("""\ FROM scratch COPY scratch/2.0.0/deps/packages.txt /tmp/packages.txt """) - assert expected_containerfile == (context / image.name / new_version / "Containerfile.scratch").read_text() - assert (context / image.name / new_version / "Containerfile.scratch").is_file() + assert expected_containerfile == (context / image.name / new_version / containerfile_name).read_text() assert (context / image.name / new_version / "deps").is_dir() assert (context / image.name / new_version / "deps" / "packages.txt").is_file() assert (context / image.name / new_version / "test").is_dir() @@ -895,13 +895,13 @@ def test_create_version_nested_subpath(self, get_tmpcontext): ) assert expected_yaml in (context / "bakery.yaml").read_text() assert (context / "scratch" / "2" / "0" / "0").is_dir() - assert (context / "scratch" / "2" / "0" / "0" / "Containerfile.scratch").is_file() + assert (context / "scratch" / "2" / "0" / "0" / "Containerfile").is_file() expected_containerfile = textwrap.dedent("""\ FROM scratch COPY scratch/2/0/0/deps/packages.txt /tmp/packages.txt """) - assert expected_containerfile == (context / "scratch" / "2" / "0" / "0" / "Containerfile.scratch").read_text() + assert expected_containerfile == (context / "scratch" / "2" / "0" / "0" / "Containerfile").read_text() def test_create_version_exists_force(self, get_tmpcontext): """Test creating an existing version in the BakeryConfig with force works.""" @@ -928,10 +928,10 @@ def test_create_version_exists_force(self, get_tmpcontext): ) assert expected_yaml in (context / "bakery.yaml").read_text() assert (context / "scratch" / "1").is_dir() - assert (context / "scratch" / "1" / "Containerfile.scratch").is_file() + assert (context / "scratch" / "1" / "Containerfile").is_file() assert ( "COPY scratch/1/deps/packages.txt /tmp/packages.txt" - in (context / "scratch" / "1" / "Containerfile.scratch").read_text() + in (context / "scratch" / "1" / "Containerfile").read_text() ) assert not (context / "1.0.0").is_dir() diff --git a/posit-bakery/test/features/cli/ci/matrix.feature b/posit-bakery/test/features/cli/ci/matrix.feature index 243a794ba..bfd188960 100644 --- a/posit-bakery/test/features/cli/ci/matrix.feature +++ b/posit-bakery/test/features/cli/ci/matrix.feature @@ -47,3 +47,10 @@ Feature: matrix | --image-version | 9.9.9 | When I execute the command Then The command fails + + Scenario: Generating a full CI matrix for the multiversion suite + Given I call bakery ci matrix + * in the multiversion context + When I execute the command + Then The command succeeds + * the matrix matches testdata ci/matrix/multiversion/default.json diff --git a/posit-bakery/test/image/bake/testdata/cache_registry/barebones_plan.json b/posit-bakery/test/image/bake/testdata/cache_registry/barebones_plan.json index b799058da..c733ba7cf 100644 --- a/posit-bakery/test/image/bake/testdata/cache_registry/barebones_plan.json +++ b/posit-bakery/test/image/bake/testdata/cache_registry/barebones_plan.json @@ -14,7 +14,7 @@ "target": { "scratch-1-0-0-scratch": { "context": ".", - "dockerfile": "scratch/1.0.0/Containerfile.scratch", + "dockerfile": "scratch/1.0.0/Containerfile", "labels": { "org.opencontainers.image.created": "2025-01-01T00:00:00+00:00", "org.opencontainers.image.source": "https://github.com/posit-images-shared/posit-images-shared", @@ -30,9 +30,7 @@ }, "tags": [ "ghcr.io/posit-dev/scratch:1.0.0", - "ghcr.io/posit-dev/scratch:1.0.0-scratch", - "ghcr.io/posit-dev/scratch:latest", - "ghcr.io/posit-dev/scratch:scratch" + "ghcr.io/posit-dev/scratch:latest" ], "args": {}, "platforms": [ @@ -41,13 +39,13 @@ "cache_from": [ { "type": "registry", - "ref": "ghcr.io/posit-dev/scratch/cache:1.0.0-scratch-amd64" + "ref": "ghcr.io/posit-dev/scratch/cache:1.0.0--amd64" } ], "cache_to": [ { "type": "registry", - "ref": "ghcr.io/posit-dev/scratch/cache:1.0.0-scratch-amd64", + "ref": "ghcr.io/posit-dev/scratch/cache:1.0.0--amd64", "mode": "max", "compression": "zstd", "oci-mediatypes": "true" diff --git a/posit-bakery/test/image/bake/testdata/default/barebones_plan.json b/posit-bakery/test/image/bake/testdata/default/barebones_plan.json index 1fb21bbab..fe87abbd4 100644 --- a/posit-bakery/test/image/bake/testdata/default/barebones_plan.json +++ b/posit-bakery/test/image/bake/testdata/default/barebones_plan.json @@ -14,7 +14,7 @@ "target": { "scratch-1-0-0-scratch": { "context": ".", - "dockerfile": "scratch/1.0.0/Containerfile.scratch", + "dockerfile": "scratch/1.0.0/Containerfile", "labels": { "org.opencontainers.image.created": "2025-01-01T00:00:00+00:00", "org.opencontainers.image.source": "https://github.com/posit-images-shared/posit-images-shared", @@ -30,9 +30,7 @@ }, "tags": [ "ghcr.io/posit-dev/scratch:1.0.0", - "ghcr.io/posit-dev/scratch:1.0.0-scratch", - "ghcr.io/posit-dev/scratch:latest", - "ghcr.io/posit-dev/scratch:scratch" + "ghcr.io/posit-dev/scratch:latest" ], "args": {}, "platforms": [ diff --git a/posit-bakery/test/image/bake/testdata/temp_registry/barebones_plan.json b/posit-bakery/test/image/bake/testdata/temp_registry/barebones_plan.json index 7076e2df2..9d6044289 100644 --- a/posit-bakery/test/image/bake/testdata/temp_registry/barebones_plan.json +++ b/posit-bakery/test/image/bake/testdata/temp_registry/barebones_plan.json @@ -14,7 +14,7 @@ "target": { "scratch-1-0-0-scratch": { "context": ".", - "dockerfile": "scratch/1.0.0/Containerfile.scratch", + "dockerfile": "scratch/1.0.0/Containerfile", "labels": { "org.opencontainers.image.created": "2025-01-01T00:00:00+00:00", "org.opencontainers.image.source": "https://github.com/posit-images-shared/posit-images-shared", diff --git a/posit-bakery/test/image/test_image_target.py b/posit-bakery/test/image/test_image_target.py index c07546cb3..5d9bbaf89 100644 --- a/posit-bakery/test/image/test_image_target.py +++ b/posit-bakery/test/image/test_image_target.py @@ -1137,6 +1137,26 @@ def test_get_merge_sources_empty_metadata_no_sources(self, basic_standard_image_ assert len(basic_standard_image_target.get_merge_sources()) == 0 + def test_containerfile_with_scratch_os_has_no_os_component(self, basic_standard_image_target): + """Scratch OS (empty extension) with no variant produces a plain Containerfile.""" + from posit_bakery.config import ImageVersionOS + + basic_standard_image_target.image_os = ImageVersionOS(name="scratch") + basic_standard_image_target.image_variant = None + assert basic_standard_image_target.containerfile.name == "Containerfile" + + @pytest.mark.build + def test_build_uses_build_platforms_when_image_os_is_none(self, basic_standard_image_target): + """build() uses DEFAULT_PLATFORMS when image_os is None instead of crashing.""" + basic_standard_image_target.image_os = None + with ( + patch("python_on_whales.docker.build") as mock_build, + patch("pathlib.Path.is_file", return_value=True), + ): + basic_standard_image_target.build() + call_kwargs = mock_build.call_args[1] + assert call_kwargs["platforms"] == ["linux/amd64"] + 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 = [ diff --git a/posit-bakery/test/plugins/builtin/dgoss/test_command.py b/posit-bakery/test/plugins/builtin/dgoss/test_command.py index 5e9ee5a5e..4c53ea145 100644 --- a/posit-bakery/test/plugins/builtin/dgoss/test_command.py +++ b/posit-bakery/test/plugins/builtin/dgoss/test_command.py @@ -25,18 +25,16 @@ def test_from_image_target(self, basic_standard_image_target): assert basic_standard_image_target.context.version_path / "test" == dgoss_command.test_path assert dgoss_command.wait == 1 - def test_dgoss_environment(self, basic_standard_image_target): + def test_dgoss_environment(self, basic_standard_image_target, monkeypatch): """Test that DGossCommand dgoss_environment returns the expected environment variables.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) dgoss_command = DGossCommand.from_image_target(image_target=basic_standard_image_target) expected_env = { "GOSS_FILES_PATH": str(basic_standard_image_target.context.version_path / "test"), "GOSS_SLEEP": "1", "GOSS_OPTS": "--format json --no-color", } - for key, value in expected_env.items(): - assert dgoss_command.dgoss_environment[key] == value, ( - f"Expected {key} to be {value}, got {dgoss_command.dgoss_environment[key]}" - ) + assert dgoss_command.dgoss_environment == expected_env def test_image_environment(self, basic_standard_image_target): """Test that DGossCommand image_environment returns the expected environment variables.""" diff --git a/posit-bakery/test/resources/barebones/bakery.yaml b/posit-bakery/test/resources/barebones/bakery.yaml index de374f3aa..7640a47ad 100644 --- a/posit-bakery/test/resources/barebones/bakery.yaml +++ b/posit-bakery/test/resources/barebones/bakery.yaml @@ -16,3 +16,4 @@ images: latest: true os: - name: "Scratch" + extension: "" diff --git a/posit-bakery/test/resources/barebones/scratch/1.0.0/Containerfile.scratch b/posit-bakery/test/resources/barebones/scratch/1.0.0/Containerfile similarity index 100% rename from posit-bakery/test/resources/barebones/scratch/1.0.0/Containerfile.scratch rename to posit-bakery/test/resources/barebones/scratch/1.0.0/Containerfile diff --git a/posit-bakery/test/resources/barebones/scratch/template/Containerfile.scratch.jinja2 b/posit-bakery/test/resources/barebones/scratch/template/Containerfile.jinja2 similarity index 100% rename from posit-bakery/test/resources/barebones/scratch/template/Containerfile.scratch.jinja2 rename to posit-bakery/test/resources/barebones/scratch/template/Containerfile.jinja2 diff --git a/posit-bakery/test/resources/fail-fast/scratch/1.0.0/Containerfile.scratch.min b/posit-bakery/test/resources/fail-fast/scratch/1.0.0/Containerfile.min similarity index 100% rename from posit-bakery/test/resources/fail-fast/scratch/1.0.0/Containerfile.scratch.min rename to posit-bakery/test/resources/fail-fast/scratch/1.0.0/Containerfile.min diff --git a/posit-bakery/test/resources/fail-fast/scratch/1.0.0/Containerfile.scratch.std b/posit-bakery/test/resources/fail-fast/scratch/1.0.0/Containerfile.std similarity index 100% rename from posit-bakery/test/resources/fail-fast/scratch/1.0.0/Containerfile.scratch.std rename to posit-bakery/test/resources/fail-fast/scratch/1.0.0/Containerfile.std diff --git a/posit-bakery/test/resources/multiversion/bakery.yaml b/posit-bakery/test/resources/multiversion/bakery.yaml new file mode 100644 index 000000000..a7a2d91bb --- /dev/null +++ b/posit-bakery/test/resources/multiversion/bakery.yaml @@ -0,0 +1,23 @@ +repository: + url: "github.com/posit-dev/images-shared" + vendor: "Posit Software, PBC" + maintainer: + name: "Posit Docker Team" + email: "docker@posit.co" + +registries: + - host: "ghcr.io" + namespace: "posit-dev" + +images: + - name: "test-image" + versions: + - name: "2.0.0" + latest: true + os: + - name: Ubuntu 22.04 + primary: true + - name: "1.0.0" + os: + - name: Ubuntu 22.04 + primary: true