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..f42b193 --- /dev/null +++ b/checks/systemd.nix @@ -0,0 +1,406 @@ +{ + 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"; + }; + }; + } + ); + + # Test 8: Args with spaces are properly quoted for systemd + withSpacedArgs = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + flags."--greeting" = "hello world"; + systemd.serviceConfig.Type = "simple"; + }; + } + ); + + # Test 9: Args with quotes and backslashes + withSpecialArgs = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + flags."--greeting" = ''say "hi"''; + systemd.serviceConfig.Type = "simple"; + }; + } + ); + + # Test 10: Env vars with spaces + withSpecialEnv = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + env.MY_VAR = "hello world"; + env.SIMPLE = "plain"; + systemd.serviceConfig.Type = "simple"; + }; + } + ); + + # Test 11: Multiple extraPackages in PATH + withMultiPath = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + extraPackages = [ + pkgs.jq + pkgs.coreutils + ]; + systemd.serviceConfig.Type = "simple"; + }; + } + ); + + # Test 12: Minimal config (only required fields) + minimalConfig = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + }; + } + ); + + # Test 13: wrapper output still works when systemd module is imported + withWrapper = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + imports = [ wlib.modules.systemd ]; + config = { + pkgs = pkgs; + package = pkgs.hello; + flags."--greeting" = "world"; + systemd.serviceConfig.Type = "simple"; + }; + } + ); + + 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" + + # Test 8: Args with spaces are properly quoted + echo "Test 8: Args with spaces are quoted for systemd" + spaced='${readUserService withSpacedArgs.outputs.systemd-user "hello"}' + echo "$spaced" | grep -qF '"hello world"' || { echo "FAIL: spaced arg not quoted"; echo "$spaced"; exit 1; } + echo "PASS: args with spaces" + + # Test 9: Args with quotes and backslashes + echo "Test 9: Args with quotes and backslashes" + special='${readUserService withSpecialArgs.outputs.systemd-user "hello"}' + echo "$special" | grep -qF '"say \"hi\""' || { echo "FAIL: special chars not escaped"; echo "$special"; exit 1; } + echo "PASS: args with special chars" + + # Test 10: Env vars with spaces + echo "Test 10: Env vars with spaces" + specialEnv='${readUserService withSpecialEnv.outputs.systemd-user "hello"}' + echo "$specialEnv" | grep -qF 'MY_VAR=hello world' || { echo "FAIL: env with spaces"; echo "$specialEnv"; exit 1; } + echo "$specialEnv" | grep -qF 'SIMPLE=plain' || { echo "FAIL: simple env missing"; echo "$specialEnv"; exit 1; } + echo "PASS: env vars with spaces" + + # Test 11: Multiple extraPackages in PATH + echo "Test 11: Multiple extraPackages in PATH" + multiPath='${readUserService withMultiPath.outputs.systemd-user "hello"}' + echo "$multiPath" | grep -q '${pkgs.jq}' || { echo "FAIL: jq not in PATH"; echo "$multiPath"; exit 1; } + echo "$multiPath" | grep -q '${pkgs.coreutils}' || { echo "FAIL: coreutils not in PATH"; echo "$multiPath"; exit 1; } + echo "PASS: multiple extraPackages" + + # Test 12: Minimal config produces a valid unit + echo "Test 12: Minimal config" + minimal='${readUserService minimalConfig.outputs.systemd-user "hello"}' + echo "$minimal" | grep -q 'ExecStart=.*/bin/hello' || { echo "FAIL: minimal missing ExecStart"; echo "$minimal"; exit 1; } + echo "$minimal" | grep -q '\[Service\]' || { echo "FAIL: minimal missing [Service] section"; echo "$minimal"; exit 1; } + echo "PASS: minimal config" + + # Test 13: wrapper output still works with systemd module + echo "Test 13: wrapper still works with systemd module" + ${withWrapper.wrapper}/bin/hello | grep -q 'world' || { echo "FAIL: wrapper broken"; exit 1; } + echo "PASS: wrapper still works" + + 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..1993ca9 --- /dev/null +++ b/lib/modules/systemd.nix @@ -0,0 +1,137 @@ +{ + config, + lib, + ... +}: +let + cfg = config.systemd; + pkgs = config.pkgs; + + serviceName = config.binName; + + # Import the systemd unit generation helpers directly from nixpkgs. + systemdLib = import (pkgs.path + "/nixos/lib/systemd-lib.nix") { + inherit lib pkgs; + config.systemd = { + globalEnvironment = { }; + enableStrictShellChecks = true; + package = pkgs.systemd; + }; + utils = { }; + }; + + unitOptions = import (pkgs.path + "/nixos/lib/systemd-unit-options.nix") { + inherit lib; + systemdUtils.lib = systemdLib; + }; + + # Evaluate a single service using the same submodule composition as + # NixOS (stage2ServiceOptions + unitConfig + stage2ServiceConfig). + svcEval = lib.evalModules { + modules = [ + unitOptions.stage2ServiceOptions + systemdLib.unitConfig + systemdLib.stage2ServiceConfig + { _module.args.name = serviceName; } + { config = cfg; } + ]; + }; + + svc = svcEval.config; + + hasTimer = cfg ? startAt && cfg.startAt != [ ] && cfg.startAt != ""; + + timerEval = lib.evalModules { + modules = [ + unitOptions.stage2TimerOptions + systemdLib.unitConfig + systemdLib.timerConfig + { _module.args.name = serviceName; } + { + config = { + wantedBy = [ "timers.target" ]; + timerConfig.OnCalendar = cfg.startAt; + }; + } + ]; + }; + + timer = timerEval.config; + + mkOutput = + type: + let + unitDir = if type == "user" then "systemd/user" else "systemd/system"; + + serviceFile = pkgs.writeTextDir "${unitDir}/${serviceName}.service" (systemdLib.serviceToUnit svc) + .text; + + timerFile = pkgs.writeTextDir "${unitDir}/${serviceName}.timer" (systemdLib.timerToUnit timer).text; + in + if hasTimer then + pkgs.symlinkJoin { + name = "${serviceName}-${type}-units"; + paths = [ + serviceFile + timerFile + ]; + } + else + serviceFile; +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. in NixOS. + + ExecStart, Environment, PATH, preStart and postStop are set from the + wrapper by default. If startAt is set, a .timer unit is included in + the output. + ''; + }; + + config.systemd = { + enableDefaultPath = lib.mkDefault false; + serviceConfig.ExecStart = lib.mkDefault ( + let + # Systemd parses ExecStart using its own unquoting rules: bare + # words are split on whitespace, double-quoted strings preserve + # spaces. Backslash and double-quote inside a quoted word must + # be escaped with a backslash. + escapeForSystemd = + s: + let + escaped = lib.replaceStrings [ "\\" "\"" ] [ "\\\\" "\\\"" ] s; + in + if lib.hasInfix " " s || lib.hasInfix "\t" s || lib.hasInfix "\"" s then + "\"${escaped}\"" + else + escaped; + in + lib.concatStringsSep " " ([ config.exePath ] ++ map escapeForSystemd 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 unit files."; + default = mkOutput "user"; + }; + + options.outputs.systemd-system = lib.mkOption { + type = lib.types.package; + readOnly = true; + description = "The generated systemd system unit files."; + default = mkOutput "system"; + }; +}