commit 06b725cb77d74bed94b3f7f3dcdc9a59a2e06239
parent c74543c4ff7e2f0f7cee71d0d173f5a47c94f79b
Author: Daniel Barlow <dan@telent.net>
Date: Sun, 17 Sep 2023 16:32:22 +0100
document the module-based-services decision
Diffstat:
2 files changed, 207 insertions(+), 0 deletions(-)
diff --git a/doc/adr/README b/doc/adr/README
@@ -0,0 +1,6 @@
+Architecture Decision Records
+#############################
+
+In this directory you will find descriptions of Liminix architecture
+decisions.
+
diff --git a/doc/adr/module-system.rst b/doc/adr/module-system.rst
@@ -0,0 +1,201 @@
+Module system
+#############
+
+**Status:** Adopted; implemented in July-September 2023
+
+
+Context
+*******
+
+Liminix users need a way to assemble a full system configuration by
+combining smaller, more isolated and reusable components, otherwise
+systems will be unwieldy and copy-and-paste will be rife.
+
+
+Alternatives
+************
+
+NixOS module system
+===================
+
+The NixOS module system addresses many of these concerns. A module is
+a Nix function which accepts a ``configuration`` attrset and some
+other parameters, and returns a new fragment of ``configuration``
+which is merged into it. It includes a DSL describing the permitted
+types of values for each key in the configuration, which is used for
+checking that the supplied parameters are valid and also governs what
+to do if two modules both specify a value for the same key. (Usually
+they are "merged", using some type-appropriate concept of merging.)
+
+Usually a NixOS module looks only (or mostly only) at a particular
+subtree of the overall configuration which is hardcoded in the module
+definition, but the configuration fragment it returns may touch any
+part of the schema. For example, the factorio module refers to
+``config.services.factorio``, and it returns values for keys in
+``systemd.services.factorio`` and ``networking.firewall``. There is no
+way to use this module to run **two** factorio services with different
+config (e.g. on different ports) - the only way to make that
+possible would be to extend the module definition so that it
+accepts a collection of game configurations and then create
+a systemd service for each.
+
+
+NixWRT module system
+====================
+
+NixWRT, the (now defunct) predecessor of Liminix, used a homegrown
+module system modelled on the Nixpkgs overlay pattern. Each module is
+a function that accepts ``super`` and ``self`` parameters, and
+using <handwaves>that fixpoint magic thing</handwaves>
+is called in a chain with the configuration returned by the previous
+module and the final configuration.
+
+NixWRT modules mostly don't refer to the configuration object to
+decide how to configure themselves, but accept their parameters
+directly as function parameters. For example, the configuration
+file for "arhcive" (a backup server) includes this text:
+
+.. code-block:: nix
+
+ (sshd {
+ hostkey = secrets.sshHostKey;
+ authkeys = { root = lib.splitString "\n" secrets.myKeys; };
+ })
+ busybox
+ (usbdisk {
+ label = "backup-disk";
+ mountpoint = "/srv";
+ fstype = "ext4";
+ options = "rw";
+ })
+
+This gives us flexibility that NixOS modules don't: for example, if we
+want to mount two USB disks, we can simply repeat that module twice
+with different parameters - and the module definition doesn't have to
+handle it specially.
+
+However, the downside of this system is that we didn't implement any
+concept of "types" - there is no type information, so there is no
+systematic checking that parameters are valid, and if two modules set
+the same config key then the rules for merging are entirely ad hoc.
+
+There is a further (arguable) downside, which is that the
+configuration is not just data - it's now part code. While it could be
+feasible (though I've never seen it done) to encode a NixOS
+configuration using Yaml or XML and then manipulate it as data, this
+is not even possible using the NixWRT system.
+
+
+Use services for everything
+===========================
+
+The most common properties that a Liminix configuration needs to
+define are:
+
+* which services (processes) to run
+* what packages to install
+* permitted users and groups
+* Linux kernel configuration options
+* Busybox applets
+* filesystem layout
+
+Suppose we only had services?
+
+A Liminix service is (also) a derivation, so it is able to
+create any files it likes inside its own store path, and
+transitively require other packages simply by referring to them.
+If it needs particular kernel options it could define them
+as kernel modules to be loaded on demand when the service
+starts (see the nftables module for an example). However:
+
+* there is no way for a service to add busybox modules
+
+* it cannot create files outside of its store path, so
+ wouldn't be able to make e.g. :file:`/etc/something.conf`
+
+* no way to create users/groups. We could steal the DynamicUsers idea
+ from systemd and make them on demand, but this starts to get a bit
+ more complicated.
+
+These limitations force us to reject this option as a general
+solution - though we should strive *where possible* to implement
+functionality as services and to minimise the proportion of Liminix
+that manipulates the global configuration.
+
+
+Decision
+********
+
+"Why not both?" None of these options is sufficient alone, so we are
+going to do a mixture.
+
+We will use the NixOS module system, but instead of expecting modules
+to create systemd services as instances, they will expose "service
+templates": functions that accept an attrset and return an
+appropriately configured service that can be assigned by the caller
+to a key in ``config.services``.
+
+We will typecheck the service template function parameters using the
+same type-checking code as NixOS uses for its modules.
+
+An example may make this clearer: to add an NTP
+service you first add :file:`modules/ntp` to your ``imports`` list,
+then you create a service by calling
+:code:`config.system.service.ntp.build { .... }` with the appropriate
+service-dependent configuration parameters.
+
+.. code-block:: nix
+
+ let svc = config.system.service;
+ in {
+ # ...
+ imports = [
+ ./modules/ntp
+ # ....
+ ];
+ config.services.ntp = svc.ntp.build {
+ pools = { "pool.ntp.org" = ["iburst"]; };
+ makestep = { threshold = 1.0; limit = 3; };
+ };
+
+Merely including the module won't define the service on its own: it
+only creates the template in ``config.system.service.foo`` and you
+have to create the actual service using the template.
+
+
+
+Consequences
+************
+
+This decision has both good and bad consequences
+
+Pro
+===
+
+* We have a workable system for reusing configuration elements in
+ Liminix.
+
+* We have type checking for most imortant things, reducing the risk of
+ deploying an invalid configuration.
+
+* We have a simple mechanism for creating multiple services based on
+ the same module, without buulding that logic into the module
+ definition itself. For example, we could create two SSH daemons on
+ different ports, or DHCP clients with different configurations on
+ different network devices.
+
+* We expect to be able to automate the generation of module
+ documentation.
+
+Con
+===
+
+
+* By departing somewhat from the NixOS conventions we increase the
+ amount of code we have to write/maintain ourselves - and the
+ learning burden on users who are already familiar with that system.
+
+* Liminix configurations contain function calls and aren't just data,
+ which means we can ony realistically interpret or introspect
+ them with the Nix interpreter itself - we can't query them
+ as data with other non-Nix tools.