Skip to content

Tutorial

Installing

Typically you are able to install uPlaybook by running: python3 -m pip install uplaybook

For more detailed installation instructions, see Installing uPlaybook

Your First Playbook

"Playbooks" are the main artifact of uPlaybook. A playbook contains the recipe that declares the desired state of the system.

Playbooks are Python code, with some features and conventions to make it more ergonomic for configuration management operations.

For our first example, let's set up an Apache server:

from uplaybook import pyinfra, fs

pyinfra.apt.packages(packages=["apache2"])
fs.ln(src="/etc/apache2/mods-available/proxy.load",
      path="/etc/apache2/mods-enabled",
      symbolic=True)
fs.ln(src="/etc/apache2/mods-available/proxy_http.load",
      path="/etc/apache2/mods-enabled",
      symbolic=True)
pyinfra.systemd.service(service="apache2", running=True, enabled=True)

This playbook installs the "apache2" package and creates a symlink to enable the proxy and proxy_http modules. It also ensures that Apache2 is enabled at boot and running.

Run the above with: up apache-module.pb (if you saved the above to the file "apache-module.pb":

$ sudo up apache-module.pb
=# packages(packages=['apache2'], present=True, latest=False, update=False, upgrade=False, force=False,
no_recommends=False, allow_downgrades=False)
=> ln(path=/etc/apache2/mods-enabled, src=/etc/apache2/mods-available/proxy.load, symbolic=True)
=> ln(path=/etc/apache2/mods-enabled, src=/etc/apache2/mods-available/proxy_http.load, symbolic=True)
=# service(service=apache2, running=True, restarted=False, reloaded=False, enabled=True, daemon_reload=False,
user_mode=False)

*** RECAP:  total=4 changed=2 failure=0

The ">" shows that action was taken, "#" means no action required, the system state already matches the declaration.

Declarative Config Management

uPlaybook implements "declarative configuration management", meaning statements declare the desired state. In the above playbook, you are declaring that the "apache2" package is installed, and that the proxy.load and proxy_http.load symlinks exist.

This differs from shell scripting which specify actions: some actions may be safe to run if previously run (such as changing ownership, a noop if the owner is already the desired owner), but some may not (re-adding a user to a system will fail).

One benefit of this is that uPlaybook understand when an action changes the system. We can use this to trigger additional actions in this case. We call this "notifying a handler".

Notifying Handlers

Handlers and notifying them is an idea taken from Ansible. Let's modify the above playbook:

from uplaybook import pyinfra, fs

def restart_apache():
    pyinfra.systemd.service(service="apache2", restarted=True)

pyinfra.apt.packages(packages=["apache2"])
fs.ln(src="/etc/apache2/mods-available/proxy.load",
      path="/etc/apache2/mods-enabled",
      symbolic=True).notify(restart_apache)     #   <-- Added notify here
fs.ln(src="/etc/apache2/mods-available/proxy_http.load",
      path="/etc/apache2/mods-enabled",
      symbolic=True).notify(restart_apache)     #   <-- Added notify here
pyinfra.systemd.service(service="apache2", running=True, enabled=True)

A handler is a python function, and you chain .notify(retart_apache) to the fs.ln task.

Notifications are deferred until the end of the playbook, or until core.flush_handlers() is called. Multiple notifys of the same handler will be deduplicated. You can perform multiple tasks that notify the apache restart, but only one restart will be done.

In other words, you can enable multiple modules, copy or update configuration files, and any (or all) of them will later restart apache to make the changes take effect.

On the other hand, if you re-run the playbook later, and no changes are necessary (the playbook already reflects system state), no restart will be done.

Running the above produces:

$ sudo up apache-module.pb
=# packages(packages=['apache2'], present=True, latest=False, update=False, upgrade=False, force=False,
no_recommends=False, allow_downgrades=False)
=> ln(path=/etc/apache2/mods-enabled, src=/etc/apache2/mods-available/proxy.load, symbolic=True)
=> ln(path=/etc/apache2/mods-enabled, src=/etc/apache2/mods-available/proxy_http.load, symbolic=True)
=# service(service=apache2, running=True, restarted=False, reloaded=False, enabled=True, daemon_reload=False,
user_mode=False)
>> *** Starting handler: restart_apache
=> service(service=apache2, running=True, restarted=True, reloaded=False, daemon_reload=False, user_mode=False)
>> *** Done with handlers

*** RECAP:  total=5 changed=3 failure=0

fs.builder()

fs.builder() is a powerful way to create a large set of filesystem objects. It takes a list of operations to perform, and optionally a set of defaults. So you can specify defaults for permissions, ownership, etc, and then optionally override them in the specific items.

For example, to set up headscale:

from uplaybook.core import Item

def restart_headscale():
    pyinfra.systemd.service(service="headscale", restarted=True)

fs.builder(defaults=Item(owner="headscale", group="headscale", mode="a=-,ug+rwX"),
           items=[
               Item(path="/etc/headscale", state="directory"),
               Item(path="/etc/headscale/config.yaml", notify=restart_headscale),
               Item(path="/etc/headscale/acls.yaml", notify=restart_headscale),
               Item(path="/etc/headscale/derp.yaml", notify=restart_headscale),
               ])

Reading Docs

How do you know what tasks and arguments are available? uPlaybook includes an "updocs" command that will display documentation:

$ updocs
[lists available modules]
$ up --up-doc fs
[lists available "fs" tasks]
$ updocs fs.
# fs.ln

    Create a link from `src` to `path`.

    Args:
        path: Name of destination of link. (templateable).
        src: Name of location of source to create link from. (templateable).
        symbolic: If True, makes a symbolic link. (bool, default: False)
    [...]

Additionally, documentation is available at: uPlaybook Documentation

Playbook Arguments

Playbooks can also take arguments on the command line to "parameterize" playbooks. Let's say, for example, that we want to have a generic playbook that enables the module specified on the command-line:

from uplaybook import pyinfra, fs, core, ARGS   # <-- Need "core, ARGS" now

core.playbook_args(options=[
    core.Argument(name="module_name"),
])

def restart_apache():
    pyinfra.systemd.service(service="apache2", restarted=True)

pyinfra.apt.packages(packages=["apache2"])
fs.ln(src="/etc/apache2/mods-available/{{ ARGS.module_name }}.load",
    path="/etc/apache2/mods-enabled",
    symbolic=True).notify(restart_apache)
pyinfra.systemd.service(service="apache2", running=True, enabled=True)

If we run up apache-module.pb, we now get:

$ sudo up apache-module
usage: up:apache-module.pb [-h] module_name
up:apache-module.pb: error: the following arguments are required: module_name

So now we can run:

$ sudo up apache-module.pb proxy
=# packages(packages=['apache2'], present=True, latest=False, update=False, upgrade=False, force=False,
no_recommends=False, allow_downgrades=False)
=> ln(path=/etc/apache2/mods-enabled, src=/etc/apache2/mods-available/{{ ARGS.module_name }}.load, symbolic=True)
=# service(service=apache2, running=True, restarted=False, reloaded=False, enabled=True, daemon_reload=False,
user_mode=False)
>> *** Starting handler: restart_apache
=> service(service=apache2, running=True, restarted=True, reloaded=False, daemon_reload=False, user_mode=False)
>> *** Done with handlers

*** RECAP:  total=4 changed=2 failure=0

Directory Playbooks

Once playbooks start getting more complex, it can become useful to make "directory playbooks", which combines the playbook with additional files the playbook may need such as templates or files.

If we create a "my-website" directory and create a "playbook" file within it, we now have a directory playbook.

$ mkdir my-website
$ nano my-website/playbook

In the "playbook" file put the following:

from uplaybook import pyinfra, fs, core

def restart_apache():
    pyinfra.systemd.service(service="apache2", restarted=True)

pyinfra.apt.packages(packages=["apache2", "libapache2-mod-uwsgi"])
for module_name in ["proxy", "uwsgi"]:
    fs.ln(src="/etc/apache2/mods-available/{{ module_name }}.load",
          path="/etc/apache2/mods-enabled",
          symbolic=True).notify(restart_apache)
fs.cp(path="/etc/apache2/sites-available/my-website.conf").notify(restart_apache)
fs.ln(src="/etc/apache2/sites-available/my-website.conf",
      path="/etc/apache2/sites-enabled",
      symbolic=True).notify(restart_apache)

pyinfra.systemd.service(service="apache2", running=True, enabled=True)

Also write the file "my-website/my-website.conf.j2" with the following contents:

#  My uwsgi website

Why do we call the file "my-website.conf.j2"? By default, fs.cp() will: use the base name of the resulting file as the source, and add ".j2" because by default it will do Jinja2 template expansion on the file contents. It can also Jinja2 expand file names as well, if doing a recursive write.

Now, if we run it:

$ sudo up my-website
=# packages(packages=['apache2', 'libapache2-mod-uwsgi'], present=True, latest=False, update=False, upgrade=False,
force=False, no_recommends=False, allow_downgrades=False)
=# ln(path=/etc/apache2/mods-enabled, src=/etc/apache2/mods-available/{{ module_name }}.load, symbolic=True)
=# ln(path=/etc/apache2/mods-enabled, src=/etc/apache2/mods-available/{{ module_name }}.load, symbolic=True)
=> cp(path=/etc/apache2/sites-available/my-website.conf, src=/tmp/my-website/my-website.conf.j2, template=True,
template_filenames=True, recursive=True) (Contents)
=> ln(path=/etc/apache2/sites-enabled, src=/etc/apache2/sites-available/my-website.conf, symbolic=True)
=# service(service=apache2, running=True, restarted=False, reloaded=False, enabled=True, daemon_reload=False,
user_mode=False)
>> *** Starting handler: restart_apache
=> service(service=apache2, running=True, restarted=True, reloaded=False, daemon_reload=False, user_mode=False)
>> *** Done with handlers

*** RECAP:  total=7 changed=3 failure=0

It makes sure apache2 and mod-uwsgi are installed, enables the "proxy" and "proxy-uwsgi" modules, writes a "my-website.conf" file and symlinks it into the "sites-enabled" directory.

Templates

Your playbooks can include Jinja2 template files, which are a convenient way to provide configuration files and similar. When you fs.cp() files with the "template" argument set to True (the default), the files are rendered using Jinja2, and pick up values from the uplaybook variables.

uPlaybook includes some "magic" that causes variables in your playbooks, arguments, and platform special variables.

For example, if you have a "setup.conf.j2":

{% if platform.release_id == 'ubuntu' %}
memory_mb = {{ ((platform.memory_total / (1024 * 1024)) * 0.5) | int}}
{% endif %}

And your playbook has:

fs.cp(src="setup.conf.j2", path="/etc/myservice/setup.conf")

Then on the ubuntu platform, the file will contain (on a system with 32GB of memory):

memory_mb = 16000

(or so).

Playbook Magic

Playbooks are largely Python, but there are some conventions and customizations to make it more suiltable for an Ansible-like use case. Here are the things that diverge from normal Python semantics:

"Global" Variables

Variables set in your playbooks become available to the other playbooks and also (most importantly) to file and argument templates. The primary reason for this is to allow including "settings" files:

core.include(playbook="{{ environment }}")
debug(msg="This is a {{ env_type | default('development') }} environment")

In programming having this sort of flat name-space is discouraged, but for playbooks it seems more ergonomic.

Templating Arguments

Many arguments (denoted as "templatable" in the documentation) can take Jinja2 template expressions. This is partly a hold-over from Ansible, where you can't do "f strings" in YAML, but also can be useful for situations like picking up default values:

fs.cp(dst="{{ dirname | default('/var/lib/my_project') }}/my_project.config")

Keyword Arguments

To make playbooks more clear, it's required to always use the argument keyword name:

fs.cp(src="foo", dst="bar")

This is another hold-over from Ansible, where the YAML requires that you have the argument names. It just makes the playbooks more self-explanatory.

Playbook Search Path

uPlaybook searches for playbooks through multiple directories (specified by the "UP_PLAYBOOK_PATH" environment variable), which defaults to: .:.uplaybooks:~/.config/uplaybook:~/.config/uplaybook/library:/etc/uplaybook".

This allows you to have playbooks in directories that are local to that project and available an discoverable when you are in the project, but also have per-user and system-wide playbook as well.

When you run up without any arguments, it displays a list of playbooks it finds in the search path, along with short descriptions of them. You can then run up [PLAYBOOK_NAME] --help to get more detailed information about the playbook and the arguments it takes.

For example, I have playbooks in my "release" project which create a new release, and archive old releases. This makes use of templating files in the project, running git commands, etc. In my Ansible project, I had a playbook that creates a new "role", using templating to set up the scaffolding.

Playbook Documentation

Playbooks can include a docstring. The first line of the docstring should contain a short description of the playbook, and the remainder of the docstring should go into further detail about the playbook. The description is used when playbooks are listed and the additional documentation is displayed with "--help":

For example, if you have the file "new_blog.pb":

#!/usr/bin/env python3

"""Create and edit a new blog entry
Add a new blog entry, and optionally commit it to the git repo and publish it
to the blog webserver.
"""

from uplaybook import core

#  this is needed for "--help" to work
core.playbook_args()

[...]

You get the following output when running uPlaybook:

$ up
usage: up [--help] [--up-full-traceback] [--up-list-playbooks] [--up-debug] [playbook]

Available playbooks:
  - new_blog.pb (.)
      Create and edit a new blog entry

$ up new_blog --help
usage: up:new_blog [-h]

Create and edit a new blog entry Add a new blog entry, and optionally commit it to the
git repo and publish it to the blog webserver.

options:
  -h, --help  show this help message and exit

She-bang

uPlaybook can be run as a "script" on Unix-like OSes by adding a "she-bang" line #!/usr/bin/env -S python3 -m uplaybook.cli.

1
2
#!/usr/bin/env -S python3 -m uplaybook.cli
[rest of playbook here]

If you chmod 755 the playbook file, you can then directly run the playbook file as a command. For example if your playbook is in a file called "myplaybook", you can now run myplaybook instead of up myplaybook. This would primarily be used for file-based playbooks, not directory-based, as for directory playbooks you'd need to do path/to/directory/playbook; that will work it just isn't conventional.

Include Other Playbooks

Your playbooks can reference other files. This can be useful for logical separation of components, including common variables, etc...

core.include(playbook="common-vars")

By default, variables set in an included playbook are "hoisted" up into the global name space, but that can be overridden with the argument hoist_vars=False.