
My Portable Dev Setup With Ansible
Introduction #
As a consultant, I often find myself working on laptops provided by clients.
While the initial excitement of a new laptop is short-lived, the pain of setting
it up lingers for much longer. To speed-up the process, I’ve replaced my
install.sh
with an Ansible playbook.
Naive approach #
Initially, I pragmatically started out with a simple install.sh
script. However,
this approach has a few drawbacks:
- Lack of Automation: Every step requires manual input. Although I thought this would be handy for selectively installing desired components, it quickly became cumbersome.
- Lack of Idempotency: The changes to
~/.zshrc
will be appended repeatedly, when executed more than once. - Absence of Feedback: The script provides no feedback on whether things have changed after running it.
#!/bin/sh
DIR_NAME=$(dirname "$0")
ABS_DIR_NAME=$(realpath "$DIR_NAME")
echo "Do you want to symlink nvim to ~/.config/nvim?"
select yn in "Yes" "No"; do
case $yn in
Yes ) mkdir -p ~/.config && ln -s $ABS_DIR_NAME/nvim ~/.config/nvim; break;;
No ) break;;
esac
done
echo "Do you want to symlink .wezterm.lua to ~/.wezterm.lua?"
select yn in "Yes" "No"; do
case $yn in
Yes ) ln -s $ABS_DIR_NAME/wezterm/.wezterm.lua ~/.wezterm.lua; break;;
No ) break;;
esac
done
echo "Do you want to install brew packages?"
select yn in "Yes" "No"; do
case $yn in
Yes ) brew install $(<$ABS_DIR_NAME/brew_packages.txt); break;;
No ) break;;
esac
done
echo "Do you want to enable fzf in zsh?"
select yn in "Yes" "No"; do
case $yn in
Yes )
echo "" >> ~/.zshrc
echo "# Set up fzf key bindings and fuzzy completion" >> ~/.zshrc
echo "source <(fzf --zsh)" >> ~/.zshrc
echo "" >> ~/.zshrc
break;;
No ) break;;
esac
done
echo "Do you want to install the latest node?"
select yn in "Yes" "No"; do
case $yn in
Yes ) nvm install --lts; break;;
No) break;;
esac
done
Exploring Alternatives to Ansible #
There are numerous alternatives to Ansible, ranging from other
infastructure-as-code
software like Terraform and
OpenTofu, to utilities aligned with the philosophy of
dotfiles such as
Mackup and chezmoi.
All of these could be excellent options for setting up your development machine.
However, I chose Ansible for the following reasons:
- Familiarity: I’m already well-versed in Ansible.
- Simplicity: I don’t need to rely on yet another piece of software.
- Privacy: I don’t require all my configuration files to be hosted on github.com.
- Control: As I’m dealing with potentially sensitive data, I prefer a tool that allows me to manually specify what needs to be done.
Automation with Ansible #
Instead of the tedious task of manually setting up my laptop every single time, let’s automate the boring stuff using Ansible. Ansible offers several advantages:
- Longevity: Ansible has been around since February 2012, indicating its stability and long-term viability.
- Package Management: It supports installing Homebrew packages.
- Configuration Management: It provides the ability to modify configurations in existing files.
- Secret Handling: It supports dealing with secrets through integrations with 1Password, LastPass, and other password managers.
However, Ansible also has some drawbacks:
- Learning Curve: Ansible has its own syntax and concepts, which may require some learning and familiarization.
- Complexity: As the number of tasks and configurations grows, Ansible playbooks can become complex and harder to maintain.
- Overhead: Setting up and managing Ansible adds an additional layer of complexity to the setup process.
Design Principles #
To address my requirements, I’ve established three key design principles:
- Keep it Simple: I don’t want an overly complex mechanism to manage my machines.
- Allow Multiple Profiles: My personal laptop should have a different configuration compared to my client’s laptop.
- Shared Core: The different profiles should have a shared core, defining the applications I use everywhere (e.g., WezTerm, Neovim, etc.).
Shared Core #
Ansible allows defining a set of tasks in a file and importing them into a
playbook. We can define all our common tasks in a file named
bootstrap-tasks-base.yml
. A handy feature of Ansible is that it can deal with
different CPU types. If you own ARM-based systems and Intel-based systems, you
can use the ansible_machine
variable to change the behaviour of a specific
step. For example, Brew is installed in a different location
depending on the machine’s CPU architecture:
- set_fact: homebrew_dir="{{ (ansible_machine == 'arm64') | ternary('/opt/homebrew', '/usr/local') }}"
- name: Ensure zsh is installed
command: sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
args:
creates: /bin/zsh
- name: Ensure Homebrew is installed
command: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
args:
creates: '{{ homebrew_dir }}/bin/brew'
- name: Ensures packages are present via homebrew
community.general.homebrew:
name: '{{ item }}'
path: '/Applications'
state: present
with_items:
- wezterm
- neovim
- font-fira-code-nerd-font
- font-hack-nerd-font
- htop
- fzf
# Anything else you use on all your machines
- name: Set .zprofile settings
ansible.builtin.blockinfile:
path: ~/.zprofile
create: true # creates the file if it doesn't exist
append_newline: true
prepend_newline: true
marker_begin: BEGIN zprofile
marker_end: END zprofile
block: |
source ~/.profile
- name: Set fzf settings
ansible.builtin.blockinfile:
path: ~/.profile
create: true # creates the file if it doesn't exist
append_newline: true
prepend_newline: true
marker_begin: BEGIN fzf
marker_end: END fzf
block: |
# Set up fzf key bindings and fuzzy completion
source <(fzf --zsh)
export FZF_DEFAULT_OPTS='--height 50% --layout=reverse --border=horizontal --padding=1% --info=inline-right --color=bw --scrollbar=┃'
- name: Create symlinks
ansible.builtin.file:
src: '{{ item.src }}'
dest: '{{ item.dest }}'
state: link
with_items:
- src: '{{ playbook_dir }}/nvim'
dest: ~/.config/nvim
- src: '{{ playbook_dir }}/wezterm/.wezterm.lua'
dest: ~/.wezterm.lua
Personal Machine Configuration #
After defining our set of common tasks, we can include them in a playbook named
bootstrap-personal-machine.yml
to configure my personal machine.
---
- name: Setup My Developer Machine
hosts: localhost
tasks:
- name: Base setup
ansible.builtin.import_tasks:
file: bootstrap-tasks-base.yml
We can then extend this setup with all the configuration and tools that need to be specific for this machine.
---
- name: Setup My Developer Machine
hosts: localhost
tasks:
- name: Base setup
ansible.builtin.import_tasks:
file: bootstrap-tasks-base.yml
- name: Ensures packages are present via homebrew
community.general.homebrew_cask:
name: '{{ item }}'
path: '/Applications'
greedy: true
state: present
with_items:
- homebrew/cask/cmake
- name: Ensures packages are present via homebrew
community.general.homebrew:
name: '{{ item }}'
path: '/Applications'
state: present
with_items:
- prettier
- nvm
- temurin@21
- coursier
# Anything else you use on all your machines
- name: Set nvm settings
ansible.builtin.blockinfile:
path: ~/.profile
append_newline: true
prepend_newline: true
marker_begin: BEGIN nvm
marker_end: END nvm
block: |
export NVM_DIR="$HOME/.nvm"
[ -s "{{ homebrew_dir }}/opt/nvm/nvm.sh" ] && \. "{{ homebrew_dir }}/opt/nvm/nvm.sh" # This loads nvm
[ -s "{{ homebrew_dir }}/opt/nvm/etc/bash_completion.d/nvm" ] && \. "{{ homebrew_dir }}/opt/nvm/etc/bash_completion.d/nvm" # This loads nvm bash_completion
- name: Install node versions
ansible.builtin.shell: |
source "$HOME/.profile"
source "$NVM_DIR/nvm.sh"
nvm install {{ item }}
args:
executable: /bin/bash
register: ret
changed_when:
- ("Downloading and installing node v" + item + "..." in ret.stdout)
with_items:
- 20.14.0
# Add as many versions as you want
Rolling Out the Changes #
The last thing we need to do is to run the following command: ansible-playbook bootstrap-personal-machine.yml
. This will bootstrap our machine according to
the file we specified. Keep in mind the log below shows everything is “ok”, as
everything is already up-to-date on my side:
PLAY [Setup My Developer Machine]
TASK [Gathering Facts]
ok: [localhost]
TASK [Ensure zsh is installed]
ok: [localhost]
TASK [Ensure Homebrew is installed]
ok: [localhost]
TASK [Ensures packages are present via homebrew]
ok: [localhost] => (item=wezterm)
ok: [localhost] => (item=neovim)
ok: [localhost] => (item=font-fira-code-nerd-font)
ok: [localhost] => (item=font-hack-nerd-font)
ok: [localhost] => (item=htop)
ok: [localhost] => (item=fzf)
TASK [Set fzf settings]
ok: [localhost]
TASK [Create symlinks]
ok: [localhost] => (item={'src': '<source file>', 'dest': '~/.config/nvim'})
ok: [localhost] => (item={'src': '<source file>', 'dest': '~/.wezterm.lua'})
TASK [Ensures packages are present via homebrew]
ok: [localhost] => (item=homebrew/cask/cmake)
TASK [Ensures packages are present via homebrew]
ok: [localhost] => (item=prettier)
ok: [localhost] => (item=nvm)
ok: [localhost] => (item=temurin@21)
ok: [localhost] => (item=coursier)
TASK [Install node versions]
ok: [localhost] => (item=20.14.0)
PLAY RECAP
localhost : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Conclusion #
By leveraging Ansible, we define profiles with a shared core that we roll out on different laptops. These profiles can be iteratively fine-tuned as needed to update, remove, or install new tools and configurations. While Ansible provides a powerful and flexible solution for automating laptop setup, it’s important to consider the learning curve, complexity, and overhead associated with it. Nonetheless, the benefits of automation and consistency make Ansible a valuable tool for streamlining the setup process across multiple machines.
Posted on Jun 6, 2024 (updated on Aug 10, 2024)