Ansible Molecule para Testing de Roles
Molecule es el framework oficial de testing para roles de Ansible que permite verificar automáticamente que los roles funcionan correctamente antes de desplegarlos en producción. Con Molecule puedes definir escenarios de prueba, levantar instancias Docker o VMs de forma temporal, aplicar el rol, ejecutar assertions con Testinfra o Ansible y derribar el entorno de prueba, todo en un único comando, integrándolo fácilmente en pipelines de CI/CD.
Requisitos Previos
- Python 3.8+
- Docker instalado y en ejecución
- Ansible instalado (o se instalará con Molecule)
- El rol de Ansible a testear debe estar en la estructura estándar
# Verificar los requisitos previos
python3 --version
ansible --version
docker --version
docker info | grep "Server Version"
Instalación de Molecule
# Instalar Molecule con el driver de Docker y Testinfra
pip3 install molecule molecule-plugins[docker] pytest-testinfra ansible-lint
# Verificar la instalación
molecule --version
pytest --version
# Para entornos de producción, usar un virtualenv
python3 -m venv .venv
source .venv/bin/activate
pip install molecule molecule-plugins[docker] pytest-testinfra ansible-lint ansible
Crear un Escenario de Prueba
# Estructura de un rol Ansible con Molecule
# Si ya tienes un rol existente, agregar Molecule al mismo
cd mi-rol-ansible/
# Inicializar el escenario de prueba por defecto
molecule init scenario --driver-name docker
# La estructura creada:
# molecule/
# └── default/
# ├── molecule.yml # Configuración del escenario
# ├── converge.yml # Playbook que aplica el rol
# ├── verify.yml # Playbook de verificación (opcional)
# └── tests/
# └── test_default.py # Tests de Testinfra
# O crear un rol nuevo con Molecule ya incluido
molecule init role --driver-name docker mi-nuevo-rol
Driver Docker
El driver Docker es el más usado por su rapidez y facilidad de configuración.
# Configuración completa del escenario Docker
cat > molecule/default/molecule.yml << 'EOF'
---
dependency:
name: galaxy
driver:
name: docker
platforms:
# Ubuntu 22.04
- name: ubuntu-22
image: "geerlingguy/docker-ubuntu2204-ansible:latest"
pre_build_image: true
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
command: /lib/systemd/systemd
# Ubuntu 20.04 (para compatibilidad con versiones anteriores)
- name: ubuntu-20
image: "geerlingguy/docker-ubuntu2004-ansible:latest"
pre_build_image: true
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
command: /lib/systemd/systemd
# Rocky Linux 8
- name: rockylinux-8
image: "geerlingguy/docker-rockylinux8-ansible:latest"
pre_build_image: true
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
command: /usr/lib/systemd/systemd
provisioner:
name: ansible
playbooks:
converge: converge.yml
verify: verify.yml
inventory:
host_vars:
ubuntu-22:
ansible_user: root
ubuntu-20:
ansible_user: root
rockylinux-8:
ansible_user: root
config_options:
defaults:
interpreter_python: auto_silent
callback_result_format: yaml
lint: |
set -e
ansible-lint
lint: |
set -e
yamllint .
ansible-lint
verifier:
name: ansible
EOF
# Playbook de convergencia: aplica el rol a testear
cat > molecule/default/converge.yml << 'EOF'
---
- name: Converge
hosts: all
become: true
vars:
# Variables de prueba para el rol
mi_rol_puerto: 8080
mi_rol_usuario: appuser
mi_rol_habilitar_ssl: false
pre_tasks:
- name: Actualizar cache de apt (Ubuntu/Debian)
apt:
update_cache: true
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Actualizar cache de yum (CentOS/Rocky)
yum:
update_cache: true
when: ansible_os_family == "RedHat"
roles:
- role: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}"
EOF
Fases de Molecule: Lint y Verify
# Playbook de verificación con Ansible (alternativa a Testinfra)
cat > molecule/default/verify.yml << 'EOF'
---
- name: Verify
hosts: all
become: true
tasks:
- name: Verificar que el paquete está instalado
package_facts:
manager: auto
- name: Comprobar que nginx está instalado
assert:
that:
- "'nginx' in ansible_facts.packages"
fail_msg: "nginx no está instalado"
success_msg: "nginx está instalado correctamente"
- name: Verificar que el servicio nginx está corriendo
service_facts:
- name: Comprobar estado del servicio
assert:
that:
- "ansible_facts.services['nginx.service'] is defined"
- "ansible_facts.services['nginx.service']['state'] == 'running'"
- "ansible_facts.services['nginx.service']['status'] == 'enabled'"
fail_msg: "El servicio nginx no está en el estado esperado"
- name: Verificar que el puerto está escuchando
wait_for:
port: 80
host: localhost
timeout: 10
- name: Verificar la respuesta HTTP
uri:
url: http://localhost:80
status_code: 200
register: http_response
- name: Comprobar que el directorio de configuración existe
stat:
path: /etc/nginx/sites-enabled
register: nginx_dir
- name: Verificar directorio de configuración
assert:
that:
- nginx_dir.stat.exists
- nginx_dir.stat.isdir
EOF
Assertions con Testinfra
# Configurar Testinfra para pruebas más avanzadas
cat > molecule/default/tests/test_default.py << 'PYTHON'
"""Tests de Testinfra para el rol de Nginx."""
import pytest
import testinfra
def test_nginx_installed(host):
"""Verificar que nginx está instalado."""
pkg = host.package("nginx")
assert pkg.is_installed
def test_nginx_service_running(host):
"""Verificar que el servicio nginx está corriendo y habilitado."""
service = host.service("nginx")
assert service.is_running
assert service.is_enabled
def test_nginx_listening_on_port_80(host):
"""Verificar que nginx escucha en el puerto 80."""
socket = host.socket("tcp://0.0.0.0:80")
assert socket.is_listening
def test_nginx_config_directory(host):
"""Verificar que el directorio de configuración existe."""
config_dir = host.file("/etc/nginx")
assert config_dir.exists
assert config_dir.is_directory
assert config_dir.mode == 0o755
def test_nginx_default_config(host):
"""Verificar que el archivo de configuración principal existe."""
config = host.file("/etc/nginx/nginx.conf")
assert config.exists
assert config.user == "root"
assert config.group == "root"
def test_nginx_log_directory(host):
"""Verificar que el directorio de logs existe."""
log_dir = host.file("/var/log/nginx")
assert log_dir.exists
assert log_dir.is_directory
def test_nginx_user_exists(host):
"""Verificar que el usuario nginx existe."""
user = host.user("www-data")
assert user.exists
def test_nginx_responds_to_http(host):
"""Verificar que nginx responde a las peticiones HTTP."""
cmd = host.run("curl -s -o /dev/null -w '%{http_code}' http://localhost:80")
assert cmd.succeeded
assert cmd.stdout in ["200", "301", "302"]
def test_firewall_port_open(host):
"""Verificar que el puerto 80 está abierto en el firewall."""
# Solo para sistemas con UFW
if host.system_info.distribution == "ubuntu":
cmd = host.run("ufw status | grep '80/tcp'")
# Verificar que la regla existe o que UFW está inactivo
assert cmd.rc in [0, 1] # 0 si existe la regla, 1 si no hay regla (UFW inactivo)
# Tests parametrizados para múltiples configuraciones
@pytest.mark.parametrize("pkg", ["nginx", "openssl"])
def test_required_packages(host, pkg):
"""Verificar que todos los paquetes requeridos están instalados."""
package = host.package(pkg)
assert package.is_installed
PYTHON
# Configurar Molecule para usar Testinfra como verifier
cat >> molecule/default/molecule.yml << 'EOF'
verifier:
name: testinfra
options:
v: true
# Directorio donde están los tests
pytest_addopts: "--tb=short"
EOF
Integración con CI/CD
# Pipeline de GitHub Actions para testing automático de roles Ansible
cat > .github/workflows/molecule.yml << 'EOF'
---
name: Molecule Tests
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
jobs:
molecule:
name: Molecule - ${{ matrix.distro }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
distro:
- ubuntu-22
- ubuntu-20
- rockylinux-8
steps:
- name: Checkout del código
uses: actions/checkout@v4
- name: Configurar Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Instalar dependencias
run: |
pip install molecule molecule-plugins[docker] pytest-testinfra ansible-lint ansible
- name: Ejecutar lint de Ansible
run: ansible-lint
- name: Ejecutar tests de Molecule
run: molecule test
env:
# Pasar la distribución como variable para seleccionar la plataforma
MOLECULE_DISTRO: ${{ matrix.distro }}
PY_COLORS: "1"
ANSIBLE_FORCE_COLOR: "1"
- name: Publicar resultados de tests
uses: dorny/test-reporter@v1
if: always()
with:
name: Molecule Results - ${{ matrix.distro }}
path: "*.xml"
reporter: java-junit
EOF
# Pipeline para GitLab CI
cat > .gitlab-ci.yml << 'EOF'
---
stages:
- lint
- test
variables:
PY_COLORS: "1"
ANSIBLE_FORCE_COLOR: "1"
lint:
stage: lint
image: python:3.11
script:
- pip install ansible-lint yamllint
- yamllint .
- ansible-lint
molecule-ubuntu22:
stage: test
image: docker:24
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
before_script:
- apk add python3 py3-pip
- pip install molecule molecule-plugins[docker] pytest-testinfra ansible
script:
- molecule test
only:
- main
- merge_requests
EOF
Testing Multi-Plataforma
# Ejecutar los tests en todas las plataformas definidas
molecule test
# Ejecutar solo en una plataforma específica
molecule test --platform-name ubuntu-22
# Ejecutar fases individuales de Molecule para desarrollo
molecule create # Crear las instancias Docker
molecule converge # Aplicar el rol (sin destruir)
molecule verify # Ejecutar solo la verificación
molecule lint # Solo lint
molecule idempotence # Verificar idempotencia del rol
molecule destroy # Destruir las instancias
# Verificar la idempotencia: el rol no debe hacer cambios si se aplica dos veces
molecule idempotence
# Conectarse a una instancia para debugging
molecule login --host ubuntu-22
# Ver el estado de las instancias
molecule list
# Ejecutar un comando en todas las instancias
molecule exec -- systemctl status nginx
Solución de Problemas
Error al crear las instancias Docker:
# Verificar que Docker está corriendo
systemctl status docker
docker info
# Verificar que el usuario tiene permisos para Docker
groups $USER | grep docker
# Si no está en el grupo docker:
usermod -aG docker $USER && newgrp docker
# Intentar crear las instancias con debug
molecule create --debug
# Ver los logs de Docker si falla
docker events &
molecule create
El rol no es idempotente:
# Ejecutar la verificación de idempotencia para ver qué cambia
molecule idempotence
# El output mostrará las tareas que hacen cambios en el segundo run
# Revisar esas tareas y agregar condiciones when: para hacerlas idempotentes
# Ejemplo de tarea no idempotente y cómo corregirla:
# ANTES (no idempotente):
# - name: Agregar línea al archivo de configuración
# lineinfile:
# path: /etc/mi-app/config
# line: "max_connections=100"
#
# DESPUÉS (idempotente):
# - name: Agregar línea al archivo de configuración
# lineinfile:
# path: /etc/mi-app/config
# line: "max_connections=100"
# regexp: "^max_connections=" # Buscar si ya existe
Conclusión
Molecule transforma el desarrollo de roles Ansible de un proceso manual y propenso a errores en un flujo de trabajo automatizado y reproducible. La combinación de testing multi-plataforma, verificación de idempotencia y assertions detalladas con Testinfra garantiza que los roles funcionan correctamente en todos los sistemas objetivo antes de llegar a producción. Integrar Molecule en los pipelines de CI/CD convierte el testing de infraestructura en una parte natural del proceso de desarrollo, elevando la calidad y confiabilidad de la automatización de sistemas.


