]> git.scottworley.com Git - nix-profile-gc/blob - modules/profile-gc.nix
Separate monitorHomeProfiles option that defaults to true
[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 = ''
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 monitorHomeProfiles = lib.mkOption {
38 description = "Monitor profiles in users' $HOME/.local/state/nix/profiles/";
39 type = lib.types.bool;
40 default = true;
41 };
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
46 };
47 dryRun = lib.mkOption {
48 description = "Say what would have been deleted rather than actually deleting profiles";
49 type = lib.types.bool;
50 default = false;
51 };
52 keepLast = lib.mkOption {
53 description = ''
54 Number of recent profiles to keep.
55 This control is similar to nix-env --delete-generation's +5 syntax.
56 '';
57 type = lib.types.ints.unsigned;
58 default = 5;
59 };
60 keepLastActive = lib.mkOption {
61 description = "Number of recent active profiles to keep";
62 type = lib.types.ints.unsigned;
63 default = 5;
64 };
65 keepLastActiveSystem = lib.mkOption {
66 description = "Number of recent active system profiles to keep";
67 type = lib.types.ints.unsigned;
68 default = 5;
69 };
70 keepLastActiveBoot = lib.mkOption {
71 description = "Number of recent active boot profiles to keep";
72 type = lib.types.ints.unsigned;
73 default = 3;
74 };
75 activeThreshold = lib.mkOption {
76 description = ''
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.
84
85 This threshold is approximate, see activeMeasurementGranularity.
86 Do not set less than activeMeasurementGranularity!
87 '';
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
91 # want to avoid. :(
92 type = lib.types.str;
93 default = "5 days";
94 };
95 activeMeasurementGranularity = lib.mkOption {
96 description = ''
97 How often to make a note of the currently-active profiles. This is the useful
98 granularity and minimum value of activeThreshold.
99 '';
100 default = "1 hour";
101 };
102 keepLatest = lib.mkOption {
103 description = ''
104 Keep all profiles younger than this duration (systemd.time format).
105 This control is similar to nix-collect-garbage's --delete-older-than.
106 '';
107 type = lib.types.str;
108 default = "6 months";
109 };
110 keepFuture = lib.mkOption {
111 description = "Keep profiles 'ahead' of the current profile (happens after rollback)";
112 type = lib.types.bool;
113 default = true;
114 };
115 logdir = lib.mkOption {
116 description = "Where to keep liveness logs";
117 type = lib.types.str;
118 default = "/var/log/profile-gc";
119 };
120 };
121 };
122 config = lib.mkIf cfg.enable {
123 assertions = [
124 {
125 assertion = cfg.enable -> config.nix.gc.automatic;
126 message = ''nix.profile-gc.enable requires nix.gc.automatic'';
127 }
128 {
129 assertion = cfg.manageHomeProfiles -> cfg.monitorHomeProfiles;
130 message = ''nix.profile-gc.manageHomeProfiles requires nix.profile-gc.monitorHomeProfiles'';
131 }
132 ];
133 systemd.services.nix-gc.serviceConfig.ExecStartPre = pkgs.writeShellScript "nix-profile-gc" ''
134 set -euo pipefail
135
136 if [[ ! -e ${cfg.logdir}/active-system
137 || ! -e ${cfg.logdir}/active-boot
138 || ! -e ${cfg.logdir}/active-profiles ]]
139 then
140 echo "Liveness logs not found. Not doing any profile garbage collection." >&2
141 exit 0
142 fi
143
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
148 exit 0
149 fi
150
151 topn() {
152 ${pkgs.coreutils}/bin/tac "$1" |
153 ${pkgs.gawk}/bin/awk \
154 --assign key="$2" \
155 --assign n="$3" \
156 --assign threshold="$alive_threshold" \
157 --assign loginterval="$alive_loginterval" \
158 '
159 !key || $1 == key {
160 val = key ? $2 : $1
161 if (++count[val] == int(threshold/loginterval)) {
162 print val
163 if (++found == n) {
164 exit 0
165 }
166 }
167 }
168 '
169 }
170
171 verbose_topn() {
172 topn "$@" | tee >(
173 echo "Keeping the last $3 $2 entries from $1:" >&2
174 ${pkgs.gawk}/bin/gawk '{ print " " $0 }' >&2 )
175 }
176
177 declare -A active_targets
178 while read -r target;do
179 active_targets[$target]=1
180 done < <(
181 verbose_topn ${cfg.logdir}/active-system "" ${escapeShellArg cfg.keepLastActiveSystem}
182 verbose_topn ${cfg.logdir}/active-boot "" ${escapeShellArg cfg.keepLastActiveBoot }
183 )
184
185 home_profile_dirs=()
186 ${lib.optionalString cfg.manageHomeProfiles gather_home_profiles}
187
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
192 unset active
193 declare -A active
194 while read -r pname;do
195 active[$pname]=1
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
201 pgen=''${p%-link}
202 pgen=''${pgen##*-}
203 if [[ "$p" != "$profile-$pgen-link" ]];then
204 echo "(Disregarding unrelated profile $p)" >&2
205 continue
206 fi
207 pname=$(${pkgs.coreutils}/bin/basename "$p")
208 if [[ "$pname" == "$current" ]];then
209 echo "Keeeping current profile $p" >&2
210 continue
211 fi
212 if [[ "''${active_targets[$(${pkgs.coreutils}/bin/readlink "$p")]:-}" ]];then
213 echo "Keeeping active system/boot profile $p" >&2
214 continue
215 fi
216 if [[ "''${active[$pname]:-}" ]];then
217 echo "Keeeping active profile $p" >&2
218 continue
219 fi
220 if (( (now - "$(${pkgs.findutils}/bin/find "$p" -printf %Ts)") < age_threshold/1000000 ));then
221 echo "Keeeping young profile $p" >&2
222 continue
223 fi
224 ${lib.optionalString cfg.keepFuture ''
225 if (( pgen > currentgen ));then
226 echo "Keeeping future profile $p" >&2
227 continue
228 fi
229 ''}
230 ${if cfg.dryRun then ''
231 echo "Would remove profile $p" >&2
232 '' else ''
233 echo "Removing profile $p" >&2
234 rm "$p"
235 ''}
236 done
237 done < <(${pkgs.findutils}/bin/find "''${NIX_STATE_DIR:-/nix/var/nix}/profiles/" "''${home_profile_dirs[@]}" -type l -not -name '*[0-9]-link')
238 '';
239 systemd.timers.profile-gc-log-active = {
240 wantedBy = [ "timers.target" ];
241 timerConfig.OnActiveSec = cfg.activeMeasurementGranularity;
242 timerConfig.OnUnitActiveSec = cfg.activeMeasurementGranularity;
243 };
244 systemd.services.profile-gc-log-active = {
245 description =
246 "Log the active profiles for gc collection policy evaluation";
247 serviceConfig.Type = "oneshot";
248 script = ''
249 home_profile_dirs=()
250 ${lib.optionalString cfg.monitorHomeProfiles gather_home_profiles}
251
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 '
258 for f;do
259 echo -n "$f "
260 ${pkgs.coreutils}/bin/readlink "$f"
261 done' - {} + \
262 >> ${cfg.logdir}/active-profiles
263 '';
264 };
265 };
266 }