Ansible OEL 8 DevOps · OEL 8 · CI/CD

AnsibleTesting Playbooks — Molecule & ansible-lint

The final piece — testing roles like real software. ansible-lint as the static analyser, Molecule for the integration-test runner with its 7-phase scenario lifecycle, the Docker driver, testinfra assertions, and the idempotence check that catches half of all role bugs.

A role is software. It deserves the same testing rigour as application code: a static analyser to catch obvious mistakes, a runtime test that proves it actually works, and an idempotence check that proves it's safe to run twice. Without these, every Ansible change is a bet that the developer remembered to manually re-test — which they didn't, because manual testing is boring.

Run it on every commit, every PR, every CI build. It's fast, opinionated, and finds real bugs:

BASH — common ansible-lint runs
# Lint the whole project (uses .ansible-lint config)
ansible-lint

# Lint with the strictest preset
ansible-lint --profile production

# Lint a specific role
ansible-lint roles/mysql

# Auto-fix what's auto-fixable
ansible-lint --fix

# List all rules
ansible-lint -L
RuleWhy it matters
name[missing]Tasks without name produce useless logs
no-changed-whencommand + shell always report changed — masks idempotency
no-log-passwordTasks handling passwords need no_log: true
fqcnUse ansible.builtin.copy, not copy — explicit collection
risky-shell-pipeshell with | needs set -o pipefail
jinja[spacing]Inconsistent {{ var }} spacing
yaml[indentation]Mixed indentation breaks readability
YAML — .ansible-lint config
---
profile: production

skip_list:
  - role-name             # we use snake_case
  - meta-no-info          # internal roles only

exclude_paths:
  - .cache/
  - molecule/
  - .github/

# Treat warnings as errors in CI
strict: true

mock_modules:
  - community.mysql.mysql_query
  - community.proxysql.proxysql_query
molecule test — full role lifecycle in CI 1. dependency install requirements 2. create spin up Docker container 3. prepare install pre-reqs (sudo, etc.) 4. converge apply the role 5. idempotence re-run, expect 0 changes 6. verify run testinfra assertions 7. destroy tear down container Scenario YAML drives every phase: molecule/default/molecule.yml driver: docker platforms: - name: oel8 · image: oraclelinux:8 One command (molecule test) runs ALL 7 phases · CI integration trivial
PhaseWhat happens
1. dependencyInstall role/collection deps from requirements.yml
2. createSpin up fresh test instances (Docker / EC2 / VM)
3. prepareOS-level pre-reqs the role assumes (sudo, python3)
4. convergeRun the role itself — the actual work
5. idempotenceRun converge AGAIN — fail if any task reports changed
6. verifyRun testinfra / ansible assertions against the result
7. destroyTear down test instances
BASH — install + initialise Molecule for an existing role
# Install
pip install "molecule[docker]" "molecule-plugins[docker]" pytest-testinfra

# In an existing role directory
cd roles/mysql

# Create the default scenario
molecule init scenario default --driver-name docker

# Layout created:
# roles/mysql/molecule/default/
# ├── molecule.yml      ← scenario config
# ├── converge.yml      ← playbook that applies the role
# ├── verify.yml        ← post-run assertions
# └── tests/test_default.py    ← testinfra Python tests
YAML — molecule/default/molecule.yml
---
dependency:
  name: galaxy
  options:
    requirements-file: requirements.yml

driver:
  name: docker

platforms:
  - name: oel8
    image: oraclelinux:8
    pre_build_image: false
    privileged: true                      # systemd needs privileged
    command: /usr/sbin/init                # boot systemd inside container
    cgroupns_mode: host
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    capabilities:
      - SYS_ADMIN
    tmpfs:
      - /run
      - /tmp

  - name: oel9
    image: oraclelinux:9
    privileged: true
    command: /usr/sbin/init
    cgroupns_mode: host
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw

provisioner:
  name: ansible
  inventory:
    group_vars:
      all:
        # Test-specific values (override role defaults)
        mysql_root_password: TestPassword123!
        mysql_databases:
          - {{ name: testdb }}
        mysql_users:
          - {{ name: testuser, password: TestUserPwd!, priv: "testdb.*:ALL" }}
  config_options:
    defaults:
      stdout_callback: yaml
      callback_result_format: yaml

verifier:
  name: ansible
  # Or: name: testinfra
  # directory: tests/
YAML — molecule/default/converge.yml
---
- name: Converge — apply the role under test
  hosts: all
  become: true

  pre_tasks:
    - name: Install Python deps the role needs (in the container)
      ansible.builtin.dnf:
        name:
          - python3-pip
          - python3-PyMySQL
        state: present

  roles:
    - role: mysql
YAML — molecule/default/verify.yml
---
- name: Verify — assert the role's effects
  hosts: all
  become: true
  gather_facts: false

  tasks:
    - name: MySQL service is enabled and running
      ansible.builtin.systemd:
        name: mysqld
      register: svc

    - ansible.builtin.assert:
        that:
          - svc.status.ActiveState == "active"
          - svc.status.UnitFileState == "enabled"

    - name: MySQL is reachable on 3306
      ansible.builtin.wait_for:
        port: 3306
        timeout: 10

    - name: testdb database exists
      community.mysql.mysql_query:
        login_user: root
        login_password: TestPassword123!
        query: "SHOW DATABASES LIKE 'testdb'"
      register: db_check

    - ansible.builtin.assert:
        that:
          - db_check.query_result[0] | length == 1

    - name: testuser can connect and SELECT
      community.mysql.mysql_query:
        login_user: testuser
        login_password: TestUserPwd!
        login_db: testdb
        query: "SELECT 1 AS ok"
      register: user_query

    - ansible.builtin.assert:
        that:
          - user_query.query_result[0][0].ok == 1

    - name: my.cnf has the expected innodb_buffer_pool_size
      ansible.builtin.shell: |
        grep '^innodb_buffer_pool_size' /etc/my.cnf
      register: cnf
      changed_when: false

    - ansible.builtin.assert:
        that:
          - "'128M' in cnf.stdout"
BASH — Molecule commands
# Run the FULL lifecycle (create → converge → idempotence → verify → destroy)
molecule test

# Just create + converge, leave the container running for debugging
molecule converge

# Drop into the test container
molecule login

# Run only verify against an already-converged container
molecule verify

# Tear down
molecule destroy

# Full test, but for a different scenario (e.g. testing the cluster role)
molecule test -s cluster

# Output goes to stdout; failures fail the command
echo $?      # 0 = pass, non-zero = fail

Molecule runs the converge playbook a second time and fails the test if any task reports changed. This catches the most common Ansible role bug: tasks that look idempotent but aren't.

YAML — task that fails idempotence
---
# BAD: command always reports changed (even when nothing happened)
- name: Initialise the database
  ansible.builtin.command: |
    mysql_install_db --datadir=/var/lib/mysql

# GOOD: guard with creates
- name: Initialise the database
  ansible.builtin.command: |
    mysql_install_db --datadir=/var/lib/mysql
  args:
    creates: /var/lib/mysql/mysql/user.frm    # if this file exists, skip task

# BAD: shell always re-runs
- name: Add line to config
  ansible.builtin.shell: echo 'log-error=/var/log/mysqld.log' >> /etc/my.cnf

# GOOD: lineinfile is idempotent
- name: Set log path in my.cnf
  ansible.builtin.lineinfile:
    path: /etc/my.cnf
    line: 'log-error=/var/log/mysqld.log'
    insertafter: "[mysqld]"

One role, multiple scenarios — test the role on different OS versions, in different configurations, and against different cluster sizes:

DIR — multi-scenario layout
roles/mysql/molecule/
├── default/                  # baseline single-host install
│   ├── molecule.yml
│   ├── converge.yml
│   └── verify.yml
├── replica/                  # configures as replica
│   ├── molecule.yml
│   ├── converge.yml
│   └── verify.yml
└── cluster/                  # 3-node MGR cluster
    ├── molecule.yml
    ├── converge.yml
    └── verify.yml
💡 Tip: Default scenario stays small (one container, fast feedback). Add specialised scenarios for the harder topologies. CI can run them in parallel via a matrix job.
YAML — pre-commit hook for ansible-lint
---
# .pre-commit-config.yaml — runs on every git commit
repos:
  - repo: https://github.com/ansible-community/ansible-lint
    rev: v24.7.0
    hooks:
      - id: ansible-lint
        args: ["--profile=production"]

  - repo: https://github.com/adrienverge/yamllint
    rev: v1.35.1
    hooks:
      - id: yamllint
BASH — install pre-commit hooks
pip install pre-commit
pre-commit install

# Now every git commit auto-runs lint + yamllint.
# Failures block the commit until fixed.
✅ Tip: Lint catches the cheap bugs. Molecule catches the real bugs. Idempotence catches the subtle bugs. Run all three on every PR and your roles become real, tested software — not just YAML someone hopes will work.

You've now walked through 8 sections covering the entirety of practical Ansible for database operations: the basics of how Ansible works, playbook fundamentals with variables and templates and conditionals, roles and reuse with Galaxy and collections, inventory and secrets management, 19 hands-on database labs across the major engines, operational concerns from backups to monitoring to runbooks, advanced features like custom plugins and rolling updates, and finally the CI/CD layer that turns Ansible from a CLI tool into a tested, audited, multi-team platform.

Every page in this series shipped with working code — playbooks, roles, templates, SVG architecture diagrams, idempotent tasks, and the operational rigour that turns ad-hoc shell scripts into reproducible infrastructure. The whole 65-page series is a single git repo, a single inventory, a single playbook entry-point — and the same discipline applies whether you're managing 3 hosts in a homelab or 500 hosts in a production fleet.

The 8 sections — 65 pages — recap:

  • Section 1 — Basics (9 pages, 1–9): how Ansible works, first playbook, modules, idempotency
  • Section 2 — Playbook Fundamentals (10 pages, 10–19): variables, templates, conditionals, handlers, blocks, includes
  • Section 3 — Roles and Reuse (6 pages, 20–25): role layout, var precedence, Galaxy, collections, multi-role orchestration
  • Section 4 — Inventory and Secrets (6 pages, 26–31): static / dynamic inventory, plugins, Vault, connection plugins
  • Section 5 — Database Labs (19 pages, 32–50): MySQL ecosystem (10 pages) + clustering & other engines (9 pages)
  • Section 6 — Database Operations (8 pages, 51–58): backups, restores, migrations, TLS, monitoring, logs, runbooks
  • Section 7 — Advanced (4 pages, 59–62): custom plugins, performance tuning, multi-environment, rolling updates
  • Section 8 — Operations and CI/CD (3 pages, 63–65): AWX, GitLab CI / GitHub Actions, Molecule + ansible-lint

That's the whole journey from ansible -m ping against one host to a tested, linted, vaulted, monitored, audited multi-environment Ansible platform driving every database in the fleet.

Thank you for following along through all 65 pages.