Chapter 12: Testing & Quality
Topic 70

yamllint and ansible-lint

ToolingLinting

Two linters sit at the bottom of the testing pyramid, and they do not overlap. yamllint checks that the file is sane YAML — indentation, trailing spaces, line length, no duplicate keys — while ansible-lint checks that the Ansible is sane — FQCN, no bare command where a module exists, no deprecated syntax, no obvious idempotency traps. You run both, because a file can be perfect YAML and terrible Ansible, or correct Ansible wrapped in YAML that breaks on the next editor that touches it.

Two linters at the base of the pyramid — non-overlapping jobs
yamllint
YAML hygiene — indentation, line length, trailing spaces, and the dangerous duplicate key that silently drops a value. Problems any tool reading the file would hit.
ansible-lint
Ansible anti-patterns — missing FQCN, a bare command where a module exists, deprecated calls. Each parses as valid YAML and sails past yamllint untouched.

Wired into pre-commit they fail in your editor's save loop, before a commit exists; wired into the larkspur-io/infra pipeline they gate the merge. Run the identical pinned versions in both places and local-green equals CI-green, which is the whole point of putting the cheapest checks where they fire most often.

yamllint for YAML Hygiene

yamllint catches the mechanical failures that have nothing to do with Ansible: a tab where spaces belong, a line 200 columns wide, trailing whitespace, and the dangerous one — a duplicate dictionary key that silently drops one value. None of these are Ansible problems; they are YAML problems that would bite any tool reading the file, and they surface before the parser ever hands the data to Ansible.

You configure it with a .yamllint file at the repo root, where the usual move is to relax line-length and the truthy rule so the linter stops fighting Ansible's yes/no conventions. The defaults are stricter than real Ansible code wants, so the config below loosens exactly the two rules that otherwise generate noise on every playbook.

.yamllint — relax the two rules that fight Ansible conventions
extends: default

rules:
  line-length:
    max: 160          # default 80 is too tight for templated task args
    level: warning
  truthy:
    allowed-values: ["true", "false", "yes", "no"]
    check-keys: false   # Ansible uses yes/no freely as task values

The line-length rule drops to a warning at 160 columns rather than an error at 80, and truthy accepts yes and no as values. Without these two adjustments the linter reds out on idiomatic Ansible — and a linter that flags correct code on every file is one the team learns to ignore, which is worse than not running it at all.

ansible-lint for Ansible Anti-Patterns

ansible-lint knows Ansible semantics, which is exactly what yamllint cannot. It flags command: systemctl restart nginx where the ansible.builtin.service module belongs, a play with no name, a task using a short module name instead of ansible.builtin.copy, and modules deprecated or removed in the ansible-core version you target. Each of these parses as valid YAML and would sail past yamllint untouched.

These are the patterns that make Ansible brittle rather than broken — code that runs today and fails the day a collection ships a same-named module or a deprecated call is finally removed. Catching them at lint time turns a future outage into a warning you fix in the editor, which is the entire economics of the bottom of the pyramid.

Rule Profiles

ansible-lint ships graduated profiles — min, basic, moderate, safety, shared, and production — each a named bar that enables progressively stricter rules. You pick one in .ansible-lint and the linter enforces exactly that bar, so a new role can start at basic while a collection headed for publication is held to production. The profile is a dial, not an all-or-nothing switch.

.ansible-lint — pick a profile and scope what the linter sees
profile: moderate      # ratchet basic -> moderate -> safety -> production as the role matures

exclude_paths:
  - .cache/
  - molecule/*/converge.yml   # scenario glue, not shipped content

enable_list:
  - fqcn[action-core]       # require ansible.builtin.* on builtin modules

Starting at basic on a fresh role and ratcheting up as it matures beats starting at production and disabling half the rules to make the team's existing code pass. The first path tightens the bar deliberately, one profile at a time; the second drowns everyone in failures on day one and trains them to reach for the skip list.

FQCN Enforcement and Deprecation Catches

The linter pushes fully-qualified collection names — ansible.builtin.copy, not bare copy — so module resolution is unambiguous. A bare copy works until the day a second installed collection also ships a module named copy, and then resolution order decides which one runs, silently. FQCN removes the ambiguity at lint time, turning a latent resolution bug into a warning you fix before it ever bites.

It also fails on modules slated for removal in the ansible-core version you target, which turns a future breaking upgrade into a lint error you fix now instead of an outage later. A deprecation warning in CI is a calendar reminder with teeth: the module still works today, but the linter has told you exactly which call to migrate before the upgrade that removes it.

noqa, pre-commit, and CI

When you genuinely mean to break a rule on one line, silence exactly that rule on exactly that line with a # noqa: <rule-id> comment — never a project-wide skip, which hides every future violation of the same rule behind one switch. Run both linters as pre-commit hooks so they catch issues before the commit exists, and run the identical pinned versions in the larkspur-io/infra pipeline so local-green equals CI-green.

.pre-commit-config.yaml — both linters, pinned, in the editor save loop
repos:
  - repo: https://github.com/adrienverge/yamllint
    rev: v1.35.1          # pin: an un-pinned linter fails CI on its own upgrade
    hooks:
      - id: yamllint

  - repo: https://github.com/ansible/ansible-lint
    rev: v24.2.0
    hooks:
      - id: ansible-lint

The line-scoped suppression below is the right shape: it waives one rule, on one task, with a reason a reviewer can read — leaving every other command in the repo still subject to the same check.

A line-scoped waiver — one rule, one task, with a reason
- name: Trigger a one-off cache warm (no module exists for this)
  ansible.builtin.command: /opt/larkspur/bin/warm-cache  # noqa: command-instead-of-module
  changed_when: false
Common Mistakes
  • Running only ansible-lint and skipping yamllint, so a duplicate key in group_vars/prod/vars.yml silently drops a variable and the play uses a stale value with no error anywhere.
  • Blanket-disabling rules with a project-wide skip list instead of a line-scoped # noqa, so a real anti-pattern hides behind the same switch that silenced one intentional exception.
  • Pinning no ansible-lint version, so a linter upgrade in CI suddenly fails a hundred files on a newly-enforced rule and blocks every merge until someone triages it under pressure.
  • Leaving bare module names like copy and service unqualified and ignoring the FQCN warning, then watching the role break when a same-named module from another installed collection wins resolution.
  • Setting the production profile on a role that is not ready for it and drowning the team in failures, when basic or moderate was the right starting bar to ratchet up from.
Best Practices
  • Run yamllint and ansible-lint as pre-commit hooks with pinned versions, so issues die in the editor and the exact same checks gate CI.
  • Choose an ansible-lint profile deliberately in .ansible-lint and ratchet it up from basic toward production as the role matures, rather than starting at the strictest bar and disabling half of it.
  • Enforce FQCN everywhere the linter asks, because an unqualified module name is a resolution bug waiting for the day a second collection ships the same short name.
  • Scope every suppression to one line with # noqa: <rule-id> and a reason, never a global skip, so the next reader sees exactly what was waived and why.
Comparable tools tflint · terraform validate the provisioning-side linters puppet-lint · cookstyle the Puppet and Chef analogs flake8 · ruff the general-code equivalents

Knowledge Check

What does yamllint catch that ansible-lint cannot, and vice versa?

  • yamllint catches YAML-level faults like a duplicate key; ansible-lint catches Ansible semantics like a missing FQCN or a bare command where a module exists
  • yamllint flags deprecated module calls and missing FQCN; ansible-lint flags indentation and trailing-whitespace errors
  • They inspect the same files for the same defects, so running either one of the two linters is enough on its own
  • yamllint actually executes the playbook against a target host to surface runtime errors, while ansible-lint by contrast only ever reads the files statically without touching a host

Why does enforcing FQCN prevent a real resolution bug rather than just being stylistic?

  • A bare module name like copy becomes ambiguous when a second installed collection ships the same name, and resolution order silently picks one
  • A fully qualified name makes the playbook run faster by letting Ansible skip the module search path entirely
  • Unqualified short module names are rejected outright by the YAML parser at load time, before Ansible's engine ever gets a chance to resolve the module against a collection
  • A fully qualified name is required before --check mode will function against any task at all

What is the purpose of ansible-lint rule profiles like basic and production?

  • They are named, graduated bars you select so a new role can start at basic and a published collection is held to production
  • They control which version of the Python interpreter the linter runs its checks under on the host
  • They switch the linter back and forth between checking raw YAML structure and checking Ansible semantics, picking one of the two modes per run
  • They set how many CPU cores the parallel lint run is permitted to consume on the runner

When is a line-scoped # noqa the right tool rather than a project-wide skip?

  • When you genuinely mean to break one rule on one task — it waives exactly that, leaving every other occurrence still checked
  • Whenever a rule is annoying, since a single project-wide skip entry in the config file is cleaner and easier to maintain than scattering inline comments through the play
  • Only for yamllint rules, because ansible-lint rules can be waived nowhere except the project-wide skip list in the config
  • Never — suppressing any rule at all on any line proves the lint config itself is broken, and the whole file should be deleted and rewritten

You got correct