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 -e
+ set -eo pipefail
in_tmpdir() {
- d=$(mktemp -d)
+ d=$(${pkgs.coreutils}/bin/mktemp -d)
pushd "$d"
"$@"
popd
- rm -r "$d"
- }
-
- as_user() {
- ${
- if cfg.userEnvironment.enable then ''
- /run/wrappers/bin/sudo -u ${escapeShellArg cfg.userEnvironment.user} "$@"
- '' else ''
- :
- ''
- }
+ ${pkgs.coreutils}/bin/rm -r "$d"
}
- # Fetch updates
- (
- cd /etc/nixos
- ${pkgs.git}/bin/git fetch
- PATH="${pkgs.keyedgit cfg.keys}/bin:$PATH" ${pkgs.polite-merge}/bin/polite-merge --ff-only --verify-signatures
- )
+ # 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 /etc/nixos/channels
+ ${pkgs.pinch}/bin/pinch update ${escapeShellArgs cfg.pinchFiles}
# Build
in_tmpdir ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
- as_user nix-build --no-out-link '<nixpkgs>' -A ${
- escapeShellArg cfg.userEnvironment.package
- }
+ ${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
- as_user nix-env -f '<nixpkgs>' -riA ${
- escapeShellArg cfg.userEnvironment.package
- }
+ ${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)}
''
}
'';
'';
};
- keys = mkOption {
- type = types.path;
+ repos = mkOption {
description = ''
- File containing GPG keys that sign updates. Updates are only merged
- if the commit at the tip of the remote branch is signed with one of
- these keys.
+ 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.
- userEnvironment = {
- enable = mkOption {
- type = types.bool;
- default = false;
- description = ''
- Whether to update a user-environment as well. This update is done
- with nix-env -riA. Note the -r! I.e., ALL OTHER PACKAGES INSTALLED
- WITH nix-env WILL BE DELETED!
-
- This presumes that you have configured 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
-
- To check if you're set up for this, run "nix-env --query". If it
- only lists one package, you're good to go.
- '';
+ (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 ];
+ };
};
+ };
- user = mkOption {
- type = types.str;
- description = ''
- The username of the user whose environment should be updated.
- '';
- };
+ 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" ];
+ };
- package = mkOption {
- type = types.str;
- example = "nixos.userPackages";
- description = ''
- The name of the single package that is the user's entire environment.
- '';
- };
+ 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 = "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 = { }; };
};
};
};
];
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
- # 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;
};
-
- assertions = [{
- assertion = cfg.userEnvironment.enable -> cfg.enable;
- message =
- "User environment upgrades cannot yet be enabled separately from system upgrades.";
- }];
};
}