Shaping Data with Filters and Tests

Scenario: A cipher list was pasted from a chat message as one long string. The template calls | join(':') on it and produces a string with a colon between every single character.

New words, in plain English

Filters transform values inside {{ }}, and knowing a handful well removes most of the awkward YAML people write to avoid them.

The scenario is a type error, and it is common. join operates on a list. Applied to a string, a string is a sequence of characters, so it happily joins the characters. The fix is not a cleverer filter - it is making the data a real YAML list:

tls_ciphers:
  - HIGH
  - '!aNULL'
  - '!MD5'

The filters worth knowing on day one: default(x) for fallbacks; join(sep) for list-to-string; length; upper/lower; basename/dirname for paths; int/bool/string for type coercion; to_json/from_json; map('attribute', 'name') to pull one field from every element; select/reject to filter a list; unique; sort; and combine to merge dictionaries.

Tests are used with is: is defined, is undefined, is none, is match('regex'), is version('2.0', '>='). when: app_version is match('^\\d+\\.\\d+\\.\\d+ ) is a genuinely useful pre-flight guard.

Analogy: Filters are kitchen implements. A sieve on a bag of flour works beautifully. A sieve on a whole cake technically also 'works' - it just does not give you anything you wanted. Check what you are putting in.

A worked example

# WRONG - tls_ciphers is a STRING, so join() joins its characters
tls_ciphers: "HIGH !aNULL !MD5"
ciphers = {{ tls_ciphers | join(':') }}     # H:I:G:H: :!:a:N...

# RIGHT - a real list
tls_ciphers:
  - HIGH
  - '!aNULL'
  - '!MD5'
ciphers = {{ tls_ciphers | join(':') }}     # HIGH:!aNULL:!MD5

# Everyday filters
{{ retries | default(3) }}
{{ users | map(attribute='name') | join(', ') }}
{{ ports | select('gt', 1024) | list }}
{{ base_config | combine(host_overrides) }}

# Tests, used with `is`
when: api_token is defined
when: app_version is match('^\d+\.\d+\.\d+
  

)

Two type traps are worth memorising. First, a value read from a file or command output is a string, even if it looks like a number - "8080" == 8080 is false, so use | int when comparing. Second, YAML's booleans are broad: yes, on and true are all boolean true, which means an unquoted country code like no (Norway) becomes false. Quote strings that could be misread.

For the modules that accept it, | default(omit) is a special and very useful value: it removes the parameter entirely rather than passing an empty one, letting the module apply its own default. That is the clean way to make an optional module argument genuinely optional.

Tip: Whenever a filter produces nonsense, check the TYPE of the input first. Nine times out of ten a string is standing where a list should be.
Goal: Put this to work in the ansible-preflight-assertions lab. Open /labs/ansible, pick ansible-preflight-assertions, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.