perf(molecule): run scenarios in parallel, enable pipelining - #50
Conversation
`make molecule` took 595s. The cost was not the role's logic but per-task connection overhead: 258 task executions across converge+idempotence, each remote task paying ~1.2-1.9s for module transfer over `docker exec`. - ansible/Makefile: fan the six scenarios out with `xargs -P` — they already own distinct container names, publish no ports, and share only read-only state on the control machine. Output carries a [scenario] prefix because runs interleave; JOBS caps the width; `molecule-serial` keeps the readable sequential path. Molecule's own --workers is unusable here: it refuses to run outside collection mode, and this project deliberately has no root galaxy.yml (it would make ansible-lint reinterpret the deploy project). - molecule/*/molecule.yml: set ANSIBLE_PIPELINING — the docker connection plugin declares has_pipelining, so this drops a `docker exec` round trip per remote task (105s -> 82s on the `default` scenario). - molecule/*/molecule.yml: drop the `dependency` step and its config. It never vendored anything: the roles invoker reads `role-file`, not `requirements-file`, and the collections invoker resolved the relative path against the process CWD, so it pointed outside the repo. Both halves only warned. Collections come from `make deps`, now a prerequisite of both molecule targets rather than an unenforced convention in the CI workflow. - molecule/*/molecule.yml: prepend `destroy`. The docker driver creates with `recreate: false`, so a container orphaned by an aborted run — likelier now that scenarios run in parallel — was reused, and converge + idempotence would then pass trivially against an already-converged host. - baseline: install `acl`. decdn_node runs `decdn key-gen` and the `decdn config validate` gate as the unprivileged decdn user, and on Debian Ansible needs ACL support to hand the temp module file across; without it both fail with no rc and the role can only report it after the fact. Molecule never caught this — it connects as root, where the hand-off is a plain chown. Fail-loud guards (hard rule 4): the target refuses an empty or truncated scenario set and a non-positive JOBS. Discovery moved from molecule to a glob, and a glob matching nothing would otherwise run zero scenarios and exit 0 — a green build that tested nothing. Each scenario also announces its own failure by name, since the bare xargs message does not. 595s -> 154s, all six scenarios green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved moderate findings concern redundant dependency installation and unsafe JOBS=00 handling; the failure-status documentation also needs correction.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Speeds Molecule validation through parallel scenarios, pipelining, safer cleanup, and clearer dependency handling.
Changes:
- Adds guarded parallel and serial Molecule targets.
- Enables pipelining and leading container destruction.
- Adds
aclto baseline packages and updates documentation and CI.
File summaries
| File | Summary |
|---|---|
Makefile |
Exposes Molecule execution targets. |
CONTRIBUTING.md |
Documents parallel and serial test execution. |
ansible/roles/baseline/defaults/main.yml |
Adds acl to baseline packages. |
ansible/README.md |
Documents Molecule workflows and scenarios. |
ansible/molecule/validation/molecule.yml |
Updates Molecule configuration and removes the ineffective dependency step. |
ansible/molecule/slow-readiness/molecule.yml |
Updates Molecule configuration and removes the ineffective dependency step. |
ansible/molecule/schema/molecule.yml |
Updates Molecule configuration and removes the ineffective dependency step. |
ansible/molecule/host-env/molecule.yml |
Updates Molecule configuration and removes the ineffective dependency step. |
ansible/molecule/generate-keystore/molecule.yml |
Updates Molecule configuration and removes the ineffective dependency step. |
ansible/molecule/default/molecule.yml |
Enables pipelining, cleanup, and removes the ineffective dependency step. |
ansible/Makefile |
Implements parallel execution and guards. Moderate (3 votes): reject all-zero JOBS values; moderate (1 vote): remove redundant workflow dependency installation; nit (1 vote): correct documented failure-status behavior. |
ansible/galaxy/CHANGELOG.md |
Records the acl package change. |
AGENTS.md |
Updates repository command guidance. |
.github/workflows/molecule.yml |
Runs capped parallel Molecule execution. Moderate (2 votes): remove the redundant standalone make deps step. |
Review details
Suppressed comments (2)
ansible/Makefile:96
- The failure-status note does not match the command below: the
bash -cwrapper converts every failed pipeline toexit 1, so xargs sees 1 and returns 123; it can never see the original 255 and return 124 or stop on that status. Please document the actual normalized behavior so failure diagnosis is not misleading.
# - xargs then exits non-zero: 123 for an ordinary failure, or 124 on a status-255
# abort, which also skips any scenario it had not started yet.
ansible/Makefile:101
- This new prerequisite makes the workflow install Galaxy collections twice:
.github/workflows/molecule.ymlstill runsmake depsbefore invoking this target, and this target invokesdepsagain. Keep the prerequisite so direct local runs remain self-contained, but remove the redundant workflow install step to avoid the extra Galaxy resolution/install during every CI run.
molecule: deps
- Files reviewed: 14/14 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…step Review findings on #50, all three verified before changing anything: - The JOBS guard rejected `0` as a literal but not `00`/`000`, and xargs parses any all-zero string as -P 0 — unlimited, not none. Reject on the numeric value (`10#` to keep leading zeros base-10) instead of matching the digit. `03` stays valid; xargs reads it as 3. - The workflow's standalone `make deps` step is now redundant: the molecule targets take `deps` as a prerequisite, so CI resolved the Galaxy requirements twice per run. Today's transient galaxy.ansible.com reset in the galaxy-build job is the argument for halving that exposure, not adding to it. - The failure-status comment described an abort path the code no longer reaches. `|| exit 1` in the wrapper normalises every scenario failure to 1, which is what keeps xargs off abort-on-255 — so every scenario always runs. Documented the behaviour that is actually there. Verified: JOBS in {0,00,000,'',abc,1x} rejected, {6,03,12} accepted; full suite green in 157s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Filed the follow-ups this PR deliberately left out, so they don't get lost with the branch:
|
There was a problem hiding this comment.
🔵 Needs a closer look
Replace the non-portable sed -u usage and correct the signal-related exit-code documentation.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
ansible/Makefile:114
sed -uis a GNU-sed-only option; the default BSDsedon macOS exits with an illegal-option error, so the new localmake moleculetarget fails before running any scenario on otherwise usable Docker setups. Use a portable prefixing loop (or omit-u; it only affects buffering) instead of requiring GNU sed.
ansible/Makefile:99
- The wrapper normalizes every non-zero
molecule | sedstatus toexit 1, including a molecule process terminated by a signal (for example, SIGTERM becomes 143 before the wrapper exits 1). Therefore xargs will report 123 for that case, not 125 as this comment states; please correct the documented exit-code distinction or preserve the signal status if 125 is intended.
# otherwise cut the suite short. So: 123 when a scenario failed, 125 if one was
# killed by a signal, and every scenario always gets to run.
- Files reviewed: 14/14 changed files
- Comments generated: 0 new
- Review effort level: Lite
* ci: cache Galaxy collections + pip, bound every job (#53) `make deps` resolved against galaxy.ansible.com on every run of `ansible-lint`, `galaxy-build` and `molecule`, uncached and without retry. The galaxy-build job on #50 died on a transient `Connection reset by peer` while resolving the transitive community.crypto and passed unchanged on re-run. Cache `ansible/collections` across all three jobs under one shared key. This is not just a speed-up: given requirements already satisfied on disk, `ansible-galaxy collection install` prints "Nothing to do" and never contacts a server — verified by pointing it at an unreachable Galaxy URL — so a hit is immune to that error class rather than merely faster than it. - Split `restore`/`save` rather than the all-in-one action: the failure being defended against is Galaxy dying part way through an install, and the combined action's post step would persist that half-written tree. `save` is gated on success. - No `restore-keys`: an older cache still satisfies the `>=` ranges in requirements.yml, which would make a `GALAXY_CACHE_EPOCH` bump a silent no-op. - `GALAXY_CACHE_EPOCH` is the manual lever to force a re-resolve, since those `>=` ranges mean a warm cache otherwise freezes the resolved set until requirements.yml changes. Declared in both workflows; they must match. Cache pip via setup-python, keyed on the workflow file — no pip manifest exists and the workflow is where the package list lives. The installs stay unpinned, so this saves the download, not the PyPI round trip; PyPI is not the flaky dependency here. Add `timeout-minutes` to every job, sized off observed runtimes (molecule 45, the ci.yml jobs 5-20). Without one a hung job burns the 360-minute default, and with `cancel-in-progress` some branch-protection setups read the resulting cancelled check as "not failed". Also add each workflow to its own path filter, so a change to this cache wiring is exercised by the PR that makes it — the helm filter already claimed ci.yml for the same reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: derive the Galaxy cache epoch from requirements.yml Review catch: GALAXY_CACHE_EPOCH was duplicated as an env var in ci.yml and molecule.yml, and nothing could catch them diverging — molecule.yml's `paths` do not include ci.yml, and ci.yml's `ansible` filter does not include molecule.yml, so editing the epoch in one file alone produces no failing check while silently splitting the shared cache key in two. A comment saying "bump BOTH" is documentation, not a guard. Fold the counter into ansible/requirements.yml, which the key already hashes. One file, one hash: divergence is now structurally impossible rather than merely discouraged. A bump also matches both workflows' `ansible/**` trigger, so it is exercised immediately instead of on the next unrelated PR. Also correct CONTRIBUTING.md: it claimed both caches use actions/cache's split restore/save. Only the Galaxy one does — pip uses setup-python's built-in cache and has no restore/save pair. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: empty commit to exercise the warm Galaxy cache No-op. The two prior runs each started cold (the first had no cache, the second moved the key by editing requirements.yml). This run is the first with a populated cache, and is what actually demonstrates the claim the change rests on: `make deps` reporting "Nothing to do" with no galaxy.ansible.com request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Why
make moleculetook 595s. Profiling thedefaultscenario (105s) showed the time is not in the role's logic but in per-task connection overhead — 258 task executions across converge+idempotence, each remote task paying ~1.2–1.9s for module transfer overdocker exec(asserts, which run locally, are ~0.03s), plus ~11s/scenario of fixed docker-driver create+destroy wait.-s defaultdestroy(final)What changed
Parallel scenarios (
ansible/Makefile) — the six scenarios already own distinct container names, publish no host ports, and share only read-only state on the control machine, so they fan out withxargs -P. Output carries a[scenario]prefix because runs interleave;JOBS=<n>caps the width;make molecule-serialkeeps the readable sequential path (it stops at the first failure, so it reports less — but legibly).Molecule's own
--workersis not usable here. I tested it: it refuses to run outside collection mode ("--workers > 1 is only supported in collection mode (galaxy.yml required)"), and adding a rootgalaxy.ymldropsansible-lintfrom production to safety profile until three metadata files are duplicated — and worker mode never runscreate, so all six scenarios failUNREACHABLEin ~40s. The Makefile records this so it isn't re-litigated.Pipelining —
community.docker.dockerdeclareshas_pipelining, and molecule's docker driver connects with exactly that plugin, soANSIBLE_PIPELININGremoves adocker execround trip per remote task.Dropped the
dependencystep — it never vendored anything. The roles invoker readsrole-file, notrequirements-file(which it filters out); the collections invoker resolved the relative path against the process CWD, so../../requirements.ymlpointed outside the repo. Both halves only warned — 12 warnings per run. Collections come frommake deps, now a prerequisite of both molecule targets instead of an unenforced ordering convention living in the CI workflow.Leading
destroy— the docker driver creates withrecreate: false, so a container orphaned by an aborted run (likelier now that six run at once) was reused, andconverge+idempotencewould pass trivially against an already-converged host.aclinbaseline_packages—decdn_noderunsdecdn key-genand thedecdn config validategate as the unprivilegeddecdnuser; on Debian Ansible needs ACL support to hand the temp module file across. Without it both fail with norcat all, which the role can only report after the fact (tasks/main.ymlalready namesaclin that message). Molecule never caught this because it connects as root, where the same hand-off is a plainchown. This one ships in the collection, so it has a CHANGELOG entry.Fail-loud guards (AGENTS.md hard rule 4)
Scenario discovery moved from molecule to a make glob, which introduces a new way to pass without testing anything. Three guards close it:
JOBS(xargs -P 0means unlimited);set -euo pipefailkeeps the prefixingsedfrom masking molecule's exit status, and each scenario announces[<name>] SCENARIO FAILED, since the barexargsmessage doesn't say which of six;xargsthen exits non-zero andmakefails.Test plan
make molecule— all six scenarios green, 154s (missing=0 failed=0each), no leftover containers.molecule converge -s default, re-ran the suite; log order isdestroy → create → prepare → converge, andpreparereportedchangedon/var/lib/decdnand/etc/decdn— on a reused container those would have beenok.SCENARIOS→ refused; truncated (SCENARIOS="default schema") → refused;JOBS=0→ refused; a failing scenario →[name] SCENARIO FAILED→xargs123 →make … Error 123.make lint(all pre-commit hooks) andmake lint-ansible(production profile, 0 failures) green.Notes for the reviewer
make molecule JOBS=3rather than the local default of one job per scenario — a hosted runner has far fewer cores and each scenario is a privileged systemd container. That cap is the one value I could not benchmark from a dev box; if this job turns flaky, drop toJOBS=1or swap inmake molecule-serial.aclis unverifiable by this suite by construction (molecule connects as root;baselineisn't exercised in containers) —make checkagainst a real host is what exercises it.default/molecule.yml: with pipelining on,command/shellmodules are fed over stdin, so the remote-tmp path behindbecome_userisn't exercised. Molecule never reproduced that path anyway for the root-connection reason above.host-env/verify.yml:159asserts a PID delta against aRestart=alwaysunit;host-env/schemahave no positive readiness assertion;validationstages.molecule-badbininto the shared project dir rather than its ephemeral dir.🤖 Generated with Claude Code