#!/bin/bash # ====================================================================== # # BACKUP SCRIPTS - CREATE LOCAL DUMPS # # ---------------------------------------------------------------------- # ah - Axel Hahn <axel.hahn@iml.unibe.ch> # ds - Daniel Schueler <daniel.schueler@iml.unibe.ch> # # 2016-11-04 ah,ds created # 2016-11-10 ..... v1.0 # 2017-03-27 ..... added first param for mode backup|restore # 2018-02-09 ..... fix: restore-selection of target uses default on return only # 2021-05-18 ..... move supported backup types to plugins/localdump/[service].sh # 2021-07-13 ..... remove leading ./ in localdump.sh restore # 2022-02-18 ..... WIP: use class like functions # 2022-03-17 ..... WIP: add lines with prefix __DB__ # 2022-11-04 ah rename hooks # 2024-03-14 ah v2.0: use profiles for local and remote databases # 2024-03-18 ah fix for db detection from file and cli restore # 2024-10-02 ah reset $rc before calling db plugin # 2024-12-13 ah set gzip compression from 9 to 4 # ====================================================================== # --- variables: # ARCHIVE_BASEDIR {string} base directory for db archive (couchdb2 only) # BACKUP_BASEDIR {string} base directory for db dumps # BACKUP_DATE {string} string with current timestamp; will be part of filename for backups # BACKUP_KEEP_DAYS {int} count of days how long to keep db dumps below $BACKUP_BASEDIR # BACKUP_PLUGINDIR {string} scripts for supported databases; [APP]/plugins/localdump # BACKUP_SCRIPT {string} script name of db service # BACKUP_TARGETDIR {string} target directory db dumps of current service # SERVICENAME {string} name of db service (one of mysql|pgsql|...) # ---------------------------------------------------------------------- # CONFIG VARS # ---------------------------------------------------------------------- . $(dirname $0)/vendor/ini.class.sh || exit 1 . $(dirname $0)/vendor/color.class.sh || exit 1 . $(dirname $0)/includes/jobhelper.sh || exit 1 . $(dirname $0)/includes/inc_bash.sh || exit 1 . $(dirname $0)/includes/dbdetect.class.sh || exit 1 if [ ! -r "${JOBFILE}" ]; then color.echo error "ERROR: missing config file ${JOBFILE}." exit 1 fi LOCALDUMP_LOADED=1 ARCHIVE_BASEDIR= BACKUP_BASEDIR= BACKUP_PLUGINDIR= DBD_DEBUG=0 # Cleanup local dumps older N days typeset -i BACKUP_KEEP_DAYS=0 BACKUP_DATE= LASTINPUT= # ---------------------------------------------------------------------- # FUNCTIONS 4 DB-WRAPPER # ---------------------------------------------------------------------- # helpfer function for SERVICENAME.backup # it is called after the service specific dump was done. # param {string} filename of created dump file function db._compressDumpfile(){ local _outfile=$1 # $myrc is last returncode - set in fetchrc if [ $myrc -eq 0 ]; then echo -n "gzip $_outfile ... " gzip -4 -f "${_outfile}" fetchrc else color.echo error "ERROR occured while dumping - no gzip of $_outfile" fi # echo -n "__DB__$SERVICENAME INFO: backup to " # ls -l "$_outfile"* 2>&1 # echo } # ---------------------------------------------------------------------- # FUNCTIONS 4 BACKUP # ---------------------------------------------------------------------- # ------------------------------------------------------------ # cleanup a backup dir: remove old files and delete empty dirs function cleanup_backup_target(){ if [ -d "${BACKUP_TARGETDIR}" ]; then h3 "CLEANUP ${BACKUP_TARGETDIR} older $BACKUP_KEEP_DAYS days ..." echo find "${BACKUP_TARGETDIR}" -mtime +$BACKUP_KEEP_DAYS -delete -print color.preset cmd find "${BACKUP_TARGETDIR}" -mtime +$BACKUP_KEEP_DAYS -delete -print color.reset if [ $(find "${BACKUP_TARGETDIR}" -type f | wc -l) -eq 0 ]; then echo "INFO: the directory is empty - deleting it" rm -rf "${BACKUP_TARGETDIR}" fi fi } # ------------------------------------------------------------ # compress a file # shared function in localdump_* # param string filename of uncompressed output file function compress_file(){ echo -n compressing $1 ... gzip -9 -f "${1}" fetchrc } # ------------------------------------------------------------ # create a backup directory with name of service # shared function in localdump_* function create_targetdir(){ mkdir -p "${BACKUP_TARGETDIR}" 2>/dev/null if [ ! -d "${BACKUP_TARGETDIR}" ]; then color.echo "error" "FATAL ERROR: directory ${BACKUP_TARGETDIR} was not created" exit 1 fi if ! chmod 0700 "${BACKUP_TARGETDIR}"; then color.echo "error" "FATAL ERROR: permissions could not be set for ${BACKUP_TARGETDIR}." exit 1 fi } # ------------------------------------------------------------ # generate a base filename for backup dump based on on db name # ... and added timestamp # param string name of database schema # --> see listBackupedDBs() and guessDB() - these function must be able to split this function get_outfile(){ echo $*__${BACKUP_DATE} } # ------------------------------------------------------------ # get name of a service script # param string name of a service function get_service_script(){ local _service=$1 local _type; _type=$( dbdetect.getType "$_service" ) ls -1 ${BACKUP_PLUGINDIR}/${_type}.sh 2>/dev/null } # ------------------------------------------------------------ # get a list of existing database profiles function get_database_profiles(){ for config in $(dbdetect.getConfigs); do if dbdetect.exists "$config"; then echo "$( dbdetect.getProfile $config )" fi done } # ------------------------------------------------------------ # show directory infos with count of files and used space # show used space and count of files and dirs function show_info_backup_target(){ if [ -d "${BACKUP_TARGETDIR}" ]; then h3 "INFO about backup target ${BACKUP_TARGETDIR}" echo -n "used space: " du -hs "${BACKUP_TARGETDIR}" echo -n "subdirs : " find "${BACKUP_TARGETDIR}" -type d | wc -l echo -n "files : " find "${BACKUP_TARGETDIR}" -type f | wc -l echo -n "free space: " df -h "${BACKUP_TARGETDIR}" | tail -1 | awk '{ print $4 }' echo fi } # ---------------------------------------------------------------------- # FUNCTIONS 4 RESTORE # ---------------------------------------------------------------------- # ------------------------------------------------------------ # restore: show profiles from that exist backups # global string BACKUP_BASEDIR base directory of all backups function listBackupedServices(){ ( test -n "${BACKUP_BASEDIR}" && test -d "${BACKUP_BASEDIR}" \ && find "${BACKUP_BASEDIR}" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; test -n "${ARCHIVE_BASEDIR}" && test -d "${ARCHIVE_BASEDIR}" \ && find "${ARCHIVE_BASEDIR}" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; ) | sort -u } # ------------------------------------------------------------ # restore: show databases or dumps of a given database that can be restored # global string BACKUP_BASEDIR base directory of all backups of selected dbprofile # param string optional: DB-Name for file filter to select from existing dumps; function listBackupedDBs(){ local _filter="$1" if [ -d "${BACKUP_TARGETDIR}" ]; then if [ -z "$_filter" ]; then # list all databases find "${BACKUP_TARGETDIR}" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; \ | sed -e "s#__[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9].*##g" \ -e "s#\.couchdb\.*##g" \ -e "s#\.gz.meta\$##g" \ -e "s#\.gz\$##g" \ | sort -ud \ | sed "s#^\./##g" else # list dumps of a database ls -ltr ${BACKUP_TARGETDIR}/${_filter}*gz | sed "s,${BACKUP_TARGETDIR}/,,g" fi else color.echo error "ERROR: ${BACKUP_TARGETDIR} does not exist - here are no backups to restore." echo echo "You can try to restore dumps:" echo "1) Restore dump files from a backup set" echo " $(dirname $0)/restore.sh $BACKUP_BASEDIR" echo "2) Copy restored dumps into $BACKUP_TARGETDIR" echo "3) Start database restore again" echo " $(dirname $0)/localdump.sh restore [profile]" echo exit 1 fi } # ------------------------------------------------------------ # guess name of the database file # param string filename of db dump; can be full path or not function guessDB(){ dumpfile=$1 # the metafile is written in sqlite backup to store full path metafile=${BACKUP_TARGETDIR}/${dumpfile}.meta if [ -f $metafile ]; then grep "^/" "$metafile" || grep "^ File: " "$metafile" | cut -c 9- else sBasename=$(basename $1) sDb=$(echo ${sBasename} | sed "s#__[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9].*##g" | sed "s#\.couchdbdump\.gz##g" ) # ^ ^ # timestamp in backup file __/ for couchdb2 restore from archive __/ if [ -z $sDb ]; then color.echo error "ERROR: db name was not detected from file $1" exit 1 fi echo $sDb fi } # ------------------------------------------------------------ # show a selection + a prompt and read the input # - If the selection is just 1 line it will be returned # - If the user presses just return the script will exit # param string selection of items to select from # param string prompt to show function showSelectAndInput(){ local _selection="$1" local _prompt="$2" local _lines typeset -i _lines; _lines=$( grep -c "." <<< "$_selection" ) case $_lines in 0) color.echo error "ERROR: No data found for a selection. Aborting." echo exit 1 ;; 1) echo "INFO: No interaction on a single choice. Using" color.echo "cmd" " $_selection" LASTINPUT="$_selection" return 0 ;; *) echo "$_selection" color.print input "${_prompt} >" read -r LASTINPUT if [ -z "$LASTINPUT" ]; then echo "No input given. Aborting." exit 1 fi ;; esac } # ------------------------------------------------------------ # read .meta file (that contains output of stats) and restore last owner and file permissions # param string filename of db dump # param string restored database file function restorePermissions(){ local sMyMeta="${1}.meta" local sTargetfile="$2" if [ -f "${sMyMeta}" ]; then # Access: (0674/-rw-rwxr--) Uid: ( 1000/ axel) Gid: ( 1000/ axel) # ^ ^ ^ # _sPerm _sUser _sGroup local _sPerm=$( grep "^Access: (" "${sMyMeta}" | cut -f 2 -d '(' | cut -f 1 -d '/') if [ -n "$_sPerm" ]; then local _sUser=$( grep "^Access: (" "${sMyMeta}" | cut -f 3 -d '(' | cut -f 1 -d '/' | tr -d ' ') local _sGroup=$( grep "^Access: (" "${sMyMeta}" | cut -f 4 -d '(' | cut -f 1 -d '/' | tr -d ' ') echo -n "Restoring file owner $_sUser:$_sGroup and permissions $_sPerm ... " chown "$_sUser:$_sGroup" "${sTargetfile}" && chmod "$_sPerm" "${sTargetfile}" fetchrc fi fi } # ------------------------------------------------------------ # show help # ------------------------------------------------------------ function showhelp(){ local _self _self=$( basename "$0" ) cat <<EOH LOCALDUMP detects existing local databases and dumps them locally. It is included in the backup.sh to dump all before a file backup will store them. It can be started seperately for manual database backups or for restore. SYNTAX: $_self [OPTIONS] <operation> <profile [more_profiles]> OPTIONS: -h|--help show this help PARAMETERS:" operation - one of check|backup|restore; optional parameter backup dump all databases/ schemes of a given service check show info only if the service is available restore import a dump into same or new database Without a filename it starts an interactive mode profile - name of database profiles You get a list of all available services without parameter Use ALL for bulk command file - filename of db dump to restore to origin database scheme EXAMPLES: $_self backup $_self backup ALL Backup all databases of all found services $_self backup mysql Backup all Mysql databases. $_self restore Start interactive restore of a database of any service. $_self restore sqlite Start interactive restore of an sqlite database. $_self restore <file-to-restore> [<database-name>] Restore a given dump file to the origin database scheme or to a new/ other database with the given name. EOH } # ---------------------------------------------------------------------- # INIT # ---------------------------------------------------------------------- while [[ "$#" -gt 0 ]]; do case $1 in -h|--help) showhelp; exit 0;; *) if grep "^-" <<< "$1" >/dev/null ; then echo; color.echo error "ERROR: Unknown parameter: $1"; echo; showhelp; exit 2 fi break; ;; esac; done mode="" case "$1" in backup|check|restore|shell) mode=$1 shift 1 ;; esac if [ -z "$mode" ]; then color.echo error "ERROR: missing parameter for operation." echo showhelp echo echo "Hint: On this machine working profiles:" get_database_profiles | nl echo exit 1 fi # ----- init vars BACKUP_BASEDIR=$(_j_getvar "${JOBFILE}" "dir-localdumps") # check if [ -z "$BACKUP_BASEDIR" ]; then color.echo error "ERROR: missing config for backup target." echo There must be an entry dir-localdumps in ${JOBFILE} exit 1 fi ARCHIVE_BASEDIR=$(_j_getvar "${JOBFILE}" dir-dbarchive) BACKUP_PLUGINDIR=$(dirname $0)/plugins/localdump DBD_BASEDIR=$BACKUP_PLUGINDIR/profiles BACKUP_KEEP_DAYS=$(_j_getvar ${JOBFILE} "keep-days") if [ $BACKUP_KEEP_DAYS -eq 0 ]; then BACKUP_KEEP_DAYS=7 fi BACKUP_DATE=$(/bin/date +%Y%m%d-%H%M) # ----- checks # . /usr/local/bin/inc_cronfunctions.sh j_requireUser "root" h1 $(date) IML BACKUP :: LOCALDUMP export SERVICENAME=$1 # BACKUP_TARGETDIR=${BACKUP_BASEDIR}/${SERVICENAME} # BACKUP_SCRIPT=$( get_service_script ${SERVICENAME} ) case "$mode" in # ------------------------------------------------------------ check) DBD_DEBUG=1 for PROFILENAME in $(dbdetect.getConfigs) do echo "----- $PROFILENAME" dbdetect.exists "${PROFILENAME}" echo done # . $BACKUP_SCRIPT $mode ;; # ------------------------------------------------------------ backup) if [ "$1" = "ALL" ] || [ -z "$1" ]; then profiles2run=$(get_database_profiles) echo "INFO: Calling local backup scripts for all active profiles" echo "$profiles2run" | nl else profiles2run=$* echo "INFO: I try to dump the profiles you gave as parameter: $profiles2run" echo fi iProfilesFound=$( grep -c . <<< "$profiles2run" ) test "$iProfilesFound" -eq "0" && echo "INFO: No match - no database dumps needed." typeset -i iProfileCounter=0 # ----- GO # PROFILENAME mysql_localhost_13306 # SERVICENAME mysql # for PROFILENAME in $profiles2run do iProfileCounter+=1 if dbdetect.setProfile "${PROFILENAME}"; then h2 "START PROFILE $iProfileCounter of $iProfilesFound [${PROFILENAME}]" SERVICENAME=$( dbdetect.getType "$PROFILENAME" ) BACKUP_PARAMS=$( dbdetect.getParams ) BACKUP_TARGETDIR=${BACKUP_BASEDIR}/${PROFILENAME} ARCHIVE_DIR=${ARCHIVE_BASEDIR}/${PROFILENAME} BACKUP_SCRIPT=$( get_service_script ${SERVICENAME} ) # ------ set env # echo "BACKUP_PARAMS = $BACKUP_PARAMS" # dbdetect.setenv eval $( dbdetect.setenv ) _j_runHooks "200-before-db-service" h3 "BACKUP [${PROFILENAME}] -> ${SERVICENAME}" rcbak=$rc rc=0 . $BACKUP_SCRIPT $mode test $rc -gt 0 && j_notify "db ${SERVICENAME}" "$BACKUP_SCRIPT $mode was finished with rc=$rc" $rc _j_runHooks "230-after-db-service" "$rc" # ------ unset env eval $( dbdetect.unssetenv ) # ----- post jobs: cleanup cleanup_backup_target show_info_backup_target rc=$rc+$rcbak else echo "SKIP: profile $iProfileCounter of $iProfilesFound '$PROFILENAME' " # see why it is not active DBD_DEBUG=1; dbdetect.setProfile "${PROFILENAME}"; echo; DBD_DEBUG=0 fi # just to have it in the output dbdetect.validate done ;; # ------------------------------------------------------------ restore) h1 "RESTORE DATABASE" if ! listBackupedServices | grep -q . ; then color.echo error "ERROR: No database dump was found in [${BACKUP_BASEDIR}] nor [${ARCHIVE_BASEDIR}]." exit 1 fi if [ -z $1 ] || [ ! -f "$1" ]; then parService="$1" # ----- interactive selections h2 "Select profile that has a dump" if [ -z "${parService}" ]; then showSelectAndInput "$( listBackupedServices )" "Restore for profile name" parService="$LASTINPUT" else echo "Taken from command line: $parService" fi # ----- check if profile exists if ! dbdetect.setProfile "${parService}"; then color.echo error "ERROR: profile [${parService}] is not known here (or database service is stopped)." echo exit 1 fi # ----- check if dump exists in archive and in backup if [ -d "${BACKUP_BASEDIR}/${parService}" ] && [ -d "${ARCHIVE_BASEDIR}/${parService}" ]; then echo showSelectAndInput "$(echo "${BACKUP_BASEDIR}"; echo "${ARCHIVE_BASEDIR}")" "Select a source directory" BACKUP_BASEDIR="$LASTINPUT" else # just one test needed because BACKUP_BASEDIR is BACKUP_BASEDIR test -d "${ARCHIVE_BASEDIR}/${parService}" && BACKUP_BASEDIR="${ARCHIVE_BASEDIR}" fi # ----- check if target dir with profile exists if [ ! -d "${BACKUP_BASEDIR}/${parService}" ]; then color.echo error "ERROR: Directory does not exist '${BACKUP_BASEDIR}/${parService}'." exit 1 fi BACKUP_TARGETDIR="${BACKUP_BASEDIR}/${parService}" h2 "Select a database schema" showSelectAndInput "$(listBackupedDBs)" "Name of database to restore" fileprefix="$LASTINPUT" echo h2 "Select a specific dump for that database" showSelectAndInput "$(listBackupedDBs $fileprefix)" "Backupset to import" dbfile="$LASTINPUT" # if there is a single dump in backup folder: # '-rw-r--r-- 1 root root 481 Mar 13 12:27 ahcrawler__20240313-1227.sql.gz' # --> take the last part behind the last space to get a filename echo "$LASTINPUT" | grep -qE "^[rwxsSt\-]{10}" \ && dbfile="$(rev <<< \'"$LASTINPUT"\' | cut -d ' ' -f 1 | rev | sed "s#'\$##" )" echo sTargetDb=$(guessDB ${dbfile}) color.print input "New database name [$sTargetDb] >" read -r sTargetDb if [ -z "$sTargetDb" ]; then sTargetDb=$(guessDB ${dbfile}) fi echo sDumpfile="${BACKUP_TARGETDIR}/${dbfile}" else sDumpfile=$1 sTargetDb=$2 fi shift 2 # ----- start restore if [ ! -f "${sDumpfile}" ]; then color.echo error "ERROR: [${sDumpfile}] is not a file" rc=$rc+1 else # We expect a structure: /somedir/whatever/<PROFILENAME>/<database>.gz dumpDirname=$( dirname "${sDumpfile}" ) PROFILENAME=$( basename "${dumpDirname}" ) echo "INFO: detected profile: $PROFILENAME" echo "" if dbdetect.setProfile "${PROFILENAME}"; then SERVICENAME=$( dbdetect.getType "$PROFILENAME" ) BACKUP_TARGETDIR=${BACKUP_BASEDIR}/${PROFILENAME} BACKUP_SCRIPT=$( get_service_script ${SERVICENAME} ) ARCHIVE_DIR=${ARCHIVE_BASEDIR}/${PROFILENAME} BACKUP_PARAMS=$( dbdetect.getParams ) eval $( dbdetect.setenv ) . $BACKUP_SCRIPT $mode "${sDumpfile}" "${sTargetDb}" if [ $? -ne 0 -o $rc -ne 0 ]; then color.echo error "ERROR: $mode failed. See ouput above. :-/" else color.echo ok "OK, $mode was successful." fi # ------ unset env eval $( dbdetect.unssetenv ) else color.echo error "ERROR: Profile $PROFILENAME was detected but its database service is not available." fi fi ;; # ------------------------------------------------------------ # shell) # export BACKUP_TARGETDIR # . $BACKUP_SCRIPT # ( # mycmd= # echo # echo "Starting interactive shell..." # echo # echo "STATUS: STILL ALPHA as long existing db plugins are not rewritten." # echo # echo "INFO: Try ${SERVICENAME}.help to see database specific commands." # echo "INFO: Type exit and return to leave the shell." # echo # while [ ! "$mycmd" = "exit" ]; do # echo -n "[${SERVICENAME}]" # color.print input " $( pwd )" # echo -n " % " # read -r mycmd # if [ ! "$mycmd" = "exit" ];then # color.preset cmd # eval $mycmd # color.reset # fi # done # ) # ;; # ----- start restore *) color.echo error "ERROR: unknown command [$mode]" ;; esac echo _______________________________________________________________________________ echo STATUS $0 exit with final returncode rc=$rc exit $rc # ----------------------------------------------------------------------