#!/bin/bash # ============================================================================================== # ============================= Container Data Export ========================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Exports a container's appdata directory to a compressed tar archive. Stops # the container before archiving and restarts it after — ensures a clean, # consistent backup. Use before major updates, pool migrations, destructive # appdata operations, or when archiving a container being removed from the stack. # # Output: ContainerName_YYYY-MM-DD_HH-MM.tar.gz — timestamped, no overwrite. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Archive Verification Before Restart # The archive is tested with tar --test-file before the container is restarted. # A corrupt archive is not a usable backup — this catches tar failures, I/O # errors, and truncated writes before declaring success. If verification fails, # the container is still restarted (appdata is unchanged) and an error logged. # # Conservative Space Estimate # Required space is estimated as appdata size × 1.1 (10% buffer). The actual # compressed archive will typically be much smaller — database files compress # well, media files do not. The estimate is a conservative floor, not a # prediction. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Container Restart Rule # Tracks whether the container was running before the export. Running containers # are restarted after completion; already-stopped containers are left stopped. # The restart happens on every exit path — a failed tar does not leave the # container stuck stopped. # # Partial Archive Cleanup # If tar fails, the incomplete archive is removed. A partial archive is worse # than no archive — it can look valid but restore to an incomplete state. # # Docker Timeout # DOCKER_TIMEOUT (default: 30s) caps all docker calls. Guards against a hung # daemon blocking the script indefinitely. # # Notification Validated # platform_require_cmd confirms the notify script is present before use. # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # container_data_export.sh ContainerName /path/to/appdata /path/to/output/dir # Stop container, create archive, verify, restart container. # Example: container_data_export.sh Emby /mnt/media-servers/.../Emby /mnt/user/Backups/ # # container_data_export.sh ContainerName /path/to/appdata /path/to/output/dir --dry-run # Show what would be archived and estimated size. No container stop, no tar. # # container_data_export.sh ContainerName /path/to/appdata /path/to/output/dir --log # Verbose output: space check, tar progress, verification result. # # ============================================================================================== 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 acquire_lock if ! command -v docker &>/dev/null; then error "Docker command not found" exit 1 fi # 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 echo "$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) echo "$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 echo "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 echo "$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 echo "$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 echo "$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