Las colecciones son el formato de distribución de contenido de Ansible: agrupan módulos, plugins, roles y playbooks bajo un nombre namespace.coleccion con versionado semántico, y se instalan con ansible-galaxy igual que un paquete. En este tutorial crearás en Ubuntu 24.04 una colección miempresa.infraestructura con un módulo propio idempotente, un filtro Jinja2 y un rol, la construirás, la instalarás y la usarás desde un playbook. Después verás cómo publicarla en Ansible Galaxy y cómo declarar las colecciones de un proyecto en requirements.yml.

Requisitos previos

  • Una máquina con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con un usuario no root con privilegios sudo. Hará de nodo de control de Ansible.
  • Conocimientos básicos de playbooks de Ansible y de Python.
  • Para el paso 8 (opcional): una cuenta en galaxy.ansible.com.

Paso 1: Instalar Ansible

El paquete ansible de Ubuntu 24.04 incluye ansible-core y un conjunto amplio de colecciones de la comunidad:

sudo apt update
sudo apt install ansible
ansible --version
ansible [core 2.16.3]
  config file = None
  configured module search path = ['/home/your_user/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  ansible collection location = /home/your_user/.ansible/collections:/usr/share/ansible/collections
  ...

La línea ansible collection location indica dónde busca Ansible las colecciones instaladas. Por defecto, ansible-galaxy instala las de tu usuario en ~/.ansible/collections.

Paso 2: Crear el esqueleto de la colección

Una colección tiene un nombre de dos partes: el namespace (tu organización o usuario de Galaxy) y el nombre de la colección. Ambas deben estar en minúsculas, empezar por una letra y contener solo letras, números y guiones bajos.

Crea el esqueleto en un directorio de trabajo:

mkdir -p ~/src && cd ~/src
ansible-galaxy collection init miempresa.infraestructura
cd miempresa/infraestructura
- Collection miempresa.infraestructura was created successfully

Revisa la estructura generada:

ls
README.md  docs  galaxy.yml  meta  plugins  roles

Qué contiene cada parte:

RutaContenido
galaxy.ymlMetadatos: nombre, versión, autores, licencia y dependencias
meta/runtime.ymlVersión mínima de ansible-core y redirecciones de módulos
plugins/modules/Módulos propios
plugins/filter/Filtros Jinja2
roles/Roles incluidos en la colección

Paso 3: Rellenar los metadatos

El archivo galaxy.yml generado incluye todas las claves posibles con comentarios. Sustitúyelo por una versión con solo lo necesario:

nano galaxy.yml
namespace: miempresa
name: infraestructura
version: 1.0.0
readme: README.md
authors:
  - Tu Nombre <[email protected]>
description: Módulos, filtros y roles de infraestructura de Mi Empresa
license:
  - GPL-3.0-or-later
tags:
  - linux
  - infrastructure
dependencies:
  ansible.posix: ">=1.5.0"
repository: https://github.com/miempresa/ansible-collection-infraestructura
build_ignore:
  - "*.tar.gz"

La clave dependencies declara otras colecciones necesarias: ansible-galaxy las instala automáticamente al instalar esta. build_ignore evita que los paquetes construidos anteriormente se incluyan dentro del siguiente.

Declara también la versión mínima de ansible-core en meta/runtime.yml:

nano meta/runtime.yml
---
requires_ansible: ">=2.15.0"

Paso 4: Escribir un módulo propio

Un módulo es un programa Python que recibe parámetros, comprueba el estado actual del sistema, lo cambia solo si hace falta y devuelve JSON con changed. Como ejemplo crearás kv_setting, que gestiona una línea clave=valor en un archivo de configuración, con soporte de modo --check y --diff.

nano plugins/modules/kv_setting.py
#!/usr/bin/python
# -*- coding: utf-8 -*-
# GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import annotations

DOCUMENTATION = r"""
---
module: kv_setting
short_description: Gestiona una línea clave=valor en un archivo
version_added: "1.0.0"
description:
  - Crea, actualiza o elimina una línea C(nombre=valor) en un archivo de texto.
  - Solo modifica el archivo si el contenido cambia.
options:
  path:
    description: Ruta del archivo. Se crea si no existe y O(state=present).
    type: path
    required: true
  name:
    description: Nombre de la clave.
    type: str
    required: true
  value:
    description: Valor de la clave. Obligatorio si O(state=present).
    type: str
  state:
    description: Si la clave debe existir o no.
    type: str
    choices: [present, absent]
    default: present
author:
  - Tu Nombre (@tu_usuario)
"""

EXAMPLES = r"""
- name: Fijar el número máximo de conexiones
  miempresa.infraestructura.kv_setting:
    path: /etc/miapp/app.conf
    name: max_connections
    value: "200"

- name: Eliminar una clave obsoleta
  miempresa.infraestructura.kv_setting:
    path: /etc/miapp/app.conf
    name: legacy_mode
    state: absent
"""

RETURN = r"""
path:
  description: Ruta del archivo gestionado.
  returned: always
  type: str
  sample: /etc/miapp/app.conf
"""

import os

from ansible.module_utils.basic import AnsibleModule


def read_lines(path):
    if not os.path.exists(path):
        return []
    with open(path, encoding="utf-8") as f:
        return f.read().splitlines()


def build_lines(lines, name, value, state):
    prefix = name + "="
    result = []
    found = False
    for line in lines:
        if line.startswith(prefix):
            if state == "present" and not found:
                result.append(prefix + value)
                found = True
            continue
        result.append(line)
    if state == "present" and not found:
        result.append(prefix + value)
    return result


def main():
    module = AnsibleModule(
        argument_spec=dict(
            path=dict(type="path", required=True),
            name=dict(type="str", required=True),
            value=dict(type="str"),
            state=dict(type="str", default="present", choices=["present", "absent"]),
        ),
        required_if=[("state", "present", ("value",))],
        supports_check_mode=True,
    )
    path = module.params["path"]
    state = module.params["state"]

    if state == "absent" and not os.path.exists(path):
        module.exit_json(changed=False, path=path)

    before = read_lines(path)
    after = build_lines(before, module.params["name"], module.params["value"], state)
    changed = before != after

    result = dict(changed=changed, path=path)
    if module._diff:
        result["diff"] = dict(
            before="\n".join(before) + "\n" if before else "",
            after="\n".join(after) + "\n" if after else "",
            before_header=path,
            after_header=path,
        )

    if changed and not module.check_mode:
        try:
            with open(path, "w", encoding="utf-8") as f:
                f.write("\n".join(after) + "\n" if after else "")
        except OSError as e:
            module.fail_json(msg="No se pudo escribir %s: %s" % (path, e), **result)

    module.exit_json(**result)


if __name__ == "__main__":
    main()

Las claves de este módulo:

  • DOCUMENTATION, EXAMPLES y RETURN son YAML que ansible-doc muestra y que Galaxy usa para generar la documentación.
  • argument_spec valida los parámetros antes de ejecutar nada; required_if exige value cuando state=present.
  • El módulo calcula el contenido final y lo compara con el actual: si son iguales devuelve changed=False sin tocar el archivo. Esa es la base de la idempotencia.
  • Con supports_check_mode=True y la comprobación de module.check_mode, --check informa de lo que cambiaría sin escribir.

Paso 5: Añadir un filtro y un rol

Los filtros Jinja2 van en plugins/filter/. Crea uno que convierte un texto cualquiera en un nombre válido para DNS:

mkdir -p plugins/filter
nano plugins/filter/dns.py
# -*- coding: utf-8 -*-
# GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import annotations

import re


def to_dns_label(value):
    """Convierte un texto en una etiqueta DNS válida: minúsculas, letras, números y guiones."""
    label = re.sub(r"[^a-z0-9-]+", "-", str(value).lower())
    label = re.sub(r"-{2,}", "-", label).strip("-")
    return label[:63]


class FilterModule:
    def filters(self):
        return {"to_dns_label": to_dns_label}

Añade también un rol. Dentro de una colección, los nombres de rol solo pueden tener minúsculas, números y guiones bajos:

ansible-galaxy role init --init-path roles motd
nano roles/motd/tasks/main.yml
---
- name: Publicar el mensaje de bienvenida
  ansible.builtin.copy:
    content: "{{ motd_texto }}\n"
    dest: /etc/motd
    owner: root
    group: root
    mode: "0644"

Define el valor por defecto de la variable:

nano roles/motd/defaults/main.yml
---
motd_texto: "Servidor gestionado con Ansible. Los cambios manuales se sobrescribirán."

Paso 6: Construir e instalar la colección

ansible-galaxy collection build empaqueta la colección en un archivo .tar.gz con un manifiesto y las sumas de todos los archivos. Desde la raíz de la colección:

ansible-galaxy collection build
Created collection for miempresa.infraestructura at /home/your_user/src/miempresa/infraestructura/miempresa-infraestructura-1.0.0.tar.gz

Instálala para tu usuario. Como declara ansible.posix como dependencia, ansible-galaxy la resolverá: si ya tienes una versión compatible (el paquete ansible de Ubuntu la incluye), no descargará nada:

ansible-galaxy collection install miempresa-infraestructura-1.0.0.tar.gz

Comprueba que Ansible la encuentra y que la documentación del módulo es correcta:

ansible-galaxy collection list miempresa.infraestructura
ansible-doc miempresa.infraestructura.kv_setting
# /home/your_user/.ansible/collections/ansible_collections
Collection               Version
------------------------ -------
miempresa.infraestructura 1.0.0

Paso 7: Usar la colección en un playbook

Dentro de un playbook, los módulos, filtros y roles de una colección se llaman por su nombre completo (FQCN, namespace.coleccion.elemento). Es la forma recomendada: deja claro de dónde viene cada cosa y evita colisiones de nombres.

Prueba primero el módulo con un comando ad hoc, en modo --check y con --diff:

ansible localhost -m miempresa.infraestructura.kv_setting \
  -a "path=/tmp/demo.conf name=max_connections value=200" --check --diff
--- before: /tmp/demo.conf
+++ after: /tmp/demo.conf
@@ -0,0 +1 @@
+max_connections=200

localhost | CHANGED => {
    "changed": true,
    "path": "/tmp/demo.conf"
}

El archivo no se ha creado porque es una simulación. Crea ahora un playbook de prueba:

mkdir -p ~/proyecto && cd ~/proyecto
nano demo.yml
---
- name: Probar la colección miempresa.infraestructura
  hosts: localhost
  connection: local
  gather_facts: false
  tasks:
    - name: Fijar max_connections
      miempresa.infraestructura.kv_setting:
        path: /tmp/demo.conf
        name: max_connections
        value: "200"

    - name: Mostrar un nombre DNS derivado
      ansible.builtin.debug:
        msg: "{{ 'Servidor Web Producción 01' | miempresa.infraestructura.to_dns_label }}"

Ejecútalo dos veces:

ansible-playbook demo.yml
ansible-playbook demo.yml

En la primera ejecución la tarea del módulo aparece como changed; en la segunda, como ok, porque el valor ya es el correcto. La tarea debug muestra el filtro en acción:

TASK [Mostrar un nombre DNS derivado] ******************************************
ok: [localhost] => {
    "msg": "servidor-web-producci-n-01"
}

El filtro sustituye los caracteres no válidos en DNS, como la ó, por guiones.

Los roles de la colección se usan también por su nombre completo, por ejemplo en un play contra tus servidores:

- name: Configurar el mensaje de bienvenida
  hosts: webservers
  become: true
  roles:
    - role: miempresa.infraestructura.motd

Paso 8: Publicar la colección en Ansible Galaxy

Para publicar en Galaxy, el namespace de galaxy.yml debe existir en Galaxy y pertenecer a tu cuenta. Al iniciar sesión en galaxy.ansible.com con GitHub se crea un namespace con tu usuario (con guiones convertidos en guiones bajos); para otro nombre, como el de tu organización, solicítalo desde la propia web.

Obtén un token de API en galaxy.ansible.com, en la sección Collections > API token, y guárdalo en una variable de entorno para no dejarlo en el historial dentro de la línea de comandos:

read -rs GALAXY_TOKEN

Pega el token y pulsa Intro. Después publica el paquete:

cd ~/src/miempresa/infraestructura
ansible-galaxy collection publish miempresa-infraestructura-1.0.0.tar.gz --api-key "$GALAXY_TOKEN"
Publishing collection artifact '/home/your_user/src/miempresa/infraestructura/miempresa-infraestructura-1.0.0.tar.gz' to default https://galaxy.ansible.com/api/
Collection has been published to the Galaxy server default https://galaxy.ansible.com/api/
Waiting until Galaxy import task https://galaxy.ansible.com/api/... has completed
Collection has been successfully published and imported to the Galaxy server default https://galaxy.ansible.com/api/

Una versión publicada no se puede sobrescribir. Para cualquier cambio, incrementa version en galaxy.yml siguiendo versionado semántico (1.0.1 para correcciones, 1.1.0 para funciones nuevas, 2.0.0 para cambios incompatibles), vuelve a construir y publica.

Paso 9: Declarar dependencias con requirements.yml

En un proyecto de Ansible, las colecciones necesarias se declaran en requirements.yml para que cualquier persona, o un pipeline de CI, pueda instalar exactamente lo mismo. En el directorio del proyecto:

cd ~/proyecto
nano requirements.yml
---
collections:
  - name: community.general
    version: ">=9.0.0,<10.0.0"

  - name: ansible.posix
    version: ">=1.5.0"

  - name: https://github.com/miempresa/ansible-collection-infraestructura.git
    type: git
    version: v1.0.0

La última entrada instala la colección desde un repositorio Git, en la etiqueta v1.0.0. El repositorio debe contener galaxy.yml en su raíz. Para repositorios privados, usa una URL SSH ([email protected]:miempresa/ansible-collection-infraestructura.git) con una clave con acceso.

Para que el proyecto use sus propias copias de las colecciones, independientes de las del sistema, crea un ansible.cfg:

nano ansible.cfg
[defaults]
collections_path = ./collections

Instala las dependencias en ese directorio:

ansible-galaxy collection install -r requirements.yml -p ./collections

Comprueba qué versiones se han instalado:

ansible-galaxy collection list -p ./collections

Añade collections/ a tu .gitignore: el archivo que se versiona es requirements.yml, no las colecciones descargadas. Para actualizar a la última versión que cumpla los rangos, añade --upgrade al comando de instalación.

Solución de problemas

couldn't resolve module/action 'miempresa.infraestructura.kv_setting'. Ansible no encuentra la colección en ninguna ruta de collections_path. Comprueba con ansible-galaxy collection list dónde está instalada y con ansible-config dump --only-changed si un ansible.cfg del proyecto cambia las rutas.

Los cambios en el módulo no se aplican. Estás ejecutando la copia instalada, no la de ~/src. Vuelve a construir e instalar con ansible-galaxy collection install miempresa-infraestructura-1.0.0.tar.gz --force.

ansible-galaxy collection publish falla con un error de permisos sobre el namespace. El namespace de galaxy.yml no existe en Galaxy o tu cuenta no es propietaria. Revísalo en la sección My Namespaces de galaxy.ansible.com.

La instalación desde Git falla con galaxy.yml not found. El repositorio no tiene galaxy.yml en la raíz. Si la colección está en un subdirectorio, indícalo en la URL con #/ruta/, por ejemplo https://github.com/miempresa/repo.git#/colecciones/infraestructura/.

Conclusión

Has creado una colección de Ansible con un módulo idempotente con soporte de --check y --diff, un filtro Jinja2 y un rol; la has construido, instalado y usado con nombres completos, y has visto cómo publicarla en Galaxy y fijar las dependencias de un proyecto con requirements.yml. Como siguientes pasos puedes añadir pruebas con ansible-test sanity y ansible-test units, probar los roles de la colección con Molecule, o automatizar la publicación en Galaxy desde CI al crear una etiqueta de versión.