saltstack/salt

View on GitHub
doc/topics/tutorials/jinja_to_execution_module.rst

Summary

Maintainability
Test Coverage
.. _tutorial-jinja_to_execution-module:

=================================================
How to Convert Jinja Logic to an Execution Module
=================================================

.. versionadded: 2016.???

.. note::
    This tutorial assumes a basic knowledge of Salt states and specifically
    experience using the `maps.jinja` idiom.

    This tutorial was written by a salt user who was told "if your maps.jinja
    is too complicated, write an execution module!". If you are experiencing
    over-complicated jinja, read on.

The Problem: Jinja Gone Wild
----------------------------

It is often said in the Salt community that "Jinja is not a Programming Language".
There's an even older saying known as Maslow's hammer.
It goes something like
"if all you have is a hammer, everything looks like a nail".
Jinja is a reliable hammer, and so is the `maps.jinja` idiom.
Unfortunately, it can lead to code that looks like the following.

.. code-block:: jinja

    # storage/maps.yaml

    {% import_yaml 'storage/defaults.yaml' as default_settings %}
    {% set storage = default_settings.storage %}
    {% do storage.update(salt['grains.filter_by']({
        'Debian': {
        },
        'RedHat': {
        }
    }, merge=salt['pillar.get']('storage:lookup'))) %}

    {% if 'VirtualBox' == grains.get('virtual', None) or 'oracle' == grains.get('virtual', None) %}
    {%   do storage.update({'depot_ip': '192.168.33.81', 'server_ip':  '192.168.33.51'}) %}
    {% else %}
    {%   set colo = pillar.get('inventory', {}).get('colo', 'Unknown') %}
    {%   set servers_list = pillar.get('storage_servers', {}).get(colo, [storage.depot_ip, ]) %}
    {%   if opts.id.startswith('foo') %}
    {%     set modulus = servers_list | count %}
    {%     set integer_id = opts.id | replace('foo', '') | int %}
    {%     set server_index = integer_id % modulus %}
    {%   else %}
    {%     set server_index = 0 %}
    {%   endif %}
    {%   do storage.update({'server_ip': servers_list[server_index]}) %}
    {% endif %}

    {% for network, _ in salt.pillar.get('inventory:networks', {}) | dictsort %}
    {%   do storage.ipsets.hash_net.foo_networks.append(network) %}
    {% endfor %}

This is an example from the author's salt formulae demonstrating misuse of jinja.
Aside from being difficult to read and maintain,
accessing the logic it contains from a non-jinja renderer
while probably possible is a significant barrier!

Refactor
--------

The first step is to reduce the maps.jinja file to something reasonable.
This gives us an idea of what the module we are writing needs to do.
There is a lot of logic around selecting a storage server ip.
Let's move that to an execution module.

.. code-block:: jinja

    # storage/maps.yaml

    {% import_yaml 'storage/defaults.yaml' as default_settings %}
    {% set storage = default_settings.storage %}
    {% do storage.update(salt['grains.filter_by']({
        'Debian': {
        },
        'RedHat': {
        }
    }, merge=salt['pillar.get']('storage:lookup'))) %}

    {% if 'VirtualBox' == grains.get('virtual', None) or 'oracle' == grains.get('virtual', None) %}
    {%   do storage.update({'depot_ip': '192.168.33.81'}) %}
    {% endif %}

    {% do storage.update({'server_ip': salt['storage.ip']()}) %}

    {% for network, _ in salt.pillar.get('inventory:networks', {}) | dictsort %}
    {%   do storage.ipsets.hash_net.af_networks.append(network) %}
    {% endfor %}

And then, write the module.
Note how the module encapsulates all of the logic around finding the storage server IP.

.. code-block:: python

    # _modules/storage.py
    #!python

    '''
    Functions related to storage servers.
    '''

    import re


    def ips():
        '''
        Provide a list of all local storage server IPs.

        CLI Example::

            salt \* storage.ips
        '''

        if  __grains__.get('virtual', None) in ['VirtualBox', 'oracle']:
            return ['192.168.33.51', ]

        colo = __pillar__.get('inventory', {}).get('colo', 'Unknown')
        return __pillar__.get('storage_servers', {}).get(colo, ['unknown', ])


    def ip():
        '''
        Select and return a local storage server IP.

        This loadbalances across storage servers by using the modulus of the client's id number.

        :maintainer:    Andrew Hammond <ahammond@anchorfree.com>
        :maturity:      new
        :depends:       None
        :platform:      all

        CLI Example::

            salt \* storage.ip

        '''

        numerical_suffix = re.compile(r'^.*(\d+)$')
        servers_list = ips()

        m = numerical_suffix.match(__grains__['id'])
        if m:
            modulus = len(servers_list)
            server_number = int(m.group(1))
            server_index = server_number % modulus
        else:
            server_index = 0

        return servers_list[server_index]

Conclusion
----------

That was... surprisingly straight-forward.
Now the logic is available in every renderer, instead of just Jinja.
Best of all, it can be maintained in Python,
which is a whole lot easier than Jinja.