* file backup borg folder has now -file name inside. Old folder must be manuall renamed * All modules have the module id name as prefix in the backup set, _borg_backup_set_prefix_cleanup.sh needs to be run before to clean up all names or prune will not correctly delete old entries New -T for one time target backup with custom prefix to have backups outside the automated prune. -D option to delete this set Add borg 1.2 support for compact which is called after delete and prune to actually clean up the space. -b borg executable and BORG_EXECUTEABLE override setting if borg is not in path or another borg executable should be used
377 lines
14 KiB
Bash
377 lines
14 KiB
Bash
#!/usr/bin/env bash
|
|
|
|
# start time in seconds
|
|
START=$(date +'%s');
|
|
# start logging from here
|
|
exec &> >(tee -a "${LOG}");
|
|
echo "=== [START : $(date +'%F %T')] ==[${MODULE}]====================================>";
|
|
# show info for version always
|
|
echo "Script version: ${VERSION}";
|
|
# show type
|
|
echo "Backup module : ${MODULE}";
|
|
echo "Module version: ${MODULE_VERSION}";
|
|
# borg version
|
|
echo "Borg version : ${BORG_VERSION}";
|
|
# show base folder always
|
|
echo "Base folder : ${BASE_FOLDER}";
|
|
|
|
# if force check is true set CHECK to 1unless INFO is 1
|
|
# Needs bash 4.0 at lesat for this
|
|
if [ "${FORCE_CHECK,,}" = "true" ] && [ ${INFO} -eq 0 ]; then
|
|
CHECK=1;
|
|
if [ ${DEBUG} -eq 1 ]; then
|
|
echo "Force repository check";
|
|
fi;
|
|
fi;
|
|
|
|
# remote borg path
|
|
if [ ! -z "${TARGET_BORG_PATH}" ]; then
|
|
if [[ "${TARGET_BORG_PATH}" =~ \ |\' ]]; then
|
|
echo "Space found in ${TARGET_BORG_PATH}. Aborting";
|
|
echo "There are issues with passing on paths with spaces"
|
|
echo "as parameters"
|
|
exit;
|
|
fi;
|
|
OPT_REMOTE="--remote-path="$(printf "%q" "${TARGET_BORG_PATH}");
|
|
fi;
|
|
|
|
if [ -z "${TARGET_FOLDER}" ]; then
|
|
echo "[! $(date +'%F %T')] No target folder has been set yet";
|
|
exit 1;
|
|
else
|
|
# There are big issues with TARGET FOLDERS with spaces
|
|
# we should abort anything with this
|
|
if [[ "${TARGET_FOLDER}" =~ \ |\' ]]; then
|
|
echo "Space found in ${TARGET_FOLDER}. Aborting";
|
|
echo "There is some problem with passing paths with spaces as";
|
|
echo "repository base folder"
|
|
exit;
|
|
fi;
|
|
|
|
# This does not care for multiple trailing or leading slashes
|
|
# it just makes sure we have at least one set
|
|
# for if we have a single slash, remove it
|
|
TARGET_FOLDER=${TARGET_FOLDER%/}
|
|
TARGET_FOLDER=${TARGET_FOLDER#/}
|
|
# and add slash front and back and escape the path
|
|
TARGET_FOLDER=$(printf "%q" "/${TARGET_FOLDER}/");
|
|
fi;
|
|
|
|
# if we have user/host then we build the ssh command
|
|
TARGET_SERVER='';
|
|
# allow host only (if full setup in .ssh/config)
|
|
# user@host OR ssh://user@host:port/ IF TARGET_PORT is set
|
|
# user/host/port
|
|
if [ ! -z "${TARGET_USER}" ] && [ ! -z "${TARGET_HOST}" ] && [ ! -z "${TARGET_PORT}" ]; then
|
|
TARGET_SERVER="ssh://${TARGET_USER}@${TARGET_HOST}:${TARGET_PORT}/";
|
|
# host/port
|
|
elif [ ! -z "${TARGET_HOST}" ] && [ ! -z "${TARGET_PORT}" ]; then
|
|
TARGET_SERVER="ssh://${TARGET_HOST}:${TARGET_PORT}/";
|
|
# user/host
|
|
elif [ ! -z "${TARGET_USER}" ] && [ ! -z "${TARGET_HOST}" ]; then
|
|
TARGET_SERVER="${TARGET_USER}@${TARGET_HOST}:";
|
|
# host
|
|
elif [ ! -z "${TARGET_HOST}" ]; then
|
|
TARGET_SERVER="${TARGET_HOST}:";
|
|
fi;
|
|
# we dont allow special characters, so we don't need to special escape it
|
|
REPOSITORY="${TARGET_SERVER}${TARGET_FOLDER}${BACKUP_FILE}";
|
|
echo "Repository : ${REPOSITORY}";
|
|
|
|
# check compression if given is valid and check compression level is valid if given
|
|
OPT_COMPRESSION='';
|
|
if [ ! -z "${COMPRESSION}" ]; then
|
|
# valid compression
|
|
if [ "${COMPRESSION}" = "lz4" ] || [ "${COMPRESSION}" = "zlib" ] || [ "${COMPRESSION}" = "lzma" ] || [ "${COMPRESSION}" = "zstd" ]; then
|
|
OPT_COMPRESSION="-C=${COMPRESSION}";
|
|
# if COMPRESSION_LEVEL, check it is a valid regex
|
|
# for zlib, zstd, lzma
|
|
if [ ! -z "${COMPRESSION_LEVEL}" ] && ([ "${COMPRESSION}" = "zlib" ] || [ "${COMPRESSION}" = "lzma" ] || [ "${COMPRESSION}" = "zstd" ]); then
|
|
MIN_COMPRESSION=0;
|
|
MAX_COMPRESSION=0;
|
|
case "${COMPRESSION}" in
|
|
zlib|lzma)
|
|
MIN_COMPRESSION=0;
|
|
MAX_COMPRESSION=9;
|
|
;;
|
|
zstd)
|
|
MIN_COMPRESSION=1;
|
|
MAX_COMPRESSION=22;
|
|
;;
|
|
*)
|
|
MIN_COMPRESSION=0;
|
|
MAX_COMPRESSION=0;
|
|
;;
|
|
esac;
|
|
# if [ "${COMPRESSION}" = "zlib" ] || [ "${COMPRESSION}" = "lzma" ]
|
|
# MIN_COMPRESSION=0;
|
|
# MAX_COMPRESSION=9;
|
|
# elif [ "${COMPRESSION}" = "zstd" ]; then
|
|
# MIN_COMPRESSION=1;
|
|
# MAX_COMPRESSION=22;
|
|
# fi;
|
|
error_message="[! $(date +'%F %T')] Compression level for ${COMPRESSION} needs to be a numeric value between ${MIN_COMPRESSION} and ${MAX_COMPRESSION}: ${COMPRESSION_LEVEL}";
|
|
if ! [[ "${COMPRESSION_LEVEL}" =~ ${REGEX_NUMERIC} ]]; then
|
|
echo ${error_message};
|
|
exit 1;
|
|
elif [ ${COMPRESSION_LEVEL} -lt ${MIN_COMPRESSION} ] || [ ${COMPRESSION_LEVEL} -gt ${MAX_COMPRESSION} ]; then
|
|
echo ${error_message};
|
|
exit 1;
|
|
else
|
|
OPT_COMPRESSION=${OPT_COMPRESSION}","${COMPRESSION_LEVEL};
|
|
fi;
|
|
fi;
|
|
else
|
|
echo "[! $(date +'%F %T')] Compress setting need to be lz4, zstd, zlib or lzma. Or empty for no compression: ${COMPRESSION}";
|
|
exit 1;
|
|
fi;
|
|
fi;
|
|
|
|
# home folder, needs to be set if there is eg a HOME=/ in the crontab
|
|
if [ ! -w "${HOME}" ] || [ "${HOME}" = '/' ]; then
|
|
HOME=$(eval echo "$(whoami)");
|
|
fi;
|
|
|
|
# keep optionfs (for files)
|
|
KEEP_OPTIONS=();
|
|
# keep info string (for files)
|
|
KEEP_INFO="";
|
|
# override standard keep for tagged backups
|
|
if [ ! -z "${ONE_TIME_TAG}" ]; then
|
|
BACKUP_SET="{now:%Y-%m-%dT%H:%M:%S}";
|
|
else
|
|
# build options and info string,
|
|
# also flag BACKUP_SET check if hourly is set
|
|
BACKUP_SET_CHECK=0;
|
|
if [ ${KEEP_LAST} -gt 0 ]; then
|
|
KEEP_OPTIONS+=("--keep-last=${KEEP_LAST}");
|
|
KEEP_INFO="${KEEP_INFO}, last: ${KEEP_LAST}";
|
|
fi;
|
|
if [ ${KEEP_HOURS} -gt 0 ]; then
|
|
KEEP_OPTIONS+=("--keep-hourly=${KEEP_HOURS}");
|
|
KEEP_INFO="${KEEP_INFO}, hourly: ${KEEP_HOURS}";
|
|
BACKUP_SET_CHECK=1;
|
|
fi;
|
|
if [ ${KEEP_DAYS} -gt 0 ]; then
|
|
KEEP_OPTIONS+=("--keep-daily=${KEEP_DAYS}");
|
|
KEEP_INFO="${KEEP_INFO}, daily: ${KEEP_DAYS}";
|
|
fi;
|
|
if [ ${KEEP_WEEKS} -gt 0 ]; then
|
|
KEEP_OPTIONS+=("--keep-weekly=${KEEP_WEEKS}");
|
|
KEEP_INFO="${KEEP_INFO}, weekly: ${KEEP_WEEKS}";
|
|
fi;
|
|
if [ ${KEEP_MONTHS} -gt 0 ]; then
|
|
KEEP_OPTIONS+=("--keep-monthly=${KEEP_MONTHS}");
|
|
KEEP_INFO="${KEEP_INFO}, monthly: ${KEEP_MONTHS}";
|
|
fi;
|
|
if [ ${KEEP_YEARS} -gt 0 ]; then
|
|
KEEP_OPTIONS+=("--keep-yearly=${KEEP_YEARS}");
|
|
KEEP_INFO="${KEEP_INFO}, yearly: ${KEEP_YEARS}";
|
|
fi;
|
|
if [ ! -z "${KEEP_WITHIN}" ]; then
|
|
# check for invalid string. can only be number + H|d|w|m|y
|
|
if [[ "${KEEP_WITHIN}" =~ ^[0-9]+[Hdwmy]{1}$ ]]; then
|
|
KEEP_OPTIONS+=("--keep-within=${KEEP_WITHIN}");
|
|
KEEP_INFO="${KEEP_INFO}, within: ${KEEP_WITHIN}";
|
|
if [[ "${KEEP_WITHIN}" == *"H"* ]]; then
|
|
BACKUP_SET_CHECK=1;
|
|
fi;
|
|
else
|
|
echo "[! $(date +'%F %T')] KEEP_WITHIN has invalid string.";
|
|
exit 1;
|
|
fi;
|
|
fi;
|
|
# abort if KEEP_OPTIONS is empty
|
|
if [ -z "${KEEP_OPTIONS}" ]; then
|
|
echo "[! $(date +'%F %T')] It seems no KEEP_* entries where set in a valid format.";
|
|
exit 1;
|
|
fi;
|
|
# set BACKUP_SET if empty, set to Year-month-day
|
|
if [ -z "${BACKUP_SET}" ]; then
|
|
BACKUP_SET="{now:%Y-%m-%d}";
|
|
fi;
|
|
# backup set check, and there is no hour entry (%H) in the archive string
|
|
# we add T%H:%M:%S in this case, before the last }
|
|
if [ ${BACKUP_SET_CHECK} -eq 1 ] && [[ "${BACKUP_SET}" != *"%H"* ]]; then
|
|
BACKUP_SET=$(echo "${BACKUP_SET}" | sed -e "s/}/T%H:%M:%S}/");
|
|
fi;
|
|
fi;
|
|
|
|
|
|
# for folders list split set to "#" and keep the old setting as is
|
|
_IFS=${IFS};
|
|
IFS="#";
|
|
# turn off for non file
|
|
if [ "${MODULE}" != "file" ]; then
|
|
IFS=${_IFS};
|
|
fi;
|
|
|
|
# borg call, replace ##...## parts during run
|
|
# used in all modules, except 'file'
|
|
_BORG_CALL="${BORG_COMMAND} create ${OPT_REMOTE} -v ${OPT_LIST} ${OPT_PROGRESS} ${OPT_COMPRESSION} -s --stdin-name ##FILENAME## ${REPOSITORY}::##BACKUP_SET## -";
|
|
_BORG_PRUNE="${BORG_COMMAND} prune ${OPT_REMOTE} -v --list ${OPT_PROGRESS} ${DRY_RUN_STATS} -P ##BACKUP_SET_PREFIX## ${KEEP_OPTIONS[*]} ${REPOSITORY}";
|
|
|
|
# general borg settings
|
|
# set base path to config directory to keep cache/config separated
|
|
export BORG_BASE_DIR="${BASE_FOLDER}";
|
|
# ignore non encrypted access
|
|
export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=${_BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK};
|
|
# ignore moved repo access
|
|
export BORG_RELOCATED_REPO_ACCESS_IS_OK=${_BORG_RELOCATED_REPO_ACCESS_IS_OK};
|
|
# and for debug print that tout
|
|
if [ ${DEBUG} -eq 1 ]; then
|
|
echo "export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=${_BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK};";
|
|
echo "export BORG_RELOCATED_REPO_ACCESS_IS_OK=${_BORG_RELOCATED_REPO_ACCESS_IS_OK};";
|
|
echo "export BORG_BASE_DIR=\"${BASE_FOLDER}\";";
|
|
fi;
|
|
# prepare debug commands only
|
|
COMMAND_EXPORT="export BORG_BASE_DIR=\"${BASE_FOLDER}\";"
|
|
COMMAND_INFO="${COMMAND_EXPORT}${BORG_COMMAND} info ${OPT_REMOTE} ${REPOSITORY}";
|
|
# if the is not there, call init to create it
|
|
# if this is user@host, we need to use ssh command to check if the file is there
|
|
# else a normal check is ok
|
|
# unless explicit given, check is skipped
|
|
if [ ${CHECK} -eq 1 ] || [ ${INIT} -eq 1 ]; then
|
|
echo "--- [CHECK : $(date +'%F %T')] --[${MODULE}]------------------------------------>";
|
|
if [ ! -z "${TARGET_SERVER}" ]; then
|
|
if [ ${DEBUG} -eq 1 ]; then
|
|
echo "${BORG_COMMAND} info ${OPT_REMOTE} ${REPOSITORY} 2>&1|grep \"Repository ID:\"";
|
|
fi;
|
|
# use borg info and check if it returns "Repository ID:" in the first line
|
|
REPO_CHECK=$(${BORG_COMMAND} info ${OPT_REMOTE} ${REPOSITORY} 2>&1|grep "Repository ID:");
|
|
# this is currently a hack to work round the error code in borg info
|
|
# this checks if REPO_CHECK holds this error message and then starts init
|
|
if [[ -z "${REPO_CHECK}" ]] || [[ "${REPO_CHECK}" =~ ${REGEX_ERROR} ]]; then
|
|
INIT_REPOSITORY=1;
|
|
fi;
|
|
elif [ ! -d "${REPOSITORY}" ]; then
|
|
INIT_REPOSITORY=1;
|
|
fi;
|
|
# if check but no init and repo is there but init file is missing set it
|
|
if [ ${CHECK} -eq 1 ] && [ ${INIT} -eq 0 ] && [ ${INIT_REPOSITORY} -eq 0 ] &&
|
|
[ ! -f "${BASE_FOLDER}${BACKUP_INIT_CHECK}" ]; then
|
|
# write init file
|
|
echo "[!] Add missing init check file";
|
|
echo "$(date +%s)" > "${BASE_FOLDER}${BACKUP_INIT_CHECK}";
|
|
fi;
|
|
# end if checked but repository is not here
|
|
if [ ${CHECK} -eq 1 ] && [ ${INIT} -eq 0 ] && [ ${INIT_REPOSITORY} -eq 1 ]; then
|
|
echo "[! $(date +'%F %T')] No repository. Please run with -I flag to initialze repository";
|
|
. "${DIR}/borg.backup.functions.close.sh" 1;
|
|
exit 1;
|
|
fi;
|
|
if [ ${EXIT} -eq 1 ] && [ ${CHECK} -eq 1 ] && [ ${INIT} -eq 0 ]; then
|
|
echo "Repository exists";
|
|
echo "For more information run:"
|
|
echo "${COMMAND_INFO}";
|
|
. "${DIR}/borg.backup.functions.close.sh";
|
|
exit;
|
|
fi;
|
|
fi;
|
|
if [ ${INIT} -eq 1 ] && [ ${INIT_REPOSITORY} -eq 1 ]; then
|
|
echo "--- [INIT : $(date +'%F %T')] --[${MODULE}]------------------------------------>";
|
|
if [ ${DEBUG} -eq 1 ] || [ ${DRYRUN} -eq 1 ]; then
|
|
echo "${BORG_COMMAND} init ${OPT_REMOTE} -e ${ENCRYPTION} ${OPT_VERBOSE} ${REPOSITORY}";
|
|
fi
|
|
if [ ${DRYRUN} -eq 0 ]; then
|
|
# should trap and exit properly here
|
|
${BORG_COMMAND} init ${OPT_REMOTE} -e ${ENCRYPTION} ${OPT_VERBOSE} ${REPOSITORY};
|
|
# write init file
|
|
echo "$(date +%s)" > "${BASE_FOLDER}${BACKUP_INIT_CHECK}";
|
|
echo "Repository initialized";
|
|
echo "For more information run:"
|
|
echo "${COMMAND_INFO}";
|
|
fi
|
|
. "${DIR}/borg.backup.functions.close.sh";
|
|
# exit after init
|
|
exit;
|
|
elif [ ${INIT} -eq 1 ] && [ ${INIT_REPOSITORY} -eq 0 ]; then
|
|
echo "[! $(date +'%F %T')] Repository already initialized";
|
|
echo "For more information run:"
|
|
echo "${COMMAND_INFO}";
|
|
. "${DIR}/borg.backup.functions.close.sh" 1;
|
|
exit 1;
|
|
fi;
|
|
|
|
# check for init file
|
|
if [ ! -f "${BASE_FOLDER}${BACKUP_INIT_CHECK}" ]; then
|
|
echo "[! $(date +'%F %T')] It seems the repository has never been initialized."
|
|
echo "Please run -I to initialize or if already initialzed run with -C for init update."
|
|
. "${DIR}/borg.backup.functions.close.sh" 1;
|
|
exit 1;
|
|
fi;
|
|
|
|
# PRINT OUT current data, only do this if REPO exists
|
|
if [ ${PRINT} -eq 1 ]; then
|
|
echo "--- [PRINT : $(date +'%F %T')] --[${MODULE}]------------------------------------>";
|
|
FORMAT="{archive} {comment:6} {start} - {end} [{id}] ({username}@{hostname}){NL}"
|
|
# show command on debug or dry run
|
|
if [ ${DEBUG} -eq 1 ] || [ ${DRYRUN} -eq 1 ]; then
|
|
echo "export BORG_BASE_DIR=\"${BASE_FOLDER}\";${BORG_COMMAND} list ${OPT_REMOTE} --format ${FORMAT} ${REPOSITORY}";
|
|
fi;
|
|
# run info command if not a dry drun
|
|
if [ ${DRYRUN} -eq 0 ]; then
|
|
borg list ${OPT_REMOTE} --format "${FORMAT}" ${REPOSITORY} ;
|
|
fi;
|
|
if [ ${VERBOSE} -eq 1 ]; then
|
|
echo "";
|
|
echo "Base command info:"
|
|
echo "export BORG_BASE_DIR=\"${BASE_FOLDER}\";${BORG_COMMAND} [COMMAND] ${OPT_REMOTE} ${REPOSITORY}::[BACKUP] [PATH]";
|
|
echo "Replace [COMMAND] with list for listing or extract for restoring backup data."
|
|
echo "Replace [BACKUP] with archive name."
|
|
echo "If no [PATH] is given then all files will be restored."
|
|
echo "Before extracting -n (dry run) is recommended to use."
|
|
echo "If archive size is needed the info command with archive name has to be used."
|
|
echo "When listing files in an archive set (::SET) the --format command can be used."
|
|
echo "Example: \"{mode} {user:6} {group:6} {size:8d} {csize:8d} {dsize:8d} {dcsize:8d} {mtime} {path}{extra} [{health}]{NL}\""
|
|
else
|
|
echo "export BORG_BASE_DIR=\"${BASE_FOLDER}\";${BORG_COMMAND} [COMMAND] ${OPT_REMOTE} [FORMAT] ${REPOSITORY}::[BACKUP] [PATH]";
|
|
fi;
|
|
. "${DIR}/borg.backup.functions.close.sh";
|
|
exit;
|
|
fi;
|
|
|
|
# DELETE ONE TIME TAG
|
|
if [ ! -z "${DELETE_ONE_TIME_TAG}" ]; then
|
|
echo "--- [DELETE: $(date +'%F %T')] --[${MODULE}]------------------------------------>";
|
|
# if a "*" is inside we don't do ONE archive, but globbing via -a option
|
|
DELETE_ARCHIVE=""
|
|
OPT_GLOB="";
|
|
# this is more or less for debug only
|
|
if [[ "${DELETE_ONE_TIME_TAG}" =~ $REGEX_GLOB ]]; then
|
|
OPT_GLOB="-a '${DELETE_ONE_TIME_TAG}'"
|
|
else
|
|
DELETE_ARCHIVE="::"${DELETE_ONE_TIME_TAG};
|
|
fi
|
|
# if this is borg <1.2 OPT_LIST does not work
|
|
if [ $(version $BORG_VERSION) -lt $(version "1.2.0") ]; then
|
|
OPT_LIST="";
|
|
fi;
|
|
# if exists, delete and exit
|
|
# show command on debug or dry run
|
|
if [ ${DEBUG} -eq 1 ]; then
|
|
echo "${BORG_COMMAND} delete ${OPT_REMOTE} ${OPT_LIST} -s ${OPT_GLOB} ${REPOSITORY}${DELETE_ARCHIVE}";
|
|
fi;
|
|
# run delete command if not a dry drun
|
|
# NOTE seems to be glob is not working if wrapped into another variable
|
|
if [[ "${DELETE_ONE_TIME_TAG}" =~ $REGEX_GLOB ]]; then
|
|
${BORG_COMMAND} delete ${OPT_REMOTE} ${OPT_LIST} ${DRY_RUN_STATS} -a "${DELETE_ONE_TIME_TAG}" ${REPOSITORY};
|
|
else
|
|
${BORG_COMMAND} delete ${OPT_REMOTE} ${OPT_LIST} ${DRY_RUN_STATS} ${REPOSITORY}${DELETE_ARCHIVE};
|
|
fi;
|
|
# if not a dry run, compact repository after delete
|
|
# not that compact only works on borg 1.2
|
|
if [ $(version $BORG_VERSION) -ge $(version "1.2.0") ]; then
|
|
if [ ${DRYRUN} -eq 0 ]; then
|
|
${BORG_COMMAND} compact ${REPOSITORY};
|
|
fi;
|
|
if [ ${DEBUG} -eq 1 ]; then
|
|
echo "${BORG_COMMAND} compact ${REPOSITORY}";
|
|
fi;
|
|
fi;
|
|
. "${DIR}/borg.backup.functions.close.sh";
|
|
exit;
|
|
fi;
|
|
|
|
# __END__
|