document the module-based-services decision

This commit is contained in:
Daniel Barlow 2023-09-17 16:32:22 +01:00
parent c74543c4ff
commit 06b725cb77
2 changed files with 207 additions and 0 deletions

6
doc/adr/README Normal file
View file

@ -0,0 +1,6 @@
Architecture Decision Records
#############################
In this directory you will find descriptions of Liminix architecture
decisions.

201
doc/adr/module-system.rst Normal file
View file

@ -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.