#!/bin/bash # ============================================================================================== # ============================= Container Data Export ========================================== # ============================================================================================== # Exports a container's appdata directory to a compressed tar archive. # Stops the container before archiving and restarts it after — ensures clean consistent backup. # Verifies the archive after creation — confirms backup is valid before restarting container. # # ── WHEN TO USE ─────────────────────────────────────────────────────────────────────────────── # - Before major container updates (roll back if update goes wrong) # - Before pool migrations or disk replacements # - When archiving a container being removed from the stack # - Before destructive operations on appdata (database migrations etc.) # - One-off backup of a specific container without running full backup # # ── OUTPUT FILE NAMING ──────────────────────────────────────────────────────────────────────── # ContainerName_YYYY-MM-DD_HH-MM.tar.gz # Timestamp in filename — run multiple times safely, no overwrite ✅ # # ── SPACE CHECK ─────────────────────────────────────────────────────────────────────────────── # Estimates required space as appdata size × 1.1 (10% buffer). # Compressed archive will typically be much smaller — this is a conservative floor. # gzip compression ratio depends heavily on content — database files compress well, # media files do not. If output is on a media share estimate may be pessimistic. # # ── ARCHIVE VERIFICATION ────────────────────────────────────────────────────────────────────── # After creation the archive is tested with tar --test-file before restarting the container. # If verification fails the container is still restarted (data unchanged) and an error logged. # A corrupt archive is not a usable backup — do not assume the archive is good without this. # # ── SAFEGUARDS ──────────────────────────────────────────────────────────────────────────────── # DOCKER_TIMEOUT — docker calls protected against hung daemon # Container restart rule — was running → restart | was stopped → leave stopped ✅ # Archive cleanup — partial archive removed on tar failure # Archive verification — tar --test-file after creation # Container restart on — any failure path still restarts container if it was running # validate_unraid_cmd — notify validated before use # Silent on success — only problems produce visible output # # ── USAGE ───────────────────────────────────────────────────────────────────────────────────── # container_data_export.sh ContainerName /path/to/appdata /path/to/output/dir # container_data_export.sh Emby /mnt/media-servers/Media_Server/Emby /mnt/user/Backups/ # container_data_export.sh Emby /mnt/media-servers/Media_Server/Emby /mnt/user/Backups/ --dry-run # container_data_export.sh Emby /mnt/media-servers/Media_Server/Emby /mnt/user/Backups/ --log # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" parse_args "$@" DOCKER_TIMEOUT=30 # longer timeout — stop can take time on large containers # ── Positional args ─────────────────────────────────────────────────────────────────────────── CONTAINER_NAME="${PARSED_ARGS[0]:-}" APPDATA_PATH="${PARSED_ARGS[1]:-}" OUTPUT_DIR="${PARSED_ARGS[2]:-}" # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi validate_unraid_cmd \ "/usr/local/emhttp/plugins/dynamix/scripts/notify" \ "" "" \ "unRAID notify script" || warn "unRAID notify script not found — native notifications disabled" # detect_hosts() sets MY_ID — used in summary detect_hosts # Arg validation if [[ -z "$CONTAINER_NAME" || -z "$APPDATA_PATH" || -z "$OUTPUT_DIR" ]]; then error "Usage: container_data_export.sh " error "Example: container_data_export.sh Emby /mnt/media-servers/Media_Server/Emby /mnt/user/Backups/" exit 1 fi if [[ ! -d "$APPDATA_PATH" ]]; then error "Appdata path not found: $APPDATA_PATH" exit 1 fi if [[ ! -d "$OUTPUT_DIR" ]]; then error "Output directory not found: $OUTPUT_DIR" error "Create it first: mkdir -p \"$OUTPUT_DIR\"" exit 1 fi # Space check — conservative: appdata × 1.1 APPDATA_SIZE_KB=$(du -sk "$APPDATA_PATH" 2>/dev/null | cut -f1) OUTPUT_FREE_KB=$(df "$OUTPUT_DIR" --output=avail 2>/dev/null | tail -1 | tr -d ' ') REQUIRED_KB=$(( APPDATA_SIZE_KB * 11 / 10 )) APPDATA_SIZE_H=$(du -sh "$APPDATA_PATH" 2>/dev/null | cut -f1) OUTPUT_FREE_H=$(df -h "$OUTPUT_DIR" --output=avail 2>/dev/null | tail -1 | tr -d ' ') if [[ "$OUTPUT_FREE_KB" -lt "$REQUIRED_KB" ]]; then error "Insufficient space in $OUTPUT_DIR" error "Estimated need: ~${APPDATA_SIZE_H} (×1.1 conservative) — available: ${OUTPUT_FREE_H}" exit 1 fi log "Container: $CONTAINER_NAME" log "Appdata: $APPDATA_PATH ($APPDATA_SIZE_H)" log "Output: $OUTPUT_DIR ($OUTPUT_FREE_H free)" log "Space check passed" [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made" # ── Ensure container is restarted on any exit if it was running ──────────────────────────────── CONTAINER_WAS_RUNNING=false ARCHIVE_PATH="" cleanup_on_exit() { local exit_code=$? # Remove partial archive on failure if [[ "$exit_code" -ne 0 && -n "$ARCHIVE_PATH" && -f "$ARCHIVE_PATH" ]]; then warn "Removing partial archive: $ARCHIVE_PATH" rm -f "$ARCHIVE_PATH" 2>/dev/null fi # Always restart container if it was running if [[ "$CONTAINER_WAS_RUNNING" == true && "$DRY_RUN" == false ]]; then local status status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \ "$CONTAINER_NAME" 2>/dev/null) if [[ "$status" != "true" ]]; then warn "Restarting $CONTAINER_NAME (cleanup)..." timeout "$DOCKER_TIMEOUT" docker start "$CONTAINER_NAME" >/dev/null 2>&1 || \ error "Failed to restart $CONTAINER_NAME — start it manually" fi fi } trap cleanup_on_exit EXIT # ============================================================================================== # ━━━ Stop Container ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_STOP Stop Container ━━━" STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \ "$CONTAINER_NAME" 2>/dev/null) case "$STATUS" in true) CONTAINER_WAS_RUNNING=true log "Stopping $CONTAINER_NAME for clean export..." if [[ "$DRY_RUN" == false ]]; then if timeout "$DOCKER_TIMEOUT" docker stop "$CONTAINER_NAME" >/dev/null 2>&1; then log "$CONTAINER_NAME stopped ✅" else error "Failed to stop $CONTAINER_NAME — aborting export" exit 1 fi else warn "DRY RUN — would stop $CONTAINER_NAME" fi ;; false) log "$CONTAINER_NAME is not running — archiving as-is (was stopped state respected)" ;; "") error "$CONTAINER_NAME not found — check container name" exit 1 ;; *) warn "$CONTAINER_NAME status: $STATUS — proceeding with caution" ;; esac # ============================================================================================== # ━━━ Archive ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_SYNC Archive ━━━" TIMESTAMP=$(date '+%Y-%m-%d_%H-%M') ARCHIVE_NAME="${CONTAINER_NAME}_${TIMESTAMP}.tar.gz" ARCHIVE_PATH="${OUTPUT_DIR}/${ARCHIVE_NAME}" warn "Creating: $ARCHIVE_PATH" warn "Source: $APPDATA_PATH ($APPDATA_SIZE_H)" START=$(date +%s) ARCHIVE_VERIFIED=false if [[ "$DRY_RUN" == false ]]; then if tar -czf "$ARCHIVE_PATH" \ -C "$(dirname "$APPDATA_PATH")" \ "$(basename "$APPDATA_PATH")" 2>/dev/null; then ARCHIVE_SIZE=$(du -sh "$ARCHIVE_PATH" 2>/dev/null | cut -f1) warn "Archive created: $ARCHIVE_NAME ($ARCHIVE_SIZE)" # Verify archive integrity before declaring success log "Verifying archive..." if tar --test-label -f "$ARCHIVE_PATH" 2>/dev/null || \ tar -tzf "$ARCHIVE_PATH" >/dev/null 2>&1; then log "Archive verified ✅" ARCHIVE_VERIFIED=true else error "Archive verification FAILED — archive may be corrupt" error "Container will be restarted but DO NOT rely on this backup" notify "Container export archive corrupt — $CONTAINER_NAME backup may be unusable" \ "Container Export" "warning" fi else error "tar failed — archive creation unsuccessful" exit 1 fi else warn "DRY RUN — would create: $ARCHIVE_PATH" ARCHIVE_VERIFIED=true fi END=$(date +%s) # ============================================================================================== # ━━━ Restart Container ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_START Restart Container ━━━" RESTART_OK=false if [[ "$CONTAINER_WAS_RUNNING" == true ]]; then log "Restarting $CONTAINER_NAME..." if [[ "$DRY_RUN" == false ]]; then if timeout "$DOCKER_TIMEOUT" docker start "$CONTAINER_NAME" >/dev/null 2>&1; then # Brief settle then verify sleep 3 POST_STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \ '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null) if [[ "$POST_STATUS" == "true" ]]; then log "$CONTAINER_NAME restarted and running ✅" RESTART_OK=true else error "$CONTAINER_NAME started but crashed immediately — check container logs" notify "$CONTAINER_NAME failed to stay running after export on $(hostname)" \ "Container Export" "warning" fi else error "Failed to restart $CONTAINER_NAME — start it manually" notify "$CONTAINER_NAME failed to restart after export on $(hostname)" \ "Container Export" "warning" fi else warn "DRY RUN — would restart $CONTAINER_NAME" RESTART_OK=true fi else log "$CONTAINER_NAME was not running — leaving stopped (state respected) ✅" RESTART_OK=true fi # Clear trap — clean exit, cleanup_on_exit no longer needed trap - EXIT # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== echo "" echo "━━━━━ $ICON_SUMMARY CONTAINER EXPORT SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_CONTAINERS Container: $CONTAINER_NAME" echo "$ICON_DISK Appdata: $APPDATA_PATH ($APPDATA_SIZE_H)" echo "$ICON_SYNC Archive: ${ARCHIVE_NAME:-DRY RUN} ${ARCHIVE_SIZE:+($ARCHIVE_SIZE)}" echo "$ICON_SHIELD Verified: $([[ "$ARCHIVE_VERIFIED" == true ]] && echo "✅" || echo "❌ FAILED")" echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" echo "" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no changes made" elif [[ "$ARCHIVE_VERIFIED" == true && "$RESTART_OK" == true ]]; then log "$ICON_DONE Status: done — $ARCHIVE_NAME" elif [[ "$ARCHIVE_VERIFIED" == false ]]; then echo "$ICON_ERROR Status: archive verification FAILED — check backup before relying on it" else warn "Status: complete with warnings — check restart status above" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" [[ "$ARCHIVE_VERIFIED" == false ]] && exit 1 exit 0