lib/services: fix reload/readiness bugs and add compliance coverage
Fixes several correctness bugs primarily around modular services' recent reload/notification options (#535695) and adds compliance coverage to guard them. Fixes: - `lib/services/service.nix`: - the reload-conflict assertion had inverted polarity, so it fired on the default configuration - the `mkIf` guard on `process.reloadCommand` had a misplaced paren, applying `!= null` to the `mkIf` result rather than to the condition - `process.reloadSignal` derives `process.reloadCommand`, so the assertion could not check `reloadCommand != null` -- that fired on every signal-only service. The command is now derived at `mkDefault` priority and the assertion is gated on `options.process.reloadCommand.highestPrio`, firing only when the user also set `reloadCommand` explicitly. - change `notificationProtocol` to a sub-module type - `nixos/modules/system/service/systemd/service.nix`: - `systemd.mainExecReload`'s default ran `escapeSystemdExecArgs` (a list escaper) on the `nullOr str` `process.reloadCommand`; this threw `expected a list but found a string` and would have mangled `$MAINPID`. It now uses `process.reloadCommand` verbatim. - the `Type` default read a non-existent `config.serviceManager.notificationProtocol` instead of `config.notificationProtocol`. Tests: Extend the modular-service compliance suite to guard the above: - Portable (manager-agnostic) eval assertions: `reloadSignal` derives `reloadCommand`, the conflict assertion does not fire on signal-only services but does when both are set explicitly, and `notificationProtocol.systemd`/`.s6` default to `false`. - systemd-specific eval assertions: `serviceConfig.Type` (simple/notify) and `serviceConfig.ExecReload` are asserted on the resolved host units. This directly guards the `mainExecReload` fix, which threw before it. - Runtime reload compliance test: a nested reloadable sub-service is started and reloaded, asserting the service observed the reload (recorded a SIGHUP marker). `callReload` receives the service's name path (the list of names from the top-level service down to the target sub-service); each integration joins it per its own unit-naming convention (NixOS dash-joins to the systemd unit name, e.g. `reload-inner.service`). Keeping it a path list rather than a read-only submodule option keeps the suite manager-agnostic. - `doc/build-helpers/testers.chapter.md`: document `callReload`. Follow-up to #535695. Signed-off-by: cinereal <cinereal@riseup.net> Assisted-by: Claude:claude-opus-4-8
This commit is contained in:
@@ -758,6 +758,7 @@ An attribute set of derivations which perform the tests during their build.
|
||||
- Output attribute `config` is the resulting evaluated services attrset (e.g., the value of the `system.services` option in NixOS).
|
||||
This attribute must be available even if `checkDrv` would fail.
|
||||
- Output attribute `checkDrv` is a representative derivation whose existence and buildability prove the eval is sound (e.g., `system.build.toplevel` in NixOS, but could perhaps be more specific in the case of another process manager integration).
|
||||
- The generic tester only reads `config` and `checkDrv`. An integration may return additional attributes for its own integration-specific eval checks. Such extra attributes are optional.
|
||||
|
||||
`mkTest` (function)
|
||||
|
||||
@@ -772,6 +773,14 @@ An attribute set of derivations which perform the tests during their build.
|
||||
: Path to a directory writable by service processes and readable by `testExe`.
|
||||
The integration must ensure this directory is available when the services and `testExe` run.
|
||||
|
||||
`callReload` (function)
|
||||
|
||||
: `path -> string`.
|
||||
Given a service's name `path` (the list of service names from the top-level service down to the target sub-service, e.g. `[ "reload" "inner" ]`), returns a shell command that reloads that service.
|
||||
The command is embedded in `testExe` and executed with sufficient privilege to reload the service (e.g. as root in the test VM).
|
||||
There is no manager-agnostic reload command, so every integration must provide this; the integration joins the `path` per its own unit-naming convention (the suite does not assume one).
|
||||
On NixOS the `path` dash-joins into the systemd unit name with a `.service` suffix, so the command is `systemctl reload ${lib.concatStringsSep "-" path}.service` (a top-level service is a single-element path `[ "svc" ]` -> `svc.service`; a nested sub-service `[ "parent" "child" ]` -> `parent-child.service`).
|
||||
|
||||
:::{.example #ex-modularServiceCompliance-nixos}
|
||||
|
||||
# NixOS invocation of the compliance suite
|
||||
@@ -802,6 +811,7 @@ recurseIntoAttrs (
|
||||
config = machine.config.system.services;
|
||||
checkDrv = machine.config.system.build.toplevel;
|
||||
};
|
||||
callReload = path: "systemctl reload ${lib.concatStringsSep "-" path}.service";
|
||||
mkTest =
|
||||
{
|
||||
name,
|
||||
|
||||
+19
-14
@@ -7,10 +7,11 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
options,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (lib) mkOption types;
|
||||
inherit (lib) mkEnableOption mkOption types;
|
||||
pathOrStr = types.coercedTo types.path (x: "${x}") types.str;
|
||||
in
|
||||
{
|
||||
@@ -40,7 +41,7 @@ in
|
||||
visible = "shallow";
|
||||
};
|
||||
process = {
|
||||
argv = lib.mkOption {
|
||||
argv = mkOption {
|
||||
type = types.listOf pathOrStr;
|
||||
example = lib.literalExpression ''[ (lib.getExe config.package) "--nobackground" ]'';
|
||||
description = ''
|
||||
@@ -72,15 +73,12 @@ in
|
||||
};
|
||||
|
||||
notificationProtocol = mkOption {
|
||||
type = types.listOf (
|
||||
types.enum [
|
||||
"systemd"
|
||||
"s6"
|
||||
]
|
||||
);
|
||||
|
||||
default = [ ];
|
||||
apply = v: lib.unique v;
|
||||
type = types.submodule {
|
||||
options = {
|
||||
systemd = mkEnableOption "Whether the service supports systemd-notify.";
|
||||
s6 = mkEnableOption "Whether the service supports s6-notify.";
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
Notification protocol that this service supports with the underlying service manager.
|
||||
'';
|
||||
@@ -90,13 +88,20 @@ in
|
||||
config = {
|
||||
assertions = [
|
||||
{
|
||||
assertion = config.process.reloadSignal != null && config.process.reloadCommand != null;
|
||||
# `reloadSignal` derives `reloadCommand` at `mkDefault` priority below, so a
|
||||
# conflict only exists when the user *also* set `reloadCommand` explicitly.
|
||||
# An explicit (non-`mkDefault`) definition has `defaultOverridePriority`.
|
||||
assertion =
|
||||
!(
|
||||
config.process.reloadSignal != null
|
||||
&& options.process.reloadCommand.highestPrio <= lib.modules.defaultOverridePriority
|
||||
);
|
||||
message = "reloadSignal conflicts with reloadCommand. Please either use reloadSignal or reloadCommand.";
|
||||
}
|
||||
];
|
||||
|
||||
process.reloadCommand = (lib.mkIf config.process.reloadSignal != null) (
|
||||
lib.mkForce "${pkgs.coreutils}/bin/kill -${config.process.reloadSignal} $MAINPID"
|
||||
process.reloadCommand = lib.mkIf (config.process.reloadSignal != null) (
|
||||
lib.mkDefault "${pkgs.coreutils}/bin/kill -${config.process.reloadSignal} $MAINPID"
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,33 +126,29 @@ in
|
||||
`%i` (instance), `%t` (runtime directory), and environment variables using
|
||||
`''${VAR}` syntax in your command line.
|
||||
|
||||
By default, it is either set to null or this is set to the escaped version of {option}`process.reloadCommand`
|
||||
when specified to prevent systemd substitution. Set this option explicitly to enable
|
||||
systemd's substitution features.
|
||||
By default, it is set to {option}`process.reloadCommand` when specified, or an
|
||||
empty string otherwise. Because {option}`process.reloadCommand` is already a
|
||||
command line (not an argument list), it is used verbatim so that references
|
||||
like `$MAINPID` are preserved.
|
||||
|
||||
To extend {option}`process.reloadCommand` with systemd specifiers, you can append
|
||||
to the escaped arguments:
|
||||
to the command line:
|
||||
|
||||
```nix
|
||||
systemd.mainExecReload =
|
||||
config.systemd.lib.escapeSystemdExecArgs config.process.reload + " --systemd-unit %n";
|
||||
config.process.reloadCommand + " --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.
|
||||
as additional arguments.
|
||||
|
||||
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.nullOr types.str;
|
||||
default =
|
||||
if config.process.reloadCommand then
|
||||
config.systemd.lib.escapeSystemdExecArgs config.process.reloadCommand
|
||||
else
|
||||
"";
|
||||
defaultText = lib.literalExpression "config.systemd.lib.escapeSystemdExecArgs config.process.reloadCommand";
|
||||
default = if config.process.reloadCommand != null then config.process.reloadCommand else "";
|
||||
defaultText = lib.literalExpression "config.process.reloadCommand";
|
||||
};
|
||||
|
||||
systemd.services = mkOption {
|
||||
@@ -213,9 +209,7 @@ in
|
||||
wantedBy = lib.mkDefault [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
ExecReload = config.systemd.mainExecReload;
|
||||
Type = lib.mkDefault (
|
||||
if (config.serviceManager.notificationProtocol == "systemd") then "notify" else "simple"
|
||||
);
|
||||
Type = lib.mkDefault (if config.notificationProtocol.systemd then "notify" else "simple");
|
||||
Restart = lib.mkDefault "always";
|
||||
RestartSec = lib.mkDefault "5";
|
||||
ExecStart = [
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
|
||||
let
|
||||
sharedDir = "/tmp/modular-service-compliance";
|
||||
|
||||
inherit (pkgs) lib coreutils;
|
||||
|
||||
evalSystemServices =
|
||||
services:
|
||||
evalSystem (
|
||||
{ ... }:
|
||||
{
|
||||
system.services = services;
|
||||
system.stateVersion = "25.05";
|
||||
fileSystems."/" = {
|
||||
device = "/test/dummy";
|
||||
fsType = "auto";
|
||||
};
|
||||
boot.loader.grub.enable = false;
|
||||
}
|
||||
);
|
||||
in
|
||||
let
|
||||
suite = pkgs.testers.modularServiceCompliance {
|
||||
@@ -15,23 +32,13 @@ let
|
||||
evalConfig =
|
||||
{ services }:
|
||||
let
|
||||
machine = evalSystem (
|
||||
{ ... }:
|
||||
{
|
||||
system.services = services;
|
||||
system.stateVersion = "25.05";
|
||||
fileSystems."/" = {
|
||||
device = "/test/dummy";
|
||||
fsType = "auto";
|
||||
};
|
||||
boot.loader.grub.enable = false;
|
||||
}
|
||||
);
|
||||
machine = evalSystemServices services;
|
||||
in
|
||||
{
|
||||
config = machine.config.system.services;
|
||||
checkDrv = machine.config.system.build.toplevel;
|
||||
};
|
||||
callReload = path: "systemctl reload ${lib.concatStringsSep "-" path}.service";
|
||||
mkTest =
|
||||
{
|
||||
name,
|
||||
@@ -49,6 +56,80 @@ let
|
||||
meta.maintainers = with pkgs.lib.maintainers; [ roberth ];
|
||||
};
|
||||
};
|
||||
|
||||
# systemd-specific eval assertions. serviceConfig.Type/ExecReload only exist on
|
||||
# the resolved host units, so a fresh eval is used per case.
|
||||
systemdEvalTests =
|
||||
let
|
||||
defaultUnits =
|
||||
(evalSystemServices {
|
||||
service.process.argv = [ "${coreutils}/bin/true" ];
|
||||
}).config.systemd.services;
|
||||
|
||||
notifyUnits =
|
||||
(evalSystemServices {
|
||||
service = {
|
||||
process.argv = [ "${coreutils}/bin/true" ];
|
||||
notificationProtocol.systemd = true;
|
||||
};
|
||||
}).config.systemd.services;
|
||||
|
||||
reloadUnits =
|
||||
(evalSystemServices {
|
||||
service = {
|
||||
process.argv = [ "${coreutils}/bin/true" ];
|
||||
process.reloadSignal = "HUP";
|
||||
};
|
||||
}).config.systemd.services;
|
||||
in
|
||||
{
|
||||
testDefaultType = {
|
||||
expr = defaultUnits.service.serviceConfig.Type;
|
||||
expected = "simple";
|
||||
};
|
||||
|
||||
testNotifyType = {
|
||||
expr = notifyUnits.service.serviceConfig.Type;
|
||||
expected = "notify";
|
||||
};
|
||||
|
||||
testReloadExecReload = {
|
||||
expr = reloadUnits.service.serviceConfig.ExecReload;
|
||||
expected = "${coreutils}/bin/kill -HUP $MAINPID";
|
||||
};
|
||||
};
|
||||
|
||||
systemdEval = pkgs.stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
__structuredAttrs = true;
|
||||
name = "system-services-compliance-systemd-eval-report";
|
||||
passthru = {
|
||||
tests = systemdEvalTests;
|
||||
failures = lib.runTests finalAttrs.finalPackage.tests;
|
||||
};
|
||||
testResults = lib.mapAttrs (_: test: test.expr == test.expected) finalAttrs.finalPackage.tests;
|
||||
buildCommand = ''
|
||||
touch $out
|
||||
for testName in "''${!testResults[@]}"; do
|
||||
if [[ -n "''${testResults[$testName]}" ]]; then
|
||||
echo "PASS $testName"
|
||||
else
|
||||
echo "FAIL $testName"
|
||||
fi
|
||||
done
|
||||
''
|
||||
+ lib.optionalString (lib.any (v: !v) (lib.attrValues finalAttrs.testResults)) ''
|
||||
{
|
||||
echo ""
|
||||
echo "systemd-specific eval-level compliance failures:"
|
||||
for testName in "''${!testResults[@]}"; do
|
||||
if [[ -z "''${testResults[$testName]}" ]]; then
|
||||
echo "- $testName"
|
||||
fi
|
||||
done
|
||||
} >&2
|
||||
exit 1
|
||||
'';
|
||||
});
|
||||
in
|
||||
|
||||
# Please the callTest pattern.
|
||||
@@ -64,4 +145,4 @@ pkgs.lib.mapAttrs (
|
||||
test = v;
|
||||
driver = v;
|
||||
}
|
||||
) suite
|
||||
) (suite // { systemd-eval = systemdEval; })
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
evalConfig,
|
||||
mkTest,
|
||||
sharedDir,
|
||||
callReload,
|
||||
namePrefix ? "modular-service-compliance",
|
||||
}:
|
||||
|
||||
@@ -45,6 +46,11 @@ let
|
||||
];
|
||||
warnings = [ "compliance child warning" ];
|
||||
};
|
||||
# A sub-service exercising reload derivation from a reload signal.
|
||||
services.reloadee = {
|
||||
process.argv = [ "${coreutils}/bin/true" ];
|
||||
process.reloadSignal = "HUP";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -65,7 +71,7 @@ let
|
||||
};
|
||||
|
||||
testAssertions = {
|
||||
expr = builtins.elem {
|
||||
expr = lib.elem {
|
||||
assertion = true;
|
||||
message = "compliance test assertion";
|
||||
} c.assertions;
|
||||
@@ -73,12 +79,12 @@ let
|
||||
};
|
||||
|
||||
testWarnings = {
|
||||
expr = builtins.elem "compliance test warning" c.warnings;
|
||||
expr = lib.elem "compliance test warning" c.warnings;
|
||||
expected = true;
|
||||
};
|
||||
|
||||
testSubServiceAssertions = {
|
||||
expr = builtins.elem {
|
||||
expr = lib.elem {
|
||||
assertion = true;
|
||||
message = "compliance child assertion";
|
||||
} c.services.child.assertions;
|
||||
@@ -86,14 +92,58 @@ let
|
||||
};
|
||||
|
||||
testSubServiceWarnings = {
|
||||
expr = builtins.elem "compliance child warning" c.services.child.warnings;
|
||||
expr = lib.elem "compliance child warning" c.services.child.warnings;
|
||||
expected = true;
|
||||
};
|
||||
|
||||
# The reload-conflict assertion must not fire (its `assertion` must hold) for a
|
||||
# service that sets only reloadSignal (guards the inverted-assertion fix, and the
|
||||
# priority-aware conflict detection: reloadSignal derives reloadCommand internally,
|
||||
# which must not be mistaken for a user-set conflict).
|
||||
testNoReloadConflict = {
|
||||
expr = lib.any (
|
||||
a:
|
||||
a.message
|
||||
== "reloadSignal conflicts with reloadCommand. Please either use reloadSignal or reloadCommand."
|
||||
&& !a.assertion
|
||||
) c.services.reloadee.assertions;
|
||||
expected = false;
|
||||
};
|
||||
|
||||
# Setting process.reloadSignal derives process.reloadCommand
|
||||
# (guards the misplaced-paren mkIf fix).
|
||||
testReloadSignalDerivesCommand = {
|
||||
expr = c.services.reloadee.process.reloadCommand;
|
||||
expected = "${coreutils}/bin/kill -HUP $MAINPID";
|
||||
};
|
||||
|
||||
# notificationProtocol submodule bools default to false.
|
||||
testNotificationProtocolSystemdDefault = {
|
||||
expr = c.notificationProtocol.systemd;
|
||||
expected = false;
|
||||
};
|
||||
|
||||
testNotificationProtocolS6Default = {
|
||||
expr = c.notificationProtocol.s6;
|
||||
expected = false;
|
||||
};
|
||||
|
||||
# Setting both reloadSignal and reloadCommand explicitly is a genuine conflict,
|
||||
# so the assertion must fire. Separate eval: checkDrv would fail on this.
|
||||
testReloadConflictFires = {
|
||||
expr = lib.any (
|
||||
a:
|
||||
a.message
|
||||
== "reloadSignal conflicts with reloadCommand. Please either use reloadSignal or reloadCommand."
|
||||
&& !a.assertion
|
||||
) conflictEval.config.conflict.assertions;
|
||||
expected = true;
|
||||
};
|
||||
|
||||
# Separate eval for a failing assertion — checkDrv would fail here,
|
||||
# so we only access config.
|
||||
testFailingAssertionValue = {
|
||||
expr = builtins.elem {
|
||||
expr = lib.elem {
|
||||
assertion = false;
|
||||
message = "compliance failing assertion";
|
||||
} failingEval.config.failing.assertions;
|
||||
@@ -101,6 +151,16 @@ let
|
||||
};
|
||||
};
|
||||
|
||||
conflictEval = evalConfig {
|
||||
services = {
|
||||
conflict = {
|
||||
process.argv = [ "${coreutils}/bin/true" ];
|
||||
process.reloadSignal = "HUP";
|
||||
process.reloadCommand = "${coreutils}/bin/kill -HUP $MAINPID";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
failingEval = evalConfig {
|
||||
services = {
|
||||
failing = {
|
||||
@@ -129,6 +189,24 @@ let
|
||||
exec "${coreutils}/bin/sleep" infinity
|
||||
'';
|
||||
|
||||
/**
|
||||
A reloadable service script. Like `svc`, it namespaces a comms subdirectory
|
||||
by its first argument and records the remaining arguments. It traps SIGHUP
|
||||
and appends a marker line to `$dir/reloads` on each reload, then stays alive
|
||||
as the trapping shell itself (no `exec`, so the trap and MAINPID are kept).
|
||||
*/
|
||||
reloadableSvc = writeShellScript "${namePrefix}-reloadable-svc" ''
|
||||
id="$1"; shift
|
||||
dir="${sharedDir}/$id"
|
||||
mkdir -p "$dir"
|
||||
: > "$dir/reloads"
|
||||
reload() { printf 'reloaded\n' >> "$dir/reloads"; }
|
||||
trap reload HUP
|
||||
echo "$$" > "$dir/pid"
|
||||
printf '%s\n' "$@" > "$dir/args"
|
||||
while true; do "${coreutils}/bin/sleep" 1; done
|
||||
'';
|
||||
|
||||
mkArgv =
|
||||
id: extraArgs:
|
||||
[
|
||||
@@ -171,6 +249,27 @@ let
|
||||
inherit text;
|
||||
});
|
||||
|
||||
# The reload runtime test's service tree. The reloadable unit is the *sub*-service,
|
||||
# so its name path `[ "reload" "inner" ]` handed to `callReload` exercises the
|
||||
# integration's nested unit naming (NixOS dash-joins to `reload-inner.service`).
|
||||
reloadServices = {
|
||||
reload = {
|
||||
# Parent must run something; a bare keep-alive is enough.
|
||||
process.argv = [
|
||||
reloadableSvc
|
||||
"reload-parent"
|
||||
];
|
||||
services.inner.process = {
|
||||
argv = [
|
||||
reloadableSvc
|
||||
"reload-inner"
|
||||
];
|
||||
# The script traps SIGHUP; reloadSignal derives the manager's reload command.
|
||||
reloadSignal = "HUP";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
# Eval-level tests: config structure, evaluated in the integration's
|
||||
@@ -186,9 +285,9 @@ in
|
||||
representative = evalResult.checkDrv;
|
||||
passthru = {
|
||||
tests = evalTestDefs;
|
||||
failures = lib.runTests finalAttrs.passthru.tests;
|
||||
failures = lib.runTests finalAttrs.finalPackage.tests;
|
||||
};
|
||||
testResults = lib.mapAttrs (_: test: test.expr == test.expected) finalAttrs.passthru.tests;
|
||||
testResults = lib.mapAttrs (_: test: test.expr == test.expected) finalAttrs.finalPackage.tests;
|
||||
buildCommand = ''
|
||||
touch $out
|
||||
for testName in "''${!testResults[@]}"; do
|
||||
@@ -247,5 +346,26 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
# Runtime reload: start a reloadable service, ask the integration to reload it,
|
||||
# and assert the service observed the reload (recorded a SIGHUP marker).
|
||||
reload = mkTest {
|
||||
name = "${namePrefix}-reload";
|
||||
services = reloadServices;
|
||||
testExe = mkTestScript "reload" ''
|
||||
${waitAndCheck "reload-inner" [ ]}
|
||||
${callReload [
|
||||
"reload"
|
||||
"inner"
|
||||
]}
|
||||
timeout=30; elapsed=0
|
||||
while ! grep -qx "reloaded" "${sharedDir}/reload-inner/reloads" && [ "$elapsed" -lt "$timeout" ]; do
|
||||
sleep 1; elapsed=$((elapsed + 1))
|
||||
done
|
||||
grep -qx "reloaded" "${sharedDir}/reload-inner/reloads" \
|
||||
|| { echo "reload: no reload recorded after ''${timeout}s"; cat "${sharedDir}/reload-inner/reloads"; exit 1; }
|
||||
echo "reload: reload observed"
|
||||
'';
|
||||
};
|
||||
|
||||
# See also the manual compliance items in doc/build-helpers/testers.chapter.md.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user