Initial commit

This commit is contained in:
2026-04-20 12:59:34 +12:00
commit a94d723bdf
6 changed files with 816 additions and 0 deletions
Executable
+7
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
.env
+17
View File
@@ -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
+58
View File
@@ -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
+369
View File
@@ -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 <<EOF
Usage: ${0##*/} [-f containers_list_file] [-t] [-v] [-h]
-t Test (dry run; no backups performed)
-v Verbose (debug logging to stderr)
-f FILE File containing list of container names
Default: '$containersfile'
-h Show this help
Example:
${0##*/} -f containers.servername.txt
EOF
}
parse_cmdline() {
while getopts ":f:htv" opt; do
case "$opt" in
f) containersfile="$OPTARG" ;;
h) usage; exit 0 ;;
t) dryrun=1 ;;
v) dbg=VERBOSE ;;
\?) echo "Invalid option: -$OPTARG" 1>&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 "<project> <service>" 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
+364
View File
@@ -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 <<EOF
Usage: $0 [--apply] [--quiet] [--dry-run] [--keep N] [--root PATH] [--source PATH] [--outdir PATH] [--remote PATH]
Default: creates and uploads backup, shows what WOULD be deleted (no deletes).
Flags:
--apply Actually delete pruned files and the local .zst
--quiet Minimal console output (no live progress)
--dry-run Simulate everything (no backup, no upload, no deletes)
--keep N Keep newest N .zst files per subfolder (default ${KEEP_COUNT})
--root PATH Root folder to prune (default ${PRUNE_ROOT})
--source PATH Folder to archive (default ${SOURCE_DIR})
--outdir PATH Where to write the .zst (default ${OUT_DIR})
--remote PATH rclone destination (default ${RCLONE_REMOTE})
Environment file:
A .env file in the same directory as this script is loaded automatically.
You can override its path by setting DOCKERBACKUP_ENV=/path/to/.env
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--apply) APPLY=1 ;;
--quiet) QUIET=1 ;;
--dry-run) DRYRUN=1 ;;
--keep) KEEP_COUNT="${2:?}"; shift ;;
--root) PRUNE_ROOT="${2:?}"; shift ;;
--source) SOURCE_DIR="${2:?}"; shift ;;
--outdir) OUT_DIR="${2:?}"; shift ;;
--remote) RCLONE_REMOTE="${2:?}"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&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"