Modular services: no `pkgs` (#435092)

Changed files
+271 -79
nixos
pkgs
by-name
gh
development
interpreters
+5 -1
nixos/README-modular-services.md
···
Provide it as the first attribute in the module:
```nix
{ lib, config, ... }:
{
_class = "service";
···
passthru = {
services = {
default = {
-
imports = [ ./service.nix ];
example.package = finalAttrs.finalPackage;
# ...
};
···
Provide it as the first attribute in the module:
```nix
+
# Non-module dependencies (`importApply`)
+
{ writeScript, runtimeShell }:
+
+
# Service module
{ lib, config, ... }:
{
_class = "service";
···
passthru = {
services = {
default = {
+
imports = [ (lib.modules.importApply ./service.nix { inherit pkgs; }) ];
example.package = finalAttrs.finalPackage;
# ...
};
+11 -1
nixos/doc/manual/default.nix
···
escapeShellArg
concatMapStringsSep
sourceFilesBySuffices
;
common = import ./common.nix;
···
'';
portableServiceOptions = buildPackages.nixosOptionsDoc {
-
inherit (evalModules { modules = [ ../../modules/system/service/portable/service.nix ]; }) options;
inherit revision warningsAreErrors;
transformOptions =
opt:
···
escapeShellArg
concatMapStringsSep
sourceFilesBySuffices
+
modules
;
common = import ./common.nix;
···
'';
portableServiceOptions = buildPackages.nixosOptionsDoc {
+
inherit
+
(evalModules {
+
modules = [
+
(modules.importApply ../../modules/system/service/portable/service.nix {
+
pkgs = throw "nixos docs / portableServiceOptions: Do not reference pkgs in docs";
+
})
+
];
+
})
+
options
+
;
inherit revision warningsAreErrors;
transformOptions =
opt:
+3 -1
nixos/doc/manual/development/modular-services.md
···
## Writing and Reviewing a Modular Service {#modular-service-review}
-
Refer to the contributor documentation in [`nixos/README-modular-services.md`](https://github.com/NixOS/nixpkgs/blob/master/nixos/README-modular-services.md).
## Portable Service Options {#modular-service-options-portable}
···
## Writing and Reviewing a Modular Service {#modular-service-review}
+
A typical service module consists of the following:
+
+
For more details, refer to the contributor documentation in [`nixos/README-modular-services.md`](https://github.com/NixOS/nixpkgs/blob/master/nixos/README-modular-services.md).
## Portable Service Options {#modular-service-options-portable}
+90
nixos/modules/system/service/README.md
···
- **Simple attribute structure**: Unlike `environment.etc`, `configData` uses a simpler structure with just `enable`, `name`, `text`, `source`, and `path` attributes. Complex ownership options were omitted for simplicity and portability.
Per-service user creation is still TBD.
···
- **Simple attribute structure**: Unlike `environment.etc`, `configData` uses a simpler structure with just `enable`, `name`, `text`, `source`, and `path` attributes. Complex ownership options were omitted for simplicity and portability.
Per-service user creation is still TBD.
+
+
## No `pkgs` module argument
+
+
The modular service infrastructure avoids exposing `pkgs` as a module argument to service modules. Instead, derivations and builder functions are provided through lexical closure, making dependency relationships explicit and avoiding uncertainty about where dependencies come from.
+
+
### Benefits
+
+
- **Explicit dependencies**: Services declare what they need rather than implicitly depending on `pkgs`
+
- **No interference**: Service modules can be reused in different contexts without assuming a specific `pkgs` instance. An unexpected `pkgs` version is not a failure mode anymore.
+
- **Clarity**: With fewer ways to do things, there's no ambiguity about where dependencies come from (from the module, not the OS or service manager)
+
+
### Implementation
+
+
- **Portable layer**: Service modules in `portable/` do not receive `pkgs` as a module argument. Any required derivations must be provided by the caller.
+
+
- **Systemd integration**: The `systemd/system.nix` module imports `config-data.nix` as a function, providing `pkgs` in lexical closure:
+
```nix
+
(import ../portable/config-data.nix { inherit pkgs; })
+
```
+
+
- **Service modules**:
+
1. Should explicitly declare their package dependencies as options rather than using `pkgs` defaults:
+
```nix
+
{
+
# Bad: uses pkgs module argument
+
foo.package = mkOption {
+
default = pkgs.python3;
+
# ...
+
};
+
}
+
```
+
+
```nix
+
{
+
# Good: caller provides the package
+
foo.package = mkOption {
+
type = types.package;
+
description = "Python package to use";
+
defaultText = lib.literalMD "The package that provided this module.";
+
};
+
}
+
```
+
+
2. `passthru.services` can still provide a complete module using the package's lexical scope, making the module truly self-contained:
+
+
**Package (`package.nix`):**
+
```nix
+
{
+
lib,
+
writeScript,
+
runtimeShell,
+
# ... other dependencies
+
}:
+
stdenv.mkDerivation (finalAttrs: {
+
# ... package definition
+
+
passthru.services.default = {
+
imports = [
+
(lib.modules.importApply ./service.nix {
+
inherit writeScript runtimeShell;
+
})
+
];
+
someService.package = finalAttrs.finalPackage;
+
};
+
})
+
```
+
+
**Service module (`service.nix`):**
+
```nix
+
# Non-module dependencies (importApply)
+
{ writeScript, runtimeShell }:
+
+
# Service module
+
{
+
lib,
+
config,
+
options,
+
...
+
}:
+
{
+
# Service definition using writeScript, runtimeShell from lexical scope
+
process.argv = [
+
(writeScript "wrapper" ''
+
#!${runtimeShell}
+
# ... wrapper logic
+
'')
+
# ... other args
+
];
+
}
+
```
+4 -1
nixos/modules/system/service/portable/config-data.nix
···
# Tests in: ../../../../tests/modular-service-etc/test.nix
# Configuration data support for portable services
# This module provides configData for services, enabling configuration reloading
# without terminating and restarting the service process.
{
lib,
-
pkgs,
...
}:
let
···
# Tests in: ../../../../tests/modular-service-etc/test.nix
+
+
# Non-modular context provided by the modular services integration.
+
{ pkgs }:
+
# Configuration data support for portable services
# This module provides configData for services, enabling configuration reloading
# without terminating and restarting the service process.
{
lib,
...
}:
let
+45 -1
nixos/modules/system/service/portable/lib.nix
···
{ lib, ... }:
let
-
inherit (lib) concatLists mapAttrsToList showOption;
in
rec {
flattenMapServicesConfigToList =
···
assertion = ass.assertion;
}) config.assertions
);
}
···
{ lib, ... }:
let
+
inherit (lib)
+
concatLists
+
mapAttrsToList
+
showOption
+
types
+
;
in
rec {
flattenMapServicesConfigToList =
···
assertion = ass.assertion;
}) config.assertions
);
+
+
/**
+
This is the entrypoint for the portable part of modular services.
+
+
It provides the various options that are consumed by service manager implementations.
+
+
# Inputs
+
+
`serviceManagerPkgs`: A Nixpkgs instance which will be used for built-in logic such as converting `configData.<path>.text` to a store path.
+
+
`extraRootModules`: Modules to be loaded into the "root" service submodule, but not into its sub-`services`. That's the modules' own responsibility.
+
+
`extraRootSpecialArgs`: Fixed module arguments that are provided in a similar manner to `extraRootModules`.
+
+
# Output
+
+
An attribute set.
+
+
`serviceSubmodule`: a Module System option type which is a `submodule` with the portable modules and this function's inputs loaded into it.
+
*/
+
configure =
+
{
+
serviceManagerPkgs,
+
extraRootModules ? [ ],
+
extraRootSpecialArgs ? { },
+
}:
+
let
+
modules = [
+
(lib.modules.importApply ./service.nix { pkgs = serviceManagerPkgs; })
+
];
+
serviceSubmodule = types.submoduleWith {
+
class = "service";
+
modules = modules ++ extraRootModules;
+
specialArgs = extraRootSpecialArgs;
+
};
+
in
+
{
+
inherit serviceSubmodule;
+
};
}
+8 -2
nixos/modules/system/service/portable/service.nix
···
{
lib,
...
···
imports = [
../../../../../modules/generic/meta-maintainers.nix
../../../misc/assertions.nix
-
./config-data.nix
];
options = {
services = mkOption {
type = types.attrsOf (
types.submoduleWith {
modules = [
-
./service.nix
];
}
);
···
+
# Non-module arguments
+
# These are separate from the module arguments to avoid implicit dependencies.
+
# This makes service modules self-contains, allowing mixing of Nixpkgs versions.
+
{ pkgs }:
+
+
# The module
{
lib,
...
···
imports = [
../../../../../modules/generic/meta-maintainers.nix
../../../misc/assertions.nix
+
(lib.modules.importApply ./config-data.nix { inherit pkgs; })
];
options = {
services = mkOption {
type = types.attrsOf (
types.submoduleWith {
modules = [
+
(lib.modules.importApply ./service.nix { inherit pkgs; })
];
}
);
+17 -9
nixos/modules/system/service/portable/test.nix
···
portable-lib = import ./lib.nix { inherit lib; };
dummyPkg =
name:
derivation {
···
modules = [
{
options.services = mkOption {
-
type = types.attrsOf (
-
types.submoduleWith {
-
class = "service";
-
modules = [
-
./service.nix
-
];
-
}
-
);
};
}
exampleConfig
];
};
test =
assert
-
exampleEval.config == {
services = {
service1 = {
process = {
···
portable-lib = import ./lib.nix { inherit lib; };
+
configured = portable-lib.configure {
+
serviceManagerPkgs = throw "do not use pkgs in this test";
+
extraRootModules = [ ];
+
extraRootSpecialArgs = { };
+
};
+
dummyPkg =
name:
derivation {
···
modules = [
{
options.services = mkOption {
+
type = types.attrsOf configured.serviceSubmodule;
};
}
exampleConfig
];
};
+
filterEval =
+
config:
+
lib.optionalAttrs (config ? process) {
+
inherit (config) assertions warnings process;
+
}
+
// {
+
services = lib.mapAttrs (k: filterEval) config.services;
+
};
+
test =
assert
+
filterEval exampleEval.config == {
services = {
service1 = {
process = {
+3 -1
nixos/modules/system/service/systemd/service.nix
···
in
{
imports = [
-
../portable/service.nix
(lib.mkAliasOptionModule [ "systemd" "service" ] [ "systemd" "services" "" ])
(lib.mkAliasOptionModule [ "systemd" "socket" ] [ "systemd" "sockets" "" ])
];
···
};
}
);
};
};
config = {
···
in
{
+
_class = "service";
imports = [
(lib.mkAliasOptionModule [ "systemd" "service" ] [ "systemd" "services" "" ])
(lib.mkAliasOptionModule [ "systemd" "socket" ] [ "systemd" "sockets" "" ])
];
···
};
}
);
+
# Rendered by the portable docs instead.
+
visible = false;
};
};
config = {
+14 -30
nixos/modules/system/service/systemd/system.nix
···
// concatMapAttrs (
subServiceName: subService: makeUnits unitType (dash prefix subServiceName) subService
) service.services;
in
{
# First half of the magic: mix systemd logic into the otherwise abstract services
options = {
system.services = mkOption {
description = ''
A collection of NixOS [modular services](https://nixos.org/manual/nixos/unstable/#modular-services) that are configured as systemd services.
'';
-
type = types.attrsOf (
-
types.submoduleWith {
-
class = "service";
-
modules = [
-
./service.nix
-
./config-data-path.nix
-
-
# TODO: Consider removing pkgs. Service modules can provide their own
-
# dependencies.
-
{
-
# Extend portable services option
-
options.services = lib.mkOption {
-
type = types.attrsOf (
-
types.submoduleWith {
-
specialArgs.pkgs = pkgs;
-
modules = [ ];
-
}
-
);
-
};
-
}
-
];
-
specialArgs = {
-
# perhaps: features."systemd" = { };
-
# TODO: Consider removing pkgs. Service modules can provide their own
-
# dependencies.
-
inherit pkgs;
-
systemdPackage = config.systemd.package;
-
};
-
}
-
);
default = { };
visible = "shallow";
};
···
// concatMapAttrs (
subServiceName: subService: makeUnits unitType (dash prefix subServiceName) subService
) service.services;
+
+
modularServiceConfiguration = portable-lib.configure {
+
serviceManagerPkgs = pkgs;
+
extraRootModules = [
+
./service.nix
+
./config-data-path.nix
+
];
+
extraRootSpecialArgs = {
+
systemdPackage = config.systemd.package;
+
};
+
};
in
{
+
_class = "nixos";
+
# First half of the magic: mix systemd logic into the otherwise abstract services
options = {
system.services = mkOption {
description = ''
A collection of NixOS [modular services](https://nixos.org/manual/nixos/unstable/#modular-services) that are configured as systemd services.
'';
+
type = types.attrsOf modularServiceConfiguration.serviceSubmodule;
default = { };
visible = "shallow";
};
+3 -17
nixos/tests/modular-service-etc/python-http-server.nix
···
{
config,
lib,
-
pkgs,
...
}:
let
···
python-http-server = {
package = mkOption {
type = types.package;
-
default = pkgs.python3;
description = "Python package to use for the web server";
};
···
];
configData = {
-
# This should probably just be {} if we were to put this module in production.
-
"webroot" = lib.mkDefault {
-
source = pkgs.runCommand "default-webroot" { } ''
-
mkdir -p $out
-
cat > $out/index.html << 'EOF'
-
<!DOCTYPE html>
-
<html>
-
<head><title>Python Web Server</title></head>
-
<body>
-
<h1>Welcome to the Python Web Server</h1>
-
<p>Serving from port ${toString config.python-http-server.port}</p>
-
</body>
-
</html>
-
EOF
-
'';
};
};
};
···
{
config,
lib,
...
}:
let
···
python-http-server = {
package = mkOption {
type = types.package;
description = "Python package to use for the web server";
};
···
];
configData = {
+
"webroot" = {
+
# Enable only if directory is set to use this path
+
enable = lib.mkDefault (config.python-http-server.directory == config.configData."webroot".path);
};
};
};
+31 -4
nixos/tests/modular-service-etc/test.nix
···
nodes = {
server =
{ pkgs, ... }:
{
system.services.webserver = {
# The python web server is simple enough that it doesn't need a reload signal.
# Other services may need to receive a signal in order to re-read what's in `configData`.
-
imports = [ ./python-http-server.nix ];
python-http-server = {
port = 8080;
};
# Add a sub-service
services.api = {
-
imports = [ ./python-http-server.nix ];
python-http-server = {
port = 8081;
};
···
print(f"Before switch - webserver PID: {webserver_pid}, api PID: {api_pid}")
# Switch to the specialisation with updated content
-
switch_output = server.succeed("/run/current-system/specialisation/updated/bin/switch-to-configuration test")
-
print(f"Switch output: {switch_output}")
# Verify services are not mentioned in the switch output (indicating they weren't touched)
assert "webserver.service" not in switch_output, f"webserver.service was mentioned in switch output: {switch_output}"
···
nodes = {
server =
{ pkgs, ... }:
+
let
+
# Normally the package services.default attribute combines this, but we
+
# don't have that, because this is not a production service. Should it be?
+
python-http-server = {
+
imports = [ ./python-http-server.nix ];
+
python-http-server.package = pkgs.python3;
+
};
+
in
{
system.services.webserver = {
# The python web server is simple enough that it doesn't need a reload signal.
# Other services may need to receive a signal in order to re-read what's in `configData`.
+
imports = [ python-http-server ];
python-http-server = {
port = 8080;
};
+
configData = {
+
"webroot" = {
+
source = pkgs.runCommand "webroot" { } ''
+
mkdir -p $out
+
cat > $out/index.html << 'EOF'
+
<!DOCTYPE html>
+
<html>
+
<head><title>Python Web Server</title></head>
+
<body>
+
<h1>Welcome to the Python Web Server</h1>
+
<p>Serving from port 8080</p>
+
</body>
+
</html>
+
EOF
+
'';
+
};
+
};
+
# Add a sub-service
services.api = {
+
imports = [ python-http-server ];
python-http-server = {
port = 8081;
};
···
print(f"Before switch - webserver PID: {webserver_pid}, api PID: {api_pid}")
# Switch to the specialisation with updated content
+
# Capture both stdout and stderr, and show stderr in real-time for debugging
+
switch_output = server.succeed("/run/current-system/specialisation/updated/bin/switch-to-configuration test 2>&1 | tee /dev/stderr")
+
print(f"Switch output (stdout+stderr): {switch_output}")
# Verify services are not mentioned in the switch output (indicating they weren't touched)
assert "webserver.service" not in switch_output, f"webserver.service was mentioned in switch output: {switch_output}"
+2
nixos/tests/php/fpm-modular.nix
···
{ lib, php, ... }:
{
name = "php-${php.version}-fpm-modular-nginx-test";
···
+
# Run with:
+
# nix-build -A nixosTests.php.fpm-modular
{ lib, php, ... }:
{
name = "php-${php.version}-fpm-modular-nginx-test";
+7 -1
pkgs/by-name/gh/ghostunnel/package.nix
···
ghostunnel,
apple-sdk_12,
darwinMinVersionHook,
}:
buildGoModule rec {
···
};
passthru.services.default = {
-
imports = [ ./service.nix ];
ghostunnel.package = ghostunnel; # FIXME: finalAttrs.finalPackage
};
···
ghostunnel,
apple-sdk_12,
darwinMinVersionHook,
+
writeScript,
+
runtimeShell,
}:
buildGoModule rec {
···
};
passthru.services.default = {
+
imports = [
+
(lib.modules.importApply ./service.nix {
+
inherit writeScript runtimeShell;
+
})
+
];
ghostunnel.package = ghostunnel; # FIXME: finalAttrs.finalPackage
};
+7 -3
pkgs/by-name/gh/ghostunnel/service.nix
···
{
lib,
config,
options,
-
pkgs,
...
}:
let
···
ghostunnel = {
package = mkOption {
description = "Package to use for ghostunnel";
type = types.package;
};
···
cfg.cacert
])
(
-
pkgs.writeScript "load-credentials" ''
-
#!${pkgs.runtimeShell}
exec $@ ${
concatStringsSep " " (
optional (cfg.keystore != null) "--keystore=$CREDENTIALS_DIRECTORY/keystore"
···
+
# Non-module dependencies (`importApply`)
+
{ writeScript, runtimeShell }:
+
+
# Service module
{
lib,
config,
options,
...
}:
let
···
ghostunnel = {
package = mkOption {
description = "Package to use for ghostunnel";
+
defaultText = "The ghostunnel package that provided this module.";
type = types.package;
};
···
cfg.cacert
])
(
+
writeScript "load-credentials" ''
+
#!${runtimeShell}
exec $@ ${
concatStringsSep " " (
optional (cfg.keystore != null) "--keystore=$CREDENTIALS_DIRECTORY/keystore"
+7 -1
pkgs/development/interpreters/php/generic.nix
···
common-updater-scripts,
curl,
jq,
version,
phpSrc ? null,
···
inherit ztsSupport;
services.default = {
-
imports = [ ./service.nix ];
php-fpm.package = lib.mkDefault finalAttrs.finalPackage;
};
};
···
common-updater-scripts,
curl,
jq,
+
coreutils,
+
formats,
version,
phpSrc ? null,
···
inherit ztsSupport;
services.default = {
+
imports = [
+
(lib.modules.importApply ./service.nix {
+
inherit formats coreutils;
+
})
+
];
php-fpm.package = lib.mkDefault finalAttrs.finalPackage;
};
};
+14 -6
pkgs/development/interpreters/php/service.nix
···
{
options,
config,
-
pkgs,
lib,
...
}:
let
cfg = config.php-fpm;
-
format = pkgs.formats.iniWithGlobalSection { };
configFile = format.generate "php-fpm.conf" {
globalSection = lib.filterAttrs (_: v: !lib.isAttrs v) cfg.settings;
sections = lib.filterAttrs (_: lib.isAttrs) cfg.settings;
···
_class = "service";
options.php-fpm = {
-
package = lib.mkPackageOption pkgs "php" {
-
example = ''
php.buildEnv {
extensions =
{ all, ... }:
···
serviceConfig = {
Type = "notify";
-
ExecReload = "${pkgs.coreutils}/bin/kill -USR2 $MAINPID";
RuntimeDirectory = "php-fpm";
RuntimeDirectoryPreserve = true;
Restart = "always";
···
finit.service = {
conditions = [ "service/syslogd/ready" ];
-
reload = "${pkgs.coreutils}/bin/kill -USR2 $MAINPID";
notify = "systemd";
};
};
···
+
# Tests in: nixos/tests/php/fpm-modular.nix
+
+
# Non-module dependencies (importApply)
+
{ formats, coreutils }:
+
+
# Service module
{
options,
config,
lib,
...
}:
let
cfg = config.php-fpm;
+
format = formats.iniWithGlobalSection { };
configFile = format.generate "php-fpm.conf" {
globalSection = lib.filterAttrs (_: v: !lib.isAttrs v) cfg.settings;
sections = lib.filterAttrs (_: lib.isAttrs) cfg.settings;
···
_class = "service";
options.php-fpm = {
+
package = lib.mkOption {
+
type = lib.types.package;
+
description = "PHP package to use for php-fpm";
+
defaultText = lib.literalMD ''The PHP package that provided this module.'';
+
example = lib.literalExpression ''
php.buildEnv {
extensions =
{ all, ... }:
···
serviceConfig = {
Type = "notify";
+
ExecReload = "${coreutils}/bin/kill -USR2 $MAINPID";
RuntimeDirectory = "php-fpm";
RuntimeDirectoryPreserve = true;
Restart = "always";
···
finit.service = {
conditions = [ "service/syslogd/ready" ];
+
reload = "${coreutils}/bin/kill -USR2 $MAINPID";
notify = "systemd";
};
};