586 lines
18 KiB
586 lines
18 KiB
#!/bin/bash
|
|
|
|
##
|
|
## Here's an example crontab:
|
|
##
|
|
## SHELL=/bin/sh
|
|
## PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
|
|
##
|
|
## 49 */2 * * * root mirror-dir run -d core-05.0k.io:10023 -u rsync /etc /home /opt/apps 2>&1 | logger -t mirror-dir
|
|
##
|
|
|
|
|
|
#:-
|
|
. /etc/shlib
|
|
#:-
|
|
|
|
include common
|
|
include parse
|
|
include process
|
|
include cmdline
|
|
include array
|
|
|
|
depends shyaml lock
|
|
|
|
|
|
##
|
|
## Functions
|
|
##
|
|
|
|
|
|
MIRROR_DIR_LOG=/var/log/mirror-dir.log
|
|
MIRROR_DIR_REPORT_MAX_READ_LINE=1000000
|
|
R_DATE='[0-9]{4,4}-[01][0-9]-[0-3][0-9] [012][0-9]:[0-5][0-9]:[0-5][0-9][+-][01][0-9][0-5][0-9]'
|
|
|
|
mirror-dir:report() {
|
|
|
|
local s1 s2 s3 d1 d2 d3 host source sent received rate
|
|
|
|
while read s1 s2 d1 d2 host source sent received rate; do
|
|
s=$(date -d"$s1 $s2" --rfc-3339=seconds)
|
|
s_s=$(date -d"$s1 $s2" +%s)
|
|
d_s=$(date -d"$d1 $d2" +%s)
|
|
duration=$((d_s - s_s))
|
|
printf "%s %-15s %-30s | %s %s %s %10s\n" \
|
|
"$s" "$host" "$source" "$sent" "$received" "$rate" "$(print_duration "$duration")"
|
|
done < <(
|
|
tail "$MIRROR_DIR_LOG" -n "$MIRROR_DIR_REPORT_MAX_READ_LINE" |
|
|
egrep "^${R_DATE} (Starting|sent)" |
|
|
sed -r 's/\s*\(.*\)$//g
|
|
s/ (([0-9]{1,3},)*[0-9]{1,3})(\.[0-9]{2,2})? bytes(\/sec)?/:\1/g
|
|
s/,//g
|
|
s/ :([0-9]+)$/ rate:\1/g' |
|
|
grep -v "^--$" |
|
|
sed -r "/Starting/N;
|
|
{s/\n(${R_DATE} )(.*)sent/ \1 sent/g}" |
|
|
sed -r "s/^(${R_DATE} )Starting rsync: ([^ ]+) -> ([^ ]+) (${R_DATE} )/\1\4\3 \2/g
|
|
s/ +/ /g
|
|
s/ [a-z]+:/ /g" |
|
|
egrep "^${R_DATE} ${R_DATE} [^ ]+ /[^ ]+ [0-9]+ [0-9]+ [0-9]+$"
|
|
) |
|
|
numfmt --field=6,7 --to=iec-i --suffix=B --padding=8 |
|
|
numfmt --field=8 --to=iec-i --suffix=B/s --padding=10 |
|
|
sed -r 's/ \| / /g'
|
|
}
|
|
|
|
|
|
mirror-dir:run() {
|
|
|
|
local hostname="$1" dests="$2" source_dirs
|
|
shift 2
|
|
|
|
dests=($dests) ## individual dests can't use any space-like separators
|
|
source_dirs=("$@")
|
|
|
|
dest_path=/var/mirror/$hostname
|
|
state_dir=/var/run/mirror-dir
|
|
mkdir -p "$state_dir"
|
|
rsync_options=(
|
|
${RSYNC_OPTIONS:-} --stats --out-format='%i %o %f %l %b')
|
|
ssh_options=(${SSH_OPTIONS:-})
|
|
|
|
for dest in "${dests[@]}"; do
|
|
dest_rsync_options=("${rsync_options[@]}")
|
|
if [[ "$dest" == *"/"* ]]; then
|
|
dest_rsync_options+=("--bwlimit" "${dest##*/}")
|
|
dest="${dest%/*}"
|
|
fi
|
|
dest_for_session="$dest"
|
|
|
|
for d in "${source_dirs[@]}"; do
|
|
|
|
current_rsync_options=("${dest_rsync_options[@]}")
|
|
|
|
session_id="$(echo "${dest_for_session}$d" | md5_compat)"
|
|
session_id="${session_id:1:8}"
|
|
|
|
if [[ "$dest" == *":"* ]]; then
|
|
ssh_options+=("-p" "${dest#*:}")
|
|
dest="${dest%%:*}"
|
|
fi
|
|
|
|
dirpath="$(dirname "$d")"
|
|
if [ "$dirpath" == "/" ]; then
|
|
dir="/$(basename "$d")"
|
|
else
|
|
dir="$dirpath/$(basename "$d")"
|
|
fi
|
|
|
|
[ -d "$dir" ] || {
|
|
warn "ignoring '$dir' as it is not existing."
|
|
continue
|
|
}
|
|
|
|
lock_label=$exname-$hostname-${session_id}
|
|
|
|
tmp_exclude_patterns=/tmp/${lock_label}.exclude_patterns.tmp
|
|
## Adding the base of the dir if required... seems necessary with
|
|
## the rsync option that replicate the full path.
|
|
has_exclude_pattern=
|
|
while read-0 exclude_dir; do
|
|
if [ -z "$has_exclude_pattern" ]; then
|
|
echo "Adding exclude patterns for source '$dir':" >&2
|
|
has_exclude_pattern=1
|
|
fi
|
|
if [[ "$exclude_dir" == "/"* ]]; then
|
|
exclude_dir="$dir${exclude_dir}"
|
|
fi
|
|
echo " - $exclude_dir" >&2
|
|
p0 "$exclude_dir"
|
|
done < <(get_exclude_patterns "$dir") > "$tmp_exclude_patterns"
|
|
if [ -n "$has_exclude_pattern" ]; then
|
|
current_rsync_options+=("-0" "--exclude-from"="$tmp_exclude_patterns")
|
|
else
|
|
echo "No exclude patterns for '$dir'."
|
|
fi
|
|
echo ---------------------------------
|
|
echo "Starting rsync: $d -> $dest ($(date))"
|
|
cmd=(
|
|
nice -n 15 \
|
|
rsync "${current_rsync_options[@]}" -azvARH \
|
|
-e "sudo -u $user ssh ${ssh_options[*]}" \
|
|
--delete --delete-excluded \
|
|
--partial --partial-dir .rsync-partial \
|
|
--numeric-ids "$dir/" "$user@$dest":"$dest_path"
|
|
)
|
|
echo "${cmd[@]}"
|
|
start="$SECONDS"
|
|
retry=1
|
|
errlvls=()
|
|
while true; do
|
|
lock "$lock_label" -v -D -k -- "${cmd[@]}"
|
|
errlvl="$?"
|
|
case "$errlvl" in
|
|
20) ## Received SIGUSR1, SIGINTT
|
|
echo "!! Rsync received SIGUSR1 or SIGINT."
|
|
echo " .. Full interruption while $d -> $dest and after $((SECONDS - start))s"
|
|
append_trim "${state_dir}/${session_id}-fail" \
|
|
"$dest $d $((SECONDS - start)) signal SIGUSR1, SIGINT or SIGHUP"
|
|
break 2
|
|
;;
|
|
137|143) ## killed SIGKILL, SIGTERM
|
|
echo "!! Rsync received $(kill -l "$errlvl")"
|
|
echo " .. Full interruption while $d -> $dest and after $((SECONDS - start))s"
|
|
append_trim "${state_dir}/${session_id}-fail" \
|
|
"$dest $d $((SECONDS - start)) signal: $(kill -l "$errlvl")"
|
|
break 2
|
|
;;
|
|
0)
|
|
echo "Rsync finished with success $d -> $dest in $((SECONDS - start))s"
|
|
append_trim "${state_dir}/${session_id}-success" \
|
|
"$dest $d $((SECONDS - start)) OK"
|
|
break
|
|
;;
|
|
*)
|
|
errlvls+=("$errlvl")
|
|
echo "!! Rsync failed with an errorlevel $errlvl after $((SECONDS - start))s since start."
|
|
if [ "$retry" -lt 3 ]; then
|
|
echo "!! Triggering a retry ($((++retry))/3)"
|
|
continue
|
|
else
|
|
echo "!! Tried 3 times, bailing out."
|
|
echo " .. interruption of $d -> $dest after $((SECONDS - start))s"
|
|
append_trim "${state_dir}/${session_id}-fail" \
|
|
"$dest $d $((SECONDS - start))" \
|
|
"Failed after 3 retries (errorlevels: ${errlvls[@]})"
|
|
break
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
if [ -n "$has_exclude_pattern" ]; then
|
|
rm -fv "$tmp_exclude_patterns"
|
|
fi
|
|
done
|
|
done
|
|
|
|
}
|
|
|
|
|
|
|
|
get_exclude_patterns() {
|
|
local dir="$1"
|
|
[ -e "$config_file" ] || return
|
|
cat "$config_file" | shyaml get-values-0 "${dir//.\\./}.exclude" 2>/dev/null
|
|
}
|
|
|
|
append_trim() {
|
|
local f="$1"
|
|
shift
|
|
e "$(date --rfc-3339=s) $*"$'\n' >> "$f" &&
|
|
tail -n 5000 "$f" > "$f".tmp &&
|
|
mv "$f"{.tmp,}
|
|
}
|
|
|
|
|
|
log_tee() { tee -a "$MIRROR_DIR_LOG"; }
|
|
log_file() { cat >> "$MIRROR_DIR_LOG"; }
|
|
|
|
|
|
get_ids() {
|
|
local session_id id_done
|
|
declare -A id_done
|
|
for file in "$state_dir"/*{-fail,-success}; do
|
|
session_id=${file%-*}
|
|
[ "${id_done["$session_id"]}" ] && continue
|
|
id_done["$session_id"]=1
|
|
echo "${session_id##*/}"
|
|
done
|
|
}
|
|
|
|
|
|
mirror-dir:_get_sources() {
|
|
local DIR=("$@")
|
|
|
|
config_file="/etc/$exname/config.yml"
|
|
|
|
if [ "${#DIR[@]}" == 0 ]; then
|
|
if [ -e "$config_file" ]; then
|
|
info "No source provided on command line," \
|
|
"reading '$config_file' for default sources"
|
|
DIR=($(eval echo $(shyaml get-values default.sources < "$config_file")))
|
|
fi
|
|
fi
|
|
array_values_to_stdin DIR
|
|
}
|
|
|
|
|
|
[[ "${BASH_SOURCE[0]}" != "${0}" ]] && SOURCED=true
|
|
|
|
version=0.1
|
|
desc='Manage mirroring of local directory to distant hosts'
|
|
help=""
|
|
|
|
|
|
|
|
##
|
|
## Code
|
|
##
|
|
|
|
|
|
|
|
cmdline.spec.gnu
|
|
|
|
|
|
cmdline.spec.gnu backup
|
|
|
|
cmdline.spec:backup:valued:-d,--dest:run() {
|
|
dests+=("$1")
|
|
}
|
|
|
|
dests=()
|
|
|
|
cmdline.spec::cmd:backup:run() {
|
|
|
|
# usage="usage: $exname -d DEST1 [-d DEST2 [...]] [-u USER] [DIR1 [DIR2 ...]]
|
|
|
|
# Preserve as much as possible the source structure, keeping hard-links, acl,
|
|
# exact numerical uids and gids, and being able to resume in very large files.
|
|
|
|
|
|
# "
|
|
|
|
: :posarg: [DIR...] 'Local directories that should be mirrored
|
|
on destination(s).
|
|
|
|
Examples: /etc /home /var/backups
|
|
|
|
If no directories are provided, the config
|
|
file root entries will be used all as
|
|
destination to copy.'
|
|
|
|
: :optval: -d,--dest 'Can be repeated. Specifies host
|
|
destination towards which files will be
|
|
send. Note that you can specify port
|
|
number after a colon and a bandwidth limit
|
|
for rsync after a '/'.
|
|
|
|
Examples: -d liszt.musicalta:10022
|
|
-d 10.8.0.19/200'
|
|
|
|
|
|
: :optval: -u,--user "(default: 'rsync')
|
|
|
|
Local AND destination system user to log
|
|
as at both ends to transfer file. This
|
|
local user need to have a no password ssh
|
|
login to it's own account on destination.
|
|
This destination account should have full
|
|
permissions access without passwd to write
|
|
with rsync-server in the destination
|
|
directory."
|
|
|
|
: :optval: -h,--hostname "(default is taken of the hostname file)
|
|
|
|
Set the destination store, this is the
|
|
name of the directory where the files
|
|
will all directories will be copied.
|
|
Beware ! if 2 hosts use the same store,
|
|
this means they'll conflictingly update
|
|
the same destination directory. Only
|
|
use this if you know what you are
|
|
doing."
|
|
|
|
: :optfla: -q,--quiet "Prevent output on stderr. Please note that
|
|
output is always written in log file."
|
|
|
|
|
|
[ "$UID" != "0" ] && echo "You must be root." && exit 1
|
|
|
|
[ -n "$opt_hostname" ] || opt_hostname=$(hostname)
|
|
|
|
if [ -n "$opt_quiet" ]; then
|
|
log_facility=log_file
|
|
else
|
|
log_facility=log_tee
|
|
fi
|
|
|
|
if [ -z "$opt_hostname" ]; then
|
|
err "Couldn't figure a valid hostname. Please specify one with \`\`-h STORENAME\`\`."
|
|
return 1
|
|
fi
|
|
|
|
user=${opt_user:-rsync}
|
|
|
|
config_file="/etc/$exname/config.yml"
|
|
|
|
array_read-0 DIR < <(
|
|
{
|
|
{
|
|
mirror-dir:_get_sources "${DIR[@]}"
|
|
} 3>&1 1>&2 2>&3 | "$log_facility"
|
|
} 3>&1 1>&2 2>&3
|
|
)
|
|
|
|
if [ "${#DIR[@]}" == 0 ]; then
|
|
err "You must specify at least one source directory to mirror" \
|
|
"on command line (or in a config file)."
|
|
echo "$usage" >&2
|
|
exit 1
|
|
fi
|
|
info "Source directories are: ${DIR[@]}" 2>&1 | "$log_facility"
|
|
|
|
if [ "${#dests[@]}" == 0 ]; then
|
|
err "You must specify at least a destination (using \`\`-d\`\` or \`\`--dest\`\`)."
|
|
echo "$usage" >&2
|
|
return 1
|
|
fi
|
|
|
|
## XXXvlab: note that we use here a special version of awk supporting
|
|
## ``strftime``. This is only to prefix a date to the logs. Yes, we know
|
|
## about ``--out-format`` and its ``%t`` which would be ideal, but it
|
|
## doesn't output proper UTC time (it is system time, no timezone info).
|
|
mirror-dir:run "$opt_hostname" "${dests[*]}" "${DIR[@]}" 2>&1 |
|
|
awk -W interactive '{ print strftime("%Y-%m-%d %H:%M:%S%z"), $0 }' |
|
|
"$log_facility"
|
|
|
|
}
|
|
|
|
|
|
|
|
cmdline.spec.gnu report
|
|
cmdline.spec::cmd:report:run() {
|
|
mirror-dir:report
|
|
}
|
|
|
|
|
|
|
|
cmdline.spec:check:valued:-d,--dest:run() {
|
|
dests+=("$1")
|
|
}
|
|
|
|
cmdline.spec.gnu check
|
|
cmdline.spec::cmd:check:run() {
|
|
|
|
# usage="usage: $exname -d DEST1 [-d DEST2 [...]] [DIR1 [DIR2 ...]]
|
|
|
|
# Checks that mirror-dir did it's job. Will send an email if not.
|
|
# "
|
|
|
|
|
|
: :posarg: [DIR...] 'Local directories that should be mirrored
|
|
on destination(s).
|
|
|
|
Examples: /etc /home /var/backups
|
|
|
|
If no directories are provided, the config
|
|
file root entries will be used all as
|
|
destination to copy.'
|
|
|
|
: :optval: -d,--dest 'Can be repeated. Specifies host
|
|
destination towards which files will be
|
|
send. Note that you can specify port
|
|
number after a colon and a bandwidth limit
|
|
for rsync after a '/'.
|
|
|
|
Examples: -d liszt.musicalta:10022
|
|
-d 10.8.0.19/200'
|
|
|
|
: :optval: -n,--time-spec "Give a full English time spec about how
|
|
old the last full run of rsync should
|
|
be at most. Defaults to '12 hours'.
|
|
|
|
Examples: -n '12 hours'
|
|
-n '1 day'"
|
|
|
|
: :optfla: -m,--mail-alert "Send alert via email. This is intended to
|
|
use in cron."
|
|
|
|
|
|
[ "$UID" != "0" ] && echo "You must be root." && exit 1
|
|
|
|
if [ "${#dests[@]}" == 0 ]; then
|
|
err "You must specify at least a destination (using \`\`-d\`\` or \`\`--dest\`\`)."
|
|
echo "$usage" >&2
|
|
return 1
|
|
fi
|
|
|
|
if [ -n "$opt_mail_alert" ]; then
|
|
CHECK_DEFAULT_SOURCE=/etc/default/alerting
|
|
[ -f "$CHECK_DEFAULT_SOURCE" ] && . "$CHECK_DEFAULT_SOURCE"
|
|
|
|
if [ "${#MAIL_DESTS[@]}" == 0 ]; then
|
|
echo "You must set at least one recipient destination for mails." >&2
|
|
echo " You can do that in '$CHECK_DEFAULT_SOURCE', using the variable" >&2
|
|
echo " '\$MAIL_DESTS'. Note this is a bash array variable." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
array_read-0 DIR < <(mirror-dir:_get_sources "${DIR[@]}")
|
|
|
|
if [ "${#DIR[@]}" == 0 ]; then
|
|
err "You must specify at least one source directory to mirror" \
|
|
"on command line (or in a config file)."
|
|
echo "$usage" >&2
|
|
exit 1
|
|
fi
|
|
|
|
time_spec="${opt_time_spec:-12 hours}"
|
|
|
|
state_dir=/var/run/mirror-dir
|
|
|
|
## Getting max string length of source
|
|
dir_max_len=0
|
|
for d in "${DIR[@]}"; do
|
|
[ "$dir_max_len" -lt "${#d}" ] &&
|
|
dir_max_len="${#d}"
|
|
done
|
|
|
|
## Getting max string length of dests
|
|
dest_max_len=0
|
|
for d in "${dests[@]}"; do
|
|
[ "$dest_max_len" -lt "${#d}" ] &&
|
|
dest_max_len="${#d}"
|
|
done
|
|
|
|
declare -A sessions=()
|
|
bad_sessions=()
|
|
msg=()
|
|
for dest in "${dests[@]}"; do
|
|
if [[ "$dest" == *"/"* ]]; then
|
|
current_rsync_options+=("--bwlimit" "${dest##*/}")
|
|
dest="${dest%/*}"
|
|
fi
|
|
|
|
for d in "${DIR[@]}"; do
|
|
session_id="$(echo "$dest$d" | md5_compat)"
|
|
session_id="${session_id:1:8}"
|
|
sessions["$session_id"]="$dest $d"
|
|
f=$(find "$state_dir" \
|
|
-maxdepth 1 -newermt "-$time_spec" \
|
|
-type f -name "${session_id}-success")
|
|
if [ -z "$f" ]; then
|
|
if [ -e "$state_dir/${session_id}-success" ]; then
|
|
msg+=("$(printf "%-${dest_max_len}s %-${dir_max_len}s last full sync %s" \
|
|
"$dest" "$d" \
|
|
"$(stat -c %y "$state_dir/${session_id}-success" |
|
|
sed -r 's/\.[0-9]{9,9} / /g')")")
|
|
else
|
|
msg+=("$(printf "%-${dest_max_len}s %-${dir_max_len}s never finished yet" \
|
|
"$dest" "$d")")
|
|
fi
|
|
bad_sessions+=("$session_id")
|
|
fi
|
|
done
|
|
done
|
|
|
|
[ "${#msg[@]}" == 0 ] && return 0
|
|
|
|
if [ -z "$opt_mail_alert" ]; then
|
|
echo
|
|
echo "${DARKRED}These destination/source directory were" \
|
|
"last synced more than $time_spec ago:${NORMAL}"
|
|
for m in "${msg[@]}"; do
|
|
printf " %s\n" "$m"
|
|
done
|
|
echo
|
|
echo "${DARKRED}Last failed logs:${NORMAL}"
|
|
for m in "${bad_sessions[@]}"; do
|
|
if [ -e "${state_dir}"/$m-fail ]; then
|
|
echo " ${sessions[$m]}:"
|
|
tail -n 5 "${state_dir}"/$m-fail | cut -f 1,2,5- -d " " | sed -r "s/^/ /g"
|
|
echo
|
|
else
|
|
echo " ${sessions[$m]}: no fail log available"
|
|
fi
|
|
done
|
|
return 1
|
|
fi
|
|
|
|
|
|
##
|
|
## Mail
|
|
##
|
|
|
|
if [ "${#msg[@]}" != 0 ]; then
|
|
|
|
cat <<EOF | mail -s "[$(hostname)] mirror backup failing" "${MAIL_DESTS[@]}"
|
|
Hi,
|
|
|
|
Some configured mirroring targets have not finished gracefully in
|
|
the last $time_spec. Please see for yourself:
|
|
|
|
$(
|
|
for m in "${msg[@]}"; do
|
|
echo " $m"
|
|
done
|
|
)
|
|
|
|
You might want to find these following information of some use:
|
|
|
|
$(
|
|
|
|
for m in "${bad_sessions[@]}"; do
|
|
if [ -e "${state_dir}"/$m-fail ]; then
|
|
echo " ${sessions[$m]}:"
|
|
tail -n 5 "${state_dir}"/$m-fail | cut -f 1,2,5- -d " " | sed -r "s/^/ /g"
|
|
echo
|
|
else
|
|
echo " ${sessions[$m]}: no fail log available"
|
|
fi
|
|
done
|
|
|
|
)
|
|
|
|
Hoping all this will help you sort out the issue...
|
|
|
|
Yours sincerly,
|
|
--
|
|
mirror-dir-check
|
|
|
|
PS: You received this email because your email is listed in
|
|
\$MAIL_DESTS of '$CHECK_DEFAULT_SOURCE' of '$(hostname)'
|
|
(also known as $(cat /etc/mailname)).
|
|
|
|
EOF
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmdline::parse "$@"
|