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:
# 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
| Rule | Why it matters |
|---|---|
name[missing] | Tasks without name produce useless logs |
no-changed-when | command + shell always report changed — masks idempotency |
no-log-password | Tasks handling passwords need no_log: true |
fqcn | Use ansible.builtin.copy, not copy — explicit collection |
risky-shell-pipe | shell with | needs set -o pipefail |
jinja[spacing] | Inconsistent {{ var }} spacing |
yaml[indentation] | Mixed indentation breaks readability |
---
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
| Phase | What happens |
|---|---|
| 1. dependency | Install role/collection deps from requirements.yml |
| 2. create | Spin up fresh test instances (Docker / EC2 / VM) |
| 3. prepare | OS-level pre-reqs the role assumes (sudo, python3) |
| 4. converge | Run the role itself — the actual work |
| 5. idempotence | Run converge AGAIN — fail if any task reports changed |
| 6. verify | Run testinfra / ansible assertions against the result |
| 7. destroy | Tear down test instances |
# 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
---
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/
---
- 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
---
- 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"
# 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.
---
# 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:
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
---
# .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
pip install pre-commit
pre-commit install
# Now every git commit auto-runs lint + yamllint.
# Failures block the commit until fixed.
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.