1 { config, lib, pkgs, ... }:
4 cfg = config.system.autoUpgradeWithPinch;
5 pull-repo-script = path: repo:
6 pkgs.writeShellScript "pull-repo" ''
9 echo Pulling in ${escapeShellArg path} >&2
11 if [[ ! -e ${escapeShellArg path} ]];then
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}
25 cd ${escapeShellArg path}
27 if [[ "$(${pkgs.git}/bin/git remote get-url \
28 ${escapeShellArg repo.remoteName})" != \
29 ${escapeShellArg repo.url} ]]
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
40 ${pkgs.git}/bin/git -C "$d" remote set-url \
41 ${escapeShellArg repo.remoteName} \
42 ${escapeShellArg repo.url}
44 }."${repo.onRemoteURLMismatch}"
48 ${pkgs.git}/bin/git fetch \
49 ${escapeShellArg repo.remoteName} \
50 ${escapeShellArg repo.remoteBranch}
52 if [[ "$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)" != \
53 ${escapeShellArg repo.localBranch} ]]
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}\"
62 }."${repo.onBranchMismatch}"
67 ${if repo.requireSignature then ''
68 PATH="${pkgs.keyedgit repo.signingKeys}/bin:$PATH" \
69 ${pkgs.polite-merge}/bin/polite-merge --ff-only --verify-signatures
71 ${pkgs.polite-merge}/bin/polite-merge --ff-only
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" ''
81 d=$(${pkgs.coreutils}/bin/mktemp -d)
85 ${pkgs.coreutils}/bin/rm -r "$d"
89 ${concatStringsSep "\n" (mapAttrsToList (path: repo: ''
90 /run/wrappers/bin/sudo -u ${escapeShellArg repo.user} \
91 ${pull-repo-script path repo}
95 ${pkgs.pinch}/bin/pinch update ${escapeShellArgs cfg.pinchFiles}
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)}
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)}
118 system.autoUpgradeWithPinch = {
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".
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.
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.
149 type = types.attrsOf (types.submodule {
152 description = "Remote git repo.";
155 remoteName = mkOption {
156 description = ''Name of the git remote. Customarily "origin".'';
160 onRemoteURLMismatch = mkOption {
162 What to do if the remote URL in the git repo doesn't match the
165 type = types.enum [ "update" "abort" ];
168 onBranchMismatch = mkOption {
170 What to do if a different branch is currently checked out.
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.)
176 type = types.enum [ "continue" "abort" ];
177 default = "continue";
180 description = "User as which to run 'git fetch'";
183 localBranch = mkOption {
188 remoteBranch = mkOption {
192 requireSignature = mkOption {
196 Only pull when the tip of the remote ref is signed by a key
197 specifed in <literal>signingKeys</literal>.
200 signingKeys = mkOption {
201 type = types.either types.path (types.listOf types.path);
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.
212 url = "https://github.com/chkno/auto-upgrade-demo-nixos";
214 signingKeys = [ ./admins.asc ];
216 "/home/alice/.config/nixpkgs" = {
217 url = "https://github.com/chkno/auto-upgrade-demo-user-nixpkgs";
219 signingKeys = [ ./admins.asc ./alice.asc ];
224 pinchFiles = mkOption {
226 Pinch files to use for channel updates. Typically these are inside
227 <literal>repos</literal>' paths.
229 type = types.listOf types.path;
230 example = [ "/etc/nixos/channels" ];
233 userEnvironments = mkOption {
235 User environments to update as part of an upgrade run.
237 type = types.attrsOf (types.submodule {
241 default = "nixos.userPackages";
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
248 otherPackagesAction = mkOption {
249 type = types.enum [ "remove" "keep" "abort" ];
252 What to do with packages other than <literal>package</literal>.
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.
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.
266 example = { alice = { }; };
271 config = lib.mkIf cfg.enable {
273 security.sudo.extraRules = lib.mkAfter [{
274 groups = [ "users" ];
276 command = "${auto-upgrade-script}";
277 options = [ "NOPASSWD" "NOSETENV" ];
280 # NOSETENV above still allows through ~17 vars, including PATH. Block those
282 security.sudo.extraConfig = ''
283 Defaults!${auto-upgrade-script} !env_check
284 Defaults!${auto-upgrade-script} !env_keep
288 (import ../overlays/keyedgit.nix)
289 (import ../overlays/pinch.nix)
290 (import ../overlays/polite-merge.nix)
292 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
293 /run/wrappers/bin/sudo ${auto-upgrade-script}
298 environment.systemPackages = [ pkgs.auto-upgrade ];
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;
308 } // config.networking.proxy.envVars;
311 config.nix.package.out
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.
328 ${auto-upgrade-script}