- Project
- Engineering
- Subject
- Configuration Management
- Date
- 2026-10-20
- Entry
- 048
Testing Ansible Roles with Molecule on Digital Ocean Droplets
For years, I’ve been using Ansible to configure my homelab, blog, and workstations. After testing out Chef and Salt, I’m glad I happened upon Ansible. The tool has grown more and I’ve gotten tons of value out of putting in the effort to learn it.
Early on, I would manually test my code to make sure whatever I added worked. It was tougher since my work used RHEL-based distributions so I would always check my work against centos as well as debian distros. This grew tiresome and I looked for ways to automate the testing.
Soon, I found Vagrant, which let me spin up local virtual machines quickly then test my Ansible roles against them to see if they had the desired impact.
Then, I was able to stand on top of Vagrant with Molecule for testing roles. It allowed you to choose a driver to set up the machine, apply your role, apply it again to see if it was idempotent, then would tear down the VM and report the results. I even got it all running in my own GitLab instance as part of a merge request. Adding the latest Ubuntu release was a two line change and BOOM, it was now part of your tests.
It was glorious…..for a short while. Then, a big breaking change came from the molecule developers after I set up testing on all of my roles. My productivity with the tool was never the same.
I would hard-code the molecule version I needed but the writing was on the wall. The vagrant driver I’d been using was no longer supported and everything would be moving to docker. My issue with that was that lots of my roles configured systemd services and emulating that in a docker container made for a testing nightmare. I could never confirm I was fixing test infrastructure bugs or bugs from my roles.
Frankly, I was spending more time keeping my test framework up to date than I was developing roles to solve real problems. I left my Molecule tests abandoned in my repository for years.
Then enters Claude Code. I’ve had lots of experience building useful web apps for myself. Even in languages and frameworks I wasn’t familiar with.
It got me thinking, is it time to revamp my role testing strategy with Molecule? With the power of the Digital Ocean API, the answer is yes.
For prerequisites, you’ll need Ansible and Molecule installed via your python package manage of choice. You’ll also need doctl to control the creation of the DO droplets.
Below is the Molecule configuration I use to test my ssh_hardening role.
# file: create.yml
# harness: Claude Code
# model: Opus 5.5
---
- name: Create DigitalOcean droplets
hosts: localhost
gather_facts: false
vars:
do_region: "{{ lookup('ansible.builtin.env', 'DO_REGION') | default('nyc3', true) }}"
do_size: "{{ lookup('ansible.builtin.env', 'DO_SIZE') | default('s-1vcpu-1gb', true) }}"
do_project: "{{ lookup('ansible.builtin.env', 'DO_PROJECT') | default('Molecule', true) }}"
do_state_file: "{{ molecule_ephemeral_directory }}/do_state.yml"
do_key_file: "{{ molecule_ephemeral_directory }}/id_ed25519"
do_user: molecule
tasks:
# Accepts DIGITALOCEAN_ACCESS_TOKEN or a token saved with `doctl auth init`.
- name: Check DigitalOcean authentication
ansible.builtin.command:
argv: [doctl, account, get]
register: do_account
changed_when: false
failed_when: false
- name: Assert DigitalOcean authentication works
ansible.builtin.assert:
that: do_account.rc == 0
fail_msg: >-
doctl cannot authenticate. Run `doctl auth init` or set DIGITALOCEAN_ACCESS_TOKEN.
{{ do_account.stderr | default(do_account.msg | default('')) }}
- name: List DigitalOcean projects
ansible.builtin.command:
argv: [doctl, projects, list, --output=json]
register: do_projects
changed_when: false
- name: Set project ID
ansible.builtin.set_fact:
do_project_id: "{{ do_projects.stdout | from_json | selectattr('name', 'eq', do_project) | map(attribute='id') | first | default('') }}"
- name: Assert project exists
ansible.builtin.assert:
that: do_project_id | length > 0
fail_msg: DigitalOcean project '{{ do_project }}' not found. Create it or set DO_PROJECT.
# Unique per run so parallel runs (macOS, Linux, CI) do not collide.
- name: Generate run ID
ansible.builtin.set_fact:
do_run_id: "{{ lookup('ansible.builtin.password', '/dev/null', chars=['ascii_lowercase', 'digits'], length=8) }}"
- name: Generate ephemeral SSH key
ansible.builtin.command:
argv: [ssh-keygen, -q, -t, ed25519, -N, "", -C, "molecule-{{ do_run_id }}", -f, "{{ do_key_file }}"]
creates: "{{ do_key_file }}"
# Registering a key stops DigitalOcean from setting and emailing a root password.
- name: Register SSH key with DigitalOcean
ansible.builtin.command:
argv:
- doctl
- compute
- ssh-key
- import
- molecule-{{ do_run_id }}
- --public-key-file={{ do_key_file }}.pub
- --output=json
register: do_ssh_key
changed_when: true
# Write state before droplets exist, so destroy can always clean up.
- name: Write DigitalOcean state
ansible.builtin.copy:
dest: "{{ do_state_file }}"
mode: "0600"
content: |
run_id: {{ do_run_id }}
ssh_key_id: {{ (do_ssh_key.stdout | from_json | first).id }}
# ssh_pwauth makes cloud-init write 50-cloud-init.conf with
# PasswordAuthentication yes, which the role must override.
- name: Write cloud-init user data
ansible.builtin.copy:
dest: "{{ molecule_ephemeral_directory }}/user-data.yml"
mode: "0600"
content: |
#cloud-config
ssh_pwauth: true
users:
- name: {{ do_user }}
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- {{ lookup('ansible.builtin.file', do_key_file ~ '.pub') }}
- name: Create droplets
ansible.builtin.command:
argv:
- doctl
- compute
- droplet
- create
- "molecule-{{ item.name }}-{{ do_run_id }}"
- --image={{ item.image }}
- --size={{ do_size }}
- --region={{ do_region }}
- --project-id={{ do_project_id }}
- --ssh-keys={{ (do_ssh_key.stdout | from_json | first).id }}
- --tag-names=molecule,molecule-{{ do_run_id }}
- --user-data-file={{ molecule_ephemeral_directory }}/user-data.yml
- --wait
- --output=json
loop: "{{ molecule_yml.platforms }}"
loop_control:
label: "{{ item.name }}"
register: do_droplets
changed_when: true
- name: Write Molecule instance config
ansible.builtin.copy:
dest: "{{ molecule_instance_config }}"
mode: "0600"
content: "{{ do_instances | to_nice_yaml }}"
vars:
do_instances: >-
{%- set out = [] -%}
{%- for r in do_droplets.results -%}
{%- set d = r.stdout | from_json | first -%}
{%- set _ = out.append({
'instance': r.item.name,
'address': d.networks.v4 | selectattr('type', 'eq', 'public') | map(attribute='ip_address') | first,
'user': do_user,
'port': 22,
'identity_file': do_key_file,
}) -%}
{%- endfor -%}
{{ out }}
- name: Wait for SSH
ansible.builtin.wait_for:
host: "{{ item.address }}"
port: 22
search_regex: OpenSSH
timeout: 300
loop: "{{ lookup('ansible.builtin.file', molecule_instance_config) | from_yaml }}"
loop_control:
label: "{{ item.instance }}"
...
# file: converge.yml
---
- name: Converge
hosts: all
become: true
roles:
- ssh_hardening
...
# file: destroy.yml
# harness: Claude Code
# model: Opus 5.5
---
- name: Destroy DigitalOcean droplets
hosts: localhost
gather_facts: false
vars:
do_state_file: "{{ molecule_ephemeral_directory }}/do_state.yml"
tasks:
- name: Read DigitalOcean state
ansible.builtin.stat:
path: "{{ do_state_file }}"
register: do_state_stat
- name: Destroy run resources
when: do_state_stat.stat.exists
vars:
do_state: "{{ lookup('ansible.builtin.file', do_state_file) | from_yaml }}"
block:
- name: Delete droplets
ansible.builtin.command:
argv:
- doctl
- compute
- droplet
- delete
- --tag-name=molecule-{{ do_state.run_id }}
- --force
register: do_delete
changed_when: do_delete.rc == 0
failed_when: do_delete.rc != 0 and 'not found' not in do_delete.stderr
- name: Delete SSH key
ansible.builtin.command:
argv: [doctl, compute, ssh-key, delete, "{{ do_state.ssh_key_id }}", --force]
register: do_key_delete
changed_when: do_key_delete.rc == 0
failed_when: do_key_delete.rc != 0 and 'not found' not in do_key_delete.stderr
- name: Remove DigitalOcean state
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "{{ do_state_file }}"
- "{{ molecule_ephemeral_directory }}/id_ed25519"
- "{{ molecule_ephemeral_directory }}/id_ed25519.pub"
- "{{ molecule_ephemeral_directory }}/user-data.yml"
- name: Write empty instance config
ansible.builtin.copy:
dest: "{{ molecule_instance_config }}"
content: "[]\n"
mode: "0600"
...
# file: molecule.yml
# harness: Claude Code
# model: Opus 5.5
---
# Droplets are created and destroyed with doctl.
# Requires doctl, authenticated with `doctl auth init` or DIGITALOCEAN_ACCESS_TOKEN.
# Optional: DO_REGION (default nyc3), DO_SIZE (default s-1vcpu-1gb),
# DO_PROJECT (default Molecule; the project must already exist).
driver:
name: default
options:
managed: true
# Slugs: doctl compute image list-distribution --public
platforms:
- name: ubuntu2604
image: ubuntu-26-04-x64
- name: ubuntu2404
image: ubuntu-24-04-x64
- name: ubuntu2204
image: ubuntu-22-04-x64
- name: debian13
image: debian-13-x64
provisioner:
name: ansible
env:
ANSIBLE_ROLES_PATH: ${MOLECULE_PROJECT_DIRECTORY}/roles
scenario:
test_sequence:
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- verify
- destroy
...
# file: prepare.yml
# harness: Claude Code
# model: Opus 5.5
---
- name: Wait for droplets to finish booting
hosts: all
gather_facts: false
tasks:
- name: Wait for SSH login
ansible.builtin.wait_for_connection:
timeout: 300
# Exit code 2 means done with recoverable errors, which DO images often report.
- name: Wait for cloud-init
ansible.builtin.command: cloud-init status --wait
register: cloud_init_status
changed_when: false
failed_when: cloud_init_status.rc not in [0, 2]
# Proves the test starts from an insecure baseline.
- name: Assert cloud-init enabled password logins
ansible.builtin.command: grep -Eiqr '^PasswordAuthentication yes' /etc/ssh/sshd_config /etc/ssh/sshd_config.d
become: true
changed_when: false
...
# file: verify.yml
# harness: Claude Code
# model: Opus 5.5
---
- name: Verify effective sshd configuration
hosts: all
become: true
gather_facts: false
vars:
expected:
permitrootlogin: "no"
passwordauthentication: "no"
kbdinteractiveauthentication: "no"
authenticationmethods: publickey
permitemptypasswords: "no"
maxauthtries: "3"
x11forwarding: "no"
allowtcpforwarding: "no"
allowagentforwarding: "no"
permittunnel: "no"
loglevel: VERBOSE
tasks:
# sshd -T prints the config after includes and defaults are applied.
- name: Read effective sshd configuration
ansible.builtin.command: sshd -T
register: sshd_t
changed_when: false
- name: Assert hardened settings are in effect
ansible.builtin.assert:
that: (item.key ~ ' ' ~ item.value) in sshd_t.stdout_lines
fail_msg: "Expected '{{ item.key }} {{ item.value }}' in sshd -T output."
quiet: true
loop: "{{ expected | dict2items }}"
loop_control:
label: "{{ item.key }}"
# file: cleanup.yml
# harness: Claude Code
# model: Opus 5.5
---
# Molecule runs this on failure, before destroy. Show sshd state for debugging.
- name: Collect sshd diagnostics
hosts: all
become: true
gather_facts: false
ignore_unreachable: true
tasks:
- name: Read recent sshd logs
ansible.builtin.command: journalctl --unit=ssh --lines=50 --no-pager
register: sshd_journal
changed_when: false
failed_when: false
- name: Show recent sshd logs
ansible.builtin.debug:
var: sshd_journal.stdout_lines
when: sshd_journal.stdout_lines is defined
...
The destroy file cleans up before and after your tests so you aren’t billed. Create and prepare set up the droplets. Converge actually calls your ansible role then verifier tests your role via assertions.
AI is exceptionally good at this stuff. I plan on slowing backfilling my roles with proper tests across Ubuntu and Debian droplets to confirm I’m not adding regressions. I’ll also set this up to run in a Forgejo Action when I open up pull requests.