Vote up!

1

How to setup test-driven development with Molecule in Ansible

Molecule is a testing framework designed for Ansible roles, collections, and playbooks. In the Ansible ecosystem, it provides a structured workflow to develop and verify infrastructure as code. 

From a broader perspective, Molecule encourages test-driven infrastructure development (TDD). Instead of deploying roles directly to production and hoping they work, developers can validate them locally or in CI pipelines with Molecule. It supports different testing strategies, linting, idempotence, and a plugin ecosystem. 

While being a little tricky to setup, Molecule enables the developer the Correct Thinking (TM) formalisms. TDD allows the developer to validate a finished step, and mark it as complete. TDD allows thinking in pieces: rather than being overwhelmed by a large task, the developer can implement and validate small pieces of the functionality being implemented - and with such incremental changes, the whole project can be completed.

~ * ~ * ~ * ~

Molecule underwent breaking changes between some releases, and some documentation online appears to be incorrect. This article works with Molecule version 25. Here, I'm just going to setup a minimal scaffold, to enable running a simple test on a single remove machine. The reader can take it from there, customizing the scaffold for their own specific needs.

Let's assume we have an ansible project with the following folder structure:

wco_ansible/
  inventory/
    ants.yml
    do.yml
  playbooks/
    setup_mongo.yml
    setup_ubuntu.yml
  requirements.yml
  roles/
    ror/
      files/
        root/
          _screenrc
      meta/
        main.yml
      molecule/
        ...
      requirements.yml
      tasks/
        packaged_app.yml
      templates/
        etc/
          mongo.conf
  vars/
    packaged_app_1.yml

We will be testing a specific task in the role ror. First, let's setup the structure of wco_ansible/roles/ror/molecule like so:

wco_ansible/roles/ror/molecule/
  README.txt
  default/
    converge.yml
    create.yml
    destroy.yml
    molecule.yml
    verify.yml
  

And the contents of the files:

## requirements.yml
collections:
  - name: community.docker
    version: ">=3.10.4"
  - name: community.general

.

## converge.yml
- name: Fail if molecule group is missing
  hosts: localhost
  tasks:
    - name: Print some info
      ansible.builtin.debug:
        msg: "{{ groups }}"
    - name: Assert group existence
      ansible.builtin.assert:
        that: "'molecule' in groups"
        fail_msg: |
          molecule group was not found inside inventory groups: {{ groups }}
- name: Converge
  hosts: molecule
  # We disable gather facts because it would fail due to our container not
  # having python installed. This will not prevent use from running 'raw'
  # commands. Most molecule users are expected to use containers that already
  # have python installed in order to avoid notable delays installing it.
  # gather_facts: false
  tasks:
    - name: Check uname
      ansible.builtin.raw: uname -a
      register: result
      changed_when: false
    - name: Print some info
      ansible.builtin.assert:
        that: result.stdout is ansible.builtin.search("^Linux")
    - name: "+++ +++ Check Python version is >= 3.8"
      command: python3 --version
      register: py_ver
      changed_when: false
    - name: "+++ +++ Assert correct Python version"
      assert:
        that:
          - py_ver.stdout is match("Python 3\\.[189].*|Python [4-9].*")

The following is important. Specifically, adding container to molevule_inventory seems to matter.

## create.yml
- name: Create
  hosts: localhost
  gather_facts: false
  vars:
    molecule_inventory:
      all:
        hosts: {}
        # molecule: {}
  tasks:
    - name: Create a container
      community.docker.docker_container:
        name: "{{ item.name }}"
        image: "{{ item.image }}"
        state: started
        command: sleep 1d
        log_driver: json-file
      register: result
      loop: "{{ molecule_yml.platforms }}"
    - name: Print some info
      ansible.builtin.debug:
        msg: "{{ result.results }}"
    - name: Fail if container is not running
      when: >
        item.container.State.ExitCode != 0 or
        not item.container.State.Running
      ansible.builtin.include_tasks:
        file: tasks/create-fail.yml
      loop: "{{ result.results }}"
      loop_control:
        label: "{{ item.container.Name }}"
    - name: Add container to molecule_inventory
      vars:
        inventory_partial_yaml: |
          all:
            children:
              molecule:
                hosts:
                  "{{ item.name }}":
                    ansible_connection: {{ item.ansible_connection }}
                    ansible_user: {{ item.ansible_user }}
                    ansible_host: {{ item.ansible_host }}
                    ansible_port: {{ item.ansible_port }}
                    ansible_ssh_common_args: {{ item.ansible_ssh_common_args }}
                    ansible_ssh_private_key_file: {{ item.ansible_ssh_private_key_file }}
      ansible.builtin.set_fact:
        molecule_inventory: >
          {{ molecule_inventory | combine(inventory_partial_yaml | from_yaml, recursive=true) }}
      loop: "{{ molecule_yml.platforms }}"
      loop_control:
        label: "{{ item.name }}"
    - name: Dump molecule_inventory
      ansible.builtin.copy:
        content: |
          {{ molecule_inventory | to_yaml }}
        dest: "{{ molecule_ephemeral_directory }}/inventory/molecule_inventory.yml"
        mode: "0600"
    - name: Force inventory refresh
      ansible.builtin.meta: refresh_inventory
    - name: Fail if molecule group is missing
      ansible.builtin.assert:
        that: "'molecule' in groups"
        fail_msg: |
          molecule group was not found inside inventory groups: {{ groups }}
      run_once: true # noqa: run-once[task]
# we want to avoid errors like "Failed to create temporary directory"
- name: Validate that inventory was refreshed
  hosts: molecule
  gather_facts: false
  tasks:
    - name: Check uname
      ansible.builtin.raw: uname -a
      register: result
      changed_when: false
    - name: Display uname info
      ansible.builtin.debug:
        msg: "{{ result.stdout }}"

.

## destroy.yml
- name: Destroy molecule containers
  hosts: molecule
  gather_facts: false
  tasks:
    - name: Stop and remove container
      delegate_to: localhost
      community.docker.docker_container:
        name: "{{ inventory_hostname }}"
        state: absent
        auto_remove: true

- name: Remove dynamic molecule inventory
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Remove dynamic inventory file
      ansible.builtin.file:
        path: "{{ molecule_ephemeral_directory }}/inventory/molecule_inventory.yml"
        state: absent

In the following file, you want to have both platforms and provisioner keys. They hold the same data for me... The host, port, and other arguments in provisioner are definitely important. The ones in platforms may be optional - but I use them in create.yml so for now they have to stay. Of note, the structure of platforms can probably be simplified:

## molecule.yml
dependency:
  name: galaxy
  options:
    requirements-file: requirements.yml
# driver:
#   name: ssh
platforms:
  - name: do-ubuntu24-3
    ansible_connection: ssh
    ansible_user: root
    ansible_host: "x.x.x.x"
    ansible_port: 22
    ansible_ssh_common_args: "-o StrictHostKeyChecking=no"
    ansible_ssh_private_key_file: ~/.ssh/your-key
    groups:
      - molecule
    image: piousbox/ubuntu-sshd:0.0.0 ## not used, but presence is required

provisioner:
  name: ansible
  inventory:
    host_vars:
      do-ubuntu24-3:
        ansible_connection: ssh
        ansible_host: x.x.x.x
        ansible_port: 22
        ansible_user: root
        ansible_ssh_common_args: "-o StrictHostKeyChecking=no"
        ansible_ssh_private_key_file: ~/.ssh/your-key

Now, a comment on ssh vs docker driver. I use both. Since I drive docker with ansible, often I need docker inside docker, or something similar, and in the end it is easier for me to spin up a virtual machine elsewhere and do testing there via ssh. On the other hand, the development laptop definitely uses a lot of docker, so the docker driver for molecule is valuable and useful. I have my own image that allows systemd, and ssh. You can read the article on How I set up the docker image with systemd and sshd, for more info on the topic.

.^.

Please login or register to post a comment.