If you want to use ConfgenFS, you should know the basics of Confgen. This article offers an introduction to it. This article should make at least some sense either way, though!
So, you know how configuration files are just that? Files? Do you also find this unacceptable? Do you wish they could be more? What if all configuration files could run code in real-time?
The Why
My inspiration for ConfgenFS came from config files that had that extra bit of more, namely scriptable configuration and plugin systems. Those would allow me to finally write meaningful configuration that finally did something instead of just declaring something. But what if you wanted to use software that doesn't support plugins or scriptable configs? After all, implementing a script runtime in every piece of software with a configuration file is not just infeasible, but also massive overkill in most cases, right? Sure, but why let that stop you?
A simple example
Say that you used a hypothetical window manager that draws borders around a given window in a customizable, yet static color. Always using the same color is boring, so you decide you want a random one each time you boot up your system. This is what that window manager wants its config file to look like:
color = "#XXXXXX"
How do we achieve a random color every time? Simple: traditionally, we don't (or change the config file each time before startup or something, but with software that's launched more often, this is way too hacky to be usable).
The How
Building on the above example, we might try using Confgen (as we're hopefully used to):
-- confgen.lua
cg.addFile "my-config.cfg.cgt"
<! -- my-config.cfg.cgt
local r = math.random(0, 255)
local g = math.random(0, 255)
local b = math.random(0, 255)
local color = string.format("%02x%02x%02x", r, g, b)
!>
color = "#<% color %>"
$ confgen confgen.lua build
I: generating my-config.cfg
$ cat build/my-config.cfg
color = "#cbb297"
$ cat build/my-config.cfg
color = "#cbb297"
# :(
This already got us most of the way there! We have a random color, the only problem being that it only changes when we rebuild our config. It might seem reasonable to just rebuild the config on reboot and be done with it, but we'll get into more complex examples later where this doesn't work.
Alright, now that we've got a Confgen project, let's just try running ConfgenFS (which usually ships with Confgen if it's built against FUSE3, check with packaging) to see what happens (no changes to the project files needed!):
# Terminal 1
$ mkdir mount
$ confgenfs confgen.lua mount
I: mounting FS @ mount
I: initializing FUSE with protocol version 7.41
I: loading confgenfile @ confgen.lua
I: generating my-config.cfg
# Terminal 2
$ cat mount/my-config.cfg
color = "#c014a9"
$ cat mount/my-config.cfg
color = "#56bc6d"
$ cat mount/my-config.cfg
color = "#911d0e"
# :D
Whoa! Our now magical file is different every time we read it! Surely, this will make people who use some sort of "declarative" and "reproducible" config scream in terror! 😏
To keep it simple, your template is run once every time the file is opened. Be sure to keep this in mind in case your templates have side effects.
Seems too complicated just for random colors? We'll get into more practical examples later!
Special Files
Inside mount
(if you've been following the above example), you'll also find an additional _cgfs
directory alongside your own files. This directory contains pseudo-files that allow you to interact
with the daemon, namely:
_cgfs/opts.json
will contain a JSON representation of the options tablecg.opt
. This is generated on demand, so it will always respond to changes you make to said table. In case you want this for a different table, you can simply make a template and usecg.fmt.json
._cgfs/eval
is by far the most interesting file. It's the only file in a ConfgenFS mount that isn't readable; it can only be written to. You can write Lua code to this file, and once the file handle is closed, that Lua code will be run in the main Lua engine context. For example:
The next time you read a template file containingecho 'cg.opt.message = "Hello, ConfgenFS!"' > ~/confgenfs/_cgfs/eval
<% opt.message %>
, that will be adjusted to contain our greeting!
The Filesystem Context (fsctx
)
Now comes the really exciting part! While what we've seen so far is already incredibly powerful,
this takes the cake. When a process reads a template file inside a ConfgenFS mount, the daemon will
pass an additional fsctx
variable to the template code. This is the Filesystem Context. This
variable is a table that, among other information, contains the PID (process ID) of the reader.
Note: for a comprehensive list of fields in
fsctx
, refer to the man pageconfgenfs(1)
.
You might be wondering what that gets us, so let me show you:
<! -- example.cgt
local cmdline = "dunno"
if fsctx then -- Always remember to check if fsctx is nil!
-- Read /proc/X/cmdline, then split at null bytes
cmdline = io.open("/proc/" .. fsctx.pid .. "/cmdline", "r"):read("*a"):gsub("%z", " ")
end
!>Command that read this: <% cmdline %>
$ cat mount/example
Command that read this: cat mount/example
$ cat -n mount/example
1 Command that read this: cat -n mount/example
Doesn't seem so weak anymore now, does it? Imagine the power of this!
Note: The reason we need to check if
fsctx
isnil
is that ConfgenFS may evaluate files without a process reading them to collect metadata on them.
A practical example using the Filesystem Context
Say that you use the Zathura document viewer (would recommend!) and want to use its feature to automatically recolor documents to dark mode:
# .config/zathura/zathurarc.cgt
set recolor "true"
But now, you realize that for your own documents, say they're located in a directory my-documents
,
you would like to see their unaltered original form so you can better work on them. You could either
manually disable recolor mode with a keybind after you open them, or you take advantage of the
Filesystem Context:
<! -- .config/zathura/zathurarc.cgt
local recolor = true
if fsctx then
local cmdline = io.open("/proc/" .. fsctx.pid .. "/cmdline", "r"):read("*a"):gsub("%z", " ")
recolor = cmdline:find("my-documents") == nil
end
!>
set recolor "<% recolor %>"
Now, recolor mode will be enabled for all documents except those where the command contains
my-documents
!
Note: ConfgenFS may get utility functions to make determining the reader command easier in the future.
A practical example using _cgfs/eval
Detecting system state to the extent necessary to determine the content of a configuration template is not always possible by only using the Filesystem Context. For example, you could be using Waybar, but you sometimes use Sway and sometimes Hyprland. You use Waybar with both compositors and want to have your workspaces in the bar for both of them. This requires a different Waybar configuration for each. This is what a config could look like:
<! -- .config/waybar/config.jsonc.cgt
local config = {}
-- various options here...
if opt.wayland_compositor == "sway" then
config["modules-left"] = { "sway/workspaces" }
elseif opt.wayland_compositor == "hyprland" then
config["modules-left"] = { "hyprland/workspaces" }
end
!><% cg.fmt.json.serialize(config) %>
Then, you could dynamically set the wayland_compositor
option in your compositor configuration
before it starts waybar:
# .config/hyprland/hyprland.conf
exec-once = echo 'cg.opt.wayland_compositor = "hyprland"' >~/confgenfs/_cgfs/eval
# start waybar...
# .config/sway/config
exec echo 'cg.opt.wayland_compositor = "sway"' >~/confgenfs/_cgfs/eval
# start waybar...
Then, your waybar config will magically contain the right configuration after you start your compositor.
A note on file sizes
ConfgenFS, by default, will always report the size of template files as 0, because it can't know
them before they're generated. Some software has issues with this, so ConfgenFS offers a workaround.
If your template calls tmpl:setAssumeDeterministic(true)
, ConfgenFS will evaluate the template
once and save the file size that results in. Any further invocations should then result in the same
number of output bytes. Note that ConfgenFS does not care if you actually do return the same amount
of data every time, it may only cause software reading the file to misbehave.
Note: For a full list of functions on
tmpl
, refer to the man pageconfgen(3)
.
Integrating it into your System
Where to mount?
You might be tempted to just mount ConfgenFS at ~/.config
, but several pieces of software will
attempt to write to their config files, which is not allowed because ConfgenFS is read-only. You
also wouldn't be able to configure software that places its configuration files somewhere else. To
work around this, the recommended mountpoint for ConfgenFS is ~/confgenfs
. You could then create
symlinks to install these config templates, for example ln -s ~/confgenfs/.bashrc ~/
or
ln -s ~/confgenfs/.config/mpv ~/.config/
.
How to run it?
On SystemD-based Linuces, a good way to start ConfgenFS is to use a SystemD user unit, as it will run before even your login shell. Here's an example:
# ~/.config/systemd/user/confgenfs.service
[Unit]
Description=ConfgenFS dotfiles
[Service]
Type=exec
ExecStart=confgenfs $HOME/dev/dotfiles/confgen.lua $HOME/confgenfs
[Install]
WantedBy=default.target
The Magic
ConfgenFS leverages the Filesystem in Userspace (FUSE) system via
libfuse, hence the FS
suffix. For that reason, it requires
the fuse
kernel module, which should ship with most distros. When you run confgenfs
, a virtual
filesystem is mounted at the given mountpoint via FUSE just like you'd mount a disk or some sort of
network filesystem. For this reason, ConfgenFS may also show up as a removable drive in some file
managers. When a file is now read, the kernel will ask the ConfgenFS daemon for the content. The
daemon then loads and generates the template.
The What? (There's a Matrix room!)
The concept of ConfgenFS is pretty new, no one's done it for all I know. For that reason, it might be hard to get used to. If you have any questions, suggestions to improve ConfgenFS or this article or just want to chat, please check out the matrix room at #confgen:mzte.de.