Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 2 additions & 80 deletions posit-bakery/posit_bakery/config/image/dev_version/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.")]
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
)
Expand Down Expand Up @@ -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",
)
Expand Down
88 changes: 2 additions & 86 deletions posit-bakery/posit_bakery/config/image/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[
Expand Down Expand Up @@ -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(
Expand Down
128 changes: 128 additions & 0 deletions posit-bakery/posit_bakery/config/image/os_validators.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading