]> git.scottworley.com Git - auto-upgrade-with-pinch/blob - modules/auto-upgrade.nix
5ee707f808faaff5a6dcacb0d85f962f4dffcc61
[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 = 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 '';
74
75 auto-upgrade-script = pkgs.writeShellScript "auto-upgrade" ''
76 ${pkgs.utillinux}/bin/flock /run/auto-upgrade-with-pinch ${
77 pkgs.writeShellScript "auto-upgrade-with-lock-held" ''
78 set -eo pipefail
79
80 in_tmpdir() {
81 d=$(${pkgs.coreutils}/bin/mktemp -d)
82 pushd "$d"
83 "$@"
84 popd
85 ${pkgs.coreutils}/bin/rm -r "$d"
86 }
87
88 # Pull updates
89 ${concatStringsSep "\n" (mapAttrsToList (path: repo: ''
90 /run/wrappers/bin/sudo -u ${escapeShellArg repo.user} \
91 ${pull-repo-script path repo}
92 '') cfg.repos)}
93
94 # Update channels
95 ${pkgs.pinch}/bin/pinch update ${escapeShellArgs cfg.pinchFiles}
96
97 # Build
98 in_tmpdir ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
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)}
104
105 # Install
106 ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch
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)}
113 ''
114 }
115 '';
116 in {
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 };
140
141 repos = mkOption {
142 description = ''
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.
148 '';
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.
171
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 };
221 };
222 };
223
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 };
232
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 = "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>.
253
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 = { }; };
267 };
268 };
269 };
270
271 config = lib.mkIf cfg.enable {
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
287 nixpkgs.overlays = [
288 (import ../overlays/keyedgit.nix)
289 (import ../overlays/pinch.nix)
290 (import ../overlays/polite-merge.nix)
291 (self: super: {
292 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
293 /run/wrappers/bin/sudo ${auto-upgrade-script}
294 '';
295 })
296 ];
297
298 environment.systemPackages = [ pkgs.auto-upgrade ];
299
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
317 xz.bin
318 ];
319
320 script = ''
321 set -eo pipefail
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
328 ${auto-upgrade-script}
329 '';
330
331 startAt = cfg.dates;
332 };
333 };
334 }