Code Quality #
Code quality in Ansible automation isn’t just about writing aesthetics — it’s a determining factor in infrastructure reliability. Ansible code written hastily without format standardization becomes enormous technical debt. When that code is hard to understand, the risk of execution failures in production environments rises sharply. We must apply strict code quality controls — from static analysis (linting), Git commit restrictions, to structured review processes — to ensure every line of our infrastructure code is safe, idempotent, and easily maintainable by all team members.
1. Applying a Strict ansible-lint Profile #
ansible-lint is our main tool for static code analysis. This tool detects common errors, bad practices, and security gaps in playbooks, roles, and variables before we run them on real infrastructure.
Since its modern versions, ansible-lint introduced the “profile” concept determining the strictness level of rules. For enterprise-scale projects prioritizing stability, we must apply the highest profile: production.
Here’s the industry-standard .ansible-lint configuration file we use to strictly control code quality:
# .ansible-lint
# Using the production profile for the highest quality standards
profile: production
# Enabling the most complete built-in rules
use_default_rules: true
# Rules we deliberately exclude with strong technical reasons
skip_list:
- yaml[line-length] # Long command/shell commands sometimes can't be split
- fqcn[action-core] # Allows writing built-in modules without full collection names to simplify code
# Rules we categorize as warnings first (not errors)
warn_list:
- no-changed-when # We allow as a warning during legacy code migration
- jinja[spacing] # Spacing in jinja templates becomes a warning so it doesn't block builds
# Directories fully excluded from linting (e.g. caches and third-party)
exclude_paths:
- .cache/
- molecule/
- vendor/
- collections/
# Points to the directory storing our internal team's custom lint rules
rulesdir:
- .ansible-lint-rules/
# Detailed terminal output configuration
verbosity: 1
show_progress: true
Why Choose the Production Profile? #
The production profile ensures our code meets real deployment readiness standards. This profile requires every task to have a clear name, all modules to use safe parameters, and no deprecated functions that could break execution on newer Ansible versions.
2. Writing Custom Lint Rules #
Sometimes, the built-in ansible-lint rules don’t cover our organization’s internal policies. For example, we might want to prohibit using sensitive variables (secrets) written directly in plaintext without Ansible Vault encryption. For this need, we can write custom rules using the Python programming language.
We create a Python script inside the .ansible-lint-rules/NoPlaintextSecrets.py folder:
# .ansible-lint-rules/NoPlaintextSecrets.py
"""Custom rule to detect plaintext secret writing in variables."""
from ansiblelint.rules import AnsibleLintRule
import re
# Regex patterns to detect keys indicated to contain secrets
SECRET_KEYS = [
r'password',
r'api_key',
r'secret',
r'token',
r'private_key'
]
class NoPlaintextSecrets(AnsibleLintRule):
id = 'COMP001'
shortdesc = 'Storing secrets in plaintext is strictly forbidden'
description = (
'All variables containing sensitive keywords like password, '
'api_key, or token must be stored using Ansible Vault encryption '
'or referenced using Jinja2 syntax. Plaintext is forbidden.'
)
severity = 'VERY_HIGH'
tags = ['security', 'company-policy']
def matchplay(self, file, data):
"""Check variables declared at the playbook/play level."""
errors = []
if not data:
return errors
# Look for the 'vars' variable block at the play level
play_vars = data.get('vars', {})
if isinstance(play_vars, dict):
for key, value in play_vars.items():
# If the key matches secret criteria and the value isn't Jinja2 syntax {{ ... }}
if any(re.search(pattern, key, re.IGNORECASE) for pattern in SECRET_KEYS):
if isinstance(value, str) and not (value.startswith('{{') and value.endswith('}}')):
errors.append(
self.create_matcherror(
message=f"Sensitive variable '{key}' written plaintext. Use Ansible Vault!",
filename=file.name
)
)
return errors
def matchtask(self, task, file):
"""Check variables written at the task level (vars or arguments)."""
errors = []
task_vars = task.get('vars', {})
if isinstance(task_vars, dict):
for key, value in task_vars.items():
if any(re.search(pattern, key, re.IGNORECASE) for pattern in SECRET_KEYS):
if isinstance(value, str) and not (value.startswith('{{') and value.endswith('}}')):
errors.append(
self.create_matcherror(
message=f"Task variable '{key}' detected plaintext in task '{task.get('name')}'",
filename=file.name
)
)
return errors
The custom rule above scans every YAML file and triggers a build failure if it detects variables like db_password: "raw_password" without being wrapped in Jinja2 curly braces referencing an encrypted Vault file.
3. Automating Validation Through Pre-commit Hooks #
Leaving linting only to the CI/CD pipeline running on the Git server is an inefficient practice. Developers must wait several minutes for the pipeline just to learn there’s a spacing or minor syntax error. This slows the development cycle.
We must integrate these tests directly into developer computers using pre-commit hooks. This tool automatically intercepts the Git commit process and validates changed files before the commit is successfully created.
Here’s the complete .pre-commit-config.yaml configuration we must use in every project repository:
# .pre-commit-config.yaml
# Defines hooks that run before a git commit is successfully created
repos:
# 1. Integration with the official Ansible Lint
- repo: https://github.com/ansible/ansible-lint
rev: v24.2.0
hooks:
- id: ansible-lint
name: "Ansible Linting (Profile: Production)"
args: ['--profile=production']
files: \.(yml|yaml)$
exclude: ^(molecule|vendor|collections)/
# 2. Integration with Yamllint to tidy up YAML spacing/format
- repo: https://github.com/adrienverge/yamllint
rev: v1.35.1
hooks:
- id: yamllint
name: "YAML Format Auditor"
args: [-c=.yamllint]
# 3. Standard hook collection to maintain repository file integrity
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-yaml
name: "Verify YAML Syntax"
- id: check-merge-conflict
name: "Prevent Git Merge Conflicts"
- id: trailing-whitespace
name: "Remove Trailing Whitespaces"
- id: end-of-file-fixer
name: "Ensure Newline at EOF"
- id: detect-private-key
name: "Block Accidental SSH Private Key Commits"
- id: check-added-large-files
name: "Prevent Committing Large Files (>500KB)"
args: ['--maxkb=500']
# 4. Integration with Gitleaks to prevent credential (secret) leaks
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
name: "Scan Repository for Leaked Secrets"
Installation on Developer Machines: #
To use this pre-commit automation system, we just run the following commands once on our local computer:
# Install the pre-commit tool using the python package manager
pip install pre-commit
# Install the hook into our repository's local Git database
pre-commit install
# Run a manual test over all files for the first time
pre-commit run --all-files
Now, every time we run git commit -m "update configuration", all the tests above execute automatically. If any file violates formatting or security rules, the commit process is automatically cancelled and we’re given a report of which lines to fix.
4. yamllint Configuration for Format Consistency #
Although Ansible can read messy YAML formats as long as the structure is valid, inconsistent formatting (e.g. mixing 2-space and 4-space indentation) makes code hard to read for other team members.
We use yamllint to enforce consistent YAML writing style across all files.
Here’s the .yamllint configuration we recommend:
# .yamllint
# Documentation of YAML file writing format rules
---
extends: default
rules:
# Limits the maximum number of characters in a single line
line-length:
max: 160 # We loosen to 160 characters to accommodate long shell commands
level: warning # Gives a warning without blocking the build if violated
# Sets boolean truth values (true/false)
truthy:
allowed-values: ['true', 'false'] # Only allows lowercase true/false writing
check-keys: false # Don't check associative keys
# Sets the spacing inside file comments
comments:
min-spaces-before-comment: 1
require-starting-space: true
# Sets the spacing inside curly braces
braces:
min-spaces-inside: 0
max-spaces-inside: 1
# Enforces consistent indentation
indentation:
spaces: 2 # Must use 2 spaces for each indentation level
indent-sequences: consistent
With these rules, we ensure no messy-spacing YAML files enter the main repository.
5. Quality Control Flow in CI/CD Pipelines #
To guarantee no dirty code enters the main branch even if developers deliberately disable their local pre-commit hooks, we must apply a final validation gateway in the CI/CD pipeline (e.g. using GitHub Actions).
Here’s the code journey workflow from local computers to production:
flowchart TD
A["Developer Writes Code"] --> B["Pre-commit Hook (Local)"]
B -->|"Passed"| C["Git Push to a New Branch"]
B -->|"Failed"| D["Fix Code on the Local Machine"]
C --> E["Open a Pull Request (PR)"]
E --> F["CI Pipeline (Lint & Syntax Check)"]
F -->|"Passed"| G["Peer Review (Manual Checklist)"]
F -->|"Failed"| H["Block the PR & Send Error Notifications"]
G -->|"Approved"| I["Merge to the Main Branch"]
G -->|"Rejected"| DHere’s an example GitHub Actions automation pipeline configuration .github/workflows/validate.yml to run code quality checks on every Pull Request:
# .github/workflows/validate.yml
name: Code Quality Validation
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
validate:
name: Lint & Verify Syntax
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install ansible-lint yamllint pre-commit
- name: Run Yamllint Audit
run: yamllint -c .yamllint .
- name: Run Ansible Lint Verification
run: ansible-lint --profile=production
- name: Execute Playbook Syntax Verification
run: |
# Run syntax checks on all our main playbooks
for playbook in playbooks/*.yml; do
ansible-playbook "$playbook" --syntax-check
done
6. Effective Inline Documentation Practices #
Good inline documentation doesn’t explain WHAT a code line does (because Ansible YAML syntax is already very declarative and easy to read), but rather explains WHY we chose that approach or the architectural decisions behind it.
Code Comparison Example: #
# ANTI-PATTERN: A task without purpose explanation, using raw modules, and not descriptive
- name: Run command
command: systemctl restart nginx
- name: install packages
apt:
name: ["nginx", "git"]
# CORRECT: Descriptive tasks, using dedicated modules, and providing WHY context
- name: Restart nginx to load the newly renewed Let's Encrypt SSL certificates
systemd:
name: nginx
state: restarted
- name: Install prerequisite packages for the internal web proxy
apt:
name:
- nginx
- git
state: present
update_cache: true
# Inline documentation explains non-common/non-intuitive logic
- name: Wait until the application is ready to accept HTTP connections
uri:
url: "http://localhost:8080/health"
status_code: 200
register: result
until: result.status == 200
retries: 10
delay: 5
# TECHNICAL EXCEPTION: Our application takes approximately 20 seconds
# of database connection initialization at startup before the /health endpoint
# is accessible. Therefore, we give a 5-second delay with a 10-attempt limit.
7. Code Quality Review Checklist #
We must include this review guide on every Pull Request. Reviewers must mark each checklist item below before giving approval:
CORRECTNESS & IDEMPOTENCY:
□ Tasks don't use command/shell modules if a dedicated module exists (e.g. apt, systemd, file).
□ All command/shell tasks include changed_when conditionals or creates/removes to be idempotent.
□ Handlers are used correctly to respond to changes, not calling direct restart commands.
□ Error handling (failed_when or ignore_errors) is implemented on failure-prone tasks.
SECURITY & SECRETS:
□ No API keys, database passwords, or private keys written plaintext.
□ Tasks displaying sensitive output have the no_log: true parameter.
□ Root access (become: true) is limited only to tasks truly needing privileges.
□ Temporary files created in tasks are always deleted at the end of execution.
CODE STRUCTURE & VARIABLES:
□ All role variables are declared with default values in the defaults/main.yml directory.
□ No hardcoded configuration values; all are moved to conceptual variables.
□ Boolean data types are written consistently in lowercase format (true/false).
□ Task parameters are arranged in structured list form (YAML blocks), not long inline strings.
Anti-Patterns to Avoid #
Here are some fatal Ansible code quality mistakes we must avoid:
1. Using Command/Shell Without Idempotency Limits #
Raw shell command usage without limits makes tasks always return a changed status on every execution, breaking the idempotency principle.
# ANTI-PATTERN: Changing file permissions using shell (always returns changed status)
- name: Change file permission
shell: chmod 644 /var/www/index.html
# CORRECT: Using the built-in file module guaranteeing changed status only with real changes
- name: Set the index file access rights safely and idempotently
file:
path: /var/www/index.html
mode: '0644'
state: file
2. Hiding Failures Without Proper Handling #
Using ignore_errors: true carelessly to avoid build failures only hides the real problem that will eventually break the system.
# ANTI-PATTERN: Ignoring errors on a critical download process
- name: Download application binary
get_url:
url: "https://example.com/app.tar.gz"
dest: /tmp/app.tar.gz
ignore_errors: true
# CORRECT: Handling failures with structured automatic retries
- name: Download the application binary with temporary network failure tolerance
get_url:
url: "https://example.com/app.tar.gz"
dest: /tmp/app.tar.gz
register: download_result
until: download_result is succeeded
retries: 3
delay: 10
Summary #
- Production Profile Is Mandatory — We must enable
profile: productionin the.ansible-lintfile to ensure the best code standard compliance from the start of development.- Pre-commit Hooks as the First Filter — Use
pre-commitintegration on our local computers to filter syntax errors, YAML file formats, and secret leaks before a successful commit.- Yamllint for Spacing Tidiness — Configure
.yamllintconsistently to minimize spacing and indentation writing style differences among developers in the team.- Guarantee Task Idempotency — Never let
commandorshellmodules run withoutchanged_when: falseorcreates/removesparameters so change status is always accurate.- Use Dedicated Modules — Avoid writing manual bash commands when Ansible already provides dedicated, stability-tested modules (like
apt,systemd, orcopy).- Write WHY Documentation — Always include inline comments explaining the background of technical decisions or logic exceptions in our configuration files.
- Map the Quality Flow in the Pipeline — Apply automatic code quality verification on CI/CD servers before allowing Pull Requests to merge into the main branch.