diff --git a/modules/misc/tmpfiles.nix b/modules/misc/tmpfiles.nix index 82b791d89..9403f7d85 100644 --- a/modules/misc/tmpfiles.nix +++ b/modules/misc/tmpfiles.nix @@ -89,6 +89,46 @@ let } ); + attrsWith' = placeholder: elemType: lib.types.attrsWith { inherit elemType placeholder; }; + + nonEmptyAttrsWith' = + placeholder: elemType: + let + attrs = lib.types.addCheck (attrsWith' placeholder elemType) (s: s != { }); + in + attrs + // { + name = "nonEmptyAttrsOf"; + description = "non-empty ${attrs.description}"; + emptyValue = { }; # no .value attribute, meaning there is not empty value + substSubModules = m: nonEmptyAttrsWith' placeholder (elemType.substSubModules m); + }; + + configSubmodule = lib.types.submodule { + options.rules = lib.mkOption { + description = "The rules contained in this configuration."; + example = { + "%C".d = { + mode = "0755"; + user = "alice"; + group = "alice"; + age = "4 weeks"; + }; + }; + type = nonEmptyAttrsWith' "path" (nonEmptyAttrsWith' "tmpfiles-type" ruleSubmodule); + }; + options.purgeOnChange = lib.mkOption { + description = '' + Whether the rules that are marked for purging, will automatically + be purged when the set of rules changes. + + See {manpage}`systemd-tmpfiles(8)` for details about purging. + ''; + type = lib.types.bool; + default = false; + }; + }; + modulePrefix = [ "systemd" "user" @@ -100,6 +140,9 @@ let mkConfigFile = name: rules: + { + suffix ? [ name ], + }: let escapeArgument = lib.strings.escapeC [ "\t" @@ -115,7 +158,7 @@ let { text = '' # This file was generated by Home Manager and should not be modified. - # Please change the option '${lib.showAttrPath (modulePrefix ++ [ name ])}' instead. + # Please change the option '${lib.showAttrPath (modulePrefix ++ suffix)}' instead. ${lib.pipe rules [ (lib.mapAttrs (_path: lib.attrValues)) (lib.mapAttrsToList (path: map (mkRule path))) @@ -128,6 +171,13 @@ let ''; }; + nonPurgedConfigs = lib.filterAttrs (_name: config: !config.purgeOnChange) cfg.settings; + purgedConfigs = lib.filterAttrs (_name: config: config.purgeOnChange) cfg.settings; + + # WARNING: When changing this path, the next home-manager generation will + # not find and the rules of the old generation that are subject to purging. + purgedRulesConfigName = "purge-on-change"; + in { meta.maintainers = [ lib.maintainers.dawidsowa ]; @@ -147,7 +197,7 @@ in persistent files. ''; example = { - cache."%C".d = { + cache.rules."%C".d = { mode = "0755"; user = "alice"; group = "alice"; @@ -155,58 +205,70 @@ in }; }; default = { }; - type = - let - attrsWith' = placeholder: elemType: lib.types.attrsWith { inherit elemType placeholder; }; - nonEmptyAttrsWith' = - placeholder: elemType: - let - attrs = lib.types.addCheck (attrsWith' placeholder elemType) (s: s != { }); - in - attrs - // { - name = "nonEmptyAttrsOf"; - description = "non-empty ${attrs.description}"; - emptyValue = { }; # no .value attribute, meaning there is not empty value - substSubModules = m: nonEmptyAttrsWith' placeholder (elemType.substSubModules m); - }; - in - attrsWith' "config-name" ( - nonEmptyAttrsWith' "path" (nonEmptyAttrsWith' "tmpfiles-type" ruleSubmodule) + type = attrsWith' "config-name" configSubmodule; + }; + + config = lib.mkMerge [ + + (lib.mkIf pkgs.stdenv.hostPlatform.isLinux { + # The activation script must be enabled unconditionally in order to + # guarantee that the old rules are purged even if the new set of rules + # is empty, i.e. `cfg.rulesToPurgeOnChange == [ ]`. + home.activation.purgeTmpfiles = lib.hm.dag.entryAfter [ "writeBoundary" ] ( + let + relativeXdgConfigHome = lib.strings.removePrefix "${config.home.homeDirectory}/" config.xdg.configHome; + configPath = "home-files/${relativeXdgConfigHome}/${purgedRulesConfigName}"; + in + '' + if [[ -v oldGenPath && -f $oldGenPath/${configPath} ]] && + diff -q $oldGenPath/${configPath} $newGenPath/${configPath} &>/dev/null; then + verboseEcho "Purge old tmpfiles" + run ${pkgs.systemd}/bin/systemd-tmpfiles --user --purge ''${DRY_RUN:+--dry-run} $oldGenPath/${configPath} + fi + '' ); - }; + }) - config = lib.mkIf (cfg.settings != { }) { - assertions = [ - (lib.hm.assertions.assertPlatform "systemd.user.tmpfiles" pkgs lib.platforms.linux) - ]; + (lib.mkIf (cfg.settings != { }) { + assertions = [ + (lib.hm.assertions.assertPlatform "systemd.user.tmpfiles" pkgs lib.platforms.linux) + ]; - warnings = lib.flatten ( - lib.mapAttrsToListRecursive ( - path: value: - lib.optional - (lib.last path == "argument" && lib.match ''.*\\([nrt]|x[0-9A-Fa-f]{2}).*'' value != null) - '' - The '${lib.showAttrPath (modulePrefix ++ path)}' option - appears to contain escape sequences, which will be escaped again. - Unescape them if this is not intended. The assigned string is: - "${value}" - '' - ) cfg.settings - ); + warnings = lib.flatten ( + lib.mapAttrsToListRecursive ( + path: value: + lib.optional + (lib.last path == "argument" && lib.match ''.*\\([nrt]|x[0-9A-Fa-f]{2}).*'' value != null) + '' + The '${lib.showAttrPath (modulePrefix ++ path)}' option + appears to contain escape sequences, which will be escaped again. + Unescape them if this is not intended. The assigned string is: + "${value}" + '' + ) cfg.settings + ); - xdg.configFile = { - "systemd/user/basic.target.wants/systemd-tmpfiles-setup.service".source = - "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-setup.service"; - "systemd/user/systemd-tmpfiles-setup.service".source = - "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-setup.service"; - "systemd/user/timers.target.wants/systemd-tmpfiles-clean.timer".source = - "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-clean.timer"; - "systemd/user/systemd-tmpfiles-clean.service".source = - "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-clean.service"; - } - // lib.mapAttrs' ( - name: rules: lib.nameValuePair (mkFileName name) (mkConfigFile name rules) - ) cfg.settings; - }; + xdg.configFile = { + "systemd/user/basic.target.wants/systemd-tmpfiles-setup.service".source = + "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-setup.service"; + "systemd/user/systemd-tmpfiles-setup.service".source = + "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-setup.service"; + "systemd/user/timers.target.wants/systemd-tmpfiles-clean.timer".source = + "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-clean.timer"; + "systemd/user/systemd-tmpfiles-clean.service".source = + "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-clean.service"; + } + // lib.mapAttrs' ( + name: config: lib.nameValuePair (mkFileName name) (mkConfigFile name config.rules { }) + ) nonPurgedConfigs + // lib.optionalAttrs (purgedConfigs != { }) { + ${mkFileName purgedRulesConfigName} = + let + purgedConfigsMerged = lib.foldl' lib.recursiveUpdate { } (lib.attrValues purgedConfigs); + in + mkConfigFile purgedRulesConfigName purgedConfigsMerged.rules { suffix = [ ]; }; + }; + }) + + ]; } diff --git a/modules/programs/glab.nix b/modules/programs/glab.nix index 1aac3c004..16ff2a86f 100644 --- a/modules/programs/glab.nix +++ b/modules/programs/glab.nix @@ -37,7 +37,7 @@ in # Use `systemd-tmpfiles` since glab requires its configuration file to have # mode 0600. systemd.user.tmpfiles.settings.glab = lib.mkIf (cfg.settings != { }) { - "${config.xdg.configHome}/glab-cli/config.yml" = { + rules."${config.xdg.configHome}/glab-cli/config.yml" = { "C+".argument = yaml.generate "glab-config" cfg.settings; z.mode = "0600"; }; diff --git a/tests/integration/standalone/rclone/sops-nix.nix b/tests/integration/standalone/rclone/sops-nix.nix index a7baf0614..fc676505e 100644 --- a/tests/integration/standalone/rclone/sops-nix.nix +++ b/tests/integration/standalone/rclone/sops-nix.nix @@ -28,7 +28,7 @@ in uid = 1000; }; - systemd.tmpfiles.settings.age."/home/alice/age-key".f = { + systemd.tmpfiles.settings.age.rules."/home/alice/age-key".f = { mode = "400"; user = "alice"; group = "users"; diff --git a/tests/modules/misc/tmpfiles/basic-rules.nix b/tests/modules/misc/tmpfiles/basic-rules.nix index 30a451737..68b00f87b 100644 --- a/tests/modules/misc/tmpfiles/basic-rules.nix +++ b/tests/modules/misc/tmpfiles/basic-rules.nix @@ -2,8 +2,8 @@ imports = [ ./common-stubs.nix ]; systemd.user.tmpfiles.settings = { - cache."%C".d.age = "4 weeks"; - myTool."%h/.config/myTool.conf"."f+" = { + cache.rules."%C".d.age = "4 weeks"; + myTool.rules."%h/.config/myTool.conf"."f+" = { mode = "0644"; user = "alice"; group = "users"; @@ -12,6 +12,8 @@ }; nmt.script = '' + assertPathNotExists home-files/.config/user-tmpfiles.d/home-manager-purge-on-change.conf + cacheRulesFile=home-files/.config/user-tmpfiles.d/home-manager-cache.conf assertFileExists $cacheRulesFile assertFileRegex $cacheRulesFile "^'d' '%C' '-' '-' '-' '4 weeks' $" diff --git a/tests/modules/misc/tmpfiles/default.nix b/tests/modules/misc/tmpfiles/default.nix index 1caa5fc7e..608796fe9 100644 --- a/tests/modules/misc/tmpfiles/default.nix +++ b/tests/modules/misc/tmpfiles/default.nix @@ -1,6 +1,7 @@ { tmpfiles-no-rules = ./no-rules.nix; tmpfiles-basic-rules = ./basic-rules.nix; + tmpfiles-rules-with-purging = ./rules-with-purging.nix; tmpfiles-escaped-argument-warning = ./escaped-argument-warning.nix; } diff --git a/tests/modules/misc/tmpfiles/escaped-argument-warning.nix b/tests/modules/misc/tmpfiles/escaped-argument-warning.nix index 9284de3c8..0a7441b7e 100644 --- a/tests/modules/misc/tmpfiles/escaped-argument-warning.nix +++ b/tests/modules/misc/tmpfiles/escaped-argument-warning.nix @@ -1,11 +1,11 @@ { imports = [ ./common-stubs.nix ]; - systemd.user.tmpfiles.settings.foo.path.f.argument = "my\\x20unescaped\\x20config"; + systemd.user.tmpfiles.settings.foo.rules.path.f.argument = "my\\x20unescaped\\x20config"; test.asserts.warnings.expected = [ '' - The 'systemd.user.tmpfiles.settings.foo.path.f.argument' option + The 'systemd.user.tmpfiles.settings.foo.rules.path.f.argument' option appears to contain escape sequences, which will be escaped again. Unescape them if this is not intended. The assigned string is: "my\x20unescaped\x20config" diff --git a/tests/modules/misc/tmpfiles/rules-with-purging.nix b/tests/modules/misc/tmpfiles/rules-with-purging.nix new file mode 100644 index 000000000..a453bfcb6 --- /dev/null +++ b/tests/modules/misc/tmpfiles/rules-with-purging.nix @@ -0,0 +1,28 @@ +{ + imports = [ ./common-stubs.nix ]; + + systemd.user.tmpfiles.settings = { + cache.rules."%C".d.age = "4 weeks"; + myTool = { + rules = { + "%h/.config/myTool.conf"."f+".argument = "my_config"; + "%h/.config/myToolPurged.conf"."f+$".argument = "my_config_purged"; + }; + purgeOnChange = true; + }; + }; + + nmt.script = '' + cacheRulesFile=home-files/.config/user-tmpfiles.d/home-manager-cache.conf + assertFileExists $cacheRulesFile + assertFileRegex $cacheRulesFile "^'d' '%C' '-' '-' '-' '4 weeks' $" + + assertPathNotExists home-files/.config/user-tmpfiles.d/home-manager-myTool.conf + myToolRulesFile=home-files/.config/user-tmpfiles.d/home-manager-purge-on-change.conf + assertFileExists $myToolRulesFile + assertFileRegex $myToolRulesFile \ + "^'f+' '%h/.config/myTool.conf' '-' '-' '-' '-' my_config$" + assertFileRegex $myToolRulesFile \ + "^'f+$' '%h/.config/myToolPurged.conf' '-' '-' '-' '-' my_config_purged$" + ''; +}