Babushka: test-driven sysadmin.

Writing deps

It’s all very well to run babushka blah and have it do jobs for you, but the real power is in babushka’s ability to automate whatever chore you want, not just ones that others have thought of already.

Context (or lack of it)

When you’re writing a dep, you don’t have to think about context at all, just one little task in isolation. As long as your requires are correct, you can leave the overall structure to babushka and just write each little dep separately.

When you invoke the dep, babushka uses the requires in each dep to assemble a tree of deps and achieve the end goal you’re after.

The other reason to ignore context when writing each dep is that context is coupling. Keeping deps decoupled means they can be more easily re-used in different situations, where the context is probably different.

Writing met? and meet

The section on how deps work explains the structure of a dep. To see how each section works, let’s build a simple dep to symlink a directory. Code on the left, shell output on the right.

dep 'work symlinked'
$ babushka ‘work symlinked’
work symlinked {
} ✓ work symlinked
$

That’s the simplest dep there is: you don’t need to supply a dep block if you have nothing to put in it. Since the met? and meet blocks both default to being empty, though, it’s not very useful.

An empty met? block defaults to true (so that deps that do nothing but require other deps act as passthroughs).

Let’s add a met? block with a simple check.

dep 'work symlinked' do
  met? {
    # The String#p patch expands the string
    # and returns a Fancypath, which has lots
    # of useful helpers.
    "~/work".p.exists?
  }
end
$ babushka ‘work symlinked’
work symlinked {
  meet {
  }
} ✗ work symlinked
$

This time, the logic we put in the met? block returned false (because ~/work didn’t exist). To see when that block is being run, let’s add some logging:

dep 'work symlinked' do
  met? {
    "~/work".p.exists?.tap {|result|
      log "met?: #{result}."
    }
  }
end
$ babushka ‘work symlinked’
work symlinked {
  met?: false.
  meet {
  }
  met?: false.
} ✗ work symlinked
$

This shows that babushka is running the met? block twice: first to check, and then again after running meet (which is empty in this case). This is where meet comes in: it’s a piece of logic that, when run, should make met? return true.

Let’s fill in the meet block to complete the dep.

dep 'work symlinked' do
  met? {
    "~/work".p.exists?.tap {|result|
      log "met?: #{result}."
    }
  }
  meet {
    shell "ln -s ~/jobs/acme ~/work"
    log "made the symlink."
  }
end
$ babushka ‘work symlinked’
work symlinked {
  met?: false.
  meet {
    made the symlink.
  }
  met?: true.
} ✓ work symlinked
$

This is how deps work (detailed here): it’s the work meet did on the system, not its return value, that matters. Once meet runs, the met? check is passing, and we’re done. Without the logging, the final version of our dep looks like this:

dep 'work symlinked' do
  met? {
    "~/work".p.exists?
  }
  meet {
    shell "ln -s ~/jobs/acme ~/work"
  }
end
$ babushka ‘work symlinked’
work symlinked {
  meet {
  }
} ✓ work symlinked
$

Note the clean split between met? and meet: met? checks, and meet acts. If you find you’re checking for the presence of some condition in your meet block, it probably means you’re trying to do too much in a single dep, and you should be splitting it up into smaller ones. Remember, deps are small, self-contained and context-free - the more focused, the better.

Requirements

The next step in defining a larger task is to take the deps, each one a little nugget of functionality, and relate them to each other. Along with met? and meet, there’s a third keyword that’s core to the babushka DSL, and that’s requires. Unlike met? and meet, which accept a block, requires accepts a list, each item of which names another dep that this one directly depends on.

This allows us to assemble deps into a tree of dependencies, while still ignoring most of the context (see above): from the point of view of any one dep, all we have to worry about are its direct requirements. The shape of the dependency tree is implied by those requirements, and is lazily assembled by babushka as it runs the deps.

There’s no restrictions on what you can require from where: any dep can require any number of other deps. It’s also fine for a given dep to be required from many others – a given dep will only be invoked once per run. Each subsequent time a requirement pulls it in, the cached result of the first run is used.

In fact, many core deps do this extensively. For example, deps defined against a package manager template all require a dep for the package manager itself (e.g. ‘bundler.gem’, and so on, all require ‘rubygems’). If we have a dep pulling in some libraries our project requires, then on OS X, only the first one will cause the ‘homebrew’ dep to be invoked; subsequent deps use the cached value:

$ babushka 'project libs'
project libs {
  curl.lib {
    homebrew {
      binary.homebrew {
        repo.homebrew {
        } ✓ repo.homebrew
        'brew' runs from /usr/local/bin.
      } ✓ binary.homebrew
      build tools {
        xcode tools {
          'cc', 'gcc', 'c++', 'g++', 'llvm-gcc', 'llvm-g++', 'clang', 'make', 'ld' & 'libtool' run from /usr/bin.
        } ✓ xcode tools
      } ✓ build tools
    } ✓ homebrew
  } ✓ curl.lib
  png.lib {
    ✓ homebrew (cached)
    ✓ system has libpng-1.5.13 brew
  } ✓ png.lib
  yaml.lib {
    ✓ homebrew (cached)
    ✓ system has libyaml-0.1.4 brew
  } ✓ yaml.lib
} ✓ project libs

The only invalid case is a dependency loop, e.g. a -> b -> c -> a. Babushka detects these, by maintaining a dep callstack and checking that each dep it’s about to run isn’t already in the stack. If a loop is detected, babushka will fail with a descriptive message.

In sum, there’s no need to worry about the resulting graph, or run order, or anything like that: just specify each dep’s immediate requirements, and it’ll all come out in the wash.

Dep parameters

As discussed in the section on running deps, it’s nice to think of deps as similar to a method: they wrap up a bit of logic, separating it from the context it’s subsequently used in. In order to truly use and re-use a method, though, it needs to be parameterised: there’s not much point to wrapping logic in a method if we have to repeat it each time the inputs change. So just as a method has parameters, so do deps.

Deps haven’t always had parameters: originally, babushka had a rather unsavoury system of globally shared variables, that at one point even involved method_missing (we were all young once). Parameters were introduced in Sep 2011, and all that global nonsense is gone now; the relevant version bump has more details on the redesign.

Parameters are added to a dep by naming them as symbols following its name.

dep 'git', :version do
  # ...
end

That defines a ‘version’ parameter, which allows us to supply an argument like this:

dep 'a gittish task' do
  requires 'git'.with(version: '1.8.0')
end

As well as passing named arguments in a hash, you can pass them positionally. When passing by name, you can supply as many or as few as you like; when passing positionally, you have to supply them all (the arity has to match).

dep 'file linked', :src, :target do
  # ...
end

dep 'babushka linked' do
  requires 'file linked'.with('./bin/babushka.rb', '~/bin/babushka')
end

Arguments can also be supplied on the commandline when invoking a parameterised dep directly; see running deps.

Parameters are always optional, and lazy. It’s not until a parameter’s value is requested that it’s resolved; if it doesn’t have a value at that point, then babushka will prompt for it. (If --defaults was used, then the run will fail on an unset parameter.)

Parameters can have default values, which are suggested when prompting and selectable by hitting ‘enter’:

dep 'postgres', :version do
  version.default('9.2.4')
end

They also support silent defaults (“bang defaults”), that are used without prompting whenever a value wasn’t supplied:

dep 'nginx.src', :prefix do
  prefix.default!('/opt/nginx')
end

The prompt message can be customised (all these settings can be chained):

dep 'nginx.src', :prefix do
  prefix.ask("Where should nginx be installed?").default('/opt/nginx')
end

And choices accepted when prompting can be restricted:

dep 'vhost configured', :type do
  # Without descriptions
  type.choose(:static, :proxy, :unicorn)

  # Including descriptions
  type.choose(
    :static => "A statically served vhost",
    :proxy => "A vhost proxied to a custom host/port",
    :unicorn => "A vhost proxying to a unicorn via unix socket"
  )
end

This explains why dep parameters aren’t just block parameters, like this:

dep 'git', do |version| # This wouldn't work.
  # ...
end

Local variables aren’t available in helper methods, which don’t close over the scope they’re defined in. But more importantly, for chained defaults and lazy prompting to work, referencing parameters has to be a method call.

Parameter limitations

Dep parameters do have some limitations, mainly stemming from the fact that they’re Parameter objects wrapping their values, not the values themselves.

In most situations, parameters will convert and compare as strings correctly. For example given a parameter ‘param’, these cases will all work as expected:

param == 'value'
param[/value/]
File.exists?(param)
Regexp.escape(param)

In cases where there is no default conversion, or it’s not the one we expect, though, the param will fail:

# This will fail: the default conversion here is `#to_i`,
# which isn't defined on Parameter.
'value'[arg]

# This will fail too: the key is the param's value, not
# the param itself, so nil is returned.
{'key' => 'val'}[arg]

In those cases, we just need to convert the parameter explicitly:

'value'[arg.to_s]
{'key' => 'val'}[arg.to_s]

Some proxy objects solve this by becoming transparent to the caller via #method_missing. Doing so could solve these cases, but I think it makes debugging too susprising: it effectively creates an invisible object. I think the occasional bit of explicitness to maintain predictability is the right tradeoff.

Templated Deps

The basic dep, with just requires, met? and meet, is all you need to describe an end goal. But this generic nature of met? and meet means just as they’re general purpose, they can lack focus. For example, installing an app using the system’s package manager has a predictable met? block—check whether the package is present and its binaries are in the path.

A lot of chores are variations on a theme like this, or just too cumbersome to do repeatedly at a low level. So babushka provides a way to write dep templates, or meta deps, that can be reused later. These meta deps allow you to focus the DSL, and make it even more concise.

For example, Babushka ships with a meta dep that knows how to install TextMate bundles, given just the URL. All the actual logic, including the code for met? and meet, is wrapped up in the meta dep.

meta :tmbundle do
  accepts_value_for :source

  template {
    requires 'TextMate.app'
    def path
      '~/Library/Application Support/TextMate/Bundles' / name
    end
    met? {
      path.dir?
    }
    meet {
      path.parent.mkdir
      git source, :to => path
      shell %Q{osascript -e 'tell app "TextMate" to reload bundles'}
    }
  }
end

Notice how the contents of the template block looks like a normal dep. That’s because it is – the meta dep is a factory that produces deps with their met? and meet blocks already set (they can be overridden in the dep, though).

Also notice the item outside the template: accepts_value_for. This call is actually part of a second DSL on top of which babushka’s real DSL is built. That call means that deps defined against this template have a new DSL method, ‘source’, that can set and return a value:

meta :tmbundle do
  accepts_value_for :source
  # ...
end

dep 'Rspec.tmbundle'
  source 'https://github.com/dchelimsky/rspec-tmbundle.git'
  meet {
    log "This dep would install from #{source}."
  }
end

In fact, this is how the entire babushka DSL is built: met?, meet, and so on are defined in just the same way. The only difference is where they’re defined: they’re found on the base template, so they’re available in all deps.

Let’s look again at the rspec dep we defined above, against the ‘tmbundle’ template.

dep 'Rspec.tmbundle'
  source 'https://github.com/dchelimsky/rspec-tmbundle.git'
end

Notice there’s no imperative code there at all—just declarations. That’s what the DSL aims for. Instead of saying “do this, then do this, then do this”, the code should say “here’s a description of the problem, now you work it out.” Also notice that there’s no TextMate-specific logic. Adding this extra level of abstraction means all that’s left are the specifics for this TextMate bundle.