Configuring a new computer tends to involve the same steps every time as you install software, set up SSH keys, and get the screen brightness just right. It’s even worse if you have to configure multiple machines at once. You may have written a shell script to take care of all these things automatically, but if you have to edit & rerun it after it executes halfway, the fact that part of it already ran could cause problems when it’s run again. Moreover, if you have to run the same commands on multiple machines at once, things can get tedious, especially if config files or scripts need to be copied over to each one.
Ansible can be used to automate this process and ensure that commands are only run when appropriate. Ansible can run tasks on multiple hosts at once just by logging in over SSH, and Ansible tasks are able to detect when changes aren’t needed so that they only perform actions when necessary.
An Ansible operation requires at least two files: a list of tasks to execute (the playbook) and a list of hosts to execute them on (the inventory).

Playbooks and Tasks

An Ansible playbook is a YAML file containing a list of plays; each play is a dictionary specifying which hosts to operate on, any variables or other configurations to use throughout the play, and a list of tasks to execute on the hosts; each task is a dictionary containing various optional entries and one entry named after an Ansible module (the command that actually gets executed for the task) whose value consists of any parameters to the command. For example:

---
# YAML files always begin with three hyphens.  They just do.
 
- hosts: all
  sudo: yes  # Run all of the tasks as root.
  tasks:
      # Giving a task a name is optional but encouraged.
      # This name will be printed out by Ansible as part of its progress messages, after all.
    - name: Install essential software
      # Execute the same action for a list of values.
      # The current value is stored in the 'item' variable, which is interpolated into the command using Jinja templates.
      apt: name={{ item }} state=present
      with_items:
        - git
        - vim
        - screen
        - nethack-console
 
    - name: Add .screenrc to default home directory
      # The 'copy' module copies a local file to the remote machine, overwriting any pre-existing files.
      # To keep Ansible from copying when the destination file already exists (regardless of its contents), add "force=no" to the "copy:" line.
      copy: src=/local/path/.screenrc dest=/etc/skel/.screenrc
 
    - name: Add thing-server to /etc/hosts
      # The 'lineinfile' module adds a line to a file and does nothing if the line is already there.
      lineinfile: dest=/etc/hosts line='192.168.2.159 thing-server.example.com' state=present
 
    - name: Turn on baz in foo
      # 'lineinfile' can also edit lines already in a file in order to ensure the target line is present.
      # If /etc/foo contains a "do_baz=no" line (possibly with extra spaces), it will be replaced with "do_baz=yes".
      # Otherwise, "do_baz=yes" will be added to the end of the file.
      lineinfile: dest=/etc/foo regexp='^\s*do_baz\s*=\s*no\s*$' line='do_baz=yes' state=present
 
    - name: Add admin's pubkey to a user's .ssh/authorized_keys
      # The 'lookup' function fetches the contents of the file 'admin.pub' on the local machine for use as the "key" argument to the authorized_key module:
      authorized_key: key={{ lookup('file', 'admin.pub') }} user=poor_sap state=present

Of course, there isn’t an Ansible module for everything (yet), so some commands have to be run with the command module; unlike most other modules, the command itself isn’t a named parameter but rather “free form”:

- name: Foo all the bars
  command: foo -A /etc/bar creates=/etc/baz

The “creates=/etc/baz” at the end shows two things:

  1. We can keep the command from being run again if it’s already been run once by telling Ansible that it doesn’t need to do anything if the /etc/baz file already exists on the remote host. We can invert the condition by instead writing removes=/etc/baz so that Ansible won’t do anything iff the file does exist.
  2. Any named parameters for the command module are simply placed at the end of the line after the command command. If we want to be clear about what’s a parameter and what isn’t, we can place all the parameters in the task’s args dictionary:

    - name: Foo all the bars
      command: foo -A /etc/bar
      args:
        # This is a proper YAML dictionary, so we use colons, not equal signs.
        creates: /etc/bar
        # Also, change directory before running the command:
        chdir: /usr/local/share/glarch

The command module splits the command along whitespace and does not honor any special shell characters; to have your command parsed & run by an actual shell, use the shell module instead, which handles its parameters the same way as command.

Inventories

The inventory file, at its simplest, is just a list of hosts, one per line, though we can also organize the hosts into groups in order to determine which tasks are run on them:

# These hosts aren't in a group and will only be operated on by plays that have "hosts: all".
foo.example.com
bar.example.com
 
[dbservers]
# These hosts will only be operated on by plays that have "hosts: dbservers" or "hosts: all".
baz.example.com
xyzzy.example.com
 
[webservers]
# These hosts will only be operated on by plays that have "hosts: webservers" or "hosts: all".
quux.example.com
glarch.example.com
# xyzzy.example.com appears is in both the dbservers and webservers groups, and so plays that affect either group will run on it.
# (Plays that affect all hosts will only run on xyzzy once.)
xyzzy.example.com

Running

Finally, we run all this with:

ansible-playbook -i hosts-file playbook.yml

If we omit the “-i hosts-file”, Ansible will use its default inventory file (usually /etc/ansible/hosts) for the lists of hosts. If a task fails on a host, Ansible will stop running commands on that host for the rest of the play.

Examples

This playbook sets up two-factor authentication with Google Authenticator based on these instructions and enables two-factor login for root; if these things have already been carried out before on a given host, nothing will be changed. For each host on which two-factor login is newly set up, Ansible will print out a URL for a QR code which must then be scanned with the Google Authenticator app.

---
- hosts: all
  sudo: yes
  tasks:
   - name: Install libpam-google-authenticator
     apt: pkg=libpam-google-authenticator state=installed update_cache=true
    
   - name: Edit /etc/pam.d/sshd
     lineinfile:
       dest=/etc/pam.d/sshd
       line='auth required pam_google_authenticator.so'
       state=present
     # If "lineinfile" makes a change to the file, the "Restart server" handler defined below will be notified.
     notify:
      - Restart server
 
   - name: Edit /etc/ssh/sshd_config
     lineinfile:
       dest=/etc/ssh/sshd_config
       regexp='^ChallengeResponseAuthentication no$'
       line='ChallengeResponseAuthentication yes'
       state=present
     notify:
      - Restart server
 
   - name: Run google-authenticator
     # This command runs google-authenticator and captures the URL of the generated QR code.
     shell: yes | google-authenticator | perl -nle '/http\S+/ and print $&'  creates=/root/.google_authenticator
     # "register" saves the results of the command to the variable "googleauth".
     # The output is in "googleauth.stdout".
     register: googleauth
 
   - name: Print output of google-authenticator
     # The below line causes this task to only be run if the previous task changed something on the system (from Ansible's perspective, if /root/.google_authenticator was created).
     when: googleauth|changed
     debug: var=googleauth.stdout
 
  handlers:
   # After all of the tasks have been executed, the handlers (a special kind of task) will be run on any hosts on which they were notified.
   - name: Restart server
     command: /sbin/reboot

Example output for this playbook (when running on only one host):

addtechimg1.png

This playbook ensures that a specific user account exists on all of the hosts. If it does not exist on a host, it is created, a password is generated for it (and printed to standard output), and its login shell is set to /bin/bash.

---
- hosts: all
  sudo: yes
  vars:
   # The name of the user to create.
   # We can override this value by passing `-e username=VALUE` to `ansible-playbook` on the command .
   # We can also set the variable on a per-host basis by creating a 'host_vars/HOSTNAME' file containing a YAML dictionary that defines 'username'.
   # We can even set it for all hosts in a group with a 'group_vars/GROUPNAME' file.
   username: luser
  tasks:
   - name: Checking whether a user exists...
     user: name="{{ username }}" state=present
     register: createdUser
 
   - name: Install openssl
     when: createdUser|changed
     apt: name=openssl state=present
 
   - name: Create password
     when: createdUser|changed
     command: openssl rand -base64 32
     register: newpass
 
   - name: Print password for new user
     when: createdUser|changed
     # 'inventory_hostname' is a special Ansible variable containing the name of the current host as recorded in the inventory file.
     debug: msg="Password for {{ username }} on {{ inventory_hostname }} - {{ newpass.stdout }}"
 
   - name: Install whois
     # `whois` provides `mkpasswd`
     when: createdUser|changed
     apt: name=whois state=present
 
   - name: Encrypt password
     when: createdUser|changed
     shell: "echo {{ newpass.stdout }} | mkpasswd --method=SHA-512 -s"
     register: cryptPass
 
   - name: Set password and shell for new user
     when: createdUser|changed
     user: name="{{ username }}" password="{{ cryptPass.stdout }}" shell=/bin/bash

Example output:

addtechimg2.png