# Copyright 2014-2015 Canonical Limited.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#  http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Copyright 2013 Canonical Ltd.
#
# Authors:
#  Charm Helpers Developers <juju@lists.ubuntu.com>
"""
The ansible package enables you to easily use the configuration management
tool `Ansible`_ to setup and configure your charm. All of your charm
configuration options and relation-data are available as regular Ansible
variables which can be used in your playbooks and templates.

.. _Ansible: https://www.ansible.com/

Usage
=====

Here is an example directory structure for a charm to get you started::

    charm-ansible-example/
    |-- ansible
    |   |-- playbook.yaml
    |   `-- templates
    |       `-- example.j2
    |-- config.yaml
    |-- copyright
    |-- icon.svg
    |-- layer.yaml
    |-- metadata.yaml
    |-- reactive
    |   `-- example.py
    |-- README.md

Running a playbook called ``playbook.yaml`` when the ``install`` hook is run
can be as simple as::

    from charmhelpers.contrib import ansible
    from charms.reactive import hook

    @hook('install')
    def install():
        ansible.install_ansible_support()
        ansible.apply_playbook('ansible/playbook.yaml')

Here is an example playbook that uses the ``template`` module to template the
file ``example.j2`` to the charm host and then uses the ``debug`` module to
print out all the host and Juju variables that you can use in your playbooks.
Note that you must target ``localhost`` as the playbook is run locally on the
charm host::

    ---
    - hosts: localhost
      tasks:
        - name: Template a file
          template:
            src: templates/example.j2
            dest: /tmp/example.j2

        - name: Print all variables available to Ansible
          debug:
            var: vars

Read more online about `playbooks`_ and standard Ansible `modules`_.

.. _playbooks: https://docs.ansible.com/ansible/latest/user_guide/playbooks.html
.. _modules: https://docs.ansible.com/ansible/latest/user_guide/modules.html

A further feature of the Ansible hooks is to provide a light weight "action"
scripting tool. This is a decorator that you apply to a function, and that
function can now receive cli args, and can pass extra args to the playbook::

    @hooks.action()
    def some_action(amount, force="False"):
        "Usage: some-action AMOUNT [force=True]"  # <-- shown on error
        # process the arguments
        # do some calls
        # return extra-vars to be passed to ansible-playbook
        return {
            'amount': int(amount),
            'type': force,
        }

You can now create a symlink to hooks.py that can be invoked like a hook, but
with cli params::

    # link actions/some-action to hooks/hooks.py

    actions/some-action amount=10 force=true

Install Ansible via pip
=======================

If you want to install a specific version of Ansible via pip instead of
``install_ansible_support`` which uses APT, consider using the layer options
of `layer-basic`_ to install Ansible in a virtualenv::

    options:
      basic:
        python_packages: ['ansible==2.9.0']
        include_system_packages: true
        use_venv: true

.. _layer-basic: https://charmsreactive.readthedocs.io/en/latest/layer-basic.html#layer-configuration

"""
import os
import json
import stat
import subprocess
import functools

import charmhelpers.contrib.templating.contexts
import charmhelpers.core.host
import charmhelpers.core.hookenv
import charmhelpers.fetch


charm_dir = os.environ.get('CHARM_DIR', '')
ansible_hosts_path = '/etc/ansible/hosts'
# Ansible will automatically include any vars in the following
# file in its inventory when run locally.
ansible_vars_path = '/etc/ansible/host_vars/localhost'


def install_ansible_support(from_ppa=True, ppa_location='ppa:ansible/ansible'):
    """Installs Ansible via APT.

    By default this installs Ansible from the `PPA`_ linked from
    the Ansible `website`_ or from a PPA set in ``ppa_location``.

    .. _PPA: https://launchpad.net/~ansible/+archive/ubuntu/ansible
    .. _website: http://docs.ansible.com/intro_installation.html#latest-releases-via-apt-ubuntu

    If ``from_ppa`` is ``False``, then Ansible will be installed from
    Ubuntu's Universe repositories.
    """
    if from_ppa:
        charmhelpers.fetch.add_source(ppa_location)
        charmhelpers.fetch.apt_update(fatal=True)
    charmhelpers.fetch.apt_install('ansible')
    with open(ansible_hosts_path, 'w+') as hosts_file:
        hosts_file.write('localhost ansible_connection=local ansible_remote_tmp=/root/.ansible/tmp')


def apply_playbook(playbook, tags=None, extra_vars=None):
    """Run a playbook.

    This helper runs a playbook with juju state variables as context,
    therefore variables set in application config can be used directly.
    List of tags (--tags) and dictionary with extra_vars (--extra-vars)
    can be passed as additional parameters.

    Read more about playbook `_variables`_ online.

    .. _variables: https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html

    Example::

        # Run ansible/playbook.yaml with tag install and pass extra
        # variables var_a and var_b
        apply_playbook(
            playbook='ansible/playbook.yaml',
            tags=['install'],
            extra_vars={'var_a': 'val_a', 'var_b': 'val_b'}
        )

        # Run ansible/playbook.yaml with tag config and extra variable nested,
        # which is passed as json and can be used as dictionary in playbook
        apply_playbook(
            playbook='ansible/playbook.yaml',
            tags=['config'],
            extra_vars={'nested': {'a': 'value1', 'b': 'value2'}}
        )

        # Custom config file can be passed within extra_vars
        apply_playbook(
            playbook='ansible/playbook.yaml',
            extra_vars="@some_file.json"
        )

    """
    tags = tags or []
    tags = ",".join(tags)
    charmhelpers.contrib.templating.contexts.juju_state_to_yaml(
        ansible_vars_path, namespace_separator='__',
        allow_hyphens_in_keys=False, mode=(stat.S_IRUSR | stat.S_IWUSR))

    # we want ansible's log output to be unbuffered
    env = os.environ.copy()
    env['PYTHONUNBUFFERED'] = "1"
    call = [
        'ansible-playbook',
        '-c',
        'local',
        playbook,
    ]
    if tags:
        call.extend(['--tags', '{}'.format(tags)])
    if extra_vars:
        call.extend(['--extra-vars', json.dumps(extra_vars)])
    subprocess.check_call(call, env=env)


class AnsibleHooks(charmhelpers.core.hookenv.Hooks):
    """Run a playbook with the hook-name as the tag.

    This helper builds on the standard hookenv.Hooks helper,
    but additionally runs the playbook with the hook-name specified
    using --tags (ie. running all the tasks tagged with the hook-name).

    Example::

        hooks = AnsibleHooks(playbook_path='ansible/my_machine_state.yaml')

        # All the tasks within my_machine_state.yaml tagged with 'install'
        # will be run automatically after do_custom_work()
        @hooks.hook()
        def install():
            do_custom_work()

        # For most of your hooks, you won't need to do anything other
        # than run the tagged tasks for the hook:
        @hooks.hook('config-changed', 'start', 'stop')
        def just_use_playbook():
            pass

        # As a convenience, you can avoid the above noop function by specifying
        # the hooks which are handled by ansible-only and they'll be registered
        # for you:
        # hooks = AnsibleHooks(
        #     'ansible/my_machine_state.yaml',
        #     default_hooks=['config-changed', 'start', 'stop'])

        if __name__ == "__main__":
            # execute a hook based on the name the program is called by
            hooks.execute(sys.argv)
    """

    def __init__(self, playbook_path, default_hooks=None):
        """Register any hooks handled by ansible."""
        super(AnsibleHooks, self).__init__()

        self._actions = {}
        self.playbook_path = playbook_path

        default_hooks = default_hooks or []

        def noop(*args, **kwargs):
            pass

        for hook in default_hooks:
            self.register(hook, noop)

    def register_action(self, name, function):
        """Register a hook"""
        self._actions[name] = function

    def execute(self, args):
        """Execute the hook followed by the playbook using the hook as tag."""
        hook_name = os.path.basename(args[0])
        extra_vars = None
        if hook_name in self._actions:
            extra_vars = self._actions[hook_name](args[1:])
        else:
            super(AnsibleHooks, self).execute(args)

        charmhelpers.contrib.ansible.apply_playbook(
            self.playbook_path, tags=[hook_name], extra_vars=extra_vars)

    def action(self, *action_names):
        """Decorator, registering them as actions"""
        def action_wrapper(decorated):

            @functools.wraps(decorated)
            def wrapper(argv):
                kwargs = dict(arg.split('=') for arg in argv)
                try:
                    return decorated(**kwargs)
                except TypeError as e:
                    if decorated.__doc__:
                        e.args += (decorated.__doc__,)
                    raise

            self.register_action(decorated.__name__, wrapper)
            if '_' in decorated.__name__:
                self.register_action(
                    decorated.__name__.replace('_', '-'), wrapper)

            return wrapper

        return action_wrapper
