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 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 07:04:52 +02:00
parent eb9dda416b
commit 993a66d8c6
6 changed files with 94 additions and 2 deletions

View File

@@ -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

View File

@@ -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:-/}"

View File

@@ -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:-}"

View File

@@ -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.

View File

@@ -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", ""),

View File

@@ -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(),