Optimizing anssible code

Some ansible code can take a long time to run, this is in most cases due to a design choice in the playbook. Mostly when using loops where they are not needed, or loops to parse large datasets, this takes lots of time.
For a few of these optimizations there are things that can be done to improve performance:

Avoid large loops on registered vars

In many online examples you will find loops that use "with_items".
This is the old way, it is better to use "loop" according to the best practices.

- name: An old loop using with_items
  ansible.builtin.set_fact:
    small_fact: "[] + [{{ item }}]"
  with_items: "{{ var }}"
  when: "'text' in item"

This will still work, but when using multiple of these with_item loops in the same playbook, you will have a variable "item" that has a value at the start of the loop. It could have some nasty side effects, when this value is used somewhere else in the play.

The new approach looks like this:

- name: A new loop with loop
  ansible.builtin.set_fact:
    small_fact: "[] + [{{ small_var }}]"
  loop: "{{ var }}"
  loop_control:
    loop_var: small_var
  when: "'text' in small_var" 

The above loop is the same as the with_items loop, but if you use different loop_vars for each loop, there is no chance of using a value where it shouldn't be used.

If loops must itterate over a lot of items, the efficiency is not great and will take a lot of time to complete. Imagine your playbook has to loop over the registered output of a previous task and that regisered variable contains 10000 lines.
If you do this with a standard loop (as shown above), it will take a very long time.

Example:


- name: Generate 10000 lines of output
  ansible.builtin.shell: |
    for i in $(seq 1 10000);
    do
        echo "${i} this is a line";
    done
  register: _output

- name: Loop over the output to find 55
  ansible.builtin.set_fact:
    line_number: "{{ ansible_loop.index0 | int }}"
  when:
    - "'55' in _line"
  loop: "{{ _output.stdout_lines }}"
  loop_control:
    loop_var: _line
    extended: true
    label: "{{ ansible_loop.index0 }}"

This is a valid playbook and checks out great in code linting and syntax checks.
As with the code it looks nice and structured as we would like to see it.
But, when we execute this playbook, it wil take 10 minutes to complete, giving 9999 times skipped as output, and just 1 OK.

There must be room for improvement here...

The trick here is using a jinja2 filter to parse the output and if we replace the loop with a jinja2 filter, we end up with the following playbook:


-  ansible.builtin.shell: |
    for i in $(seq 1 10000);
    do
        echo "${i} this is a line";
    done
  register: _output

- name: Loop over the output to find 55
  ansible.builtin.set_fact:
    line_number: >-
      {%- for linenum in range(_output.stdout_lines | length) -%}
        {%- if '55' in _output.stdout_lines[linenum] -%}
        {{ linenum | int }}
      {%- endif -%}
      {%- endfor -%}

In the above play, we still loop throug the output, but we do this with a single jinja2 filter, which is much more efficient than an ansible loop.
It also removes all the 9999 non-matching lines in the output.

The performance/runtime difference is vast at these volumes:

playbook number of records runtime
loop ansible 10000 600 sec
loop ansible 1000 30 sec
jinja 10000 1,2 sec
jinja 1000 1 sec

Avoid using include_tasks in a loop

Somtimes you'll need an inner- and outer loop to handle some variables in multiple instances. In this example we will create a standard set of files in each directory, the file sto create and the list of directories are given by 2 variables.

WARNING Jinja2 always returns a string value, so formatting is verry important when you need a list or a dict returned.

Example:

In this example we want the same set of files to be templated for every environment.
When you do this in a loop, you generally put the template tasks in a separate file and include this in an inner of two loops (1 for the environments and 1 for the files) to render all files in 1 run.
So you will be using a nested loop.

In the example below, we don't create a nested loop, but generate a string variable, in which all combinations of both lists are present.
Then we loop over this list to generate the file needed.
There is no nested loop and no include_tasks itteration.
This will improve afficiency a great deal.

- name: Example play
  hosts: localhost
  gather_facts: false
  vars:
    template_files:
      - credentials
      - inventory
      - notifications
      - organization
      - projects
      - roles
      - schedules
      - teams
      - templates
      - users
      - workflows
    environments:
      - dev
      - test
      - accp
      - prod

    tasks:

      - name: Create the loop var
        ansible.builtin.set_fact:
          template_loop: >
            {%- set return_value = [] -%}
            {%- for env in environments -%}
              {%- for file in template_files -%}
              {%- set _ = return_value.append( env + ',' + file ) -%}
              {%- endfor -%}
            {%- endfor -%}
            {{ return_value }}

      - name: "Template all files loop"
        ansible.builtin.template:
          src: "{{ curr_file.split(',')[-1] }}.yml.j2"
          dest: "/tmp/{{ project_name }}/group_vars/{{ curr_file.split(',')[0] }}/{{ curr_file.split(',')[-1] }}.yml"
          lstrip_blocks: true
          mode: "0640"
        loop: "{{ template_loop }}"
        loop_control:
          loop_var: curr_file

The "Create the loop var" task, creates a list of all combinations of environments and files.
All fields are comma separated.
A smal piece looks like this:

template_loop: ["dev,credentials","dev,inventory","dev,notifications", .... etc.. ]"

The "Template all files loop" uses a standard single loop. We don't need a include_tasks to create the templated files.
This makes the playbook more readable and more efficient.

The loop_var "curr_file" is filled with the next part of the template_loop variable.
Like: curr_var: "dev,credentials" To be able to use these variables to create the file we need, we must split them.
We do that with split, but we directly set the var with the part we want to use there.
For example: - The first part, the environment: "{{ curr_file.split(',')[0] }}"
- The last part, the filename: '"{{ curr_file.split(',')[-1] }}"
If there are more elements, these can be addressed through a zero based index number.

By applying the above examples, the number of tasks will be reduced, performance will increase and the number of yaml files will decrease.
The code will even be more readable.

Create variables using jinja2

It is possible to build variables fast and consistent with jinja2.
Jinja2 is much faster than any ansible code to construct variables and here we find a way to make a playbook run much faster.
By getting the variables straight out of a multilayer dict (eg. a satellite inventory or something like that), we can filter a subset to target only the systems we need. That is a performance gain (but we could do that with a inventory filter too), but a jinja filter is more dynamic and works inside the playbook.

First we go back to some jinja basics to create variable types:

String

String is the default type for the return value.

Integer

When you want to return an integer, you must convert it to integer.
In the task below, we loop through the output of a shell task in ansible to find a string and return the line number in a variable.
Since the linenumber is returned as a string, we need to convert it to number again.

- name: Loop over the output to find 'text'
  ansible.builtin.set_fact:
    line_number: >-
      {%- for i in range(_output.stdout_lines | length) -%}
        {%- if 'text' in _output.stdout_lines[i] -%}
        {{ i | int }}
      {%- endif -%}
      {%- endfor -%}

NOTE Each time the searchstring is found, a line number is added to the output, be sure you use the correct separators, if needed.

List

Verified with ansible version 2.15 and ansible 2.19. A list is defined in python (therefore in ansible too) as: ['item1','item2',]

To return this structure in jinja is easy when you know how. A pain in the ... if you don't.
We take the previous example en make a few modifications:

- name: Loop over the output to find 'text'
  ansible.builtin.set_fact:
    line_number: >
      {%- set return_value = [] -%}
      {%- for i in range(_output.stdout_lines | length) -%}
        {%- if 'text' in _output.stdout_lines[i] -%}
        {%- set _ = return_value.append( i | int ) -%}
        {%- endif -%}
      {%- endfor -%}
      {{ return_value }}

The code is almost identical to the previous example, but to create a list variable we added some lines: We first create a list return_value variable (list definition) and then append the values as needed.
Be sure to place these on separate lines as shown in the example, or it won't work and a string will be returned instead.
The return value is now placed between single quotes, so these will be added to the output as these are wanted in the list definition.
In a list there must be a comma separator placed between each item, after the last item there is no comma, this is why we use the loop.last variable to determine if we need a comma or not.

Dictionary

Verified with ansible version 2.15 up to ansible 2.19.
A dict in python (and therefore ansible) is defined as follows:
{ "key": "value", "key2": "value2" }

To return this in a jinja created structure, we use the following jinja:

- name: Loop over the output to find 'text'
  ansible.builtin.set_fact:
    line_number: >
      {%- set return_val = dict() -%}
      {%- for i in range(_output.stdout_lines | length) -%}
        {%- if 'text' in _output.stdout_lines[i] -%}
        {%- set _ = return_val.update({ i : _output.stdout_lines[i] }) -%}
      {%- endif -%}
      {%- endfor -%}
      {{ return_val }}

First we create the return_value as a dict in jinja.
We add items using the jinja "update" method, which will add an item to the dict.

We now get a dict returned in which there are, not only the line number, but also the contents of that particular line.
This way we can extract variables from an output or any text variable in a fast and predictable way.

Jinja has lost of other filters and search options, which makes it possible to search for elements in dicts, like select or select_attr.
Read the jinja2 documentation for more information on this.

Building a list of dicts

Verified with ansible version 2.15, in ansible 2.19, they revised the jinja2 engine and the return value will always be a string, killing this optimization.
Somtimes we have a list of dicts with a number of items that misses a number of values you want in there. As the lists are differrent in size, a lists_mergeby is not working.

This is a solution I cresated for the configuration as code for ansible automation platform 2.5, where the role user assignment must be done on organization_id. Initially this will work fine installation works, but as time progresses, organizations will be deleted and added. This will disrupt the numbering of the organizations in your config on recovery and will render your configuration as code useless.

The code below is created to make the mapping dynamicly, so the configuration Let's give an example:
List 1: ( this is a result from a query in rhaap )

    "gateway_organizations": [
        {
            "description": "The default organization for Ansible Automation Platform",
            "id": 1,
            "name": "Default",
        },
        {
            "description": "Automation platform managent id=2",
            "id": 2,
            "name": "MGT",
        },
        {
            "description": "Database team collection based id=5",
            "id": 5,
            "name": "ORACLE_COLL",
        },
        {
            "description": "Database team role based id=8",
            "id": 9,
            "name": "ORACLE_REQ",
        },
        {
            "description": "Organization for team TST id=9",
            "id": 8,
            "name": "ORG_TST",
        },
        {
            "description": "RHEL Organization collection based id=3",
            "id": 3,
            "name": "RHEL_COLL",
        },
        {
            "description": "RHEL Organization role based id=6",
            "id": 7,
            "name": "RHEL_REQ",
        },
        {
            "description": "WEB server team collection based id=4",
            "id": 4,
            "name": "WEB_COLL",
        },
        {
            "description": "WEB server team role based id=7",
            "id": 6,
            "name": "WEB_REQ",
        }
    ]

The above result is very much edited, only the items we need are remaining.

The list we need the information for is the following:

gateway_role_user_assignments_all:
  - role_definition: Organization Member
    user: wilco
    name: MGT

  - role_definition: Organization Member
    user: coll_upload
    name: MGT

  - role_definition: Team Member
    user: coll_upload
    name: MGT

  - role_definition: Organization Member
    user: coll_get
    name: MGT

  - role_definition: Team Member
    user: coll_get
    name: MGT

  - role_definition: Organization Member
    user: ee_upload
    name: MGT

  - role_definition: Team Member
    user: ee_upload
    name: RHEL_COLL

  - role_definition: Organization Member
    user: ee_pull
    name: MGT

  - role_definition: Team Member
    user: ee_pull
    name: RHEL_COLL

  - role_definition: Organization Admin
    user: mgt
    name: MGT

  - role_definition: Organization Admin
    user: CaC_admin_MGT
    name: MGT

  - role_definition: Organization Admin
    user: CaC_admin_RHEL_COLL
    name: RHEL_COLL

  - role_definition: Organization Admin
    user: CaC_admin_RHEL_REQ
    name: RHEL_REQ

  - role_definition: Organization Admin
    user: CaC_admin_WEB_COLL
    name: WEB_COLL

  - role_definition: Organization Admin
    user: CaC_admin_WEB_REQ

  - role_definition: Organization Admin
    user: CaC_admin_ORACLE_COLL
    name: ORACLE_COLL

  - role_definition: Organization Admin
    user: CaC_admin_ORACLE_REQ
    name: ORACLE_REQ

  - role_definition: Organization Admin
    user: CaC_admin_TST
    name: ORG_TST

In this list, we added the organization name instead of the organization id.

The mapping will be done by the following jinja2 task:

    - name: Create the gateway_role_user_assignments variable
      ansible.builtin.set_fact:
        gateway_role_user_assignments: >-
          {%- set return_value = [] }
          {%- for item in gateway_role_user_assignments_all -%}
            {%- set ret = dict() -%}
              {%- set _ = ret.update('role_definition': item.role_definition) -%}
              {%- set _ = ret.update('user': item.user) -%}
              {%- set _ = ret.update('object_id': orgs_list|selectattr('name', '==', item.name)|first).id) -%}
            {%- set _ = return_value.append( ret) -%}
          {%- endfor -%}
          {{ return_vaule }}

This will create the variable that the config as code needs to map all users to their roles using the organization id.
And there is no problem when a organization is deleted and a recovery is done of the complete configuration (the gap is resolved by the script ).

Json

To return any of the above variables as a json variable, just use a set_fact.

  ansible.builtin.set_fact:
    json_var: "{{ jinja_built_var | to_nice_json }}"

This should work.

Avoid jinja spacing errors in linting

Nothing can be more frustrating as receiving a jinja spacing error while linting your code. . You know that there is nothing wrong with it, it runs without any errors and still every time you run the linter, this error pops up: Improve jinja spacing....

The code:

  - name: Add the controller to the names of the templates
    ansible.builtin.set_fact:
      ee_on_templates: |
        {%- set return_value = [] -%}
        {%- for i in range(_template_list | length) -%}
        {% set _ = return_value.append( inventory_hostname + i) -%}
        {%- endfor -%}
        {{ return_value }}

In the above code, we did everything to remove spaces... like {%- -%} and still the linter complains sometimes.

It looks like valid code, and it is... But:

WARNING  Listing 1 violation(s) that are fatal
jinja[spacing]: Jinja2 spacing could be improved

The final trick is not to try to ignore the error, but change the set_fact ever so slightly:

  - name: Add the controller to the names of the templates
    ansible.builtin.set_fact:
      ee_on_templates: >
        {%- set return_value = [] -%}
        {%- for i in range(_template_list | length) -%}
        {% set _ = return_value.append( inventory_hostname + i) -%}
        {%- endfor -%}
        {{ return_value }}

The | instead of > makes all the difference..

Creating complex loops using jinja2

In some cases you would create a loop within a loop to perform a complex task that could look like this:

In main.yml:

    - name: "Loop to write secure credentials"
      ansible.builtin.include_tasks:
        file: secure_group.yml
      vars:
        curr_env2: "{{ lcred[0] }}"
        creds: "{{ lcred[1] }}"
      loop_control:
        loop_var: lcred
      loop: |-
          [
          {%- for key, env_item in code_environment_vars.items() -%}
            {%- if env_item.credentials != [] -%}
              {%- for cred in env_item.credentials -%}
               ['{{ key }}',{{ cred }}],
          {%- endfor -%}
          {%- endif -%}
          {%- endfor -%}
          ]
      when: creds.credential_type != 'Machine'
      no_log: true

The jinja code in the above task creates a list of comma separated items from a dict, as specified in the automate organization addition.
Link to vars structure
Specificly the list will contain credential items per environment.
And the loop will itterate over this list that is created dynamicly and will include the file with the tasks for every item. This is a loop that is used very much in ansible code, and this is not so efficient, but to be able to run multiple tasks it is sometimes the only way.

In secure_group.yml:
What we did here, is create a vaulted credential in a rather complex manner, and we needed multiple tasks to do it, so we had to use include_tasks.

---
- name: Be sure to clean the file with unencrypted value
  block:
    - name: Write token to file
      ansible.builtin.copy:
        content: "{{ creds.encrypt }}"
        dest: /tmp/file
        mode: "0600"

    - name: Encrypt a var and register result
      ansible.builtin.command:
        argv:
          - ./encrypt.sh
          - /tmp/file
          - dummy
          - "{{ team_password }}"
      register: _vaulted
      changed_when: false
      no_log: true

  always:
    - name: Clean the unencrypted file
      ansible.builtin.file:
        path: /tmp/file
        state: absent

- name: Write the encrypted credential to file
  ansible.builtin.blockinfile:
    path: "/tmp/{{ team_project_name }}/group_vars/{{ curr_env2 }}/controller_credentials.yml"
    insertafter: EOF
    marker: ''
    block: |
      {% filter indent(width=2, first=true) %}
      - name: {{ creds.name }}
      {% if cred.description is defined %}
        description: {{ creds.description }}
      {% else %}
        description:
      {% endif %}
        credential_type: {{ creds.credential_type }}
        organization: {{ organization_long_name | upper }}
        inputs:
      {% if creds.credential_type == 'Ansible Galaxy/Automation Hub API Token' %}
          auth_url: ''
          token: {{ '"{{ ahub_token }}"' }}
          url: '{{ creds.url }}'
        update_secrets: true
      {% endif %}
      {% if creds.credential_type == 'Source Control' %}
          ssh_key_data: {{ _vaulted.stdout }}
          username: {{ creds.username }}
      {% endif %}
      {% if creds.credential_type == 'Vault' %}
          vault_password: {{ _vaulted.stdout }}
      {% endif %}
      {% if creds.credential_type == 'Container Registry' %}
          host: {{ creds.host }}
          username: {{ creds.username }}
          password: {{ _vaulted.stdout }}
          verify_ssl: {{ creds.verify_ssl | lower }}
      {% endif %}
      {% endfilter %}

- name: Remove empty lines at end of file            # noqa: command-instead-of-module
  ansible.builtin.shell:
    cmd: sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' controller_credentials.yml
    chdir: "/tmp/{{ team_project_name }}/group_vars/{{ curr_env2 }}"
  changed_when: true

Now we know better and merged the above code into 1 task: And it still does the same...

    - name: "Loop to write secure credentials"                # noqa: jinja[spacing]
      ansible.builtin.blockinfile:
        path: "/tmp/{{ team_project_name }}/group_vars/{{ lcred[0] }}/controller_credentials.yml"
        insertafter: EOF
        marker: ''
        block: |
          {% filter indent(width=2, first=true) %}
          - name: {{ lcred[1].name }}
          {% if lcred[1].description is defined %}
            description: {{ lcred[1].description }}
          {% else %}
            description:
          {% endif %}
            credential_type: {{ lcred[1].credential_type }}
            organization: {{ organization_long_name | upper }}
            inputs:
          {% if lcred[1].credential_type == 'Ansible Galaxy/Automation Hub API Token' %}
              auth_url: ''
              token: {{ '"{{ ahub_token }}"' }}
              url: '{{ lcred[1].url }}'
            update_secrets: true
          {% endif %}
          {%- endfilter %}
          {% if lcred[1].credential_type == 'Source Control' %}
          {% filter indent(width=2, first=true) %}
              ssh_key_data: !vault |
          {% endfilter %}
          {% filter indent(width=10, first=true) %}
          {{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
          {%- endfilter %}
          {% filter indent(width=2, first=true) %}
              username: {{ lcred[1].username }}
          {%- endfilter %}
          {% endif %}
          {% if lcred[1].credential_type == 'Vault' %}
          {% filter indent(width=2, first=true) %}
              vault_password: !vault |
          {% endfilter %}
          {% filter indent(width=10, first=true) %}
          {{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
          {%- endfilter %}
          {% endif %}
          {% if lcred[1].credential_type == 'Container Registry' %}
          {% filter indent(width=2, first=true) %}
              host: {{ lcred[1].host }}
              username: {{ lcred[1].username }}
              password: !vault |
          {% endfilter %}
          {% filter indent(width=10, first=true) %}
          {{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
          {%- endfilter %}
          {% filter indent(width=2, first=true) %}
              verify_ssl: {{  lcred[1].verify_ssl | lower }}
          {%- endfilter %}
          {% endif %}
      loop_control:
        loop_var: lcred
      loop: |-
          [
          {%- for key, env_item in code_environment_vars.items() -%}
            {%- if env_item.credentials != [] -%}
              {%- for cred in env_item.credentials -%}
               ['{{ key }}',{{ cred }}],
          {%- endfor -%}
          {%- endif -%}
          {%- endfor -%}
          ]
      when: lcred[1].credential_type != 'Machine'
      no_log: true

As the loop is not altered, we will not explain that.
But the jinja in the blokinfile is rather complex, but if you obide by a few rules, you can write this yourself.

The result of this code is this:
(I deleted some lines, to keep this readable)
The base layout of the file was already templated, this just adds the credentials.

---
controller_credentials:

  - name: INFRA_automation_hub_image_pull_secret
    description:
    credential_type: Container Registry
    organization: ORG_INFRA
    inputs:
      host: rhaap25.homelab
      username: ee_pull
      password: !vault |
            $ANSIBLE_VAULT;1.1;AES256
            65316138303936616434353063336661326162303462376434336461323666396637383064616538
            3334363164616263650a373932663635633864663139343366343864616464623263386436653632
            6565
      verify_ssl: false

  - name: INFRA_gitlab
    description:
    credential_type: Source Control
    organization: ORG_INFRA
    inputs:
      ssh_key_data: !vault |
            $ANSIBLE_VAULT;1.1;AES256
            62616339383061323463616162346534336534326266396662336666333265333163386534646562
            3635633330663165633163633165663331643262356530370a336635353066366664363730393435
            32626361656666356666306331613633373163383662656664653736653333656232373666363666
            613232323138333630326430386333373231
      username: AAP_user

  - name: INFRA_vault
    description:
    credential_type: Vault
    organization: ORG_INFRA
    inputs:
      vault_password: !vault |
            $ANSIBLE_VAULT;1.1;AES256
            66333662343338353062353038326430373161643638636264386336643462373737396664356663
            31386565313562366336376665333631653630613034663533656631653930376439

As you can see, all credentials are nicely formatted and vaulted in one loop.The loop will add the credentials in the inventory in a number of directories, that are the environments in the source dict.

Let's breakup the jinja that renders this: Indentation is used here to clarify the code, do not use this in your code...

# We start with the correct indentation for our yaml file
{% filter indent(width=2, first=true) %}
- name: {{ lcred[1].name }}
    # an IF statement must complete within the same FILTER
    {% if lcred[1].description is defined %}
    description: {{ lcred[1].description }}
    {% else %}
    description:
    {% endif %}
credential_type: {{ lcred[1].credential_type }}
organization: {{ organization_long_name | upper }}
inputs:
    {% if lcred[1].credential_type == 'Ansible Galaxy/Automation Hub API Token' %}
        auth_url: ''
        token: {{ '"{{ ahub_token }}"' }}
        url: '{{ lcred[1].url }}'
    update_secrets: true
    {% endif %}
{%- endfilter %}
{% if lcred[1].credential_type == 'Source Control' %}
# the same goes for a FILTER within an IF statement it must complete before ENDIF
    {% filter indent(width=2, first=true) %}
        ssh_key_data: !vault |
    {% endfilter %}
    {% filter indent(width=10, first=true) %}
    {{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
    {%- endfilter %}
    {% filter indent(width=2, first=true) %}
        username: {{ lcred[1].username }}
    {%- endfilter %}
{% endif %}
# Here we start a new IF..
{% if lcred[1].credential_type == 'Vault' %}
    {% filter indent(width=2, first=true) %}
        vault_password: !vault |
    {% endfilter %}
    {% filter indent(width=10, first=true) %}
    {{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
    {%- endfilter %}
{% endif %}
# Here we start a new IF
{% if lcred[1].credential_type == 'Container Registry' %}
    {% filter indent(width=2, first=true) %}
        host: {{ lcred[1].host }}
        username: {{ lcred[1].username }}
        password: !vault |
    {% endfilter %}
    {% filter indent(width=10, first=true) %}
    {{ lcred[1].encrypt | vault(team_password,inventory_hostname,'') }}
    {%- endfilter %}
    {% filter indent(width=2, first=true) %}
        verify_ssl: {{  lcred[1].verify_ssl | lower }}
    {%- endfilter %}
{% endif %}

This looks like the number of filter statements could be reduced, but ansible-lint will fail with a load error, without a clear error message. Keep these rules in mind when writing complex jinja code.

Dynamic variable names

When running large playbooks with multiple scenario's in them, you might want to have some variable created only in a cetain situation, dependent of the value in a variable. Something like wyou have 3 variables with different content for use in a certain environemnt, when starting the playbook there is a variable in the inventory or even a value in the hostvars that combined with a prefix will point to a variable you defined and you want this to be dynamic.

Let me give you an example:

You want to define a separate timezone for each ditribution:

vars:
  timezone_redhat: UTC
  timezone_ubuntu: Europe/Amsterdam
  timezone_alpine: Europe/Switzerland
  timezone_centos: America/NewYork

  tasks:

    - name: Set timezone for distro
      community.general.timezone:
        name: "{{ 'timezone_' + vars['ansible_distribution'] | lower }}

Avoid host_key error when redeploying a host

When you use your infrastructure playbook to remove- and create a host with the same name (redeploy), the host key changes. Sometimes the play will fail after (re)creating the host with a host key error. To prevent this, use the following in the deployment play only:

- name: Configure the host on infrastructure
  hosts: "{{ instances | default('localhost') }}"
  gather_facts: false
  vars:
    ansible_ssh_host_key_checking: false

WARNING Do not use this in your inventory, it reduces security for all hosts in the inventory.
If you just use this in your deployment play, you will be safe and the play won't fail on this error.