#!/bin/sh # # Purpose {{{ # This script will … # 1. List all users directories with a minimum size. # 2. For each, get the list of backup directories. # 3. For each backup directories. # a. Ignore those that contain a .ignore.me hidden file. # b. Get current backup directory size. # c. Compare current size with a previous value (if available) from db. # d. Increase informations iterations for this backup (N-1 become N-2,…). # e. Store current backup dir size to N-1. # f. Prepare email's body if difference is not significant. # 4. Send email to user if a content exists. # 5. Remove all backup informations with iterations ≤ -10. # … # # 2022-12-07 # }}} # Sqlite3 database info {{{ # Table name : SQLITE_TABLE_NAME (default : "backups_info"). # 6 fields : # name TEXT (eg. username/duplicati_mr013370) # user TEXT (eg. username) # size INT (eg. 16000) # iteration INT (eg. -2) # date TEXT (eg. 2022-12-05) # emailed TEXT (eg True) # # A primary key is built from those 6 fields to avoid duplicate entries. # # The script will try to get the last iteration of a backup (iteration = -1) for # a comparison with the current backup directory size. # The script will decrease all iterations number of a backup (iteration - 1). # The script will finally remove all entries with an iteration below -10. # }}} # Vars {{{ PROGNAME=$(basename "${0}"); readonly PROGNAME PROGDIR=$(readlink -m $(dirname "${0}")); readonly PROGDIR ARGS="${*}"; readonly ARGS readonly NBARGS="${#}" [ -z "${DEBUG}" ] && DEBUG=1 ## Export DEBUG for sub-script export DEBUG ## Default values for some vars readonly DUP_DIR_DEFAULT="/home" readonly SQLITE_TABLE_NAME="backups_info" ## Temp files DUP_TMP_DIR=$(mktemp -d -t duplicati-monitor-backup-XXXXXX.tmp) ; readonly DUP_TMP_DIR readonly DUP_USERS_DIR_LIST="${DUP_TMP_DIR}/users.dir.list" ## Minimum size for a user|backup directory (for du comparison) readonly MIN_SIZE_INT_DEFAULT="10" readonly MIN_SIZE_UNIT_DEFAULT="MB" readonly MIN_SIZE_DEFAULT="${MIN_SIZE_INT_DEFAULT}${MIN_SIZE_UNIT_DEFAULT}" ## Default domain for email addresses readonly USER_EMAIL_DOMAIN_DEFAULT=$(hostname -d) ## Colors readonly PURPLE='\033[1;35m' readonly RED='\033[0;31m' readonly RESET='\033[0m' readonly COLOR_DEBUG="${PURPLE}" # }}} usage() { # {{{ cat <<- HELP usage: $PROGNAME [-d|-h|-m|-s] Monitor users backups for a Duplicati backend. Store some informations in a sqlite3 db file. And send an email if the size between two backups is not significant. EXAMPLES : - Apply to default directory (${DUP_DIR_DEFAULT}) ${PROGNAME} - Apply to a specific directory (/mnt/remote.duplicati) ${PROGNAME} --dir /mnt/remote.duplicati OPTIONS : -d,--dir,--directory Path where users home directory are stored (default: ${DUP_DIR_DEFAULT}). --db,--sqlite3 Path to sqlite3 database file to store backups informations (default relative to --directory: ${DUP_DIR_DEFAULT}/.duplicati/monitor.db). --domain,--mail-domain Domain used to build user's email address. (default: ${USER_EMAIL_DOMAIN_DEFAULT}). --debug Enable debug messages. -h,--help Print this help message. -m,--mail,--mail-template,--template Template used to build email content for users. (default relative to --directory: ${DUP_DIR_DEFAULT}/.duplicati/mail.template). -s,--size,--min-size Minimal size for a user directory. This value is also used to compare backup directory size between two runs. Expect to have both an integer AND a unit. If one is missing, the default one will be used. (default: ${MIN_SIZE_DEFAULT}). HELP } # }}} debug_message() { # {{{ local_debug_message="${1}" ## Print message if DEBUG is enable (=0) [ "${DEBUG}" -eq "0" ] && printf '\e[1;35m%-6b\e[m\n' "DEBUG − ${PROGNAME} : ${local_debug_message}" unset local_debug_message return 0 } # }}} error_message() { # {{{ local_error_message="${1}" local_error_code="${2}" ## Print message printf '%b\n' "ERROR − ${PROGNAME} : ${RED}${local_error_message}${RESET}" unset local_error_message exit "${local_error_code:=66}" } # }}} define_vars() { # {{{ ## If dup_dir wasn't defined (argument) {{{ if [ -z "${dup_dir}" ]; then ## Use default value readonly dup_dir="${DUP_DIR_DEFAULT}" fi ## }}} ## If dup_info wasn't defined {{{ if [ -z "${dup_info}" ]; then ## Store it in previous define dup_dir readonly dup_info="${dup_dir}/.duplicati/monitor_backup" fi ## }}} ## If mail_template wasn't defined {{{ if [ -z "${mail_template}" ]; then ## Store it in previous define dup_dir readonly mail_template="${dup_info}/mail.template" fi ## }}} ## If mail_template_bottom wasn't defined {{{ if [ -z "${mail_template_bottom}" ]; then ## Store it in previous define dup_dir readonly mail_template_bottom="${mail_template}.bottom" fi ## }}} ## If db_path wasn't defined {{{ if [ -z "${db_path}" ]; then ## Store it in previous dup_info directory readonly db_path="${dup_info}/monitor.db" fi ## }}} ## If min_size wasn't defined (argument) {{{ if [ -z "${min_size}" ]; then ## Use default value readonly min_size="${MIN_SIZE_DEFAULT}" fi ## }}} ## Define min_size_int {{{ min_size_int=$(printf -- '%s' "${min_size}" \ | tr --delete --complement -- '0-9') if [ -z "${min_size_int}" ]; then min_size_int="${MIN_SIZE_INT_DEFAULT}" readonly min_size_int fi ## }}} ## Define min_size_unit {{{ min_size_unit=$(printf -- '%s' "${min_size}" \ | tr --delete --complement -- 'a-zA-Z') if [ -z "${min_size_unit}" ]; then min_size_unit="${MIN_SIZE_UNIT_DEFAULT}" readonly min_size_unit fi ## }}} ## If user_email_domain wasn't defined (argument) {{{ if [ -z "${user_email_domain}" ]; then ## Use default value readonly user_email_domain="${USER_EMAIL_DOMAIN_DEFAULT}" fi ## }}} } # }}} is_directory_absent() { # {{{ local_directory_absent="${1}" debug_prefix="${2:-}" ## Directory doesn't exists by default return_is_directory_absent="0" ### Check if the directory exists # shellcheck disable=SC2086 if test -d "${local_directory_absent}"; then return_is_directory_absent="1" debug_message "${debug_prefix}is_directory_absent − \ The directory ${RED}${local_directory_absent}${COLOR_DEBUG} exists." else return_is_directory_absent="0" debug_message "${debug_prefix}is_directory_absent − \ The directory ${RED}${local_directory_absent}${COLOR_DEBUG} doesn't exist." fi unset local_directory_absent unset debug_prefix return "${return_is_directory_absent}" } # }}} is_file_absent() { # {{{ local_file_absent="${1}" debug_prefix="${2:-}" ## File exists by default return_is_file_absent="1" ### Check if the file exists # shellcheck disable=SC2086 if find ${local_file_absent} > /dev/null 2>&1; then return_is_file_absent="1" debug_message "${debug_prefix}is_file_absent − \ The file ${RED}${local_file_absent}${COLOR_DEBUG} exists." else return_is_file_absent="0" debug_message "${debug_prefix}is_file_absent − \ The file ${RED}${local_file_absent}${COLOR_DEBUG} doesn't exist." fi unset local_file_absent unset debug_prefix return "${return_is_file_absent}" } # }}} is_file_present() { # {{{ local_file_present="${1}" debug_prefix="${2:-}" ## File doesn't exist by default return_is_file_present="1" ### Check if the file exists # shellcheck disable=SC2086 if find ${local_file_present} > /dev/null 2>&1; then return_is_file_present="0" debug_message "${debug_prefix}is_file_present − \ The file ${RED}${local_file_present}${COLOR_DEBUG} exists." else return_is_file_present="1" debug_message "${debug_prefix}is_file_present − \ The file ${RED}${local_file_present}${COLOR_DEBUG} doesn't exist." fi unset local_file_present unset debug_prefix return "${return_is_file_present}" } # }}} clean_temp_files() { # {{{ ## Remove temp files if DEBUG is not set if [ ! "${DEBUG}" -eq "0" ]; then rm --recursive --force -- "${DUP_TMP_DIR}" else debug_message "| Temp files are available in ${RED}${DUP_TMP_DIR}${COLOR_DEBUG} for any debug." fi return "0" } # }}} main() { # {{{ # Define all vars define_vars debug_message "--- MAIN BEGIN" debug_message "| Temp files are availabe here : ${RED}${DUP_TMP_DIR}${COLOR_DEBUG} ." # Requirements {{{ if [ ! $(command -v sqlite3) ]; then debug_message "| Need to install script dependency (${RED}sqlite3${COLOR_DEBUG})." sudo aptitude install sqlite3 fi # }}} # If dup_dir doesn't exists {{{ # AND exit with error is_directory_absent "${dup_dir}" "| " \ && error_message "The directory (${dup_dir}) doesn't exists. Check your configuration or use -d|--dir option to give an existing directory. Use --help for more informations." 11 # }}} # If dup_info doesn't exists {{{ # AND exit with error is_directory_absent "${dup_info}" "| " \ && debug_message "| Create the ${RED}${dup_info}${COLOR_DEBUG} directory." \ && mkdir --parent -- "${dup_info}" # }}} # If mail_template doesn't exists {{{ # AND exit with error is_file_absent "${mail_template}" "| " \ && error_message "Email template (${mail_template}) doesn't exists. Please provide a path to a valid file (--template option) or create a template in appropriate directory (${dup_info}). See --help for more informations." 12 # }}} # If db_path doesn't exists {{{ # Init a table in a new sqlite3 database file if is_file_absent "${db_path}" "| "; then debug_message "| Init table (${RED}${SQLITE_TABLE_NAME}${COLOR_DEBUG}) in a new SQLite database file (${RED}${db_path}${COLOR_DEBUG})." sqlite3 "${db_path}" "CREATE table ${SQLITE_TABLE_NAME}( name TEXT, user TEXT, size INT, iteration INT, date TEXT, emailed TEXT, PRIMARY KEY (name, user, size, iteration, date, emailed) );" \ || error_message "Can't init a new TABLE (${SQLITE_TABLE_NAME}) in SQLite database (${db_path})." 13 fi # }}} # Move to dup_dir debug_message "| Move to users backup base path (${RED}${dup_dir}${COLOR_DEBUG})." cd -- "${dup_dir}" >/dev/null \ || error_message "Can't move to directory (${dup_dir}). Check the permissions or try with 'sudo'." 14 # Get the sorted list of users directories. {{{ find . -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf "%f\n" | sort > "${DUP_USERS_DIR_LIST}" # Test if the users directories list exists and is not empty # OR exit with error is_file_present "${DUP_USERS_DIR_LIST}" "| " \ || error_message "The users list (${DUP_USERS_DIR_LIST}) seems empty or doesn't exists." 15 # }}} # While loop for each user directory while IFS= read -r user_dir; do ## Define path for an email content user_email_template="${DUP_TMP_DIR}/${user_dir}.mail" ## Check the size of user directory {{{ if ! du --summarize --block-size="${min_size}" -- "${user_dir}" >/dev/null 2>&1; then error_message "Can't get size of ${user_dir} user directory. Check the permissions or try with 'sudo'" 21 fi user_dir_size=$(du --summarize --block-size="${min_size}" -- "${user_dir}" \ | cut --fields=1 -- ) ### Multiply by min_size_int to have the correct size order : $(( user_dir_size=user_dir_size*min_size_int )) if [ "${user_dir_size}" -gt "${min_size_int}" ]; then debug_message "|-- Manage ${RED}${user_dir}${COLOR_DEBUG} directory BEGIN" else debug_message "| ${RED}Skip ${user_dir}${COLOR_DEBUG} directory because it's below the minimum size (~${user_dir_size} ${min_size_unit} ≤ ${min_size})." unset user_dir_size ## Continue the while loop with next argument continue fi unset user_dir_size ## }}} ## Get the list of user backups {{{ DUP_USER_BACKUP_LIST="${DUP_TMP_DIR}/${user_dir}.backup.list" find "${user_dir}" -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf "%f\n" | sort > "${DUP_USER_BACKUP_LIST}" ## Test if the user backups list exists and is not empty ## OR exit with error is_file_present "${DUP_USER_BACKUP_LIST}" "|| " \ || error_message "Backups list (${DUP_USER_BACKUP_LIST}) for user (${user_dir}) seems empty or doesn't exists." 22 ## }}} ## While loop for each backup directory while IFS= read -r backup_dir; do ### Ignore directory if a .ignore.me file exists {{{ find "${user_dir}/${backup_dir}/.ignore.me" >/dev/null 2>&1 \ && debug_message "|| ${RED}Skip ${user_dir}/${backup_dir}${COLOR_DEBUG} directory because a .ignore.me file exists." \ && continue ### }}} debug_message "||- Manage ${RED}${user_dir}/${backup_dir}${COLOR_DEBUG} directory BEGIN" ### Get current size {{{ if ! du --summarize --block-size="${min_size}" -- "${user_dir}/${backup_dir}" >/dev/null 2>&1; then error_message "Can't get size of ${user_dir}/${backup_dir} backup directory. Check the permissions or try with 'sudo'" 31 fi backup_dir_current_size=$(du --summarize --block-size="${min_size}" -- "${user_dir}/${backup_dir}" \ | cut --fields=1 -- ) ### Multiply by min_size_int to have the correct size order : $(( backup_dir_current_size=backup_dir_current_size*min_size_int )) debug_message "||| Get backup current size (~${RED}${backup_dir_current_size}${COLOR_DEBUG} ${min_size_unit})." ### }}} ### Get previous size {{{ if [ $(sqlite3 "${db_path}" "SELECT size FROM ${SQLITE_TABLE_NAME} WHERE name='${user_dir}/${backup_dir}' AND iteration='-1'" | wc --lines --) -eq "1" ] ; then backup_dir_previous_size=$(sqlite3 "${db_path}" "SELECT size FROM ${SQLITE_TABLE_NAME} WHERE name='${user_dir}/${backup_dir}' AND iteration='-1'") debug_message "||| Use backup previous size (${RED}${backup_dir_previous_size}${COLOR_DEBUG} ${min_size_unit}) from SQLite database." else backup_dir_previous_size="0" debug_message "||| Set backup previous size to zero (${RED}${backup_dir_previous_size}${COLOR_DEBUG} ${min_size_unit})." fi ### }}} ### Compare current and previous sizes {{{ : $(( backup_dir_compare_size=backup_dir_current_size-backup_dir_previous_size )) if [ "${backup_dir_compare_size}" -lt "${min_size_int}" ] ; then debug_message "||| The differences between current and previous sizes is not enough (~${RED}${backup_dir_compare_size}${COLOR_DEBUG} ${min_size_unit})." backup_dir_email="True" else debug_message "||| The differences between current and previous sizes is correct (~${RED}${backup_dir_compare_size}${COLOR_DEBUG} ${min_size_unit})." backup_dir_email="False" fi ### }}} ### Increase iterations for registred informations {{{ sqlite3 "${db_path}" "UPDATE ${SQLITE_TABLE_NAME} SET iteration = iteration - 1 WHERE name='${user_dir}/${backup_dir}'" \ || error_message "Can't increase iterations on SQLite db file (${db_path}) for backup dir (${user_dir}/${backup_dir})." 32 ### }}} ### Add a new record {{{ sqlite3 "${db_path}" "INSERT INTO ${SQLITE_TABLE_NAME} values( \ '${user_dir}/${backup_dir}', \ '${user_dir}', \ ${backup_dir_current_size}, \ -1, \ '$(date +%Y/%m/%d-%H:%M)', \ '${backup_dir_email}' \ )" || error_message "Can't add a new on SQLite db file (${db_path}) for backup dir (${user_dir}/${backup_dir})." 33 ### }}} ### Build email if required {{{ if [ "${backup_dir_email}" = "True" ]; then ### Use default email template to build email body for this user is_file_absent "${user_email_template}" "| " \ && cp -- "${mail_template}" "${user_email_template}" ### Add informations specific to this backup {{{ printf '%b' "## ${backup_dir} Taille précédente pour cette sauvegarde/Previous size for this backup : ~${backup_dir_previous_size} ${min_size_unit} (compress). Taille actuelle pour cette sauvegarde/Current size for this backup : ~${backup_dir_current_size} ${min_size_unit} (compress). La différence de taille (~${backup_dir_compare_size} ${min_size_unit}) est inférieure au minimum attendu (${min_size}). --- The difference (~${backup_dir_compare_size} ${min_size_unit}) is bellow the minimum expected size (${min_size}). " >> "${user_email_template}" ### }}} ### Add all recorded informations from SQLite db {{{ if [ $(sqlite3 "${db_path}" "SELECT * FROM ${SQLITE_TABLE_NAME} WHERE name='${user_dir}/${backup_dir}'" | wc --lines --) -ge "1" ] ; then printf '%b' "\nInformations issues des tests précédents : Recorded informations from previous tests : " >> "${user_email_template}" printf ".mode box\nSELECT * FROM ${SQLITE_TABLE_NAME} WHERE name='${user_dir}/${backup_dir}' ORDER BY name, iteration\n" | sqlite3 "${db_path}" >> "${user_email_template}" #sqlite3 "${db_path}" "SELECT * FROM ${SQLITE_TABLE_NAME} WHERE name='${user_dir}/${backup_dir}' ORDER BY name, iteration" fi printf '%b\n' "" >> "${user_email_template}" ### }}} fi ### }}} ## Unset vars {{{ unset backup_dir_current_size unset backup_dir_previous_size unset backup_dir_email #unset ## }}} debug_message "||- Manage ${RED}${user_dir}/${backup_dir}${COLOR_DEBUG} directory END" done < "${DUP_USER_BACKUP_LIST}" ## Done while loop for each backup directory ## Send email if a content exists {{{ if is_file_present "${user_email_template}" "| "; then user_email_address="${user_dir}@${user_email_domain}" ### Add final informations if available {{{ is_file_present "${mail_template_bottom}" "| " \ && debug_message "| Add extra content (${RED}${mail_template_bottom}${COLOR_DEBUG}) at the end of email for user." \ && cat "${mail_template_bottom}" >> "${user_email_template}" ### }}} ### Send email {{{ debug_message "| Send email content (${user_email_template}) to user email address (${RED}${user_email_address}${COLOR_DEBUG})." mail -r "duplicati@duplicati.$(hostname --domain)" -s "Duplicati backup warning" "${user_email_address}" < "${user_email_template}" \ || error_message "Can't send content (${user_email_template}) by mail to user (${user_email_address}). See --debug option for more options." 23 ### }}} fi ## }}} ## Remove temp list if DEBUG is not set [ ! "${DEBUG}" -eq "0" ] && rm --force -- "${DUP_USER_BACKUP_LIST}" ## Unset vars {{{ unset DUP_USER_BACKUP_LIST unset backup_dir unset user_email_template ## }}} debug_message "|-- Manage ${RED}${user_dir}${COLOR_DEBUG} directory END" done < "${DUP_USERS_DIR_LIST}" # Done while loop for each user directory # Remove SQLite records with an iterations ≤ -10 sqlite3 "${db_path}" "DELETE FROM ${SQLITE_TABLE_NAME} WHERE iteration<=-10" \ || error_message "Can't remove from SQLite database (${db_path}) records with iterations ≤ -10." 15 debug_message "| Move back to previous directory (${RED}before ${dup_dir}${COLOR_DEBUG})." cd -- - >/dev/null \ || error_message "Can't move back to previous directory (before ${dup_dir}). Check the permissions or try with 'sudo'." 16 ## Remove temp files if DEBUG is not set {{{ if [ ! "${DEBUG}" -eq "0" ]; then debug_message "| ${RED}Clean temp files.${COLOR_DEBUG}" clean_temp_files else mv -- "${DUP_TMP_DIR}" "${dup_info}/$(date +%Y%m%d).logs" debug_message "| Temp files are available in ${RED}${dup_info}/$(date +%Y%m%d).logs${COLOR_DEBUG} for any debug." fi ## }}} debug_message "--- MAIN END" } # }}} # Manage arguments # {{{ # This code can't be in a function due to argument management if [ ! "${NBARGS}" -eq "0" ]; then manage_arg="0" ## If the first argument is not an option if ! printf -- '%s' "${1}" | grep -q -E -- "^-+"; then ## Print help message and exit printf '%b\n' "${RED}Invalid option: ${1}${RESET}" printf '%b\n' "---" usage exit 1 fi # Parse all options (start with a "-") one by one while printf -- '%s' "${1}" | grep -q -E -- "^-+"; do case "${1}" in -d|--dir|--directory ) ## Define dup_dir ## Move to the next argument shift ## Define var readonly dup_dir="${1}" ;; --db|--sqlite3 ) ## Define db_path ## Move to the next argument shift ## Define var readonly db_path="${1}" ;; --domain|--mail-domain ) ## Define user_email_domain ## Move to the next argument shift ## Define var readonly user_email_domain="${1}" ;; --debug ) ## debug DEBUG=0 debug_message "--- Manage argument BEGIN" ;; -h|--help ) ## help usage ## Exit after help informations exit 0 ;; -m,--mail,--mail-template,--template ) ## Define mail_template ## Move to the next argument shift ## Define var readonly mail_template="${1}" ;; -s,--size,--min-size ) ## Define min_size ## Move to the next argument shift ## Define var readonly min_size="${1}" ;; * ) ## unknow option printf '%b\n' "${RED}Invalid option: ${1}${RESET}" printf '%b\n' "---" usage exit 1 ;; esac debug_message "| ${RED}${1}${COLOR_DEBUG} option managed." ## Move to the next argument shift manage_arg=$((manage_arg+1)) done debug_message "| ${RED}${manage_arg}${COLOR_DEBUG} argument(s) successfully managed." else debug_message "| No arguments/options to manage." fi debug_message "--- Manage argument END" # }}} main exit 0