Callbacks or hooks, and reusable series of tasks, in Ansible roles
I'm struggling with making my Ansible roles somewhat composeable and re-usable.
I have a role that performs several series of steps. Depending on the other roles being applied to a host, other roles need to run tasks at each step.
I'm working with PostgreSQL and PostgreSQL extension management, but to keep it simple let's use an example of a webserver and some apps that run on it.
- role: webserver
- role: myapp
- role: myotherapp
The webserver
role:
- installs the webserver
- templates its configuration
- sets up its database
- starts the webserver
The myapp
role needs to be able to add some settings to the webserver's config that must be applied before the webserver's database is created. It must also perform some actions before the webserver is first stated.
The myotherapp
role also needs to add some settings to the webserver's config before first-run, without clobbering the settings from myapp
. It must also perform some actions after the webserver has first started, restart it, do some more work, and restart it again.
(This is contrived, but expresses all the needs of my real use case without going into way more about PostgreSQL innards that you ever wanted to know).
I thought I should be able to use handlers for this. Have webserver
just fire notify
s at each relevant step, so other roles could hook those notifications to append to lists or add keys to hashes used for config templating, fire their own tasks, etc. Have a restart_webserver
handler that myotherapp
can notify each time it wants to issue a restart, without having to know how that restart is actually performed. etc.
However:
-
If there's a notification, the handler must exist. Ansible doesn't permit optional handlers.
-
If there's more than one handler for a notification, Ansible just picks the first one and ignores all the rest.
-
Notifications fire only at the end of a play section. You can't fire them immediately. There's
meta: flush_handlers
but it flushes all handlers, even those from other unrelated roles and tasks that might not be expecting their handlers to run early. -
It's astonishingly difficult to append to a list, or add keys to a dictionary. So it's quite hard to compose multiple related roles, without just manually gluing the configuration together with some
host_vars
yml.
So handlers and notifications don't seem to be how to do this.
How does one write composeable tasks and roles?
Am I trying to use a bicycle wheel as a hammer? Are you just not meant to use roles this way? It seems consistent with the concept of a role.
Should anything vaguely re-usable or less than totally linear be written by dropping Ansible's tasks/roles/vars/etc system and doing it in Python as an Ansible module? If so, is there any practical way to implement most of the brains of such a module as Ansible task-lists, variables yml files, etc, and just having the control logic in the module, so the module doesn't have to re-implement tons of stuff?
The "PostgreSQL guts" version:
-
A
postgres_server
role. Installs Pg from distro packages or from source code as required. Has lots of vars indefaults/main.yml
that can be overridden to control behaviour. Provides the facility to template a PostgreSQL configuration to supply parameters forpostgresql.conf
. Lets the caller override or prepend lines topg_hba.conf
. initdb's PostgreSQL once installed. Starts PostgreSQL once configured. Knows how to start, stop and restart thepostgres_server
(handler). Exposes facts to tell other roles where PostgreSQL is installed. -
A
bdr_extension
role. Assigned to a host where the bdr extension will be installed in PostgreSQL. Needs to see facts exposed bypostgres_sever
to find out how to install. Can fetch/compile/install BDR from git or install from OS packages.PostgreSQL cannot start succesfully if
bdr
is in the configuration'sshared_preload_libraries
. Sopostgres_server
's starting of the server must be delayed until this is installed, if this extension is enabled. -
A
bdr_node
role. Depends onpostgres_server
andbdr_extension
. Knows the PostgreSQL configuration options required to allow BDR to run on PostgreSQL, e.g.max_wal_senders = 20
. Needs to be able to ensure that the PostgreSQL config optionshared_preload_libraries
hasbdr
in its comma-separated list of values whenpostgres_server
generates the config. Overrides the defaultpg_hba.conf
template inpostgres_server
to enable replication entries.This role must run some SQL commands after PostgreSQL has started.
-
A
pg_stat_statements
role. Can installpg_stat_statements
extension from git or OS packages. Needs to see facts exposed bypostgres_sever
to find out how to install. Needs to be able to append some extension-specific lines topostgresql.conf
. Needs to be able to append'pg_stat_statements'
to theshared_preload_libraries
setting in the PostgreSQL server configuration.PostgreSQL cannot start succesfully if
shared_preload_libraries
is in the configuration'spg_stat_statements
. Sopostgres_server
's starting of the server must be delayed until this is installed, if this extension is enabled.
So a BDR node is-a PostgreSQL server, and has-a BDR extension and pg_stat_statements extension, as well as others.
I expected to be able to expose handlers in the postgres_server
role, and invoke them from other roles as needed to invoke appropriate activities in the postgres_server
role, like a DB server restart, but it doesn't seem to be designed that way at all.
Am I getting it all completely backwards by thinking of Ansible roles as actually modelling roles?
I'm not familiar with PostgreSQL so I'm unsure of the contents of the various configuration files you mentioned, but if you need to allow multiple roles to contribute to a single configuration file, I've accomplished this in one of two ways depending on what the configuration file supports:
Use a configuration file with lots of include statements to include additional configuration files located in sub-directories. This works well with something like Apache for rewrite rules, application-specific configuration directives, and virtual hosts.
Assemble the configuration file from multiple partial configuration files stored in a directory using the assemble module. I've used this method to construct a master.cf file for Postfix as I had a
postfix
role and another role that needed to add to the Postfix master.cf file. So I modified thepostfix
role to create a /etc/postfix/master.d directory and a handler that assembles the files in that directory into a master.cf file. Unfortunately, as you pointed out, since I can't call the handler in thepostfix
role from another role, I had to duplicate that handler in my additional role. It's slightly less than idea but it works and it's idempotent.