diff --git a/AGENTS.md b/AGENTS.md index 9281194..1fc8896 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,16 @@ One flake builds every machine I own. ## Repository Conventions -- Follow `docs/technical-writing.md` for comments, user-facing descriptions, commit prose, pull-request prose, and Markdown documentation. +- Use `docs/technical-writing.md` and `docs/dictionary.md` while authoring any file. + Apply them to comments, user-facing descriptions, commit prose, pull-request prose, and Markdown documentation. - Comments posted to the forge under the operator's account must end with `— Pi`. The sign-off identifies the agent as the author instead of the operator. - Commit messages follow Conventional Commits, defined in `docs/conventional-commits.md`. Use a scope for the changed module or host, such as `fish`, `nvim`, or `neogaia`. - Keep commit messages about the Git history, not the forge workflow. Do not reference issue or pull-request numbers unless the commit itself requires a stable external reference. +- During pull-request development, add follow-up commits instead of amending existing commits. + Preserve review history until the pull request is ready to merge. - Use `gitea-axi` for Gitea issues, pull requests, labels, reviews, comments, and milestones. It resolves this repository from the `origin` remote inside the checkout. @@ -66,7 +69,7 @@ One flake builds every machine I own. - Guest modules are host-agnostic wiring, while host files own deployment-specific service configuration such as served volumes and host paths. For example, `guests/copyparty.nix` should expose options and translate them into guest mounts/service settings, while `hosts/pikachu/default.nix` chooses `/inbox`, `/media`, and their backing paths. Guest addresses and routes are host-level configuration because a guest module can be used on multiple hosts. -- `self.lib.registry.collect { hosts = self.nixosConfigurations; }` reads only top-level host `config.registry` declarations. +- The registry collector reads only top-level host `config.registry` declarations. Registry declarations inside guest interiors live under `config.containers..config.registry`. The collected registry does not include those guest-interior declarations. Publish guest values from host-side code, or extend the collector to recurse into guests. diff --git a/docs/dictionary.md b/docs/dictionary.md index 2037892..fc775e6 100644 --- a/docs/dictionary.md +++ b/docs/dictionary.md @@ -45,11 +45,15 @@ Use the simplest accurate word when a term is not listed. | operator | Agent tooling | The human driving the development process. Use when distinguishing the human from the agent acting on their behalf. | | guest | dotfiles | A systemd-nspawn container managed by this flake. Use for configured containers under `guests/`. | | host | dotfiles | A machine with a NixOS configuration built by this flake. | -| module | dotfiles | In this repository, a self-contained configuration under `modules/`, usually scoped to one program, service, or subsystem. Use `Nix module` for the language-level module-system concept. | +| module | dotfiles | In this repository, a self-contained configuration under `modules/`. Use for one program, service, or subsystem. | | registry | dotfiles | A typed configuration namespace whose entries can be collected across hosts and reused by other configuration. | | Conventional Commits | Git | A commit-message format defined in `docs/conventional-commits.md`. | | forge | Git | A web service that hosts Git repositories, pull requests, issues, and code review comments. Use when the exact provider does not matter. | | flake | Nix | A Nix project with pinned inputs and declared outputs. Use for the repository-level build unit defined by `flake.nix`. | -| Nix module | Nix | A Nix language unit that declares or sets options through the Nix module system. | +| assembly file | Nix | A Nix file under `flake/` that constructs flake outputs or arguments passed to NixOS modules. | +| assembly function | Nix | A project function in `flake/` that creates modules, systems, or output values. | +| library file | Nix | A Nix file that defines reusable project functions instead of a NixOS module or flake output. | +| library function | Nix | A pure project function exposed under `util`. | +| NixOS module | Nix | A Nix file evaluated by `nixosSystem` for a NixOS configuration. | | overlay | Nix | A function that modifies a Nix package set by adding or replacing packages. | | workspace | Window management | A named or numbered group of windows in a graphical session. Some desktop environments call this a virtual desktop. | diff --git a/docs/nix.md b/docs/nix.md new file mode 100644 index 0000000..b1177af --- /dev/null +++ b/docs/nix.md @@ -0,0 +1,270 @@ +# Nix Conventions + +This document records local Nix style and design conventions. +Use these conventions when changing this flake. +Read this document with `docs/dictionary.md`. + +## Names + +### Reserve `lib` for `nixpkgs.lib` + +Use `lib` only for the base `nixpkgs.lib` value. +Do not use `lib` for project functions. +Use `util` for library functions passed through module arguments. +Use `flake.mk` for assembly functions used by flake outputs. + +Avoid: + +```nix +specialArgs = { + lib = util; +}; +``` + +Use: + +```nix +specialArgs = { + inherit util; +}; +``` + +Then call library functions through `util` inside modules: + +```nix +{ + lib, + util, + ... +}: +{ + networking.networkmanager.unmanaged = [ "interface-name:${util.bridgeName 10}" ]; +} +``` + +### Name project function namespaces by role + +Put pure project functions under `util`. +Put functions that create systems or output values under `flake`. +This keeps data-only functions separate from functions that carry flake context. + +Avoid: + +```nix +util.mkHosts { root = self + "/hosts"; } +``` + +Use: + +```nix +flake.mkHosts { root = self + "/hosts"; } +``` + +## File Placement + +### Put files in the directory that owns their role + +Choose the file location before writing a Nix file. +Put assembly files under `flake/`. +Put library files under `lib/`. +Put modules under `modules/`. +Put host configurations under `hosts//`. +Put host-side guest wiring under `guests/`. +Put registry definitions under `registry/`. +Keep `flake.nix` focused on inputs and output wiring. +Do not add root-level Nix files other than `flake.nix`. + +A module is self-contained configuration under `modules/`. +A module configures one program, service, or subsystem. +Files under `hosts/`, `guests/`, and `modules/` return NixOS modules. + +### Use `default.nix` for module directories with support files + +Use `default.nix` as the primary module when a module directory owns support files. +Keep sibling files for data or code read by that primary module. + +Avoid: + +```text +modules/shell/fish/fish.nix +modules/shell/fish/config.fish +``` + +Use: + +```text +modules/shell/fish/default.nix +modules/shell/fish/config.fish +``` + +### Use non-module `default.nix` files only for directory assembly + +Outside `modules/`, use `default.nix` to connect files in its directory. +Do not define primary assembly functions in `default.nix`. +Put primary assembly functions in named files. + +Avoid: + +```text +flake/default.nix # defines mkHost +``` + +Use: + +```text +flake/default.nix # imports ./host.nix +flake/host.nix # defines mkHosts +``` + +### Keep assembly files focused on one concept + +Give each assembly file under `flake/` one top-level concept. +Keep helper functions private to that file when no other file needs them. +Export the constructor for the concept when another assembly file needs it. +Name that constructor `mk`. + +Avoid: + +```nix +{ + inherit collectNixFiles mkHost mkHosts; +} +``` + +Use: + +```nix +{ + inherit mkHosts; +} +``` + +## Function Arguments + +### Use closed interfaces for assembly files and library files + +Use exact function argument patterns for assembly files and library files. +The caller owns the argument set for these files. +The imported file names each accepted attribute. +The imported file rejects stale or accidental extra attributes. + +Avoid: + +```nix +{ + inputs, + lib, + self, + ... +}: +``` + +Use: + +```nix +{ + inputs, + lib, + self, +}: +``` + +### Pass function arguments explicitly + +Pass only the values an assembly file or library file needs. +Bind `lib` to the base `nixpkgs.lib` value at the call site. +Use `inherit` only for values that keep the same name. + +Avoid: + +```nix +import ./flake inputs +``` + +Use: + +```nix +let + inherit (nixpkgs) lib; +in +import ./flake { inherit lib inputs self; } +``` + +### Keep NixOS module interfaces open + +Files under `hosts/`, `guests/`, and `modules/` return NixOS modules. +`nixosSystem` owns their argument shape. +Name each argument the module uses. +Keep `...` so the module accepts standard arguments and special arguments. + +Avoid: + +```nix +{ + config, + lib, + pkgs, + registry, +}: +``` + +Use: + +```nix +{ + config, + lib, + pkgs, + registry, + ... +}: +``` + +## `let` Bindings + +### Use role-specific binding names + +A `let` binding name must explain the value's role in the expression. +Avoid names that describe only the value's type. + +Avoid: + +```nix +let + names = lib.attrNames hosts; +in +lib.genAttrs names mkHost +``` + +Use: + +```nix +let + hostNames = lib.attrNames hosts; +in +lib.genAttrs hostNames mkHost +``` + +## Standard Library Calls + +### Qualify standard library calls in flake assembly + +Use qualified standard library calls inside assembly files. +Use `lib.mapAttrs` instead of inheriting `mapAttrs` from `lib`. +This keeps the source of each function visible. + +Avoid: + +```nix +let + inherit (lib) mapAttrs; +in +mapAttrs f attrs +``` + +Use: + +```nix +lib.mapAttrs f attrs +``` + diff --git a/flake.nix b/flake.nix index f5e8259..aeaff75 100644 --- a/flake.nix +++ b/flake.nix @@ -76,14 +76,9 @@ { self, nixpkgs, ... }@inputs: let inherit (nixpkgs) lib; - my = import ./lib { inherit lib inputs self; }; in { - # Helper functions for discovering and building hosts. - lib = my; - - # Every host under hosts/ is discovered and built. - nixosConfigurations = my.mkHosts (self + "/hosts"); + inherit (import ./flake { inherit lib inputs self; }) nixosConfigurations; # A project shell for agent-local resources that should travel with this # checkout rather than the operator's global profile. diff --git a/flake/default.nix b/flake/default.nix new file mode 100644 index 0000000..230e711 --- /dev/null +++ b/flake/default.nix @@ -0,0 +1,53 @@ +{ + lib, + inputs, + self, +}: +let + util = import ../lib { inherit lib; }; + + registry = import ./registry.nix { + inherit lib; + hosts = self.nixosConfigurations; + root = self + "/registry"; + }; + + modules = import ./module.nix { + inherit util; + root = self + "/modules"; + }; + + guestModules = import ./guest.nix { + inherit + inputs + lib + registry + specialArgs + util + ; + repositoryModules = modules; + root = self + "/guests"; + }; + + specialArgs = { + inherit + inputs + self + util + ; + registry = registry.collected; + }; +in +{ + nixosConfigurations = import ./host.nix { + inherit + inputs + lib + specialArgs + guestModules + ; + registryModule = registry.module; + repositoryModules = modules; + root = self + "/hosts"; + }; +} diff --git a/flake/guest.nix b/flake/guest.nix new file mode 100644 index 0000000..5f8c50f --- /dev/null +++ b/flake/guest.nix @@ -0,0 +1,279 @@ +{ + inputs, + lib, + root, + specialArgs, + repositoryModules, + registry, + util, +}: +let + mkGuest = + name: + { config, lib, ... }: + let + cfg = config.guests.${name}; + + directoryMounts = lib.filterAttrs (_: mount: mount.directory != null) cfg.mounts; + + directoryScript = lib.concatStringsSep "\n" ( + lib.mapAttrsToList ( + _: mount: + '' + ${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} + '' + ) directoryMounts + ); + in + { + options.guests.${name} = { + 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"; + + route = lib.mkOption { + type = registry.types.route; + 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. + ''; + }; + interior = lib.mkOption { + type = lib.types.deferredModule; + default = { }; + description = "NixOS module imported into the guest interior."; + }; + 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. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + 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); + mountsWithInvalidSource = lib.attrNames (lib.filterAttrs (_: mount: !hasOneSource mount) cfg.mounts); + fileMountsWithHostSettings = lib.attrNames ( + lib.filterAttrs (_: mount: mount.file != null && hasDirectorySettings mount) cfg.mounts + ); + directoryMountsWithPartialHostSettings = lib.attrNames ( + lib.filterAttrs (_: mount: mount.directory != null && hasDirectorySettings mount && !hasCompleteDirectorySettings mount) cfg.mounts + ); + in + [ + { + assertion = mountsWithInvalidSource == [ ]; + message = '' + guests.${name} mounts ${lib.concatStringsSep ", " mountsWithInvalidSource} must set exactly one of `directory` or `file`. + ''; + } + { + assertion = fileMountsWithHostSettings == [ ]; + message = '' + guests.${name} file mounts ${lib.concatStringsSep ", " fileMountsWithHostSettings} must not set `owner`, `group`, or `mode`. + ''; + } + { + assertion = directoryMountsWithPartialHostSettings == [ ]; + message = '' + guests.${name} directory mounts ${lib.concatStringsSep ", " directoryMountsWithPartialHostSettings} must 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. + ''; + } + ]; + + registry.address.${config.networking.hostName}.${name} = + lib.mkIf (cfg.address != null) cfg.address; + + registry.route.${config.networking.hostName}.${name} = + lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route; + + systemd.services."container@${name}" = + let + bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (util.bridgeName cfg.vlan)}.device"; + in + { + after = [ bridgeDevice ]; + wants = [ bridgeDevice ]; + + serviceConfig = lib.filterAttrs (_: v: v != null) { + MemoryMax = cfg.limits.memory; + CPUQuota = cfg.limits.cpu; + TasksMax = cfg.limits.tasksMax; + }; + preStart = lib.mkIf (directoryMounts != { }) directoryScript; + }; + + 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 = [ + inputs.sops-nix.nixosModules.sops + inputs.stylix.nixosModules.stylix + + repositoryModules + registry.module + cfg.interior + + { + modules.guest.enable = true; + + networking.useNetworkd = true; + networking.useHostResolvConf = false; + + systemd.network.networks."20-eth0" = { + matchConfig.Name = "eth0"; + linkConfig.MACAddress = cfg.mac; + networkConfig.DHCP = "yes"; + }; + } + ]; + }; + }; + }; + }; +in +let + files = util.collectNixFiles root; + guestFiles = lib.filterAttrs (file: type: type == "regular" && lib.hasSuffix ".nix" file) (builtins.readDir root); + names = map (file: lib.removeSuffix ".nix" file) (lib.attrNames guestFiles); +in +{ + imports = files ++ map mkGuest names; +} diff --git a/flake/host.nix b/flake/host.nix new file mode 100644 index 0000000..a25372d --- /dev/null +++ b/flake/host.nix @@ -0,0 +1,45 @@ +{ + inputs, + lib, + root, + specialArgs, + repositoryModules, + + guestModules, + registryModule, +}: +let + mkHost = + { + root, + name, + }: + lib.nixosSystem { + inherit specialArgs; + modules = [ + inputs.chaotic.nixosModules.default + inputs.disko.nixosModules.disko + inputs.sops-nix.nixosModules.sops + inputs.stylix.nixosModules.stylix + + repositoryModules + guestModules + + # TODO: refactor + registryModule (root + "/${name}") + + { + networking.hostName = name; + modules.host.enable = true; + } + ]; + }; +in +let + directories = lib.filterAttrs (_name: type: type == "directory") (builtins.readDir root); + names = lib.attrNames directories; +in +lib.genAttrs names ( + name: + mkHost { inherit root name; } +) diff --git a/flake/module.nix b/flake/module.nix new file mode 100644 index 0000000..9dead8f --- /dev/null +++ b/flake/module.nix @@ -0,0 +1,7 @@ +{ + root, + util, +}: +{ + imports = util.collectNixFiles root; +} diff --git a/lib/registry.nix b/flake/registry.nix similarity index 56% rename from lib/registry.nix rename to flake/registry.nix index ee2cf30..67c7c05 100644 --- a/lib/registry.nix +++ b/flake/registry.nix @@ -1,35 +1,16 @@ -{ lib, self }: +{ hosts, lib, root }: let - inherit (lib) - all - concatLists - concatMapStringsSep - filter - filterAttrs - foldl' - hasAttr - hasSuffix - mapAttrs - mapAttrs' - mapAttrsToList - mkOption - nameValuePair - removeSuffix - types - unique - ; - - checkType = types.submodule { + checkType = lib.types.submodule { options = { - assertion = mkOption { - type = types.functionTo types.bool; + assertion = lib.mkOption { + type = lib.types.functionTo lib.types.bool; description = '' Predicate run against this registry's flattened collected entries. ''; }; - message = mkOption { - type = types.str; + message = lib.mkOption { + type = lib.types.str; description = '' Error message shown when this registry validation check fails. ''; @@ -38,7 +19,7 @@ let }; predicates = { - uniqueValue = value: entries: builtins.length (unique (map value entries)) == builtins.length entries; + uniqueValue = value: entries: builtins.length (lib.unique (map value entries)) == builtins.length entries; }; registryLib = lib // { @@ -49,23 +30,23 @@ let registryDefinitionModule = { options = { - type = mkOption { - type = types.raw; + type = lib.mkOption { + type = lib.types.raw; description = '' NixOS option type for one registry entry. ''; }; - replicate = mkOption { - type = types.bool; + replicate = lib.mkOption { + type = lib.types.bool; default = false; description = '' Whether collected entries are merged and published under every host key. ''; }; - checks = mkOption { - type = types.listOf checkType; + checks = lib.mkOption { + type = lib.types.listOf checkType; default = [ ]; description = '' Validation checks for this registry's flattened collected entries. @@ -74,10 +55,10 @@ let }; }; - registryName = fileName: removeSuffix ".nix" fileName; + registryName = fileName: lib.removeSuffix ".nix" fileName; - registryOption = registry: mkOption { - type = types.attrsOf (types.attrsOf registry.type); + registryOption = registry: lib.mkOption { + type = lib.types.attrsOf (lib.types.attrsOf registry.type); default = { }; description = '' Lazily collected fleet-visible registry entries, nested by host and entry name. @@ -98,19 +79,19 @@ let inherit (evaluated.config) type replicate checks; }; - definitions = mapAttrs' ( + definitions = lib.mapAttrs' ( name: type: let - path = self + "/registry/${name}"; + path = root + "/${name}"; in - nameValuePair (registryName name) (mkRegistry path) - ) (filterAttrs (name: type: type == "regular" && hasSuffix ".nix" name) (builtins.readDir (self + "/registry"))); + lib.nameValuePair (registryName name) (mkRegistry path) + ) (lib.filterAttrs (name: type: type == "regular" && lib.hasSuffix ".nix" name) (builtins.readDir root)); flattenRegistryEntries = registryName: data: - concatLists ( - mapAttrsToList ( + lib.concatLists ( + lib.mapAttrsToList ( host: entries: - mapAttrsToList (entry: value: { + lib.mapAttrsToList (entry: value: { path = "registry.${registryName}.${host}.${entry}"; inherit host entry value; }) entries @@ -118,17 +99,17 @@ let ); rawDeclarations = registryName: hosts: - mapAttrs (_hostName: host: host.config.registry.${registryName} or { }) hosts; + lib.mapAttrs (_hostName: host: host.config.registry.${registryName} or { }) hosts; replicatedEntries = registryName: raw: let - entries = concatLists ( - concatLists ( - mapAttrsToList ( + entries = lib.concatLists ( + lib.concatLists ( + lib.mapAttrsToList ( _evalHost: hostDeclarations: - mapAttrsToList ( + lib.mapAttrsToList ( hostName: declarations: - mapAttrsToList (entryName: value: { + lib.mapAttrsToList (entryName: value: { path = "registry.${registryName}.${hostName}.${entryName}"; inherit entryName value; }) declarations @@ -137,9 +118,9 @@ let ) ); in - foldl' ( + lib.foldl' ( acc: entry: - if hasAttr entry.entryName acc then + if lib.hasAttr entry.entryName acc then if acc.${entry.entryName}.value == entry.value then acc else @@ -152,13 +133,13 @@ let replicatedData = registryName: hosts: raw: let - entries = mapAttrs (_entryName: entry: entry.value) (replicatedEntries registryName raw); + entries = lib.mapAttrs (_entryName: entry: entry.value) (replicatedEntries registryName raw); in - mapAttrs (_hostName: _host: entries) hosts; + lib.mapAttrs (_hostName: _host: entries) hosts; validationAssertions = data: - concatLists ( - mapAttrsToList ( + lib.concatLists ( + lib.mapAttrsToList ( registryName: registry: let entries = flattenRegistryEntries registryName data.${registryName}; @@ -173,28 +154,28 @@ let assertValid = data: let assertions = validationAssertions data; - failures = filter (assertion: !assertion.assertion) assertions; + failures = lib.filter (assertion: !assertion.assertion) assertions; in - assert lib.assertMsg (all (assertion: assertion.assertion) assertions) ( - concatMapStringsSep "\n" (assertion: assertion.message) failures + assert lib.assertMsg (lib.all (assertion: assertion.assertion) assertions) ( + lib.concatMapStringsSep "\n" (assertion: assertion.message) failures ); data; join = registry: host: entry: - filterAttrs (_name: value: value != null) ( - mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions + lib.filterAttrs (_name: value: value != null) ( + lib.mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions ); collectData = { hosts }: let - raw = mapAttrs (registryName: _registry: rawDeclarations registryName hosts) definitions; - data = mapAttrs ( + raw = lib.mapAttrs (registryName: _registry: rawDeclarations registryName hosts) definitions; + data = lib.mapAttrs ( registryName: registry: if registry.replicate or false then replicatedData registryName hosts raw.${registryName} else - mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts + lib.mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts ) definitions; in assertValid data; @@ -206,11 +187,11 @@ let }; in { - inherit definitions predicates; + types = lib.mapAttrs (_name: definition: definition.type) definitions; module = { - options.registry = mapAttrs (_name: registry: registryOption registry) definitions; + options.registry = lib.mapAttrs (_name: registry: registryOption registry) definitions; }; - collect = args: withOperations (collectData args); + collected = withOperations (collectData { inherit hosts; }); } diff --git a/guests/actualbudget.nix b/guests/actualbudget.nix index f22edd2..4310651 100644 --- a/guests/actualbudget.nix +++ b/guests/actualbudget.nix @@ -1,7 +1,6 @@ { config, lib, - my, ... }: let @@ -9,18 +8,6 @@ let dataDir = "/data"; in { - imports = [ - (my.guest { - name = "actualbudget"; - interior = { - modules.services.actualbudget = { - enable = true; - inherit dataDir; - }; - }; - }) - ]; - options.guests.actualbudget = { dataPath = lib.mkOption { type = lib.types.nullOr lib.types.str; @@ -39,7 +26,13 @@ in ]; guests.actualbudget = { - mounts.${dataDir}.hostPath = cfg.dataPath; + interior = { + modules.services.actualbudget = { + enable = true; + inherit dataDir; + }; + }; + 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 ee2148f..4b79572 100644 --- a/guests/copyparty.nix +++ b/guests/copyparty.nix @@ -1,7 +1,6 @@ { config, lib, - my, ... }: let @@ -34,26 +33,6 @@ let }; in { - imports = [ - (my.guest { - name = "copyparty"; - interior = { - modules.services.copyparty = { - enable = true; - inherit (cfg) accountName stateDir; - passwordFile = "/run/secrets/${cfg.passwordSecret}"; - reverseProxy = { - enable = cfg.reverseProxy.enable; - inherit (cfg.reverseProxy) trustedSources; - }; - volumes = lib.mapAttrs (_urlPath: volume: { - inherit (volume) path access flags; - }) cfg.volumes; - }; - }; - }) - ]; - options.guests.copyparty = { accountName = lib.mkOption { type = lib.types.str; @@ -106,19 +85,33 @@ in ]; guests.copyparty = { - secrets = [ cfg.passwordSecret ]; + interior.modules.services.copyparty = { + enable = true; + inherit (cfg) accountName stateDir; + passwordFile = "/run/secrets/${cfg.passwordSecret}"; + reverseProxy = { + enable = cfg.reverseProxy.enable; + inherit (cfg.reverseProxy) trustedSources; + }; + volumes = lib.mapAttrs (_urlPath: volume: { + inherit (volume) path access flags; + }) cfg.volumes; + }; + 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 5941932..0000000 --- a/guests/nesting-sample.nix +++ /dev/null @@ -1,11 +0,0 @@ -args@{ my, ... }: -# 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. -my.guest { - name = "nesting-sample"; - interior = { - virtualisation.oci-containers.containers.hello = { - image = "docker.io/library/hello-world"; - }; - }; -} args diff --git a/guests/reverse-proxy.nix b/guests/reverse-proxy.nix index c8871aa..27eb287 100644 --- a/guests/reverse-proxy.nix +++ b/guests/reverse-proxy.nix @@ -1,16 +1,14 @@ { config, lib, - my, ... }: let giteaSshBackend = "10.23.20.45:2022"; in { - imports = [ - (my.guest { - name = "reverse-proxy"; + config = lib.mkIf config.guests.reverse-proxy.enable { + guests.reverse-proxy = { interior = { modules.services.reverse-proxy.enable = true; @@ -66,11 +64,7 @@ in } ''; }; - }) - ]; - config = lib.mkIf config.guests.reverse-proxy.enable { - guests.reverse-proxy = { limits = { memory = lib.mkDefault "1G"; cpu = lib.mkDefault "100%"; diff --git a/guests/sample.nix b/guests/sample.nix deleted file mode 100644 index 293621a..0000000 --- a/guests/sample.nix +++ /dev/null @@ -1,5 +0,0 @@ -args@{ my, ... }: -# 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. -my.guest { 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; diff --git a/lib/default.nix b/lib/default.nix index 7826de4..9c535ce 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -1,100 +1,9 @@ -{ - lib, - inputs, - self, -}: +{ lib }: let - inherit (lib) - attrNames - filterAttrs - genAttrs - flatten - hasSuffix - mapAttrsToList - ; - - registry = import ./registry.nix { inherit lib self; }; - - collectNixFiles = - dir: - flatten ( - mapAttrsToList ( - name: type: - let - path = dir + "/${name}"; - in - if type == "directory" then - collectNixFiles path - else if type == "regular" && hasSuffix ".nix" name then - [ path ] - else - [ ] - ) (builtins.readDir dir) - ); - - moduleFiles = collectNixFiles (self + "/modules"); - guestFiles = collectNixFiles (self + "/guests"); - - specialArgs = { - inherit inputs self; - my = self.lib; - registry = self.lib.registry.collect { hosts = self.nixosConfigurations; }; - }; - - bridgeName = id: "br-vlan${toString id}"; - - guests = import ./guests.nix { - inherit - inputs - lib - self - specialArgs - bridgeName - moduleFiles - registry - ; - }; - - mkHost = - { - hostName, - system ? "x86_64-linux", - }: - inputs.nixpkgs.lib.nixosSystem { - inherit system specialArgs; - modules = - moduleFiles - ++ guestFiles - ++ [ - inputs.chaotic.nixosModules.default - inputs.disko.nixosModules.disko - inputs.sops-nix.nixosModules.sops - inputs.stylix.nixosModules.stylix - registry.module - (self + "/hosts/${hostName}") - { - networking.hostName = hostName; - modules.host.enable = true; - } - ]; - }; - - mkHosts = - hostsDir: - let - hostNames = attrNames (filterAttrs (_name: type: type == "directory") (builtins.readDir hostsDir)); - in - genAttrs hostNames (hostName: mkHost { inherit hostName; }); + files = import ./files.nix { inherit lib; }; + network = import ./network.nix { inherit lib; }; in { - inherit - collectNixFiles - mkHost - mkHosts - bridgeName - registry - ; - - inherit (guests) deriveMac guest; - + inherit (files) collectNixFiles; + inherit (network) bridgeName deriveMac; } diff --git a/lib/files.nix b/lib/files.nix new file mode 100644 index 0000000..daecfdd --- /dev/null +++ b/lib/files.nix @@ -0,0 +1,22 @@ +{ lib }: +let + collectNixFiles = + dir: + lib.flatten ( + lib.mapAttrsToList ( + name: type: + let + path = dir + "/${name}"; + in + if type == "directory" then + collectNixFiles path + else if type == "regular" && lib.hasSuffix ".nix" name then + [ path ] + else + [ ] + ) (builtins.readDir dir) + ); +in +{ + inherit collectNixFiles; +} diff --git a/lib/guests.nix b/lib/guests.nix deleted file mode 100644 index 6822f3b..0000000 --- a/lib/guests.nix +++ /dev/null @@ -1,370 +0,0 @@ -{ - inputs, - lib, - self, - specialArgs, - bridgeName, - moduleFiles, - registry, -}: -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` 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 = self.lib.registry.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/`, 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 - ++ [ - registry.module - inputs.sops-nix.nixosModules.sops - inputs.stylix.nixosModules.stylix - { - modules.guest.enable = true; - } - guestNet - interior - ]; - }; - }; - }; - }; -in -{ - inherit deriveMac guest; -} diff --git a/lib/network.nix b/lib/network.nix new file mode 100644 index 0000000..f8524f1 --- /dev/null +++ b/lib/network.nix @@ -0,0 +1,12 @@ +{ lib }: +{ + bridgeName = id: "br-vlan${toString id}"; + + 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 ]); +} diff --git a/modules/default.nix b/modules/default.nix index 1c1310a..43489f2 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -64,7 +64,6 @@ in useUserPackages = true; extraSpecialArgs = { inherit inputs; - my = inputs.self.lib; }; users.${user.name} = { home.username = user.name; diff --git a/modules/network/vlans.nix b/modules/network/vlans.nix index cc2c929..dc79b05 100644 --- a/modules/network/vlans.nix +++ b/modules/network/vlans.nix @@ -1,14 +1,14 @@ { config, lib, - my, + util, ... }: # The host networking foundation: per-VLAN bridges over a tagged trunk. let cfg = config.modules.network.vlans; - inherit (my) bridgeName; + inherit (util) bridgeName; vlanName = id: "${cfg.trunk}.${toString id}"; in diff --git a/modules/shell/fish/fish.nix b/modules/shell/fish/default.nix similarity index 100% rename from modules/shell/fish/fish.nix rename to modules/shell/fish/default.nix diff --git a/modules/shell/nvim/nvim.nix b/modules/shell/nvim/default.nix similarity index 100% rename from modules/shell/nvim/nvim.nix rename to modules/shell/nvim/default.nix diff --git a/modules/shell/tmux/tmux.nix b/modules/shell/tmux/default.nix similarity index 100% rename from modules/shell/tmux/tmux.nix rename to modules/shell/tmux/default.nix