153 lines
5.9 KiB
Markdown
153 lines
5.9 KiB
Markdown
|
|
# NixOS Modules
|
||
|
|
|
||
|
|
This note covers `04-nixos-module/`, which defines the smallest useful NixOS module: one option, one effect, exposed as a flake output other flakes can import.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. What a Module Is
|
||
|
|
|
||
|
|
A NixOS module is a function from module arguments (`{ lib, config, pkgs, ... }`) to an attrset with two sections:
|
||
|
|
|
||
|
|
- `options`: declarations of typed configuration points.
|
||
|
|
- `config`: values assigned to options declared by this module, and by other modules in the set.
|
||
|
|
|
||
|
|
The NixOS module system collects every module, merges their `options` declarations, merges their `config` assignments, resolves the merged `config` against the merged `options`, and exposes the final result as a single `config` value. Your system configuration is just another module in the set.
|
||
|
|
|
||
|
|
A module can also appear as a plain attrset when it has no arguments. The function form is the general case.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. The Module in `04-nixos-module/`
|
||
|
|
|
||
|
|
`module.nix` declares one option namespace, `playground.greeter`, with two options:
|
||
|
|
|
||
|
|
- `enable`: a boolean toggle, produced by `lib.mkEnableOption`.
|
||
|
|
- `name`: a string with default `"world"`, produced by `lib.mkOption`.
|
||
|
|
|
||
|
|
When `enable` is true, the `config` block defines `environment.etc."greeting".text` to `"hello, ${cfg.name}"`. `lib.mkIf cfg.enable { ... }` wraps the block so it contributes nothing when disabled, rather than setting options to null or empty values.
|
||
|
|
|
||
|
|
The module has no `imports`, no `pkgs` usage, and no `services.*` interaction. That keeps the example focused on declaring an option and conditionally assigning one piece of config.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. Exposing the Module From a Flake
|
||
|
|
|
||
|
|
A flake ships modules through the `nixosModules` output:
|
||
|
|
|
||
|
|
```nix
|
||
|
|
outputs = { self, nixpkgs, ... }: {
|
||
|
|
nixosModules.default = import ./module.nix;
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
Downstream flakes consume it like any other module:
|
||
|
|
|
||
|
|
```nix
|
||
|
|
{
|
||
|
|
inputs.playground.url = "github:you/nix-playground?dir=04-nixos-module";
|
||
|
|
|
||
|
|
outputs = { self, nixpkgs, playground }: {
|
||
|
|
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
|
||
|
|
system = "x86_64-linux";
|
||
|
|
modules = [
|
||
|
|
playground.nixosModules.default
|
||
|
|
./configuration.nix
|
||
|
|
];
|
||
|
|
};
|
||
|
|
};
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`nixosModules.<name>` is the conventional path; nothing in the module system enforces the name, but tools and users expect it.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. Verifying a Module Without Building a System
|
||
|
|
|
||
|
|
`nix flake check` needs the module to be exercised somehow. The example uses the smallest viable approach: evaluate a throwaway NixOS configuration that imports the module, set the options to known values, and read one attribute back out.
|
||
|
|
|
||
|
|
```nix
|
||
|
|
testConfig = nixpkgs.lib.nixosSystem {
|
||
|
|
inherit system;
|
||
|
|
modules = [
|
||
|
|
self.nixosModules.default
|
||
|
|
{
|
||
|
|
system.stateVersion = "24.11";
|
||
|
|
playground.greeter = { enable = true; name = "flakes"; };
|
||
|
|
}
|
||
|
|
];
|
||
|
|
};
|
||
|
|
|
||
|
|
checks.${system}.greeting = pkgs.runCommand "greeting-check" {
|
||
|
|
got = testConfig.config.environment.etc."greeting".text;
|
||
|
|
expected = "hello, flakes";
|
||
|
|
} ''
|
||
|
|
[ "$got" = "$expected" ] || { echo "got=$got"; exit 1; }
|
||
|
|
echo ok > $out
|
||
|
|
'';
|
||
|
|
```
|
||
|
|
|
||
|
|
Three things to notice:
|
||
|
|
|
||
|
|
- The test config never triggers `system.build.toplevel`, so assertions that normally demand `fileSystems."/"`, a bootloader, and a hostname do not fire. Reading a single value through `.config.…` is a pure option lookup.
|
||
|
|
- `pkgs.runCommand` receives the computed greeting as an environment variable `got`. The comparison happens at build time, inside the sandbox, so a mismatch fails the build.
|
||
|
|
- The check only proves evaluation and one config value. Richer modules deserve richer checks: multiple configurations, negative cases, or a full `pkgs.nixosTest` VM.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. Option Types and Merging
|
||
|
|
|
||
|
|
`lib.types` covers the common cases:
|
||
|
|
|
||
|
|
- `types.bool`, `types.int`, `types.str`, `types.path`, `types.package`.
|
||
|
|
- `types.listOf <t>`, `types.attrsOf <t>`, `types.nullOr <t>`.
|
||
|
|
- `types.enum [ "a" "b" ]`, `types.submodule { options = { ... }; }`.
|
||
|
|
- `types.lines`, `types.separatedString <sep>` for accumulating strings.
|
||
|
|
|
||
|
|
Each type defines how multiple assignments merge. Booleans OR together by default, strings with `types.lines` concatenate with newlines, lists with `types.listOf` concatenate, attrsets with `types.attrsOf` deep-merge. When you need to override the merge, use `lib.mkForce`, `lib.mkDefault`, `lib.mkBefore`, or `lib.mkAfter`.
|
||
|
|
|
||
|
|
This merge behavior is why multiple modules can each add packages to `environment.systemPackages` without stepping on each other: the list type accumulates.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. Commands to Try
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd 04-nixos-module
|
||
|
|
|
||
|
|
nix flake show # confirms nixosModules.default and checks.*.greeting
|
||
|
|
nix flake check # evaluates the module and builds the check
|
||
|
|
|
||
|
|
# Inspect the module's effect on the test configuration:
|
||
|
|
nix eval .#checks.x86_64-linux.greeting.drvAttrs.got
|
||
|
|
|
||
|
|
# Flip the option off in a scratch eval to confirm mkIf elides the config:
|
||
|
|
nix eval --impure --expr '
|
||
|
|
let
|
||
|
|
flake = builtins.getFlake (toString ./.);
|
||
|
|
sys = (import <nixpkgs> {}).lib.nixosSystem {
|
||
|
|
system = "x86_64-linux";
|
||
|
|
modules = [
|
||
|
|
flake.nixosModules.default
|
||
|
|
{ system.stateVersion = "24.11"; playground.greeter.enable = false; }
|
||
|
|
];
|
||
|
|
};
|
||
|
|
in sys.config.environment.etc ? greeting
|
||
|
|
'
|
||
|
|
```
|
||
|
|
|
||
|
|
The last command should print `false`: with `enable = false`, the `mkIf` block contributes nothing, so `environment.etc.greeting` is never declared.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. When to Reach for a Module
|
||
|
|
|
||
|
|
Modules earn their weight in a few recurring situations:
|
||
|
|
|
||
|
|
- The same configuration needs to turn on across several hosts or flakes.
|
||
|
|
- Consumers deserve typed, documented knobs instead of raw config snippets.
|
||
|
|
- The integration belongs alongside `services.*`, `environment.*`, `users.*`, or `systemd.*` rather than next to them.
|
||
|
|
- A change set benefits from option merging, so multiple modules can contribute without overwriting each other.
|
||
|
|
|
||
|
|
For a one-off tweak to a single host, a plain `configuration.nix` snippet is usually smaller. The module system pays off once the same configuration needs reuse, parameterization, or validation.
|