diff --git a/changelogs/fragments/51134-docker_swarm_service-change-on-updated-image.yml b/changelogs/fragments/51134-docker_swarm_service-change-on-updated-image.yml new file mode 100644 index 00000000000..d6d12c209c1 --- /dev/null +++ b/changelogs/fragments/51134-docker_swarm_service-change-on-updated-image.yml @@ -0,0 +1,2 @@ +minor_changes: + - "docker_swarm_service - Resolve image digest from registry to detect and deploy changed images. This behaviour can be turned of by using the new option ``resolve_image: false``" diff --git a/lib/ansible/modules/cloud/docker/docker_swarm_service.py b/lib/ansible/modules/cloud/docker/docker_swarm_service.py index ebf130c661e..2889d8373c2 100644 --- a/lib/ansible/modules/cloud/docker/docker_swarm_service.py +++ b/lib/ansible/modules/cloud/docker/docker_swarm_service.py @@ -23,10 +23,18 @@ options: description: - Service name image: + type: str required: true description: - Service image path and tag. Maps docker service IMAGE parameter. + resolve_image: + type: bool + required: false + default: true + description: + - If the current image digest should be resolved from registry and updated if changed. + version_added: 2.8 state: required: true default: present @@ -335,6 +343,9 @@ requirements: (see L(here,https://github.com/docker/docker-py/issues/1310) for details). Version 2.1.0 or newer is only available with the C(docker) module." - "Docker API >= 1.24" +notes: + - "Images will only resolve to the latest digest when using Docker API >= 1.30 and docker-py >= 3.2.0. + When using older versions use C(force_update: true) to trigger the swarm to resolve a new image." ''' RETURN = ''' @@ -519,9 +530,9 @@ import time import shlex import operator from ansible.module_utils.docker_common import ( - DockerBaseClass, AnsibleDockerClient, DifferenceTracker, + DockerBaseClass, ) from ansible.module_utils.basic import human_to_bytes from ansible.module_utils.six import string_types @@ -530,6 +541,8 @@ from ansible.module_utils._text import to_text try: from distutils.version import LooseVersion from docker import types + from docker.utils import parse_repository_tag + from docker.errors import DockerException except Exception: # missing docker-py handled in ansible.module_utils.docker pass @@ -622,11 +635,11 @@ class DockerService(DockerBaseClass): 'update_order': self.update_order} @staticmethod - def from_ansible_params(ap, old_service): + def from_ansible_params(ap, old_service, image_digest): s = DockerService() + s.image = image_digest s.constraints = ap['constraints'] s.placement_preferences = ap['placement_preferences'] - s.image = ap['image'] s.args = ap['args'] s.endpoint_mode = ap['endpoint_mode'] s.dns = ap['dns'] @@ -818,8 +831,9 @@ class DockerService(DockerBaseClass): differences.add('update_max_failure_ratio', parameter=self.update_max_failure_ratio, active=os.update_max_failure_ratio) if self.update_order is not None and self.update_order != os.update_order: differences.add('update_order', parameter=self.update_order, active=os.update_order) - if self.image != os.image.split('@')[0]: - differences.add('image', parameter=self.image, active=os.image.split('@')[0]) + has_image_changed, change = self.has_image_changed(os.image) + if has_image_changed: + differences.add('image', parameter=self.image, active=change) if self.user and self.user != os.user: differences.add('user', parameter=self.user, active=os.user) if self.dns != os.dns: @@ -857,6 +871,11 @@ class DockerService(DockerBaseClass): return True return False + def has_image_changed(self, old_image): + if '@' not in self.image: + old_image = old_image.split('@')[0] + return self.image != old_image, old_image + def __str__(self): return str({ 'mode': self.mode, @@ -1172,12 +1191,38 @@ class DockerServiceManager(): def remove_service(self, name): self.client.remove_service(name) + def get_image_digest(self, name, resolve=True): + if ( + not name + or not resolve + or self.client.docker_py_version < LooseVersion('3.2') + or self.client.docker_api_version < LooseVersion('1.30') + ): + return name + repo, tag = parse_repository_tag(name) + if not tag: + tag = 'latest' + name = repo + ':' + tag + distribution_data = self.client.inspect_distribution(name) + digest = distribution_data['Descriptor']['digest'] + return '%s@%s' % (name, digest) + def __init__(self, client): self.client = client self.diff_tracker = DifferenceTracker() def run(self): module = self.client.module + + image = module.params['image'] + try: + image_digest = self.get_image_digest( + name=image, + resolve=module.params['resolve_image'] + ) + except DockerException as e: + return module.fail_json( + msg="Error looking for an image named %s: %s" % (image, e)) try: current_service = self.get_service(module.params['name']) except Exception as e: @@ -1185,7 +1230,11 @@ class DockerServiceManager(): msg="Error looking for service named %s: %s" % (module.params['name'], e)) try: - new_service = DockerService.from_ansible_params(module.params, current_service) + new_service = DockerService.from_ansible_params( + module.params, + current_service, + image_digest + ) except Exception as e: return module.fail_json( msg="Error parsing module parameters: %s" % e) @@ -1291,6 +1340,7 @@ def main(): limit_memory=dict(default=0, type='str'), reserve_cpu=dict(default=0, type='float'), reserve_memory=dict(default=0, type='str'), + resolve_image=dict(default=True, type='bool'), restart_policy_delay=dict(default=0, type='int'), restart_policy_attempts=dict(default=0, type='int'), restart_policy_window=dict(default=0, type='int'), diff --git a/test/integration/targets/docker_swarm_service/tasks/tests/misc.yml b/test/integration/targets/docker_swarm_service/tasks/tests/misc.yml index 183c0374b34..aecf32ebe66 100644 --- a/test/integration/targets/docker_swarm_service/tasks/tests/misc.yml +++ b/test/integration/targets/docker_swarm_service/tasks/tests/misc.yml @@ -86,10 +86,14 @@ published_port: 60001 target_port: 60001 +- name: fake image key as it is not predictable + set_fact: + ansible_docker_service_output: "{{ output.ansible_docker_service|combine({'image': 'busybox'}) }}" + - name: assert service matches expectations assert: that: - - output.ansible_docker_service == service_expected_output + - ansible_docker_service_output == service_expected_output - name: delete sample service register: output diff --git a/test/integration/targets/docker_swarm_service/tasks/tests/options.yml b/test/integration/targets/docker_swarm_service/tasks/tests/options.yml index d96d125126f..ae418f01312 100644 --- a/test/integration/targets/docker_swarm_service/tasks/tests/options.yml +++ b/test/integration/targets/docker_swarm_service/tasks/tests/options.yml @@ -1269,6 +1269,43 @@ - reserve_memory_2 is not changed - reserve_memory_3 is changed +################################################################### +# resolve_image ################################################### +################################################################### + +- name: resolve_image (false) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + resolve_image: false + register: resolve_image_1 + +- name: resolve_image (false idempotency) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + resolve_image: false + register: resolve_image_2 + +- name: resolve_image (change) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + resolve_image: true + register: resolve_image_3 + +- name: cleanup + docker_swarm_service: + name: "{{ service_name }}" + state: absent + diff: no + +- assert: + that: + - resolve_image_1 is changed + - resolve_image_2 is not changed + - resolve_image_3 is changed + ################################################################### # restart_policy ################################################## ###################################################################