refactor(guest): simplify guest options

This commit is contained in:
2026-08-28 22:51:40 -04:00
parent 9f2957f4be
commit f5b854ca02
6 changed files with 251 additions and 356 deletions

View File

@@ -8,355 +8,285 @@
util,
}:
let
mkGuest =
mkGuestOptions =
{
config,
lib,
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;
{
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
vlan = lib.mkOption {
type = lib.types.ints.between 1 4094;
example = 10;
description = ''
The tagged VLAN this guest lives on. The guest attaches to its host's
`br-vlan<id>` bridge for that VLAN. The id must be one of the host's
`modules.network.vlans.ids`.
'';
};
mac = lib.mkOption {
type = lib.types.str;
default = util.deriveMac "${config.networking.hostName}.${name}";
defaultText = lib.literalMD "a stable address derived from the host name and guest option 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 host name and guest option 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";
createdMounts = lib.filterAttrs (_guestPath: m: m.create != null) cfg.mounts;
mountCreateScript = lib.concatStringsSep "\n" (
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 = {
directory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/media";
description = "Host directory bind-mounted into the guest.";
};
file = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/run/secrets/service-password";
description = "Host file bind-mounted into the guest.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Mount the host path read-only inside the guest.";
};
owner = lib.mkOption {
type = lib.types.nullOr (
lib.types.oneOf [
lib.types.str
lib.types.ints.positive
]
);
default = null;
description = "Owner assigned to the host directory before the guest starts.";
};
group = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Group assigned to the host directory before the guest starts.";
};
mode = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "0770";
description = "Mode assigned to the host directory before the guest starts.";
};
};
}
);
default = { };
example = lib.literalExpression ''
{
"/data/media" = { directory = "/srv/media"; };
"/data/config" = { directory = "/srv/config/jellyfin"; };
}
'';
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, a whole pool, or a decrypted secret.
Mounts are read-write unless `readOnly` is set.
'';
};
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.
'';
};
};
};
mkGuestConfig =
{
config,
interior,
lib,
name,
}:
let
cfg = config.guests.${name};
directoryMounts = lib.filterAttrs (_: mount: mount.directory != null) cfg.mounts;
directoryScript = lib.concatStringsSep "\n" (
lib.mapAttrsToList (
_guestPath: m:
let
create = m.create;
in
_: mount:
''
${config.system.path}/bin/install -d -o ${lib.escapeShellArg (toString create.owner)} -g ${lib.escapeShellArg create.group} -m ${lib.escapeShellArg create.mode} ${lib.escapeShellArg m.hostPath}
${config.system.path}/bin/install -d ${lib.optionalString (mount.owner != null) "-o ${lib.escapeShellArg (toString mount.owner)}"} ${lib.optionalString (mount.group != null) "-g ${lib.escapeShellArg mount.group}"} ${lib.optionalString (mount.mode != null) "-m ${lib.escapeShellArg mount.mode}"} ${lib.escapeShellArg mount.directory}
''
) createdMounts
) directoryMounts
);
guestNet =
{ lib, ... }:
{
config = lib.mkIf networked {
networking.useNetworkd = true;
networking.useHostResolvConf = false;
networking.useNetworkd = true;
networking.useHostResolvConf = false;
systemd.network.networks."20-eth0" = {
matchConfig.Name = "eth0";
linkConfig.MACAddress = cfg.mac;
networkConfig.DHCP = "yes";
};
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 = util.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";
lib.mkIf cfg.enable {
registry.address.${config.networking.hostName}.${name} = lib.mkIf (cfg.address != null) cfg.address;
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.
'';
};
};
}
registry.route.${config.networking.hostName}.${name} = lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route;
assertions =
let
hasDirectorySettings = mount: mount.owner != null || mount.group != null || mount.mode != null;
hasCompleteDirectorySettings = mount: mount.owner != null && mount.group != null && mount.mode != null;
hasOneSource = mount: (mount.directory != null) != (mount.file != null);
mountsWithoutOneSource = lib.attrNames (lib.filterAttrs (_: mount: !hasOneSource mount) cfg.mounts);
fileMountsWithDirectorySettings = lib.attrNames (
lib.filterAttrs (_: mount: mount.file != null && hasDirectorySettings mount) cfg.mounts
);
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 = [
incompleteHostDirectoryMounts = lib.attrNames (
lib.filterAttrs (_: mount: mount.directory != null && hasDirectorySettings mount && !hasCompleteDirectorySettings mount) cfg.mounts
);
in
[
{
assertion = mountCollisions == [ ];
assertion = mountsWithoutOneSource == [ ];
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.
guests.${name} mounts must set exactly one of directory or file for ${lib.concatStringsSep ", " mountsWithoutOneSource}.
'';
}
{
assertion = cfg.backend == "container";
assertion = fileMountsWithDirectorySettings == [ ];
message = ''
guests.${name}.backend = "${cfg.backend}" is not implemented. Only the "container" backend is built; "microvm" is reserved for future work.
guests.${name} file mounts must not set owner, group, or mode for ${lib.concatStringsSep ", " fileMountsWithDirectorySettings}.
'';
}
{
assertion = !networked || lib.elem cfg.vlan config.modules.network.vlans.ids;
assertion = incompleteHostDirectoryMounts == [ ];
message = ''
guests.${name} has incomplete host directory settings for ${lib.concatStringsSep ", " incompleteHostDirectoryMounts}. Set owner, group, and mode together.
'';
}
{
assertion = 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" ] (util.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 (util.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 =
[
repositoryModules
]
++ [
registryAssembly.module
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
{
modules.guest.enable = true;
}
guestNet
interior
];
systemd.services."container@${name}" = lib.mkMerge [
{
serviceConfig = lib.filterAttrs (_: v: v != null) {
MemoryMax = cfg.limits.memory;
CPUQuota = cfg.limits.cpu;
TasksMax = cfg.limits.tasksMax;
};
preStart = lib.mkIf (directoryMounts != { }) directoryScript;
}
(let
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (util.bridgeName cfg.vlan)}.device";
in
{
after = [ bridgeDevice ];
wants = [ bridgeDevice ];
})
];
containers.${name} = {
autoStart = true;
privateNetwork = lib.mkDefault true;
hostBridge = util.bridgeName cfg.vlan;
privateUsers = lib.mkDefault "no";
bindMounts = lib.mapAttrs (_: mount: {
hostPath = if mount.directory != null then mount.directory else mount.file;
isReadOnly = mount.readOnly;
}) cfg.mounts;
inherit specialArgs;
config = {
imports = [
repositoryModules
registryAssembly.module
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
{
modules.guest.enable = true;
}
guestNet
interior
];
};
};
};
in
{
inherit mkGuest;
mkGuest =
{
name,
interior ? { },
}:
{ config, lib, ... }:
{
options.guests.${name} = mkGuestOptions { inherit config lib name; };
config = mkGuestConfig {
inherit
config
interior
lib
name
;
};
};
}

View File

@@ -39,7 +39,7 @@ in
];
guests.actualbudget = {
mounts.${dataDir}.hostPath = cfg.dataPath;
mounts.${dataDir}.directory = cfg.dataPath;
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";

View File

@@ -106,19 +106,20 @@ in
];
guests.copyparty = {
secrets = [ cfg.passwordSecret ];
mounts =
{
${cfg.stateDir} = {
hostPath = cfg.statePath;
create = {
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = "storage";
mode = "0770";
};
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = "storage";
mode = "0770";
};
${"/run/secrets/${cfg.passwordSecret}"} = {
file = config.sops.secrets.${cfg.passwordSecret}.path;
readOnly = true;
};
}
// lib.mapAttrs' (_urlPath: volume: lib.nameValuePair volume.path { inherit (volume) hostPath; }) cfg.volumes;
// lib.mapAttrs' (_urlPath: volume: lib.nameValuePair volume.path { directory = volume.hostPath; }) cfg.volumes;
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";

View File

@@ -1,11 +0,0 @@
args@{ flake, ... }:
# A sample guest whose interior runs an OCI container on Podman.
# The image is pulled at runtime, so the guest builds with no build-time fetch.
flake.mkGuest {
name = "nesting-sample";
interior = {
virtualisation.oci-containers.containers.hello = {
image = "docker.io/library/hello-world";
};
};
} args

View File

@@ -1,5 +0,0 @@
args@{ flake, ... }:
# The tracer-bullet guest: the thinnest complete path from discovery to a
# running nested container. Its interior is just the guest-base — the baseline
# toolset and SSH access — so it proves the concept without carrying a service.
flake.mkGuest { name = "sample"; } args

View File

@@ -40,26 +40,6 @@
modules.network.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
modules.network.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
# The walking-skeleton guest, enabled like any module: proves the guest path
# end to end through this host's `nix flake check`.
# Modest caps keep the skeleton guest from starving the laptop.
guests.sample.enable = true;
guests.sample.limits = {
memory = "1G";
cpu = "100%";
tasksMax = 512;
};
# The nesting guest, run with `nesting` on: proves an interior OCI container
# on Podman builds end to end through this host's `nix flake check`.
guests.nesting-sample.enable = true;
guests.nesting-sample.nesting = true;
guests.nesting-sample.limits = {
memory = "1G";
cpu = "100%";
tasksMax = 512;
};
modules.shell.herdr.enable = true;
modules.agents.tools.gitea-axi.enable = true;
modules.agents.pi.enable = true;