From daf1cfbde09e84d53cbe2548a216fbaec69e62a2 Mon Sep 17 00:00:00 2001 From: Piotr Wojciechowski <23406016+WojciechowskiPiotr@users.noreply.github.com> Date: Mon, 4 Feb 2019 14:08:46 +0100 Subject: [PATCH] docker_node: Docker Swarm node operations module (#50584) * * docker_node: New module for operations on Docker Swarm node * Shared code for Docker Swarm modules * * docker_node: Removed the attribute `force` as it is not used for any operation * docker_node_facts: Update module to use client class AnsibleDockerSwarmClient instead of AnsibleDockerClient * docker_node_facts: List of nodes can be provided as input, inspect all registered nodes or manager host itself * docker_node: Update in method name called from AnsibleDockerSwarmClient * docker_node: Additional method to shared module to get formatted output list of registered nodes * docker_node: Additional method to shared module to get formatted output list of registered nodes * docker_node: removed state list (featue moved to docker_swarm_facts) * docker_node: Node labels manipulation (remove, replace, merge) * module_utils/docker_swarm: Updated output for nodes list - adding swarm leader flag * docker_node_facts: update in input and return values, update in documentation section * docker_node: Updated operations on labels, tracking if change is required * docker_node: Updated documentation, parameter 'hostname' is now required docker_node_facts: Updated documentation * * Failing Ansible tasl if not run on swarm manager - code cleanup * * docker_node: Remove the 'action' list from output * * docker_node: variable name change to be align with Python best practice, BOTMETA.yml update * * module_utils/docker_swarm.py: fix for incorrect fail() action * docker_node: documentation and code small updates * * docker_node: revised labels manipulation * docker_node_facts: Reverting to repository version, moving this change to separate PR * * docker_node: Documentation update * * docker_node: Update to node availability and role modification * * docker_node: Update to check_mode handling * * docker_node: Code cleanup * docker_node_facts: Code cleanup * docker_node_facts: Adding back the module with only update to use AnsibleDockerSwarmClient instead of AnsibleDockerClient docker_node: cosmetic code changes BOTMETA: updated on $team_docker * docker_node: BOTMETA update --- .github/BOTMETA.yml | 3 +- lib/ansible/module_utils/docker_swarm.py | 218 +++++++++++++ .../modules/cloud/docker/docker_node.py | 295 ++++++++++++++++++ .../modules/cloud/docker/docker_node_facts.py | 5 +- 4 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 lib/ansible/module_utils/docker_swarm.py create mode 100644 lib/ansible/modules/cloud/docker/docker_node.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 87fd28ad8cc..8d2b597a1ae 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -584,6 +584,7 @@ files: maintainers: $team_windows_core support: core $module_utils/docker_common.py: *docker + $module_utils/docker_swarm.py: *docker $module_utils/ec2.py: support: core labels: @@ -1337,7 +1338,7 @@ macros: team_cumulus: isharacomix jrrivers team_cyberark_conjur: jvanderhoof ryanprior team_digital_ocean: aluvenko, BondAnthony, mgregson - team_docker: akshay196 danihodovic dariko DBendit felixfontein jwitko kassiansun tbouvet + team_docker: akshay196 danihodovic dariko DBendit felixfontein jwitko kassiansun tbouvet WojciechowskiPiotr team_e_spirit: MatrixCrawler getjack team_extreme: bigmstone LindsayHill team_fortimanager: Ghilli3 lweighall p4r4n0y1ng Ftntcorecse diff --git a/lib/ansible/module_utils/docker_swarm.py b/lib/ansible/module_utils/docker_swarm.py new file mode 100644 index 00000000000..19e3a379e00 --- /dev/null +++ b/lib/ansible/module_utils/docker_swarm.py @@ -0,0 +1,218 @@ +# (c) 2019 Piotr Wojciechowski (@wojciechowskipiotr) +# (c) Thierry Bouvet (@tbouvet) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import json + +try: + from docker.errors import APIError +except ImportError: + # missing docker-py handled in ansible.module_utils.docker_common + pass + +from ansible.module_utils._text import to_native +from ansible.module_utils.docker_common import AnsibleDockerClient + + +class AnsibleDockerSwarmClient(AnsibleDockerClient): + + def __init__(self, **kwargs): + super(AnsibleDockerSwarmClient, self).__init__(**kwargs) + + def get_swarm_node_id(self): + """ + Get the 'NodeID' of the Swarm node or 'None' if host is not in Swarm. It returns the NodeID + of Docker host the module is executed on + :return: + NodeID of host or 'None' if not part of Swarm + """ + + try: + info = self.info() + except APIError as exc: + self.fail(msg="Failed to get node information for %s" % to_native(exc)) + + if info: + json_str = json.dumps(info, ensure_ascii=False) + swarm_info = json.loads(json_str) + if swarm_info['Swarm']['NodeID']: + return swarm_info['Swarm']['NodeID'] + return None + + def check_if_swarm_node(self, node_id=None): + """ + Checking if host is part of Docker Swarm. If 'node_id' is not provided it reads the Docker host + system information looking if specific key in output exists. If 'node_id' is provided then it tries to + read node information assuming it is run on Swarm manager. The get_node_inspect() method handles exception if + it is not executed on Swarm manager + + :param node_id: Node identifier + :return: + bool: True if node is part of Swarm, False otherwise + """ + + if node_id is None: + try: + info = self.info() + except APIError: + self.fail(msg="Failed to get host information.") + + if info: + json_str = json.dumps(info, ensure_ascii=False) + swarm_info = json.loads(json_str) + if swarm_info['Swarm']['NodeID']: + return True + return False + else: + try: + node_info = self.get_node_inspect(node_id=node_id) + except APIError: + return + + if node_info['ID'] is not None: + return True + return False + + def check_if_swarm_manager(self): + """ + Checks if node role is set as Manager in Swarm. The node is the docker host on which module action + is performed. The inspect_swarm() will fail if node is not a manager + + :return: True if node is Swarm Manager, False otherwise + """ + + try: + self.inspect_swarm() + return True + except APIError: + return False + + def fail_task_if_not_swarm_manager(self): + """ + If host is not a swarm manager then Ansible task on this host should end with 'failed' state + """ + if not self.check_if_swarm_manager(): + self.fail(msg="This node is not a manager.") + + def check_if_swarm_worker(self): + """ + Checks if node role is set as Worker in Swarm. The node is the docker host on which module action + is performed. Will fail if run on host that is not part of Swarm via check_if_swarm_node() + + :return: True if node is Swarm Worker, False otherwise + """ + + if self.check_if_swarm_node() and not self.check_if_swarm_manager(): + return True + return False + + def check_if_swarm_node_is_down(self, node_id=None): + """ + Checks if node status on Swarm manager is 'down'. If node_id is provided it query manager about + node specified in parameter, otherwise it query manager itself. If run on Swarm Worker node or + host that is not part of Swarm it will fail the playbook + + :param node_id: node ID or name, if None then method will try to get node_id of host module run on + :return: + True if node is part of swarm but its state is down, False otherwise + """ + + if node_id is None: + node_id = self.get_swarm_node_id() + + node_info = self.get_node_inspect(node_id=node_id) + if node_info['Status']['State'] == 'down': + return True + return False + + def get_node_inspect(self, node_id=None, skip_missing=False): + """ + Returns Swarm node info as in 'docker node inspect' command about single node + + :param skip_missing: if True then function will return None instead of failing the task + :param node_id: node ID or name, if None then method will try to get node_id of host module run on + :return: + Single node information structure + """ + + if node_id is None: + node_id = self.get_swarm_node_id() + + if node_id is None: + self.fail(msg="Failed to get node information.") + + try: + node_info = self.inspect_node(node_id=node_id) + except APIError as exc: + if exc.status_code == 503: + self.fail(msg="Cannot inspect node: To inspect node execute module on Swarm Manager") + if exc.status_code == 404: + if skip_missing is False: + self.fail(msg="Error while reading from Swarm manager: %s" % to_native(exc)) + else: + return None + except Exception as exc: + self.module.fail_json(msg="Error inspecting swarm node: %s" % exc) + + json_str = json.dumps(node_info, ensure_ascii=False) + node_info = json.loads(json_str) + return node_info + + def get_all_nodes_inspect(self): + """ + Returns Swarm node info as in 'docker node inspect' command about all registered nodes + + :return: + Structure with information about all nodes + """ + try: + node_info = self.nodes() + except APIError as exc: + if exc.status_code == 503: + self.fail(msg="Cannot inspect node: To inspect node execute module on Swarm Manager") + self.fail(msg="Error while reading from Swarm manager: %s" % to_native(exc)) + except Exception as exc: + self.module.fail_json(msg="Error inspecting swarm node: %s" % exc) + + json_str = json.dumps(node_info, ensure_ascii=False) + node_info = json.loads(json_str) + return node_info + + def get_all_nodes_list(self, output='short'): + """ + Returns list of nodes registered in Swarm + + :param output: Defines format of returned data + :return: + If 'output' is 'short' then return data is list of nodes hostnames registered in Swarm, + if 'output' is 'long' then returns data is list of dict containing the attributes as in + output of command 'docker node ls' + """ + nodes_list = [] + + nodes_inspect = self.get_all_nodes_inspect() + if nodes_inspect is None: + return None + + if output == 'short': + for node in nodes_inspect: + nodes_list.append(node['Description']['Hostname']) + elif output == 'long': + for node in nodes_inspect: + node_property = {} + + node_property.update({'ID': node['ID']}) + node_property.update({'Hostname': node['Description']['Hostname']}) + node_property.update({'Status': node['Status']['State']}) + node_property.update({'Availability': node['Spec']['Availability']}) + if 'ManagerStatus' in node: + if node['ManagerStatus']['Leader'] is True: + node_property.update({'Leader': True}) + node_property.update({'ManagerStatus': node['ManagerStatus']['Reachability']}) + node_property.update({'EngineVersion': node['Description']['Engine']['EngineVersion']}) + + nodes_list.append(node_property) + else: + return None + + return nodes_list diff --git a/lib/ansible/modules/cloud/docker/docker_node.py b/lib/ansible/modules/cloud/docker/docker_node.py new file mode 100644 index 00000000000..ef913df45d3 --- /dev/null +++ b/lib/ansible/modules/cloud/docker/docker_node.py @@ -0,0 +1,295 @@ +#!/usr/bin/python +# +# (c) 2019 Piotr Wojciechowski +# 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_node +short_description: Manage Docker Swarm node +version_added: "2.8" +description: + - Manages the Docker nodes via Swarm Manager. + - This module allows to change the node's role, its availability, and to modify, add or remove node labels. +options: + hostname: + description: + - The hostname or ID of node as registered in Swarm. + - If more than one node is registered using the same hostname the ID must be used, + otherwise module will fail. + required: true + type: str + labels: + description: + - User-defined key/value metadata that will be assigned as node attribute. + - The actual state of labels assigned to the node when module completes its work depends on + I(labels_state) and I(labels_to_remove) parameters values. See description below. + required: false + type: dict + labels_state: + description: + - It defines the operation on the labels assigned to node and labels specified in I(labels) option. + - Set to C(merge) to combine labels provided in I(labels) with those already assigned to the node. + If no labels are assigned then it will add listed labels. For labels that are already assigned + to the node, it will update their values. The labels not specified in I(labels) will remain unchanged. + If I(labels) is empty then no changes will be made. + - Set to C(replace) to replace all assigned labels with provided ones. If I(labels) is empty then + all labels assigned to the node will be removed. + choices: + - merge + - replace + default: 'merge' + required: false + type: str + labels_to_remove: + description: + - List of labels that will be removed from the node configuration. The list has to contain only label + names, not their values. + - If the label provided on the list is not assigned to the node, the entry is ignored. + - If the label is both on the I(labels_to_remove) and I(labels), then value provided in I(labels) remains + assigned to the node. + - If I(labels_state) is C(replace) and I(labels) is not provided or empty then all labels assigned to + node are removed and I(labels_to_remove) is ignored. + required: false + type: list + availability: + description: Node availability to assign. If not provided then node availability remains unchanged. + choices: + - active + - pause + - drain + required: false + type: str + role: + description: Node role to assign. If not provided then node role remains unchanged. + choices: + - manager + - worker + required: false + type: str +extends_documentation_fragment: + - docker +requirements: + - "python >= 2.6" + - "docker-py >= 1.10.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). + For Python 2.6, C(docker-py) must be used. Otherwise, it is recommended to + install the C(docker) Python module. Note that both modules should I(not) + be installed at the same time. Also note that when both modules are installed + and one of them is uninstalled, the other might no longer function and a + reinstall of it is required." + - Docker API >= 1.25 +author: + - Piotr Wojciechowski (@WojciechowskiPiotr) + - Thierry Bouvet (@tbouvet) + +''' + +EXAMPLES = ''' +- name: Set node role + docker_node: + hostname: mynode + role: manager + +- name: Set node availability + docker_node: + hostname: mynode + availability: drain + +- name: Replace node labels with new labels + docker_node: + hostname: mynode + labels: + key: value + labels_state: replace + +- name: Merge node labels and new labels + docker_node: + hostname: mynode + labels: + key: value + +- name: Remove all labels assigned to node + docker_node: + hostname: mynode + labels_state: replace + +- name: Remove selected labels from the node + docker_node: + hostname: mynode + labels_to_remove: + - key1 + - key2 +''' + +RETURN = ''' +node_facts: + description: Information about node after 'update' operation + returned: success + type: dict + +''' + +try: + from docker.errors import APIError +except ImportError: + # missing docker-py handled in ansible.module_utils.docker_common + pass + +from ansible.module_utils.docker_common import ( + DockerBaseClass, +) + +from ansible.module_utils._text import to_native + +from ansible.module_utils.docker_swarm import AnsibleDockerSwarmClient + + +class TaskParameters(DockerBaseClass): + def __init__(self, client): + super(TaskParameters, self).__init__() + + # Spec + self.name = None + self.labels = None + self.labels_state = None + self.labels_to_remove = None + + # Node + self.availability = None + self.role = None + + for key, value in client.module.params.items(): + setattr(self, key, value) + + +class SwarmNodeManager(DockerBaseClass): + + def __init__(self, client, results): + + super(SwarmNodeManager, self).__init__() + + self.client = client + self.results = results + self.check_mode = self.client.check_mode + + self.client.fail_task_if_not_swarm_manager() + + self.parameters = TaskParameters(client) + + self.node_update() + + def node_update(self): + if not (self.client.check_if_swarm_node(node_id=self.parameters.hostname)): + self.client.fail(msg="This node is not part of a swarm.") + return + + if self.client.check_if_swarm_node_is_down(): + self.client.fail(msg="Can not update the node. The node is down.") + + try: + node_info = self.client.inspect_node(node_id=self.parameters.hostname) + except APIError as exc: + self.client.fail(msg="Failed to get node information for %s" % to_native(exc)) + + changed = False + node_spec = dict( + Availability=self.parameters.availability, + Role=self.parameters.role, + Labels=self.parameters.labels, + ) + + if self.parameters.role is None: + node_spec['Role'] = node_info['Spec']['Role'] + else: + if not node_info['Spec']['Role'] == self.parameters.role: + node_spec['Role'] = self.parameters.role + changed = True + + if self.parameters.availability is None: + node_spec['Availability'] = node_info['Spec']['Availability'] + else: + if not node_info['Spec']['Availability'] == self.parameters.availability: + node_info['Spec']['Availability'] = self.parameters.availability + changed = True + + if self.parameters.labels_state == 'replace': + if self.parameters.labels is None: + node_spec['Labels'] = {} + if node_info['Spec']['Labels']: + changed = True + else: + if (node_info['Spec']['Labels'] or {}) != self.parameters.labels: + node_spec['Labels'] = self.parameters.labels + changed = True + elif self.parameters.labels_state == 'merge': + node_spec['Labels'] = dict(node_info['Spec']['Labels'] or {}) + if self.parameters.labels is not None: + for key, value in self.parameters.labels.items(): + if node_spec['Labels'].get(key) != value: + node_spec['Labels'][key] = value + changed = True + + if self.parameters.labels_to_remove is not None: + for key in self.parameters.labels_to_remove: + if not self.parameters.labels.get(key): + if node_spec['Labels'].get(key): + node_spec['Labels'].pop(key) + changed = True + else: + self.client.module.warn( + "Label '%s' listed both in 'labels' and 'labels_to_remove'. " + "Keeping the assigned label value." + % to_native(key)) + + if changed is True: + if not self.check_mode: + try: + self.client.update_node(node_id=node_info['ID'], version=node_info['Version']['Index'], + node_spec=node_spec) + except APIError as exc: + self.client.fail(msg="Failed to update node : %s" % to_native(exc)) + self.results['node_facts'] = self.client.get_node_inspect(node_id=node_info['ID']) + self.results['changed'] = changed + else: + self.results['node_facts'] = node_info + self.results['changed'] = changed + + +def main(): + argument_spec = dict( + hostname=dict(type='str', required=True), + labels=dict(type='dict'), + labels_state=dict(type='str', choices=['merge', 'replace'], default='merge'), + labels_to_remove=dict(type='list', elements='str'), + availability=dict(type='str', choices=['active', 'pause', 'drain']), + role=dict(type='str', choices=['worker', 'manager']), + ) + + client = AnsibleDockerSwarmClient( + argument_spec=argument_spec, + supports_check_mode=True, + min_docker_version='1.10.0', + min_docker_api_version='1.25', + ) + + results = dict( + changed=False, + ) + + SwarmNodeManager(client, results) + client.module.exit_json(**results) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/cloud/docker/docker_node_facts.py b/lib/ansible/modules/cloud/docker/docker_node_facts.py index df9844cd29c..6ac4aee84b8 100644 --- a/lib/ansible/modules/cloud/docker/docker_node_facts.py +++ b/lib/ansible/modules/cloud/docker/docker_node_facts.py @@ -75,9 +75,10 @@ node_facts: ''' -from ansible.module_utils.docker_common import AnsibleDockerClient from ansible.module_utils._text import to_native +from ansible.module_utils.docker_swarm import AnsibleDockerSwarmClient + try: from docker.errors import APIError, NotFound except ImportError: @@ -103,7 +104,7 @@ def main(): name=dict(type='str', required=True), ) - client = AnsibleDockerClient( + client = AnsibleDockerSwarmClient( argument_spec=argument_spec, supports_check_mode=True, min_docker_version='1.10.0',