Skip to main content
RunBook Academy

AnsibleXXVII · Collections, Galaxy and Dependency TrustCollections and dependency trust

Where collections live and which copy wins

Intermediate⏱ ~19 minansibleansible-galaxyansible-doc

What you'll learn

  • Name the four locations a collection can be loaded from and the order they are searched
  • Prove which copy of a collection a given run will load
  • Explain why a playbook-adjacent collections directory is invisible to ansible-doc
  • Recognise a source-tree collection from its version and say why it cannot be pinned

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

A collection is a directory. Ansible looks for it in more than one place, takes the first one it finds, and does not mention the others. That is the entire mechanism, and it produces one of the most disorienting failures in the tool: a playbook that works and an ansible-doc that says the collection does not exist.

Both are correct. They search different paths.

The four places

Read-only / Safethe configured paths
$ ansible-config dump | grep COLLECTIONS_PATHS
COLLECTIONS_PATHS(default) = ['/home/opsuser/.ansible/collections', '/usr/share/ansible/collections']

Illustrative output

Two paths there, plus two more that do not appear in that output at all:

1. A playbook-adjacent collections/ directory. If the directory containing the playbook has a collections/ansible_collections/... tree, it is searched — and searched first. This is the project-local install and it is the one this course recommends, because it puts the dependency inside the repository where review can see it.

2. COLLECTIONS_PATHS, in order. By default a per-user directory followed by a system-wide one. ANSIBLE_COLLECTIONS_PATH or collections_path in ansible.cfg replaces the list.

3. Inside a role, at <role>/collections/, for a role that vendors its own dependencies.

4. Adjacent to another collection, for a collection that ships inside another collection’s tree. Rare; mentioned so that you recognise it when find turns one up.

First match wins. There is no merge, no version comparison, no warning that a second copy exists.

The demonstration: loaded by the playbook, invisible to ansible-doc

A project with a collection in the playbook-adjacent directory — collections/ansible_collections/example_corp/platform/ — containing one filter plugin. Run a playbook that uses it:

Read-only / Safethe playbook finds it
$ ansible-playbook -i localhost, filter-test.yml
TASK [Use the collection filter] ***********************************************
ok: [localhost] => {
  "msg": 24
}

It works. The filter resolved, ran, and returned. Now ask ansible-doc about the same plugin, in the same directory, in the same shell:

Read-only / Safeansible-doc does not
$ ansible-doc -t filter example_corp.platform.to_cidr
[ERROR]: filter example_corp.platform.to_cidr Missing documentation (or could not parse documentation): 'Invalid plugin FQCN (example_corp.platform.to_cidr): unable to locate collection example_corp.platform': unable to locate collection example_corp.platform: No module named 'ansible_collections.example_corp'

Nothing is broken. ansible-playbook searches the playbook-adjacent directory because it knows where the playbook is. ansible-doc has no playbook, so it searches COLLECTIONS_PATHS only — and the collection is not there.

Point the environment variable at the project and it appears:

Read-only / Safethe same plugin, once the path includes it
$ ANSIBLE_COLLECTIONS_PATH=./collections ansible-doc -t filter example_corp.platform.to_cidr
> FILTER example_corp.platform.to_cidr (/home/opsuser/project/collections/ansible_collections/example_corp/platform/plugins/filter/netmask.py)

Returns the CIDR prefix length for a dotted-quad IPv4 netmask.

OPTIONS (red indicates it is required):

 _input  A dotted-quad netmask such as 255.255.255.0.
      type: str

That header line is the answer to “which copy”. ansible-doc prints the absolute path of the file it loaded. When two controllers disagree about a module’s behaviour, this is the comparison that ends the argument, and it is one command.

Proving which copy, in three commands

Read-only / Safewhich copy is loaded
ansible-config dump | grep COLLECTIONS_PATHS
ansible-galaxy collection list
ansible-doc -t module community.docker.docker_container | head -1

The first says where it will look. The second says what is there, with the path each set was found under printed as a comment header. The third says which file was actually opened for one specific plugin.

Run all three when the question is “why does this behave differently here”, and run the third one on both machines when the answer is not obvious from the first two.

Read-only / Safethe path header in the list output
$ ansible-galaxy collection list
# /home/opsuser/.ansible/collections/ansible_collections
Collection        Version
----------------- -------
community.general 12.4.0

# /usr/share/ansible/collections/ansible_collections
Collection        Version
----------------- -------
community.general 9.5.1
ansible.posix     3.0.0

Illustrative output

Two copies of community.general, at versions three major releases apart. The per-user one is earlier in the path, so it wins. Nothing about a run will tell you the system copy exists — only this command does.

The version that is not a version

Install a collection from a Galaxy tarball and it carries a MANIFEST.json with its version in it. Clone a collection from git into your collections directory and it does not:

Read-only / Safea collection with no manifest
$ ansible-galaxy collection list -p ./collections
[WARNING]: Collection at '/home/opsuser/project/collections/ansible_collections/example_corp/platform' does not have a MANIFEST.json file, nor has it galaxy.yml: cannot detect version.

# /home/opsuser/project/collections/ansible_collections
Collection            Version
--------------------- -------
example_corp.platform *

* is not a version. It means this is a working tree and I have no idea what is in it.

A * in a production controller’s manifest is a finding. It means the content came from somewhere other than a published artefact — a git clone, an rsync, a manual copy — so there is no checksum to verify, no version to pin, and no way to reproduce this controller except by knowing which commit somebody happened to have checked out.

Compare with the same collection installed from a built artefact:

Read-only / Safethe same collection, installed properly
$ ansible-galaxy collection list -p ./collections
# /home/opsuser/project/collections/ansible_collections
Collection            Version
--------------------- -------
example_corp.platform 1.0.0

Knowledge check

Knowledge check · 4 questions

  1. Q1. ansible-playbook runs a task using example_corp.platform.to_cidr successfully, but ansible-doc -t filter example_corp.platform.to_cidr reports that it cannot locate the collection. What is happening?

  2. Q2. ansible-galaxy collection list shows community.general 12.4.0 under ~/.ansible/collections and community.general 9.5.1 under /usr/share/ansible/collections. Which one does a run load?

  3. Q3. A collection listed with a version of * came from a source checkout or a manual copy rather than a built artefact.

  4. Q4. Which commands contribute evidence about which copy of a collection a run will load? Select all that apply.

Passing score: 75%. Answers are checked in this browser.