EXTENDIENDO Y EMBEBIENDO ANSIBLE CON Python

Alejandro Guirao Rodríguez

@lekum

github.com/lekum

OCTUBRE '15

INTRODUCCIÓN A ANSIBLE

Fuente: Ansible Blog

¿QUÉ ES ANSIBLE?

  • Es una herramienta de Gestión de la configuración
  • Ventajas frente a otras herramientas
    • Funciona sobre SSH y no requiere agentes en los hosts
      • Python 2.X en los hosts
    • Configuración muy legible (YAML)

ARQUITECTURA DE ANSIBLE

Fuente: sysadmincasts

FICHEROS DE HOSTS (INVENTARIO)

[web]
10.0.15.21
10.0.15.22

[db]
10.0.15.23

[db:vars]
db_user=guest

[infrastructure:children]
web
db

COMANDOS AD-HOC

  • Acciones que no hay que realizar continuamente, de usar y tirar
ansible grupo -m módulo -a opciones
  • Ejecución de un módulo en cada host de un grupo

Hello World! : Ping

(ansible)alex ~ $ ansible all -m ping
10.0.15.22 | success >> {
    "changed": false, 
    "ping": "pong"
}

10.0.15.21 | success >> {
    "changed": false, 
    "ping": "pong"
}

10.0.15.23 | success >> {
    "changed": false, 
    "ping": "pong"
}
ansible all -m ping

PLAYBOOKS

  • Los playbooks son ficheros YAML que especifican una lista de plays
  • Cada play es una lista de tareas (tasks) relacionadas aplicadas a un conjunto de hosts
- name: Ensure that nginx is started
  service: name=nginx state=started
  • Cada tarea consiste en la ejecución de un módulo con ciertos parámetros y un nombre legible:

UN PLAYBOOK PARA DESPLEGAR nginx

---
- hosts: web
  sudo: yes
  vars:
      - external_port: 80
      - internal_port: 8000

  tasks:

      - name: Add the apt repository for nginx
        apt_repository: repo="ppa:nginx/stable" update_cache=yes

      - name: Install nginx
        apt: name=nginx state=present

      - name: Ensure that nginx is started
        service: name=nginx state=started

      - name: Remove default site
        file: path=/etc/nginx/sites-enabled/default state=absent
        notify:
            - Restart nginx

      - name: Configure a site
        template: src=templates/site.j2 dest=/etc/nginx/sites-available/site

      - name: Enable a site
        file: src=/etc/nginx/sites-available/site dest=/etc/nginx/sites-enabled/site state=link
        notify:
            - Restart nginx

  handlers:
      - name: Restart nginx
        service: name=nginx state=restarted
intro/deploy-nginx.yml

EJECUTEMOS EL playbook...

$ ansible-playbook deploy-nginx.yml 

PLAY [web] ******************************************************************** 

GATHERING FACTS *************************************************************** 
ok: [10.0.15.21]
ok: [10.0.15.22]

TASK: [Add the apt repository for nginx] ************************************** 
changed: [10.0.15.21]
changed: [10.0.15.22]

TASK: [Install nginx] ********************************************************* 
changed: [10.0.15.22]
changed: [10.0.15.21]

TASK: [Ensure that nginx is started] ****************************************** 
ok: [10.0.15.21]
ok: [10.0.15.22]

TASK: [Remove default site] *************************************************** 
changed: [10.0.15.21]
changed: [10.0.15.22]

TASK: [Configure a site] ****************************************************** 
changed: [10.0.15.21]
changed: [10.0.15.22]

TASK: [Enable a site] ********************************************************* 
changed: [10.0.15.21]
changed: [10.0.15.22]

TASK: [Remove default site] *************************************************** 
ok: [10.0.15.21]
ok: [10.0.15.22]

NOTIFIED: [Restart nginx] ***************************************************** 
changed: [10.0.15.21]
changed: [10.0.15.22]

PLAY RECAP ******************************************************************** 
10.0.15.21                 : ok=9    changed=6    unreachable=0    failed=0   
10.0.15.22                 : ok=9    changed=6    unreachable=0    failed=0  

ENCAPSULACIÓN: roles

Los roles encapsulan:

  • Variables
  • Tasks
  • Handlers
  • Files y templates
  • Dependencias de otros roles

EMBEBIENDO Ansible

¿embebiendo?

  • Embeber: invocar a los módulos de Ansible y ejecutar playbooks desde tu código Python

 

DISCLAIMERS...

  • Estos ejemplos son válidos Ansible v 1.9.X.
    • ¿v2?
  •  Sólo funciona con Python 2.X :-(
  • El código fuente de los ejemplos está en:

EJECUTANDO UNA TAREA DE ANSIBLE

FUNDAMENTOS

  • Importa estas clases:
from ansible.runner import Runner
from ansible.inventory import Inventory
  • Contruye tu inventario:
inventory = Inventory(["localhost"])
  • Invoca a Runner.run():
runner = Runner(module_name='setup', module_args='',
                pattern='localhost', inventory=inventory,
                transport="local")
result = runner.run()

¿QUÉ SON LOS "FACTS"?

ansible all -m setup
  • Hostnames
  • Direcciones IP
  • Información del HW
  • Versiones del SW instalado...

Obtienes:

EJEMPLO: FLASK FACTER

from ansible.runner import Runner
from ansible.inventory import Inventory
from flask import Flask
from flask_restful import Resource, Api

class AnsibleFactResource(Resource):
    """
    A read-only local ansible fact
    """

    def __init__(self):
        self.facts = None
        Resource.__init__(self)

    def get_local_facts(self):
        """
        Loads the Ansible local facts in self.facts
        Calls the Ansible Python API v1 'setup' module
        """
        inventory = Inventory(["localhost"])
        runner = Runner(module_name='setup', module_args='',
                               pattern='localhost', inventory=inventory,
                               transport="local")
        result = runner.run()
        self.facts = result["contacted"]["localhost"]["ansible_facts"]

    def get(self, fact_name):
        """
        Returns a top-level fact (not nested)
        """
        self.get_local_facts()
        try:
            return self.facts[fact_name]
        except KeyError:
            return "Not a valid fact: %s" % fact_name

app = Flask(__name__)
api = Api(app)
api.add_resource(AnsibleFactResource, '/facts/<string:fact_name>')

if __name__ == '__main__':
    app.run(debug=True, port=8000)
embedding/task/flask_facter.py

EJECUTANDO UN PLAYBOOK DE ANSIBLE

FUNDAMENTOS

  • Importa estas clases :
from ansible.playbook import PlayBook
from ansible.inventory import Host, Inventory
from ansible import callbacks
from ansible import utils
  • Construye tu inventario
    # Create the inventory
    controller = Host(name = "localhost")
    controller.set_variable('users', user_list)
    controller.set_variable('apt_packages', package_list)
    local_inventory = Inventory([])
    local_inventory.get_group('all').add_host(controller)

FUNDAMENTOS

  • Configura los callback
    # Boilerplate for callbacks setup
    utils.VERBOSITY = 0
    # Output callbacks setup
    output_callbacks = callbacks.PlaybookCallbacks(verbose=utils.VERBOSITY)
    # API callbacks setup
    stats = callbacks.AggregateStats()
    api_callbacks = callbacks.PlaybookRunnerCallbacks(stats, verbose=utils.VERBOSITY)
  • Invoca a PlayBook.run()
    provision_playbook = PlayBook(playbook = "installer.yml",
                                  stats = stats,
                                  callbacks = output_callbacks,
                                  runner_callbacks = api_callbacks,
                                  inventory = local_inventory,
                                  transport = "local",
                                  become_pass = sudo_password
                                 )
    playbook_result = provision_playbook.run()

EJEMPLO: INSTALLER

import sys
from getpass import getpass

from ansible.playbook import PlayBook
from ansible.inventory import Host, Inventory
from ansible import callbacks
from ansible import utils

def run_installer(user_list, package_list, sudo_password):
    """
    Runs the playbook `installer.yml` with the supplied parameters
    """

    # Create the inventory
    controller = Host(name = "localhost")
    controller.set_variable('users', user_list)
    controller.set_variable('apt_packages', package_list)
    local_inventory = Inventory([])
    local_inventory.get_group('all').add_host(controller)

    # Boilerplate for callbacks setup
    utils.VERBOSITY = 0
    # Output callbacks setup
    output_callbacks = callbacks.PlaybookCallbacks(verbose=utils.VERBOSITY)
    # API callbacks setup
    stats = callbacks.AggregateStats()
    api_callbacks = callbacks.PlaybookRunnerCallbacks(stats, verbose=utils.VERBOSITY)

    provision_playbook = PlayBook(playbook = "installer.yml",
                                  stats = stats,
                                  callbacks = output_callbacks,
                                  runner_callbacks = api_callbacks,
                                  inventory = local_inventory,
                                  transport = "local",
                                  become_pass = sudo_password
                                 )
    playbook_result = provision_playbook.run()
    return playbook_result

def get_selection_list(initial_prompt, input_prompt, continue_prompt):
    """
    Return a selection list chosen according to a flow described by:
        - initial_prompt: To enter the selection menu
        - input_prompt: To enter an item
        - continue_prompt: To continue entering another item
    """
    results = []
    enter_selection = raw_input(initial_prompt)
    enter_selection = True if enter_selection in ["y", "Y", "yes"] else False
    if enter_selection:
        while True:
            current_result = raw_input(input_prompt)
            if not current_result:
                break
            results.append(current_result)
            continue_selection = raw_input(continue_prompt)
            continue_selection = True if continue_selection in ["y","Y","yes"] else False
            if not continue_selection:
                break
    return results


if __name__ == '__main__':

    packages = []

    print("")
    print("Automated installation script")
    print("=============================")
    print("")

    sudo_password = getpass("Enter sudo password:")

    users = get_selection_list(
                               "Create users? (y/N)",
                               "Enter username:",
                               "Add more users? (y/N)"
                              )

    print("")

    packages = get_selection_list(
                                  "Install packages? (y/N)",
                                  "Enter package name:",
                                  "Add more packages? (y/N)"
                              )

    run_installer(user_list=users, package_list=packages, sudo_password=sudo_password)
embedding/playbook/installer.py

EXTENDIENDO ANSIBLE

Fuente: Ansible Blog

¿EXTENDIENDO?

  • Extender: añadir más funcionalidad a Ansible or personalizar su comportamiento con código Python
  • Módulos
  • Plugins
  • Scripts de inventario dinámico

CREANDO UN MÓDULO DE ANSIBLE

un módulo de ansible ...

  • ... es un fichero ejecutable
    • En ./library o en ANSIBLE_LIBRARY_PATH
  • ... tiene una interfaz JSON, por lo que es agnóstico respecto al lenguaje de programación
    • Auque es más sencillo hacerlos en Python
#!/usr/bin/python
# -*- coding: utf-8 -*-

DOCUMENTATION = '''
'''

EXAMPLES = '''
'''
# Your custom Python code / external libraries
from mypackage import mypythonicfunction  

def mypythonicfunction(...):
    """
    Here you do the hard work. Use external libraries or your own code
    And return a meaningful value and message to main()
    """ 
    return status, msg

def main():
    module = AnsibleModule(
        argument_spec=dict(
        # Here you parse the arguments
        )
        # and set the options and behaviour of Ansible
    )

    # Here you assign your arguments to Python variables
    variable_a = module.params['var_a']

    # and call your mypythonicfunction() with the variables
    status, msg = mypythonicfunction(variable_a, ...)

    # Parse the results and return valid JSONs and meaningful messages
    if status == "ok":
        module.exit_json(changed=changed, msg=msg)
    else:
        module.fail_json(msg=msg)

# Boilerplate but IMPORTANT code
from ansible.module_utils.basic import *
if __name__ == "__main__":
    main()

ESTRUCTURA DEL MÓDULO

DOCUMENTACIÓN Y EJEMPLOS

  • Se emplean por el comando make webdocs para generar el HTML

Puntos clave:

  • Mantener la parte de options sincronizada con el argument_spec
  • Especificar los requirements
  • Usar la sección de notes
  • Probar los ejemplos que pones :-)

LA PARTE PYTHÓNICA

  • Aunque puedes aprovechar funciones muy cómodas de Ansible
    • run_command
  • Intenta usar Python puro
    • Tu lógica de negocio
  • Consejos:
    • Devuelve un value y un msg
    • No imprimas a stdout o stderr

LA FUNCIÓN MAIN()

  • Crea una instancia de la clase AnsibleModule
  • No te olvides de:
    • Especificar las posibles exclusiones mutuas
    • Especificar si se soporta el check mode
  • El diccionario argument_spec define los argumentos del módulo
    • Required vs optional
    • Default values
    • Possible choices
    • Aliases
  • Life is easy: el diccionario params recoge los parámetros con los que ha sido invocado
  • Invoca a la parte pythónica y parsea los resultados
  • Funciones auxiliares super útiles:
exit_json(changed, ...)

fail_json(msg, ...)

LA FUNCIÓN MAIN()

BOILERPLATE CODE

  • Coloca estas líneas al final del fichero:
  • ¡Resiste la tentación de hacer imports explícitos!
from ansible.module_utils.basic import *
if __name__ == "__main__":
    main()

CONSEJOS PARA CREACIÓN DE MÓDULOS

  • Crea un módulo que te gustaría usar:
    • Idempotente
    • Que soporte el check mode
  • Prueba tu módulo con el script de test-module:
ansible/hacking/test-module
-m /path/to/module
-a module_args

EJEMPLO DE MÓDULO: TAIGA_ISSUE

  • Usa la biblioteca python-taiga para comunicarse con Taiga mediante su REST API
extending/module/library/taiga_issue.py

CREANDO UN SCRIPT DE INVENTARIO DINÁMICO

SCRIPT DE INVENTARIO DINÁMICO

  • Se trata de un fichero ejecutable que soporta estos switches por líneas de comandos:
    • --host=<hostname> para mostrar detalles del host
    • --list para listar grupos
  • La salida es JSON

EJEMPLO: SHELVE_INVENTORY

#!/usr/bin/env python

import argparse
import json
import sys
import shelve
import ConfigParser

def parse_args():
    parser = argparse.ArgumentParser(description="Shelve inventory script")
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument('--list', action='store_true')
    group.add_argument('--host')
    return parser.parse_args()

def get_shelve_groups(shelvefile):
    d = shelve.open(shelvefile)
    g = d["groups"]
    d.close()
    return g

def get_shelve_host_details(shelvefile, host):
    d = shelve.open(shelvefile)
    h = d["hostvars"][host]
    d.close()
    return h

def main():

    config = ConfigParser.RawConfigParser()
    config.read('shelve_inventory.ini')
    shelvefile = config.get('defaults', 'shelvefile')

    args = parse_args()
    if args.list:
        groups = get_shelve_groups(shelvefile)
        json.dump(groups, sys.stdout)
    else:
        details = get_shelve_host_details(shelvefile, args.host)
        json.dump(details, sys.stdout)

if __name__ == '__main__':
    main()
extending/dynamic_inventory/shelve_inventory.py

CREANDO PLUGINS

PLUGINS

  • ¡Se ejecutan en el nodo controller!
  • Los hay de diferentes tipos:
    • Callback plugins
    • Connection plugins
    • Lookup plugins
    • Filter plugins
    • Action plugins
    • Vars plugins
    • Cache plugins

CÓMO añadir plugins

  • O puedes cambiar tu fichero ansible.cfg:
[defaults]
callback_plugins = ./plugins
  • Ubicación por defecto: /usr/share/ansible/plugins/
action_plugins/
lookup_plugins/
callback_plugins/
connection_plugins/
filter_plugins/
vars_plugins/

CREANDO UN CALLBAck PLUGIN

CALLBACK PLUGINS

  • Reaccionan ante eventos del Playbook y/o el Runner
  • En el repositorio de Ansible hay un ejemplo con una lista completa de los métodos disponibles
  • Basta con que definas la clase CallbackModule y hagas un override los métodos que quieras

EJEMPLO: NOTIFY_SEND

from subprocess import call

def notify_send(title, message, icon):
    call(["notify-send", title, message,"-i", icon])

class CallbackModule(object):

    def runner_on_failed(self, host, res, ignore_errors=False):
        template = "Host: {}\nModule: {}\nMessage: {}"
        notification = template.format(
                                       host,
                                       res.get('invocation').get('module_name'),
                                       res.get('msg')
                                      )
        notify_send("ANSIBLE FAILURE", notification, "dialog-warning")
extending/plugin/callbacks/plugins/notify_send.py

CREAndo un CONNECTION PLUGIN

CONNECTION PLUGINS

  • Un connection plugin permite al nodo controlador conectarse a los hosts usando varios protocolos:
    • SSH
    • paramiko_ssh
    • local
    • winrm (para Win$ hosts)
    • chroot
    • jails (para FreeBSD)
    • zones (Solaris)
    • libvirt_lxc
    • ...
  • La lista de connection plugins oficiales disponibles la puedes encontrar aquí

fundamentos de CONNECTION PLUGINs

  • Define una clase Connection y haz un override de los siguientes métodos:
class Connection(object):

    def __init__(self, runner, host, port, user,
                 password, private_key_file, *args, **kwargs):
        [...]

     def connect(self):
        [...]

     def exec_command(self, cmd, tmp_path, become_user=None,
                      sudoable=False, executable='/bin/sh', in_data=None):
        [...]

     def put_file(self, in_path, out_path):
        [...]

     def fetch_file(self, in_path, out_path):
        [...]

     def close(self):
        [...]

creando un LOOKUP PLUGIN

LOOKUP PLUGINS

  • Acceso a datos
    • Filesystem
    • Databases
    • Servicios externos
  • Sintaxis:
contents: "{{ lookup('file', '/etc/foo.txt') }}"
  • En ellos se basa la construcción with_

fundamentos de LOOKUP PLUGINS

from ansible import utils, errors

class LookupModule(object):

    def __init__(self, basedir=None, **kwargs):
        self.basedir = basedir

    def run(self, terms, inject=None, **kwargs):
        # terms holds the parameters of the lookup call
  • Implementa un método run() de la clase LookupModule que devuelva una lista de resultados
    • Cada elemento se corresponde con cada elemento de la lista terms

EJEMPLO: SHELVEFILE

import shelve
import os
from ansible import utils, errors

class LookupModule(object):

    def __init__(self, basedir=None, **kwargs):
        self.basedir = basedir

    def read_shelve(self, shelve_filename, key):
        """
        Read the value of "key" from a shelve file
        """
        d = shelve.open(shelve_filename)
        res = d.get(key, None)
        d.close()
        return res

    def run(self, terms, inject=None, **kwargs):

        terms = utils.listify_lookup_plugin_terms(terms, self.basedir, inject)
        ret = []

        if not isinstance(terms, list):
            terms = [ terms ]

        for term in terms:
            playbook_path = None
            relative_path = None
            paramvals = {"file": None, "key": None}
            params = term.split()

            try:
                for param in params:
                    name, value = param.split('=')
                    assert(name in paramvals)
                    paramvals[name] = value

            except (ValueError, AssertionError), e:
                # In case "file" or "key" are not present
                raise errors.AnsibleError(e)

            file = paramvals['file']
            key = paramvals['key']
            basedir_path  = utils.path_dwim(self.basedir, file)

            # Search also in the role/files directory and in the playbook directory
            if '_original_file' in inject:
                relative_path = utils.path_dwim_relative(inject['_original_file'], 'files', file, self.basedir, check=False)
            if 'playbook_dir' in inject:
                playbook_path = os.path.join(inject['playbook_dir'], file)

            for path in (basedir_path, relative_path, playbook_path):
                if path and os.path.exists(path):
                    res = self.read_shelve(path, key)
                    if res is None:
                        raise errors.AnsibleError("Key %s not found in shelve file %s" % (key, file))
                    # Convert the value read to string
                    ret.append(str(res))
                    break
            else:
                raise errors.AnsibleError("Could not locate shelve file in lookup: %s" % file)

        return ret
extending/plugin/lookup/plugins/shelvefile.py

Uso:

{{ lookup('shelvefile', 'file=book.db key=current_book_author') }}

CREANDO UN FILTER PLUGIN

FILTER PLUGINS

{{ some_variable | default(5) }}
{{ some_list | min }}
{{ some_register_variable | changed }}
  • Sintaxis: variable | filter

fundamentos de FILTER PLUGINs

class FilterModule(object):

    def filters(self):
        return {
                'filter1': filter1functionname,
                'filter2': filter2functionname,
                [...]
               }
  • Crea una clase FilterModule con un método filters que devuelva un diccionario que mapee los filters a funciones

EJEMPLO: ROT13

def rot13(s):
    def lookup(v):
        o, c = ord(v), v.lower()
        if 'a' <= c <= 'm':
            return chr(o + 13)
        if 'n' <= c <= 'z':
            return chr(o - 13)
        return v
    return ''.join(map(lookup, s))

class FilterModule(object):

    def filters(self):
        return {
                'rot13': rot13
               }
extending/plugin/filter/plugins/rot13.py

Uso:

{{ variable | rot13 }}

OTROS PLUGINS

ACTION PLUGINS

  • Acciones en el nodo controlador durante la ejecución de un módulo
  • Para desarrollar uno, implementa una clase ActionModule con un método run()
    •  self.runner._execute_module() para invocar al módulo real

VARS PLUGINS

  • Las construcciones como host_vars y group_vars de los playbooks funcionan mediante vars plugins
  • Es una funcionalidad bastante oscura y poco documentada de Ansible, sólo he podido encontrar este código de ejemplo en el repositorio de Ansible

VARS PLUGIN TEMPLATE CODE

class VarsModule(object):

    """
    Loads variables for groups and/or hosts
    """

    def __init__(self, inventory):

        """ constructor """

        self.inventory = inventory
        self.inventory_basedir = inventory.basedir()


    def run(self, host, vault_password=None):
        """ For backwards compatibility, when only vars per host were retrieved
            This method should return both host specific vars as well as vars
            calculated from groups it is a member of """
        return {}


    def get_host_vars(self, host, vault_password=None):
        """ Get host specific variables. """
        return {}


    def get_group_vars(self, group, vault_password=None):
        """ Get group specific variables. """
        return {}

CACHE PLUGINS

Fact caching:

  • hostvars de hosts no contactados en la ejecución del playbook

  • v1.8+

  • Para usarlo, pon esto en tu ansible.cfg

[defaults]
gathering = smart
fact_caching = redis # Name of the plugin
fact_caching_timeout = 86400

CACHE PLUGINS

  • Tipos de cache plugins:
    • Memory
    • JSONfile
    • Memcached
    • Redis

CACHE PLUGIN TEMPLATE CODE


import exceptions

class BaseCacheModule(object):

    def get(self, key):
        raise exceptions.NotImplementedError

    def set(self, key, value):
        raise exceptions.NotImplementedError

    def keys(self):
        raise exceptions.NotImplementedError

    def contains(self, key):
        raise exceptions.NotImplementedError

    def delete(self, key):
        raise exceptions.NotImplementedError

    def flush(self):
        raise exceptions.NotImplementedError

    def copy(self):
        raise exceptions.NotImplementedError

REFERencias

Libros:

Artículos:

Happy hacking!

Extendiendo y embebiendo Ansible con Python

By Alejandro Guirao Rodríguez

Extendiendo y embebiendo Ansible con Python

  • 4,405