Writing and Running Playbooks

Ansible zero to hero, Part 3

#Ansible #Full course #Getting started

Playbooks are the main power of ansible. I like to think of a playbook as an easy to manage script. Playbooks allow us to run multiple sets of tasks called plays, define variables and add logic such as conditions and loops.

This is an example of a very basic playbook:

---
- name: first play
  hosts: web.example.com #The host must be selected from the inventory
  vars:
    # some variable definitions]
  #configurations for example
  order:  # The order in which hosts are selected, options: inventory, reverse_inventory, sorted, reverse_sorted and shuffle
  remote_user: root
  become: true
  hosts: 
  tasks:
    - name: first task
      yum:
        name: httpd
        status: present
    - name: second task
      service:
        name: httpd
        enabled: true
- name: second play
  hosts: database.example.com
  tasks:
    - name: first task
      service:
        name: mariadb
        enabled: true
...

So to clarify the terminology:

  • task: a single action in the playbook, each task uses a single ansible module to perform the action.
  • play: ordered set of tasks.
  • playbook: text file containing a list of one or more plays to run in a specific order.

In the example we see a playbook that contains 2 plays, this playbook is a simple YAML file that the ansible command can get as an input and run. Like any languadge ansible playbooks have specific syntax we need to use when writing them, here are some points we need to keep in mind:

  • Playbooks are written in YAML format.
  • Uses indentation with space characters (tab characters are not allowed) a convention is to use 2 spaces for indentation, but it can be more.
  • Can use blank lines for readability.
  • Data elements at the same level in the hierarchy must have the same indentation.
  • Items that are children of another item must be indented more than their parents.
  • Begins with a line consisting of three dashes (—) as a start of document marker, and optionally ends with three dots (…).
  • Multi line strings can be written in 2 ways:
    • vertical bar (|) - newline characters within the string are preserved.

    • greater-than (>) - newline characters are to be converted to spaces and that leading white spaces are removed.

      include_newlines: |
              Example Company
              123 Main Street
              Atlanta, GA 30303
      
      fold_newlines: >
              This is an example
              of a long string,
              that will become
              a single sentence once folded.
      

To verify that the syntax is correct:

ansible-plybook --syntax-check

** See Ansible docs for more examples and clarification.

Remote Users and Privilege Escalation in Plays

Plays can override the user and privileges set on the configuration file. The following keys are available for configuration within a play:

  • remote_user
  • become boolean - enable or disable privilege escalation.
  • become_method - define the privilege escalation method.
  • become_user - the user account to use for privilege escalation.

Running Playbooks

To run an ansible playbook we just use the ansible-playbook command:

ansible-playbook PLAYBOOK_FILE

We can run a playbook as dry-run to see what would happen without really change anything on the hosts:

ansible-playbook -C PLAYBOOK_FILE

We can also change the verbosity level:

  1. -v The task results are displayed.
  2. -vv Both task results and task configuration are displayed.
  3. -vvv Includes information about connections to managed hosts.
  4. -vvvv Adds extra verbosity options to the connection plug-ins, including users being used in the managed hosts to execute scripts, and what scripts have been executed.

One of the most important features of playbooks is that it should be safe to re-running. Meaning that if a specific task needs to do something that as already been done it will not be executed, this saves us a lot of problems.

Most ansible modules are safe to re-run and that makes our life easier when writing the playbook, but we need to keep in mind that some modules are not safe like the command and shell modules. We need to be careful when writing a playbook that uses unsafe modules and add some condition to not run the task in case the desired outcome as already been reached.

Ansible Variables

Ansible variables are the same as any other language they are used to store values that can be reused, or hold outputs from previous commands. This can simplify the creation and maintenance of a playbook. In the above example we saw a very simple playbook that installed the httpd package and restarted the httpd service, we could have used a variable to hold the httpd value.

Variables can be defined in many places but the main scopes are:

  1. Global scope - set from the command line or Ansible configuration.
    ansible-playbook main.yml -e "package=apache"
  1. Play scope - set in the play and related structures.

    • Vars block at the beginning of a play:
        - hosts: all
        vars:
            user: joe
            home: /home/joe
    
    • variables in external files. the vars_files contains a list of names for external variable files relative to the location of the playbook:
        # playbook:
         ...
            - hosts: all
            vars_files:
                - vars/users.yml
         ...
        # cat users.yml:
         user: joe
         home: /home/joe
    
  2. Host scope:

  • set on host groups and individual hosts by the inventory (like we saw on part 2).
  • fact gathering - When ansible logs into a host he gathers facts about this host. The facts are saved into several variables that can be used within the playbook. See facts and discovered system facts
  • registered tasks - When we run a task we can register the result of that task as a variable
- hosts: web_servers
  tasks:
    - shell: /usr/bin/foo
      register: foo_result
      ignore_errors: True
    - shell: /usr/bin/bar
      when: foo_result.rc == 5
  1. Magic variables - contain information about Ansible operations, including the python version being used, the hosts and groups in inventory, and the directories for playbooks and roles. The most commonly used magic variables are hostvars, groups, group_names, and inventory_hostname, see all on Magic variables ans examples on Accessing information about other hosts with magic variables

** ansible variables must start with a letter and can only contain letters, numbers, and underscores.

A variable can be:

  1. string:
  2. number:
  3. list:
  4. dictionary:
   vars:
     user: joe        #string
     id: 1            # number
     friends_list:         # list
       - cartman
       - kenny
     friends_dict:       # dictionary
       best: kenny
       funnest: cartman

The difference between a list and a dictionary, is that list variables are accessed with an index like friends_list[0] and dictionary variables are accessed with a key like friends_dict[‘best’] or friends_dict.best

Using variables

To use the variable in a task we wrap it with curly braces {{ }}, note that we need to use quotes "” when a variable is used as the first element:

    tasks:
    # No need for quotes "" since user is not the first element
    - name: Creates the user {{ user }}
        user:
        # Need quotes "" since user is the first element
        name: "{{ user }}"

Variables precedence

From least to greatest (the last listed variables winning prioritization):

  1. command line values (eg “-u user”)
  2. role defaults [1]
  3. inventory file or script group vars [2]
  4. inventory group_vars/all [3]
  5. playbook group_vars/all [3]
  6. inventory group_vars/* [3]
  7. playbook group_vars/* [3]
  8. inventory file or script host vars [2]
  9. inventory host_vars/* [3]
  10. playbook host_vars/* [3]
  11. host facts / cached set_facts [4]
  12. play vars
  13. play vars_prompt
  14. play vars_files
  15. role vars (defined in role/vars/main.yml)
  16. block vars (only for tasks in block)
  17. task vars (only for the task)
  18. include_vars
  19. set_facts / registered vars
  20. role (and include_role) params
  21. include params
  22. extra vars (always win precedence)

Writing Loops and Conditional Tasks

Loops

When we write a script we often need to preform some actions multiple times, pull/skip a task until a certain condition is met. Ansible lets you do this with loops and conditional tasks.

We can iterate a task over a set of items using the loop keyword. The loop keyword is added in the task level, and takes as a value a list or dictionary of items over which the task should be iterated. The variable {{ item }} holds the value used during each iteration.

Example:

# Using list
- name: firewalld and sshd are running
  service:
    name: "{{ item }}"
    state: started
  loop:
    - firewalld
    - sshd

# Using dictionary
- name: Users exist and are in the correct groups
  user:
    name: "{{ item.name }}"
    state: present
    groups: "{{ item.groups }}"
  loop:
    - name: cartman
      groups: wheel
    - name: kenny
      groups: root

Consult the documentation for more advanced looping scenarios link.

Registering variables with a loop

You can register the output of a loop as a variable. For example:

- shell: "echo {{ item }}"
  loop:
    - "one"
    - "two"
  register: echo

When you use register with a loop, the data structure placed in the variable will contain a results attribute that is a list of all responses from the module.

Conditional Tasks

Ansible can use conditionals to execute tasks or plays when certain conditions are met. Playbook variables, registered variables, and Ansible facts can all be tested with conditionals. Operators to compare strings, numeric data, and Boolean values are available.

The when statement is used to run a task conditionally. In the when statement we define the condition or conditions that needs to be meet the execute the task. We can use and, or, () to combine multiple conditions

---
- name: Simple Boolean Task Demo
  hosts: all
  vars:
    s_name: my_service
  tasks:
    - name: "{{ s_name }} is started when there is enough memory"
      service:
        name: "{{ s_name }}"
        state: started
      when:
        - ansible_memfree_mb > 8192
        - ansible_distribution == "RedHat"

Example Conditionals:

Equal (value is a string)   |   ansible_machine == "x86_64"
Equal (value is numeric)    |   max_memory == 512
Numeric comparessions       |   < | > | <= | => | !=
Variable exists             |   min_memory is defined
Variable does not exist     |   min_memory is not defined
Boolean variable is true    |   memory_available
Boolean variable is false   |   not memory_available
element in linst            |	ansible_distribution in supported_distros

# OR
- when: ansible_distribution == "RedHat" or ansible_distribution == "Fedora"

# And
- when: ansible_distribution_version == "7.5" and ansible_kernel == "3.10.0-327.el7.x86_64"

- when:
  - ansible_distribution_version == "7.5"
  - ansible_kernel == "3.10.0-327.el7.x86_64"

# Combine conditions
- when: >
    ( ansible_distribution == "RedHat" and
      ansible_distribution_major_version == "7" )
    or
    ( ansible_distribution == "Fedora" and
    ansible_distribution_major_version == "28" )

Ansible Handlers

Playbooks have a basic event system that can be used to respond to changes made by tasks in the playbook. These ‘events’ are used to notify the playbook on certain actions and they are triggered by the notify keyword. The events are triggered at the end of each block of tasks in a play, and will only be triggered once even if notified by multiple different tasks.

Each event or ‘notify‘ statment can have a handler to handle the event. Handlers are tasks that respond to a notification triggered by other tasks. Handlers can be considered as inactive tasks that only get triggered when explicitly invoked using a notify statement.

Example: Apache server is only restarted by the restart apache handler when a configuration file is updated and notifies it:

tasks:
  - name: copy demo.example.conf configuration template
    template:
      src: /var/lib/templates/demo.example.conf.template
      dest: /etc/httpd/conf.d/demo.example.conf
    notify:
      - restart apache
handlers:
  - name: restart apache
    service:
      name: httpd
      state: restarted

Some notes to keep in mind:

  • Tasks only notify their handlers when the task changes something on a managed host.
  • Use unique handler names. If you trigger more than one handler with the same name, the first one(s) get overwritten. Only the last one defined will run.
  • Each handler is triggered at the end of a block of tasks in a playbook.
  • If more than one task notifies a handler, the handler only runs once after all other tasks in the block have completed. If no tasks notify it, a handler will not run.
  • A task may call more than one handler in its notify section.
  • Handlers always run in the order specified by the handlers section of the play. They do not run in the order in which they are listed by notify statements in a task, or in the order in which tasks notify them.
  • Handlers normally run after all other tasks in the play complete. A handler called by a task in the tasks part of the playbook will not run until all tasks under tasks have been processed. (There are some minor exceptions to this.)

IMPORTANT Handlers are meant to perform an extra action when a task makes a change to a managed host. They should not be used as a replacement for normal tasks.

Handling Errors on a Play

Ansible evaluates the return code of each task to determine whether the task succeeded or failed. Normally, when a task fails Ansible immediately aborts the rest of the play on that host, skipping all subsequent tasks. However, sometimes you might want to have play execution continue even if a task, there are a number of approaches to this, depending on the desired outcome:

  • Ignoring Task Failure: We can ignore failed tasks with the ignore_errors keyword.

    name: Latest version of notapkg is installed
    yum:
        name: notapkg
        state: latest
    ignore_errors: yes
    
  • Forcing Execution of Handlers after Task Failure:

    Normally when a task fails and the play aborts on that host, any handlers that had been notified by earlier tasks in the play will not run. If you set force_handlers: yes on the play, then notified handlers are called even if the play aborted because a later task failed.

    hosts: all
    force_handlers: yes
    tasks:
        - name: a task which always notifies its handler
        command: /bin/true
        notify: restart the database
        - name: a task which fails because the package doesn't exist
        yum:
            name: notapkg
            state: latest
    handlers:
        - name: restart the database
        service:
            name: mariadb
            state: restarted
    
  • Specifying Task Failure Conditions:

    You can use the failed_when keyword on a task to specify which conditions indicate that the task has failed. This is often used with command modules that may successfully execute a command, but the command’s output indicates a failure.

    tasks:
    - name: Run user creation script
        shell: /usr/local/bin/create_users.sh
        register: command_result
        failed_when: "'Password missing' in command_result.stdout"
    

    The fail module can also be used to force a task failure. The above scenario can alternatively be written as two tasks:

    tasks:
    - name: Run user creation script
        shell: /usr/local/bin/create_users.sh
        register: command_result
        ignore_errors: yes
    
    - name: Report script failure
        fail:
        msg: "The password is missing in the output"
        when: "'Password missing' in command_result.stdout"
    

    You can use the fail module to provide a clear failure message for the task. This approach also enables delayed failure, allowing you to run intermediate tasks to complete or roll back other changes.

  • Specifying When a Task Reports “Changed” Results:

    The changed_when keyword can be used to control when a task reports that it has changed.

    - name: get Kerberos credentials as "admin"
        shell: echo "{{ krb_admin_pass }}" | kinit -f admin
        changed_when: false
    # changed_when: false  == only reports ok or failed.
    
    tasks:
    - shell:
        cmd: /usr/local/bin/upgrade-database
        register: command_result
        changed_when: "'Success' in command_result.stdout"
        notify:
        - restart_database
    
    handlers:
    - name: restart_database
        service:
        name: mariadb
        state: restarted
    
  • Ansible Blocks and Error Handling:

    In playbooks, blocks can be used to control how tasks are executed. For example, a task block can have a when keyword to apply a conditional to multiple tasks:

    - name: block example
    hosts: all
    tasks:
        - name: installing and configuring Yum versionlock plugin 
        block:
        - name: package needed by yum
            yum:
            name: yum-plugin-versionlock
            state: present
        - name: lock version of tzdata
            lineinfile:
            dest: /etc/yum/pluginconf.d/versionlock.list
            line: tzdata-2016j-1
            state: present
        when: ansible_distribution == "RedHat"
    

    Blocks also allow for error handling in combination with the rescue and always statements. If any task in a block fails, tasks in its rescue block are executed in order to recover.

    • block: Defines the main tasks to run.
    • rescue: Defines the tasks to run if the tasks defined in the block clause fail.
    • always: Defines the tasks that will always run independently of the success or failure of tasks defined in the block and rescue clauses.
    tasks:
        - name: Upgrade DB
        block:
            - name: upgrade the database
            shell:
                cmd: /usr/local/lib/upgrade-database
        rescue:
            - name: revert the database upgrade
            shell:
                cmd: /usr/local/lib/revert-database
        always:
            - name: always restart the database
            service:
                name: mariadb
                state: restarted
    

    Note that a condition on a block also applies to its rescue and always if present.