{ config, lib, pkgs, ... }: let cfg = config.clan.localbackup; uniqueFolders = lib.unique ( lib.flatten (lib.mapAttrsToList (_name: state: state.folders) config.clan.core.state) ); rsnapshotConfig = target: '' config_version 1.2 snapshot_root ${target.directory} sync_first 1 cmd_cp ${pkgs.coreutils}/bin/cp cmd_rm ${pkgs.coreutils}/bin/rm cmd_rsync ${pkgs.rsync}/bin/rsync cmd_ssh ${pkgs.openssh}/bin/ssh cmd_logger ${pkgs.inetutils}/bin/logger cmd_du ${pkgs.coreutils}/bin/du cmd_rsnapshot_diff ${pkgs.rsnapshot}/bin/rsnapshot-diff ${lib.optionalString (target.postBackupHook != null) '' cmd_postexec ${pkgs.writeShellScript "postexec.sh" '' set -efu -o pipefail ${target.postBackupHook} ''} ''} retain snapshot ${builtins.toString config.clan.localbackup.snapshots} ${lib.concatMapStringsSep "\n" (folder: '' backup ${folder} ${config.networking.hostName}/ '') uniqueFolders} ''; in { options.clan.localbackup = { targets = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule ( { name, ... }: { options = { name = lib.mkOption { type = lib.types.strMatching "^[a-zA-Z0-9._-]+$"; default = name; description = "the name of the backup job"; }; directory = lib.mkOption { type = lib.types.str; description = "the directory to backup"; }; mountpoint = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "mountpoint of the directory to backup. If set, the directory will be mounted before the backup and unmounted afterwards"; }; preMountHook = lib.mkOption { type = lib.types.nullOr lib.types.lines; default = null; description = "Shell commands to run before the directory is mounted"; }; postMountHook = lib.mkOption { type = lib.types.nullOr lib.types.lines; default = null; description = "Shell commands to run after the directory is mounted"; }; preUnmountHook = lib.mkOption { type = lib.types.nullOr lib.types.lines; default = null; description = "Shell commands to run before the directory is unmounted"; }; postUnmountHook = lib.mkOption { type = lib.types.nullOr lib.types.lines; default = null; description = "Shell commands to run after the directory is unmounted"; }; preBackupHook = lib.mkOption { type = lib.types.nullOr lib.types.lines; default = null; description = "Shell commands to run before the backup"; }; postBackupHook = lib.mkOption { type = lib.types.nullOr lib.types.lines; default = null; description = "Shell commands to run after the backup"; }; }; } ) ); default = { }; description = "List of directories where backups are stored"; }; snapshots = lib.mkOption { type = lib.types.int; default = 20; description = "Number of snapshots to keep"; }; }; config = let mountHook = target: '' if [[ -x /run/current-system/sw/bin/localbackup-mount-${target.name} ]]; then /run/current-system/sw/bin/localbackup-mount-${target.name} fi if [[ -x /run/current-system/sw/bin/localbackup-unmount-${target.name} ]]; then trap "/run/current-system/sw/bin/localbackup-unmount-${target.name}" EXIT fi ''; in lib.mkIf (cfg.targets != { }) { environment.systemPackages = [ (pkgs.writeShellScriptBin "localbackup-create" '' set -efu -o pipefail export PATH=${ lib.makeBinPath [ pkgs.rsnapshot pkgs.coreutils pkgs.util-linux ] } ${lib.concatMapStringsSep "\n" (target: '' ${mountHook target} echo "Creating backup '${target.name}'" ${lib.optionalString (target.preBackupHook != null) '' ( ${target.preBackupHook} ) ''} declare -A preCommandErrors ${lib.concatMapStringsSep "\n" ( state: lib.optionalString (state.preBackupCommand != null) '' echo "Running pre-backup command for ${state.name}" if ! /run/current-system/sw/bin/${state.preBackupCommand}; then preCommandErrors["${state.name}"]=1 fi '' ) (builtins.attrValues config.clan.core.state)} rsnapshot -c "${pkgs.writeText "rsnapshot.conf" (rsnapshotConfig target)}" sync rsnapshot -c "${pkgs.writeText "rsnapshot.conf" (rsnapshotConfig target)}" snapshot '') (builtins.attrValues cfg.targets)}'') (pkgs.writeShellScriptBin "localbackup-list" '' set -efu -o pipefail export PATH=${ lib.makeBinPath [ pkgs.jq pkgs.findutils pkgs.coreutils pkgs.util-linux ] } (${ lib.concatMapStringsSep "\n" (target: '' ( ${mountHook target} find ${lib.escapeShellArg target.directory} -mindepth 1 -maxdepth 1 -name "snapshot.*" -print0 -type d \ | jq -Rs 'split("\u0000") | .[] | select(. != "") | { "name": ("${target.name}::" + .)}' ) '') (builtins.attrValues cfg.targets) }) | jq -s . '') (pkgs.writeShellScriptBin "localbackup-restore" '' set -efu -o pipefail export PATH=${ lib.makeBinPath [ pkgs.rsync pkgs.coreutils pkgs.util-linux pkgs.gawk ] } if [[ "''${NAME:-}" == "" ]]; then echo "No backup name given via NAME environment variable" exit 1 fi if [[ "''${FOLDERS:-}" == "" ]]; then echo "No folders given via FOLDERS environment variable" exit 1 fi name=$(awk -F'::' '{print $1}' <<< $NAME) backupname=''${NAME#$name::} if command -v localbackup-mount-$name; then localbackup-mount-$name fi if command -v localbackup-unmount-$name; then trap "localbackup-unmount-$name" EXIT fi if [[ ! -d $backupname ]]; then echo "No backup found $backupname" exit 1 fi IFS=':' read -ra FOLDER <<< "''$FOLDERS" for folder in "''${FOLDER[@]}"; do mkdir -p "$folder" rsync -a "$backupname/${config.networking.hostName}$folder/" "$folder" done '') ] ++ (lib.mapAttrsToList ( name: target: pkgs.writeShellScriptBin ("localbackup-mount-" + name) '' set -efu -o pipefail ${lib.optionalString (target.preMountHook != null) target.preMountHook} ${lib.optionalString (target.mountpoint != null) '' if ! ${pkgs.util-linux}/bin/mountpoint -q ${lib.escapeShellArg target.mountpoint}; then ${pkgs.util-linux}/bin/mount -o X-mount.mkdir ${lib.escapeShellArg target.mountpoint} fi ''} ${lib.optionalString (target.postMountHook != null) target.postMountHook} '' ) cfg.targets) ++ lib.mapAttrsToList ( name: target: pkgs.writeShellScriptBin ("localbackup-unmount-" + name) '' set -efu -o pipefail ${lib.optionalString (target.preUnmountHook != null) target.preUnmountHook} ${lib.optionalString ( target.mountpoint != null ) "${pkgs.util-linux}/bin/umount ${lib.escapeShellArg target.mountpoint}"} ${lib.optionalString (target.postUnmountHook != null) target.postUnmountHook} '' ) cfg.targets; clan.core.backups.providers.localbackup = { # TODO list needs to run locally or on the remote machine list = "localbackup-list"; create = "localbackup-create"; restore = "localbackup-restore"; }; }; }