]> git.scottworley.com Git - nix-profile-gc/blob - modules/profile-gc.nix
Manage profiles in users' $HOME/.local/state/nix/profiles/
[nix-profile-gc] / modules / profile-gc.nix
1 # nix-profile-gc: More gently remove old profiles
2 # Copyright (C) 2022 Scott Worley <scottworley@scottworley.com>
3 #
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.
7 #
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.
12 #
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/>.
15
16 { lib, config, pkgs, ... }:
17 let
18 inherit (lib) escapeShellArg;
19 cfg = config.nix.profile-gc;
20 parse-duration = duration: pkgs.runCommand "duration" { buildInputs = with pkgs; [ systemd ]; } ''
21 set -euo pipefail
22 parsed=$(systemd-analyze timespan ${escapeShellArg duration} | awk '$1 == "μs:" { print $2 }')
23 echo "$parsed" > "$out"
24 '';
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" )
30 fi
31 done < <(${pkgs.coreutils}/bin/cut -d: -f6 /etc/passwd | ${pkgs.coreutils}/bin/sort -u)
32 '';
33 in {
34 options = {
35 nix.profile-gc = {
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
41 };
42 dryRun = lib.mkOption {
43 description = "Say what would have been deleted rather than actually deleting profiles";
44 type = lib.types.bool;
45 default = false;
46 };
47 keepLast = lib.mkOption {
48 description = ''
49 Number of recent profiles to keep.
50 This control is similar to nix-env --delete-generation's +5 syntax.
51 '';
52 type = lib.types.ints.unsigned;
53 default = 5;
54 };
55 keepLastActive = lib.mkOption {
56 description = "Number of recent active profiles to keep";
57 type = lib.types.ints.unsigned;
58 default = 5;
59 };
60 keepLastActiveSystem = lib.mkOption {
61 description = "Number of recent active system profiles to keep";
62 type = lib.types.ints.unsigned;
63 default = 5;
64 };
65 keepLastActiveBoot = lib.mkOption {
66 description = "Number of recent active boot profiles to keep";
67 type = lib.types.ints.unsigned;
68 default = 3;
69 };
70 activeThreshold = lib.mkOption {
71 description = ''
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.
79
80 This threshold is approximate, see activeMeasurementGranularity.
81 Do not set less than activeMeasurementGranularity!
82 '';
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
86 # want to avoid. :(
87 type = lib.types.str;
88 default = "5 days";
89 };
90 activeMeasurementGranularity = lib.mkOption {
91 description = ''
92 How often to make a note of the currently-active profiles. This is the useful
93 granularity and minimum value of activeThreshold.
94 '';
95 default = "1 hour";
96 };
97 keepLatest = lib.mkOption {
98 description = ''
99 Keep all profiles younger than this duration (systemd.time format).
100 This control is similar to nix-collect-garbage's --delete-older-than.
101 '';
102 type = lib.types.str;
103 default = "6 months";
104 };
105 keepFuture = lib.mkOption {
106 description = "Keep profiles 'ahead' of the current profile (happens after rollback)";
107 type = lib.types.bool;
108 default = true;
109 };
110 logdir = lib.mkOption {
111 description = "Where to keep liveness logs";
112 type = lib.types.str;
113 default = "/var/log/profile-gc";
114 };
115 };
116 };
117 config = lib.mkIf cfg.enable {
118 assertions = [
119 {
120 assertion = cfg.enable -> config.nix.gc.automatic;
121 message = ''nix.profile-gc.enable requires nix.gc.automatic'';
122 }
123 ];
124 systemd.services.nix-gc.serviceConfig.ExecStartPre = pkgs.writeShellScript "nix-profile-gc" ''
125 set -euo pipefail
126
127 if [[ ! -e ${cfg.logdir}/active-system
128 || ! -e ${cfg.logdir}/active-boot
129 || ! -e ${cfg.logdir}/active-profiles ]]
130 then
131 echo "Liveness logs not found. Not doing any profile garbage collection." >&2
132 exit 0
133 fi
134
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
139 exit 0
140 fi
141
142 topn() {
143 ${pkgs.coreutils}/bin/tac "$1" |
144 ${pkgs.gawk}/bin/awk \
145 --assign key="$2" \
146 --assign n="$3" \
147 --assign threshold="$alive_threshold" \
148 --assign loginterval="$alive_loginterval" \
149 '
150 !key || $1 == key {
151 val = key ? $2 : $1
152 if (++count[val] == int(threshold/loginterval)) {
153 print val
154 if (++found == n) {
155 exit 0
156 }
157 }
158 }
159 '
160 }
161
162 verbose_topn() {
163 topn "$@" | tee >(
164 echo "Keeping the last $3 $2 entries from $1:" >&2
165 ${pkgs.gawk}/bin/gawk '{ print " " $0 }' >&2 )
166 }
167
168 declare -A active_targets
169 while read -r target;do
170 active_targets[$target]=1
171 done < <(
172 verbose_topn ${cfg.logdir}/active-system "" ${escapeShellArg cfg.keepLastActiveSystem}
173 verbose_topn ${cfg.logdir}/active-boot "" ${escapeShellArg cfg.keepLastActiveBoot }
174 )
175
176 home_profile_dirs=()
177 ${gather_home_profiles}
178
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
183 unset active
184 declare -A active
185 while read -r pname;do
186 active[$pname]=1
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
192 pgen=''${p%-link}
193 pgen=''${pgen##*-}
194 if [[ "$p" != "$profile-$pgen-link" ]];then
195 echo "(Disregarding unrelated profile $p)" >&2
196 continue
197 fi
198 pname=$(${pkgs.coreutils}/bin/basename "$p")
199 if [[ "$pname" == "$current" ]];then
200 echo "Keeeping current profile $p" >&2
201 continue
202 fi
203 if [[ "''${active_targets[$(${pkgs.coreutils}/bin/readlink "$p")]:-}" ]];then
204 echo "Keeeping active system/boot profile $p" >&2
205 continue
206 fi
207 if [[ "''${active[$pname]:-}" ]];then
208 echo "Keeeping active profile $p" >&2
209 continue
210 fi
211 if (( (now - "$(${pkgs.findutils}/bin/find "$p" -printf %Ts)") < age_threshold/1000000 ));then
212 echo "Keeeping young profile $p" >&2
213 continue
214 fi
215 ${lib.optionalString cfg.keepFuture ''
216 if (( pgen > currentgen ));then
217 echo "Keeeping future profile $p" >&2
218 continue
219 fi
220 ''}
221 ${if cfg.dryRun then ''
222 echo "Would remove profile $p" >&2
223 '' else ''
224 echo "Removing profile $p" >&2
225 rm "$p"
226 ''}
227 done
228 done < <(${pkgs.findutils}/bin/find "''${NIX_STATE_DIR:-/nix/var/nix}/profiles/" "''${home_profile_dirs[@]}" -type l -not -name '*[0-9]-link')
229 '';
230 systemd.timers.profile-gc-log-active = {
231 wantedBy = [ "timers.target" ];
232 timerConfig.OnActiveSec = cfg.activeMeasurementGranularity;
233 timerConfig.OnUnitActiveSec = cfg.activeMeasurementGranularity;
234 };
235 systemd.services.profile-gc-log-active = {
236 description =
237 "Log the active profiles for gc collection policy evaluation";
238 serviceConfig.Type = "oneshot";
239 script = ''
240 home_profile_dirs=()
241 ${gather_home_profiles}
242
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 '
249 for f;do
250 echo -n "$f "
251 ${pkgs.coreutils}/bin/readlink "$f"
252 done' - {} + \
253 >> ${cfg.logdir}/active-profiles
254 '';
255 };
256 };
257 }