11 KiB
AGENTS.md
Shared NixOS / nix-darwin module library ("FutureWare" flake). This repo contains no deployable system config — it exports reusable modules, one shell-script package, and SSH access/signing identities that other repos (host configs, dotfiles) import as a flake input.
Commands
| Task | Command |
|---|---|
| Verify a change (CI does exactly this) | nix flake check |
| Enter dev shell (installs git hooks) | nix develop |
| Update pinned inputs | nix flake update (commit message: nix flake update) |
| Build the shipped package | nix build .#git-safe-sync |
| Nix lint autofix | statix fix (inside nix develop) |
| Check every system (incl. aarch64) | nix flake check --all-systems |
nix flake check silently omits the non-native systems (aarch64-linux,
aarch64-darwin, x86_64-darwin) unless --all-systems is passed — relevant
because several modules branch on isDarwin.
There is no nix fmt: the flake defines no formatter output. Formatting is
enforced by git hooks (see below). There are no unit tests. nix flake check
evaluates every nixosModules output standalone (via lib.evalModules with no
host config) plus the package, check, and devShell derivations, so it catches
eval-time breakage in a module but not misbehaviour once combined with a real
host config. Only the host's own systems are checked in their own repos.
CI (.github/workflows/check.yaml) runs nix flake check on ubuntu-latest
only. Nothing here is verified against nix-darwin or aarch64 in CI.
Tooling / formatting
Hooks are defined once in pre-commit.nix (nixfmt, statix, deadnix, nil,
flake-checker, prettier, black, clang-format, ripsecrets, plus file hygiene
checks) and consumed by checks.*.pre-commit-check and devShells.default.
.pre-commit-config.yamlat the repo root is a generated symlink into the Nix store, produced by thenix developshellHook and gitignored. Never edit it; editpre-commit.nix.- Hooks only get installed when you enter
nix develop(direnv does this via.envrc, which also doeswatch_file pre-commit.nix). If you skip the dev shell, nothing formats or lints your commit. - nixfmt is the RFC-style formatter; its output (vertical argument lists,
trailing commas,
final:/_prev:underscore-prefixed unused args) is the house style — match it rather than hand-formatting. - statix rules come from
assets/statix.toml, which disablesrepeated_keyson purpose (modules are not yet split finely enough).nil(the LSP-based checker) is what surfacesbuiltins.-prefix warnings. - ripsecrets runs on every commit. Public SSH keys that trip it need an inline
# pragma: allowlist secret(seemodules/futureware.nix).
Layout
flake.nix outputs: nixosModules, packages, checks, devShells, lib.pre-commit
modules/*.nix one file per exported module, name == flake output key
packages/*.nix standalone packages (callPackage-compatible)
data/identities.nix users + access/signing keys, exposed as module arg
pre-commit.nix hook definitions (also a flake output: lib.pre-commit)
assets/statix.toml statix config, referenced by path from pre-commit.nix
Every file in modules/ is exported explicitly in flake.nix — adding a module
means adding an import line there. data/identities.nix is exported
differently: as _module.args.identities, so consumer modules can list
identities in their function arguments.
Module conventions
Cross-platform is a hard requirement. These modules are imported by both NixOS and nix-darwin hosts, so a module must not assume an option or a domain exists. Two patterns are used throughout:
- Guard on option existence:
hasGitOption = options ? programs.git;thenlib.optionalAttrs hasGitOption { ... }/lib.optionalAttrs (!hasGitOption) { ... }(futureware.nix,tools.nix,boot.nixforoptions ? boot.lanzaboote). The!has...branch usually installs an equivalent by hand (/etc/gitconfig,/etc/htoprc). - Guard on platform with
pkgs.stdenv.hostPlatform.isDarwin/.isLinux(nix-gc.nix,nix-settings.nix,tools.nix). Usestdenv.hostPlatform, not the oldstdenv.isDarwin(migration already applied repo-wide).
Prefer lib.mkDefault for anything a host might want to change (scheduler
times, services.openssh.hostKeys, boot.zfs.devNodes). Reserve mkForce for
cases where a module must win outright (nvidia-fabricmanager, zpool-trim).
modules/sshd.nix documents the priority reasoning for hostKeys — read it
before changing defaults there.
Custom options live in the fw.* namespace: fw.tpm.hostPath,
fw.hardware.gpus.{enable,devices,pools,resolvedPools,allCudaCapabilities}.
flake.nix carries a TODO to collapse this into a single default module gated
by mkEnableOptions under fw.features — don't preempt that refactor.
Modules must compose independently, never assume ordering or co-import.
When one module wants to react to another, read it defensively:
config.fw.hardware.gpus.enable or false, config.services.telegraf.enable or false,
config.boot.lanzaboote.enable or false. telegraf.nix and nvidia.nix are
the canonical pair: telegraf adds the nvidia_smi input only if the GPU option
is on, and nvidia adds its telegraf tweaks only if telegraf is enabled.
Schedules are UTC. Timer values (nix.gc 00:15, nix.optimise 00:45,
ZFS scrub/trim, SMART test windows) are written assuming UTC; keep that
assumption when adding timers.
identities.nix is an API, not just data. getAccessKeys { user; } returns
"<key> <name>" lines for authorized_keys; getSigningEntries { user; }
returns signed-key entries (omit user for everyone). Key names become the
trailing comment on the key, so they are user-visible.
Packages: packages/git-safe-sync.nix is a writeShellApplication script,
exported twice — as packages.<system>.git-safe-sync and via a nixpkgs.overlays
entry in modules/git-safe-sync.nix (module files that only inject an overlay
take _: as their argument). The script requires signed commits
(git verify-commit / merge --ff-only --verify-signatures).
Non-obvious context
modules/futureware.nixis intended for internal LAN servers only. It hardcodes private infrastructure:nix-cache.home.arpasubstituters,git.home.arpaknown hosts, andgit insteadOfrewrites that mirror GitHub repos onto the private Gitea. It therefore also setsconnect-timeout/fallbackdefaults so an unreachable cache doesn't hang builds.- The repo's own remote is
git.home.arpa:futureware-tech/nix.git(private Gitea mirror of the GitHub project);futureware.nixencodes that mirror relationship. modules/tpm.niximplements a deliberate two-stage secret scheme: agenix + TPM2 unlocks the SSH host key, sops-nix then decrypts everything else with it (decrypting many secrets via TPM is too slow). It also reorderssystem.activationScripts.setupSecrets*withdeps = lib.mkAfter [ "agenixInstall" ]— sops needs the key agenix installs, and cannot depend on theagenixscript itself because that runs after user setup. It expects a specific on-disk layout underfw.tpm.hostPath(tpm.id,secrets/ssh_host_ed25519_key.age) and a sibling../common/secrets/root-password.bin.modules/nvidia.niximports a second, separate nixpkgs instance (cudaPkgs) withcudaSupport = trueso CUDA rebuilds stay scoped togpu-burninstead of recompiling everything. Its pool model: declaredevices(uuid/model/cudaCapability/powerLimit/compute) andpools(names → device names);resolvedPoolsderivesCUDA_VISIBLE_DEVICES/NVIDIA_VISIBLE_DEVICESfrom sorted UUIDs and exposesapplyCudaArchesto rebuild a package for a pool's architectures only whenneedsRebuild. Onlycompute = trueGPUs contribute build architectures;allCudaCapabilitiesincludes all of them so stress testing works. It pinshardware.nvidia.open = false(stability on Ampere) and needshardware.enableRedistributableFirmwarefor GSP firmware.modules/boot.nixtakes a module argumentrootDisks ? [ ](passed by the consumer when importing the module) and mirrors/boot1to/boot2..Nvia a hourlyefi-mirrorunit, with a sentinel-file check that exits 66 rather than mirroring garbage. It relies onnotify-failed@%nfrommodules/systemd.nix.modules/systemd.nixprovides templatednotify-failed@/notify-succeeded@units (system and user) that mailsystemctl statusviasendmail. Any unit wanting failure mail setsOnFailure = [ "notify-failed@%n.service" ].modules/zfs.nixopens with a long comment block that is the authoritative runbook for creating pools/datasets on this infrastructure (recordsize choice, ashift, xattr=sa, autoexpand, reservation,chattr +i). Consult it before changing ZFS defaults.modules/zfs.nixalso takes arootDisks ? [ ]module argument (same style asboot.nix) of full paths to whole disks. Only entries starting with/dev/disk/by-path/are used: theID_PATHis parsed out of each and turned into udev rules — in stage 2 and in the initrd — symlinking every partition of the matching disk into/dev/disk/rpool, numbered1..Nlike/boot1 /boot2inboot.nix. Whole disks are deliberately not linked: UEFI requires a boot partition, so the root pool always lives on a partition. When such entries existboot.zfs.devNodespoints there, sozpool import -dcan never see a foreign pool with the same name.boot.zfs.devNodesis a directory (passed to-d), not a device list, andforceImportRootdoes not resolve duplicate pool names — it only overrides in-use/dirty checks. ArootDisksentry in another naming scheme (by-id,by-uuid,/dev/sda) is silently ignored, and a wrongby-pathentry makes the disk invisible to the import, so the host will not boot.modules/telegraf.nixintentionally leavesoutputs.prometheus_clientwithout a listen port (secure by default; hosts put it behind a reverse proxy). Don't add a port here.- Temporarily,
nvidia.nixemits GPU throttle counters through a telegrafexecCSV hack because upstreamnvidia_smilacks those fields; it is marked for removal once upstream ships them.