diff --git a/restore-karakeep.sh b/restore-karakeep.sh new file mode 100755 index 0000000..aa36c52 --- /dev/null +++ b/restore-karakeep.sh @@ -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 +# ./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