Idempotency, changed_when & Facts
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeI just ran the playbook with the deploy
tag:
ansible-playbook ansible/playbook.yml -i ansible/hosts.ini -t deploy
Notice that several tasks say "Changed"... but that's a lie! The first two are related to Composer - we'll talk about those later. Right now, I want to focus on the last 4: fixing the directory permissions and the 3 bin/console
commands:
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... lines 12 - 156 | |
- name: Fix var directory permissions | |
file: | |
path: "{{ symfony_var_dir }}" | |
state: directory | |
mode: 0777 | |
recurse: yes | |
tags: | |
- permissions | |
- deploy | |
# Symfony console commands | |
- name: Create DB if not exists | |
command: '{{ symfony_console_path }} doctrine:database:create --if-not-exists' | |
tags: | |
- deploy | |
- name: Execute migrations | |
command: '{{ symfony_console_path }} doctrine:migrations:migrate --no-interaction' | |
tags: | |
- deploy | |
- name: Load data fixtures | |
command: '{{ symfony_console_path }} hautelook_alice:doctrine:fixtures:load --no-interaction' | |
tags: | |
- deploy | |
// ... lines 182 - 195 |
Changed and Idempotency
But first... why do we care? I mean, sure, it says "Changed" when nothing really changed... but who cares? First, let me give you a fuzzy, philosophical reason. Tasks are meant to be idempotent... which is a hipster tech word to mean that it should be safe to run a task over and over again without any side effects.
And in reality, our tasks are idempotent. If we run this "Fix var directory permissions" task over and over and over again... that's fine! Nothing weird will happen. It's simply that the tasks are reporting that something is changing each time... when really... it's not!
I know, I know... this seems like such a silly detail. But soon, we're going to start making decision in our playbook based on whether or not a task reports as "changed".
Actually, this is already happening:
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... lines 12 - 182 | |
handlers: | |
- name: Restart Nginx | |
become: true | |
service: | |
name: nginx | |
state: restarted | |
// ... lines 189 - 195 |
The "Restart Nginx" handler is only called when this task changes:
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... lines 12 - 56 | |
- name: Enable Symfony config template from Nginx available sites | |
become: true | |
file: | |
src: "/etc/nginx/sites-available/{{ server_name }}.conf" | |
dest: "/etc/nginx/sites-enabled/{{ server_name }}.conf" | |
state: link | |
notify: Restart Nginx | |
// ... lines 64 - 195 |
So, as a best practice - as much as we can - we want our tasks to correctly report whether or not they changed.
Using changed_when: false
How do we fix this? Well, the first task - fixing var directory permissions - is a little surprising. This is a core module... so, shouldn't it be correctly reporting whether or not the permissions actually changed? Well yes... but when you set recurse
to yes
, it always says changed:
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... lines 12 - 156 | |
- name: Fix var directory permissions | |
file: | |
// ... lines 159 - 161 | |
recurse: yes | |
// ... lines 163 - 195 |
The easiest way to fix this is to add changed_when
set to false
:
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... lines 12 - 156 | |
- name: Fix var directory permissions | |
// ... lines 158 - 162 | |
changed_when: false | |
// ... lines 164 - 196 |
That's not perfect, but it's fine here:
ansible-playbook ansible/playbook.yml -i ansible/hosts.ini -t deploy
Dynamic changed_when
But what about the other tasks... like creating the database?
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... lines 12 - 167 | |
# Symfony console commands | |
- name: Create DB if not exists | |
command: '{{ symfony_console_path }} doctrine:database:create --if-not-exists' | |
tags: | |
- deploy | |
// ... lines 173 - 196 |
Technically, the first time we run this, it will create the database. But each time after, it does nothing! If we want this task to be smart, we need to detect whether it did or did not create the database.
And there's a really cool way to do that. First, add a register
key under the task set to db_create_result
:
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... lines 12 - 167 | |
# Symfony console commands | |
- name: Create DB if not exists | |
// ... line 170 | |
register: db_create_result | |
// ... lines 172 - 202 |
This will create a new variable containing info about the task, including its output. This is called a fact, because we're collecting facts about the system.
To see what it looks like, below this, temporarily add a debug
task:
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... lines 12 - 167 | |
# Symfony console commands | |
- name: Create DB if not exists | |
// ... line 170 | |
register: db_create_result | |
// ... lines 172 - 174 | |
- debug: | |
// ... lines 176 - 202 |
This is a shorthand way of using the debug
module. Add var: db_create_result
to print that. Oh, and below, give it the deploy
tag:
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... lines 12 - 174 | |
- debug: | |
var: db_create_result | |
tags: | |
- deploy | |
// ... lines 179 - 202 |
Ok, try it!
ansible-playbook ansible/playbook.yml -i ansible/hosts.ini -t deploy
Whoa, awesome! Check this out. That variable shows when the task started, when it ended and most importantly, its output! It says:
Database
symfony
for connection named default already exists. Skipped.
Ah, ha! Copy the "already exists. Skipped" part. We can use this in our playbook to know whether or not the task did anything.
How? Instead of saying changed_when: false
, use an expression: not db_create_result.stdout
- stdout
is the key in the variable - not db_create_result.stdout|search('already exists.
Skipped'):
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... lines 12 - 167 | |
# Symfony console commands | |
- name: Create DB if not exists | |
// ... line 170 | |
register: db_create_result | |
changed_when: "not db_create_result.stdout|search('already exists. Skipped')" | |
// ... lines 173 - 201 |
If you use Twig, this will look familiar: we're reading a variable and piping it through some search
filter, which comes from Jinja.
Tip
Using tests as filters is deprecated since Ansible v2.5 and will be removed
in v2.9, use is %test_name%
or is not %test_name%
instead where %test_name%
might be any test like success
, failed
, search
, etc, for example:
# ansible/playbook.yml
---
- hosts: vb
# ...
tasks:
# ...
# Symfony console commands
- name: Create DB if not exists
command: '{{ symfony_console_path }} doctrine:database:create --if-not-exists'
register: db_create_result
changed_when: db_create_result.stdout is not search('already exists. Skipped')
For the migration task, we can do the same. Register the variable first: db_migrations_result
. Copy the changed_when
and paste that below:
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... line 12 | |
// ... lines 14 - 175 | |
- name: Execute migrations | |
// ... line 177 | |
register: db_migrations_result | |
changed_when: "not db_migrations_result.stdout|search('No migrations to execute')" | |
// ... lines 180 - 201 |
So what language happens when there are no migrations to execute? Go to your virtual machine and run the migrations to find out:
./bin/console doctrine:migrations:migrate --no-interaction
Yes! It says:
No migrations to execute
That's the key! Copy that language. Now, the same as before: paste that into the expression and update the variable name to db_migration_results
:
- hosts: vb | |
// ... lines 3 - 10 | |
tasks: | |
// ... line 12 | |
// ... lines 14 - 175 | |
- name: Execute migrations | |
// ... line 177 | |
register: db_migrations_result | |
changed_when: "not db_migrations_result.stdout|search('No migrations to execute')" | |
// ... lines 180 - 201 |
Awesome! Finally, the last task loads the fixtures. This is tricky because... technically, this task fully empties the database and re-adds the fixture each time. Because of that, you could say this is always changing something on the server.
So, you can let this say "changed" or set changed_when:
false if you want all your tasks to show up as not changed. Unless we start relying on the changed state of this task to trigger other actions, it doesn't really matter.
Moment of truth: let's head to our terminal and try the playbook:
ansible-playbook ansible/playbook.yml -i ansible/hosts.ini -t deploy
Yes! The last 4 tasks are all green: not changed. Now, let's do something totally crazy - like run doctrine:database:drop --force
on the virtual machine:
./bin/console doctrine:database:drop --force
Try the playbook now: we should see some changes. Yes! Both the database create task and migrations show up as changed.
Ok, let's do more with facts by introducing environment variables.
Hi, I just got a warning in my playbook after updating Ansible to 2.5. Just wanted to let you know. :-)
[DEPRECATION WARNING]: Using tests as filters is deprecated. Instead of using `result|search` instead use `result is search`. This feature will be removed in version 2.9.