© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2022
M. ZadkaDevOps in Pythonhttps://doi.org/10.1007/978-1-4842-7996-0_11

11. Ansible

Moshe Zadka1  
(1)
Belmont, CA, USA
 

Like Puppet or Salt, Ansible is a configuration management system. Ansible does not have a custom agent. It usually works with SSH, though it also supports other modes, like Docker or local-based actions.

When using SSH, Ansible calculates the commands locally. It then sends simple commands and files through the SSH connection.

By default, Ansible tries to use the local SSH command as the control machine. If the local command is unsuitable, Ansible falls back to using the Paramiko library.

11.1 Ansible Basics

Ansible can be installed using pip install ansible in a virtual environment. After installing it, the simplest thing is to ping the localhost.
$ ansible localhost -m ping

This is useful since if this works, it means quite a few things are configured correctly: running the SSH command, configuring the SSH keys, and the SSH host keys.

The best way to use Ansible, as always when using SSH communication, is with a locally-encrypted private key that is loaded into an SSH agent. Since Ansible uses the local SSH command by default, if ssh localhost works the right way (without asking for a password), Ansible works correctly. If the localhost is not running an SSH daemon, replace the following examples with a separate Linux host, possibly running locally as a virtual machine.

Slightly more sophisticated, but still not requiring a complicated setup, is running a specific command.
$ ansible localhost -a "/bin/echo hello world"
You can also give an explicit address.
$ ansible 10.40.32.195 -m ping

Try to SSH to 10.40.42.195.

The set of hosts Ansible try to access by default is called the inventory. Specifying the inventory statically can be done using either an INI or a YAML format file. However, the more common option is to write an inventory script that generates the list of machines.

An inventory script is simply a Python file that can be run with the arguments --list and --host <hostname>. By default, Ansible uses the same Python to run the inventory script. It is possible to make the inventory script a real script running with any interpreter, such as a different version of Python, by adding a shebang line. Traditionally, the file is not named with .py. Among other things, this avoids accidental imports of the file.

When run with --list, it is supposed to output the inventory as formatted JSON. When run with --host, it is supposed to print the variables for the host. It is perfectly acceptable to always print an empty dictionary in these circumstances.

Here is a simple inventory script.
#!/usr/bin/env python3
# save as simple.inv
import sys
import json
if '--host' in sys.argv[1:]:
    print(json.dumps({}))
else:
    print(json.dumps(dict(all='localhost')))
This inventory script is not very dynamic. It always prints the same thing. Run it with
$ chmod +x ./simple.inv
$ ./simple.inv

Though simple, it is a valid inventory script.

Use it with
$ ansible -i simple.inv all -m ping

This again pings (using SSH) the localhost.

Ansible is not primarily used to run ad hoc commands against hosts. It is designed to run playbooks. Playbooks are YAML files that describe tasks.
---
- hosts: all
  tasks:
    - name: hello printer
      shell: echo "hello world"

This playbook, which could be saved as echo.yml, runs echo "hello world" on all connected hosts.

The following runs it with the inventory script created.
$ ansible-playbook -i simple.inv echo.yml

This is the most common command to use when running Ansible day to day. Other commands are mostly used for debugging and troubleshooting, but the flow is to rerun the playbook a lot in normal circumstances.

By “a lot,” I mean that playbooks should generally be written to be safely idempotent; executing the same playbook in the same circumstances again should not have any effect. In Ansible, idempotency is a property of the playbook, not of the basic building blocks.

For example, the following playbook is not idempotent.
---
- hosts: all
  tasks:
    - name: hello printer
      shell: echo "hello world" >> /etc/hello
One way to make it idempotent is to notice the file is already there.
---
- hosts: all
  tasks:
    - name: hello printer
      shell: echo "hello world" >> /etc/hello
      creates: /etc/hello

This notices the file exists and skips the command if so.

Instead of listing tasks in the playbooks, these are generally delegated to roles in more complex settings.

Roles are a way of separating concerns and flexibly combining them per host.
---
- hosts: all
  roles:
    - common
Then under roles/common/tasks/main.yml
---
- name: hello printer
  shell: echo "hello world" >> /etc/hello
  creates: /etc/hello

This does the same thing as earlier, but now it is directed through more files. The benefit is that if you have many different hosts and you need to combine instructions for some of them, this is a convenient platform to define parts of more complicated setups.

11.2 Ansible Concepts

When Ansible needs to use secrets, it has its internal vault. The vault has encrypted secrets and is decrypted with a password. Sometimes this password is in a file (ideally on an encrypted volume).

Ansible roles and playbooks are Jinja2 YAML files. This means they can use interpolation and support a few Jinja2 filters.

Some useful ones are from/to_json/yaml, which allows data to be parsed and serialized back and forth. The map filter is a meta-filter that applies an item-by-item filter to an iterable object.

Inside the filters, there is a set of variables defined. Variables can come from multiple sources: the vault (for secrets), directly in the playbook or role, or in files included from it. Variables can also come from the inventory (which can be useful if different inventories are used with the same playbook). The ansible_facts variable is a dictionary that has the facts about the current host: operating system, IP, and more.

They can also be defined directly on the command line. While this is dangerous, it can be useful for quick iterations.

In playbooks, it is often the case that you need to define both which user to log in as and which user (usually root) to execute tasks as.

Those can be configured on a playbook and overridden per task level.

The user that you log in as is remote_user. The user that executes is either remote_user if become is False or become_user if become is True. If become is True, the user switching is done by become_method.

The following are the defaults.
  • remote_user – same as local user

  • become_userroot

  • becomeFalse

  • become_methodsudo

These defaults are usually correct, except for become, which often needs to be overridden to True. In general, it is best to configure machines so that, whatever you choose the become_method to be, the process of user switching does not require passwords.

For example, the following work on common cloud-provider versions of Ubuntu.
- hosts: databases
  remote_user: ubuntu
  become: True
  tasks:
  - name: ensure that postgresql is started
    service:
      name: postgresql
      state: started

If this is impossible, you need to give the argument --ask-become-pass to have Ansible ask for the credentials at runtime. Note that while this works, it hampers automation attempts, so it is best to avoid it.

Ansible supports patterns to indicate which hosts to update. In ansible-playbook, this is done with --limit. It is possible to do set arithmetic on groups: : means union, :! means set difference, and :& means intersection. In that case, the basic sets are the sets as defined in the inventory. For example, databases:!mysql limits the command to only databases hosts that are not MySQL.

Patterns can be regular expressions that match hostnames or IPs.

11.3 Ansible Extensions

You have seen one way to extend ansible using custom Python code: dynamic inventory. In the dynamic inventory example, you wrote an ad hoc script. The script, however, was run as a separate process. A better way to extend Ansible, and one that generalizes beyond inventory, is to use plugins.

An inventory plugin is a Python file. There are several places for this file so that ansible can find it. The easiest is plugins/inventory_plugins in the same directory as the playbook and roles.

This file should define a class called InventoryModule that inherits from BaseInventoryPlugin. The class should define two methods: verify_file and parse. The verify_file function is mostly an optimization. It is meant to quickly skip the parsing if the file is not the right one for the plugin. It is an optimization since parse can (and should) raise AnsibleParserError if the file cannot be parsed for any reason. Ansible then tries the other inventory plugins.

The parse function signature is
def parse(self, inventory, loader, path, cache=True):
    pass
The following is a simple example of parsing JSON.
def parse(self, inventory, loader, path, cache=True):
    super(InventoryModule, self).parse(inventory, loader, path, cache)
    try:
      with open(path) as fpin:
          data = json.loads(fpin.read())
    except ValueError as exc:
        raise AnsibleParseError(exc)
    for host in data:
        self.inventory.add_host(server['name'])

The inventory object is how to manage the inventory. It has methods for add_group, add_child, and set_variable, which is how the inventory is extended.

The loader is a flexible loader that can guess a file’s format and load it. The path is the path to the file which has the plugin parameters. Notice that if the plugin is specific enough, the parameters and the loader might not be needed in some cases.

The other common plugin to write is a lookup plugin. Lookup plugins can be called from the Jinja2 templates in Ansible to do arbitrary computation. This is often a good alternative when templates start getting too complicated. Jinja2 does not scale well to a complex algorithm or easily call into third-party libraries.

Lookup plugins are sometimes used for complex computation and sometimes for calling into a library to allow computing a parameter in a role. For example, it can take the name of an environment and calculate (based on local conventions) what are the related objects.
class LookupModule(LookupBase):
    def run(self, terms, variables=None, **kwargs):
        pass
For example, you can write a lookup plugin that calculates the largest common path of several paths.
class LookupModule(LookupBase):
    def run(self, terms, variables=None, **kwargs):
        return os.path.commonpath(terms)

Note that when using lookup modules, both lookup and query can be used from Jinja2. By default, lookup converts the return value into a string. The parameter wantslist can be sent to avoid a conversion if the return value is a list. Even in that case, it is important to only return a simple object—something composed only of integers, floats, and strings, or lists and dictionaries thereof. Custom classes are coerced into strings in various surprising ways.

11.4 Summary

Ansible is a simple configuration management tool that is easy to set up, requiring just SSH access. Writing new inventory and lookup plugins allows implementing custom processing with little overhead.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset