nixpkgs/nixos/lib/systemd-lib.nix
Jared Baur 38907ef84d
nixos/systemd-lib: fix assertRangeOrOneOf when value is not comparable
We cannot compare non-numerical values to a min or max value in a range.
2024-05-06 10:41:20 -07:00

585 lines
19 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{ config, lib, pkgs, utils }:
let
inherit (lib)
all
attrByPath
attrNames
concatLists
concatMap
concatMapStrings
concatStrings
concatStringsSep
const
elem
filter
filterAttrs
flatten
flip
head
isInt
isFloat
isList
isPath
length
makeBinPath
makeSearchPathOutput
mapAttrs
mapAttrsToList
mkAfter
mkIf
optional
optionalAttrs
optionalString
pipe
range
replaceStrings
reverseList
splitString
stringLength
stringToCharacters
tail
toIntBase10
trace
types
;
inherit (lib.strings) toJSON;
cfg = config.systemd;
lndir = "${pkgs.buildPackages.xorg.lndir}/bin/lndir";
systemd = cfg.package;
in rec {
shellEscape = s: (replaceStrings [ "\\" ] [ "\\\\" ] s);
mkPathSafeName = replaceStrings ["@" ":" "\\" "[" "]"] ["-" "-" "-" "" ""];
# a type for options that take a unit name
unitNameType = types.strMatching "[a-zA-Z0-9@%:_.\\-]+[.](service|socket|device|mount|automount|swap|target|path|timer|scope|slice)";
makeUnit = name: unit:
if unit.enable then
pkgs.runCommand "unit-${mkPathSafeName name}"
{ preferLocalBuild = true;
allowSubstitutes = false;
# unit.text can be null. But variables that are null listed in
# passAsFile are ignored by nix, resulting in no file being created,
# making the mv operation fail.
text = optionalString (unit.text != null) unit.text;
passAsFile = [ "text" ];
}
''
name=${shellEscape name}
mkdir -p "$out/$(dirname -- "$name")"
mv "$textPath" "$out/$name"
''
else
pkgs.runCommand "unit-${mkPathSafeName name}-disabled"
{ preferLocalBuild = true;
allowSubstitutes = false;
}
''
name=${shellEscape name}
mkdir -p "$out/$(dirname "$name")"
ln -s /dev/null "$out/$name"
'';
boolValues = [true false "yes" "no"];
digits = map toString (range 0 9);
isByteFormat = s:
let
l = reverseList (stringToCharacters s);
suffix = head l;
nums = tail l;
in elem suffix (["K" "M" "G" "T"] ++ digits)
&& all (num: elem num digits) nums;
assertByteFormat = name: group: attr:
optional (attr ? ${name} && ! isByteFormat attr.${name})
"Systemd ${group} field `${name}' must be in byte format [0-9]+[KMGT].";
hexChars = stringToCharacters "0123456789abcdefABCDEF";
isMacAddress = s: stringLength s == 17
&& flip all (splitString ":" s) (bytes:
all (byte: elem byte hexChars) (stringToCharacters bytes)
);
assertMacAddress = name: group: attr:
optional (attr ? ${name} && ! isMacAddress attr.${name})
"Systemd ${group} field `${name}' must be a valid MAC address.";
assertNetdevMacAddress = name: group: attr:
optional (attr ? ${name} && (! isMacAddress attr.${name} && attr.${name} != "none"))
"Systemd ${group} field `${name}` must be a valid MAC address or the special value `none`.";
isNumberOrRangeOf = check: v:
if isInt v
then check v
else let
parts = splitString "-" v;
lower = toIntBase10 (head parts);
upper = if tail parts != [] then toIntBase10 (head (tail parts)) else lower;
in
length parts <= 2 && lower <= upper && check lower && check upper;
isPort = i: i >= 0 && i <= 65535;
isPortOrPortRange = isNumberOrRangeOf isPort;
assertPort = name: group: attr:
optional (attr ? ${name} && ! isPort attr.${name})
"Error on the systemd ${group} field `${name}': ${attr.name} is not a valid port number.";
assertPortOrPortRange = name: group: attr:
optional (attr ? ${name} && ! isPortOrPortRange attr.${name})
"Error on the systemd ${group} field `${name}': ${attr.name} is not a valid port number or range of port numbers.";
assertValueOneOf = name: values: group: attr:
optional (attr ? ${name} && !elem attr.${name} values)
"Systemd ${group} field `${name}' cannot have value `${toString attr.${name}}'.";
assertValuesSomeOfOr = name: values: default: group: attr:
optional (attr ? ${name} && !(all (x: elem x values) (splitString " " attr.${name}) || attr.${name} == default))
"Systemd ${group} field `${name}' cannot have value `${toString attr.${name}}'.";
assertHasField = name: group: attr:
optional (!(attr ? ${name}))
"Systemd ${group} field `${name}' must exist.";
assertRange = name: min: max: group: attr:
optional (attr ? ${name} && !(min <= attr.${name} && max >= attr.${name}))
"Systemd ${group} field `${name}' is outside the range [${toString min},${toString max}]";
assertRangeOrOneOf = name: min: max: values: group: attr:
optional (attr ? ${name} && !(((isInt attr.${name} || isFloat attr.${name}) && min <= attr.${name} && max >= attr.${name}) || elem attr.${name} values))
"Systemd ${group} field `${name}' is not a value in range [${toString min},${toString max}], or one of ${toString values}";
assertMinimum = name: min: group: attr:
optional (attr ? ${name} && attr.${name} < min)
"Systemd ${group} field `${name}' must be greater than or equal to ${toString min}";
assertOnlyFields = fields: group: attr:
let badFields = filter (name: ! elem name fields) (attrNames attr); in
optional (badFields != [ ])
"Systemd ${group} has extra fields [${concatStringsSep " " badFields}].";
assertInt = name: group: attr:
optional (attr ? ${name} && !isInt attr.${name})
"Systemd ${group} field `${name}' is not an integer";
checkUnitConfig = group: checks: attrs: let
# We're applied at the top-level type (attrsOf unitOption), so the actual
# unit options might contain attributes from mkOverride and mkIf that we need to
# convert into single values before checking them.
defs = mapAttrs (const (v:
if v._type or "" == "override" then v.content
else if v._type or "" == "if" then v.content
else v
)) attrs;
errors = concatMap (c: c group defs) checks;
in if errors == [] then true
else trace (concatStringsSep "\n" errors) false;
toOption = x:
if x == true then "true"
else if x == false then "false"
else toString x;
attrsToSection = as:
concatStrings (concatLists (mapAttrsToList (name: value:
map (x: ''
${name}=${toOption x}
'')
(if isList value then value else [value]))
as));
generateUnits = { allowCollisions ? true, type, units, upstreamUnits, upstreamWants, packages ? cfg.packages, package ? cfg.package }:
let
typeDir = ({
system = "system";
initrd = "system";
user = "user";
nspawn = "nspawn";
}).${type};
in pkgs.runCommand "${type}-units"
{ preferLocalBuild = true;
allowSubstitutes = false;
} ''
mkdir -p $out
# Copy the upstream systemd units we're interested in.
for i in ${toString upstreamUnits}; do
fn=${package}/example/systemd/${typeDir}/$i
if ! [ -e $fn ]; then echo "missing $fn"; false; fi
if [ -L $fn ]; then
target="$(readlink "$fn")"
if [ ''${target:0:3} = ../ ]; then
ln -s "$(readlink -f "$fn")" $out/
else
cp -pd $fn $out/
fi
else
ln -s $fn $out/
fi
done
# Copy .wants links, but only those that point to units that
# we're interested in.
for i in ${toString upstreamWants}; do
fn=${package}/example/systemd/${typeDir}/$i
if ! [ -e $fn ]; then echo "missing $fn"; false; fi
x=$out/$(basename $fn)
mkdir $x
for i in $fn/*; do
y=$x/$(basename $i)
cp -pd $i $y
if ! [ -e $y ]; then rm $y; fi
done
done
# Symlink all units provided listed in systemd.packages.
packages="${toString packages}"
# Filter duplicate directories
declare -A unique_packages
for k in $packages ; do unique_packages[$k]=1 ; done
for i in ''${!unique_packages[@]}; do
for fn in $i/etc/systemd/${typeDir}/* $i/lib/systemd/${typeDir}/*; do
if ! [[ "$fn" =~ .wants$ ]]; then
if [[ -d "$fn" ]]; then
targetDir="$out/$(basename "$fn")"
mkdir -p "$targetDir"
${lndir} "$fn" "$targetDir"
else
ln -s $fn $out/
fi
fi
done
done
# Symlink units defined by systemd.units where override strategy
# shall be automatically detected. If these are also provided by
# systemd or systemd.packages, then add them as
# <unit-name>.d/overrides.conf, which makes them extend the
# upstream unit.
for i in ${toString (mapAttrsToList
(n: v: v.unit)
(filterAttrs (n: v: (attrByPath [ "overrideStrategy" ] "asDropinIfExists" v) == "asDropinIfExists") units))}; do
fn=$(basename $i/*)
if [ -e $out/$fn ]; then
if [ "$(readlink -f $i/$fn)" = /dev/null ]; then
ln -sfn /dev/null $out/$fn
else
${if allowCollisions then ''
mkdir -p $out/$fn.d
ln -s $i/$fn $out/$fn.d/overrides.conf
'' else ''
echo "Found multiple derivations configuring $fn!"
exit 1
''}
fi
else
ln -fs $i/$fn $out/
fi
done
# Symlink units defined by systemd.units which shall be
# treated as drop-in file.
for i in ${toString (mapAttrsToList
(n: v: v.unit)
(filterAttrs (n: v: v ? overrideStrategy && v.overrideStrategy == "asDropin") units))}; do
fn=$(basename $i/*)
mkdir -p $out/$fn.d
ln -s $i/$fn $out/$fn.d/overrides.conf
done
# Create service aliases from aliases option.
${concatStrings (mapAttrsToList (name: unit:
concatMapStrings (name2: ''
ln -sfn '${name}' $out/'${name2}'
'') (unit.aliases or [])) units)}
# Create .wants, .upholds and .requires symlinks from the wantedBy, upheldBy and
# requiredBy options.
${concatStrings (mapAttrsToList (name: unit:
concatMapStrings (name2: ''
mkdir -p $out/'${name2}.wants'
ln -sfn '../${name}' $out/'${name2}.wants'/
'') (unit.wantedBy or [])) units)}
${concatStrings (mapAttrsToList (name: unit:
concatMapStrings (name2: ''
mkdir -p $out/'${name2}.upholds'
ln -sfn '../${name}' $out/'${name2}.upholds'/
'') (unit.upheldBy or [])) units)}
${concatStrings (mapAttrsToList (name: unit:
concatMapStrings (name2: ''
mkdir -p $out/'${name2}.requires'
ln -sfn '../${name}' $out/'${name2}.requires'/
'') (unit.requiredBy or [])) units)}
${optionalString (type == "system") ''
# Stupid misc. symlinks.
ln -s ${cfg.defaultUnit} $out/default.target
ln -s ${cfg.ctrlAltDelUnit} $out/ctrl-alt-del.target
ln -s rescue.target $out/kbrequest.target
mkdir -p $out/getty.target.wants/
ln -s ../autovt@tty1.service $out/getty.target.wants/
ln -s ../remote-fs.target $out/multi-user.target.wants/
''}
''; # */
makeJobScript = name: text:
let
scriptName = replaceStrings [ "\\" "@" ] [ "-" "_" ] (shellEscape name);
out = (pkgs.writeShellScriptBin scriptName ''
set -e
${text}
'').overrideAttrs (_: {
# The derivation name is different from the script file name
# to keep the script file name short to avoid cluttering logs.
name = "unit-script-${scriptName}";
});
in "${out}/bin/${scriptName}";
unitConfig = { config, name, options, ... }: {
config = {
unitConfig =
optionalAttrs (config.requires != [])
{ Requires = toString config.requires; }
// optionalAttrs (config.wants != [])
{ Wants = toString config.wants; }
// optionalAttrs (config.upholds != [])
{ Upholds = toString config.upholds; }
// optionalAttrs (config.after != [])
{ After = toString config.after; }
// optionalAttrs (config.before != [])
{ Before = toString config.before; }
// optionalAttrs (config.bindsTo != [])
{ BindsTo = toString config.bindsTo; }
// optionalAttrs (config.partOf != [])
{ PartOf = toString config.partOf; }
// optionalAttrs (config.conflicts != [])
{ Conflicts = toString config.conflicts; }
// optionalAttrs (config.requisite != [])
{ Requisite = toString config.requisite; }
// optionalAttrs (config ? restartTriggers && config.restartTriggers != [])
{ X-Restart-Triggers = "${pkgs.writeText "X-Restart-Triggers-${name}" (pipe config.restartTriggers [
flatten
(map (x: if isPath x then "${x}" else x))
toString
])}"; }
// optionalAttrs (config ? reloadTriggers && config.reloadTriggers != [])
{ X-Reload-Triggers = "${pkgs.writeText "X-Reload-Triggers-${name}" (pipe config.reloadTriggers [
flatten
(map (x: if isPath x then "${x}" else x))
toString
])}"; }
// optionalAttrs (config.description != "") {
Description = config.description; }
// optionalAttrs (config.documentation != []) {
Documentation = toString config.documentation; }
// optionalAttrs (config.onFailure != []) {
OnFailure = toString config.onFailure; }
// optionalAttrs (config.onSuccess != []) {
OnSuccess = toString config.onSuccess; }
// optionalAttrs (options.startLimitIntervalSec.isDefined) {
StartLimitIntervalSec = toString config.startLimitIntervalSec;
} // optionalAttrs (options.startLimitBurst.isDefined) {
StartLimitBurst = toString config.startLimitBurst;
};
};
};
serviceConfig = { name, config, ... }: {
config = {
name = "${name}.service";
environment.PATH = mkIf (config.path != []) "${makeBinPath config.path}:${makeSearchPathOutput "bin" "sbin" config.path}";
};
};
pathConfig = { name, config, ... }: {
config = {
name = "${name}.path";
};
};
socketConfig = { name, config, ... }: {
config = {
name = "${name}.socket";
};
};
sliceConfig = { name, config, ... }: {
config = {
name = "${name}.slice";
};
};
targetConfig = { name, config, ... }: {
config = {
name = "${name}.target";
};
};
timerConfig = { name, config, ... }: {
config = {
name = "${name}.timer";
};
};
stage2ServiceConfig = {
imports = [ serviceConfig ];
# Default path for systemd services. Should be quite minimal.
config.path = mkAfter [
pkgs.coreutils
pkgs.findutils
pkgs.gnugrep
pkgs.gnused
systemd
];
};
stage1ServiceConfig = serviceConfig;
mountConfig = { config, ... }: {
config = {
name = "${utils.escapeSystemdPath config.where}.mount";
mountConfig =
{ What = config.what;
Where = config.where;
} // optionalAttrs (config.type != "") {
Type = config.type;
} // optionalAttrs (config.options != "") {
Options = config.options;
};
};
};
automountConfig = { config, ... }: {
config = {
name = "${utils.escapeSystemdPath config.where}.automount";
automountConfig =
{ Where = config.where;
};
};
};
commonUnitText = def: lines: ''
[Unit]
${attrsToSection def.unitConfig}
'' + lines + optionalString (def.wantedBy != [ ]) ''
[Install]
WantedBy=${concatStringsSep " " def.wantedBy}
'';
targetToUnit = def:
{ inherit (def) name aliases wantedBy requiredBy upheldBy enable overrideStrategy;
text =
''
[Unit]
${attrsToSection def.unitConfig}
'';
};
serviceToUnit = def:
{ inherit (def) name aliases wantedBy requiredBy upheldBy enable overrideStrategy;
text = commonUnitText def (''
[Service]
'' + (let env = cfg.globalEnvironment // def.environment;
in concatMapStrings (n:
let s = optionalString (env.${n} != null)
"Environment=${toJSON "${n}=${env.${n}}"}\n";
# systemd max line length is now 1MiB
# https://github.com/systemd/systemd/commit/e6dde451a51dc5aaa7f4d98d39b8fe735f73d2af
in if stringLength s >= 1048576 then throw "The value of the environment variable ${n} in systemd service ${def.name}.service is too long." else s) (attrNames env))
+ (if def ? reloadIfChanged && def.reloadIfChanged then ''
X-ReloadIfChanged=true
'' else if (def ? restartIfChanged && !def.restartIfChanged) then ''
X-RestartIfChanged=false
'' else "")
+ optionalString (def ? stopIfChanged && !def.stopIfChanged) ''
X-StopIfChanged=false
'' + attrsToSection def.serviceConfig);
};
socketToUnit = def:
{ inherit (def) name aliases wantedBy requiredBy upheldBy enable overrideStrategy;
text = commonUnitText def ''
[Socket]
${attrsToSection def.socketConfig}
${concatStringsSep "\n" (map (s: "ListenStream=${s}") def.listenStreams)}
${concatStringsSep "\n" (map (s: "ListenDatagram=${s}") def.listenDatagrams)}
'';
};
timerToUnit = def:
{ inherit (def) name aliases wantedBy requiredBy upheldBy enable overrideStrategy;
text = commonUnitText def ''
[Timer]
${attrsToSection def.timerConfig}
'';
};
pathToUnit = def:
{ inherit (def) name aliases wantedBy requiredBy upheldBy enable overrideStrategy;
text = commonUnitText def ''
[Path]
${attrsToSection def.pathConfig}
'';
};
mountToUnit = def:
{ inherit (def) name aliases wantedBy requiredBy upheldBy enable overrideStrategy;
text = commonUnitText def ''
[Mount]
${attrsToSection def.mountConfig}
'';
};
automountToUnit = def:
{ inherit (def) name aliases wantedBy requiredBy upheldBy enable overrideStrategy;
text = commonUnitText def ''
[Automount]
${attrsToSection def.automountConfig}
'';
};
sliceToUnit = def:
{ inherit (def) name aliases wantedBy requiredBy upheldBy enable overrideStrategy;
text = commonUnitText def ''
[Slice]
${attrsToSection def.sliceConfig}
'';
};
# Create a directory that contains systemd definition files from an attrset
# that contains the file names as keys and the content as values. The values
# in that attrset are determined by the supplied format.
definitions = directoryName: format: definitionAttrs:
let
listOfDefinitions = mapAttrsToList
(name: format.generate "${name}.conf")
definitionAttrs;
in
pkgs.runCommand directoryName { } ''
mkdir -p $out
${(concatStringsSep "\n"
(map (pkg: "cp ${pkg} $out/${pkg.name}") listOfDefinitions)
)}
'';
# The maximum number of characters allowed in a GPT partition label. This
# limit is specified by UEFI and enforced by systemd-repart.
# Corresponds to GPT_LABEL_MAX from systemd's gpt.h.
GPTMaxLabelLength = 36;
}