first commit
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# ep-zpool-balancer
|
||||
|
||||
A Bash script that rebalances data across all vdevs of a ZFS pool after new
|
||||
disks (or vdevs) have been added to a `raidz2`/`raidz3` (or any other) pool.
|
||||
|
||||
## Why
|
||||
|
||||
ZFS only spreads **new** writes across all vdevs in a pool. Blocks written
|
||||
before new vdevs were added stay exactly where they are, so old data keeps
|
||||
living on the original vdevs while newly added vdevs stay mostly empty. ZFS
|
||||
has no built-in rebalance operation.
|
||||
|
||||
`ep-zpool-balancer.sh` works around this by rewriting every file in place:
|
||||
|
||||
1. Copy the file to a temporary file (`cp --preserve=all`) in the same
|
||||
directory, so it stays on the same dataset.
|
||||
2. Verify the copy with a SHA-256 checksum comparison against the original.
|
||||
3. Atomically replace the original with the verified copy (`mv`).
|
||||
|
||||
Because the copy is a fresh write, ZFS allocates its blocks across the whole
|
||||
pool — including the new vdevs — which gradually rebalances pool usage.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux/BSD host with the pool imported (the script must run where the ZFS
|
||||
pool is mounted, not from Windows).
|
||||
- `bash`, plus standard GNU utilities: `zfs`, `zpool`, `find`, `stat`,
|
||||
`sha256sum`, `awk`, `df`, `numfmt`, `realpath`.
|
||||
- Enough free space headroom on the pool to temporarily hold a second copy
|
||||
of the largest file being rewritten (see `--min-free`).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
chmod +x ep-zpool-balancer.sh
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
ep-zpool-balancer.sh -p PATH [PATH ...] [options]
|
||||
ep-zpool-balancer.sh -P POOLNAME [options]
|
||||
```
|
||||
|
||||
### Target selection (one required)
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `-p, --path PATH` | Directory (inside a ZFS dataset) to rebalance. Repeatable. |
|
||||
| `-P, --pool POOLNAME` | Rebalance every mounted dataset belonging to this pool. |
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `-m, --min-free PERCENT` | Skip/abort rewriting a file if pool free space would drop below this percentage. Default: `10`. |
|
||||
| `-s, --state-file PATH` | File tracking already-rebalanced files, so a run can be resumed. Default: `<target>/.ep-zpool-balancer-state`. |
|
||||
| `-r, --reset-state` | Clear the state file and start fresh. |
|
||||
| `-e, --exclude PATTERN` | Shell glob pattern to exclude (matched against full file path). Repeatable. |
|
||||
| `-H, --include-hardlinks` | Also rewrite files with more than one hardlink (skipped by default, since rewriting breaks the hardlink group). |
|
||||
| `-l, --log-file PATH` | Also append log output to this file. |
|
||||
| `-n, --dry-run` | Show what would be rebalanced without changing anything. |
|
||||
| `-c, --clean-tmp` | Remove leftover `*.ep-zpool-balancer.tmp.*` files from a previous interrupted run before starting. |
|
||||
| `-v, --verbose` | Verbose/debug output. |
|
||||
| `-h, --help` | Show usage help. |
|
||||
|
||||
## Examples
|
||||
|
||||
Dry-run a single dataset first to see what would happen:
|
||||
|
||||
```bash
|
||||
./ep-zpool-balancer.sh -p /tank/data -n
|
||||
```
|
||||
|
||||
Rebalance it for real:
|
||||
|
||||
```bash
|
||||
./ep-zpool-balancer.sh -p /tank/data
|
||||
```
|
||||
|
||||
Rebalance every dataset in pool `tank`, keeping at least 15% free space:
|
||||
|
||||
```bash
|
||||
./ep-zpool-balancer.sh -P tank -m 15
|
||||
```
|
||||
|
||||
Resume an interrupted run (already-rewritten files are skipped via the state
|
||||
file):
|
||||
|
||||
```bash
|
||||
./ep-zpool-balancer.sh -p /tank/data
|
||||
```
|
||||
|
||||
Exclude a directory and log to a file:
|
||||
|
||||
```bash
|
||||
./ep-zpool-balancer.sh -p /tank/data -e '/tank/data/no-touch/*' -l rebalance.log
|
||||
```
|
||||
|
||||
## How it decides what to skip
|
||||
|
||||
- Files already recorded in the state file (already rebalanced this run/series of runs).
|
||||
- Symlinks and non-regular files.
|
||||
- Files matching an `--exclude` pattern.
|
||||
- Hardlinked files (`nlink > 1`), unless `-H/--include-hardlinks` is given.
|
||||
- Any file whose rewrite would push pool free space below `--min-free`.
|
||||
|
||||
## Safety notes
|
||||
|
||||
- The original file is only replaced after the copy is verified
|
||||
byte-for-byte via SHA-256; on any mismatch or error, the original is left
|
||||
untouched and the temp file is removed.
|
||||
- `Ctrl-C` finishes the current file safely, then stops; progress already
|
||||
made is preserved in the state file so the next run resumes where it left
|
||||
off.
|
||||
- Exit codes: `0` success, `2` completed with errors, `130` interrupted.
|
||||
- Always test with `--dry-run` first, and confirm `zpool status` shows the
|
||||
pool healthy before running against production data.
|
||||
|
||||
## Checking rebalance progress
|
||||
|
||||
Use standard ZFS tooling to observe per-vdev allocation before/after a run:
|
||||
|
||||
```bash
|
||||
zpool list -v tank
|
||||
zpool iostat -v tank
|
||||
```
|
||||
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ep-zpool-balancer.sh
|
||||
#
|
||||
# Rebalances data across all vdevs of a ZFS pool (raidz2/raidz3 or any other
|
||||
# vdev layout) after new disks/vdevs have been added.
|
||||
#
|
||||
# Background: ZFS only spreads *new* writes across all vdevs in a pool.
|
||||
# Blocks written before new vdevs were added stay put, so old data keeps
|
||||
# living on the original (now more full / more used) vdevs while new vdevs
|
||||
# stay empty. This script forces a rebalance by rewriting every file
|
||||
# in place: copy -> verify checksum -> atomic rename. The rewritten data
|
||||
# is allocated fresh by ZFS across the whole pool, including new vdevs.
|
||||
#
|
||||
# Usage:
|
||||
# ep-zpool-balancer.sh -p /pool/dataset [options]
|
||||
# ep-zpool-balancer.sh -P poolname [options] # rebalance every mounted dataset in the pool
|
||||
#
|
||||
# See --help for full option list.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
TARGET_PATHS=()
|
||||
POOL_NAME=""
|
||||
DRY_RUN=0
|
||||
VERBOSE=0
|
||||
INCLUDE_HARDLINKS=0
|
||||
MIN_FREE_PERCENT=10
|
||||
STATE_FILE=""
|
||||
RESET_STATE=0
|
||||
EXCLUDES=()
|
||||
FORCE_CLEAN_TMP=0
|
||||
LOG_FILE=""
|
||||
|
||||
TOTAL_FILES=0
|
||||
TOTAL_BYTES=0
|
||||
DONE_FILES=0
|
||||
DONE_BYTES=0
|
||||
SKIPPED_FILES=0
|
||||
ERROR_FILES=0
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
TMP_SUFFIX=".ep-zpool-balancer.tmp"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
log() {
|
||||
local level="$1"; shift
|
||||
local msg="$*"
|
||||
local ts
|
||||
ts="$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
local line="[$ts] [$level] $msg"
|
||||
echo "$line"
|
||||
if [[ -n "$LOG_FILE" ]]; then
|
||||
echo "$line" >> "$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
info() { log "INFO" "$@"; }
|
||||
warn() { log "WARN" "$@"; }
|
||||
err() { log "ERROR" "$@" >&2; }
|
||||
debug() { [[ "$VERBOSE" -eq 1 ]] && log "DEBUG" "$@" || true; }
|
||||
|
||||
die() {
|
||||
err "$@"
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME - rebalance a ZFS raidz2/raidz3 pool after adding disks
|
||||
|
||||
USAGE:
|
||||
$SCRIPT_NAME -p PATH [PATH ...] [options]
|
||||
$SCRIPT_NAME -P POOLNAME [options]
|
||||
|
||||
TARGET SELECTION (one required):
|
||||
-p, --path PATH Directory (inside a ZFS dataset) to rebalance.
|
||||
Can be given multiple times.
|
||||
-P, --pool POOLNAME Rebalance every mounted dataset of this pool.
|
||||
|
||||
OPTIONS:
|
||||
-m, --min-free PERCENT Abort/skip if pool free space would drop below
|
||||
this percentage while rewriting a file.
|
||||
Default: $MIN_FREE_PERCENT
|
||||
-s, --state-file PATH File used to track already-rebalanced files so
|
||||
the run can be safely resumed. Default:
|
||||
<target>/.ep-zpool-balancer-state
|
||||
-r, --reset-state Ignore/clear existing state file and start fresh.
|
||||
-e, --exclude PATTERN Shell glob pattern to exclude (matched against
|
||||
the full file path). Can be given multiple times.
|
||||
-H, --include-hardlinks Also rewrite files with more than one hardlink.
|
||||
Default: such files are skipped, since rewriting
|
||||
them in place would break the hardlink group.
|
||||
-l, --log-file PATH Also append log output to this file.
|
||||
-n, --dry-run Show what would be done without changing anything.
|
||||
-c, --clean-tmp Remove leftover *.ep-zpool-balancer.tmp files from
|
||||
a previous interrupted run before starting.
|
||||
-v, --verbose Verbose/debug output.
|
||||
-h, --help Show this help.
|
||||
|
||||
EXAMPLES:
|
||||
# Rebalance one dataset, dry-run first
|
||||
$SCRIPT_NAME -p /tank/data -n
|
||||
$SCRIPT_NAME -p /tank/data
|
||||
|
||||
# Rebalance every dataset in pool "tank", keep 15% free
|
||||
$SCRIPT_NAME -P tank -m 15
|
||||
|
||||
# Resume an interrupted run
|
||||
$SCRIPT_NAME -p /tank/data
|
||||
|
||||
NOTES:
|
||||
* The script rewrites files in place (copy, verify checksum, atomic
|
||||
rename), so it needs headroom equal to roughly the largest file size
|
||||
on the target dataset. Use -m/--min-free to keep a safety margin.
|
||||
* Progress is tracked in a state file so the script can be interrupted
|
||||
(Ctrl-C) and safely resumed later.
|
||||
* Files with multiple hardlinks are skipped by default (see -H).
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-p|--path)
|
||||
[[ $# -ge 2 ]] || die "$1 requires an argument"
|
||||
TARGET_PATHS+=("$2"); shift 2 ;;
|
||||
-P|--pool)
|
||||
[[ $# -ge 2 ]] || die "$1 requires an argument"
|
||||
POOL_NAME="$2"; shift 2 ;;
|
||||
-m|--min-free)
|
||||
[[ $# -ge 2 ]] || die "$1 requires an argument"
|
||||
MIN_FREE_PERCENT="$2"; shift 2 ;;
|
||||
-s|--state-file)
|
||||
[[ $# -ge 2 ]] || die "$1 requires an argument"
|
||||
STATE_FILE="$2"; shift 2 ;;
|
||||
-r|--reset-state)
|
||||
RESET_STATE=1; shift ;;
|
||||
-e|--exclude)
|
||||
[[ $# -ge 2 ]] || die "$1 requires an argument"
|
||||
EXCLUDES+=("$2"); shift 2 ;;
|
||||
-H|--include-hardlinks)
|
||||
INCLUDE_HARDLINKS=1; shift ;;
|
||||
-l|--log-file)
|
||||
[[ $# -ge 2 ]] || die "$1 requires an argument"
|
||||
LOG_FILE="$2"; shift 2 ;;
|
||||
-n|--dry-run)
|
||||
DRY_RUN=1; shift ;;
|
||||
-c|--clean-tmp)
|
||||
FORCE_CLEAN_TMP=1; shift ;;
|
||||
-v|--verbose)
|
||||
VERBOSE=1; shift ;;
|
||||
-h|--help)
|
||||
usage; exit 0 ;;
|
||||
*)
|
||||
die "Unknown option: $1 (see --help)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#TARGET_PATHS[@]} -eq 0 && -z "$POOL_NAME" ]]; then
|
||||
usage
|
||||
die "Either -p/--path or -P/--pool is required"
|
||||
fi
|
||||
|
||||
if ! [[ "$MIN_FREE_PERCENT" =~ ^[0-9]+$ ]]; then
|
||||
die "--min-free must be an integer percentage"
|
||||
fi
|
||||
|
||||
for cmd in zfs zpool find stat sha256sum awk df numfmt; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || die "Required command not found: $cmd"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Resolve the ZFS pool name that a given directory lives on.
|
||||
path_to_pool() {
|
||||
local path="$1"
|
||||
local fstype src
|
||||
fstype="$(df -PT "$path" 2>/dev/null | awk 'NR==2{print $2}')"
|
||||
[[ "$fstype" == "zfs" ]] || return 1
|
||||
src="$(df -P "$path" 2>/dev/null | awk 'NR==2{print $1}')"
|
||||
echo "${src%%/*}"
|
||||
}
|
||||
|
||||
# Return available bytes and pool capacity percent used, for a pool.
|
||||
pool_free_percent() {
|
||||
local pool="$1"
|
||||
zfs list -Hp -o avail,used "$pool" 2>/dev/null | awk '{
|
||||
avail=$1; used=$2; total=avail+used;
|
||||
if (total <= 0) { print 0; exit }
|
||||
printf "%d", (avail / total) * 100
|
||||
}'
|
||||
}
|
||||
|
||||
pool_avail_bytes() {
|
||||
local pool="$1"
|
||||
zfs list -Hp -o avail "$pool" 2>/dev/null
|
||||
}
|
||||
|
||||
is_excluded() {
|
||||
local path="$1" pattern
|
||||
for pattern in "${EXCLUDES[@]:-}"; do
|
||||
[[ -n "$pattern" ]] || continue
|
||||
# shellcheck disable=SC2053
|
||||
[[ "$path" == $pattern ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
human() {
|
||||
numfmt --to=iec-i --suffix=B "$1" 2>/dev/null || echo "${1}B"
|
||||
}
|
||||
|
||||
state_has() {
|
||||
local key="$1"
|
||||
[[ -n "$STATE_FILE" && -f "$STATE_FILE" ]] || return 1
|
||||
grep -qxF "$key" "$STATE_FILE" 2>/dev/null
|
||||
}
|
||||
|
||||
state_add() {
|
||||
local key="$1"
|
||||
[[ "$DRY_RUN" -eq 1 || -z "$STATE_FILE" ]] && return 0
|
||||
echo "$key" >> "$STATE_FILE"
|
||||
}
|
||||
|
||||
interrupted=0
|
||||
on_interrupt() {
|
||||
interrupted=1
|
||||
warn "Interrupted. Finishing current file safely, then stopping..."
|
||||
}
|
||||
trap on_interrupt INT TERM
|
||||
|
||||
print_summary() {
|
||||
local elapsed=$(( $(date +%s) - START_TIME ))
|
||||
info "----------------------------------------------------------------"
|
||||
info "Summary: processed=$DONE_FILES skipped=$SKIPPED_FILES errors=$ERROR_FILES"
|
||||
info "Data rewritten: $(human "$DONE_BYTES") of $(human "$TOTAL_BYTES") total"
|
||||
info "Elapsed: ${elapsed}s"
|
||||
info "----------------------------------------------------------------"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core: rebalance a single file
|
||||
# ---------------------------------------------------------------------------
|
||||
rebalance_file() {
|
||||
local file="$1"
|
||||
local pool="$2"
|
||||
|
||||
if is_excluded "$file"; then
|
||||
debug "Excluded: $file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if state_has "$file"; then
|
||||
debug "Already done (state file): $file"
|
||||
SKIPPED_FILES=$((SKIPPED_FILES + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "$file" || -L "$file" ]]; then
|
||||
debug "Skipping non-regular/symlink: $file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local nlink size
|
||||
nlink="$(stat -c '%h' "$file" 2>/dev/null || echo 1)"
|
||||
size="$(stat -c '%s' "$file" 2>/dev/null || echo 0)"
|
||||
|
||||
if [[ "$INCLUDE_HARDLINKS" -eq 0 && "$nlink" -gt 1 ]]; then
|
||||
warn "Skipping (hardlinked x$nlink, use -H to include): $file"
|
||||
SKIPPED_FILES=$((SKIPPED_FILES + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
local avail
|
||||
avail="$(pool_avail_bytes "$pool")"
|
||||
if [[ -z "$avail" ]]; then
|
||||
err "Could not determine free space for pool '$pool'; skipping: $file"
|
||||
ERROR_FILES=$((ERROR_FILES + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Require headroom for the copy itself plus the configured free-space floor.
|
||||
local needed=$(( size + (size / 10) + 1 ))
|
||||
local free_pct
|
||||
free_pct="$(pool_free_percent "$pool")"
|
||||
if (( avail < needed )) || (( free_pct < MIN_FREE_PERCENT )); then
|
||||
warn "Skipping (insufficient free space, ${free_pct}% free < ${MIN_FREE_PERCENT}%): $file"
|
||||
SKIPPED_FILES=$((SKIPPED_FILES + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
info "[dry-run] Would rebalance: $file ($(human "$size"))"
|
||||
DONE_FILES=$((DONE_FILES + 1))
|
||||
DONE_BYTES=$((DONE_BYTES + size))
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tmp="${file}${TMP_SUFFIX}.$$"
|
||||
debug "Rewriting: $file -> $tmp"
|
||||
|
||||
if ! cp --preserve=all -- "$file" "$tmp" 2>/dev/null; then
|
||||
err "Copy failed, leaving original untouched: $file"
|
||||
rm -f -- "$tmp"
|
||||
ERROR_FILES=$((ERROR_FILES + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sum_orig sum_tmp
|
||||
sum_orig="$(sha256sum -- "$file" | awk '{print $1}')"
|
||||
sum_tmp="$(sha256sum -- "$tmp" | awk '{print $1}')"
|
||||
|
||||
if [[ "$sum_orig" != "$sum_tmp" || -z "$sum_orig" ]]; then
|
||||
err "Checksum mismatch, aborting this file (original left intact): $file"
|
||||
rm -f -- "$tmp"
|
||||
ERROR_FILES=$((ERROR_FILES + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! mv -f -- "$tmp" "$file"; then
|
||||
err "Atomic replace failed, cleaning up tmp file: $file"
|
||||
rm -f -- "$tmp"
|
||||
ERROR_FILES=$((ERROR_FILES + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
state_add "$file"
|
||||
DONE_FILES=$((DONE_FILES + 1))
|
||||
DONE_BYTES=$((DONE_BYTES + size))
|
||||
debug "Rebalanced: $file ($(human "$size"))"
|
||||
return 0
|
||||
}
|
||||
|
||||
clean_stale_tmp_files() {
|
||||
local target="$1"
|
||||
local found
|
||||
found="$(find "$target" -xdev -type f -name "*${TMP_SUFFIX}.*" 2>/dev/null)"
|
||||
[[ -z "$found" ]] && return 0
|
||||
|
||||
if [[ "$FORCE_CLEAN_TMP" -eq 1 ]]; then
|
||||
warn "Removing leftover temp files under $target"
|
||||
find "$target" -xdev -type f -name "*${TMP_SUFFIX}.*" -print -delete 2>/dev/null \
|
||||
| while read -r f; do debug "Removed stale tmp: $f"; done
|
||||
else
|
||||
warn "Found leftover temp files from a previous interrupted run under $target."
|
||||
warn "Re-run with --clean-tmp to remove them automatically, or delete manually."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rebalance one target directory
|
||||
# ---------------------------------------------------------------------------
|
||||
rebalance_path() {
|
||||
local target
|
||||
target="$(realpath -e "$1" 2>/dev/null)" || die "Path does not exist: $1"
|
||||
[[ -d "$target" ]] || die "Not a directory: $target"
|
||||
|
||||
local pool
|
||||
pool="$(path_to_pool "$target")" || die "Not on a ZFS filesystem: $target"
|
||||
info "Target: $target (pool: $pool)"
|
||||
|
||||
local free_pct
|
||||
free_pct="$(pool_free_percent "$pool")"
|
||||
info "Pool '$pool' free space: ${free_pct}%"
|
||||
if (( free_pct < MIN_FREE_PERCENT )); then
|
||||
die "Pool '$pool' free space (${free_pct}%) is already below --min-free (${MIN_FREE_PERCENT}%); aborting."
|
||||
fi
|
||||
|
||||
local state="$STATE_FILE"
|
||||
if [[ -z "$state" ]]; then
|
||||
state="$target/.ep-zpool-balancer-state"
|
||||
fi
|
||||
if [[ "$RESET_STATE" -eq 1 && -f "$state" ]]; then
|
||||
info "Resetting state file: $state"
|
||||
rm -f -- "$state"
|
||||
fi
|
||||
STATE_FILE="$state"
|
||||
[[ "$DRY_RUN" -eq 0 ]] && : > "${STATE_FILE}.lock" 2>/dev/null && rm -f "${STATE_FILE}.lock"
|
||||
|
||||
clean_stale_tmp_files "$target"
|
||||
|
||||
info "Scanning files under $target ..."
|
||||
local list
|
||||
list="$(mktemp)"
|
||||
trap 'rm -f "$list"' RETURN
|
||||
find "$target" -xdev -type f ! -name "$(basename "$state")" ! -name "*${TMP_SUFFIX}.*" -print0 > "$list.nul" 2>/dev/null
|
||||
mv "$list.nul" "$list"
|
||||
|
||||
local file_count=0
|
||||
local total_bytes=0
|
||||
while IFS= read -r -d '' f; do
|
||||
file_count=$((file_count + 1))
|
||||
total_bytes=$(( total_bytes + $(stat -c '%s' "$f" 2>/dev/null || echo 0) ))
|
||||
done < "$list"
|
||||
|
||||
TOTAL_FILES=$((TOTAL_FILES + file_count))
|
||||
TOTAL_BYTES=$((TOTAL_BYTES + total_bytes))
|
||||
info "Found $file_count files ($(human "$total_bytes")) to consider."
|
||||
|
||||
local processed=0
|
||||
while IFS= read -r -d '' f; do
|
||||
if [[ "$interrupted" -eq 1 ]]; then
|
||||
warn "Stopping early due to interrupt."
|
||||
break
|
||||
fi
|
||||
processed=$((processed + 1))
|
||||
rebalance_file "$f" "$pool"
|
||||
if (( processed % 100 == 0 )); then
|
||||
info "Progress: $processed/$file_count files in this target ($(human "$DONE_BYTES") rewritten so far overall)"
|
||||
fi
|
||||
done < "$list"
|
||||
|
||||
rm -f "$list"
|
||||
trap - RETURN
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
main() {
|
||||
if [[ -n "$POOL_NAME" ]]; then
|
||||
zpool list -H "$POOL_NAME" >/dev/null 2>&1 || die "Pool not found: $POOL_NAME"
|
||||
local mountpoints
|
||||
mountpoints="$(zfs list -Hr -o mountpoint "$POOL_NAME" 2>/dev/null | grep -v -E '^(none|legacy|-)$')"
|
||||
[[ -n "$mountpoints" ]] || die "No mounted datasets found for pool: $POOL_NAME"
|
||||
while IFS= read -r mp; do
|
||||
[[ -n "$mp" ]] || continue
|
||||
TARGET_PATHS+=("$mp")
|
||||
done <<< "$mountpoints"
|
||||
fi
|
||||
|
||||
info "Starting ep-zpool-balancer (dry-run=$DRY_RUN, min-free=${MIN_FREE_PERCENT}%)"
|
||||
for p in "${TARGET_PATHS[@]}"; do
|
||||
[[ "$interrupted" -eq 1 ]] && break
|
||||
STATE_FILE_SAVED="$STATE_FILE"
|
||||
rebalance_path "$p"
|
||||
# Reset per-target state file override so next path uses its own default.
|
||||
STATE_FILE="$STATE_FILE_SAVED"
|
||||
done
|
||||
|
||||
print_summary
|
||||
[[ "$ERROR_FILES" -gt 0 ]] && exit 2
|
||||
[[ "$interrupted" -eq 1 ]] && exit 130
|
||||
exit 0
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user