Just few days ago, I accidentally messed up my Ubuntu workstation, it was a silly mistake. It’s lucky that I have a setup script to install everything. But it was messy, hard to maintain, and full of litte tricks to check if file exists or if the file has a particular line of text so that I don’t append the same configuration value twice. Since I’m learning Ansible, I decided to “rewrite” that horrible bash script as an Ansible’s playbook so that the next time when I mess up (and I will), I can have something more reliable to use.

This blog post won’t cover the basic concepts of Ansible (I’m an Ansible beginner myself so can’t help you there :D). Anyway, the main idea is to have a simple playbook that I can run ansible-playbook local.yml to provision my local machine.

My playbook is pretty simple:

- hosts: localhost
  become: true

  vars:
  tasks:
  • vars is for setting up variables that I will re-use in different tasks
  • tasks is… a list of tasks

Since I’m setting up a brand new machine, it usually involves a lot of sudo magic. So, the hard requirement is that the user running ansible must be able to use sudo without a password. One way to achieve this is to run sudo visudo and then add username ALL=(ALL) NOPASSWD: ALL at the end of the file.

Add new APT repositories

The first thing that I usually do when setting up a new machine is to add some custom repositories. The normal flow for this is to first add the official GPG key and then set up the repository. For example, to add the docker repository

curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -
sudo add-apt-repository \
   "deb [arch=amd64 trusted=yes] https://download.docker.com/linux/debian \
   $(lsb_release -cs) \
   stable"

This process can be translated into 2 tasks in an Ansible playbook

tasks:
  - name: add docker key
    apt_key:
      url: "https://download.docker.com/linux/ubuntu/gpg"
      state: present
  
  - name: add docker repo
    apt_repository:
      repo: "deb [arch=amd64 trusted=yes] https://download.docker.com/linux/ubuntu focal stable"
      filename: "docker"
      state: present

The first task is straightforward, apt_key module handles apt-key add command. The second task is a bit more interesting

  • Instead of getting the lsb release (via $(lsb_release -cs)), I specifically set focal (aka Ubuntu 20.04) because I’m using Mint instead of Ubuntu and it has a different code name and the official docker repo unfortunately doesn’t support it. I will need to spend some time thinking about a better and more portable approach.
  • Instead of relying on Ansible to generate the repo file name (to be stored at /etc/apt/sources.list.d/), I find out that having a fixed name is better because otherwise you will end up with 2 different files (at some point) defining the same repo and apt doesn’t like it.

I need more than 1 repos, technically speaking I can just copy/paste those 2 tasks and replace them with proper content, but fortunately Ansible has support for with_items, think of it as map

tasks:
  - name: add necessary keys
    apt_key:
      url: "{{ item }}"
      state: present
    loop:
      - https://download.docker.com/linux/ubuntu/gpg
      - https://packages.microsoft.com/keys/microsoft.asc
      - https://dl.google.com/linux/linux_signing_key.pub

  - name: add repositories
    apt_repository:
      repo: "{{ item.repo }}"
      filename: "{{ item.filename }}"
      state: present
      update_cache: false
    with_items:
      - { repo: "deb [arch=amd64 trusted=yes] https://download.docker.com/linux/ubuntu focal stable", filename: "docker" }
      - { repo: "deb [arch=amd64 trusted=yes] https://packages.microsoft.com/repos/vscode stable main", filename: "vscode" }
      - { repo: "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main", filename: "chrome" }
      - { repo: "deb [arch=amd64 trusted=yes] http://ppa.launchpad.net/longsleep/golang-backports/ubuntu focal main", filename: "golang" }
      - { repo: "deb [arch=amd64 trusted=yes] http://ppa.launchpad.net/bamboo-engine/ibus-bamboo/ubuntu focal main", filename: "ibus_bamboo" }

There is also a simpler loop, for example, here is a task to install multiple packages

    - name: install basic packages
      apt: 
        name: "{{ item }}"
        state: present
      loop:
        - curl
        - htop

Config templates

Ansible comes with template module which we can use to set up a file in the host with a template. For example, here I am setting up my gitconfig with a template

- hosts: localhost
  become: true

  vars:
    user: "{{ ansible_facts.env.SUDO_USER }}"
    full_name: "Tan Nguyen"
  tasks:
    - name: set git config
      become_user: "{{ user }}"
      template:
        src: gitconfig
        dest: "/home/{{ user }}/.gitconfig"

and the template looks like this

[user]
	email = {{ email }}
	name = {{ full_name }}
[core]
	editor = vim
[pull]
	rebase = true

It’s self-explanatory, we define several variables in vars and the template pick them up automatically. One thing to note here is the use of ansible_facts.env.SUDO_USER which is one of the special variables, it basically refers to the user who is running ansible.

Deal with secrets

It’s common (at least for me) to store secrets in various config files, for example the token to access my private gitlab repositories. Since I’m keeping everything in git, having secrets in plain text is dangerous and wrong in so many levels.

Fortunately, ansible comes with ansible-vault which can be use to do various kind of encryption so that secrets can be safely stored in a version-control system such as git. For my simple setup, I just need an encrypted string

ansible-vault encrypt_string --ask-vault-pass <secret>
New Vault password: 
Confirm New Vault password: 
!vault |
          $ANSIBLE_VAULT;1.1;AES256
          35613063313131336431626636393066386536623462646166646431366130363430616266353734
          3031363038373637396630663863653434353937656332310a386664343431656231353430633664
          37383939326163333630353933343330336532633337323731386266376537373666313135633336
          6132386438373533350a613130306233336134646236393434323137646565336261613864386339
          3564
Encryption successful

And to use it, simply replace the actual value with it, for example

- hosts: localhost
  become: true

  vars:
    user: "{{ ansible_facts.env.SUDO_USER }}"
    full_name: "Tan Nguyen"
    email: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          34363132333730623833383965373463663734653537346330613435633562653831303563313463
          3266373363663730333265626239616463623339373135380a616337396639306432313164636462
          66356361393438376361653834323862646139376639333163653961303761643937356336643035
          6637356262326238370a383438386463303734343034386134656164356263336333633033383830
          33613530623761353037666237356463393231666535313030386463343333366534
    gitlab_username: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          39356137383338393432313634396134366134663531636230363264363233656564626538663430
          6538326537323930633135323638333564363539393839630a643433633333343837323438323364
          61623232363730373938353532373538623039323539666338613430663432633362363333316236
          6562316461333636300a383461623162326631636162386339613434393764313838663065633737
          3238
    gitlab_token: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          62353563326233316362643734346163623432346238323665396134326665346263613932643333
          3339613035386635613039383064356361643365353530340a636166646461336462333834373630
          35623361666437383161306565666162393264313731653434343164313064313739363165363730
          6335646335633338350a356232396662613463383331393930356430366236626535323137303535
          65356465353965336165323737386331396263616435323461333534613136356238
  tasks:
    - name: set git config
      become_user: "{{ user }}"
      template:
        src: gitconfig
        dest: "/home/{{ user }}/.gitconfig"

and refer to them as you would normally do in the template

[user]
	email = {{ email }}
	name = {{ full_name }}
[core]
	editor = vim
[pull]
	rebase = true
[merge]
	tool = meld
[url "https://{{ gitlab_username }}:{{ gitlab_token }}@gitlab.com"]
	insteadOf = https://gitlab.com

Now whenever you want to run the playbook, you need to have specify --ask-vault-pass OR follow one of the many setups here. For the sake of simplicity, I just use one password for everything and store it somewhere safe (for example a stickit note in my closet :D)

And that’s it! It’s everything I need for my simple Ansible playbook to set up a fresh Ubuntu-based distro. You can check out the full (real) playbook here