Skip to content

Commit 47022e4

Browse files
thirasclaude
andcommitted
perf(molecule): run scenarios in parallel, enable pipelining
`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>
1 parent 5982672 commit 47022e4

14 files changed

Lines changed: 178 additions & 51 deletions

File tree

‎.github/workflows/molecule.yml‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ jobs:
3636
- name: Install Galaxy collections
3737
run: make deps
3838
- name: molecule test
39-
# --all runs every scenario under ansible/molecule/: default
40-
# (operator-provisioned keystore + rendered-value assertions), schema
41-
# (config key-set drift against the upstream field list), validation
42-
# (bad knobs must be rejected by the role's own asserts), generate-keystore
43-
# (opt-in host-side wallet generation), host-env (host-provisioned
44-
# /etc/decdn/decdn.env — no secret on the control machine), and
45-
# slow-readiness (advisory /metrics probe timeout).
46-
run: molecule test --all
39+
# `make molecule` runs every scenario under ansible/molecule/ and fans them out
40+
# in parallel; ansible/Makefile documents the set and the fail-loud guards.
41+
#
42+
# JOBS is capped at 3 rather than the default (one job per scenario, currently
43+
# 6): every scenario is a privileged systemd container, and they share this
44+
# runner's cores and cgroup hierarchy. If this job turns flaky, drop to JOBS=1
45+
# or swap in `make molecule-serial` — the latter also serialises the output.
46+
run: make molecule JOBS=3

‎AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ deploys (its targets must run from `ansible/`). `make help` lists root targets.
103103
make hooks # one-time: install pre-commit git hook (pip install pre-commit first)
104104
make lint # all pre-commit hooks on all files (hygiene, shellcheck, yamllint, markdown)
105105
make lint-ansible # vendor collections + full ansible-lint (production profile)
106-
make molecule # containerised converge/verify of the decdn_node role (needs Docker)
106+
make molecule # containerised converge/verify of the decdn_node role — all six
107+
# scenarios in parallel (needs Docker); cap with JOBS=<n>
108+
make molecule-serial # the same suite one scenario at a time (readable failure output)
107109
make lint-helm # chart: helm lint + render tests + kubeconform + schema keys (needs helm, yq, Docker)
108110
make security # = security-ansible + security-helm (KICS over the rendered chart; needs helm)
109111

‎CONTRIBUTING.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ Run it on demand with `make lint-ansible`, or `pre-commit run ansible-lint --hoo
3737
run via **pre-commit locally only** (`make hooks` / `make lint`), not in CI.
3838
- **`molecule.yml`** — containerised converge + idempotence + verify of the `decdn_node`
3939
role (privileged systemd Docker container; scoped to `ansible/**`). Run locally with
40-
`make molecule` (needs Docker).
40+
`make molecule` (needs Docker) — it runs all six scenarios in parallel, so reach for
41+
`make molecule-serial` when you need to read a failure in order.
4142

4243
## Supply-chain / pinning rules
4344

‎Makefile‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Convenience targets for the deCDN DevOps monorepo.
22
# Run from the repo root. Ansible-specific work is delegated to ansible/Makefile.
3-
.PHONY: help hooks lint lint-ansible lint-helm security security-ansible security-helm molecule galaxy-build galaxy-check
3+
.PHONY: help hooks lint lint-ansible lint-helm security security-ansible security-helm molecule molecule-serial galaxy-build galaxy-check
44
SHELL := /bin/bash
55

66
# KICS runs straight from the engine image, pinned by digest. This target IS the
@@ -59,9 +59,12 @@ security-helm: ## KICS scan of the decdn-node chart's rendered manifests (
5959
lint-helm: ## helm lint + render tests + kubeconform + shared schema-key check (needs helm, yq, python3>=3.11, docker)
6060
KUBECONFORM="docker run --rm -i $(KUBECONFORM_IMAGE)" $(CHART)/tests/render-test.sh
6161

62-
molecule: ## containerised converge/verify of the decdn_node role (needs Docker)
62+
molecule: ## containerised converge/verify of the decdn_node role, scenarios in parallel (needs Docker)
6363
$(MAKE) -C ansible molecule
6464

65+
molecule-serial: ## same suite, one scenario at a time (readable output on failure)
66+
$(MAKE) -C ansible molecule-serial
67+
6568
galaxy-build: ## stage + build the decdn.node Galaxy collection artifact
6669
$(MAKE) -C ansible build
6770

‎ansible/Makefile‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Convenience targets for the deCDN Ansible project.
22
# Always run from the ansible/ directory.
3-
.PHONY: deps lint check deploy molecule build galaxy-check
3+
.PHONY: deps lint check deploy molecule molecule-serial build galaxy-check
44
SHELL := /bin/bash
55

66
# Install the required Galaxy collections (>= constraints in requirements.yml)
@@ -67,11 +67,51 @@ deploy:
6767
# stub daemon (needs Docker; a privileged systemd container). See molecule/.
6868
# Scenarios: default (rendered values + idempotence), schema (config key-set drift
6969
# against the upstream field list), validation (bad knobs must be rejected by the
70-
# role's own asserts), generate-keystore (opt-in host-side wallet), slow-readiness
71-
# (advisory /metrics probe timeout).
72-
# `--all` runs every scenario: `default` (operator-provisioned keystore) and
73-
# `generate-keystore` (opt-in host-side wallet generation).
74-
molecule:
70+
# role's own asserts), generate-keystore (opt-in host-side wallet), host-env
71+
# (host-provisioned /etc/decdn/decdn.env), slow-readiness (advisory /metrics probe
72+
# timeout).
73+
#
74+
# The scenarios run concurrently: distinct container names, no published host ports,
75+
# per-scenario ephemeral dirs, and what they share on the control machine (the
76+
# vendored collections, roles/, the stub under default/files/) they only read.
77+
# 595s -> 151s on a 16-core box. They DO share one Docker daemon and the host cgroup
78+
# hierarchy — every platform is a privileged systemd container — which is why CI caps
79+
# JOBS. Output interleaves, hence the [scenario] prefix; `molecule-serial` is the
80+
# escape hatch when a failure needs reading in order. Override the fan-out width with
81+
# e.g. `make molecule JOBS=2` on a small machine.
82+
#
83+
# Molecule's own --workers is not usable here: it refuses to run outside collection
84+
# mode ("--workers > 1 is only supported in collection mode (galaxy.yml required)"),
85+
# and this project deliberately has no galaxy.yml at its root — that would make
86+
# ansible-lint reinterpret the deploy project as a collection (see galaxy/README.md).
87+
#
88+
# Fail loud (hard rule 4), in three places, because a test harness that passes when it
89+
# did not run is worse than a slow one:
90+
# - the guard below refuses an empty or truncated scenario set. Discovery moved from
91+
# molecule to a glob, and a glob that matches nothing (wrong cwd, renamed layout)
92+
# would otherwise run zero scenarios and exit 0 — a green build that tested nothing.
93+
# - pipefail stops the [scenario] prefixing pipe from masking molecule's status, and
94+
# each scenario announces its own failure by name (the bare xargs message does not).
95+
# - xargs then exits non-zero: 123 for an ordinary failure, or 124 on a status-255
96+
# abort, which also skips any scenario it had not started yet.
97+
SCENARIOS := $(notdir $(patsubst %/,%,$(dir $(wildcard molecule/*/molecule.yml))))
98+
SCENARIO_DIRS := $(notdir $(patsubst %/,%,$(wildcard molecule/*/)))
99+
JOBS ?= $(words $(SCENARIOS))
100+
101+
molecule: deps
102+
@test -n "$(strip $(SCENARIOS))" && test "$(strip $(SCENARIOS))" = "$(strip $(SCENARIO_DIRS))" || { \
103+
echo "scenario discovery failed: molecule.yml in [$(SCENARIOS)] but scenario dirs are [$(SCENARIO_DIRS)]"; \
104+
echo "refusing to run a silently-truncated suite (this target must run from ansible/)"; exit 1; }
105+
@case '$(JOBS)' in ''|*[!0-9]*|0) \
106+
echo "JOBS must be a positive integer (got '$(JOBS)'); note xargs -P 0 means unlimited"; exit 1 ;; esac
107+
@printf '%s\n' $(SCENARIOS) | xargs -P $(JOBS) -I{} \
108+
bash -c 'set -euo pipefail; molecule test -s {} --no-command-borders 2>&1 | sed -u "s/^/[{}] /" \
109+
|| { echo "[{}] SCENARIO FAILED"; exit 1; }'
110+
111+
# The same suite, one scenario at a time (molecule's own --all). It stops at the FIRST
112+
# failing scenario (--continue-on-failure defaults off), so it reports less than the
113+
# parallel target — but readably, which is the point.
114+
molecule-serial: deps
75115
molecule test --all
76116

77117
# --- Galaxy collection (decdn.node) ------------------------------------------

‎ansible/README.md‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,22 @@ primitives underneath it. All take `--dry-run`. See
191191
```bash
192192
make lint # yamllint + ansible-lint (production profile)
193193
ansible-playbook playbooks/site.yml --syntax-check
194-
make molecule # containerised converge + idempotence + verify (needs Docker)
194+
make molecule # all six molecule scenarios, in parallel (needs Docker)
195+
make molecule JOBS=2 # …capped to two at a time on a small machine
196+
make molecule-serial # …one at a time, when a failure needs readable output
195197
```
196198

197-
`make molecule` converges the **`decdn_node`** role in a privileged systemd container
198-
against a stub daemon (`molecule/default/`): it installs via the `manual` method (no
199+
`make molecule` runs every scenario under `molecule/`: **`default`** (described below),
200+
`schema` (config key-set drift against the upstream field list), `validation` (bad knobs
201+
must be rejected by the role's own asserts), `generate-keystore` (opt-in host-side
202+
wallet), `host-env` (host-provisioned `/etc/decdn/decdn.env`) and `slow-readiness`
203+
(advisory `/metrics` probe timeout). They are independent, so they run concurrently —
204+
~151s instead of ~595s — and each line of output is prefixed with its scenario name
205+
because the runs interleave. `make molecule-serial` is the escape hatch when that
206+
interleaving gets in the way of reading a failure.
207+
208+
The `default` scenario converges the **`decdn_node`** role in a privileged systemd
209+
container against a stub daemon: it installs via the `manual` method (no
199210
published release needed), stages a placeholder keystore, renders `node.toml` + the
200211
hardened unit, starts the service, and passes the role's own `/metrics` readiness probe;
201212
`verify.yml` then asserts the node user, valid TOML, a valid systemd unit, loopback-only

‎ansible/galaxy/CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ collection adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- `baseline_packages` now includes `acl`. `decdn_node` runs two tasks as the
12+
unprivileged `decdn` user (`decdn key-gen`, and the `decdn config validate` gate),
13+
and on Debian Ansible needs ACL support to hand the temp module file to that user.
14+
Without it both fail without an `rc`, which the role can only report after the fact
15+
("becoming the unprivileged decdn user needs the `acl` package on this host").
16+
917
### Removed
1018

1119
- `decdn_delivery_floor` and `decdn_rate_bounds_poll_interval_sec`: upstream

‎ansible/molecule/default/molecule.yml‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
# ssh_hardening (sysctl, mount, auth, sshd) and fail2ban — none of which behave
1212
# meaningfully in a throwaway systemd container. It is validated on real hosts via
1313
# `make check` instead.
14-
dependency:
15-
name: galaxy
16-
options:
17-
requirements-file: ../../requirements.yml
14+
# No `dependency` step (and no dependency config): molecule never vendored anything
15+
# here. Its roles invoker reads `role-file`, not `requirements-file`, and its
16+
# collections invoker resolves a relative path against the process CWD — so
17+
# ../../requirements.yml pointed outside the repo and both halves just warned on every
18+
# run. The collections come from `make deps` -> ansible/collections, surfaced by
19+
# ANSIBLE_COLLECTIONS_PATH below.
1820
driver:
1921
name: docker
2022
platforms:
@@ -34,11 +36,24 @@ provisioner:
3436
env:
3537
ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/roles"
3638
ANSIBLE_COLLECTIONS_PATH: "${MOLECULE_PROJECT_DIRECTORY}/collections"
39+
# The docker connection plugin supports pipelining, which drops a `docker exec`
40+
# round trip per remote task — 105s -> 82s measured on this scenario. Safe here:
41+
# the containers have no sudo requiretty.
42+
# Known coverage boundary: with pipelining on, command/shell modules are fed over
43+
# stdin, so the remote-tmp path behind `become_user` is not exercised. Molecule
44+
# connects as root, where that path is a plain chown, so it never reproduced the
45+
# real-host `acl` failure the role warns about (tasks/main.yml) either way.
46+
ANSIBLE_PIPELINING: "true"
3747
verifier:
3848
name: ansible
3949
scenario:
4050
test_sequence:
41-
- dependency
51+
# The leading `destroy` is deliberate. The docker driver creates with
52+
# `recreate: false`, so a container orphaned by an aborted run (Ctrl-C, OOM —
53+
# both likelier now that the scenarios run in parallel) would be REUSED, and
54+
# `converge` + `idempotence` would then pass trivially against a host that was
55+
# already converged by the previous run.
56+
- destroy
4257
- create
4358
- prepare
4459
- converge

‎ansible/molecule/generate-keystore/molecule.yml‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
# (host-level hardening is meaningless in a throwaway systemd container) — see
99
# ../default/molecule.yml. This scenario reuses the shared stub daemon under
1010
# ../default/files/decdn-node-stub (which implements a `key-gen` subcommand).
11-
dependency:
12-
name: galaxy
13-
options:
14-
requirements-file: ../../requirements.yml
11+
# No `dependency` step (and no dependency config): molecule never vendored anything
12+
# here. Its roles invoker reads `role-file`, not `requirements-file`, and its
13+
# collections invoker resolves a relative path against the process CWD — so
14+
# ../../requirements.yml pointed outside the repo and both halves just warned on every
15+
# run. The collections come from `make deps` -> ansible/collections, surfaced by
16+
# ANSIBLE_COLLECTIONS_PATH below.
1517
driver:
1618
name: docker
1719
platforms:
@@ -30,13 +32,19 @@ provisioner:
3032
env:
3133
ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/roles"
3234
ANSIBLE_COLLECTIONS_PATH: "${MOLECULE_PROJECT_DIRECTORY}/collections"
35+
# The docker connection plugin supports pipelining, which drops a `docker exec`
36+
# round trip per remote task (measured at -22% on the `default` scenario, which
37+
# also carries the coverage note). Safe here: the containers have no sudo requiretty.
38+
ANSIBLE_PIPELINING: "true"
3339
verifier:
3440
name: ansible
3541
scenario:
3642
# No `prepare` — the role must generate the wallet itself. `idempotence` proves
3743
# the stat/`creates:` gating makes a second converge a no-op (nothing re-minted).
3844
test_sequence:
39-
- dependency
45+
# Leading `destroy` so an orphaned container is never reused — see
46+
# ../default/molecule.yml.
47+
- destroy
4048
- create
4149
- converge
4250
- idempotence

‎ansible/molecule/host-env/molecule.yml‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
# (host-level hardening is meaningless in a throwaway systemd container) — see
1111
# ../default/molecule.yml. This scenario reuses the shared stub daemon under
1212
# ../default/files/decdn-node-stub.
13-
dependency:
14-
name: galaxy
15-
options:
16-
requirements-file: ../../requirements.yml
13+
# No `dependency` step (and no dependency config): molecule never vendored anything
14+
# here. Its roles invoker reads `role-file`, not `requirements-file`, and its
15+
# collections invoker resolves a relative path against the process CWD — so
16+
# ../../requirements.yml pointed outside the repo and both halves just warned on every
17+
# run. The collections come from `make deps` -> ansible/collections, surfaced by
18+
# ANSIBLE_COLLECTIONS_PATH below.
1719
driver:
1820
name: docker
1921
platforms:
@@ -32,6 +34,10 @@ provisioner:
3234
env:
3335
ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/roles"
3436
ANSIBLE_COLLECTIONS_PATH: "${MOLECULE_PROJECT_DIRECTORY}/collections"
37+
# The docker connection plugin supports pipelining, which drops a `docker exec`
38+
# round trip per remote task (measured at -22% on the `default` scenario, which
39+
# also carries the coverage note). Safe here: the containers have no sudo requiretty.
40+
ANSIBLE_PIPELINING: "true"
3541
verifier:
3642
name: ansible
3743
scenario:
@@ -40,7 +46,9 @@ scenario:
4046
# converge must restart the daemon (verify.yml compares MainPID across it). The
4147
# order matters: idempotence has to run while the file is still untouched.
4248
test_sequence:
43-
- dependency
49+
# Leading `destroy` so an orphaned container is never reused — see
50+
# ../default/molecule.yml.
51+
- destroy
4452
- create
4553
- prepare
4654
- converge

0 commit comments

Comments
 (0)