Dotfiles management using CFEngine

3 minutes read (1062 words)

Dotfiles management is a common problem. If one has anything more than just graphical customizations in their favorite shell / editor / MUA / other utility, it's annoying to be ssh-ed into a different machine or just be using one's laptop instead of desktop and finding out that that really nifty alias / shortcut / mapping one would like to use right now is not part of the vanilla options but instead a custom mapping or worse comes from a plug-in.

It comes to little surprise then that one can get dotfile managers — tools to synchronize one's most beloved setup to all sorts of different machines — ten to a penny. Octodots currently lists 23 ‘general purpose’ dotfile managers written in Perl, Python, Ruby, Rust, Shell, Go, Haskell and Nix; many given that the site is very much not an exhaustive list. The choice between them boils down to choosing which programming language one considers the best or which configuration syntax one prefers.

For myself I am using CFEngine to do that task. It is written in C and uses an configuration syntax that is verbose and straight up difficult to read. According to the above points then it should be low on my list, shouldn't it? CFE has some advantages though. It's written in C, so it doesn't require an installation of Python or Perl or Ruby, and doesn't need the bucket load of additional libraries something written in Rust or Haskell requires. On my machine cf-agent and cf-execd are dynamically linked binaries taking a staggering 408 respective 44 kilobytes of disk space that mostly link to libraries modern distributions have installed anyway.

In practice of course none of this makes any particular difference. Most distributions ship with Python by default too and both Perl and Ruby are just one apt-get, dnf, or pacman away. And the price of having more and more libraries cluttering a system is becoming cheap enough that few people care about it.

Still, CFEngine has some advantages. For one, it's already there. Since I'm using CFE for the automation of my servers and computers it saves me from installing and learning yet another tool. But also, it's immensely powerful if need be. Most dotfile management is glorified paper pushing; such a tool does little more than making sure a specific file it placed in a specific location and in the best case also updated should the source change. But sometimes you want more than that. You want specific files for specific hosts. Or you want templates. Or you want templates filled with information like the IP-address of a host. CFEngine provides you with all that. Mustache templates are built in and the IP-address of a host is just a $(vars.default:sys.ipv4) away. It also allows you to run arbitrary commands, check processes or completely replace cron. Arguably not always sensible but when you require it the additional power can be nice to have.

That all being said, the mayor selling point of CFEngine is the abstraction level it can provide. A lot of other tools can use git with much less hassle than CFEngine can. Yet sometimes you want to configure a host that doesn't have git. With yadm you are left to your own devices. With CFEngine it is outright easy to define two classes — one for hosts that have git and one that don't, and then write bundles in a way that hosts that have git pull their files from git and the others pull from a CFEngine host or via scp or even HTTPS. In fact, you could abstract that detail away as well just writing down which files go where, having set up a library with some bundles and bodies that allow CFEngine to choose the protocol based on the classes it has.

My setup

Now, let's talk about my personal way in more detail. When you're using CFEngine as non-root configuration files are in the folder ~/.cfagent by default. In my setup, masterfiles is a git-repository that contains the promises and templates/files to deploy. The main reason for directly deploying the masterfiles to the machines instead of pulling from a central server is that this way I am able to update configuration more immediate and most importantly without having to have access to the Internet.

Failsafe

The failsafe.cf is the promise bundle cf-agent will run to update when not bootstrapped from a server. In my case it's a simple file that just copies the promises from the masterfiles to the inputs:

body common control
{
  bundlesequence =>
    { "local_update"
    };
}

bundle agent local_update
{
  files:
      any::
        "$(default:sys.inputdir)"
          depth_search => recursive,
          copy_from => copyfrom_sync("$(default:sys.masterdir)/promises/");
}

body copy_from copyfrom_sync(f)
{
  source => "$(f)";
  purge => "true";
  preserve => "true";
  type_check => "false";
  compare => "digest";
}

body depth_search recursive
{
  depth => "inf";
}

The main bundlesequence in promises.cf is simple. First it updates the masterfiles git repository if it can, then it deploys each of the dotfiles (shortened for the sake of brevity).

body common control
{
  inputs =>
    { "$(default:sys.libdir)/perms.cf"
    , […]
    , "tmux.cf"
    };

  bundlesequence =>
    { "git_update"
    , […]
    , "tmux"
    };
}

bundle agent git_update
{
  commands:
      any::
        "/usr/bin/git"
        args => "pull",
        contain => in_dir('$(default:sys.masterdir)');
}

If you want you can also define some global values in that file, e.g. one pointing to the configured $XDG-CONFIG-DIR.

I've split management into several files, generally one per utility (tmux) or concept (mail). Each of the files defines a bundle that does the actual work. For example:

bundle agent nvim
{
  vars:
      "nvcfg" string => "$(globals.xdgcfg)/nvim/";
      "cplist" slist => { "after", "colors", "ftdetect", "ftplugin", "syntax", "UltiSnips" };
  files:
      "$(nvcfg)/init.vim"
        perms => p_mode("644"),
        copy_from => local_cp("$(default:sys.masterdir)/nvim/init.vim");
      "$(nvcfg)/autoload/."
        create => "true",
        perms => p_mode("755");
      "$(nvcfg)/autoload/plug.vim"
        perms => p_mode("644"),
        copy_from => local_cp("$(default:sys.masterdir)/nvim/plug.vim");
      "$(nvcfg)/$(cplist)/."
        create => "true",
        perms => p_mode("755");
      "$(nvcfg)/$(cplist)"
        depth_search => recursive,
        copy_from => copyfrom_sync("$(default:sys.masterdir)/nvim/$(cplist)");

  commands:
      "/usr/bin/nvim"
        args => "+PlugUpdate +qa",
        contain => silent,
        action => if_elapsed_day;
}

body depth_search recursive
{
  depth => "inf";
}

Here the power of CFE becomes a bit more obvious. While somewhat unreadable this promise bundle creates a bunch of directories for neovim, copies all the files from masterfiles into that directory, updates vim-plug if necessary and runs nvim +PlugUpdate +qa, but at the maximum once a day. This kind of management is harder to achieve using regular dotfile managers.