Ansible Bootstrap

Date: August 15, 2020 Author: Brian Hooper

Tags:


TLDR

A few simple approaches for using Ansible to bootstrap your local workspace (laptop/desktop).

I have found this to be very helpful with:

  • codifying my workspace and its configuration
  • quickly provisioning new workspaces (i.e. new desktop/laptop)
  • quickly recovering from a workspace failure (i.e. hardware failure)
  • maintaining a consistent workspace configuration (i.e. individual/projects/teams)
  • improving productivity through standardization

In a previous post I walked through setting up Ansible to run in a Python Virtual Environment, which we will be using today as the baseline. At the time of this writing I am using Ubuntu 20.04 as the Linux OS for my workspaces (desktop/laptop) so the patterns and code samples will be designed to work with Ubuntu. Just note package managers, package dependencies and similar artifacts may differ if you are using another OS.

The source code for this post is available via a snippets repo hosted on GitLab.

If you’d like to clone the snippets for this post along with others from my blog:


    git clone git@gitlab.com:KnownTraveler/snippets.git

RECOMMENDATION: Use Keybase for Encrypted Git Repo

I have been using this Ansible Bootstrap solution to manage and maintain my workspaces. I personally use this approach to also manage more sensitive artifacts (.dotfiles, api keys, ssh keys, pgp keys, paper tokens, etc) in which case I recommend using Keybase to host the project as an Encrypted Git Repository.


Configuration

Here are the topics and several patterns we will be walking through today:

  • Project Structure
  • Main Task Pattern
  • Running Ansible
  • Language Patterns
    • Golang (binary)
    • Python (pyenv)
    • Ruby (rbenv)
  • Link Pattern
    • NodeJS
    • Terraform
  • Repo Pattern
    • GitLab
  • Utils Patterns
    • APT packages
    • SNAP packages

Project Structure

Ok, here is the basic project structure we are working with. We simply have an Ansible Role (base) and Playbook (playbook_bootstrap.yaml) along with a few scripts:

  • init.sh - Initializes the localhost (if needed) to install python via pyenv, ansible, and virtualenv dependencies.
  • ansible.sh - Runs the Ansible Playbook on the localhost within a Python Virtual Environment you should have the following basic structure:
    /ansible-bootstrap
    |
    |__ /roles
    |   |__ /base
    |   |   |__/files
    |   |   |__/handlers
    |   |   |__/meta
    |   |   |__/tasks
    |   |   |  |__/ubuntu
    |   |   |  |__main_ubuntu.yaml
    |   |   |  |__main.yaml
    |   |   |__/vars
    |   |__/scripts
    |   |__/venv
    |   |__ansible.sh
    |   |__init.sh
    |   |__playbook_bootstrap.yaml
    |   |__README.md
    |   |__requirements.txt

Bootstrap Playbook

The playbook_bootstrap.yaml simply specifies the localhost and calls the base role.


# BOOTSTRAP PLAYBOOK
- name: playbook_bootstrap
  hosts: localhost
  connection: local

  roles:
    - base

Main Task Pattern

Ok, let’s take a look at core pattern for organizing main tasks in the base role. This pattern is helpful if you need to support different distributions or versions across multiple workspaces. The provided flexibility using this approach allows you to scale out and support a wider variety of uses cases including entire teams of developers running different operating systems.

The primary entry point to our role is defined in (tasks/main.yaml) which simply calls Ubuntu Main Tasks (tasks/main_ubuntu.yaml) when the host is “Ubuntu”.


# UBUNTU LINUX
- include_tasks: tasks/main_ubuntu.yaml
  when: ansible_distribution == "Ubuntu"

Because I use a consistent OS across my workspaces I have all my tasks organized under tasks/ubuntu/. However, if you need to support different OS distributions this could be further modified where common tasks tasks/common are used across and OS specific tasks are grouped together:

    .
    |__/tasks
    |  |__/centos
    |  |__/common
    |  |__/debian
    |  |__/fedora
    |  |__/ubuntu
    |  |__main_centos.yaml
    |  |__main_common.yaml
    |  |__main_debian.yaml
    |  |__main_fedora.yaml
    |  |__main_ubuntu.yaml
    |  |__main.yaml

Simply modify tasks/main.yaml to look something like:


# CENTOS LINUX
- include_tasks: tasks/main_centos.yaml
  when: ansible_distribution == "CentOS"

# DEBIAN LINUX
- include_tasks: tasks/main_debian.yaml
  when: ansible_distribution == "Debian"

# FEDORA LINUX
- include_tasks: tasks/main_fedora.yaml
  when: ansible_distribution == "Fedora"

# UBUNTU LINUX
- include_tasks: tasks/main_ubuntu.yaml
  when: ansible_distribution == "Ubuntu"

# COMMON TASKS
- include_tasks: tasks/main_common.yaml

If needed you can also use conditionals to account for differences across major versions of a operating system.

For example, the value is 18 for Ubuntu 18.04.


# UBUNTU LINUX 18.04
- include_tasks: tasks/main_ubuntu_18.yaml
  when: 
    - ansible_distribution == "Ubuntu"
    - ansible_distribution_major_version == "18"

# UBUNTU LINUX 20.04
- include_tasks: tasks/main_ubuntu_20.yaml
  when: 
    - ansible_distribution == "Ubuntu"
    - ansible_distribution_major_version == "20"

Now that we have reviewed our project structure and main task pattern lets take a look at how we run Ansible to bootstrap our workspace along with some more specific types of patterns with examples that you can use to configure your workspace.


Running Ansible

As I covered in a previous post, by installing Ansible in a Python virtual environment it enables us to maintain the Ansible dependencies which are basically python packages, independent of the ones used by the Operating System. This is helpful if you need to support multiple projects with various versions of Python.

Using a wrapper script like ansible.sh we can ensure that we have a simple and consistent process for bootstrapping our workspace.

Ansible Wrapper Script


#!/usr/bin/bash

# SOURCE LIBRARY FUNCTIONS
. ./scripts/_lib.sh

banner "Ansible Script"

# INSTALL VIRTUALENV MODULE
if [ ! -f "$HOME/.pyenv/shims/virtualenv" ]
then
    header "Python Module virtualenv (venv) missing"
    header "Installing virtualenv using pip"
    pip install virtualenv
else
    header "Python Module virtualenv (venv) installed"
fi

# ACTIVATE VIRTUAL ENVIRONMENT
if [ ! -f "./venv/bin/activate" ]
then
    header "Creating Virtual Environment"
    python -m virtualenv venv

    header "Activating Virtual Environment"
    source ./venv/bin/activate
else 
    header "Activating Virtual Environment"
    source ./venv/bin/activate
fi

# INSTALL REQUIREMENTS
header "Installing Requirements"
pip install -r requirements.txt

# RUN ANSIBLE
header "Running Ansible"
ansible-playbook -i "localhost," -c local playbook_bootstrap.yaml -e "ansible_python_interpreter=<project_path>/venv/bin/python" --ask-become-pass

# DEACTIVATE VIRTUAL ENVIRONMENT
header "Deactivating Virtual Environment"
deactivate
Running Ansible Script

    # COMMAND
    ./ansible.sh

    # OUTPUT
    ========================================
    Ansible Bootstrap Script
    ========================================

    Activating Virtual Environment
    ----------------------------------------

    Installing Requirements
    ----------------------------------------

    ...
    < pip install output here >
    ...

    Running Ansible
    ----------------------------------------

    ...
    < ansible output here >
    ---

    Deactivating Virtual Environment
    ----------------------------------------

Language Patterns

There are different methods for installing and configuring a programming language in your workspace. Whether you are installing via a package manager (apt/yum), via a downloaded package (.deb/.rpm), installing via an artifact (.tar/.zip), or using a version manager.

Here are a few example patterns for installing the following languages:

  • Golang (tar.gz)
  • Python (pyenv)
  • Ruby (rbenv)

Golang Example

Here is the task pattern that I use for installing and configuring Golang.

Please note that I am managing .bash configurations in a separate task to centralize and simplify things.


# GOLANG
# ----------------------------------------

# Setting GOPATH
export GOPATH=$HOME/.go

# Setting GOROOT
export GOROOT=/usr/local/go

# Setting PATH for GOLANG BINARY
export PATH="$GOPATH/bin:$GOROOT/bin:$PATH"
Variables

The following variables are defined in vars/main.yaml

  • {{workspace}} - this is our main workspace path and is usually /home/<username>
  • {{project}} - this is our ansible .git project repository path
  • {{downloads}} - this is our main downloads path and is usually /home/<username>/Downloads
  • {{owner}} - default filesystem owner configuration this is username
  • {{group}} - default filesystem group configuration this is username
  • {{golang_version}} - the specific version of golang to install
Task

Here is the config_golang.yaml task pattern. Note that we are using when conditionals to trigger when specific actions should run depending on the presence of expected files and directories for our golang configuration:


# CHECK TO SEE IF GOPATH EXISTS
- name: (Role:{{role}}) --> Verify GOPATH Directory Exists
  stat:
    path: "{{workspace}}/.go"
  register: gopath_install
  
# CREATE OUR GOPATH PROJECT DIRECTORY
- name: (Role:{{role}}) --> Create GOPATH Directory [workspace/.go] in Workspace
  file:
    path: "{{workspace}}/.go"
    owner: "{{owner}}"
    group: "{{group}}"
    mode: 0755
    state: directory

# CHECK TO SEE IF GOPATH EXISTS
- name: (Role:{{role}}) --> Verify When Condition
  command: echo "{{workspace}}/.go" 
  when: gopath_install.stat.exists == False

# CHECK TO SEE IF GO BINARY EXISTS
- name: (Role:{{role}}) --> Verify GO Binary Directory Exists
  stat:
    path: "/usr/local/go/bin/go"
  register: golang_install

# Install GO Binary
- name: (Role:{{role}}) --> Install GO Binary at Version {{golang_version}}
  shell: |
    if [ -d "{{workspace}}/.go" ]
    then
      wget "https://golang.org/dl/go{{golang_version}}.linux-amd64.tar.gz" -O "{{downloads}}/go{{golang_version}}.linux-amd64.tar.gz"
      tar -C "/usr/local" -xzvf "{{downloads}}/go{{golang_version}}.linux-amd64.tar.gz"
      rm {{downloads}}/go{{golang_version}}.linux-amd64.tar.gz
    else
      echo "GO binary already installed at /usr/local/go"
    fi    
  become: true  
  when: golang_install.stat.exists == False

Python Example

Here is the task pattern that I use for installing and configuring Python using pyenv.

Please note that I am managing .bash configurations in a separate task to centralize and simplify things.


# PYTHON | PYENV
# ----------------------------------------

# Setting PYENV_ROOT
export PYENV_ROOT="$HOME/.pyenv"

# Setting PATH for PYENV BINS
export PATH="$PYENV_ROOT/bin:$PATH"

# Setting PATH for PYENV SHIMS
export PATH="$PYENV_ROOT/shims:$PATH"
Variables

The following variables are defined in vars/main.yaml

  • {{workspace}} - this is our main workspace path and is usually /home/<username>
  • {{project}} - this is our ansible .git project repository path
  • {{downloads}} - this is our main downloads path and is usually /home/<username>/Downloads
  • {{owner}} - default filesystem owner configuration this is username
  • {{group}} - default filesystem group configuration this is username
  • {{python_version}} - the specific version of python to install
  • {{install_pips}} - boolean (true/false) flag
Task

Here is the config_python.yaml task pattern. Note that we are using when conditionals to trigger when specific actions should run depending on the presence of expected files and directories for our python configuration:


# CHECK TO SEE IF PYENV PATH EXISTS
- name: (Role:{{role}}) --> Verify PYENV Directory Exists
  stat:
    path: "{{workspace}}/.pyenv"
  register: pyenv_install

###############################################################################
# INSTALL PYENV (Python Environment Manager)
# Note: Add ~/.pyenv/bin to your $PATH for access to the pyenv command-line utility.
# Example: echo 'export PATH="$HOME/.pyenv/bin:$PATH"' >> ~/.bashrc
- name: (Role:{{role}}) --> Install PYENV
  shell: |
    if [ ! -d "{{workspace}}/.pyenv" ]
    then
      echo "Installing PYENV"
      git clone https://github.com/pyenv/pyenv.git {{workspace}}/.pyenv
      chown -R "{{owner}}:{{group}}" "{{workspace}}/.pyenv"
    else 
      echo "PYENV is already installed at {{workspace}}/.pyenv"
    fi    
  when: pyenv_install.stat.exists == False

# CHECK TO SEE IF PYTHON VERSION EXISTS
- name: (Role:{{role}}) --> Verify Python Version is Installed via PYENV
  stat:
    path: "{{workspace}}/.pyenv/versions/{{python_version}}"
  register: python_install

# INSTALL PYTHON VERSION USING PYENV
- name: (Role:{{role}}) --> Install Python Version via PYENV
  command: "{{workspace}}/.pyenv/bin/pyenv install {{ python_version }}"
  when: python_install.stat.exists == False

# SET GLOBAL PYTHON VERSION USING PYENV
- name: (Role:{{role}}) --> Set Global Python Version via PYENV
  command: "{{workspace}}/.pyenv/bin/pyenv global {{ python_version }}"
  when: python_install.stat.exists == False

# Installs Python Module virtualenv
- name: Install PIP virtualenv
  pip:
    name: virtualenv
    version: 20.2.2
    state: present
  when: install_pips == True

Ruby Example

Here is the task pattern that I use for installing and configuring Ruby using rbenv.

Please note that I am managing .bash configurations in a separate task to centralize and simplify things.


# RUBY | RBENV
# ----------------------------------------

# Setting RBENV_ROOT
export RBENV_ROOT="$HOME/.rbenv"

# Setting Path for RBENV BINS
export PATH="$HOME/.rbenv/bin:$PATH"

# Setting Path for RBENV SHIMS
export PATH="$HOME/.rbenv/shims:$PATH"
Variables

The following variables are defined in vars/main.yaml

  • {{workspace}} - this is our main workspace path and is usually /home/<username>
  • {{project}} - this is our ansible .git project repository path
  • {{downloads}} - this is our main downloads path and is usually /home/<username>/Downloads
  • {{owner}} - default filesystem owner configuration this is username
  • {{group}} - default filesystem group configuration this is username
  • {{ruby_version}} - the specific version of ruby to install
  • {{install_gems}} - boolean (true/false) flag
Task

Here is the config_ruby.yaml task pattern. Note that we are using when conditionals to trigger when specific actions should run depending on the presence of expected files and directories for our ruby configuration:


# CREATE OUR GEM DIRECTORY
- name: (Role:{{role}}) --> Create Directory .gem in Workspace
  file:
    path: "{{workspace}}/.gem"
    owner: "{{owner}}"
    group: "{{group}}"
    mode: 0755
    state: directory

# CHECK TO SEE IF RBENV PATH EXISTS
- name: (Role:{{role}}) --> Verify RBENV Directory Exists
  stat:
    path: "{{workspace}}/.rbenv"
  register: rbenv_install

###############################################################################
# INSTALL RBENV (Ruby Environment Manager)
# Note: Add ~/.rbenv/bin to your $PATH for access to the rbenv command-line utility.
# Example: echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
- name: (Role:{{role}}) --> Install RBENV
  shell: |
    if [ ! -d "{{workspace}}/.rbenv" ]
    then
      echo "Installing RBENV"
      git clone https://github.com/rbenv/rbenv.git {{workspace}}/.rbenv
      chown -R "{{owner}}:{{group}}" "{{workspace}}/.rbenv"
    else 
      echo "RBENV is already installed at {{workspace}}/.rbenv"
    fi    
  when: rbenv_install.stat.exists == False


# CHECK TO SEE IF RBENV PLUGINS RUBY-BUILDPATH EXISTS
- name: (Role:{{role}}) --> Verify RBENV PluginsRuby-Build Directory Exists
  stat:
    path: "{{workspace}}/.rbenv/plugins/ruby-build"
  register: rubybuild_install

# INSTALL RBENV PLUGIN RUBY-BUILD
# As an rbenv plugin
# rbenv install --list  (lists all available versions of Ruby)
# rbenv install 2.2.0   (installs Ruby 2.2.0 to ~/.rbenv/versions)
- name: (Role:{{role}}) --> Install Ruby Build as an RBENV Plugin
  shell: |
    if [ ! -d "{{workspace}}/.rbenv/plugins/ruby-build" ]
    then
      echo "Installing RBENV Plugin Ruby-Build"
      mkdir -p "{{workspace}}/.rbenv/plugins"
      git clone https://github.com/rbenv/ruby-build.git "{{workspace}}/.rbenv/plugins/ruby-build"
      chown -R "{{owner}}:{{group}}" "{{workspace}}/.rbenv"
    else 
      echo "RBENV Plugin Ruby-Build is already installed at {{workspace}}/.rbenv/plugins/ruby-build"
    fi    
  when: rubybuild_install.stat.exists == False

# CHECK TO SEE IF RUBY VERSION EXISTS
- name: (Role:{{role}}) --> Verify Ruby Version is Installed via RBENV
  stat:
    path: "{{workspace}}/.rbenv/versions/{{ruby_version}}"
  register: ruby_install

# INSTALL RUBY VERSION USING RBENV
- name: (Role:{{role}}) --> Install Ruby Version via RBENV
  command: "{{workspace}}/.rbenv/bin/rbenv install {{ ruby_version }}"
  when: ruby_install.stat.exists == False

# SET GLOBAL RUBY VERSION USING RBENV
- name: (Role:{{role}}) --> Set Global Ruby Version via RBENV
  command: "{{workspace}}/.rbenv/bin/rbenv global {{ ruby_version }}"
  when: ruby_install.stat.exists == False

There are often times when you need to download and maintain multiple versions of a specific tool, where creating symbolic links is a preferred approach as you can link/unlink to different versions as needed. Here is an example pattern for using this approach with NodeJS and Terraform whereby the Task notifies a Handler to create a new Link when a new version is installed.


NodeJS Example

Please note that I am managing .bash configurations in a separate task to centralize and simplify things.


# NODE.JS | NPM | NPX
# ----------------------------------------

# Setting NODEJS_ROOT
export NODEJS_ROOT="$HOME/.nodejs"

# Setting Path for NODE.JS Binary Tools (node, npm, npx)
export PATH="$HOME/.nodejs:$PATH"
Variables

The following variables are defined in vars/main.yaml

  • {{workspace}} - this is our main workspace path and is usually /home/<username>
  • {{project}} - this is our ansible .git project repository path
  • {{downloads}} - this is our main downloads path and is usually /home/<username>/Downloads
  • {{owner}} - default filesystem owner configuration this is username
  • {{group}} - default filesystem group configuration this is username
  • {{nodejs_version}} - the specific version of nodejs to install
  • {{nodejs_distro}} - the specific distro of nodejs to install (e.g. linux-x64)
Task

Here is the config_nodejs.yaml task pattern. Note that we are using when conditionals to trigger when specific actions should run depending on the presence of expected files and directories for our nodejs configuration:


# CREATE OUR NODEJS DIRECTORY
- name: (Role:{{role}}) --> Create Directory .nodejs in Workspace
  file:
    path: "{{workspace}}/.nodejs"
    owner: "{{owner}}"
    group: "{{group}}"
    mode: 0755
    state: directory

# CHECK TO SEE IF NODE VERSION EXISTS
- name: (Role:{{role}}) --> Verify Node.js Version Exists
  stat:
    path: "{{workspace}}/.nodejs/node-v{{nodejs_version}}-{{nodejs_distro}}"
  register: nodejs_install

# INSTALL NODEJS
# Example: https://nodejs.org/dist/v14.15.4/node-v14.15.4-linux-x64.tar.xz
- name: (Role:{{role}}) --> Install NodeJS
  shell: |
    if [ ! -f "{{workspace}}/.nodejs/node-v{{nodejs_version}}-{{nodejs_distro}}" ]
    then
      echo "Installing Node.js for Ubuntu Linux"
      wget "https://nodejs.org/dist/v{{nodejs_version}}/node-v{{nodejs_version}}-{{nodejs_distro}}.tar.xz" -O "{{downloads}}/node-v{{nodejs_version}}-{{nodejs_distro}}.tar.xz"
      tar -xJvf "{{downloads}}/node-v{{nodejs_version}}-{{nodejs_distro}}.tar.xz" -C "{{workspace}}/.nodejs"
      rm "{{downloads}}/node-v{{nodejs_version}}-{{nodejs_distro}}.tar.xz"
    else 
      echo "Node.js is already installed at {{workspace}}/.nodejs/node-v{{nodejs_version}}-{{nodejs_distro}}"
    fi    
  when: nodejs_install.stat.exists == False
  notify: Link NodeJS
Handler

The Link NodeJS handler is defined in handlers/main.yaml


# Link NodeJS
# IF Prior Symlink Exists, Unlink and Link to Specified Version
# ELSE, Link to Specified Version
- name: Link NodeJS
  shell: |
    if [ -f "{{workspace}}/.nodejs/node-v{{nodejs_version}}-{{nodejs_distro}}" ]
    then
      echo "Unlinking old Node.js Version for Ubuntu Linux"
      unlink "{{workspace}}/.nodejs/node"
      unlink "{{workspace}}/.nodejs/npm"
      unlink "{{workspace}}/.nodejs/npx"
      echo "Linking new Node.js Version {{nodejs_version}} for Ubuntu Linux"
      ln -s "{{workspace}}/.nodejs/node-v{{nodejs_version}}-{{nodejs_distro}}/bin/node" "{{workspace}}/.nodejs/node"
      ln -s "{{workspace}}/.nodejs/node-v{{nodejs_version}}-{{nodejs_distro}}/lib/node_modules/npm/bin/npm-cli.js" "{{workspace}}/.nodejs/npm"
      ln -s "{{workspace}}/.nodejs/node-v{{nodejs_version}}-{{nodejs_distro}}/lib/node_modules/npm/bin/npx-cli.js" "{{workspace}}/.nodejs/npx"
    else 
      echo "Linking new Node.js Version {{nodejs_version}} for Ubuntu Linux"
      ln -s "{{workspace}}/.nodejs/node-v{{nodejs_version}}-{{nodejs_distro}}/bin/node" "{{workspace}}/.nodejs/node"
      ln -s "{{workspace}}/.nodejs/node-v{{nodejs_version}}-{{nodejs_distro}}/lib/node_modules/npm/bin/npm-cli.js" "{{workspace}}/.nodejs/npm"
      ln -s "{{workspace}}/.nodejs/node-v{{nodejs_version}}-{{nodejs_distro}}/lib/node_modules/npm/bin/npx-cli.js" "{{workspace}}/.nodejs/npx"
    fi    

Terraform Example

Please note that I am managing .bash configurations in a separate task to centralize and simplify things.


# TERRAFORM
# ----------------------------------------

# Setting TERRAFORM_ROOT
export TERRAFORM_ROOT="$HOME/.terraform"

# Setting Path for TERRAFORM Binary
export PATH="$HOME/.terraform:$PATH"
Variables

The following variables are defined in vars/main.yaml

  • {{workspace}} - this is our main workspace path and is usually /home/<username>
  • {{project}} - this is our ansible .git project repository path
  • {{downloads}} - this is our main downloads path and is usually /home/<username>/Downloads
  • {{owner}} - default filesystem owner configuration this is username
  • {{group}} - default filesystem group configuration this is username
  • {{terraform_version}} - the specific version of terraform to install
Task

Here is the config_terraform.yaml task pattern. Note that we are using when conditionals to trigger when specific actions should run depending on the presence of expected files and directories for our terraform configuration:


# CREATE OUR TERRAFORM DIRECTORY
- name: (Role:{{role}}) --> Create Directory .terraform in Workspace
  file:
    path: "{{workspace}}/.terraform"
    owner: "{{owner}}"
    group: "{{group}}"
    mode: 0755
    state: directory

# CHECK TO SEE IF TERRAFORM BINARY EXISTS
- name: (Role:{{role}}) --> Verify TERRAFORM Binary Exists
  stat:
    path: "{{workspace}}/.terraform/terraform_{{terraform_version}}"
  register: terraform_install

# INSTALL TERRAFORM
# Example: https://releases.hashicorp.com/terraform/0.14.2/terraform_0.14.2_linux_amd64.zip
- name: (Role:{{role}}) --> Install Terraform
  shell: |
    if [ ! -f "{{workspace}}/.terraform/terraform_{{terraform_version}}" ]
    then
      echo "Installing Terraform for Ubuntu Linux"
      wget https://releases.hashicorp.com/terraform/{{terraform_version}}/terraform_{{terraform_version}}_linux_amd64.zip -O "{{downloads}}/terraform_{{terraform_version}}_linux_amd64.zip"
      unzip "{{downloads}}/terraform_{{terraform_version}}_linux_amd64.zip" -d "{{downloads}}"
      cp "{{downloads}}/terraform" "{{workspace}}/.terraform/terraform_{{terraform_version}}"
      rm "{{downloads}}/terraform_{{terraform_version}}_linux_amd64.zip"
      rm "{{downloads}}/terraform"
    else 
      echo "Terraform is already installed at {{workspace}}/.terraform/terraform_{{terraform_version}}"
    fi    
  when: terraform_install.stat.exists == False
  notify: Link Terraform
Handler

The Link Terraform handler is defined in handlers/main.yaml


# Link Terraform
# IF Prior Symlink Exists, Unlink and Link to Specified Version
# ELSE, Link to Specified Version
- name: Link Terraform
  shell: |
    if [ -f "{{workspace}}/.terraform/terraform" ]
    then
      echo "Unlinking old Terraform Version for Ubuntu Linux"
      unlink "{{workspace}}/.terraform/terraform"
      echo "Linking new Terraform Version {{terraform_version}} for Ubuntu Linux"
      ln -s "{{workspace}}/.terraform/terraform_{{terraform_version}}" "{{workspace}}/.terraform/terraform"
    else 
      echo "Linking new Terraform Version {{terraform_version}} for Ubuntu Linux"
      ln -s "{{workspace}}/.terraform/terraform_{{terraform_version}}" "{{workspace}}/.terraform/terraform"
    fi    

Repo Pattern

This pattern is helpful when you have a set of git repositories that you need to configure on your workspace, especially when helping to onboard new members to a project or product team. This basic patterns works well for any git repository. (e.g. BitBucket, GitHub, GitLab, etc)


GitLab Example

Variables

The following variables are defined in vars/main.yaml


gitlab_root: "/home/<username>/GitLab"

gitlab_dirs:
- { path: "/home/<username>/GitLab", owner: "<username>", group: "<username>" }
- { path: "/home/<username>/GitLab/<namespace_one>", owner: "<username>", group: "<username>" }
- { path: "/home/<username>/GitLab/<namespace_two>", owner: "<username>", group: "<username>" }

gitlab_repos:
# Namespace One ----------------------------------------
- { name: "projectA", source: "git@gitlab.com:NamespaceOne/projectA.git", target: "/home/<username>/GitLab/<namespace_one>/projectA" }
- { name: "projectB", source: "git@gitlab.com:NamespaceOne/projectB.git", target: "/home/<username>/GitLab/<namespace_one>/projectB" }
- { name: "projectC", source: "git@gitlab.com:NamespaceOne/projectC.git", target: "/home/<username>/GitLab/<namespace_one>/projectC" }
# Namespace Two ----------------------------------------
- { name: "projectX", source: "git@gitlab.com:NamespaceOne/projectX.git", target: "/home/<username>/GitLab/<namespace_one>/projectX" }
- { name: "projectY", source: "git@gitlab.com:NamespaceOne/projectY.git", target: "/home/<username>/GitLab/<namespace_one>/projectY" }
- { name: "projectZ", source: "git@gitlab.com:NamespaceOne/projectZ.git", target: "/home/<username>/GitLab/<namespace_one>/projectZ" }
Task

Here is the config_gitlab.yaml task pattern. Note that we are using loops along with when conditionals to trigger when specific actions should run depending on the presence of expected files and directories for our gitlab repo configuration:


- debug:
    msg: "Configure GitLab for {{ ansible_distribution }}"

# CREATE GITLAB DIRECTORY
- name: Create GitLab Directory
  file:
    path: '{{ item.path }}'
    owner: '{{ item.owner }}'
    group: '{{ item.group }}'
    mode: 0755
    state: directory
  loop: "{{ gitlab_dirs }}"

# VERIFY GITLAB REPOSITORIES
- name: Verify GitLab Repositories
  stat:
    path: "{{ item.target }}"
  loop: "{{ gitlab_repos }}"
  register: gitlab_repos_list

# CREATE GITLAB REPOSITORIES
- name: Create GitLab Repositories
  shell: |
    if [ ! -d "{{ item.item.target }}" ]
    then
      echo "Cloning GitLab Repository {{ item.item.name }}"
      git clone {{ item.item.source }} {{ item.item.target }}
    else 
      echo "GitLab Repository {{ item.item.name }} already exists"
    fi    
  loop: "{{ gitlab_repos_list.results }}"
  when: item.stat.exists == False

Utils Pattern

This pattern is helpful when you have a set of utilities that you need to configure on your workspace via package managers like apt and snap that require custom options. This basic patterns works well for using any package manager to install custom utilities.

Because we are running Ansible in a Virtual Environment (venv) this approach appears to work better as you don’t have to override the ansible_python_interpreter to use the python version outside of the virtual environment when attempting to use built-in ansible resources like apt.


APT & SNAP Example

Variables

The following variables are defined in vars/main.yaml


install_apt_utils: True
apt_utils:
- { name: "curl", target: "/usr/bin/curl", options: "-y" }
- { name: "gparted", target: "/usr/sbin/gparted", options: "-y" }
- { name: "jq", target: "/usr/bin/jq", options: "-y" }
- { name: "net-tools", target: "/usr/sbin/ifconfig", options: "-y" }
- { name: "nmap", target: "/usr/bin/nmap", options: "-y" }
- { name: "traceroute", target: "/usr/sbin/traceroute", options: "-y" }
- { name: "wget", target: "/usr/bin/wget", options: "-y" }
- { name: "whois", target: "/usr/bin/whois", options: "-y" }

install_snap_utils: True
snap_utils:
- { name: "atom", target: "/snap/bin/atom", options: "--classic" }
- { name: "brave", target: "/snap/bin/brave", options: "" }
- { name: "gimp", target: "/snap/bin/gimp", options: "" }
- { name: "remmina", target: "/snap/bin/remmina", options: "" }
- { name: "slack", target: "/snap/bin/slack", options: "--classic" }
Task

Here is the config_utils.yaml task pattern. Note that we are using loops along with when conditionals to trigger when specific actions should run for installing apt or snap packages depending on the presence of expected files:


# VERIFY COMMAND LINE UTILITIES (APT)
- name: Verify APT Command Line Utilities
  stat:
    path: "{{ item.target }}"
  loop: "{{ apt_utils }}"
  register: apt_utils
  when: install_apt_utils == True

# INSTALL COMMAND LINE UTILITIES
- name: (Role:{{role}}) --> Install Command Line Utilities via APT
  become: True
  shell: |
    if [ ! -f "{{ item.item.target }}" ]
    then
      echo "Command line utility {{ item.item.name }} is not installed at {{ item.item.target }}"
      echo "Installing {{ item.item.name }}"
      apt install {{ item.item.name }} {{ item.item.options}}
    else 
      echo "Command line utility {{ item.item.name }} is already installed at {{ item.item.target }}"
    fi    
  loop: "{{ apt_utils.results }}"
  when:
    - install_apt_utils == True
    - item.stat.exists == False

# VERIFY COMMAND LINE UTILITIES (SNAP)
- name: Verify SNAP Command Line Utilities
  stat:
    path: "{{ item.target }}"
  loop: "{{ snap_utils }}"
  register: snap_utils
  when: install_snap_utils == True

# INSTALL COMMAND LINE UTILITIES (SNAP)
- name: (Role:{{role}}) --> Install Command Line Utilities via SNAP
  become: True
  shell: |
    if [ ! -f "{{ item.item.target }}" ]
    then
      echo "Command line utility {{ item.item.name }} is not installed at {{ item.item.target }}"
      echo "Installing {{ item.item.name }}"
      snap install {{ item.item.name }} {{ item.item.options}}
    else 
      echo "Command line utility {{ item.item.name }} is already installed at {{ item.item.target }}"
    fi    
  loop: "{{ snap_utils.results }}"
  when:
    - install_snap_utils == True
    - item.stat.exists == False

Next

I have been using this Ansible Bootstrap solution to manage and maintain my workspaces. The source code for this post is available via a snippets repo hosted on GitLab with additional examples of configuration tasks:

  • config_aws
  • config_bash
  • config_bitbucket
  • config_docker
  • config_git
  • config_github
  • config_gitlab
  • config_golang
  • config_hugo
  • config_keybase
  • config_nodejs
  • config_obs
  • config_postman
  • config_ruby
  • config_terraform
  • config_utils

RECOMMENDATION: Use Keybase for Encrypted Git Repo

I personally use this approach to also manage more sensitive artifacts (.dotfiles, api keys, ssh keys, pgp keys, paper tokens, etc) in which case I recommend using Keybase to host the project as an Encrypted Git Repository. More to come!


Resources


© Traveler Theme 2023
Let's Stay Connected

Handcrafted by a @KnownTraveler