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