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