Molecule Testing
Molecule tests a role by actually running it: it provisions fresh machines, applies the role, applies it again to prove idempotence, runs assertions against the end state, and tears everything down. In this repo, "fresh machines" are real EC2 VMs — the same self-service test VMs developers create by hand — so a passing run means the role worked on a genuine, fleet-shaped target: real systemd, real SELinux, real firewalld, no public IP, downloads through the squid proxy.
Whether a role must have a scenario, and what a green check means for PR review, is policy — see Development Standards → Molecule testing. This page is the mechanics: how the plumbing works and how to run it.
How it works
Molecule normally drives Docker or Vagrant. Neither fits here (no Docker
daemon in the Fargate workspaces, and containers can't honestly exercise
systemd/SELinux/reboots), so our scenarios use Molecule's delegated
("default") driver: Molecule delegates provisioning to a pair of shared
playbooks in molecule/shared/ at the repo root, and they do what a developer
would do by hand:
create.yml— onecoder create --template aws-test-vmper platform inmolecule.yml(async, so multi-VM scenarios build in parallel — the wall-clock cost is the slowest single build, ~2–3 min). Platform names are prefixedmol-and the owner's name is worked into the hostname, so concurrent developers never collide. It then resolves each VM's private IP from EC2, waits for SSH, and hands Molecule the connection details. Existing workspaces are reused: a secondmolecule convergeagainst a living VM skips the build entirely.- Molecule runs the scenario's playbooks over plain SSH (the fleet key your
workspace ssh-agent already holds):
prepare.ymlonce per VM if present,converge.yml(applies the role), the idempotence re-run (must report zero changes), optionalside_effect.yml, thenverify.yml(assertions about the end state — ports listening, services running, files owned correctly). destroy.yml—coder deleteper platform, then pollscoder listuntil they're really gone (a transient CLI error mid-delete doesn't fail a green run).
In CI the same flow runs on the self-hosted runner with
MOLECULE_RUN_SUFFIX=<scenario><run id> appended to every workspace and
hostname, so a CI run and your interactive run of the same role never share a
VM, and neither do two scenarios of the same run.
Running it
Prerequisites: a dev workspace (the coder/aws CLIs, fleet SSH key, and
molecule itself are all preinstalled), with the role cloned inside an
ansible-epic checkout at roles/<name>/ — the scenarios resolve the shared
playbooks and the role code by relative path, so a standalone role clone
outside the monorepo won't work.
Always run from the role's root directory (Molecule discovers
./molecule/ relative to where you run it):
cd roles/<name>
molecule test # the full cycle: destroy → create → converge → idempotence → verify → destroy
molecule converge # create (or reuse) VMs and apply the role — the iteration loop
molecule verify # run the assertions against the current VMs
molecule destroy # tear the VMs down
Two behaviors worth internalizing:
molecule teststarts with a destroy — it guarantees a clean slate, so it always builds fresh VMs. That's what you want for a final proof, and not what you want while iterating.molecule testalso destroys on failure. If you're debugging, iterate withconverge(VMs survive, failures leave the machine inspectable over SSH) and savetestfor the end. Don't start aconvergewhile a failedtest's destroy is still running — it races the dying VMs' IPs.
The VMs are ordinary test-VM workspaces: they show up in coder list, the
8-hour autostop backstops aborted runs, and you can SSH to them mid-scenario
(ssh ec2-user@<ip> with the IP from coder list or the EC2 console — or
just read it out of Molecule's inventory below).
Running other playbooks against Molecule's VMs
Molecule writes a normal Ansible inventory for its VMs, which means anything
that takes -i can run against them — a role's regression test suite, ad hoc
checks:
ansible-playbook -i ~/.ansible/tmp/molecule.*/inventory roles/iris/tests/main.yml -e @extra_vars/users.yml
Writing a scenario for a role
The copy-paste boilerplate — with commentary on every required line — lives in
molecule/README.md at the repo root; start there rather than from
upstream examples, because several of its lines are load-bearing in this
environment specifically:
ANSIBLE_ROLES_PATH(provisioner env) must put the checkout first and keep the default locations after it. Without the pin, Molecule silently tests the image-baked copy of the role instead of your working tree; without the appended defaults, roles that depend on other roles (meta dependencies,include_role) fail syntax-check.- Per-platform
ansible_ssh_common_argshost_vars — Molecule's delegated defaults setIdentitiesOnly=yes, which disables ssh-agent keys, and the fleet key exists only in the agent. - Proxy vars in both shapes (flat
http_proxy/… plus an env dict) — test VMs have no direct egress, and some roles' task-levelenvironment:blocks shadow the play-level one. - Tag-gated roles need
ansible_args: [--tags, install], and every scenario playbook plus the shared create/destroy carry play-leveltags: alwaysso the filter can't cut them off.
Multi-host scenarios (e.g. a PRD/DR pair) are just more entries in
platforms: — each becomes its own VM, built in parallel — with per-host
config under provisioner.inventory.host_vars.<platform>, exactly like
production host_vars/ files. The iris role's scenario is the reference
example, including composing per-host variables over a shared base with
combine() and pointing paired hosts at each other by platform name.
Platform sizing: t3.medium is the default and fits most roles — but watch
for software that sizes itself from system RAM (IRIS's InstanceType: OLTP
demands 9 GB of shared memory on a 16 GB box; the scenario uses NONPROD).
Declare data disks per platform in the same YAML structure the template takes.
CI
Scenario runs in CI are label-gated, never per-push — each run builds
real VMs, so they're deliberate. Apply the cloud's label (molecule-aws
or molecule-azure) to the role PR. Whether a later push re-runs the
check or the label has to be re-applied depends on how that repo wired the
trigger; both wirings are documented in molecule/README.md. What a green
check is worth — and that it must be green on the PR's current head before
approval — is the standards page's
territory.
The run executes on a self-hosted runner created for that job and deleted
when it finishes, so scenarios run in parallel and nothing idles in between.
A repo opts in with a CODER_SESSION_TOKEN secret and secrets: inherit;
without them, AWS falls back to the shared long-lived runner (if a run sits
queued, that workspace is stopped or outdated — coder start / coder update
it), while Azure fails fast, having no long-lived runner to fall back to.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Role changes have no effect on converge | The scenario is missing the ANSIBLE_ROLES_PATH pin and is testing the image-baked role copy — check the error paths for ~/.ansible/roles/<name> |
Permission denied (publickey) connecting to VMs |
Missing per-platform ansible_ssh_common_args override (IdentitiesOnly=yes is blocking the ssh-agent key), or the workspace's ssh-agent isn't loaded (ssh-add -l) |
| Downloads time out on the VMs | Proxy vars missing from scenario group_vars (both flat and dict shapes), or the URL's host isn't in the squid allowlist |
| Syntax stage fails on a dependency role | ANSIBLE_ROLES_PATH replaced the default path instead of appending to it |
create fails at flag parsing, no workspace made |
JSON/YAML passed through --parameter (CSV parser) — the shared playbooks use --rich-parameter-file; so must any manual create |
A failed run left mol-* workspaces behind |
coder delete mol-<platform> — or just run molecule destroy from the role |