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 ${cfg.operation}
203 configured_action=$(userenv_query "$user" .otherPackagesAction)
205 case "$configured_action" in
208 set-remove) action=--set;;
209 *) die "Unexpected otherPackagesAction $configured_action for user $user"
211 hydrate /run/wrappers/bin/sudo -u "$user" \
212 ${pkgs.nix}/bin/nix-env -f '<nixpkgs>' "$action" -A "$(userenv_query "$user" .package)"
214 done < <( config_query '.userEnvironments | keys []' )
221 system.autoUpgradeWithPinch = {
227 Whether to periodically upgrade NixOS to the latest version.
228 Presumes that /etc/nixos is a git repo with a remote and
229 contains a pinch file called "channels".
237 Specification (in the format described by
238 <citerefentry><refentrytitle>systemd.time</refentrytitle>
239 <manvolnum>7</manvolnum></citerefentry>) of the time at
240 which the update will occur.
244 operation = lib.mkOption {
245 type = lib.types.enum [
253 `nixos-rebuild switch --upgrade` or run
254 `nixos-rebuild boot --upgrade`
258 signingKeys = mkOption {
259 type = types.listOf types.path;
261 Files containing GPG keys that are authorized to sign updates.
262 Updates are only merged if the commit at the tip of the remote
263 ref is signed with one of these keys.
267 upgradeConfig = mkOption {
268 type = types.listOf types.path;
270 Configuration files that specify what git repo paths to pull, what
271 pinch files to update from, and what user environments to update.
272 These are specified in separate configuration files processed at
273 update time so that changes to this configuration take effect in
274 the same update cycle.
278 upgradeConfigOwnershipPolicy = mkOption {
286 Verify ownership of upgrade config files before using them for
289 root = Config must be writable only by root.
290 wheel = Config must be writable only by root and wheel.
291 any = No checks. Not recommended.
297 config = lib.mkIf cfg.enable {
299 security.sudo.extraRules = lib.mkAfter [
301 groups = [ "users" ];
305 command = "${auto-upgrade-script}";
314 # NOSETENV above still allows through ~17 vars, including PATH. Block those
316 security.sudo.extraConfig = ''
317 Defaults!${auto-upgrade-script} !env_check
318 Defaults!${auto-upgrade-script} !env_keep
322 (import ../overlays/pinch.nix)
323 (import ../overlays/polite-merge.nix)
325 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
326 /run/wrappers/bin/sudo ${auto-upgrade-script}
331 environment.systemPackages = [ pkgs.auto-upgrade ];
333 systemd.services.nixos-upgrade = {
334 description = "NixOS Upgrade";
335 restartIfChanged = false;
336 unitConfig.X-StopOnRemoval = false;
337 serviceConfig.Type = "oneshot";
341 inherit (config.environment.sessionVariables) NIX_PATH;
344 // config.networking.proxy.envVars;
347 config.nix.package.out
359 # Chill for awhile before applying updates. If applying an update
360 # badly breaks things, we want a window in which an operator can
361 # intervene either to fix the problem or disable automatic updates.
364 ${auto-upgrade-script}