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.
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
.
---
# 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.
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:
#!/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!
Top comments (3)
Great article, thanks!
If anyone is also reading this and face the error
Change the line in shell script
to
and also one line below
[target_group]
in thesetup_test_inventory()
function toto specify a port
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.I'd suggest connecting to the randomly mapped host port. Adjust the script as follows: