From a94d723bdfba8b05ea3cdca90eb91b85c899753a Mon Sep 17 00:00:00 2001 From: zorruno Date: Mon, 20 Apr 2026 12:59:34 +1200 Subject: [PATCH] Initial commit --- .env.example | 7 + .gitignore | 1 + README.md | 17 ++ containers.txt | 58 +++++++ docker-backup.sh | 369 ++++++++++++++++++++++++++++++++++++++++++ dockerbackup_prune.sh | 364 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 816 insertions(+) create mode 100755 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 containers.txt create mode 100755 docker-backup.sh create mode 100755 dockerbackup_prune.sh diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..201e298 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# /etc/dockerbackup/.env +RCLONE_REMOTE="gdrive:backuplocation/sublocation" +SOURCE_DIR="/mybackups" +PRUNE_ROOT="/mybackups" +OUT_DIR="/tmp" +KEEP_COUNT=5 +ZSTD_EXTRA_OPTS="-T0 -12" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffec089 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Docker Backup Scripts + +Simple shell scripts for backing up Docker-related data and pruning old backups. + +## Files + +- `docker-backup.sh` - create backups +- `dockerbackup_prune.sh` - prune old backups +- `containers.txt` - list of containers or backup targets +- `.env.example` - example environment file + +## Setup + +Copy the example env file and edit it for your system: + +```bash +cp .env.example .env diff --git a/containers.txt b/containers.txt new file mode 100644 index 0000000..590eb54 --- /dev/null +++ b/containers.txt @@ -0,0 +1,58 @@ +# Container names + + +#bitnami +#containers.panda.old +#containers.panda.txt +#dashbling +#_disabled-chrome2mqtt +#_disabled-chromecast-mqtt-connector +#_disabled-huginn +#_disabled-ipsec-vpn-server +#_disabled-isd +#_disabled-rtl-mqtt +#_disabled-rtl-sdr +#_disabled-speedy +#_disabled-tasmoadmin +#_disabled-tasmobackup +zigbee2mqtt +#_disabled-zigbee2mqttassistant +#_disabled-zoneminder +diun +#docker-backup.sh +#docker-compose.yml.sample +docker-ntp +#down-backup-up +#evnex-mqtt +gitea +grafana +#guacamole +#homeassistant +#influxdb +influxdb2 +mediawiki +mosquitto +mqcontrol-scripts +#mqtt-chromecast +#mqttview +mqtt-watchdog +nodered +nsupdater +ombi +#openvpn-as +pihole +portainer +rtl-433tomqtt +rustdesk +#smashing +speedtest +#tasmota-device-manager +tautulli +telegraf +traefik +ttyd +#tvheadend +#weewx +whoami +wireguard +zigbee2mqtt diff --git a/docker-backup.sh b/docker-backup.sh new file mode 100755 index 0000000..971781e --- /dev/null +++ b/docker-backup.sh @@ -0,0 +1,369 @@ +#!/usr/bin/env bash +# AUTHOR: Karl Mowatt-Wilson (updates by zorruno) +# HISTORY: +# 2025-08-22 - add start wait with one retry; fix find_working_dir typo; tidy. +# 2025-08-22 - tidy, stricter bash, compose detection, better checks, clearer logging. +# 2022-03-25 - add -t option for dry-run (no backups performed). +# 2022-01-15 - fix error reporting when a container is not found. +# 2022-01-09 - ignore trailing comments +# 2022-01-03 - display help; list from file; -f option +# 2021-10-16 - structure and logging +# 2020-05-ish - first version + +set -Eeuo pipefail + +timestamp_format='%Y-%m-%d_%H-%M-%S' + +# verbosity: set non-empty to enable debug +dbg= +# dbg=VERBOSE + +# default containers list file +containersfile="containers.txt" + +# dry run (or pass -t) +dryrun= + +# base directory for backups +BACKUP_DIR="/dockerbackups" + +# start/health wait tuning +START_TIMEOUT=90 # seconds to wait for service to be up +WAIT_INTERVAL=3 # seconds between polls +RETRIES=1 # number of restart retries on failure (per container) + +# log file +logdir="$HOME/.local/share/log/${0##*/}" +mkdir -p "$logdir" +ymdhms="$(/bin/date +'%Y-%m-%d_%H:%M:%S')" +ymd="${ymdhms%%_*}" +logfile="$logdir/${0##*/}_${ymd}.log" + +# compose command detection +COMPOSE_CMD=() +if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD=(docker-compose) +else + echo "ERROR: Neither 'docker compose' nor 'docker-compose' found." >&2 + exit 3 +fi + +log() { + local timestamp + timestamp="$(/bin/date +"$timestamp_format")" + while [ "$#" -gt 0 ]; do + echo "$timestamp: ${0##*/}: $1" >>"$logfile" + if [ -n "$dbg" ]; then + echo "$timestamp LOG: ${0##*/}: $1" >&2 + else + echo "$1" >&2 + fi + shift + done +} + +debug() { + local timestamp + timestamp="$(/bin/date +"$timestamp_format")" + while [ "$#" -gt 0 ]; do + echo "$timestamp DBG: ${0##*/}: $1" >>"$logfile" + if [ -n "$dbg" ]; then + echo "$timestamp DBG: ${0##*/}: $1" >&2 + fi + shift + done +} + +fail() { + local exitcode="$1" + shift + log "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + log "${0##*/}: FATAL ERROR" + while [ "$#" -gt 0 ]; do + log "$1" + shift + done + sleep 1 + tidy_up + exit "$exitcode" +} + +tidy_up() { + debug "tidy_up" + cd || log "Failed to cd in tidy_up" + trap - EXIT + trap - INT +} + +usage() { + cat 1>&2 <&2; usage; exit 1 ;; + :) echo "Option -$OPTARG requires an argument" 1>&2; exit 1 ;; + esac + done + shift $((OPTIND - 1)) +} + +toolcheck() { + local tool + while [ "$#" -gt 0 ]; do + tool="$1" + shift + debug "Checking for tool: '$tool'" + if ! command -v "$tool" >/dev/null 2>&1; then + fail 3 "Failed to find required tool: '$tool'" + fi + done +} + +compose() { + "${COMPOSE_CMD[@]}" "$@" +} + +# Return " " for a container by inspecting labels +get_compose_identity() { + # arg: container_name + local name="$1" + local proj serv + proj="$(docker inspect "$name" 2>/dev/null | jq -r '.[0].Config.Labels["com.docker.compose.project"] // empty')" + serv="$(docker inspect "$name" 2>/dev/null | jq -r '.[0].Config.Labels["com.docker.compose.service"] // empty')" + if [ -z "$proj" ] || [ -z "$serv" ]; then + echo "" + return 1 + fi + echo "$proj $serv" +} + +# Check current state/health for any container matching project+service +# Returns 0 if running and healthy (or no healthcheck), else 1 +service_is_healthy() { + # args: project service + local proj="$1" + local serv="$2" + local ids + # find containers by labels (may be multiple if scaled; any healthy one counts) + ids="$(docker ps -a -q --filter "label=com.docker.compose.project=$proj" --filter "label=com.docker.compose.service=$serv")" || true + if [ -z "$ids" ]; then + debug "No containers found for project=$proj service=$serv" + return 1 + fi + local id + for id in $ids; do + # status: created, restarting, running, removing, paused, exited, dead + local st hlth + st="$(docker inspect -f '{{.State.Status}}' "$id" 2>/dev/null || echo "unknown")" + if [ "$st" != "running" ]; then + continue + fi + # Health may not exist; treat missing as pass if running + hlth="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$id" 2>/dev/null || echo "none")" + if [ "$hlth" = "none" ] || [ "$hlth" = "healthy" ]; then + debug "Container $id is running (health=$hlth)" + return 0 + fi + done + return 1 +} + +# Wait for service to become healthy/running within timeout +wait_for_service() { + # args: project service timeout_seconds + local proj="$1" + local serv="$2" + local timeout="$3" + local start now + start="$(date +%s)" + while true; do + if service_is_healthy "$proj" "$serv"; then + return 0 + fi + now="$(date +%s)" + if [ $(( now - start )) -ge "$timeout" ]; then + return 1 + fi + sleep "$WAIT_INTERVAL" + done +} + +down_backup_up() { + # args: WORKING_DIR PROJECT SERVICE + local WORKING_DIR="$1" + local PROJ="$2" + local SERV="$3" + + local PROJECT_NAME + local BAK_YMDHMS + local BACKUP_CODE_DIR + local BACKUP_CODE_ZSTD + + PROJECT_NAME="$(basename "$WORKING_DIR")" + BAK_YMDHMS="$(date +'%Y-%m-%d_%H-%M-%S')" + BACKUP_CODE_DIR="${BACKUP_DIR}/${PROJECT_NAME}" + BACKUP_CODE_ZSTD="${BACKUP_CODE_DIR}/$(hostname -s).${PROJECT_NAME}.${BAK_YMDHMS}.zst" + + mkdir -p "${BACKUP_CODE_DIR}" + log "Backup target: ${BACKUP_CODE_ZSTD}" + + if [ -n "$dryrun" ]; then + log "DRY-RUN: would run: compose down; tar --zstd; compose pull; compose up -d; wait for ${PROJ}/${SERV}" + return 0 + fi + + # stop, archive, start, then verify + compose down || log "WARNING: non-zero exit from 'compose down' (continuing)" + tar --one-file-system --zstd -cf "${BACKUP_CODE_ZSTD}" "${WORKING_DIR}" + ls -lh "${BACKUP_CODE_DIR}" || true + compose pull || log "WARNING: non-zero exit from 'compose pull' (continuing)" + compose up -d + + if wait_for_service "$PROJ" "$SERV" "$START_TIMEOUT"; then + log "Service ${PROJ}/${SERV} is up." + return 0 + fi + + log "Service ${PROJ}/${SERV} did not become healthy in ${START_TIMEOUT}s. Retrying ($RETRIES)." + local attempt=1 + while [ "$attempt" -le "$RETRIES" ]; do + log "Retry $attempt: restarting ${PROJ}/${SERV}" + compose down || log "WARNING: retry: non-zero from 'compose down'" + compose up -d + if wait_for_service "$PROJ" "$SERV" "$START_TIMEOUT"; then + log "Service ${PROJ}/${SERV} is up after retry." + return 0 + fi + attempt=$(( attempt + 1 )) + done + + fail 1 "Service ${PROJ}/${SERV} failed to start/healthy after ${RETRIES} retry(ies)." +} + +find_working_dir() { + # args: CONTAINER_NAME + local name="$1" + local wd="" + if docker inspect "$name" >/dev/null 2>&1; then + wd="$(docker inspect "$name" 2>/dev/null | jq -r '.[0].Config.Labels["com.docker.compose.project.working_dir"] // empty')" + fi + if [ -z "$wd" ]; then + if [ -d "/dockervolumes/$name" ]; then + wd="/dockervolumes/$name" + elif [ -d "$HOME/prj/docker/$name" ]; then + wd="$HOME/prj/docker/$name" + else + wd="" + fi + fi + printf '%s' "$wd" +} + +container_exists() { + docker ps -a --format '{{.Names}}' | grep -Fxq "$1" +} + +backup_container() { + local container_name="$1" + log "Processing container '$container_name'" + + local container_start_time container_finish_time container_duration container_duration_m + container_start_time="$(date +%s)" + + if ! container_exists "$container_name"; then + log "Container not found: '$container_name' (skipping)" + return 0 + fi + + # capture compose identity before we stop anything + local identity proj serv + identity="$(get_compose_identity "$container_name" || true)" + if [ -z "$identity" ]; then + fail 1 "Could not determine compose identity (project/service) for '$container_name'." + fi + proj="$(echo "$identity" | awk '{print $1}')" + serv="$(echo "$identity" | awk '{print $2}')" + debug "Compose identity: project=$proj service=$serv" + + local working_dir + working_dir="$(find_working_dir "$container_name")" + if [ -z "$working_dir" ]; then + fail 1 "Could not determine working_dir for container '$container_name'." + fi + log "Working dir: $working_dir" + if [ ! -d "$working_dir" ]; then + fail 1 "Working dir does not exist: '$working_dir'" + fi + + local original_dir + original_dir="$(pwd)" + cd "$working_dir" || fail 1 "Could not cd to '$working_dir'" + + down_backup_up "$working_dir" "$proj" "$serv" || log "WARNING: non-zero exit from backup function" + + cd "$original_dir" || fail 1 "Could not cd back to '$original_dir'" + + container_finish_time="$(date +%s)" + container_duration=$((container_finish_time - container_start_time)) + container_duration_m=$((container_duration / 60)) + log "Finished '$container_name' after ${container_duration}s (approx ${container_duration_m} min)" +} + +trap tidy_up EXIT +trap 'exit 130' INT + +parse_cmdline "$@" + +debug "-----" +debug "logfile = '$logfile'" + +toolcheck docker jq sed tar + +if [ ! -f "$containersfile" ]; then + fail 2 "Containers file not found: '$containersfile'" +fi +if [ ! -r "$containersfile" ]; then + fail 2 "Containers file not readable: '$containersfile'" +fi + +containers="$(sed -rn 's/^ +//; s/#.*//; s/ +$//; /^$/b; p' "$containersfile" || true)" + +if [ -z "${containers:-}" ]; then + log "No containers listed in '$containersfile' (nothing to do)." + exit 0 +fi + +script_start_time="$(date +%s)" + +for container in $containers; do + backup_container "$container" +done + +script_finish_time="$(date +%s)" +script_duration=$((script_finish_time - script_start_time)) +script_duration_m=$((script_duration / 60)) + +debug "-----" +log "Script finished after ${script_duration}s (${script_duration_m} min)" + +exit 0 +# EOF diff --git a/dockerbackup_prune.sh b/dockerbackup_prune.sh new file mode 100755 index 0000000..4d1ae32 --- /dev/null +++ b/dockerbackup_prune.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +############################################################################### +# dockerbackup_prune.sh +# +# A) Create a dated .zst backup of /dockerbackups (tar stream | zstd, output .zst) +# - Clean single-line progress with percent and current dir/file (requires pv) +# - Fallback: single-line [N/TOTAL] without percent if pv is unavailable +# B) Upload via rclone +# C) Prune *.zst under PRUNE_ROOT subfolders, keeping newest N per subfolder +# D) Delete the local .zst (only with --apply) +# +# Restore: +# zstd -d -c /path/backup.zst | tar -x -C /restore/target +############################################################################### + +# --------------------------- Defaults (can be overridden) -------------------- +SOURCE_DIR="/mybackups" # what to archive +PRUNE_ROOT="/mybackups" # where to prune .zst +PRUNE_PATTERN="*.zst" # files to prune +KEEP_COUNT=5 # keep newest N per subfolder + +RCLONE_REMOTE="gdrive:location/sublocation" # rclone destination +OUT_DIR="/tmp" # where to write the .zst +BACKUP_PREFIX="panda-dockerbackup" # backup name prefix + +# Optional zstd tuning, e.g. "-19 -T0" for max level, multithread +ZSTD_EXTRA_OPTS="" + +# --------------------------- Load .env (same dir as script) ----------------- +# Order of precedence: +# defaults < .env (same dir) < CLI flags +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +ENV_FILE="${DOCKERBACKUP_ENV:-$SCRIPT_DIR/.env}" +if [[ -f "$ENV_FILE" ]]; then + # Warn if perms are too open; still load to keep it convenient + perms="$(stat -c '%a' "$ENV_FILE" 2>/dev/null || stat -f '%Lp' "$ENV_FILE" 2>/dev/null || echo '')" + if [[ -n "$perms" && ! "$perms" =~ ^(600|400)$ ]]; then + echo "$(date '+%F %T') [WARN] $ENV_FILE permissions are $perms; recommend 600." >&2 + fi + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a +fi + +# --------------------------- CLI flags -------------------------------------- +APPLY=0 +QUIET=0 +DRYRUN=0 + +usage() { + cat <&2; usage; exit 2 ;; + esac + shift +done + +# --------------------------- Logging helpers -------------------------------- +ts() { date '+%F %T'; } +log() { [[ $QUIET -eq 0 ]] && echo "$(ts) [$1] $2"; } +log_err() { echo "$(ts) [ERROR] $1" >&2; } +run() { if [[ $DRYRUN -eq 1 ]]; then log "DRY" "$*"; else "$@"; fi; } + +# Truncate to terminal width and erase tail so old text is cleared +truncate_and_print() { + local s="$1" cols + if [[ -t 2 ]]; then + cols=$(tput cols 2>/dev/null || echo 120) + else + cols=120 + fi + (( cols<20 )) && cols=120 + if (( ${#s} > cols-1 )); then + s="${s:0:cols-1}" + fi + printf '\r%s\033[K' "$s" >&2 +} + +cleanup_tmp="" +pipe_pid="" +pipe_pgid="" +pct_fd_open=0 +path_fd_open=0 +status_line_shown=0 + +cleanup() { + local why="${1:-EXIT}" + # Kill the whole pipeline (tar|pv|zstd) + if [[ -n "$pipe_pid" ]]; then + if command -v ps >/dev/null 2>&1; then + pipe_pgid=$(ps -o pgid= "$pipe_pid" 2>/dev/null | tr -d ' ' || true) + fi + if [[ -n "$pipe_pgid" ]]; then + kill -TERM "-$pipe_pgid" 2>/dev/null || true + sleep 0.2 + kill -KILL "-$pipe_pgid" 2>/dev/null || true + else + kill -TERM "$pipe_pid" 2>/dev/null || true + sleep 0.2 + kill -KILL "$pipe_pid" 2>/dev/null || true + fi + wait "$pipe_pid" 2>/dev/null || true + fi + # Close FDs + if (( pct_fd_open == 1 )); then exec 4>&- 4<&-; pct_fd_open=0; fi + if (( path_fd_open == 1 )); then exec 3>&- 3<&-; path_fd_open=0; fi + # Remove fifos/dir + if [[ -n "$cleanup_tmp" ]]; then + rm -f "$cleanup_tmp/pct" "$cleanup_tmp/path" 2>/dev/null || true + rmdir "$cleanup_tmp" 2>/dev/null || true + fi + # End the progress line cleanly + if (( status_line_shown == 1 )) && [[ $QUIET -eq 0 ]]; then + printf '\n' >&2 + fi + # Exit with 130 on INT/TERM + case "$why" in + INT|TERM) exit 130 ;; + esac +} + +trap 'log_err "Command failed: $BASH_COMMAND"; cleanup ERR; exit 1' ERR +trap 'cleanup INT' INT +trap 'cleanup TERM' TERM +trap 'cleanup EXIT' EXIT + +# --------------------------- Preconditions ---------------------------------- +[[ -d "$SOURCE_DIR" ]] || { log_err "Source dir not found: $SOURCE_DIR"; exit 1; } +[[ -d "$PRUNE_ROOT" ]] || { log_err "Prune root not found: $PRUNE_ROOT"; exit 1; } +command -v tar >/dev/null 2>&1 || { log_err "tar not found"; exit 1; } +command -v zstd >/dev/null 2>&1 || { log_err "zstd not found"; exit 1; } +command -v rclone >/dev/null 2>&1 || { log_err "rclone not found"; exit 1; } +command -v find >/dev/null 2>&1 || { log_err "find not found"; exit 1; } + +# Timestamp in name: YYYY-MM-DD_HH-MM (system timezone or override externally) +STAMP="${STAMP_OVERRIDE:-$(date +%F_%H-%M)}" +BACKUP_NAME="${BACKUP_PREFIX}-${STAMP}.zst" +BACKUP_PATH="${OUT_DIR%/}/${BACKUP_NAME}" + +RCLONE_FLAGS=() +[[ $QUIET -eq 1 ]] && RCLONE_FLAGS+=("-q") || RCLONE_FLAGS+=("--progress") + +# Silence zstd "Read: ... ==> 100%" +ZSTD_FLAGS=("-q") +[[ -n "$ZSTD_EXTRA_OPTS" ]] && ZSTD_FLAGS+=($ZSTD_EXTRA_OPTS) + +log "INFO" "Mode: $( [[ $DRYRUN -eq 1 ]] && echo 'DRY-RUN ALL' || ([[ $APPLY -eq 1 ]] && echo 'APPLY DELETES' || echo 'NO DELETES') )" +log "INFO" "Creating archive: $BACKUP_PATH (.zst)" +log "INFO" "Keeping newest $KEEP_COUNT file(s) per subfolder matching: $PRUNE_PATTERN" +log "INFO" "Source: $SOURCE_DIR" +log "INFO" "Remote: $RCLONE_REMOTE" +log "INFO" "Prune root (root itself will be skipped): $PRUNE_ROOT" + +# --------------------------- Step A: create .zst with one-liner -------------- +log "STEP" "A) Creating archive" + +if [[ $DRYRUN -eq 1 ]]; then + log "DRY" "tar -C \"$SOURCE_DIR\" -cvf - . | pv -n -s \$(du -sb \"$SOURCE_DIR\"|cut -f1) 2>pct.fifo | zstd ${ZSTD_FLAGS[*]} -o \"$BACKUP_PATH\"" +else + mkdir -p "$OUT_DIR" + + export LC_ALL=C + TOTAL_FILES=$(find "$SOURCE_DIR" -type f | wc -l | awk '{print $1}') + TOTAL_BYTES=$(du -sb "$SOURCE_DIR" | cut -f1) + + tmpdir="$(mktemp -d)"; cleanup_tmp="$tmpdir" + path_fifo="$tmpdir/path"; pct_fifo="$tmpdir/pct" + mkfifo "$path_fifo" + have_pv=0 + if command -v pv >/dev/null 2>&1 && [[ $QUIET -eq 0 ]] && [[ -t 2 ]]; then + mkfifo "$pct_fifo"; have_pv=1 + fi + + # Open FIFOs RDWR so writers/readers never block + exec 3<> "$path_fifo"; path_fd_open=1 + if (( have_pv == 1 )); then exec 4<> "$pct_fifo"; pct_fd_open=1; fi + + # Run pipeline in background; send tar -v names into the path FIFO + if (( have_pv == 1 )); then + ( + set -o pipefail + tar -C "$SOURCE_DIR" -cvf - . \ + 2> >(sed -u 's#^\./##' > "$path_fifo" || true) \ + | pv -n -s "$TOTAL_BYTES" 2> "$pct_fifo" \ + | zstd "${ZSTD_FLAGS[@]}" -o "$BACKUP_PATH" + ) & + else + ( + set -o pipefail + tar -C "$SOURCE_DIR" -cvf - . \ + 2> >(sed -u 's#^\./##' > "$path_fifo" || true) \ + | zstd "${ZSTD_FLAGS[@]}" -o "$BACKUP_PATH" + ) & + fi + pipe_pid=$! + + # Status loop: single clean line on stderr + files_seen=0 + last_path="" + last_pct="0.0" + status_line_shown=0 + last_print_time=0 + + while kill -0 "$pipe_pid" 2>/dev/null; do + # read percent from pv (if any) + if (( have_pv == 1 )); then + if IFS= read -r -t 0.03 pct_line <&4; then last_pct="$pct_line"; fi + fi + # read latest path from tar + if IFS= read -r -t 0.03 path_line <&3; then + last_path="$path_line" + [[ -n "$last_path" ]] && (( files_seen++ )) || true + fi + + # Split into dir/file for display (already relative) + display_rel="$last_path" + dir_part="$(dirname -- "$display_rel" 2>/dev/null || echo .)" + base_part="$(basename -- "$display_rel" 2>/dev/null || echo .)" + [[ "$dir_part" == "." ]] && dir_part="." + + # Refresh line and erase tail (\033[K) + if [[ $QUIET -eq 0 ]]; then + now_ns=$(date +%s%N) + if (( now_ns - last_print_time > 50000000 )); then + if (( have_pv == 1 )); then + line="[$(printf '%.1f' "${last_pct:-0}")%] archiving: ${dir_part}/ ${base_part}" + else + line="[${files_seen}/${TOTAL_FILES}] archiving: ${dir_part}/ ${base_part}" + fi + truncate_and_print "$line" + status_line_shown=1 + last_print_time=$now_ns + fi + fi + done + + # Drain, finish, cleanup + while IFS= read -r -t 0.03 path_line <&3; do + last_path="$path_line"; (( files_seen++ )) || true + [[ $QUIET -eq 0 ]] && truncate_and_print "[${files_seen}/${TOTAL_FILES}] archiving: $(dirname -- "$last_path")/ $(basename -- "$last_path")" + status_line_shown=1 + done || true + + wait "$pipe_pid" + if [[ $QUIET -eq 0 ]]; then + printf '\n' >&2 + status_line_shown=0 + fi + + # Close and clean up + exec 3>&- 3<&-; path_fd_open=0 + if (( have_pv == 1 )); then exec 4>&- 4<&-; pct_fd_open=0; fi + rm -f "$path_fifo" "$pct_fifo" 2>/dev/null || true + rmdir "$tmpdir" 2>/dev/null || true + cleanup_tmp="" +fi +log "OK" "Archive step completed" + +# --------------------------- Step B: rclone copy ----------------------------- +log "STEP" "B) rclone copy to remote" +if [[ $DRYRUN -eq 1 ]]; then + log "DRY" "rclone copy \"$BACKUP_PATH\" \"$RCLONE_REMOTE\" ${RCLONE_FLAGS[*]}" +else + run rclone copy "$BACKUP_PATH" "$RCLONE_REMOTE" "${RCLONE_FLAGS[@]}" +fi +log "OK" "Upload step completed" + +# --------------------------- Step C: prune .zst ------------------------------ +log "STEP" "C) Pruning $PRUNE_PATTERN under $PRUNE_ROOT (keep $KEEP_COUNT)" +export LC_ALL=C +prune_ok=1 + +while IFS= read -r -d '' dir; do + mapfile -d '' -t files < <( + find "$dir" -maxdepth 1 -type f -name "$PRUNE_PATTERN" -printf '%T@ %p\0' | + sort -z -n | + cut -z -d' ' -f2- + ) + count=${#files[@]} + if (( count == 0 )); then + log "SKIP" "No matches in: $dir" + continue + fi + + keep_from=$(( count - KEEP_COUNT )) + (( keep_from < 0 )) && keep_from=0 + + if (( count <= KEEP_COUNT )); then + log "KEEP" "At or below limit in: $dir (count=$count)" + continue + fi + + keep_list=("${files[@]:keep_from:KEEP_COUNT}") + del_list=("${files[@]:0:keep_from}") + + for k in "${keep_list[@]}"; do + log "KEEP" "Keep: $k" + done + for f in "${del_list[@]}"; do + if [[ $DRYRUN -eq 1 || $APPLY -eq 0 ]]; then + log "WOULD" "Remove: $f" + else + if rm -f -- "$f"; then + log "DELETE" "Removed: $f" + else + log_err "Failed to remove: $f"; prune_ok=0 + fi + fi + done +done < <(find "$PRUNE_ROOT" -mindepth 1 -type d -print0) + +if [[ $prune_ok -ne 1 ]]; then + log_err "Prune step encountered errors; aborting final delete" + exit 1 +fi +log "OK" "Prune step completed" + +# --------------------------- Step D: remove local .zst ----------------------- +log "STEP" "D) Removing local backup" +if [[ $DRYRUN -eq 1 || $APPLY -eq 0 ]]; then + log "WOULD" "Remove local backup: $BACKUP_PATH" +else + if rm -f -- "$BACKUP_PATH"; then + log "DELETE" "Removed local backup: $BACKUP_PATH" + else + log_err "Failed to remove local backup: $BACKUP_PATH" + exit 1 + fi +fi + +log "DONE" "All steps completed successfully"