Logo

Developer Blog

Anton Lijcklama à Nijeholt

Dedicated to cultivating engaging and supportive engineering cultures.

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.
bash
#!/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:

yaml
- 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.

yaml
---
- 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.

yaml
---
- 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:

bash
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)