diff --git a/README.md b/README.md index 44037a9..bde5a53 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,9 @@ Built-in options (always available): - `wrapper`: The resulting wrapped package (read-only, auto-generated from other options) - `apply`: Function to extend the configuration with additional modules (read-only) +Optional modules (import via `wlib.modules.`): +- `systemd`: Generates systemd service files (user and/or system), options are passed through from NixOS + Custom types: - `wlib.types.file`: File type with `content` and `path` options - `content`: File contents as string @@ -266,6 +269,105 @@ Wraps notmuch with INI-based configuration: }).wrapper ``` +### Generating systemd Services + +Import `wlib.modules.systemd` to generate systemd service files for your wrapper. +The options under `systemd` are the same as `systemd.services.` in NixOS, +passed through directly. + +`ExecStart` (including args), `Environment`, `PATH`, `preStart` and `postStop` +are picked up from the wrapper automatically, so you only need to set what's +specific to the service. + +The same config produces both a user and system service file, available at +`config.outputs.systemd-user` and `config.outputs.systemd-system`. Use +whichever fits your deployment. + +```nix +wlib.wrapModule ({ config, wlib, ... }: { + imports = [ wlib.modules.systemd ]; + + config = { + package = config.pkgs.hello; + flags."--greeting" = "world"; + env.HELLO_LANG = "en"; + systemd = { + description = "Hello service"; + serviceConfig.Type = "simple"; + serviceConfig.Restart = "on-failure"; + }; + }; +}) +``` + +Settings merge when using `apply`: + +```nix +extended = myWrapper.apply { + systemd.serviceConfig.Restart = "always"; + systemd.environment.EXTRA = "value"; +}; +``` + +#### Using in NixOS + +You need both `systemd.packages` for the unit file and the corresponding +`wantedBy` to actually activate it. NixOS does not read the `[Install]` section +from unit files, it creates the `.wants` symlinks from the module option instead. + +As a user service (for all users): + +```nix +# configuration.nix +{ pkgs, wrappers, ... }: +let + myHello = wrappers.wrapperModules.hello.apply { + inherit pkgs; + systemd.serviceConfig.Restart = "always"; + }; +in { + systemd.packages = [ myHello.outputs.systemd-user ]; + # NixOS needs this to create the .wants symlink, the [Install] + # section in the unit file alone is not enough + systemd.user.services.hello.wantedBy = [ "default.target" ]; +} +``` + +As a system service: + +```nix +# configuration.nix +{ pkgs, wrappers, ... }: +let + myHello = wrappers.wrapperModules.hello.apply { + inherit pkgs; + systemd.serviceConfig.Restart = "always"; + }; +in { + systemd.packages = [ myHello.outputs.systemd-system ]; + systemd.services.hello.wantedBy = [ "multi-user.target" ]; +} +``` + +#### Using in home-manager + +For per-user services, link via `xdg.dataFile`: + +```nix +# home.nix +{ pkgs, wrappers, ... }: +let + myHello = wrappers.wrapperModules.hello.apply { + inherit pkgs; + systemd.wantedBy = [ "default.target" ]; + systemd.serviceConfig.Restart = "always"; + }; +in { + xdg.dataFile."systemd/user/hello.service".source = + "${myHello.outputs.systemd-user}/systemd/user/hello.service"; +} +``` + ## alternatives - [wrapper-manager](https://github.com/viperML/wrapper-manager) by viperML. This project focuses more on a single module system, configuring wrappers and exporting them. This was an inspiration when building this library, but I wanted to have a more granular approach with a single module per package and a collection of community made modules. diff --git a/checks/systemd.nix b/checks/systemd.nix new file mode 100644 index 0000000..4598600 --- /dev/null +++ b/checks/systemd.nix @@ -0,0 +1,252 @@ +{ + pkgs, + self, +}: + +let + lib = pkgs.lib; + + # Test 1: Defaults from wrapper, both outputs from same config + withDefaults = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + flags."--greeting" = "world"; + env.HELLO_LANG = "en"; + systemd = { + description = "Hello service"; + serviceConfig.Type = "simple"; + wantedBy = [ "default.target" ]; + }; + }; + } + ); + + # Test 2: Override ExecStart + withOverride = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + env.FOO = "bar"; + systemd.serviceConfig = { + ExecStart = "/custom/bin/thing"; + Type = "oneshot"; + }; + }; + } + ); + + # Test 3: Service name from binName + customBinName = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + binName = "my-hello"; + systemd.serviceConfig.Type = "simple"; + }; + } + ); + + # Test 4: Deep merging via apply + baseModule = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + systemd = { + description = "Hello service"; + serviceConfig.Type = "simple"; + wantedBy = [ "default.target" ]; + }; + }; + } + ); + + extended = baseModule.apply { + systemd.serviceConfig.Restart = "always"; + systemd.environment.EXTRA = "value"; + }; + + # Test 5: Unit ordering + withDeps = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + systemd = { + description = "Hello with deps"; + after = [ "network.target" ]; + wants = [ "network.target" ]; + serviceConfig.Type = "simple"; + }; + }; + } + ); + + # Test 6: exePath, extraPackages, preHook, postHook + withHooks = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + extraPackages = [ pkgs.jq ]; + preHook = "echo pre"; + postHook = "echo post"; + systemd.serviceConfig.Type = "simple"; + }; + } + ); + + # Test 7: startAt generates a timer + withTimer = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + systemd = { + serviceConfig.Type = "oneshot"; + startAt = "hourly"; + }; + }; + } + ); + + readUserService = drv: name: builtins.readFile "${drv}/systemd/user/${name}.service"; + readSystemService = drv: name: builtins.readFile "${drv}/systemd/system/${name}.service"; + readUserTimer = drv: name: builtins.readFile "${drv}/systemd/user/${name}.timer"; + readSystemTimer = drv: name: builtins.readFile "${drv}/systemd/system/${name}.timer"; +in +pkgs.runCommand "systemd-test" { } '' + echo "Testing systemd module..." + + # Test 1a: User service output + echo "Test 1a: User service defaults from wrapper" + user='${readUserService withDefaults.outputs.systemd-user "hello"}' + echo "$user" | grep -q 'Description=Hello service' || { echo "FAIL: missing description"; exit 1; } + echo "$user" | grep -q 'ExecStart=.*/bin/hello' || { echo "FAIL: ExecStart should default to exePath"; echo "$user"; exit 1; } + echo "$user" | grep -q '\-\-greeting' || { echo "FAIL: ExecStart should include args"; echo "$user"; exit 1; } + echo "$user" | grep -qF '"HELLO_LANG=en"' || { echo "FAIL: Environment should include env"; echo "$user"; exit 1; } + echo "$user" | grep -q 'WantedBy=default.target' || { echo "FAIL: missing WantedBy"; exit 1; } + echo "PASS: user service defaults" + + # Test 1b: System service output from same config + echo "Test 1b: System service output from same config" + system='${readSystemService withDefaults.outputs.systemd-system "hello"}' + echo "$system" | grep -q 'Description=Hello service' || { echo "FAIL: missing description"; exit 1; } + echo "$system" | grep -q 'ExecStart=.*/bin/hello' || { echo "FAIL: ExecStart should default to exePath"; echo "$system"; exit 1; } + echo "$system" | grep -qF '"HELLO_LANG=en"' || { echo "FAIL: Environment should include env"; echo "$system"; exit 1; } + echo "PASS: system service output from same config" + + # Test 2: Override ExecStart + echo "Test 2: Override ExecStart" + override='${readUserService withOverride.outputs.systemd-user "hello"}' + echo "$override" | grep -q 'ExecStart=/custom/bin/thing' || { echo "FAIL: ExecStart override not applied"; echo "$override"; exit 1; } + echo "$override" | grep -q 'Type=oneshot' || { echo "FAIL: Type override not applied"; exit 1; } + echo "PASS: override ExecStart" + + # Test 3: Service name from binName + echo "Test 3: Service name from binName" + test -f "${customBinName.outputs.systemd-user}/systemd/user/my-hello.service" || { + echo "FAIL: user service file should be named my-hello.service" + ls -la "${customBinName.outputs.systemd-user}/systemd/user/" + exit 1 + } + test -f "${customBinName.outputs.systemd-system}/systemd/system/my-hello.service" || { + echo "FAIL: system service file should be named my-hello.service" + ls -la "${customBinName.outputs.systemd-system}/systemd/system/" + exit 1 + } + echo "PASS: service name from binName" + + # Test 4: Deep merging via apply + echo "Test 4: Deep merging via apply" + extended='${readUserService extended.outputs.systemd-user "hello"}' + echo "$extended" | grep -q 'Description=Hello service' || { echo "FAIL: description lost after apply"; exit 1; } + echo "$extended" | grep -q 'Type=simple' || { echo "FAIL: Type lost after apply"; exit 1; } + echo "$extended" | grep -q 'Restart=always' || { echo "FAIL: Restart not merged"; exit 1; } + echo "$extended" | grep -qF '"EXTRA=value"' || { echo "FAIL: environment not merged"; exit 1; } + echo "$extended" | grep -q 'WantedBy=default.target' || { echo "FAIL: WantedBy lost after apply"; exit 1; } + echo "PASS: deep merging via apply" + + # Test 5: Unit ordering + echo "Test 5: Unit ordering" + withDeps='${readUserService withDeps.outputs.systemd-user "hello"}' + echo "$withDeps" | grep -q 'After=network.target' || { echo "FAIL: missing After"; exit 1; } + echo "$withDeps" | grep -q 'Wants=network.target' || { echo "FAIL: missing Wants"; exit 1; } + echo "PASS: unit ordering" + + # Test 6: exePath, extraPackages, preHook, postHook + echo "Test 6: exePath, extraPackages, preHook, postHook" + hooks='${readUserService withHooks.outputs.systemd-user "hello"}' + echo "$hooks" | grep -q 'ExecStart=${pkgs.hello}/bin/hello' || { echo "FAIL: ExecStart should use exePath"; echo "$hooks"; exit 1; } + echo "$hooks" | grep -q '${pkgs.jq}' || { echo "FAIL: extraPackages (jq) not in PATH"; echo "$hooks"; exit 1; } + echo "$hooks" | grep -q 'ExecStartPre=.*hello-pre-start' || { echo "FAIL: preHook not mapped to ExecStartPre"; echo "$hooks"; exit 1; } + echo "$hooks" | grep -q 'ExecStopPost=.*hello-post-stop' || { echo "FAIL: postHook not mapped to ExecStopPost"; echo "$hooks"; exit 1; } + echo "PASS: exePath, extraPackages, preHook, postHook" + + # Test 7: startAt generates a timer + echo "Test 7: startAt generates a timer" + timerSvc='${readUserService withTimer.outputs.systemd-user "hello"}' + echo "$timerSvc" | grep -q 'ExecStart=.*/bin/hello' || { echo "FAIL: service missing ExecStart"; echo "$timerSvc"; exit 1; } + timer='${readUserTimer withTimer.outputs.systemd-user "hello"}' + echo "$timer" | grep -q 'OnCalendar=hourly' || { echo "FAIL: timer missing OnCalendar"; echo "$timer"; exit 1; } + echo "$timer" | grep -q 'WantedBy=timers.target' || { echo "FAIL: timer missing WantedBy"; echo "$timer"; exit 1; } + systemTimer='${readSystemTimer withTimer.outputs.systemd-system "hello"}' + echo "$systemTimer" | grep -q 'OnCalendar=hourly' || { echo "FAIL: system timer missing OnCalendar"; echo "$systemTimer"; exit 1; } + echo "PASS: startAt generates a timer" + + echo "SUCCESS: All systemd tests passed" + touch $out +'' diff --git a/lib/default.nix b/lib/default.nix index ec93b18..5729140 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -249,7 +249,9 @@ let inherit modules class specialArgs; }; - modules = lib.genAttrs [ "package" "wrapper" "meta" ] (name: import ./modules/${name}.nix); + modules = lib.genAttrs [ "package" "wrapper" "meta" "systemd" ] ( + name: import ./modules/${name}.nix + ); /** Create a wrapper configuration using the NixOS module system. diff --git a/lib/modules/systemd.nix b/lib/modules/systemd.nix new file mode 100644 index 0000000..8f3d696 --- /dev/null +++ b/lib/modules/systemd.nix @@ -0,0 +1,164 @@ +{ + config, + lib, + ... +}: +let + cfg = config.systemd; + + serviceName = builtins.unsafeDiscardStringContext (config.binName or "unknown"); + + mkNixosEval = + type: + let + nixosPath = + if type == "user" then + [ + "systemd" + "user" + "services" + ] + else + [ + "systemd" + "services" + ]; + in + import (config.pkgs.path + "/nixos/lib/eval-config.nix") { + inherit lib; + system = null; + modules = [ + { config.nixpkgs.hostPlatform = config.pkgs.stdenv.hostPlatform.system; } + { config.systemd.globalEnvironment = lib.mkForce { }; } + { config = lib.setAttrByPath nixosPath { ${serviceName} = cfg; }; } + ]; + }; + + mkUtils = + nixos: + import (config.pkgs.path + "/nixos/lib/utils.nix") { + inherit lib; + inherit (config) pkgs; + inherit (nixos) config; + }; + + mkOutputs = + type: + let + nixos = mkNixosEval type; + utils = mkUtils nixos; + + servicePath = + if type == "user" then + [ + "systemd" + "user" + "services" + ] + else + [ + "systemd" + "services" + ]; + + timerPath = + if type == "user" then + [ + "systemd" + "user" + "timers" + ] + else + [ + "systemd" + "timers" + ]; + + unitDir = if type == "user" then "systemd/user" else "systemd/system"; + + evaluatedService = (lib.getAttrFromPath servicePath nixos.config).${serviceName}; + serviceUnit = utils.systemdUtils.lib.serviceToUnit evaluatedService; + + evaluatedTimers = lib.getAttrFromPath timerPath nixos.config; + hasTimer = evaluatedTimers ? ${serviceName}; + timerUnit = utils.systemdUtils.lib.timerToUnit evaluatedTimers.${serviceName}; + in + { + service = config.pkgs.writeTextDir "${unitDir}/${serviceName}.service" serviceUnit.text; + timer = + if hasTimer then + config.pkgs.writeTextDir "${unitDir}/${serviceName}.timer" timerUnit.text + else + null; + }; + + userOutputs = mkOutputs "user"; + systemOutputs = mkOutputs "system"; +in +{ + _file = "lib/modules/systemd.nix"; + + options.systemd = lib.mkOption { + type = lib.types.submodule { freeformType = with lib.types; attrsOf anything; }; + default = { }; + description = '' + Systemd service configuration. + Accepts the same options as systemd.services. or + systemd.user.services. in NixOS. + + ExecStart, Environment, PATH, preStart and postStop are set from the + wrapper by default. + + If startAt is set, a corresponding .timer unit is generated alongside + the service. + + The generated unit files are available at outputs.systemd-user and + outputs.systemd-system. + ''; + }; + + config.systemd = { + enableDefaultPath = lib.mkDefault false; + serviceConfig.ExecStart = lib.mkDefault ( + lib.concatStringsSep " " ([ config.exePath ] ++ config.args) + ); + environment = lib.mkDefault config.env; + path = lib.mkDefault config.extraPackages; + preStart = lib.mkIf (config.preHook != "") (lib.mkDefault config.preHook); + postStop = lib.mkIf (config.postHook != "") (lib.mkDefault config.postHook); + }; + + options.outputs.systemd-user = lib.mkOption { + type = lib.types.package; + readOnly = true; + description = "The generated systemd user service file (and timer if startAt is set)."; + default = + if userOutputs.timer != null then + config.pkgs.symlinkJoin { + name = "${serviceName}-user-units"; + paths = [ + userOutputs.service + userOutputs.timer + ]; + } + else + userOutputs.service; + }; + + options.outputs.systemd-system = lib.mkOption { + type = lib.types.package; + readOnly = true; + description = "The generated systemd system service file (and timer if startAt is set)."; + default = + if systemOutputs.timer != null then + config.pkgs.symlinkJoin { + name = "${serviceName}-system-units"; + paths = [ + systemOutputs.service + systemOutputs.timer + ]; + } + else + systemOutputs.service; + }; +}