diff --git a/src/image/utils/schema/triggers.py b/src/image/utils/schema/triggers.py index 408f698f8..9b9fb0b8a 100644 --- a/src/image/utils/schema/triggers.py +++ b/src/image/utils/schema/triggers.py @@ -1,7 +1,8 @@ import pydantic from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Dict, List, Literal, Optional, Any +from typing_extensions import Annotated LATEST_SCHEMA_VERSION = 1 @@ -20,10 +21,18 @@ class ImageUploadReleaseSchema(pydantic.BaseModel): """Schema of the release option for uploads in the image.yaml trigger""" end_of_life: datetime = pydantic.Field(alias="end-of-life") - risks: List[Literal["edge", "beta", "candidate", "stable"]] + risks: List[Literal[*KNOWN_RISKS_ORDERED]] model_config = pydantic.ConfigDict(extra="forbid") + @pydantic.field_validator("risks", mode="after") + def _ensure_non_empty_risks(cls, value): + if not value: + raise ImageTriggerValidationError( + "At least one upload risk must be present." + ) + return value + class ImageUploadSchema(pydantic.BaseModel): """Schema of each upload within the image.yaml files.""" @@ -31,30 +40,27 @@ class ImageUploadSchema(pydantic.BaseModel): source: str commit: str directory: str - release: Optional[Dict[str, ImageUploadReleaseSchema]] + release: Optional[Dict[str, ImageUploadReleaseSchema]] = None model_config = pydantic.ConfigDict(extra="forbid") - class ChannelsSchema(pydantic.BaseModel): """Schema of the 'release' tracks within the image.yaml file.""" end_of_life: datetime = pydantic.Field(alias="end-of-life") - stable: Optional[str] - candidate: Optional[str] - beta: Optional[str] - edge: Optional[str] + stable: str = None + candidate: str = None + beta: str = None + edge: str = None model_config = pydantic.ConfigDict(extra="forbid") - # TODO: Do we need the pre keyword? Causes the validator to be - # called prior to other validation. - @pydantic.field_validator("stable", "candidate", "beta", "edge", mode="before") - def _check_risks(cls, values: List) -> List: + @pydantic.model_validator(mode="after") + def _check_risks(self, values: List) -> List: """There must be at least one risk specified.""" error = "At least one risk must be specified per track." - if not any(values): + if not any([self.stable, self.candidate, self.beta, self.edge]): raise ImageTriggerValidationError(error) return values @@ -64,13 +70,18 @@ class ImageSchema(pydantic.BaseModel): """Validates the schema of the image.yaml files.""" version: str - upload: Optional[List[ImageUploadSchema]] - release: Optional[Dict[str, ChannelsSchema]] + upload: Optional[List[ImageUploadSchema]] = None + release: Optional[Dict[str, ChannelsSchema]] = None model_config = pydantic.ConfigDict(extra="forbid") + @pydantic.field_validator("version", mode="before") + def _ensure_version_is_str(cls, value: Any): + """Ensure that version is always cast to str.""" + return str(value) + @pydantic.field_validator("upload") - def ensure_unique_triggers( + def _ensure_unique_triggers( cls, v: Optional[List[ImageUploadSchema]] ) -> Optional[List[ImageUploadSchema]]: """Ensure that the triggers are unique.""" diff --git a/tests/unit/test_image_trigger_file_validator.py b/tests/unit/test_image_trigger_file_validator.py new file mode 100644 index 000000000..e1e6615d9 --- /dev/null +++ b/tests/unit/test_image_trigger_file_validator.py @@ -0,0 +1,84 @@ +from pathlib import Path +import pytest + +import src.image.prepare_single_image_build_matrix as prep_matrix +from src.image.utils.schema.triggers import ImageTriggerValidationError +from glob import glob +from pathlib import Path + + +def test_existing_image_trigger_files(): + + for oci_path in glob("oci/*"): + prep_matrix.load_trigger_yaml(Path(oci_path)) + + +def test_image_trigger_validator_missing_channel_risks(): + + image_trigger = { + "version": 1, + "release": { + "latest": { + "end-of-life": "2030-05-01T00:00:00Z", + }, + }, + "upload": [], + } + with pytest.raises(ImageTriggerValidationError): + prep_matrix.validate_image_trigger(image_trigger) + + +def test_image_trigger_validator_missing_release_risks(): + + image_trigger = { + "version": 1, + "release": { + "latest": { + "end-of-life": "2030-05-01T00:00:00Z", + "candidate": "1.0-22.04_candidate", + }, + }, + "upload": [ + { + "source": "canonical/rocks-toolbox", + "commit": "17916dd5de270e61a6a3fd3f4661a6413a50fd6f", + "directory": "mock_rock/1.2", + "release": { + "1.2-22.04": { + "end-of-life": "2030-05-01T00:00:00Z", + "risks": [], + } + }, + }, + ], + } + with pytest.raises(ImageTriggerValidationError): + prep_matrix.validate_image_trigger(image_trigger) + + +def test_image_trigger_validator_minimal_input(): + + image_trigger = { + "version": 1, + "release": { + "latest": { + "end-of-life": "2030-05-01T00:00:00Z", + "candidate": "1.0-22.04_candidate", + }, + }, + "upload": [ + { + "source": "canonical/rocks-toolbox", + "commit": "17916dd5de270e61a6a3fd3f4661a6413a50fd6f", + "directory": "mock_rock/1.2", + "release": { + "1.2-22.04": { + "end-of-life": "2030-05-01T00:00:00Z", + "risks": ["beta"], + } + }, + }, + ], + } + + prep_matrix.validate_image_trigger(image_trigger) diff --git a/tests/unit/test_prepare_single_image_build_matrix.py b/tests/unit/test_prepare_single_image_build_matrix.py index a4874205d..b3c62eb8c 100644 --- a/tests/unit/test_prepare_single_image_build_matrix.py +++ b/tests/unit/test_prepare_single_image_build_matrix.py @@ -2,6 +2,10 @@ import pytest import src.image.prepare_single_image_build_matrix as prep_matrix +from src.image.utils.schema.triggers import ImageTriggerValidationError +import yaml +from glob import glob +from pathlib import Path def test_is_track_eol():