LearnSalt Configuration ManagementJinja and Advanced States

Jinja Conditionals and Loops

Use if/else and for loops in Jinja to create dynamic states that adapt to each minion.

One file, two machines, two different jobs

There is a state file on this master called /srv/salt/packages.sls, and the top file hands it to every machine. It installs one package, jq, and it installs it on everything it touches. Two machines, one instruction, identical work.

That is fine right up until the fleet stops being identical. minion1 is meant to be a web server. minion2 is meant to be a database server. They need different software, and nobody wants two state files that quietly drift apart the first time somebody edits one of them.

By the end of this lesson that single file will install nginx on one machine and sqlite3 on the other, and it will not mention either machine by name anywhere inside it.

The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. Every screen here was captured by booting this exact lab and doing exactly what you are about to do. Your own timestamps, durations and version numbers will be your own. The lab user is saltops, the master is named saltmaster, and the two minions are minion1 and minion2. Your progress in the lab is tracked automatically, so type commands naturally.

Look at both trees at once

Salt keeps two directories on this master and they hold different kinds of thing. /srv/salt is the state tree: instructions, the work to be done. /srv/pillar is the pillar tree: data assigned to particular machines.

ls -l takes more than one directory in a single command. It prints a heading for each one, and it sorts those headings alphabetically no matter which order you type them in.

Before you run it, commit to a number: how many files are waiting for you across the two trees?

ls -l /srv/salt /srv/pillar

prompt: saltops@saltmaster:~$ answer: ls -l /srv/salt /srv/pillar ||| ls -l /srv/pillar /srv/salt output: /srv/pillar: total 12 -rw-r--r-- 1 saltops saltops 45 Jul 31 19:14 packages_db.sls -rw-r--r-- 1 saltops saltops 45 Jul 31 19:14 packages_web.sls -rw-r--r-- 1 saltops saltops 69 Jul 31 19:14 top.sls

/srv/salt: total 12 drwxr-xr-x 2 saltops saltops 4096 Jul 31 19:09 files -rw-r--r-- 1 saltops saltops 351 Jul 31 19:14 packages.sls -rw-r--r-- 1 saltops saltops 28 Jul 31 19:14 top.sls hint: One ls, two paths, long format: ls -l /srv/salt /srv/pillar

Two headings, six entries, and you typed /srv/salt first but /srv/pillar printed first. That is ls sorting the paths for you.

Read the pillar tree first. packages_web.sls and packages_db.sls are the per-machine data, 45 bytes each. top.sls is the sheet that assigns them, and it hands the web file to minion1 and the db file to minion2.

Now the state tree. top.sls is 28 bytes, which is about as small as a top file gets, and it assigns packages to '*', every machine. packages.sls is 351 bytes and that number is the joke: almost all of it is comments left for you.

Read the owner column on every line. Both trees say saltops saltops, which is your own account. That is what lets you write these files with a plain redirect and no sudo in front of it.

What is actually in the state file

This is the whole of /srv/salt/packages.sls as the setup left it. Three hundred and fifty one bytes, and one real instruction at the bottom:

# This state should install packages from pillar data.
# TODO: Add a Jinja for loop to iterate over pillar['packages']
# TODO: Add Jinja if/else to handle role-based logic
# HINT: pillar['role'] is 'webserver' on minion1, 'database' on minion2
# HINT: pillar['packages'] is a list of package names

install_base_tools:
  pkg.installed:
    - name: jq

One state, called install_base_tools, and it names one package. Every machine that receives this file does exactly the same thing, because there is nothing in the file that could make it behave otherwise.

The pillar tree already holds the difference between the two machines. The state tree has no way to reach it yet. What is missing is a language that can ask a question and repeat itself, and that language is Jinja.

What Jinja is doing to your file

Jinja is a template language, and Salt runs every .sls file through it before reading the file as YAML. That is two passes over the same text, and keeping the two apart in your head explains almost every Jinja error you will ever meet.

Pass one is Jinja. It looks for two kinds of tag:

When pass one finishes, every tag is gone. What is left is ordinary YAML.

Pass two is YAML. Salt reads the plain text that pass one produced and turns it into the states it is going to run.

The order is the whole idea. Jinja never sees your states, because states do not exist yet when it runs. YAML never sees your logic, because the logic is already gone by the time it looks.

Put every {% ... %} tag on a line of its own, starting at column 0 with no leading spaces. Jinja does not know it is looking at YAML. It is substituting text, line by line, and an indented tag leaves its indentation behind in the YAML that pass two has to read. The parse error you get back then points at the YAML, which is not where you made the mistake.

Where Jinja came from

Armin Ronacher wrote Jinja in 2008 for Python web applications, where the job was filling values into HTML pages before sending them to a browser. It had years of use behind it before Salt existed.

Salt did not invent a template language of its own. It reached for one that already worked, which is why the syntax inside your .sls files looks nothing like the rest of Salt and a great deal like a web framework.

That borrowing is why two words show up inside your state files for free. grains is what a machine worked out about itself: its operating system, its memory, its network cards. pillar is what you assigned to it from the master. Both are available to Jinja while the file is being rendered, and the file is rendered once for each machine, with that machine's own values. One source file, one result per machine.

Commit: which half reads the file first?

Before you write any of this, take a position on the order. It decides how you read every error message for the rest of your Salt career.

Write a state that asks a question

The smallest useful piece of Jinja logic is {% if %} / {% else %} / {% endif %}. Everything between if and else survives pass one when the condition is true. Everything between else and endif survives when it is false. The other half is deleted, tags and all.

A heredoc is the shell's way of typing several lines into a file in one go: everything between the << 'EOF' marker and the closing EOF becomes the content of the file. /srv/salt belongs to your account, so there is no sudo here.

cat > /srv/salt/family.sls << 'EOF'
{% if grains['os'] == 'Debian' %}
/tmp/family.txt:
  file.managed:
    - contents: This machine is in the Debian family
{% else %}
/tmp/family.txt:
  file.managed:
    - contents: This machine is not in the Debian family
{% endif %}
EOF

Read what pass one is going to hand to pass two. Whichever branch wins, the leftover YAML is four lines: a state ID, a function, and one argument. The other branch is gone as if you never wrote it.

Three details in that file are worth naming now:

Both machines in this lab are Debian. So before you apply it, commit to two things: which branch does each machine take, and will the two halves of the screen look the same or different?

Apply it to both machines

state.apply followed by a name runs one named state file and nothing else. family is the file name without the .sls, exactly like the entries in a top file. Aim it at '*' so both machines run it at once, and keep the quotes so your shell leaves the star alone.

sudo salt '*' state.apply family

prompt: saltops@saltmaster:~$ answer: sudo salt '*' state.apply family ||| sudo salt "*" state.apply family ||| sudo salt '*' state.sls family ||| sudo salt "*" state.sls family output: minion1: ---------- ID: /tmp/family.txt Function: file.managed Result: True Comment: File /tmp/family.txt updated Started: 19:15:32.646522 Duration: 4.122 ms Changes: ---------- diff: New file

Summary for minion1 ------------ Succeeded: 1 (changed=1) Failed: 0 ------------ Total states run: 1 Total run time: 4.122 ms minion2: ---------- ID: /tmp/family.txt Function: file.managed Result: True Comment: File /tmp/family.txt updated Started: 19:15:32.644842 Duration: 4.208 ms Changes: ---------- diff: New file

Summary for minion2 hint: Apply one named state file to every machine: sudo salt '*' state.apply family

Two blocks, one per machine, and they match. Both took the if branch, because both machines answer Debian for that grain, so the else half was deleted on both of them before anything ran.

Learn this block shape now, because every state run you ever read is made of it:

Then the summary. Succeeded: 1 (changed=1) reads as one state ran, it worked, and it did something. A run where the file already had the right content would say changed=0, and that is the normal, boring, correct state of a fleet that is already configured.

The box stops just after Summary for minion2, so minion2's own numbers scroll past below it. You have already read a full summary directly above, in minion1's block, and the two are the same shape.

A state file that contains {% if %} is the first of the five things your lab grades.

Both machines took the same branch, so the else half did nothing today. That is not wasted work. It is the line that keeps working on the day somebody adds a machine that is not Debian, and it is the reason a single state file can survive a mixed fleet. {% elif %} sits between if and else when you need a third case, and you can chain as many as you like.

Commit: what has to be different on every trip round a loop?

A conditional picks one of two things. A loop produces many things, and that raises a question a conditional never has to answer.

Give each machine its own list

The pillar files already exist and top.sls already assigns them, so the wiring is done. What you are going to change is the contents, and there is a reason to keep them short.

A loop over two names prints two state blocks per machine, so the screen you are about to read doubles in length before it teaches you anything extra. Start with exactly one name each and the difference between the two machines is impossible to miss.

Write the web server's data:

cat > /srv/pillar/packages_web.sls << 'EOF'
role: webserver
packages:
  - nginx
EOF

Then the database server's:

cat > /srv/pillar/packages_db.sls << 'EOF'
role: database
packages:
  - sqlite3
EOF

Both files use the same two key names, role and packages, and hold different values under them. That sameness is what makes one state file work everywhere. The file asks for packages and gets whatever that machine was given.

packages: is a YAML list even when it has one item in it. The item goes on its own line, indented two spaces under the key, and starts with a dash followed by a space. - nginx is a list item. -nginx is not an error, which is worse: YAML reads it as ordinary text, so packages quietly becomes a string instead of a list. A loop over a string walks it one character at a time, and you get a state per letter.

Replace the skeleton with a loop

Now the file the whole lesson is aimed at. {% for %} takes a variable name, the word in, and something to walk through. Everything up to {% endfor %} is repeated once per item, with the variable holding that item.

cat > /srv/salt/packages.sls << 'EOF'
{% for p in pillar.get('packages', []) %}
install_{{ p }}:
  pkg.installed:
    - name: {{ p }}
{% endfor %}
EOF

Five lines, and every one of them is doing something:

A heredoc prints nothing when it works, so read the file back and check your own typing before you point it at two machines.

cat /srv/salt/packages.sls

prompt: saltops@saltmaster:~$ answer: cat /srv/salt/packages.sls output: {% for p in pillar.get('packages', []) %} install_{{ p }}: pkg.installed:

Five lines and not a comment in sight. The 351 bytes of TODO notes are gone, and so is the hardcoded jq.

Look at where the two tags sit. {% for %} and {% endfor %} are both flush against the left edge at column 0, and the three YAML lines between them keep their own indentation. That is the column-0 rule in practice: the tags are scaffolding for pass one, and they must not leave any of themselves in the YAML that pass two reads.

Look at what is not in the file. No machine name. No minion1, no minion2, no if asking which is which. The only thing that decides what this file does is the pillar of the machine it is rendered for.

A state file containing {% for %} is the second of the five things your lab grades, and that loop walking a pillar value is the third.

Challenge: the step everybody skips

Scaffolding off for this one. No command is shown.

You edited two pillar files a moment ago. Both minions fetched their pillar when they connected and have been holding that copy ever since, and nothing about writing a file on the master reaches out and tells them otherwise.

Make both machines go and collect their pillar again, in one command, before you run anything that depends on it. The module is the one Salt uses for housekeeping on itself, and the function says exactly what it does.

prompt: saltops@saltmaster:~$ answer: sudo salt '*' saltutil.refresh_pillar ||| sudo salt "*" saltutil.refresh_pillar output: minion2: True minion1: True hint: The housekeeping module is saltutil, and the function refreshes pillar on the machine it runs on.

Two machines, two True values. Both took the instruction and went back to the master for their data.

True here means "I did that" and nothing more. It does not tell you what arrived, whether it was what you meant, or even that anything arrived. A machine assigned no pillar at all answers with the same True. Refresh proves the instruction landed. Reading the data is still a separate job.

One detail to file away: minion2 printed first. Salt prints each machine's block as that answer arrives, so the order on screen is arrival order and nothing else. Never read meaning into which name is at the top.

Make this a reflex. Edit pillar, refresh, then use it. The alternative is debugging a file that was already correct.

Commit: how many times does that file get rendered?

One file. One command. Two machines whose pillar disagrees about what should be installed. Take a position on what happens to the file itself.

Practice Jinja Conditionals and Loops in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.

More lessons in Jinja and Advanced States