1. Introduction
Ansible is simple, flexible, and powerful. Like any powerful tool, there are many ways to use it, some better than others.
This document aims to gather good practices from the field of Ansible practitioners at Red Hat, consultants, developers, and others. And thus it strives to give any Red Hat employee, partner or customer (or any Ansible user) a guideline from which to start in good conditions their automation journey.
Those are opinionated guidelines based on the experience of many people. They are not meant to be followed blindly if they don’t fit the reader’s specific use case, organization or needs; there is a reason why they are called good and not best practices.
The reader of this document is expected to have working practice of Ansible. If they are new to Ansible, the Getting started section of the official Ansible documentation is a better place to start.
This document covers not just coding practices but the full spectrum of Ansible-related good practices, including naming conventions, AAP configuration management, Git workflow and versioning, testing, CI/CD, and security. It is organized into four parts:
Part I — Foundations covers the overarching principles, structural decisions, and naming conventions that should be established before writing any code.
Part II — Developing Ansible Content covers good practices for writing roles, collections, playbooks, inventories, plugins, and general coding style.
Part III — AAP and Configuration as Code covers managing Ansible Automation Platform configuration declaratively through Git.
Part IV — Lifecycle and Operations covers the operational aspects of automation: Git workflow, testing, CI/CD, promotion, and security.
Each section is made of guidelines, one sentence hopefully easy to remember, followed by description, rationale and examples. The HTML version of this document makes the content collapsable so that all guidelines can be seen at once in a very overseeable way, for the reader to uncollapse the content of guidelines they are interested in.
A rationale is expected for each good practice, with a reference if applicable. It is really helpful to know not only how to do certain things, but why to do them in this way. It will also help with further revisions of the standards as some items may become obsolete or no longer applicable. If the reason is not included, there is a risk of keeping items that are no longer applicable, or alternatively blindly removing items that should be kept. It also has great educational value for understanding how things actually work (or how they don’t).
1.1. Where to get and maintain this document
This document is published to https://redhat-cop.github.io/automation-good-practices/, it is open source and its source code is maintained at https://github.com/redhat-cop/automation-good-practices/.
Part I — Foundations
2. Automation structures
Before we start to describe roles, playbooks, etc, we need to decide which one we use for what. This section is meant for topics which span across multiple structures and don’t fit nicely within one.
2.1. Guiding principles for Automation Good Practices
Details
As an overall guiding principle for designing good automation, inspired by the Zen of Python, by Tim Peters, the Zen of Ansible was created to serve as a guidepost to follow. The principles defined in this are very applicable and can give guidance when the specific practice is unclear.
The Zen of Ansible, by Tim Appnel Ansible is not Python. YAML sucks for coding. Playbooks are not for programming. Ansible users are (most probably) not programmers. Clear is better than cluttered. Concise is better than verbose. Simple is better than complex. Readability counts. Helping users get things done matters most. User experience beats ideological purity. “Magic” conquers the manual. When giving users options, always use convention over configuration. Declarative is always better than imperative - most of the time. Focus avoids complexity. Complexity kills productivity. If the implementation is hard to explain, it's a bad idea. Every shell command and UI interaction is an opportunity to automate. Just because something works, doesn't mean it can't be improved. Friction should be eliminated whenever possible. Automation is a continuous journey that never ends.
2.2. Define which structure to use for which purpose
Details
- Explanations
-
define for which use case to use roles, playbooks, potentially workflows (in Ansible Controller/AWX), and how to split the code you write.
- Rationale
-
especially when writing automation in a team, it is important to have a certain level of consistency and make sure everybody has the same understanding. By lack of doing so, your automation becomes unreadable and difficult to grasp for new members or even for existing members.
Following a consistent structure will increase re-usability. If one team member uses roles where another one uses playbooks, they will both struggle to reuse the code of each other. Metaphorically speaking, only if stones have been cut at roughly the same size, can they be properly used to build a house.
- Examples
-
The following is only one example of how to structure your content but has proven robust enough on multiple occasions.
Figure 1. Structure of Automation-
a landscape is anything you want to deploy at once, the underlay of your cloud, a three tiers application, a complete application cluster… This level is represented at best by a Controller/AWX workflow, potentially by a "playbook of playbooks", i.e. a playbook made of imported type playbooks, as introduced next.
-
a type must be defined such that each managed host has one and only one type, applicable using a unique playbook.
-
each type is then made of multiple functions, represented by roles, so that the same function used by the same type can be re-used, written only once.
-
and finally components are used to split a function in maintainable bits. By default a component is a task file within the function-role, if the role becomes too big, there is a case for splitting the function role into multiple component roles.
if functions are defined mostly for re-usability purposes, components are more used for maintainability / readability purposes. A re-usable component might be a candidate for promotion to a function. Let’s have a more concrete example to clarify:
-
as already written, a landscape could be a three tier application with web-front-end, middleware and database. We would probably create a workflow to deploy this landscape at once.
-
our types would be relatively obvious here as we would have "web-front-end server", "middleware server" and "database server". Each type can be fully deployed by one and only one playbook (avoid having numbered playbooks and instructions on how to call them one after the other).
-
each server type is then made up of one or more functions, each implemented as a role. For example, the middleware server type could be made of a "virtual machine" (to create the virtual machine hosting the middleware server), a "base Linux OS" and a "JBoss application server" function.
-
and then the base OS role could be made of multiple components (DNS, NTP, SSH, etc), each represented by a separate
tasks/{component}.ymlfile, included or imported from thetasks/main.ymlfile of the function-role. If a component becomes too big to fit within one task file, it might make sense that it gets its own component-role, included from the function-role.in terms of re-usability, see how you could simply create a new "integrated three tiers server" type (e.g. for test purposes), by just re-combining the "virtual machine", "base Linux OS", "JBoss application server", "PostgreSQL database" and "Apache web-server" function-roles into one new playbook.
-
Beware that those rules, once defined, shouldn’t be applied too strictly. There can always be reasons for breaking the rules, and sometimes requires discussion with your team to decide what is more important. For example if a "hardened Linux OS" and a "normal Linux OS" are two different functions, or the same function with different parameters. You could consider SSH to be a function on its own and not a component of the base OS. Also, external re-usable roles and collections, obviously not respecting your rules, might force you to bend them. Important is to break the rules not by ignorance of those but because of good and practical reasons. Respecting the rules is to know and acknowledge them, not to follow them blindly even if they don’t make sense. As long as exceptions are discussed openly in the team, they won’t hurt.
3. Naming conventions
Consistent naming conventions are a cornerstone of maintainable automation. This section defines naming standards for repositories, files, AAP resources, and Git artifacts.
| For naming conventions related to Ansible code (variables, tasks, handlers, templates), see the Roles Good Practices for Ansible and Coding Style Good Practices for Ansible sections. |
3.1. Be descriptive, consistent, and concise in all names
Details
- Explanations
-
Names should clearly communicate purpose without being excessively long. Use the same pattern throughout a project: pick one separator convention (hyphens for repos and files, underscores for AAP resources), one file extension (
.ymlnot mixed.ymland.yaml), and one casing convention per context. - Rationale
-
Consistent naming reduces cognitive load, makes resources easier to search and discover, and helps new team members navigate the codebase. Abbreviations should only be used when they are standard and widely understood (e.g.
aap,ee,config,dev,prod,qa). - Examples
-
# Good - descriptive, consistent separators per context automation-playbooks # Repository (hyphens) deploy-webapp.yml # Playbook file (hyphens) eng_deploy_webapp_canary # AAP Job Template (underscores) feature/add-webserver-role # Git branch (hyphens) # Bad - inconsistent separators and casing AutomationPlaybooks # Mixed case repo name deploy_webapp.yml # Underscores in filenames Eng Deploy Webapp # Spaces and mixed case in AAP
3.2. Use standard naming patterns for repositories
Details
- Explanations
-
Repository names should follow the pattern
{purpose}-{type}, using lowercase with hyphens, and stay under 50 characters. - Rationale
-
A predictable repo naming pattern makes it easy to identify the purpose and type of each repository at a glance, especially when managing many repos within an organization.
- Examples
-
# Good automation-playbooks # Playbook repository aap-config-as-code # AAP Configuration my-org-ansible-collection # Custom collection automation-ee-example # Execution Environment # Bad AutomationPlaybooks # Wrong case my_playbooks # Underscores in repo names
3.3. Name playbook files with verb-noun pattern
Details
- Explanations
-
Playbook files should follow the pattern
{verb}-{noun}.yml, clearly describing the action being performed. Some teams use apb_prefix (e.g.pb_deploy-webapp.yml) to distinguish playbooks from other YAML files in the repository, which can simplify automated scanning and testing. Group variable files should be named after the category they contain (e.g.credentials.yml,organizations.yml). - Rationale
-
Verb-noun naming makes the purpose of each playbook immediately obvious when listing files, and avoids ambiguity about what a playbook does. A consistent prefix like
pb_can be useful when a repository contains many YAML files (inventories, variable files, playbooks) and tooling needs to target only playbooks. - Examples
-
# Good - Verb-noun pattern deploy-webapp.yml configure-webserver.yml backup-database.yml patch-systems.yml # Good - With pb_ prefix for easy identification pb_deploy-webapp.yml pb_configure-webserver.yml # Bad - No verb, unclear purpose webapp.yml webserver.yml database.yml
3.4. Follow standard Git branch and commit naming conventions
Details
- Explanations
-
Branch names should follow
{type}/{description}using hyphens (e.g.feature/add-webserver-role,fix/inventory-syntax-error,hotfix/security-patch). Commit messages should follow the conventional commits format:{type}: {description}(e.g.feat:,fix:,docs:,chore:,test:,refactor:). - Rationale
-
Structured branch and commit naming enables automated tooling (changelog generation, release automation), makes Git history more readable, and helps with code review.
- Examples
-
# Branch names feature/add-webserver-role fix/correct-inventory-validation hotfix/security-patch # Commit messages feat: add webserver role with molecule tests fix: correct database connection timeout docs: update installation guide chore: update dependencies # Bad branch names new-feature my-branch bug-fix # Bad commit messages added stuff fixes update
3.5. Name AAP resources using lowercase with underscores and organization prefixes
Details
- Explanations
-
All AAP resource names (organizations, teams, credentials, projects, inventories, job templates, workflow templates, execution environments, schedules) must use lowercase only with underscores as separators. Resource names should include an abbreviated organization prefix (e.g.
eng_,plat_,ops_) to ensure uniqueness within the AAP instance.The specific naming patterns per resource type are:
-
Organizations: Full descriptive name (e.g.
engineering,platform) -
Teams:
{org}_{purpose}(e.g.eng_admins,ops_oncall) -
Credentials:
{org}_{type}_{identity}_{target}(e.g.eng_machine_ansible_linux_prod) -
Projects:
{org}_{domain}_{purpose}_{type}(e.g.eng_webapp_deploy_playbooks) -
Inventories:
{org}_{target}_{scope}(e.g.eng_webservers_app) -
Job Templates:
{org}_{action}_{target}_{scope}(e.g.eng_deploy_webapp_canary) -
Workflow Templates:
{org}_{workflow}_{scope}_{purpose}(e.g.eng_workflow_full_deployment) -
Execution Environments:
{org}_{purpose}_ee_{version}(e.g.eng_automation_ee_26.1.6-0) -
Schedules:
{org}_{freq}_{jt_action}_{jt_target}_{variant}(e.g.ops_daily_backup_database_full)
-
- Rationale
-
AAP instances can contain hundreds of resources from multiple teams. Without a consistent naming standard, it becomes impossible to determine ownership, purpose, or scope of resources. Organization prefixes provide a namespace that prevents collisions and makes filtering and searching straightforward.
- Examples
-
# Credentials - include identity for uniqueness eng_machine_ansible_linux_prod # ansible user → prod linux servers plat_scm_git_github_main # git user → github main org ops_cloud_svc_backup_aws_prod # svc_backup → AWS prod account # Job Templates - include action, target, scope eng_deploy_webapp_canary # canary deployment ops_backup_database_full_daily # daily full backup sec_scan_compliance_cis_weekly # weekly CIS scan # Bad - spaces, mixed case, no org prefix Deploy Web Application linux-servers prod
Part II — Developing Ansible Content
4. Roles Good Practices for Ansible
| this section has been rewritten, using OASIS metastandards repository as a starting place. If you have anything to add or review, please comment. |
4.1. Role design considerations
4.1.1. Basic design
Details
- Explanations
-
Design roles focused on the functionality provided, not the software implementation.
- Rationale
-
Try to design roles focused on the functionality, not on the software implementation behind it. This will help abstracting differences between different providers, and help the user to focus on the functionality, not on technical details.
- Examples
-
For an example, designing a role to implement an NTP configuration on a server would be a role. The role internally would have the logic to decide whether to use ntpd, chronyd, and the ntp site configurations. However, when the underlying implementations become too divergent, for example implementing an email server with postfix or sendmail, then separate roles are encouraged.
Design roles to accomplish a specific, guaranteed outcome and limit the scope of the role to that outcome. This will help abstracting differences between different providers (see above), and help the user to focus on the functionality, not on technical details.
-
Design the interface focused on the functionality, not on the software implementation behind it.
Details
- Explanations
-
Limit the consumer’s need to understand specific implementation details about a collection, the role names within, and the file names. Presenting the collection as a low-code/no-code "automation application" provides the developer flexibility as the content grows and matures, and limits change the consumer may have to make in a later version.
- Examples
-
-
In the first example, the
mycollection.runrole has been designed to be an entry-point for the execution of multiple roles.mycollection.runprovides a standard interface for the user, without exposing the details of the underlying automations. The implementation details ofthing_1andthing_2may be changed by the developer without impacting the user as long as the interface ofmycollection.rundoes not change.
-
Listing 1. Do this:- hosts: all gather_facts: false tasks: - name: Perform several actions include_role: mycollection.run vars: actions: - name: thing_1 vars: thing_1_var_1: 1 thing_1_var_2: 2 - name: thing_2 vars: thing_2_var_1: 1 thing_2_var_2: 2-
In this example, the user must maintain awareness of the structure of the
thing_1andthing_2roles, and the order of operations necessary for these roles to be used together. If the implementation of these roles is changed, the user will need to modify their playbook. .Don’t do this:
- hosts: all gather_facts: false tasks: - name: Do thing_1 include_role: name: mycollection.thing_1 tasks_from: thing_1.yaml vars: thing_1_var_1: 1 thing_1_var_2: 2 - name: Do thing_2 include_role: name: mycollection.thing_2 tasks_from: thing_2.yaml vars: thing_2_var_1: 1 thing_2_var_2: 2 -
Place content common to multiple roles in a single reusable role, "common" is a typical name for this role when roles are packaged in a collection. Author loosely coupled, hierarchical content.
Details
- Explanations
-
Roles that have hard dependencies on external roles or variables have limited flexibility and increased risk that changes to the dependency will result in unexpected behavior or failures. Coupling describes the degree of dependency between roles and variables that need to act in coordination. Hierarchical content is an architectural approach to designing your content where individual roles have parent-child relationships in an overall tree structure
4.1.2. Role Structure
Details
- Explanations
-
New roles should be initiated in line, with the skeleton directory, which has standard boilerplate code for a Galaxy-compatible Ansible role and some enforcement around these standards
- Rationale
-
A consistent file tree structure will help drive consistency and reusability across the entire environment.
4.1.3. Role Distribution
Details
- Explanations
-
Use semantic versioning for Git release tags. Use 0.y.z before the role is declared stable (interface-wise).
- Rationale
-
There are some restrictions for Ansible Galaxy and Automation Hub. The versioning must be in strict X.Y.Z[ab][W] format, where X, Y, and Z are integers.
4.1.4. Naming parameters
Details
-
All defaults and all arguments to a role should have a name that begins with the role name to help avoid collision with other names. Avoid names like
packagesin favor of a name likefoo_packages.- Rationale
-
Ansible has no namespaces, doing so reduces the potential for conflicts and makes clear what role a given variable belongs to.)
-
Same argument applies for modules provided in the roles, they also need a
$ROLENAME_prefix:foo_module. While they are usually implementation details and not intended for direct use in playbooks, the unfortunate fact is that importing a role makes them available to the rest of the playbook and therefore creates opportunities for name collisions. -
Moreover, internal variables (those that are not expected to be set by users) are to be prefixed by two underscores:
__foo_variable.- Rationale
-
role variables, registered variables, custom facts are usually intended to be local to the role, but in reality are not local to the role - as such a concept does not exist, and pollute the global namespace. Using the name of the role reduces the potential for name conflicts and using the underscores clearly marks the variables as internals and not part of the common interface. The two underscores convention has prior art in some popular roles like geerlingguy.ansible-role-apache). This includes variables set by set_fact and register, because they persist in the namespace after the role has finished!
-
Prefix all tags within a role with the role name or, alternatively, a "unique enough" but descriptive prefix.
-
Do not use dashes in role names. This will cause issues with collections.
4.1.5. Providers
Details
When there are multiple implementations of the same functionality, we call them “providers”.
A role supporting multiple providers should have an input variable called $ROLENAME_provider.
If this variable is not defined, the role should detect the currently running provider on the system, and respect it.
- Rationale
-
users can be surprised if the role changes the provider if they are running one already. If there is no provider currently running, the role should select one according to the OS version.
- Example
-
on RHEL 7, chrony should be selected as the provider of time synchronization, unless there is ntpd already running on the system, or user requests it specifically. Chrony should be chosen on RHEL 8 as well, because it is the only provider available.
The role should set a variable or custom fact called $ROLENAME_provider_os_default to the appropriate default value for the given OS version.
- Rationale
-
users may want to set all their managed systems to a consistent state, regardless of the provider that has been used previously. Setting
$ROLENAME_providerwould achieve it, but is suboptimal, because it requires selecting the appropriate value by the user, and if the user has multiple system versions managed by a single playbook, a common value supported by all of them may not even exist. Moreover, after a major upgrade of their systems, it may force the users to change their playbooks to change their$ROLENAME_providersetting, if the previous value is not supported anymore. Exporting$ROLENAME_provider_os_defaultallows the users to set$ROLENAME_provider: "{{ $ROLENAME_provider_os_default }}"(thanks to the lazy variable evaluation in Ansible) and thus get a consistent setting for all the systems of the given OS version without having to decide what the actual value is - the decision is delegated to the role).
4.1.6. Distributions and Versions
Details
- Explanations
-
Avoid testing for distribution and version in tasks. Rather add a variable file to "vars/" for each supported distribution and version with the variables that need to change according to the distribution and version.
- Rationale
-
This way it is easy to add support to a new distribution by simply dropping a new file in to "vars/", see below Supporting multiple distributions and versions. See also Vars vs Defaults which mandates "Avoid embedding large lists or 'magic values' directly into the playbook." Since distribution-specific values are kind of "magic values", it applies to them. The same logic applies for providers: a role can load a provider-specific variable file, include a provider-specific task file, or both, as needed. Consider making paths to templates internal variables if you need different templates for different distributions.
4.1.7. Package roles in an Ansible collection to simplify distribution and consumption
Details
- Rationale
-
Packaging roles as a collection allows you to distribute many roles in a single cohesive unit of re-usable automation. Inside a collection, you can share custom plugins across all roles in the collection instead of duplicating them in each role’s
library/directory. Collections give your roles a namespace, which removes the potential for naming collisions when developing new roles. - Example
-
See the Ansible documentation on migrating roles to collections for details.
4.1.8. Check Mode
Details
-
The role should work in check mode, meaning that first of all, they should not fail check mode, and they should also not report changes when there are no changes to be done. If it is not possible to support it, please state the fact and provide justification in the documentation. This applies to the first run of the role.
-
Reporting changes properly is related to the other requirement: idempotency. Roles should not perform changes when applied a second time to the same system with the same parameters, and it should not report that changes have been done if they have not been done. Due to this, using
command:is problematic, as it always reports changes. Therefore, override the result by usingchanged_when: -
Concerning check mode, one usual obstacle to supporting it are registered variables. If there is a task which registers a variable and this task does not get executed (e.g. because it is a
command:or another task which is not properly idempotent), the variable will not get registered and further accesses to it will fail (or worse, use the previous value, if the role has been applied before in the play, because variables are global and there is no way to unregister them). To fix, either use a properly idempotent module to obtain the information (e.g. instead of usingcommand: catto read file into a registered variable, useslurpand apply.content|b64decodeto the result like here), or apply propercheck_mode:andchanged_when:attributes to the task. more_info. -
Another problem are commands that you need to execute to make changes. In check mode, you need to test for changes without actually applying them. If the command has some kind of "--dry-run" flag to enable executing without making actual changes, use it in check_mode (use the variable
ansible_check_modeto determine whether we are in check mode). But you then need to setchanged_when:according to the command status or output to indicate changes. See (https://github.com/linux-system-roles/selinux/pull/38/files#diff-2444ad0870f91f17ca6c2a5e96b26823L101) for an example. -
Another problem is using commands that get installed during the install phase, which is skipped in check mode. This will make check mode fail if the role has not been executed before (and the packages are not there), but does the right thing if check mode is executed after normal mode.
-
To view reasoning for supporting why check mode in first execution may not be worthwhile: see here. If this is to be supported, see hhaniel’s proposal, which seems to properly guard even against such cases.
4.1.9. Idempotency
Details
- Explanations
-
Reporting changes properly is related to the other requirement: idempotency. Roles should not perform changes when applied a second time to the same system with the same parameters, and it should not report that changes have been done if they have not been done. Due to this, using
command:is problematic, as it always reports changes. Therefore, override the result by usingchanged_when: - Rationale
-
Additional automation or other integrations, such as with external ticketing systems, should rely on the idempotence of the ansible role to report changes accurately
4.1.10. Supporting multiple distributions and versions
Details
- Use Cases
-
-
The role developer needs to be able to set role variables to different values depending on the OS platform and version. For example, if the name of a service is different between EL8 and EL9, or a config file location is different.
-
The role developer needs to handle the case where the user specifies
gather_facts: falsein the playbook. -
The role developer needs to access the platform specific vars in role integration tests without making a copy.
-
The recommended solution below requires at least some ansible_facts to be defined, and so relies on gathering some facts.
If you just want to ensure the user always uses gather_facts: true, and do not want to handle this in the role, then the role documentation should state that gather_facts: true or setup: is required in order to use the role, and the role should use fail: with a descriptive error message if the necessary facts are not defined.
|
If it is desirable to use roles that require facts, but fact gathering is expensive, consider using a cache plugin List of Cache Plugins, and also consider running a periodic job on the controller to refresh the cache.
4.1.11. Platform specific variables
Details
- Explanations
-
You normally use
vars/main.yml(automatically included) to set variables used by your role. If some variables need to be parameterized according to distribution and version (name of packages, configuration file paths, names of services), use this in the beginning of yourtasks/main.yml: - Examples
- name: Ensure ansible_facts used by role
setup:
gather_subset: min
when: __rolename_required_facts | difference(ansible_facts.keys() | list) | length > 0
- name: Set platform/version specific variables
include_vars: "{{ __rolename_vars_file }}"
loop:
- "{{ ansible_facts['os_family'] }}.yml"
- "{{ ansible_facts['distribution'] }}.yml"
- "{{ ansible_facts['distribution'] }}_{{ ansible_facts['distribution_major_version'] }}.yml"
- "{{ ansible_facts['distribution'] }}_{{ ansible_facts['distribution_version'] }}.yml"
vars:
__rolename_vars_file: "{{ role_path }}/vars/{{ item }}"
when: __rolename_vars_file is file
-
Add this as the first task in
tasks/main.yml:- name: Set platform/version specific variables include_tasks: tasks/set_vars.yml -
Add files to
vars/for the required OS platforms and versions.
The files in the loop are in order from least specific to most specific:
-
os_familycovers a group of closely related platforms (e.g.RedHatcovers RHEL, CentOS, Fedora) -
distribution(e.g.Fedora) is more specific thanos_family -
distribution_distribution_major_version(e.g.RedHat_8) is more specific thandistribution -
distribution_distribution_version(e.g.RedHat_8.3) is the most specific
See Commonly Used Facts for an explanation of the facts and their common values.
Each file in the loop list will allow you to add or override variables to specialize the values for platform and/or version.
Using the when: item is file test means that you do not have to provide all of the vars/ files, only the ones you need.
For example, if every platform except Fedora uses srv_name for the service name, you can define myrole_service: srv_name in vars/main.yml then define myrole_service: srv2_name in vars/Fedora.yml.
In cases where this would lead to duplicate vars files for similar distributions (e.g. CentOS 7 and RHEL 7), use symlinks to avoid the duplication.
With this setup, files can be loaded twice.
For example, on Fedora, the distribution_major_version is the same as distribution_version so the file vars/Fedora_31.yml will be loaded twice if you are managing a Fedora 31 host.
If distribution is RedHat then os_family will also be RedHat, and vars/RedHat.yml will be loaded twice.
This is usually not a problem - you will be replacing the variable with the same value, and the performance hit is negligible.
If this is a problem, construct the file list as a list variable, and filter the variable passed to loop using the unique filter (which preserves the order):
|
- name: Set vars file list
set_fact:
__rolename_vars_file_list:
- "{{ ansible_facts['os_family'] }}.yml"
- "{{ ansible_facts['distribution'] }}.yml"
- "{{ ansible_facts['distribution'] }}_{{ ansible_facts['distribution_major_version'] }}.yml"
- "{{ ansible_facts['distribution'] }}_{{ ansible_facts['distribution_version'] }}.yml"
- name: Set platform/version specific variables
include_vars: "{{ __rolename_vars_file }}"
loop: "{{ __rolename_vars_file_list | unique | list }}"
vars:
__rolename_vars_file: "{{ role_path }}/vars/{{ item }}"
when: __rolename_vars_file is file
Or define your __rolename_vars_file_list in your vars/main.yml.
The task Ensure ansible_facts used by role handles the case where the user specifies gather_facts: false in the playbook.
It gathers only the facts required by the role.
The role developer may need to add additional facts to the list, and use a different gather_subset.
See Setup Module for more information.
Gathering facts can be expensive, so gather only the facts required by the role.
Using a separate task file for tasks/set_vars.yml allows role integration tests to access the internal variables.
For example, if the role developer wants to pre-populate a VM with the packages used by the role, the following tasks can be used:
- hosts: all
tasks:
- name: Set platform/version specific variables
include_role:
name: my.fqcn.rolename
tasks_from: set_vars.yml
public: true
- name: Install test packages
package:
name: "{{ __rolename_packages }}"
state: present
In this way, the role developer does not have to copy and maintain a separate list of role packages.
4.1.12. Platform specific tasks
Details
Platform specific tasks, however, are different.
You probably want to perform platform specific tasks once, for the most specific match.
In that case, use lookup('first_found') with the file list in order of most specific to least specific, including a "default":
- name: Perform platform/version specific tasks
include_tasks: "{{ lookup('first_found', __rolename_ff_params) }}"
vars:
__rolename_ff_params:
files:
- "{{ ansible_facts['distribution'] }}_{{ ansible_facts['distribution_version'] }}.yml"
- "{{ ansible_facts['distribution'] }}_{{ ansible_facts['distribution_major_version'] }}.yml"
- "{{ ansible_facts['distribution'] }}.yml"
- "{{ ansible_facts['os_family'] }}.yml"
- "default.yml"
paths:
- "{{ role_path }}/tasks/setup"
Then you would provide tasks/setup/default.yml to do the generic setup, and e.g. tasks/setup/Fedora.yml to do the Fedora specific setup.
The tasks/setup/default.yml is required in order to use lookup('first_found'), which will give an error if no file is found.
If you want to have the "use first file found" semantics, but do not want to have to provide a default file, add skip: true:
- name: Perform platform/version specific tasks
include_tasks: "{{ lookup('first_found', __rolename_ff_params) }}"
vars:
__rolename_ff_params:
files:
- "{{ ansible_facts['distribution'] }}_{{ ansible_facts['distribution_version'] }}.yml"
- "{{ ansible_facts['os_family'] }}.yml"
paths:
- "{{ role_path }}/tasks/setup"
skip: true
NOTE:
-
Use
include_tasksorinclude_varswithlookup('first_found')instead ofwith_first_found.loopis not needed - the include forms take a string or a list directly. -
Always specify the explicit, absolute path to the files to be included, using
{{ role_path }}/varsor{{ role_path }}/tasks, when using these idioms. See below "Ansible Best Practices" for more information. -
Use the
ansible_facts['name']bracket notation rather than theansible_facts.nameoransible_nameform. For example, useansible_facts['distribution']instead ofansible_distributionoransible.distribution. Theansible_nameform relies on fact injection, which can break if there is already a fact of that name. Also, the bracket notation is what is used in Ansible documentation such as Commonly Used Facts and Operating System and Distribution Variance.
4.1.13. Supporting multiple providers
Details
Use a task file per provider and include it from the main task file, like this example from storage:
- name: include the appropriate provider tasks
include_tasks: "main_{{ storage_provider }}.yml"
The same process should be used for variables (not defaults, as defaults can
not be loaded according to a variable).
You should guarantee that a file exists for each provider supported, or use an explicit, absolute path using role_path.
See below "Ansible Best Practices" for more information.
4.1.14. Generating files from templates
Details
-
Add
{{ ansible_managed | comment }}at the top of the template file to indicate that the file is managed by Ansible roles, while making sure that multi-line values are properly commented. For more information, see Adding comments to files. -
When commenting, don’t include anything like "Last modified: {{ date }}". This would change the file at every application of the role, even if it doesn’t need to be changed for other reasons, and thus break proper change reporting.
-
Use standard module parameters for backups, keep it on unconditionally (
backup: true), until there is a user request to have it configurable. -
Make prominently clear in the HOWTO (at the top) what settings/configuration files are replaced by the role instead of just modified.
-
Use
{{ role_path }}/subdir/as the filename prefix when including files if the name has a variable in it.- Rationale
-
your role may be included by another role, and if you specify a relative path, the file could be found in the including role. For example, if you have something like
include_vars: "{{ ansible_facts['distribution'] }}.yml"and you do not provide every possiblevars/{{ ansible_facts['distribution'] }}.ymlin your role, Ansible will look in the including role for this file. Instead, to ensure that only your role will be referenced, useinclude_vars: "{{role_path}}/vars/{{ ansible_facts['distribution'] }}.yml". Same with other file based includes such asinclude_tasks. See Ansible Developer Guide » Ansible architecture » The Ansible Search Path for more information.
4.1.15. Vars vs Defaults
Details
-
Avoid embedding large lists or "magic values" directly into the playbook. Such static lists should be placed into the
vars/main.ymlfile and named appropriately -
Every argument accepted from outside of the role should be given a default value in
defaults/main.yml. This allows a single place for users to look to see what inputs are expected. Document these variables in the role’s README.md file copiously -
Use the
defaults/main.ymlfile in order to avoid use of the default Jinja2 filter within a playbook. Using the default filter is fine for optional keys on a dictionary, but the variable itself should be defined indefaults/main.ymlso that it can have documentation written about it there and so that all arguments can easily be located and identified. -
Don’t define defaults in
defaults/main.ymlif there is no meaningful default. It is better to have the role fail if the variable isn’t defined than have it do something dangerously wrong. Still do add the variable todefaults/main.ymlbut commented out, so that there is one single source of input variables. -
Avoid giving default values in
vars/main.ymlas such values are very high in the precedence order and are difficult for users and consumers of a role to override. -
As an example, if a role requires a large number of packages to install, but could also accept a list of additional packages, then the required packages should be placed in
vars/main.ymlwith a name such asfoo_packages, and the extra packages should be passed in a variable namedfoo_extra_packages, which should default to an empty array indefaults/main.ymland be documented as such.
4.1.16. Documentation conventions
4.1.17. Create a meaningful README file for every role
Details
- Rationale
-
The documentation is essential for the success of the content. Place the README file in the root directory of the role. The README file exists to introduce the user to the purpose of the role and any important information on how to use it, such as credentials that are required.
At minimum include:
-
Example playbooks that allow users to understand how the developed content works are also part of the documentation.
-
The inbound and outbound role argument specifications
-
List of user-facing capabilities within the role
-
The unit of automation the role provides
-
The outcome of the role
-
The roll-back capabilities of the role
-
Designation of the role as idempotent (True/False)
-
Designation of the role as atomic if applicable (True/False)
4.1.18. Don’t use host group names or at least make them a parameter
Details
- Explanations
-
It is relatively common to use (inventory) group names in roles:
-
either to loop through the hosts in the group, generally in a cluster context
-
or to validate that a host is in a specific group
Instead, store the host name(s) in a (list) variable, or at least make the group name a parameter of your role. You can always set the variable at group level to avoid repetitions.
-
- Rationale
-
Groups are a feature of the data in your inventory, meaning that you mingle data with code when you use those groups in your code. Rely on the inventory-parsing process to provide your code with the variables it needs instead of enforcing a specific structure of the inventory. Not all inventory sources are flexible enough to provide exactly the expected group name. Even more importantly, in a cluster context for example, if the group name is fixed, you can’t describe (and hence automate) more than one cluster in each inventory. You can’t possibly have multiple groups with the same name in the same inventory. On the other hand, variables can have any kind of value for each host, so that you can have as many clusters as you want.
- Examples
-
Assuming we have the following inventory (not according to recommended practices for sake of simplicity):
Listing 2. An inventory with two clusters[cluster_group_A] host1 ansible_host=localhost host2 ansible_host=localhost host3 ansible_host=localhost [cluster_group_B] host4 ansible_host=localhost host5 ansible_host=localhost host6 ansible_host=localhost [cluster_group_A:vars] cluster_group_name=cluster_group_A [cluster_group_B:vars] cluster_group_name=cluster_group_BWe can then use one of the following three approaches in our role (here as playbook, again for sake of simplicity):
Listing 3. A playbook showing how to loop through a group--- - name: Show how to loop through a set of groups hosts: cluster_group_? gather_facts: false become: false tasks: - name: The loop happens for each host, might be too much ansible.builtin.debug: msg: do something with {{ item }} loop: "{{ groups[cluster_group_name] }}" - name: The loop happens only for the first host in each group ansible.builtin.debug: msg: do something with {{ item }} loop: "{{ groups[cluster_group_name] }}" when: inventory_hostname == groups[cluster_group_name][0] - name: Make the first host of each group fail to simulate non-availability ansible.builtin.assert: that: inventory_hostname != groups[cluster_group_name][0] - name: The loop happens only for the first _available_ host in each group ansible.builtin.debug: msg: do something with {{ item }} loop: "{{ groups[cluster_group_name] }}" when: >- inventory_hostname == (groups[cluster_group_name] | intersect(ansible_play_hosts))[0]The first approach is probably best to create a cluster configuration file listing all cluster’s hosts. The other approaches are good to make sure each action is performed only once, but this comes at the price of many skips. The second one fails if the first host isn’t reachable (which might be what you’d want anyway), and the last one has the best chance to be executed once and only once, even if some hosts aren’t available.
the variable cluster_group_namecould have a default group name value in your role, of course properly documented, for simple use cases.Overall, it is best to avoid this kind of constructs if the use case permits, as they are clumsy.
4.1.19. Prefix task names in sub-tasks files of roles
Details
- Explanation
-
It is a common practice to have
tasks/main.ymlfile including other tasks files, which we’ll call sub-tasks files. Make sure that the tasks' names in these sub-tasks files are prefixed with a shortcut reminding of the sub-tasks file’s name. - Rationale
-
Especially in a complex role with multiple (sub-)tasks file, it becomes difficult to understand which task belongs to which file. Adding a prefix, in combination with the role’s name automatically added by Ansible, makes it a lot easier to follow and troubleshoot a role play.
- Examples
-
In a role with one
tasks/main.ymltask file, includingtasks/sub.yml, the tasks in this last file would be named as follows:Listing 4. A prefixed task in a sub-tasks file- name: sub | Some task description mytask: [...]The log output will then look something like
TASK [myrole : sub | Some task description] **, which makes it very clear where the task is coming from.with a verbosity of 2 or more, ansible-playbook will show the full path to the task file, but this generally means that you need to restart the play in a higher verbosity to get the information you could have had readily available.
4.1.20. Argument Validation
Details
- Explanation
-
Starting from ansible version 2.11, an option is available to activate argument validation for roles by utilizing an argument specification. When this specification is established, a task is introduced at the onset of role execution to validate the parameters provided for the role according to the defined specification. If the parameters do not pass the validation, the role execution will terminate.
- Rationale
-
Argument validation significantly contributes to the stability and reliability of the automation. It also makes the playbook using the role fail fast instead of failing later when an incorrect variable is utilized. By ensuring roles receive accurate input data and mitigating common issues, we can enhance the effectiveness of the Ansible playbooks using the roles.
- Examples
-
The specification is defined in the meta/argument_specs.yml. For more details on how to write the specification, refer to https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_reuse_roles.html#specification-format.
Listing 5. Argument Specification file that validates the arguments provided to the role.argument_specs: main: short_description: Role description. options: string_arg1: description: string argument description. type: "str" default: "x" choices: ["x", "y"] dict_arg1: description: dict argument description. type: dict required: True options: key1: description: key1 description. type: int key2: description: key2 description. type: str key3: description: key3 description. type: dict
4.2. References
Details
Links that contain additional standardization information that provide context, inspiration or contrast to the standards described above.
-
https://github.com/debops/debops/blob/v0.7.2/docs/debops-policy/code-standards-policy.rst). For inspiration, as the DebOps project has some specific guidance that we do not necessarily want to follow.
5. Collections good practices
Note: Unreviewed work. Please contribute to the discussion in the Automation Red Hat COP
5.1. Collection Structure should be at the type or landscape level
Details
- Explanations
-
Collections should be comprised of roles collected either at the type or landscape level. See The Structures Definition
- Rationale
-
Gathering and publishing collections, rather than individual roles, allows for easier distribution and particularly becomes more important when we discuss Execution Environments.
5.2. Create implicit collection variables and reference them in your roles' defaults variables
Details
- Explanations
-
Often, variables will want to be defined on a collection level, but this can cause issues with roles being able to be reused. By defining collection wide variables and referencing them in roles' defaults variables, this can be made clear and roles can remain reusable. Collection variables are nowhere defined explicitly and are to be documented in the collection’s documentation.
- Rationale
-
Variables that are shared across collections can cause collisions when roles are reused outside of the original collection. Role variables should continue to be named according to our recommendations for naming variables It still remains possible to overwrite collection variable values for a specific role. Each role has it’s own set of defaults for the variable.
- Examples
-
For a collection "mycollection", two roles exist. "alpha" and "beta". For this example, there is no default for the controller_username and would have to be defined in one’s inventory. The no_log variable does have defaults defined, and thus only needs to be defined if the default is being overwritten.
Listing 6. Alpha defaults/main.yml# specific role variables alpha_job_name: 'some text' # collection wide variables alpha_controller_username: "{{ mycollection_controller_username }}" alpha_no_log: "{{ mycollection_no_log | default('true') }}"Listing 7. Beta defaults/main.yml# specific role variables beta_job_name: 'some other text' # collection wide variables beta_controller_username: "{{ mycollection_controller_username }}" beta_no_log: "{{ mycollection_no_log | default('false') }}"
5.3. Include a README file in each collection
Details
- Explanation
-
Include a README file that is in the root of the collection and which contains:
-
Information about the purpose of the collection
-
A link to the collection license file
-
General usage information such as which versions of ansible-core are supported and any libraries or SDKs which are required by the collection
Generating the README’s plugin documentation from the plugin code helps eliminate documentation errors. Supplemental documentation such as user guides may be written in reStructured Text (rst) and located in the docs/docsite/rst/ directory of the collection.
-
- Examples
-
Use https://github.com/ansible-network/collection_prep to generate the documentation for the collection
5.4. Include a license file in a collection root directory
Details
- Explanation
-
Include a license file in the root directory Name the license file either LICENSE or COPYING. The contents may be either the text of the applicable license, or a link to the canonical reference for the license on the Internet (such as https://opensource.org/licenses/BSD-2-Clause ) If any file in the collection is licensed differently from the larger collection it is a part of (such as module utilities), note the applicable license in the header of the file.
6. Playbooks good practices
6.1. Keep your playbooks as simple as possible
Details
- Explanations
-
Don’t put too much logic in your playbook, put it in your roles (or even in custom modules), and try to limit your playbooks to a list of a roles.
- Rationale
-
Roles are meant to be re-used and the structure helps you to make your code re-usable. The more code you put in roles, the higher the chances you, or others, can reuse it. Also, if you follow the type-function pattern, you can very easily create new (type) playbooks by just re-shuffling the roles. This way you can create a playbook for each purpose without having to duplicate a lot of code. This, in turn, also helps with the maintainability as there is only a single place where necessary changes need to be implemented, and that is in the role
- Examples
-
Listing 8. An example of playbook containing only roles
--- - name: A playbook can solely be a list of roles hosts: all gather_facts: false become: false roles: - role1 - role2 - role3we’ll explain later why there might be a case for using include_role/import_roletasks instead of the role section.
6.2. Use either the tasks or roles section in playbooks, not both
Details
- Explanations
-
A playbook can contain
pre_tasks,roles,tasksandpost_taskssections. Avoid using bothrolesandtaskssections, the latter possibly containingimport_roleorinclude_roletasks. - Rationale
-
The order of execution between
rolesandtasksisn’t obvious, and hence mixing them should be avoided. - Examples
-
Either you need only static importing of roles and you can use the
rolessection, or you need dynamic inclusion and you should use only thetaskssection. Of course, for very simple cases, you can just usetaskswithoutroles.
6.3. Use tags cautiously either for roles or for complete purposes
Details
- Explanations
-
limit your usage of tags to two aspects:
-
either tags called like the roles to switch on/off single roles,
-
or specific tags to reach a meaningful purpose
-
Don’t set tags which can’t be used on their own, or can be destructive if used on their own.
Also document tags and their purpose(s).
- Rationale
-
there is nothing worse than tags which can’t be used alone, they bear the risk to destroy something by being called standalone. An acceptable exception is the pattern to use the role name as tag name, which can be useful while developing the playbook to test, or exclude, individual roles.
Important is that your users don’t need to learn the right sequence of tags necessary to get a meaningful result, one tag should be enough.
- Examples
-
Listing 9. An example of playbook importing roles with tags
--- - name: A playbook can be a list of roles imported with tags hosts: all gather_facts: false become: false tasks: - name: Import role1 ansible.builtin.import_role: name: role1 tags: - role1 - deploy - name: Import role2 ansible.builtin.import_role: name: role2 tags: - role2 - deploy - configure - name: Import role3 ansible.builtin.import_role: name: role3 tags: - role3 - configureYou see that each role can be skipped/run individually, but also that the tags
deployandconfigurecan be used to do something we’ll assume to be meaningful, without having to explain at length what they do.The same approach is also possible with
include_rolebut requires additionally toapplythe same tags to the role’s tasks, which doesn’t make the code easier to read:Listing 10. An example of playbook including roles with tags- name: a playbook can be a list of roles included with tags applied hosts: all gather_facts: false become: false tasks: - name: include role1 include_role: name: role1 apply: tags: - role1 - deploy tags: - role1 - deploy - name: include role2 include_role: name: role2 apply: tags: - role2 - deploy - configure tags: - role2 - deploy - configure - name: include role3 include_role: name: role3 apply: tags: - role3 - configure tags: - role3 - configure
6.4. Use the verbosity parameter with debug statements
Details
- Explanations
-
Debug messages should have a verbosity defined as appropriate for the message.
- Rationale
-
Debug messages are useful during testing and development, and can be useful to retain as playbooks go into production for future troubleshooting. However, log messages will clutter your output, which can confuse users with non-relevant information.
- Examples
-
Listing 11. Adding verbosity to debug messages
- name: don't make messages always display debug: msg: "This message will clutter your log in production" - name: this message will only appear when verbosity is 2 or more debug: msg: "Some more debug information if needed" verbosity: 2
7. Inventories and Variables Good Practices for Ansible
7.1. Identify your Single Source(s) of Truth and use it/them in your inventory
Details
- Explanations
-
A Single Source of Truth (SSOT) is the place where the "ultimate" truth about a certain data is generated, stored and maintained. There can be more than one SSOT, each for a different piece of information, but they shouldn’t overlap and even less conflict. As you create your inventory, you identify these SSOTs and combine them into one inventory using dynamic inventory sources (we’ll see how later on). Only the aspects which are not already provided by other sources are kept statically in your inventory. Doing this, your inventory becomes another source of truth, but only for the data it holds statically, because there is no other place to keep it.
- Rationale
-
You limit your effort to maintain your inventory to its absolute minimum and you avoid generating potentially conflicting information with the rest of your IT.
- Examples
-
You can typically identify three kinds of candidates as SSOTs:
-
technical ones, where your managed devices live anyway, like a cloud or virtual manager (OpenStack, RHV, Public Cloud API, …) or management systems (Satellite, monitoring systems, …). Those sources provide you with technical information like IP addresses, OS type, etc.
-
managed ones, like a Configuration Management Database (CMDB), where your IT anyway manages a lot of information of use in an inventory. A CMDB provides you with more organizational information, like owner or location, but also with "to-be" technical information.
-
the inventory itself, only for the data which doesn’t exist anywhere else.
Ansible provides a lot of inventory plugins to pull data from those sources and they can be combined into one big inventory. This gives you a complete model of the environment to be automated, with limited effort to maintain it, and no confusion about where to modify it to get the result you need.
-
7.2. Differentiate clearly between "As-Is" and "To-Be" information
Details
- Explanations
-
As you combine multiple sources, some will represent:
-
discovered information grabbed from the existing environment, this is the "As-Is" information.
-
managed information entered in a tool, expressing the state to be reached, hence the "To-Be" information.
In general, the focus of an inventory is on the managed information because it represents the desired state you want to reach with your automation. This said, some discovered information is required for the automation to work.
-
- Rationale
-
Mixing up these two kind of information can lead to your automation taking the wrong course of action by thinking that the current situation is aligned with the desired state. That can make your automation go awry and your automation engineers confused. There is a reason why Ansible makes the difference between "facts" (As-Is) and "variables" (To-Be), and so should you. In the end, automation is making sure that the As-Is situation complies to the To-Be description.
many CMDBs have failed because they don’t respect this principle. This and the lack of automation leads to a mix of unmaintained As-Is and To-Be information with no clear guideline on how to keep them up-to-date, and no real motivation to do so. - Examples
-
The technical tools typically contain a lot of discovered information, like an IP address or the RAM size of a VM. In a typical cloud environment, the IP address isn’t part of the desired state, it is assigned on the fly by the cloud management layer, so you can only get it dynamically from the cloud API and you won’t manage it. In a more traditional environment nevertheless, the IP address will be static, managed more or less manually, so it will become part of your desired state. In this case, you shouldn’t use the discovered information or you might not realize that there is a discrepancy between As-Is and To-Be.
The RAM size of a VM will be always present in two flavours, e.g. As-Is coming from the technical source and To-Be coming from the CMDB, or your static inventory, and you shouldn’t confuse them. By lack of doing so, your automation might not correct the size of the VM where it should have aligned the As-Is with the To-Be.
7.3. Define your inventory as structured directory instead of single file
Details
- Explanations
-
Everybody has started with a single file inventory in ini-format (the courageous ones among us in YAML format), combining list of hosts, groups and variables. An inventory can nevertheless be also a directory containing:
-
list(s) of hosts
-
list(s) of groups, with sub-groups and hosts belonging to those groups
-
dynamic inventory plug-ins configuration files
-
dynamic inventory scripts (deprecated but still simple to use)
-
structured
host_varsdirectories -
structured
group_varsdirectoriesThe recommendation is to start with such a structure and extend it step by step.
-
- Rationale
-
It is the only way to combine simply multiple sources into one inventory, without the trouble to call ansible with multiple
-i {inventory_file}parameters, and keep the door open for extending it with dynamic elements.It is also simpler to maintain in a Git repository with multiple maintainers as the chance to get a conflict is reduced because the information is spread among multiple files. You can drop roles'
defaults/main.ymlfile into the structure and adapt it to your needs very quickly.And finally it gives you a better overview of what is in your inventory without having to dig deeply into it, because already the structure (as revealed with
treeorfind) gives you a first idea of where to search what. This makes on-boarding of new maintainers a lot easier. - Examples
-
The following is a complete inventory as described before. You don’t absolutely need to start at this level of complexity, but the experience shows that once you get used to it, it is actually a lot easier to understand and maintain than a single file.
Listing 12. Tree of a structured inventory directoryinventory_example/ (1) ├── dynamic_inventory_plugin.yml (2) ├── dynamic_inventory_script.py (3) ├── groups_and_hosts (4) ├── group_vars/ (5) │ ├── alephs/ │ │ └── capital_letter.yml │ ├── all/ │ │ └── ansible.yml │ ├── alphas/ │ │ ├── capital_letter.yml │ │ └── small_caps_letter.yml │ ├── betas/ │ │ └── capital_letter.yml │ ├── greek_letters/ │ │ └── small_caps_letter.yml │ └── hebrew_letters/ │ └── small_caps_letter.yml └── host_vars/ (6) ├── host1.example.com/ │ └── ansible.yml ├── host2.example.com/ │ └── ansible.yml └── host3.example.com/ ├── ansible.yml └── capital_letter.yml1 this is your inventory directory 2 a configuration file for a dynamic inventory plug-in 3 a dynamic inventory script, old style and deprecated but still used (and supported) 4 a file containing a static list of hosts and groups, the name isn’t important (often called hostsbut some might confuse it with/etc/hostsand it also contains groups). See below for an example.5 the group_varsdirectory to define group variables. Notice how each group is represented by a directory of its name containing one or more variable files.6 the host_varsdirectory to define host variables. Notice how each host is represented by a directory of its name containing one or more variable files.The groups and hosts file could look as follows, important is to not put any variable definition in this file.
Listing 13. Content of thegroups_and_hostsfile[all] host1.example.com host2.example.com host3.example.com [alphas] host1.example.com [betas] host2.example.com [greek_letters:children] alphas betas [alephs] host3.example.com [hebrew_letters:children] alephsListing the hosts under
[all]isn’t really required but makes sure that no host is forgotten, should it not belong to any other group. The ini-format isn’t either an obligation but it seems easier to read than YAML, as long as no variable is involved, and makes it easier to maintain in an automated manner usinglineinfile(without needing to care for the indentation).Regarding the group and host variables, the name of the variable files is actually irrelevant, you can verify it by calling
ansible-inventory -i inventory_example --list: you will see nowhere the namecapital_letterorsmall_caps_letter(you might seeansiblethough, but for other reasons…). We nevertheless follow the convention to name our variable files after the role they are steering (so we assume the rolescapital_letterandsmall_caps_letter). If correctly written, thedefaults/main.ymlfile from those roles can be simply "dropped" into our inventory structure and adapted accordingly to our needs. We reserve the nameansible.ymlfor the Ansible related variables (user, connection, become, etc).you can even create a sub-directory in a host’s or group’s variable directory and put there the variable files. This is useful if you have many variables related to the same topic you want to group together but maintain in separate files. For example Satellite requires many variables to be fully configured, so you can have a structure as follows (again, the name of the sub-directory satelliteand of the files doesn’t matter):Listing 14. Example of a complex tree of variables with sub-directoryinventory_satellite/ ├── groups_and_hosts └── host_vars/ └── sat6.example.com/ ├── ansible.yml └── satellite/ ├── content_views.yml ├── hostgroups.yml └── locations.yml
7.4. Rely on your inventory to loop over hosts, don’t create lists of hosts
Details
- Explanations
-
To perform the same task on multiple hosts, don’t create a variable with a list of hosts and loop over it. Instead use as much as possible the capabilities of your inventory, which is already a kind of list of hosts.
The anti-pattern is especially obvious in the example of provisioning hosts on some kind of manager. Commonly seen automation tasks of this kind are spinning up a list of VMs via a hypervisor manager like oVirt/RHV or vCenter, or calling a management tool like Foreman/Satellite or even our beloved AWX/Tower/controller.
- Rationale
-
There are 4 main reasons for following this advice:
-
a list of hosts is more difficult to maintain than an inventory structure, and tends to become very quickly difficult to oversee. This is especially true as you generally need to maintain your hosts also in your inventory. This brings us to the 2nd advantage:
-
you avoid duplicating information, as you often need the same kind of information in your inventory that you also need in order to provision your VMs. In your inventory, you can also use groups to define group variables, automatically inherited by hosts. You can try to implement a similar inheritance pattern with your list of hosts, but it quickly becomes difficult and hand-crafted.
-
as you loop through the hosts of an inventory, Ansible helps you with parallelization, throttling, etc, all of which you can’t do easily with your own list (technically, you can combine async and loop to reach something like this, but it’s a lot more complex to handle than letting Ansible do the heavy lifting for you).
-
you can very simply limit the play to certain hosts, using for example the
--limitparameter ofansible-playbook(or the 'limit' field in Tower/controller), even using groups and patterns. You can’t really do this with your own list of hosts.
-
- Examples
-
Our first idea could be to define managers and hosts first in an inventory:
Listing 15. Content of the "bad"groups_and_hostsfile[managers] manager_a manager_b [managed_hosts] host1 host2 host3Each manager has a list of hosts, which can look like this:
Listing 16. List of hosts ininventory_bad/host_vars/manager_a/provision.yml--- provision_list_of_hosts: - name: host1 provision_value: uno - name: host2 provision_value: dueSo that we can loop over the list in this way:
Listing 17. The "bad" way to loop over hosts- name: Provision hosts in a bad way hosts: managers gather_facts: false become: false tasks: - name: Create some file to simulate an API call to provision a host ansible.builtin.copy: content: "{{ item.provision_value }}\n" dest: "/tmp/bad_{{ inventory_hostname }}_{{ item.name }}.txt" force: true owner: root group: root mode: "0644" loop: "{{ provision_list_of_hosts }}"check the resulting files using e.g. head -n-0 /tmp/bad_*.As said, no way to limit the hosts provisioned, and no parallelism. Compare then with the recommended approach, with a slightly different structure:
Listing 18. Content of the "good"groups_and_hostsfile[managers] manager_a manager_b [managed_hosts_a] host1 host2 [managed_hosts_b] host3 [managed_hosts:children] managed_hosts_a managed_hosts_bIt is now the hosts and their groups which carry the relevant information, it is not anymore parked in one single list (and can be used for other purposes):
Listing 19. The "good" variable structure$ cat inventory_good/host_vars/host1/provision.yml provision_value: uno $ cat inventory_good/group_vars/managed_hosts_a/provision.yml manager_hostname: manager_a
And the provisioning playbook now runs in parallel and can be limited to specific hosts:
Listing 20. The "good" way to loop over hosts- name: Provision hosts in a good way hosts: managed_hosts gather_facts: false become: false tasks: - name: Create some file to simulate an API call to provision a host ansible.builtin.copy: content: "{{ provision_value }}\n" dest: "/tmp/good_{{ manager_hostname }}_{{ inventory_hostname }}.txt" force: true owner: root group: root mode: "0644"The result isn’t overwhelming in this simple setup but you would of course better appreciate if the provisioning would take half an hour instead of a fraction of seconds:
Listing 21. Comparison of the execution times between the "good" and the "bad" implementation$ ANSIBLE_STDOUT_CALLBACK=profile_tasks \ ansible-playbook -i inventory_bad playbook_bad.yml Saturday 23 October 2021 13:11:45 +0200 (0:00:00.040) 0:00:00.040 ****** Saturday 23 October 2021 13:11:45 +0200 (0:00:00.858) 0:00:00.899 ****** =============================================================================== create some file to simulate an API call to provision a host ------------ 0.86s $ ANSIBLE_STDOUT_CALLBACK=profile_tasks \ ansible-playbook -i inventory_good playbook_good.yml Saturday 23 October 2021 13:11:55 +0200 (0:00:00.040) 0:00:00.040 ****** Saturday 23 October 2021 13:11:56 +0200 (0:00:00.569) 0:00:00.610 ****** =============================================================================== create some file to simulate an API call to provision a host ------------ 0.57s
if for some reason, you can’t follow the recommendation, you can at least avoid duplicating too much information by indirectly referencing the hosts' variables as in "{{ hostvars[item.name]['provision_value'] }}". Not so bad…
7.5. Restrict your usage of variable types
Details
- Explanations
-
-
Avoid playbook and play variables, as well as
include_vars. Opt for inventory variables instead. -
Avoid using scoped variables unless required for runtime reasons, e.g. for loops and for temporary variables based on runtime variables. Another valid exception is when nested variables are too complicated to be defined at once.
-
- Rationale
-
There are 22 levels of variable precedence. This is almost impossible to keep in mind for a "normal" human and can lead to all kind of weird behaviors if not under control. In addition, the use of play(book) variables is not recommended as it blurs the separation between code and data. The same applies to all constructs including specific variable files as part of the play (i.e.
include_vars). By reducing the number of variable types, you end up with a more simple and overseeable list of variables. Together with some explanations why they have their specific precedence, so that they become easier to remember and use wisely:-
role defaults (defined in
defaults/main.yml), they are… defaults and can be overwritten by anything. -
inventory vars, they truly represent your desired state. They have their own internal precedence (group before host) but that’s easy to remember.
-
host facts don’t represent a desired state but the current state, and no other variable should have the same name because of Differentiate clearly between "As-Is" and "To-Be" information so that the precedence doesn’t really matter.
-
role vars (defined in
vars/main.yml) represent constants used by the role to separate code from data, and shouldn’t either collide with the inventory variables, but can be overwritten by extra vars if you know what you’re doing. -
scoped vars, at the block or task level, are local to their scope and hence internal to the role, and can’t collide with other variable types.
-
runtime vars, defined by register or set_facts, are taking precedence over almost everything defined previously, which makes sense as they represent the current state of the automation.
-
scoped params, at the role or include level this time, are admittedly a bit out of order and should be avoided to limit surprises.
-
and lastly, extra_vars overwrite everything else (even runtime vars, which can be quite surprising)
-
| we didn’t explicitly consider Workflow and Job Template variables but they are all extra vars in this consideration. |
The following picture summarizes this list in a simplified and easier to keep in mind way, highlighting which variables are meant to overwrite others:
| even if we write that variables shouldn’t overwrite each other, they still all share the same namespace and can potentially overwrite each other. It is your responsibility as automation author to make sure they don’t. |
7.6. Prefer inventory variables over extra vars to describe the desired state
Details
- Explanations
-
Don’t use extra vars to define your desired state. Make sure your inventory completely describes how your environment is supposed to look like. Use extra vars only for troubleshooting, debugging or validation purposes.
- Rationale
-
Inventory variables are typically in some kind of persistent tracked storage (be it a database or Git), and should be your sole source representing your desired state so that you can refer to it non-ambiguously. On the other hand, extra vars are bound to a specific job or ansible-call and disappear together with history.
- Examples
-
Don’t use extra vars for the RAM size of VM to create, because this is part of the desired state of your environment, and nobody would know one year down the line if the VM was really created with the proper RAM size according to the state of the inventory. You may use an extra variable to protect a critical part of a destructive playbook, something like
are_you_really_really_sure: true/false, which is validated before e.g. a VM is destroyed and recreated to change parameters which can’t be changed on the fly. You can also use extra vars to enforce fact values which can’t be reproduced easily, like overwritingansible_memtotal_mbto simulate a RAM size fact of terabytes to validate that your code can cope with it.Another example could be the usage of
no_log: "{{ no_log_in_case_of_trouble | default(true) }}to exceptionally "uncover" the output of failing tasks even though they are security relevant.
8. Plugins good practices
| Work in Progress… |
8.1. Python Guidelines
-
Review Ansible guidelines for modules and development.
-
Use PEP8.
-
File headers and functions should have comments for their intent.
8.2. Write documentation for all plugin types
Details
- Explanations
-
All plugins, regardless of type, need documentation that describes the input parameters, outputs, and practical examples of how to use it.
- Examples
-
See the Ansible Developer Guide sections on Plugin Configuration and Documentation Standards and Module Documenting for more details.
8.3. Use sphinx (reST) formatted docstrings in Python code
Details
- Explanations
-
Sphinx (reST) formatted docstring are preferred for Ansible development. This includes all parameters, yields, raises, or returns for all classes, private and public functions written in Python.
- Rationale
-
PEP-257 states that: "All modules should normally have docstrings, and all functions and classes exported by a module should also have docstrings. Public methods (including the init constructor) should also have docstrings. A package may be documented in the module docstring of the init.py file in the package directory."
- Examples
"""[Summary]
:param [ParamName]: [ParamDescription], defaults to [DefaultParamVal]
:type [ParamName]: [ParamType](, optional)
...
:raises [ErrorType]: [ErrorDescription]
...
:return: [ReturnDescription]
:rtype: [ReturnType]
"""
8.4. Use Python type hints to document variable types.
Details
- Explanations
-
Use Python type hints to document variable types. Type hints are supported in Python 3.5 and greater.
- Rationale
-
Type hints communicate what type a variable can be expected to be in the code. They can be consumed by static analysis tools to ensure that variable usage is consistent within the code base.
- Examples
MyPy is a static type checker, which could analyze the following snippet:
----
def greeting(name: str) -> str:
return 'Hello ' + name
----
8.5. The use of unittest is discouraged, use pytest instead.
Details
- Explanations
-
Use pytest for writing unit tests for plugins
- Rationale
-
Pytest is the testing framework used by Ansible Engineering and will provide the best experience for plugin developers. The Ansible Developer Guide section on unit testing has detailed information on when and how to use unit tests.
- Examples
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import pytest
from ansible.modules.copy import AnsibleModuleError, split_pre_existing_dir
from ansible.module_utils.basic import AnsibleModule
ONE_DIR_DATA = (('dir1',
('.', ['dir1']),
('dir1', []),
),
('dir1/',
('.', ['dir1']),
('dir1', []),
),
)
@pytest.mark.parametrize('directory, expected', ((d[0], d[2]) for d in ONE_DIR_DATA))
def test_split_pre_existing_dir_one_level_exists(directory, expected, mocker):
mocker.patch('os.path.exists', side_effect=[True, False, False])
split_pre_existing_dir(directory) == expected
8.6. Formatting of manually maintained plugin argspecs
Details
- Explanations
-
Ensure a consistent approach to the way complex argument_specs are formatted within a collection.
- Rationale
-
When hand-writing a complex argspec, the author may choose to build up to data structure from multiple dictionaries or vars. Other authors may choose to implement a complex, nested argspec as a single dictionary. Within a single collection, select one style and use it consistently.
- Examples
-
Use of a sngle dictionary
Two different examples of using multiple dictionaries.
8.7. Keep plugin entry files to a minimal size.
Details
- Explanations
-
Keep the entry file to a plugin to a minimal and easily maintainable size.
- Rationale
-
Long and complex code files can be difficult to maintain. Move reusable functions and classes, such as those for data validation or manipulation, to a module_utils/ (for Ansible modules) or plugin_utils/ (for all other plugin types) file and import them into plugins. This keeps the Python code easier to read and maintain.
8.8. Plugins should be initially developed using the ansible plugin builder
Details
- Explanations
-
The ansible.plugin_builder is a tool which helps developers scaffold new plugins.
8.9. Use clear error/info messages
Details
- Explanations
-
This will make it easier to troubleshoot failures if they occur
- Rationale
-
Error messages that communicate specific details of the failure will aid in resolving the problem. Unclear error messages such as "Failed!" are unnecessarily obscure.
Information can be displayed to the user based on the verbosity the task is being executed at.
The base AnsibleModule class from which all modules should be created provides helper methods for reporting warnings and deprecations, and for exiting the module in the case of a failure.
There is a Display class available which enables the display of information at different verbosity levels in all plugin types.
- Examples
# Causing a module to exit with a failure status
if checksum and checksum_src != checksum:
module.fail_json(
msg='Copied file does not match the expected checksum. Transfer failed.',
checksum=checksum_src,
expected_checksum=checksum
)
# Displaying a warning during module execution, without exiting
try:
result = get_kms_metadata_with_backoff(connection, key_id)['KeyMetadata']
key_id = result['Arn']
except is_boto3_error_code('AccessDeniedException'):
module.warn('Permission denied fetching key metadata ({0})'.format(key_id))
return None
# Displaying a notice about a deprecation
if importer_ssl_client_key is None and module.params['client_key'] is not None:
importer_ssl_client_key = module.params['client_key']
module.deprecate("In Ansible 2.9.2 `feed_client_key` option was added. Until community.general 3.0.0 the default "
"value will come from client_key option",
version="3.0.0", collection_name='community.general')
# Display information only when a user has set an increased level of verbosity
# ansible-playbook -i inventory -vvvv test-playbook.yml
from ansible.utils.display import Display
display = Display()
lookupfile = self.find_file_in_search_path(variables, 'files', term)
display.vvvv("File lookup using {0} as file".format(lookupfile))
9. Coding Style Good Practices for Ansible
It has proven useful to agree on certain guiding principles as early as possible in any automation project. Doing so makes it much easier to onboard new Ansible developers. Project guidelines can also be shared with other departments working on automation which in turn improves the re-usability of playbooks, roles, modules, and documentation.
Another major benefit is that it makes code review process less time-consuming and more reliable; making both the developer and reviewer more likely to engage in a constructive review conversation.
This section contains suggestions for such coding-style guidelines. The list is neither complete nor are all of the guidelines necessary in every automation project. Experience shows that it makes sense to start with a minimum set of guidelines because the longer the list the lower the chance of people actually reading through it. Additional guidelines can always be added later should the situation warrant it.
9.1. Naming things
-
Use valid Python identifiers following standard naming conventions of being in
snake_case_naming_schemesfor all YAML or Python files, variables, arguments, repositories, and other such names (like dictionary keys). -
Do not use special characters other than underscore in variable names, even if YAML/JSON allow them.
Details
- Explanation
-
Using such variables in Jinja2 or Python would be then very confusing and probably not functional.
- Rationale
-
even when Ansible currently allows names that are not valid identifier, it may stop allowing them in the future, as it happened in the past already. Making all names valid identifiers will avoid encountering problems in the future. Dictionary keys that are not valid identifiers are also less intuitive to use in Jinja2 (a dot in a dictionary key would be particularly confusing).
-
Use mnemonic and descriptive names that are human-readable and do not shorten more than necessary. A pattern
object[_feature]_actionhas proven useful as it guarantees a proper sorting in the file system for roles and playbooks. Systems support long identifier names, so use them! -
Avoid numbering roles and playbooks, you’ll never know how they’ll be used in the future.
-
Name all tasks, plays, and task blocks to improve readability.
-
Write task names in the imperative (e.g. "Ensure service is running"), this communicates the action of the task.
-
Avoid abbreviations in names, or use capital letter for abbreviations where it cannot be avoided.
9.2. YAML and Jinja2 Syntax
-
Indent at two spaces
-
Indent list contents beyond the list definition
Details
Listing 22. Do this:example_list: - example_element_1 - example_element_2 - example_element_3 - example_element_4Listing 23. Don’t do this:example_list: - example_element_1 - example_element_2 - example_element_3 - example_element_4 -
Split long expressions into multiple lines.
Details
- Rationale
-
Long lines are difficult to read, and many teams even ask for a line length limit around 120-150 characters. ansible-lint defaults to 82 characters per line - see (Ansible Lint YAML rules). You don’t want to have to skip
yaml[line-length]in your.ansible-lint, or litter your code with# noqa yaml[line-length]. - Examples
-
there are multiple ways to avoid long lines but the most generic one is to use the YAML folding sign (
>-):Listing 24. Usage of the YAML folding sign- name: Call a very long command line ansible.builtin.command: >- echo Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas mollis, ante in cursus congue, mauris orci tincidunt nulla, non gravida tortor mi non nunc. - name: Set a very long variable ansible.builtin.set_fact: meaningless_variable: >- Ut ac neque sit amet turpis ullamcorper auctor. Cras placerat dolor non ipsum posuere malesuada at ac ipsum. Duis a neque fermentum nulla imperdiet blandit.Always use the sign >-instead of>unless you are absolutely sure the trailing newline is not significant. The sign>adds a newline character to the last line, effectively turningnon nunc.at the end of the example string above into"non nunc.\n", and>-doesn’t add the newline character. It is really easy to introduce an error by using>and silently add a newline to a variable, like a filename, which leads to strange, hard to decipher errors. See section "Wrap longer lines of code" for more information about line wrapping.
-
If the
when:condition results in a line that is too long, and is anandexpression, then break it into a list of conditions.Details
- Rationale
-
Ansible will
andthe list elements together (Ansible UseGuide » Conditionals). Multiple conditions that all need to be true (a logicaland) can also be specified as a list, but beware of bare variables inwhen:. - Examples
-
Listing 25. Do this
when: - myvar is defined - myvar | boolListing 26. instead of thiswhen: myvar is defined and myvar | bool
-
All roles need to, minimally, pass a basic ansible-playbook syntax check run
-
Spell out all task arguments in YAML style and do not use
key=valuetype of argumentsDetails
Listing 27. Do this:tasks: - name: Print a message ansible.builtin.debug: msg: This is how it's done.Listing 28. Don’t do this:tasks: - name: Print a message ansible.builtin.debug: msg="This is the exact opposite of how it's done." -
Use
trueandfalsefor boolean values in playbooks.Details
- Explanation
-
Do not use the Ansible-specific
yesandnoas boolean values in YAML as these are completely custom extensions used by Ansible and are not part of the YAML spec and also avoid the use of the Python-styleTrueandFalsefor boolean values in playbooks. - Rationale
-
YAML 1.1 allows all variants whereas YAML 1.2 allows only true/false, and we want to be ready for when it becomes the default, and avoid a massive migration effort.
-
Use positive boolean variable names (e.g.
enabled,ssl_verify) rather than negative ones (e.g.disabled,no_ssl).Details
- Rationale
-
Negative boolean names create double negatives when used in conditions (
when: not webserver_disabled), which are harder to read and reason about. Always preferwebserver_enabled: trueoverwebserver_disabled: false.
-
Avoid comments in playbooks when possible. Instead, ensure that the task
namevalue is descriptive enough to tell what a task does. Variables are commented in thedefaultsandvarsdirectories and, therefore, do not need explanation in the playbooks themselves. -
Use a single space separating the template markers from the variable name inside all Jinja2 template points. For instance, always write it as
{{ variable_name_here }}. The same goes if the value is an expression.{{ variable_name | default('hiya, doc') }} -
When naming files, use the
.ymlextension and not.yaml..ymlis whatansible-galaxy initdoes when creating a new role template. -
Use double quotes for YAML strings with the exception of Jinja2 strings which will use single quotes.
-
Do not use quotes unless you have to, especially for short module-keyword-like strings like
present,absent, etc. But do use quotes for user-side strings such as descriptions, names, and messages. -
Even if JSON is valid YAML and Ansible understands it, do only use JSON syntax if it makes sense (e.g. a variable file automatically generated) or adds to the readability. In doubt, nobody expects JSON so stick to YAML.
-
Break up lengthy Jinja templates into multiple templates when there are distinct logical sections.
Details
- Rationale
-
Long and complex Jinja templates can be difficult to maintain and debug. By splitting excessively long templates into logical componets that can be included as-needed, each template will be easier to maintain.
-
Jinja templates should not be used to create structured data but instead text and semi-structured data. Filter plugins are preferred over Jinja templates for the use of data manipulation or transformation.
Details
- Rationale
-
When working with structured data or data transformations it is preferable to use a programming language (such as Python) that has better support and tooling to do this kind of work. Custom filter plugins can be written to handle complex or unique use-cases. Tasks will be much more legible if data is managed and manipulated via plugins than with in-line Jinja.
9.3. Ansible Guidelines
-
Always use Fully Qualified Collection Names (FQCN) for all Ansible modules and plugins.
Details
- Explanation
-
FQCN uses the format
namespace.collection.module, for exampleansible.builtin.debuginstead of justdebug. Since Ansible 2.10, collection-provided modules must be referenced by their FQCN to avoid ambiguity when multiple collections define modules with the same short name. - Rationale
-
Using short module names can lead to name collisions when working with multiple collections, and may break playbooks as modules move between collections. FQCN usage is also enforced by ansible-lint’s
productionprofile (see the Testing section). It makes it explicit which collection provides the module and improves readability by documenting the module’s origin directly in the task.
Listing 29. Do this:- name: Print a message ansible.builtin.debug: msg: "Using FQCN ensures there is no ambiguity." - name: Ensure a package is installed ansible.builtin.package: name: vim state: presentListing 30. Don’t do this:- name: Print a message debug: msg: "Short name is ambiguous and may cause conflicts when multiple collections are in use." - name: Ensure a package is installed package: name: vim state: present -
Ensure that all tasks are idempotent.
-
Prefer the command module over the shell module unless you explicitly need shell functionality such as, e.g., piping. Even better, use a dedicated module, if it exists. If not, see the section about idempotency and check mode and make sure that your task is idempotent and supports check mode properly; your task will likely need options such as
changed_when:and maybecheck_mode:). -
Anytime
commandorshellmodules are used, add a comment in the code with justification to help with future maintenance. -
Use the
| boolfilter when using bare variables (expressions consisting of just one variable reference without any operator) inwhen. -
Break complex task files down into discrete parts.
Details
- Rationale
-
Task files that are very or and/or contain highly nested blocks are difficult to maintain. Breaking a large or complex task file into multiple discrete files makes it easier to read and understand what is being done in each part.
-
Use bracket notation instead of dot notation for value retrieval (e.g.
item['key']vs.item.key)Details
- Rationale
-
Dot notation will fail in some cases (such as when a variable name includes a hyphen) and it’s better to stay consistent than to switch between the two options within a role or playbook. Additionally, some key names collide with attributes and methods of Python dictionaries such as
count,copy,title, and others (refer to the Ansible User Guide for an extended list) - Example
-
This post provides an excellent demonstration of how using dot notation syntax can impact your playbooks.
-
Do not use
meta: end_play.Details
- Rationale
-
It aborts the whole play instead of a given host (with multiple hosts in the inventory). If absolutely necessary, consider using
meta: end_host.
-
Task names can be made dynamic by using variables wrapped in Jinja2 templates at the end of the string
Details
- Rationale
-
This can help with reading the logs. For example, if the task is managing one of several devices, and you want the task name output to show the device being managed. However, the template must come at the end of the string - see (Ansible Lint name template rule). Note that in some cases, it can make it harder for users to correlate the logs to the code. For example, if there is a log message like "Manage the disk device /dev/dsk/0001", and the user tries to do something like
grep "Manage the disk device /dev/dsk/0001" rolename/tasks/*.ymlto figure out which task this comes from, they will not find it. If the template comes at the end of the string, the user will know to omit the device name fromgrep. A better way to debug is to useansible-playbook -vv, which will show the exact file and line number of the task. - Example
-
.Do this:
tasks: - name: Manage the disk device {{ storage_device_name }} some.module: device: "{{ storage_device_name }}"Listing 31. Don’t do this:tasks: - name: Manage {{ storage_device_name }}, the disk device some.module: device: "{{ storage_device_name }}" -
Do not use variables (wrapped in Jinja2 templates) for play names; variables don’t get expanded properly there. The same applies to loop variables (by default
item) in task names within a loop. They, too, don’t get properly expanded and hence are not to be used there. -
Do not override role defaults or vars or input parameters using
set_fact. Use a different name instead.Details
- Rationale
-
a fact set using
set_factcan not be unset and it will override the role default or role variable in all subsequent invocations of the role in the same playbook. A fact has a different priority than other variables and not the highest, so in some cases overriding a given parameter will not work because the parameter has a higher priority (Ansible User Guide » Using Variables)
-
Use the smallest scope for variables. Facts are global for playbook run, so it is preferable to use other types of variables. Therefore limit (preferably avoid) the use of
set_fact. Role variables are exposed to the whole play when the role is applied usingroles:orimport_role:. A more restricted scope such as task or block variables is preferred. -
Beware of
ignore_errors: true; especially in tests. If you set on a block, it will ignore all the asserts in the block ultimately making them pointless. -
Do not use the
eq,equalto, or==Jinja tests introduced in Jinja 2.10, use Ansible built-inmatch,search, orregexinstead.Details
- Explanation
-
The issue is only with Jinja versions older than 2.10. RPM distributions of Ansible generally use the underlying OS platform python library for Jinja e.g. python-jinja2. This is especially problematic on EL7. The only supported Ansible RPM on that platform is 2.9, which uses the EL7 platform python-jinja2 library, which is 2.7 (and will likely never be upgraded). As of mid-2022, there are many users using EL7 for the control node. I believe this means AAP 1.x users will also be affected. Users not affected:
-
AAP 2.x users - there should be an option to use EL8 runners, or otherwise, build the EEs in such a way as to use Jinja 2.11 or later
-
Users running Ansible from a pip install
-
Users running Ansible installed via RPM on EL8 or later
-
- Rationale
-
These tests are not present in versions of Jinja older than 2.10, which are used on older controller platforms, such as EL7. If you want to ensure that your code works on older platforms, use the built-in Ansible tests such as (match), (search), or (regex) instead.
- Example
-
You have a
listofdict, and you want to filter out elements that have the keytypewith the valuebad_type.
Listing 32. Do this:tasks: - name: Do something some.module: param: "{{ list_of_dict | rejectattr('type', 'search', '^bad_type$') | list }}"Listing 33. Don’t do this:tasks: - name: Do something some.module: param: "{{ list_of_dict | rejectattr('type', 'eq', 'bad_type') | list }}"When using
match,search, orregex, and you want an exact match, you must specify the regex^STRING$, otherwise, you will match partial strings. -
Avoid the use of
when: foo_result is changedwhenever possible. Use handlers, and, if necessary, handler chains to achieve this same result. -
Use the various include/import statements in Ansible.
Details
- Explanation
-
Doing so can lead to simplified code and a reduction of repetition. This is the closest that Ansible comes to callable sub-routines, so use judgment about callable routines to know when to similarly include a sub playbook. Some examples of good times to do so are
-
When a set of multiple commands share a single
whenconditional -
When a set of multiple commands are being looped together over a list of items
-
When a single large role is doing many complicated tasks and cannot easily be broken into multiple roles, but the process proceeds in multiple related stages
-
-
Avoid calling the
packagemodule iteratively with the{{ item }}argument, as this is impressively more slow than calling it with the linename: "{{ foo_packages }}". The same can go for many other modules that can be given an entire list of items all at once. -
Use meta modules when possible.
Details
- Rationale
-
This will allow our playbooks to run on the widest selection of operating systems possible without having to modify any more tasks than is necessary.
- Examples
-
-
Instead of using the
upstartandsystemdmodules, use theservicemodule when at all possible. -
Similarly for package management, use
packageinstead ofyumordnfor similar.
-
-
Avoid the use of
lineinfilewherever that might be feasible.Details
- Rationale
-
Slight miscalculations in how it is used can lead to a loss of idempotence. Modifying config files with it can cause the Ansible code to become arcane and difficult to read, especially for someone not familiar with the file in question. Try editing files directly using other built-in modules (e.g.
ini_file,blockinfile,xml), or reading and parsing. If you are modifying more than a tiny number of lines or in a manner more than trivially complex, try leveraging thetemplatemodule, instead. This will allow the entire structure of the file to be seen by later users and maintainers. The use oflineinfileshould include a comment with justification. Alternatively, most configuration files have their own modules, such as community.general.ssh_config or community.general.nmcli. Using these make code cleaner to read and ensure idempotence.
-
Limit use of the
copymodule to copying remote files, static files, and to uploading binary blobs. For most file pushes, use thetemplatemodule. Even if there currently is nothing in the file that is being templated, if there is the possibility in the future that it might be added, having the file handled by thetemplatemodule now makes adding that functionality much simpler than if the file is initially handled by thecopymodule and then needs to be moved before it can be edited. -
When using the
templatemodule, append.j2to the template file name.Details
- Example
-
If you want to use the
ansible.builtin.templatemodule to create a file calledexample.confsomewhere on the managed host, name the template for this filetemplates/example.conf.j2. - Rationale
-
When you are at the stage of writing a template file you usually already know how the file should end up looking on the file system, so at that point it is convenient to use Jinja2 syntax highlighting to make sure your templating syntax checks out. Should you need syntax highlighting for whatever language the target file should be in, it is very easy to define in your editor settings to use, e.g., HTML syntax highlighting for all files ending in
.html.j2. It is much less straightforward to automatically enable Jinja2 syntax highlighting for some files ending on.html.
-
Keep filenames and templates as close to the name on the destination system as possible.
Details
- Rationale
-
This will help with both editor highlighting as well as identifying source and destination versions of the file at a glance. Avoid duplicating the remote full path in the role directory, however, as that creates unnecessary depth in the file tree for the role. Grouping sets of similar files into a subdirectory of
templatesis allowable, but avoid unnecessary depth to the hierarchy.
-
Using agnostic modules like
packageonly makes sense if the features required are very limited. In many cases, if the platform is different, the package name is also different so that usingpackagedoesn’t help a lot. Prefer then the more specificyum,dnforaptmodule if you anyway need to differentiate. -
Use
float,int, andboolfilters to "cast" public API variables to ensure type safety, especially for numeric operations in Jinja.Details
- Example
-
Variables set by users in the public API are not guaranteed to be any specific data type, and may be
strtype when some numeric type is expected:
> ansible -c local -i localhost --extra-vars int_val=1 localhost -m debug -a "msg={{ int_val < 0 }}" localhost | FAILED! => { "msg": "Unexpected templating type error occurred on ({{ int_val < 0 }}): '<' not supported between instances of 'str' and 'int'" }- Rationale
-
It is generally not possible to guarantee that all user inputs retain their desired numeric type, and if not, will likely be
strtype. If you use numeric variables where the value comes from user input, use thefloat,int, andboolfilters to "cast" the values to the type for numeric operations. If you are simply converting the value to a string, you do not have to use the cast. Numeric operations include:-
arithmetic:
int_var + 3,float_var * 3.14159 -
comparison:
int_var == 0,float_var >= 2.71828 -
unary:
-int_var,+float_var
-
Here are some examples:
> ansible -c local -i localhost --extra-vars int_val=1 localhost -m debug -a "msg={{ int_val | int < 0 }}" localhost | SUCCESS => { "msg": false } > ansible -c local -i localhost -e float_val=0.5 localhost -m debug -a "msg='float_val is less than 1.0 {{ float_val | float + 0.1 < 1.0 }}'" localhost | SUCCESS => { "msg": "float_val is less than 1.0 True" }
9.4. Wrap longer lines of code
ansible-lint has a pretty short line length, which causes problems if you are trying to use good programming practices by having descriptive variable names, which usually end up being quite long. Here are some examples of how to deal with line wrapping in common scenarios:
-
Jinja expressions can be wrapped. Within the Jinja expression, whitespace and newline characters aren’t significant, so take advantage of this to wrap lines into as readable a form as possible. Remember, in a
when,that,failed_when, or other such keywords, you can just write Jinja code - you do not need the"{{ … }}" -
Start an expression with '{{' followed by newline if the line will otherwise be too long. But what if the code is already indented a lot, and the variable I’m assigning to is already very long, and I can’t put anything else on the line? Just start the assignment on the next line.
-
Use backslash escapes in double quoted strings. But what if I have a very long string that I cannot use
>-to wrap because I cannot have extra spaces in the value e.g. like a url value? Use a backslash escape in a double quoted string. YAML will concatenate the values with no spaces.
Details
- Rationale
-
Use of whitespace and multi-line indentation makes expressions easier to read.
- name: Wrap long Jinja expressions
foo: "{{ a_very.long_variable.name |
somefilter('with', 'many', 'arguments') |
another_filter | list }}"
when: a_very.long_variable.name |
somefilter('with', 'many', 'arguments') |
another_filter | list
- name: Wrap when first line is already too long
very_indented_foo: "{{
a_very.long_variable.name |
somefilter('with', 'many', 'arguments') |
another_filter | list }}"
when: \
a_very.long_variable.name |
somefilter('with', 'many', 'arguments') |
another_filter | list | length > 0
- name: Set some test variables
set_fact:
my_very_long_variable_1: "{{ __pre_digest | filter1 }}"
my_very_long_variable_2: "{{ __pre_digest | filter2 }}"
vars:
__pre_digest: "{{ a_very.long_variable.name | some_filter }}"
- name: Use a very long URL
uri:
url: "https://{{ my_very_long_value_for_hostname }}:\
{{ my_very_long_value_for_port }}\
{{ my_very_long_value_for_uri }}?\
{{ my_very_long_value_for_query }}"
- name: Very long line with Jinja expression
foo: "{{ a_very.long_variable.name | somefilter('with', 'many', 'arguments') | another_filter | list }}"
- name: First line is already too long
very_indented_foo: "{{ a_very.long_variable.name |
somefilter('with', 'many', 'arguments') |
another_filter | list }}"
when: a_very.long_variable.name |
somefilter('with', 'many', 'arguments') |
another_filter | list | length > 0
- name: Redundancy in expressions
set_fact:
my_very_long_variable_1: "{{ a_very.long_variable.name | some_filter | filter1 }}"
my_very_long_variable_2: "{{ a_very.long_variable.name | some_filter | filter2 }}"
- name: URL string is too long
uri:
url: "https://{{ my_very_long_value_for_hostname }}:{{ my_very_long_value_for_port }}{{ my_very_long_value_for_uri }}{{ my_very_long_value_for_query }}"
Part III — AAP and Configuration as Code
10. AAP Configuration as Code
Managing Ansible Automation Platform configuration declaratively through Git is a foundational practice for scalable, auditable, and reproducible automation.
This section covers good practices for using the infra.aap_configuration collection and structuring AAP Configuration as Code (CaC) repositories.
10.1. Manage all AAP configuration declaratively using Git and the infra.aap_configuration collection
Details
- Explanations
-
All AAP objects — organizations, teams, credentials, projects, inventories, job templates, workflow templates, execution environments, schedules, and settings — should be defined as YAML data in a Git repository and applied using the
infra.aap_configurationcollection. No manual changes should be made through the AAP UI for anything that can be expressed as code. - Rationale
-
Configuration as Code provides version control (all changes tracked), reproducibility (rebuild environments from scratch), consistency (same config across Dev/QA/Prod), auditability (clear change history), disaster recovery (restore quickly from Git), and simplified migration between platforms.
Manual UI changes create configuration drift, are not auditable, and cannot be reproduced reliably.
- Examples
-
# collections/requirements.yml collections: - name: infra.aap_configuration version: "2.9.0" # Always pin to an exact version# playbook.yml - Use the dispatch role for streamlined configuration --- - name: Configure AAP hosts: aap_dev connection: local gather_facts: false tasks: - name: Apply AAP Configuration ansible.builtin.include_role: name: infra.aap_configuration.dispatchThe
dispatchrole automatically reads variables fromgroup_varsandhost_vars, applies configuration in the correct dependency order, handles idempotency, and provides clear output of changes made.
10.2. Use the dispatch role for simplified and ordered configuration application
Details
- Explanations
-
The
infra.aap_configuration.dispatchrole should be used rather than calling individual roles directly. It automatically handles the correct order of operations (organizations before credentials, credentials before projects, projects before job templates, etc.). - Rationale
-
Manually ordering role calls is error-prone and creates maintenance burden. The dispatch role ensures dependencies are resolved automatically and configuration is applied idempotently.
If you call individual roles in the wrong order, you will get "Object not found" errors (e.g. creating a job template before the project it references exists).
- Examples
-
The dispatch role applies configuration in this order:
-
Organizations
-
Credential Types
-
Credentials
-
Projects
-
Inventories
-
Job Templates
-
Workflows
-
Schedules
-
10.3. Structure CaC repositories with environment-specific group_vars
Details
- Explanations
-
AAP CaC repositories should use Ansible inventory with group-based variable files to manage environment-specific configuration. Shared configuration belongs in
group_vars/all/while environment-specific settings go ingroup_vars/aap_dev/,group_vars/aap_qa/, andgroup_vars/aap_prod/. - Rationale
-
This structure leverages Ansible’s native variable precedence system, where environment-specific
group_varsoverride sharedallsettings. It keeps the repository organized, makes it clear which configuration is shared versus environment-specific, and makes it easy to use CD pipelines to roll out configuration changes across AAP instances. - Examples
-
aap-config-as-code/ ├── playbook.yml ├── inventory.yml ├── collections/ │ └── requirements.yml ├── group_vars/ │ ├── all/ # Shared across all environments │ │ ├── organizations.yml │ │ ├── teams.yml │ │ ├── credential_types.yml │ │ └── labels.yml │ ├── aap_dev/ # Dev-specific │ │ ├── credentials.yml │ │ ├── inventories.yml │ │ ├── projects.yml │ │ └── job_templates.yml │ ├── aap_qa/ # QA-specific │ └── aap_prod/ # Prod-specific └── README.md# Apply configuration to a specific environment ansible-playbook playbook.yml -i inventory.yml -l aap_dev # Dry-run before applying ansible-playbook playbook.yml -i inventory.yml -l aap_prod --check --diff
10.4. Organize AAP variable files by coupling: shared resources in type files, tightly coupled resources in JT bundles
Details
- Explanations
-
AAP resources fall into two categories based on their coupling:
-
Shared resources (credentials, inventories, schedules, teams, organizations, labels) are reused across multiple job templates and should be defined in one file per object type (e.g.
credentials.yml,inventories.yml). -
Tightly coupled resources (execution environments, projects, and job templates) are versioned together and should be defined in JT bundle files (e.g.
jt_eng_deploy_webapp.yml).
-
- Rationale
-
Shared resources would otherwise be duplicated across many files, creating maintenance burden and inconsistency. JT bundles keep tightly coupled resources together so that when one changes (e.g. a new EE version), the related project and job template are updated in the same commit.
- Examples
-
group_vars/all/ ├── organizations.yml # controller_organizations_all ├── teams.yml # controller_teams_all ├── credentials.yml # controller_credentials_all (shared) ├── inventories.yml # controller_inventories_all (shared) ├── schedules.yml # controller_schedules_all ├── jt_eng_deploy_webapp.yml # EE + Project + JT bundle └── jt_ops_backup_db_daily.yml # EE + Project + JT bundleListing 36. Shared resource file (credentials.yml)controller_credentials_all: - name: "eng_machine_ansible_linux_prod" credential_type: "Machine" organization: "engineering" - name: "eng_scm_git_github_main" credential_type: "Source Control" organization: "engineering"Listing 37. JT bundle file (jt_eng_deploy_webapp.yml)controller_execution_environments_eng_deploy_webapp: - name: "eng_automation_ee_26.1.6-0" image: "quay.io/company/eng-ee@sha256:abc123..." credential: "eng_registry_svc_builder_quay_io" controller_projects_eng_deploy_webapp: - name: "eng_webapp_deploy_playbooks" scm_url: "https://github.com/company/eng-webapp-deploy-playbooks.git" credential: "eng_scm_git_github_main" organization: "engineering" controller_templates_eng_deploy_webapp: - name: "eng_deploy_webapp_canary" project: "eng_webapp_deploy_playbooks" inventory: "eng_webservers_app" playbook: "deploy-webapp.yml" execution_environment: "eng_automation_ee_26.1.6-0" credentials: - "eng_machine_ansible_linux_prod"
10.5. Handle secrets in CaC using vault or environment variable lookups
Details
- Explanations
-
CaC credential definitions must never contain plain-text secrets. Use
lookup('env', …)to reference environment variables at runtime, or encrypt files withansible-vault.See the Security section for the full set of secret management practices.
- Rationale
-
CaC repositories are shared across teams and environments. A secret committed to a CaC repo is visible to everyone with read access to the repository and persists in Git history permanently.
- Examples
-
# Good - reference environment variable controller_credentials: - name: "Dev SSH Key" credential_type: "Machine" inputs: username: "ansible" ssh_key_data: "{{ lookup('env', 'SSH_PRIVATE_KEY_DEV') }}"# Encrypt sensitive files with vault ansible-vault encrypt group_vars/aap_prod/credentials.yml
10.6. Always pin the infra.aap_configuration collection to an exact version
Details
- Explanations
-
The
collections/requirements.ymlfile must specify an exact version ofinfra.aap_configuration, never a range or>=constraint. - Rationale
-
Collection updates may change behavior, add required parameters, or deprecate features. Pinning to an exact version ensures reproducible builds and prevents unexpected breakage when a new collection version is released. Version upgrades should be deliberate and tested.
- Examples
-
# Good - pinned to exact version collections: - name: infra.aap_configuration version: "2.9.0" # Bad - unpinned, will break unexpectedly collections: - name: infra.aap_configuration # Bad - range allows untested versions collections: - name: infra.aap_configuration version: ">=2.0.0"
10.7. Test CaC playbooks for idempotency and use check mode before applying to production
Details
- Explanations
-
After applying CaC configuration, run the playbook again. The second run should report
changed=0, confirming that all configuration is already in the desired state. Before applying to production, always run with--check --diffto preview changes without making modifications.For general idempotency practices in Ansible, see Roles Good Practices for Ansible and Testing.
- Rationale
-
If a CaC playbook is not idempotent, it may create duplicate resources, overwrite unrelated settings, or fail on subsequent runs. Production AAP environments serve live automation workflows, so unexpected changes can break running jobs, remove schedules, or modify credentials.
- Examples
-
# Run once - applies changes ansible-playbook playbook.yml -i inventory.yml -l aap_dev # Run again - should show no changes ansible-playbook playbook.yml -i inventory.yml -l aap_dev # Expected: "ok=X changed=0 unreachable=0 failed=0" # Preview changes to production without applying ansible-playbook playbook.yml -i inventory.yml -l aap_prod --check --diff
10.8. Apply CaC through CI/CD pipelines, not manually
Details
- Explanations
-
CaC playbooks should be triggered by CI/CD pipelines (e.g. GitHub Actions, Tekton, Jenkins) on Git events (push, tag creation), not run manually from developer workstations.
- Rationale
-
Manual execution introduces human error, bypasses quality gates, and creates inconsistent application of configuration. CI/CD pipelines ensure that every configuration change goes through the same validation, testing, and approval process.
- Examples
-
# Example CI/CD pipeline step (generic) - name: Apply AAP Configuration run: | cd aap-config-as-code ansible-galaxy collection install -r collections/requirements.yml ansible-playbook playbook.yml \ -i inventory.yml \ -l aap_${{ env.TARGET_ENVIRONMENT }}
Part IV — Lifecycle and Operations
11. Git workflow and versioning
A well-defined Git workflow and versioning strategy is essential for managing Ansible automation at scale. This section covers branching strategies, versioning approaches, tagging practices, and promotion workflows for automation content.
11.1. Use trunk-based development with a single main branch
Details
- Explanations
-
All development should target a single
mainbranch. Changes are made through short-lived feature branches that are merged via pull requests after review and CI validation. There should be no long-lived environment branches (e.g.dev,qa,prod). - Rationale
-
Long-lived environment branches inevitably diverge, creating merge conflicts and making it unclear what state each environment is in. Trunk-based development with a single
mainbranch provides a single source of truth, reduces merge conflicts through frequent integration, and keeps the codebase in a consistently releasable state.Environment promotion is handled through tags and release manifests, not branches.
- Examples
-
# Good - short-lived feature branch from main git checkout main && git pull git checkout -b feature/add-webserver-role # Develop, test locally, then push git push origin feature/add-webserver-role # Create PR, get approval, merge, delete branch # Bad - long-lived environment branches main ├── dev (branch) # diverges from main ├── qa (branch) # diverges from dev └── prod (branch) # diverges from qa
11.2. Keep feature branches short-lived
Details
- Explanations
-
Feature branches should live for hours to days, not weeks. Break large changes into smaller, incremental pull requests that can be merged frequently. Use feature flags if a feature is incomplete but the code should still be merged.
- Rationale
-
Long-lived branches accumulate merge conflicts, make code review harder (large diffs), and delay integration. Short-lived branches reduce risk and provide faster feedback on changes.
- Examples
-
# Good - branch naming conventions feature/add-webserver-role # New feature fix/correct-inventory-validation # Bug fix hotfix/security-patch # Emergency production fix # Branch lifecycle git checkout -b feature/add-monitoring main # ... develop for 1-2 days ... git push origin feature/add-monitoring # Create PR → review → merge → delete branch
11.3. Choose a versioning strategy that fits your release cadence
Details
- Explanations
-
Two common versioning strategies for automation content are:
-
Semantic Versioning (SemVer):
MAJOR.MINOR.PATCH— best for libraries, collections, and content with API contracts where consumers need compatibility signals. -
Calendar Versioning (CalVer):
YY.M.D-PATCH— best for internal automation platforms with frequent releases where the question "when was this deployed?" matters most.Pick one strategy and apply it consistently across all components (Git tags, collections, execution environments, AAP project references).
-
- Rationale
-
SemVer communicates API compatibility (major bump = breaking changes) but requires subjective judgment on each release. CalVer provides instant date visibility, eliminates version bump debates, and pairs naturally with trunk-based development.
For internal automation platforms, CalVer is often the better choice because operators care more about when something was released than whether it has "breaking changes" (which in an internal platform are managed through documentation and team communication).
For public collections or libraries consumed by external users, SemVer is preferred because consumers need compatibility signals.
- Examples
-
Listing 38. CalVer examples
26.1.6-0 # January 6, 2026 - Initial release 26.1.6-1 # January 6, 2026 - Same-day hotfix 26.1.7-0 # January 7, 2026 - New release 26.2.15-0 # February 15, 2026 - New releaseListing 39. SemVer examples1.0.0 # Initial release 1.1.0 # New feature, backward compatible 1.1.1 # Bug fix 2.0.0 # Breaking changeIf using CalVer for Ansible collections, ensure your format is PEP 440 compatible (no leading zeros in month or day).
11.4. Use immutable Git tags for releases
Details
- Explanations
-
Release tags must be immutable: once created, they should never be deleted, moved, or force-pushed. Use annotated tags with descriptive messages that include a summary of changes, testing status, and rollback instructions.
- Rationale
-
Immutable tags provide a reliable audit trail and enable deterministic promotion across environments. If tags can be moved, it becomes impossible to know what code was actually deployed to a given environment at a given time.
- Examples
-
# Good - annotated tag with descriptive message git tag -a 26.1.5-0 -m "Release January 5, 2026 Features: - Add webserver role with HA support - Add database backup automation Testing: - All molecule tests pass - Integration tests successful Rollback: Revert to 26.1.4-0" git push origin 26.1.5-0 # Bad - lightweight tag with no message git tag 26.1.5-0 # Never do this - violates immutability git tag -d 26.1.5-0 git tag 26.1.5-0 <different-commit> git push --force origin 26.1.5-0For same-day hotfixes, increment the patch number rather than reusing the tag:
26.1.5-0 # Original release 26.1.5-1 # Hotfix (do NOT delete and recreate 26.1.5-0)
11.5. Promote the same tag across all environments
Details
- Explanations
-
A single release tag (e.g.
26.1.5-0) should promote unchanged through Dev, QA, and Production. The release manifest or deployment tooling tracks which tag is deployed to which environment, rather than creating environment-specific tags. - Rationale
-
If different tags are used per environment, there is no guarantee that the code deployed to production is the same code that was tested in QA. Promoting the same immutable tag ensures true atomic promotion — the exact same artifact is tested and deployed everywhere.
- Examples
-
# Good - same tag promoted through environments Dev: 26.1.5-0 ← deployed first QA: 26.1.5-0 ← same tag promoted after dev validation Prod: 26.1.5-0 ← same tag promoted after QA sign-off # Bad - different tags per environment Dev: 26.1.5-dev QA: 26.1.5-qa Prod: 26.1.5-prod
11.6. Never use "latest" or branch names in production references
Details
- Explanations
-
Production AAP projects, execution environments, and container images must always reference a specific version tag or image digest, never
latest,main, or any mutable reference. - Rationale
-
Mutable references make deployments non-reproducible. If production uses
latestand someone pushes a new image, production changes without any approval or testing process. This violates every principle of controlled change management. - Examples
-
# Good - specific version references controller_projects: - name: "Prod Automation" scm_branch: "26.1.5-0" controller_execution_environments: - name: "Prod EE" image: "quay.io/myorg/automation-ee:26.1.5-0" # Bad - mutable references controller_projects: - name: "Prod Automation" scm_branch: "main" controller_execution_environments: - name: "Prod EE" image: "quay.io/myorg/automation-ee:latest"
11.7. Synchronize versions across all components
Details
- Explanations
-
When releasing, all components (Git tags, collection versions, execution environment image tags, AAP configuration references) should use the same version identifier.
- Rationale
-
Version mismatches between components are a common source of production incidents. If the playbook expects collection version
26.1.6-0but the execution environment was built with26.1.5-0dependencies, behavior may differ. Keeping all versions synchronized simplifies tracking and debugging. - Examples
-
# Good - all components use the same version Release: 26.1.5-0 ├── AAP Config tag: 26.1.5-0 ├── Collection version: 26.1.5-0 └── EE image tag: 26.1.5-0 # Bad - mismatched versions Release: 26.1.5-0 ├── AAP Config tag: 26.1.5-0 ├── Collection version: 26.1.4-0 ← stale └── EE image tag: 26.1.5-1 ← ahead
11.8. Protect the main branch with required reviews and CI checks
Details
- Explanations
-
The
mainbranch should be protected with branch protection rules:-
No direct commits (all changes via pull request)
-
At least one approving review required
-
All CI status checks must pass (pre-commit, linting, tests)
-
No force pushes or branch deletion
-
- Rationale
-
Unprotected main branches allow untested, unreviewed code to enter the codebase. Branch protection ensures every change goes through quality gates before reaching the shared codebase.
- Examples
-
# GitHub branch protection settings required_pull_request_reviews: required_approving_review_count: 1 required_status_checks: contexts: - pre-commit - ansible-lint - molecule-test allow_force_pushes: false allow_deletions: false
11.9. Document breaking changes prominently
Details
- Explanations
-
When a release contains breaking changes (renamed variables, removed features, changed behavior), document them prominently in the Git tag message and in a CHANGELOG file.
- Rationale
-
Without CalVer or SemVer major bumps to signal breaking changes, clear documentation becomes the primary mechanism for communicating incompatibilities. Operators need to know about breaking changes before promoting a release to production.
- Examples
-
git tag -a 26.2.1-0 -m "WARNING: BREAKING CHANGES Breaking: - Removed deprecated inventory format - Changed role variable prefix from 'ws_' to 'webserver_' Migration: - See CHANGELOG.md for migration guide - Update all playbooks referencing 'ws_' variables Features: - Add SSL support to webserver role - Add monitoring integration"
12. Testing
Testing is a critical part of the automation lifecycle. This section covers testing philosophy, strategies, and tools for validating Ansible content at every stage of development.
12.1. Test as early as possible in the development cycle (shift-left)
Details
- Explanations
-
Testing should happen at the earliest feasible point: local development first, then pre-commit hooks, then CI, then integration, and finally validation in production-like environments. Each stage should be faster than the next, providing rapid feedback to developers.
- Rationale
-
Bugs caught early are cheaper and faster to fix. A syntax error caught in a pre-commit hook takes seconds to fix; the same error caught in a production deployment takes hours to diagnose and remediate.
- Examples
-
Local Dev → Pre-commit → CI → Integration → Production ↓ ↓ ↓ ↓ ↓ <1 sec <30 sec <5 min <15 min ValidatedThe test pyramid for Ansible automation should look like this:
-
Base layer (fastest, most tests): Syntax validation and linting (
yamllint,ansible-lint,ansible-playbook --syntax-check) -
Middle layer: Unit tests (
pytestfor plugins/modules) and Molecule tests for roles -
Top layer (slowest, fewest tests): Integration tests and end-to-end validation
-
12.2. Use ansible-lint with the production profile
Details
- Explanations
-
Run
ansible-lintwith theproductionprofile to enforce the strictest set of rules. This should be part of both pre-commit hooks and CI pipelines. - Rationale
-
The
productionprofile catches issues that less strict profiles allow, such as missing FQCNs, unnamed tasks, deprecated modules, and unsafe practices. Using the strictest profile ensures consistent quality across the codebase. - Examples
-
# Run ansible-lint with production profile ansible-lint --profile production # Auto-fix some issues ansible-lint --fix # Generate an ignore file for gradual adoption ansible-lint --generate-ignore
12.3. Use Molecule to test roles in isolated container environments
Details
- Explanations
-
Every role should have at least one Molecule test scenario that validates the role in an isolated container environment. The full Molecule test sequence includes create, converge, idempotence, verify, and destroy.
- Rationale
-
Molecule provides a reproducible and isolated testing environment for roles. It tests the complete lifecycle: that the role can be applied, that it is idempotent (running twice produces no changes), and that the desired state is achieved (verification). Without Molecule, role testing requires manual provisioning of test environments.
- Examples
-
# Run the default scenario (full lifecycle) cd roles/webserver/ molecule test # Run a specific scenario molecule test -s centos # Quick iteration during development (skip destroy) molecule converge # Apply the role molecule verify # Check results molecule destroy # Clean up when doneListing 40. Molecule test sequence1. dependency - Install dependencies 2. cleanup - Clean up from previous run 3. destroy - Destroy existing instances 4. syntax - Check playbook syntax 5. create - Create test instances 6. prepare - Prepare instances 7. converge - Run the role 8. idempotence - Run again (should not change) 9. verify - Run verification tests 10. destroy - Destroy instancesListing 41. Example molecule.yml configuration--- dependency: name: galaxy driver: name: docker platforms: - name: test-instance image: docker.io/geerlingguy/docker-rockylinux9-ansible:latest pre_build_image: true privileged: true provisioner: name: ansible playbooks: converge: converge.yml verify: verify.yml verifier: name: ansible
12.4. Test on multiple platforms when roles support them
Details
- Explanations
-
If a role is designed to support multiple operating system families (e.g. Red Hat and Debian), create Molecule scenarios for each platform and test them.
- Rationale
-
Package names, service names, file paths, and configuration syntax vary between operating system families. A role that works on RHEL 9 may fail silently on Ubuntu if platform-specific variables or tasks are incorrect. Multi-platform testing catches these issues before they reach production.
- Examples
-
roles/webserver/molecule/ ├── default/ # Rocky Linux 9 (primary target) ├── centos/ # CentOS / RHEL variants └── ubuntu/ # Debian / Ubuntu variants# Test all scenarios for scenario in molecule/*/; do molecule test -s $(basename $scenario) done
12.5. Write unit tests for custom modules, plugins, and filters
Details
- Explanations
-
Custom Ansible modules, filter plugins, and lookup plugins should have
pytest-based unit tests with good coverage. Unit tests should follow the Arrange-Act-Assert pattern and be independent of each other. - Rationale
-
Custom Python code in Ansible collections has the same quality requirements as any other software. Unit tests catch regressions, document expected behavior, and enable confident refactoring.
- Examples
-
# tests/unit/test_sample_filter.py import pytest from ansible_collections.myorg.collection.plugins.filter.sample_filter import FilterModule class TestSampleFilter: def test_basic_functionality(self): """Test basic filter operation.""" fm = FilterModule() filters = fm.filters() result = filters['sample_filter']("test") assert result == "TEST" def test_empty_input(self): """Test filter handles empty input correctly.""" fm = FilterModule() filters = fm.filters() with pytest.raises(ValueError): filters['sample_filter']("")# Run unit tests with coverage pytest tests/unit/ -v --cov=plugins --cov-report=term # Run a specific test pytest tests/unit/test_basic.py::TestClass::test_method -v
12.6. Always verify idempotency during testing
Details
- Explanations
-
Every role and playbook must be tested for idempotency: running it twice in a row should produce
changed=0on the second run. Molecule’s default test sequence includes an idempotence step. For complex playbooks outside of Molecule, verify idempotency manually.For guidance on writing idempotent code (e.g. using
changed_whenandcreates), see Roles Good Practices for Ansible and Coding Style Good Practices for Ansible. - Rationale
-
Non-idempotent automation causes unpredictable behavior, unnecessary restarts, and makes it impossible to use check mode reliably. Testing for idempotency is distinct from writing idempotent code — both are necessary.
- Examples
-
# Manual idempotency test ansible-playbook deploy.yml -i inventory.yml # Run again - should show zero changes ansible-playbook deploy.yml -i inventory.yml # Expected: "changed=0"
12.7. Use descriptive test names that explain the expected behavior
Details
- Explanations
-
Test function and class names should describe what behavior is being tested and what the expected outcome is.
- Rationale
-
When tests fail, the test name is the first thing developers see. A descriptive name like
test_filter_handles_empty_inputimmediately communicates what broke, whiletest1requires reading the test code to understand the failure. - Examples
-
# Good - descriptive names def test_module_returns_correct_value(): pass def test_filter_handles_empty_input(): pass def test_role_creates_config_directory(): pass # Bad - uninformative names def test1(): pass def test_stuff(): pass
| For security-related testing practices (secret detection, vulnerability scanning, image scanning), see the Security section. |
13. CI/CD and promotion
Continuous Integration and Continuous Delivery pipelines are the backbone of a reliable automation lifecycle. This section covers good practices for CI/CD workflows, pre-commit hooks, quality validation, and environment promotion for Ansible content.
13.1. Use pre-commit hooks to enforce quality before code enters the repository
Details
- Explanations
-
Install and configure pre-commit hooks in every automation repository. Hooks should run syntax validation, linting, secret detection, and formatting checks automatically before each commit.
- Rationale
-
Pre-commit hooks provide the fastest possible feedback loop — issues are caught before code even reaches the repository, saving CI time and preventing bad code from entering the shared codebase. Without pre-commit hooks, developers rely solely on CI pipelines which are slower and create noisy failure notifications.
- Examples
-
# Install pre-commit in a repository pip install pre-commit pre-commit install # Run all hooks manually pre-commit run --all-files # Update hook versions pre-commit autoupdateA typical pre-commit configuration for an Ansible project includes:
Hook Purpose yamllintYAML syntax and formatting
ansible-lintAnsible best practices
detect-secretsPrevent secrets from being committed
check-yamlBasic YAML validity
end-of-file-fixerEnsure files end with newline
trailing-whitespaceRemove trailing whitespace
13.2. Run the same checks in CI that run in pre-commit
Details
- Explanations
-
CI pipelines should run the same pre-commit hooks as developers run locally, ensuring that even if a developer bypasses local hooks (e.g.
git commit --no-verify), the checks still run. - Rationale
-
Pre-commit hooks can be bypassed locally, intentionally or accidentally. Additionally, some development environments may not support installing pre-commit at all (e.g. restricted workstations, certain cloud-based editors). CI pipelines serve as the authoritative quality gate that cannot be bypassed, ensuring that no code enters the main branch without passing all checks regardless of the developer’s local setup.
- Examples
-
# .github/workflows/pre-commit.yml name: Pre-commit Checks on: [push, pull_request] jobs: pre-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 - run: pip install pre-commit - run: pre-commit run --all-files --show-diff-on-failure
13.3. Design CI pipelines for fast feedback
Details
- Explanations
-
PR validation pipelines should complete in under 5 minutes. Use caching, parallel job execution, conditional execution (only run relevant tests based on changed files), and matrix strategies to keep pipelines fast.
- Rationale
-
Slow CI pipelines discourage frequent commits and pull requests, lead to larger and riskier changes, and reduce developer productivity. Fast feedback encourages the short-lived branches and frequent integration that trunk-based development requires.
- Examples
-
# Cache dependencies for speed - name: Cache pre-commit hooks uses: actions/cache@v4 with: path: ~/.cache/pre-commit key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} # Run tests in parallel with matrix strategy strategy: matrix: scenario: [default, centos, ubuntu] fail-fast: false # Only run when relevant files change on: push: paths: - 'roles/**' - 'plugins/**'
13.4. Separate testing (CI) from building and releasing (CD)
Details
- Explanations
-
Use CI tools (e.g. GitHub Actions) for testing, linting, and validation — things that run on every push and PR. Use CD tools (e.g. CI/CD pipelines, AAP workflows) for building artifacts, publishing to registries, and deploying to environments.
- Rationale
-
Testing and building serve different purposes and have different security requirements. Testing needs access to the code but not to production credentials. Building and releasing need access to registries and deployment targets but should only run on approved code (merged to main, tagged).
Separating these concerns reduces the attack surface and makes each pipeline simpler.
- Examples
-
CI (Testing) CD (Building/Releasing) Trigger Pre-commit validation
Build and publish collections
Tag creation
Ansible-lint
Build execution environments
Merge to main
Unit and Molecule tests
Create release manifests
Manual approval
Security scanning
Promote between environments
Pipeline trigger
13.5. Implement quality gates at each promotion stage
Details
- Explanations
-
Each environment promotion (Dev → QA → Prod) should have explicit quality gates that must pass before promotion proceeds:
-
Dev: All CI tests pass (lint, syntax, unit, molecule)
-
QA: Dev tests pass + code review completed + QA lead approval
-
Prod: QA tests pass + QA sign-off + security scan clean + change advisory board (CAB) approval + rollback plan documented
-
- Rationale
-
Quality gates prevent untested or unapproved changes from reaching production. Without explicit gates, the promotion process becomes informal and error-prone, leading to incidents that could have been prevented.
- Examples
-
Development: ✓ All CI tests pass ✓ Code review approved → Auto-deploy to Dev QA Promotion: ✓ Dev validation complete ✓ QA lead approval → Deploy to QA Production Promotion: ✓ QA testing complete ✓ QA sign-off ✓ Security scan passed ✓ CAB approval received ✓ Change window scheduled ✓ Rollback plan documented → Deploy to Prod
13.6. Validate PR quality with automated checks
Details
- Explanations
-
Pull request validation should include automated checks beyond code linting:
-
PR size limits (flag overly large PRs)
-
Semantic PR title validation (for changelog generation)
-
Changed file analysis (flag production config changes for extra review)
-
Documentation update checks (ensure docs are updated with code changes)
-
Merge conflict detection
-
- Rationale
-
Automated PR checks reduce the burden on reviewers by catching mechanical issues before human review begins. They also enforce team conventions consistently without relying on reviewer memory.
- Examples
-
# Example PR validation workflow - name: Check PR size run: | CHANGED=$(git diff --stat origin/main...HEAD | tail -1) echo "Changes: $CHANGED" - name: Validate PR title run: | # Ensure conventional commit format echo "${{ github.event.pull_request.title }}" | \ grep -qE "^(feat|fix|docs|chore|test|refactor|ci|perf):"
13.7. Use release manifests to track what is deployed where
Details
- Explanations
-
Create release manifests that record the exact version of every component (Git commit SHAs, collection versions, execution environment image digests) and which environment each version is deployed to.
- Rationale
-
Release manifests provide a single source of truth for "what is running in production right now." They enable deterministic rollback (deploy exactly the previous manifest), auditability (who approved what, when), and troubleshooting (quickly identify version mismatches).
- Examples
-
# releases/release-26.1.5-0.yaml version: "26.1.5-0" created: "2025-01-05T10:00:00Z" components: aap_configuration: commit: "abc123def456..." tag: "26.1.5-0" collections: version: "26.1.5-0" execution_environment: tag: "26.1.5-0" digest: "sha256:fedcba..." environments: dev: deployed_at: "2025-01-05T09:00:00Z" qa: deployed_at: "2025-01-05T11:00:00Z" validated: true prod: deployed_at: "2025-01-05T15:00:00Z" approved_by: "CAB"
13.8. Always have a documented rollback plan
Details
- Explanations
-
Before promoting to production, document the rollback procedure: which previous version to revert to, how to trigger the rollback, and what validation to perform after rollback.
- Rationale
-
Production incidents happen. Having a pre-documented rollback plan reduces the mean time to recovery (MTTR) from hours to minutes. Without a rollback plan, teams must figure out the recovery process under pressure during an incident.
- Examples
-
# Rollback by deploying the previous known-good version # Current: 26.1.5-1 (has issues) # Rollback to: 26.1.5-0 (last known good) # Create a new tag pointing to the rollback state git tag -a 26.1.6-0 -m "Rollback to 26.1.5-0 state Rolled back from: 26.1.5-1 Reason: Critical issue in webserver role Rollback approved by: CHG0001236"
| For vulnerability scanning, SBOM generation, and security scanning as part of CI/CD, see the Security section. |
14. Security
Security is a cross-cutting concern that affects every aspect of Ansible automation. This section covers good practices for secret management, access control, security scanning, and maintaining a secure automation lifecycle.
14.1. Never store secrets in Git repositories
Details
- Explanations
-
No passwords, API tokens, private keys, or other sensitive values should ever appear in plain text in any file tracked by Git. This includes playbooks, variable files, templates, scripts, and configuration files.
Use one or more of the following approaches:
-
ansible-vaultto encrypt files or individual values -
lookup('env', …)to reference environment variables at runtime -
External secret management systems (e.g. HashiCorp Vault, CyberArk) accessed via lookup plugins
-
AAP credential objects that reference secrets stored in the platform’s credential store
-
- Rationale
-
Secrets committed to Git persist in the repository history permanently, even if subsequently deleted. A single committed secret can compromise entire environments and is nearly impossible to fully remediate (the repository history must be rewritten, all downstream forks and clones are still exposed).
- Examples
-
# Good - reference environment variable - name: Configure API ansible.builtin.template: src: config.j2 dest: /etc/app/config.yml vars: api_token: "{{ lookup('env', 'API_TOKEN') }}" # Good - use ansible-vault encrypted variable - name: Configure database ansible.builtin.template: src: db.conf.j2 dest: /etc/app/db.conf vars: db_password: "{{ vault_db_password }}" # Bad - hardcoded secret - name: Configure API ansible.builtin.template: src: config.j2 dest: /etc/app/config.yml vars: api_token: "sk-1234567890abcdef"
14.2. Enforce secret detection with pre-commit hooks and CI scanning
Details
- Explanations
-
Use automated secret detection tools in both pre-commit hooks and CI pipelines:
-
detect-secretsfor pattern-based detection in code -
gitleaksfor scanning Git history for leaked credentialsCI pipelines should fail immediately if a secret is detected, blocking the merge.
-
- Rationale
-
Human review alone is insufficient to catch accidentally committed secrets. Automated scanning provides a safety net that catches secrets before they enter the repository, and scanning Git history catches secrets that may have been committed previously.
- Examples
-
# Scan current codebase for secrets detect-secrets scan # Scan Git history for leaked secrets gitleaks detect # CI pipeline step - name: Check for secrets run: | detect-secrets scan --all-files gitleaks detect --no-git
14.3. Enable push protection on your Git hosting platform
Details
- Explanations
-
Most Git hosting platforms offer server-side push protection that blocks pushes containing detected secrets before they ever reach the repository. This is a complementary layer to client-side pre-commit hooks and CI scanning:
-
GitHub push protection scans pushes for known secret patterns and blocks the push if a match is found.
-
GitLab secret push protection provides equivalent server-side blocking.
Push protection should be enabled at the organization level so that all repositories are covered by default.
-
- Rationale
-
Pre-commit hooks run client-side and can be bypassed (
git commit --no-verify). CI scanning catches secrets after they have been pushed, meaning the secret is already in the repository history. Push protection is the only mechanism that blocks secrets at the server before they enter the repository at all, closing the gap between client-side hooks and post-push CI scanning. - Examples
-
Layer When it runs Can be bypassed? Pre-commit hooks
Before local commit
Yes (
--no-verify)Push protection
During
git push(server-side)Only with explicit override and audit trail
CI secret scanning
After push, during pipeline
No, but secret is already in history
14.4. Apply least-privilege access control
Details
- Explanations
-
Every service account, CI pipeline, and AAP credential should have the minimum permissions required to perform its function.
-
CI pipelines that only run tests should not have access to production secrets
-
Deployment pipelines should only have write access to their target environment
-
AAP service accounts should be scoped to specific organizations and resources
-
- Rationale
-
Over-privileged accounts increase the blast radius of security incidents. If a CI pipeline with production credentials is compromised, the attacker gains production access. If that pipeline only has test credentials, the blast radius is limited to the test environment.
- Examples
-
Account / Pipeline Required Permissions Not Required PR validation pipeline
Run tests, read code
Deploy, access secrets
Dev deployment pipeline
Deploy to dev, read dev secrets
Access to QA or prod
Prod deployment pipeline
Deploy to prod, read prod secrets
Write to dev or QA
14.5. Use AAP credential objects instead of embedding credentials in playbooks
Details
- Explanations
-
AAP provides a credential management system where secrets are stored securely in the platform and injected at runtime. Playbooks should reference credential names, never contain credential values.
AAP credentials support multiple types including machine (SSH), source control (Git), cloud provider, container registry, and vault credentials.
- Rationale
-
AAP credential objects are stored encrypted at rest, access-controlled via RBAC, and audited. Embedding credentials in playbooks or variable files bypasses all of these security controls.
- Examples
-
# Good - AAP job template references credential by name controller_templates: - name: "deploy_webapp_prod" credentials: - "eng_machine_ansible_linux_prod" - "eng_scm_git_github_main" # Good - CaC credential definition references external secret controller_credentials: - name: "eng_machine_ansible_linux_prod" credential_type: "Machine" inputs: username: "ansible" ssh_key_data: "{{ lookup('env', 'SSH_PRIVATE_KEY_PROD') }}"
14.6. Implement regular secret rotation
Details
- Explanations
-
Credentials used by technical and service accounts (passwords, tokens, API keys, SSH keys) should be rotated on a regular schedule. The rotation process should be automated where possible and documented where it cannot be.
- Rationale
-
Long-lived credentials have a higher probability of being compromised over time. Regular rotation limits the window of exposure if a credential is leaked. Automated rotation ensures the process is consistent and does not depend on human memory.
- Examples
-
Recommended rotation intervals:
Credential Type Rotation Interval API tokens
90 days
SSH keys
Annually
Service account passwords
90 days
AAP admin credentials
90 days
CI/CD pipeline tokens
90 days
14.7. Scan container images and generate SBOMs for every release
Details
- Explanations
-
Every execution environment image should be scanned for known vulnerabilities before it is deployed to any environment. Use tools like Trivy or Grype in the CI/CD pipeline. Additionally, generate a Software Bill of Materials (SBOM) for every release using tools like Syft.
Define severity thresholds that control whether the pipeline proceeds or fails.
- Rationale
-
Container images bundle operating system packages, Python libraries, and Ansible collections. Any of these components may have known security vulnerabilities. Scanning catches these issues before they are deployed, reducing the attack surface of automation infrastructure. SBOMs provide a complete inventory of components, enabling vulnerability tracking and compliance reporting.
- Examples
-
# Generate SBOM syft quay.io/myorg/automation-ee:26.1.5-0 -o spdx-json > sbom.json # Scan with Trivy trivy image --severity CRITICAL,HIGH quay.io/myorg/automation-ee:26.1.5-0 # Scan with Grype grype quay.io/myorg/automation-ee:26.1.5-0 --fail-on high # Scan Python dependencies pip-audit -r requirements.txt safety check -r requirements.txtRecommended severity thresholds by environment:
Environment Fail Threshold Policy Development
None
Warn only
QA
High
Warn and track
Production
High/Critical
Block deployment
14.8. Use Git as the audit trail for all configuration changes
Details
- Explanations
-
When all configuration is managed as code (GitOps), Git automatically provides a complete audit trail: who changed what, when, and why (via commit messages and PR descriptions).
- Rationale
-
Compliance and security frameworks (SOC 2, PCI DSS, HIPAA) require auditable change management. Git provides this automatically when all changes flow through pull requests with reviews and approvals.
Manual UI changes in AAP bypass this audit trail entirely, which is why Configuration as Code is a security practice, not just a convenience.
- Examples
-
# Who made this change? git log --all --oneline -- group_vars/aap_prod/ # What changed in the last 30 days? git log --since="30 days ago" --pretty=format:'%h|%an|%ad|%s' --date=iso # Full audit export for compliance git log --all --pretty=format:'%H|%an|%ae|%ad|%s' --date=iso > audit.csv
14.9. Implement network segmentation between environments
Details
- Explanations
-
Development, QA, and production environments should be network-isolated from each other. Only explicitly permitted traffic (e.g. CI/CD pipelines deploying to a target environment) should cross environment boundaries.
- Rationale
-
Network segmentation limits the blast radius of security incidents. If the development environment is compromised, network isolation prevents the attacker from pivoting to production. It also prevents accidental cross-environment access (e.g. a dev playbook accidentally targeting production hosts).
- Examples
-
Network segmentation can be implemented through:
-
Network policies (in Kubernetes/OpenShift)
-
Firewall rules (in traditional infrastructure)
-
VPC/subnet separation (in cloud environments)
-
Separate AAP instances or organizations per environment
-
14.10. Conduct regular security reviews and access audits
Details
- Explanations
-
Perform periodic reviews of:
-
Service account permissions (are they still least-privilege?)
-
Active credentials (are any stale or unused?)
-
CI/CD pipeline access (who can trigger production deployments?)
-
AAP RBAC configuration (are team permissions appropriate?)
-
- Rationale
-
Access requirements change over time as team members join, leave, or change roles. Without periodic review, permissions accumulate ("permission creep") and the environment becomes over-privileged.
- Examples
-
Recommended review cadence:
Review Type Cadence Service account permissions
Quarterly
Active credential audit
Quarterly
CI/CD pipeline access
Semi-annually
AAP RBAC review
Quarterly
Security scan results
Weekly