Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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.

Start your All-Access Pass
Buy just this tutorial for $10.00

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:

---
- 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.

Leave a comment!

6
Login or Register to join the conversation
Default user avatar
Default user avatar Steven Weber | posted 5 years ago

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.

1 Reply

Hey Steven,

Thanks for noticing us! I added a note in https://github.com/knpunive...

Cheers!

1 Reply
Default user avatar
Default user avatar sokphea chea | posted 5 years ago | edited

I got a warning :D


TASK [Create DB if not exists] ********************************************************************************************************
 [WARNING]: when statements should not include jinja2 templating delimiters such as {{ }} or {% %}. Found: not {{
db_create_result.stdout|search('already exists. Skipped') }}

ok: [192.168.33.10]

TASK [Execute migrations] *************************************************************************************************************
 [WARNING]: when statements should not include jinja2 templating delimiters such as {{ }} or {% %}. Found: not {{
db_migrations_result.stdout|search('No migrations to execute') }}
Reply

Hey sokphea chea!

Ah, awesome! So, in Ansible 2.3, they deprecated using the jinja syntax in when and changed_when. But actually, that's ok! I didn't realize it, but those {{ are totally unnecessary. So, remove them :). Everything will still work the same and the warnings will go away. We're going to update our code to show that!

Thanks for the ping on this!

Cheers!

Reply
Default user avatar
Default user avatar sokphea chea | weaverryan | posted 5 years ago | edited

That warning gone, thank you weaverryan
go go knp university!

Reply

We are glad to help you :)
FYI: we have updated the scripts, so you can follow them without problems

Have a nice day!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built using an older version of Symfony, but the core concepts of Ansible are still valid. New versions of Ansible may contain some features that we don't use here.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.5.9",
        "symfony/symfony": "3.1.*", // v3.1.4
        "doctrine/orm": "^2.5", // v2.7.2
        "doctrine/doctrine-bundle": "^1.6", // 1.6.4
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.3.11
        "symfony/monolog-bundle": "^2.8", // 2.11.1
        "symfony/polyfill-apcu": "^1.0", // v1.2.0
        "sensio/distribution-bundle": "^5.0", // v5.0.12
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.16
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.2
        "doctrine/doctrine-migrations-bundle": "^1.2", // v1.2.0
        "snc/redis-bundle": "^2.0", // 2.0.0
        "predis/predis": "^1.1", // v1.1.1
        "composer/package-versions-deprecated": "^1.11" // 1.11.99
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.0.8
        "symfony/phpunit-bridge": "^3.0", // v3.1.4
        "doctrine/data-fixtures": "^1.1", // 1.3.3
        "hautelook/alice-bundle": "^1.3" // v1.4.1
    }
}
userVoice