César D. Velandia

Ansible


Getting 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

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"

Playbooks

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

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

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

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.

ansible-lint

molecule