From 82f1438b14a9753d42d03c2bbda96c5adc301dc5 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 26 Oct 2018 17:58:17 +0800 Subject: [PATCH] Add docker_config module (#38792) * Add docker_config module * Address review comments * Merge description lines * Stop returning empty config_id in results * Add integration tests for docker_config Based on docker_secret's tests. * Ensure swarm using docker_swarm module * Add minimum docker / docker api version requirements ref: https://github.com/ansible/ansible/pull/47046 * Check Docker API version before running tests ref: https://github.com/ansible/ansible/pull/47340 * Typo * Wording * Improve example * Assert state == absent is idempotent --- .../modules/cloud/docker/docker_config.py | 294 ++++++++++++++++++ .../modules/cloud/docker/docker_swarm.py | 2 +- .../integration/targets/docker_config/aliases | 4 + .../targets/docker_config/meta/main.yml | 3 + .../targets/docker_config/tasks/main.yml | 7 + .../tasks/test_docker_config.yml | 113 +++++++ 6 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 lib/ansible/modules/cloud/docker/docker_config.py create mode 100644 test/integration/targets/docker_config/aliases create mode 100644 test/integration/targets/docker_config/meta/main.yml create mode 100644 test/integration/targets/docker_config/tasks/main.yml create mode 100644 test/integration/targets/docker_config/tasks/test_docker_config.yml diff --git a/lib/ansible/modules/cloud/docker/docker_config.py b/lib/ansible/modules/cloud/docker/docker_config.py new file mode 100644 index 00000000000..453ba0eab42 --- /dev/null +++ b/lib/ansible/modules/cloud/docker/docker_config.py @@ -0,0 +1,294 @@ +#!/usr/bin/python +# +# Copyright 2016 Red Hat | Ansible +# 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 = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: docker_config + +short_description: Manage docker configs. + +version_added: "2.8" + +description: + - Create and remove Docker configs in a Swarm environment. Similar to C(docker config create) and C(docker config rm). + - Adds to the metadata of new configs 'ansible_key', an encrypted hash representation of the data, which is then used + in future runs to test if a config has changed. If 'ansible_key' is not present, then a config will not be updated + unless the C(force) option is set. + - Updates to configs are performed by removing the config and creating it again. +options: + data: + description: + - The value of the config. Required when state is C(present). + required: false + type: str + labels: + description: + - "A map of key:value meta data, where both the I(key) and I(value) are expected to be a string." + - If new meta data is provided, or existing meta data is modified, the config will be updated by removing it and creating it again. + required: false + type: dict + force: + description: + - Use with state C(present) to always remove and recreate an existing config. + - If I(true), an existing config will be replaced, even if it has not been changed. + default: false + type: bool + name: + description: + - The name of the config. + required: true + type: str + state: + description: + - Set to C(present), if the config should exist, and C(absent), if it should not. + required: false + default: present + choices: + - absent + - present + +extends_documentation_fragment: + - docker + +requirements: + - "python >= 2.7" + - "docker >= 2.6.0" + - "Please note that the L(docker-py,https://pypi.org/project/docker-py/) Python + module has been superseded by L(docker,https://pypi.org/project/docker/) + (see L(here,https://github.com/docker/docker-py/issues/1310) for details). + Version 2.6.0 or newer is only available with the C(docker) module." + - "Docker API >= 1.30" + +author: + - Chris Houseknecht (@chouseknecht) + - John Hu (@ushuz) +''' + +EXAMPLES = ''' + +- name: Create config foo (from a file on the control machine) + docker_config: + name: foo + data: "{{ lookup('file', '/path/to/config/file') }}" + state: present + +- name: Change the config data + docker_config: + name: foo + data: Goodnight everyone! + labels: + bar: baz + one: '1' + state: present + +- name: Add a new label + docker_config: + name: foo + data: Goodnight everyone! + labels: + bar: baz + one: '1' + # Adding a new label will cause a remove/create of the config + two: '2' + state: present + +- name: No change + docker_config: + name: foo + data: Goodnight everyone! + labels: + bar: baz + one: '1' + # Even though 'two' is missing, there is no change to the existing config + state: present + +- name: Update an existing label + docker_config: + name: foo + data: Goodnight everyone! + labels: + bar: monkey # Changing a label will cause a remove/create of the config + one: '1' + state: present + +- name: Force the (re-)creation of the config + docker_config: + name: foo + data: Goodnight everyone! + force: yes + state: present + +- name: Remove config foo + docker_config: + name: foo + state: absent +''' + +RETURN = ''' +config_id: + description: + - The ID assigned by Docker to the config object. + returned: success and C(state == "present") + type: string + sample: 'hzehrmyjigmcp2gb6nlhmjqcv' +''' + +import hashlib + +try: + from docker.errors import APIError +except ImportError: + # missing docker-py handled in ansible.module_utils.docker + pass + +from ansible.module_utils.docker_common import AnsibleDockerClient, DockerBaseClass +from ansible.module_utils._text import to_native, to_bytes + + +class ConfigManager(DockerBaseClass): + + def __init__(self, client, results): + + super(ConfigManager, self).__init__() + + self.client = client + self.results = results + self.check_mode = self.client.check_mode + + parameters = self.client.module.params + self.name = parameters.get('name') + self.state = parameters.get('state') + self.data = parameters.get('data') + self.labels = parameters.get('labels') + self.force = parameters.get('force') + self.data_key = None + + def __call__(self): + if self.state == 'present': + self.data_key = hashlib.sha224(to_bytes(self.data)).hexdigest() + self.present() + elif self.state == 'absent': + self.absent() + + def get_config(self): + ''' Find an existing config. ''' + try: + configs = self.client.configs(filters={'name': self.name}) + except APIError as exc: + self.client.fail("Error accessing config %s: %s" % (self.name, to_native(exc))) + + for config in configs: + if config['Spec']['Name'] == self.name: + return config + return None + + def create_config(self): + ''' Create a new config ''' + config_id = None + # We can't see the data after creation, so adding a label we can use for idempotency check + labels = { + 'ansible_key': self.data_key + } + if self.labels: + labels.update(self.labels) + + try: + if not self.check_mode: + config_id = self.client.create_config(self.name, self.data, labels=labels) + except APIError as exc: + self.client.fail("Error creating config: %s" % to_native(exc)) + + if isinstance(config_id, dict): + config_id = config_id['ID'] + + return config_id + + def present(self): + ''' Handles state == 'present', creating or updating the config ''' + config = self.get_config() + if config: + self.results['config_id'] = config['ID'] + data_changed = False + attrs = config.get('Spec', {}) + if attrs.get('Labels', {}).get('ansible_key'): + if attrs['Labels']['ansible_key'] != self.data_key: + data_changed = True + labels_changed = False + if self.labels and attrs.get('Labels'): + # check if user requested a label change + for label in attrs['Labels']: + if self.labels.get(label) and self.labels[label] != attrs['Labels'][label]: + labels_changed = True + # check if user added a label + labels_added = False + if self.labels: + if attrs.get('Labels'): + for label in self.labels: + if label not in attrs['Labels']: + labels_added = True + else: + labels_added = True + if data_changed or labels_added or labels_changed or self.force: + # if something changed or force, delete and re-create the config + self.absent() + config_id = self.create_config() + self.results['changed'] = True + self.results['config_id'] = config_id + else: + self.results['changed'] = True + self.results['config_id'] = self.create_config() + + def absent(self): + ''' Handles state == 'absent', removing the config ''' + config = self.get_config() + if config: + try: + if not self.check_mode: + self.client.remove_config(config['ID']) + except APIError as exc: + self.client.fail("Error removing config %s: %s" % (self.name, to_native(exc))) + self.results['changed'] = True + + +def main(): + argument_spec = dict( + name=dict(type='str', required=True), + state=dict(type='str', choices=['absent', 'present'], default='present'), + data=dict(type='str'), + labels=dict(type='dict'), + force=dict(type='bool', default=False) + ) + + required_if = [ + ('state', 'present', ['data']) + ] + + client = AnsibleDockerClient( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=required_if, + min_docker_version='2.6.0', + min_docker_api_version='1.30', + ) + + results = dict( + changed=False, + ) + + ConfigManager(client, results)() + client.module.exit_json(**results) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/cloud/docker/docker_swarm.py b/lib/ansible/modules/cloud/docker/docker_swarm.py index ec2f8ef2377..eab63492bba 100644 --- a/lib/ansible/modules/cloud/docker/docker_swarm.py +++ b/lib/ansible/modules/cloud/docker/docker_swarm.py @@ -136,7 +136,7 @@ extends_documentation_fragment: - docker requirements: - python >= 2.7 - - "docker-py >= 2.6.0" + - "docker >= 2.6.0" - "Please note that the L(docker-py,https://pypi.org/project/docker-py/) Python module has been superseded by L(docker,https://pypi.org/project/docker/) (see L(here,https://github.com/docker/docker-py/issues/1310) for details). diff --git a/test/integration/targets/docker_config/aliases b/test/integration/targets/docker_config/aliases new file mode 100644 index 00000000000..2b3832dde58 --- /dev/null +++ b/test/integration/targets/docker_config/aliases @@ -0,0 +1,4 @@ +shippable/posix/group2 +skip/osx +skip/freebsd +destructive diff --git a/test/integration/targets/docker_config/meta/main.yml b/test/integration/targets/docker_config/meta/main.yml new file mode 100644 index 00000000000..07da8c6ddae --- /dev/null +++ b/test/integration/targets/docker_config/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - setup_docker diff --git a/test/integration/targets/docker_config/tasks/main.yml b/test/integration/targets/docker_config/tasks/main.yml new file mode 100644 index 00000000000..e65a75d814e --- /dev/null +++ b/test/integration/targets/docker_config/tasks/main.yml @@ -0,0 +1,7 @@ +- name: Check Docker API version + command: "{{ ansible_python.executable }} -c 'import docker; print(docker.from_env().version()[\"ApiVersion\"])'" + register: docker_api_version + ignore_errors: yes + +- include_tasks: test_docker_config.yml + when: docker_api_version.rc == 0 and docker_api_version.stdout is version('1.30', '>=') diff --git a/test/integration/targets/docker_config/tasks/test_docker_config.yml b/test/integration/targets/docker_config/tasks/test_docker_config.yml new file mode 100644 index 00000000000..cf07e51624b --- /dev/null +++ b/test/integration/targets/docker_config/tasks/test_docker_config.yml @@ -0,0 +1,113 @@ +- name: Make sure we're not already using Docker swarm + docker_swarm: + state: absent + force: true + +- name: Create a Swarm cluster + docker_swarm: + state: present + advertise_addr: "{{ansible_default_ipv4.address}}" + +- name: Parameter name should be required + docker_config: + state: present + ignore_errors: yes + register: output + +- name: assert failure when called with no name + assert: + that: + - 'output.failed' + - 'output.msg == "missing required arguments: name"' + +- name: Test parameters + docker_config: + name: foo + state: present + ignore_errors: yes + register: output + +- name: assert failure when called with no data + assert: + that: + - 'output.failed' + - 'output.msg == "state is present but all of the following are missing: data"' + +- name: Create config + docker_config: + name: db_password + data: opensesame! + state: present + register: output + +- name: Create variable config_id + set_fact: + config_id: "{{ output.config_id }}" + +- name: Inspect config + command: "docker config inspect {{ config_id }}" + register: inspect + +- debug: var=inspect + +- name: assert config creation succeeded + assert: + that: + - "'db_password' in inspect.stdout" + - "'ansible_key' in inspect.stdout" + +- name: Create config again + docker_config: + name: db_password + data: opensesame! + state: present + register: output + +- name: assert create config is idempotent + assert: + that: + - not output.changed + +- name: Update config + docker_config: + name: db_password + data: newpassword! + state: present + register: output + +- name: assert config was updated + assert: + that: + - output.changed + - output.config_id != config_id + +- name: Remove config + docker_config: + name: db_password + state: absent + +- name: Check that config is removed + command: "docker config inspect {{ config_id }}" + register: output + ignore_errors: yes + +- name: assert config was removed + assert: + that: + - output.failed + +- name: Remove config + docker_config: + name: db_password + state: absent + register: output + +- name: assert remove config is idempotent + assert: + that: + - not output.changed + +- name: Remove a Swarm cluster + docker_swarm: + state: absent + force: true