From 7d9af4a648b210a137158a30c0d06782b77d8cd2 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Fri, 17 Apr 2026 12:13:37 +0200 Subject: [PATCH] Add NixOS module example with a note file for it --- 04-nixos-module/flake.lock | 27 +++++++ 04-nixos-module/flake.nix | 56 ++++++++++++++ 04-nixos-module/module.nix | 23 ++++++ notes/006-nixos-modules.md | 152 +++++++++++++++++++++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 04-nixos-module/flake.lock create mode 100644 04-nixos-module/flake.nix create mode 100644 04-nixos-module/module.nix create mode 100644 notes/006-nixos-modules.md diff --git a/04-nixos-module/flake.lock b/04-nixos-module/flake.lock new file mode 100644 index 0000000..682004d --- /dev/null +++ b/04-nixos-module/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/04-nixos-module/flake.nix b/04-nixos-module/flake.nix new file mode 100644 index 0000000..6803969 --- /dev/null +++ b/04-nixos-module/flake.nix @@ -0,0 +1,56 @@ +{ + # Defines a minimal NixOS module with one option and one config effect, + # exposes it as `nixosModules.default`, and verifies it by evaluating a + # throwaway NixOS configuration that imports the module. + description = "A minimal NixOS module"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs, ... }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + + # Throwaway NixOS configuration used only by the check below. + # We never build `system.build.toplevel`, so bootloader and + # `fileSystems` assertions never fire; reading a single config + # attribute is enough to prove the module evaluates. + testConfig = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + self.nixosModules.default + { + system.stateVersion = "24.11"; + playground.greeter = { + enable = true; + name = "flakes"; + }; + } + ]; + }; + in + { + # Other flakes consume the module via: + # imports = [ inputs.nix-playground.nixosModules.default ]; + nixosModules.default = import ./module.nix; + + # Builds only if the merged config's greeting matches expectation. + checks.${system}.greeting = + pkgs.runCommand "greeting-check" + { + got = testConfig.config.environment.etc."greeting".text; + expected = "hello, flakes"; + } + '' + if [ "$got" = "$expected" ]; then + echo ok > $out + else + echo "unexpected greeting: $got" >&2 + exit 1 + fi + ''; + }; +} diff --git a/04-nixos-module/module.nix b/04-nixos-module/module.nix new file mode 100644 index 0000000..9aafa63 --- /dev/null +++ b/04-nixos-module/module.nix @@ -0,0 +1,23 @@ +{ lib, config, ... }: + +let + cfg = config.playground.greeter; +in +{ + # One option namespace keeps the example focused. + options.playground.greeter = { + enable = lib.mkEnableOption "the playground greeter"; + + name = lib.mkOption { + type = lib.types.str; + default = "world"; + description = "Name placed in the generated /etc/greeting file."; + }; + }; + + # `mkIf` discards the whole config block when enable is false, so a + # system that imports the module but leaves it disabled pays nothing. + config = lib.mkIf cfg.enable { + environment.etc."greeting".text = "hello, ${cfg.name}"; + }; +} diff --git a/notes/006-nixos-modules.md b/notes/006-nixos-modules.md new file mode 100644 index 0000000..dddae77 --- /dev/null +++ b/notes/006-nixos-modules.md @@ -0,0 +1,152 @@ +# 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.` 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 `, `types.attrsOf `, `types.nullOr `. +- `types.enum [ "a" "b" ]`, `types.submodule { options = { ... }; }`. +- `types.lines`, `types.separatedString ` 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 {}).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.