Dotfiles management using CFEngine
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.