]> git.scottworley.com Git - auto-upgrade-with-pinch/blob - modules/auto-upgrade.nix
Add some syncs to reduce risk on machines that can power off unexpectedly
[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 { config, lib, pkgs, ... }:
8 with lib;
9 let
10 local-pkgs = import ../. { inherit pkgs; };
11 cfg = config.system.autoUpgradeWithPinch;
12 pull-repo-script = pkgs.writeShellScript "pull-repo" ''
13 set -eo pipefail
14
15 path=$1
16 config=$2
17
18 prop() {
19 ${pkgs.jq}/bin/jq -r ".$1" <<< "$config"
20 }
21
22 echo Pulling in "$path" >&2
23
24 if [[ ! -e "$path" ]];then
25 d=$(mktemp -d)
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")"
31 mv "$d" "$path"
32 fi
33
34 cd "$path"
35
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
40 abort) exit 1;;
41 update) echo Updating it >&2
42 ${pkgs.git}/bin/git -C "$d" remote set-url "$(prop remoteName)" "$(prop url)";;
43 esac
44 fi
45
46 ${pkgs.git}/bin/git fetch "$(prop remoteName)" "$(prop remoteBranch)"
47
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
53 abort) exit 1;;
54 continue) exit 0;;
55 esac
56 fi
57
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
62 else
63 ${pkgs.polite-merge}/bin/polite-merge merge --ff-only
64 fi
65 '';
66
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" ''
72 set -eo pipefail
73
74 dry_run=false
75 pinch_args=()
76 if [[ "$1" == --dry-run ]];then
77 dry_run=true
78 pinch_args=( --dry-run )
79 fi
80
81 hydrate() {
82 if "$dry_run";then
83 echo "Would run: $*"
84 else
85 "$@"
86 fi
87 }
88
89 die() {
90 echo "$*" >&2
91 exit 1
92 }
93
94 in_tmpdir() {
95 d=$(${pkgs.coreutils}/bin/mktemp -d)
96 pushd "$d"
97 "$@"
98 popd
99 ${pkgs.coreutils}/bin/rm -r "$d"
100 }
101
102 ${optionalString (cfg.upgradeConfigOwnershipPolicy != "any") (''
103 verify_ownership() {
104 if [[ "$1" != /* ]];then
105 die "Unexpected relative path: $1"
106 fi
107 if [[ "$1" != / ]];then
108 verify_ownership "$(${pkgs.coreutils}/bin/dirname "$1")"
109 fi
110 if [[ ! -e "$1" ]];then
111 die "Could not find upgrade config: $1 does not exist"
112 fi
113 if [[ -h "$1" ]];then
114 verify_ownership "$(
115 ${pkgs.coreutils}/bin/realpath --no-symlinks \
116 "$(${pkgs.coreutils}/bin/dirname "$1")/$(${pkgs.coreutils}/bin/readlink "$1")"
117 )"
118 fi
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"
122 fi
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"
126 fi
127 if [[ "$perms" == l* ]];then
128 return 0 # Root-owned symlinks are fine
129 fi
130 if [[ "$perms" == *w? ]];then
131 die "Will not use world-writable upgrade config in $1"
132 fi
133 ${
134 {
135 root = ''
136 if [[ "$perms" == *w???? ]];then
137 die "Will not use group-writable upgrade config in $1"
138 fi
139 '';
140 wheel = ''
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"
145 fi
146 fi
147 '';
148 }."${cfg.upgradeConfigOwnershipPolicy}"
149 }
150 }
151 ''
152 + concatMapStringsSep "\n" (f: "verify_ownership ${escapeShellArg f}")
153 cfg.upgradeConfig)}
154
155 config=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -A config \
156 --arg upgradeConfig ${
157 escapeShellArg ("["
158 + lib.concatMapStringsSep " " lib.strings.escapeNixString
159 cfg.upgradeConfig + "]")
160 } ${../upgrade-config.nix})
161
162 config_query() {
163 ${pkgs.jq}/bin/jq -r "$@" <<< "$config"
164 }
165
166 repo_query() {
167 config_query --arg path "$1" ".repos[\$ARGS.named.path]$2"
168 }
169
170 userenv_query() {
171 config_query --arg user "$1" ".userEnvironments[\$ARGS.named.user]$2"
172 }
173
174 # Pull updates
175 while read path;do
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 []' )
179
180 # Update channels
181 config_query '.pinchFiles[]' | ${pkgs.findutils}/bin/xargs --no-run-if-empty --delimiter=\\n ${pkgs.pinch}/bin/pinch update "''${pinch_args[@]}"
182
183 # Build
184 in_tmpdir hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
185 while read user;do
186 pushd /
187 hydrate /run/wrappers/bin/sudo -u "$user" \
188 ${pkgs.nix}/bin/nix-build --no-out-link '<nixpkgs>' -A "$(userenv_query "$user" .package)"
189 popd
190 done < <( config_query '.userEnvironments | keys []' )
191 sync
192
193 # Install
194 hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch
195 sync
196 while read user;do
197 remove_arg=-r
198 if [[ "$(userenv_query "$user" .otherPackagesAction)" == keep ]];then
199 remove_arg=
200 fi
201 hydrate /run/wrappers/bin/sudo -u "$user" \
202 ${pkgs.nix}/bin/nix-env -f '<nixpkgs>' $remove_arg -iA "$(userenv_query "$user" .package)"
203 sync
204 done < <( config_query '.userEnvironments | keys []' )
205 ''
206 }
207 '';
208
209 in {
210 options = {
211 system.autoUpgradeWithPinch = {
212
213 enable = mkOption {
214 type = types.bool;
215 default = false;
216 description = ''
217 Whether to periodically upgrade NixOS to the latest version.
218 Presumes that /etc/nixos is a git repo with a remote and
219 contains a pinch file called "channels".
220 '';
221 };
222
223 dates = mkOption {
224 default = "04:40";
225 type = types.str;
226 description = ''
227 Specification (in the format described by
228 <citerefentry><refentrytitle>systemd.time</refentrytitle>
229 <manvolnum>7</manvolnum></citerefentry>) of the time at
230 which the update will occur.
231 '';
232 };
233
234 signingKeys = mkOption {
235 type = types.listOf types.path;
236 description = ''
237 Files containing GPG keys that are authorized to sign updates.
238 Updates are only merged if the commit at the tip of the remote
239 ref is signed with one of these keys.
240 '';
241 };
242
243 upgradeConfig = mkOption {
244 type = types.listOf types.path;
245 description = ''
246 Configuration files that specify what git repo paths to pull, what
247 pinch files to update from, and what user environments to update.
248 These are specified in separate configuration files processed at
249 update time so that changes to this configuration take effect in
250 the same update cycle.
251 '';
252 };
253
254 upgradeConfigOwnershipPolicy = mkOption {
255 type = types.enum [ "root" "wheel" "any" ];
256 default = "root";
257 description = ''
258 Verify ownership of upgrade config files before using them for
259 system upgrades.
260
261 root = Config must be writable only by root.
262 wheel = Config must be writable only by root and wheel.
263 any = No checks. Not recommended.
264 '';
265 };
266 };
267 };
268
269 config = lib.mkIf cfg.enable {
270
271 security.sudo.extraRules = lib.mkAfter [{
272 groups = [ "users" ];
273 commands = [{
274 command = "${auto-upgrade-script}";
275 options = [ "NOPASSWD" "NOSETENV" ];
276 }];
277 }];
278 # NOSETENV above still allows through ~17 vars, including PATH. Block those
279 # as well:
280 security.sudo.extraConfig = ''
281 Defaults!${auto-upgrade-script} !env_check
282 Defaults!${auto-upgrade-script} !env_keep
283 '';
284
285 nixpkgs.overlays = [
286 (import ../overlays/pinch.nix)
287 (import ../overlays/polite-merge.nix)
288 (self: super: {
289 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
290 /run/wrappers/bin/sudo ${auto-upgrade-script}
291 '';
292 })
293 ];
294
295 environment.systemPackages = [ pkgs.auto-upgrade ];
296
297 systemd.services.nixos-upgrade = {
298 description = "NixOS Upgrade";
299 restartIfChanged = false;
300 unitConfig.X-StopOnRemoval = false;
301 serviceConfig.Type = "oneshot";
302 environment = config.nix.envVars // {
303 inherit (config.environment.sessionVariables) NIX_PATH;
304 HOME = "/root";
305 } // config.networking.proxy.envVars;
306
307 path = with pkgs; [
308 config.nix.package.out
309 coreutils
310 git
311 gitMinimal
312 gnutar
313 gzip
314 xz.bin
315 ];
316
317 script = ''
318 set -eo pipefail
319
320 # Chill for awhile before applying updates. If applying an update
321 # badly breaks things, we want a window in which an operator can
322 # intervene either to fix the problem or disable automatic updates.
323 sleep 2h
324
325 ${auto-upgrade-script}
326 '';
327
328 startAt = cfg.dates;
329 };
330 };
331 }