]> git.scottworley.com Git - nix-profile-gc/blame - modules/profile-gc.nix
profile-gc: Confidence gained! Change dryRun default: true → false
[nix-profile-gc] / modules / profile-gc.nix
CommitLineData
0029e3b3
SW
1{ lib, config, pkgs, ... }:
2let
3 inherit (lib) escapeShellArg;
4 cfg = config.nix.profile-gc;
5 parse-duration = duration: pkgs.runCommand "duration" { buildInputs = with pkgs; [ systemd ]; } ''
6 set -euo pipefail
7 parsed=$(systemd-analyze timespan ${escapeShellArg duration} | awk '$1 == "μs:" { print $2 }')
8 echo "$parsed" > "$out"
9 '';
10in {
11 options = {
12 nix.profile-gc = {
13 enable = lib.mkEnableOption "Automatic profile garbage collection";
14 dryRun = lib.mkOption {
15 description = "Say what would have been deleted rather than actually deleting profiles";
16 type = lib.types.bool;
646f1750 17 default = false;
0029e3b3
SW
18 };
19 keepLast = lib.mkOption {
20 description = ''
21 Number of recent profiles to keep.
22 This control is similar to nix-env --delete-generation's +5 syntax.
23 '';
24 type = lib.types.ints.unsigned;
25 default = 5;
26 };
27 keepLastActive = lib.mkOption {
28 description = "Number of recent active profiles to keep";
29 type = lib.types.ints.unsigned;
30 default = 5;
31 };
32 keepLastActiveSystem = lib.mkOption {
33 description = "Number of recent active system profiles to keep";
34 type = lib.types.ints.unsigned;
35 default = 5;
36 };
37 keepLastActiveBoot = lib.mkOption {
38 description = "Number of recent active boot profiles to keep";
39 type = lib.types.ints.unsigned;
40 default = 3;
41 };
42 activeThreshold = lib.mkOption {
43 description = ''
44 A system profile that is active (or is either /run/current-system or /run/booted-system)
45 for at least this long (of powered-on machine time) is considered 'active' for
46 the purpose of evaluating the keepLastActive number of profiles. This mechanism is
47 intended to preserve profiles that are in some sense stable, that have served us well,
48 so they don't immediately become gc-elligible when a system hasn't been updated in
49 awhile (so keepLatest won't protect them) generates a bunch of broken profiles (so
50 keepLast won't protect them) while trying to get up to date.
51
26814c83
SW
52 This threshold is approximate, see activeMeasurementGranularity.
53 Do not set less than activeMeasurementGranularity!
0029e3b3 54 '';
26814c83
SW
55 # We admonish the user "Do not set less than activeMeasurementGranularity!" and check
56 # it at runtime rather than verifying this with an assertion at evaluation time because
57 # parsing these durations at evaluation-time requires import-from-derivation, which we
58 # want to avoid. :(
0029e3b3
SW
59 type = lib.types.str;
60 default = "5 days";
61 };
26814c83
SW
62 activeMeasurementGranularity = lib.mkOption {
63 description = ''
64 How often to make a note of the currently-active profiles. This is the useful
65 granularity and minimum value of activeThreshold.
66 '';
67 default = "1 hour";
68 };
0029e3b3
SW
69 keepLatest = lib.mkOption {
70 description = ''
71 Keep all profiles younger than this duration (systemd.time format).
72 This control is similar to nix-collect-garbage's --delete-older-than.
73 '';
74 type = lib.types.str;
75 default = "6 months";
76 };
77 keepFuture = lib.mkOption {
78 description = "Keep profiles 'ahead' of the current profile (happens after rollback)";
79 type = lib.types.bool;
80 default = true;
81 };
82 logdir = lib.mkOption {
83 description = "Where to keep liveness logs";
84 type = lib.types.str;
85 default = "/var/log/profile-gc";
86 };
87 };
88 };
89 config = lib.mkIf cfg.enable {
90 assertions = [
91 {
92 assertion = cfg.enable -> config.nix.gc.automatic;
93 message = ''nix.profile-gc.enable requires nix.gc.automatic'';
94 }
95 ];
96 systemd.services.nix-gc.serviceConfig.ExecStartPre = pkgs.writeShellScript "nix-profile-gc" ''
97 set -euo pipefail
98
99 if [[ ! -e ${cfg.logdir}/active-system
100 || ! -e ${cfg.logdir}/active-boot
101 || ! -e ${cfg.logdir}/active-profiles ]]
102 then
103 echo "Liveness logs not found. Not doing any profile garbage collection." >&2
104 exit 0
105 fi
106
107 alive_threshold="$(< ${parse-duration cfg.activeThreshold})"
13c82a53 108 alive_loginterval="$(< ${parse-duration cfg.activeMeasurementGranularity})"
0029e3b3
SW
109 if (( alive_threshold < alive_loginterval ));then
110 echo "Liveness threshold is too low. Not doing any profile garbage collection." >&2
111 exit 0
112 fi
113
114 topn() {
115 ${pkgs.coreutils}/bin/tac "$1" |
116 ${pkgs.gawk}/bin/awk \
117 --assign key="$2" \
118 --assign n="$3" \
119 --assign threshold="$alive_threshold" \
120 --assign loginterval="$alive_loginterval" \
121 '
122 !key || $1 == key {
123 val = key ? $2 : $1
124 if (++count[val] == int(threshold/loginterval)) {
125 print val
126 if (++found == n) {
127 exit 0
128 }
129 }
130 }
131 '
132 }
133
134 verbose_topn() {
135 topn "$@" | tee >(
136 echo "Keeping the last $3 $2 entries from $1:" >&2
137 ${pkgs.gawk}/bin/gawk '{ print " " $0 }' >&2 )
138 }
139
140 declare -A active_targets
141 while read target;do
142 active_targets[$target]=1
143 done < <(
144 verbose_topn ${cfg.logdir}/active-system "" ${escapeShellArg cfg.keepLastActiveSystem}
145 verbose_topn ${cfg.logdir}/active-boot "" ${escapeShellArg cfg.keepLastActiveBoot }
146 )
147
148 now=$(${pkgs.coreutils}/bin/date +%s)
149 age_threshold="$(< ${parse-duration cfg.keepLatest})"
150 while read profile;do
151 echo "Contemplating profiles for $profile:" >&2
152 unset active
153 declare -A active
154 while read p;do
155 active[$p]=1
156 done < <(verbose_topn ${cfg.logdir}/active-profiles "$profile" ${escapeShellArg cfg.keepLastActive})
157 current=$(${pkgs.coreutils}/bin/readlink "$profile")
158 currentgen=''${current%-link}
159 currentgen=''${currentgen##*-}
160 for p in "$profile"-*-link;do
161 pgen=''${p%-link}
162 pgen=''${pgen##*-}
163 if [[ "$p" != "$profile-$pgen-link" ]];then
164 echo "(Disregarding unrelated profile $p)" >&2
165 continue
166 fi
167 if [[ "$p" == "$current" ]];then
168 echo "Keeeping current profile $p" >&2
169 continue
170 fi
be8ae874 171 if [[ "''${active_targets[$(${pkgs.coreutils}/bin/readlink "$p")]:-}" ]];then
0029e3b3
SW
172 echo "Keeeping active system/boot profile $p" >&2
173 continue
174 fi
be8ae874 175 if [[ "''${active[$p]:-}" ]];then
0029e3b3
SW
176 echo "Keeeping active profile $p" >&2
177 continue
178 fi
179 if (( (now - "$(${pkgs.findutils}/bin/find "$p" -printf %Ts)") < age_threshold/1000000 ));then
180 echo "Keeeping young profile $p" >&2
181 continue
182 fi
183 ${lib.optionalString cfg.keepFuture ''
184 if (( pgen > currentgen ));then
185 echo "Keeeping future profile $p" >&2
186 continue
187 fi
188 ''}
189 ${if cfg.dryRun then ''
190 echo "Would remove profile $p" >&2
191 '' else ''
192 echo "Removing profile $p" >&2
193 rm "$p"
194 ''}
195 done
196 done < <(${pkgs.findutils}/bin/find ''${NIX_STATE_DIR:-/nix/var/nix}/profiles/ -type l -not -name '*[0-9]-link')
197 '';
198 systemd.timers.profile-gc-log-active = {
199 wantedBy = [ "timers.target" ];
13c82a53 200 timerConfig.OnActiveSec = cfg.activeMeasurementGranularity;
26814c83 201 timerConfig.OnUnitActiveSec = cfg.activeMeasurementGranularity;
0029e3b3
SW
202 };
203 systemd.services.profile-gc-log-active = {
204 description =
205 "Log the active profiles for gc collection policy evaluation";
206 serviceConfig.Type = "oneshot";
207 script = ''
208 ${pkgs.coreutils}/bin/mkdir -p ${cfg.logdir}
209 ${pkgs.coreutils}/bin/readlink /run/current-system >> ${cfg.logdir}/active-system
210 ${pkgs.coreutils}/bin/readlink /run/booted-system >> ${cfg.logdir}/active-boot
211 ${pkgs.findutils}/bin/find ''${NIX_STATE_DIR:-/nix/var/nix}/profiles/ \
212 -type l -not -name '*[0-9]-link' \
213 -exec ${pkgs.stdenv.shell} -c '
214 for f;do
215 echo -n "$f "
216 ${pkgs.coreutils}/bin/readlink "$f"
217 done' - {} + \
218 >> ${cfg.logdir}/active-profiles
219 '';
220 };
221 };
222}