]> git.scottworley.com Git - nix-profile-gc/blob - modules/profile-gc.nix
profile-gc: Start the timer on boot
[nix-profile-gc] / modules / profile-gc.nix
1 { lib, config, pkgs, ... }:
2 let
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 '';
10 in {
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;
17 default = true;
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
52 This threshold is approximate, see activeMeasurementGranularity.
53 Do not set less than activeMeasurementGranularity!
54 '';
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. :(
59 type = lib.types.str;
60 default = "5 days";
61 };
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 };
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})"
108 alive_loginterval="$(< ${parse-duration cfg.activeMeasurementGranularity})"
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
171 if [[ "''${active_targets[$(${pkgs.coreutils}/bin/readlink "$p")]:-}" ]];then
172 echo "Keeeping active system/boot profile $p" >&2
173 continue
174 fi
175 if [[ "''${active[$p]:-}" ]];then
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" ];
200 timerConfig.OnActiveSec = cfg.activeMeasurementGranularity;
201 timerConfig.OnUnitActiveSec = cfg.activeMeasurementGranularity;
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 }