Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>`):
- `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
Expand Down Expand Up @@ -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.<name>` 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.
Expand Down
252 changes: 252 additions & 0 deletions checks/systemd.nix
Original file line number Diff line number Diff line change
@@ -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
''
4 changes: 3 additions & 1 deletion lib/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading