#!/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."