Steuerkram/scripts/backup.sh

156 lines
5.8 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# Steuerkram – Backup-Script mit smarter Rotation
# GoBD: Datensicherung muss regelmäßig erfolgen.
# Usage: ./scripts/backup.sh
#
# Retention:
# - letzte 7 Tage: täglich (ein Backup pro Tag)
# - Tage 8 – 28: wöchentlich
# - Tage 29 – 365: monatlich
# - älter als 1 Jahr: werden gelöscht
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BACKUP_DIR="$PROJECT_DIR/backups"
LOCK_FILE="$BACKUP_DIR/.backup.lock"
# Backup-Verzeichnis sicherstellen, bevor Lock erworben wird
mkdir -p "$BACKUP_DIR"
# Verhindert mehrfachen gleichzeitigen Start (z.B. bei Verzögerung durch langen Backup-Lauf)
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "$(date '+%Y-%m-%d %H:%M:%S') WARN: Backup läuft bereits (Lock: $LOCK_FILE). Abbruch." >&2
exit 1
fi
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "$(date '+%Y-%m-%d %H:%M:%S') Erstelle Backup $TIMESTAMP..."
# MariaDB Dump (aus dem Container)
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
--project-directory "$PROJECT_DIR" \
exec -T db mariadb-dump \
--user=steuerkram \
--password=steuerkram \
--single-transaction \
--routines \
--triggers \
steuerkram > "$BACKUP_DIR/steuerkram_db_$TIMESTAMP.sql"
# Nutzdateien sichern: Belege UND Logo (im Container ausführen – umgeht
# Host-Berechtigungen auf Bind-Mount-Dateien).
#
# Bewusst defensiv: gesichert wird jeder Ort, an dem Nutzdateien liegen KÖNNEN,
# sofern er nicht leer ist. Das deckt auch den Übergangszustand während eines
# Updates ab, in dem die Skripte schon neu sind, die Dateien aber erst beim
# nächsten Container-Start an ihren neuen Platz wandern. Ein fest verdrahteter
# Einzelpfad war die Ursache dafür, dass die Archive nach Fix #116 unbemerkt
# leer blieben.
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
--project-directory "$PROJECT_DIR" \
exec -T web sh -c '
cd /var/www/html/data || exit 1
SRC=""
# belege/logo = aktuelle Struktur
# legacy-uploads = Übergangs-Mount der alten Sammelablage
# uploads = Sammelablage im noch laufenden Container des
# Vorgängerstands (dort ist data/uploads gemountet)
for d in belege logo legacy-uploads uploads; do
if [ -d "$d" ] && [ -n "$(ls -A "$d" 2>/dev/null)" ]; then
SRC="$SRC $d"
fi
done
# Logo aus dem alten Web-Root (Stand vor Fix #116). Nur heranziehen,
# wenn data/uploads nicht schon dieselbe Ablage abdeckt – sonst gäbe es
# zwei Einträge namens uploads/ im Archiv.
WR=/var/www/html/public/static/uploads
if [ -d "$WR" ] && [ -n "$(ls -A "$WR" 2>/dev/null)" ] \
&& [ ! -d uploads ]; then
# shellcheck disable=SC2086
exec tar -czf - $SRC -C /var/www/html/public/static uploads
fi
if [ -z "$SRC" ]; then
# Nichts vorhanden -> leeres, aber gültiges Archiv
exec tar -czf - -T /dev/null
fi
# shellcheck disable=SC2086
exec tar -czf - $SRC
' > "$BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz"
# Plausibilitätsprüfung: ein Archiv ohne Dateien deutet auf einen falschen
# Quellpfad hin und darf nicht unbemerkt als gültiges Backup durchgehen.
UPL_ARCHIVE="$BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz"
FILE_COUNT=$(tar -tzf "$UPL_ARCHIVE" 2>/dev/null | grep -vc '/$' || true)
if [ "${FILE_COUNT:-0}" -eq 0 ]; then
echo " WARNUNG: Das Datei-Backup enthält keine einzige Datei!" >&2
echo " Quellpfad prüfen: data/belege und data/logo im web-Container." >&2
fi
echo " DB: $BACKUP_DIR/steuerkram_db_$TIMESTAMP.sql"
echo " Uploads: $BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz"
# --- Smarte Rotation ---
echo ""
echo "Starte Rotation..."
NOW=$(date +%s)
declare -A keep_daily # YYYYMMDD → timestamp (letzte 7 Tage, täglich)
declare -A keep_weekly # Wochennr. → timestamp (Tage 8–28, wöchentlich)
declare -A keep_monthly # YYYYMM → timestamp (Tage 29–365, monatlich)
# Alle vorhandenen Timestamps ermitteln, älteste zuerst
# (neuere Einträge überschreiben Buckets → neuestes Backup pro Periode bleibt)
mapfile -t TIMESTAMPS < <(
for f in "$BACKUP_DIR"/steuerkram_db_????????_??????.sql; do
[ -f "$f" ] || continue
basename "$f" .sql | sed 's/steuerkram_db_//'
done | sort
)
for TS in "${TIMESTAMPS[@]}"; do
DATE_PART="${TS:0:8}" # YYYYMMDD
YEAR="${TS:0:4}"
MONTH="${TS:4:2}"
DAY="${TS:6:2}"
FILE_EPOCH=$(date -d "${YEAR}-${MONTH}-${DAY}" +%s)
AGE_DAYS=$(( (NOW - FILE_EPOCH) / 86400 ))
WEEK_BUCKET=$(( AGE_DAYS / 7 ))
if [ "$AGE_DAYS" -le 7 ]; then keep_daily["$DATE_PART"]="$TS"
elif [ "$AGE_DAYS" -le 28 ]; then keep_weekly["$WEEK_BUCKET"]="$TS"
elif [ "$AGE_DAYS" -le 365 ]; then keep_monthly["${YEAR}${MONTH}"]="$TS"
fi
# Älter als 365 Tage → kein Bucket → wird weiter unten gelöscht
done
# Keep-Set aus allen Buckets zusammenführen
declare -A KEEP
for TS in \
$([ ${#keep_daily[@]} -gt 0 ] && echo "${keep_daily[@]}") \
$([ ${#keep_weekly[@]} -gt 0 ] && echo "${keep_weekly[@]}") \
$([ ${#keep_monthly[@]} -gt 0 ] && echo "${keep_monthly[@]}") ; do
KEEP["$TS"]=1
done
# Backups löschen, die in keinem Bucket landen
DELETED=0
for TS in "${TIMESTAMPS[@]}"; do
if [ -z "${KEEP[$TS]+x}" ]; then
rm -f "$BACKUP_DIR/steuerkram_db_$TS.sql"
rm -f "$BACKUP_DIR/steuerkram_uploads_$TS.tar.gz"
echo " Gelöscht: $TS"
DELETED=$((DELETED + 1))
fi
done
echo " Behalten: ${#KEEP[@]} | täglich: ${#keep_daily[@]} | wöchentlich: ${#keep_weekly[@]} | monatlich: ${#keep_monthly[@]}"
echo " Gelöscht: $DELETED"
echo "$(date '+%Y-%m-%d %H:%M:%S') Backup & Rotation abgeschlossen."