Moving to dict-based roles
I have been using a role-base approach to managing my SaltStack states, using lists of roles in the pillar to apply those roles to systems. This has started to breakdown when I want to dynamically add roles rather than have a set list per-host (e.g. to add an operating system role to all hosts without listing them on each) due to how SaltStack merges pillar data.
The problem
SaltStack merges dictionaries recursively but replaces other types of data, if the same key is specified. This means that if one pillar file (e.g. a host-specific file) has a list and another (e.g. an operating system specific file) has a list with the same key, which ever one is read second will overwrite the other.
As a concrete example, if the pillar’s top.sls
has:
base:
some-host:
host.some_host
'os:Debian':
- match: grain
- os.debian
host/some_hosts.sls
has:
roles:
- server
- router
- webserver
and os/debian.sls
has:
roles:
- debian
then ‘some-host’s computed pillar data (assuming it is a Debian system) for the roles
key will either be ['debian']
or ['server', 'router', 'webserver']
depending in which order they are read.
The solution
To get around this, so that the roles can “stack” (i.e. os/debian.sls
adds the debian
role rather than replace all the other roles with it), we can turn the roles
data in to a dictionary and use the keys of that dictionary as the list of roles. I set the value for each key to True
, which is useful (as I shall explain):
To do this, modify the two role files (host/some_hosts.sls
and os/debian.sls
) to (respectively):
roles:
server: True
router: True
webserver: True
roles:
debian: True
Now, since dictionaries are merged and there are no duplicate keys then the computed value for roles
will now be (regardless of the order the two files are read): {'server': True, 'router': True, 'webserver': True, 'debian': True}
Using it in states
Using a truth value (for which True
is the obvious choice) for the dictionary value means roles can be easily tested for with this jinja construct:
{% if salt['pillar.get']('roles:debian', False) %}
{% endif %}
One way to include states for each role, called role.$role_name
(you will need to create an empty roles/none.sls
for any host with no roles):
include:
{# N.B. roles.none gets included if there are no others. We need to have *something* here or salt will error due to the empty include #}
{%- for (role, enabled) in salt['pillar.get']('roles', {'none': True}).items() %}
{%- if enabled %}
- roles.{{ role }}
{%- endif %}
{%- endfor %}
An alternative, to creating roles/none.sls
, is to wrap the include with an if
(note that having established that roles
is in pillar, it will not error to access it directly):
{% if salt['pillar.get']('roles', False) %}
include:
{%- for (role, enabled) in pillar.roles.items() %}
{%- if enabled %}
- roles.{{ role }}
{%- endif %}
{%- endfor %}
{% endif %}