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:
@@ -78,6 +78,12 @@ _backup_target_impl() {
|
|||||||
;;
|
;;
|
||||||
esac
|
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)
|
local start_time; start_time=$(date +%s)
|
||||||
|
|
||||||
# 5. Get timestamp
|
# 5. Get timestamp
|
||||||
|
|||||||
@@ -290,6 +290,44 @@ get_target_remotes() {
|
|||||||
|
|
||||||
# ── Disk info ────────────────────────────────────────────────
|
# ── 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)"
|
# Compact one-line disk info: "USED/TOTAL (FREE free)"
|
||||||
remote_disk_info_short() {
|
remote_disk_info_short() {
|
||||||
local base="${REMOTE_BASE:-/}"
|
local base="${REMOTE_BASE:-/}"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ load_target() {
|
|||||||
TARGET_NAME="${TARGET_NAME:-$name}"
|
TARGET_NAME="${TARGET_NAME:-$name}"
|
||||||
TARGET_FOLDERS="${TARGET_FOLDERS:-}"
|
TARGET_FOLDERS="${TARGET_FOLDERS:-}"
|
||||||
TARGET_EXCLUDE="${TARGET_EXCLUDE:-}"
|
TARGET_EXCLUDE="${TARGET_EXCLUDE:-}"
|
||||||
|
TARGET_INCLUDE="${TARGET_INCLUDE:-}"
|
||||||
TARGET_REMOTE="${TARGET_REMOTE:-}"
|
TARGET_REMOTE="${TARGET_REMOTE:-}"
|
||||||
TARGET_RETENTION="${TARGET_RETENTION:-}"
|
TARGET_RETENTION="${TARGET_RETENTION:-}"
|
||||||
TARGET_PRE_HOOK="${TARGET_PRE_HOOK:-}"
|
TARGET_PRE_HOOK="${TARGET_PRE_HOOK:-}"
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ rsync_to_remote() {
|
|||||||
local source_dir="$1"
|
local source_dir="$1"
|
||||||
local remote_dest="$2"
|
local remote_dest="$2"
|
||||||
local link_dest="${3:-}"
|
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 attempt=0
|
||||||
local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
|
local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
|
||||||
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
|
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
|
||||||
@@ -27,6 +30,11 @@ rsync_to_remote() {
|
|||||||
rsync_opts+=($RSYNC_EXTRA_OPTS)
|
rsync_opts+=($RSYNC_EXTRA_OPTS)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Append include/exclude filters
|
||||||
|
if [[ ${#extra_filter_opts[@]} -gt 0 ]]; then
|
||||||
|
rsync_opts+=("${extra_filter_opts[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
rsync_opts+=(-e "$rsync_ssh")
|
rsync_opts+=(-e "$rsync_ssh")
|
||||||
|
|
||||||
# Ensure source ends with /
|
# Ensure source ends with /
|
||||||
@@ -68,6 +76,9 @@ rsync_local() {
|
|||||||
local source_dir="$1"
|
local source_dir="$1"
|
||||||
local local_dest="$2"
|
local local_dest="$2"
|
||||||
local link_dest="${3:-}"
|
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 attempt=0
|
||||||
local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
|
local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
|
||||||
|
|
||||||
@@ -86,6 +97,11 @@ rsync_local() {
|
|||||||
rsync_opts+=($RSYNC_EXTRA_OPTS)
|
rsync_opts+=($RSYNC_EXTRA_OPTS)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Append include/exclude filters
|
||||||
|
if [[ ${#extra_filter_opts[@]} -gt 0 ]]; then
|
||||||
|
rsync_opts+=("${extra_filter_opts[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
# Ensure source ends with /
|
# Ensure source ends with /
|
||||||
[[ "$source_dir" != */ ]] && source_dir="$source_dir/"
|
[[ "$source_dir" != */ ]] && source_dir="$source_dir/"
|
||||||
|
|
||||||
@@ -131,6 +147,31 @@ transfer_folder() {
|
|||||||
# Strip leading / to create relative subpath in snapshot
|
# Strip leading / to create relative subpath in snapshot
|
||||||
local rel_path="${dest_name:-${folder_path#/}}"
|
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
|
if _is_rclone_mode; then
|
||||||
local snap_subpath="targets/${target_name}/snapshots/${timestamp}/${rel_path}"
|
local snap_subpath="targets/${target_name}/snapshots/${timestamp}/${rel_path}"
|
||||||
log_info "Transferring $folder_path for $target_name (rclone)..."
|
log_info "Transferring $folder_path for $target_name (rclone)..."
|
||||||
@@ -153,7 +194,7 @@ transfer_folder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_info "Transferring $folder_path for $target_name (local)..."
|
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
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -161,7 +202,7 @@ transfer_folder() {
|
|||||||
ensure_remote_dir "$dest" || return 1
|
ensure_remote_dir "$dest" || return 1
|
||||||
|
|
||||||
log_info "Transferring $folder_path for $target_name..."
|
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.
|
# Finalize a snapshot: rename .partial -> final, update latest symlink.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class Target:
|
|||||||
name: str = ""
|
name: str = ""
|
||||||
folders: str = ""
|
folders: str = ""
|
||||||
exclude: str = ""
|
exclude: str = ""
|
||||||
|
include: str = ""
|
||||||
remote: str = ""
|
remote: str = ""
|
||||||
retention: str = ""
|
retention: str = ""
|
||||||
pre_hook: str = ""
|
pre_hook: str = ""
|
||||||
@@ -26,6 +27,7 @@ class Target:
|
|||||||
"TARGET_NAME": self.name,
|
"TARGET_NAME": self.name,
|
||||||
"TARGET_FOLDERS": self.folders,
|
"TARGET_FOLDERS": self.folders,
|
||||||
"TARGET_EXCLUDE": self.exclude,
|
"TARGET_EXCLUDE": self.exclude,
|
||||||
|
"TARGET_INCLUDE": self.include,
|
||||||
"TARGET_REMOTE": self.remote,
|
"TARGET_REMOTE": self.remote,
|
||||||
"TARGET_RETENTION": self.retention,
|
"TARGET_RETENTION": self.retention,
|
||||||
"TARGET_PRE_HOOK": self.pre_hook,
|
"TARGET_PRE_HOOK": self.pre_hook,
|
||||||
@@ -48,6 +50,7 @@ class Target:
|
|||||||
name=data.get("TARGET_NAME", name),
|
name=data.get("TARGET_NAME", name),
|
||||||
folders=data.get("TARGET_FOLDERS", ""),
|
folders=data.get("TARGET_FOLDERS", ""),
|
||||||
exclude=data.get("TARGET_EXCLUDE", ""),
|
exclude=data.get("TARGET_EXCLUDE", ""),
|
||||||
|
include=data.get("TARGET_INCLUDE", ""),
|
||||||
remote=data.get("TARGET_REMOTE", ""),
|
remote=data.get("TARGET_REMOTE", ""),
|
||||||
retention=data.get("TARGET_RETENTION", ""),
|
retention=data.get("TARGET_RETENTION", ""),
|
||||||
pre_hook=data.get("TARGET_PRE_HOOK", ""),
|
pre_hook=data.get("TARGET_PRE_HOOK", ""),
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class TargetEditScreen(Screen):
|
|||||||
yield Static("Folders (comma-separated):")
|
yield Static("Folders (comma-separated):")
|
||||||
yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders")
|
yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders")
|
||||||
yield Button("Browse...", id="btn-browse")
|
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 Static("Exclude patterns:")
|
||||||
yield Input(value=target.exclude, placeholder="*.tmp,*.log", id="te-exclude")
|
yield Input(value=target.exclude, placeholder="*.tmp,*.log", id="te-exclude")
|
||||||
yield Static("Remote override:")
|
yield Static("Remote override:")
|
||||||
@@ -152,6 +154,7 @@ class TargetEditScreen(Screen):
|
|||||||
name=name,
|
name=name,
|
||||||
folders=folders,
|
folders=folders,
|
||||||
exclude=self.query_one("#te-exclude", Input).value.strip(),
|
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(),
|
remote=self.query_one("#te-remote", Input).value.strip(),
|
||||||
retention=self.query_one("#te-retention", Input).value.strip(),
|
retention=self.query_one("#te-retention", Input).value.strip(),
|
||||||
pre_hook=self.query_one("#te-prehook", Input).value.strip(),
|
pre_hook=self.query_one("#te-prehook", Input).value.strip(),
|
||||||
|
|||||||
Reference in New Issue
Block a user