Brandon
  • Home
  • Articles
  • Notes

On this page

  • Install Ansible
  • Ansible Playbook
  • Ansible Tasks
  • Configuring cli tools
  • Ansible Role
  • Escape hatch
  • OS distributions
  • Running the playbook
  • Conclusion

Dotfiles are good for you

Setup a development environment with Ansible

ansible
macos
dotfiles
Author

Brandon Rundquist

TL;DR

Dotfiles GitHub repository

Having a consistent development environment increases productivity in a big way.

The ability to have a consistent environment across multiple machines is a huge increase in productivity. It allows for a developer to be able to move between machines without having to reconfigure everything.

For this use case we are using Ansible for provisioning our software. Ansible is a popular configuration management tool that is a staple.

You can find people saying Ansible is too complicated for the task but setting up a good set of dotfiles is not simple!

A bash script is going to be a mess and hard to maintain. Dotfile managers like yadm, chezmoi, and dotbot are good but they are not as flexible as Ansible. And full system provisioning like Guix or Nix is too complicated.

Ansible uses an amalgamation of YAML, Jinja like templating. You can even implement Python functions that are used within the template.

Now we will go through the process of setting up a development environment using Ansible.

Install Ansible

We are going to install on the same machine we are installing Ansible on. You can also install over SSH which is how it is normally used.

python3 -m pip install ansible

Now we will have the command ansible-playbook available.

Ansible Playbook

Ansible executes what are known as playbooks which are essentially YAML file that Ansible can parse.

Below is our initial playbook.yml file

Now we will have the command ansible-playbook available.

Ansible executes what are known as playbooks which are essentially YAML file that Ansible can parse.

Below a common preamble to an Ansible playbook.yml file.

---
1- hosts: all
2  become: true
3  become_method: sudo
4  vars:
    ansible_remote_tmp: /tmp
1
This determines where to code is run. Ansible can run on many hosts and we are only running it locally so we can define it as all.
2
This is to become an user with elevated permissions.
3
The method used to elevate the user.
4
Set variables that can be used throughout the playbook.

Now we can get into the meat of a playbook file, the tasks. An Ansible task is where we mutate our environment.

Ansible Tasks

  tasks:
    - name: Check if Homebrew is installed
      command: which brew
      register: homebrew_installed
      ignore_errors: yes
    - name: Install Homebrew
      shell: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
      when: homebrew_installed.rc != 0
    - name: Install Homebrew packages
      homebrew_tap:
        name: homebrew/cask
      when: homebrew_installed.rc == 0
1- name: Install fish
2  homebrew:
3    name: fish
4    state: present
- name: Install tmux
  homebrew:
    name: tmux
    state: present
1
The name of the task.
2
The module to use.
3
The name of the package to install.
4
The state of the package.

We can define a list of packages to install under name.

- name: Ensure Homebrew packages are installed
  homebrew:
    state: present
    name:
      - zsh
      - tmux
Note

Using a {{ loop }} to iterate through the packages does them individually so it is preferred to use a list under name.

Now let’s install some casks. Casks are used to install GUI applications.

- name: Install Homebrew casks
  homebrew_cask:
    state: present
    name:
      - visual-studio-code
      - font-fira-code
      - quarto

This gets us the packages we want installed. The full list can be found in the repository. Now we can move on to configuring our terminal.

Configuring cli tools

Now let’s configure tmux. tmux is a terminal multiplexer that allows for multiple terminal sessions to be run in a single terminal window.

It’s configuration consists of a single file ~/.tmux.conf. We are going to do the following:

  1. Check if ~/.tmux.conf exists.
  2. Remove existing ~/.tmux.conf symlink if it exists.
  3. Backup ~/.tmux.conf if it exists and is not a symlink.
  4. Symlink new ~/.tmux.conf from present working directory.

This will utilize of the stat and file modules in Ansible.

 --- Tmux Configuration ---
- name: Check if ~/.tmux.conf exists
  stat:
1    path: "{{ ansible_env.HOME }}/.tmux.conf"
2  register: tmux_conf

- name: Remove existing ~/.tmux.conf symlink if it exists
  file:
    path: "{{ ansible_env.HOME }}/.tmux.conf"
3    state: absent
4  when: tmux_conf.stat.islnk is defined and tmux_conf.stat.islnk

- name: Backup ~/.tmux.conf if it exists and is not a symlink
  copy:
    src: "{{ ansible_env.HOME }}/.tmux.conf"
5    dest: "{{ ansible_env.HOME }}/.tmux.conf.backup"
    remote_src: yes
  when: tmux_conf.stat.exists and not tmux_conf.stat.islnk

- name: Symlink new ~/.tmux.conf from present working directory
  file:
    src: "{{ ansible_env.PWD }}/.tmux.conf"
    dest: "{{ ansible_env.HOME }}/.tmux.conf"
6    state: link
7    force: yes
1
The path to the file.
2
Register the result of the stat module.
3
Remove the file if it is a symlink.
4
Only run if the file is a symlink.
5
Backup the file if it exists and is not a symlink.
6
Create a symlink to the new file.
7
Force the symlink to be created.

This will create a symlink to the new ~/.tmux.conf file.

This is going to be a common pattern for all of the configuration files and directories.

We can create an Ansible role to encapsulate this pattern.

Ansible Role

An Ansible role is a way to encapsulate a set of tasks into a single unit. This allows for reuse and sharing of the tasks.

The symlinking of files and directories from the dotfiles repository is a common pattern that can be encapsulated into a role.

Here is a role that symlinks a file or directory from the dotfiles repository.

---
1- name: Ensure the symlink source exists
  stat:
    path: "{{ source }}"
  register: source_check

2- name: Fail if the symlink source does not exist
  fail:
    msg: "The symlink source {{ source }} does not exist."
  when: not source_check.stat.exists

3- name: Check if destination exists
  stat:
    path: "{{ destination }}"
  register: dest_check

4- name: Remove existing symlink if present
  file:
    path: "{{ destination }}"
    state: absent
  # when: not dest_check.stat.exists and dest_check.stat.islnk
  when: dest_check.stat.exists and not (dest_check.stat.islnk | default(false))

5- name: Backup existing file or directory if present and not a symlink
  copy:
    src: "{{ destination }}"
    dest: "{{ destination }}.bak"
    remote_src: yes
  when: dest_check.stat.exists and not dest_check.stat.islnk

6- name: Remove original file or directory after backup
  file:
    path: "{{ destination }}"
    state: absent
  when: dest_check.stat.exists and not dest_check.stat.islnk

7- name: Create symlink
  file:
    src: "{{ source }}"
    dest: "{{ destination }}"
    state: link
1
Check if the source exists.
2
Fail if the source does not exist.
3
Check if the destination exists.
4
Remove the destination if it exists.
5
Backup the destination if it exists.
6
Remove the destination after backup.
7
Create the symlink.

This role can be used to symlink any file or directory from the dotfiles repository.

Escape hatch

If you need to run a command that is not covered by Ansible you can use the command module.

Here is a task that installs Visual Studio Code extensions.

- name: Install VS Code extensions
1  command: code --install-extension "{{ item }}"
2  loop:
    - antiantisepticeye.
    - vscode-color-picker
    - asvetliakov.vscode-neovim
    - ban.spellright
1
The command to run that gets interpolated with the values from the below loop.
2
The loop that iterates over the list of extensions.

OS distributions

Ansible can be used to provision any OS distribution. The only thing that needs to be changed is the package manager.

For example, on Ubuntu we use apt and on MacOS we use homebrew.

  tasks:
    - name: Include macOS tasks
      include_tasks: tasks/macos.yml
1      when: ansible_facts['os_family'] == 'Darwin'
      tags:
        - macos
        - brew
        - cask
1
Only run the tasks on MacOS.

Running the playbook

To run the playbook we use the ansible-playbook command.

If we want to only run a specific task we can use the --tags flag. Here is a makefile that runs the playbook.

GLOBAL_PYTHON := python3

1VENV := venv
2PYTHON := $(VENV)/bin/python
3PIP := $(VENV)/bin/pip

4TAGS := all

REQUIREMENTS := requirements.txt

# Check if all is in TAGS
5ALL_FLAG := $(findstring all, $(TAGS))
# Check if sudo is in TAGS
6SUDO_REQUIRED := $(findstring sudo, $(TAGS))
7ifeq ($(SUDO_REQUIRED)$(ALL_FLAG),)
  ASK_PASS_FLAG := 
else
  ASK_PASS_FLAG := --ask-become-pass
endif

8install: $(VENV)/bin/activate

$(VENV)/bin/activate: $(REQUIREMENTS) | $(VENV)
    $(PIP) install --upgrade pip
    $(PIP) install -r $(REQUIREMENTS)
    @touch $(VENV)/bin/activate

# Ensure the venv directory exists
$(VENV):
    $(GLOBAL_PYTHON) -m venv $(VENV)

dotfiles:
9    $(PYTHON) -m ansible playbook playbook.yml --tags $(TAGS) $(ASK_PASS_FLAG)
1
The virtual environment directory.
2
The Python executable in the virtual environment.
3
The pip executable in the virtual environment.
4
The tags to run.
5
Check if all is in the tags.
6
Check if sudo is in the tags.
7
If sudo is not in the tags then do not ask for a password.
8
Install the virtual environment.
9
Run the playbook with the tags.

Conclusion

This is a basic introduction to using Ansible to provision a development environment. It is a powerful tool that can be used to provision any environment.