diff --git a/README.md b/README.md index 7493e85e..21a10379 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ If you use the Ansible package and do not update collections independently, use - community.docker.docker_image: manage Docker images - community.docker.docker_image_info: retrieve information on Docker images - community.docker.docker_image_load: load Docker images from archives + - community.docker.docker_image_push: push Docker images to registries - community.docker.docker_login: log in and out to/from registries - community.docker.docker_network: manage Docker networks - community.docker.docker_network_info: retrieve information on Docker networks diff --git a/meta/runtime.yml b/meta/runtime.yml index 7616e6fe..a4eb75ac 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -16,6 +16,7 @@ action_groups: - docker_image - docker_image_info - docker_image_load + - docker_image_push - docker_login - docker_network - docker_network_info diff --git a/plugins/modules/docker_image_push.py b/plugins/modules/docker_image_push.py new file mode 100644 index 00000000..d2ece0c5 --- /dev/null +++ b/plugins/modules/docker_image_push.py @@ -0,0 +1,189 @@ +#!/usr/bin/python +# +# Copyright (c) 2023, Felix Fontein +# 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 + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: docker_image_push +version_added: 3.6.0 +short_description: Push Docker images to registries +description: + - Pushes a Docker image to a registry. + +extends_documentation_fragment: + - community.docker.docker.api_documentation + - community.docker.attributes + - community.docker.attributes.actiongroup_docker + +attributes: + check_mode: + support: none + diff_mode: + support: none + +options: + name: + description: + - "Image name. Name format must be one of V(name), V(repository/name), or V(registry_server:port/name). + - The name can optionally include the tag by appending V(:tag_name), or it can contain a digest by appending V(@hash:digest)." + type: str + required: true + tag: + description: + - Used to select an image when pulling. Defaults to V(latest). + - If O(name) parameter format is C(name:tag) or C(image@hash:digest), then O(tag) will be ignored. + type: str + default: latest + +requirements: + - "Docker API >= 1.25" + +author: + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Push an image + community.docker.docker_image_push: + name: registry.example.com:5000/repo/image + tag: latest +''' + +RETURN = ''' +image: + description: Image inspection results for the affected image. + returned: success + type: dict + sample: {} +''' + +import traceback + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.docker.plugins.module_utils.common_api import ( + AnsibleDockerClient, + RequestException, +) + +from ansible_collections.community.docker.plugins.module_utils.util import ( + DockerBaseClass, + is_image_name_id, + is_valid_tag, +) + +from ansible_collections.community.docker.plugins.module_utils._api.errors import DockerException +from ansible_collections.community.docker.plugins.module_utils._api.utils.utils import ( + parse_repository_tag, +) + +from ansible_collections.community.docker.plugins.module_utils._api.auth import ( + get_config_header, + resolve_repository_name, +) + + +class ImagePusher(DockerBaseClass): + def __init__(self, client): + super(ImagePusher, self).__init__() + + self.client = client + self.check_mode = self.client.check_mode + + parameters = self.client.module.params + self.name = parameters['name'] + self.tag = parameters['tag'] + + if is_image_name_id(self.name): + self.client.fail("Cannot push an image by ID") + if not is_valid_tag(self.tag, allow_empty=True): + self.client.fail('"{0}" is not a valid docker tag!'.format(self.tag)) + + # If name contains a tag, it takes precedence over tag parameter. + repo, repo_tag = parse_repository_tag(self.name) + if repo_tag: + self.name = repo + self.tag = repo_tag + + if is_image_name_id(self.tag): + self.client.fail("Cannot push an image by digest") + if not is_valid_tag(self.tag, allow_empty=False): + self.client.fail('"{0}" is not a valid docker tag!'.format(self.tag)) + + def push(self): + image = self.client.find_image(name=self.name, tag=self.tag) + if not image: + self.client.fail('Cannot find image %s:%s' % (self.name, self.tag)) + + results = dict( + changed=False, + actions=[], + image=image, + ) + + push_registry, push_repo = resolve_repository_name(self.name) + try: + results['actions'].append('Pushed image %s:%s' % (self.name, self.tag)) + + headers = {} + header = get_config_header(self.client, push_registry) + if header: + headers['X-Registry-Auth'] = header + response = self.client._post_json( + self.client._url("/images/{0}/push", self.name), + data=None, + headers=headers, + stream=True, + params={'tag': self.tag}, + ) + self.client._raise_for_status(response) + for line in self.client._stream_helper(response, decode=True): + self.log(line, pretty_print=True) + if line.get('errorDetail'): + raise Exception(line['errorDetail']['message']) + status = line.get('status') + if status == 'Pushing': + results['changed'] = True + except Exception as exc: + if 'unauthorized' in str(exc): + if 'authentication required' in str(exc): + self.client.fail("Error pushing image %s/%s:%s - %s. Try logging into %s first." % + (push_registry, push_repo, self.tag, to_native(exc), push_registry)) + else: + self.client.fail("Error pushing image %s/%s:%s - %s. Does the repository exist?" % + (push_registry, push_repo, self.tag, str(exc))) + self.client.fail("Error pushing image %s:%s: %s" % (self.name, self.tag, to_native(exc))) + + return results + + +def main(): + argument_spec = dict( + name=dict(type='str', required=True), + tag=dict(type='str', default='latest'), + ) + + client = AnsibleDockerClient( + argument_spec=argument_spec, + supports_check_mode=False, + ) + + try: + results = ImagePusher(client).push() + client.module.exit_json(**results) + except DockerException as e: + client.fail('An unexpected Docker error occurred: {0}'.format(to_native(e)), exception=traceback.format_exc()) + except RequestException as e: + client.fail( + 'An unexpected requests error occurred when trying to talk to the Docker daemon: {0}'.format(to_native(e)), + exception=traceback.format_exc()) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/docker_image_push/aliases b/tests/integration/targets/docker_image_push/aliases new file mode 100644 index 00000000..2e1acc0a --- /dev/null +++ b/tests/integration/targets/docker_image_push/aliases @@ -0,0 +1,6 @@ +# 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 + +azp/4 +destructive diff --git a/tests/integration/targets/docker_image_push/meta/main.yml b/tests/integration/targets/docker_image_push/meta/main.yml new file mode 100644 index 00000000..ff316450 --- /dev/null +++ b/tests/integration/targets/docker_image_push/meta/main.yml @@ -0,0 +1,8 @@ +--- +# 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 + +dependencies: + - setup_docker_registry + - setup_docker_python_deps diff --git a/tests/integration/targets/docker_image_push/tasks/main.yml b/tests/integration/targets/docker_image_push/tasks/main.yml new file mode 100644 index 00000000..88b23cfe --- /dev/null +++ b/tests/integration/targets/docker_image_push/tasks/main.yml @@ -0,0 +1,13 @@ +--- +# 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 + +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +- when: ansible_facts.distribution ~ ansible_facts.distribution_major_version not in ['CentOS6', 'RedHat6'] + include_tasks: + file: test.yml diff --git a/tests/integration/targets/docker_image_push/tasks/run-test.yml b/tests/integration/targets/docker_image_push/tasks/run-test.yml new file mode 100644 index 00000000..65853ddd --- /dev/null +++ b/tests/integration/targets/docker_image_push/tasks/run-test.yml @@ -0,0 +1,7 @@ +--- +# 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 + +- name: "Loading tasks from {{ item }}" + include_tasks: "{{ item }}" diff --git a/tests/integration/targets/docker_image_push/tasks/test.yml b/tests/integration/targets/docker_image_push/tasks/test.yml new file mode 100644 index 00000000..a56c9530 --- /dev/null +++ b/tests/integration/targets/docker_image_push/tasks/test.yml @@ -0,0 +1,38 @@ +--- +# 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 + +- name: Create random name prefix + set_fact: + name_prefix: "{{ 'ansible-docker-test-%0x' % ((2**32) | random) }}" +- name: Create image and container list + set_fact: + inames: [] + cnames: [] + +- debug: + msg: "Using name prefix {{ name_prefix }}" + +- block: + - include_tasks: run-test.yml + with_fileglob: + - "tests/*.yml" + + always: + - name: "Make sure all images are removed" + docker_image: + name: "{{ item }}" + state: absent + with_items: "{{ inames }}" + - name: "Make sure all containers are removed" + docker_container: + name: "{{ item }}" + state: absent + force_kill: true + with_items: "{{ cnames }}" + + when: docker_api_version is version('1.25', '>=') + +- fail: msg="Too old docker / docker-py version to run docker_image tests!" + when: not(docker_api_version is version('1.25', '>=')) and (ansible_distribution != 'CentOS' or ansible_distribution_major_version|int > 6) diff --git a/tests/integration/targets/docker_image_push/tasks/tests/basic.yml b/tests/integration/targets/docker_image_push/tasks/tests/basic.yml new file mode 100644 index 00000000..e5cc1b46 --- /dev/null +++ b/tests/integration/targets/docker_image_push/tasks/tests/basic.yml @@ -0,0 +1,61 @@ +--- +# 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 + +- vars: + image_name: registry.example.com:5000/foo/bar:baz + block: + - name: Make sure image is not present + docker_image: + name: "{{ image_name }}" + state: absent + + - name: Push non-existing image (must fail) + docker_image_push: + name: "{{ image_name }}" + register: fail_1 + ignore_errors: true + + - name: Push image ID (must fail) + docker_image_push: + name: "sha256:{{ docker_test_image_digest_v1_image_id }}" + register: fail_2 + ignore_errors: true + + - name: Push image with digest (must fail) + docker_image_push: + name: "{{ docker_test_image_digest_base }}@sha256:{{ docker_test_image_digest_v1 }}" + register: fail_3 + ignore_errors: true + + - name: Push invalid tag (must fail) + docker_image_push: + name: "{{ docker_test_image_hello_world }}" + tag: foo/bar + register: fail_4 + ignore_errors: true + + - name: Push invalid tag 2 (must fail) + docker_image_push: + name: "{{ docker_test_image_digest_base }}:foo bar" + register: fail_5 + ignore_errors: true + + - assert: + that: + - fail_1 is failed + - >- + 'Cannot find image registry.example.com:5000/foo/bar:baz' == fail_1.msg + - fail_2 is failed + - >- + 'Cannot push an image by ID' == fail_2.msg + - fail_3 is failed + - >- + 'Cannot push an image by digest' == fail_3.msg + - fail_4 is failed + - >- + '"foo/bar" is not a valid docker tag!' == fail_4.msg + - fail_5 is failed + - >- + '"foo bar" is not a valid docker tag!' == fail_5.msg diff --git a/tests/integration/targets/docker_image_push/tasks/tests/registry.yml b/tests/integration/targets/docker_image_push/tasks/tests/registry.yml new file mode 100644 index 00000000..ddf9addc --- /dev/null +++ b/tests/integration/targets/docker_image_push/tasks/tests/registry.yml @@ -0,0 +1,87 @@ +--- +# 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 + +- name: Run registry tests only when registry is present + when: registry_address is defined + block: + - name: Pull images + docker_image: + name: "{{ item }}" + source: pull + loop: + - "{{ docker_test_image_hello_world }}" + - "{{ docker_test_image_alpine }}" + register: pulled_images + + - name: Determining pushed image names + set_fact: + image_name_base: "{{ registry_address }}/test/{{ name_prefix }}" + image_name_base2: "{{ registry_frontend2_address }}/test/{{ name_prefix }}" + image_tag: latest + + - name: Registering image name + set_fact: + inames: "{{ inames + [image_name_base ~ ':' ~ image_tag, image_name_base2 ~ ':' ~ image_tag] }}" + + - name: Tag first image + docker_image: + name: "{{ docker_test_image_hello_world }}" + repository: "{{ item }}" + source: local + force_tag: true + loop: + - "{{ image_name_base }}:{{ image_tag }}" + - "{{ image_name_base2 }}:{{ image_tag }}" + + - name: Push first image + docker_image_push: + name: "{{ image_name_base }}:{{ image_tag }}" + register: push_1 + + - name: Push first image (idempotent) + docker_image_push: + name: "{{ image_name_base }}:{{ image_tag }}" + register: push_2 + + - name: Tag second image + docker_image: + name: "{{ docker_test_image_alpine }}" + repository: "{{ image_name_base }}:{{ image_tag }}" + source: local + force_tag: true + + - name: Push second image with same name + docker_image_push: + name: "{{ image_name_base }}:{{ image_tag }}" + register: push_3 + + - assert: + that: + - push_1 is changed + - push_1.image.Id == pulled_images.results[0].image.Id + - push_2 is not changed + - push_2.image.Id == pulled_images.results[0].image.Id + - push_3 is changed + - push_3.image.Id == pulled_images.results[1].image.Id + + - when: registry_frontend2_address != 'n/a' + block: + - name: Make sure we are logged out from registry + docker_login: + registry_url: "{{ registry_frontend2_address }}" + username: testuser + password: hunter2 + state: absent + + - name: Push image (unauthenticated) + docker_image_push: + name: "{{ image_name_base2 }}:{{ image_tag }}" + register: push_4 + ignore_errors: true + + - assert: + that: + - push_4 is failed + - push_4.msg == 'Error pushing image ' ~ image_name_base2 ~ ':' ~ image_tag ~ ': no basic auth credentials' diff --git a/tests/integration/targets/setup_docker/vars/main.yml b/tests/integration/targets/setup_docker/vars/main.yml index e4eafc24..6e43e5a2 100644 --- a/tests/integration/targets/setup_docker/vars/main.yml +++ b/tests/integration/targets/setup_docker/vars/main.yml @@ -4,6 +4,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later docker_test_image_digest_v1: e004c2cc521c95383aebb1fb5893719aa7a8eae2e7a71f316a4410784edb00a9 +docker_test_image_digest_v1_image_id: 758ec7f3a1ee85f8f08399b55641bfb13e8c1109287ddc5e22b68c3d653152ee docker_test_image_digest_v2: ee44b399df993016003bf5466bd3eeb221305e9d0fa831606bc7902d149c775b docker_test_image_digest_base: quay.io/ansible/docker-test-containers docker_test_image_hello_world: quay.io/ansible/docker-test-containers:hello-world