]> git.scottworley.com Git - auto-upgrade-with-pinch/blobdiff - modules/auto-upgrade.nix
Sync multiple repos, update multiple users
[auto-upgrade-with-pinch] / modules / auto-upgrade.nix
index 974ebb7a9d96a091e0157428aa6d817b9bfa0949..ef705eb8ad30176d93202f6c99f1504e93c013ef 100644 (file)
@@ -1,6 +1,118 @@
 { config, lib, pkgs, ... }:
 with lib;
-let cfg = config.system.autoUpgradeWithPinch;
+let
+  cfg = config.system.autoUpgradeWithPinch;
+  pull-repo-script = path: repo:
+    pkgs.writeShellScript "pull-repo" ''
+      set -eo pipefail
+
+      echo Pulling in ${escapeShellArg path} >&2
+
+      if [[ ! -e ${escapeShellArg path} ]];then
+        d=$(mktemp -d)
+        ${pkgs.git}/bin/git init "$d"
+        ${pkgs.git}/bin/git -C "$d" checkout -b \
+          ${escapeShellArg repo.localBranch}
+        ${pkgs.git}/bin/git -C "$d" remote add \
+          ${escapeShellArg repo.remoteName} \
+          ${escapeShellArg repo.url}
+        ${pkgs.git}/bin/git -C "$d" branch -u \
+          ${escapeShellArg repo.remoteBranch}
+        mkdir -p "$(dirname ${escapeShellArg path})"
+        mv "$d" ${escapeShellArg path}
+      fi
+
+      cd ${escapeShellArg path}
+
+      if [[ "$(${pkgs.git}/bin/git remote get-url \
+               ${escapeShellArg repo.remoteName})" != \
+            ${escapeShellArg repo.url} ]]
+      then
+        echo Expected git remote ${escapeShellArg repo.remoteName} \
+          to point at ${escapeShellArg repo.url} \
+          but it points at "$(${pkgs.git}/bin/git remote get-url \
+                              ${escapeShellArg repo.remoteName})" >&2
+        ${
+          {
+            abort = "exit 1";
+            update = ''
+              echo Updating it >&2
+              ${pkgs.git}/bin/git -C "$d" remote set-url \
+                ${escapeShellArg repo.remoteName} \
+                ${escapeShellArg repo.url}
+            '';
+          }."${repo.onRemoteURLMismatch}"
+        }
+      fi
+
+      ${pkgs.git}/bin/git fetch \
+        ${escapeShellArg repo.remoteName} \
+        ${escapeShellArg repo.remoteBranch}
+
+      if [[ "$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)" != \
+              ${escapeShellArg repo.localBranch} ]]
+      then
+        echo Could not merge because currently-checked-out \
+             \""$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)"\" is not \
+             \"${escapeShellArg repo.localBranch}\"
+        ${
+          {
+            abort = "exit 1";
+            continue = "exit 0";
+          }."${repo.onBranchMismatch}"
+        }
+      fi
+
+
+      ${if repo.requireSignature then ''
+        PATH="${pkgs.keyedgit repo.signingKeys}/bin:$PATH" \
+          ${pkgs.polite-merge}/bin/polite-merge --ff-only --verify-signatures
+      '' else ''
+        ${pkgs.polite-merge}/bin/polite-merge --ff-only
+      ''}
+    '';
+
+  auto-upgrade-script = pkgs.writeShellScript "auto-upgrade" ''
+    ${pkgs.utillinux}/bin/flock /run/auto-upgrade-with-pinch ${
+      pkgs.writeShellScript "auto-upgrade-with-lock-held" ''
+        set -eo pipefail
+
+        in_tmpdir() {
+          d=$(${pkgs.coreutils}/bin/mktemp -d)
+          pushd "$d"
+          "$@"
+          popd
+          ${pkgs.coreutils}/bin/rm -r "$d"
+        }
+
+        # Pull updates
+        ${concatStringsSep "\n" (mapAttrsToList (path: repo: ''
+          /run/wrappers/bin/sudo -u ${escapeShellArg repo.user} \
+            ${pull-repo-script path repo}
+        '') cfg.repos)}
+
+        # Update channels
+        ${pkgs.pinch}/bin/pinch update ${escapeShellArgs cfg.pinchFiles}
+
+        # Build
+        in_tmpdir ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
+        ${concatStringsSep "\n" (mapAttrsToList (user: env: ''
+          /run/wrappers/bin/sudo -u ${escapeShellArg user} \
+            nix-build --no-out-link '<nixpkgs>' \
+            -A ${escapeShellArg env.package}
+        '') cfg.userEnvironments)}
+
+        # Install
+        ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch
+        ${concatStringsSep "\n" (mapAttrsToList (user: env: ''
+          /run/wrappers/bin/sudo -u ${escapeShellArg user} \
+            nix-env -f '<nixpkgs>' \
+            ${optionalString (env.otherPackagesAction != "keep") "-r"} \
+            -iA ${escapeShellArg env.package}
+        '') cfg.userEnvironments)}
+      ''
+    }
+  '';
 in {
   options = {
     system.autoUpgradeWithPinch = {
@@ -26,30 +138,159 @@ in {
         '';
       };
 
-      key = mkOption {
-        type = types.path;
+      repos = mkOption {
         description = ''
-          GPG key that signs updates.  Updates are only merged if the commit
-          at the tip of the remote branch is signed with this key.
+          Git repositories to pull before running pinch.  These are maintained
+          as git checkouts at specified places in the filesystem with specified
+          ownership rather than kept read-only in the nix store so that humans
+          can use them both as points of intervention in the automation and to
+          author and push changes back up.
         '';
+        type = types.attrsOf (types.submodule {
+          options = {
+            url = mkOption {
+              description = "Remote git repo.";
+              type = types.str;
+            };
+            remoteName = mkOption {
+              description = ''Name of the git remote.  Customarily "origin".'';
+              type = types.str;
+              default = "origin";
+            };
+            onRemoteURLMismatch = mkOption {
+              description = ''
+                What to do if the remote URL in the git repo doesn't match the
+                URL configured here.
+              '';
+              type = types.enum [ "update" "abort" ];
+              default = "update";
+            };
+            onBranchMismatch = mkOption {
+              description = ''
+                What to do if a different branch is currently checked out.
+
+                (Changes from <literal>remoteBranch</literal> are only ever
+                merged into <literal>localBranch</literal>, so if a different
+                branch is checked out, no remote changes will be merged.)
+              '';
+              type = types.enum [ "continue" "abort" ];
+              default = "continue";
+            };
+            user = mkOption {
+              description = "User as which to run 'git fetch'";
+              type = types.str;
+            };
+            localBranch = mkOption {
+              description = "";
+              type = types.str;
+              default = "master";
+            };
+            remoteBranch = mkOption {
+              type = types.str;
+              default = "master";
+            };
+            requireSignature = mkOption {
+              type = types.bool;
+              default = true;
+              description = ''
+                Only pull when the tip of the remote ref is signed by a key
+                specifed in <literal>signingKeys</literal>.
+              '';
+            };
+            signingKeys = mkOption {
+              type = types.either types.path (types.listOf types.path);
+              description = ''
+                Files containing GPG keys that are authorized to sign updates.
+                Updates are only merged if the commit at the tip of the remote
+                ref is signed with one of these keys.
+              '';
+            };
+          };
+        });
+        example = {
+          "/etc/nixos" = {
+            url = "https://github.com/chkno/auto-upgrade-demo-nixos";
+            user = "root";
+            signingKeys = [ ./admins.asc ];
+          };
+          "/home/alice/.config/nixpkgs" = {
+            url = "https://github.com/chkno/auto-upgrade-demo-user-nixpkgs";
+            user = "alice";
+            signingKeys = [ ./admins.asc ./alice.asc ];
+          };
+        };
+      };
+
+      pinchFiles = mkOption {
+        description = ''
+          Pinch files to use for channel updates.  Typically these are inside
+          <literal>repos</literal>' paths.
+        '';
+        type = types.listOf types.path;
+        example = [ "/etc/nixos/channels" ];
+      };
+
+      userEnvironments = mkOption {
+        description = ''
+          User environments to update as part of an upgrade run.
+        '';
+        type = types.attrsOf (types.submodule {
+          options = {
+            package = mkOption {
+              type = types.str;
+              default = "nixos.userPackages";
+              description = ''
+                The name of the single package that will be updated.  You'll
+                want to create an 'entire user environment' package as shown in
+                https://nixos.wiki/wiki/FAQ#How_can_I_manage_software_with_nix-env_like_with_configuration.nix.3F
+              '';
+            };
+            otherPackagesAction = mkOption {
+              type = types.enum [ "remove" "keep" "abort" ];
+              default = "remove";
+              description = ''
+                What to do with packages other than <literal>package</literal>.
+
+                THIS DEFAULTS TO "remove", WHICH IS POTENTIALLY SOMEWHAT
+                DESTRUCTIVE!  This is the default because it is the recommended
+                setting -- This module recommends managing your environment
+                through your one entire-environment <literal>package</literal>.
+                This keeps your environment declarative and ensures that all
+                packages receive regular updates.
+              '';
+              # It seems like "upgrade" ought to be another choice here, powered
+              # by "nix-env --upgrade".  But when I tried this, it didn't work.
+            };
+          };
+        });
+        example = { alice = { }; };
       };
     };
   };
 
   config = lib.mkIf cfg.enable {
+
+    security.sudo.extraRules = lib.mkAfter [{
+      groups = [ "users" ];
+      commands = [{
+        command = "${auto-upgrade-script}";
+        options = [ "NOPASSWD" "NOSETENV" ];
+      }];
+    }];
+    # NOSETENV above still allows through ~17 vars, including PATH.  Block those
+    # as well:
+    security.sudo.extraConfig = ''
+      Defaults!${auto-upgrade-script} !env_check
+      Defaults!${auto-upgrade-script} !env_keep
+    '';
+
     nixpkgs.overlays = [
       (import ../overlays/keyedgit.nix)
       (import ../overlays/pinch.nix)
+      (import ../overlays/polite-merge.nix)
       (self: super: {
         auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
-        set -e
-        (
-          cd /etc/nixos
-          ${self.keyedgit cfg.key}/bin/git pull --ff-only --verify-signatures
-          ${self.pinch}/bin/pinch update channels
-        )
-
-        ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch --no-build-output
+          /run/wrappers/bin/sudo ${auto-upgrade-script}
         '';
       })
     ];
@@ -77,14 +318,25 @@ in {
       ];
 
       script = ''
-        set -e
+        set -eo pipefail
 
         # Chill for awhile before applying updates.  If applying an update
         # badly breaks things, we want a window in which an operator can
         # intervene either to fix the problem or disable automatic updates.
         sleep 2h
 
-        ${pkgs.auto-upgrade}/bin/auto-upgrade
+        # Wait until outside business hours
+        now=$(date +%s)
+        day_of_week=$(date +%u)
+        business_start=$(date -d  8:00 +%s)
+        business_end=$(  date -d 17:00 +%s)
+        if (( day_of_week <= 5 && now > business_start && now < business_end ));then
+          delay=$((business_end - now))
+          echo "Waiting $delay seconds so we don't upgrade during business hours" >&2
+          sleep "$delay"
+        fi
+
+        ${auto-upgrade-script}
       '';
 
       startAt = cfg.dates;