]> git.scottworley.com Git - auto-upgrade-with-pinch/blame - modules/auto-upgrade.nix
License and README
[auto-upgrade-with-pinch] / modules / auto-upgrade.nix
CommitLineData
15d7ea95
SW
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
901670f5
SW
7{ config, lib, pkgs, ... }:
8with lib;
364c110c 9let
edaaa0c0 10 local-pkgs = import ../. { inherit pkgs; };
364c110c 11 cfg = config.system.autoUpgradeWithPinch;
eff66a9c
SW
12 pull-repo-script = pkgs.writeShellScript "pull-repo" ''
13 set -eo pipefail
4cbd961f 14
eff66a9c
SW
15 path=$1
16 config=$2
4cbd961f 17
eff66a9c
SW
18 prop() {
19 ${pkgs.jq}/bin/jq -r ".$1" <<< "$config"
20 }
f1a53b29 21
eff66a9c 22 echo Pulling in "$path" >&2
f1a53b29 23
eff66a9c
SW
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
4cbd961f 33
eff66a9c 34 cd "$path"
f1a53b29 35
eff66a9c
SW
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
4cbd961f 45
eff66a9c 46 ${pkgs.git}/bin/git fetch "$(prop remoteName)" "$(prop remoteBranch)"
4cbd961f 47
eff66a9c
SW
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
4cbd961f 57
eff66a9c
SW
58 if [[ "$(prop requireSignature)" == true ]]; then
59 ${pkgs.polite-merge}/bin/polite-merge \
edaaa0c0 60 -c gpg.program=${escapeShellArg (local-pkgs.keyed-gpg cfg.signingKeys)} \
eff66a9c
SW
61 merge --ff-only --verify-signatures
62 else
63 ${pkgs.polite-merge}/bin/polite-merge merge --ff-only
64 fi
65 '';
9dbfef33 66
364c110c 67 auto-upgrade-script = pkgs.writeShellScript "auto-upgrade" ''
77278dee 68 ${pkgs.util-linux}/bin/flock /run/auto-upgrade-with-pinch ${
364c110c 69 pkgs.writeShellScript "auto-upgrade-with-lock-held" ''
4cbd961f 70 set -eo pipefail
eb0fa99c 71
f1a53b29
SW
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
eb0fa99c 92 in_tmpdir() {
4cbd961f 93 d=$(${pkgs.coreutils}/bin/mktemp -d)
eb0fa99c
SW
94 pushd "$d"
95 "$@"
96 popd
4cbd961f 97 ${pkgs.coreutils}/bin/rm -r "$d"
eb0fa99c
SW
98 }
99
f1a53b29
SW
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
e830691a 153 config=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -A config \
f1a53b29
SW
154 --arg upgradeConfig ${
155 escapeShellArg ("["
156 + lib.concatMapStringsSep " " lib.strings.escapeNixString
157 cfg.upgradeConfig + "]")
e830691a 158 } ${../upgrade-config.nix})
f1a53b29
SW
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
9dbfef33 172 # Pull updates
f1a53b29
SW
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 []' )
364c110c 177
fae44c38 178 # Update channels
f1a53b29 179 config_query '.pinchFiles[]' | ${pkgs.findutils}/bin/xargs --no-run-if-empty --delimiter=\\n ${pkgs.pinch}/bin/pinch update "''${pinch_args[@]}"
fae44c38 180
eb0fa99c 181 # Build
f1a53b29
SW
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 []' )
eb0fa99c
SW
187
188 # Install
f1a53b29
SW
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 []' )
364c110c
SW
198 ''
199 }
200 '';
f1a53b29 201
901670f5
SW
202in {
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 };
d8537205 226
5ea02587
SW
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
f1a53b29
SW
236 upgradeConfig = mkOption {
237 type = types.listOf types.path;
d8537205 238 description = ''
f1a53b29
SW
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.
d8537205 244 '';
4cbd961f 245 };
eb0fa99c 246
f1a53b29
SW
247 upgradeConfigOwnershipPolicy = mkOption {
248 type = types.enum [ "root" "wheel" "any" ];
249 default = "root";
4cbd961f 250 description = ''
f1a53b29
SW
251 Verify ownership of upgrade config files before using them for
252 system upgrades.
eb0fa99c 253
f1a53b29
SW
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.
4cbd961f 257 '';
eb0fa99c 258 };
901670f5
SW
259 };
260 };
261
262 config = lib.mkIf cfg.enable {
364c110c
SW
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
d8537205 278 nixpkgs.overlays = [
d8537205 279 (import ../overlays/pinch.nix)
5048e8ce 280 (import ../overlays/polite-merge.nix)
5aaf4680
SW
281 (self: super: {
282 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
4acf153c 283 /run/wrappers/bin/sudo ${auto-upgrade-script}
5aaf4680
SW
284 '';
285 })
d8537205 286 ];
5aaf4680
SW
287
288 environment.systemPackages = [ pkgs.auto-upgrade ];
289
901670f5
SW
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
901670f5
SW
307 xz.bin
308 ];
309
310 script = ''
4cbd961f 311 set -eo pipefail
8569b965
SW
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
364c110c 318 ${auto-upgrade-script}
901670f5
SW
319 '';
320
321 startAt = cfg.dates;
322 };
323 };
324}