Automated Mac setup with Ansible

Dan Bohea

bohea.uk : info@bohea.uk : @dan_bohea

Goal

Automate system setup from a clean install of macOS 10.11

...as much as we can.

Goal

Why?

Why?

macOS updates are bad juju

Why?

Manual setup from a clean install takes too long

Why?

I run more than one system

Why?

I'm a freelance frontend Drupal dev and my tech stack is typical (sprawling)

Why?

Decent provisioning can double as 
half-decent documentation

Why?

I can't help trying to automate things

Other good reasons

A new hire can mean a new system

Help normalise systems between developers

Other good reasons

How far I've got

Macsible

For the most part this is an opinionated Ansible playbook that is in no way intended for use by anyone other than myself and as such is currently unsupported.

Disclaimer

Global node.js modules (6)

  • bower

  • generator-ctools-layout

  • generator-drupalmodule

  • grunt-cli

  • gulp

  • yo

Homebrew
packages (7)

  • bfg

  • duti

  • Git

  • Git Extras

  • LastPass CLI

  • node.js

  • tree

pip packages
(3)

  • ansible-lint

  • mkdocs

  • passlib

Homebrew Cask apps (56*)

  • Alfred

  • AppCleaner

  • aText

  • Atom

  • Bartender

  • BetterTouchTool

  • Caffeine

  • Cakebrew

  • Crashplan

  • DaisyDisk

  • Dash

  • Doxie

  • Dropbox

  • Evernote

  • f.lux

  • Firefox

  • Flash Player

  • Google Chrome

  • Google Drive

  • Google Hangouts

  • Google Plus Auto Backup

  • Handbrake

  • Hazel

  • HipChat

  • ImageOptim

  • iStat Menus

  • iTerm2

  • Kaleidoscope

  • Keyboard Maestro

  • LibreOffice

  • LimeChat

  • Moom

  • Name Mangler

  • OmniGraffle

  • Opera

  • Opera Mobile Emulator

  • Parallels Desktop

  • Path Finder

  • Postbox

  • Sequel Pro

  • Sketch

  • Sketch Tool

  • Sketch Toolbox

  • SketchUp

  • Skitch

  • Skype

  • Steam

  • The Unarchiver

  • Tower

  • Transmission

  • Transmit

  • Vagrant

  • VirtualBox

  • VLC

  • VMware Fusion

  • xScope

* excluding Quick Look plugins

Quick Look plugins (9)

  • betterzipql

  • qlcolorcode

  • qlmarkdown

  • qlprettypatch

  • qlstephen

  • quicklook-csv

  • quicklook-json

  • suspicious-package

  • webpquicklook

Dotfiles

  • .bash_aliases

  • .bash_profile

  • .duti

  • .gitconfig

  • .gitignore_global

Atom packages (28)

  • afterglow-monokai-syntax

  • atom-beautify

  • autocomplete-plus

  • autocomplete-sass

  • bottom-dock

  • double-tag

  • emmet

  • file-icons

  • git-plus

  • git-time-machine

  • grunt-manager

  • gulp-manager

  • highlight-line

  • highlight-selected

  • isotope-ui

  • language-ansible

  • language-patch

  • linter

  • linter-ansible-linting

  • linter-manager

  • open-terminal-here

  • pigments

  • project-manager

  • sort-lines

  • split-diff

  • svg-preview

  • todo-manager

  • travis-ci-status

macOS defaults

  • Activity Monitor

  • Calendar

  • Dock

  • Finder

  • General UI & UX

  • Mission Control

  • Safari & Webkit

  • Screen

  • Spotlight

  • SSD

  • Terminal

  • TextEdit

  • Time Machine

  • Trackpad & mouse

Other things

  • App preferences

  • Associates file types
    with apps

  • Composer

  • Custom spelllingz

  • Directory structures

  • Dock apps

  • known_hosts

  • Login Items

  • Project Git repos

  • Symlinks

  • Sketch plugins

  • Vagrant plugins

More other things

  • Different provisioning for laptop / desktop systems.
  • Subsections can be run or excluded via Ansible tags:
    • single apps
    • prioritised groups of apps
    • and more...

109 apps, tools & plugins and a boat load of config

in under 1 hour*

* "40 Mbps down" internet connection

What it doesn't do

  • Adobe CC app installs

  • Apple App Store app installs

  • Enter license details

  • Sign in to apps

  • SSH keys

  • Time Machine exclusions

It also doesn't do any of this:

  • Install a LAMP stack

  • Install Drush

  • Install Sass etc.

Here I'm using virtual machines and project-level dependency management.

How?

  • The basics
  • macOS applications
  • Preferences
  • Other stuff
  • Testing
  • Code examples*

Breakdown:

*if I don't overrun again

The basics

The basics

  • Ansible
  • Package managers
  • Dotfiles

Ansible

  • Relatively easy to get set up & hack on
  • Idempotent
  • Clear YAML based syntax

Package managers

  • Homebrew
  • npm
  • pip
  • apm

Dotfiles

  • Text file based config for CLI tools.
  • Stored separately from Ansible playbook or roles.
  • Multiple approaches to deployment.
  • Share with & learn from others
    http://dotfiles.github.io

Highlights of this level of provisioning

  • Global libraries installed & configured.
  • Text file or CLI based config is pretty straight forward.

macOS applications

Homebrew Cask

Homebrew Cask extends Homebrew and brings its elegance, simplicity, and speed to macOS applications and large binaries alike.

3210 Casks maintained by 422 contributors.

Cask wins

  • App installation on macOS via CLI
  • App availability is really impressive
  • homebrew_cask Ansible module

Cask quirks

  • Symlinks
  • Apps offering to move themselves
  • ~/Applications vs /Applications
  • App update functionality unaware of Cask
  • Limited app version support only via caskroom/versions

Cask improvements inbound

  • Apps will be moved to their target directory
    (no more symlinks) - merged
  • /Applications will become the default - merged
  • Better support for apps that auto-update
  • Support for apps that require a specific location

Preferences

plist files

  • XML based Apple standard for storing preferences.
  • "defaults" command for basic interaction.
  • Whole files can be copied/pasted but are not necessarily designed for this (avoid).

Working with plist files

  • Stored in binary format so need to be converted to be readable.
  • Multiple tools offer conversion

plist file opened directly in text editor

bplist00�	

Xlocation^nightColorTemp_WebKitDefaultFontSize\locationTypeXlateLock_SULastCheckTimeWversion]lateColorTemp_locationTextField_WebKitStandardFontXwakeTime_50.976297,-2.838684
�QL3A����#@����"�7Wta126bu_.Lucida Grande UI�(7O\ew�������������

plist file converted by "defaults"

$ defaults read org.herf.Flux
{
    SULastCheckTime = "2016-03-01 17:00:00 +0000";
    WebKitDefaultFontSize = 11;
    WebKitStandardFont = ".Lucida Grande UI";
    lateColorTemp = "3430.46215160473";
    lateLock = 0;
    location = "50.976297,-2.838684";
    locationTextField = ta126bu;
    locationType = L;
    nightColorTemp = 3500;
    version = 3;
    wakeTime = 480;
}

plist file viewed in Xcode

Using the "defaults" command

# Print all of the user's defaults
defaults read

# Print user defaults for the "org.herf.Flux" domain
defaults read org.herf.Flux

# Set dock orientation to "Left"
defaults write com.apple.dock orientation -string "left"

Ansible osx_defaults module

- name: Set late colour temperature
  osx_defaults:
    domain: org.herf.Flux
    key: lateColorTemp
    type: int
    value: 3500

osx_defaults tips

  • Only available in Ansible 2+
  • Have only had success with "int", "float" & "bool" types.
  • Other types may need to be run using "defaults" via the "command" or "shell" modules.
  • Use handlers to "killall" relevant processes once changes have been made (e.g. "cfprefsd").

Setting default apps for files

duti

# Contents of .my_prefs:

# Example using UTI
com.github.atom       public.source-code  all
# Example using file extension
org.m0k.transmission  .torrent            all

A Uniform Type Identifier (UTI) is a text string used on software provided by Apple Inc. to uniquely identify a given class or type of item.

# Run duti with preferences file
duti .my_prefs

duti tip

duti -e .png


identifier: public.png
description: Portable Network Graphics image
declaration: {
    UTTypeIdentifier = public.png
    UTTypeDescription = Portable Network Graphics image
    UTTypeConformsTo = public.image
    UTTypeTagSpecification = {
        com.apple.ostype = PNGf
        public.mime-type = image/png
        com.apple.nspboard-type = Apple PNG pasteboard type
        public.filename-extension = png
    }
}

 -e option (undocumented as of 1.5.2):

Retrieves the UTI declaration associated with a specified filename extension.

Testing

Available test environments

  • On the control machine :O

  • On a second Mac

  • Virtual machines

  • Automated tests

Travis CI - will this even work?

  • Has a macOS build environment

  • Xcode + Command Line Tools pre-installed

  • macOS 10.9, 10.10 & 10.11 available

  • Homebrew pre-installed

My Mac setup. Tested per push.

WINNER

Test coverage

  • macOS 10.11.

  • Syntax check all Ansible code.

  • Lint all Ansible code.

  • Run Ansible playbook as if targeting my laptop (mostly).

Avoiding known CI issues with Ansible tags

Avoid testing:

  • low priority roles that take too long to avoid build timeout.
  • lower priority, less unique Cask apps to reduce log verbosity & build time issues.

Roles

danbohea.homebrew

Installs Homebrew & Homebrew Cask on macOS.

  • Galaxy role
  • Very minimal
  • No dependencies
  • Includes handlers
  • CI tests

danbohea.skype

  • Example of app role on Galaxy

  • Prefs available to override via default vars

  • Provides "killall" handler

  • CI tests

# tasks/main.yml

- name: Install Skype.
  homebrew_cask: name=skype state=present
  environment:
    HOMEBREW_CASK_OPTS: --appdir="{{ skype_appdir }}"
  notify: brew cask cleanup

- name: Set default dialpad window visibility.
  osx_defaults:
    domain: com.skype.skype
    key: DialpadOpen
    type: int
    value: "{{ skype_DialpadOpen }}"
  notify:
    - killall Skype

- name: Set default contacts view mode.
  osx_defaults:
    domain: com.skype.skype
    key: ContactListViewMode
    type: int
    value: "{{ skype_ContactListViewMode }}"
  notify:
    - killall Skype
# defaults/main.yml

skype_appdir: "/Applications"

skype_DialpadOpen: 0

skype_ContactListViewMode: 1
# handlers/main.yml

- name: killall Skype
  command: killall Skype
  ignore_errors: yes

"Batch" roles

DRYer way to batch install smaller packages via their respective package managers.

  • atom
  • node_pkgs
  • pip_pkgs
  • quick_look_plugins
# tasks/main.yml


- name: Install global node packages
  npm:
    name: "{{ item }}"
    global: yes
  with_items: "{{ node_pkgs_list }}"
# defaults/main.yml

node_pkgs_list:
  - bower
  - generator-ctools-layout
  - generator-drupalmodule
  - grunt-cli
  - gulp
  - yo

cask_app role

  • Designed to be used multiple times in the same play.

  • Simple, DRY way to install an app.

  • Allows for per-app tags.

  • Ensures app symlinks are generated in /Applications.

  • Doesn't handle app prefs.

# tasks/main.yml

- name: Install {{ name }} to {{ cask_app_appdir }}
  homebrew_cask: name={{ name }} state=present
  environment:
    HOMEBREW_CASK_OPTS: --appdir={{ cask_app_appdir }}
  notify: brew cask cleanup
# meta/main.yml

# 'allow_duplicates' is required to allow this role to be run as a dependency
# more than once in the same play.

allow_duplicates: yes
dependencies:
  - danbohea.homebrew
# Example of cask_app usage in playbook

- { name: "dropbox", tags: ["dropbox"], role: cask_app }
- { name: "phpstorm", tags: ["phpstorm"], role: cask_app }

dock_items role

  • Overridable default list.
  • List order affects Dock item order.
  • Results are dependent on what apps have actually been installed (see installed_apps role).
# tasks/main.yml

- name: Wipe all default app icons from the Dock
  command: defaults write com.apple.dock persistent-apps -array
  notify: killall Dock

# - Only adds apps to the Dock that are already installed
- name: Add applications to Dock
  command: >
           defaults write com.apple.dock persistent-apps -array-add '<dict><key>tile-data</key><dict><key>file-data</key><dict><key>_CFURLString</key><string>/Applications/{{ item }}</string><key>_CFURLStringType</key><integer>0</integer></dict></dict></dict>'
  with_items: "{{ dock_apps | intersect(installed_apps.stdout_lines) }}"
  notify: killall Dock
# defaults/main.yml

dock_apps:
  - Path Finder.app
  - Dash.app
  - Skype.app
  - Google Chrome.app
  - Evernote.app
# installed_apps/tasks/main.yml

# Command uses `basename` to return only filenames (excludes paths)
# Source: http://superuser.com/a/620591

- name: Get list of .app files in /Applications
  command: find . -depth 1 -name "*.app" -exec basename {} \;
  args:
    chdir: /Applications
  register: installed_apps
  changed_when: false

Roadmap

Roadmap

Abstract roles to Ansible Galaxy where possible.

Galaxy role per configurable app:

Galaxy roles for common macOS features e.g

  • overridable preferences
  • open source so more prefs can be easily added
  • custom spellings (LocalDictionary)
  • dock app management

Roadmap

  • Galaxy roles will all use the tag "macsible".
  • Get Involved!
  • See my Skype & Google Chrome roles for basic examples.
  • Wish list: Yeoman generator for app roles.

Thoughts on provisioning
Linux & Windows dev systems

Linux

  • Ansible still supported
  • Better package managers
  • No App Store :D
  • No Apple :D
  • Should theoretically be easier?

Windows

  • No Ansible support for Windows control machine.
  • Could still theoretically provision a Windows target with Ansible from a Mac/Linux based control machine (faff!)?
  • While Puppet supports Windows, boxen does not (doesn't look like it will either).
  • Check out Choclatey for a "Cask-esque" package manager option.

Questions

Thank you please

Dan Bohea

bohea.uk : info@bohea.uk : @dan_bohea

Automated Mac setup with Ansible

By Dan Bohea

Automated Mac setup with Ansible

DrupalCamp Brighton 2016 / NWDUG July 2016

  • 2,661