Login to bookmark this video
Buy Access to Course
18.

Idempotency, changed_when & Facts

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

I 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:

195 lines | ansible/playbook.yml
---
- 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:

195 lines | ansible/playbook.yml
---
- 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:

195 lines | ansible/playbook.yml
---
- 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:

195 lines | ansible/playbook.yml
---
- 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:

196 lines | ansible/playbook.yml
---
- 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?

196 lines | ansible/playbook.yml
---
- 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:

202 lines | ansible/playbook.yml
---
- 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:

202 lines | ansible/playbook.yml
---
- 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:

202 lines | ansible/playbook.yml
---
- 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'):

201 lines | ansible/playbook.yml
---
- 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:

201 lines | ansible/playbook.yml
---
- 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:

201 lines | ansible/playbook.yml
---
- 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.