#!/usr/bin/python
#
# Copyright 2016 Red Hat | Ansible
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.

DOCUMENTATION = '''

module: docker_service

short_description: Manage docker services and containers.

version_added: "2.1"

author: "Chris Houseknecht (@chouseknecht)"

description:
  - Consumes docker compose to start, shutdown and scale services.
  - Works with compose versions 1 and 2.
  - Compose can be read from a docker-compose.yml (or .yaml) file or inline using the C(definition) option.
  - See the examples for more details.
  - Supports check mode.

options:
  project_src:
      description:
        - Path to a directory containing a docker-compose.yml or docker-compose.yaml file.
        - Mutually exclusive with C(definition).
        - Required when no C(definition) is provided.
      type: path
      required: false
  project_name:
      description:
        - Provide a project name. If not provided, the project name is taken from the basename of C(project_src).
        - Required when no C(definition) is provided.
      type: str
      required: false
  files:
      description:
        - List of file names relative to C(project_src). Overrides docker-compose.yml or docker-compose.yaml.
        - Files are loaded and merged in the order given.
      type: list
      required: false
  state:
      description:
        - Desired state of the project.
        - Specifying I(present) is the same as running I(docker-compose up).
        - Specifying I(absent) is the same as running I(docker-compose down).
      choices:
        - absent
        - present
      default: present
      type: str
      required: false
  services:
      description:
        - When C(state) is I(present) run I(docker-compose up) on a subset of services.
      type: list
      required: false
  scale:
      description:
        - When C(sate) is I(present) scale services. Provide a dictionary of key/value pairs where the key
          is the name of the service and the value is an integer count for the number of containers.
      type: complex
      required: false
  dependencies:
      description:
        - When C(state) is I(present) specify whether or not to include linked services.
      type: bool
      required: false
      default: true
  definition:
      description:
        - Provide docker-compose yaml describing one or more services, networks and volumes.
        - Mutually exclusive with C(project_src) and C(project_files).
      type: complex
      required: false
  hostname_check:
      description:
        - Whether or not to check the Docker daemon's hostname against the name provided in the client certificate.
      type: bool
      required: false
      default: false
  recreate:
      description:
        - By default containers will be recreated when their configuration differs from the service definition.
        - Setting to I(never) ignores configuration differences and leaves existing containers unchanged.
        - Setting to I(always) forces recreation of all existing containers.
      type: str
      required: false
      choices:
        - always
        - never
        - smart
      default: smart
  build:
      description:
        - Whether or not to build images before starting containers.
        - Missing images will always be built.
        - If an image is present and C(build) is false, the image will not be built.
        - If an image is present and C(build) is true, the image will be built.
      type: bool
      required: false
      default: true
  remove_images:
      description:
        - Use with state I(absent) to remove the all images or only local images.
      type: str
      required: false
      default: null
  remove_volumes:
      description:
        - Use with state I(absent) to remove data volumes.
      required: false
      type: bool
      default: false
  stopped:
      description:
        - Use with state I(present) to leave the containers in an exited or non-running state.
      required: false
      type: bool
      default: false
  restarted:
      description:
        - Use with state I(present) to restart all containers.
      required: false
      type: bool
      default: false
  debug:
      description:
        - Include I(actions) in the return values.
      required: false
      type: bool
      default: false

extends_documentation_fragment:
    - docker

requirements:
    - "python >= 2.6"
    - "docker-compose >= 1.7.0"
    - "Docker API >= 1.20"
'''

EXAMPLES = '''
# Examples use the django example at U(https://docs.docker.com/compose/django/). Follow it to create the flask
# directory

- name: Run using a project directory
  hosts: localhost
  connection: local
  gather_facts: no
  tasks:
    - docker_service:
        project_src: flask
        state: absent

    - docker_service:
        project_src: flask
      register: output

    - debug: var=output

    - docker_service:
        project_src: flask
        build: no
      register: output

    - debug: var=output

    - assert:
        that: "not output.changed "

    - docker_service:
        project_src: flask
        build: no
        stopped: true
      register: output

    - debug: var=output

    - assert:
        that:
          - "not web.flask_web_1.state.running"
          - "not db.flask_db_1.state.running"

    - docker_service:
        project_src: flask
        build: no
        restarted: true
      register: output

    - debug: var=output

    - assert:
        that:
          - "web.flask_web_1.state.running"
          - "db.flask_db_1.state.running"

- name: Scale the web service to 2
  hosts: localhost
  connection: local
  gather_facts: no
  tasks:
    - docker_service:
        project_src: flask
        scale:
          web: 2
      register: output

    - debug: var=output

- name: Run with inline v2 compose
  hosts: localhost
  connection: local
  gather_facts: no
  tasks:
    - docker_service:
        project_src: flask
        state: absent

    - docker_service:
        project_name: flask
        definition:
          version: '2'
          services:
            db:
              image: postgres
            web:
              build: "{{ playbook_dir }}/flask"
              command: "python manage.py runserver 0.0.0.0:8000"
              volumes:
                - "{{ playbook_dir }}/flask:/code"
              ports:
                - "8000:8000"
              depends_on:
                - db
      register: output

    - debug: var=output

    - assert:
        that:
          - "web.flask_web_1.state.running"
          - "db.flask_db_1.state.running"

- name: Run with inline v1 compose
  hosts: localhost
  connection: local
  gather_facts: no
  tasks:
    - docker_service:
        project_src: flask
        state: absent

    - docker_service:
        project_name: flask
        definition:
            db:
              image: postgres
            web:
              build: "{{ playbook_dir }}/flask"
              command: "python manage.py runserver 0.0.0.0:8000"
              volumes:
                - "{{ playbook_dir }}/flask:/code"
              ports:
                - "8000:8000"
              links:
                - db
      register: output

    - debug: var=output

    - assert:
        that:
          - "web.flask_web_1.state.running"
          - "db.flask_db_1.state.running"
'''

RETURN = '''
service:
  description: Name of the service.
  returned: success
  type: complex
  contains:
      container_name:
          description: Name of the container. Format is I(project_service_#).
          returned: success
          type: complex
          contains:
              cmd:
                  description: One or more commands to be executed in the container.
                  returned: success
                  type: list
                  example: ["postgres"]
              image:
                  description: Name of the image from which the container was built.
                  returned: success
                  type: str
                  example: postgres
              labels:
                  description: Meta data assigned to the container.
                  returned: success
                  type: complex
                  example: {...}
              networks:
                  description: Contains a dictionary for each network to which the container is a member.
                  returned: success
                  type: complex
                  contains:
                      IPAddress:
                          description: The IP address assigned to the container.
                          returned: success
                          type: string
                          example: 172.17.0.2
                      IPPrefixLen:
                          description: Number of bits used by the subnet.
                          returned: success
                          type: int
                          example: 16
                      aliases:
                          description: Aliases assigned to the container by the network.
                          returned: success
                          type: list
                          example: ['db']
                      globalIPv6:
                          description: IPv6 address assigned to the container.
                          returned: success
                          type: str
                          example: ''
                      globalIPv6PrefixLen:
                          description: IPv6 subnet length.
                          returned: success
                          type: int
                          example: 0
                      links:
                          description: List of container names to which this container is linked.
                          returned: success
                          type: list
                          example: null
                      macAddress:
                          description: Mac Address assigned to the virtual NIC.
                          returned: success
                          type: str
                          example: "02:42:ac:11:00:02"
              state:
                  description: Information regarding the current disposition of the container.
                  returned: success
                  type: complex
                  contains:
                      running:
                          description: Whether or not the container is up with a running process.
                          returned: success
                          type: bool
                          example: true
                      status:
                          description: Description of the running state.
                          returned: success
                          type: str
                          example: running

actions:
  description: Provides the actions to be taken on each service as determined by compose.
  returned: when in check mode or I(debug) true
  type: complex
  contains:
      service_name:
          description: Name of the service.
          returned: always
          type: complex
          contains:
              action:
                  description: A descriptive name of the action to be performed on the set of containers
                               within the service.
                  returned: always
                  type: list
                  contains:
                      id:
                          description: the container's long ID
                          returned: always
                          type: string
                      name:
                          description: the container's name
                          returned: always
                          type: string
                      short_id:
                          description: the container's short ID
                          returned: always
                          type: string
'''

HAS_COMPOSE = True
HAS_COMPOSE_EXC = None

import yaml

from ansible.module_utils.basic import *

try:
    from compose.cli.command import project_from_options
    from compose.service import ConvergenceStrategy
    from compose.cli.main import convergence_strategy_from_opts, build_action_from_opts, image_type_from_opt
except ImportError as exc:
    HAS_COMPOSE = False
    HAS_COMPOSE_EXC = str(exc)

from ansible.module_utils.docker_common import *


AUTH_PARAM_MAPPING = {
    u'docker_host': u'--host',
    u'tls': u'--tls',
    u'cacert_path': u'--tlscacert',
    u'cert_path': u'--tlscert',
    u'key_path': u'--tlskey',
    u'tls_verify': u'--tlsverify'
}


class ContainerManager(DockerBaseClass):

    def __init__(self, client):

        super(ContainerManager, self).__init__()

        self.client = client
        self.project_src = None
        self.files = None
        self.project_name = None
        self.state = None
        self.definition = None
        self.hostname_check = None
        self.timeout = None
        self.remove_images = None
        self.remove_orphans = None
        self.remove_volumes = None
        self.stopped = None
        self.restarted = None
        self.recreate = None
        self.build = None
        self.dependencies = None
        self.services = None
        self.scale = None
        self.debug = None

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

        self.check_mode = client.check_mode

        if not self.debug:
            self.debug = client.module._debug

        self.options = dict()
        self.options.update(self._get_auth_options())
        self.options[u'--skip-hostname-check'] = (not self.hostname_check)

        if self.project_name:
            self.options[u'--project-name'] = self.project_name

        if self.files:
            self.options[u'--file'] = self.files

        if not HAS_COMPOSE:
            self.client.fail("Unable to load docker-compose. Try `pip install docker-compose`. Error: %s" % HAS_COMPOSE_EXC)

        self.log("options: ")
        self.log(self.options, pretty_print=True)

        if self.definition:
            if not self.project_name:
                self.client.fail("Parameter error - project_name required when providing definition.")

            self.project_src = tempfile.mkdtemp(prefix="ansible")
            compose_file = os.path.join(self.project_src, "docker-compose.yml")
            try:
                self.log('writing: ')
                self.log(yaml.dump(self.definition, default_flow_style=False))
                with open(compose_file, 'w') as f:
                    f.write(yaml.dump(self.definition, default_flow_style=False))
            except Exception as exc:
                self.client.fail("Error writing to %s - %s" % (compose_file, str(exc)))
        else:
            if not self.project_src:
                self.client.fail("Parameter error - project_src required.")

        try:
            self.log("project_src: %s" % self.project_src)
            self.project = project_from_options(self.project_src, self.options)
        except Exception as exc:
            self.client.fail("Configuration error - %s" % str(exc))

    def exec_module(self):
        result = dict()

        if self.state == 'present':
            result = self.cmd_up()
        elif self.state == 'absent':
            result = self.cmd_down()

        if self.definition:
            compose_file = os.path.join(self.project_src, "docker-compose.yml")
            self.log("removing %s" % compose_file)
            os.remove(compose_file)
            self.log("removing %s" % self.project_src)
            os.rmdir(self.project_src)

        if not self.check_mode and not self.debug and result.get('actions'):
            result.pop('actions')

        return result

    def _get_auth_options(self):
        options = dict()
        for key, value in self.client.auth_params.items():
            if value is not None:
                option = AUTH_PARAM_MAPPING.get(key)
                if option:
                    options[option] = value
        return options

    def cmd_up(self):

        start_deps = self.dependencies
        service_names = self.services
        detached = True
        result = dict(changed=False, actions=dict(), ansible_facts=dict())

        up_options = {
            u'--no-recreate': False,
            u'--build': self.build,
            u'--no-build': False,
            u'--no-deps': False,
            u'--force-recreate': False,
        }

        if self.recreate == 'never':
            up_options[u'--no-recreate'] = True
        elif self.recreate == 'always':
            up_options[u'--force-recreate'] = True

        if self.remove_orphans:
            up_options[u'--remove-orphans'] = True

        converge = convergence_strategy_from_opts(up_options)
        self.log("convergence strategy: %s" % converge)

        for service in self.project.services:
            if not service_names or service.name in service_names:
                plan = service.convergence_plan(strategy=converge)
                if plan.action != 'noop':
                    result['changed'] = True
                if self.debug or self.check_mode:
                    result['actions'][service.name] = dict()
                    result['actions'][service.name][plan.action] = []
                    for container in plan.containers:
                        result['actions'][service.name][plan.action].append(dict(
                            id=container.id,
                            name=container.name,
                            short_id=container.short_id,
                        ))

        if not self.check_mode and result['changed']:
            try:
                self.project.up(
                    service_names=service_names,
                    start_deps=start_deps,
                    strategy=converge,
                    do_build=build_action_from_opts(up_options),
                    detached=detached,
                    remove_orphans=self.remove_orphans)
            except Exception as exc:
                self.client.fail("Error bring %s up - %s" % (self.project.name, str(exc)))

        if self.stopped:
            result.update(self.cmd_stop(service_names))

        if self.restarted:
            result.update(self.cmd_restart(service_names))

        if self.scale:
            result.update(self.cmd_scale())

        for service in self.project.services:
            result['ansible_facts'][service.name] = dict()
            for container in service.containers(stopped=True):
                inspection = container.inspect()
                # pare down the inspection data to the most useful bits
                facts = dict()
                facts['cmd'] = inspection['Config']['Cmd']
                facts['labels'] = inspection['Config']['Labels']
                facts['image'] = inspection['Config']['Image']
                facts['state'] = dict(
                    running=inspection['State']['Running'],
                    status=inspection['State']['Status'],
                )
                facts['networks'] = dict()
                for key, value in inspection['NetworkSettings']['Networks'].items():
                    facts['networks'][key] = dict(
                        aliases=inspection['NetworkSettings']['Networks'][key]['Aliases'],
                        globalIPv6=inspection['NetworkSettings']['Networks'][key]['GlobalIPv6Address'],
                        globalIPv6PrefixLen=inspection['NetworkSettings']['Networks'][key]['GlobalIPv6PrefixLen'],
                        IPAddress=inspection['NetworkSettings']['Networks'][key]['IPAddress'],
                        IPPrefixLen=inspection['NetworkSettings']['Networks'][key]['IPPrefixLen'],
                        links=inspection['NetworkSettings']['Networks'][key]['Links'],
                        macAddress=inspection['NetworkSettings']['Networks'][key]['MacAddress'],
                    )
                result['ansible_facts'][service.name][container.name] = facts

        return result

    def cmd_down(self):
        result = dict(
            changed=False,
            actions=dict(),
        )

        for service in self.project.services:
            containers = service.containers(stopped=True)
            if len(containers):
                result['changed'] = True
            if self.debug or self.check_mode:
                result['actions'][service.name] = dict()
                result['actions'][service.name]['deleted'] = [container.name for container in containers]

        if not self.check_mode and result['changed']:
            image_type = image_type_from_opt('--rmi', self.remove_images)
            try:
                self.project.down(image_type, self.remove_volumes, self.remove_orphans)
            except Exception as exc:
                self.client.fail("Error bringing %s down - %s" % (self.project.name, str(exc)))

        return result

    def cmd_stop(self, service_names):
        result = dict(
            changed=False,
            actions=dict()
        )
        for service in self.project.services:
            if not service_names or service.name in service_names:
                result['actions'][service.name] = dict()
                result['actions'][service.name]['stop'] = []
                for container in service.containers(stopped=False):
                    result['changed'] = True
                    if self.debug:
                        result['actions'][service.name]['stop'].append(dict(
                            id=container.id,
                            name=container.name,
                            short_id=container.short_id,
                        ))

        if not self.check_mode and result['changed']:
            try:
                self.project.stop(service_names=service_names)
            except Exception as exc:
                self.client.fail("Error stopping services for %s - %s" % (self.project.name, str(exc)))

        return result

    def cmd_restart(self, service_names):
        result = dict(
            changed=False,
            actions=dict()
        )

        for service in self.project.services:
            if not service_names or service.name in service_names:
                result['actions'][service.name] = dict()
                result['actions'][service.name]['restart'] = []
                for container in service.containers(stopped=True):
                    result['changed'] = True
                    if self.debug or self.check_mode:
                        result['actions'][service.name]['restart'].append(dict(
                            id=container.id,
                            name=container.name,
                            short_id=container.short_id,
                        ))

        if not self.check_mode and result['changed']:
            try:
                self.project.restart(service_names=service_names)
            except Exception as exc:
                self.client.fail("Error restarting services for %s - %s" % (self.project.name, str(exc)))

        return result

    def cmd_scale(self):
        result = dict(
            changed=False,
            actions=dict()
        )

        for service in self.project.services:
            if service.name in self.scale:
                result['actions'][service.name] = dict()
                containers = service.containers(stopped=True)
                if len(containers) != self.scale[service.name]:
                    result['changed'] = True
                    if self.debug or self.check_mode:
                        result['actions'][service.name]['scale'] = self.scale[service.name] - len(containers)
                    if not self.check_mode:
                        try:
                            service.scale(self.scale[service.name])
                        except Exception as exc:
                            self.client.fail("Error scaling %s - %s" % (service.name, str(exc)))
        return result


def main():
    argument_spec = dict(
        project_src=dict(type='path'),
        project_name=dict(type='str',),
        files=dict(type='list'),
        state=dict(type='str', choices=['absent', 'present'], default='present'),
        definition=dict(type='dict'),
        hostname_check=dict(type='bool', default=False),
        recreate=dict(type='str', choices=['always','never','smart'], default='smart'),
        build=dict(type='bool', default=True),
        remove_images=dict(type='str', choices=['all', 'local']),
        remove_volumes=dict(type='bool', default=False),
        remove_orphans=dict(type='bool', default=False),
        stopped=dict(type='bool', default=False),
        restarted=dict(type='bool', default=False),
        scale=dict(type='dict'),
        services=dict(type='list'),
        dependencies=dict(type='bool', default=True),
        debug=dict(type='bool', default=False)
    )

    mutually_exclusive = [
        ('definition', 'project_src'),
        ('definition', 'files')
    ]

    client = AnsibleDockerClient(
        argument_spec=argument_spec,
        mutually_exclusive=mutually_exclusive,
        supports_check_mode=True
    )

    result = ContainerManager(client).exec_module()
    client.module.exit_json(**result)


if __name__ == '__main__':
    main()
