]> git.scottworley.com Git - nix-profile-gc/blob - modules/profile-gc.nix
488bc0ad00162ee134bacd56e239a518d1365c6e
[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 is approximate and has a useful granularity of an hour
53 (config.systemd.timers.profile-gc-log-active.timerConfig.OnActiveSec).
54 Do not set less than this.
55 '';
56 # We admonish the user "Do not set less than this." and check it at runtime rather
57 # than verifying this with an assertion now because parsing these durations at
58 # configuration-time requires import-from-derivation, which we want to avoid. :(
59 type = lib.types.str;
60 default = "5 days";
61 };
62 keepLatest = lib.mkOption {
63 description = ''
64 Keep all profiles younger than this duration (systemd.time format).
65 This control is similar to nix-collect-garbage's --delete-older-than.
66 '';
67 type = lib.types.str;
68 default = "6 months";
69 };
70 keepFuture = lib.mkOption {
71 description = "Keep profiles 'ahead' of the current profile (happens after rollback)";
72 type = lib.types.bool;
73 default = true;
74 };
75 logdir = lib.mkOption {
76 description = "Where to keep liveness logs";
77 type = lib.types.str;
78 default = "/var/log/profile-gc";
79 };
80 };
81 };
82 config = lib.mkIf cfg.enable {
83 assertions = [
84 {
85 assertion = cfg.enable -> config.nix.gc.automatic;
86 message = ''nix.profile-gc.enable requires nix.gc.automatic'';
87 }
88 ];
89 systemd.services.nix-gc.serviceConfig.ExecStartPre = pkgs.writeShellScript "nix-profile-gc" ''
90 set -euo pipefail
91
92 if [[ ! -e ${cfg.logdir}/active-system
93 || ! -e ${cfg.logdir}/active-boot
94 || ! -e ${cfg.logdir}/active-profiles ]]
95 then
96 echo "Liveness logs not found. Not doing any profile garbage collection." >&2
97 exit 0
98 fi
99
100 alive_threshold="$(< ${parse-duration cfg.activeThreshold})"
101 alive_loginterval="$(< ${parse-duration config.systemd.timers.profile-gc-log-active.timerConfig.OnActiveSec})"
102 if (( alive_threshold < alive_loginterval ));then
103 echo "Liveness threshold is too low. Not doing any profile garbage collection." >&2
104 exit 0
105 fi
106
107 topn() {
108 ${pkgs.coreutils}/bin/tac "$1" |
109 ${pkgs.gawk}/bin/awk \
110 --assign key="$2" \
111 --assign n="$3" \
112 --assign threshold="$alive_threshold" \
113 --assign loginterval="$alive_loginterval" \
114 '
115 !key || $1 == key {
116 val = key ? $2 : $1
117 if (++count[val] == int(threshold/loginterval)) {
118 print val
119 if (++found == n) {
120 exit 0
121 }
122 }
123 }
124 '
125 }
126
127 verbose_topn() {
128 topn "$@" | tee >(
129 echo "Keeping the last $3 $2 entries from $1:" >&2
130 ${pkgs.gawk}/bin/gawk '{ print " " $0 }' >&2 )
131 }
132
133 declare -A active_targets
134 while read target;do
135 active_targets[$target]=1
136 done < <(
137 verbose_topn ${cfg.logdir}/active-system "" ${escapeShellArg cfg.keepLastActiveSystem}
138 verbose_topn ${cfg.logdir}/active-boot "" ${escapeShellArg cfg.keepLastActiveBoot }
139 )
140
141 now=$(${pkgs.coreutils}/bin/date +%s)
142 age_threshold="$(< ${parse-duration cfg.keepLatest})"
143 while read profile;do
144 echo "Contemplating profiles for $profile:" >&2
145 unset active
146 declare -A active
147 while read p;do
148 active[$p]=1
149 done < <(verbose_topn ${cfg.logdir}/active-profiles "$profile" ${escapeShellArg cfg.keepLastActive})
150 current=$(${pkgs.coreutils}/bin/readlink "$profile")
151 currentgen=''${current%-link}
152 currentgen=''${currentgen##*-}
153 for p in "$profile"-*-link;do
154 pgen=''${p%-link}
155 pgen=''${pgen##*-}
156 if [[ "$p" != "$profile-$pgen-link" ]];then
157 echo "(Disregarding unrelated profile $p)" >&2
158 continue
159 fi
160 if [[ "$p" == "$current" ]];then
161 echo "Keeeping current profile $p" >&2
162 continue
163 fi
164 if [[ "''${active_targets[$(${pkgs.coreutils}/bin/readlink "$p")]}" ]];then
165 echo "Keeeping active system/boot profile $p" >&2
166 continue
167 fi
168 if [[ "''${active[$p]}" ]];then
169 echo "Keeeping active profile $p" >&2
170 continue
171 fi
172 if (( (now - "$(${pkgs.findutils}/bin/find "$p" -printf %Ts)") < age_threshold/1000000 ));then
173 echo "Keeeping young profile $p" >&2
174 continue
175 fi
176 ${lib.optionalString cfg.keepFuture ''
177 if (( pgen > currentgen ));then
178 echo "Keeeping future profile $p" >&2
179 continue
180 fi
181 ''}
182 ${if cfg.dryRun then ''
183 echo "Would remove profile $p" >&2
184 '' else ''
185 echo "Removing profile $p" >&2
186 rm "$p"
187 ''}
188 done
189 done < <(${pkgs.findutils}/bin/find ''${NIX_STATE_DIR:-/nix/var/nix}/profiles/ -type l -not -name '*[0-9]-link')
190 '';
191 systemd.timers.profile-gc-log-active = {
192 wantedBy = [ "timers.target" ];
193 timerConfig.OnActiveSec = "1 hour";
194 };
195 systemd.services.profile-gc-log-active = {
196 description =
197 "Log the active profiles for gc collection policy evaluation";
198 serviceConfig.Type = "oneshot";
199 script = ''
200 ${pkgs.coreutils}/bin/mkdir -p ${cfg.logdir}
201 ${pkgs.coreutils}/bin/readlink /run/current-system >> ${cfg.logdir}/active-system
202 ${pkgs.coreutils}/bin/readlink /run/booted-system >> ${cfg.logdir}/active-boot
203 ${pkgs.findutils}/bin/find ''${NIX_STATE_DIR:-/nix/var/nix}/profiles/ \
204 -type l -not -name '*[0-9]-link' \
205 -exec ${pkgs.stdenv.shell} -c '
206 for f;do
207 echo -n "$f "
208 ${pkgs.coreutils}/bin/readlink "$f"
209 done' - {} + \
210 >> ${cfg.logdir}/active-profiles
211 '';
212 };
213 };
214 }