From 993a66d8c65a05f6c21ca85988e572b06cdda8a7 Mon Sep 17 00:00:00 2001 From: shuki Date: Fri, 6 Mar 2026 07:04:52 +0200 Subject: [PATCH] Add include/exclude filter support and disk space pre-check - Add TARGET_INCLUDE field for rsync include patterns (comma-separated) - Pass TARGET_INCLUDE and TARGET_EXCLUDE to rsync in transfer_folder - Include mode uses --include='*/' + patterns + --exclude='*' + --prune-empty-dirs - Abort backup if remote disk usage >= 95% Co-Authored-By: Claude Opus 4.6 --- lib/backup.sh | 6 +++++ lib/remotes.sh | 38 ++++++++++++++++++++++++++++++++ lib/targets.sh | 1 + lib/transfer.sh | 45 ++++++++++++++++++++++++++++++++++++-- tui/models.py | 3 +++ tui/screens/target_edit.py | 3 +++ 6 files changed, 94 insertions(+), 2 deletions(-) diff --git a/lib/backup.sh b/lib/backup.sh index c225fdc..51caded 100644 --- a/lib/backup.sh +++ b/lib/backup.sh @@ -78,6 +78,12 @@ _backup_target_impl() { ;; esac + # 4.5. Check remote disk space (fail if >= 95%) + check_remote_disk_space 95 || { + log_error "Remote '$remote_name' has insufficient disk space" + return 1 + } + local start_time; start_time=$(date +%s) # 5. Get timestamp diff --git a/lib/remotes.sh b/lib/remotes.sh index 8e03953..8f38acc 100644 --- a/lib/remotes.sh +++ b/lib/remotes.sh @@ -290,6 +290,44 @@ get_target_remotes() { # ── Disk info ──────────────────────────────────────────────── +# Return the disk usage percentage (integer, no %) for REMOTE_BASE. +# Returns 0 (unknown) on unsupported remote types. +remote_disk_usage_pct() { + local base="${REMOTE_BASE:-/}" + local pct_raw="" + case "${REMOTE_TYPE:-ssh}" in + ssh) + pct_raw=$(remote_exec "df '$base' 2>/dev/null | tail -1 | awk '{print \$5}'" 2>/dev/null) || return 1 + ;; + local) + pct_raw=$(df "$base" 2>/dev/null | tail -1 | awk '{print $5}') || return 1 + ;; + *) + echo "0" + return 0 + ;; + esac + # Strip the % sign + echo "${pct_raw%%%}" +} + +# Check remote disk space. Fail if usage >= threshold (default 95%). +# Usage: check_remote_disk_space [threshold] +check_remote_disk_space() { + local threshold="${1:-95}" + local pct + pct=$(remote_disk_usage_pct) || { + log_warn "Could not check remote disk space, proceeding anyway" + return 0 + } + if [[ "$pct" =~ ^[0-9]+$ ]] && (( pct >= threshold )); then + log_error "Remote disk usage is ${pct}% (threshold: ${threshold}%). Aborting backup." + return 1 + fi + log_debug "Remote disk usage: ${pct}% (threshold: ${threshold}%)" + return 0 +} + # Compact one-line disk info: "USED/TOTAL (FREE free)" remote_disk_info_short() { local base="${REMOTE_BASE:-/}" diff --git a/lib/targets.sh b/lib/targets.sh index 615c170..32c6a01 100644 --- a/lib/targets.sh +++ b/lib/targets.sh @@ -48,6 +48,7 @@ load_target() { TARGET_NAME="${TARGET_NAME:-$name}" TARGET_FOLDERS="${TARGET_FOLDERS:-}" TARGET_EXCLUDE="${TARGET_EXCLUDE:-}" + TARGET_INCLUDE="${TARGET_INCLUDE:-}" TARGET_REMOTE="${TARGET_REMOTE:-}" TARGET_RETENTION="${TARGET_RETENTION:-}" TARGET_PRE_HOOK="${TARGET_PRE_HOOK:-}" diff --git a/lib/transfer.sh b/lib/transfer.sh index 42d0f31..fe87da0 100644 --- a/lib/transfer.sh +++ b/lib/transfer.sh @@ -8,6 +8,9 @@ rsync_to_remote() { local source_dir="$1" local remote_dest="$2" local link_dest="${3:-}" + shift 3 || true + # Remaining args are extra rsync options (e.g. --exclude, --include) + local -a extra_filter_opts=("$@") local attempt=0 local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd) @@ -27,6 +30,11 @@ rsync_to_remote() { rsync_opts+=($RSYNC_EXTRA_OPTS) fi + # Append include/exclude filters + if [[ ${#extra_filter_opts[@]} -gt 0 ]]; then + rsync_opts+=("${extra_filter_opts[@]}") + fi + rsync_opts+=(-e "$rsync_ssh") # Ensure source ends with / @@ -68,6 +76,9 @@ rsync_local() { local source_dir="$1" local local_dest="$2" local link_dest="${3:-}" + shift 3 || true + # Remaining args are extra rsync options (e.g. --exclude, --include) + local -a extra_filter_opts=("$@") local attempt=0 local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" @@ -86,6 +97,11 @@ rsync_local() { rsync_opts+=($RSYNC_EXTRA_OPTS) fi + # Append include/exclude filters + if [[ ${#extra_filter_opts[@]} -gt 0 ]]; then + rsync_opts+=("${extra_filter_opts[@]}") + fi + # Ensure source ends with / [[ "$source_dir" != */ ]] && source_dir="$source_dir/" @@ -131,6 +147,31 @@ transfer_folder() { # Strip leading / to create relative subpath in snapshot local rel_path="${dest_name:-${folder_path#/}}" + # Build include/exclude filter args for rsync + local -a filter_opts=() + if [[ -n "${TARGET_INCLUDE:-}" ]]; then + # Include mode: allow directory traversal, include matched patterns, exclude rest + filter_opts+=(--include="*/") + local -a inc_patterns + IFS=',' read -ra inc_patterns <<< "$TARGET_INCLUDE" + for pat in "${inc_patterns[@]}"; do + pat="${pat#"${pat%%[![:space:]]*}"}" + pat="${pat%"${pat##*[![:space:]]}"}" + [[ -n "$pat" ]] && filter_opts+=(--include="$pat") + done + filter_opts+=(--exclude="*") + # Prune empty dirs left by directory traversal + filter_opts+=(--prune-empty-dirs) + elif [[ -n "${TARGET_EXCLUDE:-}" ]]; then + local -a exc_patterns + IFS=',' read -ra exc_patterns <<< "$TARGET_EXCLUDE" + for pat in "${exc_patterns[@]}"; do + pat="${pat#"${pat%%[![:space:]]*}"}" + pat="${pat%"${pat##*[![:space:]]}"}" + [[ -n "$pat" ]] && filter_opts+=(--exclude="$pat") + done + fi + if _is_rclone_mode; then local snap_subpath="targets/${target_name}/snapshots/${timestamp}/${rel_path}" log_info "Transferring $folder_path for $target_name (rclone)..." @@ -153,7 +194,7 @@ transfer_folder() { } log_info "Transferring $folder_path for $target_name (local)..." - rsync_local "$folder_path" "$dest" "$link_dest" + rsync_local "$folder_path" "$dest" "$link_dest" "${filter_opts[@]}" return fi @@ -161,7 +202,7 @@ transfer_folder() { ensure_remote_dir "$dest" || return 1 log_info "Transferring $folder_path for $target_name..." - rsync_to_remote "$folder_path" "$dest" "$link_dest" + rsync_to_remote "$folder_path" "$dest" "$link_dest" "${filter_opts[@]}" } # Finalize a snapshot: rename .partial -> final, update latest symlink. diff --git a/tui/models.py b/tui/models.py index 6e38b50..405df1c 100644 --- a/tui/models.py +++ b/tui/models.py @@ -6,6 +6,7 @@ class Target: name: str = "" folders: str = "" exclude: str = "" + include: str = "" remote: str = "" retention: str = "" pre_hook: str = "" @@ -26,6 +27,7 @@ class Target: "TARGET_NAME": self.name, "TARGET_FOLDERS": self.folders, "TARGET_EXCLUDE": self.exclude, + "TARGET_INCLUDE": self.include, "TARGET_REMOTE": self.remote, "TARGET_RETENTION": self.retention, "TARGET_PRE_HOOK": self.pre_hook, @@ -48,6 +50,7 @@ class Target: name=data.get("TARGET_NAME", name), folders=data.get("TARGET_FOLDERS", ""), exclude=data.get("TARGET_EXCLUDE", ""), + include=data.get("TARGET_INCLUDE", ""), remote=data.get("TARGET_REMOTE", ""), retention=data.get("TARGET_RETENTION", ""), pre_hook=data.get("TARGET_PRE_HOOK", ""), diff --git a/tui/screens/target_edit.py b/tui/screens/target_edit.py index 2ee741c..983f077 100644 --- a/tui/screens/target_edit.py +++ b/tui/screens/target_edit.py @@ -36,6 +36,8 @@ class TargetEditScreen(Screen): yield Static("Folders (comma-separated):") yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders") yield Button("Browse...", id="btn-browse") + yield Static("Include patterns:") + yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include") yield Static("Exclude patterns:") yield Input(value=target.exclude, placeholder="*.tmp,*.log", id="te-exclude") yield Static("Remote override:") @@ -152,6 +154,7 @@ class TargetEditScreen(Screen): name=name, folders=folders, exclude=self.query_one("#te-exclude", Input).value.strip(), + include=self.query_one("#te-include", Input).value.strip(), remote=self.query_one("#te-remote", Input).value.strip(), retention=self.query_one("#te-retention", Input).value.strip(), pre_hook=self.query_one("#te-prehook", Input).value.strip(),