Тестирование инфраструктуры с Molecule и Testinfra: TDD для Ansible-ролей

Исходная ситуация

DevOps-команда «ДевОпсПро» обслуживает инфраструктуру из 300 серверов. За последний год они написали 45 Ansible-ролей для автоматизации: от установки PostgreSQL до настройки Kubernetes-нод. Проблема: ни одна роль не имела тестов.

Последствия:

  • 3 production-инцидента за квартал из-за непротестированных ролей. Роль для настройки firewall закрывала SSH-порт на production-сервере, потребовался физический доступ к KVM-консоли.
  • Нет уверенности в идемпотентности — повторный запуск роли мог менять конфигурацию, даже если изменения не требовались. Один раз повторный запуск роли PostgreSQL сбросил pg_hba.conf к дефолтным значениям.
  • Страх обновлений — никто не рисковал обновлять роли, потому что невозможно проверить, что изменение не сломает что-то на 5 из 300 серверов.
  • Multi-platform проблемы — роли тестировались только на Ubuntu, но часть серверов работала на Debian и Rocky Linux. «Работает у меня» — фраза, которую слышали постоянно.

Мы внедрили Molecule + Testinfra для автоматического тестирования каждой роли перед мержем в main-ветку.

Molecule: что это и как работает

Molecule — фреймворк для тестирования Ansible-ролей. Он создаёт виртуальную среду (Docker-контейнер, VM или облачный инстанс), применяет к ней Ansible-роль и запускает тесты. Полный цикл: create → converge → verify → destroy.

Установка:

# Создаём виртуальное окружение
python3 -m venv ~/molecule-env
source ~/molecule-env/bin/activate

# Устанавливаем Molecule с Docker driver и Testinfra
pip install molecule molecule-plugins[docker] pytest-testinfra ansible-core

# Проверяем версии
molecule --version
# molecule 24.6.0
# ansible-core 2.16.x

Инициализируем Molecule в существующей роли:

# Переходим в директорию роли
cd roles/postgresql

# Инициализируем Molecule (создаст директорию molecule/default/)
molecule init scenario --driver-name docker

# Структура:
# roles/postgresql/
# ├── defaults/
# ├── handlers/
# ├── tasks/
# ├── templates/
# └── molecule/
#     └── default/
#         ├── molecule.yml      # Конфигурация сценария
#         ├── converge.yml      # Плейбук для применения роли
#         ├── verify.yml        # Ansible-тесты (или Testinfra)
#         └── prepare.yml       # Подготовка среды (опционально)

Конфигурация molecule.yml для роли PostgreSQL:

# molecule/default/molecule.yml
---
dependency:
  name: galaxy

driver:
  name: docker

platforms:
  - name: pg-ubuntu2404
    image: geerlingguy/docker-ubuntu2404-ansible:latest
    command: /lib/systemd/systemd
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    privileged: true
    pre_build_image: true

  - name: pg-debian12
    image: geerlingguy/docker-debian12-ansible:latest
    command: /lib/systemd/systemd
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    privileged: true
    pre_build_image: true

  - name: pg-rocky9
    image: geerlingguy/docker-rockylinux9-ansible:latest
    command: /lib/systemd/systemd
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    privileged: true
    pre_build_image: true

provisioner:
  name: ansible
  inventory:
    group_vars:
      all:
        postgresql_version: "16"
        postgresql_listen_addresses: "*"
        postgresql_max_connections: 200
        postgresql_databases:
          - name: testdb
            owner: testuser
        postgresql_users:
          - name: testuser
            password: "testpass123"
            role_attr_flags: "CREATEDB"

verifier:
  name: testinfra

Testinfra: инфраструктурные ассерты на Python

Testinfra — библиотека для pytest, которая позволяет писать тесты для инфраструктуры: проверять установленные пакеты, запущенные сервисы, открытые порты, содержимое файлов.

Тесты для роли PostgreSQL:

# molecule/default/tests/test_postgresql.py
import pytest


def test_postgresql_package_installed(host):
    """PostgreSQL 16 должен быть установлен."""
    pkg = host.package("postgresql-16")
    assert pkg.is_installed
    assert pkg.version.startswith("16.")


def test_postgresql_service_running(host):
    """Сервис PostgreSQL должен быть запущен и в автозагрузке."""
    svc = host.service("postgresql")
    assert svc.is_running
    assert svc.is_enabled


def test_postgresql_port_listening(host):
    """PostgreSQL должен слушать на порту 5432."""
    socket = host.socket("tcp://0.0.0.0:5432")
    assert socket.is_listening


def test_postgresql_config_listen_addresses(host):
    """postgresql.conf должен содержать listen_addresses = '*'."""
    cfg = host.file("/etc/postgresql/16/main/postgresql.conf")
    assert cfg.exists
    assert cfg.contains("listen_addresses = '*'")


def test_postgresql_config_max_connections(host):
    """max_connections должен быть 200."""
    cfg = host.file("/etc/postgresql/16/main/postgresql.conf")
    assert cfg.contains("max_connections = 200")


def test_pg_hba_md5_auth(host):
    """pg_hba.conf должен разрешать md5 аутентификацию."""
    hba = host.file("/etc/postgresql/16/main/pg_hba.conf")
    assert hba.exists
    assert hba.contains("host\\s+all\\s+all\\s+0.0.0.0/0\\s+scram-sha-256")


def test_database_exists(host):
    """База данных testdb должна существовать."""
    cmd = host.run(
        "sudo -u postgres psql -tAc "
        "\"SELECT 1 FROM pg_database WHERE datname='testdb'\""
    )
    assert cmd.stdout.strip() == "1"


def test_user_exists(host):
    """Пользователь testuser должен существовать."""
    cmd = host.run(
        "sudo -u postgres psql -tAc "
        "\"SELECT 1 FROM pg_roles WHERE rolname='testuser'\""
    )
    assert cmd.stdout.strip() == "1"


def test_user_can_connect(host):
    """testuser должен иметь возможность подключиться к testdb."""
    cmd = host.run(
        "PGPASSWORD=testpass123 psql -h 127.0.0.1 -U testuser "
        "-d testdb -tAc 'SELECT 1'"
    )
    assert cmd.rc == 0
    assert cmd.stdout.strip() == "1"


def test_data_directory_permissions(host):
    """Data directory должен принадлежать postgres."""
    data_dir = host.file("/var/lib/postgresql/16/main")
    assert data_dir.exists
    assert data_dir.user == "postgres"
    assert data_dir.group == "postgres"
    assert data_dir.mode == 0o700


def test_log_directory_exists(host):
    """Директория логов должна существовать."""
    log_dir = host.file("/var/log/postgresql")
    assert log_dir.exists
    assert log_dir.is_directory

Запуск тестов:

# Полный цикл: create → converge → verify → destroy
molecule test

# Или по шагам (удобно для разработки)
molecule create       # Создаёт контейнеры
molecule converge     # Применяет роль
molecule verify       # Запускает тесты
molecule login        # SSH в контейнер для отладки
molecule destroy      # Удаляет контейнеры

# Вывод molecule verify:
# TASK [Running Testinfra tests] *****
# tests/test_postgresql.py::test_postgresql_package_installed[pg-ubuntu2404] PASSED
# tests/test_postgresql.py::test_postgresql_service_running[pg-ubuntu2404] PASSED
# tests/test_postgresql.py::test_postgresql_port_listening[pg-ubuntu2404] PASSED
# ...
# tests/test_postgresql.py::test_postgresql_package_installed[pg-debian12] PASSED
# tests/test_postgresql.py::test_postgresql_package_installed[pg-rocky9] PASSED
# ===== 30 passed in 45.23s =====

Тестирование идемпотентности

Идемпотентность — свойство роли давать одинаковый результат при многократном применении. Molecule проверяет это автоматически: после converge запускает роль повторно и проверяет, что нет «changed» задач.

# molecule.yml — включаем проверку идемпотентности
provisioner:
  name: ansible
  playbooks:
    converge: converge.yml
    idempotence: converge.yml  # тот же плейбук
  options:
    diff: true
    v: true

Команда molecule idempotence запускает плейбук повторно и проваливает тест, если хотя бы одна задача имеет статус «changed».

Типичные проблемы с идемпотентностью, которые мы обнаружили:

# ПЛОХО: template всегда перезаписывает файл (даже если содержимое не изменилось)
- name: Configure app
  copy:
    content: |
      DB_HOST={{ db_host }}
      DB_PORT={{ db_port }}
      TIMESTAMP={{ ansible_date_time.iso8601 }}  # ← Каждый запуск меняет!
    dest: /etc/app/config.env

# ХОРОШО: убираем динамические значения из конфигурации
- name: Configure app
  template:
    src: config.env.j2
    dest: /etc/app/config.env
    mode: '0640'
    owner: app
    group: app

# ПЛОХО: shell/command без changed_when
- name: Initialize database
  command: /usr/local/bin/init-db.sh
  # Каждый запуск показывает changed!

# ХОРОШО: добавляем проверку и creates/changed_when
- name: Initialize database
  command: /usr/local/bin/init-db.sh
  args:
    creates: /var/lib/app/.db-initialized
  changed_when: false

Из 45 ролей «ДевОпсПро» 32 имели проблемы с идемпотентностью. Molecule помог обнаружить и исправить все за 2 недели.

Multi-platform тестирование

Molecule позволяет тестировать роль одновременно на нескольких ОС. Мы создали матрицу платформ, покрывающую все ОС в инфраструктуре:

# molecule/default/molecule.yml — multi-platform
platforms:
  - name: ubuntu-2204
    image: geerlingguy/docker-ubuntu2204-ansible:latest
    command: /lib/systemd/systemd
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    privileged: true
    pre_build_image: true
    groups:
      - debian_family

  - name: ubuntu-2404
    image: geerlingguy/docker-ubuntu2404-ansible:latest
    command: /lib/systemd/systemd
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    privileged: true
    pre_build_image: true
    groups:
      - debian_family

  - name: debian-12
    image: geerlingguy/docker-debian12-ansible:latest
    command: /lib/systemd/systemd
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    privileged: true
    pre_build_image: true
    groups:
      - debian_family

  - name: rocky-9
    image: geerlingguy/docker-rockylinux9-ansible:latest
    command: /lib/systemd/systemd
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    privileged: true
    pre_build_image: true
    groups:
      - rhel_family

Тесты Testinfra адаптируются под разные ОС:

# tests/test_hardening.py — multi-platform тесты
import pytest


@pytest.fixture()
def get_os_family(host):
    return host.system_info.distribution


def test_firewall_installed(host):
    """Файрволл должен быть установлен."""
    distro = host.system_info.distribution
    if distro in ("ubuntu", "debian"):
        assert host.package("ufw").is_installed
    elif distro in ("rocky", "centos", "redhat"):
        assert host.package("firewalld").is_installed


def test_ssh_port_custom(host):
    """SSH должен слушать на порту 2222."""
    socket = host.socket("tcp://0.0.0.0:2222")
    assert socket.is_listening


def test_ssh_config_hardened(host):
    """SSH должен быть захарденен."""
    cfg = host.file("/etc/ssh/sshd_config")
    assert cfg.contains("PermitRootLogin no")
    assert cfg.contains("PasswordAuthentication no")
    assert cfg.contains("MaxAuthTries 3")


def test_unnecessary_services_disabled(host):
    """Ненужные сервисы должны быть отключены."""
    for svc_name in ["cups", "avahi-daemon", "bluetooth"]:
        svc = host.service(svc_name)
        if svc.exists:
            assert not svc.is_running
            assert not svc.is_enabled

CI/CD интеграция с GitLab

Каждый merge request с изменениями в Ansible-ролях автоматически запускает Molecule-тесты в GitLab CI:

# .gitlab-ci.yml
stages:
  - lint
  - test
  - deploy

variables:
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"

# Шаблон для Molecule-тестов
.molecule_template: &molecule_template
  stage: test
  image: docker:24-dind
  services:
    - docker:24-dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
  before_script:
    - apk add --no-cache python3 py3-pip gcc musl-dev python3-dev libffi-dev
    - pip3 install molecule molecule-plugins[docker] pytest-testinfra ansible-core
  cache:
    key: molecule-pip
    paths:
      - .cache/pip

# Линтинг
ansible-lint:
  stage: lint
  image: python:3.12-slim
  before_script:
    - pip install ansible-lint yamllint
  script:
    - yamllint -c .yamllint roles/
    - ansible-lint roles/
  rules:
    - changes:
        - roles/**/*

# Тест роли PostgreSQL
test:postgresql:
  <<: *molecule_template
  script:
    - cd roles/postgresql
    - molecule test --all
  rules:
    - changes:
        - roles/postgresql/**/*

# Тест роли Hardening
test:hardening:
  <<: *molecule_template
  script:
    - cd roles/hardening
    - molecule test --all
  rules:
    - changes:
        - roles/hardening/**/*

# Тест роли Nginx
test:nginx:
  <<: *molecule_template
  script:
    - cd roles/nginx
    - molecule test --all
  rules:
    - changes:
        - roles/nginx/**/*

# Динамический pipeline: тестируем только изменённые роли
test:changed-roles:
  <<: *molecule_template
  script:
    - |
      # Находим изменённые роли
      CHANGED_ROLES=$(git diff --name-only $CI_MERGE_REQUEST_DIFF_BASE_SHA HEAD \
        | grep '^roles/' | cut -d'/' -f2 | sort -u)
      
      if [ -z "$CHANGED_ROLES" ]; then
        echo "No roles changed, skipping tests"
        exit 0
      fi
      
      FAILED=0
      for role in $CHANGED_ROLES; do
        echo "=== Testing role: $role ==="
        if [ -d "roles/$role/molecule" ]; then
          cd "roles/$role"
          molecule test --all || FAILED=1
          cd ../.. 
        else
          echo "WARNING: Role $role has no Molecule tests!"
        fi
      done
      
      exit $FAILED
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes:
        - roles/**/*

Время выполнения тестов в CI: роль с 3 платформами — 3-5 минут. Все 45 ролей параллельно — 8-12 минут (GitLab запускает их как отдельные jobs).

TDD для инфраструктуры и результаты

Мы внедрили TDD-подход для новых ролей: сначала пишем тесты Testinfra, которые описывают желаемое состояние, затем — задачи Ansible, которые приводят систему к этому состоянию.

Пример: создание роли для установки Redis с нуля:

# Шаг 1: Пишем тесты СНАЧАЛА
# molecule/default/tests/test_redis.py

def test_redis_installed(host):
    assert host.package("redis-server").is_installed

def test_redis_service(host):
    svc = host.service("redis-server")
    assert svc.is_running
    assert svc.is_enabled

def test_redis_port(host):
    assert host.socket("tcp://127.0.0.1:6379").is_listening

def test_redis_maxmemory(host):
    cmd = host.run("redis-cli CONFIG GET maxmemory")
    assert "1073741824" in cmd.stdout  # 1GB

def test_redis_password_required(host):
    # Без пароля — ошибка
    cmd = host.run("redis-cli PING")
    assert "NOAUTH" in cmd.stdout
    # С паролем — PONG
    cmd = host.run("redis-cli -a 'SecureRedisPass!' PING")
    assert "PONG" in cmd.stdout

def test_redis_persistence(host):
    cfg = host.file("/etc/redis/redis.conf")
    assert cfg.contains("appendonly yes")
    assert cfg.contains("appendfsync everysec")

def test_redis_bind_localhost(host):
    cfg = host.file("/etc/redis/redis.conf")
    assert cfg.contains("bind 127.0.0.1")

# Шаг 2: Запускаем тесты — все FAILED (RED)
# Шаг 3: Пишем tasks/main.yml — тесты проходят (GREEN)
# Шаг 4: Рефакторим (REFACTOR)

Результаты после 3 месяцев использования Molecule + Testinfra:

МетрикаДоПосле
Production-инциденты из-за ролей3-4 в квартал0
Роли с проблемами идемпотентности32 из 45 (71%)0 из 45 (0%)
Поддерживаемые ОС на роль1 (Ubuntu)3-4 (Ubuntu, Debian, Rocky)
Время от коммита до production1-2 дня (ручной review)30 минут (автотесты + review)
Уверенность команды в обновленияхНизкаяВысокая

Рекомендации:

  • Начните с самых критичных ролей (hardening, database, monitoring) — на них тесты дают максимальный ROI.
  • Используйте Docker driver для скорости — VM через Vagrant работает в 5-10 раз медленнее.
  • Тестируйте идемпотентность всегда — это ловит 80% скрытых багов в ролях.
  • Не пишите тесты ради тестов — тестируйте поведение (порт слушает, сервис работает), а не реализацию (файл содержит строку X).
  • Интегрируйте в CI с первого дня — тесты, которые не запускаются автоматически, перестают поддерживаться за месяц.

Часто задаваемые вопросы

Ansible verifier использует assert модуль в плейбуках — это работает, но неудобно для сложных проверок. Testinfra даёт полноценный Python с pytest: параметризация, фикстуры, подробные сообщения об ошибках, интеграция с IDE. Для простых ролей подходит ansible verifier, для сложных (база данных, кластер) — Testinfra значительно удобнее.
Скорость: тест в Docker выполняется за 1-3 минуты, в VM через Vagrant — за 5-15 минут. Docker подходит для 90% ролей. VM нужны только при тестировании ядра (sysctl, модули ядра), сетевых namespace, iptables/nftables или когда роль использует systemd-nspawn.
Molecule поддерживает prepare.yml — плейбук, который выполняется после create и перед converge. В нём устанавливают зависимости. Для внешних API используйте mock-серверы (WireMock) или testcontainers. Для баз данных — дополнительные контейнеры через Docker network в molecule.yml.
Для простой роли (установка пакета + конфигурация + сервис) — 30-60 минут. Для сложной (PostgreSQL с репликацией, Kubernetes с kubelet) — 2-4 часа. Это одноразовая инвестиция: после написания тесты запускаются автоматически при каждом изменении и окупаются после первого пойманного бага.
Да, это минимальный полезный сценарий: molecule test запускает converge дважды и проваливает тест при наличии changed задач. Даже без Testinfra-тестов это ловит 80% проблем с ролями. Рекомендуем начать с проверки идемпотентности и постепенно добавлять Testinfra-ассерты.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#molecule#testinfra#ansible#testing#tdd#docker#ci/cd#gitlab
Комментарии 0

Оставить комментарий

загрузка...