From 54c3d1c24e90dd4d1cec0bc3c1f488dfc861fe1f Mon Sep 17 00:00:00 2001 From: Dario Zanzico Date: Tue, 18 Sep 2018 10:54:44 +0200 Subject: [PATCH] New module: add docker_stack module (clound/docker/docker_stack) (#24588) * add docker_stack module + tests --- .../modules/cloud/docker/docker_stack.py | 298 ++++++++++++++++++ test/integration/targets/docker_stack/aliases | 4 + .../docker_stack/files/stack_compose_base.yml | 5 + .../files/stack_compose_overrides.yml | 5 + .../targets/docker_stack/meta/main.yml | 3 + .../targets/docker_stack/tasks/main.yml | 4 + .../targets/docker_stack/tasks/test_stack.yml | 99 ++++++ .../targets/docker_stack/vars/main.yml | 15 + 8 files changed, 433 insertions(+) create mode 100644 lib/ansible/modules/cloud/docker/docker_stack.py create mode 100644 test/integration/targets/docker_stack/aliases create mode 100644 test/integration/targets/docker_stack/files/stack_compose_base.yml create mode 100644 test/integration/targets/docker_stack/files/stack_compose_overrides.yml create mode 100644 test/integration/targets/docker_stack/meta/main.yml create mode 100644 test/integration/targets/docker_stack/tasks/main.yml create mode 100644 test/integration/targets/docker_stack/tasks/test_stack.yml create mode 100644 test/integration/targets/docker_stack/vars/main.yml diff --git a/lib/ansible/modules/cloud/docker/docker_stack.py b/lib/ansible/modules/cloud/docker/docker_stack.py new file mode 100644 index 00000000000..951291ef9a1 --- /dev/null +++ b/lib/ansible/modules/cloud/docker/docker_stack.py @@ -0,0 +1,298 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018 Dario Zanzico (git@dariozanzico.com) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +ANSIBLE_METADATA = {'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.1'} + +DOCUMENTATION = ''' +--- +module: docker_stack +author: "Dario Zanzico (@dariko)" +short_description: docker stack module +description: +- Manage docker stacks using the 'docker stack' command + on the target node + (see examples) +version_added: "2.8" +options: + name: + required: true + description: + - Stack name + state: + description: + - Service state. + default: "present" + choices: + - present + - absent + compose: + required: true + default: [] + description: + - List of compose definitions. Any element may be a string + referring to the path of the compose file on the target host + or the YAML contents of a compose file nested as dictionary. + prune: + required: false + default: false + description: + - If true will add the C(--prune) option to the C(docker stack deploy) command. + This will have docker remove the services not present in the + current stack definition. + type: bool + with_registry_auth: + required: false + default: false + description: + - If true will add the C(--with-registry-auth) option to the C(docker stack deploy) command. + This will have docker send registry authentication details to Swarm agents. + type: bool + resolve_image: + required: false + choices: ["always", "changed", "never"] + description: + - If set will add the C(--resolve-image) option to the C(docker stack deploy) command. + This will have docker query the registry to resolve image digest and + supported platforms. If not set, docker use "always" by default. + absent_retries: + required: false + default: 0 + description: + - If C(>0) and C(state==absent) the module will retry up to + C(absent_retries) times to delete the stack until all the + resources have been effectively deleted. + If the last try still reports the stack as not completely + removed the module will fail. + absent_retries_interval: + required: false + default: 1 + description: + - Interval in seconds between C(absent_retries) + +requirements: +- jsondiff +- pyyaml +''' + +RETURN = ''' +docker_stack_spec_diff: + description: | + dictionary containing the differences between the 'Spec' field + of the stack services before and after applying the new stack + definition. + sample: > + "docker_stack_specs_diff": + {'test_stack_test_service': {u'TaskTemplate': {u'ContainerSpec': {delete: [u'Env']}}}} + returned: on change + type: dict +''' + +EXAMPLES = ''' +- name: deploy 'stack1' stack from file + docker_stack: + state: present + name: stack1 + compose: + - /opt/stack.compose + +- name: deploy 'stack2' from base file and yaml overrides + docker_stack: + state: present + name: stack2 + compose: + - /opt/stack.compose + - version: '3' + services: + web: + image: nginx:latest + environment: + ENVVAR: envvar + +- name: deprovision 'stack1' + docker_stack: + state: absent +''' + + +import json +import tempfile +from ansible.module_utils.six import string_types +from time import sleep + +try: + from jsondiff import diff as json_diff + HAS_JSONDIFF = True +except ImportError: + HAS_JSONDIFF = False + +try: + from yaml import dump as yaml_dump + HAS_YAML = True +except ImportError: + HAS_YAML = False + +from ansible.module_utils.basic import AnsibleModule, os + + +def docker_stack_services(module, stack_name): + docker_bin = module.get_bin_path('docker', required=True) + rc, out, err = module.run_command([docker_bin, + "stack", + "services", + stack_name, + "--format", + "{{.Name}}"]) + if err == "Nothing found in stack: %s\n" % stack_name: + return [] + return out.strip().split('\n') + + +def docker_service_inspect(module, service_name): + docker_bin = module.get_bin_path('docker', required=True) + rc, out, err = module.run_command([docker_bin, + "service", + "inspect", + service_name]) + if rc != 0: + return None + else: + ret = json.loads(out)[0]['Spec'] + return ret + + +def docker_stack_deploy(module, stack_name, compose_files): + docker_bin = module.get_bin_path('docker', required=True) + command = [docker_bin, "stack", "deploy"] + if module.params["prune"]: + command += ["--prune"] + if module.params["with_registry_auth"]: + command += ["--with-registry-auth"] + if module.params["resolve_image"]: + command += ["--resolve-image", + module.params["resolve_image"]] + for compose_file in compose_files: + command += ["--compose-file", + compose_file] + command += [stack_name] + return module.run_command(command) + + +def docker_stack_inspect(module, stack_name): + ret = {} + for service_name in docker_stack_services(module, stack_name): + ret[service_name] = docker_service_inspect(module, service_name) + return ret + + +def docker_stack_rm(module, stack_name, retries, interval): + docker_bin = module.get_bin_path('docker', required=True) + command = [docker_bin, "stack", "rm", stack_name] + + rc, out, err = module.run_command(command) + + while err != "Nothing found in stack: %s\n" % stack_name and retries > 0: + sleep(interval) + retries = retries - 1 + rc, out, err = module.run_command(command) + return rc, out, err + + +def main(): + module = AnsibleModule( + argument_spec={ + 'name': dict(required=True, type='str'), + 'compose': dict(required=False, type='list', default=[]), + 'prune': dict(default=False, type='bool'), + 'with_registry_auth': dict(default=False, type='bool'), + 'resolve_image': dict(type='str', choices=['always', 'changed', 'never']), + 'state': dict(default='present', choices=['present', 'absent']), + 'absent_retries': dict(type='int', default=0), + 'absent_retries_interval': dict(type='int', default=1) + }, + supports_check_mode=False + ) + + if not HAS_JSONDIFF: + return module.fail_json(msg="jsondiff is not installed, try 'pip install jsondiff'") + + if not HAS_YAML: + return module.fail_json(msg="yaml is not installed, try 'pip install pyyaml'") + + state = module.params['state'] + compose = module.params['compose'] + name = module.params['name'] + absent_retries = module.params['absent_retries'] + absent_retries_interval = module.params['absent_retries_interval'] + + if state == 'present': + if not compose: + module.fail_json(msg=("compose parameter must be a list " + "containing at least one element")) + + compose_files = [] + for i, compose_def in enumerate(compose): + if isinstance(compose_def, dict): + compose_file_fd, compose_file = tempfile.mkstemp() + module.add_cleanup_file(compose_file) + with os.fdopen(compose_file_fd, 'w') as stack_file: + compose_files.append(compose_file) + stack_file.write(yaml_dump(compose_def)) + elif isinstance(compose_def, string_types): + compose_files.append(compose_def) + else: + module.fail_json(msg="compose element '%s' must be a " + + "string or a dictionary" % compose_def) + + before_stack_services = docker_stack_inspect(module, name) + + rc, out, err = docker_stack_deploy(module, name, compose_files) + + after_stack_services = docker_stack_inspect(module, name) + + if rc != 0: + module.fail_json(msg="docker stack up deploy command failed", + out=out, + rc=rc, err=err) + + before_after_differences = json_diff(before_stack_services, + after_stack_services) + for k in before_after_differences.keys(): + if isinstance(before_after_differences[k], dict): + before_after_differences[k].pop('UpdatedAt', None) + before_after_differences[k].pop('Version', None) + if not list(before_after_differences[k].keys()): + before_after_differences.pop(k) + + if not before_after_differences: + module.exit_json(changed=False) + else: + module.exit_json( + changed=True, + docker_stack_spec_diff=json_diff(before_stack_services, + after_stack_services, + dump=True)) + + else: + if docker_stack_services(module, name): + rc, out, err = docker_stack_rm(module, name, absent_retries, absent_retries_interval) + if rc != 0: + module.fail_json(msg="'docker stack down' command failed", + out=out, + rc=rc, + err=err) + else: + module.exit_json(changed=True, msg=out, err=err, rc=rc) + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/test/integration/targets/docker_stack/aliases b/test/integration/targets/docker_stack/aliases new file mode 100644 index 00000000000..2b3832dde58 --- /dev/null +++ b/test/integration/targets/docker_stack/aliases @@ -0,0 +1,4 @@ +shippable/posix/group2 +skip/osx +skip/freebsd +destructive diff --git a/test/integration/targets/docker_stack/files/stack_compose_base.yml b/test/integration/targets/docker_stack/files/stack_compose_base.yml new file mode 100644 index 00000000000..4a3e7963b45 --- /dev/null +++ b/test/integration/targets/docker_stack/files/stack_compose_base.yml @@ -0,0 +1,5 @@ +version: '3' +services: + busybox: + image: busybox:latest + command: sleep 3600 diff --git a/test/integration/targets/docker_stack/files/stack_compose_overrides.yml b/test/integration/targets/docker_stack/files/stack_compose_overrides.yml new file mode 100644 index 00000000000..1b81c71b30b --- /dev/null +++ b/test/integration/targets/docker_stack/files/stack_compose_overrides.yml @@ -0,0 +1,5 @@ +version: '3' +services: + busybox: + environment: + envvar: value diff --git a/test/integration/targets/docker_stack/meta/main.yml b/test/integration/targets/docker_stack/meta/main.yml new file mode 100644 index 00000000000..07da8c6ddae --- /dev/null +++ b/test/integration/targets/docker_stack/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - setup_docker diff --git a/test/integration/targets/docker_stack/tasks/main.yml b/test/integration/targets/docker_stack/tasks/main.yml new file mode 100644 index 00000000000..952dced127f --- /dev/null +++ b/test/integration/targets/docker_stack/tasks/main.yml @@ -0,0 +1,4 @@ +- include_tasks: test_stack.yml + when: + - ansible_os_family != 'RedHat' or ansible_distribution_major_version != '6' + - ansible_distribution != 'Fedora' or ansible_distribution_major_version|int >= 26 diff --git a/test/integration/targets/docker_stack/tasks/test_stack.yml b/test/integration/targets/docker_stack/tasks/test_stack.yml new file mode 100644 index 00000000000..b8d369ad88b --- /dev/null +++ b/test/integration/targets/docker_stack/tasks/test_stack.yml @@ -0,0 +1,99 @@ +- name: Create a Swarm cluster + docker_swarm: + state: present + advertise_addr: "{{ansible_default_ipv4.address}}" + +- name: install docker_swarm python requirements + pip: + name: jsondiff,pyyaml + +- name: Create a stack without name + register: output + docker_stack: + state: present + ignore_errors: yes + +- name: assert failure when name not set + assert: + that: + - output is failed + - 'output.msg == "missing required arguments: name"' + +- name: Create a stack without compose + register: output + docker_stack: + name: test_stack + ignore_errors: yes + +- name: assert failure when compose not set + assert: + that: + - output is failed + - 'output.msg == "compose parameter must be a list containing at least one element"' + +- name: Ensure stack is absent + register: output + docker_stack: + state: absent + name: test_stack + absent_retries: 30 + +- name: Copy compose files + copy: + src: "{{item}}" + dest: "{{output_dir}}/" + with_items: + - stack_compose_base.yml + - stack_compose_overrides.yml + +- name: Create stack with compose file + register: output + docker_stack: + state: present + name: test_stack + compose: + - "{{output_dir}}/stack_compose_base.yml" + +- name: assert test_stack changed on stack creation with compose file + assert: + that: + - output is changed + +- name: Update stack with YAML + register: output + docker_stack: + state: present + name: test_stack + compose: + - "{{stack_compose_base}}" + - "{{stack_compose_overrides}}" + +- name: assert test_stack correctly changed on update with yaml + assert: + that: + - output is changed + - output.docker_stack_spec_diff == stack_update_expected_diff + +- name: Delete stack + register: output + docker_stack: + state: absent + name: test_stack + absent_retries: 30 + +- name: assert delete of existing stack returns changed + assert: + that: + - output is changed + +- name: Delete stack again + register: output + docker_stack: + state: absent + name: test_stack + absent_retries: 30 + +- name: assert state=absent idempotency + assert: + that: + - output is not changed diff --git a/test/integration/targets/docker_stack/vars/main.yml b/test/integration/targets/docker_stack/vars/main.yml new file mode 100644 index 00000000000..0872f23784a --- /dev/null +++ b/test/integration/targets/docker_stack/vars/main.yml @@ -0,0 +1,15 @@ +stack_compose_base: + version: '3' + services: + busybox: + image: busybox:latest + command: sleep 3600 + +stack_compose_overrides: + version: '3' + services: + busybox: + environment: + envvar: value + +stack_update_expected_diff: '{"test_stack_busybox": {"TaskTemplate": {"ContainerSpec": {"Env": ["envvar=value"]}}}}'