]> git.scottworley.com Git - auto-upgrade-with-pinch/blame - modules/auto-upgrade.nix
Sync multiple repos, update multiple users
[auto-upgrade-with-pinch] / modules / auto-upgrade.nix
CommitLineData
901670f5
SW
1{ config, lib, pkgs, ... }:
2with lib;
364c110c
SW
3let
4 cfg = config.system.autoUpgradeWithPinch;
4cbd961f
SW
5 pull-repo-script = path: repo:
6 pkgs.writeShellScript "pull-repo" ''
7 set -eo pipefail
8
9 echo Pulling in ${escapeShellArg path} >&2
10
11 if [[ ! -e ${escapeShellArg path} ]];then
12 d=$(mktemp -d)
13 ${pkgs.git}/bin/git init "$d"
14 ${pkgs.git}/bin/git -C "$d" checkout -b \
15 ${escapeShellArg repo.localBranch}
16 ${pkgs.git}/bin/git -C "$d" remote add \
17 ${escapeShellArg repo.remoteName} \
18 ${escapeShellArg repo.url}
19 ${pkgs.git}/bin/git -C "$d" branch -u \
20 ${escapeShellArg repo.remoteBranch}
21 mkdir -p "$(dirname ${escapeShellArg path})"
22 mv "$d" ${escapeShellArg path}
23 fi
24
25 cd ${escapeShellArg path}
26
27 if [[ "$(${pkgs.git}/bin/git remote get-url \
28 ${escapeShellArg repo.remoteName})" != \
29 ${escapeShellArg repo.url} ]]
30 then
31 echo Expected git remote ${escapeShellArg repo.remoteName} \
32 to point at ${escapeShellArg repo.url} \
33 but it points at "$(${pkgs.git}/bin/git remote get-url \
34 ${escapeShellArg repo.remoteName})" >&2
35 ${
36 {
37 abort = "exit 1";
38 update = ''
39 echo Updating it >&2
40 ${pkgs.git}/bin/git -C "$d" remote set-url \
41 ${escapeShellArg repo.remoteName} \
42 ${escapeShellArg repo.url}
43 '';
44 }."${repo.onRemoteURLMismatch}"
45 }
46 fi
47
48 ${pkgs.git}/bin/git fetch \
49 ${escapeShellArg repo.remoteName} \
50 ${escapeShellArg repo.remoteBranch}
51
52 if [[ "$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)" != \
53 ${escapeShellArg repo.localBranch} ]]
54 then
55 echo Could not merge because currently-checked-out \
56 \""$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)"\" is not \
57 \"${escapeShellArg repo.localBranch}\"
58 ${
59 {
60 abort = "exit 1";
61 continue = "exit 0";
62 }."${repo.onBranchMismatch}"
63 }
64 fi
65
66
67 ${if repo.requireSignature then ''
68 PATH="${pkgs.keyedgit repo.signingKeys}/bin:$PATH" \
69 ${pkgs.polite-merge}/bin/polite-merge --ff-only --verify-signatures
70 '' else ''
71 ${pkgs.polite-merge}/bin/polite-merge --ff-only
72 ''}
73 '';
9dbfef33 74
364c110c 75 auto-upgrade-script = pkgs.writeShellScript "auto-upgrade" ''
2b58720b 76 ${pkgs.utillinux}/bin/flock /run/auto-upgrade-with-pinch ${
364c110c 77 pkgs.writeShellScript "auto-upgrade-with-lock-held" ''
4cbd961f 78 set -eo pipefail
eb0fa99c
SW
79
80 in_tmpdir() {
4cbd961f 81 d=$(${pkgs.coreutils}/bin/mktemp -d)
eb0fa99c
SW
82 pushd "$d"
83 "$@"
84 popd
4cbd961f 85 ${pkgs.coreutils}/bin/rm -r "$d"
eb0fa99c
SW
86 }
87
9dbfef33 88 # Pull updates
4cbd961f
SW
89 ${concatStringsSep "\n" (mapAttrsToList (path: repo: ''
90 /run/wrappers/bin/sudo -u ${escapeShellArg repo.user} \
91 ${pull-repo-script path repo}
92 '') cfg.repos)}
364c110c 93
fae44c38 94 # Update channels
4cbd961f 95 ${pkgs.pinch}/bin/pinch update ${escapeShellArgs cfg.pinchFiles}
fae44c38 96
eb0fa99c
SW
97 # Build
98 in_tmpdir ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
4cbd961f
SW
99 ${concatStringsSep "\n" (mapAttrsToList (user: env: ''
100 /run/wrappers/bin/sudo -u ${escapeShellArg user} \
101 nix-build --no-out-link '<nixpkgs>' \
102 -A ${escapeShellArg env.package}
103 '') cfg.userEnvironments)}
eb0fa99c
SW
104
105 # Install
106 ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch
4cbd961f
SW
107 ${concatStringsSep "\n" (mapAttrsToList (user: env: ''
108 /run/wrappers/bin/sudo -u ${escapeShellArg user} \
109 nix-env -f '<nixpkgs>' \
110 ${optionalString (env.otherPackagesAction != "keep") "-r"} \
111 -iA ${escapeShellArg env.package}
112 '') cfg.userEnvironments)}
364c110c
SW
113 ''
114 }
115 '';
901670f5
SW
116in {
117 options = {
118 system.autoUpgradeWithPinch = {
119
120 enable = mkOption {
121 type = types.bool;
122 default = false;
123 description = ''
124 Whether to periodically upgrade NixOS to the latest version.
125 Presumes that /etc/nixos is a git repo with a remote and
126 contains a pinch file called "channels".
127 '';
128 };
129
130 dates = mkOption {
131 default = "04:40";
132 type = types.str;
133 description = ''
134 Specification (in the format described by
135 <citerefentry><refentrytitle>systemd.time</refentrytitle>
136 <manvolnum>7</manvolnum></citerefentry>) of the time at
137 which the update will occur.
138 '';
139 };
d8537205 140
4cbd961f 141 repos = mkOption {
d8537205 142 description = ''
4cbd961f
SW
143 Git repositories to pull before running pinch. These are maintained
144 as git checkouts at specified places in the filesystem with specified
145 ownership rather than kept read-only in the nix store so that humans
146 can use them both as points of intervention in the automation and to
147 author and push changes back up.
d8537205 148 '';
4cbd961f
SW
149 type = types.attrsOf (types.submodule {
150 options = {
151 url = mkOption {
152 description = "Remote git repo.";
153 type = types.str;
154 };
155 remoteName = mkOption {
156 description = ''Name of the git remote. Customarily "origin".'';
157 type = types.str;
158 default = "origin";
159 };
160 onRemoteURLMismatch = mkOption {
161 description = ''
162 What to do if the remote URL in the git repo doesn't match the
163 URL configured here.
164 '';
165 type = types.enum [ "update" "abort" ];
166 default = "update";
167 };
168 onBranchMismatch = mkOption {
169 description = ''
170 What to do if a different branch is currently checked out.
eb0fa99c 171
4cbd961f
SW
172 (Changes from <literal>remoteBranch</literal> are only ever
173 merged into <literal>localBranch</literal>, so if a different
174 branch is checked out, no remote changes will be merged.)
175 '';
176 type = types.enum [ "continue" "abort" ];
177 default = "continue";
178 };
179 user = mkOption {
180 description = "User as which to run 'git fetch'";
181 type = types.str;
182 };
183 localBranch = mkOption {
184 description = "";
185 type = types.str;
186 default = "master";
187 };
188 remoteBranch = mkOption {
189 type = types.str;
190 default = "master";
191 };
192 requireSignature = mkOption {
193 type = types.bool;
194 default = true;
195 description = ''
196 Only pull when the tip of the remote ref is signed by a key
197 specifed in <literal>signingKeys</literal>.
198 '';
199 };
200 signingKeys = mkOption {
201 type = types.either types.path (types.listOf types.path);
202 description = ''
203 Files containing GPG keys that are authorized to sign updates.
204 Updates are only merged if the commit at the tip of the remote
205 ref is signed with one of these keys.
206 '';
207 };
208 };
209 });
210 example = {
211 "/etc/nixos" = {
212 url = "https://github.com/chkno/auto-upgrade-demo-nixos";
213 user = "root";
214 signingKeys = [ ./admins.asc ];
215 };
216 "/home/alice/.config/nixpkgs" = {
217 url = "https://github.com/chkno/auto-upgrade-demo-user-nixpkgs";
218 user = "alice";
219 signingKeys = [ ./admins.asc ./alice.asc ];
220 };
eb0fa99c 221 };
4cbd961f 222 };
eb0fa99c 223
4cbd961f
SW
224 pinchFiles = mkOption {
225 description = ''
226 Pinch files to use for channel updates. Typically these are inside
227 <literal>repos</literal>' paths.
228 '';
229 type = types.listOf types.path;
230 example = [ "/etc/nixos/channels" ];
231 };
eb0fa99c 232
4cbd961f
SW
233 userEnvironments = mkOption {
234 description = ''
235 User environments to update as part of an upgrade run.
236 '';
237 type = types.attrsOf (types.submodule {
238 options = {
239 package = mkOption {
240 type = types.str;
241 default = "nixos.userPackages";
242 description = ''
243 The name of the single package that will be updated. You'll
244 want to create an 'entire user environment' package as shown in
245 https://nixos.wiki/wiki/FAQ#How_can_I_manage_software_with_nix-env_like_with_configuration.nix.3F
246 '';
247 };
248 otherPackagesAction = mkOption {
249 type = types.enum [ "remove" "keep" "abort" ];
250 default = "remove";
251 description = ''
252 What to do with packages other than <literal>package</literal>.
eb0fa99c 253
4cbd961f
SW
254 THIS DEFAULTS TO "remove", WHICH IS POTENTIALLY SOMEWHAT
255 DESTRUCTIVE! This is the default because it is the recommended
256 setting -- This module recommends managing your environment
257 through your one entire-environment <literal>package</literal>.
258 This keeps your environment declarative and ensures that all
259 packages receive regular updates.
260 '';
261 # It seems like "upgrade" ought to be another choice here, powered
262 # by "nix-env --upgrade". But when I tried this, it didn't work.
263 };
264 };
265 });
266 example = { alice = { }; };
eb0fa99c 267 };
901670f5
SW
268 };
269 };
270
271 config = lib.mkIf cfg.enable {
364c110c
SW
272
273 security.sudo.extraRules = lib.mkAfter [{
274 groups = [ "users" ];
275 commands = [{
276 command = "${auto-upgrade-script}";
277 options = [ "NOPASSWD" "NOSETENV" ];
278 }];
279 }];
280 # NOSETENV above still allows through ~17 vars, including PATH. Block those
281 # as well:
282 security.sudo.extraConfig = ''
283 Defaults!${auto-upgrade-script} !env_check
284 Defaults!${auto-upgrade-script} !env_keep
285 '';
286
d8537205
SW
287 nixpkgs.overlays = [
288 (import ../overlays/keyedgit.nix)
289 (import ../overlays/pinch.nix)
5048e8ce 290 (import ../overlays/polite-merge.nix)
5aaf4680
SW
291 (self: super: {
292 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
4acf153c 293 /run/wrappers/bin/sudo ${auto-upgrade-script}
5aaf4680
SW
294 '';
295 })
d8537205 296 ];
5aaf4680
SW
297
298 environment.systemPackages = [ pkgs.auto-upgrade ];
299
901670f5
SW
300 systemd.services.nixos-upgrade = {
301 description = "NixOS Upgrade";
302 restartIfChanged = false;
303 unitConfig.X-StopOnRemoval = false;
304 serviceConfig.Type = "oneshot";
305 environment = config.nix.envVars // {
306 inherit (config.environment.sessionVariables) NIX_PATH;
307 HOME = "/root";
308 } // config.networking.proxy.envVars;
309
310 path = with pkgs; [
311 config.nix.package.out
312 coreutils
313 git
314 gitMinimal
315 gnutar
316 gzip
901670f5
SW
317 xz.bin
318 ];
319
320 script = ''
4cbd961f 321 set -eo pipefail
8569b965
SW
322
323 # Chill for awhile before applying updates. If applying an update
324 # badly breaks things, we want a window in which an operator can
325 # intervene either to fix the problem or disable automatic updates.
326 sleep 2h
327
f43ffe15
SW
328 # Wait until outside business hours
329 now=$(date +%s)
330 day_of_week=$(date +%u)
331 business_start=$(date -d 8:00 +%s)
332 business_end=$( date -d 17:00 +%s)
333 if (( day_of_week <= 5 && now > business_start && now < business_end ));then
334 delay=$((business_end - now))
335 echo "Waiting $delay seconds so we don't upgrade during business hours" >&2
336 sleep "$delay"
337 fi
338
364c110c 339 ${auto-upgrade-script}
901670f5
SW
340 '';
341
342 startAt = cfg.dates;
343 };
344 };
345}