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:
- Any
pre_tasks
defined in the play - Any handlers triggered by
pre_tasks
- 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) - Any tasks defined in the play
- Any handlers triggered by the roles or tasks
- Any
post_tasks
defined in the play - 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
:
- 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). - If
change_provided
is true, I combinejob_name
with the underscore andchange_string
. Only this content is assigned, and any spaces before or after the variables are omitted because I previously used{%-
and-%}
. - The
else
statement is similar to what you might know from other languages. In this case, I combinejob_name
with an underscore andcurrent_date_time
. - Finally, the
endif
closes theif
.
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 andchanged
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:
- win_command
- win_shell
- template or win_template: This allows your script to use variables so you can have a script customized to each target or condition.
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.