nixos/modular-services: add systemd.mainExecStart option

Provide indirection for ExecStart to enable control over systemd
specifier and variable substitution, while escaping process.argv
by default for literal arguments.
This commit is contained in:
Robert Hensing 2025-12-10 01:04:26 +01:00
commit 7b4d26bf86
2 changed files with 111 additions and 1 deletions

View file

@ -58,6 +58,61 @@ in
(lib.mkAliasOptionModule [ "systemd" "socket" ] [ "systemd" "sockets" "" ])
];
options = {
systemd.lib = mkOption {
description = ''
Library functions for working with systemd services.
Available functions:
- `escapeSystemdExecArgs`: Escapes a list of arguments for use in ExecStart.
Prevents systemd's specifier (%) and variable ($) substitution by escaping
them to %% and $$ respectively.
Example: `escapeSystemdExecArgs [ "/bin/echo" "Unit %n" ]`
produces `"/bin/echo" "Unit %%n"`
'';
type = types.lazyAttrsOf types.raw;
readOnly = true;
};
systemd.mainExecStart = mkOption {
description = ''
Main command line for systemd's ExecStart with systemd's specifier and
environment variable substitution enabled.
This option sets the primary ExecStart entry. Additional ExecStart entries
can be added via `systemd.service.serviceConfig.ExecStart` with `lib.mkBefore`
or `lib.mkAfter`.
This option allows you to use systemd specifiers like `%n` (unit name),
`%i` (instance), `%t` (runtime directory), and environment variables using
`''${VAR}` syntax in your command line.
By default, this is set to the escaped version of {option}`process.argv`
to prevent systemd substitution. Set this option explicitly to enable
systemd's substitution features.
To extend {option}`process.argv` with systemd specifiers, you can append
to the escaped arguments:
```nix
systemd.mainExecStart =
config.systemd.lib.escapeSystemdExecArgs config.process.argv + " --systemd-unit %n";
```
This pattern allows you to pass the unit name (or other systemd specifiers)
as additional arguments while keeping the base command from {option}`process.argv`
properly escaped.
See {manpage}`systemd.service(5)` (section "COMMAND LINES") for details on
variable substitution and {manpage}`systemd.unit(5)` (section "SPECIFIERS")
for available specifiers like `%n`, `%i`, `%t`.
'';
type = types.str;
default = config.systemd.lib.escapeSystemdExecArgs config.process.argv;
defaultText = lib.literalExpression "config.systemd.lib.escapeSystemdExecArgs config.process.argv";
};
systemd.services = mkOption {
description = ''
This module configures systemd services, with the notable difference that their unit names will be prefixed with the abstract service name.
@ -106,6 +161,10 @@ in
};
};
config = {
systemd.lib = {
inherit escapeSystemdExecArgs;
};
# Note that this is the systemd.services option above, not the system one.
systemd.services."" = {
# TODO description;
@ -115,7 +174,7 @@ in
Restart = lib.mkDefault "always";
RestartSec = lib.mkDefault "5";
ExecStart = [
(escapeSystemdExecArgs config.process.argv)
config.systemd.mainExecStart
];
};
};

View file

@ -52,6 +52,45 @@ let
};
};
# Test that systemd.mainExecStart overrides process.argv
# and allows systemd's specifier and variable substitution
system.services.argv-with-subst = {
process = {
argv = [
hello'
"--greeting"
"This should be ignored"
];
};
systemd.mainExecStart = ''/bin/sh -c "echo %n and ''${HOME}"'';
};
# Test that process.argv escapes % and $ by default
system.services.argv-escaped = {
process = {
argv = [
"/bin/sh"
"-c"
"echo %n and \${HOME}"
];
};
};
# Test extending process.argv with systemd specifiers
system.services.argv-extended =
{ config, ... }:
{
process = {
argv = [
hello'
"--greeting"
"Fun $1 fact, remainder is often expressed as m%n"
];
};
systemd.mainExecStart =
config.systemd.lib.escapeSystemdExecArgs config.process.argv + " --systemd-unit %n";
};
# irrelevant stuff
system.stateVersion = "25.05";
fileSystems."/".device = "/test/dummy";
@ -83,6 +122,18 @@ runCommand "test-modular-service-systemd-units"
grep 'ExecStart="${hello}/bin/hello" "--greeting" ".*database.*"' ${toplevel}/etc/systemd/system/bar-db.service >/dev/null
grep -F 'RestartSec=42' ${toplevel}/etc/systemd/system/bar-db.service >/dev/null
# Test that systemd.mainExecStart overrides process.argv
# Note: %n and $HOME are NOT escaped, allowing systemd to substitute them
grep -F 'ExecStart=/bin/sh -c "echo %n and ''${HOME}"' ${toplevel}/etc/systemd/system/argv-with-subst.service >/dev/null
# Test that process.argv escapes % as %% and $ as $$
# This prevents systemd from performing specifier/variable substitution
grep -F 'ExecStart="/bin/sh" "-c" "echo %%n and $${HOME}"' ${toplevel}/etc/systemd/system/argv-escaped.service >/dev/null
# Test extending process.argv with systemd specifiers
# The base command should be escaped ($1 -> $$1, m%n -> m%%n), but the appended --systemd-unit %n should not be
grep -F 'ExecStart="${hello}/bin/hello" "--greeting" "Fun $$1 fact, remainder is often expressed as m%%n" --systemd-unit %n' ${toplevel}/etc/systemd/system/argv-extended.service >/dev/null
[[ ! -e ${toplevel}/etc/systemd/system/foo.socket ]]
[[ ! -e ${toplevel}/etc/systemd/system/bar.socket ]]
[[ ! -e ${toplevel}/etc/systemd/system/bar-db.socket ]]