The Essentials of Ansible, Without Which Your Playbooks Are Just a Blob of Stuck Together Macaroni

I conduct many code reviews on Ansible and write a lot myself. Through analyzing mistakes (both others' and my own), as well as a number of interviews, I realized the main error users make with Ansible is diving into the complex without mastering the basics.

To correct this universal injustice, I decided to write an introduction to Ansible for those who already know it. I warn you, this is not a recap of the manuals; it’s a long read filled with words and devoid of pictures.

The expected reader level is someone who has already written thousands of lines of YAML, has something in production, but finds it all "somehow crooked."

Names

The main mistake of Ansible users is not knowing what things are called. If you don’t know the names, you can’t understand what’s written in the documentation. A vivid example: during an interview, a person, who claimed to have written extensively in Ansible, could not answer the question, "What elements make up a playbook?" When I hinted that "the expected answer was that a playbook consists of plays," the devastating comment came, "we don’t use that." People write in Ansible for pay and don’t utilize plays. In reality, they do use them, but don’t know what they are.

So let’s start with the basics: what things are called. Maybe you already know this, or maybe you don’t, because you didn’t pay attention when reading the documentation.

ansible-playbook executes the playbook. A playbook is a file with the extension yml/yaml, within which something like this exists:

---
- hosts: group1
  roles:
    - role1

- hosts: group2,group3
  tasks:
    - debug:

We’ve already understood that the entire file is the playbook. We can show where the roles are and where the tasks are. But where is the play here? And how does play differ from role or playbook?

This information is all in the documentation. And this is what is overlooked. Beginners — because it’s overwhelming and you can’t remember everything at once. Experienced users — because these are "trivial things." If you are experienced, re-read these pages at least once every six months, and your code will be a class better.

So remember: A playbook is a list consisting of plays and import_playbook.
Here is one play:

- hosts: group1
  roles:
    - role1

And here is another play:

- hosts: group2,group3
  tasks:
    - debug:

So what is a play? What is its purpose?

A play is a key element for a playbook because it is the play that connects the list of roles and/or tasks with the list of hosts on which they need to be executed. Deep in the documentation, you can find a mention of delegate_to, local lookup plugins, network-cli-specific settings, jump hosts, etc. They allow slightly changing the execution location of tasks. But, forget about it. Each of these tricky options has very specific applications, and they are definitely not universal. We're talking about the basics that everyone should know and use.

If you want to execute "something" "somewhere" — you write a play. Not a role. Not a role with modules and delegates. You simply write a play. In which, in the hosts field, you list where to execute it, and in roles/tasks — what to execute.

Simple, isn't it? How could it be otherwise?

One of the characteristic moments when people feel the urge to do this not through a play is with "a role that configures everything." There's a desire to have a role that configures both server type one and type two servers.

An archetypical example is monitoring. You want to have a monitoring role that sets up monitoring. The monitoring role is assigned to the monitoring hosts (in the corresponding play). But it turns out we need to install packages on the hosts we are monitoring. Why not use a delegate? And we also need to configure iptables. Delegate? We also need to write/update the config for the DBMS to allow monitoring. Delegate! And if creativity kicks in, it can turn into delegating include_role in a nested loop through a clever filter on the group list, and inside include_role you can still do delegate_to it again. And so it goes…

A good intention — to have a single monitoring role that "does everything" — leads us into a hell of complexity from which the only frequent escape is to rewrite everything from scratch.

Where did the mistake happen? The moment you realized that to perform task "x" on host X you needed to go to host Y and do "y" there, you should have done a simple exercise: go and write a play that does y on host Y. Not add something to "x," but write it from scratch. Even if it meant hardcoding variables.

It seems that everything mentioned in the previous paragraphs is correct. But this is not your case! Because you want to write reusable code that is DRY and resembles a library, and you need to find a way to do that.

Here lies another serious mistake. A mistake that turned many projects from tolerably written (it could be better, but everything works and it's easy to extend) into a complete horror, where even the author can't make sense of it. It works, but heaven forbid you try to change something.

This mistake sounds like this: a role is a library function. This analogy has doomed so many good initiatives that it's simply sad to watch. A role is not a library function. It cannot perform calculations and it cannot make decisions at the play level. Remind me, what decisions does play make?

Thank you, you are right. Play makes a decision (more precisely, it contains information) about which tasks and roles to execute on which hosts.

If you delegate this decision to a role, especially with calculations involved, you are condemning yourself (and anyone who will try to decipher your code) to a miserable existence. A role does not determine where it runs. That decision is made by play. A role does what it is told, where it is told.

Why programming in Ansible is dangerous and why COBOL is better than Ansible will be discussed in the chapter on variables and Jinja. For now, let's just say this — every calculation you perform leaves behind an indelible trace of changing global variables, and there's nothing you can do about it. Once two "traces" intersect — everything is lost.

Note for the nitpickers: a role can certainly influence control flow. There are delegate_to and it has reasonable applications. There are meta: end host/play. But! Remember, we are learning the basics? Forgot about delegate_to. We are talking about the simplest and most beautiful code in Ansible. Code that is easy to read, easy to write, easy to debug, easy to test, and easy to extend. So, once again:

play and only play decides on which hosts what is executed.

In this section, we have dealt with the opposition between play and role. Now let's talk about the relationship between tasks and role.

Tasks and Roles

Let’s consider play:

- hosts: somegroup
  pre_tasks:
    - some_tasks1:
  roles:
     - role1
     - role2
  post_tasks:
     - some_task2:
     - some_task3:

Suppose you need to do foo. It looks like foo: name=foobar state=present. Where to write this? In pre? Post? Create a role?

… And where did the tasks go?

We again start with the basics — the structure of play. If you are struggling with this issue, you cannot use play as a foundation for everything else, and your result will be "shaky".

Playbook: hosts directive, play settings, and sections pre_tasks, tasks, roles, post_tasks. Other parameters for play are not important to us at this time.

The order of their sections with tasks and roles: pre_tasks, roles, tasks, post_tasks. Since the semantic order of execution is tasks and roles unclear, best practices suggest adding the section tasks, only if there is no roles. If there is roles, then all accompanying tasks are placed in the sections pre_tasks/post_tasks.

Only what is semantically clear remains: first pre_tasks, then roles, then post_tasks.

But we still haven't answered the question: where should the module call foo be written? Do we need to write a whole role for each module? Or is it better to have a broad role for everything? If not a role, then where to write it — in pre or post?

If there are no reasoned answers to these questions, it is a sign of a lack of intuition, those 'shaky foundations'. Let's figure it out. First, a control question: If the play has pre_tasks and post_tasks (and there are neither tasks nor roles), can anything break if I move the first task from post_tasks to the end of pre_tasks?

Naturally, the phrasing of the question suggests that it will break. But what exactly?

… Handlers. Understanding the basics reveals an important fact: all handlers are flushed automatically after each section. That is, all tasks from pre_tasksare executed, then all handlers that were notified. Then all roles and all handlers that were notified in the roles are executed. Then post_tasks and their handlers.

Thus, if you move a task from post_tasks downward API support (simultaneously with this in pre_tasks, you may potentially execute it before the handler is executed. For example, if in pre_tasks something is installed and configured, and something is sent to it in web server, then moving this task to the section post_tasks will lead to the moment when 'sending' occurs, and the server will not yet be started, causing everything to break. pre_tasks Now let's think again, why do we need

to allow us to work with the results of the execution of roles (including handlers). pre_tasks and post_tasks? Например, для того, чтобы выполнить всё нужное (включая хэндлеры) до выполнения роли. А post_tasks A meticulous Ansible expert will tell us that there is

meta: flush_handlers , but why do we need flush_handlers if we can rely on the order of execution of sections in the play? Moreover, using meta: flush_handlers might give us unexpected results with repeating handlers, causing strange warnings in cases of usingwhen block at , etc. The better you know Ansible, the more nuances you will be able to name for a 'crafty' solution. A simple solution — using natural separation between pre/roles/post — does not raise nuances. The better you understand Ansible, the more nuances you can identify for a "clever" solution. A simple solution is using natural separation between pre/roles/post, which does not raise any nuances.

And, we return to our 'foo'. Where should it be placed? In pre, post, or roles? Clearly, it depends on whether we need the handler's results for foo. If we don’t, there’s no need to put foo in either pre or post — these sections have a specific meaning — executing tasks before and after the main array of code.

Now the answer to the question "role or task" boils down to what already exists in the play — if there are tasks, then we need to add to tasks. If there are roles — we need to create a role (even if it only consists of one task). I remind you, tasks and roles are not used simultaneously.

Understanding the basics of Ansible provides well-founded answers to what seem like subjective questions.

Tasks and Roles (Part Two)

Now let's discuss the situation when you are just starting to write a playbook. You need to do foo, bar, and baz. Is that three tasks, one role, or three roles? To generalize the question: at what point should roles be introduced? What’s the point of writing roles when tasks can be written?… And what is a role?

One of the biggest mistakes (I have mentioned this before) is to think that a role is like a function in a program’s library. What does a generalized description of a function look like? It takes arguments as input, interacts with side causes, produces side effects, and returns a value.

Now, pay attention. What of this can be done in a role? Triggering side effects — always possible; that’s the essence of Ansible — to create side effects. Having side causes? Easy. But when it comes to "passing a value and returning it" — that’s where it falls short. First of all, you cannot pass a value to a role. You can set a global variable with a lifespan up to the play in the vars section for the role. You can set a global variable with a lifespan in the play inside the role. Or even with a lifespan for the playbook (set_fact/register). But you cannot have "local variables". You cannot "accept a value" and "return it."

From this follows an important point: you cannot write something in Ansible that doesn't trigger side effects. Changing global variables is always a side effect for a function. In Rust, for example, changing a global variable is unsafe. In Ansible — the only way to affect values for a role. Note the wording: not "pass a value to a role," but "change the values that the role uses." There is no isolation between roles. There is no isolation between tasks and roles.

Total: A role is not a function..

What is good about roles? First of all, roles have default values (/default/main.yaml), and secondly, roles have additional directories for storing files.

What are the advantages of default values? In Maslow's rather twisted pyramid of variable priority in Ansible, role defaults are the least prioritized (except for command-line parameters in Ansible). This means that if you need to provide default values without worrying about them overriding values from inventory or group variables, role defaults are the only correct place for you. (I'm slightly misleading—there's also |d(your_default_here), but when it comes to static locations—it's only role defaults).

What else is good about roles? They have their own directories. These are directories for variables, both static (i.e., computed for the role) and dynamic (there's a sort of pattern or anti-pattern— include_vars together with {{ ansible_distribution }}-{{ ansible_distribution_major_version }}.yml.). These directories are for files/, templates/. Additionally, it allows having roles with their own modules and plugins (library/). But, compared to tasks in playbooks (which can also have all this), the benefit here is only that files are not dumped in one big pile, but in several separate piles.Another detail: you can try to create roles that will be available for reuse (through Galaxy). After the advent of collections, the distribution of roles can be considered nearly forgotten.

Thus, roles have two important features: they have defaults (a unique feature) and they allow structuring code.

Returning to the original question: when to make tasks and when to make roles? Tasks in playbooks are most often used either as 'glue' before/after roles or as independent building blocks (in which case there should be no roles in the code). A mix of normal tasks with roles is undoubtedly messy. It is advisable to stick to a specific style—either tasks or roles. Roles offer separation of entities and defaults, while tasks allow for faster code readability. Typically, more 'static' (important and complex) code is moved to roles, while auxiliary scripts are written in the tasks style.

Returning to the initial question: when to use tasks and when to use roles? Tasks in a playbook are most often used either as "glue" before/after roles or as standalone building blocks (in which case there should be no roles in the code). A mix of normal tasks with roles is undoubtedly untidy. It is important to adhere to a specific style — either tasks or roles. Roles provide separation of entities and defaults, while tasks allow for quicker code reading. Typically, more "static" (important and complex) code is moved into roles, and auxiliary scripts are written in the task format.

It is possible to do import_role as a task, but if you are writing this, be prepared to explain yourself about why you want to do it.

A nitpicking reader might say that roles can import roles, that roles can have dependencies through galaxy.yml, and there’s also the scary and terrible include_role — I remind you, we are enhancing skills in basic Ansible, not in rhythmic gymnastics.

Handlers and Tasks

Let’s discuss one more obvious thing: handlers. Knowing how to use them correctly is almost an art. What is the difference between a handler and a task?

Since we are recalling the basics, here is an example:

- hosts: group1
  tasks:
    - foo:
      notify: handler1
  handlers:
     - name: handler1
       bar:

In a role, handlers are located in rolename/handlers/main.yaml. Handlers are shared among all participants in the play: pre/post_tasks can call role handlers, and a role can call handlers from the play. However, "cross-role" calls to handlers create much more confusion than repeating a trivial handler. (Another element of best practices is to try to avoid repeating handler names).

The main difference is that a task is executed (idempotently) always (plus/minus tags and block), while a handler is triggered by a change in state (notify only triggers if there was a change). What are the implications of this? For example, if you run the play again and there were no changes, the handler will not run. But why might we need to execute a handler when there was no change in the triggering task? For instance, because something broke and there was a change, but the execution did not reach the handler. For example, because the network was temporarily down. The config changed, but the service was not restarted. On the next run, the config does not change and the service remains with the old version of the config.

The situation with the config is not solvable (more precisely, you could invent a special restart protocol with file flags, etc., but that’s no longer ‘basic ansible’ in any form). However, there is another common scenario: we installed an application, wrote its .service-file, and now we want to daemon_reload and state=startedAnd the natural place for this seems to be the handler. However, if it is made not a handler but a task at the end of the task list or a role, it will be idempotently executed every time. Even if the playbook breaks in the middle. This does not solve the problem of restarted (you cannot make a task with the restarted attribute, as idempotency is lost), but it is definitely worth doing state=started. The overall stability of the playbooks increases, as the number of dependencies and dynamic states decreases.

Another positive aspect of the handler is that it does not clutter the output. No changes — no unnecessary skipped or ok messages in the output — making it easier to read. However, this is also a negative aspect — if you find a typo in a linearly executed task on the first run, the handlers will be executed only when changed, i.e., under certain conditions — very rarely. For example, the first time in five years. And, of course, there will be a typo in the name, and everything will break. You cannot run them a second time — there’s no changed.

We must separately discuss the accessibility of variables. For example, if you notify a task with a loop, what will happen to the variables? You can guess analytically, but it is not always trivial, especially when variables come from different places.

So handlers are much less useful and much more problematic than they seem. If something can be written elegantly (without tricks) without handlers, it’s better to do it without them. If it doesn’t work out nicely — it’s better to use them.

A meticulous reader will rightly point out that we have not discussed listen, that a handler can call notify for another handler, that a handler can include import_tasks (which can do include_role with with_items), that the handler system in Ansible is Turing-complete, that handlers from include_role intersect curiously with handlers from the play, etc. — all of this is clearly not "fundamentals".

Although there is one particular WTF that is actually a feature and should be remembered. If you have a task executing with delegate_to and it has notify, then the corresponding handler is executed without delegate_to, i.e., on the host to which the play is assigned. (Although the handler can, of course, have delegate_to too).

I want to say a few words about reusable roles separately. Before collections appeared, there was an idea that universal roles could be created, which could be ansible-galaxy install and off I went. It works on all OSs in every scenario. My opinion is this: it does not work. Any role with mass support for 100,500 cases is doomed to the depths of corner case bugs. They can be patched through massive testing, but like any testing, you either have a Cartesian product of input values and a total function, or you have 'covered individual scenarios'. In my opinion, it's much better if the role is linear (cyclomatic complexity 1). include_varsThe fewer ifs (explicit or declarative - in the form of

or form block by the set of variables), the better the role. Sometimes you have to create branches, but I repeat, the fewer there are, the better. So, seemingly a good role with galaxy (it works!) with a ton of include_vars may be less preferable than your own role with five tasks. The moment when a role with galaxy is better is when you start writing something. The moment it becomes worse is when something breaks, and you suspect it’s due to 'the role with galaxy'. You open it, and there are five includes, eight task lists, and a stack of block ‘s... And you have to figure it out. Instead of five tasks in a linear list, where there's almost nothing to break. blockIn the following parts

A bit about inventory, group variables, host_group_vars plugin, hostvars. How to tie spaghetti into a Gordian knot. Scope and precedence of variables, Ansible's memory model. 'So where should we actually store the database username?'

  • jinja: {{ jinja }}
  • — nosql notype nonsensical soft clay. It's everywhere, even where you least expect it. A bit about !!unsafe and tasty yaml. Release of the openSUSE Leap 15.2 distribution

Source: habr.com

Buy reliable website hosting with DDoS protection, VPS VDS servers 🔥 Buy reliable website hosting with DDoS protection, VPS VDS servers | ProHoster