X-Git-Url: http://git.scottworley.com/auto-upgrade-with-pinch/blobdiff_plain/901670f5f4337998c430ae27c3b31f6db4a5a8fe..4cbd961f432ca85b73d746fe71c253d59eedc201:/modules/auto-upgrade.nix diff --git a/modules/auto-upgrade.nix b/modules/auto-upgrade.nix index 973ac22..ef705eb 100644 --- a/modules/auto-upgrade.nix +++ b/modules/auto-upgrade.nix @@ -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 '' \ + -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 '' \ + ${optionalString (env.otherPackagesAction != "keep") "-r"} \ + -iA ${escapeShellArg env.package} + '') cfg.userEnvironments)} + '' + } + ''; in { options = { system.autoUpgradeWithPinch = { @@ -25,11 +137,166 @@ in { which the update will occur. ''; }; + + repos = mkOption { + description = '' + 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 remoteBranch are only ever + merged into localBranch, 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 signingKeys. + ''; + }; + 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 + repos' 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 package. + + 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 package. + 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 { - nixpkgs.overlays = [ (import ../overlays/pinch.nix) ]; + + 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" '' + /run/wrappers/bin/sudo ${auto-upgrade-script} + ''; + }) + ]; + + environment.systemPackages = [ pkgs.auto-upgrade ]; + systemd.services.nixos-upgrade = { description = "NixOS Upgrade"; restartIfChanged = false; @@ -47,19 +314,29 @@ in { gitMinimal gnutar gzip - pinch xz.bin ]; script = '' - set -e - ( - cd /etc/nixos - git pull --ff-only - pinch update channels - ) - - ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch --no-build-output + 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 + + # 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;