Files
shell/restore-karakeep.sh

448 lines
14 KiB
Bash
Executable File

#!/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