Learn › Salt Configuration Management › Jinja and Advanced States
Create Jinja2 templates for configuration files and deploy them with file.managed.
A config file is never quite the same on two machines. The hostname is different. The operating system may be different. The port comes from whoever owns the service, not from whoever wrote the file.
So you have two bad options and one good one. You can keep one config file per server and hand-edit fifty of them. You can keep one config file for everybody and let it be wrong almost everywhere. Or you can keep one file with holes in it, and let Salt fill the holes in differently for each machine that receives it.
That third file is called a template. By the end of this lesson you will have written one, watched Salt fill it in, and read the exact error it prints when a value is missing.
The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. Every output printed below was captured from this lab's own master with one real minion attached, which is exactly the machine your lab boots. One box prints nothing at all when it works, and that box is blank on purpose. The lab user is saltops, the master is named saltmaster, and there is exactly one minion, named minion1.
Everything in this lesson lives in one of two directories, so look at both before you touch either.
/srv/salt is the file roots: the state files that describe work, plus any file a minion is allowed to fetch. This is what the salt:// address points at./srv/pillar is the pillar roots: data the master keeps on behalf of minions, handed out per machine.ls -l takes more than one path at a time and prints a labelled section for each. Before you run it, commit to one thing: which of the two trees do you expect to hold more files?
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 8 -rw-r--r-- 1 saltops saltops 155 Jul 31 18:48 appconfig.sls -rw-r--r-- 1 saltops saltops 29 Jul 31 18:48 top.sls
/srv/salt: total 12 drwxr-xr-x 2 saltops saltops 4096 Jul 31 18:45 files -rw-r--r-- 1 saltops saltops 33 Jul 31 18:48 top.sls drwxr-xr-x 3 saltops saltops 4096 Jul 31 18:48 webapp hint: A long listing accepts two paths at once: ls -l /srv/salt /srv/pillar
Two labelled sections, and ls sorted the arguments for you, so /srv/pillar printed first.
Read the owner column on every line. Each file and directory says saltops saltops, which is you. That matters more than it looks: you can write into both of these trees with an ordinary editor or an ordinary redirect. No sudo is needed for anything you create in here today.
/srv/pillar holds two small files. appconfig.sls is 155 bytes of data and you will read every one of them later in this lesson. /srv/salt holds a top.sls and a webapp directory of work that already exists. Nothing in either tree is a template yet, and that is the gap you are about to fill.
Jinja is a templating language: a way of writing a file with blanks in it, plus the rules for filling those blanks in. Salt ships with it built in and uses it as the default renderer.
The old-fashioned name for this is a mail merge. You write one letter that says Dear NAME, and the system prints a hundred letters with a hundred different names. A Salt template is the same trick pointed at config files instead of letters.
There are three pieces of Jinja syntax and you only need the first one today:
{{ something }} is output. Replace this with a value.{% something %} is logic, such as if and for. That is the next lesson.{# something #} is a comment. It disappears from the finished file.The values that fill those blanks come from three places in Salt. Grains are facts a minion works out about itself, such as its operating system. Pillar is data the master keeps for that minion. Context is a short list of values written into the state file itself, useful when a value belongs to one state and nowhere else.
Armin Ronacher wrote Jinja in 2008 as the template engine for Python web work, and it went on to become the default in Flask. It was designed for turning data into HTML pages.
Salt adopted it years later for a different job with the same shape. A config file and a web page are both text with holes in them, and both need the holes filled from data the program already holds. Rather than invent a template language, Salt reached for the one Python people already knew.
This is why Jinja syntax turns up in places that surprise people, including .sls files that never asked for it. Where in the sequence Salt runs the renderer is the question that decides all of those cases, and it is not a file setting or anything you switch on. A later step in this lesson makes you take a position on it, and then makes Salt prove the answer on your own screen.
Templates are conventionally named with a .j2 ending, short for Jinja2. Put yours straight into the file roots so salt:// can reach it.
A heredoc writes a multi-line file in one go. cat > path << 'EOF' starts it, and a line reading EOF on its own ends it. Quote that opening 'EOF' exactly as shown, because the quotes stop your shell from touching anything inside the block.
cat > /srv/salt/app.conf.j2 << 'EOF'
# rendered by Salt on {{ grains['id'] }}
os={{ grains['os'] }}
kernel={{ grains['kernel'] }}
port={{ pillar.get('app_port', 8080) }}
EOF
Four lines, four blanks, two data sources. Three of the blanks ask grains for a fact the minion knows about itself. The fourth asks pillar for a key called app_port, and hands 8080 as a fallback in case that key is not there.
Now read the file back, because reading a heredoc back is the cheapest way to catch a typo before Salt does.
cat /srv/salt/app.conf.j2
prompt: saltops@saltmaster:~$ answer: cat /srv/salt/app.conf.j2 output:
os={{ grains['os'] }} kernel={{ grains['kernel'] }} port={{ pillar.get('app_port', 8080) }} hint: Print the file you just wrote: cat /srv/salt/app.conf.j2
Every brace is still there. That is the correct result and it is worth pausing on. A template is a source file, not a finished file, and Salt never edits it in place.
Read the two shapes side by side:
{{ grains['os'] }} looks a value up by name and fails loudly if the name is wrong. You will see exactly how loudly before this lesson ends.{{ pillar.get('app_port', 8080) }} looks a value up and takes a second argument as a fallback, so a missing key produces 8080 instead of a failure.The first line uses grains['id'], which is the minion ID. Every file that comes off this template will name the machine it was written for, in its own first line. Creating this .j2 file is the first of the five things your lab grades, and the grains references in it are the fifth.
You named the file app.conf.j2. Take a position on what that ending buys you before you write the state that uses it.
>>> Nothing at all. Salt does not look at file extensions when it decides whether to render something. The .j2 is a convention, and a good one: anyone reading the directory can see at a glance that this file is not safe to deploy as it stands. If you picked automatic rendering, that guess is the reason people spend an afternoon on a config file full of literal braces. The switch is a line you write in the state file, and you are about to write it. If you picked the private answer, nothing about a name restricts who may fetch a file. What is inside /srv/salt is servable, full stop, which is why secrets belong in pillar instead.
A state file describes work you want done on a minion. file.managed is the function that puts a file somewhere and keeps it that way.
cat > /srv/salt/jinjaconf.sls << 'EOF'
/etc/app-tmpl.conf:
file.managed:
- source: salt://app.conf.j2
- template: jinja
EOF
Four lines, and the first one is doing double duty. Read it back before you apply anything.
cat /srv/salt/jinjaconf.sls
prompt: saltops@saltmaster:~$ answer: cat /srv/salt/jinjaconf.sls output: /etc/app-tmpl.conf: file.managed:
hint: Print the state file you just wrote: cat /srv/salt/jinjaconf.sls
Line by line, because all four are load bearing:
/etc/app-tmpl.conf: is the state ID, and Salt reuses it as the destination path when you do not give a separate - name:. Two jobs, one line. Any state ID must be unique across everything you apply.file.managed: is the work: put a file at that path and keep it correct.- source: salt://app.conf.j2 is where the content comes from. salt:// is the master's file server, and it is rooted at /srv/salt, so salt://app.conf.j2 means the file you wrote a minute ago.- template: jinja says run that sourced file through Jinja before writing it. Leave this line out and file.managed copies the source bytes exactly as they are, braces and all.That fourth line is the second of the five things your lab grades, and it is the only line in this file that has anything to do with templating.
state.apply followed by a name applies one state file. The name is the file's path under /srv/salt with the .sls dropped, so jinjaconf means /srv/salt/jinjaconf.sls.
Before you run it, commit to a number: how many states are in that file, and how many results should come back?
sudo salt 'minion1' state.apply jinjaconf
prompt: saltops@saltmaster:~$ answer: sudo salt 'minion1' state.apply jinjaconf ||| sudo salt "minion1" state.apply jinjaconf ||| sudo salt minion1 state.apply jinjaconf ||| sudo salt '*' state.apply jinjaconf output: minion1: ---------- ID: /etc/app-tmpl.conf Function: file.managed Result: True Comment: File /etc/app-tmpl.conf updated Started: 18:48:35.525987 Duration: 20.634 ms Changes: ---------- diff: New file mode: 0644
Summary for minion1 ------------ Succeeded: 1 (changed=1) Failed: 0 ------------ Total states run: 1 Total run time: 20.634 ms hint: Target the minion in quotes, then the state module and the file name without .sls: sudo salt 'minion1' state.apply jinjaconf
One state, one result block, and this shape is the one you will read for the rest of your Salt life.
ID: is the state ID from your file, which here is also the destination path.Function: names what ran. file.managed, as written.Result: True means it did what it said. False would mean it failed, and None would mean it was a test run that changed nothing.Comment: is the sentence a human reads. File /etc/app-tmpl.conf updated is Salt telling you it wrote the file.Changes: is what actually moved. diff: New file means there was nothing there before, so there is no line by line difference to show. mode: 0644 is the permission it set, because you did not ask for a particular one.Then the summary. Succeeded: 1 (changed=1) counts two different things: one state succeeded, and one of those successes changed something on disk. Hold on to that (changed=1). It is tracking something the Result: line does not, and you will find out what shortly.
The state says it wrote a file. That is a claim about the master's view of the world, not proof of what is on the other machine.
So go and look. cmd.run runs a shell command on the minion and hands the output back, which means you can read a remote file without logging in anywhere.
Quote the whole remote command so it travels as one argument. Before you run it, picture the four lines you wrote and decide what each one becomes.
sudo salt 'minion1' cmd.run 'cat /etc/app-tmpl.conf'
prompt: saltops@saltmaster:~$ answer: sudo salt 'minion1' cmd.run 'cat /etc/app-tmpl.conf' ||| sudo salt "minion1" cmd.run "cat /etc/app-tmpl.conf" ||| sudo salt minion1 cmd.run 'cat /etc/app-tmpl.conf' ||| sudo salt '*' cmd.run 'cat /etc/app-tmpl.conf' output: minion1: # rendered by Salt on minion1 os=Debian kernel=Linux port=8080 hint: Run a shell command on the minion and read the file: sudo salt 'minion1' cmd.run 'cat /etc/app-tmpl.conf'
There is not a single brace left. Put the two screens next to each other, because the delta is the whole lesson:
{{ grains['id'] }} became minion1, so the file names the machine it was written for.{{ grains['os'] }} became Debian. Send the same template to a Red Hat box and that word changes by itself.{{ grains['kernel'] }} became Linux. That is the kernel family, not the version number.{{ pillar.get('app_port', 8080) }} became 8080.The indentation is Salt's, not the file's. Everything a minion returns is printed four spaces under its ID, so the real file on disk has no leading spaces at all.
Applying the state and reading the deployed config back is the fourth of the five things your lab grades. And the port= line is the third, pillar data reaching the template. Look hard at that line before you move on, because the next step is about it.
Your template asked for pillar.get('app_port', 8080) and the file says port=8080. Two things could have produced that number, and this master's pillar is small enough to check.
Here is the whole of /srv/pillar/appconfig.sls, exactly as it ships on your box. This is the 155 byte file you listed at the start:
app:
name: webapp
port: 8080
workers: 4
log_level: info
admin_email: [email protected]
allowed_hosts:
- 127.0.0.1
- 10.100.1.0/24
Read it as a shape, not as a list. There is exactly one key at the top: app. Everything else is nested one level under it.
port: 8080 is right there in appconfig.sls.>>> The fallback. pillar.get('app_port', 8080) asks for a key named app_port at the top of pillar, and the only key at the top of this pillar is app. Nothing named app_port exists, so .get did what it promised and returned its second argument. If you picked the pillar value, look at the nesting again: the real value is called port and it lives one level down, so you would reach it as {{ pillar['app']['port'] }}. If you picked both, there is no merge and no preference. .get returns the key or the fallback, never a blend of the two. The uncomfortable part is that the two numbers happen to match here, so the output looks right either way. That is exactly how a template ships to a hundred machines carrying a default nobody chose.
A fallback is a promise not to fail, not a promise to be correct. When a templated value looks suspiciously like a round default, go and read the pillar rather than trusting the file. {{ pillar['app']['port'] }} fails loudly when the key is wrong, and on a value that matters, loud is what you want.
You applied this state a few minutes ago. Run the identical command again. Nothing about the command changes, and this time the file is already correct.
Before you press Enter, decide which line of the result block you expect to be different.
sudo salt 'minion1' state.apply jinjaconf
prompt: saltops@saltmaster:~$ answer: sudo salt 'minion1' state.apply jinjaconf ||| sudo salt "minion1" state.apply jinjaconf ||| sudo salt minion1 state.apply jinjaconf ||| sudo salt '*' state.apply jinjaconf output: minion1: ---------- ID: /etc/app-tmpl.conf Function: file.managed Result: True Comment: File /etc/app-tmpl.conf is in the correct state Started: 18:48:37.681682 Duration: 14.409 ms Changes:
Summary for minion1 ------------ Succeeded: 1 Failed: 0 ------------ Total states run: 1 Total run time: 14.409 ms hint: Exactly the command you ran before, unchanged: sudo salt 'minion1' state.apply jinjaconf
Result: True again, and almost everything else moved.
Comment: went from File /etc/app-tmpl.conf updated to File /etc/app-tmpl.conf is in the correct state. Changes: is now empty, with nothing under it at all. And the summary reads Succeeded: 1 with the (changed=1) gone.
This is idempotence, and it is the property that makes configuration management safe to run on a schedule. A state describes the end result you want, not a set of steps to perform. Salt rendered the template, compared the result with what is already on the minion, found them identical, and wrote nothing.
So read the Changes: block, not the Result: line, when you want to know whether a run did anything. True means correct. Empty changes mean it was already correct before you arrived.
Count what you built. A template with four blanks in it. A state that points at that template and switches rendering on. A deployed file on another machine that names that machine, its operating system and its kernel, with no braces left anywhere.
That pipeline is the working core of configuration management, and it does not get more complicated as the fleet grows. One template, one state, and every machine that receives it produces its own correct file.
What is left is knowing where the rendering happens in the sequence, and what it looks like when you ask for something that is not there.
file.managed does not have to fetch a source. It can carry the content inline with - contents: written straight into the state file. So imagine a state that does exactly that, holds {{ grains['os'] }} in its contents, and has no - template: jinja line anywhere.
>>> The real value. This trips up almost everybody, so be precise about the order: Salt renders the whole .sls file through Jinja first, and only then parses the result as YAML. Anything written inside a state file has already been through Jinja by the time any function runs. - template: jinja is for content that arrives from somewhere else, which means a - source: file, and that is why your jinjaconf.sls needs it. If you picked the literal braces, that is the right instinct applied one layer too high. It is what happens to a sourced file with no template line, not to text inside the state itself. If you picked the YAML error, that is a sharp observation a
Practice Jinja Templating in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.