From fbcfe495cc958343d29a67bbc0edc008cc76a60c Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Mon, 8 Jun 2026 15:39:06 -0500 Subject: [PATCH 01/16] Allow empty extension/tagDisplayName for scratch OS Introduce `_OSLESS_NAMES` (containing "scratch") in `shared.py`. The `default_factory` lambdas for `ExtensionField` and `TagDisplayNameField` now return `""` when the OS name is in that set. The `pattern` validators are relaxed from `+` (one-or-more) to `*` (zero-or-more) so explicitly-supplied empty strings are also accepted. --- posit-bakery/posit_bakery/config/shared.py | 18 +++++++++++++----- .../test/config/image/test_version_os.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/posit-bakery/posit_bakery/config/shared.py b/posit-bakery/posit_bakery/config/shared.py index 79eac2ed2..a2e506831 100644 --- a/posit-bakery/posit_bakery/config/shared.py +++ b/posit-bakery/posit_bakery/config/shared.py @@ -10,12 +10,18 @@ from posit_bakery.const import REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN +_OSLESS_NAMES = frozenset({"scratch"}) + # Shared field configuration for file extensions. ExtensionField = Annotated[ str, Field( - default_factory=lambda data: re.sub(r"[^a-zA-Z0-9_-]", "", data.get("name", "").lower()), - pattern=r"^[a-zA-Z0-9_-]+$", + 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=r"^[a-zA-Z0-9_-]*$", validate_default=True, ), ] @@ -25,10 +31,12 @@ TagDisplayNameField = Annotated[ str, Field( - default_factory=lambda data: re.sub( - REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN, "-", data.get("name", "").lower() + 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=r"^[a-zA-Z0-9_.-]+$", + pattern=r"^[a-zA-Z0-9_.-]*$", validate_default=True, ), ] diff --git a/posit-bakery/test/config/image/test_version_os.py b/posit-bakery/test/config/image/test_version_os.py index d9ba4e10b..310eabfce 100644 --- a/posit-bakery/test/config/image/test_version_os.py +++ b/posit-bakery/test/config/image/test_version_os.py @@ -122,6 +122,21 @@ def test_equality(self): assert os1 != os3 assert os2 != os3 + def test_scratch_derives_empty_extension_and_tag(self): + i = ImageVersionOS(name="scratch") + assert i.extension == "" + assert i.tagDisplayName == "" + + def test_scratch_case_insensitive(self): + i = ImageVersionOS(name="Scratch") + 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", [ From 331c8193a642915b5f294eedd8d292cca0eea045 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Mon, 8 Jun 2026 15:46:32 -0500 Subject: [PATCH 02/16] Extract _resolve_name_to_build_os helper from populate_build_os MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the inline name→BuildOS resolution logic from the field validator into a module-level function so Task 3 can reuse it without duplication. No behavior change; all existing tests pass. --- .../posit_bakery/config/image/version_os.py | 69 +++++++++---------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/posit-bakery/posit_bakery/config/image/version_os.py b/posit-bakery/posit_bakery/config/image/version_os.py index 9e4d63d08..7646baf8e 100644 --- a/posit-bakery/posit_bakery/config/image/version_os.py +++ b/posit-bakery/posit_bakery/config/image/version_os.py @@ -11,6 +11,35 @@ 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() + + 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: + 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.""" @@ -83,49 +112,13 @@ def __eq__(self, other): @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]: From 9fc522163849fe98e631d99f476bd4a471d481f5 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Mon, 8 Jun 2026 15:51:54 -0500 Subject: [PATCH 03/16] Add artifactOs field and artifact_build_os property to ImageVersionOS Adds an optional `artifactOs` field to `ImageVersionOS` for dev versions that use a scratch OS but need to resolve artifact download URLs against a real OS (e.g. ubuntu-24.04). The `artifact_build_os` property returns the resolved `BuildOS` for `artifactOs` when set, falling back to `buildOS` otherwise. Also fixes trailing-hyphen stripping in `_resolve_name_to_build_os` so that hyphenated OS names like "ubuntu-24.04" resolve correctly, and restores the comment explaining int conversion before max() for numeric ordering. --- .../posit_bakery/config/image/version_os.py | 27 ++++++++++++++++++- .../test/config/image/test_version_os.py | 26 ++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/posit-bakery/posit_bakery/config/image/version_os.py b/posit-bakery/posit_bakery/config/image/version_os.py index 7646baf8e..81fdec33d 100644 --- a/posit-bakery/posit_bakery/config/image/version_os.py +++ b/posit-bakery/posit_bakery/config/image/version_os.py @@ -21,7 +21,7 @@ def _resolve_name_to_build_os(name: str) -> BuildOS: 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() + match_dict["name"] = match_dict.get("name", "").strip().rstrip("-") if match_dict["name"] in ALTERNATE_NAMES: match_dict["name"] = ALTERNATE_NAMES[match_dict["name"]] @@ -31,6 +31,7 @@ def _resolve_name_to_build_os(name: str) -> BuildOS: 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: @@ -95,6 +96,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.""" @@ -109,6 +118,16 @@ 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: @@ -124,3 +143,9 @@ def populate_build_os(cls, value: BuildOS, info: ValidationInfo) -> BuildOS: 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/test/config/image/test_version_os.py b/posit-bakery/test/config/image/test_version_os.py index 310eabfce..390720059 100644 --- a/posit-bakery/test/config/image/test_version_os.py +++ b/posit-bakery/test/config/image/test_version_os.py @@ -168,3 +168,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"] From 14a7b2a2bcd2475cf149e411036207e07076f9d5 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Mon, 8 Jun 2026 16:01:14 -0500 Subject: [PATCH 04/16] Fix test_create_version for empty-extension OS The assertion `Containerfile.{previous_os.extension}` produced `Containerfile.` when scratch OS has an empty extension field. Fall back to the lowercased OS name when extension is empty, matching the actual rendered filename (`Containerfile.scratch`). --- posit-bakery/test/config/test_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/posit-bakery/test/config/test_config.py b/posit-bakery/test/config/test_config.py index d31a7d8b2..55f6a3dd4 100644 --- a/posit-bakery/test/config/test_config.py +++ b/posit-bakery/test/config/test_config.py @@ -858,7 +858,9 @@ 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_suffix = previous_os.extension or previous_os.name.lower().strip() + containerfile_name = f"Containerfile.{containerfile_suffix}" if containerfile_suffix else "Containerfile" + assert (context / image.name / new_version / containerfile_name).is_file() expected_containerfile = textwrap.dedent("""\ FROM scratch From 2d1abed2720313d3f202ba0bf3dd8002d763f428 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Mon, 8 Jun 2026 16:03:40 -0500 Subject: [PATCH 05/16] Add OSValidatorMixin with five OS validators Consolidates the four duplicated OS validators from version.py, dev_version/base.py, and matrix.py into a single mixin with generic messages. Adds a fifth validator, error_untaggable_os, which must run after make_single_os_primary (declaration order enforces this) to catch non-primary OSes with an empty tagDisplayName. --- .../config/image/os_validators.py | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 posit-bakery/posit_bakery/config/image/os_validators.py 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..df08d856b --- /dev/null +++ b/posit-bakery/posit_bakery/config/image/os_validators.py @@ -0,0 +1,117 @@ +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")): + 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: + if os.count(unique_os) > 1: + 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")): + 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. + @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 From 1b1cb66b2d82370916d213a00c4f93ffb5ba84b1 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Mon, 8 Jun 2026 16:15:08 -0500 Subject: [PATCH 06/16] Replace duplicated OS validators with OSValidatorMixin Wire OSValidatorMixin into ImageVersion, BaseImageDevelopmentVersion, and ImageMatrix, removing the four duplicate @field_validator("os") methods from each class body. - Add sourceType to the mixin's name-guard so dev version classes (which have no name/namePattern field) still trigger warnings and errors correctly - Update all test message assertions to match the mixin's generic messages ("image configuration" instead of class-specific strings) - Add three new tests for error_untaggable_os behaviour on scratch OS --- .../config/image/dev_version/base.py | 82 +---------------- .../posit_bakery/config/image/matrix.py | 88 +------------------ .../config/image/os_validators.py | 7 +- .../posit_bakery/config/image/version.py | 84 +----------------- .../config/image/dev_version/test_channel.py | 12 +-- posit-bakery/test/config/image/test_matrix.py | 12 ++- .../test/config/image/test_version.py | 38 ++++++-- 7 files changed, 54 insertions(+), 269 deletions(-) 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/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 index df08d856b..130312bb4 100644 --- a/posit-bakery/posit_bakery/config/image/os_validators.py +++ b/posit-bakery/posit_bakery/config/image/os_validators.py @@ -19,7 +19,7 @@ def check_os_not_empty(cls, os: list[ImageVersionOS], info: ValidationInfo) -> l :return: The unmodified list of ImageVersionOS objects. """ - if not (info.data.get("name") or info.data.get("namePattern")): + if not (info.data.get("name") or info.data.get("namePattern") or info.data.get("sourceType")): return os if not os: log.warning( @@ -74,7 +74,7 @@ def max_one_primary_os(cls, os: list[ImageVersionOS], info: ValidationInfo) -> l :raises ValueError: If more than one OS is marked as primary. """ - if not (info.data.get("name") or info.data.get("namePattern")): + 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: @@ -92,6 +92,9 @@ def max_one_primary_os(cls, os: list[ImageVersionOS], info: ValidationInfo) -> l # 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]: 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/test/config/image/dev_version/test_channel.py b/posit-bakery/test/config/image/dev_version/test_channel.py index b39b5e8f5..4eeff6912 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): 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..8479fbbe3 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,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 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_make_single_os_primary(self, caplog): """Test that if only one OS is defined, it is automatically made primary.""" @@ -124,7 +124,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 +139,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 +712,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 From b2fb772e7c5f31ffc036f012d91446636127822e Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Mon, 8 Jun 2026 16:27:18 -0500 Subject: [PATCH 07/16] Fix AttributeError in build() when image_os is None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build() called self.image_os.platforms directly at the docker.build call site, crashing with AttributeError when image_os is None. build_platforms was already computed safely with a null guard two lines earlier — use it consistently. Adds two tests: - scratch OS (empty extension) + no variant produces a plain Containerfile - build() uses DEFAULT_PLATFORMS instead of crashing when image_os is None --- .../posit_bakery/image/image_target.py | 2 +- posit-bakery/test/image/test_image_target.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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/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 = [ From 0d21a2ad03374859259bfa4e5b68e640a52167de Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 9 Jun 2026 08:59:44 -0500 Subject: [PATCH 08/16] Use artifact_build_os in channel.py for scratch OS support Replace the three buildOS call sites in get_version(), get_url_by_os(), and _resolve_os_urls() with artifact_build_os. This ensures scratch OS entries (which have family=UNKNOWN) delegate artifact resolution to their configured artifactOs instead of failing. --- .../config/image/dev_version/channel.py | 6 +-- .../config/image/dev_version/test_channel.py | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) 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/test/config/image/dev_version/test_channel.py b/posit-bakery/test/config/image/dev_version/test_channel.py index 4eeff6912..6a4827627 100644 --- a/posit-bakery/test/config/image/dev_version/test_channel.py +++ b/posit-bakery/test/config/image/dev_version/test_channel.py @@ -1033,6 +1033,48 @@ 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 + + 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"}], + ) + version._resolve_os_urls() + + for call in mock_get.call_args_list: + assert call.args[2] == SUPPORTED_OS["ubuntu"]["24"] + + 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: From 15d1ce1646650f7eca77c9a85fe4562b70b2cc42 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 11 Jun 2026 14:44:06 -0500 Subject: [PATCH 09/16] Fix test fixtures for scratch OS empty extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scratch OS now uses empty extension/tagDisplayName, producing a bare Containerfile (no suffix) and OS-unsuffixed tags (only version and :latest, no :scratch or :1.0.0-scratch). - Rename Containerfile.scratch → Containerfile and Containerfile.scratch.jinja2 → Containerfile.jinja2 in the barebones fixture - Update test_config.py assertions to use bare Containerfile instead of Containerfile.scratch - Drop OS-suffix tags from get/tags/barebones expected JSON - Add multiversion context and default matrix testdata --- .../ci/matrix/multiversion/default.json | 1 + .../testdata/get/tags/barebones/default.json | 4 +--- .../cli/testdata/get/tags/barebones/uid.json | 4 +--- posit-bakery/test/config/test_config.py | 14 +++++------ .../test/features/cli/ci/matrix.feature | 7 ++++++ .../test/resources/barebones/bakery.yaml | 1 + .../{Containerfile.scratch => Containerfile} | 0 ...le.scratch.jinja2 => Containerfile.jinja2} | 0 .../test/resources/multiversion/bakery.yaml | 23 +++++++++++++++++++ 9 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 posit-bakery/test/cli/testdata/ci/matrix/multiversion/default.json rename posit-bakery/test/resources/barebones/scratch/1.0.0/{Containerfile.scratch => Containerfile} (100%) rename posit-bakery/test/resources/barebones/scratch/template/{Containerfile.scratch.jinja2 => Containerfile.jinja2} (100%) create mode 100644 posit-bakery/test/resources/multiversion/bakery.yaml 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/test_config.py b/posit-bakery/test/config/test_config.py index 55f6a3dd4..bee1a8e49 100644 --- a/posit-bakery/test/config/test_config.py +++ b/posit-bakery/test/config/test_config.py @@ -858,16 +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() - containerfile_suffix = previous_os.extension or previous_os.name.lower().strip() - containerfile_name = f"Containerfile.{containerfile_suffix}" if containerfile_suffix else "Containerfile" + 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() @@ -897,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.""" @@ -930,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/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/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 From e152a9d90dbfe40c1feed8cb0a8e2670da4520a3 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 11 Jun 2026 14:44:22 -0500 Subject: [PATCH 10/16] Fix bake plan fixtures for scratch OS empty extension Commit 456788a2 updated the barebones bakery.yaml to use empty extension for Scratch OS and renamed the Containerfile, but the bake plan JSON fixtures still referenced Containerfile.scratch and included OS-suffix tags (1.0.0-scratch, scratch). Update the three bake testdata fixtures to match the new Scratch OS behavior. --- .../bake/testdata/cache_registry/barebones_plan.json | 10 ++++------ .../image/bake/testdata/default/barebones_plan.json | 6 ++---- .../bake/testdata/temp_registry/barebones_plan.json | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) 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", From 1510faebf720ae23fd78f1145b7202eb9dcaac5c Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 9 Jun 2026 10:10:55 -0500 Subject: [PATCH 11/16] Fix remaining test review findings - test_dgoss_environment: use full dict equality after delenv(GITHUB_ACTIONS) so extra keys (e.g. GH_TOKEN leaking in CI) cannot go undetected - test_resolve_os_urls_uses_artifact_os_for_scratch: capture _resolve_os_urls() return value and assert artifactDownloadURL was set, not just that the mock was called with the right BuildOS - Rename fail-fast Containerfile.scratch.{min,std} to Containerfile.{min,std} so bakery finds them now that Scratch OS defaults to empty extension; fixes test_building_images_from_a_project_using_sequential_build_with_failfast failing with BakeryFileError under just test-all --- .../test/config/image/dev_version/test_channel.py | 7 +++++-- posit-bakery/test/config/image/test_version_os.py | 12 +++++------- .../test/plugins/builtin/dgoss/test_command.py | 8 +++----- .../{Containerfile.scratch.min => Containerfile.min} | 0 .../{Containerfile.scratch.std => Containerfile.std} | 0 5 files changed, 13 insertions(+), 14 deletions(-) rename posit-bakery/test/resources/fail-fast/scratch/1.0.0/{Containerfile.scratch.min => Containerfile.min} (100%) rename posit-bakery/test/resources/fail-fast/scratch/1.0.0/{Containerfile.scratch.std => Containerfile.std} (100%) 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 6a4827627..48f73db77 100644 --- a/posit-bakery/test/config/image/dev_version/test_channel.py +++ b/posit-bakery/test/config/image/dev_version/test_channel.py @@ -1038,10 +1038,11 @@ 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="https://cdn.posit.co/connect/daily/ubuntu2404/amd64/rstudio-connect_2026.05.0_amd64.deb", + download_url=expected_url, ) version = ImageDevelopmentVersionFromProductChannel( sourceType="stream", @@ -1049,10 +1050,12 @@ def test_resolve_os_urls_uses_artifact_os_for_scratch(self): channel="daily", os=[{"name": "scratch", "artifactOs": "ubuntu-24.04"}], ) - version._resolve_os_urls() + 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 diff --git a/posit-bakery/test/config/image/test_version_os.py b/posit-bakery/test/config/image/test_version_os.py index 390720059..cdb21fafe 100644 --- a/posit-bakery/test/config/image/test_version_os.py +++ b/posit-bakery/test/config/image/test_version_os.py @@ -122,13 +122,9 @@ def test_equality(self): assert os1 != os3 assert os2 != os3 - def test_scratch_derives_empty_extension_and_tag(self): - i = ImageVersionOS(name="scratch") - assert i.extension == "" - assert i.tagDisplayName == "" - - def test_scratch_case_insensitive(self): - i = ImageVersionOS(name="Scratch") + @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 == "" @@ -161,6 +157,8 @@ def test_empty_extension_accepted_explicitly(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): 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/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 From 882419cbed83193c667bc14c1fac0b0f0a865af4 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 9 Jun 2026 10:52:41 -0500 Subject: [PATCH 12/16] Fix DeprecationWarning in regexReplace filter test Python 3.14 raises DeprecationWarning for invalid escape sequences processed through Jinja2's unicode-escape codec. The string literal '\d' inside the Jinja2 template triggers this; '\\d' makes Jinja2 decode it to \d correctly without the warning. --- posit-bakery/test/config/templating/test_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(): From a46ea62a57e529477bda9a483f3132c20121dd9f Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 9 Jun 2026 12:47:54 -0500 Subject: [PATCH 13/16] Scope scratch OS empty-field defaults to ImageVersionOS Move _OSLESS_NAMES and the scratch-aware default_factory out of shared.py into version_os.py. The shared ExtensionField/ TagDisplayNameField are used by ImageVariant too; keeping the scratch check there would silently produce empty extension/ tagDisplayName for any variant named "scratch", risking tag and Containerfile collisions. - shared.py: restore original default_factory and + patterns - version_os.py: own _OSLESS_NAMES and * patterns locally --- .../posit_bakery/config/image/version_os.py | 23 ++++++++++++++++--- posit-bakery/posit_bakery/config/shared.py | 18 ++++----------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/posit-bakery/posit_bakery/config/image/version_os.py b/posit-bakery/posit_bakery/config/image/version_os.py index 81fdec33d..fbcf8e084 100644 --- a/posit-bakery/posit_bakery/config/image/version_os.py +++ b/posit-bakery/posit_bakery/config/image/version_os.py @@ -5,10 +5,13 @@ 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 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.]*)") @@ -63,16 +66,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=r"^[a-zA-Z0-9_-]*$", + 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=r"^[a-zA-Z0-9_.-]*$", + 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"], diff --git a/posit-bakery/posit_bakery/config/shared.py b/posit-bakery/posit_bakery/config/shared.py index a2e506831..79eac2ed2 100644 --- a/posit-bakery/posit_bakery/config/shared.py +++ b/posit-bakery/posit_bakery/config/shared.py @@ -10,18 +10,12 @@ from posit_bakery.const import REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN -_OSLESS_NAMES = frozenset({"scratch"}) - # Shared field configuration for file extensions. ExtensionField = Annotated[ 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=r"^[a-zA-Z0-9_-]*$", + default_factory=lambda data: re.sub(r"[^a-zA-Z0-9_-]", "", data.get("name", "").lower()), + pattern=r"^[a-zA-Z0-9_-]+$", validate_default=True, ), ] @@ -31,12 +25,10 @@ TagDisplayNameField = Annotated[ 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()) + default_factory=lambda data: re.sub( + REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN, "-", data.get("name", "").lower() ), - pattern=r"^[a-zA-Z0-9_.-]*$", + pattern=r"^[a-zA-Z0-9_.-]+$", validate_default=True, ), ] From e3d871d183c91ddf8b9b11b131d1f3076a44ad6c Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 11 Jun 2026 14:50:53 -0500 Subject: [PATCH 14/16] Extract OS field regex patterns to named constants Lines 76 and 91 of version_os.py had inline pattern strings for extension and tagDisplayName validation. Extract them to named constants in const.py alongside the existing tag-suffix pattern. --- posit-bakery/posit_bakery/config/image/version_os.py | 10 +++++++--- posit-bakery/posit_bakery/const.py | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/posit-bakery/posit_bakery/config/image/version_os.py b/posit-bakery/posit_bakery/config/image/version_os.py index fbcf8e084..e2894879e 100644 --- a/posit-bakery/posit_bakery/config/image/version_os.py +++ b/posit-bakery/posit_bakery/config/image/version_os.py @@ -6,7 +6,11 @@ from pydantic_core.core_schema import ValidationInfo from posit_bakery.config.shared import BakeryYAMLModel -from posit_bakery.const import REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN +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 @@ -73,7 +77,7 @@ class ImageVersionOS(BakeryYAMLModel): if data.get("name", "").lower().strip() in _OSLESS_NAMES else re.sub(r"[^a-zA-Z0-9_-]", "", data.get("name", "").lower()) ), - pattern=r"^[a-zA-Z0-9_-]*$", + 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.", @@ -88,7 +92,7 @@ class ImageVersionOS(BakeryYAMLModel): if data.get("name", "").lower().strip() in _OSLESS_NAMES else re.sub(REGEX_IMAGE_TAG_SUFFIX_ALLOWED_CHARACTERS_PATTERN, "-", data.get("name", "").lower()) ), - pattern=r"^[a-zA-Z0-9_.-]*$", + 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 :--.", diff --git a/posit-bakery/posit_bakery/const.py b/posit-bakery/posit_bakery/const.py index d8a3bd098..2e8a4fe74 100644 --- a/posit-bakery/posit_bakery/const.py +++ b/posit-bakery/posit_bakery/const.py @@ -24,6 +24,8 @@ class GetTagsOutputFormat(str, Enum): UID = "uid" +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})|)$" From b69e4ecad9ed17233acb99b59f991eb199a74654 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 11 Jun 2026 14:51:58 -0500 Subject: [PATCH 15/16] Add comment explaining difference between OS regex constants --- posit-bakery/posit_bakery/const.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/posit-bakery/posit_bakery/const.py b/posit-bakery/posit_bakery/const.py index 2e8a4fe74..81320d9aa 100644 --- a/posit-bakery/posit_bakery/const.py +++ b/posit-bakery/posit_bakery/const.py @@ -24,6 +24,8 @@ 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 = ( From 094db411fd629d23c0ec200f9129f3341c76e176 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 11 Jun 2026 15:07:23 -0500 Subject: [PATCH 16/16] Raise on conflicting artifactOs in duplicate OS entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate OS entries that share the same name/extension/tagDisplayName but have different artifactOs values represent a config conflict — the set-based deduplication in deduplicate_os would silently drop one, producing incorrect artifact URL resolution. Detect this case and raise a ValueError instead of collapsing silently. --- .../posit_bakery/config/image/os_validators.py | 10 +++++++++- posit-bakery/test/config/image/test_version.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/posit-bakery/posit_bakery/config/image/os_validators.py b/posit-bakery/posit_bakery/config/image/os_validators.py index 130312bb4..09f16ccf9 100644 --- a/posit-bakery/posit_bakery/config/image/os_validators.py +++ b/posit-bakery/posit_bakery/config/image/os_validators.py @@ -40,7 +40,15 @@ def deduplicate_os(cls, os: list[ImageVersionOS], info: ValidationInfo) -> list[ """ unique_oses = set(os) for unique_os in unique_oses: - if os.count(unique_os) > 1: + 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) diff --git a/posit-bakery/test/config/image/test_version.py b/posit-bakery/test/config/image/test_version.py index 8479fbbe3..ba5e0a55b 100644 --- a/posit-bakery/test/config/image/test_version.py +++ b/posit-bakery/test/config/image/test_version.py @@ -112,6 +112,20 @@ def test_deduplicate_os(self, caplog): assert "WARNING" 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.""" i = ImageVersion(name="1.0.0", os=[{"name": "Ubuntu 22.04"}])