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.
15 local-pkgs = import ../. { inherit pkgs; };
16 cfg = config.system.autoUpgradeWithPinch;
17 pull-repo-script = pkgs.writeShellScript "pull-repo" ''
24 ${pkgs.jq}/bin/jq -r ".$1" <<< "$config"
27 echo Pulling in "$path" >&2
29 if [[ ! -e "$path" ]];then
31 ${pkgs.git}/bin/git init "$d"
32 ${pkgs.git}/bin/git -C "$d" checkout -b "$(prop localBranch)"
33 ${pkgs.git}/bin/git -C "$d" remote add "$(prop remoteName)" "$(prop url)"
34 ${pkgs.git}/bin/git -C "$d" branch -u "$(prop remoteBranch)"
35 mkdir -p "$(${pkgs.coreutils}/bin/dirname "$path")"
41 if [[ "$(${pkgs.git}/bin/git remote get-url "$(prop remoteName)")" != "$(prop url)" ]]; then
42 echo Expected git remote "$(prop remoteName)" to point at "$(prop url)" \
43 but it points at "$(${pkgs.git}/bin/git remote get-url "$(prop remoteName)")" >&2
44 case "$(prop onRemoteURLMismatch)" in
46 update) echo Updating it >&2
47 ${pkgs.git}/bin/git -C "$d" remote set-url "$(prop remoteName)" "$(prop url)";;
51 ${pkgs.git}/bin/git fetch "$(prop remoteName)" "$(prop remoteBranch)"
53 if [[ "$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)" != "$(prop localBranch)" ]];then
54 echo Could not merge because currently-checked-out \
55 \""$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)"\" is not \
56 \""$(prop localBranch)"\"
57 case "$(prop onBranchMismatch)" in
63 if [[ "$(prop requireSignature)" == true ]]; then
64 ${pkgs.polite-merge}/bin/polite-merge \
65 -c gpg.program=${escapeShellArg (local-pkgs.keyed-gpg cfg.signingKeys)} \
66 merge --ff-only --verify-signatures
68 ${pkgs.polite-merge}/bin/polite-merge merge --ff-only
72 auto-upgrade-script = pkgs.writeShellScript "auto-upgrade" ''
73 ${pkgs.coreutils}/bin/nice -n 17 \
74 ${pkgs.util-linux}/bin/ionice -c 3 \
75 ${pkgs.util-linux}/bin/flock /run/auto-upgrade-with-pinch ${pkgs.writeShellScript "auto-upgrade-with-lock-held" ''
80 if [[ "$1" == --dry-run ]];then
82 pinch_args=( --dry-run )
99 d=$(${pkgs.coreutils}/bin/mktemp -d)
103 ${pkgs.coreutils}/bin/rm -r "$d"
106 ${optionalString (cfg.upgradeConfigOwnershipPolicy != "any") (
109 if [[ "$1" != /* ]];then
110 die "Unexpected relative path: $1"
112 if [[ "$1" != / ]];then
113 verify_ownership "$(${pkgs.coreutils}/bin/dirname "$1")"
115 if [[ ! -e "$1" ]];then
116 die "Could not find upgrade config: $1 does not exist"
118 if [[ -h "$1" ]];then
120 ${pkgs.coreutils}/bin/realpath --no-symlinks \
121 "$(${pkgs.coreutils}/bin/dirname "$1")/$(${pkgs.coreutils}/bin/readlink "$1")"
124 perms="$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%M")"
125 if [[ "$perms" == d*t ]];then
126 die "Will not use upgrade config in sticky directory $1"
128 owner=$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%u")
129 if [[ "$owner" != root ]];then
130 die "Will not use upgrade config not owned by root in $1"
132 if [[ "$perms" == l* ]];then
133 return 0 # Root-owned symlinks are fine
135 if [[ "$perms" == *w? ]];then
136 die "Will not use world-writable upgrade config in $1"
141 if [[ "$perms" == *w???? ]];then
142 die "Will not use group-writable upgrade config in $1"
146 if [[ "$perms" == *w???? ]];then
147 group=$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%g")
148 if [[ "$group" != wheel ]];then
149 die "Will not use non-wheel-group group-writable upgrade config in $1"
154 ."${cfg.upgradeConfigOwnershipPolicy}"
158 + concatMapStringsSep "\n" (f: "verify_ownership ${escapeShellArg f}") cfg.upgradeConfig
161 config=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -A config \
162 --arg upgradeConfig ${
164 "[" + lib.concatMapStringsSep " " lib.strings.escapeNixString cfg.upgradeConfig + "]"
166 } ${../upgrade-config.nix})
169 ${pkgs.jq}/bin/jq -r "$@" <<< "$config"
173 config_query --arg path "$1" ".repos[\$ARGS.named.path]$2"
177 config_query --arg user "$1" ".userEnvironments[\$ARGS.named.user]$2"
182 hydrate /run/wrappers/bin/sudo -u "$(repo_query "$path" .user)" \
183 ${pull-repo-script} "$path" "$(repo_query "$path" "")"
184 done < <( config_query '.repos | keys []' )
187 config_query '.pinchFiles[]' | ${pkgs.findutils}/bin/xargs --no-run-if-empty --delimiter=\\n ${pkgs.pinch}/bin/pinch update "''${pinch_args[@]}"
190 in_tmpdir hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
193 hydrate /run/wrappers/bin/sudo -u "$user" \
194 ${pkgs.nix}/bin/nix-build --no-out-link '<nixpkgs>' -A "$(userenv_query "$user" .package)"
196 done < <( config_query '.userEnvironments | keys []' )
200 hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch
204 if [[ "$(userenv_query "$user" .otherPackagesAction)" == keep ]];then
207 hydrate /run/wrappers/bin/sudo -u "$user" \
208 ${pkgs.nix}/bin/nix-env -f '<nixpkgs>' $remove_arg -iA "$(userenv_query "$user" .package)"
210 done < <( config_query '.userEnvironments | keys []' )
217 system.autoUpgradeWithPinch = {
223 Whether to periodically upgrade NixOS to the latest version.
224 Presumes that /etc/nixos is a git repo with a remote and
225 contains a pinch file called "channels".
233 Specification (in the format described by
234 <citerefentry><refentrytitle>systemd.time</refentrytitle>
235 <manvolnum>7</manvolnum></citerefentry>) of the time at
236 which the update will occur.
240 signingKeys = mkOption {
241 type = types.listOf types.path;
243 Files containing GPG keys that are authorized to sign updates.
244 Updates are only merged if the commit at the tip of the remote
245 ref is signed with one of these keys.
249 upgradeConfig = mkOption {
250 type = types.listOf types.path;
252 Configuration files that specify what git repo paths to pull, what
253 pinch files to update from, and what user environments to update.
254 These are specified in separate configuration files processed at
255 update time so that changes to this configuration take effect in
256 the same update cycle.
260 upgradeConfigOwnershipPolicy = mkOption {
268 Verify ownership of upgrade config files before using them for
271 root = Config must be writable only by root.
272 wheel = Config must be writable only by root and wheel.
273 any = No checks. Not recommended.
279 config = lib.mkIf cfg.enable {
281 security.sudo.extraRules = lib.mkAfter [
283 groups = [ "users" ];
287 command = "${auto-upgrade-script}";
296 # NOSETENV above still allows through ~17 vars, including PATH. Block those
298 security.sudo.extraConfig = ''
299 Defaults!${auto-upgrade-script} !env_check
300 Defaults!${auto-upgrade-script} !env_keep
304 (import ../overlays/pinch.nix)
305 (import ../overlays/polite-merge.nix)
307 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
308 /run/wrappers/bin/sudo ${auto-upgrade-script}
313 environment.systemPackages = [ pkgs.auto-upgrade ];
315 systemd.services.nixos-upgrade = {
316 description = "NixOS Upgrade";
317 restartIfChanged = false;
318 unitConfig.X-StopOnRemoval = false;
319 serviceConfig.Type = "oneshot";
323 inherit (config.environment.sessionVariables) NIX_PATH;
326 // config.networking.proxy.envVars;
329 config.nix.package.out
341 # Chill for awhile before applying updates. If applying an update
342 # badly breaks things, we want a window in which an operator can
343 # intervene either to fix the problem or disable automatic updates.
346 ${auto-upgrade-script}