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"
28 enable = lib.mkEnableOption "Automatic profile garbage collection";
29 dryRun = lib.mkOption {
30 description = "Say what would have been deleted rather than actually deleting profiles";
31 type = lib.types.bool;
34 keepLast = lib.mkOption {
36 Number of recent profiles to keep.
37 This control is similar to nix-env --delete-generation's +5 syntax.
39 type = lib.types.ints.unsigned;
42 keepLastActive = lib.mkOption {
43 description = "Number of recent active profiles to keep";
44 type = lib.types.ints.unsigned;
47 keepLastActiveSystem = lib.mkOption {
48 description = "Number of recent active system profiles to keep";
49 type = lib.types.ints.unsigned;
52 keepLastActiveBoot = lib.mkOption {
53 description = "Number of recent active boot profiles to keep";
54 type = lib.types.ints.unsigned;
57 activeThreshold = lib.mkOption {
59 A system profile that is active (or is either /run/current-system or /run/booted-system)
60 for at least this long (of powered-on machine time) is considered 'active' for
61 the purpose of evaluating the keepLastActive number of profiles. This mechanism is
62 intended to preserve profiles that are in some sense stable, that have served us well,
63 so they don't immediately become gc-elligible when a system hasn't been updated in
64 awhile (so keepLatest won't protect them) generates a bunch of broken profiles (so
65 keepLast won't protect them) while trying to get up to date.
67 This threshold is approximate, see activeMeasurementGranularity.
68 Do not set less than activeMeasurementGranularity!
70 # We admonish the user "Do not set less than activeMeasurementGranularity!" and check
71 # it at runtime rather than verifying this with an assertion at evaluation time because
72 # parsing these durations at evaluation-time requires import-from-derivation, which we
77 activeMeasurementGranularity = lib.mkOption {
79 How often to make a note of the currently-active profiles. This is the useful
80 granularity and minimum value of activeThreshold.
84 keepLatest = lib.mkOption {
86 Keep all profiles younger than this duration (systemd.time format).
87 This control is similar to nix-collect-garbage's --delete-older-than.
92 keepFuture = lib.mkOption {
93 description = "Keep profiles 'ahead' of the current profile (happens after rollback)";
94 type = lib.types.bool;
97 logdir = lib.mkOption {
98 description = "Where to keep liveness logs";
100 default = "/var/log/profile-gc";
104 config = lib.mkIf cfg.enable {
107 assertion = cfg.enable -> config.nix.gc.automatic;
108 message = ''nix.profile-gc.enable requires nix.gc.automatic'';
111 systemd.services.nix-gc.serviceConfig.ExecStartPre = pkgs.writeShellScript "nix-profile-gc" ''
114 if [[ ! -e ${cfg.logdir}/active-system
115 || ! -e ${cfg.logdir}/active-boot
116 || ! -e ${cfg.logdir}/active-profiles ]]
118 echo "Liveness logs not found. Not doing any profile garbage collection." >&2
122 alive_threshold="$(< ${parse-duration cfg.activeThreshold})"
123 alive_loginterval="$(< ${parse-duration cfg.activeMeasurementGranularity})"
124 if (( alive_threshold < alive_loginterval ));then
125 echo "Liveness threshold is too low. Not doing any profile garbage collection." >&2
130 ${pkgs.coreutils}/bin/tac "$1" |
131 ${pkgs.gawk}/bin/awk \
134 --assign threshold="$alive_threshold" \
135 --assign loginterval="$alive_loginterval" \
139 if (++count[val] == int(threshold/loginterval)) {
151 echo "Keeping the last $3 $2 entries from $1:" >&2
152 ${pkgs.gawk}/bin/gawk '{ print " " $0 }' >&2 )
155 declare -A active_targets
157 active_targets[$target]=1
159 verbose_topn ${cfg.logdir}/active-system "" ${escapeShellArg cfg.keepLastActiveSystem}
160 verbose_topn ${cfg.logdir}/active-boot "" ${escapeShellArg cfg.keepLastActiveBoot }
163 now=$(${pkgs.coreutils}/bin/date +%s)
164 age_threshold="$(< ${parse-duration cfg.keepLatest})"
165 while read profile;do
166 echo "Contemplating profiles for $profile:" >&2
171 done < <(verbose_topn ${cfg.logdir}/active-profiles "$profile" ${escapeShellArg cfg.keepLastActive})
172 current=$(${pkgs.coreutils}/bin/readlink "$profile")
173 currentgen=''${current%-link}
174 currentgen=''${currentgen##*-}
175 for p in "$profile"-*-link;do
178 if [[ "$p" != "$profile-$pgen-link" ]];then
179 echo "(Disregarding unrelated profile $p)" >&2
182 if [[ "$p" == "$current" ]];then
183 echo "Keeeping current profile $p" >&2
186 if [[ "''${active_targets[$(${pkgs.coreutils}/bin/readlink "$p")]:-}" ]];then
187 echo "Keeeping active system/boot profile $p" >&2
190 if [[ "''${active[$p]:-}" ]];then
191 echo "Keeeping active profile $p" >&2
194 if (( (now - "$(${pkgs.findutils}/bin/find "$p" -printf %Ts)") < age_threshold/1000000 ));then
195 echo "Keeeping young profile $p" >&2
198 ${lib.optionalString cfg.keepFuture ''
199 if (( pgen > currentgen ));then
200 echo "Keeeping future profile $p" >&2
204 ${if cfg.dryRun then ''
205 echo "Would remove profile $p" >&2
207 echo "Removing profile $p" >&2
211 done < <(${pkgs.findutils}/bin/find ''${NIX_STATE_DIR:-/nix/var/nix}/profiles/ -type l -not -name '*[0-9]-link')
213 systemd.timers.profile-gc-log-active = {
214 wantedBy = [ "timers.target" ];
215 timerConfig.OnActiveSec = cfg.activeMeasurementGranularity;
216 timerConfig.OnUnitActiveSec = cfg.activeMeasurementGranularity;
218 systemd.services.profile-gc-log-active = {
220 "Log the active profiles for gc collection policy evaluation";
221 serviceConfig.Type = "oneshot";
223 ${pkgs.coreutils}/bin/mkdir -p ${cfg.logdir}
224 ${pkgs.coreutils}/bin/readlink /run/current-system >> ${cfg.logdir}/active-system
225 ${pkgs.coreutils}/bin/readlink /run/booted-system >> ${cfg.logdir}/active-boot
226 ${pkgs.findutils}/bin/find ''${NIX_STATE_DIR:-/nix/var/nix}/profiles/ \
227 -type l -not -name '*[0-9]-link' \
228 -exec ${pkgs.stdenv.shell} -c '
231 ${pkgs.coreutils}/bin/readlink "$f"
233 >> ${cfg.logdir}/active-profiles