From 58558fec0bbccdf8c9c5124d3cdbcfa5305c9850 Mon Sep 17 00:00:00 2001 From: cinereal Date: Sat, 25 Jul 2026 21:15:30 +0200 Subject: [PATCH 1/4] lib/services: fix test expectations for the reload options `process` gained `reloadCommand` and `reloadSignal`, and every service now carries an assertion guarding their combination, so the expected values in `test.nix` no longer matched. Compare only `process.argv` and only the assertions that are violated, so the test keeps expressing what it is about rather than restating every option. Assisted-by: Claude:claude-opus-5 --- lib/services/test.nix | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/services/test.nix b/lib/services/test.nix index 65d05ceb39e9..767a954f675f 100644 --- a/lib/services/test.nix +++ b/lib/services/test.nix @@ -91,10 +91,16 @@ let ]; }; + # Every service carries some assertions that hold; only the violated ones are of interest here. + failures = lib.filter (a: !a.assertion); + filterEval = config: lib.optionalAttrs (config ? process) { - inherit (config) assertions warnings process; + inherit (config) warnings; + assertions = failures config.assertions; + # Only `argv` is relevant here; `process` also carries the reload options. + process = { inherit (config.process) argv; }; } // { services = lib.mapAttrs (k: filterEval) config.services; @@ -165,7 +171,7 @@ let ]; assert - portable-lib.getAssertions [ "service1" ] exampleEval.config.services.service1 == [ + failures (portable-lib.getAssertions [ "service1" ] exampleEval.config.services.service1) == [ { message = "in service1: you can't enable this for that reason"; assertion = false; @@ -177,7 +183,7 @@ let "in service3.services.exclacow: The `bar' service is deprecated and will go away soon!" ]; assert - portable-lib.getAssertions [ "service3" ] exampleEval.config.services.service3 == [ + failures (portable-lib.getAssertions [ "service3" ] exampleEval.config.services.service3) == [ { message = "in service3.services.exclacow: you can't enable this for such reason"; assertion = false; From 85f295f5665e2d66ac5bb8af8d60e52389f8c5a6 Mon Sep 17 00:00:00 2001 From: lassulus Date: Sun, 12 Apr 2026 22:04:11 +0200 Subject: [PATCH 2/4] lib/services/service: add flags and flagFormat options Add `process.flags` for declarative command-line flag generation using `lib.cli.toCommandLine`, with per-flag ordering via valueMeta. Add `process.flagFormat` to control flag rendering. --- lib/services/service.nix | 81 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/lib/services/service.nix b/lib/services/service.nix index 5911202547fe..63fe7a085ce5 100644 --- a/lib/services/service.nix +++ b/lib/services/service.nix @@ -49,6 +49,83 @@ in This is a raw command-line that should not contain any shell escaping. If expansion of environmental variables is required then use a shell script or `importas` from `pkgs.execline`. + + When `flags` are set, the generated arguments are appended to `argv`. + ''; + }; + + flagFormat = mkOption { + type = types.functionTo (types.attrsOf types.anything); + default = name: { + option = name; + sep = null; + explicitBool = false; + }; + description = '' + Function mapping flag names to option format specs + for `lib.cli.toCommandLine`. + + Receives the flag name and returns `{ option, sep, explicitBool, formatArg? }`. + ''; + example = lib.literalExpression '' + name: { + option = name; + sep = "="; + explicitBool = false; + } + ''; + }; + + flags = mkOption { + type = types.attrListWith { + elemType = types.nullOr ( + types.oneOf [ + types.bool + types.int + types.path + types.str + ] + ); + asAttrs = true; + }; + default = { }; + description = '' + Flags to pass to the service process. + The key is the flag name (e.g. `"--port"`), the value is the flag value. + + Each `name = value` pair is rendered via `lib.cli.toCommandLine` + using `flagFormat`. + + - `null`: the flag is omitted (regardless of `flagFormat`) + - bool: rendered per `flagFormat.explicitBool` + - `explicitBool = false` (default): `true` emits the bare flag, + `false` is omitted + - `explicitBool = true`: both `true` and `false` are rendered as + explicit arguments via `flagFormat.formatArg` + - string / path / int: rendered as the option's argument, joined to the + option name per `flagFormat.sep` and stringified by + `flagFormat.formatArg` + + To pass the same flag multiple times, use the list form with + repeated keys, e.g. + `[ { "--host" = "a"; } { "--host" = "b"; } ]`. + + Use `lib.mkOrder` to influence ordering between flags + (lower = earlier, default 1000). + + The generated arguments are appended to `argv`. + ''; + example = lib.literalExpression '' + { + "--port" = "8080"; + "--verbose" = true; + "--config" = lib.mkOrder 500 "/etc/foo.conf"; + } + # or, for repeated flags: + [ + { "--host" = "localhost"; } + { "--host" = "0.0.0.0"; } + ] ''; }; @@ -103,5 +180,9 @@ in process.reloadCommand = lib.mkIf (config.process.reloadSignal != null) ( lib.mkDefault "${pkgs.coreutils}/bin/kill -${config.process.reloadSignal} $MAINPID" ); + + process.argv = lib.modules.mapDefinitionValue ( + attr: lib.cli.toCommandLine config.process.flagFormat attr + ) (lib.mkMerge options.process.flags.valueMeta.definitions); }; } From 639d4f74e5fa78e70fc774c5e2603d2f29b60204 Mon Sep 17 00:00:00 2001 From: cinereal Date: Sun, 26 Jul 2026 16:51:44 +0200 Subject: [PATCH 3/4] lib/services/service: pin flag ordering relative to argv `attrListWith` re-emits every flag as an `lib.mkOrder` definition, so `argv` and `flags` share one ordering space. Unadorned flags arrived at `lib.modules.defaultOrderPriority`, the same priority as unadorned `argv`, and only landed after `argv` because the declaring module's `config` happened to be collected last. Give flags that carry no ordering property of their own a priority of 1250, between `lib.modules.defaultOrderPriority` and `lib.mkAfter`. Plain flags now provably follow plain `argv`, `lib.mkAfter` on `argv` still places trailing positional arguments after the flags, and an explicit `lib.mkOrder` on a flag is honoured verbatim, which is what interleaving a sub-command among flags needs. Also render path flag values through `pathOrStr`. `lib.cli.toCommandLine` formats values with `lib.generators.mkValueStringDefault`, which has no case for paths and aborts; coercing first yields the store path, matching `argv`. Assisted-by: Claude:claude-opus-5 --- lib/services/service.nix | 57 +++++++++++++++++--- lib/services/test.nix | 113 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 8 deletions(-) diff --git a/lib/services/service.nix b/lib/services/service.nix index 63fe7a085ce5..20d74e1793b2 100644 --- a/lib/services/service.nix +++ b/lib/services/service.nix @@ -12,7 +12,31 @@ }: let inherit (lib) mkEnableOption mkOption types; + + # Paths are interpolated rather than `toString`ed on purpose: interpolation + # copies the path into the store, so the resulting argument still resolves on + # the machine that runs the service. `toString` would yield the path of the + # source tree the configuration was evaluated from, which is not there at + # runtime. pathOrStr = types.coercedTo types.path (x: "${x}") types.str; + + # `argv` and `flags` share a single `lib.mkOrder` space, so flags need a + # priority. This one sits between `lib.modules.defaultOrderPriority` (1000, + # what an unadorned `argv` definition gets) and `lib.mkAfter` (1500): plain + # flags follow plain `argv` entries, while `lib.mkAfter` on `argv` still lands + # after the flags. See the `flags` option description. + unadornedFlagPriority = 1250; + + # `attrListWith` re-emits every flag wrapped in `lib.mkOrder`, using + # `lib.modules.defaultOrderPriority` for flags that carried no ordering + # property of their own. Rewrite exactly that priority; anything else is an + # explicit `lib.mkOrder` from the user and is passed through verbatim. + atFlagPriority = + def: + if def.value._type or null == "order" && def.value.priority == lib.modules.defaultOrderPriority then + def // { value = lib.mkOrder unadornedFlagPriority def.value.content; } + else + def; in { # https://nixos.org/manual/nixos/unstable/#modular-services @@ -50,7 +74,8 @@ in If expansion of environmental variables is required then use a shell script or `importas` from `pkgs.execline`. - When `flags` are set, the generated arguments are appended to `argv`. + When `flags` are set, the arguments rendered from them are merged into + `argv`. See `flags` for how the two are ordered against each other. ''; }; @@ -82,8 +107,10 @@ in types.oneOf [ types.bool types.int - types.path - types.str + # `pathOrStr`, not `types.path`: `lib.cli.toCommandLine` renders + # values with `lib.generators.mkValueStringDefault`, which has no + # case for paths and would abort. + pathOrStr ] ); asAttrs = true; @@ -110,16 +137,30 @@ in repeated keys, e.g. `[ { "--host" = "a"; } { "--host" = "b"; } ]`. - Use `lib.mkOrder` to influence ordering between flags - (lower = earlier, default 1000). + The rendered arguments are merged into `argv`, so `argv` and `flags` + share a single `lib.mkOrder` space: - The generated arguments are appended to `argv`. + - A flag with no ordering property of its own is placed at priority + 1250, between `lib.modules.defaultOrderPriority` (1000, which is + what an unadorned `argv` definition gets) and `lib.mkAfter` (1500). + Plain flags therefore follow the command name and any other plain + `argv` arguments. + - `lib.mkAfter` on `argv` still lands after the flags, which is how + trailing positional arguments are expressed. + - `lib.mkOrder` on a flag is honoured verbatim against `argv`, so a + sub-command can be placed between two groups of flags. + + Because 1250 is substituted for flags that carry no ordering property, + `lib.mkOrder 1000` on a flag is indistinguishable from leaving that + flag unadorned. To order a flag around plain `argv` entries, pick a + priority next to 1000, such as 999 or 1001. ''; example = lib.literalExpression '' { "--port" = "8080"; "--verbose" = true; - "--config" = lib.mkOrder 500 "/etc/foo.conf"; + # ordered ahead of the unadorned flags above + "--config" = lib.mkOrder 1100 "/etc/foo.conf"; } # or, for repeated flags: [ @@ -183,6 +224,6 @@ in process.argv = lib.modules.mapDefinitionValue ( attr: lib.cli.toCommandLine config.process.flagFormat attr - ) (lib.mkMerge options.process.flags.valueMeta.definitions); + ) (lib.mkMerge (map atFlagPriority options.process.flags.valueMeta.definitions)); }; } diff --git a/lib/services/test.nix b/lib/services/test.nix index 767a954f675f..0b08026c335d 100644 --- a/lib/services/test.nix +++ b/lib/services/test.nix @@ -77,6 +77,60 @@ let ]; }; }; + # The default `flagFormat`, and one flag of every supported value kind. + flagsDefault = { + process = { + argv = [ "/bin/flagged" ]; + flags = { + "--bool-off" = false; + "--bool-on" = true; + "--config" = ./test.nix; + "--count" = 3; + "--name" = "example"; + "--unset" = null; + }; + }; + }; + # A `flagFormat` that joins with `=` and spells out booleans. + flagsCustomFormat = { + process = { + argv = [ "/bin/flagged" ]; + flagFormat = name: { + option = "--${name}"; + sep = "="; + explicitBool = true; + }; + flags = { + port = 8080; + quiet = false; + verbose = true; + }; + }; + }; + # The list form, which allows a flag to be repeated. + flagsRepeated = { + process = { + argv = [ "/bin/flagged" ]; + flags = [ + { "--host" = "a"; } + { "--host" = "b"; } + ]; + }; + }; + # `argv` and `flags` share one `lib.mkOrder` space. + flagsOrdering = { + process = { + argv = lib.mkMerge [ + (lib.mkBefore [ "/bin/gt" ]) + (lib.mkOrder 800 [ "server" ]) + (lib.mkAfter [ "TRAILING" ]) + ]; + flags = lib.mkMerge [ + { "--listen" = "a"; } + { "--disable-landlock" = lib.mkOrder 600 true; } + ]; + }; + }; }; }; @@ -162,6 +216,65 @@ let assertions = [ ]; warnings = [ ]; }; + flagsDefault = { + process = { + argv = [ + "/bin/flagged" + "--bool-on" + "--config" + "${./test.nix}" + "--count" + "3" + "--name" + "example" + ]; + }; + services = { }; + assertions = [ ]; + warnings = [ ]; + }; + flagsCustomFormat = { + process = { + argv = [ + "/bin/flagged" + "--port=8080" + "--quiet=false" + "--verbose=true" + ]; + }; + services = { }; + assertions = [ ]; + warnings = [ ]; + }; + flagsRepeated = { + process = { + argv = [ + "/bin/flagged" + "--host" + "a" + "--host" + "b" + ]; + }; + services = { }; + assertions = [ ]; + warnings = [ ]; + }; + flagsOrdering = { + process = { + argv = [ + "/bin/gt" + "--disable-landlock" + "server" + "--listen" + "a" + "TRAILING" + ]; + }; + services = { }; + assertions = [ ]; + warnings = [ ]; + }; }; }; From 89e1ea15f2c1e16775401206ef235f496611d399 Mon Sep 17 00:00:00 2001 From: lassulus Date: Sat, 18 Apr 2026 17:49:58 +0200 Subject: [PATCH 4/4] snid: use process.flags to construct process.argv --- pkgs/by-name/sn/snid/service.nix | 38 +++++++++++++++++--------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/pkgs/by-name/sn/snid/service.nix b/pkgs/by-name/sn/snid/service.nix index f536cdb54825..5e4d279b87c2 100644 --- a/pkgs/by-name/sn/snid/service.nix +++ b/pkgs/by-name/sn/snid/service.nix @@ -10,10 +10,8 @@ }: let inherit (lib) - concatMap getExe mkOption - optional types ; cfg = config.snid; @@ -141,22 +139,26 @@ in process.argv = [ (getExe cfg.package) - "-mode" - cfg.mode - ] - ++ concatMap (l: [ - "-listen" - l - ]) cfg.listen - ++ concatMap (c: [ - "-backend-cidr" - c - ]) cfg.backendCidrs - ++ optional (cfg.defaultHostname != null) "-default-hostname=${cfg.defaultHostname}" - ++ optional (cfg.nat46Prefix != null) "-nat46-prefix=${cfg.nat46Prefix}" - ++ optional (cfg.backendPort != null) "-backend-port=${toString cfg.backendPort}" - ++ optional (cfg.unixDirectory != null) "-unix-directory=${cfg.unixDirectory}" - ++ optional cfg.proxyProto "-proxy-proto"; + ]; + + process.flagFormat = flag: { + option = "-${flag}"; + explicitBool = false; + sep = null; + }; + + process.flags = lib.mkMerge [ + { + mode = cfg.mode; + default-hostname = cfg.defaultHostname; + nat46-prefix = cfg.nat46Prefix; + backend-port = cfg.backendPort; + unix-directory = cfg.unixDirectory; + proxy-proto = cfg.proxyProto; + } + (map (v: { listen = v; }) cfg.listen) + (map (v: { backend-cidr = v; }) cfg.backendCidrs) + ]; } // lib.optionalAttrs (options ? systemd) { systemd.service = {