diff --git a/plugins/module_utils/podman/podman_image_lib.py b/plugins/module_utils/podman/podman_image_lib.py index d149ca8..376df20 100644 --- a/plugins/module_utils/podman/podman_image_lib.py +++ b/plugins/module_utils/podman/podman_image_lib.py @@ -136,9 +136,9 @@ class PodmanImageBuilder: self.executable = executable self.auth_config = auth_config or {} - def build_image(self, image_name, build_config, path=None, containerfile_hash=None): + def build_image(self, image_name, build_config, path=None, containerfile_hash=None, platform=None): """Build an image with the given configuration.""" - args = self._construct_build_args(image_name, build_config, path, containerfile_hash) + args = self._construct_build_args(image_name, build_config, path, containerfile_hash, platform) # Handle inline container file temp_file_path = None @@ -162,13 +162,17 @@ class PodmanImageBuilder: if temp_file_path and os.path.exists(temp_file_path): os.remove(temp_file_path) - def _construct_build_args(self, image_name, build_config, path, containerfile_hash): + def _construct_build_args(self, image_name, build_config, path, containerfile_hash, platform=None): """Construct build command arguments.""" args = ["build", "-t", image_name] # Add authentication self._add_auth_args(args) + # Add platform for cross-platform builds + if platform: + args.extend(["--platform", platform]) + # Add build-specific arguments if build_config.get("force_rm"): args.append("--force-rm") @@ -268,11 +272,13 @@ class PodmanImagePuller: self.executable = executable self.auth_config = auth_config or {} - def pull_image(self, image_name, arch=None, pull_extra_args=None): + def pull_image(self, image_name, arch=None, platform=None, pull_extra_args=None): """Pull an image from a registry.""" args = ["pull", image_name] - if arch: + if platform: + args.extend(["--platform", platform]) + elif arch: args.extend(["--arch", arch]) self._add_auth_args(args) @@ -533,10 +539,28 @@ class PodmanImageManager: # Check architecture if specified arch = self.params.get("arch") + platform = self.params.get("platform") if arch: inspect_data = self.inspector.inspect_image(image_name) if inspect_data and inspect_data[0].get("Architecture") != arch: return None + elif platform: + # Platform format: os/arch or os/arch/variant (e.g. linux/amd64) + inspect_data = self.inspector.inspect_image(image_name) + if inspect_data: + platform_parts = platform.split("/") + required_os = platform_parts[0] if len(platform_parts) >= 1 else None + required_arch = platform_parts[1] if len(platform_parts) >= 2 else None + img_os = inspect_data[0].get("Os") or inspect_data[0].get("os") + img_arch = inspect_data[0].get("Architecture") or inspect_data[0].get("architecture") + # Normalize arch for comparison (e.g. x86_64 -> amd64, aarch64 -> arm64) + arch_map = {"x86_64": "amd64", "aarch64": "arm64"} + if img_arch and img_arch in arch_map: + img_arch = arch_map[img_arch] + if required_os and img_os != required_os: + return None + if required_arch and img_arch != required_arch: + return None return images @@ -618,7 +642,8 @@ class PodmanImageManager: # Build the image if not self.module.check_mode: image_id, output, podman_command = self.builder.build_image( - self.repository.full_name, build_config, path, containerfile_hash + self.repository.full_name, build_config, path, containerfile_hash, + self.params.get("platform") ) self.results["stdout"] = output self.results["image"] = self.inspector.inspect_image(image_id) @@ -634,7 +659,10 @@ class PodmanImageManager: if not self.module.check_mode: unused, podman_command = self.puller.pull_image( - self.repository.full_name, self.params.get("arch"), self.params.get("pull_extra_args") + self.repository.full_name, + self.params.get("arch"), + self.params.get("platform"), + self.params.get("pull_extra_args"), ) self.results["image"] = self.inspector.inspect_image(self.repository.full_name) self.results["podman_actions"].append(podman_command) diff --git a/plugins/modules/podman_image.py b/plugins/modules/podman_image.py index e7dfe4d..b084951 100644 --- a/plugins/modules/podman_image.py +++ b/plugins/modules/podman_image.py @@ -19,6 +19,12 @@ DOCUMENTATION = r""" description: - CPU architecture for the container image type: str + platform: + description: + - Platform for the container image (e.g. C(linux/amd64), C(linux/arm64)). + - Specify the platform for selecting the image when pulling or building. + - Mutually exclusive with C(arch). + type: str name: description: - Name of the image to pull, push, or delete. It may contain a tag using the format C(image:tag). @@ -348,6 +354,11 @@ EXAMPLES = r""" name: nginx arch: amd64 +- name: Pull an image for a specific platform (e.g. x86 on M-series Mac) + containers.podman.podman_image: + name: nginx + platform: linux/amd64 + - name: Build a container from file inline containers.podman.podman_image: name: mycustom_image @@ -456,6 +467,7 @@ def main(): argument_spec=dict( name=dict(type="str", required=True), arch=dict(type="str"), + platform=dict(type="str"), tag=dict(type="str", default="latest"), pull=dict(type="bool", default=True), pull_extra_args=dict(type="str"), @@ -528,6 +540,7 @@ def main(): mutually_exclusive=( ["auth_file", "username"], ["auth_file", "password"], + ["arch", "platform"], ), ) diff --git a/tests/integration/targets/podman_image/tasks/main.yml b/tests/integration/targets/podman_image/tasks/main.yml index 80668cb..90e9584 100644 --- a/tests/integration/targets/podman_image/tasks/main.yml +++ b/tests/integration/targets/podman_image/tasks/main.yml @@ -380,6 +380,26 @@ - item.Architecture == "arm" loop: "{{ imageinfo_arch.images }}" + - name: Pull an image for a specific platform + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: quay.io/coreos/etcd:v3.5.27 + platform: linux/amd64 + register: pull_platform1 + + - name: Pull the same image for the same platform + containers.podman.podman_image: + executable: "{{ test_executable | default('podman') }}" + name: quay.io/coreos/etcd:v3.5.27 + platform: linux/amd64 + register: pull_platform2 + + - name: Ensure platform pull is idempotent + assert: + that: + - pull_platform1 is changed + - pull_platform2 is not changed + - name: Build Docker image containers.podman.podman_image: executable: "{{ test_executable | default('podman') }}" @@ -599,9 +619,11 @@ state: absent loop: - docker.io/library/ubuntu + - docker.io/library/alpine - quay.io/sshnaidm1/alpine-sh - quay.io/coreos/etcd:v3.3.11 - quay.io/coreos/etcd:v3.5.19 + - quay.io/coreos/etcd:v3.5.27 - localhost/testimage - localhost/testimage2 - localhost/testimage2:testtag diff --git a/tests/unit/plugins/modules/test_podman_image.py b/tests/unit/plugins/modules/test_podman_image.py index cd3a6cb..757282b 100644 --- a/tests/unit/plugins/modules/test_podman_image.py +++ b/tests/unit/plugins/modules/test_podman_image.py @@ -48,6 +48,8 @@ class TestPodmanImageModule: ), # Valid authentication parameters ({"name": "alpine", "username": "testuser", "password": "testpass"}, True), + # Valid platform parameter (issue #1003) + ({"name": "alpine", "platform": "linux/amd64"}, True), ], ) def test_module_parameter_validation(self, test_params, expected_valid): @@ -138,19 +140,22 @@ class TestPodmanImageModule: mutually_exclusive_combinations = [ ({"auth_file": "/path/to/auth", "username": "user"}, True), ({"auth_file": "/path/to/auth", "password": "pass"}, True), + ({"arch": "amd64", "platform": "linux/amd64"}, True), # arch and platform ({"username": "user", "password": "pass"}, False), # This should be allowed ({"auth_file": "/path/to/auth"}, False), # This should be allowed + ({"platform": "linux/amd64"}, False), # platform alone is allowed ] for params, should_be_exclusive in mutually_exclusive_combinations: # This tests the logic of mutual exclusion has_auth_file = "auth_file" in params has_credentials = "username" in params or "password" in params + has_arch_and_platform = "arch" in params and "platform" in params if should_be_exclusive: - assert has_auth_file and has_credentials + assert (has_auth_file and has_credentials) or has_arch_and_platform else: - assert not (has_auth_file and has_credentials) or not has_auth_file + assert not (has_auth_file and has_credentials) and not has_arch_and_platform def test_required_together_logic(self): """Test that username and password are required together."""