# 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 """ 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() proxy_settings = charmhelpers.core.hookenv.env_proxy_settings() if proxy_settings: env.update(proxy_settings) 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