1 # auto-upgrade-with-pinch: Secure managed NixOS updates
3 # This program is free software: you can redistribute it and/or modify it
4 # under the terms of the GNU General Public License as published by the
5 # Free Software Foundation, version 3.
7 { config, lib, pkgs, ... }:
10 local-pkgs = import ../. { inherit pkgs; };
11 cfg = config.system.autoUpgradeWithPinch;
12 pull-repo-script = pkgs.writeShellScript "pull-repo" ''
19 ${pkgs.jq}/bin/jq -r ".$1" <<< "$config"
22 echo Pulling in "$path" >&2
24 if [[ ! -e "$path" ]];then
26 ${pkgs.git}/bin/git init "$d"
27 ${pkgs.git}/bin/git -C "$d" checkout -b "$(prop localBranch)"
28 ${pkgs.git}/bin/git -C "$d" remote add "$(prop remoteName)" "$(prop url)"
29 ${pkgs.git}/bin/git -C "$d" branch -u "$(prop remoteBranch)"
30 mkdir -p "$(${pkgs.coreutils}/bin/dirname "$path")"
36 if [[ "$(${pkgs.git}/bin/git remote get-url "$(prop remoteName)")" != "$(prop url)" ]]; then
37 echo Expected git remote "$(prop remoteName)" to point at "$(prop url)" \
38 but it points at "$(${pkgs.git}/bin/git remote get-url "$(prop remoteName)")" >&2
39 case "$(prop onRemoteURLMismatch)" in
41 update) echo Updating it >&2
42 ${pkgs.git}/bin/git -C "$d" remote set-url "$(prop remoteName)" "$(prop url)";;
46 ${pkgs.git}/bin/git fetch "$(prop remoteName)" "$(prop remoteBranch)"
48 if [[ "$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)" != "$(prop localBranch)" ]];then
49 echo Could not merge because currently-checked-out \
50 \""$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)"\" is not \
51 \""$(prop localBranch)"\"
52 case "$(prop onBranchMismatch)" in
58 if [[ "$(prop requireSignature)" == true ]]; then
59 ${pkgs.polite-merge}/bin/polite-merge \
60 -c gpg.program=${escapeShellArg (local-pkgs.keyed-gpg cfg.signingKeys)} \
61 merge --ff-only --verify-signatures
63 ${pkgs.polite-merge}/bin/polite-merge merge --ff-only
67 auto-upgrade-script = pkgs.writeShellScript "auto-upgrade" ''
68 ${pkgs.coreutils}/bin/nice -n 17 \
69 ${pkgs.util-linux}/bin/ionice -c 3 \
70 ${pkgs.util-linux}/bin/flock /run/auto-upgrade-with-pinch ${
71 pkgs.writeShellScript "auto-upgrade-with-lock-held" ''
76 if [[ "$1" == --dry-run ]];then
78 pinch_args=( --dry-run )
95 d=$(${pkgs.coreutils}/bin/mktemp -d)
99 ${pkgs.coreutils}/bin/rm -r "$d"
102 ${optionalString (cfg.upgradeConfigOwnershipPolicy != "any") (''
104 if [[ "$1" != /* ]];then
105 die "Unexpected relative path: $1"
107 if [[ "$1" != / ]];then
108 verify_ownership "$(${pkgs.coreutils}/bin/dirname "$1")"
110 if [[ ! -e "$1" ]];then
111 die "Could not find upgrade config: $1 does not exist"
113 if [[ -h "$1" ]];then
115 ${pkgs.coreutils}/bin/realpath --no-symlinks \
116 "$(${pkgs.coreutils}/bin/dirname "$1")/$(${pkgs.coreutils}/bin/readlink "$1")"
119 perms="$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%M")"
120 if [[ "$perms" == d*t ]];then
121 die "Will not use upgrade config in sticky directory $1"
123 owner=$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%u")
124 if [[ "$owner" != root ]];then
125 die "Will not use upgrade config not owned by root in $1"
127 if [[ "$perms" == l* ]];then
128 return 0 # Root-owned symlinks are fine
130 if [[ "$perms" == *w? ]];then
131 die "Will not use world-writable upgrade config in $1"
136 if [[ "$perms" == *w???? ]];then
137 die "Will not use group-writable upgrade config in $1"
141 if [[ "$perms" == *w???? ]];then
142 group=$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%g")
143 if [[ "$group" != wheel ]];then
144 die "Will not use non-wheel-group group-writable upgrade config in $1"
148 }."${cfg.upgradeConfigOwnershipPolicy}"
152 + concatMapStringsSep "\n" (f: "verify_ownership ${escapeShellArg f}")
155 config=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -A config \
156 --arg upgradeConfig ${
158 + lib.concatMapStringsSep " " lib.strings.escapeNixString
159 cfg.upgradeConfig + "]")
160 } ${../upgrade-config.nix})
163 ${pkgs.jq}/bin/jq -r "$@" <<< "$config"
167 config_query --arg path "$1" ".repos[\$ARGS.named.path]$2"
171 config_query --arg user "$1" ".userEnvironments[\$ARGS.named.user]$2"
176 hydrate /run/wrappers/bin/sudo -u "$(repo_query "$path" .user)" \
177 ${pull-repo-script} "$path" "$(repo_query "$path" "")"
178 done < <( config_query '.repos | keys []' )
181 config_query '.pinchFiles[]' | ${pkgs.findutils}/bin/xargs --no-run-if-empty --delimiter=\\n ${pkgs.pinch}/bin/pinch update "''${pinch_args[@]}"
184 in_tmpdir hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
186 hydrate /run/wrappers/bin/sudo -u "$user" -D / \
187 ${pkgs.nix}/bin/nix-build --no-out-link '<nixpkgs>' -A "$(userenv_query "$user" .package)"
188 done < <( config_query '.userEnvironments | keys []' )
191 hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch
194 if [[ "$(userenv_query "$user" .otherPackagesAction)" == keep ]];then
197 hydrate /run/wrappers/bin/sudo -u "$user" \
198 ${pkgs.nix}/bin/nix-env -f '<nixpkgs>' $remove_arg -iA "$(userenv_query "$user" .package)"
199 done < <( config_query '.userEnvironments | keys []' )
206 system.autoUpgradeWithPinch = {
212 Whether to periodically upgrade NixOS to the latest version.
213 Presumes that /etc/nixos is a git repo with a remote and
214 contains a pinch file called "channels".
222 Specification (in the format described by
223 <citerefentry><refentrytitle>systemd.time</refentrytitle>
224 <manvolnum>7</manvolnum></citerefentry>) of the time at
225 which the update will occur.
229 signingKeys = mkOption {
230 type = types.listOf types.path;
232 Files containing GPG keys that are authorized to sign updates.
233 Updates are only merged if the commit at the tip of the remote
234 ref is signed with one of these keys.
238 upgradeConfig = mkOption {
239 type = types.listOf types.path;
241 Configuration files that specify what git repo paths to pull, what
242 pinch files to update from, and what user environments to update.
243 These are specified in separate configuration files processed at
244 update time so that changes to this configuration take effect in
245 the same update cycle.
249 upgradeConfigOwnershipPolicy = mkOption {
250 type = types.enum [ "root" "wheel" "any" ];
253 Verify ownership of upgrade config files before using them for
256 root = Config must be writable only by root.
257 wheel = Config must be writable only by root and wheel.
258 any = No checks. Not recommended.
264 config = lib.mkIf cfg.enable {
266 security.sudo.extraRules = lib.mkAfter [{
267 groups = [ "users" ];
269 command = "${auto-upgrade-script}";
270 options = [ "NOPASSWD" "NOSETENV" ];
273 # NOSETENV above still allows through ~17 vars, including PATH. Block those
275 security.sudo.extraConfig = ''
276 Defaults!${auto-upgrade-script} !env_check
277 Defaults!${auto-upgrade-script} !env_keep
281 (import ../overlays/pinch.nix)
282 (import ../overlays/polite-merge.nix)
284 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
285 /run/wrappers/bin/sudo ${auto-upgrade-script}
290 environment.systemPackages = [ pkgs.auto-upgrade ];
292 systemd.services.nixos-upgrade = {
293 description = "NixOS Upgrade";
294 restartIfChanged = false;
295 unitConfig.X-StopOnRemoval = false;
296 serviceConfig.Type = "oneshot";
297 environment = config.nix.envVars // {
298 inherit (config.environment.sessionVariables) NIX_PATH;
300 } // config.networking.proxy.envVars;
303 config.nix.package.out
315 # Chill for awhile before applying updates. If applying an update
316 # badly breaks things, we want a window in which an operator can
317 # intervene either to fix the problem or disable automatic updates.
320 ${auto-upgrade-script}