Ansible CI Toolkit v1: the year I stopped trusting my own image
The Ansible CI image is ten years old, has run in production for at least eight and has been public since 2022 (episode one ); in July 2026 it finally earns its version 1.0.0. Nothing about the concept changed; everything about the trust chain did. This episode is about turning an image that worked into an image you can audit, told honestly through its CHANGELOG, which reads like a CVE diary.
Merging the split
The 2022-era mistake I could never forgive myself: two images, ansible and ansible-lint, because “what belongs in the image” had no answer. v1.0.0 merged them into one Ansible CI Toolkit: ansible-core 2.21.3, ansible-lint 26.8.0, 17 pinned Galaxy collections (Linux, Windows, VMware, AWS/GCP, Kubernetes, Foreman, OVH), ARA reporting and the whole Windows/AD stack: Kerberos libraries, pywinrm with basic/ntlm/credssp/kerberos auth, a generic /etc/krb5.conf template you can either mount, override with KRB5_CONFIG, or bake in a rebuild. One image, 302 MB and the CI job is one image: line.
It also grew the runtime guards: non-root ansible user (UID 1000) with an owned /src workspace and the ephemeral-runner env vars baked in (HOST_KEY_CHECKING=False, SSH_PIPELINING=True, GATHERING=smart, retry files off).
The reproducibility ladder
The 2022 image pinned one layer out of three. The 2026 image climbs the whole ladder, in order:
- Base image pinned by digest:
alpine:3.24.1@sha256:28bd5..., not a tag. A tag is a promise; a digest is a fact. - Every Python dependency
==-pinned inrequirements.txt. constraints-lock.txt: a fullpip freezeof a validated build, applied viaPIP_CONSTRAINTin the builder. Now transitive dependencies resolve identically on every rebuild: the lock is a fact about a build that happened, not a hope.- Transitive Galaxy collections pinned (
amazon.aws 11.4.0,vmware.vmware 2.10.0, …), withANSIBLE_COLLECTIONS_ON_ANSIBLE_VERSION_MISMATCH=errorat build so a collection that outruns its ansible-core fails loudly instead of degrading silently. - cosign
signing, keyless: the Sigstore certificate is issued to the GitLab pipeline’s OIDC token. One signature per digest covers every tag pointing at it,
:latestincluded and the pipeline verifies itself in-line (blocking).
The only deliberate exceptions are floors (msgpack>=1.2.1, setuptools>=78.1.1), where a security fix must be guaranteed in the venv; the lock file caps them at the validated version. Pins everywhere else, floors where security demands and the difference is documented instead of guessed.
The CVE diary
The CHANGELOG is the real product demo. Two entries worth framing:
- The pin that was not a cage: upgrading the base to
alpine:3.24.1did not pull the OpenSSL 3.5.8 fix for CVE-2026-14456 (QUIC DoS, HIGH), because the digest snapshot bundled 3.5.7-r0. The fix:apk upgrade --no-cachein both stages. Lesson: a pinned base buys reproducibility, not safety; the upgrade-on-build is the bridge and the trade-off (some drift between builds) is documented rather than hidden. - The findings that were not mine: Trivy
flagged msgpack and setuptools HIGH. Both Alpine system packages were fixed since 3.24; the findings came from pip’s own vendored copies (
pip/_vendorpins msgpack 1.1.2, setuptools 70.3.0), not runtime-reachable, no upstream fix exists since pip vendors its own. They went into.trivyignorewith a written justification per entry and the justification was rewritten when the base changed. Ignored findings with reasons; silent ignores are how supply chains rot.
Add the rest of the fence: hadolint on every pipeline, Trivy HIGH/CRITICAL blocking and a test job that runs inside the built image checking versions, collection list, binaries, krb5 config and every Python import the collections need (pywinrm, gssapi, ara, boto3, kubernetes, docker). If the image cannot pass its own exam, it does not ship.
Never trust your own image
- “Works” and “auditable” are different milestones. The venv trick survived untouched since the very first image; the trust chain did not.
- A digest pin is reproducibility, not safety. The OpenSSL CVE proved the need for both and for documenting the drift you accept.
- Every ignore needs a reason in writing.
.trivyignorewithout justification is a to-do list disguised as a policy. - Test inside the artifact. The best test job for an image is the image itself, entrypoint overridden, running its own inventory.
The image: gitlab.com/6admin.io/docker/alpine/ansible
, registry.gitlab.com/6admin.io/docker/alpine/ansible/ansible, SemVer tags, cosign-signed digests. And its inventory reads itself from DNS: the dns-inventory article
is the other half of the pipeline.