288 lines
13 KiB
Bash
Executable File
288 lines
13 KiB
Bash
Executable File
#!/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.
|
||
#
|
||
# ==============================================================================================
|
||
# OPERATIONAL MODEL
|
||
# ==============================================================================================
|
||
#
|
||
# 1. Validate arguments — container name, appdata path, output directory all required
|
||
# 2. Verify the output directory exists and holds enough free space for the archive
|
||
# 3. Record whether the container is currently running
|
||
# 4. Stop the container if it was running
|
||
# 5. tar czf the appdata directory to a timestamped archive
|
||
# 6. Verify the archive with tar --test-file
|
||
# 7. Restart the container only if it was running before — a container found stopped
|
||
# stays stopped
|
||
#
|
||
# ==============================================================================================
|
||
# CONFIGURATION
|
||
# ==============================================================================================
|
||
#
|
||
# This tool takes everything as arguments rather than from conf:
|
||
#
|
||
# <container> Container to stop for the duration of the export
|
||
# <appdata_path> Directory to archive
|
||
# <output_dir> Destination for the archive — must already exist
|
||
#
|
||
# That is deliberate. It is used for one-off exports of arbitrary containers, including
|
||
# ones being removed from the stack entirely, so there is no meaningful configured list
|
||
# to draw from and nothing host-specific to alias.
|
||
#
|
||
# Note the archive is written as root and is not chowned afterwards. That is fine for the
|
||
# operator-invoked use this tool is for, but worth knowing if the output directory is a
|
||
# user share reached over SMB.
|
||
#
|
||
# ==============================================================================================
|
||
# 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.
|
||
#
|
||
# ==============================================================================================
|
||
# 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 <ContainerName> <appdata_path> <output_dir>"
|
||
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
|
||
#
|
||
# One traversal of the appdata tree, not two. This measured it twice — once with du -sk for the
|
||
# arithmetic and again with du -sh for the message — and walked $OUTPUT_DIR twice as well. On a
|
||
# container's appdata that is the expensive call in this script, paid twice to print a string.
|
||
# An unreadable size is not a small size. Defaulting either of these to 0 makes the check below
|
||
# pass — a zero requirement clears any free space, and the export then runs toward a disk that
|
||
# was never measured. Unknown stops here instead.
|
||
if ! APPDATA_SIZE_MB=$(dir_size_mb "$APPDATA_PATH"); then
|
||
error "Could not measure $APPDATA_PATH — refusing to export without a space check"
|
||
exit 1
|
||
fi
|
||
if ! OUTPUT_FREE_MB=$(disk_free_mb "$OUTPUT_DIR"); then
|
||
error "Could not read free space on $OUTPUT_DIR — refusing to export without a space check"
|
||
exit 1
|
||
fi
|
||
REQUIRED_MB=$(( APPDATA_SIZE_MB * 11 / 10 ))
|
||
APPDATA_SIZE_H=$(format_mb "$APPDATA_SIZE_MB")
|
||
OUTPUT_FREE_H=$(format_mb "$OUTPUT_FREE_MB")
|
||
|
||
if [[ "$OUTPUT_FREE_MB" -lt "$REQUIRED_MB" ]]; 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
|
||
container_force_restart_if_needed "$CONTAINER_NAME" "$CONTAINER_WAS_RUNNING"
|
||
}
|
||
trap cleanup_on_exit EXIT
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Stop Container ━━━
|
||
# ==============================================================================================
|
||
echo ""
|
||
echo "━━━ $ICON_STOP Stop Container ━━━"
|
||
|
||
container_stop_for_maintenance "$CONTAINER_NAME" CONTAINER_WAS_RUNNING \
|
||
"Stopping $CONTAINER_NAME for clean export..." log || exit 1
|
||
[[ "$CONTAINER_WAS_RUNNING" == false ]] && \
|
||
echo "$CONTAINER_NAME is not running — archiving as-is (was stopped state respected)"
|
||
|
||
# ==============================================================================================
|
||
# ━━━ 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 ━━━"
|
||
|
||
container_restart_after_maintenance "$CONTAINER_NAME" "$CONTAINER_WAS_RUNNING" 3 "Container Export"
|
||
|
||
# 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 |