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 %}