From 861af0fc94df9454f4e92d6892f75588763164bb Mon Sep 17 00:00:00 2001 From: Tyler Miller Date: Thu, 29 Jun 2023 00:50:28 -0700 Subject: [PATCH] fix(launchd): improve `StartCalendarInterval` Stricter launchd -> StartCalendarInterval type: - Verify that the integers passed to `Minute`, `Hour`, etc. are within range. - When provided, the value for StartCalendarInterval must be a non-empty list of calendar intervals and must not contain duplicates entries (throw an error otherwise). - For increased flexibility and backwards-compatibility, allow an attrset to be passed as well (which will be type-checked and is functionally equivalent to passing a singleton list). Allowing an attrset or list is precisely in-line with what `launchd.plist(5)` accepts for StartCalendarInterval. Migrate `nix.gc.interval` and `nix.optimise.interval` over to use this new type, and update their defaults to run weekly instead of daily. Create `modules/launchd/types.nix` file for easier/modular use of launchd types needed in multiple files. Documentation: - Update and improve wording/documentation of launchd's `StartCalendarInterval`. - Improve wording/documentation of `nix.gc.interval` and `nix.optimise.interval` ("time interval" can be misleading as it's actually a "calendar interval"; e.g. `{ Hour = 3; Minute = 15;}` runs daily, not every 3.25 hours). --- modules/launchd/launchd.nix | 67 ++++--------- modules/launchd/types.nix | 110 ++++++++++++++++++++++ modules/services/nix-gc/default.nix | 13 ++- modules/services/nix-optimise/default.nix | 13 ++- 4 files changed, 146 insertions(+), 57 deletions(-) create mode 100644 modules/launchd/types.nix diff --git a/modules/launchd/launchd.nix b/modules/launchd/launchd.nix index 9fecde66..add05141 100644 --- a/modules/launchd/launchd.nix +++ b/modules/launchd/launchd.nix @@ -2,6 +2,10 @@ with lib; +let + launchdTypes = import ./types.nix { inherit config lib; }; +in + { options = { Label = mkOption { @@ -344,55 +348,21 @@ with lib; default = null; example = [{ Hour = 2; Minute = 30; }]; description = '' - This optional key causes the job to be started every calendar interval as specified. Missing arguments - are considered to be wildcard. The semantics are much like `crontab(5)`. Unlike cron which skips job - invocations when the computer is asleep, launchd will start the job the next time the computer wakes + This optional key causes the job to be started every calendar interval as specified. The semantics are + much like {manpage}`crontab(5)`: Missing attributes are considered to be wildcard. Unlike cron which skips + job invocations when the computer is asleep, launchd will start the job the next time the computer wakes up. If multiple intervals transpire before the computer is woken, those events will be coalesced into - one event upon wake from sleep. + one event upon waking from sleep. + + ::: {.important} + The list must not be empty and must not contain duplicate entries (attrsets which compare equally). + ::: + + ::: {.caution} + Since missing attrs become wildcards, an empty attrset effectively means "every minute". + ::: ''; - type = types.nullOr (types.listOf (types.submodule { - options = { - Minute = mkOption { - type = types.nullOr types.int; - default = null; - description = '' - The minute on which this job will be run. - ''; - }; - - Hour = mkOption { - type = types.nullOr types.int; - default = null; - description = '' - The hour on which this job will be run. - ''; - }; - - Day = mkOption { - type = types.nullOr types.int; - default = null; - description = '' - The day on which this job will be run. - ''; - }; - - Weekday = mkOption { - type = types.nullOr types.int; - default = null; - description = '' - The weekday on which this job will be run (0 and 7 are Sunday). - ''; - }; - - Month = mkOption { - type = types.nullOr types.int; - default = null; - description = '' - The month on which this job will be run. - ''; - }; - }; - })); + type = types.nullOr launchdTypes.StartCalendarInterval; }; StandardInPath = mkOption { @@ -886,6 +856,5 @@ with lib; }; }; - config = { - }; + config = {}; } diff --git a/modules/launchd/types.nix b/modules/launchd/types.nix new file mode 100644 index 00000000..38d7f204 --- /dev/null +++ b/modules/launchd/types.nix @@ -0,0 +1,110 @@ +{ lib, ... }: + +let + inherit (lib) imap1 types mkOption showOption optionDescriptionPhrase mergeDefinitions; + inherit (builtins) map filter length deepSeq throw toString concatLists; + inherit (lib.options) showDefs; + wildcardText = lib.literalMD "`*`"; + + /** + A type of list which does not allow duplicate elements. The base/inner + list type to use (e.g. `types.listOf` or `types.nonEmptyListOf`) is passed + via argument `listType`, which must be the final type and not a function. + + NOTE: The extra check for duplicates is quadratic and strict, so use this + type sparingly and only: + + * when needed, and + * when the list is expected to be recursively short (e.g. < 10 elements) + and shallow (i.e. strict evaluation of the list won't take too long) + + The implementation of this function is similar to that of + `types.nonEmptyListOf`. + */ + types'.uniqueList = listType: listType // { + description = "unique ${types.optionDescriptionPhrase (class: class == "noun") listType}"; + substSubModules = m: types'.uniqueList (listType.substSubModules m); + # This has been taken from the implementation of `types.listOf`, but has + # been modified to throw on duplicates. This check cannot be done in the + # `check` fn as this check is deep/strict, and because `check` runs + # prior to merging. + merge = loc: defs: + let + # Each element of `dupes` is a list. When there are duplicates, + # later lists will be duplicates of earlier lists, so just throw on + # the first set of duplicates found so that we don't have duplicate + # error msgs. + checked = filter (li: + if length li > 1 + then throw "The option `${showOption loc}' contains duplicate entries after merging:\n${showDefs li}" + else false) dupes; + dupes = map (def: filter (def': def'.value == def.value) merged) merged; + merged = filter (x: x ? value) (concatLists (imap1 (n: def: + imap1 (m: el: + let + inherit (def) file; + loc' = loc ++ ["[definition ${toString n}-entry ${toString m}]"]; + in + (mergeDefinitions + loc' + listType.nestedTypes.elemType + [{ inherit file; value = el; }] + ).optionalValue // {inherit loc' file;} + ) def.value + ) defs)); + in + deepSeq checked (map (x: x.value) merged); + }; +in { + StartCalendarInterval = let + CalendarIntervalEntry = types.submodule { + options = { + Minute = mkOption { + type = types.nullOr (types.ints.between 0 59); + default = null; + defaultText = wildcardText; + description = '' + The minute on which this job will be run. + ''; + }; + + Hour = mkOption { + type = types.nullOr (types.ints.between 0 23); + default = null; + defaultText = wildcardText; + description = '' + The hour on which this job will be run. + ''; + }; + + Day = mkOption { + type = types.nullOr (types.ints.between 1 31); + default = null; + defaultText = wildcardText; + description = '' + The day on which this job will be run. + ''; + }; + + Weekday = mkOption { + type = types.nullOr (types.ints.between 0 7); + default = null; + defaultText = wildcardText; + description = '' + The weekday on which this job will be run (0 and 7 are Sunday). + ''; + }; + + Month = mkOption { + type = types.nullOr (types.ints.between 1 12); + default = null; + defaultText = wildcardText; + description = '' + The month on which this job will be run. + ''; + }; + }; + }; + in + types.either CalendarIntervalEntry (types'.uniqueList (types.nonEmptyListOf CalendarIntervalEntry)); +} diff --git a/modules/services/nix-gc/default.nix b/modules/services/nix-gc/default.nix index 49fb3281..9fe8e86d 100644 --- a/modules/services/nix-gc/default.nix +++ b/modules/services/nix-gc/default.nix @@ -6,6 +6,7 @@ with lib; let cfg = config.nix.gc; + launchdTypes = import ../../launchd/types.nix { inherit config lib; }; in { @@ -35,9 +36,13 @@ in }; interval = mkOption { - type = types.attrs; - default = { Hour = 3; Minute = 15; }; - description = "The time interval at which the garbage collector will run."; + type = launchdTypes.StartCalendarInterval; + default = [{ Weekday = 7; Hour = 3; Minute = 15; }]; + description = '' + The calendar interval at which the garbage collector will run. + See the {option}`serviceConfig.StartCalendarInterval` option of + the {option}`launchd` module for more info. + ''; }; options = mkOption { @@ -63,7 +68,7 @@ in command = "${config.nix.package}/bin/nix-collect-garbage ${cfg.options}"; environment.NIX_REMOTE = optionalString config.nix.useDaemon "daemon"; serviceConfig.RunAtLoad = false; - serviceConfig.StartCalendarInterval = [ cfg.interval ]; + serviceConfig.StartCalendarInterval = cfg.interval; serviceConfig.UserName = cfg.user; }; diff --git a/modules/services/nix-optimise/default.nix b/modules/services/nix-optimise/default.nix index 94f6e1cb..d8dc401c 100644 --- a/modules/services/nix-optimise/default.nix +++ b/modules/services/nix-optimise/default.nix @@ -14,6 +14,7 @@ let ; cfg = config.nix.optimise; + launchdTypes = import ../../launchd/types.nix { inherit config lib; }; in { @@ -41,9 +42,13 @@ in }; interval = mkOption { - type = types.attrs; - default = { Hour = 3; Minute = 15; }; - description = "The time interval at which the optimiser will run."; + type = launchdTypes.StartCalendarInterval; + default = [{ Weekday = 7; Hour = 4; Minute = 15; }]; + description = '' + The calendar interval at which the optimiser will run. + See the {option}`serviceConfig.StartCalendarInterval` option of + the {option}`launchd` module for more info. + ''; }; }; @@ -63,7 +68,7 @@ in "/bin/wait4path ${config.nix.package} && exec ${config.nix.package}/bin/nix-store --optimise" ]; RunAtLoad = false; - StartCalendarInterval = [ cfg.interval ]; + StartCalendarInterval = cfg.interval; UserName = cfg.user; }; };