← All TILs · ansible

default, default(omit), mandatory and ternary: sudo rules that don't set every field

ansible - 2026-10-02

Eighth entry in the Shaping data in Ansible series. Data from people is incomplete: a field left out, left blank, or set to "not decided". In YAML these are three different values:

Four filters deal with them: default, default(omit), mandatory and ternary. The example is part 1's sudo rules for the PostgreSQL service, rendered by the linux-system-roles.sudo role, where not every rule sets every option. Everything below ran with ansible-core 2.21.4 and the sudo role 1.5.0, with each file checked by visudo.

The rules and the role

Part 1 explains sudoers rules and the sudo system role. The role takes user_specifications, one dict per rule, and its template, templates/sudoers.j2, writes one line per dict:

The rules, as they might come from a form:

postgres_sudo_rules:
  - name: restart
    commands: [/usr/bin/systemctl restart postgresql.service]
    nopasswd: true
    runas: root
  - name: status
    commands: [/usr/bin/systemctl status postgresql.service]
    nopasswd: false
  - name: journal
    commands: [/usr/bin/journalctl -u postgresql.service]
    runas:              # left empty: null
  - name: reload
    commands: [/usr/bin/systemctl reload postgresql.service]
    group: ""           # left blank
    nopasswd:           # null: not decided

status has no runas, journal's is null, reload has a blank group and an undecided nopasswd, and only restart sets everything.

default(omit) only works on module arguments

omit is a special value that makes Ansible leave an argument out of a task, as if it had never been written. A natural first try is to use it for status's missing runas:

user_specifications:
  - "{{ base | combine({'commands': rule.commands, 'operators': rule.runas | default(omit)}) }}"

The role's template failed: "object of type '_OmitType' has no len()". Inside a dict that a template reads, the omit value isn't removed; it stays there as a special object. The docs, Making variables optional, describe omit for module variables only, and that's all it does. With journal's null runas the result was the same kind of error: "object of type 'NoneType' has no len()". is defined is true for null, so the template goes on to measure it.

Where omit does work is on a task's own arguments. The same broken rule, a command without its full path, which sudo refuses, rendered twice with validate: "{{ item.validate | default(omit) }}":

For this role, the answer to an optional key is an empty list, which its template skips: 'operators': [rule.runas] if rule.runas | default(none) else []. default(none) turns a missing runas into null first, so both cases end up as [].

mandatory: a forgotten field that would vanish silently

A rule whose commands were forgotten didn't fail anywhere. The role's template skipped it, visudo accepted the empty file, and the file had no rules at all. Nobody would notice until the service account couldn't restart PostgreSQL.

mandatory fails the task when a value is undefined, with a message of your choice:

'commands': rule.commands | mandatory('rule ' ~ rule.name ~ ' has no commands')

That gave "rule stop has no commands". It only checks that the value is defined: [] | mandatory('empty') returned [] without complaint. Where an empty list matters too, add an assert task on its length.

default and its second argument

default(value) replaces an undefined value. It leaves '' and null alone. default(value, true) also replaces anything falsy, any value that counts as false: '', null, [], 0 and false itself.

Expression Result
'' \| default('%postgres') ''
'' \| default('%postgres', true) '%postgres'
null \| default('%postgres') null
null \| default('%postgres', true) '%postgres'
false \| default(true, true) true
0 \| default(5, true) 5

reload's blank group needs the second argument: users: [rule.group | default('%postgres', true)]. Never use it on a boolean or a number. nopasswd: false | default(true, true) turns an explicit "no" into "yes".

ternary and its third value

ternary(a, b) returns a when its input is true and b otherwise. A third value, ternary(a, b, c), is used when the input is null:

Expression Result
[true, false, null] \| map('ternary', ['NOPASSWD'], [], ['PASSWD']) [['NOPASSWD'], [], ['PASSWD']]
[true, false, null] \| map('ternary', ['NOPASSWD'], []) [['NOPASSWD'], [], []]

Without the third value, "not decided" is treated as false. With it, the rule can say so: PASSWD is sudo's explicit "ask for a password", which is also its default. nopasswd | default(none) | ternary(['NOPASSWD'], [], ['PASSWD']) gives a missing nopasswd the same treatment as a null one.

The result

Built with all four, the role's template wrote, and visudo accepted:

%postgres ALL=(root) NOPASSWD: /usr/bin/systemctl restart postgresql.service
%postgres ALL= /usr/bin/systemctl status postgresql.service
%postgres ALL= PASSWD: /usr/bin/journalctl -u postgresql.service
%postgres ALL= PASSWD: /usr/bin/systemctl reload postgresql.service

Which one

The example repository

The series' companion repository, abdelhousni/ansible-data-shaping-series, runs all of the above on the local machine, changing nothing outside its out/ directory. render.yml builds the rules from vars/rules.yml, renders them with the sudo role's template, validated by visudo, and records each failure and result in out/render.txt. Its CI runs it on every push and compares the output with the expected one.

Sources

Created 2026-10-02T22:31:33+02:00 · Edit