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