Sunday, 21 December 2025

Ansible 26. Ensuring Idempotency in Ansible.

 Idempotency means running the same Ansible playbook multiple times should NOT change anything after the first successful run — unless it is truly required.

In simpler terms, if the system is already in the desired state, Ansible should do nothing and report "ok" rather than "changed."

This is one of the core principles of Ansible.

🔁 Ensuring Idempotency in Ansible

The core principle of Idempotency is crucial for configuration management, especially in Ansible.

What is Idempotency?

  • Definition: Idempotency means that running a playbook multiple times yields the same end state.

  • Practical Effect: If a task has already achieved the desired configuration, it should not make any further changes on subsequent runs. This prevents unnecessary work, saves time, and minimizes risk.

Ansible's Approach to Idempotency

  • Built-in Idempotency: Most core Ansible modules are inherently idempotent.

    • Examples: Modules like file, apt, and template check the current state before making changes. For example, the file module won't try to create a directory if it already exists.

  • Many modules are already idempotent:

    ✔ Files, users, groups

    ✔ Packages (yum, apt, dnf)

    ✔ Services

    ✔ Templates

    ✔ Copy

    ✔ lineinfile

    ✔ cron

    So most of Ansible’s built-in modules automatically check:

    “Is the system already in the desired state?”

    If yes → No change.

  • Handling Non-Idempotent Tasks: Some tasks, especially those using generic modules like command or shell, are not idempotent by default. These modules will run every time, potentially causing issues.

  •  Some things are NOT idempotent by default

    Examples:

    • command module

    • shell module

    • Running SQL scripts

    • Copying files without checksum

    • Adding duplicate lines in a file

    • Restarting services blindly

    You must design idempotency manually.

    • To make these tasks idempotent, you must provide explicit controls using the following directives:


DirectiveFunctionExample Use Case
createsSkips the command if the specified file exists.Run a setup script once to create a marker file (.initialized).
removesSkips the command if the specified file does not exist.Only run a cleanup script if a temporary file is present.
changed_when: falseManually prevents a task from reporting that it changed the system, even if the command runs.

Now task is idempotent, because it will never show “changed”.

Use when running informational commands like df -h.
stat with whenUse the stat module to check for a file's existence and then use the when directive on a subsequent task to run it only if the file is absent.

Also Most modules have a state parameter. To be idempotent, define the specific state you want rather than an action you want to take.
Desired GoalCorrect State
Ensure a service is runningstate: started
Ensure a package is gonestate: absent
Ensure a directory existsstate: directory
Ensure a user is presentstate: present
Run an installation only if the resulting application binary is missing.

Take an example for below:

1. Skips the command if the specified file exists.

oelggvm01 is my managed node and lets try to create a directory using command module,

[oracle@oel01db ansible-project]$ cat ./playbooks/command_non_idempotent.yml
---
- name: Create directory using command module
  hosts: all
  tasks:
    - name: Create a directory
      command: mkdir /tmp/log
[oracle@oel01db ansible-project]$
[oracle@oel01db ansible-project]$ cat ./inventory/hosts
[db_servers]
oelggvm01
[oracle@oel01db ansible-project]$

[oracle@oel01db ansible-project]$ ansible-playbook -i ./inventory/hosts ./playbooks/command_non_idempotent.yml

PLAY [Create directory using command module] *********************************************************************************************************************************

TASK [Gathering Facts] *******************************************************************************************************************************************************
ok: [oelggvm01]

TASK [Create a directory] ****************************************************************************************************************************************************
[WARNING]: Consider using the file module with state=directory rather than running 'mkdir'.  If you need to use command because file is insufficient you can add 'warn:
false' to this command task or set 'command_warnings=False' in ansible.cfg to get rid of this message.
changed: [oelggvm01]

PLAY RECAP *******************************************************************************************************************************************************************
oelggvm01                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

[oracle@oel01db ansible-project]$

Lets re-run and see how it behaves.


[oracle@oel01db ansible-project]$ ansible-playbook -i ./inventory/hosts ./playbooks/command_non_idempotent.yml

PLAY [Create directory using command module] *********************************************************************************************************************************

TASK [Gathering Facts] *******************************************************************************************************************************************************
ok: [oelggvm01]

TASK [Create a directory] ****************************************************************************************************************************************************
[WARNING]: Consider using the file module with state=directory rather than running 'mkdir'.  If you need to use command because file is insufficient you can add 'warn:
false' to this command task or set 'command_warnings=False' in ansible.cfg to get rid of this message.
fatal: [oelggvm01]: FAILED! => {"changed": true, "cmd": ["mkdir", "/tmp/log"], "delta": "0:00:00.053740", "end": "2025-12-22 12:14:29.821650", "msg": "non-zero return code", "rc": 1, "start": "2025-12-22 12:14:29.767910", "stderr": "mkdir: cannot create directory ‘/tmp/log’: File exists", "stderr_lines": ["mkdir: cannot create directory ‘/tmp/log’: File exists"], "stdout": "", "stdout_lines": []}

PLAY RECAP *******************************************************************************************************************************************************************
oelggvm01                  : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

[oracle@oel01db ansible-project]$

This time it failed as command module is not idempotent.

Let's make the command module idempotent by using the creates argument

[oracle@oel01db ansible-project]$ cat ./playbooks/command_non_idempotent.yml
---
- name: Create directory using command module
  hosts: all
  tasks:
    - name: Create a directory
      command: mkdir /tmp/log
      args:
        creates: /tmp/log
[oracle@oel01db ansible-project]$ ansible-playbook -i ./inventory/hosts ./playbooks/command_non_idempotent.yml

PLAY [Create directory using command module] *********************************************************************************************************************************

TASK [Gathering Facts] *******************************************************************************************************************************************************
ok: [oelggvm01]

TASK [Create a directory] ****************************************************************************************************************************************************
ok: [oelggvm01]

PLAY RECAP *******************************************************************************************************************************************************************
oelggvm01                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

[oracle@oel01db ansible-project]$


This time task is reported as OK.
  

Create a playbook using the file module which is idempotent in nature.


[oracle@oel01db ansible-project]$ cat ./playbooks/file_idempotent.yml
---
- name: Ensure directory exists (idempotent)
  hosts: all
  tasks:
    - name: Ensure /tmp/mydir exists
      ansible.builtin.file:
        path: /tmp/mydir
        state: directory
        mode: '0755'
[oracle@oel01db ansible-project]$
[oracle@oel01db ansible-project]$ ansible-playbook -i ./inventory/hosts ./playbooks/file_idempotent.yml

PLAY [Ensure directory exists (idempotent)] **********************************************************************************************************************************

TASK [Gathering Facts] *******************************************************************************************************************************************************
ok: [oelggvm01]

TASK [Ensure /tmp/mydir exists] **********************************************************************************************************************************************
changed: [oelggvm01]

PLAY RECAP *******************************************************************************************************************************************************************
oelggvm01                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

[oracle@oel01db ansible-project]$

So in the first execution, its updated as changed=1 

Let's re-run

[oracle@oel01db ansible-project]$ ansible-playbook -i ./inventory/hosts ./playbooks/file_idempotent.yml

PLAY [Ensure directory exists (idempotent)] **********************************************************************************************************************************

TASK [Gathering Facts] *******************************************************************************************************************************************************
ok: [oelggvm01]

TASK [Ensure /tmp/mydir exists] **********************************************************************************************************************************************
ok: [oelggvm01]

PLAY RECAP *******************************************************************************************************************************************************************
oelggvm01                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

[oracle@oel01db ansible-project]$

So system is already in the desired state, Ansible did  nothing and report "ok" rather than "changed."


Now let's see a simple example for:

Use the stat module to check for a file's existence and then use the when directive on a subsequent task to run it only if the file is absent along with command module.


[oracle@oel01db ansible-project]$ cat ./playbooks/stats_idempotent.yml
---
- name: Use stat and when to control task execution
  hosts: all
  tasks:

    - name: Check if /tmp/mydir2 exists
      ansible.builtin.stat:
        path: /tmp/mydir2
      register: mydir_stat

    - name: Create directory only if absent
      ansible.builtin.command: mkdir /tmp/mydir2
      when: not mydir_stat.stat.exists
[oracle@oel01db ansible-project]$
[oracle@oel01db ansible-project]$ ansible-playbook -i ./inventory/hosts ./playbooks/stats_idempotent.yml

PLAY [Use stat and when to control task execution] *************************************************************************************************************************************************

TASK [Gathering Facts] *****************************************************************************************************************************************************************************
ok: [oelggvm01]

TASK [Check if /tmp/mydir2 exists] *****************************************************************************************************************************************************************
ok: [oelggvm01]

TASK [Create directory only if absent] *************************************************************************************************************************************************************
[WARNING]: Consider using the file module with state=directory rather than running 'mkdir'.  If you need to use command because file is insufficient you can add 'warn: false' to this command task
or set 'command_warnings=False' in ansible.cfg to get rid of this message.
changed: [oelggvm01]

PLAY RECAP *****************************************************************************************************************************************************************************************
oelggvm01                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

[oracle@oel01db ansible-project]$


How does it work ?

1️⃣ stat task

ansible.builtin.stat: path: /tmp/mydir2

This collects metadata and stores it in mydir_stat.

Important fields:

mydir_stat.stat.exists # true / false mydir_stat.stat.isdir # true if directory mydir_stat.stat.isfile # true if file

2️⃣ when condition

when: not mydir_stat.stat.exists

Meaning:

“Run this task only if /tmp/mydir2 does NOT exist


Execution behavior

Situationmkdir runs?Result
/tmp/mydir does not exist✅ YesDirectory created
/tmp/mydir exists❌ NoTask skipped
/tmp/mydir is a file❌ NoTask skipped (⚠️ wrong state not fixed)

⚠️ Important limitation (interview point)

This method:

  • Avoids failure

  • Avoids re-running command

  • ❌ Does NOT enforce correct state

If /tmp/mydir exists as a file, Ansible skips — but system is wrong.


No comments:

Post a Comment

Building a Safer PostgreSQL CI/CD Pipeline with GitHub Actions: Dev → PR Review → Test Promotion

In my previous post, we explored a simple push-to-main deployment strategy . While functional, that model is not considered an industry best...