From 0abd3121fbf833324b904027ddcee8ad84232a1a Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 29 Aug 2026 17:05:44 -0400 Subject: [PATCH] refactor(guest): simplify guest options --- flake/guest.nix | 552 +++++++++++++++++--------------------- guests/actualbudget.nix | 2 +- guests/copyparty.nix | 17 +- guests/nesting-sample.nix | 11 - guests/sample.nix | 5 - hosts/neogaia/default.nix | 20 -- 6 files changed, 251 insertions(+), 356 deletions(-) delete mode 100644 guests/nesting-sample.nix delete mode 100644 guests/sample.nix diff --git a/flake/guest.nix b/flake/guest.nix index 3f4d8d8..81b948d 100644 --- a/flake/guest.nix +++ b/flake/guest.nix @@ -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` 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` 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/`, 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 + ; + }; + }; } diff --git a/guests/actualbudget.nix b/guests/actualbudget.nix index fbfb352..10d95ed 100644 --- a/guests/actualbudget.nix +++ b/guests/actualbudget.nix @@ -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%"; diff --git a/guests/copyparty.nix b/guests/copyparty.nix index 5f73fd9..8e2de69 100644 --- a/guests/copyparty.nix +++ b/guests/copyparty.nix @@ -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%"; diff --git a/guests/nesting-sample.nix b/guests/nesting-sample.nix deleted file mode 100644 index 5ecd5c5..0000000 --- a/guests/nesting-sample.nix +++ /dev/null @@ -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 diff --git a/guests/sample.nix b/guests/sample.nix deleted file mode 100644 index 7651aa2..0000000 --- a/guests/sample.nix +++ /dev/null @@ -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 diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index 41d0d9e..17093aa 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -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;