Dotfiles are good for you
Setup a development environment with Ansible
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
- 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
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:
- Check if
~/.tmux.conf
exists. - Remove existing
~/.tmux.conf
symlink if it exists. - Backup
~/.tmux.conf
if it exists and is not a symlink. - 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.