modular services: Add configData option for etc-like files

+451
+27
nixos/modules/system/service/README.md
··· 8 8 9 9 # Design decision log 10 10 11 + ## Initial design 12 + 11 13 - `system.services.<name>`. Alternatives considered 12 14 - `systemServices`: similar to does not allow importing a composition of services into `system`. Not sure if that's a good idea in the first place, but I've kept the possibility open. 13 15 - `services.abstract`: used in https://github.com/NixOS/nixpkgs/pull/267111, but too weird. Service modules should fit naturally into the configuration system. ··· 26 28 2. `systemd/system` configures SystemD _system units_. 27 29 - This reserves `modules/service` for actual service modules, at least until those are lifted out of NixOS, potentially 28 30 31 + ## Configuration Data (`configData`) Design 32 + 33 + Without a mechanism for adding files, all configuration had to go through `process.*`, requiring process restarts even when those would have been avoidable. 34 + Many services implement automatic reloading or reloading on e.g. `SIGUSR1`, but those mechanisms need files to read. `configData` provides such files. 35 + 36 + ### Naming and Terminology 37 + 38 + - **`configData` instead of `environment.etc`**: The name `configData` is service manager agnostic. While systemd system services can use `/etc`, other service managers may expose configuration data differently (e.g., different directory, relative paths). 39 + 40 + - **`path` attribute**: Each `configData` entry automatically gets a `path` attribute set by the service manager implementation, allowing services to reference the location of their configuration files. These paths themselves are not subject to change from generation to generation; only their contents are. 41 + 42 + - **`name` attribute**: In `environment.etc` this would be `target` but that's confusing, especially for symlinks, as it's not the symlink's target. 43 + 44 + ### Service Manager Integration 45 + 46 + - **Portable base**: The `configData` interface is declared in `portable/config-data.nix`, making it available to all service manager implementations. 47 + 48 + - **Systemd integration**: The systemd implementation (`systemd/system.nix`) maps `configData` entries to `environment.etc` entries under `/etc/system-services/`. 49 + 50 + - **Path computation**: `systemd/config-data-path.nix` recursively computes unique paths for services and sub-services (e.g., `/etc/system-services/webserver/` vs `/etc/system-services/webserver-api/`). 51 + Fun fact: for the module system it is a completely normal module, despite its recursive definition. 52 + If we parameterize `/etc/system-services`, it will have to become an `importApply` style module nonetheless (function returning module). 53 + 54 + - **Simple attribute structure**: Unlike `environment.etc`, `configData` uses a simpler structure with just `enable`, `name`, `text`, `source`, and `path` attributes. Complex ownership options were omitted for simplicity and portability. 55 + Per-service user creation is still TBD.
+65
nixos/modules/system/service/portable/config-data-item.nix
··· 1 + # Tests in: ../../../../tests/modular-service-etc/test.nix 2 + # This file is a function that returns a module. 3 + pkgs: 4 + { 5 + lib, 6 + name, 7 + config, 8 + options, 9 + ... 10 + }: 11 + let 12 + inherit (lib) mkOption types; 13 + in 14 + { 15 + options = { 16 + enable = mkOption { 17 + type = types.bool; 18 + default = true; 19 + description = '' 20 + Whether this configuration file should be generated. 21 + This option allows specific configuration files to be disabled. 22 + ''; 23 + }; 24 + 25 + name = mkOption { 26 + type = types.str; 27 + description = '' 28 + Name of the configuration file (relative to the service's configuration directory). Defaults to the attribute name. 29 + ''; 30 + }; 31 + 32 + path = mkOption { 33 + type = types.str; 34 + readOnly = true; 35 + description = '' 36 + The actual path where this configuration file will be available. 37 + This is determined by the service manager implementation. 38 + 39 + On NixOS it is an absolute path. 40 + Other service managers may provide a relative path, in order to be unprivileged and/or relocatable. 41 + ''; 42 + }; 43 + 44 + text = mkOption { 45 + default = null; 46 + type = types.nullOr types.lines; 47 + description = "Text content of the configuration file."; 48 + }; 49 + 50 + source = mkOption { 51 + type = types.path; 52 + description = "Path of the source file."; 53 + }; 54 + }; 55 + 56 + config = { 57 + name = lib.mkDefault name; 58 + source = lib.mkIf (config.text != null) ( 59 + let 60 + name' = "service-configdata-" + lib.replaceStrings [ "/" ] [ "-" ] name; 61 + in 62 + lib.mkDerivedConfig options.text (pkgs.writeText name') 63 + ); 64 + }; 65 + }
+44
nixos/modules/system/service/portable/config-data.nix
··· 1 + # Tests in: ../../../../tests/modular-service-etc/test.nix 2 + # Configuration data support for portable services 3 + # This module provides configData for services, enabling configuration reloading 4 + # without terminating and restarting the service process. 5 + { 6 + lib, 7 + pkgs, 8 + ... 9 + }: 10 + let 11 + inherit (lib) mkOption types; 12 + inherit (lib.modules) importApply; 13 + in 14 + { 15 + options = { 16 + configData = mkOption { 17 + default = { }; 18 + example = lib.literalExpression '' 19 + { 20 + "server.conf" = { 21 + text = ''' 22 + port = 8080 23 + workers = 4 24 + '''; 25 + }; 26 + "ssl/cert.pem" = { 27 + source = ./cert.pem; 28 + }; 29 + } 30 + ''; 31 + description = '' 32 + Configuration data files for the service 33 + 34 + These files are made available to the service and can be updated without restarting the service process, enabling configuration reloading. 35 + The service manager implementation determines how these files are exposed to the service (e.g., via a specific directory path). 36 + This path is available in the `path` sub-option for each `configData.<name>` entry. 37 + 38 + This is particularly useful for services that support configuration reloading via signals (e.g., SIGHUP) or which pick up changes automatically, so that no downtime is required in order to reload the service. 39 + ''; 40 + 41 + type = types.lazyAttrsOf (types.submodule (importApply ./config-data-item.nix pkgs)); 42 + }; 43 + }; 44 + }
+1
nixos/modules/system/service/portable/service.nix
··· 11 11 _class = "service"; 12 12 imports = [ 13 13 ../../../misc/assertions.nix 14 + ./config-data.nix 14 15 ]; 15 16 options = { 16 17 services = mkOption {
+39
nixos/modules/system/service/systemd/config-data-path.nix
··· 1 + # Tests in: ../../tests/modular-service-etc/test.nix 2 + # This module sets the path for configData entries in systemd services 3 + let 4 + setPathsModule = 5 + prefix: 6 + { lib, name, ... }: 7 + let 8 + inherit (lib) mkOption types; 9 + servicePrefix = "${prefix}${name}"; 10 + in 11 + { 12 + _class = "service"; 13 + options = { 14 + # Extend portable configData option 15 + configData = mkOption { 16 + type = types.lazyAttrsOf ( 17 + types.submodule ( 18 + { config, ... }: 19 + { 20 + config = { 21 + path = lib.mkDefault "/etc/system-services/${servicePrefix}/${config.name}"; 22 + }; 23 + } 24 + ) 25 + ); 26 + }; 27 + services = mkOption { 28 + type = types.attrsOf ( 29 + types.submoduleWith { 30 + modules = [ 31 + (setPathsModule "${servicePrefix}-") 32 + ]; 33 + } 34 + ); 35 + }; 36 + }; 37 + }; 38 + in 39 + setPathsModule ""
+26
nixos/modules/system/service/systemd/system.nix
··· 26 26 else 27 27 "${before}-${after}"; 28 28 29 + makeNixosEtcFiles = 30 + prefix: service: 31 + let 32 + # Convert configData entries to environment.etc entries 33 + serviceConfigData = lib.mapAttrs' (name: cfg: { 34 + name = 35 + # cfg.path is read only and prefixed with unique service name; see ./config-data-path.nix 36 + assert lib.hasPrefix "/etc/system-services" cfg.path; 37 + lib.removePrefix "/etc/" cfg.path; 38 + value = { 39 + inherit (cfg) enable source; 40 + }; 41 + }) (service.configData or { }); 42 + 43 + # Recursively process sub-services 44 + subServiceConfigData = concatMapAttrs ( 45 + subServiceName: subService: makeNixosEtcFiles (dash prefix subServiceName) subService 46 + ) service.services; 47 + in 48 + serviceConfigData // subServiceConfigData; 49 + 29 50 makeUnits = 30 51 unitType: prefix: service: 31 52 concatMapAttrs (unitName: unitModule: { ··· 51 72 class = "service"; 52 73 modules = [ 53 74 ./service.nix 75 + ./config-data-path.nix 54 76 55 77 # TODO: Consider removing pkgs. Service modules can provide their own 56 78 # dependencies. ··· 101 123 102 124 systemd.sockets = concatMapAttrs ( 103 125 serviceName: topLevelService: makeUnits "sockets" serviceName topLevelService 126 + ) config.system.services; 127 + 128 + environment.etc = concatMapAttrs ( 129 + serviceName: topLevelService: makeNixosEtcFiles serviceName topLevelService 104 130 ) config.system.services; 105 131 }; 106 132 }
+1
nixos/tests/all-tests.nix
··· 918 918 modularService = pkgs.callPackage ../modules/system/service/systemd/test.nix { 919 919 inherit evalSystem; 920 920 }; 921 + modular-service-etc = runTest ./modular-service-etc/test.nix; 921 922 molly-brown = runTest ./molly-brown.nix; 922 923 mollysocket = runTest ./mollysocket.nix; 923 924 monado = runTest ./monado.nix;
+67
nixos/tests/modular-service-etc/python-http-server.nix
··· 1 + # Tests in: ./test.nix 2 + # This module provides a basic web server based on the python built-in http.server package. 3 + { 4 + config, 5 + lib, 6 + pkgs, 7 + ... 8 + }: 9 + let 10 + inherit (lib) mkOption types; 11 + in 12 + { 13 + _class = "service"; 14 + 15 + options = { 16 + python-http-server = { 17 + package = mkOption { 18 + type = types.package; 19 + default = pkgs.python3; 20 + description = "Python package to use for the web server"; 21 + }; 22 + 23 + port = mkOption { 24 + type = types.port; 25 + default = 8000; 26 + description = "Port to listen on"; 27 + }; 28 + 29 + directory = mkOption { 30 + type = types.str; 31 + default = config.configData."webroot".path; 32 + defaultText = lib.literalExpression ''config.configData."webroot".path''; 33 + description = "Directory to serve files from"; 34 + }; 35 + }; 36 + }; 37 + 38 + config = { 39 + process.argv = [ 40 + "${lib.getExe config.python-http-server.package}" 41 + "-m" 42 + "http.server" 43 + "${toString config.python-http-server.port}" 44 + "--directory" 45 + config.python-http-server.directory 46 + ]; 47 + 48 + configData = { 49 + # This should probably just be {} if we were to put this module in production. 50 + "webroot" = lib.mkDefault { 51 + source = pkgs.runCommand "default-webroot" { } '' 52 + mkdir -p $out 53 + cat > $out/index.html << 'EOF' 54 + <!DOCTYPE html> 55 + <html> 56 + <head><title>Python Web Server</title></head> 57 + <body> 58 + <h1>Welcome to the Python Web Server</h1> 59 + <p>Serving from port ${toString config.python-http-server.port}</p> 60 + </body> 61 + </html> 62 + EOF 63 + ''; 64 + }; 65 + }; 66 + }; 67 + }
+181
nixos/tests/modular-service-etc/test.nix
··· 1 + # Run with: 2 + # cd nixpkgs 3 + # nix-build -A nixosTests.modular-service-etc 4 + 5 + # This tests the NixOS modular service integration to make sure `etc` entries 6 + # are generated correctly for `configData` files. 7 + { lib, ... }: 8 + { 9 + _class = "nixosTest"; 10 + name = "modular-service-etc"; 11 + 12 + nodes = { 13 + server = 14 + { pkgs, ... }: 15 + { 16 + system.services.webserver = { 17 + # The python web server is simple enough that it doesn't need a reload signal. 18 + # Other services may need to receive a signal in order to re-read what's in `configData`. 19 + imports = [ ./python-http-server.nix ]; 20 + python-http-server = { 21 + port = 8080; 22 + }; 23 + 24 + # Add a sub-service 25 + services.api = { 26 + imports = [ ./python-http-server.nix ]; 27 + python-http-server = { 28 + port = 8081; 29 + }; 30 + configData = { 31 + "webroot" = { 32 + source = pkgs.runCommand "api-webroot" { } '' 33 + mkdir -p $out 34 + cat > $out/index.html << 'EOF' 35 + <!DOCTYPE html> 36 + <html> 37 + <head><title>API Sub-service</title></head> 38 + <body> 39 + <h1>API Sub-service</h1> 40 + <p>This is a sub-service running on port 8081</p> 41 + </body> 42 + </html> 43 + EOF 44 + cat > $out/status.json << 'EOF' 45 + {"status": "ok", "service": "api", "port": 8081} 46 + EOF 47 + ''; 48 + }; 49 + }; 50 + }; 51 + }; 52 + 53 + networking.firewall.allowedTCPPorts = [ 54 + 8080 55 + 8081 56 + ]; 57 + 58 + specialisation.updated.configuration = { 59 + system.services.webserver = { 60 + configData = { 61 + "webroot" = { 62 + source = lib.mkForce ( 63 + pkgs.runCommand "webroot-updated" { } '' 64 + mkdir -p $out 65 + cat > $out/index.html << 'EOF' 66 + <!DOCTYPE html> 67 + <html> 68 + <head><title>Updated Python Web Server</title></head> 69 + <body> 70 + <h1>Updated content via specialisation</h1> 71 + <p>This content was changed without restarting the service</p> 72 + </body> 73 + </html> 74 + EOF 75 + '' 76 + ); 77 + }; 78 + }; 79 + 80 + services.api = { 81 + configData = { 82 + "webroot" = { 83 + source = lib.mkForce ( 84 + pkgs.runCommand "api-webroot-updated" { } '' 85 + mkdir -p $out 86 + cat > $out/index.html << 'EOF' 87 + <!DOCTYPE html> 88 + <html> 89 + <head><title>Updated API Sub-service</title></head> 90 + <body> 91 + <h1>Updated API Sub-service</h1> 92 + <p>This sub-service content was also updated</p> 93 + </body> 94 + </html> 95 + EOF 96 + cat > $out/status.json << 'EOF' 97 + {"status": "updated", "service": "api", "port": 8081, "version": "2.0"} 98 + EOF 99 + '' 100 + ); 101 + }; 102 + }; 103 + }; 104 + }; 105 + }; 106 + }; 107 + 108 + client = 109 + { pkgs, ... }: 110 + { 111 + environment.systemPackages = [ pkgs.curl ]; 112 + }; 113 + }; 114 + 115 + testScript = '' 116 + start_all() 117 + 118 + server.wait_for_unit("multi-user.target") 119 + client.wait_for_unit("multi-user.target") 120 + 121 + # Wait for the web servers to start 122 + server.wait_for_unit("webserver.service") 123 + server.wait_for_open_port(8080) 124 + server.wait_for_unit("webserver-api.service") 125 + server.wait_for_open_port(8081) 126 + 127 + # Check that the configData directories were created with unique paths 128 + server.succeed("test -d /etc/system-services/webserver/webroot") 129 + server.succeed("test -f /etc/system-services/webserver/webroot/index.html") 130 + server.succeed("test -d /etc/system-services/webserver-api/webroot") 131 + server.succeed("test -f /etc/system-services/webserver-api/webroot/index.html") 132 + server.succeed("test -f /etc/system-services/webserver-api/webroot/status.json") 133 + 134 + # Check that the main web server is serving the configData content 135 + client.succeed("curl -f http://server:8080/index.html | grep 'Welcome to the Python Web Server'") 136 + client.succeed("curl -f http://server:8080/index.html | grep 'Serving from port 8080'") 137 + 138 + # Check that the sub-service is serving its own configData content 139 + client.succeed("curl -f http://server:8081/index.html | grep 'API Sub-service'") 140 + client.succeed("curl -f http://server:8081/index.html | grep 'This is a sub-service running on port 8081'") 141 + client.succeed("curl -f http://server:8081/status.json | grep '\"service\": \"api\"'") 142 + 143 + # Record PIDs before switching to verify services aren't restarted 144 + webserver_pid = server.succeed("systemctl show webserver.service --property=MainPID --value").strip() 145 + api_pid = server.succeed("systemctl show webserver-api.service --property=MainPID --value").strip() 146 + 147 + print(f"Before switch - webserver PID: {webserver_pid}, api PID: {api_pid}") 148 + 149 + # Switch to the specialisation with updated content 150 + switch_output = server.succeed("/run/current-system/specialisation/updated/bin/switch-to-configuration test") 151 + print(f"Switch output: {switch_output}") 152 + 153 + # Verify services are not mentioned in the switch output (indicating they weren't touched) 154 + assert "webserver.service" not in switch_output, f"webserver.service was mentioned in switch output: {switch_output}" 155 + assert "webserver-api.service" not in switch_output, f"webserver-api.service was mentioned in switch output: {switch_output}" 156 + 157 + # Verify the content was updated without restarting the services 158 + server.succeed("systemctl is-active webserver.service") 159 + server.succeed("systemctl is-active webserver-api.service") 160 + 161 + # Verify PIDs are the same (services weren't restarted) 162 + webserver_pid_after = server.succeed("systemctl show webserver.service --property=MainPID --value").strip() 163 + api_pid_after = server.succeed("systemctl show webserver-api.service --property=MainPID --value").strip() 164 + 165 + print(f"After switch - webserver PID: {webserver_pid_after}, api PID: {api_pid_after}") 166 + 167 + assert webserver_pid == webserver_pid_after, f"webserver.service was restarted: PID changed from {webserver_pid} to {webserver_pid_after}" 168 + assert api_pid == api_pid_after, f"webserver-api.service was restarted: PID changed from {api_pid} to {api_pid_after}" 169 + 170 + # Check main service updated content 171 + client.succeed("curl -f http://server:8080/index.html | grep 'Updated content via specialisation'") 172 + client.succeed("curl -f http://server:8080/index.html | grep 'This content was changed without restarting the service'") 173 + 174 + # Check sub-service updated content 175 + client.succeed("curl -f http://server:8081/index.html | grep 'Updated API Sub-service'") 176 + client.succeed("curl -f http://server:8081/index.html | grep 'This sub-service content was also updated'") 177 + client.succeed("curl -f http://server:8081/status.json | grep '\"version\": \"2.0\"'") 178 + ''; 179 + 180 + meta.maintainers = with lib.maintainers; [ roberth ]; 181 + }