Applying design patterns to write maintainable Ansible

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.

Three suggestions for daily git workflow

In our quest for productivity, it’s easy to be lured by the allure of sophisticated tooling. However, complexity often comes at a cost, sometimes sidelining our actual projects for the sake of toolchain tweaks. That’s why I try to keep my setup minimalistic, aiming for the sweet spot where simplicity meets functionality. In this spirit, I want to share three adjustments I recently integrated into my workflow, offering a blend of improved practices without much overhead.

Start with the Commit Messages

Let’s face it, a bad commit message can be a real downer, especially when it’s your own! It’s crucial not just to document what was changed but to capture the why behind it. Despite the abundance of advice on crafting meaningful commit messages, many of us still fall short. Why? It often boils down to timing – we start writing the commit messages too late in the game.

What if I did the opposite:

  1. Draft Beforehand: Start by drafting your commit reason in a file (like CHANGELOG or .git_commit) using your favorite IDE, not a cramped text editor.
  2. Keep It Private: Add this file to your .gitignore to ensure it stays out of version control.
  3. Code With Purpose: With your intentions clearly outlined, proceed with your changes, then add your files.
  4. Commit With Clarity: Use git commit -F CHANGELOG to pull your polished message into the commit, enhancing both documentation and your focus on the task at hand.

This method not only improves your commit messages but also primes your mindset for the changes ahead.

Streamlining Commits with Git Alias

It is unlikely you can get your changes to be working in the first go. Your linting, testing etc will point out, your gaps. It is also unlikely, your coworkers will appreciate a review request with multiple of commit in it saying, “bug fixes”. And then if we forget squashing the commits before merging … there goes the project’s git history.

To simplify, consider git commit --amend. It has the useful --no-edit and -a flags to help tidy up your follow up edits beyond first commit. However, to keep your remote happy, you need to force push your changes. Summing it up, after every effort to fix your change is followed by

git commit -F CHANGELOG -a --amend --no-edit
git push origin BRANCH -f

This is where the git alias comes in. Run the following command

git config --global alias.cp '!f(){ \
    git commit -F CHANGELOG -a --amend --no-edit && \
    local branch=$(git rev-parse --abbrev-ref HEAD) && \
    git push --force-with-lease origin "$branch":"$branch"; \ 
}; f'

This gives us a new git command or alias – git cp, short for commit and push. A little reduction of friction between your iteration cycles that ensures your pull requests are tidy.
P.S. A pit fall to avoid – if you have added a new file that is outside your current working directory in the middle of your change, you’ll need to add it. But hopefully, your command prompt is configured to show the git branch and status, so that you catch that real quick.

➜  cryptogpt git:(main) ✗ git status
On branch main
Your branch is behind 'origin/main' by 13 commits, and can be fast-forwarded.
  (use "git pull" to update your local branch)

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   requirements.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	Dockerfile

no changes added to commit (use "git add" and/or "git commit -a")
➜  cryptogpt git:(main) ✗ 

The ✗ tells me that I have un-commited changes. ohmyz.sh will do this right out of the box for you.

Git pre-commit hooks

And lastly, we have everyone’s favorite pre-commit hooks. Pre-commit hooks allows easy management of your commit hooks, has great documentation and all the hooks to satisfy the needs of a polyglot code base. It is great way to minimize noise in code reviews and standardize the whole team into best practices. Sometimes you have needs that are custom to your projects. And pre-commit-hooks makes it really easy to introduce and distribute such custom hooks.

Custom hook example – Ansible Inventory generator

Ansible inventories provide a great way of abstracting your implementations in a way that is similar to dependency injection. It lets us organize configuration data from higher level groups of servers, down to then more host specific configurations. This eliminates unnecessary when conditions (equivalent to if-else checks) and encourages abstractions for resiliency to changing business needs.
However, this results in the variable values to be distributed across multiple files all, group_vars/ , host_vars/ making it difficult to get a complete view of configuration values for each server until runtime. If you are deploying to a big fleet, incorrect values could get very costly. A shift-left solution here is to use the ansible-inventory command to generate all the values for each server in your inventory. Here is an example command that dumps the values to a file that can be easily reviewed whenever you are refactoring the inventory variables.

### contents of bin/generate_inventory.sh 
ansible-inventory -i inventories/dev --list -y 2>&1 > sed 1d dev_inventory

### example output
all:
  children:
    americas:
      children:
        nyc:
          hosts:
            server-01:
              variable: &id001
              - a
              - b
            server-02: *id001
	       ...

Checking the file into the git repo helps set a base level which you can compare anytime the variables are moved around. This is where we bring in a custom pre-commit hook.

To call the script at a pre-commit stage, add a section to your.pre-commit-config.yaml. This will now regenerate and overwrite the dev_inventory file every time we try to commit.

 - repo: local
   hooks:
     - id: inventory
       name: Ansible inventory generation
       entry: bin/gen_inventory.sh
       language: system

If the generated file differs from the existing one, git will see an un-staged file preventing the commit. This gives you the opportunity to review the diff and gives you early feedback before executing the code. Shift left and catch the drift or bugs even before it reaches CI.
If you want multiple teams or projects to share this, you can push it to a git repository and point repo to it.

P.S.
Some times, you need an escape hatch to just commit without evaluation, git commit --no-verify will bypass the hooks and let you commit.

Wrapping Up

Retooling can be a constant chase for productivity. Instead an approach could be evidence based. Asking does this help us move left, fail early and fail fast is a powerful guiding principle to evaluate if this investment of time will compound over time to help us ship software faster. Hoping this helps someone, and they leave behind what they have found useful in improving their git workflow. Here’s to continuous improvement and the joy of coding!

Using Cloudflare’s Free SSL Certificate

In the year 2024, there can be only one reason for not using SSL everywhere – laziness. Laziness has sabotaged the migration of my blog from an overpriced shared vps to AWS. But this time, when the cert expired and Cloudflare stopped routing traffic, the SRE inside me have had it enough with the downtime. While still in bed, I started clickops-ing this cert to be SSLlabs poster child.

I soon found out that since I do not use the hosting provider as my domain registrar, my site is not eligible for a proper free certificate, and all they can offer are self-signed certificate. Cloudflare’s Full (strict) SSL validation test requires a valid SSL certificate on the origin server for end-to-end encryption. Since there is no root(admin access) on a shared vps server, you cannot install certbot and Letsencrypt yourself out of this.

Luckily generating an Origin Certificate from Cloudflare is easy even with clickops! They provide a validated path between Cloudflare and the origin server without the need for a publicly trusted certificate on the origin.

Generating a Cloudflare Origin Certificate

  1. Log into your Cloudflare dashboard.
  2. Navigate to the SSL/TLS section and select the Origin Server tab.
  3. Click on Create Certificate. For most users, the default settings provided by Cloudflare will suffice. These include a wildcard certificate that covers your domain and any subdomains.
  4. When prompted, choose the validity period for your certificate. Cloudflare allows you to select a duration of up to 15 years, providing long-term coverage.
  5. Cloudflare will generate a private key and a certificate. Copy both, as you will need them for the next steps.

Step 2: Importing the Certificate through cPanel

  1. Log into your cPanel account on your hosting provider
  2. Scroll down to the Security section and click on SSL/TLS.
  3. Under the SSL/TLS section, find and click on Manage SSL sites under the Install and Manage SSL for your site (HTTPS) menu.
  4. Select the domain you’re installing the certificate for from the drop-down menu.
  5. In the fields provided, paste the certificate and private key that you copied from Cloudflare.
  6. Click on Install Certificate. This process installs the Cloudflare Origin Certificate on your InMotion Hosting server, enabling it to pass Cloudflare’s Full (strict) SSL verification.

Step 3: Verifying the Installation

  1. Once the installation is complete, it’s crucial to verify that everything is working as intended.
  2. You can use SSL verification tools like SSLLabs to test your site’s SSL status.
  3. Additionally, check your website’s loading behavior to ensure there are no SSL errors or warnings.

Bonus

Put your SSL/TLS setting on Cloudflare to Full(strict) and select least supported version as TLS 1.3 for the Edge certificate.

Result

After a couple of hours of clicking through cPanel and Cloudflare menu, I finally feel vindicated.

The main reason I wanted to write this down was firstly I strongly detest remembering UI navigation, but sometimes it buys you time while you are automating the process out of your cognitive boundaries. The other reason is I do not want a clickops post to be the stack top of my blog, and will serve as push for migrating off this fleecing vps to the terraformed AWS nirvana.