{ config, lib, pkgs, ... }: with lib; 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 = { enable = mkOption { type = types.bool; default = false; description = '' Whether to periodically upgrade NixOS to the latest version. Presumes that /etc/nixos is a git repo with a remote and contains a pinch file called "channels". ''; }; dates = mkOption { default = "04:40"; type = types.str; description = '' Specification (in the format described by systemd.time 7) of the time at 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 { 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; unitConfig.X-StopOnRemoval = false; serviceConfig.Type = "oneshot"; environment = config.nix.envVars // { inherit (config.environment.sessionVariables) NIX_PATH; HOME = "/root"; } // config.networking.proxy.envVars; path = with pkgs; [ config.nix.package.out coreutils git gitMinimal gnutar gzip xz.bin ]; script = '' 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; }; }; }