Engineering Blog

Back
Published on December 12, 2024 by

Executable Ansible Playbooks

Why we use chmod +x on our playbooks and why that is a good idea.

We use Ansible to provision, configure, and orchestrate our infrastructure. The code has grown over the years, and systems have sprung up that we need to interact with when a playbook is running. We manage our inventory in Netbox, we record playbook runs using ARA, and store secrets in Vault/OpenBao.

As a result, our playbooks require more and more knowledge about our environment, and the chance of "holding them wrong" increases.

How it Started

Like most Ansible users, we initially called all playbooks like this:

$ ansible-playbook playbooks/state/all.yml -i inventory/prod-rma1 -l www --diff

This works great, but it is more verbose than necessary. Some in our team, including - if not limited to - me, prefer short commands over long ones. Especially if they need to be used frequently.

So we took our existing playbooks like this one:

- name: Base network and boot setup
  hosts: '{{ playbook_hosts | default("ansible-managed") }}'

And added a shebang:

#!/usr/bin/env ansible-playbook
- name: Base network and boot setup
  hosts: '{{ playbook_hosts | default("ansible-managed") }}'

Then we made the file executable:

$ chmod +x playbooks/state/all.yml

And presto, we could drop the command name from our CLI, calling the same playbook as follows:

$ playbooks/state/all.yml -i inventory/prod-rma1 -l www

How it is Going

When integrating ARA logging, we discovered that operators would always have to use --diff / -D, for differences to actually be logged.

We figured that typing --diff is not something we wanted to add to our documentation. We wanted to enforce it. We also had some configuration we needed to apply, depending on the inventory selection.

Maybe there is another way, but we figured: Since we already use our playbooks somewhat like CLIs, why not wrap ansible-playbook and go all-in?

So that's what we did. We wrote our own Python CLI called ap, that would inspect the arguments destined for ansible-playbook, and introduce some of its own arguments for a better user experience.

The script uses Typer and looks roughly as follows (this is a minimized version to illustrate the concept):

import os
import subprocess
import sys

from typer import Context
from typer import Option
from typer import Typer
from typing import Annotated


cli = Typer(add_completion=False, add_help_option=False, no_args_is_help=True)


@cli.command(name='ap', context_settings={
    "allow_extra_args": True,
    "ignore_unknown_options": True
}, add_help_option=False, no_args_is_help=True)
def main(
    ctx: Context,
    inventory: Annotated[list[str], Option("--inventory", "-i", help=(
        "Inventories to use, each either a site or a full inventory path"
    ))] = [],
) -> None:
    args = ['ansible-playbook', *(a for a in ctx.args)]

    # Configure inventories
    for i in inventory:
        i = i.removeprefix('inventory/')

        args.append('-i')
        args.append(f'inventory/{i}')

    # Always use diff
    if '-D' not in args and '--diff' not in args:
        args.append('--diff')

    # Configure systems
    os.environ.update(ara_env(args))
    os.environ.update(netbox_env(args))
    os.environ.update(secrets_env(args))

    # Execute
    result = subprocess.run(args, env=os.environ)
    sys.exit(result.returncode)


if __name__ == '__main__':
    cli()

Aside from enforcing arguments, this also gave us the flexibility to shorten our inventory calls a little, as the inventory prefix is really always the same. So while we started with this:

$ ansible-playbook playbooks/state/all.yml -i inventory/prod-rma1 -l www --diff

We can now call this, which is equivalent:

$ playbooks/state/all.yml -i prod-rma1 -l www

And now we can use all these saved keystrokes on useful things, like our engineering blog!


If you have comments or corrections to share, you can reach our engineers at engineering-blog@cloudscale.ch.

Back to overview