Test Ansible playbooks using Docker
Tags: ansible, docker, ci, programming
Published: 2023-03-21 21:28:17

---
title: Test Ansible playbooks using Docker
published: true
description: A way to test Ansible playbooks simply with Docker.
tags: #ansible #docker #ci #programming
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nqpvvingga864eriedmb.jpg
# Use a ratio of 100:42 for best results.
# published_at: 2023-03-21 20:00 +0000
---

Okay so this is what we're going to do:
+ Setting up an ssh accessible Ubuntu container.
+ Running an Ansible playbook against it to *test* that playbook.

## Come again?
I know it's weird, hear me out.

Here's the scenario that we're trying to tackle. Imagine that:
**1.** You have Ansible playbooks to configure certain machines.
**2.** You run these playbooks once during the initial setup and forget about them.
**3.** A lot of time passes.
**4.** Later a developer needs to add a package or some change to the given host or host group.
**5.** They should update the ansible playbook and run it, but they're afraid they'll break the environment. Who knows when were those playbooks last run? Maybe they even have an invalid description of the current system. As a result the developer either: **A.:** Just does the changes by hand and best case, **B.:** ...updates the playbooks as well. But who know if it's a correct update.

This is how playbooks *decay* - at least in our environments.

![People playing a clutch Jenga game.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m0itnw58djjrjo4lwrlo.gif)

## Why such a basic validity check could be of good use
Having some simple tests could solve our issues, and provide some constant feedback about the playbooks' validity. But how do you test Ansible playbooks (other than lint them)? Playbooks are just ordered instructions describing a *desired state*.

Still, they could be faulty - it's easy to make mistakes when you're trying to translate your desired configuration steps into Ansible tasks. You may mess up a task, forget a necessary parameter on a module, maybe you try to install something for which you forget to provide it's prerequisites.

Preferably we want some tests which can **run our playbooks against an ephemeral environment** to see if they're -well- *runnable*. This is where **docker** comes into the picture. In docker you can test *most of the things* that you'd want to do with Ansible. Obviously for more complicated playbooks this might not be a good solution.

___

## Test setup
This is how our small demo directory -with which we'll make our point- looks like:
```
.
├── Dockerfile
├── ansible.cfg
├── machine-setup.yml
└── setup-container.sh
```

### 1. Example Playbook and config
There's a very simple playbook `machine-setup.yml`, that sets up some packages on the host group called `target_group`.
```yaml
---
# yamllint disable rule:line-length
- name: Setup Machine
  hosts: target_group
  gather_facts: false
  become: true

  tasks:
    - name: Install requirements
      apt:
        update_cache: true
        pkg:
          - python3
          - flake8
          - pylint
          - python3-pip
        state: latest
      register: task_result
      until: not task_result.failed
      retries: 1
```
We provide some necessary Ansible config in a cfg file `ansible.cfg`:
```
[defaults]
# Avoid host key checking - to to run without interaction.
host_key_checking = False
```
### 2. Test Container
We create a `Dockerfile` too which describes for us an ssh accessible ubuntu container.
```dockerfile
FROM ubuntu:latest

ARG USER=${USER}

# Add sudo and openssh-server
ENV DEBIAN_FRONTEND=noninteractive
RUN apt update && apt install openssh-server sudo -y

# Setup running user on the container with sudo rights and
# password-less ssh login
RUN useradd -m ${USER}
RUN adduser ${USER} sudo
RUN echo "${USER} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/sudoers

# As the user setup the ssh identity using the key in the tmp folder
USER "${USER}"
RUN mkdir ~/.ssh
RUN chmod -R 700 ~/.ssh
COPY --chown=${USER}:sudo id_rsa.pub /home/${USER}/.ssh/id_rsa.pub
RUN cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
RUN chmod 644 ~/.ssh/id_rsa.pub
RUN chmod 644 ~/.ssh/authorized_keys

# start ssh with port exposed
USER root
RUN service ssh start

EXPOSE 22

CMD ["/usr/sbin/sshd", "-D"]
```

### 3. Testing Script
Our script will start this container, run the playbook against it and clean up everything. Let's see it step by step.

+ We make sure to have a unique container name, and a path to our assets.
```bash
identifier="$(< /dev/urandom tr -dc 'a-z0-9' | fold -w 5 | head -n 1)" ||:
NAME="compute-node-sim-${identifier}"
base_dir="$(dirname "$(readlink -f "$0")")"
```

+ We add a function to create **temporary directory** to store our temporary assets (like the inventory and the ssh id).
```bash
function setup_tempdir() {
    TEMP_DIR=$(mktemp --directory "/tmp/${NAME}".XXXXXXXX)
    export TEMP_DIR
}
```

+ We setup a `cleanup` function which will be able to clean up the temporary folder and the container as well.
```bash
function cleanup() {
    container_id=$(docker inspect --format="{{.Id}}" "${NAME}" ||:)
    if [[ -n "${container_id}" ]]; then
        echo "Cleaning up container ${NAME}"
        docker rm --force "${container_id}"
    fi
    if [[ -n "${TEMP_DIR:-}" && -d "${TEMP_DIR:-}" ]]; then
        echo "Cleaning up tepdir ${TEMP_DIR}"
        rm -rf "${TEMP_DIR}"
    fi
}
```

+ We need a function that will create ssh identities so Ansible can access the container through ssh. During `docker build` these will be added inside the container by a `COPY` step (see above in the Dockerfile).
```bash
function create_temporary_ssh_id() {
    ssh-keygen -b 2048 -t rsa -C "${USER}@email.com" -f "${TEMP_DIR}/id_rsa" -N ""
    chmod 600 "${TEMP_DIR}/id_rsa"
    chmod 644 "${TEMP_DIR}/id_rsa.pub"
}
```

+ We build and start the container with this function - providing the `TEMP_DIR` as it's context. We figure out the container's address for ssh.
```bash
function start_container() {
    docker build --tag "compute-node-sim" \
        --build-arg USER \
        --file "${base_dir}/Dockerfile" \
        "${TEMP_DIR}"
    docker run -d -P --name "${NAME}" "compute-node-sim"
    CONTAINER_ADDR=$(docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${NAME}")
    export CONTAINER_ADDR
}
```

+ We create a **test inventory** which has our `target_group` containing only the container by it's address we fetched above. We also add the ssh identity to be used when running the playbook.
```bash
function setup_test_inventory() {
    TEMP_INVENTORY_FILE="${TEMP_DIR}/hosts"

    cat > "${TEMP_INVENTORY_FILE}" << EOL
[target_group]
${CONTAINER_ADDR}:22
[target_group:vars]
ansible_ssh_private_key_file=${TEMP_DIR}/id_rsa
EOL
    export TEMP_INVENTORY_FILE
}
```

+ We add the playbook runner function. It will run against our temporary inventory.
```bash
function run_ansible_playbook() {
    ANSIBLE_CONFIG="${base_dir}/ansible.cfg"
    ansible-playbook -i "${TEMP_INVENTORY_FILE}" -vvv "${base_dir}/machine-setup.yml"
}
```

+ Now we run it all:
```bash
setup_tempdir
trap cleanup EXIT
trap cleanup ERR
create_temporary_ssh_id
start_container
setup_test_inventory
run_ansible_playbook
```
___

## Full script for reference:
```bash
#!/usr/bin/env bash

set -euo pipefail

identifier="$(< /dev/urandom tr -dc 'a-z0-9' | fold -w 5 | head -n 1)" ||:
NAME="compute-node-sim-${identifier}"
base_dir="$(dirname "$(readlink -f "$0")")"

function cleanup() {
    container_id=$(docker inspect --format="{{.Id}}" "${NAME}" ||:)
    if [[ -n "${container_id}" ]]; then
        echo "Cleaning up container ${NAME}"
        docker rm --force "${container_id}"
    fi
    if [[ -n "${TEMP_DIR:-}" && -d "${TEMP_DIR:-}" ]]; then
        echo "Cleaning up tepdir ${TEMP_DIR}"
        rm -rf "${TEMP_DIR}"
    fi
}

function setup_tempdir() {
    TEMP_DIR=$(mktemp --directory "/tmp/${NAME}".XXXXXXXX)
    export TEMP_DIR
}

function create_temporary_ssh_id() {
    ssh-keygen -b 2048 -t rsa -C "${USER}@email.com" -f "${TEMP_DIR}/id_rsa" -N ""
    chmod 600 "${TEMP_DIR}/id_rsa"
    chmod 644 "${TEMP_DIR}/id_rsa.pub"
}

function start_container() {
    docker build --tag "compute-node-sim" \
        --build-arg USER \
        --file "${base_dir}/Dockerfile" \
        "${TEMP_DIR}"
    docker run -d -P --name "${NAME}" "compute-node-sim"
    CONTAINER_ADDR=$(docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${NAME}")
    export CONTAINER_ADDR
}

function setup_test_inventory() {
    TEMP_INVENTORY_FILE="${TEMP_DIR}/hosts"

    cat > "${TEMP_INVENTORY_FILE}" << EOL
[target_group]
${CONTAINER_ADDR}:22
[target_group:vars]
ansible_ssh_private_key_file=${TEMP_DIR}/id_rsa
EOL
    export TEMP_INVENTORY_FILE
}

function run_ansible_playbook() {
    ANSIBLE_CONFIG="${base_dir}/ansible.cfg"
    ansible-playbook -i "${TEMP_INVENTORY_FILE}" -vvv "${base_dir}/machine-setup.yml"
}

setup_tempdir
trap cleanup EXIT
trap cleanup ERR
create_temporary_ssh_id
start_container
setup_test_inventory
run_ansible_playbook
```

Hope you found something useful here!