1 # nix-profile-gc: More gently remove old profiles
2 # Copyright (C) 2022 Scott Worley <scottworley@scottworley.com>
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation, version 3.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program. If not, see <https://www.gnu.org/licenses/>.
16 { lib, config, pkgs, ... }:
18 inherit (lib) escapeShellArg;
19 cfg = config.nix.profile-gc;
20 parse-duration = duration: pkgs.runCommand "duration" { buildInputs = with pkgs; [ systemd ]; } ''
22 parsed=$(systemd-analyze timespan ${escapeShellArg duration} | awk '$1 == "μs:" { print $2 }')
23 echo "$parsed" > "$out"
25 gather_home_profiles = lib.optionalString cfg.manageHomeProfiles ''
26 while read -r home_dir;do
27 home_profile_dir="$home_dir/.local/state/nix/profiles"
28 if [[ -d "$home_profile_dir" ]];then
29 home_profile_dirs+=( "$home_profile_dir" )
31 done < <(${pkgs.coreutils}/bin/cut -d: -f6 /etc/passwd | ${pkgs.coreutils}/bin/sort -u)
36 enable = lib.mkEnableOption "Automatic profile garbage collection";
37 manageHomeProfiles = lib.mkOption {
38 description = "Manage profiles in users' $HOME/.local/state/nix/profiles/";
39 type = lib.types.bool;
40 default = false; # Will flip to true later
42 dryRun = lib.mkOption {
43 description = "Say what would have been deleted rather than actually deleting profiles";
44 type = lib.types.bool;
47 keepLast = lib.mkOption {
49 Number of recent profiles to keep.
50 This control is similar to nix-env --delete-generation's +5 syntax.
52 type = lib.types.ints.unsigned;
55 keepLastActive = lib.mkOption {
56 description = "Number of recent active profiles to keep";
57 type = lib.types.ints.unsigned;
60 keepLastActiveSystem = lib.mkOption {
61 description = "Number of recent active system profiles to keep";
62 type = lib.types.ints.unsigned;
65 keepLastActiveBoot = lib.mkOption {
66 description = "Number of recent active boot profiles to keep";
67 type = lib.types.ints.unsigned;
70 activeThreshold = lib.mkOption {
72 A system profile that is active (or is either /run/current-system or /run/booted-system)
73 for at least this long (of powered-on machine time) is considered 'active' for
74 the purpose of evaluating the keepLastActive number of profiles. This mechanism is
75 intended to preserve profiles that are in some sense stable, that have served us well,
76 so they don't immediately become gc-elligible when a system hasn't been updated in
77 awhile (so keepLatest won't protect them) generates a bunch of broken profiles (so
78 keepLast won't protect them) while trying to get up to date.
80 This threshold is approximate, see activeMeasurementGranularity.
81 Do not set less than activeMeasurementGranularity!
83 # We admonish the user "Do not set less than activeMeasurementGranularity!" and check
84 # it at runtime rather than verifying this with an assertion at evaluation time because
85 # parsing these durations at evaluation-time requires import-from-derivation, which we
90 activeMeasurementGranularity = lib.mkOption {
92 How often to make a note of the currently-active profiles. This is the useful
93 granularity and minimum value of activeThreshold.
97 keepLatest = lib.mkOption {
99 Keep all profiles younger than this duration (systemd.time format).
100 This control is similar to nix-collect-garbage's --delete-older-than.
102 type = lib.types.str;
103 default = "6 months";
105 keepFuture = lib.mkOption {
106 description = "Keep profiles 'ahead' of the current profile (happens after rollback)";
107 type = lib.types.bool;
110 logdir = lib.mkOption {
111 description = "Where to keep liveness logs";
112 type = lib.types.str;
113 default = "/var/log/profile-gc";
117 config = lib.mkIf cfg.enable {
120 assertion = cfg.enable -> config.nix.gc.automatic;
121 message = ''nix.profile-gc.enable requires nix.gc.automatic'';
124 systemd.services.nix-gc.serviceConfig.ExecStartPre = pkgs.writeShellScript "nix-profile-gc" ''
127 if [[ ! -e ${cfg.logdir}/active-system
128 || ! -e ${cfg.logdir}/active-boot
129 || ! -e ${cfg.logdir}/active-profiles ]]
131 echo "Liveness logs not found. Not doing any profile garbage collection." >&2
135 alive_threshold="$(< ${parse-duration cfg.activeThreshold})"
136 alive_loginterval="$(< ${parse-duration cfg.activeMeasurementGranularity})"
137 if (( alive_threshold < alive_loginterval ));then
138 echo "Liveness threshold is too low. Not doing any profile garbage collection." >&2
143 ${pkgs.coreutils}/bin/tac "$1" |
144 ${pkgs.gawk}/bin/awk \
147 --assign threshold="$alive_threshold" \
148 --assign loginterval="$alive_loginterval" \
152 if (++count[val] == int(threshold/loginterval)) {
164 echo "Keeping the last $3 $2 entries from $1:" >&2
165 ${pkgs.gawk}/bin/gawk '{ print " " $0 }' >&2 )
168 declare -A active_targets
169 while read -r target;do
170 active_targets[$target]=1
172 verbose_topn ${cfg.logdir}/active-system "" ${escapeShellArg cfg.keepLastActiveSystem}
173 verbose_topn ${cfg.logdir}/active-boot "" ${escapeShellArg cfg.keepLastActiveBoot }
177 ${gather_home_profiles}
179 now=$(${pkgs.coreutils}/bin/date +%s)
180 age_threshold="$(< ${parse-duration cfg.keepLatest})"
181 while read -r profile;do
182 echo "Contemplating profiles for $profile:" >&2
185 while read -r pname;do
187 done < <(verbose_topn ${cfg.logdir}/active-profiles "$profile" ${escapeShellArg cfg.keepLastActive})
188 current=$(${pkgs.coreutils}/bin/readlink "$profile")
189 currentgen=''${current%-link}
190 currentgen=''${currentgen##*-}
191 for p in "$profile"-*-link;do
194 if [[ "$p" != "$profile-$pgen-link" ]];then
195 echo "(Disregarding unrelated profile $p)" >&2
198 pname=$(${pkgs.coreutils}/bin/basename "$p")
199 if [[ "$pname" == "$current" ]];then
200 echo "Keeeping current profile $p" >&2
203 if [[ "''${active_targets[$(${pkgs.coreutils}/bin/readlink "$p")]:-}" ]];then
204 echo "Keeeping active system/boot profile $p" >&2
207 if [[ "''${active[$pname]:-}" ]];then
208 echo "Keeeping active profile $p" >&2
211 if (( (now - "$(${pkgs.findutils}/bin/find "$p" -printf %Ts)") < age_threshold/1000000 ));then
212 echo "Keeeping young profile $p" >&2
215 ${lib.optionalString cfg.keepFuture ''
216 if (( pgen > currentgen ));then
217 echo "Keeeping future profile $p" >&2
221 ${if cfg.dryRun then ''
222 echo "Would remove profile $p" >&2
224 echo "Removing profile $p" >&2
228 done < <(${pkgs.findutils}/bin/find "''${NIX_STATE_DIR:-/nix/var/nix}/profiles/" "''${home_profile_dirs[@]}" -type l -not -name '*[0-9]-link')
230 systemd.timers.profile-gc-log-active = {
231 wantedBy = [ "timers.target" ];
232 timerConfig.OnActiveSec = cfg.activeMeasurementGranularity;
233 timerConfig.OnUnitActiveSec = cfg.activeMeasurementGranularity;
235 systemd.services.profile-gc-log-active = {
237 "Log the active profiles for gc collection policy evaluation";
238 serviceConfig.Type = "oneshot";
241 ${gather_home_profiles}
243 ${pkgs.coreutils}/bin/mkdir -p ${cfg.logdir}
244 ${pkgs.coreutils}/bin/readlink /run/current-system >> ${cfg.logdir}/active-system
245 ${pkgs.coreutils}/bin/readlink /run/booted-system >> ${cfg.logdir}/active-boot
246 ${pkgs.findutils}/bin/find ''${NIX_STATE_DIR:-/nix/var/nix}/profiles/ "''${home_profile_dirs[@]}" \
247 -type l -not -name '*[0-9]-link' \
248 -exec ${pkgs.stdenv.shell} -c '
251 ${pkgs.coreutils}/bin/readlink "$f"
253 >> ${cfg.logdir}/active-profiles