Ansible : the basics - Simple IT Automation

mail

Ansible glossary

play
block of code associating tasks to one or more hosts. Example :
- hosts: 127.0.0.1
  connection: local
  gather_facts: no
  tasks:

  - debug:
      msg: "hello, world"

  - debug:
      msg: "farewell, world"
playbook
group of one or more plays
mail

Ansible playbooks

Definitions :

As seen in the Introduction to Ansible article, Ansible can be used to perform ad-hoc tasks. But it can also execute procedures called playbooks (examples).

Playbooks :
  • are written in YAML.
  • are composed of one or more plays. A play is a list of hosts with associated roles and tasks. A task is, basically speaking, calling an Ansible module, as seen in the Introduction to Ansible article.

Important things about playbooks (source) :

  • When running the playbook, which runs top to bottom, hosts with failed tasks are taken out of the rotation for the entire playbook. If things fail, simply correct the playbook file and rerun.
  • Modules (hence playbooks) are idempotent : if you run them again, they will make only the changes they must in order to bring the system to the desired state. Ansible relies on facts before taking any action; and playbooks must be designed :
    • NOT as a list of actions to do
    • but as the description of a desired state
    For example, if you instruct Ansible to install a package, it will first detect whether this package is already installed or not (during the facts gathering preliminary step), then act accordingly. This makes it very safe to rerun the same playbook multiple times : it won’t change things unless it has to do so.
    except for shell and command modules, where idempotence cannot be guaranteed automatically (details).
  • Each task has a name parameter which is included in the output from running the playbook. This is for humans only and should be as descriptive as possible.
  • It is usually wise to track playbooks in SCM tools such as Git.

My first playbook :

  1. Save this as playbook.yml :
    ---
    # this is my 1st playbook
    
    - hosts: groupSlaves
      tasks:
      - name: test connection
        ping:
    groupSlaves is the group name defined in the inventory file.
  2. Launch it : ansible-playbook playbook.yml
  3. It may return :
    PLAY [groupSlaves] *****************************************************************
    
    GATHERING FACTS ***************************************************************
    ok: [192.168.105.80]
    ok: [192.168.105.114]
    
    TASK: [test connection] *******************************************************
    ok: [192.168.105.80]
    ok: [192.168.105.114]
    
    PLAY RECAP ********************************************************************
    192.168.105.114		: ok=2	changed=0	unreachable=0	failed=0
    192.168.105.80		: ok=2	changed=0	unreachable=0	failed=0
    

A full playbook :

To know what's performed by this playbook, just read the name lines.
---
# playbook_web.yml

- hosts: myGroup1:myGroup2

  vars:
    apacheUser:        'www-data'
    apacheGroup:       'www-data'
    documentRoot:      '/var/www/test/' # final '/' expected
    websiteLocalPath:  '/root/'
    websiteConfigFile: 'test.conf'

  tasks:
  - name: install Apache
    apt: name=apache2 state=present

  - name: disable default Apache website
    shell: a2dissite default

  - name: define Apache FQDN
    shell: echo "ServerName localhost" > /etc/apache2/conf.d/fqdn

  - name: create docRoot
    file: state=directory path={{ documentRoot }} owner={{ apacheUser }} group={{ apacheGroup }}

  - name: deploy website
    copy: src={{ websiteLocalPath }}index.html dest={{ documentRoot }} owner={{ apacheUser }} group={{ apacheGroup }}

  - name: deploy website conf
    copy: src={{ websiteLocalPath }}{{ websiteConfigFile }} dest=/etc/apache2/sites-available/

  - name: enable website
    shell: a2ensite {{ websiteConfigFile }}

  - name: reload Apache
    service: name=apache2 state=reloaded enabled=yes

- hosts: 127.0.0.1
  connection: local
  tasks:
  - name: check everything is ok on webserver 'myGroup1'
    shell: wget -S -O - -Y off http://192.168.105.114/index.html

  - name: check everything is ok on webserver 'myGroup2'
    shell: wget -S -O - -Y off http://192.168.105.80/index.html

How to run actions on the master in a playbook (source) :

Let's say you just deployed a new web server + a web application. Wouldn't it be great if you could run some checks at the end of the playbook, just to make sure everything's responding as expected ? To do so, you'd have to run some commands from the master host : use this code as the last play of your playbook :

- hosts: 127.0.0.1
  connection: local
  tasks:
  - name: make sure blah is blah.
    shell: 'myCheckCommand'

If myCheckCommand returns a Unix success :

Testing with myCheckCommand being true, execution of this specific play returns :

PLAY [127.0.0.1] **************************************************************

GATHERING FACTS ****************************************************************
ok: [127.0.0.1]

TASK: [make sure blah is blah.] ***********************************************
changed: [127.0.0.1]

PLAY RECAP *********************************************************************
127.0.0.1	: ok=2	changed=1	unreachable=0	failed=0

If myCheckCommand returns a Unix failure :

Now with false, output becomes :

PLAY [127.0.0.1] **************************************************************

GATHERING FACTS ****************************************************************
ok: [127.0.0.1]

TASK: [make sure blah is blah.] ***********************************************
failed: [127.0.0.1] => {"changed": true, "cmd": "false", "delta": "0:00:00.015746",
	"end": "2014-10-16 15:11:49.153606", "rc": 1, "start": "2014-10-16 15:11:49.137860"}

FATAL: all hosts have already failed -- aborting

PLAY RECAP *********************************************************************
	to retry, use: --limit @/root/fileNameOfMyPlaybook.retry

127.0.0.1	: ok=1	changed=0	unreachable=0	failed=1

Playbooks with roles (source, role file tree) :

Initial Ansible syntax (early versions) :

- hosts: webservers
  roles:
    - role_X
    - role_Y
These are processed as static imports.

Updated syntax (for Ansible 2.4+) :

- hosts: webservers
  tasks:
    - import_role:
        name: role_X
    - include_role:
        name: role_Y
You may choose between import_role and include_role considering the static or dynamic import that will be performed.

Old vs new syntax :

"Old" syntax (compact mode) :
- hosts: webservers
  roles:
  - { role: role_X, myVariable: "42", tags: "tag1, tag2" }
"Old" syntax (verbose mode) :
- hosts: webservers
  roles:
    - role: role_X
      vars:
        myVariable: "42"
      tags:
        - tag1
        - tag2
"New" syntax :
- hosts: webservers
  tasks:
    - import_role:
        name: role_X
      vars:
        myVariable: "42"
      tags:
        - tag1
        - tag2
mail

The inventory file

The inventory file :

Default groups (source)

  • The implicit group all includes all slaves.
  • There is also another group named ungrouped. The logic behind Ansible is that all slaves must belong to at least 2 groups : all and "an other one". If there is no such "other one", ungrouped will be that one.
Both groups will always exist and don't need to be explicitly declared.
mail

Introduction to Ansible

Usage :

Ansible is installed on a master host to rule them all. There's nothing to install on slaves (except SSH keys ).

Setup Ansible master on a Debian Buster (inspired by) :

  1. as root :
    apt install python3-pip
  2. as a non-root user : setup + activate a Python virtual environment
  3. still as a non-root user, and from within the virtual environment (if present) :
    pip3 install -U ansible

Setup SSH on the master (source) :

  1. Create a new key : ssh-keygen -t rsa will generate the 2048-bit /root/.ssh/id_rsa RSA private key.
  2. Deploy it to the slave(s)
  3. Configure SSH accordingly (/root/.ssh/config) :
    Host slave1
    	hostname	192.168.105.114
    	user		root
    	IdentityFile	~/.ssh/id_rsa
    
    Host slave2
    	hostname	192.168.105.80
    	user		root
    	IdentityFile	~/.ssh/id_rsa
  4. List slave(s) into the inventory file :
    192.168.105.114	# slave1
    192.168.105.80	# slave2
  5. Check communication between master and slave(s) :
    ansible all -m ping -u root
    192.168.105.114 | success >> {
    	"changed": false,
    	"ping": "pong"
    }
    
    192.168.105.80 | success >> {
    	"changed": false,
    	"ping": "pong"
    }
  6. It works !!!
  7. Define groups of hosts in the inventory file

Flags :

CLI flags are common to several Ansible commands / tools. See this dedicated article.

Example :

Get information about slaves (source) :

ansible all -m setup
This will output a VERY long list of inventory information (aka facts) about the target(s). To get detailed information on a specific topic, you can apply a filter :
ansible myGroup2 -m setup -a 'filter=ansible_processor*'
192.168.105.80 | success >> {
	"ansible_facts": {
		"ansible_processor": [
			"Intel(R) Core(TM)2 Duo CPU	 E8400 @ 3.00GHz"
		],
		"ansible_processor_cores": 1,
		"ansible_processor_count": 1,
		"ansible_processor_threads_per_core": 1,
		"ansible_processor_vcpus": 1
	},
	"changed": false
}

Run shell commands on slaves (source) :

ansible all -a "hostname"
run a basic command on all slaves :
192.168.105.114 | success | rc=0 >>
ansibleSlave
This is for basic commands (single binary, no options).
ansible myGroup1 -a "echo $(hostname)"
This is executed on the master because double quotes are interpreted locally :
192.168.105.114 | success | rc=0 >>
ansibleMaster
ansible myGroup1 -a 'echo $(hostname)'
This is sent to the right slave but not executed, because shell/subshell commands are not interpreted :
192.168.105.114 | success | rc=0 >>
$(hostname)
ansible myGroup1 -m shell -a 'echo $(hostname)'
Thanks to the shell module, this command is executed as expected :
192.168.105.114 | success | rc=0 >>
ansibleSlave
ansible all -m shell -a 'echo $(hostname) | grep -e "[a-z]"'
It's possible to run "complex" shell commands now :
192.168.105.80 | success | rc=0 >>
ansibleSlave2

192.168.105.114 | success | rc=0 >>
ansibleSlave

File transfer (source) :

Ansible can scp files from the master to its slaves :

ansible all -m copy -a "src=/home/test.txt dest=/home/"

It's possible to rename the file during the copy by specifying a different destination name : ... "src=/home/test.txt dest=/home/otherName"

Manage packages (source) :

Ansible can query its slaves about software using some dedicated packages :

  • apt for Debianoids. This module is part of the default install.
  • yum for Red Hatoids.

Possible values : installed, latest, removed, absent, present.

Make sure the package openssh-server is installed :
ansible all -m apt -a "name=openssh-server state=installed"
192.168.105.114 | success >> {
	"changed": false
}

192.168.105.80 | success >> {
	"changed": false
}
If the specified package was not already installed, this will install it. The FULL command output (install procedure) will be reported by Ansible.
Make sure the package apache2 is absent :
ansible all -m apt -a "name=apache2 state=absent"
192.168.105.80 | success >> {
	"changed": false
}

192.168.105.114 | success >> {
	"changed": false
}

Users and groups (source, user module) :

Create a user account for Bob :

ansible myGroup1 -m user -a "name=bob state=present"
192.168.105.114 | success >> {
	"changed": true,
	"comment": "",
	"createhome": true,
	"group": 1001,
	"home": "/home/bob",
	"name": "bob",
	"shell": "/bin/sh",
	"state": "present",
	"system": false,
	"uid": 1001
}
And if I run the same command again, whereas Bob's account already exists :
192.168.105.114 | success >> {
	"append": false,
	"changed": false,
	"comment": "",
	"group": 1001,
	"home": "/home/bob",
	"move_home": false,
	"name": "bob",
	"shell": "/bin/sh",
	"state": "present",
	"uid": 1001
}

Delete Bob's user account :

ansible myGroup1 -m user -a "name=bob state=absent remove=yes"
192.168.105.114 | success >> {
	"changed": true,
	"force": false,
	"name": "bob",
	"remove": true,
	"state": "absent"
	"stderr": "userdel: bob mail spool (/var/mail/bob) not found\n"
}
Running the same command again (no user account named Bob anymore) :
192.168.105.114 | success >> {
	"changed": false,
	"name": "bob",
	"state": "absent"
}
remove=yes instructs Ansible to delete the homedir as well.
remove=no is equivalent to not using the "remove" option at all (defaults to no), and leaves the homedir untouched.

List existing user accounts :

  • ansible myGroup1 -m shell -a 'less /etc/passwd | cut -d ":" -f 1'
  • ansible myGroup1 -m shell -a 'sed -r "s/^([^:]+).*/\1/" /etc/passwd'
  • ansible myGroup1 -m shell -a 'awk -F ":" "{print \$1}" /etc/passwd'
This gets complex because of escaping quotes and some special characters
192.168.105.114 | success | rc=0 >>
root
daemon
bin

nobody
libuuid
messagebus
bob

Deploying from Git (source) :

ansible webservers -m git -a "repo=git://foo.example.org/repo.git dest=/srv/myapp version=HEAD"

Try this with REAL stuff to deploy.

Manage services (sources 1, 2) :

Start a service : ansible all -m service -a "name=ssh state=started"

192.168.105.114 | success >> {
	"changed": false,
	"name": "ssh",
	"state": "started"
}

192.168.105.80 | success >> {
	"changed": false,
	"name": "ssh",
	"state": "started"
}
Accepted states :
  • started : start service if not running
  • stopped : stop service if running
  • restarted : always restart
  • reloaded : always reload
  • running : ?