2025-04-12 [src]

ConfgenFS: All config files are now scriptable

Does software keep disappointing you with plain, weak config files? Is static generation with Confgen not enough for you? Look no further!

code, confgen, config, lua, template-engine, fuse3, filesystem


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:

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 page confgenfs(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 is nil 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 page confgen(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.