# -*- coding: utf-8 -*-
# Copyright (c) 2019, Ximon Eighteen <ximon.eighteen@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

DOCUMENTATION = r"""
name: docker_machine
author: Ximon Eighteen (@ximon18)
short_description: Docker Machine inventory source
requirements:
  - L(Docker Machine,https://docs.docker.com/machine/)
extends_documentation_fragment:
  - ansible.builtin.constructed
  - community.library_inventory_filtering_v1.inventory_filter
description:
  - Get inventory hosts from Docker Machine.
  - Uses a YAML configuration file that ends with V(docker_machine.(yml|yaml\)).
  - The plugin sets standard host variables C(ansible_host), C(ansible_port), C(ansible_user) and C(ansible_ssh_private_key).
  - The plugin stores the Docker Machine 'env' output variables in C(dm_) prefixed host variables.
notes:
  - The configuration file must be a YAML file whose filename ends with V(docker_machine.yml) or V(docker_machine.yaml). Other
    filenames will not be accepted.
options:
  plugin:
    description: Token that ensures this is a source file for the C(docker_machine) plugin.
    required: true
    choices: ['docker_machine', 'community.docker.docker_machine']
  daemon_env:
    description:
      - Whether docker daemon connection environment variables should be fetched, and how to behave if they cannot be fetched.
      - With V(require) and V(require-silently), fetch them and skip any host for which they cannot be fetched. A warning
        will be issued for any skipped host if the choice is V(require).
      - With V(optional) and V(optional-silently), fetch them and not skip hosts for which they cannot be fetched. A warning
        will be issued for hosts where they cannot be fetched if the choice is V(optional).
      - With V(skip), do not attempt to fetch the docker daemon connection environment variables.
      - If fetched successfully, the variables will be prefixed with C(dm_) and stored as host variables.
    type: str
    choices:
      - require
      - require-silently
      - optional
      - optional-silently
      - skip
    default: require
  running_required:
    description:
      - When V(true), hosts which Docker Machine indicates are in a state other than C(running) will be skipped.
    type: bool
    default: true
  verbose_output:
    description:
      - When V(true), include all available nodes metadata (for example C(Image), C(Region), C(Size)) as a JSON object named
        C(docker_machine_node_attributes).
    type: bool
    default: true
  filters:
    version_added: 3.5.0
"""

EXAMPLES = '''
---
# Minimal example
plugin: community.docker.docker_machine

---
# Example using constructed features to create a group per Docker Machine driver
# (https://docs.docker.com/machine/drivers/), for example:
#   $ docker-machine create --driver digitalocean ... mymachine
#   $ ansible-inventory -i ./path/to/docker-machine.yml --host=mymachine
#   {
#     ...
#     "digitalocean": {
#       "hosts": [
#           "mymachine"
#       ]
#     ...
#   }
plugin: community.docker.docker_machine
strict: false
keyed_groups:
  - separator: ''
    key: docker_machine_node_attributes.DriverName

---
# Example grouping hosts by Digital Machine tag
plugin: community.docker.docker_machine
strict: false
keyed_groups:
  - prefix: tag
    key: 'dm_tags'

---
# Example using compose to override the default SSH behaviour of asking the user to accept the remote host key
plugin: community.docker.docker_machine
compose:
  ansible_ssh_common_args: '"-o StrictHostKeyChecking=accept-new"'
'''

from ansible.errors import AnsibleError
from ansible.module_utils.common.text.converters import to_native
from ansible.module_utils.common.text.converters import to_text
from ansible.module_utils.common.process import get_bin_path
from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable
from ansible.utils.display import Display

from ansible_collections.community.docker.plugins.plugin_utils.unsafe import make_unsafe
from ansible_collections.community.library_inventory_filtering_v1.plugins.plugin_utils.inventory_filter import parse_filters, filter_host

import json
import re
import subprocess

display = Display()


class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):
    ''' Host inventory parser for ansible using Docker machine as source. '''

    NAME = 'community.docker.docker_machine'

    DOCKER_MACHINE_PATH = None

    def _run_command(self, args):
        if not self.DOCKER_MACHINE_PATH:
            try:
                self.DOCKER_MACHINE_PATH = get_bin_path('docker-machine')
            except ValueError as e:
                raise AnsibleError(to_native(e))

        command = [self.DOCKER_MACHINE_PATH]
        command.extend(args)
        display.debug('Executing command {0}'.format(command))
        try:
            result = subprocess.check_output(command)
        except subprocess.CalledProcessError as e:
            display.warning('Exception {0} caught while executing command {1}, this was the original exception: {2}'.format(type(e).__name__, command, e))
            raise e

        return to_text(result).strip()

    def _get_docker_daemon_variables(self, machine_name):
        '''
        Capture settings from Docker Machine that would be needed to connect to the remote Docker daemon installed on
        the Docker Machine remote host. Note: passing '--shell=sh' is a workaround for 'Error: Unknown shell'.
        '''
        try:
            env_lines = self._run_command(['env', '--shell=sh', machine_name]).splitlines()
        except subprocess.CalledProcessError:
            # This can happen when the machine is created but provisioning is incomplete
            return []

        # example output of docker-machine env --shell=sh:
        #   export DOCKER_TLS_VERIFY="1"
        #   export DOCKER_HOST="tcp://134.209.204.160:2376"
        #   export DOCKER_CERT_PATH="/root/.docker/machine/machines/routinator"
        #   export DOCKER_MACHINE_NAME="routinator"
        #   # Run this command to configure your shell:
        #   # eval $(docker-machine env --shell=bash routinator)

        # capture any of the DOCKER_xxx variables that were output and create Ansible host vars
        # with the same name and value but with a dm_ name prefix.
        vars = []
        for line in env_lines:
            match = re.search('(DOCKER_[^=]+)="([^"]+)"', line)
            if match:
                env_var_name = match.group(1)
                env_var_value = match.group(2)
                vars.append((env_var_name, env_var_value))

        return vars

    def _get_machine_names(self):
        # Filter out machines that are not in the Running state as we probably cannot do anything useful actions
        # with them.
        ls_command = ['ls', '-q']
        if self.get_option('running_required'):
            ls_command.extend(['--filter', 'state=Running'])

        try:
            ls_lines = self._run_command(ls_command)
        except subprocess.CalledProcessError:
            return []

        return ls_lines.splitlines()

    def _inspect_docker_machine_host(self, node):
        try:
            inspect_lines = self._run_command(['inspect', node])
        except subprocess.CalledProcessError:
            return None

        return json.loads(inspect_lines)

    def _ip_addr_docker_machine_host(self, node):
        try:
            ip_addr = self._run_command(['ip', node])
        except subprocess.CalledProcessError:
            return None

        return ip_addr

    def _should_skip_host(self, machine_name, env_var_tuples, daemon_env):
        if not env_var_tuples:
            warning_prefix = 'Unable to fetch Docker daemon env vars from Docker Machine for host {0}'.format(machine_name)
            if daemon_env in ('require', 'require-silently'):
                if daemon_env == 'require':
                    display.warning('{0}: host will be skipped'.format(warning_prefix))
                return True
            else:  # 'optional', 'optional-silently'
                if daemon_env == 'optional':
                    display.warning('{0}: host will lack dm_DOCKER_xxx variables'.format(warning_prefix))
        return False

    def _populate(self):
        daemon_env = self.get_option('daemon_env')
        filters = parse_filters(self.get_option('filters'))
        try:
            for node in self._get_machine_names():
                node_attrs = self._inspect_docker_machine_host(node)
                if not node_attrs:
                    continue

                unsafe_node_attrs = make_unsafe(node_attrs)

                machine_name = unsafe_node_attrs['Driver']['MachineName']
                if not filter_host(self, machine_name, unsafe_node_attrs, filters):
                    continue

                # query `docker-machine env` to obtain remote Docker daemon connection settings in the form of commands
                # that could be used to set environment variables to influence a local Docker client:
                if daemon_env == 'skip':
                    env_var_tuples = []
                else:
                    env_var_tuples = self._get_docker_daemon_variables(machine_name)
                    if self._should_skip_host(machine_name, env_var_tuples, daemon_env):
                        continue

                # add an entry in the inventory for this host
                self.inventory.add_host(machine_name)

                # check for valid ip address from inspect output, else explicitly use ip command to find host ip address
                # this works around an issue seen with Google Compute Platform where the IP address was not available
                # via the 'inspect' subcommand but was via the 'ip' subcomannd.
                if unsafe_node_attrs['Driver']['IPAddress']:
                    ip_addr = unsafe_node_attrs['Driver']['IPAddress']
                else:
                    ip_addr = self._ip_addr_docker_machine_host(node)

                # set standard Ansible remote host connection settings to details captured from `docker-machine`
                # see: https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html
                self.inventory.set_variable(machine_name, 'ansible_host', make_unsafe(ip_addr))
                self.inventory.set_variable(machine_name, 'ansible_port', unsafe_node_attrs['Driver']['SSHPort'])
                self.inventory.set_variable(machine_name, 'ansible_user', unsafe_node_attrs['Driver']['SSHUser'])
                self.inventory.set_variable(machine_name, 'ansible_ssh_private_key_file', unsafe_node_attrs['Driver']['SSHKeyPath'])

                # set variables based on Docker Machine tags
                tags = unsafe_node_attrs['Driver'].get('Tags') or ''
                self.inventory.set_variable(machine_name, 'dm_tags', make_unsafe(tags))

                # set variables based on Docker Machine env variables
                for kv in env_var_tuples:
                    self.inventory.set_variable(machine_name, 'dm_{0}'.format(kv[0]), make_unsafe(kv[1]))

                if self.get_option('verbose_output'):
                    self.inventory.set_variable(machine_name, 'docker_machine_node_attributes', unsafe_node_attrs)

                # Use constructed if applicable
                strict = self.get_option('strict')

                # Composed variables
                self._set_composite_vars(self.get_option('compose'), unsafe_node_attrs, machine_name, strict=strict)

                # Complex groups based on jinja2 conditionals, hosts that meet the conditional are added to group
                self._add_host_to_composed_groups(self.get_option('groups'), unsafe_node_attrs, machine_name, strict=strict)

                # Create groups based on variable values and add the corresponding hosts to it
                self._add_host_to_keyed_groups(self.get_option('keyed_groups'), unsafe_node_attrs, machine_name, strict=strict)

        except Exception as e:
            raise AnsibleError('Unable to fetch hosts from Docker Machine, this was the original exception: %s' %
                               to_native(e), orig_exc=e)

    def verify_file(self, path):
        """Return the possibility of a file being consumable by this plugin."""
        return (
            super(InventoryModule, self).verify_file(path) and
            path.endswith(('docker_machine.yaml', 'docker_machine.yml')))

    def parse(self, inventory, loader, path, cache=True):
        super(InventoryModule, self).parse(inventory, loader, path, cache)
        self._read_config_data(path)
        self._populate()
