diff --git a/.github/workflows/build_images.yaml b/.github/workflows/build_images.yaml index 73f1953e..90145033 100644 --- a/.github/workflows/build_images.yaml +++ b/.github/workflows/build_images.yaml @@ -18,6 +18,10 @@ on: type: string description: Flavor name/s | one (alone), several (separated by commas) default: "*" + platforms: + type: string + description: Platforms to build for, formatted as os/arch (e.g. linux/amd64). Can be a single platform, a comma-separated list, or * for all + default: "*" secrets: type: string description: Secrets to pass to the image @@ -34,6 +38,10 @@ on: type: string description: Git reference from where to pull the workflows_repo default: v2 + runs_on: + type: string + description: Runner labels to run the job on, formatted as a JSON array (e.g. '["ubuntu-24.04"]' or '["self-hosted", "linux"]') + default: '' secrets: FS_CHECKS_PEM_FILE: required: false @@ -55,7 +63,7 @@ jobs: build-images: env: GH_TOKEN: ${{ secrets.GITHUB_DOCKER_REGISTRY_CREDS || github.token }} - runs-on: ${{ fromJSON(vars.BUILD_DOCKER_IMAGES_RUNS_ON || '["ubuntu-24.04"]') }} + runs-on: ${{ fromJSON(inputs.runs_on || '["ubuntu-24.04"]') }} steps: - name: Configure Azure Credentials uses: azure/login@v2 @@ -115,6 +123,7 @@ jobs: vars: | repo_name="${{ github.repository }}" flavors="${{ inputs.flavors }}" + platforms="${{ inputs.platforms }}" auth_strategy="${{ inputs.auth_strategy }}" snapshots_registry="${{ vars.DOCKER_REGISTRY_SNAPSHOTS }}" releases_registry="${{ vars.DOCKER_REGISTRY_RELEASES }}" diff --git a/firestarter/tests/test_build_images_functionality.py b/firestarter/tests/test_build_images_functionality.py index 4ba9a975..4f9aeb54 100644 --- a/firestarter/tests/test_build_images_functionality.py +++ b/firestarter/tests/test_build_images_functionality.py @@ -10,6 +10,7 @@ import subprocess from mock_classes import DaggerContextMock, DaggerImageMock import os +import re import docker import json import requests @@ -25,6 +26,7 @@ "releases_registry": "xxxx.azurecr.io", "snapshots_registry_creds": "test_snapshots_creds", "releases_registry_creds": "test_releases_creds", + "platforms": "linux/amd64,linux/arm64", } secrets = {} config_file_path = f"{os.path.dirname(os.path.realpath(__file__))}/fixtures/build_images.yaml" @@ -426,6 +428,59 @@ async def test_compile_images_for_all_flavors(mocker) -> None: assert result[7]["repository"] == "repo3" assert result[7]["image_tag"] == "flavor3-custom-tag" +@pytest.mark.asyncio +async def test_compile_images_filtering_by_platform(mocker) -> None: + mocker.patch("dagger.Config") + mocker.patch("dagger.Connection") + mocker.patch.object(builder, "compile_image_and_publish") + + dagger_config_mock = dagger.Config + dagger_connection_mock = dagger.Connection + + builder._flavors = "flavor1, flavor3" + builder._platforms = builder.validate_platforms("linux/amd64") + builder.filter_flavors() + + result = await builder.compile_images_for_all_flavors() + built_flavors = set([image["flavor"] for image in result]) + assert built_flavors == {"flavor1", "flavor3"} + + builder._platforms = builder.validate_platforms("arm64") + + result = await builder.compile_images_for_all_flavors() + built_flavors = set([image["flavor"] for image in result]) + assert built_flavors == {"flavor3"} + + builder._platforms = builder.validate_platforms("*") + + result = await builder.compile_images_for_all_flavors() + built_flavors = set([image["flavor"] for image in result]) + assert built_flavors == {"flavor1", "flavor3"} + +def test_validate_platforms() -> None: + all_valid_platforms = "linux/amd64 , linux/arm64,arm64 , amd64" + returned_platforms = builder.validate_platforms(all_valid_platforms) + assert returned_platforms == all_valid_platforms.replace(" ", "") + + some_valid_platforms = " linux/amd64, arm64 " + returned_platforms = builder.validate_platforms(some_valid_platforms) + assert returned_platforms == some_valid_platforms.replace(" ", "") + + invalid_platforms = "platform_doesnt_exist, test" + with pytest.raises(ValueError, match=re.escape("Invalid platform(s): platform_doesnt_exist, test.")): + builder.validate_platforms(invalid_platforms) + + no_platforms = ",,,," + with pytest.raises(ValueError, match=re.escape("Invalid platform(s):")): + builder.validate_platforms(no_platforms) + +def test_check_if_build_all_platforms(): + builder._platforms = '*' + assert builder.check_if_build_all_platforms() == True + + builder._platforms = 'linux/amd64' + assert builder.check_if_build_all_platforms() == False + # The object correctly returns the flavor data of a chosen flavor, # as written in fixtures/build_images.yaml def test_get_flavor_data() -> None: diff --git a/firestarter/workflows/build_images/build_images.py b/firestarter/workflows/build_images/build_images.py index fc9078a5..eaecd753 100644 --- a/firestarter/workflows/build_images/build_images.py +++ b/firestarter/workflows/build_images/build_images.py @@ -52,6 +52,9 @@ def __init__(self, **kwargs) -> None: self._workflow_run_url = self.vars.get('workflow_run_url', None) self._service_path = self.vars.get('service_path', '') self._flavors = self.vars.get('flavors', 'default') + self._platforms = self.validate_platforms( + self.vars.get('platforms', '*') + ) self._container_structure_filename = self.vars.get( 'container_structure_filename', None ) @@ -123,6 +126,10 @@ def workflow_run_url(self): def flavors(self): return self._flavors + @property + def platforms(self): + return self._platforms + @property def service_path(self): return self._service_path @@ -251,7 +258,6 @@ def filter_flavors(self): self._flavors = final_flavors_list - def filter_auto_build(self): logger.info( 'Publishing all flavors with auto build enabled:', @@ -359,6 +365,33 @@ async def compile_images_for_all_flavors(self): dockerfile, extra_registries,\ extra_tags, platforms = self.get_flavor_data(flavor) + platforms_to_build = [] + if self.check_if_build_all_platforms(): + platforms_to_build = platforms + else: + allowed_platforms = self.platforms.replace(' ', '').split(',') + normalized_allowed_platforms = { + p.replace('linux/', '') for p in allowed_platforms + } + + # Get the platforms to build for this flavor by checking the intersection + platforms_to_build = [ + platform for platform in platforms + if platform.replace('linux/', '') in normalized_allowed_platforms + ] + + logger.info( + f"Building flavor {flavor} for platforms: {platforms_to_build}" + ) + + if len(platforms_to_build) == 0: + print( + f"::warning title=BuildImages Warning::" + f"No matching platforms to build for flavor {flavor}. " + f"Skipping..." + ) + continue + # Set the build arguments for the current flavor build_args_list = [ dagger.BuildArg(name=key, value=value) @@ -424,7 +457,7 @@ async def compile_images_for_all_flavors(self): secrets, dockerfile, image, - platforms + platforms_to_build ) image_tag = image.split(":")[1] @@ -444,12 +477,43 @@ async def compile_images_for_all_flavors(self): "workflow_run_url": self.workflow_run_url }) + if len(results_list) == 0: + print( + f"::warning title=BuildImages Warning::" + f"No images were built. " + f"Please check the workflow filters are correct." + ) + yaml.default_flow_style = False with open(os.path.join("/tmp", self.output_results), "w") as f: yaml.dump(results_list, f) return results_list + def check_if_build_all_platforms(self): + return self.platforms.replace(' ', '') == '*' + + def validate_platforms(self, platforms): + trimmed_platforms = platforms.replace(' ', '') + if trimmed_platforms == '*': + return trimmed_platforms + + platform_list = trimmed_platforms.split(',') + + invalid_platforms = [] + for platform in platform_list: + if not re.match(r'^(linux/)?(amd64|arm64)$', platform): + invalid_platforms.append(platform) + + if invalid_platforms: + raise ValueError( + f"Invalid platform(s): {', '.join(invalid_platforms)}. " + "Valid platforms are: linux/amd64, linux/arm64, amd64, arm64, " + "or '*'. Whitespace around comma-separated entries is ignored." + ) + + return trimmed_platforms + def get_flavor_data(self, flavor): flavor_data = self.config.images[flavor]