#!/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_swarm
short_description: Manage Swarm cluster
version_added: "2.7"
description:
     - Create a new Swarm cluster.
     - Add/Remove nodes or managers to an existing cluster.
options:
    advertise_addr:
        description:
            - Externally reachable address advertised to other nodes.
            - This can either be an address/port combination
                in the form C(192.168.1.1:4567), or an interface followed by a
                port number, like C(eth0:4567).
            - If the port number is omitted,
                the port number from the listen address is used.
            - If C(advertise_addr) is not specified, it will be automatically
                detected when possible.
    listen_addr:
        description:
            - Listen address used for inter-manager communication.
            - This can either be an address/port combination in the form
                C(192.168.1.1:4567), or an interface followed by a port number,
                like C(eth0:4567).
            - If the port number is omitted, the default swarm listening port
                is used.
        default: 0.0.0.0:2377
    force:
        description:
            - Use with state C(present) to force creating a new Swarm, even if already part of one.
            - Use with state C(absent) to Leave the swarm even if this node is a manager.
        type: bool
        default: 'no'
    state:
        description:
            - Set to C(present), to create/update a new cluster.
            - Set to C(join), to join an existing cluster.
            - Set to C(absent), to leave an existing cluster.
            - Set to C(remove), to remove an absent node from the cluster.
            - Set to C(inspect) to display swarm informations.
        required: true
        default: present
        choices:
          - present
          - join
          - absent
          - remove
          - inspect
    node_id:
        description:
            - Swarm id of the node to remove.
            - Used with I(state=remove).
    join_token:
        description:
            - Swarm token used to join a swarm cluster.
            - Used with I(state=join).
    remote_addrs:
        description:
            - Remote address of a manager to connect to.
            - Used with I(state=join).
    task_history_retention_limit:
        description:
            - Maximum number of tasks history stored.
            - Docker default value is C(5).
    snapshot_interval:
        description:
            - Number of logs entries between snapshot.
            - Docker default value is C(10000).
    keep_old_snapshots:
        description:
            - Number of snapshots to keep beyond the current snapshot.
            - Docker default value is C(0).
    log_entries_for_slow_followers:
        description:
            - Number of log entries to keep around to sync up slow followers after a snapshot is created.
    heartbeat_tick:
        description:
            - Amount of ticks (in seconds) between each heartbeat.
            - Docker default value is C(1s).
    election_tick:
        description:
            - Amount of ticks (in seconds) needed without a leader to trigger a new election.
            - Docker default value is C(10s).
    dispatcher_heartbeat_period:
        description:
            - The delay for an agent to send a heartbeat to the dispatcher.
            - Docker default value is C(5s).
    node_cert_expiry:
        description:
            - Automatic expiry for nodes certificates.
            - Docker default value is C(3months).
    name:
        description:
            - The name of the swarm.
    labels:
        description:
            - User-defined key/value metadata.
    signing_ca_cert:
        description:
            - The desired signing CA certificate for all swarm node TLS leaf certificates, in PEM format.
    signing_ca_key:
        description:
            - The desired signing CA key for all swarm node TLS leaf certificates, in PEM format.
    ca_force_rotate:
        description:
            - An integer whose purpose is to force swarm to generate a new signing CA certificate and key,
                if none have been specified.
            - Docker default value is C(0).
    autolock_managers:
        description:
            - If set, generate a key and use it to lock data stored on the managers.
            - Docker default value is C(no).
        type: bool
    rotate_worker_token:
        description: Rotate the worker join token.
        type: bool
        default: 'no'
    rotate_manager_token:
        description: Rotate the manager join token.
        type: bool
        default: 'no'
extends_documentation_fragment:
    - docker
requirements:
    - python >= 2.7
    - "docker-py >= 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.1.0 or newer is only available with the C(docker) module."
    - Docker API >= 1.25
author:
  - Thierry Bouvet (@tbouvet)
'''

EXAMPLES = '''

- name: Init a new swarm with default parameters
  docker_swarm:
    state: present

- name: Update swarm configuration
  docker_swarm:
    state: present
    election_tick: 5

- name: Add nodes
  docker_swarm:
    state: join
    advertise_addr: 192.168.1.2
    join_token: SWMTKN-1--xxxxx
    remote_addrs: [ '192.168.1.1:2377' ]

- name: Leave swarm for a node
  docker_swarm:
    state: absent

- name: Remove a swarm manager
  docker_swarm:
    state: absent
    force: true

- name: Remove node from swarm
  docker_swarm:
    state: remove
    node_id: mynode

- name: Inspect swarm
  docker_swarm:
    state: inspect
  register: swarm_info
'''

RETURN = '''
swarm_facts:
  description: Informations about swarm.
  returned: success
  type: complex
  contains:
      JoinTokens:
          description: Tokens to connect to the Swarm.
          returned: success
          type: complex
          contains:
              Worker:
                  description: Token to create a new I(worker) node
                  returned: success
                  type: str
                  example: SWMTKN-1--xxxxx
              Manager:
                  description: Token to create a new I(manager) node
                  returned: success
                  type: str
                  example: SWMTKN-1--xxxxx
actions:
  description: Provides the actions done on the swarm.
  returned: when action failed.
  type: list
  example: "['This cluster is already a swarm cluster']"

'''

import json
from distutils.version import LooseVersion
from time import sleep
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 (
    AnsibleDockerClient,
    DockerBaseClass,
    docker_version,
)
from ansible.module_utils._text import to_native


class TaskParameters(DockerBaseClass):
    def __init__(self, client):
        super(TaskParameters, self).__init__()

        self.state = None
        self.advertise_addr = None
        self.listen_addr = None
        self.force_new_cluster = None
        self.remote_addrs = None
        self.join_token = None

        # Spec
        self.snapshot_interval = None
        self.task_history_retention_limit = None
        self.keep_old_snapshots = None
        self.log_entries_for_slow_followers = None
        self.heartbeat_tick = None
        self.election_tick = None
        self.dispatcher_heartbeat_period = None
        self.node_cert_expiry = None
        self.external_cas = None
        self.name = None
        self.labels = None
        self.log_driver = None
        self.signing_ca_cert = None
        self.signing_ca_key = None
        self.ca_force_rotate = None
        self.autolock_managers = None
        self.rotate_worker_token = None
        self.rotate_manager_token = None

        for key, value in client.module.params.items():
            setattr(self, key, value)

        self.update_parameters(client)

    def update_parameters(self, client):
        self.spec = client.create_swarm_spec(
            snapshot_interval=self.snapshot_interval,
            task_history_retention_limit=self.task_history_retention_limit,
            keep_old_snapshots=self.keep_old_snapshots,
            log_entries_for_slow_followers=self.log_entries_for_slow_followers,
            heartbeat_tick=self.heartbeat_tick,
            election_tick=self.election_tick,
            dispatcher_heartbeat_period=self.dispatcher_heartbeat_period,
            node_cert_expiry=self.node_cert_expiry,
            name=self.name,
            labels=self.labels,
            signing_ca_cert=self.signing_ca_cert,
            signing_ca_key=self.signing_ca_key,
            ca_force_rotate=self.ca_force_rotate,
            autolock_managers=self.autolock_managers,
            log_driver=self.log_driver
        )


class SwarmManager(DockerBaseClass):

    def _get_minimal_versions(self):
        # TODO: Move this and the same from docker_container.py to docker_common.py
        self.option_minimal_versions = dict()
        for option, data in self.client.module.argument_spec.items():
            self.option_minimal_versions[option] = dict()
        self.option_minimal_versions.update(dict(
            signing_ca_cert=dict(docker_api_version='1.30'),
            signing_ca_key=dict(docker_api_version='1.30'),
            ca_force_rotate=dict(docker_api_version='1.30'),
        ))

        for option, data in self.option_minimal_versions.items():
            # Test whether option is supported, and store result
            support_docker_py = True
            support_docker_api = True
            if 'docker_py_version' in data:
                support_docker_py = self.client.docker_py_version >= LooseVersion(data['docker_py_version'])
            if 'docker_api_version' in data:
                support_docker_api = self.client.docker_api_version >= LooseVersion(data['docker_api_version'])
            data['supported'] = support_docker_py and support_docker_api
            # Fail if option is not supported but used
            if not data['supported']:
                # Test whether option is specified
                if 'detect_usage' in data:
                    used = data['detect_usage']()
                else:
                    used = self.client.module.params.get(option) is not None
                    if used and 'default' in self.client.module.argument_spec[option]:
                        used = self.client.module.params[option] != self.client.module.argument_spec[option]['default']
                if used:
                    # If the option is used, compose error message.
                    if 'usage_msg' in data:
                        usg = data['usage_msg']
                    else:
                        usg = 'set %s option' % (option, )
                    if not support_docker_api:
                        msg = 'docker API version is %s. Minimum version required is %s to %s.'
                        msg = msg % (self.client.docker_api_version_str, data['docker_api_version'], usg)
                    elif not support_docker_py:
                        if LooseVersion(data['docker_py_version']) < LooseVersion('2.0.0'):
                            msg = ("docker-py version is %s. Minimum version required is %s to %s. "
                                   "Consider switching to the 'docker' package if you do not require Python 2.6 support.")
                        elif self.client.docker_py_version < LooseVersion('2.0.0'):
                            msg = ("docker-py version is %s. Minimum version required is %s to %s. "
                                   "You have to switch to the Python 'docker' package. First uninstall 'docker-py' before "
                                   "installing 'docker' to avoid a broken installation.")
                        else:
                            msg = "docker version is %s. Minimum version required is %s to %s."
                        msg = msg % (docker_version, data['docker_py_version'], usg)
                    else:
                        # should not happen
                        msg = 'Cannot %s with your configuration.' % (usg, )
                    self.client.fail(msg)

    def __init__(self, client, results):

        super(SwarmManager, self).__init__()

        self.client = client
        self.results = results
        self.check_mode = self.client.check_mode

        self._get_minimal_versions()

        self.parameters = TaskParameters(client)

    def __call__(self):
        choice_map = {
            "present": self.init_swarm,
            "join": self.join,
            "absent": self.leave,
            "remove": self.remove,
            "inspect": self.inspect_swarm
        }

        choice_map.get(self.parameters.state)()

    def __isSwarmManager(self):
        try:
            data = self.client.inspect_swarm()
            json_str = json.dumps(data, ensure_ascii=False)
            self.swarm_info = json.loads(json_str)
            return True
        except APIError:
            return False

    def inspect_swarm(self):
        try:
            data = self.client.inspect_swarm()
            json_str = json.dumps(data, ensure_ascii=False)
            self.swarm_info = json.loads(json_str)
            self.results['changed'] = False
            self.results['swarm_facts'] = self.swarm_info
        except APIError:
            return

    def init_swarm(self):
        if self.__isSwarmManager():
            self.__update_swarm()
            return

        try:
            self.client.init_swarm(
                advertise_addr=self.parameters.advertise_addr, listen_addr=self.parameters.listen_addr,
                force_new_cluster=self.parameters.force_new_cluster, swarm_spec=self.parameters.spec)
        except APIError as exc:
            self.client.fail(msg="Can not create a new Swarm Cluster: %s" % to_native(exc))

        self.__isSwarmManager()
        self.results['actions'].append("New Swarm cluster created: %s" % (self.swarm_info['ID']))
        self.results['changed'] = True
        self.results['swarm_facts'] = {u'JoinTokens': self.swarm_info['JoinTokens']}

    def __update_spec(self, spec):
        if (self.parameters.node_cert_expiry is None):
            self.parameters.node_cert_expiry = spec['CAConfig']['NodeCertExpiry']

        if (self.parameters.dispatcher_heartbeat_period is None):
            self.parameters.dispatcher_heartbeat_period = spec['Dispatcher']['HeartbeatPeriod']

        if (self.parameters.snapshot_interval is None):
            self.parameters.snapshot_interval = spec['Raft']['SnapshotInterval']
        if (self.parameters.keep_old_snapshots is None):
            self.parameters.keep_old_snapshots = spec['Raft']['KeepOldSnapshots']
        if (self.parameters.heartbeat_tick is None):
            self.parameters.heartbeat_tick = spec['Raft']['HeartbeatTick']
        if (self.parameters.log_entries_for_slow_followers is None):
            self.parameters.log_entries_for_slow_followers = spec['Raft']['LogEntriesForSlowFollowers']
        if (self.parameters.election_tick is None):
            self.parameters.election_tick = spec['Raft']['ElectionTick']

        if (self.parameters.task_history_retention_limit is None):
            self.parameters.task_history_retention_limit = spec['Orchestration']['TaskHistoryRetentionLimit']

        if (self.parameters.autolock_managers is None):
            self.parameters.autolock_managers = spec['EncryptionConfig']['AutoLockManagers']

        if (self.parameters.name is None):
            self.parameters.name = spec['Name']

        if (self.parameters.labels is None):
            self.parameters.labels = spec['Labels']

        if 'LogDriver' in spec['TaskDefaults']:
            self.parameters.log_driver = spec['TaskDefaults']['LogDriver']

        self.parameters.update_parameters(self.client)

        return self.parameters.spec

    def __update_swarm(self):
        try:
            self.inspect_swarm()
            version = self.swarm_info['Version']['Index']
            spec = self.swarm_info['Spec']
            new_spec = self.__update_spec(spec)
            del spec['TaskDefaults']
            if spec == new_spec:
                self.results['actions'].append("No modification")
                self.results['changed'] = False
                return
            self.client.update_swarm(
                version=version, swarm_spec=new_spec, rotate_worker_token=self.parameters.rotate_worker_token,
                rotate_manager_token=self.parameters.rotate_manager_token)
        except APIError as exc:
            self.client.fail(msg="Can not update a Swarm Cluster: %s" % to_native(exc))
            return

        self.inspect_swarm()
        self.results['actions'].append("Swarm cluster updated")
        self.results['changed'] = True

    def __isSwarmNode(self):
        info = self.client.info()
        if info:
            json_str = json.dumps(info, ensure_ascii=False)
            self.swarm_info = json.loads(json_str)
            if self.swarm_info['Swarm']['NodeID']:
                return True
        return False

    def join(self):
        if self.__isSwarmNode():
            self.results['actions'].append("This node is already part of a swarm.")
            return
        try:
            self.client.join_swarm(
                remote_addrs=self.parameters.remote_addrs, join_token=self.parameters.join_token, listen_addr=self.parameters.listen_addr,
                advertise_addr=self.parameters.advertise_addr)
        except APIError as exc:
            self.client.fail(msg="Can not join the Swarm Cluster: %s" % to_native(exc))
        self.results['actions'].append("New node is added to swarm cluster")
        self.results['changed'] = True

    def leave(self):
        if not(self.__isSwarmNode()):
            self.results['actions'].append("This node is not part of a swarm.")
            return
        try:
            self.client.leave_swarm(force=self.parameters.force)
        except APIError as exc:
            self.client.fail(msg="This node can not leave the Swarm Cluster: %s" % to_native(exc))
        self.results['actions'].append("Node has leaved the swarm cluster")
        self.results['changed'] = True

    def __get_node_info(self):
        try:
            node_info = self.client.inspect_node(node_id=self.parameters.node_id)
        except APIError as exc:
            raise exc
        json_str = json.dumps(node_info, ensure_ascii=False)
        node_info = json.loads(json_str)
        return node_info

    def __check_node_is_down(self):
        for _x in range(0, 5):
            node_info = self.__get_node_info()
            if node_info['Status']['State'] == 'down':
                return True
            sleep(5)
        return False

    def remove(self):
        if not(self.__isSwarmManager()):
            self.client.fail(msg="This node is not a manager.")

        try:
            status_down = self.__check_node_is_down()
        except APIError:
            return

        if not(status_down):
            self.client.fail(msg="Can not remove the node. The status node is ready and not down.")

        try:
            self.client.remove_node(node_id=self.parameters.node_id, force=self.parameters.force)
        except APIError as exc:
            self.client.fail(msg="Can not remove the node from the Swarm Cluster: %s" % to_native(exc))
        self.results['actions'].append("Node is removed from swarm cluster.")
        self.results['changed'] = True


def main():
    argument_spec = dict(
        advertise_addr=dict(type='str'),
        state=dict(type='str', choices=['present', 'join', 'absent', 'remove', 'inspect'], default='present'),
        force=dict(type='bool', default=False),
        listen_addr=dict(type='str', default='0.0.0.0:2377'),
        remote_addrs=dict(type='list', elements='str'),
        join_token=dict(type='str'),
        snapshot_interval=dict(type='int'),
        task_history_retention_limit=dict(type='int'),
        keep_old_snapshots=dict(type='int'),
        log_entries_for_slow_followers=dict(type='int'),
        heartbeat_tick=dict(type='int'),
        election_tick=dict(type='int'),
        dispatcher_heartbeat_period=dict(type='int'),
        node_cert_expiry=dict(type='int'),
        name=dict(type='str'),
        labels=dict(type='dict'),
        signing_ca_cert=dict(type='str'),
        signing_ca_key=dict(type='str'),
        ca_force_rotate=dict(type='int'),
        autolock_managers=dict(type='bool'),
        node_id=dict(type='str'),
        rotate_worker_token=dict(type='bool', default=False),
        rotate_manager_token=dict(type='bool', default=False)
    )

    required_if = [
        ('state', 'join', ['advertise_addr', 'remote_addrs', 'join_token']),
        ('state', 'remove', ['node_id'])
    ]

    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.25',
    )

    results = dict(
        changed=False,
        result='',
        actions=[]
    )

    SwarmManager(client, results)()
    client.module.exit_json(**results)


if __name__ == '__main__':
    main()
