LetsBeBiz-Redesign/letsbe-ansible-runner/scripts/restore.sh

512 lines
22 KiB
Bash

#!/bin/bash
# =============================================================================
# LetsBe Restore Script
# =============================================================================
#
# Restores backups created by backups.sh.
#
# Usage:
# restore.sh list List available local backups
# restore.sh list-remote List available remote backups
# restore.sh download <DATE> Download a remote backup set locally
# restore.sh postgres <TOOL> <FILE> Restore a PostgreSQL database
# restore.sh mysql <TOOL> <FILE> Restore a MySQL/MariaDB database
# restore.sh mongo <TOOL> <FILE> Restore a MongoDB database
# restore.sh env <FILE> Restore env files
# restore.sh configs <FILE> Restore config files
# restore.sh nginx <FILE> Restore nginx configs
# restore.sh full <DATE> Full restore from a backup date
#
# Examples:
# restore.sh list
# restore.sh postgres chatwoot /tmp/letsbe-backups/pg_chatwoot_20260207_020000.sql.gz
# restore.sh env /tmp/letsbe-backups/dir_env-files_20260207_020000.tar.gz
# restore.sh download 20260207_020000
# restore.sh full 20260207_020000
#
# IMPORTANT:
# - Always stop the tool's application containers before restoring its database.
# - Database containers must remain running during restore.
# - After restore, restart the full tool stack.
#
# =============================================================================
set -uo pipefail
LETSBE_BASE="/opt/letsbe"
BACKUP_DIR="/tmp/letsbe-backups"
RCLONE_REMOTE="backup"
# =============================================================================
# HELPERS
# =============================================================================
log() {
echo "[RESTORE] $*"
}
die() {
echo "[RESTORE ERROR] $*" >&2
exit 1
}
require_file() {
local file=$1
[[ -f "$file" ]] || die "File not found: $file"
}
# Find a running container by suffix pattern
find_container() {
local pattern=$1
docker ps --format '{{.Names}}' | grep -E "(^|-)${pattern}$" | head -1
}
# =============================================================================
# COMMANDS
# =============================================================================
cmd_list() {
log "Available local backups in ${BACKUP_DIR}:"
echo ""
if [[ -d "$BACKUP_DIR" ]]; then
ls -lhS "$BACKUP_DIR"/ 2>/dev/null || echo " (empty)"
else
echo " No backup directory found."
fi
}
cmd_list_remote() {
if ! command -v rclone &> /dev/null; then
die "rclone not installed"
fi
if ! rclone listremotes 2>/dev/null | grep -q "^${RCLONE_REMOTE}:"; then
die "rclone remote '${RCLONE_REMOTE}' not configured"
fi
log "Available remote backups:"
echo ""
echo "Daily:"
rclone lsd "${RCLONE_REMOTE}:letsbe-backups/" 2>/dev/null | grep -v "weekly" | awk '{print " " $NF}'
echo ""
echo "Weekly:"
rclone lsd "${RCLONE_REMOTE}:letsbe-backups/weekly/" 2>/dev/null | awk '{print " " $NF}'
}
cmd_download() {
local date_str=$1
if ! command -v rclone &> /dev/null; then
die "rclone not installed"
fi
local remote_path="${RCLONE_REMOTE}:letsbe-backups/${date_str}/"
log "Downloading backup from ${remote_path}..."
mkdir -p "$BACKUP_DIR"
rclone copy "$remote_path" "$BACKUP_DIR/" --progress
log "Download complete. Files in ${BACKUP_DIR}/"
}
cmd_restore_postgres() {
local tool=$1
local file=$2
require_file "$file"
# Map tool name to container suffix, db name, and user
local container db_name db_user
case "$tool" in
chatwoot) container="chatwoot-postgres"; db_name="chatwoot_production"; db_user="chatwoot" ;;
nextcloud) container="nextcloud-postgres"; db_name="nextcloud"; db_user="nextcloud" ;;
keycloak) container="keycloak-db"; db_name="keycloak"; db_user="keycloak" ;;
n8n) container="n8n-postgres"; db_name="n8n"; db_user="postgres" ;;
calcom) container="calcom-postgres"; db_name="calcom"; db_user="postgres" ;;
umami) container="umami-db"; db_name="umami"; db_user="postgres" ;;
nocodb) container="nocodb-postgres"; db_name="nocodb"; db_user="postgres" ;;
typebot) container="typebot-db"; db_name="typebot"; db_user="postgres" ;;
windmill) container="windmill-db"; db_name="windmill"; db_user="postgres" ;;
glitchtip) container="glitchtip-postgres"; db_name="postgres"; db_user="postgres" ;;
penpot) container="penpot-postgres"; db_name="penpot"; db_user="postgres" ;;
gitea) container="gitea-db"; db_name="gitea"; db_user="postgres" ;;
odoo) container="odoo-postgres"; db_name="postgres"; db_user="postgres" ;;
listmonk) container="listmonk-db"; db_name="listmonk"; db_user="postgres" ;;
documenso) container="documenso-db"; db_name="documenso_db"; db_user="postgres" ;;
redash) container="redash-postgres"; db_name="postgres"; db_user="postgres" ;;
activepieces) container="activepieces-postgres"; db_name="activepieces"; db_user="postgres" ;;
orchestrator) container="orchestrator-db"; db_name="orchestrator"; db_user="orchestrator" ;;
*) die "Unknown PostgreSQL tool: $tool. Use one of: chatwoot, nextcloud, keycloak, n8n, calcom, umami, nocodb, typebot, windmill, glitchtip, penpot, gitea, odoo, listmonk, documenso, redash, activepieces, orchestrator" ;;
esac
local actual_container
actual_container=$(find_container "$container")
[[ -z "$actual_container" ]] && die "Container matching '$container' not found. Is it running?"
log "Restoring PostgreSQL: $tool"
log " Container: $actual_container"
log " Database: $db_name"
log " File: $file"
echo ""
read -p "WARNING: This will DROP and recreate database '$db_name'. Continue? (yes/no): " confirm
[[ "$confirm" == "yes" ]] || die "Restore cancelled."
log "Dropping and recreating database..."
docker exec "$actual_container" psql -U "$db_user" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" postgres 2>/dev/null || true
docker exec "$actual_container" psql -U "$db_user" -c "DROP DATABASE IF EXISTS \"$db_name\";" postgres
docker exec "$actual_container" psql -U "$db_user" -c "CREATE DATABASE \"$db_name\";" postgres
log "Restoring from backup..."
if [[ "$file" == *.gz ]]; then
gunzip -c "$file" | docker exec -i "$actual_container" psql -U "$db_user" "$db_name"
else
docker exec -i "$actual_container" psql -U "$db_user" "$db_name" < "$file"
fi
log "PostgreSQL restore complete for $tool."
log "Restart the $tool application containers to reconnect."
}
cmd_restore_mysql() {
local tool=$1
local file=$2
require_file "$file"
local container db_name db_user
case "$tool" in
wordpress) container="wordpress-mysql"; db_name="wordpress"; db_user="root" ;;
ghost) container="ghost-db"; db_name="ghost"; db_user="root" ;;
*) die "Unknown MySQL tool: $tool. Use one of: wordpress, ghost" ;;
esac
local actual_container
actual_container=$(find_container "$container")
[[ -z "$actual_container" ]] && die "Container matching '$container' not found. Is it running?"
log "Restoring MySQL: $tool"
log " Container: $actual_container"
log " Database: $db_name"
log " File: $file"
echo ""
read -p "WARNING: This will overwrite database '$db_name'. Continue? (yes/no): " confirm
[[ "$confirm" == "yes" ]] || die "Restore cancelled."
log "Restoring from backup..."
# Read root password from credentials
local creds_file="${LETSBE_BASE}/env/credentials.env"
local db_pass=""
if [[ "$tool" == "wordpress" ]]; then
db_pass=$(grep "^WORDPRESS_MARIADB_ROOT_PASSWORD=" "$creds_file" 2>/dev/null | cut -d'=' -f2-)
elif [[ "$tool" == "ghost" ]]; then
db_pass=$(grep "^GHOST_MYSQL_PASSWORD=" "$creds_file" 2>/dev/null | cut -d'=' -f2-)
fi
[[ -z "$db_pass" ]] && die "Could not read database password from $creds_file"
if [[ "$file" == *.gz ]]; then
gunzip -c "$file" | docker exec -i "$actual_container" mysql -u"$db_user" -p"$db_pass" "$db_name"
else
docker exec -i "$actual_container" mysql -u"$db_user" -p"$db_pass" "$db_name" < "$file"
fi
log "MySQL restore complete for $tool."
log "Restart the $tool application containers to reconnect."
}
cmd_restore_mongo() {
local tool=$1
local file=$2
require_file "$file"
local container db_name
case "$tool" in
librechat) container="librechat-mongodb"; db_name="LibreChat" ;;
*) die "Unknown MongoDB tool: $tool. Use: librechat" ;;
esac
local actual_container
actual_container=$(find_container "$container")
[[ -z "$actual_container" ]] && die "Container matching '$container' not found. Is it running?"
log "Restoring MongoDB: $tool"
log " Container: $actual_container"
log " Database: $db_name"
log " File: $file"
echo ""
read -p "WARNING: This will drop and restore database '$db_name'. Continue? (yes/no): " confirm
[[ "$confirm" == "yes" ]] || die "Restore cancelled."
log "Restoring from backup..."
if [[ "$file" == *.gz ]]; then
gunzip -c "$file" | docker exec -i "$actual_container" mongorestore --db "$db_name" --drop --archive
else
docker exec -i "$actual_container" mongorestore --db "$db_name" --drop --archive < "$file"
fi
log "MongoDB restore complete for $tool."
log "Restart the $tool application containers to reconnect."
}
cmd_restore_env() {
local file=$1
require_file "$file"
log "Restoring env files from: $file"
echo ""
read -p "WARNING: This will overwrite files in ${LETSBE_BASE}/env/. Continue? (yes/no): " confirm
[[ "$confirm" == "yes" ]] || die "Restore cancelled."
# Backup current env files first
local timestamp
timestamp=$(date +%Y%m%d_%H%M%S)
if [[ -d "${LETSBE_BASE}/env" ]]; then
log "Backing up current env files to ${LETSBE_BASE}/env.pre-restore.${timestamp}..."
cp -a "${LETSBE_BASE}/env" "${LETSBE_BASE}/env.pre-restore.${timestamp}"
fi
log "Extracting..."
tar xzf "$file" -C "${LETSBE_BASE}/"
chmod 600 "${LETSBE_BASE}/env/"*.env 2>/dev/null || true
log "Env files restored."
}
cmd_restore_configs() {
local file=$1
require_file "$file"
log "Restoring config files from: $file"
echo ""
read -p "WARNING: This will overwrite files in ${LETSBE_BASE}/config/. Continue? (yes/no): " confirm
[[ "$confirm" == "yes" ]] || die "Restore cancelled."
local timestamp
timestamp=$(date +%Y%m%d_%H%M%S)
if [[ -d "${LETSBE_BASE}/config" ]]; then
cp -a "${LETSBE_BASE}/config" "${LETSBE_BASE}/config.pre-restore.${timestamp}"
fi
tar xzf "$file" -C "${LETSBE_BASE}/"
log "Config files restored."
}
cmd_restore_nginx() {
local file=$1
require_file "$file"
log "Restoring nginx configs from: $file"
echo ""
read -p "WARNING: This will overwrite files in ${LETSBE_BASE}/nginx/. Continue? (yes/no): " confirm
[[ "$confirm" == "yes" ]] || die "Restore cancelled."
local timestamp
timestamp=$(date +%Y%m%d_%H%M%S)
if [[ -d "${LETSBE_BASE}/nginx" ]]; then
cp -a "${LETSBE_BASE}/nginx" "${LETSBE_BASE}/nginx.pre-restore.${timestamp}"
fi
tar xzf "$file" -C "${LETSBE_BASE}/"
log "Nginx configs restored."
log "Run: systemctl restart nginx"
}
cmd_full_restore() {
local date_str=$1
local backup_path="$BACKUP_DIR"
log "=== Full System Restore for date: $date_str ==="
echo ""
echo "This will restore ALL databases and configuration files from the backup."
echo "Make sure all tool containers are stopped (except database containers)."
echo ""
read -p "Continue with full restore? (yes/no): " confirm
[[ "$confirm" == "yes" ]] || die "Full restore cancelled."
# Check if files exist locally, download if not
local pg_count
pg_count=$(ls "${backup_path}"/pg_*"${date_str}"* 2>/dev/null | wc -l)
if [[ "$pg_count" -eq 0 ]]; then
log "Backup files not found locally. Attempting remote download..."
cmd_download "$date_str"
fi
# Restore env files
local env_file="${backup_path}/dir_env-files_${date_str}.tar.gz"
if [[ -f "$env_file" ]]; then
log "Restoring env files..."
# Non-interactive for full restore (already confirmed above)
local timestamp
timestamp=$(date +%Y%m%d_%H%M%S)
[[ -d "${LETSBE_BASE}/env" ]] && cp -a "${LETSBE_BASE}/env" "${LETSBE_BASE}/env.pre-restore.${timestamp}"
tar xzf "$env_file" -C "${LETSBE_BASE}/"
chmod 600 "${LETSBE_BASE}/env/"*.env 2>/dev/null || true
log " Env files restored."
fi
# Restore configs
local cfg_file="${backup_path}/dir_letsbe-config_${date_str}.tar.gz"
if [[ -f "$cfg_file" ]]; then
log "Restoring config files..."
tar xzf "$cfg_file" -C "${LETSBE_BASE}/"
log " Config files restored."
fi
# Restore nginx configs
local nginx_file="${backup_path}/dir_nginx-configs_${date_str}.tar.gz"
if [[ -f "$nginx_file" ]]; then
log "Restoring nginx configs..."
tar xzf "$nginx_file" -C "${LETSBE_BASE}/"
log " Nginx configs restored."
fi
# Restore all PostgreSQL databases found for this date
log "Restoring PostgreSQL databases..."
for pg_file in "${backup_path}"/pg_*"${date_str}"*.sql.gz; do
[[ -f "$pg_file" ]] || continue
# Extract tool name from filename: pg_<tool>_<date>.sql.gz
local tool_name
tool_name=$(basename "$pg_file" | sed "s/^pg_//;s/_${date_str}.*//")
log " Restoring PostgreSQL: $tool_name"
# Find container and restore without interactive prompt
local container db_name db_user
case "$tool_name" in
chatwoot) container="chatwoot-postgres"; db_name="chatwoot_production"; db_user="chatwoot" ;;
nextcloud) container="nextcloud-postgres"; db_name="nextcloud"; db_user="nextcloud" ;;
keycloak) container="keycloak-db"; db_name="keycloak"; db_user="keycloak" ;;
n8n) container="n8n-postgres"; db_name="n8n"; db_user="postgres" ;;
calcom) container="calcom-postgres"; db_name="calcom"; db_user="postgres" ;;
umami) container="umami-db"; db_name="umami"; db_user="postgres" ;;
nocodb) container="nocodb-postgres"; db_name="nocodb"; db_user="postgres" ;;
typebot) container="typebot-db"; db_name="typebot"; db_user="postgres" ;;
windmill) container="windmill-db"; db_name="windmill"; db_user="postgres" ;;
glitchtip) container="glitchtip-postgres"; db_name="postgres"; db_user="postgres" ;;
penpot) container="penpot-postgres"; db_name="penpot"; db_user="postgres" ;;
gitea) container="gitea-db"; db_name="gitea"; db_user="postgres" ;;
odoo) container="odoo-postgres"; db_name="postgres"; db_user="postgres" ;;
listmonk) container="listmonk-db"; db_name="listmonk"; db_user="postgres" ;;
documenso) container="documenso-db"; db_name="documenso_db"; db_user="postgres" ;;
redash) container="redash-postgres"; db_name="postgres"; db_user="postgres" ;;
activepieces) container="activepieces-postgres"; db_name="activepieces"; db_user="postgres" ;;
orchestrator) container="orchestrator-db"; db_name="orchestrator"; db_user="orchestrator" ;;
*) log " Skipping unknown tool: $tool_name"; continue ;;
esac
local actual_container
actual_container=$(find_container "$container")
if [[ -z "$actual_container" ]]; then
log " WARNING: Container '$container' not running, skipping $tool_name"
continue
fi
docker exec "$actual_container" psql -U "$db_user" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" postgres 2>/dev/null || true
docker exec "$actual_container" psql -U "$db_user" -c "DROP DATABASE IF EXISTS \"$db_name\";" postgres 2>/dev/null || true
docker exec "$actual_container" psql -U "$db_user" -c "CREATE DATABASE \"$db_name\";" postgres 2>/dev/null || true
gunzip -c "$pg_file" | docker exec -i "$actual_container" psql -U "$db_user" "$db_name" > /dev/null 2>&1
log " OK: $tool_name"
done
# Restore MySQL databases
log "Restoring MySQL databases..."
for mysql_file in "${backup_path}"/mysql_*"${date_str}"*.sql.gz; do
[[ -f "$mysql_file" ]] || continue
local tool_name
tool_name=$(basename "$mysql_file" | sed "s/^mysql_//;s/_${date_str}.*//")
log " Restoring MySQL: $tool_name"
local container db_name db_pass
case "$tool_name" in
wordpress)
container="wordpress-mysql"; db_name="wordpress"
db_pass=$(grep "^WORDPRESS_MARIADB_ROOT_PASSWORD=" "${LETSBE_BASE}/env/credentials.env" 2>/dev/null | cut -d'=' -f2-)
;;
ghost)
container="ghost-db"; db_name="ghost"
db_pass=$(grep "^GHOST_MYSQL_PASSWORD=" "${LETSBE_BASE}/env/credentials.env" 2>/dev/null | cut -d'=' -f2-)
;;
*) log " Skipping unknown MySQL tool: $tool_name"; continue ;;
esac
local actual_container
actual_container=$(find_container "$container")
if [[ -z "$actual_container" ]]; then
log " WARNING: Container '$container' not running, skipping $tool_name"
continue
fi
if [[ -n "$db_pass" ]]; then
gunzip -c "$mysql_file" | docker exec -i "$actual_container" mysql -uroot -p"$db_pass" "$db_name" 2>/dev/null
log " OK: $tool_name"
else
log " SKIP: No password found for $tool_name"
fi
done
# Restore MongoDB databases
log "Restoring MongoDB databases..."
for mongo_file in "${backup_path}"/mongo_*"${date_str}"*.archive.gz; do
[[ -f "$mongo_file" ]] || continue
local tool_name
tool_name=$(basename "$mongo_file" | sed "s/^mongo_//;s/_${date_str}.*//")
log " Restoring MongoDB: $tool_name"
case "$tool_name" in
librechat)
local actual_container
actual_container=$(find_container "librechat-mongodb")
if [[ -n "$actual_container" ]]; then
gunzip -c "$mongo_file" | docker exec -i "$actual_container" mongorestore --db LibreChat --drop --archive 2>/dev/null
log " OK: $tool_name"
else
log " WARNING: Container not running, skipping"
fi
;;
*) log " Skipping unknown MongoDB tool: $tool_name" ;;
esac
done
log ""
log "=== Full Restore Complete ==="
log "Now restart all tool stacks:"
log " for stack in ${LETSBE_BASE}/stacks/*/docker-compose.yml; do"
log " docker-compose -f \"\$stack\" restart"
log " done"
log " systemctl restart nginx"
}
# =============================================================================
# MAIN
# =============================================================================
if [[ $# -lt 1 ]]; then
echo "LetsBe Restore Tool"
echo ""
echo "Usage:"
echo " $0 list List local backups"
echo " $0 list-remote List remote backups"
echo " $0 download <DATE> Download remote backup"
echo " $0 postgres <TOOL> <FILE> Restore PostgreSQL database"
echo " $0 mysql <TOOL> <FILE> Restore MySQL database"
echo " $0 mongo <TOOL> <FILE> Restore MongoDB database"
echo " $0 env <FILE> Restore env files"
echo " $0 configs <FILE> Restore config files"
echo " $0 nginx <FILE> Restore nginx configs"
echo " $0 full <DATE> Full system restore"
echo ""
echo "PostgreSQL tools: chatwoot, nextcloud, keycloak, n8n, calcom, umami,"
echo " nocodb, typebot, windmill, glitchtip, penpot, gitea, odoo, listmonk,"
echo " documenso, redash, activepieces, orchestrator"
echo ""
echo "MySQL tools: wordpress, ghost"
echo ""
echo "MongoDB tools: librechat"
exit 1
fi
case "$1" in
list) cmd_list ;;
list-remote) cmd_list_remote ;;
download) [[ $# -ge 2 ]] || die "Usage: $0 download <DATE>"; cmd_download "$2" ;;
postgres) [[ $# -ge 3 ]] || die "Usage: $0 postgres <TOOL> <FILE>"; cmd_restore_postgres "$2" "$3" ;;
mysql) [[ $# -ge 3 ]] || die "Usage: $0 mysql <TOOL> <FILE>"; cmd_restore_mysql "$2" "$3" ;;
mongo) [[ $# -ge 3 ]] || die "Usage: $0 mongo <TOOL> <FILE>"; cmd_restore_mongo "$2" "$3" ;;
env) [[ $# -ge 2 ]] || die "Usage: $0 env <FILE>"; cmd_restore_env "$2" ;;
configs) [[ $# -ge 2 ]] || die "Usage: $0 configs <FILE>"; cmd_restore_configs "$2" ;;
nginx) [[ $# -ge 2 ]] || die "Usage: $0 nginx <FILE>"; cmd_restore_nginx "$2" ;;
full) [[ $# -ge 2 ]] || die "Usage: $0 full <DATE>"; cmd_full_restore "$2" ;;
*) die "Unknown command: $1. Run '$0' for usage." ;;
esac