36 Commits

Author SHA1 Message Date
5a8147401b refactor(host): simplify local names 2026-08-28 18:38:56 -04:00
276e7a02a6 refactor(host): derive host paths from root 2026-08-28 18:25:03 -04:00
ab969fe5de refactor(flake): use singular assembly file names 2026-08-28 16:33:30 -04:00
81732aef84 refactor(flake): assemble repository modules 2026-08-28 16:16:40 -04:00
0f9b639c0d refactor(lib): move nix file discovery helper 2026-08-28 11:23:12 -04:00
d43452a8d3 docs(nix): use real flake namespace example 2026-08-28 11:12:46 -04:00
64d22473c3 refactor(flake): pass assembly namespace to modules 2026-08-28 11:09:13 -04:00
3f24669f78 docs(nix): define assembly file ownership 2026-08-28 11:01:39 -04:00
d0b00ada96 refactor(flake): split assembly by concept 2026-08-28 11:01:15 -04:00
fc834168aa docs(nix): define default file placement 2026-08-28 10:54:25 -04:00
119eadbfb8 refactor(shell): use default modules for grouped tools 2026-08-28 10:54:00 -04:00
ebd61ee60d docs(nix): tighten writing style 2026-08-28 10:41:10 -04:00
12f3abc11e docs(nix): remove premature flake outputs rule 2026-08-28 10:38:46 -04:00
eef8e37fbb docs(nix): use NixOS module terminology 2026-08-28 10:37:59 -04:00
1654aef9ae docs(nix): clarify file placement terms 2026-08-28 10:33:37 -04:00
f16d041e63 docs(nix): match flake import examples 2026-08-28 10:22:55 -04:00
b61b8e3abf docs(nix): use actual flake binding name 2026-08-28 10:22:03 -04:00
a55592f59e docs(nix): remove premature let scope rule 2026-08-28 10:20:25 -04:00
09aee329cf docs(nix): remove unobserved let rule 2026-08-28 10:18:34 -04:00
71570edeaa docs(nix): narrow let binding rules 2026-08-28 10:15:56 -04:00
276e4a3172 docs(nix): split let binding rules 2026-08-28 10:14:50 -04:00
8493f12829 docs(nix): clarify let binding rules 2026-08-28 10:12:56 -04:00
720ebdba63 docs(nix): remove premature forwarding rule 2026-08-28 10:11:06 -04:00
9f01253d92 docs(nix): use host forwarding example 2026-08-28 10:09:24 -04:00
0c4e4df772 docs(nix): clarify module file interfaces 2026-08-28 10:08:10 -04:00
597dbd4369 docs(nix): improve assembly function example 2026-08-28 10:05:49 -04:00
1e9f1bf907 docs(nix): refine function terminology 2026-08-28 10:03:35 -04:00
f3494be6cc docs(nix): apply writing conventions 2026-08-28 09:52:05 -04:00
8cc171974a docs(agents): require writing guides 2026-08-28 09:45:12 -04:00
3c3d9d80f2 docs(nix): clarify convention examples 2026-08-28 09:43:52 -04:00
4c40aa12a7 docs(nix): record local conventions 2026-08-28 09:33:50 -04:00
a8aa5b6fb5 refactor(flake): qualify nixpkgs lib calls 2026-08-28 09:32:15 -04:00
41b9d41776 Revert "refactor(flake): use nixpkgs lib directly"
This reverts commit 4a422599c7.
2026-08-28 09:18:11 -04:00
4a422599c7 refactor(flake): use nixpkgs lib directly 2026-08-28 09:10:11 -04:00
5185abd8a2 docs(agents): preserve pull request review history 2026-08-28 09:10:11 -04:00
828cc15129 refactor(flake): move assembly code out of lib 2026-08-28 09:00:28 -04:00
27 changed files with 486 additions and 443 deletions

View File

@@ -51,9 +51,9 @@ Use the simplest accurate word when a term is not listed.
| 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`. |
| 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. |
| assembly function | Nix | A project function exposed under `flake` because it creates modules, systems, or assembly 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`. |
| library function | Nix | A pure project function exposed under `flake.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. |

View File

@@ -10,8 +10,8 @@ Read this document with `docs/dictionary.md`.
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.
Use `flake.util` for library functions passed through module arguments.
Use `flake.mk<Name>` for assembly functions passed through module arguments.
Avoid:
@@ -25,39 +25,39 @@ Use:
```nix
specialArgs = {
inherit util;
inherit flake;
};
```
Then call library functions through `util` inside modules:
Then call project functions through `flake` inside modules:
```nix
{
flake,
lib,
util,
...
}:
{
networking.networkmanager.unmanaged = [ "interface-name:${util.bridgeName 10}" ];
networking.networkmanager.unmanaged = [ "interface-name:${flake.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`.
Put pure project functions under `flake.util`.
Put functions that create modules, systems, or assembly values under `flake`.
This keeps data-only functions separate from functions that carry flake context.
Avoid:
```nix
util.mkHosts { root = self + "/hosts"; }
flake.util.mkGuest { name = "sample"; }
```
Use:
```nix
flake.mkHosts { root = self + "/hosts"; }
flake.mkGuest { name = "sample"; }
```
## File Placement
@@ -112,15 +112,15 @@ flake/default.nix # defines mkHost
Use:
```text
flake/default.nix # imports ./host.nix
flake/host.nix # defines mkHosts
flake/default.nix # imports ./hosts.nix
flake/hosts.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.
Export the constructor for the concept when the concept has one.
Name that constructor `mk<Concept>`.
Avoid:

21
flake.lock generated
View File

@@ -404,21 +404,6 @@
"type": "github"
}
},
"monorepo": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1788059616,
"narHash": "sha256-HBYT3h0vXRsvmgPOhUW3kgPTkgdl73PEwRTJQ5X48aM=",
"path": "/home/alexion/dev",
"type": "path"
},
"original": {
"path": "/home/alexion/dev",
"type": "path"
}
},
"nixos-hardware": {
"inputs": {
"nixpkgs": [
@@ -557,12 +542,8 @@
"firefox-addons": "firefox-addons",
"gitea-axi": "gitea-axi",
"home-manager": "home-manager_3",
"monorepo": "monorepo",
"nixos-hardware": "nixos-hardware",
"nixpkgs": [
"monorepo",
"nixpkgs"
],
"nixpkgs": "nixpkgs_2",
"nixpkgs-stable": "nixpkgs-stable",
"nixpkgs-unstable": "nixpkgs-unstable",
"nixvim": "nixvim",

View File

@@ -2,10 +2,8 @@
description = "Alexion's NixOS configuration — one flake for every host";
inputs = {
monorepo.url = "path:/home/alexion/dev";
# Base package set shared with the monorepo.
nixpkgs.follows = "monorepo/nixpkgs";
# Base channel.
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
# Fresher packages, reachable per-package as `unstable.<name>`.
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable";
@@ -78,9 +76,13 @@
{ self, nixpkgs, ... }@inputs:
let
inherit (nixpkgs) lib;
flake = import ./flake { inherit lib inputs self; };
in
{
inherit (import ./flake { inherit lib inputs self; }) nixosConfigurations;
# Every host under hosts/ is discovered and built.
nixosConfigurations = flake.mkHosts {
root = self + "/hosts";
};
# A project shell for agent-local resources that should travel with this
# checkout rather than the operator's global profile.

View File

@@ -4,50 +4,58 @@
self,
}:
let
# TODO: refactor the registry code
registryAssembly = import ./registry.nix { inherit lib self; };
util = import ../lib { inherit lib; };
mkModules = import ./module.nix { inherit util; };
registry = import ./registry.nix {
inherit lib;
hosts = self.nixosConfigurations;
root = self + "/registry";
};
modules = import ./module.nix {
inherit util;
modules = mkModules {
root = self + "/modules";
};
guestModules = import ./guest.nix {
inherit
inputs
lib
registry
specialArgs
util
;
repositoryModules = modules;
root = self + "/guests";
};
#----------------------------------------------------------
guestFiles = util.collectNixFiles (self + "/guests");
collectedRegistry = registryAssembly.collect { hosts = self.nixosConfigurations; };
specialArgs = {
inherit
flake
inputs
self
;
registry = collectedRegistry;
};
flake = {
inherit (hosts) mkHosts;
inherit (guests) mkGuest;
inherit util;
};
guests = import ./guest.nix {
inherit
inputs
lib
self
specialArgs
registryAssembly
util
;
registry = registry.collected;
repositoryModules = modules;
};
in
{
nixosConfigurations = import ./host.nix {
hosts = import ./host.nix {
inherit
inputs
lib
specialArgs
guestModules
guestFiles
registryAssembly
;
registryModule = registry.module;
repositoryModules = modules;
root = self + "/hosts";
};
}
in
flake

View File

@@ -1,56 +1,116 @@
{
inputs,
lib,
root,
self,
specialArgs,
repositoryModules,
registry,
registryAssembly,
util,
}:
let
mkGuest =
name:
{
name,
interior ? { },
}:
{ config, lib, ... }:
let
cfg = config.guests.${name};
optionPath = [ "guests" ] ++ lib.splitString "." name;
cfg = lib.getAttrFromPath optionPath config;
machineName = lib.replaceStrings [ "." ] [ "-" ] name;
directoryMounts = lib.filterAttrs (_: mount: mount.directory != null) cfg.mounts;
networked = cfg.vlan != null;
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
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" (
lib.mapAttrsToList (
_guestPath: m:
let
create = m.create;
in
''
${config.system.path}/bin/install -d -o ${lib.escapeShellArg (toString create.owner)} -g ${lib.escapeShellArg create.group} -m ${lib.escapeShellArg create.mode} ${lib.escapeShellArg m.hostPath}
''
) 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.guests.${name} = {
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.ints.between 1 4094;
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. The id must be one of the host's
`modules.network.vlans.ids`.
`br-vlan<id>` bridge for that VLAN. Left null, the guest keeps a
private network with no bridge attachment. The id must be one of the
host's `modules.network.vlans.ids`.
'';
};
mac = lib.mkOption {
type = lib.types.str;
default = util.deriveMac "${config.networking.hostName}.${name}";
defaultText = lib.literalMD "a stable address derived from the host name and guest option path";
default = util.deriveMac name;
defaultText = lib.literalMD "a stable address derived from the guest's namespace path";
example = "bc:24:11:00:00:01";
description = ''
The guest's MAC address on its VLAN, pinned inside the guest by its
own networkd. Set it to reuse an existing address so a router's DHCP
reservation keeps working. Left unset, a stable address is derived
from the host name and guest option path in the locally-administered range.
from the guest's namespace path in the locally-administered range.
'';
};
address = lib.mkOption {
@@ -66,7 +126,7 @@ let
enable = lib.mkEnableOption "a reverse-proxy route for this guest";
route = lib.mkOption {
type = registry.types.route;
type = registryAssembly.definitions.route.type;
description = "Route registry entry published for this guest.";
};
};
@@ -74,43 +134,51 @@ let
type = lib.types.attrsOf (
lib.types.submodule {
options = {
directory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
hostPath = lib.mkOption {
type = lib.types.str;
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.";
description = "The path on the host bind-mounted into the guest.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Mount the host path read-only inside the guest.";
description = ''
Mount the path read-only. Read-write by default, since a
service must write to the pool data it owns.
'';
};
owner = lib.mkOption {
type = lib.types.nullOr (
lib.types.oneOf [
lib.types.str
lib.types.ints.positive
]
);
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 = "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.";
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.
'';
};
};
}
@@ -118,21 +186,35 @@ let
default = { };
example = lib.literalExpression ''
{
"/data/media" = { directory = "/srv/media"; };
"/data/config" = { directory = "/srv/config/jellyfin"; };
"/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, a whole pool, or a decrypted secret.
Mounts are read-write unless `readOnly` is set.
granularity — a single folder or a whole pool. Each mount is
read-write unless `readOnly` is set.
'';
};
interior = lib.mkOption {
type = lib.types.deferredModule;
default = { };
description = "NixOS module imported into the guest interior.";
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 {
@@ -165,115 +247,116 @@ let
'';
};
};
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 {
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
[
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 [
{
assertion = mountsWithInvalidSource == [ ];
message = ''
guests.${name} mounts ${lib.concatStringsSep ", " mountsWithInvalidSource} must set exactly one of `directory` or `file`.
'';
serviceConfig = limitConfig;
preStart = lib.mkIf (createdMounts != { }) mountCreateScript;
}
(lib.mkIf networked (
let
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (util.bridgeName cfg.vlan)}.device";
in
{
after = [ bridgeDevice ];
wants = [ bridgeDevice ];
}
))
]
);
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
autoStart = cfg.autoStart;
privateNetwork = lib.mkDefault true;
hostBridge = lib.mkIf networked (util.bridgeName cfg.vlan);
privateUsers = lib.mkDefault "no";
additionalCapabilities = lib.optionals cfg.nesting [ "CAP_NET_ADMIN" ];
allowedDevices = lib.optionals cfg.nesting [
{
node = "/dev/net/tun";
modifier = "rwm";
}
{
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.
'';
node = "/dev/fuse";
modifier = "rwm";
}
];
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;
bindMounts = userMounts // secretMounts;
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";
};
}
];
imports =
[
repositoryModules
]
++ [
registryAssembly.module
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
{
modules.guest.enable = true;
}
guestNet
interior
];
};
};
};
};
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;
inherit mkGuest;
}

View File

@@ -1,12 +1,13 @@
{
inputs,
lib,
root,
specialArgs,
repositoryModules,
guestModules,
registryModule,
# TODO refactor guests
guestFiles,
# TODO refactor registry
registryAssembly,
}:
let
mkHost =
@@ -16,30 +17,35 @@ let
}:
lib.nixosSystem {
inherit specialArgs;
modules = [
inputs.chaotic.nixosModules.default
inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
modules =
guestFiles
++ [
inputs.chaotic.nixosModules.default
inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
repositoryModules
guestModules
repositoryModules
# TODO: refactor
registryModule (root + "/${name}")
# TODO: refactor
registryAssembly.module (root + "/${name}")
{
networking.hostName = name;
modules.host.enable = true;
}
];
{
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; }
)
{
mkHosts =
{ root }:
let
directories = lib.filterAttrs (_name: type: type == "directory") (builtins.readDir root);
names = lib.attrNames directories;
in
lib.genAttrs names (
name:
mkHost { inherit root name; }
);
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
{
config,
lib,
flake,
...
}:
let
@@ -33,6 +34,26 @@ let
};
in
{
imports = [
(flake.mkGuest {
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;
@@ -85,33 +106,19 @@ in
];
guests.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;
};
secrets = [ cfg.passwordSecret ];
mounts =
{
${cfg.stateDir} = {
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;
hostPath = cfg.statePath;
create = {
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = "storage";
mode = "0770";
};
};
}
// lib.mapAttrs' (_urlPath: volume: lib.nameValuePair volume.path { directory = volume.hostPath; }) cfg.volumes;
// lib.mapAttrs' (_urlPath: volume: lib.nameValuePair volume.path { inherit (volume) hostPath; }) cfg.volumes;
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";

11
guests/nesting-sample.nix Normal file
View File

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

View File

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

5
guests/sample.nix Normal file
View File

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

View File

@@ -40,6 +40,26 @@
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;

View File

@@ -1,6 +1,7 @@
{
config,
lib,
flake,
pkgs,
inputs,
...
@@ -63,7 +64,7 @@ in
useGlobalPkgs = true;
useUserPackages = true;
extraSpecialArgs = {
inherit inputs;
inherit flake inputs;
};
users.${user.name} = {
home.username = user.name;
@@ -121,6 +122,5 @@ in
modules.shell.nvim.enable = lib.mkDefault true;
modules.shell.git.enable = lib.mkDefault true;
modules.shell.direnv.enable = lib.mkDefault true;
modules.shell.yazi.enable = lib.mkDefault true;
};
}

View File

@@ -1,18 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# Ghostty as the desktop terminal.
let
cfg = config.modules.desktop.apps.ghostty;
user = config.user.name;
in
{
options.modules.desktop.apps.ghostty.enable = lib.mkEnableOption "Ghostty as the desktop terminal";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.ghostty ];
};
}

View File

@@ -9,8 +9,8 @@ in
# Each piece is turned on at default priority, so a host can still override
# any one of them while the single flag above enables the whole desktop.
config = lib.mkIf cfg.enable {
modules.desktop.apps.alacritty.enable = lib.mkDefault true;
modules.desktop.apps.firefox.enable = lib.mkDefault true;
modules.desktop.apps.ghostty.enable = lib.mkDefault true;
modules.desktop.session.hyprland.enable = lib.mkDefault true;
modules.desktop.session.hyprland.hypridle.enable = lib.mkDefault true;
modules.desktop.session.hyprland.hyprlock.enable = lib.mkDefault true;

View File

@@ -61,7 +61,7 @@ in
settings = {
"$mod" = "SUPER";
"$terminal" = "ghostty";
"$terminal" = "alacritty";
# Session variables handed to the compositor directly.
# UWSM launches the session without the shell profile that would carry

View File

@@ -89,7 +89,6 @@ in
window-rewrite-default = g "f2d0";
window-rewrite = {
"class<Alacritty>" = g "f120";
"class<com.mitchellh.ghostty>" = g "f120";
"class<.*[Ff]irefox.*>" = g "f269";
"class<chromium.*>" = g "f268";
"class<[Cc]ode>" = g "f121";

View File

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

View File

@@ -20,7 +20,6 @@ in
# nix-direnv caches the evaluated shell so re-entering a directory is instant
# instead of re-running `nix develop`, and adds the `use flake` stdlib helper.
nix-direnv.enable = true;
stdlib = builtins.readFile ./repo-flake.sh;
};
};
}

View File

@@ -1,33 +0,0 @@
if declare -f use_flake >/dev/null; then
__dotfiles_use_flake_definition="$(declare -f use_flake)"
eval "${__dotfiles_use_flake_definition/use_flake/__dotfiles_nix_direnv_use_flake}"
unset __dotfiles_use_flake_definition
use_flake() {
local flake_expr="${1:-.}"
if [[ "$flake_expr" == //* ]]; then
local git_root
if ! git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
log_error 'use flake // requires a Git repository'
return 1
fi
local flake_suffix="${flake_expr#//}"
if [[ -n "$flake_suffix" && "$flake_suffix" != \#* && "$flake_suffix" != /* ]]; then
flake_expr="path:${git_root}/${flake_suffix}"
else
flake_expr="path:${git_root}${flake_suffix}"
fi
shift
__dotfiles_nix_direnv_use_flake "$flake_expr" "$@"
return
fi
__dotfiles_nix_direnv_use_flake "$@"
}
else
log_error 'nix-direnv must define use_flake before the // wrapper loads'
return 1
fi

View File

@@ -38,9 +38,6 @@ in
[ui]
prompt_new_tab_name = false
[experimental]
kitty_graphics = true
'';
};
};

View File

@@ -16,16 +16,6 @@ vim.api.nvim_create_autocmd("FileType", {
end,
})
-- Soft-wrap prose buffers without inserting line breaks.
vim.api.nvim_create_autocmd("FileType", {
pattern = { "gitcommit", "markdown", "text" },
callback = function()
vim.opt_local.wrap = true
vim.opt_local.linebreak = true
vim.opt_local.breakindent = true
end,
})
-- Turn line blame off again once the Neogit status buffer is closed.
vim.api.nvim_create_autocmd("BufUnload", {
callback = function(args)

View File

@@ -92,18 +92,6 @@ in
action = "<cmd>nohlsearch<CR>";
options.desc = "Clear search highlight";
}
{
mode = "n";
key = "j";
action = "gj";
options.desc = "Move down by display line";
}
{
mode = "n";
key = "k";
action = "gk";
options.desc = "Move up by display line";
}
{
mode = "n";
key = "<leader>e";

View File

@@ -1,32 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# Yazi, a terminal file manager.
let
cfg = config.modules.shell.yazi;
user = config.user.name;
in
{
options.modules.shell.yazi.enable = lib.mkEnableOption "Yazi, a terminal file manager";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.yazi = {
enable = true;
enableFishIntegration = true;
extraPackages = with pkgs; [
fd
ffmpegthumbnailer
fzf
imagemagick
jq
poppler
ripgrep
unar
zoxide
];
};
};
}