Linting, molecule & policy gates
Catch it in CI, not on the fleet.
Getting a file mode wrong costs about ten seconds on your laptop, five minutes in a pull request, and a full credential rotation across four hundred servers once the playbook has already run. Same two characters. Three wildly different prices. Everything in this lesson is machinery for dragging the moment you find out toward the cheap end of that scale.
Locks make the three checks concrete. You can measure whether a key was cut to the right dimensions, you can check whether the locksmith followed the standard, or you can walk up to the door and turn the handle. Three different questions, and passing one tells you nothing about the other two. yamllint asks whether the file is even shaped like YAML (YAML Ain't Markup Language, the indentation-based text format your playbooks are written in). ansible-lint asks whether the tasks inside it are written safely. Molecule builds a throwaway machine, runs the role against it for real, then runs it a second time to find out whether the role was telling the truth.
Layer One: Does The File Even Parse
yamllint knows nothing about Ansible. It reads YAML as YAML and complains about shape: wrong indentation, trailing whitespace, over-long lines, bare words like yes that YAML quietly converts into a boolean, and duplicate keys. That last one earns its place in a security course. When the same key appears twice in one mapping, YAML keeps the last value and says nothing at all. Append ansible_become_user: root to a group_vars file that already set ansible_become_user: appuser forty lines further up, and you have quietly promoted every task that runs against that group. No parse error. No warning. Nothing in the diff that looks alarming to a reviewer skimming at five o'clock on a Friday.
---extends: defaultrules:line-length:max: 160comments:# ansible-lint's bundled copy of yamllint uses 1, so match itmin-spaces-from-content: 1truthy:# Default allowed-values is already ["true", "false"], which is what we want.# check-keys must go, though: GitHub Actions workflows use `on:` as a key and# yamllint reads that as the YAML 1.1 boolean true.check-keys: false
yamllint --strict group_vars/ playbooks/echo "exit=$?"
group_vars/webservers.yml1:1 warning missing document start "---" (document-start)41:1 error duplication of key "ansible_become_user" in mapping (key-duplicates)14:161 error line too long (172 > 160 characters) (line-length)playbooks/site.yml22:7 error wrong indentation: expected 8 but found 6 (indentation)exit=1
The exit code matters more than the text, because the exit code is the only part a pipeline reads. yamllint exits 1 the moment it finds an error, and 0 when the worst thing it found was a warning. Notice what that means for the missing document start above: on its own, it would have sailed straight through. Add --strict and warnings exit 2 instead. In continuous integration (CI, the robot that runs your checks automatically on every proposed change) you want that, because a rule quietly demoted to a warning is a rule everybody stops reading inside a month. ansible-lint carries its own copy of yamllint and reports the findings under its yaml rule (yaml[line-length], yaml[truthy], and so on), reading your .yamllint file if you have one, so the two tools agree instead of arguing about line length in your pull requests.
Layer Two: The Rules With Real Weight
ansible-lint is the reviewer who has read every Ansible incident report ever written and never gets tired at the end of a long day. It loads your playbooks and roles the way Ansible itself does, then runs a few hundred rules over them. Here is a role with six ordinary-looking tasks. Four of them are real bugs.
---- name: Create the webapp service accountansible.builtin.user:name: webappsystem: trueshell: /usr/sbin/nologincreate_home: falsebecome: true- name: Install nginxansible.builtin.package:name: nginxstate: latestbecome: true- name: Create the config directoryansible.builtin.file:path: /etc/webappstate: directoryowner: webappgroup: webappmode: "0750"become: true- name: Render the application environment fileansible.builtin.template:src: app.env.j2dest: /etc/webapp/app.envowner: webappgroup: webappbecome: true- name: Count failed SSH loginsansible.builtin.shell:cmd: grep 'Failed password' /var/log/secure | wc -lregister: failed_loginsbecome: true- name: Fetch the monitoring agent installeransible.builtin.command:cmd: curl -sSL -o /tmp/agent.sh https://downloads.example.net/agent.shbecome: true
ansible-lint --profile production roles/webapp/tasks/main.ymlecho "exit=$?"
WARNING Listing 6 violation(s) that are fatalpackage-latest: Package installs should not use latest.roles/webapp/tasks/main.yml:10 Task/Handler: Install nginxrisky-file-permissions: File permissions unset or incorrect.roles/webapp/tasks/main.yml:25 Task/Handler: Render the application environment fileno-changed-when: Commands should not change things if nothing needs doing.roles/webapp/tasks/main.yml:33 Task/Handler: Count failed SSH loginsrisky-shell-pipe: Shells that use pipes should set the pipefail option.roles/webapp/tasks/main.yml:33 Task/Handler: Count failed SSH loginscommand-instead-of-module: curl used in place of get_url or uri moduleroles/webapp/tasks/main.yml:39 Task/Handler: Fetch the monitoring agent installerno-changed-when: Commands should not change things if nothing needs doing.roles/webapp/tasks/main.yml:39 Task/Handler: Fetch the monitoring agent installerRead documentation for instructions on how to ignore specific rule violations.Failed: 6 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Profile 'production' was required, but 'min' profile passed.exit=2
risky-file-permissions is the one with teeth, and ansible-lint rates it VERY_HIGH for good reason. When ansible.builtin.template creates a file and you gave it no mode, Ansible falls back to the remote user's umask (the default permission mask that account happens to carry, subtracted from what would otherwise be granted). With the usual root umask of 0022 that lands on 0644: readable by everyone on the box. This particular file is an application environment file full of database credentials, so every local account on that host, including whichever one your web process runs as after somebody finds a bug in it, can now read your production password. Nothing failed. Nothing logged. The task went green. Note the directory task two steps above it, which sets mode explicitly and is therefore clean: the rule fires on missing modes, not on modes you chose.
risky-shell-pipe fires on the login-counting task because a shell pipeline reports the exit status of its last command only. If grep cannot open /var/log/secure, the pipeline still returns 0, wc still prints 0, and your "how many failed SSH (Secure Shell, the encrypted remote login protocol) attempts were there" check reports a comfortable zero forever. A control that silently reports all-clear is worse than no control, because you stop looking at it. The fix is set -o pipefail, and it needs executable: /bin/bash alongside it, because on Debian and Ubuntu /bin/sh is dash (a small, fast, stripped-down shell) and dash has no pipefail option at all. Half the internet's copy-pasted fix for this rule is broken for exactly that reason.
no-changed-when fires twice, on the shell task and the command task, because command, shell and raw have no idea whether they changed anything and so report changed on every single run. Two things break. Handlers wired to those tasks fire every time, turning a routine converge into an unplanned rolling restart of production. And the changed count in your run summary stops carrying information, which matters because that number is very often the only evidence you have about whether today's run altered a machine at all. Set changed_when: false for read-only commands, or give the task a creates: or removes: path so it can tell when its work is already done. The rule accepts any of the three.
command-instead-of-module spots curl and points you at ansible.builtin.get_url, which is not pedantry: get_url takes a checksum argument and curl does not, so switching modules is how you stop running whatever bytes a download server felt like handing you today. package-latest flags state: latest because the artifact you tested is then not the artifact you shipped, two hosts converged an hour apart end up on different versions, and a package repository compromised between runs gets pulled onto the fleet by your own automation. Its cousin, the rule named latest, catches the same idea in ansible.builtin.git tasks with no version pinned, where you check out whatever the branch tip says at this exact second and then execute it.
Six Profiles And The One You Are Held To
Rules are grouped into six named profiles that stack: min, basic, moderate, safety, shared, production. Each one contains everything in the profile before it and adds more, so they are rungs on a ladder rather than flavors to pick between. min catches only content Ansible cannot load at all. basic is where command-instead-of-module and the deprecation rules live. moderate adds the naming rules. safety brings in risky-file-permissions, risky-shell-pipe, risky-octal, package-latest and latest. no-changed-when sits one rung higher again, in shared. production adds fqcn (fully qualified collection names, the ansible.builtin.copy style of writing a module name), sanity, meta-no-dependencies and friends, and it is the bar for anything you deploy to a machine somebody depends on.
The --profile flag does not merely change the report. It unloads every rule outside the profile you chose, so --profile safety will genuinely never mention no-changed-when to you, however many command tasks you write. Run with no profile at all and ansible-lint grades you instead: the last line names the highest profile your code currently meets, which makes a decent backlog. The fqcn rule up in production has a security reason people miss. An unqualified copy: is resolved at run time through a search path, and that path checks a library/ directory sitting next to your playbook before it reaches the builtin module. Drop a file called library/copy.py into a repository and every unqualified copy: task in it now runs your code instead of Ansible's. Writing ansible.builtin.copy removes the ambiguity, and ambiguity is where supply-chain tricks make their home.
The Same Role, Fixed
---- name: Create the webapp service accountansible.builtin.user:name: webappsystem: trueshell: /usr/sbin/nologincreate_home: falsebecome: true- name: Install nginxansible.builtin.package:name: nginxstate: presentbecome: true- name: Create the config directoryansible.builtin.file:path: /etc/webappstate: directoryowner: webappgroup: webappmode: "0750"become: true- name: Render the application environment fileansible.builtin.template:src: app.env.j2dest: /etc/webapp/app.envowner: webappgroup: webappmode: "0600"become: true- name: Count failed SSH loginsansible.builtin.shell:cmd: set -o pipefail && grep 'Failed password' /var/log/secure | wc -lexecutable: /bin/bashregister: failed_loginschanged_when: false# grep exits 1 when nothing matched, which is a normal answer, and 2 when it# could not read the file, which is the failure pipefail now surfaces.failed_when: failed_logins.rc not in [0, 1]become: true- name: Fetch the monitoring agent installeransible.builtin.get_url:url: https://downloads.example.net/agent.shdest: /root/agent-install.shmode: "0700"checksum: "sha256:{{ agent_sha256 }}"become: true
ansible-lint --profile production roles/webapp/tasks/main.ymlecho "exit=$?"
Passed: 0 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Profile 'production' was required, and it passed.exit=0
Two details in there are worth slowing down for. The first is the quotes around "0600". Write mode: 600 without them and YAML hands Ansible the decimal number 600, which Ansible applies as octal 1130: a sticky bit, an owner who can only execute, and a group that can write but not read. Not the permissions you asked for, and not permissions any human would pick on purpose. The risky-octal rule, also in the safety profile, exists entirely because of that one footgun. The second is the download destination, which moved off /tmp. A world-writable directory is a bad place to drop a file you are about to run as root, because any other local account can get there first and leave something waiting for you.
Suppressions That Survive A Review
Sometimes the linter is wrong about your situation and you need a way to say so that a reviewer can audit. Inline, a # noqa: rule-id comment on the task's name line skips exactly that rule on exactly that task. Put the reason directly above it, always, because a bare noqa is indistinguishable from somebody making a red light go away at six in the evening. For anything long-lived, prefer .ansible-lint-ignore, a plain text file of path and rule-id pairs separated by a space. Violations listed there stop failing the build but keep printing, under a heading announcing that they were deliberately ignored, so they stay in front of whoever runs the linter next month.
# The vendor appliance image ships dash as /bin/sh and has no bash at all,# so pipefail is genuinely unavailable here. Vendor case 41822.# Revisit 2026-10-01. Do not copy this pattern into new roles.- name: Rotate the vendor mail spool # noqa: risky-shell-pipeansible.builtin.shell:cmd: /opt/vendor/bin/spool --dump | /opt/vendor/bin/rotatechanged_when: truebecome: true
---# The bar this repository is held to. Anything below production is a plan, not a gate.profile: productionexclude_paths:- .cache/- .venv/- collections/ # third-party content pulled by ansible-galaxy# Opt-in rules: no profile switches these on for you.enable_list:- no-log-password # password arguments on tasks that lack no_log: true# ansible-lint's default warn_list is ["experimental", "jinja[spacing]",# "fqcn[deep]"], and no-log-password is tagged experimental, so by default it# would only ever print a warning. Emptying warn_list makes it fail the build.warn_list: []# Deliberately empty. Use .ansible-lint-ignore instead, so suppressed# findings keep printing rather than vanishing.skip_list: []
Layer Three: Molecule Runs It Twice
A molecule scenario is a dress rehearsal on a stage you are allowed to burn down afterwards. It is a directory under molecule/ holding a config file and a few playbooks. The driver says what creates the disposable machine: podman or docker containers, which are programs that run a lightweight isolated copy of a Linux system on your laptop. Those drivers ship in the separate molecule-plugins package, not in molecule itself. platforms says which images. converge.yml applies the role under test, verify.yml checks the result. Running molecule test walks the sequence in order and any failure stops it dead.
---dependency:name: galaxydriver:name: podman # from molecule-plugins[podman]platforms:- name: webapp-rocky9image: docker.io/geerlingguy/docker-rockylinux9-ansible:latestpre_build_image: truecommand: /usr/sbin/init # systemd, so service state is testableprivileged: truetmpfs:- /run- /tmpprovisioner:name: ansibleverifier:name: ansiblescenario:test_sequence:- destroy- create- converge- idempotence- verify- destroy
---- name: Verifyhosts: allbecome: truegather_facts: falsetasks:- name: Stat the application environment fileansible.builtin.stat:path: /etc/webapp/app.envregister: app_env- name: Assert the secret file is readable only by its owneransible.builtin.assert:that:- app_env.stat.exists- app_env.stat.mode | default('') == "0600"- app_env.stat.pw_name | default('') == "webapp"fail_msg: >-/etc/webapp/app.env is mode {{ app_env.stat.mode | default('absent') }}owned by {{ app_env.stat.pw_name | default('nobody') }};expected 0600 owned by webapp.
Point the scenario at the original version of the role, the one with curl in it, and watch what happens on the second pass.
molecule test
INFO [default > discovery] scenario test matrix: destroy, create, converge, idempotence, verify, destroyINFO [default > converge] ExecutingPLAY RECAP *********************************************************************webapp-rocky9 : ok=7 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0INFO [default > idempotence] ExecutingPLAY RECAP *********************************************************************webapp-rocky9 : ok=7 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0CRITICAL Idempotence test failed because of the following tasks:* [webapp-rocky9] => webapp : Count failed SSH logins* [webapp-rocky9] => webapp : Fetch the monitoring agent installer
The idempotence step is the single most valuable automated test an Ansible role can have, and the mechanism behind it is refreshingly dumb. Molecule runs converge.yml a second time against the same container and searches the output for the text changed= followed by anything other than zero. First pass: seven tasks succeeded, six of them did work, which is what you expect on a fresh machine. Second pass: the account, the package, the directory and the template all report ok because they were already correct, so changed drops to two. Those two are the shell task and the command task, and molecule names them. Neither module can tell whether it needed to do anything, so both report changed forever, and forever includes run two. Swap curl for get_url with a checksum and the second run skips the download entirely. The no-changed-when violations from layer two and this idempotence failure are one defect seen from two angles, which is a good sign your gates are pointed at something real.
verify.yml is where you assert the security property instead of hoping for it. The default verifier is plain Ansible, so you write ordinary tasks: stat the environment file, assert the mode is exactly 0600 and the owner is webapp, and you now own a test that goes red the day somebody deletes that mode line. If you would rather write Python, molecule.yml also accepts verifier: name: testinfra and you write pytest functions against expressions like host.file("/etc/webapp/app.env").mode. The honest limit is the container. A role that passes inside a privileged podman image with a stand-in init system is not proven against a real host running SELinux (Security-Enhanced Linux, the kernel access-control layer) in enforcing mode on your fleet's own hardened base image. Molecule raises your floor. It does not certify your ceiling.
Making The Gate An Actual Gate
---name: ansible-cion:pull_request:push:branches: [main]jobs:lint:name: lintruns-on: ubuntu-24.04steps:- uses: actions/checkout@v5- uses: actions/setup-python@v6with:python-version: "3.12"- run: pip install "ansible-core>=2.16" ansible-lint yamllint- run: ansible-galaxy install -r requirements.yml- run: yamllint --strict .- run: ansible-lint --profile production --offlinemolecule:name: moleculeruns-on: ubuntu-24.04steps:- uses: actions/checkout@v5- uses: actions/setup-python@v6with:python-version: "3.12"- run: pip install "ansible-core>=2.16" molecule "molecule-plugins[podman]"- name: molecule testrun: molecule testworking-directory: roles/webapp
---stages:- verifylint:stage: verifyimage: python:3.12-slimscript:- pip install --quiet "ansible-core>=2.16" ansible-lint yamllint- ansible-galaxy install -r requirements.yml- yamllint --strict .- ansible-lint --profile production --offline# allow_failure defaults to false, so a non-zero exit fails the pipeline
The chain is short and every link in it is an exit code. ansible-lint returns 2 when it finds violations, yamllint returns 1, molecule returns non-zero when idempotence or verify fails. A non-zero exit fails the step, a failed step fails the job, a failed job fails the check. The --offline flag earns the ansible-galaxy line beside it: without it, ansible-lint reaches out at lint time to refresh its JSON schemas, which quietly makes your merge gate depend on somebody else's web server being reachable. Under GitHub Actions both linters notice where they are running and switch to annotation output, so violations land inline on the changed lines of the pull request rather than three thousand log lines deep. Those action versions are worth pinning to a commit hash rather than a tag, since a tag is a name somebody else can move. The outputs in this lesson come from ansible-core 2.21, ansible-lint 26.6 and molecule 26.6; older majors report the same findings with slightly different wording.
None of that blocks a merge on its own. On GitHub you have to open the branch ruleset, switch on "Require status checks to pass", and name the lint and molecule checks specifically. On GitLab it is Settings, then Merge requests, then the "Pipelines must succeed" merge check. Until somebody does that, a red pipeline is a strongly worded opinion that anyone with the merge button can overrule.
Worth doing on your first day with an existing repository: run ansible-lint --profile production against it exactly as it stands and write the number down. It will be ugly, and that is fine. Run it again with --generate-ignore and ansible-lint writes what it just found into .ansible-lint-ignore for you, which freezes today's mess as a baseline and makes tomorrow's additions fail immediately. Then delete the risky-file-permissions and no-log-password lines out of that file first, because those two are the ones that leave a readable secret sitting on a disk somebody else can reach, and let the gate hold the line while you work through the rest.