#!/bin/bash # restore-karakeep.sh # Restore Karakeep Docker volumes from a backup created by backup-karakeep.sh # # Usage: # ./restore-karakeep.sh # ./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 $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