Skip to main content

4 things to know about programming using Ansible

Ansible isn't a coding language, but it can help to have a programmer's point of view when you use it.
Image
Laptop with code, pen, notebook

Photo by Lukas from Pexels

Ansible is not a programming language.

Maybe this isn't new information for you, or perhaps you've just never thought about it. Ansible works really well, so what does it matter whether it's a programming language?

I started as a programmer (a long time ago, in a galaxy far, far away...), but my first contact was with Microsoft BASIC and then COBOL, CICS, and Perl. I've used other languages that aren't so old or odd, like Java, Python, JavaScript, and many more. I don't mean to say I was proficient in all of them, but I always tried to learn how to do things in the latest exciting language.

And recently, this happened with Ansible—even though Ansible is not a programming language.

Ansible is not a language

If Ansible isn't a programming language, then what is it?

Ansible is a tool written in Python, and it uses the declarative markup language YAML to describe the desired state of devices and configuration.

In association with the idea of a "desired state," Ansible also uses the concept of idempotency. You declare aspects you want to be configured on a target device, such as that a file or directory exists, a package is installed, a service is running, and so on. When you do this, you can run your Ansible playbook multiple times without "breaking" the target. Most Ansible modules know what needs to be done and report that a change has been made to the state of a resource only when a change is necessary.

[ Get started with IT automation with the Ansible Automation Platform beginner's guide. ]

What follows is my view on what you need to know when programming using Ansible.

1. Conditionals

The simplest way to define whether a task will be executed in Ansible is by using the when condition. You can find many examples in the Ansible documentation, but here's one that demonstrates some interesting aspects:

---
- name: When
  hosts: localhost
  gather_facts: False
  vars:
    my_number: "1138"
    my_string: "AA-589"
  tasks:
    - name: A - The Force is strong with this one
      ansible.builtin.debug:
        msg: "Use the Force, Luke"
      when:
        - (my_number | int) == 1138
        - my_string == 'AA-589'

    - name: B - Others
      ansible.builtin.debug:
        msg: "Do other things"
      when: (my_number | int) != 1138 or (my_string != 'AA-589')

In the first task, I use a list of conditions under when, which implies a logical and. Also, notice that I force the type of the var my_number to be an integer (in this case, I need to do that because I need the variable to be provided through other methods).

In the second task, the conditions are on one line because I used a logical or. In my example, this combination negates the condition on the first task.

When the playbook runs, the first task is executed, and the second is skipped.

But the first task is skipped, and the second is executed when you run the playbook by passing different values for at least one of the variables:

$ ansible-playbook 01_when.yml -e my_number=5150 -e my_string="Other rebel"

2. Execution flow

There are some peculiar aspects when you look at the execution order of some things in Ansible.

In a simple playbook, the sequence of tasks is executed in the order they appear. If you invoke roles at the play level, as in the example below, the roles execute before the first task:

---
- name: Simple play
  hosts: webservers
  roles:
    - common
    - webservers
  tasks:
    - name: Task 01
      (...)
...

Handlers are another special case. The example provided in the official Ansible documentation looks like this:

---
- name: Verify apache installation
  hosts: webservers
  vars:
    http_port: 80
    max_clients: 200
  remote_user: root
  tasks:
    - name: Ensure Apache httpd is the latest version
      ansible.builtin.yum:
        name: httpd
        state: latest

    - name: Write httpd config file
      ansible.builtin.template:
        src: /srv/httpd.j2
        dest: /etc/httpd.conf
      notify:
      - Restart apache

    - name: Ensure httpd is running
      ansible.builtin.service:
        name: httpd
        state: started

  handlers:
    - name: Restart httpd
      ansible.builtin.service:
        name: httpd
        state: restarted
...

In the task Write httpd config file, the notify clause points to a handler task named Restart httpd. The handler action is executed only if this task results in a changed state. The service restarts when the changes are done.

In the handlers section, the task name Restart httpd is executed when the tasks that point to it have made changes.

In this simple scenario, the handlers are executed at the end, if at all.

More specifically, according to the documentation, when you use the roles option at the play level, Ansible treats the roles as static imports and processes them during playbook parsing. Ansible executes each play in this order:

  1. Any pre_tasks defined in the play
  2. Any handlers triggered by pre_tasks
  3. Each role listed in roles, in the order listed; any role dependencies defined in the role's meta/main.yml run first (subject to tag filtering and conditionals)
  4. Any tasks defined in the play
  5. Any handlers triggered by the roles or tasks
  6. Any post_tasks defined in the play
  7. Any handlers triggered by post_tasks

So if a play has pre_tasks or roles with handlers, this affects the order of execution of actions, so it's important to pay attention to the sequence because it won't be linear.

My choice is to use import_roles or include_roles to invoke the roles in sequential order, and not to combine this method with roles at the play level (unless my main playbook has only roles at the play level and nothing more).

[ Want to test your sysadmin skills? Take a skills assessment today. ]

The docs describe the differences between import and include. The basic idea is that import* statements are static (pre-processed) when a playbook is parsed, while include* statements are dynamic. This has implications on the behavior of loops, when conditions, and other aspects, but those are out of scope for this article.

3. If/Then/Else

The previous example involving when is a kind of if/then statement, but at the task level. What if you want to use a simple if condition to assign a variable or invoke a different lookup (or anything else that requires a decision)?

Well, you can sometimes use Jinja2 expressions to help with that.

---
- name: Jinja2 IF
  hosts: localhost
  vars:
    change_provided: True
    job_name: death_star
    change_string: destroy
    current_date_time: 1977-01-01
  tasks:
    - name: Set base schedule name
      ansible.builtin.set_fact:
        base_schedule_name: "{%- if (change_provided | bool) -%}
                            {{ job_name }}_{{ change_string }}
                            {%- else -%}
                            {{ job_name }}_{{ current_date_time }}
                            {%- endif -%}"

    - name: Show value
      ansible.builtin.debug:
        msg: "{{ base_schedule_name }}"
...

In the playbook above, I defined variables statically to demonstrate the if in the Jinja2 code. Those variables would have come from some calculation or other processes in a real-life situation.

[ Download now: Advanced Linux commands cheat sheet. ]

Here's what is happening when I use set_fact to assign a value to base_schedule_name:

  1. The {% and %} are delimiters indicating a Jinja2 code. In this case, there are extra - marks to indicate to Jinja2 that I want to remove the space characters I have before the variables (the spaces are only for aesthetics).
  2. If change_provided is true, I combine job_name with the underscore and change_string. Only this content is assigned, and any spaces before or after the variables are omitted because I previously used {%- and -%}.
  3. The else statement is similar to what you might know from other languages. In this case, I combine job_name with an underscore and current_date_time.
  4. Finally, the endif closes the if.

As you can see, this is a little dangerous and can get messy. Use a good dose of common sense.

It is possible to use only a when condition in a task to do the same thing. However, it would also be possible to handle a relatively more complex logic with Jinja2, such as processing a loop, counting and filtering elements from a list, and so on. That does risk making things too complicated.

As in any real programming language, use what makes things easier to implement, maintain, troubleshoot, and understand.

4. Run a process in the operating system

At the beginning of this article, I mentioned the idea of idempotency. Whenever possible, try to use an Ansible module to perform an action because it contains the necessary logic to:

  • Execute what needs to be executed only when it's needed
  • Set the changed state of the task according to what really happened (it shows ok when the desired state was already there and changed when it had to make a change)

With that said, what if you really need to execute an action in the operating system? After all, sometimes you don't have a specific Ansible module that can perform a task for you.

Well, there are options to do this, depending on what operating system runs on your target.

For Linux, you can use one of these modules:

  • command: The command passed as an argument will not be processed by a shell, so environment var and I/O redirections will not work.
  • shell: Similar to the module command, but it uses a shell to execute the commands passed to it and accepts I/O redirections.

For Windows, you can use the equivalent modules:

The command you want to run on your target can be an executable binary or a script (shell script on Linux, PowerShell on Windows). If it is a script, you may need to use the copy or win_copy module to send the script to your target machine before you can run it there.

Essentially, you can send and run a script containing your logic.

A frame of mind

Ansible isn't a programming language, but it can help to have a programming language frame of mind when you use it. After this article, you may (possibly due to some subliminal messages?) start feeling a little like an Ansible Jedi.

Topics:   Ansible   Programming   Automation  
Author’s photo

Roberto Nozaki

Roberto Nozaki (RHCSA/RHCE/RHCA) is an Automation Principal Consultant at Red Hat Canada where he specializes in IT automation with Ansible. More about me

Try Red Hat Enterprise Linux

Download it at no charge from the Red Hat Developer program.