Reported symptoms
A pipeline that has worked for a year starts failing on the CI runner. The same commit works on every engineer workstation.
The failures do not look like one problem:
- The run fails with
Attempting to decrypt but no vault secrets found, against vaulted files nobody has touched. - Before it stops, it is roughly ten times slower than the same run on a workstation.
- The verbose output shows it connecting to hosts that the repository inventory configuration was supposed to exclude.
- Host key checking behaves differently.
Four symptoms, four theories, four people. The vault password file is present and readable on the runner. The inventory is committed. Nothing in the repository changed.
Somebody checks that ansible.cfg exists in the workspace on the
runner. It does. Its contents are byte-identical to the copy in the
repository. That is where the investigation stops for two hours.
Evidence provided
$ ansible-config dump --only-changedCONFIG_FILE() = /srv/automation/ansible.cfg
DEFAULT_FORKS(/srv/automation/ansible.cfg) = 50
DEFAULT_VAULT_PASSWORD_FILE(/srv/automation/ansible.cfg) = /etc/ansible/vault-pass
DEFAULT_HOST_LIST(/srv/automation/ansible.cfg) = ['/srv/automation/inventory']$ ansible-config dump --only-changed[WARNING]: Ansible is being run in a world writable directory (/builds/ops/automation), ignoring it as an ansible.cfg source. For more information see https://docs.ansible.com/ansible/devel/reference_appendices/config.html#cfg-in-world-writable-dir
CONFIG_FILE() = None$ ls -l ansible.cfg; md5sum ansible.cfg-rw-r--r-- 1 build build 214 Aug 11 09:02 ansible.cfg
5d41402abc4b2a76b9719d911017c592 ansible.cfg$ stat -c '%a %n' .777 .$ grep -n -A3 'unpack' .gitlab-ci.yml script:
- tar -xf artefacts/dependencies.tar -C .$ env | grep -i ansibleWork the evidence before reading on
The file is present, correct and identical. The ansible-config output
disagrees with all three of those facts being sufficient.
- Compare the two
ansible-config dumpoutputs. One names a configuration file and the other does not. What doesCONFIG_FILE() = Nonemean for every setting the file was providing? - Read the warning line above it. It names a directory, not a file. Check the permissions of that directory rather than of the file.
- Four symptoms appeared at once. What single change would produce all four simultaneously?
Before continuing: why would Ansible deliberately refuse to read a configuration file that is present and valid?
Root cause
1. Ansible refuses configuration from a world-writable directory
Ansible discovers ansible.cfg in the current working directory, among
other places. It will not load one from a directory that is
world-writable, and it says so:
[WARNING]: Ansible is being run in a world writable directory (/builds/ops/automation), ignoring it as an ansible.cfg source.
The reason is a genuine security boundary. A configuration file determines which plugin paths are searched, which Python interpreter runs on targets, where the vault password comes from, and which inventory is used. Honouring one that any local user could rewrite would let any local user redirect an automation run.
The refusal is about the directory, not the file. A perfectly permissioned
ansible.cfg inside a world-writable directory is still ignored,
because anyone who can write to the directory can replace the file.
2. Every setting reverted at once
The runner has no ANSIBLE_CONFIG environment variable and no
user-level or system-level configuration file. So when the repository
configuration was skipped, there was nothing to take its place and
every setting fell back to its built-in default simultaneously:
| Setting | Repository value | Fallback |
|---|---|---|
| Vault password file | a path | none, so decryption fails |
| Inventory | the repository inventory | the built-in default |
| Forks | 50 | 5 |
| Host key checking | as configured | the built-in default |
That is why it presented as four unrelated faults. One cause, four visible effects, and each effect plausible enough on its own to sustain its own theory.
3. The workspace permissions came from a build step
A build step unpacks a dependency archive into the workspace, preserving the permissions recorded in the archive. Those permissions include a world-writable mode on the extraction target.
Nothing about the Ansible configuration changed. The directory it lives in did, and it will change again on the next build unless the build step is fixed.
Resolution
- Establish what the runs under default settings actually did. A run with the wrong inventory and the wrong connection settings may have reached hosts the repository configuration was there to exclude; that is the part with consequences beyond a red build.
- Fix the workspace permissions so the directory is not world-writable, and confirm
ansible-config dump --only-changednow names the intended file. - Fix the build step that created the permissions. A workspace recreated by the next build will be world-writable again, and the incident will return looking like a flaky pipeline.
- Set
ANSIBLE_CONFIGexplicitly in the CI job to an absolute path. This removes the dependency on the working directory and on its permissions entirely, and it is the durable fix rather than the immediate one. - Add a pipeline step that prints
ansible-config dump --only-changedinto the log before anything runs, so the effective configuration is recorded with every run. - Add an assertion that the resolved configuration file is the expected one, and fail the build when it is not.
- Check the other pipelines and scheduled jobs on the same runner for the same dependency on directory-relative discovery.
- Treat the world-writable workspace as a security finding in its own right and raise it separately - any local user on that runner could have replaced the configuration or the playbooks.
Verification
- The intended configuration file is in force.
ansible-config dump --only-changedon the runner names it, and every setting the repository provides appears with that file as its source. - The check can fail. Make a scratch directory world-writable, run the same command there, and confirm the warning returns and
CONFIG_FILE()reports None. Watching the failure is how you know the check means something. - The pipeline assertion goes red. Point it at a directory with the wrong permissions on a scratch branch and confirm the build fails before any play runs.
- The individual settings that matter are correct. Confirm the vault identity, the inventory path and the forks value by name; a named configuration file is not proof that every setting arrived, since the file could also be the wrong one.
- The permissions survive a fresh build. Trigger a build from clean and confirm the workspace is not world-writable afterwards, which tests the build-step fix rather than the manual one.
- The run succeeds end to end with the vault, the correct inventory and the configured concurrency, and takes a duration consistent with 50 forks rather than 5.
- No other job on the runner depends on directory-relative discovery. Each one either sets
ANSIBLE_CONFIGor asserts the resolved file.
Prevention
- Set
ANSIBLE_CONFIGexplicitly in every pipeline and scheduled job. Directory-relative discovery is convenient at a terminal and inappropriate in automation, where the working directory is decided by whatever ran last. - Print the effective configuration at the start of every run.
ansible-config dump --only-changedin the log turns this entire class of problem into a two-second comparison. - Assert the resolved configuration file in CI, so a run with the wrong configuration fails before it connects to anything.
- Keep workspace permissions tight, and treat a world-writable checkout as a build failure. Ansible refusing to read it is a symptom; the security exposure is the finding.
- Remember that configuration discovery is first-match-wins, not merged. Losing one file does not partially degrade behaviour, it reverts every setting that file provided.
- When the same commit behaves differently on two machines, compare effective configuration first. The code is provably identical, so the difference is in the environment, and this is where it usually is.
- Be suspicious of several unrelated symptoms appearing together. One cause with four effects is more likely than four causes arriving on the same afternoon.