Writing Ansible code typically involves a procedural mindset. However, as the codebase expands, the lack of adequate abstractions can slow down modifications. Drawing from design pattern lessons can enhance the codebase’s resilience and adaptability, allowing for quicker responses to business requirements. In this article, I’ll discuss how adopting the principles of three design patterns has improved our Ansible codebase, accommodating an increasing diversity of hardware, operating systems, network configurations, and external services.
First, let’s review three design patterns for better understanding and application.
Strategy Pattern
The Strategy Pattern enables the dynamic selection of algorithms at runtime. For example, consider reaching the fourth floor of a building with both staircases and an elevator. This pattern offers the flexibility to choose the elevator on days when your legs are sore, or opt for the stairs to get some cardio in if you’ve skipped the gym recently.
Dependency Injection
Dependency Injection is a technique where dependencies are provided (injected) at runtime rather than being hardcoded within the component itself. This promotes loose coupling, making the software easier to extend, maintain, and test. A relatable example is home renovation; instead of directly managing individual contractors for electrical, plumbing, and carpentry work, you entrust a general contractor with the task. This contractor then coordinates all the necessary resources, akin to how dependency injection manages component dependencies.
Dependency Inversion
Dependency Inversion emphasizes that high-level modules should not depend on low-level modules, but both should rely on abstractions. Moreover, abstractions should not be dependent on details, but rather, details should depend on abstractions. To illustrate, consider the electrical system in a house: the power outlets or the appliances you plug in do not need to be modified if you change your electricity provider. The outlets are designed to a universal standard (an abstraction), not to the specifics of any single provider.
Applying Design Pattern Lessons in Ansible
Let’s envision a scenario where we’re tasked with provisioning a web development stack. Initially, we create a straightforward Ansible playbook to install Apache and MySQL:
- name: Setup a webserver hosts: localhost tasks: - name: Install Apache debug: msg: "Apache will be installed" tags: apache - name: Install MySQL debug: msg: "MySQL will be installed" tags: mysql
Later, a request arrives to support a project using a MERN stack, necessitating the setup of MongoDB and Nginx. One approach could be to create an additional playbook:
- name: Setup a webserver hosts: localhost tasks: - name: Install Apache debug: msg: "Apache will be installed" when: web_server_type == "apache" # Additional task details... - name: Install Nginx debug: msg: "Nginx will be installed" when: web_server_type == "nginx" tags: Install nginx
However, as projects evolve to support multiple operating systems, data centers, software versions across various environments, and the management of numerous roles, it becomes clear that our approach needs refinement.
Refactoring with Design Patterns in Mind
Before we proceed with refactoring, let’s consider how design pattern lessons can be applied in Ansible:
- Reduce Specificity: Instead of relying on detailed checks, aim to build abstractions that encapsulate the variability.
- Depend on Abstractions: Ensure that abstractions do not hinge on specific details, but rather, that details derive from these abstractions.
- Runtime Flexibility: Allow for the selection of specific implementations at runtime to accommodate varying requirements.
- Externalize Dependencies: Move dependency management from tasks or roles to a higher level, utilizing variables for greater control and flexibility.
- Component Swappability: Enable easy replacement of components with alternatives, minimizing the need for extensive refactoring.
Leveraging Ansible Constructs for Design Patterns
Let’s delve into how Ansible’s features support the application of design pattern principles, making our automation solutions more adaptable, maintainable, and scalable.
Ansible Inventory
Ansible Inventory enables the organization of your infrastructure into logical groups and distributes configuration data hierarchically through group or host variables. This structure allows for precise control without the need to specify conditions for each usage explicitly.
Consider the following inventory structure as an example:
all: children: mean_stack: hosts: mean_server_1: ansible_host: 192.168.1.101 ansible_user: ubuntu ansible_ssh_private_key_file: /path/to/ssh/key mean_server_2: ansible_host: 192.168.1.102 ansible_user: ubuntu ansible_ssh_private_key_file: /path/to/ssh/key lamp_stack: hosts: lamp_server_1: ansible_host: 192.168.1.103 ansible_user: ubuntu ansible_ssh_private_key_file: /path/to/ssh/key
For each group, we define a corresponding variable file. Note that the variable names are consistent across different implementations, promoting abstraction and reusability.
# mean_stack.yml web_server_type: nginx web_server_version: 9.1 db_server_type: postgres db_server_version: 9.1
# lamp_stack.yml web_server_type: apache web_server_version: 2.1 db_server_type: mysql db_server_version: 9.1
By using the ansible-inventory
command, we can observe how Ansible parses and merges these variables, providing a clear, unified view of the configuration for each host within the specified groups:
(venv) ➜ homelab ansible-inventory -i inventory/dev --list -y all: children: lamp_stack: hosts: lamp_server_1: ansible_host: 192.168.1.103 ansible_ssh_private_key_file: /path/to/ssh/key ansible_user: ubuntu db_server_type: mysql db_server_version: 9.1 web_server_type: apache web_server_version: 2.1 mean_stack: hosts: mean_server_1: ansible_host: 192.168.1.101 ansible_ssh_private_key_file: /path/to/ssh/key ansible_user: ubuntu db_server_type: mongodb db_server_version: 11 web_server_type: nginx web_server_version: 9.1 mean_server_2: ansible_host: 192.168.1.102 ansible_ssh_private_key_file: /path/to/ssh/key ansible_user: ubuntu db_server_type: mongodb db_server_version: 11 web_server_type: nginx web_server_version: 9.1
The Limit Flag
The limit flag (-l
) in ansible-playbook
command is an effective method for specifying which host groups should be targeted when executing a playbook. In my view, this represents a shift in control from the code to the operator, streamlining the execution process. It negates the need for additional conditional statements such as when
within the code, instead leveraging the data defined in the inventory to dictate behavior.
Here’s an example of using the -l
flag to target a specific high-level group:
ansible-playbook -i inventories/homelab/dev -l lamp_stack deploy_stack.yml ansible-playbook -i inventories/homelab/prod -l lamp_stack deploy_stack.yml ansible-playbook -i inventories/homelab/dev -l mean_stack deploy_stack.yml ansible-playbook -i inventories/homelab/prod -l mean_stack deploy_stack.yml
Note we are applying the same playbook but controlling which set of variables Ansible finally picks to pass to the playbooks and roles.
include_tasks and include_role
The include_tasks
directive in Ansible allows for the segmentation of playbooks into smaller, more focused components, facilitating the separation of concerns. Similarly, include_role
enables the construction of higher-level abstractions.
Consider the following example of a deploy_stack.yml
playbook:
--- - name: deploy stack hosts: all roles: - role: webserver - role: dbserver
This playbook is designed to be generic, capable of deploying a stack without specifying the particular technologies—such as which database or web server to use. The selection of specific technologies is driven by the -l
limit flag and the corresponding data in the inventory, which determines the applicable variables.
For instance, we can define a high-level webserver
role that remains agnostic of the specific web server being implemented. The dbserver
role follows a similar pattern. Below is an example where the webserver
role dynamically includes a specific web server setup based on the web_server_type
variable:
--- - name: Include web server setup role ansible.builtin.include_role: name: "{{web_server_type}}" tags: - nginx - apache
Moving on to a concrete implementation, let’s examine an nginx
role. The roles/nginx/tasks/main.yml
file might contain tasks like the following, demonstrating the role’s specific actions and the inclusion of additional tasks:
--- - name: Task in nginx role ansible.builtin.debug: msg: nginx will be installed tags: nginx - name: A group of tasks separated by duty include_task: demo.yml tags: nginx
--- - name: a task in a playbook ansible.builtin.debug: msg: included to help installation of nginx
This structure allows for modular playbook design, where roles and tasks can be dynamically included based on the deployment’s requirements, enhancing flexibility and maintainability.
Putting it all together
. ├── bin │ └── dynamic_inventory.py ├── deploy_stack.yml ├── inventory │ └── dev │ ├── group_vars │ │ ├── lamp_stack │ │ └── mean_stack │ ├── host_vars │ └── hosts └── roles ├── apache │ └── tasks │ └── main.yml ├── nginx │ └── tasks │ ├── demo.yml │ └── main.yml └── webserver └── tasks └── main.yml 13 directories, 9 files (venv) ➜ homelab ansible-playbook -i inventory/dev -l mean_stack deploy_stack.yml PLAY [setup a webserver] *************************************************************************************************** TASK [Gathering Facts] ***************************************************************************************************** ok: [mean_server_2] ok: [mean_server_1] TASK [Include web server setup role] *************************************************************************************** TASK [nginx : Task in nginx role] ****************************************************************************************** ok: [mean_server_1] => { "msg": "nginx will be installed" } ok: [mean_server_2] => { "msg": "nginx will be installed" } TASK [nginx : include_tasks] *********************************************************************************************** included: /Users/t/projects/ansible/homelab/roles/nginx/tasks/demo.yml for mean_server_1, mean_server_2 TASK [nginx : a task in a playbook] **************************************************************************************** ok: [mean_server_1] => { "msg": "included to help installation of nginx" } ok: [mean_server_2] => { "msg": "included to help installation of nginx" } PLAY RECAP ***************************************************************************************************************** mean_server_1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 mean_server_2 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 (venv) ➜ homelab ansible-playbook -i inventory/dev -l lamp_stack deploy_stack.yml PLAY [setup a webserver] *************************************************************************************************** TASK [Gathering Facts] ***************************************************************************************************** ok: [lamp_server_1] TASK [Include web server setup role] *************************************************************************************** TASK [apache : Task in apache role] **************************************************************************************** ok: [lamp_server_1] => { "msg": "apache will be installed" } PLAY RECAP ***************************************************************************************************************** lamp_server_1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Honorable mention
--tags
and --skip-tags
are two flexible options that allow selecting tasks across your roles and playbooks from the ansible-playbook
command. An practice that has been useful is to tag every task in a role with the name of the role. This is due to the fact that ansible-playbook command does not allow you to run a role adhoc, but if the tasks in your role are tagged with the role name, it helps run multiple roles by providing a list of tags.
ansible-playbook -i inventory/dev --tags nginx,mongodb deploy_stack.yml
Concluding remarks
Design patterns are language neutral and the lessons we learn from them can be useful even beyond object oriented design paradigms. Despite Ansible being a procedural configuration management tool, being aware of those lessons help us write cleaner maintainable code that can be changed easily to respond to changing business needs. In this article I reviewed some of the lessons I have learned trying to adopt the spirit of a few useful design patterns while writing Ansible code. While the example is a bit facetious, I hope this would be useful. Leave a comment if there other patterns that you have come across that makes it easier to write cleaner Ansible.