Managing .dotfiles with SaltStack

I’m not writing this to convince you that managing your dotfiles is a good idea, nor shall I preach that SaltStack is the best way to do it — I simply want to share that this is how I do it and that I think it’s pretty sweet!

So a little about me: I write code. I write code at home. I write code at work. I write code when I’m travelling. I even write code on Christmas Day. So it’s safe to say; I switch machines, a lot. Managing my configuration across them used to be a pain. I’ve tried KitchenPlan, I’ve tried Homesick and I’ve even written my own bash scripts. No matter what approach, I always end up with local changes on each of my machines and refuse to even try and merge them … that was, until I started using SaltStack.

Disclaimer: I’ve been using SaltStack for production infrastructure for almost two years. I’m extremely biased and happily so :)

SaltStack is a remote execution and provisioning tool … yes, like Ansible — only SaltStack’s reactor and scalability make it much better, in my humble opinion — another article on that very soon when I discuss my continuous delivery pipeline.

Screw The Commentary, Just Show Me The Code

If you just want to look for yourself and don’t need the extra fluff, here you go!

Lets Get Started

The first script in the codebase in the bash entry script. It’s super simple and only does three basic things.

  1. Are we on OSX and do we need homebrew? It will install it for you.
  2. Do we need to install SaltStack? Boom. Done for you also.
  3. Run a SaltStack highstate. “Highstate” is the described state we require our machine in: packages, configuration, etc.
#!/bin/bash
##
# Are we on OSX and do we need homebrew?
if [[ `uname` == ‘Darwin’ ]];
then
command -v brew > /dev/null 2>&1
if [ $? -ne 0 ];
then
echo “Homebrew not found. Installing …”
ruby -e “$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
fi
# Do we need to install SaltStack?
command -v salt-call > /dev/null 2>&1
if [ $? -ne 0 ];
then
echo “SaltStack not found. Installing …”
brew install saltstack
fi
else
# Linux (Hopefully …): SaltStack Bootstrap one-liner
curl -L https://bootstrap.saltstack.com | sudo sh -s — git develop
fi
DIR=$(pwd)
HOMEDIR=$HOME
USERNAME=$(whoami)
# Set the user, home-directory, and state root
sudo salt-call — local — file-root=$DIR/states/ — output=highstate — state-output=changes — log-level=quiet grains.setval username $USERNAME
sudo salt-call — local — file-root=$DIR/states/ — output=highstate — state-output=changes — log-level=quiet grains.setval homedir $HOMEDIR
sudo salt-call — local — file-root=$DIR/states/ — output=highstate — state-output=changes — log-level=quiet grains.setval stateroot $DIR/states
# Apply the high state
sudo salt-call — local — file-root=$DIR/states/ — output=highstate — state-output=changes — log-level=quiet state.highstate

Digging into the Highstate

So how do we describe this state? Through state files, of course. A state file is a very small YAML file that tells SaltStack what we want it to do. SaltStack determines the highstate you require from the Top file. The top file is simply a file called “top.sls” that determines what states to run based on grains. A grain is a bit of meta data about the machines you’re running the states on: CPU, OS, RAM, etc.

Lets start with the top file:

# Mine is super basic
base:
‘*’:
— setup
— tmux
- vim
— git
— zsh
— composer
  ’G@os:MacOS’:
— osx

We match on a wildcard first, ‘*’ and list a bunch of states to run on all machines. Then I need to get slightly more specific and run a state only on OSX machines, aptly named: osx

G@os:MacOS

Lets break this down:

  1. G — Match on a grain
  2. os — Which grain to match on
  3. MacOS — The value we need

There are various different techniques to match — if you have more complicated needs, take a look at the documentation.

Now that SaltStack knows what states to run on my machine, we can take a look at a couple of examples:

packages: # Name of this command
pkg.installed: # SaltStack module / state to use
- pkgs: # Arguments we need to pass in
- zsh
- tmux

In the above example, we’re looking to install some packages. SaltStack provides many states we can utilise, from managing packages to sending Slack notifications … and even booting EC2 instances!

Highlights of what SaltStack can automate for you and a full list at the following link:

I’m not going to run through all of the states in my dotfiles, the YAML format and function exposing state names kind of make it really easy to understand what they’re each doing. I will, however, explain a few of the awesome features of state files:

Jinja Parsing

SaltStack is written in Python and due to that — we can utilise Jinja. In the bash script above, you’ll notice I set a few variables. This is so I can access them inside the states. This git configuration state is a good example of accessing my home directory, state root and username within a state.

git-config:
file.symlink:
— name: {{ grains.homedir }}/.gitconfig
— target: {{ grains.stateroot }}/git/config
— user: {{ grains.username }}
— force: True

It’s also worth noting that these variables can then be used inside any file / templating things we want to do also … like dynamic zshrc files — just add “template: jinja”, like below, to tell SaltStack we want to interpolate the files.

Tip: You can use all Jinja syntax, even {% if .. %}

State

zsh-zshrc:
file.managed:
— name: {{ grains.homedir }}/.zshrc
— source: {{ grains.stateroot }}/zsh/zshrc
— user: {{ grains.username }}
— template: jinja

zshrc Template

# Load antigen
source {{ grains.homedir }}/.dotfiles/zsh/antigen/antigen.zsh
source {{ grains.homedir }}/.zshrc.user
BASE16_SHELL=”{{ grains.homedir }}/.dotfiles/base16-shell/base16-default.dark.sh”
[[ -s $BASE16_SHELL ]] && source $BASE16_SHELL

Prerequisites & Other Awesome Sauce

SaltStack allows us to continue the YAML simplicity awesomeness and keep us away from getting Jinja crazy, by providing us with some extra helpers:

require, unless, watch, onfail, and more!

The state below will only run if the grep returns a non-zero exit code and we also specify a dependency on another state: packages, defined in setup.sls

allow-zsh:
cmd.run:
— name: echo /usr/local/bin/zsh | sudo tee -a /etc/shells
— unless: grep “/usr/local/bin/zsh” /etc/shells
— require:
— pkg: packages

It’s worth noting that SaltStack provides lots of file helpers and my grep isn’t really necessary. Take a look at how SaltStack can manage and interface with your config files here