Skip to content
Merged
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
2 changes: 1 addition & 1 deletion doc/build-helpers/testers.chapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ Notable attributes:

Compliance suite for [modular service](https://nixos.org/manual/nixos/unstable/#modular-services) integrations.

Tests that a service manager integration correctly handles the portable modular services contract: `process.argv`, sub-services, assertions, and warnings.
Tests that a service manager integration correctly handles the portable modular services contract: `process.argv`, `process.environment` (including `null` values that unset a variable), sub-services, assertions, and warnings.

### Return value {#tester-modularServiceCompliance-return}

Expand Down
20 changes: 20 additions & 0 deletions lib/services/service.nix
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,26 @@ in
Command used for reloading in the underlying service manager to reload.
'';
};

environment = lib.mkOption {
type = types.lazyAttrsOf (
types.nullOr (types.coercedTo (types.either types.path types.package) (x: "${x}") types.str)
);
default = { };
example = lib.literalExpression ''{ FOO = "bar"; PATH = null; }'';
description = ''
Environment variables passed verbatim to the service process by the
service manager. Entries set to `null` actively unset the variable
before the process starts -- backends without native unset support use
a wrapper (e.g. `execline`'s `unexport`) so the variable is absent
even when the service manager or a backend-specific override would
otherwise supply it.

Values appear in the rendered unit and may be world-readable. For
secrets, use a backend-specific mechanism such as
`systemd.service.serviceConfig.EnvironmentFile`.
'';
};
};

notificationProtocol = mkOption {
Expand Down
11 changes: 11 additions & 0 deletions lib/services/test.nix
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ let
(dummyPkg "cowsay.sh")
"world"
];
environment = {
FOO = "bar";
DROPPED = null;
};
};
};
service3 = {
Expand Down Expand Up @@ -110,6 +114,7 @@ let
"/usr/bin/echo"
"hello"
];
environment = { };
};
services = { };
assertions = [
Expand All @@ -128,6 +133,10 @@ let
"${dummyPkg "cowsay.sh"}"
"world"
];
environment = {
FOO = "bar";
DROPPED = null;
};
};
services = { };
assertions = [ ];
Expand All @@ -136,13 +145,15 @@ let
service3 = {
process = {
argv = [ "/bin/false" ];
environment = { };
};
services.exclacow = {
process = {
argv = [
"${dummyPkg "cowsay-ng"}/bin/cowsay"
"!"
];
environment = { };
};
services = { };
assertions = [
Expand Down
11 changes: 10 additions & 1 deletion nixos/doc/manual/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,16 @@ let
};

systemdServiceOptions = buildPackages.nixosOptionsDoc {
inherit (evalModules { modules = [ ../../modules/system/service/systemd/service.nix ]; }) options;
inherit
(evalModules {
modules = [
(modules.importApply ../../modules/system/service/systemd/service.nix {
pkgs = throw "nixos docs / systemdServiceOptions: Do not reference pkgs in docs";
})
];
})
options
;
# TODO: filter out options that are not systemd-specific, maybe also change option prefix to just `service-opt-`?
inherit revision warningsAreErrors;
transformOptions =
Expand Down
31 changes: 28 additions & 3 deletions nixos/modules/system/service/systemd/service.nix
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Non-module arguments
# These are separate from the module arguments to avoid implicit dependencies.
# This makes service modules self-contained, allowing mixing of Nixpkgs versions.
{ pkgs }:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that this breaks home-manager at this location. https://github.com/nix-community/home-manager/blob/master/modules/services-modular/service.nix

Changing the import in the home-manager service.nix file to the following appears to fix it. I'm not sure if there are many other consumers of this file?

(import (nixpkgsPath + "/nixos/modules/system/service/systemd/service.nix") { inherit pkgs; })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe lib.modules.importApply instead of import, since that's what they use in this merge? But yes, the problem remains, because this might not be the only place where this import appears

@mvnetbiz mvnetbiz Jul 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the only use of pkgs unsetting environment variables with pkgs.execline in generated systemd units? Can UnsetEnvironment= be used?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks - i filed a fix based on UnsetEnvironment now at #545521.


# The module
{
lib,
config,
Expand Down Expand Up @@ -92,6 +98,11 @@ in
to prevent systemd substitution. Set this option explicitly to enable
systemd's substitution features.

When {option}`process.environment` contains keys set to `null`, the default
is automatically prefixed with `unexport KEY` invocations (from
`pkgs.execline`) so those variables are unset before the process starts,
regardless of what `Environment=` or inherited environment supplies.

To extend {option}`process.argv` with systemd specifiers, you can append
to the escaped arguments:

Expand All @@ -109,8 +120,19 @@ in
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";
default =
let
nullEnvKeys = lib.attrNames (lib.filterAttrs (_: v: v == null) config.process.environment);
in
if nullEnvKeys == [ ] then
config.systemd.lib.escapeSystemdExecArgs config.process.argv
else
lib.concatMapStringsSep " " (
k: "${escapeSystemdExecArg "${pkgs.execline}/bin/unexport"} ${escapeSystemdExecArg k}"
) nullEnvKeys
+ " "
+ config.systemd.lib.escapeSystemdExecArgs config.process.argv;
defaultText = lib.literalMD "The escaped `process.argv`, prefixed with `\"unexport\" \"KEY\"` (from `pkgs.execline`) for each key in `process.environment` set to `null`.";
};

systemd.mainExecReload = mkOption {
Expand Down Expand Up @@ -191,7 +213,7 @@ in
types.submoduleWith {
class = "service";
modules = [
./service.nix
(lib.modules.importApply ./service.nix { inherit pkgs; })
];
specialArgs = {
inherit systemdPackage;
Expand All @@ -211,6 +233,9 @@ in
systemd.services."" = {
# TODO description;
wantedBy = lib.mkDefault [ "multi-user.target" ];
environment = lib.mapAttrs (_: lib.mkDefault) (
lib.filterAttrs (_: v: v != null) config.process.environment
);
serviceConfig = {
ExecReload = config.systemd.mainExecReload;
Type = lib.mkDefault (
Expand Down
2 changes: 1 addition & 1 deletion nixos/modules/system/service/systemd/system.nix
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ let
modularServiceConfiguration = portable-lib.configure {
serviceManagerPkgs = pkgs;
extraRootModules = [
./service.nix
(lib.modules.importApply ./service.nix { inherit pkgs; })
./config-data-path.nix
];
extraRootSpecialArgs = {
Expand Down
50 changes: 50 additions & 0 deletions nixos/modules/system/service/systemd/test.nix
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,40 @@ let
};
};

# Test that `process.environment` becomes `Environment=` entries on the unit,
# that null values are dropped from `Environment=` and wrapped with unexport
# in `ExecStart`.
system.services.envvars = {
process = {
argv = [ hello' ];
environment = {
FOO = "bar";
BAZ = "qux";
DROPPED = null;
};
};
};

# Test that an explicit `systemd.service.environment` override wins over
# the portable default produced by `process.environment`.
system.services.envvars-override = {
process = {
argv = [ hello' ];
environment.FOO = "from-process";
};
systemd.service.environment.FOO = "from-systemd";
};

# Test that `process.environment` `null` unsets via wrapper even when the
# systemd layer sets the same key (true unset, not just "skip setting").
system.services.envvars-unset = {
process = {
argv = [ hello' ];
environment.FOO = null;
};
systemd.service.environment.FOO = "leaked";
};

# Test extending process.argv with systemd specifiers
system.services.argv-extended =
{ config, ... }:
Expand Down Expand Up @@ -137,6 +171,22 @@ runCommand "test-modular-service-systemd-units"
# 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

# process.environment becomes Environment= entries; null values are dropped
# from Environment= and wrapped with unexport in ExecStart.
grep -F 'Environment="FOO=bar"' ${toplevel}/etc/systemd/system/envvars.service >/dev/null
grep -F 'Environment="BAZ=qux"' ${toplevel}/etc/systemd/system/envvars.service >/dev/null
! grep -F 'Environment=.*DROPPED' ${toplevel}/etc/systemd/system/envvars.service
grep 'ExecStart=.*unexport.*DROPPED' ${toplevel}/etc/systemd/system/envvars.service >/dev/null

# systemd.service.environment override wins over process.environment.
grep -F 'Environment="FOO=from-systemd"' ${toplevel}/etc/systemd/system/envvars-override.service >/dev/null
! grep -F 'FOO=from-process' ${toplevel}/etc/systemd/system/envvars-override.service

# process.environment null uses unexport wrapper for true unset, even when
# the systemd layer has an Environment= entry for the same key.
grep -F 'Environment="FOO=leaked"' ${toplevel}/etc/systemd/system/envvars-unset.service >/dev/null
grep 'ExecStart=.*unexport.*FOO' ${toplevel}/etc/systemd/system/envvars-unset.service >/dev/null

Comment thread
KiaraGrouwstra marked this conversation as resolved.
[[ ! -e ${toplevel}/etc/systemd/system/foo.socket ]]
[[ ! -e ${toplevel}/etc/systemd/system/bar.socket ]]
[[ ! -e ${toplevel}/etc/systemd/system/bar-db.socket ]]
Expand Down
63 changes: 63 additions & 0 deletions pkgs/build-support/testers/modular-service-compliance.nix
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ let
services = {
svc = {
process.argv = [ "${coreutils}/bin/true" ];
process.environment = {
FOO = "bar";
DROPPED = null;
};
assertions = [
{
assertion = true;
Expand Down Expand Up @@ -64,6 +68,19 @@ let
expected = [ "${coreutils}/bin/true" ];
};

# A set environment variable round-trips through process.environment.
testProcessEnvironment = {
expr = c.process.environment.FOO;
expected = "bar";
};

# A null environment variable is preserved as null (unset request),
# rather than coerced to a string or dropped from the attrset.
testProcessEnvironmentNull = {
expr = c.process.environment.DROPPED;
expected = null;
};

testAssertions = {
expr = builtins.elem {
assertion = true;
Expand Down Expand Up @@ -126,6 +143,9 @@ let
mkdir -p "$dir"
echo "$$" > "$dir/pid"
printf '%s\n' "$@" > "$dir/args"
# Record the process's own environment as received from the service
# manager (NUL-delimited, as the kernel stores it).
"${coreutils}/bin/cat" "/proc/$$/environ" > "$dir/environ"
exec "${coreutils}/bin/sleep" infinity
'';

Expand Down Expand Up @@ -160,6 +180,31 @@ let
|| { echo "${id}: expected arg ${lib.escapeShellArg arg} not found"; cat "${sharedDir}/${id}/args"; exit 1; }
'') expectedArgs;

/**
Shell snippet: assert that the service's recorded environment contains
each `present` entry (an exact `KEY=value` string) and contains no
variable named in `absent`.
*/
checkEnv =
id:
{
present ? [ ],
absent ? [ ],
}:
''
# The recorded environ is NUL-delimited; render one entry per line.
tr '\0' '\n' < "${sharedDir}/${id}/environ" > "${sharedDir}/${id}/environ.lines"
''
+ lib.concatMapStrings (entry: ''
grep -qxF -- ${lib.escapeShellArg entry} "${sharedDir}/${id}/environ.lines" \
|| { echo "${id}: expected env ${lib.escapeShellArg entry} not found"; cat "${sharedDir}/${id}/environ.lines"; exit 1; }
'') present
+ lib.concatMapStrings (key: ''
if grep -qE ${lib.escapeShellArg "^${key}="} "${sharedDir}/${id}/environ.lines"; then
echo "${id}: env variable ${lib.escapeShellArg key} should be unset"; exit 1
fi
'') absent;

mkTestScript =
name: text:
lib.getExe (writeShellApplication {
Expand Down Expand Up @@ -231,6 +276,24 @@ in
);
};

environment = mkTest {
name = "${namePrefix}-environment";
services.test = {
process.argv = mkArgv "env" [ ];
process.environment = {
FOO = "bar";
DROPPED = null;
};
};
testExe = mkTestScript "environment" (
waitAndCheck "env" [ ]
+ checkEnv "env" {
present = [ "FOO=bar" ];
absent = [ "DROPPED" ];
}
);
};

sub-services = mkTest {
name = "${namePrefix}-sub-services";
services.a = {
Expand Down
Loading