]> git.scottworley.com Git - nix-profile-gc/commitdiff
profile-gc
authorScott Worley <scottworley@scottworley.com>
Thu, 17 Nov 2022 10:10:13 +0000 (02:10 -0800)
committerScott Worley <scottworley@scottworley.com>
Thu, 17 Nov 2022 10:10:13 +0000 (02:10 -0800)
modules/profile-gc.nix [new file with mode: 0644]

diff --git a/modules/profile-gc.nix b/modules/profile-gc.nix
new file mode 100644 (file)
index 0000000..488bc0a
--- /dev/null
@@ -0,0 +1,214 @@
+{ lib, config, pkgs, ... }:
+let
+  inherit (lib) escapeShellArg;
+  cfg = config.nix.profile-gc;
+  parse-duration = duration: pkgs.runCommand "duration" { buildInputs = with pkgs; [ systemd ]; } ''
+    set -euo pipefail
+    parsed=$(systemd-analyze timespan ${escapeShellArg duration} | awk '$1 == "μs:" { print $2 }')
+    echo "$parsed" > "$out"
+  '';
+in {
+  options = {
+    nix.profile-gc = {
+      enable = lib.mkEnableOption "Automatic profile garbage collection";
+      dryRun = lib.mkOption {
+        description = "Say what would have been deleted rather than actually deleting profiles";
+        type = lib.types.bool;
+        default = true;
+      };
+      keepLast = lib.mkOption {
+        description = ''
+          Number of recent profiles to keep.
+          This control is similar to nix-env --delete-generation's +5 syntax.
+        '';
+        type = lib.types.ints.unsigned;
+        default = 5;
+      };
+      keepLastActive = lib.mkOption {
+        description = "Number of recent active profiles to keep";
+        type = lib.types.ints.unsigned;
+        default = 5;
+      };
+      keepLastActiveSystem = lib.mkOption {
+        description = "Number of recent active system profiles to keep";
+        type = lib.types.ints.unsigned;
+        default = 5;
+      };
+      keepLastActiveBoot = lib.mkOption {
+        description = "Number of recent active boot profiles to keep";
+        type = lib.types.ints.unsigned;
+        default = 3;
+      };
+      activeThreshold = lib.mkOption {
+        description = ''
+          A system profile that is active (or is either /run/current-system or /run/booted-system)
+          for at least this long (of powered-on machine time) is considered 'active' for
+          the purpose of evaluating the keepLastActive number of profiles.  This mechanism is
+          intended to preserve profiles that are in some sense stable, that have served us well,
+          so they don't immediately become gc-elligible when a system hasn't been updated in
+          awhile (so keepLatest won't protect them) generates a bunch of broken profiles (so
+          keepLast won't protect them) while trying to get up to date.
+
+          This is approximate and has a useful granularity of an hour
+          (config.systemd.timers.profile-gc-log-active.timerConfig.OnActiveSec).
+          Do not set less than this.
+        '';
+        # We admonish the user "Do not set less than this." and check it at runtime rather
+        # than verifying this with an assertion now because parsing these durations at
+        # configuration-time requires import-from-derivation, which we want to avoid.  :(
+        type = lib.types.str;
+        default = "5 days";
+      };
+      keepLatest = lib.mkOption {
+        description = ''
+          Keep all profiles younger than this duration (systemd.time format).
+          This control is similar to nix-collect-garbage's --delete-older-than.
+        '';
+        type = lib.types.str;
+        default = "6 months";
+      };
+      keepFuture = lib.mkOption {
+        description = "Keep profiles 'ahead' of the current profile (happens after rollback)";
+        type = lib.types.bool;
+        default = true;
+      };
+      logdir = lib.mkOption {
+        description = "Where to keep liveness logs";
+        type = lib.types.str;
+        default = "/var/log/profile-gc";
+      };
+    };
+  };
+  config = lib.mkIf cfg.enable {
+    assertions = [
+      {
+        assertion = cfg.enable -> config.nix.gc.automatic;
+        message = ''nix.profile-gc.enable requires nix.gc.automatic'';
+      }
+    ];
+    systemd.services.nix-gc.serviceConfig.ExecStartPre = pkgs.writeShellScript "nix-profile-gc" ''
+      set -euo pipefail
+
+      if [[ ! -e ${cfg.logdir}/active-system
+         || ! -e ${cfg.logdir}/active-boot
+         || ! -e ${cfg.logdir}/active-profiles ]]
+      then
+        echo "Liveness logs not found.  Not doing any profile garbage collection." >&2
+        exit 0
+      fi
+
+      alive_threshold="$(< ${parse-duration cfg.activeThreshold})"
+      alive_loginterval="$(< ${parse-duration config.systemd.timers.profile-gc-log-active.timerConfig.OnActiveSec})"
+      if (( alive_threshold < alive_loginterval ));then
+        echo "Liveness threshold is too low.  Not doing any profile garbage collection." >&2
+        exit 0
+      fi
+
+      topn() {
+        ${pkgs.coreutils}/bin/tac "$1" |
+          ${pkgs.gawk}/bin/awk \
+            --assign key="$2" \
+            --assign n="$3" \
+            --assign threshold="$alive_threshold" \
+            --assign loginterval="$alive_loginterval" \
+            '
+              !key || $1 == key {
+                val = key ? $2 : $1
+                if (++count[val] == int(threshold/loginterval)) {
+                  print val
+                  if (++found == n) {
+                    exit 0
+                  }
+                }
+              }
+            '
+      }
+
+      verbose_topn() {
+        topn "$@" | tee >(
+          echo "Keeping the last $3 $2 entries from $1:" >&2
+          ${pkgs.gawk}/bin/gawk '{ print "  " $0 }' >&2 )
+      }
+
+      declare -A active_targets
+      while read target;do
+        active_targets[$target]=1
+      done < <(
+        verbose_topn ${cfg.logdir}/active-system "" ${escapeShellArg cfg.keepLastActiveSystem}
+        verbose_topn ${cfg.logdir}/active-boot   "" ${escapeShellArg cfg.keepLastActiveBoot  }
+      )
+
+      now=$(${pkgs.coreutils}/bin/date +%s)
+      age_threshold="$(< ${parse-duration cfg.keepLatest})"
+      while read profile;do
+        echo "Contemplating profiles for $profile:" >&2
+        unset active
+        declare -A active
+        while read p;do
+          active[$p]=1
+        done < <(verbose_topn ${cfg.logdir}/active-profiles "$profile" ${escapeShellArg cfg.keepLastActive})
+        current=$(${pkgs.coreutils}/bin/readlink "$profile")
+        currentgen=''${current%-link}
+        currentgen=''${currentgen##*-}
+        for p in "$profile"-*-link;do
+          pgen=''${p%-link}
+          pgen=''${pgen##*-}
+          if [[ "$p" != "$profile-$pgen-link" ]];then
+            echo "(Disregarding unrelated profile $p)" >&2
+            continue
+          fi
+          if [[ "$p" == "$current" ]];then
+            echo "Keeeping current profile $p" >&2
+            continue
+          fi
+          if [[ "''${active_targets[$(${pkgs.coreutils}/bin/readlink "$p")]}" ]];then
+            echo "Keeeping active system/boot profile $p" >&2
+            continue
+          fi
+          if [[ "''${active[$p]}" ]];then
+            echo "Keeeping active profile $p" >&2
+            continue
+          fi
+          if (( (now - "$(${pkgs.findutils}/bin/find "$p" -printf %Ts)") < age_threshold/1000000 ));then
+            echo "Keeeping young profile $p" >&2
+            continue
+          fi
+          ${lib.optionalString cfg.keepFuture ''
+            if (( pgen > currentgen ));then
+              echo "Keeeping future profile $p" >&2
+              continue
+            fi
+          ''}
+          ${if cfg.dryRun then ''
+            echo "Would remove profile $p" >&2
+          '' else ''
+            echo "Removing profile $p" >&2
+            rm "$p"
+          ''}
+        done
+      done < <(${pkgs.findutils}/bin/find ''${NIX_STATE_DIR:-/nix/var/nix}/profiles/ -type l -not -name '*[0-9]-link')
+    '';
+    systemd.timers.profile-gc-log-active = {
+      wantedBy = [ "timers.target" ];
+      timerConfig.OnActiveSec = "1 hour";
+    };
+    systemd.services.profile-gc-log-active = {
+      description =
+        "Log the active profiles for gc collection policy evaluation";
+      serviceConfig.Type = "oneshot";
+      script = ''
+        ${pkgs.coreutils}/bin/mkdir -p ${cfg.logdir}
+        ${pkgs.coreutils}/bin/readlink /run/current-system >> ${cfg.logdir}/active-system
+        ${pkgs.coreutils}/bin/readlink /run/booted-system  >> ${cfg.logdir}/active-boot
+        ${pkgs.findutils}/bin/find ''${NIX_STATE_DIR:-/nix/var/nix}/profiles/ \
+          -type l -not -name '*[0-9]-link' \
+          -exec ${pkgs.stdenv.shell} -c '
+            for f;do
+              echo -n "$f "
+              ${pkgs.coreutils}/bin/readlink "$f"
+            done' - {} + \
+          >> ${cfg.logdir}/active-profiles
+      '';
+    };
+  };
+}