César D. Velandia

Ansible


Getting started

Ansible is an agentless automation tool that simplifies configuration management, application deployment, and task automation. Here's how to get started:

Use inventory and the target "example" to ping as root

[example]
ansible101.xyz

ansible -i inventory example -m ping -u root

Use ansible.cfg to set a default inventory

[defaults]
INVENTORY = inventory

Log-in as root and check date, memory usage, and ping

ansible example -a "date" -u root
ansible example -a "free -h" -u root
ansible example -m ping -u root

Provisioning

Using Vagrant, start with a base image

vagrant init geerlingguy/centos

edit the Vagrantfile

# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure("2") do |config|
  config.vm.box = "geerlingguy/centos7"
  config.vm.provision "ansible" do |ansible|
    ansible.playbook = "playbook.yml"
  end
end

Create a playbook.yml

---
- name: Set up NTP on all servers.
  hosts: all
  become: yes
  tasks:
    - name: Ensue NTP is installed.
      yum: name=ntp state=present
    # - shell: |
    #     if ! rpm -qa | grep -qw ntp; then
    #       yum install -y ntp
    #     fi    
    - name: Ensure NTP is running.
      service: name=ntpd state=started enabled=yes  

Run the playbook on the vagrant VM using vagrant provision

Other useful vagrant commands

vagrant ssh
vagrant ssh-config
vagrant status

Clean up

vagrant halt
vagrant destroy

Ad-hoc commands

Ad-hoc commands are perfect for quick tasks you need to execute across multiple servers. They follow the pattern:

ansible [pattern] -m [module] -a "[module options]"

Create 2 instance of app, 1 db with Vagrant

# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure("2") do |config|
 config.vm.box = "geerlingguy/centos7"

 config.ssh.insert_key = false
 
 config.vm.synced_folder ".", "/vagrant", disabled: true
 
 config.vm.provider :virtualbox do |v|
	v.memory = 256
	v.linked_clone = true
 end

 # App server 1
 config.vm.define "app1" do |app|
  app.vm.hostname = "orc-app1.test"
  app.vm.network :private_network, ip: "192.168.60.4"
 end

 # App server 2
 config.vm.define "app2" do |app|
  app.vm.hostname = "orc-app2.test"
  app.vm.network :private_network, ip: "192.168.60.5"
 end

 # App server 3
 config.vm.define "db" do |db|
  db.vm.hostname = "orc-db.test"
  db.vm.network :private_network, ip: "192.168.60.6"
 end
end

Note: vagrant plugin install vagrant-hostsupdater required

vagrant up

a typical inventory file

#Application servers
[app]
192.168.60.4
192.168.60.5

#Database server
[db]
192.168.60.6

#Group has all the servers
[multi:children]
app
db

# Variables for all the servers
[multi:vars]
ansible_ssh_user=vagrant
ansible_ssh_private_key_file=~/.vagrant.d/insecure_private_key

Check inventory and ssh ok

vagrant ssh app1
...
ansible-inventory -i inventory --all
sudo ansible -i inventory all --list-hosts

run ad-hoc tasks on inventory groups or individual servers

# hostnames for all servers
ansible multi -i inventory -a "hostname"

# Check full config for db server
ansible -i inventory db -m setup

Checking facts

ansible multi -i inventory -a "df -h"
ansible multi -i inventory -a "date"
ansible multi -i inventory -a "free -h"

Use become flag to install (as sudo)

## become superuse to install ntp
ansible -i inventory multi --become -m yum -a "name=ntp state=present"
ansible -i inventory multi -b -m yum -a "name=ntp state=present"

## passwords
ansible -i inventory multi -K -m yum -a "name=ntp state=present"

other ad-hoc tasks

## ansible inventory enable service ntpd
ansible -i inventory multi -b -m service -a "name=ntpd state=started enabled=yes"

##stop
ansible -i inventory multi -b -a "service ntpd stop"

## change date
ansible -i inventory multi -b -a "ntpdate -q 0.rhel.pool.ntp.org"

## start
ansible -i inventory multi -b -a "service ntpd start"

## memory from db 
ansible -i inventory db -b -a "free -m"

## check using mysql_user module in the db server to setup a user
ansible -i inventory db -b -m mysql_user -a "name=django host=% password=12345 priv=*.*:ALL state=present"

## limit to a single server (or ending in .4)
ansible -i inventory app -a "free -m" --limit "192.168.60.4"
ansible -i inventory app -a "free -m" --limit "*.4"

Use module whenever possible install of action -a, such as service. Check docs via command line
ansible-doc service

Other useful commands

# run comment date on inventory in parallel (default)
ansible -i inventory multi -a "date"
ansible -i inventory multi -m command -a "date"
 
# command date but sequential (slower)
ansible -i inventory multi -a "date" -f 1

# run yum update in bg with an id to check progress and exit
ansible -i inventory multi -b -B 3600 -P 0 -a "yum -y update"

# check the status using the id from prev step
ansible -i inventory multi -b -m async_status -a "jid=xxxxxxxxx.yyyy"

# get logs for all servers
ansible multi -b -m shell -a "tail /var/log/messages | grep ansible-command | wc-l"

#cron
##ansible -i inventory multi -b -m cron -a "name=smt hour=2 job=/scr.sh"

# git to update repo
ansible -i inventory multi - b -m git -a "repo-gith.ur dest=/opt update=yes version=1.2.4"

Best Practices for Ad-hoc Commands

  • Use modules instead of raw commands whenever possible
  • Leverage the --limit parameter for targeting specific hosts
  • Consider using -B (background) for long-running operations
  • Always test commands with --check mode first

Playbooks

Playbooks are Ansible's configuration, deployment, and orchestration language. They can describe a policy you want your remote systems to enforce, or a set of steps in a general IT process.

From a shell script like

# install apache
yum instal --quiet -y httpd httpd-devel
# copy configuration file
cp httpd.cond /etc/httpd/conf/httpd.conf
cp httpd-vhosts /etc/httpd/conf/httpd-vhosts.conf
# Start Apache and configure it to run at boot.
service httpd start
chkconfig httpd on

to a basic playbook like (simply add names to each instruction or use shell: | )

---
- name: Install Apache
  hosts: all

  tasks:
    - name: Install Apache.
      command: yum install --quiet -y httpd httpd-devel

    - name: Copy configuration files.
     command: >
      cp httpd.conf /etc/httpd/httpd.conf
    - command: >
      cp httpd-vhosts /etc/httpd/conf/httpd-vhosts

    - name: Start Apache and configure it to run at boot.
      command: service httpd start
    - command: chkconfig httpd on

or even better

---
- name: Install Apache
  hosts: all

  tasks:
    - name: Install Apache.
      yum:
        name: 
          - httpd
          - httpd-devel
        state: present
      # yum: name=httpd state=present

    - name: Copy configuration files.
      copy: 
        src: "{{ item.src }}"
        destination: "{{ item.dest }}"
        owner: root
        group: root
        mode: 0644
      with_items:
        - src: httpd.conf
          dest: /etc/httpd/httpd.conf
        - src: httpd-vhosts
          dest: /etc/httpd/conf/httpd-vhosts

    - name: Ensure Apache started and boot
      service:
        name: httpd
        state: started
        enabled: true

Import and include

A simple way to organize task using  import_tasks or include_tasks

handlers:
	- import_tasks: handlers/apache.yml
	- import_tasks: handlers/app.yml
#...
tasks:
	- import_tasks: tasks/apache.yml
      vars:
      	apache_package: apache2
     	# override var for specific task
	- import_tasks: tasks/app.yml
    - include_tasks: tasks/dynamically_defined.yml
---
- name: dynamically defined task X
  find:
    paths: {{ item }}
    patterns: '*.log'
  register: found_log_file_paths
  with_items: "{{ log_file_paths }}"

Or import a complete playbook

#...
	- import_playbook: api.yml

A better way to organize is using Roles...

Real-world playbook

Installing solr on Ubuntu instance

#inventory
[solr]
192.168.0.20 ansible_user=root
#vars.yml
---
download_dir: /tmp
solr_dir: /opt/solr
solr_version: 8.2.1
solr_checksum: sha512:b372f44baeafa12ec9c06239080e04c75328c37c92e5d27e6f819b139a69eacbbb879d73f032b20db66d33ee33efb41283648727a169ce4eds67095b780a5d0c
#main.yml
---
- hosts: solr
  become: true

  vars_files:
    - vars.yml

  pre_tasks:
    - name: Update apt cache if needed.
      apt:
        update_cache: true
        cache_valid_time: 3600

  handlers:
    - name: Restart solr.
      service: 
        name: solr
        state: restarted

  tasks:
    - name: Install Java.
      apt:
        name: openjdk-8-jdk
        state: present

    - name: Install solr.
      get_url:
        url: "https://mirrors.ocf.berkeley.edu/apache/lucene/solr/{{ solr_version }}/solr-{{ solr_version }}.tgz"
        dest: "{{ download_dir }}/solr-{{solr_version}}.tgz"
        checksum: "{{ solr_checksum }}"

    # unarchive and expand in one step not possible with solr
    - name: Expand solr.
      unarchive:
        src: "{{ download_dir }}/solr-{{solr_version}}.tgz"
        dest: "{{ download_dir }}"
        remote_src: true
        creates: "{{ download_dir }}/solr-{{solr_version}}/README.txt"
    
    # check solr installation docs for options
    - name: Run solr installation script.
      command: >
        {{ download_dir }}/solr-{{solr_version}}/bin/install_solr_service.sh
        {{ download_dir }}/solr-{{solr_version}}.tgz
        -i /opt
        -d /var/solr
        -u solr
        -s solr
        -p 8983
        creates={{ solr_dir }}/bin/solr

    - name: Ensure solr is started and enabled at boot.
      service:
        name: solr
        state: started
        enabled: yes

Check syntax and deploy to inventory named solr

ansible-playbook  main.yml --syntax-check

ansible-playbook -i inventory main.yml

This playbook demonstrates several important concepts:

  • Pre-tasks for system preparation
  • Handlers for service management
  • Checksum verification for security
  • Idempotent operations
  • Service management

Playbook Best Practices

  1. Keep playbooks version controlled
  2. Use meaningful names for tasks
  3. Separate variables into vars files
  4. Use handlers for service restarts
  5. Implement proper error handling
  6. Use tags for selective execution

Handlers

just like another task but...

  • will be called at the end of a completed block using notify
  • will be triggered only once even if called multiple times
  • are usually called to restart services after config or installation changes
  • won't flush at the end (as usual) if a task fails unless the flag --force-handlers is used
  • can be called by other handlers too
---
- name: Install Apache.
  hosts: centos
  become: true

  handlers:
    - name: restart apache
      service:
        name: httpd
        state: restarted
      notify: restart memcached

  ## just like tasks can be called upon by other handlers
    - name: restart memcached
      service:
        name: memcached
        state: restarted

  tasks:
    - name: Ensure Apache is installed
      yum:
        name: httpd
        state: present

    - name: Copy test config file.
      copy:
        src: files/test.conf
        dest: /etc/httpd/conf.d/test.conf
      notify:
        - restart apache
        # - restart memcached

    # as opposed to flush them at the end of the playbook
    - name: Make sure handlers are flush right away
      meta: flush_handlers

    - name: Ensure Apache is running and starts at boot.
      service:
          name: httpd
          state: started
          enabled: true

Use flush_handlers to execute early, otherwise handlers flush at the very end if no fail

Pro tip: User the task fail (no parameters) to trigger a failure and test handlers

- fail:

Environment variables

Download a file using a proxy, two ways

#...    
  ## use block `vars` applied where injected only
  vars:
    proxy_vars:
      http_proxy: http://example-proxy:80/
      https_proxy: https://example-proxy:80/

#....handlers
#....tasks
	# define per task via environment
    - name: Download a 20MB file
      get_url:
        url: http://ipv4.download.thinkbroadband.com/20MB.zip
        dest: /tmp
      environment:
        http_proxy: http://example-proxy:80/
        https_proxy: https://example-proxy:80/

	# inject from vars via environment
    - name: Download a 10MB file
      get_url:
        url: http://ipv4.download.thinkbroadband.com/10MB.zip
        dest: /tmp
        environment: proxy_vars

Or make the variables available to all task using environment or import from files

  ## apply to entire playbook, no vars needed
  environment:
       http_proxy: http://example-proxy:80/
       https_proxy: https://example-proxy:80/
vars_files:
    - "vars/apache_default.yml"
    - "vars/apache_{{ ansible_os_family }}.yml"

Another way to load variables using the pre_tasks block

  pre_tasks:
    - name: Load variable files.
      include_vars: "{{ item }}"
      with_first_found:
        - "vars/apache_{{ ansible_os_family }}.yml"
		- "vars/apache_default.yml"

use to load first match, otherwise used default, check another example here.

In this example, choose the Apache variable file based on the value for ansible_os_family obtained from ansible gather facts pre task

---
- name: Install Apache.
  hosts: all
  # ubuntu OR centos
  become: true
  
  vars_files:
    - "vars/apache_default.yml"
    - "vars/apache_{{ ansible_os_family }}.yml"

apache_default.yml

---
apache_package: apache2
apache_service: apache2
apache_config_dir: /etc/apache2/sites-enabled

apache_RedHat.yml

---
apache_package: httpd
apache_service: httpd
apache_config_dir: /etc/httpd/conf.d

Add environment to remote host

  tasks:
    - name: Add an environment variable to the shell
      lineinfile:
      	# dest: "~/.bash_profile"
        dest: "/etc/environment"
        regexp: '^ENV_VAR='
        line: 'ENV_VAR=value'
      become: false

    - name: Get the value of an env value
      shell: 'source ~/.bash_profile && echo $ENV_VAR'
      register: foo

    - debug: msg="The variable is {{ foo.stdout }}"

working playbook: load variables (from files) based on OS, verify installation, copies config file, verify service is running, and restarts service (via handler)

---
- name: Install Apache.
  hosts: all
  become: true

  handlers:
    - name: restart apache
      service:
        name: "{{ apache_service }}"
        state: restarted

  pre_tasks:
    - debug: var=ansible_os_family
    - name: Load variable files.
      include_vars: "{{ item }}"
      with_first_found:
        - "vars/apache_{{ ansible_os_family }}.yml"
        - "vars/apache_default.yml"


  tasks:
    - name: Download a file
      get_url:
        url: http://ipv4.download.thinkbroadband.com/20MB.zip
        dest: /tmp

    - name: Ensure Apache is installed
      package:
        name: "{{ apache_package }}"
        state: present

    - name: Copy test config file.
      copy:
        src: files/test.conf
        dest: "{{ apache_config_dir }}/test.conf"
      notify:
        - restart apache

    - name: Ensure Apache is running and starts at boot.
      service:
          name: "{{ apache_service }}"
          state: started
          enabled: true

Vault

encrypt and decrypt sensitive info, like passwords, keys, users

# encrypt your keys file using AES256
ansible-vault  encrypt vars/api_key.yml

# run playbook that uses encrypted file
ansible-playbook main.yml --ask-vault-pass
ansible-playbok main.yml --vault-password-file ~/.ansible/api-pwd.txt

# decrypt when neeeded (unadvisable)
ansible-vault  decrypt vars/api_key.yml

# edit file and edit inline without decrypting it
ansible-vault edit vars/api_key.yml

A playbook using encrypted API key.

---
- hosts: localhost
  connection: local
  gather_facts: no

  vars_files:
  - vars/api_key.yml

  tasks:
    - name: Echo the API key which was injected into the env.
      shell: echo $API_KEY
      environment:
        API_KEY: "{{ myapp_api_key }}"
      register: echo_result

    - name: Show the echo_result
      debug: var=echo_result.stdout

Vault Best Practices

  • Never store vault passwords in version control
  • Rotate vault passwords regularly
  • Use different vault passwords for different environments
  • Consider using a secrets management service for production

Roles

Each role needs a meta and tasks folder. The meta folder contains role dependencies for this role (must run first) – helps compartmentalize variables and other role specific variables and files

.
├── ansible.cfg
├── inventory
├── main.yml
└── roles
    └── nodejs
        ├── defaults
        ├── files
        ├── handlers
        ├── meta
        │   └── main.yml
        ├── tasks
        │   └── main.yml
        ├── templates
        ├── tests
        └── vars
# /main.yml
---
- hosts: all
  become: yes
 
  vars:
    locations: /usr/local
  
  pre_tasks: []
  
  roles:
    - nodejs
 
  tasks:
    - name: #...
    
    - include_role: nodejs
# /meta/main.yml
---
dependencies: []

Testing

The Ansible testing spectrum:


        +  1. yamlint
        |
        |    2. ansible-playbook --syntax-check
        |
        |      3. ansible-lint
        |
        |         4. molecule test (integration)
        |
        |           5. ansible-playbook --check (against prod)
        |
        v               6. Parallel infrastructure


https://docs.ansible.com/ansible/latest/user_guide/playbooks_tests.html

Comprehensive Testing Strategy

YAML Lint: Syntax validation

yamllint playbook.yml

Syntax Check: Basic Ansible syntax

ansible-playbook --syntax-check playbook.yml

Ansible Lint: Best practices and style

ansible-lint playbook.yml

Molecule: For role testing

molecule init role my-role
molecule test

Check Mode: Dry run against production

ansible-playbook -C playbook.yml

debug

use register to capture status codes/outputs and use debug: var to print them to console:

---
- hosts: servers

  tasks:
    - name: Register the value of 'uptime'.
      command: uptime
      register: system_uptime

    - name: Print the value to console.
      debug:
        var: system_uptime.stdout

    - name: If has value changed, notify.
      debug:
        msg: "Command resulted in a change!"
      when: system_uptime is changed

Download the roles to be used locally (at the project level) to avoid version mismatches between projects, this will create a roles

# ansible.cfg
[defaults]
nocows = True
roles_path = ./roles

Use a requirements.yml file to describe roles and versions, and install locally via

ansible-galaxy install -r requirements.yml
# requirements.yml
---
roles:
  - name: owner1.role1
    version: x.y.z
  - name: owner2.role2
    version: a.b.c

Finally, add the roles in your main.yml playbook with privileges if needed (become: yes)

fail/assert

Use variables (with debug?) to create flags and use fail & assert such as:

  tasks:
    - name: Fail or fail.
      fail:
        msg: "Always fails."
      when: true

    - name: Check for something and fails.
      assert:
        that: will_fail != false

    - name: Assertions can have contain conditions.
      assert:
        that:
          - check_one
          - check_two
          - check_three == false

yamllint & --syntax-check

Install via pip3 install yamllint and run in your directory with yamllint . Override defaults by adding a .yamllint file

---
extends: default

rules:
  truthy:
    allowed-values:
      - 'true'
      - 'false'
      - 'yes'
      - 'no'

ansible-playbook main.yml --syntax-check will run static check, more details here.

Note: When referencing files in a playbook, prefer import_tasks as can be checked statically (file exists), whereas include_tasks not.

Molecule Testing Framework

Molecule provides a testing framework for Ansible roles, including:

  • Role initialization
  • Multiple driver support (Docker, Vagrant, etc.)
  • Test scenario management
  • Integration with verification tools

ansible-lint

Ansible-lint is a command-line tool for linting playbooks, roles and collections. It checks for best practices, common mistakes and potential improvements in your Ansible code.

Installation

pip install ansible-lint

Basic Usage

# Lint a playbook
ansible-lint playbook.yml

# Lint multiple playbooks
ansible-lint site.yml webserver.yml

# Lint an entire project
ansible-lint

Common Rules and Fixes

# Bad: Commands that change things should be idempotent
- name: Install package
  command: apt install nginx  # Will trigger warning

# Good: Use package module instead
- name: Install package
  package:
    name: nginx
    state: present

# Bad: Using bare variables
- name: Deploy {{file}}  # Will trigger warning

# Good: Use quotes
- name: Deploy "{{ file }}"

# Bad: Using jinja templating in when
- name: Task
  debug: msg=foo
  when: bar == "{{foo}}"  # Will trigger warning

# Good: Proper when syntax
- name: Task
  debug: msg=foo
  when: bar == foo

Configuration

Create .ansible-lint in your project root to customize rules:

# .ansible-lint
exclude_paths:
  - .cache/  # Exclude specific paths
  - .github/
  - molecule/

skip_list:  # Rules to skip
  - name[missing]  # Allow tasks without names
  - no-handler  # Allow changes without handlers

warn_list:  # Rules that only warn
  - command-instead-of-module
  - git-latest

tags:
  - formatting
  - safety

Common Rules Reference

  • command-instead-of-module: Use Ansible modules instead of raw commands
  • name[missing]: All tasks should have names
  • no-handler: Changes should notify handlers
  • package-latest: Avoid using latest for package state
  • yaml[line-length]: Line length should be limited
  • yaml[truthy]: Use true/false instead of yes/no
  • jinja[spacing]: Maintain consistent Jinja2 spacing

Integration with CI/CD

Example GitHub Actions workflow:

name: Ansible Lint
on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Run ansible-lint
        uses: ansible/ansible-lint-action@main
        with:
          targets: |
            playbook.yml
            roles/

Common Command Options

# Show rule codes in output
ansible-lint -p

# Only run specific tags
ansible-lint --tags idempotency,safety

# Exclude specific tags
ansible-lint --skip-tags formatting

# Generate JSON report
ansible-lint -f json

# Fix some violations automatically
ansible-lint --write

# Show details about rules
ansible-lint -L

# Increase verbosity
ansible-lint -v

Best Practices

  1. Run Early and Often: Integrate linting into your development workflow
  2. Custom Rules: Create custom rules for organization-specific standards
  3. CI Integration: Add linting to your CI pipeline
  4. Version Control: Keep .ansible-lint in version control
  5. Documentation: Document any rule exclusions or customizations
  6. Regular Updates: Keep ansible-lint updated for new rules and fixes

This tool is essential for maintaining consistent code quality across Ansible projects and teams.

Conclusion

Ansible provides a powerful platform for automation with:

  • Agentless architecture
  • YAML-based playbooks
  • Extensive module library
  • Strong community support
  • Enterprise-ready features

For more information, visit the official Ansible documentation.