DEV Community

Cover image for Test Ansible playbooks using Docker
Richard Lenkovits
Richard Lenkovits

Posted on • Updated on

Test Ansible playbooks using Docker

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.

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


Enter fullscreen mode Exit fullscreen mode

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.



---
# 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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

2. Test Container

We create a Dockerfile too which describes for us an ssh accessible ubuntu container.



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"]


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode
  • 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"
}


Enter fullscreen mode Exit fullscreen mode
  • 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
}


Enter fullscreen mode Exit fullscreen mode
  • 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


Enter fullscreen mode Exit fullscreen mode

Full script for reference:



#!/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


Enter fullscreen mode Exit fullscreen mode

Hope you found something useful here!

Top comments (3)

Collapse
 
zmzlois profile image
Lois

Great article, thanks!

If anyone is also reading this and face the error

fatal: [172.17.0.2]: UNREACHABLE! => {
    "changed": false,
    "msg": "Failed to connect to the host via ssh: ssh: connect to host 172.17.0.2 port 22: Operation timed out",
    "unreachable": true
}
Enter fullscreen mode Exit fullscreen mode

Change the line in shell script

docker run -d -P --name "${NAME}" "compute-node-sim"
Enter fullscreen mode Exit fullscreen mode

to

 docker run -d -p 127.0.0.1:2222:22 --name "${NAME}" "compute-node-sim"
Enter fullscreen mode Exit fullscreen mode

and also one line below [target_group] in the setup_test_inventory() function to

127.0.0.1:2222
Enter fullscreen mode Exit fullscreen mode

to specify a port

Collapse
 
pencillr profile image
Richard Lenkovits

Hey! Thanks for catching that!
I was using the -P flag to publish to random exposed ports, but maybe the docker inspect command couldn't parse the random assigned address. The structure might have changed since I wrote this.

Collapse
 
vladrassokhin profile image
Vladislav Rassokhin

I'd suggest connecting to the randomly mapped host port. Adjust the script as follows:

function start_container() { 
...
    HOST_PORT=$(docker inspect --format='{{ (index (index .NetworkSettings.Ports "22/tcp") 0).HostPort }}' "${NAME}")
    export HOST_PORT
}

...

    cat > "${TEMP_INVENTORY_FILE}" << EOL
[target_group]
localhost:${HOST_PORT}
...
Enter fullscreen mode Exit fullscreen mode