Skip to content

Ansible or How to Mess up AT SCALE!

Getting Started

To get started you will need two things: Text Editor, Python 3. You can use whatever text editor you want to use, Visual Studio Code is a popular one that runs almost anywhere. As long as you're comfortable with it, use it. If you can run python and ssh you have everything else you need.

Note

  • It is possible to run this on Windows itself, I do not use Windows so I can not help too much. If you have WSL working then you should be able to everything. If you're having problems we do have other systems that can be used

Ansible is in most package repos, for this we're using ansible-core. I recommend you install it via pip to you can ensure you have the same thing as we use in prod

Installing ansible is pretty easy, either use your package manager or pip

# pip
pip install --user ansible-core
# Redhat Systems
dnf install ansible
# Debian Systems
apt install ansible

Once you have ansible installed let's run our first command to make sure everything is in good working order ansible -i localhost localhost -m ping

Success

❯ ansible localhost -m ping
localhost | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

See that was pretty easy!

My First Inventory File

The next thing we're going to do is create a very simple inventory.ini file.

Example

[mygroup_name]
localhost

This file does one thing, tells ansible that localhost is in my_group

That's all it does! Let's run our command again, this time using the inventory file.

Success

❯ ansible -i inventory.ini my_group -m ping
localhost | SUCCESS => {
    "changed": false,
    "ping": "pong"

So we told ansible to use our inventory file -i inventory.ini and that we want to connect to my_group and run the ping module.

What if we wanted to run against all the hosts in our inventory file? We would use the special group named all

[!success]

❯ ansible -i inventory.ini all -m ping
localhost | SUCCESS => {
   "ansible_facts": {
       "discovered_interpreter_python": "/usr/bin/python3"
   },
   "changed": false,
   "ping": "pong"
}

We can run commands too!

Success

❯ ansible localhost -m ansible.builtin.command -a "ls"
localhost | CHANGED | rc=0 >>
Applications
Desktop
Documents
Downloads
Library
Old Laptop
OneDrive - Reed Elsevier Group ICO Reed Elsevier Inc
Pictures
Public
git
inventory.ini
svn

This told ansible that I want to run ls on localhost Let's try something a little more fun!

Success

❯ ansible -i inventory.ini all -m ansible.builtin.command -a "echo (β•―Β°β–‘Β°)β•―οΈ΅ ┻━┻ D O N T P A N I C"
localhost | CHANGED | rc=0 >>
(β•―Β°β–‘Β°)β•―οΈ΅ ┻━┻ D O N T P A N I C

What changed there? We told ansible that we want to use the ansible.builtin.command module. Ansible assumes that if you do not specify a module name that you just want to run an adhoc command. So what's a module? Simply put module is a connection of stuff that tells ansible how to do do stuff.

Let's take this another step farther, how about we use ansible to make sure my mac does not have the dreaded sl command!

Example

❯ ansible -i inventory.ini my_group -m community.general.homebrew -a "name=sl state=absent" localhost | SUCCESS => {
   "changed": false,
   "changed_pkgs": [],
   "msg": "Package already uninstalled: sl",
   "unchanged_pkgs": [
       "sl"
   ]
}

We can see that it says "changed:" false that means that it was not there. So no worries about being punished for messing up up a ls

And now let's make sure I have the wonderful toilet command!

Example

❯ ansible localhost -m community.general.homebrew -a "name=toilet state=present"
localhost | CHANGED => {
   "changed": true,
   "changed_pkgs": [
       "toilet"
   ],
   "msg": "Package installed: toilet",
   "unchanged_pkgs": []
}

So now we know how to use ad hoc commands. We can run them on all the hosts in our inventory file or just a group. What if we want to do more?

Using the flag --limit we can limit ansible to one host or just a group of hosts e.g. -- limit hostname.here.local

Yaml? What the Hell is a Yaml?

YAML stands for YAML Ain’t Markup Language. It's what most things use to store key value pairs in a way that humans can manage and understand without too much help. Pretty much everything with Ansible, (and most cloud stuff), uses yaml. When it comes to ansible it only cares about the content of the file and not the name, so you can use .yml or.yamlit only really matters to the humans (and sometimes Windows)

Yaml can be a real pain so do yourself a favor, use a linter
pip install --user yamllint yamlfix prettier

What the hell is a linter?

A linter is something that makes sure you code is functional. It will let you know where things are broken and maybe give you a hint on how to fix it. A formatter can help with that, while It doesn't check if it is actually good or functional it tries to make it readable. So you should be using yamllint to check your files for problems and yamlfix to help fix them automatically. Once your good you can use prettier -w to make it pretty. Don't worry, there's ways of automating this

Let's just jump right into the deep end and make an inventory file using yaml. Ideally any inventory file you will be using will be some kind of yaml. It doesn't matter if it's for ansible or kubernetes for example. Let's take a look at a simple inventory.yaml file

Example

---
all:
  hosts:
    alice:
    bob:

Here way have two hosts: alice & bob. If this was in ini it would look like

[hosts]
alice
bob

So not much of a different from our first inventory.ini We can check this file with the ansible-inventory command

❯ ansible-inventory -i simple.yml --graph
@all:
  |--@ungrouped:
  |  |--alice
  |  |--bob

Nothing really complex so far, let's add some more hosts and groups into our inventory.yml

Example

--all:
  hosts:
    alice:
    bob:
  children:
    group_1:
      hosts:
        strangechicken:
        tumescentcloaca:
    group_2:
      hosts:
        wowzer:
        imaproblem:
    group_3:
      hosts:
        group_1:
        alice:
    group_4:
      hosts:
        group_2:
        bob:
    group_5:
      hosts:
        group_3:
        group_4:

As you can see we can have many groups even groups that have other groups inside them. We can keep nesting groups as long as we follow the hosts, children, hosts naming. We can make sure this is a valid inventory by looking at with ansible-inventory

❯ ansible-inventory -i inventory.yml --graph
@all:
  |--@ungrouped:
  |--@group_1:
  |  |--strangechicken
  |  |--tumescentcloaca
  |--@group_2:
  |  |--wowzer
  |  |--imaproblem
  |--@group_3:
  |  |--group_1
  |  |--alice
  |--@group_4:
  |  |--group_2
  |  |--bob
  |--@group_5:
  |  |--group_3
  |  |--group_4

My First Playbook

Now running commands one at a time is fine but it's a lot more fun to have something that does a lot of things on a lot of things. This is where Ansible shines!

This is also where things start to get more information dense. So buckle up!

Ansible will not change anything that doesn't need to be changed, this is important for ensuring something called idempotency. That means that no matter how many times we run the same thing, we're going to get the same results.

Let's take a look at a simple but real playbook: change_genesis.yml

Example

---
- name: Change Genesis Server
  hosts: all
  become: true
  vars:
    genesis_ip: 10.194.118.129
    files_to_change:
      - /etc/profile.d/genesis.sh
      - /etc/resolv.conf
      - /etc/profile.d/yum.sh
  tasks:
    - name: Replace Bad Genesis
      ansible.builtin.replace:
        dest: "{{ item }}"
        regexp: 10\.173\.0\.129
        replace: "{{ genesis_ip }}"
      loop: "{{ files_to_change }}"

So what does this do? It may look complicated but it's fairly simple. This says look at these files for this string and replace it with that string.

Starting from the top we have name: this is the name of the play. The play is what ansible calls it's group of actions or a single action. All plays should be named starting with a capital letter and end with a letter. It will still work if you do not do this, but it will complain the entire time. Kinda like when you don't put on your seat belt…

Next hosts: and become: this is the host, hosts, and or group name to run this on. Most of the time this is going to set to all. become: tells ansible that it needs to use sudo aka root powers to do these tasks. The higher up in the play the more things will use sudo powers.

Now vars: is a special:

Like become: it can be used most anywhere in a play, or it can reference other yaml files. So if we were to put vars in something like our inventory file it would apply per host, per group, or even for everything! In this case we're only saying that the plays before need these variables. Variables can also be anything we want. If I want foo to have the value of bar then I put foo: bar.

genesis_ip: is the ip to the genesis server in this case we're using10.194.118.129
files_to_change: lists the files that we need to check.

Further down we get to tasks: tasks are the plays that that playbook is running. In this case we have single play named Replace Bad Genesis. The module name is ansible.builtin.replace that makes it function like sed. The next lines are telling it where and what. dest: is the destination file, regexp: is a regular expression, and replace: is what is going to replace the matched expression.

This play has something special attached to it called a loop: this tells the play that there's going to be more things to do so it will function in a loop, kind of like for x in a bash loop.

When you call a variable in you have to use another kind of language aside from yaml. It's a templating language/engine called Jinja2. Templating languages (engines) have been around for long time. They allow us to define how things should look while still allowing us to program-maticly change things. DO NOT WORRY, much like regex, you will not need to know it like you will need to understand yaml.

To call variables we're using jinja values. So in this case the files are represented by "{{ item }}" and the list of files is represented by " {{ files_to_change }}" this is more than likely the most complex jinja you will have to remember. The various linter's will handle the rest if you mess it up. So do not worry to much!

Let's look at the same thing done in bash

Example

sed -i 's/10.173.0129/10.194.118.129/g' /etc/profile.d/genesis.sh
sed -i 's/10.173.0129/10.194.118.129/g' /etc/resolv.conf
sed -i 's/10.173.0129/10.194.118.129/g' /etc/profile.d/yum.sh

Now lets look at a script to do the same thing

Example

#!/bin/bash
genesis_ip=10.194.119.129
bad_genesis=10.173.0.129
files=(/etc/profile.d/yum.sh, /etc/resolv.conf, /etc/profile.d/yum.sh)
for file in ${files[@]}; do
  sed -i "s/${bad_genesis}/${genesis_ip}/g" "${file}"
done

You could also use the get a bigger hammer approach too grep -rl "10.173.0.129" | xargs sed 's/10.173.0.129/10.194.118.129/g' there's a lot of ways to do the same thing. So why do it with Ansible? Well idempotency aside we do not have to worry about what system is on the other end. That same module will work the same on Linux, Mac, Windows, BSD, etc. We can also chain multiple playbooks together in ways that trying to string together multiple shell scripts start to fail at. Radssh is an incredible tool but it will only get you so far. Ansible provides a way to validate your configurations and document them in ways that other methods just simply can not do.

Digressions Aside; how Do You Actually Run the Damn Thing!?

> ansible-playbook -i inventory.yml all -u USERNAME -k change_genesis.yml

Let's break this down. ansible-playbook is the command that handles playbooks, kind of like we used ansible-inventory to look at out inventory file. -i inventory.yml all says use all the hosts in the inventory file. -u USERNAME -k says use this username and prompt for a sudo password so it can be used when needed. Some depending on how sudoers is configured you can leave this out. For most admin/op account -k isn't needed. Lastly the name of the playbook change_genesis.yml

How-to: Write an Ansible Playbook

The General Layout of the a Playbook

---
# How to use:
# ansible-playbook -i inventory/inventory.file.here playbooks/template.yml
- name: Playbook name
  hosts: all
  become: false # Change to true for sudo powers
  vars: # Any vars needed for this playbook
  handlers: # Any handlers needed for this playbook
  pre_tasks: # Any pre-tasks needed for this playbook
  post_tasks: # Any post-tasks needed for this playbook
  tasks: # The main body of the playbook

Playbooks should follow this layout where possible. You may have some things that require more or less.

Formatting and General Rules

General Rules

  • Every play should be named started with a capital letter
  • Try to to keep lines under 80 col in length
  • Use true or false to maintain truthy (yaml spec)
  • Place tags where it makes sense
  • Place options at the top of a block when possible; this helps with readability
  • Place optionals above the module where possible; this helps with readability
  • Comments should have two spaces # a single space, then the text (yaml & markdown spec)
  • Names should end with a letter where possible
  • Registers & variable names should not contain non alpha characters when possible.
  • Anything that makes changes to the system when run should be handler when at all possible
  • If you have to do ignore errors on something you either have a very niche edge case or it's lazy code. Anything that ignores errors should be a handler too.

Bad Formatting

ansible.posix.authorized_key: #Adding OPS Keys to the root for some silly reasons
user: root
state: present
key: "{{ item }}"
loop: "{{ keys }}"
notify: YouHaveBeenWarned
ignore_errors: yes

What's wrong with this?

  • There is no name that starts with a capital letter
  • There is a comment that isn't spaced out correctly and goes way past 80 col.
  • Options are at the bottom rather than the top.
  • Contains ignore_errors and yes
  • No tags

Good Formatting

- name: Add OPS Authorized Keys
  notify: YouHaveBeenWarned
  tags: [keys, ssh]
  ansible.posix.authorized_key:
    user: root
    state: present
    key: "{{ item }}"
  loop: "{{ keys }}"

Why is this better?

  • The task is named clearly and correctly
  • You know right away that it is tagged and will trigger a handler
  • Does not break truthy or ignore errors
  • Does not have an unnecessary or overly long comment

Formatting & Linting

If you follow the general rules then you should be able to format your playbooks with something like prettier and run ansible-lint on them. This will let you know if you have any errors that require fixing. You goal is to need none or as few ignores in your .ansible-lint-ignore file and be as close to the production ready profile as possible.

If you encounter problems with your playbook that are formatting related running yamlfix then prettier on them will normally fix them for you.

Once your playbook has been formatted and linted you can try it out.

The End-ish

This is has been an overview on Ansible. It is by no means a complete or even good overview. It's purely from my memory and there are bounds to be gaps or things I'm making assumptions about. I will be adding this document to github in the future once I am mostly satisfied with it's content.

As with everything in life; YMMV, caveat emptor, pass performance doesn't equal future success, and…

Have an Average Day :|