refactor(flake): move assembly code out of lib

This commit is contained in:
2026-08-29 17:05:43 -04:00
parent abac67afb4
commit 1ba3bdf8d6
12 changed files with 46 additions and 53 deletions

370
flake/guests.nix Normal file
View File

@@ -0,0 +1,370 @@
{
inputs,
lib,
self,
specialArgs,
bridgeName,
moduleFiles,
registryAssembly,
}:
let
inherit (lib) escapeShellArg mapAttrsToList;
deriveMac =
name:
let
hash = builtins.hashString "sha256" name;
octet = i: builtins.substring (i * 2) 2 hash;
in
lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]);
guest =
{
name,
interior ? { },
}:
{ config, lib, ... }:
let
optionPath = [ "guests" ] ++ lib.splitString "." name;
cfg = lib.getAttrFromPath optionPath config;
machineName = lib.replaceStrings [ "." ] [ "-" ] name;
networked = cfg.vlan != null;
userMounts = lib.mapAttrs (_guestPath: m: {
inherit (m) hostPath;
isReadOnly = m.readOnly;
}) cfg.mounts;
secretMounts = lib.listToAttrs (
map (
name:
let
path = config.sops.secrets.${name}.path;
in
lib.nameValuePair path {
hostPath = path;
isReadOnly = true;
}
) cfg.secrets
);
mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts);
limitConfig = lib.filterAttrs (_: v: v != null) {
MemoryMax = cfg.limits.memory;
CPUQuota = cfg.limits.cpu;
TasksMax = cfg.limits.tasksMax;
};
createdMounts = lib.filterAttrs (_guestPath: m: m.create != null) cfg.mounts;
mountCreateScript = lib.concatStringsSep "\n" (
mapAttrsToList (
_guestPath: m:
let
create = m.create;
in
''
${config.system.path}/bin/install -d -o ${escapeShellArg (toString create.owner)} -g ${escapeShellArg create.group} -m ${escapeShellArg create.mode} ${escapeShellArg m.hostPath}
''
) createdMounts
);
guestNet =
{ lib, ... }:
{
config = lib.mkIf networked {
networking.useNetworkd = true;
networking.useHostResolvConf = false;
systemd.network.networks."20-eth0" = {
matchConfig.Name = "eth0";
linkConfig.MACAddress = cfg.mac;
networkConfig.DHCP = "yes";
};
};
};
in
{
options = lib.setAttrByPath optionPath {
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
backend = lib.mkOption {
type = lib.types.enum [
"container"
"microvm"
];
default = "container";
description = ''
How the guest is realized. `container` runs the guest as a
systemd-nspawn nested container. `microvm` is reserved for a future
hard-isolation backend and is not built yet.
'';
};
vlan = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 1 4094);
default = null;
example = 10;
description = ''
The tagged VLAN this guest lives on. The guest attaches to its host's
`br-vlan<id>` bridge for that VLAN. Left null, the guest keeps a
private network with no bridge attachment. The id must be one of the
host's `modules.network.vlans.ids`.
'';
};
mac = lib.mkOption {
type = lib.types.str;
default = deriveMac name;
defaultText = lib.literalMD "a stable address derived from the guest's namespace path";
example = "bc:24:11:00:00:01";
description = ''
The guest's MAC address on its VLAN, pinned inside the guest by its
own networkd. Set it to reuse an existing address so a router's DHCP
reservation keeps working. Left unset, a stable address is derived
from the guest's namespace path in the locally-administered range.
'';
};
address = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.23.20.126";
description = ''
Expected IP address assigned outside NixOS for this guest.
Left null, this guest contributes no address to the address registry.
'';
};
reverseProxy = {
enable = lib.mkEnableOption "a reverse-proxy route for this guest";
route = lib.mkOption {
type = registryAssembly.definitions.route.type;
description = "Route registry entry published for this guest.";
};
};
mounts = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
hostPath = lib.mkOption {
type = lib.types.str;
example = "/srv/media";
description = "The path on the host bind-mounted into the guest.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Mount the path read-only. Read-write by default, since a
service must write to the pool data it owns.
'';
};
create = lib.mkOption {
type = lib.types.nullOr (lib.types.submodule {
options = {
type = lib.mkOption {
type = lib.types.enum [ "directory" ];
default = "directory";
description = "Host path type to create before the guest starts.";
};
owner = lib.mkOption {
type = lib.types.oneOf [
lib.types.str
lib.types.ints.positive
];
description = "Owner assigned to the created host path.";
};
group = lib.mkOption {
type = lib.types.str;
description = "Group assigned to the created host path.";
};
mode = lib.mkOption {
type = lib.types.str;
example = "0770";
description = "Mode assigned to the created host path.";
};
};
});
default = null;
description = ''
Opt-in host path creation before the guest starts. Existing
paths are left enabled unless this is set, so served storage
roots can still be required to exist explicitly.
'';
};
};
}
);
default = { };
example = lib.literalExpression ''
{
"/data/media" = { hostPath = "/srv/media"; };
"/data/config" = {
hostPath = "/srv/config/jellyfin";
readOnly = true;
};
}
'';
description = ''
Host paths bind-mounted into the guest, keyed by the path they appear
at inside the guest, so a guest sees exactly the data it should at any
granularity — a single folder or a whole pool. Each mount is
read-write unless `readOnly` is set.
'';
};
secrets = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "jellyfin-api-key" ];
description = ''
Names of the secrets this guest needs. The host is the sole
decryptor: it decrypts each named secret from its own sops files and
bind-mounts the plaintext file into the guest read-only at
`/run/secrets/<name>`, the same path it would occupy on a host, so a
service reads its credentials at a predictable location. The guest
names the files it wants and receives exactly those. It holds no age
key and decrypts nothing itself. Ownership carries across unchanged,
since the container maps ids one to one, so a secret owned by a uid on
the host is owned by that same uid inside the guest.
'';
};
limits = {
memory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "2G";
description = ''
Cap on the guest's memory, applied to its unit as `MemoryMax`.
Accepts systemd size suffixes such as `512M` or `2G`. Left null,
the guest's memory is uncapped.
'';
};
cpu = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "150%";
description = ''
Cap on the guest's CPU, applied to its unit as `CPUQuota`, where
`100%` is one full core. Left null, the guest's CPU is uncapped.
'';
};
tasksMax = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive;
default = null;
example = 512;
description = ''
Cap on the number of processes and threads the guest may spawn,
applied to its unit as `TasksMax`. Left null, the task count is
uncapped.
'';
};
};
nesting = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Grant the guest's interior the prerequisites to run Podman or other
OCI containers of its own. Off by default, so a guest cannot nest
containers. On, the guest's container gains the network-administration
capability its container runtime uses to build bridges and firewall
rules, along with the tun and fuse device nodes such a runtime reaches
for, so the interior's `virtualisation.oci-containers` works with
Podman as its default runtime.
'';
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Start the guest at boot. On by default. Disabled, the guest stays
defined and can be started on demand, but does not come up at boot.
'';
};
};
config = lib.mkIf cfg.enable {
sops.secrets = lib.genAttrs cfg.secrets (_: { });
registry.address.${config.networking.hostName}.${machineName} = lib.mkIf (cfg.address != null) cfg.address;
registry.route.${config.networking.hostName}.${machineName} = lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route;
assertions = [
{
assertion = mountCollisions == [ ];
message = ''
guests.${name} maps a mount at ${lib.concatStringsSep ", " mountCollisions}, colliding with a secret bind-mounted at the same path. Rename the mount or the secret so each in-guest path is used once.
'';
}
{
assertion = cfg.backend == "container";
message = ''
guests.${name}.backend = "${cfg.backend}" is not implemented. Only the "container" backend is built; "microvm" is reserved for future work.
'';
}
{
assertion = !networked || lib.elem cfg.vlan config.modules.network.vlans.ids;
message = ''
guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans.ids (${lib.concatMapStringsSep ", " toString config.modules.network.vlans.ids}). Declare the VLAN on the host or correct the guest's placement.
'';
}
];
systemd.services."container@${machineName}" = lib.mkIf (cfg.backend == "container") (
lib.mkMerge [
{
serviceConfig = limitConfig;
preStart = lib.mkIf (createdMounts != { }) mountCreateScript;
}
(lib.mkIf networked (
let
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (bridgeName cfg.vlan)}.device";
in
{
after = [ bridgeDevice ];
wants = [ bridgeDevice ];
}
))
]
);
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
autoStart = cfg.autoStart;
privateNetwork = lib.mkDefault true;
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
privateUsers = lib.mkDefault "no";
additionalCapabilities = lib.optionals cfg.nesting [ "CAP_NET_ADMIN" ];
allowedDevices = lib.optionals cfg.nesting [
{
node = "/dev/net/tun";
modifier = "rwm";
}
{
node = "/dev/fuse";
modifier = "rwm";
}
];
bindMounts = userMounts // secretMounts;
inherit specialArgs;
config = {
imports =
moduleFiles
++ [
registryAssembly.module
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
{
modules.guest.enable = true;
}
guestNet
interior
];
};
};
};
};
in
{
inherit deriveMac guest;
}