]>
Commit | Line | Data |
---|---|---|
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 = "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>. | |
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 | } |