Writing backward compatible Salt states
13 minute read Updated:
Have you seen the dreaded function "module.run" is using its deprecated version and will expire in version "Sodium"
(now Phosphorus) warning message? While it is quite easy to enable the new more powerful module.run
syntax for all minions, doing so also requires you to:
- Fix every state that uses the old syntax (including the upstream formulas you use)
- Keep the old and new syntax in states that need to be used with other Salt environments where the
use_superseded: [module.run]
option is not enabled - Not forget to remove the configuration option when (and if) the old syntax is deprecated
If you write Salt states (formulas) that are used by other people on the internet, or by internal teams with their own Salt masters, you may also face the following problems:
- Which Salt version to use as a baseline?
- When is it safe to adopt a new shiny Salt feature without breaking older Salt setups?
- How to gently nudge other folks to upgrade Salt so you can finally remove the legacy code?
- What workarounds to use for various incompatibilities between Salt versions (e.g.,
tojson
filter)? - How to account for different ways people apply Salt states (
salt
,salt-call
,salt-ssh
)? There are many subtle differences that can break things. - How to specify different minion settings for different Salt master/minion versions?
- How to reduce the amount of boilerplate
if/endif
Jinja statements in your states?
I this article you’ll learn a couple of patterns that will help you write backward compatible Salt states. These patterns can turn a Salt upgrade from a big event that requires a lot of intensive work into a non-stressful gradual process.
A couple of common and uncommon patterns to introspect Salt versions and features
- Three guiding principles
- Version checks
- Configuration introspection
- Module and function introspection
- CLI detection
- Injecting warnings
- Injecting failures
- A couple of
module.run
examples - DRY with Jinja macro
- DRY with custom modules
- A possible alternative
Want to quickly test different Salt versions?
The next article will explain how to set up multi-machine Vagrant environments (using Virtualbox, Parallels, VMware, or Hyper-V) for Salt testing and development. Subscribe to read it as soon as the draft is done:
Powered by Mailgit
Three guiding principles
1. Optimize for code deletion
Write deletable code. Since the software is always evolving, it is much more practical to use modern features by default and de-emphasize the old ones.
2. Let it crash
Let it crash. If it doesn’t crash by itself but should, inject a controlled failure.
3. Use nudges and reminders
Add nudges to remind yourself or others to refactor/delete the code.
Version checks
Salt minion version number is available in two forms:
% salt-call --local grains.item saltversion saltversioninfo
local:
----------
saltversion:
2019.2.0
saltversioninfo:
- 2019
- 2
- 0
- 0
Version list comparison
It is hard to compare the saltversion
grain because it is a string. Comparing the saltversioninfo
grain with a list is much more straightforward:
{% if grains.saltversioninfo >= [2017, 7, 0, 0] %}
...
{% endif %}
CAVEAT 1: List length does matter:
[3000] >= [3000]
isTrue
[3000] >= [3000, 0]
isFalse
[3000, 0] > [3000]
isTrue
CAVEAT 2: If you stuck with Salt 3000 (insecure!), you’ll need an ugly workaround for the buggy saltversion: [3000, None, None, 0]
because you can’t compare None
with an integer. Try this instead (should work with Jinja 2.7 and higher):
{% if (grains.saltversion.split('.') | map('int') | list) >= [3000, 1] %}
...
{% endif %}
Conditional pillars
It is also possible to check the minion version grain inside of the pillars. Here is how to conditionally enable the new module.run
syntax for minions that are new enough to support it (assuming that your states support both syntax variants):
# /srv/pillar/common.sls
{% if grains.saltversioninfo >= [2018] %}
use_superseded:
- module.run
{% endif %}
Codename comparison
Since Salt 3000 Neon it is possible to compare codenames using the new salt_version
module:
{% if salt['salt_version.equal']('Neon') %}
...
{% endif %}
{% if salt['salt_version.greater_than']('Neon') %}
...
{% endif %}
{% if salt['salt_version.less_than']('Sodium') %}
...
{% endif %}
If your Salt is older than 3000, you can easily backport the module. If you do not want to do that, you can use a fall-back method if the module is not available:
{% if (salt.get('salt_version.greater_than', None) and salt['salt_version.greater_than']('Fluorine'))
or (grains.saltversioninfo > [2019, 2, 0, 0]) %}
...
{% endif %}
Package version comparison
It is intended to compare platform-specific package versions, but also can be used with Salt versions. The first function is available since 2015.8.10
, but is not implemented for each package manager:
{% if salt["pkg.version_cmp"](grains.saltversion, "3001") %}
...
{% endif %}
It returns -1
, 0
, or 1
depending on the result.
The second function appeared in Salt 3001 and returns a boolean value:
{% if salt['pkg_resource.version_compare'](grains.saltversion, '>=', '3001') %}
...
{% endif %}
Again, if the module is not available, you can backport it or use a fall-back method.
Minion targeting via top.sls
Using grain-based targeting, you can apply different states and pillar values to different minion versions without polluting your sls
files with Jinja conditionals. Just assign states to minions based on the saltversion
grain:
# /srv/salt/top.sls
base:
'*': []
'saltversion:3000.3':
- match: grain
- example_state_3000_3
'saltversion:3001':
- match: grain
- example_state_3001
Salt environments
If you prefer to keep the same state name for different minion versions, try Salt environments:
# /etc/salt/master.d/saltenvs.conf
file_roots:
base:
- /srv/salt
v3000_3:
- /srv/salt_3000_3
v3001:
- /srv/salt_3001
# /srv/salt/top.sls
base:
'*': []
v3000_3:
'saltversion:3000.3':
- match: grain
- example_state
v3001:
'saltversion:3001':
- match: grain
- example_state
Do not forget to read how top files are compiled.
Master version
If you need to provide different pillar values depending on Salt master version, try using the test.version
function inside of pillar files:
# /srv/pillar/master-version.sls
{% if (salt['test.version']().split('.') | map('int') | list) >= [3000, 1] %}
...
{% endif %}
Codename comparison will work too:
# /srv/pillar/master-version.sls
{% if salt['salt_version.equal']('Neon') %}
...
{% endif %}
Jinja and other dependencies
And finally, it is possible to compare a dependency version, for example, Jinja:
{% if (salt['test.versions_information']()['Dependency Versions']['Jinja2'].split('.') |
map('int') | list) < [2, 11] %}
...
{% endif %}
Please note, that it is not possible to conditionally use different Jinja filters (e.g., tojson
vs json
). See the jinja/#842
issue for an explanation. One way to solve this problem is to use different Salt environments for different minion versions.
Configuration introspection
This pattern should be used when a state depends on some config option. Here is how to check if the new module.run
syntax is enabled:
{% if 'module.run' in salt['config.get']('use_superseded', []) %}
...
{% endif %}
Another example:
{% if salt['config.get']('use_yamlloader_old') %}
...
{% endif %}
It is possible to use opts.get()
instead of salt['config.get']()
. However, it won’t return options that are defined through pillar/grains, plus the default value is an empty string instead of None
.
{% if opts.get('use_yamlloader_old') %}
...
{% endif %}
Module and function introspection
Execution modules/functions
Module loading in Salt depends on a couple of factors like OS and installed dependencies. It is quite easy to check if a function (e.g., test.ping
) is loaded:
{% if salt.get('test.ping', None) %}
...
{% endif %}
This particular syntax works for both salt
and salt-ssh
.
It is also possible to check if a function signature has a specific argument:
{% if 'extra_args' in salt['sys.argspec']('pip.install')['pip.install']['args'] %}
...
{% endif %}
State modules/functions
State modules aren’t exposed in Jinja, so it is a bit harder to check if a state module or function exists:
{% if 'xml' in salt['sys.list_state_modules']() %}
...
{% endif %}
{% if 'xml.value_present' in salt['sys.list_state_functions']() %}
...
{% endif %}
It is also possible to check if a function signature has a specific argument:
{% if 'extra_args' in salt['sys.state_argspec']('pip.installed')['pip.installed']['args'] %}
...
{% endif %}
Other types of modules
There are many other functions that could be used to introspect different types of modules:
- sys.argspec
- sys.doc
- sys.list_modules
- sys.list_functions
- sys.list_renderers
- sys.renderer_doc
- sys.list_returners
- sys.list_returner_functions
- sys.returner_argspec
- sys.returner_doc
- sys.list_runners
- sys.list_runner_functions
- sys.runner_argspec
- sys.runner_doc
- sys.list_state_modules
- sys.list_state_functions
- sys.state_argspec
- sys.state_doc
- sys.state_schema
- test.module_report
- test.not_loaded
Here is a particularly devious example of how to check the feature that is only mentioned in the function docstring:
{% if salt['sys.state_doc']('file.symlink')['file.symlink'] |
regex_search('(Force will now remove all types of existing file system entries)') %}
...
{% endif %}
And the new baredoc
module (available in Salt 3001 Sodium) can inspect modules and states that aren’t even loaded yet:
{% if salt['baredoc.list_modules']('dockermod') %}
...
{% endif %}
{% if salt['baredoc.list_modules']('dockermod')['docker'] |
traverse('logout') %}
...
{% endif %}
{% if 'registries' in (salt['baredoc.list_modules']('dockermod')['docker'] |
traverse('logout')).get('args') %}
...
{% endif %}
{% if 'Force will now remove all types of existing file system entries' in
salt['baredoc.state_docs']('file.symlink')['file.symlink'] %}
...
{% endif %}
CLI detection
Salt states can be applied through various means: salt
, salt-call
(or salt-call --local
), salt-run
, salt-ssh
. The available features can vary depending on the CLI utility being used. Below are a couple of patterns to detect a Salt utility.
WARNING: Some of those checks aren’t very specific (e.g., the salt-call
ones), and could clash with salt-ssh
checks. When you use them in multiple if
branches, place the more specific checks first.
salt MINION state.apply
{% if opts.get('__cli') == 'salt-minion' %}
...
{% endif %}
salt-call state.apply
{% if opts.get('__cli') == 'salt-call' %}
...
{% endif %}
salt-call --local state.apply
(masterless mode)
{% if opts.get('__cli') == 'salt-call' and opts.get('file_client') == 'local' %}
...
{% endif %}
salt-run state.cmd state.apply
(and also orchestrate states)
This is helpful if you apply a state to a Salt master via salt-run
and need to change the state behavior.
{% if opts.get('__cli') == 'salt-run' and opts.get('file_client') == 'local' %}
...
{% endif %}
salt-ssh MINION state.apply
{% if 'salt-ssh' == opts | traverse('__master_opts__:__cli', '') %}
...
{% endif %}
The traverse
Jinja filter was added in Salt 2018.3.3. For older versions use opts.get('__master_opts', {}).get('__cli')
.
SSH via enable_ssh_minions: true
{% if (opts.get('__cli') == 'salt-call') and ('salt-master' == opts | traverse('__master_opts__:__cli', '')) %}
...
{% endif %}
A more universal way to detect both salt-ssh
and ssh minions is to use one of the following tests:
{% if opts.get('root_dir').endswith('/running_data') %}
...
{% endif %}
{% if (opts.get('__cli') == 'salt-call') and (opts | traverse('__master_opts__:__cli', '') in ('salt-ssh', 'salt-master')) %}
...
{% endif %}
The libsaltcli.jinja
macro
If you need to repeat those checks in multiple states, you can try the Jinja macro from the saltstack-formulas
GitHub community.
Injecting warnings
Often it is necessary to trigger conditional warnings from states, to remind yourself (or others) to take some action (upgrade Salt, rewrite a state, etc.). Below are two ways to do that.
Log-based warnings
The easiest way to emit a warning from any *.sls
file (state or pillar) is to use Jinja:
{% if SOME_CONDITION %}
{% do salt.log.warning('Attention!') %}
{% endif %}
The resulting warning will be visible in the minion log file (if used in states), or in the master log file (if used in pillars).
CAVEAT: salt.log
is not available before 2017.7.0
State warnings
The problem with log-based warnings is that you need to watch the logs in order to notice them. Fortunately, it is also possible to inject a warning into a state. Since Salt 3000 you can do this:
# warning.sls
{% if SOME_CONDITION %}
A warning:
test.configurable_test_state:
- result: true
- changes: false
- warnings: Attention!
{% endif %}
Here is how the warning looks like:
% sudo salt minion1 state.apply neon.warning
minion1:
----------
ID: A warning
Function: test.configurable_test_state
Result: True
Comment:
Started: 06:08:37.709704
Duration: 1.791 ms
Changes:
Warnings: Attention!
Summary for minion1
------------
Succeeded: 1
Failed: 0
Warnings: 1
------------
Total states run: 1
Total run time: 1.791 ms
It is very visible, and highlighted in red (although the blog syntax highlighter can’t display it).
If your Salt version is older than 3000, you can easily backport this feature, or just copy it into a custom module.
Injecting failures
Occasionally it is necessary to inject something more noticeable than a warning. There are two ways to do that.
Fail state
# failure.sls
{% if SOME_CONDITION %}
A failure:
test.configurable_test_state:
- result: false
- changes: false
- comment: You need to do something right now!
{% endif %}
% sudo salt minion1 state.apply failure
minion1:
----------
ID: A failure
Function: test.configurable_test_state
Result: False
Comment: You need to do something right now!
Started: 06:07:16.292593
Duration: 2.663 ms
Changes:
Summary for minion1
------------
Succeeded: 0
Failed: 1
------------
Total states run: 1
Total run time: 2.663 ms
ERROR: Minions returned with non-zero exit code
If you also add - failhard: true
option to the failure.sls
state, then the remaining state execution process will be stopped.
One practical example is to fail hard on all minions that do not use the new module.run
syntax:
{% if 'module.run' not in salt['config.get']('use_superseded', []) %}
module.run check:
test.configurable_test_state:
- result: false
- changes: false
- comment: Please enable the new module.run syntax in the minion config
- failhard: true
{% endif %}
Jinja raise
{% if SOME_CONDITION %}
{{ raise('You need to do something right now!') }}
{% endif %}
Here is how it fails:
salt.exceptions.TemplateError: Custom Error
; line 2
---
{% if true %}
{{ raise(' You need to do something right now!') }} <======================
{% endif %}
A couple of module.run
examples
I’ve chosen this example because it is a common stumbling block for many Salt users. Here is how the older (default) syntax looks like:
Example of the obsolete module.run syntax:
module.run:
- name: test.echo
- text: "Using the obsolete variant of module.run"
And this is the modern syntax (requires you to add use_superseded: [module.run]
to the minion config, and yes, the setting name is confusing):
Example of the modern module.run syntax:
module.run:
- test.echo:
- "Using the modern variant of module.run"
Here is a little bit of Git archaeology to show you how this feature has been evolving:
- #39891 - introduce the feature in v2017.7.0
- #41219 - shift the deprecation deadline from Oxygen (2018.3) to Fluorine (2019.2)
- #41708 - shift the deprecation deadline from Fluorine (2019.2) to Sodium (3001)
- #42602 - allow configuring the feature toggle through pillar in 2017.7.2
- #45381 - fix docs in 2017.7.3
- #55713 - fix keyword ordering in 3000
- #57370 - shift the deprecation deadline from Sodium (3001) to Phosphorus (3005)
Over the years it generated a lot of confusion: #42148, #42272, #43130, #43770, #44425, #44882, #45360, #47033, #49851, #50348, #51349, #53504, #55551, #56422, #57919, #58705…
The current sentiment is to keep the status quo (#58705, #55551). My personal opinion is that the modern syntax is much better, and with enough willpower it is possible to eventually phase out the old syntax.
Below are possible steps to switch to the new syntax.
1. Enable the feature toggle
You can do this through pillar or via the minion config:
# /etc/salt/minion.d/superseded.conf
use_superseded:
- module.run
For salt-ssh
you need to add the following configuration snippet:
# /etc/salt/master.d/superseded.conf
ssh_minion_opts:
use_superseded:
- module.run
The same can be done on a per-minion basis in a roster file:
MINION1:
host: interceptor.micros.local
minion_opts:
use_superseded:
- module.run
2. Decide how to update your states
Using the config check
Example of backward compatible module.run:
module.run:
{% if 'module.run' in salt['config.get']('use_superseded', []) %}
- test.echo:
- "Using the modern variant of module.run"
{% else %}
- name: test.echo
- text: "Using the obsolete variant of module.run"
{% endif %}
Using the top.sls condition
# /srv/salt/top.sls
base:
'*': []
{% if 'module.run' in salt['config.get']('use_superseded', []) %}
- state_that_uses_the_new_syntax
{% else %}
- state_that_uses_the_old_syntax
{% endif %}
Using Salt environments
You can rewrite your states in a separate saltenv (a fileserver directory or git branch). Then assign the saltenv to minions using the saltversion
grain or any other targeting criteria (including a custom grain).
3. Add some nudges
- Rely on the default
module.run
warning or add a customsalt.log.warning
- If the version is 3000 or newer, use a state-based warning
- Remind yourself to remove/review the
use_superseded
config option on Phosphorus using the codename comparison
DRY with Jinja macro
Below is an example of how to reduce the boilerplate code using a Jinja macro:
{% macro superseded(state_id) -%}
{% set superseded = 'module.run' in salt['config.get']('use_superseded', []) -%}
{% set new_style, old_style = caller().split('# ---') -%}
{% if superseded -%}
{{ new_style }}
{% if not salt['salt_version.less_than']('Phosphorus') -%}
{{ state_id }} - nudge to remove the setting:
test.configurable_test_state:
- result: true
- changes: false
- warnings: 'Please remove the deprecated "use_superseded: [module.run]" config option'
{% endif -%}
{% else -%}
{{ old_style }}
{{ state_id }} - nudge to add the setting:
test.configurable_test_state:
- result: true
- changes: false
- warnings: 'Please enable the "use_superseded: [module.run]" config option'
{% endif -%}
{%- endmacro %}
Usage example:
{% from superseded_macro.jinja' import superseded -%}
backward-compatible-module-run:
module.run:
{%- call superseded('backward-compatible-module-run') %}
- test.echo:
- "Using the modern variant of module.run"
# ---
- name: test.echo
- text: "Using the obsolete variant of module.run"
{% endcall %}
DRY with custom modules
Even more DRY option is to use a custom module, but this topic is out of the article’s scope. For example, folks at SUSE (who work on Uyuni) implemented a custom module.run
replacement that handles the syntax differences automatically: uyni/states/mgrcompat.py.
A possible alternative
Things could be a lot simpler if the Minion Capabilities (#50617) RFC was implemented. The idea was to allow a module/function/docstring/parameter introspection inside of Jinja templates:
'host' in capable.modules.network.ping.signature.params
capable.modules.network.ping.doc.has_parameter('host')
capable.modules.network.ping.doc.contains('Performs at ICMP ping')
Unfortunately, the RFC author no longer contributes to Salt. In addition to that, the Salt release strategy has changed, and it is unlikely that this feature (if implemented) will be backported to older versions.
Want to quickly test different Salt versions?
The next article will explain how to set up multi-machine Vagrant environments (using Virtualbox, Parallels, VMware, or Hyper-V) for Salt testing and development. Subscribe to read it as soon as the draft is done:
Powered by Mailgit