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 = ''
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 monitorHomeProfiles = lib.mkOption {
38 description = "Monitor profiles in users' $HOME/.local/state/nix/profiles/";
39 type = lib.types.bool;
42 manageHomeProfiles = lib.mkOption {
43 description = "Manage profiles in users' $HOME/.local/state/nix/profiles/";
44 type = lib.types.bool;
45 default = false; # Will flip to true later
47 dryRun = lib.mkOption {
48 description = "Say what would have been deleted rather than actually deleting profiles";
49 type = lib.types.bool;
52 keepLast = lib.mkOption {
54 Number of recent profiles to keep.
55 This control is similar to nix-env --delete-generation's +5 syntax.
57 type = lib.types.ints.unsigned;
60 keepLastActive = lib.mkOption {
61 description = "Number of recent active profiles to keep";
62 type = lib.types.ints.unsigned;
65 keepLastActiveSystem = lib.mkOption {
66 description = "Number of recent active system profiles to keep";
67 type = lib.types.ints.unsigned;
70 keepLastActiveBoot = lib.mkOption {
71 description = "Number of recent active boot profiles to keep";
72 type = lib.types.ints.unsigned;
75 activeThreshold = lib.mkOption {
77 A system profile that is active (or is either /run/current-system or /run/booted-system)
78 for at least this long (of powered-on machine time) is considered 'active' for
79 the purpose of evaluating the keepLastActive number of profiles. This mechanism is
80 intended to preserve profiles that are in some sense stable, that have served us well,
81 so they don't immediately become gc-elligible when a system hasn't been updated in
82 awhile (so keepLatest won't protect them) generates a bunch of broken profiles (so
83 keepLast won't protect them) while trying to get up to date.
85 This threshold is approximate, see activeMeasurementGranularity.
86 Do not set less than activeMeasurementGranularity!
88 # We admonish the user "Do not set less than activeMeasurementGranularity!" and check
89 # it at runtime rather than verifying this with an assertion at evaluation time because
90 # parsing these durations at evaluation-time requires import-from-derivation, which we
95 activeMeasurementGranularity = lib.mkOption {
97 How often to make a note of the currently-active profiles. This is the useful
98 granularity and minimum value of activeThreshold.
102 keepLatest = lib.mkOption {
104 Keep all profiles younger than this duration (systemd.time format).
105 This control is similar to nix-collect-garbage's --delete-older-than.
107 type = lib.types.str;
108 default = "6 months";
110 keepFuture = lib.mkOption {
111 description = "Keep profiles 'ahead' of the current profile (happens after rollback)";
112 type = lib.types.bool;
115 logdir = lib.mkOption {
116 description = "Where to keep liveness logs";
117 type = lib.types.str;
118 default = "/var/log/profile-gc";
122 config = lib.mkIf cfg.enable {
125 assertion = cfg.enable -> config.nix.gc.automatic;
126 message = ''nix.profile-gc.enable requires nix.gc.automatic'';
129 assertion = cfg.manageHomeProfiles -> cfg.monitorHomeProfiles;
130 message = ''nix.profile-gc.manageHomeProfiles requires nix.profile-gc.monitorHomeProfiles'';
133 systemd.services.nix-gc.serviceConfig.ExecStartPre = pkgs.writeShellScript "nix-profile-gc" ''
136 if [[ ! -e ${cfg.logdir}/active-system
137 || ! -e ${cfg.logdir}/active-boot
138 || ! -e ${cfg.logdir}/active-profiles ]]
140 echo "Liveness logs not found. Not doing any profile garbage collection." >&2
144 alive_threshold="$(< ${parse-duration cfg.activeThreshold})"
145 alive_loginterval="$(< ${parse-duration cfg.activeMeasurementGranularity})"
146 if (( alive_threshold < alive_loginterval ));then
147 echo "Liveness threshold is too low. Not doing any profile garbage collection." >&2
152 ${pkgs.coreutils}/bin/tac "$1" |
153 ${pkgs.gawk}/bin/awk \
156 --assign threshold="$alive_threshold" \
157 --assign loginterval="$alive_loginterval" \
161 if (++count[val] == int(threshold/loginterval)) {
173 echo "Keeping the last $3 $2 entries from $1:" >&2
174 ${pkgs.gawk}/bin/gawk '{ print " " $0 }' >&2 )
177 declare -A active_targets
178 while read -r target;do
179 active_targets[$target]=1
181 verbose_topn ${cfg.logdir}/active-system "" ${escapeShellArg cfg.keepLastActiveSystem}
182 verbose_topn ${cfg.logdir}/active-boot "" ${escapeShellArg cfg.keepLastActiveBoot }
186 ${lib.optionalString cfg.manageHomeProfiles gather_home_profiles}
188 now=$(${pkgs.coreutils}/bin/date +%s)
189 age_threshold="$(< ${parse-duration cfg.keepLatest})"
190 while read -r profile;do
191 echo "Contemplating profiles for $profile:" >&2
194 while read -r pname;do
196 done < <(verbose_topn ${cfg.logdir}/active-profiles "$profile" ${escapeShellArg cfg.keepLastActive})
197 current=$(${pkgs.coreutils}/bin/readlink "$profile")
198 currentgen=''${current%-link}
199 currentgen=''${currentgen##*-}
200 for p in "$profile"-*-link;do
203 if [[ "$p" != "$profile-$pgen-link" ]];then
204 echo "(Disregarding unrelated profile $p)" >&2
207 pname=$(${pkgs.coreutils}/bin/basename "$p")
208 if [[ "$pname" == "$current" ]];then
209 echo "Keeeping current profile $p" >&2
212 if [[ "''${active_targets[$(${pkgs.coreutils}/bin/readlink "$p")]:-}" ]];then
213 echo "Keeeping active system/boot profile $p" >&2
216 if [[ "''${active[$pname]:-}" ]];then
217 echo "Keeeping active profile $p" >&2
220 if (( (now - "$(${pkgs.findutils}/bin/find "$p" -printf %Ts)") < age_threshold/1000000 ));then
221 echo "Keeeping young profile $p" >&2
224 ${lib.optionalString cfg.keepFuture ''
225 if (( pgen > currentgen ));then
226 echo "Keeeping future profile $p" >&2
230 ${if cfg.dryRun then ''
231 echo "Would remove profile $p" >&2
233 echo "Removing profile $p" >&2
237 done < <(${pkgs.findutils}/bin/find "''${NIX_STATE_DIR:-/nix/var/nix}/profiles/" "''${home_profile_dirs[@]}" -type l -not -name '*[0-9]-link')
239 systemd.timers.profile-gc-log-active = {
240 wantedBy = [ "timers.target" ];
241 timerConfig.OnActiveSec = cfg.activeMeasurementGranularity;
242 timerConfig.OnUnitActiveSec = cfg.activeMeasurementGranularity;
244 systemd.services.profile-gc-log-active = {
246 "Log the active profiles for gc collection policy evaluation";
247 serviceConfig.Type = "oneshot";
250 ${lib.optionalString cfg.monitorHomeProfiles gather_home_profiles}
252 ${pkgs.coreutils}/bin/mkdir -p ${cfg.logdir}
253 ${pkgs.coreutils}/bin/readlink /run/current-system >> ${cfg.logdir}/active-system
254 ${pkgs.coreutils}/bin/readlink /run/booted-system >> ${cfg.logdir}/active-boot
255 ${pkgs.findutils}/bin/find ''${NIX_STATE_DIR:-/nix/var/nix}/profiles/ "''${home_profile_dirs[@]}" \
256 -type l -not -name '*[0-9]-link' \
257 -exec ${pkgs.stdenv.shell} -c '
260 ${pkgs.coreutils}/bin/readlink "$f"
262 >> ${cfg.logdir}/active-profiles