Testing Ansible Docker Container Deployment with Molecule

By | November 2, 2022

Update: There is a slight issue with the technique described in this article. Please refer to this article for a remedy.

Testing Ansible automation with Molecule means testing against Docker container(s) that poses as remote server(s). An interesting challenge, at least to me, was to use Molecule to test deployment of Docker containers – this because it will mean starting a Docker container inside another Docker container. In this article I will describe how I accomplished to test Ansible Docker container deployment with Molecule. The Ansible role that performs the Docker container deployment in this article’s example will not be complete but hopefully a good starting point for something that can be extended as required.

Prerequisites

The example in this article obviously require Ansible and Molecule. Instructions on how to install Ansible are found here. Installation of Molecule is described here – install Molecule with the Docker driver. Since Molecule is to use the Docker driver, Docker is also required and need to be started in order to be able to execute Molecule tests.

Create the Project

To create project, perform the following steps in a terminal window:

  • Create a directory named “ansible-docker-deploy-molecule-test” and enter the directory.
mkdir ansible-docker-deploy-molecule-test
cd ansible-docker-deploy-molecule-test
  • Create the Ansible collection skeleton for the “dockerdeploy” collection in the namespace “ivankrizsan” using the ansible-galaxy command.
ansible-galaxy collection init ivankrizsan.dockerdeploy
  • Move the “dockerdeploy” (collection) directory located inside the “ivankrizsan” (namespace) director to the root of the project.
mv ivankrizsan/dockerdeploy .
  • Delete the “ivankrizsan” (namespace) directory.
rmdir ivankrizsan
  • Create an Ansible configuration file named “ansible.cfg” in the root of the project with the following contents:
[defaults]
collections_paths = ./ansible_collections
roles_path = ./ansible_roles
remote_tmp = /var/tmp
  • Go to the “roles” directory of the collection “dockerdeploy”.
cd dockerdeploy/roles/
  • Create a new role named “deploy” using Molecule.
molecule init role deploy --driver-name docker

With that we have created the skeleton of the example project. If examining the directory and file structure under the “dockerdeploy/roles” directory, it should look something like this:

└── deploy
    ├── README.md
    ├── defaults
    │   └── main.yml
    ├── files
    ├── handlers
    │   └── main.yml
    ├── meta
    │   └── main.yml
    ├── molecule
    │   └── default
    │       ├── converge.yml
    │       ├── molecule.yml
    │       └── verify.yml
    ├── tasks
    │   └── main.yml
    ├── templates
    ├── tests
    │   ├── inventory
    │   └── test.yml
    └── vars
        └── main.yml

Ansible Deploy Role Parameters

Before implementing the Ansible deploy role or any tests, I want to stop for a moment to contemplate the parameters of the role. For the example in this article I have the following requirements:

  • I want to be able to specify the Docker image used when deploying a container.
  • It should be possible to specify the name of the container.
  • It should be possible to supply a command that will be executed in the container once it is deployed.
  • Finally I want to be able to start multiple containers with one invocation of the Ansible role.

Given the above, I decided that the parameters to the Docker deploy Ansible role I will develop will be passed in the form of a list of dictionaries. This not only allows for starting multiple containers with one invocation of the Ansible role but also allows for using the same name, the dictionary key, for the same parameter in multiple deployments. A list of such dictionaries may look something like this:

[
    {
      docker_image: "busybox:latest",
      container_name: "container1",
      container_command: ["date"]
    },
    {
      docker_image: "busybox:latest",
      container_name: "container2",
      container_command: [ "date" ]
    }
]

An invocation of the deploy role may then look something like this:

- name: "Deploy Docker containers"
  ansible.builtin.include_role:
    name: "deploy"
  vars:
    deployments:
      - {
          docker_image: "busybox:latest",
          container_name: "container1",
          container_command: ["date", "'+%y%m%d'"]
      }
      - {
        docker_image: "busybox:latest",
        container_name: "container2",
        container_command: [ "date", "'+%d%m%y %H:%M'" ]
      }

Deploy Role Test

Given the above example of what an invocation of the deploy role may look like, I can now implement the Molecule test of the role in true TDD spirit.

Molecule Configuration

Open the dockerdeploy/roles/deploy/molecule/default/molecule.yml file and replace its contents with the following:

---
dependency:
  name: galaxy
driver:
  name: docker
platforms:
  - name: instance
    image: dcagatay/ubuntu-dind:20.04
    pre_build_image: true
    privileged: true
provisioner:
  name: ansible
verifier:
  name: ansible
scenario:
  test_sequence:
    - dependency
    - lint
    - cleanup
    - destroy
    - syntax
    - create
    - prepare
    - converge
    # - idempotence
    - side_effect
    - verify
    - cleanup
- destroy

This file contains the Molecule configuration for the test, default in this case, and the ingredients of the secret sauce here are:

  • image: dcagatay/ubuntu-dind:20.04
    The image “dcagatay/ubuntu-dind:20.04” will be used to create the target hosts.
    It is, as the name indicates, an Ubuntu 20.04 Docker image with Docker-in-Docker capabilities which allows us to start Docker containers in a Docker container.
  • pre_build_image: true
    Use the above Docker image that is used to create the target host(s) as-is without customizations.
  • privileged: true
    In order to be able to install pip in the target host container the container need to be privileged.

Finally, the idempotence test step is disabled since it is not applicable to this test.

Test Preparation

Create a file named “prepare.yml” in dockerdeploy/roles/deploy/molecule/default/ with the following contents:

---
- name: Tests preparation playbook
  hosts: all
  tasks:<br>
    # Pip is required to install the Docker SDK for Python below.
    - name: Install pip
      ansible.builtin.apt:
        name: python3-pip
        state: present
    # Docker SDK for Python (https://pypi.org/project/docker/) is required by community.docker.docker_container.
    - name: Install Docker SDK for Python
      ansible.builtin.pip:
        name: docker
        executable: pip3
        state: latest

This file contains an Ansible playbook that will be executed prior to any tests being run in order to set up the target host(s), which as earlier are Docker container(s) with Molecule. In this case the Docker SDK for Python is installed since it is required by the community.docker.docker_container module. Before being able to install the Docker SDK, pip for Python 3 is installed since it will be used to install the Docker SDK.
Installing pip and the Docker SDK takes quite some time and creating a Docker image on which these are already installed will reduce the time spent on preparing for the Molecule tests. This is left as an exercise for the reader.

Role Test

Open the dockerdeploy/roles/deploy/molecule/default/converge.yml file and replace its contents with the following:

---
- name: Converge
  hosts: all
  tasks:
    - name: "Deploy Docker containers"
      ansible.builtin.include_role:
        name: "deploy"
      vars:
        deployments:
          - {
            docker_image: "busybox:latest",
            container_name: "container1",
            container_command: ["date", "+%y%m%d"]
          }
          - {
            docker_image: "busybox:latest",
            container_name: "container2",
            container_command: [ "date", "+%d%m%y %H:%M" ]
          }

When the deploy role has been developed, the above will start two containers named “container1” and “container2” using the busybox:latest Docker image and execute the date command with different parameters in the respective container.

Verify Test Outcome

Create a file named “verify_container_logs.yml” in the dockerdeploy/roles/deploy/molecule/default/ directory with the following contents:

# Verifies the container logs for one container and makes it possible to loop over
# multiple tasks, i.e. the tasks in this file.
---
- name: Obtain the expected result from running the date command on the control node
  ansible.builtin.shell: date {{ item.date_command_params }}
  register: expected_date_result
  delegate_to: localhost
- name: Retrieve the actual result from the Docker container logs
  ansible.builtin.command: "docker logs {{ item.container_name }}"
  register: container_logs
- name: Verify that the result from the container is the expected
  ansible.builtin.assert:
    that: "'{{ expected_date_result.stdout }}' == '{{ container_logs.stdout }}'"
    fail_msg: "The actual result '{{ container_logs.stdout }}' does NOT match the actual result '{{ expected_date_result.stdout }}'"
    success_msg: "The actual and expected results matches! The value is: '{{ container_logs.stdout }}'"

The above Ansible tasks obtains the expected result by executing the date command on the control node, retrieves the standard-out logs from a container being the actual result and finally compares the expected and the actual results. Different messages are logged depending on the outcome of the comparison. The reason for locating the tasks in a file of their own is in order to be able to loop over them and verify the output from multiple containers as will be seen below.

Open the dockerdeploy/roles/deploy/molecule/default/verify.yml file and replace its contents with the following:

---
# Verifies the logs produced by the Docker containers deployed by the test.
- name: Verify
  hosts: all
  gather_facts: false
  tasks:
  - name: Retrieve and verify logs of one container
    ansible.builtin.include_tasks: verify_container_logs.yml
    loop:
      - { container_name: "container1", date_command_params: "'+%y%m%d'"}
      - { container_name: "container2", date_command_params: "-u '+%d%m%y %H:%M'"}

The above is the main after-test verification playbook that will be run by Molecule after the tests. It iterates over the list of container name and date command parameters pairs and invokes the tasks in the verify_container_logs.yml file.

Run Test – First Attempt

With the test and test outcome verification in place, it is now time to make an attempt at running the test and confirming that it fails before the deploy role has been implemented.

  • In a terminal window, navigate to the dockerdeploy/roles/deploy directory in the project.
  • Launch the default Molecule test using the following command:
molecule test

During the execution of the Verify playbook, when the test outcome is to be verified, log output similar to the following appears:

PLAY [Verify] ******************************************************************

TASK [Retrieve and verify logs of one container] *******************************
included: /Users/ivan/ansible-docker-deploy-molecule-test/dockerdeploy/roles/deploy/molecule/default/verify_container_logs.yml for instance =&gt; (item={'container_name': 'container1', 'date_command_params': "'+%y%m%d'"})
included: /Users/ivan/ansible-docker-deploy-molecule-test/dockerdeploy/roles/deploy/molecule/default/verify_container_logs.yml for instance =&gt; (item={'container_name': 'container2', 'date_command_params': "-u '+%d%m%y %H:%M'"})

TASK [Obtain the expected result from running the date command on the control node] ***
changed: [instance -&gt; localhost]

TASK [Retrieve the actual result from the Docker container logs] ***************
fatal: [instance]: FAILED! =&gt; {"ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3"}, "changed": true, "cmd": ["docker", "logs", "container1"], "delta": "0:00:00.033648", "end": "2022-11-01 19:23:53.024898", "msg": "non-zero return code", "rc": 1, "start": "2022-11-01 19:23:52.991250", "stderr": "Error: No such container: container1", "stderr_lines": ["Error: No such container: container1"], "stdout": "", "stdout_lines": []}

PLAY RECAP *********************************************************************
instance                   : ok=3    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

WARNING  Retrying execution failure 2 of: ansible-playbook --inventory /Users/ivan/.cache/molecule/deploy/default/inventory --skip-tags molecule-notest,notest /Users/ivan/ansible-docker-deploy-molecule-test/dockerdeploy/roles/deploy/molecule/default/verify.yml
CRITICAL Ansible return code was 2, command was: ['ansible-playbook', '--inventory', '/Users/ivan/.cache/molecule/deploy/default/inventory', '--skip-tags', 'molecule-notest,notest', '/Users/ivan/ansible-docker-deploy-molecule-test/dockerdeploy/roles/deploy/molecule/default/verify.yml']
WARNING  An error occurred during the test sequence action: 'verify'. Cleaning up.
INFO     Running default &gt; cleanup
WARNING  Skipping, cleanup playbook not configured.

We can see that the Verify step fails with the error “No such container: container1”, which is not surprising at this stage since no containers were started.

Deploy Role Implementation

With the test and test-verification in place it is now time to implement the deploy role.
Edit the dockerdeploy/roles/deploy/tasks/main.yml file and replace its contents with the following:

# Deploys one or more Docker containers specified by the supplied deployments list.
#
# docker_image - Docker image that will be used to create the container to be deployed.
# container_name - Name that the deployed container will have.
# container_command - Command to be executed in the container immediately after it has been deployed.
---
- name: Deploys one or more Docker containers
  community.docker.docker_container:
    name: "{{ item.container_name }}"
    image: "{{ item.docker_image }}"
    command: "{{ item.container_command }}"
  loop: "{{ deployments }}"

This implementation of the deploy role completely relies on the community.docker.docker_container module and is very simplistic but it is acceptable in this example since the focus of this article is the testing of the role, not the role itself.

Run Test – Second Attempt

Having implemented the deploy role, it is now time to make a second attempt at running the test.

  • In a terminal window, navigate to the dockerdeploy/roles/deploy directory in the project.
  • Launch the default Molecule test using the following command:
molecule test

During the execution of the Verify playbook, when the test outcome is to be verified, log output similar to the following should appear

TASK [Verify that the result from the container is the expected] ***************
ok: [instance] => {
    "changed": false,
    "msg": "The actual and expected results matches! The value is: '221101'"
}

TASK [Obtain the expected result from running the date command on the control node] ***
changed: [instance -> localhost]

TASK [Retrieve the actual result from the Docker container logs] ***************
changed: [instance]

TASK [Verify that the result from the container is the expected] ***************
ok: [instance] => {
    "changed": false,
    "msg": "The actual and expected results matches! The value is: '011122 20:28'"
}

PLAY RECAP *********************************************************************
instance                   : ok=8    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

INFO     Verifier completed successfully.

We can see that the two results from the containers both matched the expected results and that the test verification completed successfully.

The complete example project is available on GitHub.

Final Note

In the above example, the Docker container that is deployed by the tested Ansible role is contained entirely in the container create by Molecule from the Ubuntu Docker-in-Docker image. It is also possible to mount the host´s Docker daemon in the container created by Molecule. The drawback of the latter approach is the risk of, for example, port collisions or name collisions if multiple Molecule tests are run in parallel.

Happy coding!

Leave a Reply

Your email address will not be published. Required fields are marked *