#! /bin/bash
#
# Dumps a MySQL or MariaDB database from a native or dockerized instance running
# on this box. This is a wrapper script to the mysqldump or mariadb-dump tool.
#
# If the DB engine is dockerized you need call as a Docker manager user
# (member of the docker Linux group).
#
# Accepts few mysql/mariadb-dump options as well as the optional database password
# and the optional output pathname:
#
# $0 [-u dbuser] [-p dbpass] [-h dbhost] [-P dbport]
#    [-C container] [-d database] [-f dumpfile ] [--compress] [--force]
#    [database (if not in -d)] [dumpfile (if not in -f)]
#
# Author: Kovács Zoltán <kovacs.zoltan@smartfront.hu>
#         Kovács Zoltán <kovacsz@marcusconsulting.hu>
# License: GNU/GPL v3+ (https://www.gnu.org/licenses/gpl-3.0.en.html)
# 2024-08-24 v1.1
# mod: MariaDB and MySQL dual compatibility (native and dockerized).
#      Tested with Ubuntu 24.04 LTS.
# 2023-06-18 v1.0
# new: forked from the "SMARTERP_skeleton" repository.
# 2023-02-15 v0.3
# fix: Some updates to MySQL messed with our mysqldump settings.
#      https://dev.mysql.com/doc/relnotes/mysql/5.7/en/news-5-7-31.html#mysqld-5-7-31-security
#      --no-tablespaces (the recommended option) has been added to fix.
#      https://dev.mysql.com/doc/refman/5.7/en/flush.html#flush-tables-with-read-lock
#      --single-transaction=false has been added as a temporary workaround.
# 2020-11-12 v0.2
# fix: "$(which gzip)" instad of "$GZIP", see also:
#      https://www.gnu.org/software/gzip/manual/html_node/Environment.html
# mod: Accepts a dump folder name as well, instead of a dump file name.
# 2020-09-17 v0.1 Initial release

# Accepted environment variables and their defaults.
#
MYCONTAINER=${MYCONTAINER-""}			# Docker container's name
MYDATABASE=${MYDATABASE-""}			# Database name to dump
MYDUMP=${MYDUMP-""}				# Dump file pathname
MYHOST=${MYHOST:-"localhost"}			# Connection parameter
MYOPTIONS=${MYOPTIONS-""}			# Options to pass to mysqldump
MYPASSWORD=${MYPASSWORD-""}			# Credential for the MySQL user
MYPORT=${MYPORT:-"3306"}			# Connection parameter
MYUSER=${MYUSER:-"root"}			# MySQL user for this dump

### Temporailly ignored! Need to sanitize.
MYOPTIONS=""

# Other initialisations.
#
MYDUMPFORCED=""                                 # Dumps despite failed checks
# Our default parameters for the mysql/mariadb-dump
# Content of the MYOPTIONS will also appended during the actual dump.
dumpparameters="--comments --events --routines --triggers "
dumpparameters+="--complete-insert --dump-date --force --no-create-db "
dumpparameters+="--opt --single-transaction "
## https://dev.mysql.com/doc/relnotes/mysql/5.7/en/news-5-7-31.html#mysqld-5-7-31-security
dumpparameters+="--no-tablespaces "
## https://dev.mysql.com/doc/relnotes/mysql/5.7/en/news-5-7-41.html
## a temporary workaround only
dumpparameters+="--single-transaction=false "
# Technical databases which are never dumped.
vetodatabases="information_schema mysql performance_schema sys"

# Messages.
#
MSG_ABORTED="aborted"
MSG_BADCRED="Bad credentials for MySQL"
MSG_BADOPT="Invalid option"
MSG_DOESNOTRUN="Doesn't run the database container"
MSG_DOCKERGRPNEED="You must be a member of the docker group."
MSG_FAILSIZE="Failed to size the database"
MSG_FORCED="but forced to continue"
MSG_MISSINGDEP="Fatal: missing dependency"
MSG_MISSINGDB="Missing database"
MSG_NONWRITE="The target directory isn't writable"
MSG_NOSPACE="Not enough space to dump the database"
MSG_USAGE="Usage: $0 [options] [database [dump_pathname|-]]\n"
MSG_USAGE+="Option:\tENVVAR:\n"
MSG_USAGE+=" -C\tMYCONTAINER\tMySQL Docker container's name\n"
MSG_USAGE+=" -d\tMYDATABASE\tMySQL database to dump\n"
MSG_USAGE+=" -f\tMYDUMP\t\tDumpfile pathname\n"
MSG_USAGE+=" -h\tMYHOST\t\tHostname or IP to connect (localhost)\n"
MSG_USAGE+=" -p\tMYPORT\t\tTCP port to connect (3306)\n"
MSG_USAGE+=" -P\tMYPASSWORD\tMySQL password\n"
MSG_USAGE+=" -u\tMYUSER\t\tMySQL username (root)\n"
MSG_USAGE+="--compress\t\tCompresses with gzip\n"
MSG_USAGE+="--force\t\t\tForces the operation despite the failed checks\n"

# Getting options.
#
while getopts ":-:c:C:d:D:f:F:h:H:p:P:u:U:" option
do
    case ${option} in
        "-" )
            if   [ "$OPTARG" = "compress" ]; then compress="yes"
            elif [ "$OPTARG" = "force" ]; then MYDUMPFORCED="yes"
            elif [ "$OPTARG" = "help" ]; then echo -e "$MSG_USAGE" >&2; exit
    	    else echo "$MSG_BADOPT --$OPTARG" >&2; exit 1
            fi
        ;;
        "c" | "C" )
            MYCONTAINER="$OPTARG"
        ;;
        "d" | "D" )
            MYDATABASE="$OPTARG"
        ;;
        "f" | "F" )
            MYDUMP="$OPTARG"
        ;;
        "h" | "H" )
            MYHOST="$OPTARG"
        ;;
        "P" )
            MYPASSWORD="$OPTARG"
        ;;
        "p" )
            MYPORT="$OPTARG"
        ;;
        "u" | "U" )
            MYUSER="$OPTARG"
        ;;
        \? )
        echo "$MSG_BADOPT -$OPTARG" >&2; exit 1
     ;;
  esac
done; shift $((OPTIND -1))
# All options are processed.

# Checks the dependencies.
#
# Conditional dependencies.
if [ -n "$MYCONTAINER" ]; then
    # Dockerized
    additem="docker"
else
    # Native - MySQL or MariaDB CLI?
    if [ -n "$(which mysql)" ]
    then additem="mysql mysqldump"
    else additem="mariadb mariadb-dump"; fi
fi
# Common dependencies.
TR=$(which tr 2>/dev/null)
if [ -z "$TR" ]; then echo "$MSG_MISSINGDEP tr."; exit 1 ; fi
for item in cat cut date df dirname grep gzip hostname id pwd sed tail tee $additem
do
    if [ -n "$(which $item)" ]
    then export $(echo $item | "$TR" '[:lower:]' '[:upper:]')=$(which $item)
    else echo "$MSG_MISSINGDEP $item." >&2; exit 1; fi
done
# All dependencies are available via "$THECOMMAND" (upper case) call.
#
# Unifies the call of the clients in a native environment.
if [ -z "$MYCONTAINER" ]; then
    if [ -z "$MYSQL"     -a -n "$MARIADB"      ]; then MYSQL="$MARIADB"; fi
    if [ -z "$MYSQLDUMP" -a -n "$MARIADB_DUMP" ]; then MYSQLDUMP="$MARIADB_DUMP"; fi
fi
#
# An additional bugfix (use "$(which gzip)" instead of "$GZIP"):
# https://www.gnu.org/software/gzip/manual/html_node/Environment.html
GZIP=""

# Need to be root or a Docker manager user if the DB runs in a container.
#
[[ -n "$MYCONTAINER" ]] && [[ "$USER" != 'root' ]] \
&& [[ -z "$(echo "$("$ID" -Gn "$USER") " | "$GREP" ' docker ')" ]] \
&& echo "$MSG_DOCKERGRPNEED" >&2 && exit 1 #"

# If the MySQL is dockerized the container must be running.
#
[[ -n "$MYCONTAINER" ]] \
&& [[ -z "$("$DOCKER" ps -q -f name=$MYCONTAINER)" ]] \
&& echo "$MSG_DOESNOTRUN $MYCONTAINER" >&2 && exit 1

# Determines the (mandatory) database to dump.
#
# Lack of -d the 1st non-option parameter is the database's name.
if [ -z "$MYDATABASE" -a -n "$1" ]; then MYDATABASE="$1"; shift; fi
if [ -z "$MYDATABASE" ]; then echo -e "$MSG_USAGE" >&2; exit 1; fi
# A humble sanitization.
if [[ ! "$MYDATABASE" =~ ^([[:alnum:]]|[_])*$ ]]; then
    echo -e "$MSG_USAGE" >&2; exit 1; fi
# Silently refuses MySQL internal databases.
for veto in $vetodatabases ""
do
    [[ "$MYDATABASE" = "$veto" ]] && exit 0
done
# We've a database name to dump.

# Optional backup file pathname, defaults to ./hostname.timestamp.MYDATABASE.sql
#
if [ -z "$MYDUMP" -a -n "$1" ]; then MYDUMP="$1"; shift; fi
if [ -d "$MYDUMP" ]; then
    MYDUMP+="/$MYDATABASE.$("$DATE" '+%Y%m%d_%H%M%S').$("$HOSTNAME").sql"
fi
if [ -z "$MYDUMP" ]; then
    MYDUMP="$PWD/$MYDATABASE.$("$DATE" '+%Y%m%d_%H%M%S').$("$HOSTNAME").sql"
fi
if [ "$MYDUMP" = "-" ]; then
    # If '-' was given as the MYDUMP, we need output to STDOUT.
    MYDUMP=""
    logfile="/dev/null"
else
    # Adds the relevant extension to the MYDUMP and the logfile.
    MYDUMP="${MYDUMP%.sql}.sql"
    logfile="${MYDUMP%.sql}.log"
fi
# The folder to contain the new files must be writable.
[[ -n "$MYDUMP" ]] && [[ ! -w "$("$DIRNAME" "$MYDUMP")" ]] \
&& echo "$MSG_NONWRITE \"$("$DIRNAME" "$MYDUMP")\"" >&2 && exit 1

# Prepopulates the MySQL commands.
#
my_connect=""
[[ -n "$MYHOST" ]] && my_connect+=" --host=$MYHOST"
[[ -n "$MYPORT" ]] && my_connect+=" --port=$MYPORT"
[[ -n "$MYUSER" ]] && my_connect+=" --user=$MYUSER"
[[ -n "$MYPASSWORD" ]] && my_connect+=" --password=$MYPASSWORD"

# Checks credentials and existence of the database given.
#
if [ -z "$MYCONTAINER" ]; then
    databases=$("$MYSQL" -N --batch $my_connect --execute='show databases;' 2>/dev/null )
else
    # In a containerized environment we've to find out which CLIs are inside.
    # Try the mysql first:
    MYSQL="mysql"; MYSQLDUMP="mysqldump"
    databases=$("$DOCKER" exec $MYCONTAINER sh -c "$MYSQL -N --batch $my_connect --execute='show databases;'" 2>/dev/null)
    # On failure try the mariadb:
    if [ -z "$databases" ]; then
	MYSQL="mariadb"; MYSQLDUMP="mariadb-dump"
	databases=$("$DOCKER" exec $MYCONTAINER sh -c "$MYSQL -N --batch $my_connect --execute='show databases;'" 2>/dev/null)
    fi
fi
# CLI and Credentials are OK?
[[ -z "$databases" ]] \
&& echo "$MSG_BADCRED ($MYUSER@$([[ -n "$MYCONTAINER" ]] && echo "$MYCONTAINER:")$MYHOST)." >&2 && exit 1
# Existence?
[[ ! "$databases" =~ (^|[[:space:]])"$MYDATABASE"($|[[:space:]]) ]] \
&& echo "$MSG_MISSINGDB \"$MYDATABASE\"." >&2 && exit 1
# We've the database connection and existence checked.

# Do we size the database?
#
dbsize=0
# It isn't relevant when we'll dump to the STDOUT.
if [ -n "$MYDUMP" ]; then
    # Calculates the size of the database (KB).
    SQLVERB='SELECT table_schema, '
    SQLVERB+='ROUND(SUM(data_length + index_length) / 1024, 0) '
    SQLVERB+="FROM information_schema.TABLES WHERE table_schema='$MYDATABASE' "
    SQLVERB+="GROUP BY table_schema;"
    if [ -n "$MYCONTAINER" ]; then
	# Dockerized database.
	dbsize=$("$DOCKER" exec $MYCONTAINER sh -c "echo \"$SQLVERB\" | "$MYSQL" -N --batch $my_connect" 2>/dev/null | \
	         "$CUT" -d$'\t' -f2)
    else
	# Self-hosted database.
	dbsize=$("$MYSQL" -N --batch $my_connect --execute="$SQLVERB" 2>/dev/null | \
	         "$CUT" -d$'\t' -f2)
    fi
    # Some sanitization
    dbsize="${dbsize//[[:space:]]/}"
    [[ -z "$dbsize" ]] && dbsize=0
    [[ ! "$dbsize" =~ ^([[:digit:]])*$ ]] && dbsize=0
    # On failure aborts here, except if it had forced.
    if [ $dbsize -eq 0 ]; then echo -en "$MSG_FAILSIZE" | "$TEE" -a "$logfile"
        if [ "$MYDUMPFORCED" ]; then
    	    echo " - $MSG_FORCED" | "$TEE" -a "$logfile"
        else
    	    echo " - $MSG_ABORTED" | "$TEE" -a "$logfile"; exit 1
        fi
    fi
fi
# We've the database size.

# Checks the storage space available.
# Note, that we'll compare the size of the running database, not the dump!
# TODO: find a better estimate.
#
# It isn't relevant when we'll dump to the STDOUT or the database has no size.
if [ -n "$MYDUMP" -a $dbsize -gt 0 ]; then
    # KB units
    freespace=$("$DF" --output=avail -k "$("$DIRNAME" "$MYDUMP")" | $TAIL -n1) #"
    # It is enough?
    if [ $freespace -lt $dbsize ]; then echo -en "$MSG_NOSPACE" | "$TEE" -a "$logfile"
	# On failure aborts here, except if it had forced.
        if [ "$MYDUMPFORCED" ]; then
    	    echo " - $MSG_FORCED" | "$TEE" -a "$logfile"
        else
    	    echo " - $MSG_ABORTED" | "$TEE" -a "$logfile"; exit 1
        fi
    fi
fi
# We've the space checked.

# Some cleanup.
#
[[ -n "$MYDUMP"  && -f "$MYDUMP"  ]] && rm "$MYDUMP"  >/dev/null
[[ -n "$logfile" && -f "$logfile" ]] && rm "$logfile" >/dev/null
#
# Dumping.
#
if [ -n "$MYDUMP" ]; then
    # Dumps into a file (then optionally compresses). Writes a separate log too.
    # TODO: pipelined compress - doesn't work with Docker yet(?).
    [[ -n "$MYCONTAINER" ]] \
    && "$DOCKER" exec $MYCONTAINER sh -c "$MYSQLDUMP $my_connect $dumpparameters $MYOPTIONS $MYDATABASE" \
       >"$MYDUMP" 2>>"$logfile" \
    || "$MYSQLDUMP" $my_connect $dumpparameters $MYOPTIONS $MYDATABASE \
       >"$MYDUMP" 2>>"$logfile"
    # Optional compression.
    [[ -n "$compress" ]] && "$(which gzip)" "$MYDUMP" 2>/dev/null
else
    # Dumps to STDOUT without logging.
    [[ -n "$MYCONTAINER" ]] \
    && "$DOCKER" exec $MYCONTAINER sh -c "$MYSQLDUMP $my_connect $dumpparameters $MYOPTIONS $MYDATABASE" \
       2>/dev/null \
    || "$MYSQLDUMP" $my_connect $dumpparameters $MYOPTIONS $MYDATABASE \
       2>>/dev/null
fi

# That's all, Folks! :)
