Files
Inventarsystem/restore.sh
T

539 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$SCRIPT_DIR/logs"
LOG_FILE="$LOG_DIR/restore.log"
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
SOURCE_PATH=""
BACKUP_DATE=""
DROP_DATABASE=false
RESTART_SERVICES=false
STAGED_PATH=""
COMPOSE_FILE="docker-compose-multitenant.yml"
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
BACKUP_ROOT_SYSTEM="/var/backups"
SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
DOCKER_COMPOSE=()
cleanup() {
rm -rf "$WORK_DIR" >/dev/null 2>&1 || true
}
trap cleanup EXIT
mkdir -p "$LOG_DIR"
log() {
local msg="$1"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $msg" | tee -a "$LOG_FILE"
}
show_help() {
cat <<EOF
Inventarsystem Restore / Porting Tool (Docker-first)
Usage:
$0 --source <path> [options]
$0 --date <YYYY-MM-DD|latest> [options]
$0 --list
Options:
--source <path> Backup source path.
Supported:
- old folder with CSV files
- old Inventarsystem-*.tar.gz backup
- new mongodb-*.archive.gz backup
- folder containing mongodb-*.archive.gz
--date <value> Resolve source from known backup locations.
- YYYY-MM-DD: checks backups/YYYY-MM-DD and /var/backups/Inventarsystem-YYYY-MM-DD(.tar.gz)
- latest: newest from local/system backup roots
--drop-database Drop target DB before import (recommended for full restore)
--restart-services Restart stack after restore
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--list List detected backup candidates
--help Show this help
Notes:
- Always restores into current DB name: $DB_NAME
- Supports both legacy CSV and new archive backups
- Normalizes collection names to current structure (users, items, ausleihungen, filter_presets, settings)
EOF
}
setup_compose() {
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=(docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
return 0
fi
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=($SUDO docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
return 0
fi
log "ERROR: docker compose is not available"
exit 1
}
compose() {
"${DOCKER_COMPOSE[@]}" "$@"
}
list_backups() {
echo "Detected backup candidates:"
if [ -d "$BACKUP_ROOT_LOCAL" ]; then
find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort
fi
if [ -d "$BACKUP_ROOT_SYSTEM" ]; then
find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort
fi
}
resolve_latest_source() {
local latest
latest="$({
if [ -d "$BACKUP_ROOT_LOCAL" ]; then
find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null
fi
if [ -d "$BACKUP_ROOT_SYSTEM" ]; then
find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null
fi
} | sort -nr | head -n1 | cut -d' ' -f2-)"
if [ -z "$latest" ]; then
log "ERROR: no backup candidates found"
exit 1
fi
SOURCE_PATH="$latest"
}
resolve_date_source() {
local d="$1"
if [ "$d" = "latest" ]; then
resolve_latest_source
return
fi
local p1="$BACKUP_ROOT_LOCAL/$d"
local p2="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d"
local p3="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d.tar.gz"
if [ -e "$p1" ]; then
SOURCE_PATH="$p1"
elif [ -e "$p2" ]; then
SOURCE_PATH="$p2"
elif [ -e "$p3" ]; then
SOURCE_PATH="$p3"
else
log "ERROR: no backup found for date $d"
exit 1
fi
}
ensure_stack() {
log "Ensuring mongodb/app services are running..."
compose up -d mongodb app >/dev/null
if ! compose ps --status running mongodb | grep -q mongodb; then
log "ERROR: mongodb container is not running"
exit 1
fi
if ! compose ps --status running app | grep -q app; then
log "ERROR: app container is not running"
exit 1
fi
}
stage_source() {
local src="$1"
if [ ! -e "$src" ]; then
log "ERROR: source path does not exist: $src"
exit 1
fi
if [ -f "$src" ] && [[ "$src" == *.tar.gz ]]; then
log "Extracting tar backup: $src"
tar -xzf "$src" -C "$WORK_DIR"
STAGED_PATH="$WORK_DIR"
return
fi
if [ -f "$src" ]; then
cp -f "$src" "$WORK_DIR/"
STAGED_PATH="$WORK_DIR"
return
fi
if [ -d "$src" ]; then
STAGED_PATH="$src"
return
fi
log "ERROR: unsupported source type: $src"
exit 1
}
find_archive_file() {
local root="$1"
find "$root" -type f \( -name "*.archive.gz" -o -name "*.archive" \) 2>/dev/null | head -n1 || true
}
find_best_csv_dir() {
local root="$1"
local best_dir=""
local best_count=0
while IFS= read -r dir; do
[ -z "$dir" ] && continue
local count
count="$(find "$dir" -maxdepth 1 -type f -name '*.csv' | wc -l | tr -d ' ')"
if [ "$count" -gt "$best_count" ]; then
best_count="$count"
best_dir="$dir"
fi
done < <(find "$root" -type f -name '*.csv' -printf '%h\n' 2>/dev/null | sort -u)
if [ "$best_count" -gt 0 ]; then
echo "$best_dir"
fi
}
normalize_structure() {
local py="$WORK_DIR/normalize_db.py"
cat > "$py" <<'PY'
from pymongo import MongoClient
import os
db_name = os.getenv("DB_NAME", "Inventarsystem")
host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
client = MongoClient(host, port)
db = client[db_name]
rename_map = {
"user": "users",
"item": "items",
"ausleihung": "ausleihungen",
"filter_preset": "filter_presets",
"preset": "filter_presets",
"setting": "settings",
}
for old, new in rename_map.items():
if old not in db.list_collection_names():
continue
if new not in db.list_collection_names():
db[old].rename(new)
print(f"Renamed collection {old} -> {new}")
continue
moved = 0
for doc in db[old].find({}):
db[new].insert_one(doc)
moved += 1
db[old].drop()
print(f"Merged {moved} docs from {old} into {new}")
print("Final collections:", sorted(db.list_collection_names()))
PY
local app_cid
app_cid="$(compose ps -q app)"
$SUDO docker cp "$py" "$app_cid:/tmp/normalize_db.py"
compose exec -T app env DB_NAME="$DB_NAME" python /tmp/normalize_db.py
}
import_archive() {
local archive_file="$1"
local mongo_cid
mongo_cid="$(compose ps -q mongodb)"
if [ -z "$mongo_cid" ]; then
log "ERROR: could not resolve mongodb container id"
exit 1
fi
log "Importing archive backup: $archive_file"
$SUDO docker cp "$archive_file" "$mongo_cid:/tmp/restore.archive.gz"
local drop_flag=""
if [ "$DROP_DATABASE" = true ]; then
drop_flag="--drop"
fi
compose exec -T mongodb sh -lc "mongorestore --archive=/tmp/restore.archive.gz --gzip $drop_flag"
compose exec -T mongodb rm -f /tmp/restore.archive.gz >/dev/null 2>&1 || true
normalize_structure
}
import_csv() {
local csv_dir="$1"
local app_cid py
app_cid="$(compose ps -q app)"
if [ -z "$app_cid" ]; then
log "ERROR: could not resolve app container id"
exit 1
fi
log "Importing CSV backup directory: $csv_dir"
compose exec -T app sh -lc "rm -rf /tmp/restore_csv && mkdir -p /tmp/restore_csv"
$SUDO docker cp "$csv_dir/." "$app_cid:/tmp/restore_csv"
py="$WORK_DIR/import_csv.py"
cat > "$py" <<'PY'
import os
import csv
import ast
import re
from pymongo import MongoClient
from bson.objectid import ObjectId
DB_NAME = os.getenv("DB_NAME", "Inventarsystem")
CSV_DIR = os.getenv("CSV_DIR", "/tmp/restore_csv")
DROP_DATABASE = os.getenv("DROP_DATABASE", "false").lower() == "true"
HOST = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
PORT = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
COLLECTION_MAP = {
"users": "users",
"user": "users",
"items": "items",
"item": "items",
"ausleihungen": "ausleihungen",
"ausleihung": "ausleihungen",
"filter_presets": "filter_presets",
"filter_preset": "filter_presets",
"settings": "settings",
"setting": "settings",
}
BOOL_FIELDS = {"Admin", "Verfuegbar", "enabled"}
INT_FIELDS = {"Anschaffungskosten", "filter_num"}
OID_LIKE_FIELDS = {"_id", "Item"}
client = MongoClient(HOST, PORT)
db = client[DB_NAME]
if DROP_DATABASE:
client.drop_database(DB_NAME)
db = client[DB_NAME]
def parse_scalar(key, value):
if value is None:
return None
value = value.strip()
if value == "":
return None
if key in BOOL_FIELDS:
lowered = value.lower()
if lowered == "true":
return True
if lowered == "false":
return False
if key in INT_FIELDS:
try:
return int(value)
except Exception:
pass
if key in OID_LIKE_FIELDS and re.fullmatch(r"[0-9a-fA-F]{24}", value):
return ObjectId(value)
if value.startswith("[") and value.endswith("]"):
try:
return ast.literal_eval(value)
except Exception:
pass
return value
def load_csv_file(csv_path):
docs = []
with open(csv_path, "r", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
doc = {}
for key, value in row.items():
doc[key] = parse_scalar(key, value)
docs.append(doc)
return docs
csv_files = [
os.path.join(CSV_DIR, f)
for f in sorted(os.listdir(CSV_DIR))
if f.endswith(".csv")
]
if not csv_files:
raise SystemExit(f"No CSV files found in {CSV_DIR}")
for csv_file in csv_files:
base = os.path.splitext(os.path.basename(csv_file))[0]
coll = COLLECTION_MAP.get(base, base)
docs = load_csv_file(csv_file)
db[coll].drop()
if docs:
db[coll].insert_many(docs)
print(f"Imported {len(docs)} docs into {coll} from {os.path.basename(csv_file)}")
print("Collections after CSV import:", sorted(db.list_collection_names()))
PY
$SUDO docker cp "$py" "$app_cid:/tmp/import_csv.py"
compose exec -T app env DB_NAME="$DB_NAME" CSV_DIR="/tmp/restore_csv" DROP_DATABASE="$DROP_DATABASE" python /tmp/import_csv.py
normalize_structure
}
print_counts() {
local py="$WORK_DIR/print_counts.py"
cat > "$py" <<'PY'
from pymongo import MongoClient
import os
db_name = os.getenv("DB_NAME", "Inventarsystem")
host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
client = MongoClient(host, port)
db = client[db_name]
for name in ["users", "items", "ausleihungen", "filter_presets", "settings"]:
try:
count = db[name].count_documents({})
except Exception:
count = -1
print(f"{name}: {count}")
PY
local app_cid
app_cid="$(compose ps -q app)"
$SUDO docker cp "$py" "$app_cid:/tmp/print_counts.py"
log "Post-restore collection counts:"
compose exec -T app env DB_NAME="$DB_NAME" python /tmp/print_counts.py | tee -a "$LOG_FILE"
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--source)
SOURCE_PATH="${2:-}"
shift 2
;;
--date)
BACKUP_DATE="${2:-}"
shift 2
;;
--drop-database)
DROP_DATABASE=true
shift
;;
--restart-services)
RESTART_SERVICES=true
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--list)
list_backups
exit 0
;;
--help)
show_help
exit 0
;;
*)
echo "Unknown argument: $1"
show_help
exit 1
;;
esac
done
}
main() {
parse_args "$@"
setup_compose
if [ -n "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then
log "ERROR: use either --source or --date, not both"
exit 1
fi
if [ -z "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then
resolve_date_source "$BACKUP_DATE"
fi
if [ -z "$SOURCE_PATH" ]; then
log "ERROR: provide --source <path> or --date <YYYY-MM-DD|latest>"
show_help
exit 1
fi
ensure_stack
local staged archive_file csv_dir
stage_source "$SOURCE_PATH"
staged="$STAGED_PATH"
archive_file="$(find_archive_file "$staged")"
if [ -n "$archive_file" ]; then
import_archive "$archive_file"
print_counts
else
csv_dir="$(find_best_csv_dir "$staged")"
if [ -z "$csv_dir" ]; then
log "ERROR: no supported backup data found under: $SOURCE_PATH"
log "Expected either *.archive.gz or CSV files"
exit 1
fi
import_csv "$csv_dir"
print_counts
fi
if [ "$RESTART_SERVICES" = true ]; then
log "Restarting services..."
"$SCRIPT_DIR/restart.sh"
fi
log "Restore completed successfully into DB: $DB_NAME"
}
main "$@"