diff --git a/changelogs/fragments/1064-docker-image-export-platform.yml b/changelogs/fragments/1064-docker-image-export-platform.yml new file mode 100644 index 00000000..3ff9378d --- /dev/null +++ b/changelogs/fragments/1064-docker-image-export-platform.yml @@ -0,0 +1,2 @@ +minor_changes: + - docker_image_export - adds ``platform`` parameter to allow exporting a specific platform variant from a multi-arch image (https://github.com/ansible-collections/community.docker/issues/1064, https://github.com/ansible-collections/community.docker/pull/1251). diff --git a/plugins/modules/docker_image_export.py b/plugins/modules/docker_image_export.py index b5cc10f3..9a3ddee5 100644 --- a/plugins/modules/docker_image_export.py +++ b/plugins/modules/docker_image_export.py @@ -31,9 +31,12 @@ attributes: details: - Whether the module is idempotent depends on the storage API used for images, which determines how the image ID is computed. The idempotency check needs - that the image ID equals the ID stored in archive's C(manifest.json). + the image ID to equal the ID stored in archive's C(manifest.json). This seemed to have worked fine with the default storage backend up to Docker 28, but seems to have changed in Docker 29. + - This module is B(not idempotent) when used with multi-architecture images, + regardless of Docker version. + - Full idempotency requires Docker 28 or earlier B(and) a single-architecture image. options: names: @@ -61,6 +64,13 @@ options: - Export the image even if the C(.tar) file already exists and seems to contain the right image. type: bool default: false + platform: + description: + - Ask for this specific platform when exporting. + - For example, C(linux/amd64), C(linux/arm64). + - Requires Docker API 1.48 or newer. + type: str + version_added: 5.2.0 requirements: - "Docker API >= 1.25" @@ -98,6 +108,7 @@ images: sample: [] """ +import json import traceback import typing as t @@ -119,6 +130,9 @@ from ansible_collections.community.docker.plugins.module_utils._image_archive im api_image_id, load_archived_image_manifest, ) +from ansible_collections.community.docker.plugins.module_utils._platform import ( + _Platform, +) from ansible_collections.community.docker.plugins.module_utils._util import ( DockerBaseClass, is_image_name_id, @@ -137,6 +151,7 @@ class ImageExportManager(DockerBaseClass): self.path = parameters["path"] self.force = parameters["force"] self.tag = parameters["tag"] + self.platform = parameters["platform"] if not is_valid_tag(self.tag, allow_empty=True): self.fail(f'"{self.tag}" is not a valid docker tag') @@ -198,15 +213,31 @@ class ImageExportManager(DockerBaseClass): except Exception as exc: # pylint: disable=broad-exception-caught self.fail(f"Error writing image archive {self.path} - {exc}") + def _platform_param(self) -> str: + platform = _Platform.parse_platform_string(self.platform) + platform_spec: dict[str, str] = {} + if platform.os: + platform_spec["os"] = platform.os + if platform.arch: + platform_spec["architecture"] = platform.arch + if platform.variant: + platform_spec["variant"] = platform.variant + return json.dumps(platform_spec) + def export_images(self) -> None: image_names = [name["joined"] for name in self.names] image_names_str = ", ".join(image_names) if len(image_names) == 1: self.log(f"Getting archive of image {image_names[0]}") + params: dict[str, t.Any] = {} + if self.platform: + params["platform"] = self._platform_param() try: chunks = self.client._stream_raw_result( self.client._get( - self.client._url("/images/{0}/get", image_names[0]), stream=True + self.client._url("/images/{0}/get", image_names[0]), + stream=True, + params=params, ), chunk_size=DEFAULT_DATA_CHUNK_SIZE, decode=False, @@ -215,12 +246,15 @@ class ImageExportManager(DockerBaseClass): self.fail(f"Error getting image {image_names[0]} - {exc}") else: self.log(f"Getting archive of images {image_names_str}") + params = {"names": image_names} + if self.platform: + params["platform"] = self._platform_param() try: chunks = self.client._stream_raw_result( self.client._get( self.client._url("/images/get"), stream=True, - params={"names": image_names}, + params=params, ), chunk_size=DEFAULT_DATA_CHUNK_SIZE, decode=False, @@ -277,11 +311,17 @@ def main() -> None: "aliases": ["name"], }, "tag": {"type": "str", "default": "latest"}, + "platform": {"type": "str"}, + } + + option_minimal_versions = { + "platform": {"docker_api_version": "1.48"}, } client = AnsibleDockerClient( argument_spec=argument_spec, supports_check_mode=True, + option_minimal_versions=option_minimal_versions, ) try: diff --git a/tests/integration/targets/docker_image_export/tasks/tests/platform.yml b/tests/integration/targets/docker_image_export/tasks/tests/platform.yml new file mode 100644 index 00000000..76a42b43 --- /dev/null +++ b/tests/integration/targets/docker_image_export/tasks/tests/platform.yml @@ -0,0 +1,34 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- when: docker_api_version is version('1.48', '>=') + block: + - name: Pull image for platform test + community.docker.docker_image_pull: + name: "{{ docker_test_image_hello_world }}" + platform: linux/amd64 + + - name: Export image with platform (check mode) + community.docker.docker_image_export: + name: "{{ docker_test_image_hello_world }}" + path: "{{ remote_tmp_dir }}/platform-test.tar" + platform: linux/amd64 + register: result_check + check_mode: true + + - ansible.builtin.assert: + that: + - result_check is changed + + - name: Export image with platform + community.docker.docker_image_export: + name: "{{ docker_test_image_hello_world }}" + path: "{{ remote_tmp_dir }}/platform-test.tar" + platform: linux/amd64 + register: result + + - ansible.builtin.assert: + that: + - result is changed