mirror of
https://github.com/acedanger/shell.git
synced 2026-03-27 05:16:07 -07:00
fix(restore-karakeep): gzip -t verification, fix compose pipe exit codes, dotfile-safe clear, add --compose-dir flag
This commit is contained in:
447
restore-karakeep.sh
Executable file
447
restore-karakeep.sh
Executable file
@@ -0,0 +1,447 @@
|
||||
#!/bin/bash
|
||||
|
||||
# restore-karakeep.sh
|
||||
# Restore Karakeep Docker volumes from a backup created by backup-karakeep.sh
|
||||
#
|
||||
# Usage:
|
||||
# ./restore-karakeep.sh <backup_directory>
|
||||
# ./restore-karakeep.sh --latest
|
||||
#
|
||||
# EXAMPLES:
|
||||
# ./restore-karakeep.sh /home/acedanger/backups/karakeep/20260325_143000
|
||||
# ./restore-karakeep.sh --latest # auto-selects most recent local backup
|
||||
|
||||
set -e
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
COMPOSE_DIR="/home/acedanger/docker/karakeep"
|
||||
LOCAL_BACKUP_BASE="/home/acedanger/backups/karakeep"
|
||||
# COMPOSE_DIR may be overridden with --compose-dir
|
||||
NAS_BACKUP_BASE="/mnt/share/media/backups/karakeep"
|
||||
LOG_ROOT="${SCRIPT_DIR}/logs"
|
||||
NAS_LOG_DIR="/mnt/share/media/backups/logs"
|
||||
RESTORE_TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# Create log directory and set log file paths
|
||||
mkdir -p "$LOG_ROOT"
|
||||
LOG_FILE="${LOG_ROOT}/karakeep-restore-${RESTORE_TIMESTAMP}.log"
|
||||
MARKDOWN_LOG="${LOG_ROOT}/karakeep-restore-${RESTORE_TIMESTAMP}.md"
|
||||
|
||||
# Write markdown log header
|
||||
{
|
||||
echo "# Karakeep Restore Log"
|
||||
echo "**Started**: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "**Host**: $(hostname)"
|
||||
echo ""
|
||||
} > "$MARKDOWN_LOG"
|
||||
|
||||
# Volume definitions: volume_name -> mount_path
|
||||
declare -A KARAKEEP_VOLUMES=(
|
||||
["hoarder_data"]="/data"
|
||||
["hoarder_meilisearch"]="/meili_data"
|
||||
)
|
||||
|
||||
# Logging functions
|
||||
log_message() {
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo -e "${CYAN}[${timestamp}]${NC} $1"
|
||||
echo "[${timestamp}] $1" >> "$LOG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
log_info() {
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo -e "${BLUE}[${timestamp}] INFO:${NC} $1"
|
||||
echo "[${timestamp}] INFO: $1" >> "$LOG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
log_success() {
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo -e "${GREEN}[${timestamp}] SUCCESS:${NC} $1"
|
||||
echo "[${timestamp}] SUCCESS: $1" >> "$LOG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo -e "${YELLOW}[${timestamp}] WARNING:${NC} $1"
|
||||
echo "[${timestamp}] WARNING: $1" >> "$LOG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
log_error() {
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo -e "${RED}[${timestamp}] ERROR:${NC} $1" >&2
|
||||
echo "[${timestamp}] ERROR: $1" >> "$LOG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Copy log files for this restore run to the NAS logs directory
|
||||
copy_logs_to_nas() {
|
||||
if ! mountpoint -q "/mnt/share/media" 2>/dev/null; then
|
||||
log_warning "NAS not mounted - skipping log copy to NAS"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$NAS_LOG_DIR" ]; then
|
||||
if ! mkdir -p "$NAS_LOG_DIR" 2>/dev/null; then
|
||||
log_warning "Could not create NAS log directory: $NAS_LOG_DIR"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local copied=0
|
||||
for log_file_path in "$LOG_FILE" "$MARKDOWN_LOG"; do
|
||||
if [ -f "$log_file_path" ]; then
|
||||
if cp "$log_file_path" "$NAS_LOG_DIR/" 2>/dev/null; then
|
||||
log_info "Copied log to NAS: $NAS_LOG_DIR/$(basename "$log_file_path")"
|
||||
copied=$((copied + 1))
|
||||
else
|
||||
log_warning "Failed to copy log to NAS: $log_file_path"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$copied" -gt 0 ] && log_success "Copied $copied log file(s) to NAS: $NAS_LOG_DIR"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Show usage
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Karakeep Restore Script
|
||||
|
||||
Usage: $0 <backup_directory>
|
||||
$0 --latest
|
||||
|
||||
ARGUMENTS:
|
||||
backup_directory Path to a timestamped backup directory produced by backup-karakeep.sh
|
||||
--latest Automatically use the most recent backup in $LOCAL_BACKUP_BASE
|
||||
-h, --help Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
$0 /home/acedanger/backups/karakeep/20260325_143000
|
||||
$0 --latest
|
||||
$0 /mnt/share/media/backups/karakeep/20260325_143000
|
||||
$0 --compose-dir /home/user/docker/karakeep /mnt/share/media/backups/karakeep/20260325_143000
|
||||
|
||||
WHAT THIS SCRIPT DOES:
|
||||
1. Stops all Karakeep containers
|
||||
2. Clears existing volume data
|
||||
3. Restores hoarder_data from backup archive
|
||||
4. Restores hoarder_meilisearch from backup archive
|
||||
5. Restarts all Karakeep containers
|
||||
|
||||
VOLUMES RESTORED:
|
||||
- hoarder_data (Karakeep app data: bookmarks, assets, database)
|
||||
- hoarder_meilisearch (Meilisearch search index)
|
||||
|
||||
OPTIONS:
|
||||
--compose-dir DIR Override the path to the Karakeep docker-compose directory
|
||||
(default: $COMPOSE_DIR)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
BACKUP_DIR=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
--compose-dir)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
log_error "--compose-dir requires a path argument"
|
||||
exit 1
|
||||
fi
|
||||
COMPOSE_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--latest)
|
||||
if [ ! -d "$LOCAL_BACKUP_BASE" ]; then
|
||||
log_error "Local backup base directory not found: $LOCAL_BACKUP_BASE"
|
||||
exit 1
|
||||
fi
|
||||
BACKUP_DIR=$(find "$LOCAL_BACKUP_BASE" -maxdepth 1 -mindepth 1 -type d | sort -r | head -n1)
|
||||
if [ -z "$BACKUP_DIR" ]; then
|
||||
log_error "No backups found in $LOCAL_BACKUP_BASE"
|
||||
exit 1
|
||||
fi
|
||||
log_info "Auto-selected latest backup: $BACKUP_DIR"
|
||||
shift
|
||||
;;
|
||||
"")
|
||||
log_error "No backup directory specified."
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$BACKUP_DIR" ]]; then
|
||||
BACKUP_DIR="$1"
|
||||
else
|
||||
log_error "Unexpected argument: $1"
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$BACKUP_DIR" ]]; then
|
||||
log_error "No backup directory specified."
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate backup directory
|
||||
if [ ! -d "$BACKUP_DIR" ]; then
|
||||
log_error "Backup directory not found: $BACKUP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_DIR="$(realpath "$BACKUP_DIR")"
|
||||
|
||||
# Check that the backup contains expected archives
|
||||
MISSING_ARCHIVES=()
|
||||
for volume_name in "${!KARAKEEP_VOLUMES[@]}"; do
|
||||
archive="${BACKUP_DIR}/${volume_name}.tar.gz"
|
||||
if [ ! -f "$archive" ]; then
|
||||
MISSING_ARCHIVES+=("$volume_name")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#MISSING_ARCHIVES[@]} -gt 0 ]; then
|
||||
log_warning "The following volume archives are missing from the backup:"
|
||||
for vol in "${MISSING_ARCHIVES[@]}"; do
|
||||
log_warning " - ${vol}.tar.gz"
|
||||
done
|
||||
echo ""
|
||||
echo -e "${YELLOW}Continuing will only restore the archives that are present.${NC}"
|
||||
fi
|
||||
|
||||
# Confirm restore intent
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================================${NC}"
|
||||
echo -e "${YELLOW} KARAKEEP RESTORE - DESTRUCTIVE OPERATION${NC}"
|
||||
echo -e "${YELLOW}========================================================${NC}"
|
||||
echo ""
|
||||
echo -e " Backup source : ${CYAN}${BACKUP_DIR}${NC}"
|
||||
echo -e " Compose dir : ${CYAN}${COMPOSE_DIR}${NC}"
|
||||
echo ""
|
||||
echo -e "${RED} WARNING: This will STOP all Karakeep containers and${NC}"
|
||||
echo -e "${RED} ERASE all current volume data before restoring.${NC}"
|
||||
echo -e "${RED} This action cannot be undone.${NC}"
|
||||
echo ""
|
||||
echo -n " Type 'yes' to continue: "
|
||||
read -r confirmation
|
||||
|
||||
if [[ "$confirmation" != "yes" ]]; then
|
||||
log_info "Restore cancelled by user."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Verify compose file exists
|
||||
if [ ! -f "$COMPOSE_DIR/docker-compose.yml" ]; then
|
||||
log_error "docker-compose.yml not found at $COMPOSE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify Docker is available
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
log_error "Docker is not running or not accessible"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONTAINERS_RUNNING=false
|
||||
|
||||
RESTORE_START_TIME=$(date +%s)
|
||||
log_message "=== KARAKEEP RESTORE STARTED ==="
|
||||
log_message "Host: $(hostname)"
|
||||
log_message "Restore Timestamp: $RESTORE_TIMESTAMP"
|
||||
log_message "Backup Source: $BACKUP_DIR"
|
||||
log_message "Compose Dir: $COMPOSE_DIR"
|
||||
log_info "Log file: $LOG_FILE"
|
||||
|
||||
# Record restore parameters in markdown log
|
||||
{
|
||||
echo "## Restore Parameters"
|
||||
echo "- **Backup Source**: $BACKUP_DIR"
|
||||
echo "- **Compose Dir**: $COMPOSE_DIR"
|
||||
echo ""
|
||||
} >> "$MARKDOWN_LOG"
|
||||
|
||||
# Ensure containers are restarted on unexpected exit
|
||||
cleanup_on_exit() {
|
||||
if [[ "$CONTAINERS_RUNNING" == "false" ]]; then
|
||||
log_warning "Attempting to restart Karakeep containers after unexpected exit..."
|
||||
docker compose -f "$COMPOSE_DIR/docker-compose.yml" up -d >> "$LOG_FILE" 2>&1 || \
|
||||
log_error "Failed to restart containers - manual intervention required"
|
||||
fi
|
||||
copy_logs_to_nas
|
||||
}
|
||||
trap cleanup_on_exit EXIT
|
||||
|
||||
# Step 1: Stop containers
|
||||
log_message "Step 1/5: Stopping Karakeep containers..."
|
||||
down_output=$(docker compose -f "$COMPOSE_DIR/docker-compose.yml" down --progress plain 2>&1)
|
||||
down_exit=$?
|
||||
echo "$down_output" | tee -a "$LOG_FILE" > /dev/null
|
||||
if [[ $down_exit -eq 0 ]]; then
|
||||
log_success "Containers stopped and removed"
|
||||
else
|
||||
log_warning "docker compose down reported an error (exit $down_exit) - proceeding anyway"
|
||||
fi
|
||||
|
||||
# Step 2: Ensure external volumes exist (create if absent)
|
||||
log_message "Step 2/5: Ensuring Docker volumes exist..."
|
||||
for volume_name in "${!KARAKEEP_VOLUMES[@]}"; do
|
||||
if ! docker volume inspect "$volume_name" > /dev/null 2>&1; then
|
||||
log_info "Creating missing volume: $volume_name"
|
||||
docker volume create "$volume_name"
|
||||
fi
|
||||
log_info "Volume ready: $volume_name"
|
||||
done
|
||||
|
||||
# Step 3: Clear existing volume data
|
||||
log_message "Step 3/5: Clearing existing volume data..."
|
||||
for volume_name in "${!KARAKEEP_VOLUMES[@]}"; do
|
||||
mount_path="${KARAKEEP_VOLUMES[$volume_name]}"
|
||||
archive="${BACKUP_DIR}/${volume_name}.tar.gz"
|
||||
|
||||
# Only clear volumes for which we have a backup to restore
|
||||
if [ ! -f "$archive" ]; then
|
||||
log_warning "Skipping clear of $volume_name - no archive found, keeping existing data"
|
||||
continue
|
||||
fi
|
||||
|
||||
log_info "Clearing volume: $volume_name"
|
||||
if docker run --rm \
|
||||
--volume "${volume_name}:${mount_path}" \
|
||||
alpine \
|
||||
find "${mount_path:?}" -mindepth 1 -delete 2>&1 | tee -a "$LOG_FILE"; then
|
||||
log_success "Cleared volume: $volume_name"
|
||||
else
|
||||
log_warning "Could not fully clear $volume_name - restore may overlay existing data"
|
||||
fi
|
||||
done
|
||||
|
||||
# Step 4: Restore volumes from archives
|
||||
log_message "Step 4/5: Restoring volume data from archives..."
|
||||
|
||||
RESTORE_SUCCESS=0
|
||||
RESTORE_FAILED=0
|
||||
|
||||
for volume_name in "${!KARAKEEP_VOLUMES[@]}"; do
|
||||
mount_path="${KARAKEEP_VOLUMES[$volume_name]}"
|
||||
archive="${BACKUP_DIR}/${volume_name}.tar.gz"
|
||||
|
||||
if [ ! -f "$archive" ]; then
|
||||
log_warning "Skipping restore of $volume_name - archive not found: $archive"
|
||||
RESTORE_FAILED=$((RESTORE_FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
log_info "Verifying archive integrity: $archive"
|
||||
if ! gzip -t "$archive" 2>/dev/null; then
|
||||
log_error "Archive is corrupt or invalid: $archive"
|
||||
RESTORE_FAILED=$((RESTORE_FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
log_info "Restoring volume $volume_name from $archive"
|
||||
|
||||
# Extract the archive into the volume using an Alpine helper container.
|
||||
# The archive was created with the directory name (e.g. "data" or "meili_data")
|
||||
# at the top level, so we extract into the parent of the mount path.
|
||||
if docker run --rm \
|
||||
--volume "${volume_name}:${mount_path}" \
|
||||
--volume "${archive}:/backup/${volume_name}.tar.gz:ro" \
|
||||
alpine \
|
||||
tar xzf "/backup/${volume_name}.tar.gz" -C "$(dirname "$mount_path")" 2>&1 | tee -a "$LOG_FILE"; then
|
||||
log_success "Restored volume: $volume_name"
|
||||
RESTORE_SUCCESS=$((RESTORE_SUCCESS + 1))
|
||||
else
|
||||
log_error "Failed to restore volume: $volume_name"
|
||||
RESTORE_FAILED=$((RESTORE_FAILED + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Step 5: Start containers
|
||||
log_message "Step 5/5: Starting Karakeep containers..."
|
||||
CONTAINERS_RUNNING=true
|
||||
|
||||
up_output=$(docker compose -f "$COMPOSE_DIR/docker-compose.yml" up -d --progress plain 2>&1)
|
||||
up_exit=$?
|
||||
echo "$up_output" | tee -a "$LOG_FILE" > /dev/null
|
||||
if [[ $up_exit -eq 0 ]]; then
|
||||
log_success "Karakeep containers started"
|
||||
else
|
||||
log_error "Failed to start Karakeep containers (exit $up_exit) - check docker compose logs"
|
||||
CONTAINERS_RUNNING=false
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove the trap since we handled startup cleanly
|
||||
trap - EXIT
|
||||
|
||||
# Calculate total restore time
|
||||
RESTORE_END_TIME=$(date +%s)
|
||||
RESTORE_TOTAL_TIME=$((RESTORE_END_TIME - RESTORE_START_TIME))
|
||||
|
||||
# Write markdown summary
|
||||
{
|
||||
echo "## Restore Results"
|
||||
echo "- **Volumes Restored**: $RESTORE_SUCCESS"
|
||||
echo "- **Volumes Failed**: $RESTORE_FAILED"
|
||||
echo "- **Duration**: ${RESTORE_TOTAL_TIME}s"
|
||||
echo "- **Completed**: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
} >> "$MARKDOWN_LOG"
|
||||
|
||||
# Copy logs to NAS
|
||||
copy_logs_to_nas
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================================${NC}"
|
||||
if [ "$RESTORE_FAILED" -eq 0 ]; then
|
||||
echo -e "${GREEN} KARAKEEP RESTORE COMPLETE${NC}"
|
||||
else
|
||||
echo -e "${YELLOW} KARAKEEP RESTORE COMPLETE (WITH WARNINGS)${NC}"
|
||||
fi
|
||||
echo -e "${GREEN}========================================================${NC}"
|
||||
echo ""
|
||||
echo -e " Volumes restored : ${GREEN}${RESTORE_SUCCESS}${NC}"
|
||||
echo -e " Volumes failed : ${RED}${RESTORE_FAILED}${NC}"
|
||||
echo -e " Backup source : ${CYAN}${BACKUP_DIR}${NC}"
|
||||
echo -e " Duration : ${RESTORE_TOTAL_TIME}s"
|
||||
echo -e " Log file : ${CYAN}${LOG_FILE}${NC}"
|
||||
echo -e " Markdown report : ${CYAN}${MARKDOWN_LOG}${NC}"
|
||||
echo ""
|
||||
|
||||
if [ "$RESTORE_FAILED" -gt 0 ]; then
|
||||
log_warning "Some volumes could not be restored. Review the output above."
|
||||
log_warning "Log file: $LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Karakeep has been fully restored from: $BACKUP_DIR"
|
||||
log_message "Log file: $LOG_FILE"
|
||||
log_message "Markdown report: $MARKDOWN_LOG"
|
||||
exit 0
|
||||
Reference in New Issue
Block a user