Skip to content

Commit 3219740

Browse files
committed
lib.modules: init systemd
Add a systemd module that generates service files from wrapper config. The module reuses NixOS systemd.services options directly, and picks up ExecStart, Environment, PATH, preStart and postStop from the wrapper automatically. A single config.systemd option produces both user and system service outputs (outputs.systemd-user, outputs.systemd-system), differing only in the install directory. Includes tests and README documentation with NixOS and home-manager examples.
1 parent 3cf1e83 commit 3219740

File tree

4 files changed

+416
-1
lines changed

4 files changed

+416
-1
lines changed

README.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ Built-in options (always available):
181181
- `wrapper`: The resulting wrapped package (read-only, auto-generated from other options)
182182
- `apply`: Function to extend the configuration with additional modules (read-only)
183183

184+
Optional modules (import via `wlib.modules.<name>`):
185+
- `systemd`: Generates systemd service files (user and/or system), options are passed through from NixOS
186+
184187
Custom types:
185188
- `wlib.types.file`: File type with `content` and `path` options
186189
- `content`: File contents as string
@@ -266,6 +269,105 @@ Wraps notmuch with INI-based configuration:
266269
}).wrapper
267270
```
268271

272+
### Generating systemd Services
273+
274+
Import `wlib.modules.systemd` to generate systemd service files for your wrapper.
275+
The options under `systemd` are the same as `systemd.services.<name>` in NixOS,
276+
passed through directly.
277+
278+
`ExecStart` (including args), `Environment`, `PATH`, `preStart` and `postStop`
279+
are picked up from the wrapper automatically, so you only need to set what's
280+
specific to the service.
281+
282+
The same config produces both a user and system service file, available at
283+
`config.outputs.systemd-user` and `config.outputs.systemd-system`. Use
284+
whichever fits your deployment.
285+
286+
```nix
287+
wlib.wrapModule ({ config, wlib, ... }: {
288+
imports = [ wlib.modules.systemd ];
289+
290+
config = {
291+
package = config.pkgs.hello;
292+
flags."--greeting" = "world";
293+
env.HELLO_LANG = "en";
294+
systemd = {
295+
description = "Hello service";
296+
serviceConfig.Type = "simple";
297+
serviceConfig.Restart = "on-failure";
298+
};
299+
};
300+
})
301+
```
302+
303+
Settings merge when using `apply`:
304+
305+
```nix
306+
extended = myWrapper.apply {
307+
systemd.serviceConfig.Restart = "always";
308+
systemd.environment.EXTRA = "value";
309+
};
310+
```
311+
312+
#### Using in NixOS
313+
314+
You need both `systemd.packages` for the unit file and the corresponding
315+
`wantedBy` to actually activate it. NixOS does not read the `[Install]` section
316+
from unit files, it creates the `.wants` symlinks from the module option instead.
317+
318+
As a user service (for all users):
319+
320+
```nix
321+
# configuration.nix
322+
{ pkgs, wrappers, ... }:
323+
let
324+
myHello = wrappers.wrapperModules.hello.apply {
325+
inherit pkgs;
326+
systemd.serviceConfig.Restart = "always";
327+
};
328+
in {
329+
systemd.packages = [ myHello.outputs.systemd-user ];
330+
# NixOS needs this to create the .wants symlink, the [Install]
331+
# section in the unit file alone is not enough
332+
systemd.user.services.hello.wantedBy = [ "default.target" ];
333+
}
334+
```
335+
336+
As a system service:
337+
338+
```nix
339+
# configuration.nix
340+
{ pkgs, wrappers, ... }:
341+
let
342+
myHello = wrappers.wrapperModules.hello.apply {
343+
inherit pkgs;
344+
systemd.serviceConfig.Restart = "always";
345+
};
346+
in {
347+
systemd.packages = [ myHello.outputs.systemd-system ];
348+
systemd.services.hello.wantedBy = [ "multi-user.target" ];
349+
}
350+
```
351+
352+
#### Using in home-manager
353+
354+
For per-user services, link via `xdg.dataFile`:
355+
356+
```nix
357+
# home.nix
358+
{ pkgs, wrappers, ... }:
359+
let
360+
myHello = wrappers.wrapperModules.hello.apply {
361+
inherit pkgs;
362+
systemd.wantedBy = [ "default.target" ];
363+
systemd.serviceConfig.Restart = "always";
364+
};
365+
in {
366+
xdg.dataFile."systemd/user/hello.service".source =
367+
"${myHello.outputs.systemd-user}/systemd/user/hello.service";
368+
}
369+
```
370+
269371
## alternatives
270372

271373
- [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.

checks/systemd.nix

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
{
2+
pkgs,
3+
self,
4+
}:
5+
6+
let
7+
lib = pkgs.lib;
8+
9+
# Test 1: Defaults from wrapper, both outputs from same config
10+
withDefaults = self.lib.wrapModule (
11+
{
12+
config,
13+
lib,
14+
wlib,
15+
...
16+
}:
17+
{
18+
imports = [ wlib.modules.systemd ];
19+
config = {
20+
pkgs = pkgs;
21+
package = pkgs.hello;
22+
flags."--greeting" = "world";
23+
env.HELLO_LANG = "en";
24+
systemd = {
25+
description = "Hello service";
26+
serviceConfig.Type = "simple";
27+
wantedBy = [ "default.target" ];
28+
};
29+
};
30+
}
31+
);
32+
33+
# Test 2: Override ExecStart
34+
withOverride = self.lib.wrapModule (
35+
{
36+
config,
37+
lib,
38+
wlib,
39+
...
40+
}:
41+
{
42+
imports = [ wlib.modules.systemd ];
43+
config = {
44+
pkgs = pkgs;
45+
package = pkgs.hello;
46+
env.FOO = "bar";
47+
systemd.serviceConfig = {
48+
ExecStart = "/custom/bin/thing";
49+
Type = "oneshot";
50+
};
51+
};
52+
}
53+
);
54+
55+
# Test 3: Service name from binName
56+
customBinName = self.lib.wrapModule (
57+
{
58+
config,
59+
lib,
60+
wlib,
61+
...
62+
}:
63+
{
64+
imports = [ wlib.modules.systemd ];
65+
config = {
66+
pkgs = pkgs;
67+
package = pkgs.hello;
68+
binName = "my-hello";
69+
systemd.serviceConfig.Type = "simple";
70+
};
71+
}
72+
);
73+
74+
# Test 4: Deep merging via apply
75+
baseModule = self.lib.wrapModule (
76+
{
77+
config,
78+
lib,
79+
wlib,
80+
...
81+
}:
82+
{
83+
imports = [ wlib.modules.systemd ];
84+
config = {
85+
pkgs = pkgs;
86+
package = pkgs.hello;
87+
systemd = {
88+
description = "Hello service";
89+
serviceConfig.Type = "simple";
90+
wantedBy = [ "default.target" ];
91+
};
92+
};
93+
}
94+
);
95+
96+
extended = baseModule.apply {
97+
systemd.serviceConfig.Restart = "always";
98+
systemd.environment.EXTRA = "value";
99+
};
100+
101+
# Test 5: Unit ordering
102+
withDeps = self.lib.wrapModule (
103+
{
104+
config,
105+
lib,
106+
wlib,
107+
...
108+
}:
109+
{
110+
imports = [ wlib.modules.systemd ];
111+
config = {
112+
pkgs = pkgs;
113+
package = pkgs.hello;
114+
systemd = {
115+
description = "Hello with deps";
116+
after = [ "network.target" ];
117+
wants = [ "network.target" ];
118+
serviceConfig.Type = "simple";
119+
};
120+
};
121+
}
122+
);
123+
124+
# Test 6: exePath, extraPackages, preHook, postHook
125+
withHooks = self.lib.wrapModule (
126+
{
127+
config,
128+
lib,
129+
wlib,
130+
...
131+
}:
132+
{
133+
imports = [ wlib.modules.systemd ];
134+
config = {
135+
pkgs = pkgs;
136+
package = pkgs.hello;
137+
extraPackages = [ pkgs.jq ];
138+
preHook = "echo pre";
139+
postHook = "echo post";
140+
systemd.serviceConfig.Type = "simple";
141+
};
142+
}
143+
);
144+
145+
readUserService = drv: name: builtins.readFile "${drv}/systemd/user/${name}.service";
146+
readSystemService = drv: name: builtins.readFile "${drv}/systemd/system/${name}.service";
147+
in
148+
pkgs.runCommand "systemd-test" { } ''
149+
echo "Testing systemd module..."
150+
151+
# Test 1a: User service output
152+
echo "Test 1a: User service defaults from wrapper"
153+
user='${readUserService withDefaults.outputs.systemd-user "hello"}'
154+
echo "$user" | grep -q 'Description=Hello service' || { echo "FAIL: missing description"; exit 1; }
155+
echo "$user" | grep -q 'ExecStart=.*/bin/hello' || { echo "FAIL: ExecStart should default to exePath"; echo "$user"; exit 1; }
156+
echo "$user" | grep -q '\-\-greeting' || { echo "FAIL: ExecStart should include args"; echo "$user"; exit 1; }
157+
echo "$user" | grep -qF '"HELLO_LANG=en"' || { echo "FAIL: Environment should include env"; echo "$user"; exit 1; }
158+
echo "$user" | grep -q 'WantedBy=default.target' || { echo "FAIL: missing WantedBy"; exit 1; }
159+
echo "PASS: user service defaults"
160+
161+
# Test 1b: System service output from same config
162+
echo "Test 1b: System service output from same config"
163+
system='${readSystemService withDefaults.outputs.systemd-system "hello"}'
164+
echo "$system" | grep -q 'Description=Hello service' || { echo "FAIL: missing description"; exit 1; }
165+
echo "$system" | grep -q 'ExecStart=.*/bin/hello' || { echo "FAIL: ExecStart should default to exePath"; echo "$system"; exit 1; }
166+
echo "$system" | grep -qF '"HELLO_LANG=en"' || { echo "FAIL: Environment should include env"; echo "$system"; exit 1; }
167+
echo "PASS: system service output from same config"
168+
169+
# Test 2: Override ExecStart
170+
echo "Test 2: Override ExecStart"
171+
override='${readUserService withOverride.outputs.systemd-user "hello"}'
172+
echo "$override" | grep -q 'ExecStart=/custom/bin/thing' || { echo "FAIL: ExecStart override not applied"; echo "$override"; exit 1; }
173+
echo "$override" | grep -q 'Type=oneshot' || { echo "FAIL: Type override not applied"; exit 1; }
174+
echo "PASS: override ExecStart"
175+
176+
# Test 3: Service name from binName
177+
echo "Test 3: Service name from binName"
178+
test -f "${customBinName.outputs.systemd-user}/systemd/user/my-hello.service" || {
179+
echo "FAIL: user service file should be named my-hello.service"
180+
ls -la "${customBinName.outputs.systemd-user}/systemd/user/"
181+
exit 1
182+
}
183+
test -f "${customBinName.outputs.systemd-system}/systemd/system/my-hello.service" || {
184+
echo "FAIL: system service file should be named my-hello.service"
185+
ls -la "${customBinName.outputs.systemd-system}/systemd/system/"
186+
exit 1
187+
}
188+
echo "PASS: service name from binName"
189+
190+
# Test 4: Deep merging via apply
191+
echo "Test 4: Deep merging via apply"
192+
extended='${readUserService extended.outputs.systemd-user "hello"}'
193+
echo "$extended" | grep -q 'Description=Hello service' || { echo "FAIL: description lost after apply"; exit 1; }
194+
echo "$extended" | grep -q 'Type=simple' || { echo "FAIL: Type lost after apply"; exit 1; }
195+
echo "$extended" | grep -q 'Restart=always' || { echo "FAIL: Restart not merged"; exit 1; }
196+
echo "$extended" | grep -qF '"EXTRA=value"' || { echo "FAIL: environment not merged"; exit 1; }
197+
echo "$extended" | grep -q 'WantedBy=default.target' || { echo "FAIL: WantedBy lost after apply"; exit 1; }
198+
echo "PASS: deep merging via apply"
199+
200+
# Test 5: Unit ordering
201+
echo "Test 5: Unit ordering"
202+
withDeps='${readUserService withDeps.outputs.systemd-user "hello"}'
203+
echo "$withDeps" | grep -q 'After=network.target' || { echo "FAIL: missing After"; exit 1; }
204+
echo "$withDeps" | grep -q 'Wants=network.target' || { echo "FAIL: missing Wants"; exit 1; }
205+
echo "PASS: unit ordering"
206+
207+
# Test 6: exePath, extraPackages, preHook, postHook
208+
echo "Test 6: exePath, extraPackages, preHook, postHook"
209+
hooks='${readUserService withHooks.outputs.systemd-user "hello"}'
210+
echo "$hooks" | grep -q 'ExecStart=${pkgs.hello}/bin/hello' || { echo "FAIL: ExecStart should use exePath"; echo "$hooks"; exit 1; }
211+
echo "$hooks" | grep -q '${pkgs.jq}' || { echo "FAIL: extraPackages (jq) not in PATH"; echo "$hooks"; exit 1; }
212+
echo "$hooks" | grep -q 'ExecStartPre=.*hello-pre-start' || { echo "FAIL: preHook not mapped to ExecStartPre"; echo "$hooks"; exit 1; }
213+
echo "$hooks" | grep -q 'ExecStopPost=.*hello-post-stop' || { echo "FAIL: postHook not mapped to ExecStopPost"; echo "$hooks"; exit 1; }
214+
echo "PASS: exePath, extraPackages, preHook, postHook"
215+
216+
echo "SUCCESS: All systemd tests passed"
217+
touch $out
218+
''

lib/default.nix

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,9 @@ let
249249
inherit modules class specialArgs;
250250
};
251251

252-
modules = lib.genAttrs [ "package" "wrapper" "meta" ] (name: import ./modules/${name}.nix);
252+
modules = lib.genAttrs [ "package" "wrapper" "meta" "systemd" ] (
253+
name: import ./modules/${name}.nix
254+
);
253255

254256
/**
255257
Create a wrapper configuration using the NixOS module system.

0 commit comments

Comments
 (0)