#!/bin/bash
# tar backup script: tarbut-1.2.0
VER=1.2.0

if [ -r /etc/tarbut.conf ]; then
  . /etc/tarbut.conf
fi

if [ -r ~/tarbut.conf ]; then
  . ~/tarbut.conf
fi

function error () {
  case $1 in
    NO_ARG)
      echo Error - No arguments passed;;
    NO_TARGET)
      echo Error - No target passed;;
    NO_LIST)
      echo Error - No such target \`$TARG\';;
 INVALID_LEVEL)
      echo Error - Invalid argument to --level option;;
  INVALID_OPT)
      echo Error - Invalid option;;
    NO_SNAR)
      echo Error - Incremental snap-shot not exist or write protected;;
  esac
  exit 1
}

function help () {
  cat << EOM
Version $VER
Usage: tarbut [option] <TARGET>
Backups files and directories specified in list file using tar.

<TARGET> must be one of the base-name of list files - *.lst
that tells tarbut what files/directories should be backed-up.
Option is one of [--level=x|--clean|--cleanall|--kill|--status]
Archive files' extension <EXT> depends on what or no compression
method you set in configuration file - tarbut.conf.

  --level=x   tarbut performs an incremental backup.
              If x is 0, it does full backup, creates a new
              incremental snap-shot file - <TARGET>.snar and
              (if exists) renames previous .snar to .snar.back.
              If AUTOCLEAN_LEVEL1 is 1 in tarbut.conf, it 
              also deletes series of previous level-1 backups.
              The created backup - <TARGET>_lev0.<EXT> can be
              the first generation for the period (ex; week).
              If x is 1, it creates an incremental backup
              by inspecting and updating <TARGET>.snar file.
              Achieved archive will be <TARGET>_yymmddHHMM.tar
              to avoid overwriting existent level-1 backups.

Unless --level option, tarbut performs full backup regardless
of incremental snap-shot file nor touching it at all. This
behaviour may be ideal for occasional backup or those who
always take full backup only.

  --clean     deletes incremental level-1 archives and old
              snap-shot files of corresponding TARGET.
  --cleanall  deletes incremental level-0/1 archives and all
              snap-shot files of it.
  --kill      also deletes non-incremental archives of it.
  --status    performs simple integrity check of archive group.
  --list      lists the available targets.

EOM
  exit 0
}

function list_target () {
  ls $CONFDIR/ |awk '/\.lst$/ {sub(/\.lst$/,""); print}'
  exit 0
}

function clean () {
  # Clean up files. ARG is; 1=clean 2=cleanall 3=kill
  case $1 in
    1)
      find $DESTDIR/ -maxdepth 1 -regex '.*\/'$TARG'_[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].*\.\(tar\|tgz\|tar\.bz2\)' -print0 |xargs -0 rm -f &>/dev/null
      rm -f $CACHEDIR/${TARG}.snar.{back,back2} &>/dev/null
      echo \`$TARG\' level-1 archives deleted
      echo \`$TARG\' old incremental snap-shot file deleted
      ;;
    2|3)
      find $DESTDIR/ -maxdepth 1 -regex '.*\/'$TARG'_[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].*\.\(tar\|tgz\|tar\.bz2\)' -print0 |xargs -0 rm -f &>/dev/null
      rm -f $DESTDIR/${TARG}_lev*.{tar,tgz,tar.bz2} &>/dev/null
      rm -f $CACHEDIR/${TARG}.snar{,.back,.back2} &>/dev/null
      echo \`$TARG\' level-0/1 archives deleted
      echo \`$TARG\' incremental snap-shot file deleted
      ;;
  esac
  if [ $1 -eq 3 ]; then
    rm -f $DESTDIR/${TARG}.{tar,tgz,tar.bz2} &>/dev/null
    rm -f $DESTDIR/${TARG}_m*.{tar,tgz,tar.bz2} &>/dev/null
    echo \`$TARG\' non-incremental archives deleted
  fi
  exit 0
}

function what_mtime () {
  # Set modified time of ARG file to MTIME
  RETM=$(ls -l --time-style='+%s' $1 2>&1 | \
   awk '$6 ~ /^[0-9]+$/ {print $6}')
  MTIME=${RETM:-0}
}

function mtimecmp () {
# Check existence and compare time-stamp of two files. returns;
# 0: neither F1 nor F2 exists
# 1: F1 exists but F2 not
# 2: F2 exists but F1 not
# 4: F2 newer than F1(incremental ever done and files are healthy)
# 8: F2 older than F1(incremental ever done but not healthy)

  RETC=0
  what_mtime $1
  [ $MTIME -gt 0 ] && RETC=1
  MT[0]=$MTIME
  what_mtime $2
  [ $MTIME -gt 0 ] && : $((RETC+=2))
  MT[1]=$MTIME

  [ $RETC -lt 3 ] && return $RETC
  [ ${MT[1]} -gt ${MT[0]} ] && return 4
  return 8
}

status () {
  # Integrity check of archives
  CHECK=0
  echo Status of \`$TARG\' in $DESTDIR
  MATCH=$(find $DESTDIR/ -maxdepth 1 -regex \
    '.*\/'$TARG'\(_m0\)?\.\(tar\|tgz\|tar\.bz2\)' -printf '%f')

  if [ ! -z "$MATCH" ]; then
    echo [Healthy] : Full-backup archive
    CHECK=1
  fi
  [ -f $CACHEDIR/${TARG}.snar ] || : $((CHECK+=2))

  # Check existence and compare mtime of archives.
  # Get the name of the oldest Level-1 archive.
  L1OLDEST=$(ls -t $DESTDIR |grep -E $TARG'_[0-9]{10}(_m[0-9]+)?\..*' |tail -n 1)
  : ${L1OLDEST:=tarbutdummy}

  # Get the name of the newest Level-0.
  MATCH=$(ls -tr $DESTDIR |grep -E ${TARG}_lev0'(_m[0-9]+)?\..*' |tail -n 1)
  : ${MATCH:=tarbutdummy}

  mtimecmp $DESTDIR/$MATCH $DESTDIR/$L1OLDEST

  case $? in
    0)
      [ $CHECK -eq 0 -o $CHECK -eq 2 ] && echo No backups found;;
    1)
      if [ $CHECK -ge 2 ]; then
        echo [Critical]: Level-0 incremental backup exists, but snap-shot not found
      else
        echo [Healthy] : Level-0 incremental backup
      fi
      ;;
    2)
      [ $CHECK -ge 2 ] && echo [Critical]: No incremental snap-shot file found
      echo [Notice]  : Level-1 incremental backup exists, but Level-0 not found
      ;;
    4)
      if [ $CHECK -ge 2 ]; then
        echo [Critical]: Level-0 and Level-1 incremental backup exists, but snap-shot file not found
      else
        echo [Healthy] : Level-0 and Level-1 incremental backups
      fi
      ;;
    8)
      echo [Critical]: The oldest Level-1 incremental backup is older than Level-0
      [ $CHECK -ge 2 ] && echo [Critical]: Incremental snap-shot file not found
      ;;
  esac
  exit 0
}

function mv_old () {
  # Rename archives for fail-safe. First we put names of critical
  # files into arrays for later use. ARG is $INCREMENT value.

  # Build regular expressions to use in find.
  case $1 in
   0)
     [ -f $CACHEDIR/${TARG}.snar -a ! -w $CACHEDIR/${TARG}.snar ] && error NO_SNAR
     REGPAT='.*\/'$TARG'_lev0\(_m[0-9]+\)?\.\(tar\|tgz\|tar\.bz2\)'
     if [ "$AUTOCLEAN_LEVEL1" = "1" ]; then
       REGPAT2='.*\/'$TARG'_[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]\(_m[0-9]+\)?\.\(tar\|tgz\|tar\.bz2\)'
     fi

     mv -f $CACHEDIR/${TARG}.snar.back{,2} &>/dev/null
     mv -f $CACHEDIR/${TARG}.snar{,.back} &>/dev/null
     ;;
   1)
     [ -w $CACHEDIR/${TARG}.snar ] || error NO_SNAR
     ;;
   *)
     REGPAT='.*\/'$TARG'\(_m[0-9]+\)?\.\(tar\|tgz\|tar\.bz2\)'
     ;;
  esac

  [ ${#REGPAT} -gt 0 ] && BAKTMP=( $(find $DESTDIR/ -maxdepth 1 -regex $REGPAT -print) )
  [ ${#REGPAT2} -gt 0 ] && BAKTMP2=( $(find $DESTDIR/ -maxdepth 1 -regex $REGPAT2 -print) )

  # Rename them now.
  for X in ${BAKTMP[@]} ; do
    mv -f $X ${X}.bak &>/dev/null
  done

  for X in ${BAKTMP2[@]} ; do
    mv -f $X ${X}.bak &>/dev/null
  done
}

#### Main procedures from here ####
# Arguments check.
[ -z "$1" ] && error NO_ARG
for X; do
  case $X in
   --level*)
      INCREMENT=$(echo -n $X |sed 's/--level=\([0-9]\)/\1/')
      case $INCREMENT in
        0) ;;
        1) ;;
        *) error INVALID_LEVEL;;
      esac
      ;;
  --help|-h)
      help;;
    --clean)
      CLR=1;;
 --cleanall)
      CLR=2;;
    --kill)
      CLR=3;;
   --status)
      STATUS=1;;
   --list)
      LIST=1;;
   -*)
      error INVALID_OPT;;
    *)
      TARG=$X;;
  esac
done

if [ -z "$CONFDIR" -o -z "$DESTDIR" ]; then
  echo Error - configuration error
  exit 1
fi
if [ -z "$CACHEDIR" ]; then
  echo Error - configuration error
  exit 1
fi

[ -z "$LIST" ] || list_target

[ -z "$TARG" ] && error NO_TARGET
SRCLIST=$CONFDIR/${TARG}.lst
[ -r $SRCLIST ] || error NO_LIST

[ -z "$CLR" ] || clean $CLR
[ -z "$STATUS" ] || status

# Destination archive name draft.
if [ -r $CONFDIR/${TARG}.exc ]; then
  OPT="$OPT --exclude-from=$CONFDIR/${TARG}.exc"
elif [ -r $CONFDIR/excludes ]; then
  OPT="$OPT --exclude-from=$CONFDIR/excludes"
fi

if [ "$INCREMENT" = "1" ]; then
  DEST="-f $DESTDIR/${TARG}_$(date +%y%m%d%H%M)"
  OPT="$OPT -g $CACHEDIR/${TARG}.snar"
elif [ "$INCREMENT" = "0" ]; then
  DEST="-f $DESTDIR/${TARG}_lev0"
  OPT="$OPT -g $CACHEDIR/${TARG}.snar"
else
  DEST="-f $DESTDIR/${TARG}"
fi

# Fetch source definition
SRC=$(awk <$SRCLIST '
    BEGIN{ ORS=" " }
    {
    if (/^#/) next;
    if (/^[[:blank:]]*$/) next;
    sub(/^\//,"");
    print;
    }
')

# GNU tar limitation; cannot use multi-volume compressed archive.
# If source is smaller enough than MAX_DESTSIZE, prevent multi-
# volume operation, otherwise withdraw compression.
: ${MAX_DESTSIZE:=0}
if [ ${MAX_DESTSIZE} -gt 0 ]; then
  TARCD=$(echo $OPT |awk '{
    match($0,/(-C +|--directory=)([^ ]+)/,fld)
    print fld[2]
  }')

  [ -z "$TARCD" ] || cd $TARCD
  ACTSUM=$(du -skc $SRC |awk 'END{print $1}')

  [ -z "$TARCD" ] || cd - &>/dev/null

  if [ "$COMPRESS" = "gzip" -o "$COMPRESS" = "bzip2" ]; then
    : ${MULTI_COMP_THRESHOLD:=60}
    if [ $(($ACTSUM * ${MULTI_COMP_THRESHOLD} / 100)) -le $MAX_DESTSIZE ]; then
      MAX_DESTSIZE=0
    else
      COMPRESS=none
    fi
  else
    if [ $(($ACTSUM * 95 / 100)) -le $MAX_DESTSIZE ]; then
      MAX_DESTSIZE=0
    fi
  fi
fi

case $COMPRESS in
  gzip)
    EXT=tgz
    OPT="$OPT -z";;
  bzip2)
    EXT=tar.bz2
    OPT="$OPT -j";;
  *)
    EXT=tar;;
esac

# Destination archive name conclusion.
if [ ${MAX_DESTSIZE} -gt 0 ]; then
  # In case of multi-volume operation.
  : ${MAX_VOL:=6}
  for (( X=0; X<${MAX_VOL}; X++ )) ; do
    XDEST="$XDEST ${DEST}_m${X}.$EXT"
  done
else
  XDEST="$DEST.$EXT"
fi

[ ${MAX_DESTSIZE} -gt 0 ] && OPT="$OPT -M --tape-length=${MAX_DESTSIZE}"

# Retract existent archives and snap-shots in concern
mv_old $INCREMENT

# The heart of script
cd /
nice -n 10 tar $OPT -c $XDEST $SRC 2>&1
RETVAL=$?

# Finally remove the retracted old archives on success.
if [ $RETVAL -eq 0 ]; then
  for X in ${BAKTMP[@]} ; do
    rm -f ${X}.bak &>/dev/null
  done

  for X in ${BAKTMP2[@]} ; do
    rm -f ${X}.bak &>/dev/null
  done

  rm -f $CACHEDIR/${TARG}.snar.back2 &>/dev/null
# ..or restore to what they were.
else
  for X in ${BAKTMP[@]} ; do
    mv -f ${X}.bak $X &>/dev/null
  done

  for X in ${BAKTMP2[@]} ; do
    mv -f ${X}.bak $X &>/dev/null
  done

  mv -f $CACHEDIR/${TARG}.snar.back{2,} &>/dev/null
fi

exit $RETVAL
