]> git.scottworley.com Git - auto-upgrade-with-pinch/blob - modules/auto-upgrade.nix
Add the option to use `nix-env --set` for user profiles
[auto-upgrade-with-pinch] / modules / auto-upgrade.nix
1 # auto-upgrade-with-pinch: Secure managed NixOS updates
2 #
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.
6
7 {
8 config,
9 lib,
10 pkgs,
11 ...
12 }:
13 with lib;
14 let
15 local-pkgs = import ../. { inherit pkgs; };
16 cfg = config.system.autoUpgradeWithPinch;
17 pull-repo-script = pkgs.writeShellScript "pull-repo" ''
18 set -eo pipefail
19
20 path=$1
21 config=$2
22
23 prop() {
24 ${pkgs.jq}/bin/jq -r ".$1" <<< "$config"
25 }
26
27 echo Pulling in "$path" >&2
28
29 if [[ ! -e "$path" ]];then
30 d=$(mktemp -d)
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")"
36 mv "$d" "$path"
37 fi
38
39 cd "$path"
40
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
45 abort) exit 1;;
46 update) echo Updating it >&2
47 ${pkgs.git}/bin/git -C "$d" remote set-url "$(prop remoteName)" "$(prop url)";;
48 esac
49 fi
50
51 ${pkgs.git}/bin/git fetch "$(prop remoteName)" "$(prop remoteBranch)"
52
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
58 abort) exit 1;;
59 continue) exit 0;;
60 esac
61 fi
62
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
67 else
68 ${pkgs.polite-merge}/bin/polite-merge merge --ff-only
69 fi
70 '';
71
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" ''
76 set -eo pipefail
77
78 dry_run=false
79 pinch_args=()
80 if [[ "$1" == --dry-run ]];then
81 dry_run=true
82 pinch_args=( --dry-run )
83 fi
84
85 hydrate() {
86 if "$dry_run";then
87 echo "Would run: $*"
88 else
89 "$@"
90 fi
91 }
92
93 die() {
94 echo "$*" >&2
95 exit 1
96 }
97
98 in_tmpdir() {
99 d=$(${pkgs.coreutils}/bin/mktemp -d)
100 pushd "$d"
101 "$@"
102 popd
103 ${pkgs.coreutils}/bin/rm -r "$d"
104 }
105
106 ${optionalString (cfg.upgradeConfigOwnershipPolicy != "any") (
107 ''
108 verify_ownership() {
109 if [[ "$1" != /* ]];then
110 die "Unexpected relative path: $1"
111 fi
112 if [[ "$1" != / ]];then
113 verify_ownership "$(${pkgs.coreutils}/bin/dirname "$1")"
114 fi
115 if [[ ! -e "$1" ]];then
116 die "Could not find upgrade config: $1 does not exist"
117 fi
118 if [[ -h "$1" ]];then
119 verify_ownership "$(
120 ${pkgs.coreutils}/bin/realpath --no-symlinks \
121 "$(${pkgs.coreutils}/bin/dirname "$1")/$(${pkgs.coreutils}/bin/readlink "$1")"
122 )"
123 fi
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"
127 fi
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"
131 fi
132 if [[ "$perms" == l* ]];then
133 return 0 # Root-owned symlinks are fine
134 fi
135 if [[ "$perms" == *w? ]];then
136 die "Will not use world-writable upgrade config in $1"
137 fi
138 ${
139 {
140 root = ''
141 if [[ "$perms" == *w???? ]];then
142 die "Will not use group-writable upgrade config in $1"
143 fi
144 '';
145 wheel = ''
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"
150 fi
151 fi
152 '';
153 }
154 ."${cfg.upgradeConfigOwnershipPolicy}"
155 }
156 }
157 ''
158 + concatMapStringsSep "\n" (f: "verify_ownership ${escapeShellArg f}") cfg.upgradeConfig
159 )}
160
161 config=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -A config \
162 --arg upgradeConfig ${
163 escapeShellArg (
164 "[" + lib.concatMapStringsSep " " lib.strings.escapeNixString cfg.upgradeConfig + "]"
165 )
166 } ${../upgrade-config.nix})
167
168 config_query() {
169 ${pkgs.jq}/bin/jq -r "$@" <<< "$config"
170 }
171
172 repo_query() {
173 config_query --arg path "$1" ".repos[\$ARGS.named.path]$2"
174 }
175
176 userenv_query() {
177 config_query --arg user "$1" ".userEnvironments[\$ARGS.named.user]$2"
178 }
179
180 # Pull updates
181 while read path;do
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 []' )
185
186 # Update channels
187 config_query '.pinchFiles[]' | ${pkgs.findutils}/bin/xargs --no-run-if-empty --delimiter=\\n ${pkgs.pinch}/bin/pinch update "''${pinch_args[@]}"
188
189 # Build
190 in_tmpdir hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
191 while read user;do
192 pushd /
193 hydrate /run/wrappers/bin/sudo -u "$user" \
194 ${pkgs.nix}/bin/nix-build --no-out-link '<nixpkgs>' -A "$(userenv_query "$user" .package)"
195 popd
196 done < <( config_query '.userEnvironments | keys []' )
197 sync
198
199 # Install
200 hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild ${cfg.operation}
201 sync
202 while read user;do
203 configured_action=$(userenv_query "$user" .otherPackagesAction)
204 action=
205 case "$configured_action" in
206 keep) action=-i;;
207 remove) action=-ir;;
208 set-remove) action=--set;;
209 *) die "Unexpected otherPackagesAction $configured_action for user $user"
210 esac
211 hydrate /run/wrappers/bin/sudo -u "$user" \
212 ${pkgs.nix}/bin/nix-env -f '<nixpkgs>' "$action" -A "$(userenv_query "$user" .package)"
213 sync
214 done < <( config_query '.userEnvironments | keys []' )
215 ''}
216 '';
217
218 in
219 {
220 options = {
221 system.autoUpgradeWithPinch = {
222
223 enable = mkOption {
224 type = types.bool;
225 default = false;
226 description = ''
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".
230 '';
231 };
232
233 dates = mkOption {
234 default = "04:40";
235 type = types.str;
236 description = ''
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.
241 '';
242 };
243
244 operation = lib.mkOption {
245 type = lib.types.enum [
246 "switch"
247 "boot"
248 ];
249 default = "switch";
250 example = "boot";
251 description = ''
252 Whether to run
253 `nixos-rebuild switch --upgrade` or run
254 `nixos-rebuild boot --upgrade`
255 '';
256 };
257
258 signingKeys = mkOption {
259 type = types.listOf types.path;
260 description = ''
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.
264 '';
265 };
266
267 upgradeConfig = mkOption {
268 type = types.listOf types.path;
269 description = ''
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.
275 '';
276 };
277
278 upgradeConfigOwnershipPolicy = mkOption {
279 type = types.enum [
280 "root"
281 "wheel"
282 "any"
283 ];
284 default = "root";
285 description = ''
286 Verify ownership of upgrade config files before using them for
287 system upgrades.
288
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.
292 '';
293 };
294 };
295 };
296
297 config = lib.mkIf cfg.enable {
298
299 security.sudo.extraRules = lib.mkAfter [
300 {
301 groups = [ "users" ];
302 runAs = "root";
303 commands = [
304 {
305 command = "${auto-upgrade-script}";
306 options = [
307 "NOPASSWD"
308 "NOSETENV"
309 ];
310 }
311 ];
312 }
313 ];
314 # NOSETENV above still allows through ~17 vars, including PATH. Block those
315 # as well:
316 security.sudo.extraConfig = ''
317 Defaults!${auto-upgrade-script} !env_check
318 Defaults!${auto-upgrade-script} !env_keep
319 '';
320
321 nixpkgs.overlays = [
322 (import ../overlays/pinch.nix)
323 (import ../overlays/polite-merge.nix)
324 (self: super: {
325 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
326 /run/wrappers/bin/sudo ${auto-upgrade-script}
327 '';
328 })
329 ];
330
331 environment.systemPackages = [ pkgs.auto-upgrade ];
332
333 systemd.services.nixos-upgrade = {
334 description = "NixOS Upgrade";
335 restartIfChanged = false;
336 unitConfig.X-StopOnRemoval = false;
337 serviceConfig.Type = "oneshot";
338 environment =
339 config.nix.envVars
340 // {
341 inherit (config.environment.sessionVariables) NIX_PATH;
342 HOME = "/root";
343 }
344 // config.networking.proxy.envVars;
345
346 path = with pkgs; [
347 config.nix.package.out
348 coreutils
349 git
350 gitMinimal
351 gnutar
352 gzip
353 xz.bin
354 ];
355
356 script = ''
357 set -eo pipefail
358
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.
362 sleep 2h
363
364 ${auto-upgrade-script}
365 '';
366
367 startAt = cfg.dates;
368 };
369 };
370 }