citadel/backup/restore
Nicolas Duhamel 17414fee4a Refactor: Unify interface with manage and install scripts at root
- Move backup/manage to root with auto-sourcing configuration
- Create consolidated ./install script (replaces gen-conf.sh + init-restic.sh)
- Add protection against direct execution of backup/ scripts
- Update documentation (README.md, CLAUDE.md) for new architecture
- Remove obsolete gen-conf.sh and init-restic.sh

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-23 21:41:09 +02:00

487 lines
13 KiB
Bash
Executable File

#!/bin/bash
# Restic backup restore script
# This script should only be called via ./manage restore/restore-prod <service>
set -euo pipefail # Strict mode: exit on error, undefined vars, pipe failures
# Check if called via manage script
if [ "${CALLED_FROM_MANAGE:-}" != "true" ]; then
echo "ERROR: This script should not be called directly."
echo "Use: ./manage restore <service_name> or ./manage restore-prod <service_name>"
exit 1
fi
# Configuration should be sourced before running: source backup.env
# Configuration
readonly BACKUP_DIR="$BACKUP_BASE_DIR"
readonly SERVICES_DIR="$SERVICES_BASE_DIR"
readonly CONFIG_FILE="$RESTIC_CONFIG_FILE"
readonly SCRIPT_NAME=$(basename "$0")
readonly VERSION="2.1.0"
# Global variables
USER_CHOICE=""
# Colors
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m'
# Logging functions
log() { echo -e "${BLUE}[$(date '+%H:%M:%S')]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; exit 1; }
success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
# Check dependencies
check_dependencies() {
local deps=("restic" "jq" "awk" "grep")
local missing=()
for dep in "${deps[@]}"; do
if ! command -v "$dep" &>/dev/null; then
missing+=("$dep")
fi
done
if [ ${#missing[@]} -ne 0 ]; then
error "Missing dependencies: ${missing[*]}"
fi
}
# Show help
show_help() {
cat << EOF
Usage: $SCRIPT_NAME <service_name> [OPTIONS]
Options:
--test Restore to test instance (default)
--production Restore to production instance
-h, --help Show this help message
-v, --version Show version
Examples:
$SCRIPT_NAME paperless # Restore in test mode (default)
$SCRIPT_NAME paperless --test # Restore to test instance
$SCRIPT_NAME paperless --production # Restore to production (requires confirmation)
Available services:
EOF
if [ -f "$CONFIG_FILE" ]; then
# Secure validation of config file
if grep -qE '^[^#]*export\s+RESTIC_' "$CONFIG_FILE"; then
# shellcheck source=/dev/null
source "$CONFIG_FILE"
# List services from snapshots
if restic snapshots &>/dev/null; then
restic snapshots --json 2>/dev/null | \
jq -r '.[].tags[]' 2>/dev/null | \
grep -v -E '^(daily|weekly|monthly|yearly)$' | \
sort -u | sed 's/^/ /' || echo " (cannot list services)"
else
echo " (cannot access repository)"
fi
else
error "Invalid configuration file format"
fi
else
echo " (no config found)"
fi
}
# Check prerequisites
check_prerequisites() {
# Check configuration file
if [ ! -f "$CONFIG_FILE" ]; then
error "Config file not found: $CONFIG_FILE"
fi
# Check config file permissions
if [ ! -r "$CONFIG_FILE" ]; then
error "Cannot read config file: $CONFIG_FILE"
fi
# Secure validation before source
if ! grep -qE '^[^#]*export\s+RESTIC_' "$CONFIG_FILE"; then
error "Invalid configuration file format"
fi
# Source config
# shellcheck source=/dev/null
source "$CONFIG_FILE"
# Check required variables
if [ -z "${RESTIC_REPOSITORY:-}" ]; then
error "RESTIC_REPOSITORY not defined in config"
fi
# Test restic access
if ! restic snapshots &>/dev/null; then
error "Cannot access Restic repository"
fi
log "Prerequisites OK"
}
# Show available snapshots
show_snapshots() {
local service="$1"
# Validate service name
if [[ ! "$service" =~ ^[a-zA-Z0-9_-]+$ ]]; then
error "Invalid service name format"
fi
echo ""
log "Available snapshots for $service:"
echo ""
# Get snapshots with error handling
local snapshots_json
if ! snapshots_json=$(restic snapshots --tag "$service" --json 2>/dev/null); then
error "Failed to retrieve snapshots"
fi
if [ -z "$snapshots_json" ] || [ "$snapshots_json" = "[]" ]; then
warning "No snapshots found for service: $service"
return 1
fi
# Format and display snapshots
echo "$snapshots_json" | jq -r '.[] | "\(.short_id) \(.time[:19]) \(.summary.total_files_processed // "?") files"' | \
awk 'BEGIN {printf "%-12s %-20s %s\n", "ID", "DATE", "FILES"; print "----------------------------------------"}
{printf "%-12s %-20s %s\n", $1, $2, $3" "$4}'
echo ""
return 0
}
# Get user choice
get_user_choice() {
local max_attempts=3
local attempt=0
while [ $attempt -lt $max_attempts ]; do
echo ""
echo "Enter snapshot ID (or 'latest' for most recent, 'q' to quit):"
echo -n "> "
read -r USER_CHOICE
echo ""
# Basic validation
if [ "$USER_CHOICE" = "q" ] || [ "$USER_CHOICE" = "quit" ]; then
log "Operation cancelled by user"
exit 0
fi
if [ -n "$USER_CHOICE" ]; then
# Format validation (alphanumeric or "latest")
if [[ "$USER_CHOICE" =~ ^[a-zA-Z0-9]+$ ]] || [ "$USER_CHOICE" = "latest" ]; then
return 0
else
warning "Invalid snapshot ID format. Please use alphanumeric characters only."
fi
else
warning "No selection made"
fi
((attempt++))
done
error "Maximum attempts exceeded"
}
# Resolve snapshot ID
resolve_snapshot_id() {
local service="$1"
local choice="$2"
if [ "$choice" = "latest" ]; then
local latest_id
latest_id=$(restic snapshots --tag "$service" --json 2>/dev/null | jq -r '.[-1].short_id // empty')
if [ -z "$latest_id" ]; then
error "No snapshots found for service: $service"
fi
echo "$latest_id"
else
echo "$choice"
fi
}
# Verify snapshot
verify_snapshot() {
local service="$1"
local snapshot_id="$2"
log "Verifying snapshot $snapshot_id for service $service"
# Get snapshot info
local snapshot_info
snapshot_info=$(restic snapshots --tag "$service" --json 2>/dev/null | \
jq -r ".[] | select(.short_id == \"$snapshot_id\")")
if [ -z "$snapshot_info" ]; then
error "Snapshot $snapshot_id not found for service $service"
fi
# Extract date
local date
date=$(echo "$snapshot_info" | jq -r '.time[:19]')
# Check integrity (optional but recommended)
if restic check --read-data-subset=1/10 "$snapshot_id" &>/dev/null; then
success "Found snapshot $snapshot_id from $date (integrity check passed)"
else
warning "Found snapshot $snapshot_id from $date (integrity check skipped)"
fi
}
# Check service capabilities
check_service_capabilities() {
local service="$1"
local service_dir="$SERVICES_DIR/$service"
echo ""
log "Checking service capabilities for $service"
# Check service directory exists
if [ ! -d "$service_dir" ]; then
error "Service directory not found: $service_dir"
fi
# Check for custom restore script
local custom_restore="$service_dir/restore"
local has_custom_restore=false
if [ -f "$custom_restore" ] && [ -x "$custom_restore" ]; then
has_custom_restore=true
success "Found custom restore script: $custom_restore"
else
log "No custom restore script found - will extract to temporary directory"
fi
echo "$has_custom_restore"
}
# Create secure temporary directory
create_secure_temp_dir() {
local prefix="${1:-restore}"
local temp_dir
# Use mktemp to create a secure temporary directory
temp_dir=$(mktemp -d "$TEMP_DIR/${prefix}-$(date +%Y%m%d-%H%M%S)-XXXXXX")
if [ ! -d "$temp_dir" ]; then
error "Failed to create temporary directory"
fi
# Set restrictive permissions
chmod 700 "$temp_dir"
echo "$temp_dir"
}
# Extract snapshot to temporary directory
extract_snapshot_to_temp() {
local snapshot_id="$1"
local temp_dir
temp_dir=$(create_secure_temp_dir "restore")
log "Extracting snapshot $snapshot_id to temporary directory"
log "Target directory: $temp_dir"
# Extract with error handling
if ! restic restore "$snapshot_id" --target "$temp_dir" 2>&1 | tee -a "$temp_dir/restore.log"; then
error "Failed to extract snapshot"
fi
success "Snapshot extracted to: $temp_dir"
echo ""
echo "📁 Contents of the restore:"
ls -la "$temp_dir" | head -20
echo ""
echo "💡 Manual restore instructions:"
echo " 1. Review the extracted data in: $temp_dir"
echo " 2. Copy the required files to their destination"
echo " 3. Verify permissions and ownership"
echo " 4. Remove the temporary directory: rm -rf $temp_dir"
echo ""
echo "⚠️ The directory will remain until you delete it manually"
}
# Run custom restore
run_custom_restore() {
local service="$1"
local snapshot_id="$2"
local mode="$3"
local service_dir="$SERVICES_DIR/$service"
local custom_restore="$service_dir/restore"
log "Running custom restore script for $service"
log "Script: $custom_restore"
log "Snapshot: $snapshot_id"
log "Mode: $mode"
# Create restore log
local log_file="$TEMP_DIR/restore-${service}-$(date +%Y%m%d-%H%M%S).log"
# Change directory with verification
if ! cd "$service_dir"; then
error "Cannot change to service directory: $service_dir"
fi
# Execute restore script with log capture
if "$custom_restore" "$snapshot_id" "--$mode" 2>&1 | tee "$log_file"; then
success "Custom restore script completed"
log "Restore log saved to: $log_file"
else
error "Custom restore script failed. Check log: $log_file"
fi
}
# Main function
main() {
# Check dependencies
check_dependencies
# Parse arguments
if [ $# -eq 0 ]; then
show_help
exit 0
fi
local service=""
local mode="test" # Default mode: test (safer)
# Parse arguments
while [ $# -gt 0 ]; do
case "$1" in
--test)
mode="test"
shift
;;
--production)
mode="production"
shift
;;
-h|--help)
show_help
exit 0
;;
-v|--version)
echo "$SCRIPT_NAME version $VERSION"
exit 0
;;
-*)
error "Unknown option: $1"
;;
*)
if [ -z "$service" ]; then
service="$1"
else
error "Too many arguments"
fi
shift
;;
esac
done
# Check service is specified
if [ -z "$service" ]; then
error "Service name required"
fi
# Validate service name format
if [[ ! "$service" =~ ^[a-zA-Z0-9_-]+$ ]]; then
error "Invalid service name format. Use only alphanumeric characters, hyphens, and underscores."
fi
# Check service directory exists
if [ ! -d "$SERVICES_DIR/$service" ]; then
error "Service directory not found: $SERVICES_DIR/$service"
fi
echo "=== Restore $service ==="
log "Mode: $mode"
# Step 1: Check prerequisites
check_prerequisites
# Step 2: Check service capabilities
local has_custom_restore
has_custom_restore=$(check_service_capabilities "$service")
# Step 3: Show snapshots
if ! show_snapshots "$service"; then
exit 1
fi
# Step 4: Get user choice
get_user_choice
local choice="$USER_CHOICE"
# Step 5: Resolve and verify snapshot
local snapshot_id
snapshot_id=$(resolve_snapshot_id "$service" "$choice")
verify_snapshot "$service" "$snapshot_id"
# Step 6: Display restore plan
echo ""
log "Restore plan:"
echo " Service: $service"
echo " Snapshot: $snapshot_id"
echo " Mode: $mode"
if [ "$has_custom_restore" = "true" ]; then
echo " Method: Custom restore script"
else
echo " Method: Extract to temporary directory"
fi
# Step 7: Final confirmation
echo ""
if [ "$mode" = "test" ]; then
read -p "Proceed with TEST restore? [y/N]: " -r confirm
if [[ ! $confirm =~ ^[Yy]$ ]]; then
echo "Cancelled."
exit 0
fi
else
echo -e "${RED}⚠️ WARNING: This is a PRODUCTION restore!${NC}"
echo -e "${RED}This operation may overwrite existing data.${NC}"
echo ""
read -p "Type 'yes-production' to proceed: " -r confirm
if [ "$confirm" != "yes-production" ]; then
echo "Cancelled."
exit 0
fi
fi
# Step 8: Execute restore
echo ""
if [ "$has_custom_restore" = "true" ]; then
run_custom_restore "$service" "$snapshot_id" "$mode"
else
# No custom script, extract to temp for both test and production
extract_snapshot_to_temp "$snapshot_id"
fi
echo ""
success "Restore operation completed!"
}
# Trap to clean up on interruption
trap 'echo -e "\n${RED}Interrupted${NC}"; exit 130' INT TERM
# Entry point
main "$@"