<aside> 🧭

Module 06 · Collections & Content Structure

Packaging, versioning and distributing Ansible content — and laying out a repository a team can actually work in. This is the module that turns "my playbooks" into "our platform".

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–05. You met namespaces, collections and Galaxy in Module 01 Part A4 — this module goes from consuming them to building them.

</aside>


Part A · Anatomy of a collection

A1 · What a collection actually contains

<aside> 📖

Official docs: Using collections · Developing collections · Collection structure

</aside>

<aside> 📱

The analogy. Think of an app in a phone's app store. It has a publisher and a name, and the store always shows you both — because there are four apps called "Notes" and only one of them is the one you meant. It has a version number, so "it worked on 3.1" is a precise statement rather than a feeling. It ships everything it needs in one download. And it can say "requires iOS 15 or later".

A collection is that app: amazon is the publisher, aws is the name, and Galaxy is the store. Each of those four everyday properties maps onto a real file you are about to meet — the publisher and name live in galaxy.yml, and "requires iOS 15 or later" is requires_ansible in meta/runtime.yml.

</aside>

A role packages one concern. A collection packages everything a domain needs — including the things a role structurally cannot hold: modules, plugins and playbooks.

mycompany/platform/
  galaxy.yml                 <- the manifest: namespace, name, version, dependencies
  README.md                  <- what this collection is for
  LICENSE
  meta/
    runtime.yml              <- requires_ansible, action_groups, plugin redirects
  plugins/
    modules/                 -> mycompany.platform.create_tenant
    module_utils/            <- shared Python for those modules
    action/                  <- action plugins (run on the control node)
    filter/                  -> mycompany.platform.to_subnet
    lookup/                  -> lookup('mycompany.platform.vault_secret', ...)
    inventory/               -> plugin: mycompany.platform.cmdb
    callback/                <- custom output formatting
    connection/              <- custom transports
    test/                    <- custom Jinja2 tests
  roles/
    webserver/               -> mycompany.platform.webserver
    monitoring/
  playbooks/
    site.yml                 -> mycompany.platform.site
    files/  templates/  tasks/
  docs/
  changelogs/
    changelog.yaml
  tests/
    sanity/  unit/  integration/

<aside> 🔑

Every directory maps directly to an FQCN. A module in plugins/modules/create_tenant.py becomes mycompany.platform.create_tenant. A role in roles/webserver/ becomes mycompany.platform.webserver. A filter in plugins/filter/ becomes mycompany.platform.to_subnet.

That is the whole naming system from Module 01 Part A4, seen from the producing side rather than the consuming side.

</aside>

<aside> 💡

Only galaxy.yml is mandatory. A collection containing nothing but roles/ is perfectly valid, and that is exactly how most internal collections start — as a way to version and distribute a handful of roles as one unit. Add plugins later when you actually need them.

</aside>

A2 · galaxy.yml — the manifest

<aside> 🔢

The analogy. Think about what an app's version number actually promises you. A small last-number update is a bug fix — you install it without a thought. The middle number going up means new features arrived, but everything you already do still works. A major version change is the one where the whole interface gets redesigned and the button you pressed every morning has moved.

That is semantic versioning, and it is a promise rather than a number. Shipping a redesign as a minor update is how an app loses its users' trust, and it is exactly how a collection loses its consumers' — because their pinned >=2.1,<3.0 was them believing you.

</aside>

---
namespace: mycompany
name: platform
version: 1.4.2                    # MUST be semantic versioning
readme: README.md
authors:
  - Zaeem Mazhar <[email protected]>
description: Internal platform automation - web, monitoring and base configuration
license_file: LICENSE
tags:
  - infrastructure
  - linux

dependencies:
  ansible.posix: ">=1.5.0,<2.0.0"
  community.general: ">=8.0.0"

repository: <https://git.internal/ansible/platform>
documentation: <https://docs.internal/ansible/platform>
issues: <https://git.internal/ansible/platform/-/issues>

build_ignore:                     # excluded from the built artifact
  - .git
  - .gitlab-ci.yml
  - molecule
  - "*.tar.gz"

<aside> ⚠️

Semantic versioning is enforced, not suggested. Galaxy rejects a version that is not MAJOR.MINOR.PATCH. That matters because consumers pin ranges like ">=1.4.0,<2.0.0" — which only means anything if you honour the contract:

MAJOR — a breaking change: a renamed variable, a removed role, changed default behaviour.

MINOR — new functionality, backwards compatible.

PATCH — a bug fix, no interface change.

Bumping a MINOR for something that actually breaks consumers is how you lose a team's trust in your collection.

</aside>

A3 · meta/runtime.yml

---
requires_ansible: ">=2.15.0"        # fails clearly on an older control node

action_groups:                      # lets consumers apply module_defaults in bulk
  platform:
    - create_tenant
    - delete_tenant

plugin_routing:                     # keeps old names working after a rename
  modules:
    old_module_name:
      redirect: mycompany.platform.new_module_name
    removed_module:
      tombstone:
        removal_version: "2.0.0"
        warning_text: "Use new_module_name instead"

<aside> 🎯

plugin_routing is how you rename something without breaking every consumer. A redirect keeps the old FQCN working silently; a tombstone fails with a message telling people what to use instead.

This is the mechanism the Ansible project itself used during the 2.10 split — which is why bare yum: still resolves today. Knowing that connection is a genuinely good answer to "how do collections handle deprecation?"

</aside>

🧪 Exercise A3.1 — Scaffold a collection and read what you get