Files
LinuxInstalls/seafile/backup_create.sh
T
2026-10-17 04:10:37 -03:00

408 lines
14 KiB
Bash
Executable File

#!/bin/sh
# =============================================================================
# backup_create.sh - create a complete backup of a Seafile server that was
# installed with install-seafile.sh (Docker Compose, Seafile 13.x CE).
# =============================================================================
# What gets backed up (one directory per backup, written atomically):
#
# database.sql.gz logical dump of every Seafile database (ccnet_db,
# seafile_db, seahub_db, ...) taken from the MariaDB container
# seafile-data.tar libraries, config (seafile/conf), avatars, logs, ...
# seadoc-data.tar SeaDoc data (only if present)
# seafile-caddy.tar Caddy / Let's Encrypt certificates (only if present)
# config.tar the install directory: compose files and .env (SECRETS!)
# service.systemd / service.openrc the boot service file (if any)
# backup.info metadata used by backup_restore.sh
# SHA256SUMS checksums of all of the above
#
# Consistency: by default the backup is "hot" (the server keeps running). The
# database is dumped first and the files afterwards, which is the order
# recommended by the Seafile manual. Use --stop for a fully consistent
# "cold" backup (Seafile is unavailable for the duration of the file copy).
#
# Usage: sudo sh backup_create.sh [options]
# sudo sh backup_create.sh --help
#
# Suitable for cron, e.g.: 30 3 * * * /root/backup_create.sh --keep 14
#
# POSIX sh only (works with dash, ash/busybox, bash) - no bashisms.
# =============================================================================
set -eu
umask 077
PROG=${0##*/}
# ------------------------------- defaults ------------------------------------
INSTALL_DIR=${SEAFILE_DIR:-/opt/seafile}
BACKUP_ROOT=${SEAFILE_BACKUP_DIR:-/var/backups/seafile}
KEEP=${SEAFILE_BACKUP_KEEP:-7}
STOP_STACK=0
CHECKSUM=1
SKIP_CHECKS=0
SERVICE=seafile
UNIT_DIR=${SEAFILE_UNIT_DIR:-/etc/systemd/system}
INITD_DIR=${SEAFILE_INITD_DIR:-/etc/init.d}
ENV_FILE=
DATA_DIR=
DB_CONTAINER=
DB_CLIENT=
DB_DUMP=
DBS=
PARTIAL=
LOCK=
STOPPED=0
IS_GNU_TAR=0
TAR_OPTS=
# ------------------------------- helpers -------------------------------------
if [ -t 1 ]; then
C_RED=$(printf '\033[31m'); C_YEL=$(printf '\033[33m')
C_GRN=$(printf '\033[32m'); C_OFF=$(printf '\033[0m')
else
C_RED=; C_YEL=; C_GRN=; C_OFF=
fi
log() { printf '%s==>%s %s\n' "$C_GRN" "$C_OFF" "$*"; }
warn() { printf '%sWARNING:%s %s\n' "$C_YEL" "$C_OFF" "$*" >&2; }
die() { printf '%sERROR:%s %s\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
try() { "$@" >/dev/null 2>&1; }
usage() {
cat <<EOF
Usage: sudo sh $PROG [options]
Creates a timestamped backup of Seafile (database + data + configuration).
Options:
--backup-dir PATH Where backups are stored (default: $BACKUP_ROOT)
--dir PATH Seafile install directory with the .env (default: $INSTALL_DIR)
--keep N Keep only the newest N backups, delete older ones
(default: $KEEP, 0 = keep everything)
--stop Cold backup: stop the Seafile containers while copying
files (consistent, but causes downtime)
--no-checksum Skip the SHA256SUMS step (faster for very large installs)
--skip-checks Skip the free-disk-space check
-h, --help Show this help
Environment variables: SEAFILE_DIR, SEAFILE_BACKUP_DIR, SEAFILE_BACKUP_KEEP
(same meaning as the options above).
EOF
}
set_opt() { # option value
case $1 in
--backup-dir) BACKUP_ROOT=$2 ;;
--dir) INSTALL_DIR=$2 ;;
--keep) KEEP=$2 ;;
esac
}
parse_args() {
while [ $# -gt 0 ]; do
case $1 in
--backup-dir|--dir|--keep)
[ $# -ge 2 ] || die "Option $1 needs a value."
set_opt "$1" "$2"
shift ;;
--backup-dir=*|--dir=*|--keep=*)
set_opt "${1%%=*}" "${1#*=}" ;;
--stop) STOP_STACK=1 ;;
--no-checksum) CHECKSUM=0 ;;
--skip-checks) SKIP_CHECKS=1 ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown argument: $1 (see --help)" ;;
esac
shift
done
}
validate_inputs() {
INSTALL_DIR=${INSTALL_DIR%/}
BACKUP_ROOT=${BACKUP_ROOT%/}
for _p in "$INSTALL_DIR" "$BACKUP_ROOT"; do
case $_p in
/*) ;;
*) die "Paths must be absolute: '$_p'" ;;
esac
case $_p in
*[!A-Za-z0-9_./-]*) die "Unsupported characters in path '$_p' (allowed: letters, digits, _ . / -)." ;;
esac
done
case $KEEP in
''|*[!0-9]*) die "--keep must be a non-negative number." ;;
esac
ENV_FILE=$INSTALL_DIR/.env
}
require_root() {
if [ "$(id -u)" -eq 0 ]; then return 0; fi
if have sudo && [ -f "$0" ]; then
log "Not running as root - re-executing with sudo."
exec sudo -E sh "$0" "$@"
fi
die "Run this script as root (for example: sudo sh $PROG)."
}
get_env() { # key -> value (quotes stripped) from $ENV_FILE
sed -n "s/^$1=//p" "$ENV_FILE" | head -n 1 | tr -d "'\""
}
compose_in_dir() { (cd "$INSTALL_DIR" && docker compose "$@"); }
detect_tar() {
if tar --version 2>/dev/null | grep -qi 'gnu tar'; then IS_GNU_TAR=1; fi
_d=$(mktemp -d)
if try tar --numeric-owner -cf /dev/null -C "$_d" .; then TAR_OPTS=--numeric-owner; fi
rmdir "$_d"
}
tar_dir() { # base-dir name output-file
_rc=0
# shellcheck disable=SC2086
tar $TAR_OPTS -C "$1" -cf "$3" "$2" || _rc=$?
if [ "$_rc" -eq 1 ] && [ "$IS_GNU_TAR" -eq 1 ]; then
warn "Some files changed while '$2' was being archived (normal for a running server)."
elif [ "$_rc" -ne 0 ]; then
die "Archiving '$2' failed (tar exit code $_rc)."
fi
}
# ------------------------- locking & cleanup ---------------------------------
cleanup() {
_rc=$?
trap - EXIT INT TERM HUP
if [ "$STOPPED" -eq 1 ]; then
log "Restarting the Seafile containers..."
compose_in_dir up -d --remove-orphans || warn "Could not restart Seafile - run: cd $INSTALL_DIR && docker compose up -d"
fi
if [ -n "$PARTIAL" ] && [ -d "$PARTIAL" ]; then rm -rf "$PARTIAL"; fi
if [ -n "$LOCK" ]; then rmdir "$LOCK" 2>/dev/null || true; fi
exit "$_rc"
}
acquire_lock() {
_base=/tmp
if [ -d /run ] && [ -w /run ]; then _base=/run; fi
_l=$_base/seafile-backup.lock
if mkdir "$_l" 2>/dev/null; then
LOCK=$_l
else
die "Another backup seems to be running (lock: $_l). If not, remove that directory and retry."
fi
}
# ------------------------------ database -------------------------------------
db_service() {
compose_in_dir config --services 2>/dev/null | grep -E '^(db|mysql|mariadb)$' | head -n 1 || true
}
find_db_container() {
_svc=$(db_service)
_id=
if [ -n "$_svc" ]; then
_id=$(compose_in_dir ps -q "$_svc" 2>/dev/null | head -n 1 || true)
fi
if [ -z "$_id" ] && try docker inspect seafile-mysql; then _id=seafile-mysql; fi
printf '%s' "$_id"
}
find_in_container() { # container cmd1 cmd2 ... -> first command that exists
_c=$1; shift
for _x in "$@"; do
if docker exec "$_c" sh -c "command -v $_x" >/dev/null 2>&1; then
printf '%s' "$_x"
return 0
fi
done
return 1
}
prepare_database() {
DB_CONTAINER=$(find_db_container)
[ -n "$DB_CONTAINER" ] || die "Could not find the MariaDB container. Is Seafile running? (cd $INSTALL_DIR && docker compose ps)"
[ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER" 2>/dev/null || true)" = true ] \
|| die "The database container is not running - start Seafile first (systemctl start $SERVICE)."
_pw=$(get_env INIT_SEAFILE_MYSQL_ROOT_PASSWORD)
if [ -z "$_pw" ]; then
_pw=$(docker exec "$DB_CONTAINER" printenv MYSQL_ROOT_PASSWORD 2>/dev/null || true)
fi
[ -n "$_pw" ] || die "Could not determine the MariaDB root password (INIT_SEAFILE_MYSQL_ROOT_PASSWORD in $ENV_FILE)."
MYSQL_PWD=$_pw
export MYSQL_PWD # handed to 'docker exec -e MYSQL_PWD' without appearing in 'ps'
DB_CLIENT=$(find_in_container "$DB_CONTAINER" mariadb mysql) || die "No mariadb/mysql client found in the database container."
DB_DUMP=$(find_in_container "$DB_CONTAINER" mariadb-dump mysqldump) || die "No mariadb-dump/mysqldump found in the database container."
DBS=$(docker exec -e MYSQL_PWD "$DB_CONTAINER" "$DB_CLIENT" -uroot -N -B -e 'SHOW DATABASES' 2>/dev/null \
| grep -Ev '^(information_schema|performance_schema|mysql|sys)$' | tr '\n' ' ' | sed 's/ *$//' || true)
[ -n "$DBS" ] || die "No Seafile databases found (wrong root password or empty database server?)."
case $DBS in
*[!A-Za-z0-9_\ ]*) die "Unexpected characters in database names: $DBS" ;;
esac
}
dump_database() {
log "Dumping databases: $DBS"
_sql=$PARTIAL/database.sql
# shellcheck disable=SC2086
docker exec -e MYSQL_PWD "$DB_CONTAINER" "$DB_DUMP" -uroot \
--single-transaction --quick --routines --triggers --events --hex-blob \
--databases $DBS > "$_sql" \
|| die "The database dump failed."
tail -c 300 "$_sql" | grep -q 'Dump completed' || die "The database dump looks incomplete."
gzip -f "$_sql"
}
# ------------------------------ preflight ------------------------------------
check_space() {
if [ "$SKIP_CHECKS" -eq 1 ]; then return 0; fi
_need=0
for _n in seafile-data seadoc-data seafile-caddy; do
[ -d "$DATA_DIR/$_n" ] || continue
_k=$(du -sk "$DATA_DIR/$_n" 2>/dev/null | awk '{ print $1 }')
_need=$((_need + ${_k:-0}))
done
_avail=$(df -Pk "$BACKUP_ROOT" 2>/dev/null | awk 'NR == 2 { print $4 }')
if [ -n "$_avail" ] && [ "$_avail" -lt "$_need" ]; then
die "Not enough free space in $BACKUP_ROOT: need about $((_need / 1024)) MB, have $((_avail / 1024)) MB (use --skip-checks to ignore)."
fi
}
# ------------------------------ retention ------------------------------------
prune_old() {
[ "$KEEP" -gt 0 ] || return 0
_n=0
for _d in $(ls -1d "$BACKUP_ROOT"/seafile-backup-* 2>/dev/null | sort -r); do
[ -f "$_d/backup.info" ] || continue
_n=$((_n + 1))
if [ "$_n" -gt "$KEEP" ]; then
log "Removing old backup $_d"
rm -rf "$_d"
fi
done
}
# -------------------------------- main ---------------------------------------
main() {
for _a in "$@"; do
case $_a in -h|--help) usage; exit 0 ;; esac
done
require_root "$@"
parse_args "$@"
validate_inputs
[ "$(uname -s)" = Linux ] || die "This script supports Linux only."
[ -f "$ENV_FILE" ] || die "No Seafile installation found in $INSTALL_DIR (no .env). Use --dir to point to it."
have docker || die "Docker is not installed."
docker info >/dev/null 2>&1 || die "The Docker daemon is not running."
docker compose version >/dev/null 2>&1 || die "The Docker Compose v2 plugin is missing."
have gzip || die "gzip is required."
have tar || die "tar is required."
DATA_DIR=$(get_env BASIC_STORAGE_PATH)
DATA_DIR=${DATA_DIR:-/opt}
DATA_DIR=${DATA_DIR%/}
[ -d "$DATA_DIR/seafile-data" ] || die "Data directory $DATA_DIR/seafile-data not found (BASIC_STORAGE_PATH in $ENV_FILE)."
case $BACKUP_ROOT/ in
"$DATA_DIR"/seafile-data/*|"$DATA_DIR"/seadoc-data/*|"$DATA_DIR"/seafile-caddy/*|"$DATA_DIR"/seafile-mysql/*|"$INSTALL_DIR"/*)
die "The backup directory must not be inside the Seafile data or install directory." ;;
esac
mkdir -p "$BACKUP_ROOT"
chmod 700 "$BACKUP_ROOT"
detect_tar
check_space
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
acquire_lock
_stamp=$(date +%Y%m%d-%H%M%S)
_final=$BACKUP_ROOT/seafile-backup-$_stamp
PARTIAL=$BACKUP_ROOT/.partial-$_stamp
[ ! -e "$_final" ] || die "$_final already exists."
mkdir "$PARTIAL"
prepare_database
_mode=hot
if [ "$STOP_STACK" -eq 1 ]; then
_mode=cold
_svcs=$(compose_in_dir config --services 2>/dev/null | grep -Ev '^(db|mysql|mariadb)$' | tr '\n' ' ' || true)
[ -n "$_svcs" ] || die "Could not determine which services to stop."
log "Stopping the Seafile containers (database stays up): $_svcs"
STOPPED=1
# shellcheck disable=SC2086
compose_in_dir stop -t 120 $_svcs
fi
# Database first, files afterwards (order recommended by the Seafile manual).
dump_database
log "Archiving $DATA_DIR/seafile-data (this can take a long time for big libraries)..."
tar_dir "$DATA_DIR" seafile-data "$PARTIAL/seafile-data.tar"
for _n in seadoc-data seafile-caddy; do
if [ -d "$DATA_DIR/$_n" ]; then
log "Archiving $DATA_DIR/$_n..."
tar_dir "$DATA_DIR" "$_n" "$PARTIAL/$_n.tar"
fi
done
log "Archiving configuration ($INSTALL_DIR)..."
tar_dir "$INSTALL_DIR" . "$PARTIAL/config.tar"
if [ -f "$UNIT_DIR/$SERVICE.service" ]; then
cp "$UNIT_DIR/$SERVICE.service" "$PARTIAL/service.systemd"
elif [ -f "$INITD_DIR/$SERVICE" ]; then
cp "$INITD_DIR/$SERVICE" "$PARTIAL/service.openrc"
fi
if [ "$STOPPED" -eq 1 ]; then
log "Restarting the Seafile containers..."
compose_in_dir up -d --remove-orphans || warn "Restart failed - run: cd $INSTALL_DIR && docker compose up -d"
STOPPED=0
fi
_image=$(docker inspect -f '{{.Config.Image}}' seafile 2>/dev/null || true)
{
printf 'format=1\n'
printf 'created=%s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')"
printf 'host=%s\n' "$(hostname 2>/dev/null || echo unknown)"
printf 'mode=%s\n' "$_mode"
printf 'install_dir=%s\n' "$INSTALL_DIR"
printf 'data_dir=%s\n' "$DATA_DIR"
printf 'databases=%s\n' "$DBS"
printf 'image=%s\n' "$_image"
printf 'server_hostname=%s\n' "$(get_env SEAFILE_SERVER_HOSTNAME)"
} > "$PARTIAL/backup.info"
if [ "$CHECKSUM" -eq 1 ]; then
if have sha256sum; then
log "Computing checksums..."
_files=$(cd "$PARTIAL" && ls)
# shellcheck disable=SC2086
(cd "$PARTIAL" && sha256sum $_files > SHA256SUMS)
else
warn "sha256sum not found - the backup will not carry checksums."
fi
fi
mv "$PARTIAL" "$_final"
PARTIAL=
prune_old
_size=$(du -sh "$_final" 2>/dev/null | awk '{ print $1 }')
printf '\n'
log "Backup finished: $_final (${_size:-?}, $_mode backup)"
printf ' Restore with: sudo sh backup_restore.sh %s\n' "$_final"
printf ' The backup contains your .env with all secrets - store it somewhere safe,\n'
printf ' and copy it off this machine (rsync, rclone, scp, ...).\n'
}
main "$@"