refactor(flake): split assembly code #73

Merged
alexion merged 10 commits from refactor/flake-assembly into main 2026-08-29 17:21:41 -04:00
24 changed files with 784 additions and 631 deletions

View File

@@ -4,13 +4,16 @@ One flake builds every machine I own.
## Repository Conventions ## 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`. - 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. The sign-off identifies the agent as the author instead of the operator.
- Commit messages follow Conventional Commits, defined in `docs/conventional-commits.md`. - 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`. 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. - 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. 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. - Use `gitea-axi` for Gitea issues, pull requests, labels, reviews, comments, and milestones.
It resolves this repository from the `origin` remote inside the checkout. 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. - 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. 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. 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.<guest>.config.registry`. Registry declarations inside guest interiors live under `config.containers.<guest>.config.registry`.
The collected registry does not include those guest-interior declarations. 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. Publish guest values from host-side code, or extend the collector to recurse into guests.

View File

@@ -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. | | 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/`. | | 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. | | 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. | | 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`. | | 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. | | 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`. | | 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. | | 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. | | workspace | Window management | A named or numbered group of windows in a graphical session. Some desktop environments call this a virtual desktop. |

270
docs/nix.md Normal file
View File

@@ -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<Name>` 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/<name>/`.
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<Concept>`.
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
```

View File

@@ -76,14 +76,9 @@
{ self, nixpkgs, ... }@inputs: { self, nixpkgs, ... }@inputs:
let let
inherit (nixpkgs) lib; inherit (nixpkgs) lib;
my = import ./lib { inherit lib inputs self; };
in in
{ {
# Helper functions for discovering and building hosts. inherit (import ./flake { inherit lib inputs self; }) nixosConfigurations;
lib = my;
# Every host under hosts/ is discovered and built.
nixosConfigurations = my.mkHosts (self + "/hosts");
# A project shell for agent-local resources that should travel with this # A project shell for agent-local resources that should travel with this
# checkout rather than the operator's global profile. # checkout rather than the operator's global profile.

53
flake/default.nix Normal file
View File

@@ -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";
};
}

279
flake/guest.nix Normal file
View File

@@ -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<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";
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;
}

45
flake/host.nix Normal file
View File

@@ -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; }
)

7
flake/module.nix Normal file
View File

@@ -0,0 +1,7 @@
{
root,
util,
}:
{
imports = util.collectNixFiles root;
}

View File

@@ -1,35 +1,16 @@
{ lib, self }: { hosts, lib, root }:
let let
inherit (lib) checkType = lib.types.submodule {
all
concatLists
concatMapStringsSep
filter
filterAttrs
foldl'
hasAttr
hasSuffix
mapAttrs
mapAttrs'
mapAttrsToList
mkOption
nameValuePair
removeSuffix
types
unique
;
checkType = types.submodule {
options = { options = {
assertion = mkOption { assertion = lib.mkOption {
type = types.functionTo types.bool; type = lib.types.functionTo lib.types.bool;
description = '' description = ''
Predicate run against this registry's flattened collected entries. Predicate run against this registry's flattened collected entries.
''; '';
}; };
message = mkOption { message = lib.mkOption {
type = types.str; type = lib.types.str;
description = '' description = ''
Error message shown when this registry validation check fails. Error message shown when this registry validation check fails.
''; '';
@@ -38,7 +19,7 @@ let
}; };
predicates = { 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 // { registryLib = lib // {
@@ -49,23 +30,23 @@ let
registryDefinitionModule = { registryDefinitionModule = {
options = { options = {
type = mkOption { type = lib.mkOption {
type = types.raw; type = lib.types.raw;
description = '' description = ''
NixOS option type for one registry entry. NixOS option type for one registry entry.
''; '';
}; };
replicate = mkOption { replicate = lib.mkOption {
type = types.bool; type = lib.types.bool;
default = false; default = false;
description = '' description = ''
Whether collected entries are merged and published under every host key. Whether collected entries are merged and published under every host key.
''; '';
}; };
checks = mkOption { checks = lib.mkOption {
type = types.listOf checkType; type = lib.types.listOf checkType;
default = [ ]; default = [ ];
description = '' description = ''
Validation checks for this registry's flattened collected entries. 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 { registryOption = registry: lib.mkOption {
type = types.attrsOf (types.attrsOf registry.type); type = lib.types.attrsOf (lib.types.attrsOf registry.type);
default = { }; default = { };
description = '' description = ''
Lazily collected fleet-visible registry entries, nested by host and entry name. Lazily collected fleet-visible registry entries, nested by host and entry name.
@@ -98,19 +79,19 @@ let
inherit (evaluated.config) type replicate checks; inherit (evaluated.config) type replicate checks;
}; };
definitions = mapAttrs' ( definitions = lib.mapAttrs' (
name: type: name: type:
let let
path = self + "/registry/${name}"; path = root + "/${name}";
in in
nameValuePair (registryName name) (mkRegistry path) lib.nameValuePair (registryName name) (mkRegistry path)
) (filterAttrs (name: type: type == "regular" && hasSuffix ".nix" name) (builtins.readDir (self + "/registry"))); ) (lib.filterAttrs (name: type: type == "regular" && lib.hasSuffix ".nix" name) (builtins.readDir root));
flattenRegistryEntries = registryName: data: flattenRegistryEntries = registryName: data:
concatLists ( lib.concatLists (
mapAttrsToList ( lib.mapAttrsToList (
host: entries: host: entries:
mapAttrsToList (entry: value: { lib.mapAttrsToList (entry: value: {
path = "registry.${registryName}.${host}.${entry}"; path = "registry.${registryName}.${host}.${entry}";
inherit host entry value; inherit host entry value;
}) entries }) entries
@@ -118,17 +99,17 @@ let
); );
rawDeclarations = registryName: hosts: 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: replicatedEntries = registryName: raw:
let let
entries = concatLists ( entries = lib.concatLists (
concatLists ( lib.concatLists (
mapAttrsToList ( lib.mapAttrsToList (
_evalHost: hostDeclarations: _evalHost: hostDeclarations:
mapAttrsToList ( lib.mapAttrsToList (
hostName: declarations: hostName: declarations:
mapAttrsToList (entryName: value: { lib.mapAttrsToList (entryName: value: {
path = "registry.${registryName}.${hostName}.${entryName}"; path = "registry.${registryName}.${hostName}.${entryName}";
inherit entryName value; inherit entryName value;
}) declarations }) declarations
@@ -137,9 +118,9 @@ let
) )
); );
in in
foldl' ( lib.foldl' (
acc: entry: acc: entry:
if hasAttr entry.entryName acc then if lib.hasAttr entry.entryName acc then
if acc.${entry.entryName}.value == entry.value then if acc.${entry.entryName}.value == entry.value then
acc acc
else else
@@ -152,13 +133,13 @@ let
replicatedData = registryName: hosts: raw: replicatedData = registryName: hosts: raw:
let let
entries = mapAttrs (_entryName: entry: entry.value) (replicatedEntries registryName raw); entries = lib.mapAttrs (_entryName: entry: entry.value) (replicatedEntries registryName raw);
in in
mapAttrs (_hostName: _host: entries) hosts; lib.mapAttrs (_hostName: _host: entries) hosts;
validationAssertions = data: validationAssertions = data:
concatLists ( lib.concatLists (
mapAttrsToList ( lib.mapAttrsToList (
registryName: registry: registryName: registry:
let let
entries = flattenRegistryEntries registryName data.${registryName}; entries = flattenRegistryEntries registryName data.${registryName};
@@ -173,28 +154,28 @@ let
assertValid = data: assertValid = data:
let let
assertions = validationAssertions data; assertions = validationAssertions data;
failures = filter (assertion: !assertion.assertion) assertions; failures = lib.filter (assertion: !assertion.assertion) assertions;
in in
assert lib.assertMsg (all (assertion: assertion.assertion) assertions) ( assert lib.assertMsg (lib.all (assertion: assertion.assertion) assertions) (
concatMapStringsSep "\n" (assertion: assertion.message) failures lib.concatMapStringsSep "\n" (assertion: assertion.message) failures
); );
data; data;
join = registry: host: entry: join = registry: host: entry:
filterAttrs (_name: value: value != null) ( lib.filterAttrs (_name: value: value != null) (
mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions lib.mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions
); );
collectData = collectData =
{ hosts }: { hosts }:
let let
raw = mapAttrs (registryName: _registry: rawDeclarations registryName hosts) definitions; raw = lib.mapAttrs (registryName: _registry: rawDeclarations registryName hosts) definitions;
data = mapAttrs ( data = lib.mapAttrs (
registryName: registry: registryName: registry:
if registry.replicate or false then if registry.replicate or false then
replicatedData registryName hosts raw.${registryName} replicatedData registryName hosts raw.${registryName}
else else
mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts lib.mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts
) definitions; ) definitions;
in in
assertValid data; assertValid data;
@@ -206,11 +187,11 @@ let
}; };
in in
{ {
inherit definitions predicates; types = lib.mapAttrs (_name: definition: definition.type) definitions;
module = { 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; });
} }

View File

@@ -1,7 +1,6 @@
{ {
config, config,
lib, lib,
my,
... ...
}: }:
let let
@@ -9,18 +8,6 @@ let
dataDir = "/data"; dataDir = "/data";
in in
{ {
imports = [
(my.guest {
name = "actualbudget";
interior = {
modules.services.actualbudget = {
enable = true;
inherit dataDir;
};
};
})
];
options.guests.actualbudget = { options.guests.actualbudget = {
dataPath = lib.mkOption { dataPath = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
@@ -39,7 +26,13 @@ in
]; ];
guests.actualbudget = { guests.actualbudget = {
mounts.${dataDir}.hostPath = cfg.dataPath; interior = {
modules.services.actualbudget = {
enable = true;
inherit dataDir;
};
};
mounts.${dataDir}.directory = cfg.dataPath;
limits = { limits = {
memory = lib.mkDefault "2G"; memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%"; cpu = lib.mkDefault "200%";

View File

@@ -1,7 +1,6 @@
{ {
config, config,
lib, lib,
my,
... ...
}: }:
let let
@@ -34,26 +33,6 @@ let
}; };
in 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 = { options.guests.copyparty = {
accountName = lib.mkOption { accountName = lib.mkOption {
type = lib.types.str; type = lib.types.str;
@@ -106,19 +85,33 @@ in
]; ];
guests.copyparty = { 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 = mounts =
{ {
${cfg.stateDir} = { ${cfg.stateDir} = {
hostPath = cfg.statePath; directory = cfg.statePath;
create = { owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty; group = "storage";
group = "storage"; mode = "0770";
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 = { limits = {
memory = lib.mkDefault "2G"; memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%"; cpu = lib.mkDefault "200%";

View File

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

View File

@@ -1,16 +1,14 @@
{ {
config, config,
lib, lib,
my,
... ...
}: }:
let let
giteaSshBackend = "10.23.20.45:2022"; giteaSshBackend = "10.23.20.45:2022";
in in
{ {
imports = [ config = lib.mkIf config.guests.reverse-proxy.enable {
(my.guest { guests.reverse-proxy = {
name = "reverse-proxy";
interior = { interior = {
modules.services.reverse-proxy.enable = true; modules.services.reverse-proxy.enable = true;
@@ -66,11 +64,7 @@ in
} }
''; '';
}; };
})
];
config = lib.mkIf config.guests.reverse-proxy.enable {
guests.reverse-proxy = {
limits = { limits = {
memory = lib.mkDefault "1G"; memory = lib.mkDefault "1G";
cpu = lib.mkDefault "100%"; cpu = lib.mkDefault "100%";

View File

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

View File

@@ -40,26 +40,6 @@
modules.network.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml; modules.network.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
modules.network.ssh.userKey.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.shell.herdr.enable = true;
modules.agents.tools.gitea-axi.enable = true; modules.agents.tools.gitea-axi.enable = true;
modules.agents.pi.enable = true; modules.agents.pi.enable = true;

View File

@@ -1,100 +1,9 @@
{ { lib }:
lib,
inputs,
self,
}:
let let
inherit (lib) files = import ./files.nix { inherit lib; };
attrNames network = import ./network.nix { inherit lib; };
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; });
in in
{ {
inherit inherit (files) collectNixFiles;
collectNixFiles inherit (network) bridgeName deriveMac;
mkHost
mkHosts
bridgeName
registry
;
inherit (guests) deriveMac guest;
} }

22
lib/files.nix Normal file
View File

@@ -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;
}

View File

@@ -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<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 = 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/<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
++ [
registry.module
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
{
modules.guest.enable = true;
}
guestNet
interior
];
};
};
};
};
in
{
inherit deriveMac guest;
}

12
lib/network.nix Normal file
View File

@@ -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 ]);
}

View File

@@ -64,7 +64,6 @@ in
useUserPackages = true; useUserPackages = true;
extraSpecialArgs = { extraSpecialArgs = {
inherit inputs; inherit inputs;
my = inputs.self.lib;
}; };
users.${user.name} = { users.${user.name} = {
home.username = user.name; home.username = user.name;

View File

@@ -1,14 +1,14 @@
{ {
config, config,
lib, lib,
my, util,
... ...
}: }:
# The host networking foundation: per-VLAN bridges over a tagged trunk. # The host networking foundation: per-VLAN bridges over a tagged trunk.
let let
cfg = config.modules.network.vlans; cfg = config.modules.network.vlans;
inherit (my) bridgeName; inherit (util) bridgeName;
vlanName = id: "${cfg.trunk}.${toString id}"; vlanName = id: "${cfg.trunk}.${toString id}";
in in