How to automatically set up a development machine with Ansible

ansible development-tools devops popular
Using Ansible to automatically configure a development machine

Have you ever wanted to automate the tedious installation and configuration of programs that you use every day? Would you like to automate it so that you can do it again and again on multiple computers without much effort, e.g. when you get a new laptop? Would you like to update your configuration once and update all laptops and servers you use at once? Then read on as we set up a development machine together, using the automation tool Ansible.

What we will achieve

Setting up a new computer involves two main activities: installing software and configuring it. That's why I will focus on this two activities in the article. I will assume that:

I picked some packages and configuration for illustration. What we install and use on our computers vary a lot, so consider this to be just a "getting started" example.

Specifically, we will install command line tools bat for viewing files, exa for listing directories and Vim. All of them provide much nicer experience than standard tools cat, ls and vi.

We will also install Podman as (more than) an alternative Docker client and because we like Python, we will make sure the package manager Poetry is installed on the system too.

We will store and source our custom dot files (config files starting with .) from a remote Git repository. We will work with two such files: .vimrc for configuring Vim and .aliases for storing shell aliases.

In our Vim config we allow the movement of the cursor besides the last character on the line:

set virtualedit=all

Our aliases will replace ls and docker commands with our newly installed alternatives:

alias ls='exa -la'
alias docker=podman

With that we will be able to still list directories with ls, but get a nicer output:

ls replaced with Exa

To make it work we will need to move the config files from the remote repository to the correct locations like ~/.vimrc and ~/.bashrc.

Using Ansible

To make all of that happen, we will use Ansible. It is an agent-less software automation tool that executes tasks on typically remote systems using SSH connections. It is often used to provision and configure servers, but as we will see - there are other use cases. We could do the same with plain shell scripting, however there are some advantages when using Ansible. It makes it easier to get a cross-platform solution if needed, can run on multiple different machines at once without much effort and provides automatic idempotency so that actions are not performed unnecessarily.

The article assumes that we are on Fedora, but we can easily tweak it for a different system by replacing dnf package manager and dnf Ansible module with a package manager for the respective system.

Let's start by installing Ansible:

sudo dnf install ansible -y 

If you want to test the example provided but you don't want to do it directly on your system, I wrote an article Provisioning machines locally with Ansible and Vagrant where I explain how to run Ansible in a local virtual machine instead.

Ansible playbook

The basic Ansible artefact is an Ansible playbook. It is a YAML file defining a set of tasks that we want to perform. Besides the tasks themselves, a playbook can contain some additional configuration.

We will start with the following template:

- hosts: localhost
become: yes
vars:
- user: 'pstribny'
- dotfiles_repo: 'git@github.com:stribny/dotfiles.git'
- ssh_key: '.ssh/id_rsa'

tasks:
- name: "Name of the task"
ansible_module:
module_parameter:
- value
- value2
task_parameter: value

hosts: defines the set of machines where the tasks will be performed. As we are primarily interested to configure our own machine, we use localhost as the value. If we want to configure a remote machine, we can easily replace it with an IP address. There are also other options how to define what hosts to target, e.g. by specifying it on the command line when we run the Ansible playbook, or using an inventory file.

become: yes will execute the defined tasks under the root user. We could also specify become_user: user if we want to execute the tasks under a specific user on the target machine.

vars: lists our own variables that we can reuse throughout the playbook. user will be used later on to construct the path to the user's directory, dotfiles_repo points to a remote Git repository from where we will source our dot files and ssh_key is the path to an SSH key relative to the user's home directory that will grant the access to that repository. If you want to run the final playbook from the article, these are the values that you will need to replace.

The tasks: section of the playbook defines tasks. This is only an example for now, showing that every task will have a name, a set of parameters, and specify which Ansible module should perform the task. We will be using only build-in Ansible modules, so there is no need to install anything else. We can also see two types of values: a list of parameters or a single value can be provided depending on the parameter.

Installing packages

Ansible supports many package managers out of the box. We will now install the mentioned system packages for Fedora using dnf module and Python packages using pip module. The basic usage of these modules is the same, and it is also easy to change it to a different package manager. Ansible also features a package module which would be handy when creating cross-platform configuration. Let's create the tasks:

  tasks:
- name: "Install system packages"
dnf:
name:
- vim
- bat
- exa
- podman
state: latest

- name: "Install Python packages"
pip:
name:
- poetry
state: latest

We just list the packages under name: and tell Ansible what should be the resulting state:. The basic state is present which will ensure that the particular package is installed on the system, but here we specify latest that will also upgrade a package if there is a new version.

Downloading the git repository

Checking out a remote git repository can be done with git module. We need to set repo: to our remote repository, e.g. on Github, and dest: to be the place where to put the downloaded files:

  tasks:
- name: "Check out dotfiles from repository"
git:
repo: "{{ dotfiles_repo }}"
dest: ./tmp-dotfiles
accept_hostkey: yes
key_file: "/home/{{ user }}/{{ ssh_key }}"
force: yes
delegate_to: localhost
run_once: true
become: no

We can see the use of our variables in action. Ansible uses jinja2 syntax that will be familiar to Python programmers. All we need is to put our string in quotes and surround the variable itself with double curly braces, e.g. "{{ variable }}".

accept_hostkey: yes and key_file: will make sure that our local SSH key is accepted by the remote host and vice versa.

force: yes will discard any changes in the already cloned repository. This is because we only want to keep the temp directory as a copy and nothing else.

delegate_to: makes the git module to clone the git repository locally instead of cloning it on the target machine. In our case when hosts: localhost point to the same machine it might feel weird, but if we target a remote machine it would suddenly make sense. In our case we don't want to transfer SSH keys to the remote machines and clone it there, we want to use our computer to clone the repository and copy the files to remote hosts afterwards.

run_once: is here so that we could easily extend the number of machines we want to run it on. It will ensure that even if we are configuring 5 machines, the repository will be cloned just once.

Configuration

We need to place our configuration files in the correct locations. We want to place .vimrc in ~/.vimrc and include .aliases in the Bash config file ~/.bashrc. Let's start by copying both files to the home directory. This can be done via copy module. We need to specify src and dest paths and optionally can also set file ownership and permissions.

  tasks:
- name: "Copy .vimrc"
copy:
src: ./tmp-dotfiles/.vimrc
dest: "/home/{{ user }}/.vimrc"
owner: "{{ user }}"
group: "{{ user }}"
mode: '0644'

- name: "Copy .aliases"
copy:
src: ./tmp-dotfiles/.aliases
dest: "/home/{{ user }}/.aliases"
owner: "{{ user }}"
group: "{{ user }}"
mode: '0644'

The mode: '0644' will make sure that the owner {{ user }} can both read and write to the file. We could forbid ourselves writing to the file if we would like to emphasize that the configuration should be changed in the dot files repository first. I like to keep it writable since sometimes we might want to just test something before commiting it.

If we need to synchronize a directory instead, Ansible has modules for that too, e.g. synchronize module that I use for application deployment.

Now we need to solve the ~/.aliases file that doesn't do anything on its own, unlike ~/vimrc that will be picked up by Vim automatically. Instead of mixing our aliases file with .bashrc, we will include it with the following snippet:

# in ~/.bashrc
if [ -f ~/.aliases ]; then
source ~/.aliases
fi

This simple Bash script will check whether a file ~/.aliases exists and if so, executes it. To achieve that with Ansible we are going to use blockinfile module for replacing text blocks in files. If we would want to replace only one line, there is also lineinfile module.

  tasks:
- name: "Load aliases in .bashrc file"
blockinfile:
path: "/home/{{ user }}/.bashrc"
block: |
if [ -f ~/.aliases ]; then
source ~/.aliases
fi

The usage is simple, we just specify the file in question and a block of text that should be present inside it. The pipe | is called Literal Block Scalar and can be used in Ansible to specify a block of text where new lines and trailing spaces should be preserved.

The modified file won't look like we expect. Ansible will insert its own markers around the text:

# BEGIN ANSIBLE MANAGED BLOCK
if [ -f ~/.aliases ]; then
  source ~/.aliases
fi
# END ANSIBLE MANAGED BLOCK

The final playbook

The complete devenv.yml:

---
- hosts: localhost
become: yes
vars:
- user: 'pstribny'
- dotfiles_repo: 'git@github.com:stribny/dotfiles.git'
- ssh_key: '.ssh/id_rsa'

tasks:
- name: "Install system packages"
dnf:
name:
- vim
- bat
- exa
- podman
state: latest

- name: "Install Python packages"
pip:
name:
- poetry
state: latest

- name: "Check out dotfiles from repository"
git:
repo: "{{ dotfiles_repo }}"
dest: ./tmp-dotfiles
accept_hostkey: yes
force: yes
recursive: no
key_file: "/home/{{ user }}/{{ ssh_key }}"
delegate_to: localhost
become: no
run_once: true

- name: "Copy .vimrc"
copy:
src: ./tmp-dotfiles/.vimrc
dest: "/home/{{ user }}/.vimrc"
owner: "{{ user }}"
group: "{{ user }}"
mode: '0644'

- name: "Copy .aliases"
copy:
src: ./tmp-dotfiles/.aliases
dest: "/home/{{ user }}/.aliases"
owner: "{{ user }}"
group: "{{ user }}"
mode: '0644'

- name: "Load aliases in .bashrc file"
blockinfile:
path: "/home/{{ user }}/.bashrc"
block: |
if [ -f ~/.aliases ]; then
source ~/.aliases
fi

We can run it with ansible-playbook devenv.yml --ask-become-pass. The --ask-become-pass option will prompt us for the password of the become: yes user. This is typically necessary when we want to set up our own computer (hosts: localhost) and might not be required if we connect to a remote machine with an SSH key, if the SSH key is for that user.

After the playbook runs we can test that the software is installed and configured. For instance, let's see the devenv.yml file in bat:

Bat can display files with syntax highlighting on the command line

And that's all! We can now reuse and extend the Ansible playbook and save plenty of time. For instance, we could install PostgreSQL with Ansible and set up all our accounts.

Last updated on 5.2.2021.