#! /bin/bash

# Brioche Backup
# Full and Incremental backup script with tar and LVM snapshots.
#
# Copyright (C) 2008, 2009 Amand Tihon <amand.tihon@alrj.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# Note: This script relies on GNU tar specific options,
# do not attempt to use it as-is with other tar implementations.


# Mandatory
CONFIG_FILE="/etc/brioche.conf"


#######################################################################
#  Default config values. Do not change, edit $CONFIG_FILE
#######################################################################

BACKUPTAB="/etc/briochetab"
MAILTO="root"
REPODIR="/backup"
TAR_OPTS="--one-file-system -S "
COMPRESS="gz"
COMPRESS_OPT="--gzip"
SNAPSHOT_MOUNTPOINT="/mnt/backup-snapshot"
SNAPSHOT_NAME="backup-snap"
SNAPSHOT_SIZE="5G"
USAGE_WARN="80"

USE_FTP="no"
FTP_HOST="ftpback.example.com"
FTP_DIR="/"
FTP_KEEP="4"

# Ensure that we have a minimal PATH
PATH=/sbin:/bin:/usr/sbin:/usr/bin
FINAL_STATUS="SUCCESS"
SUMMARY="/tmp/backup.sumary"


#######################################################################
#  Helpers
#######################################################################

trap 'interrupted ctrl-C' INT
trap 'interrupted KILL' TERM

NOW()
{
  echo -n `date "+%Y-%m-%d %H:%M:%S"`
}

# Log a line with timestamp.
log()
{
  echo "[`NOW`] $@"
}

# The summary that will be sent by email
summary()
{
  log $@
  echo $@ >> $SUMMARY
}

# Set final status to CRITICAL
set_critical()
{
  FINAL_STATUS="CRITICAL"
}

set_error()
{
  if [ "$FINAL_STATUS" != "CRITICAL" ]; then
    FINAL_STATUS="ERROR"
  fi
}

set_warning()
{
  if [ "$FINAL_STATUS" == "SUCCESS" ]; then
    FINAL_STATUS="WARNING"
  fi
}

finish()
{
  # Check free space
  local usage=`df -hP "${REPODIR}" | tail -n 1 | tr -s [:space:] | cut -d" " -f5 | tr -d '%'`
  log "${REPODIR} usage after backup: ${usage}%."

  if [ "$usage" -ge "$USAGE_WARN" ]; then
    set_warning
    summary "Warning : Filesystem ${REPODIR} is ${usage}% full."
  fi

  summary "Backup procedure ended on `NOW`"

  SUBJECT="Backup report for `hostname` (${FINAL_STATUS})."
  mail -s "${SUBJECT}" "${MAILTO}" <  "$SUMMARY"

  rm -f "$SUMMARY"
  exit
}

interrupted()
{
  FINAL_STATUS="INTERRUPTED"
  summary "Backup procedure interrupted by user ($1)."
  summary "Take care, an LVM snapshot may still be present !"
  finish
}

#######################################################################
#  Snapshots
#######################################################################

# Make a snapshot of a logical volume.
# Usage: make_snapshot vg lv
make_snapshot()
{
  log "Creating a snapshot volume of /dev/$1/$2"
  lvcreate --snapshot -L ${SNAPSHOT_SIZE} -n ${SNAPSHOT_NAME} /dev/$1/$2
  RETVAL=$?
  if [ "$RETVAL" != "0" ]; then
    log "Error ${RETVAL}: Unable to create a snapshot of /dev/$1/$2"
    return 1
  fi
}

# Mount the snapshot volume.
# On error, tries to remove the snapshot.
# Usage: mount_snapshot vg
mount_snapshot()
{
  log "Mounting the snapshot volume ${SNAPSHOT_NAME}."
  if [ ! -d "${SNAPSHOT_MOUNTPOINT}" ]; then
    log "Creating mountpoint ${SNAPSHOT_MOUNTPOINT}."
    mkdir -p "${SNAPSHOT_MOUNTPOINT}"
  fi

  mount /dev/$1/${SNAPSHOT_NAME} ${SNAPSHOT_MOUNTPOINT}
  RETVAL=$?
  if [ "$RETVAL" != "0" ]; then
    log "Error ${RETVAL}: Unable to mount /dev/$1/${SNAPSHOT_NAME} on ${SNAPSHOT_MOUNTPOINT}"
    remove_snapshot $1 || return 100
    return 1
  fi
}

# Remove a previously created snapshot. It must be unmounted.
# Usage: remove_snapshot vg
remove_snapshot()
{
  log "Removing the snapshot volume /dev/$1/${SNAPSHOT_NAME}."
  lvremove -f /dev/$1/${SNAPSHOT_NAME}
  RETVAL=$?
  if [ "$RETVAL" != "0" ]; then
    log "Error ${RETVAL}: Unable to remove the snapshot volume /dev/$1/${SNAPSHOT_NAME}"
    return 100
  fi
}

# Unmount the previously mounted snapshot.
# Usage: unmount_snapshot vg
unmount_snapshot()
{
  log "Unmounting the snapshot volume /dev/$1/${SNAPSHOT_NAME}."
  umount /dev/$1/${SNAPSHOT_NAME}
  RETVAL=$?
  if [ "$RETVAL" != "0" ]; then
    log "Error ${RETVAL}: Unable to unmount the snapshot volume /dev/$1/${SNAPSHOT_NAME}"
    return 100
  fi
}

#######################################################################
#  Main backup functions
#######################################################################

# Make a full backup of a directory, with snar file for subsequent incremental
# Usage: make_full_backup source_dir hostname volumename
# Returns 0 on success, 1 on error
make_full_backup()
{
  log "Making full backup of $2 - ${3}."

  local destdir="${REPODIR}/$2"
  local today=`date "+%Y%m%d"`
  local destfile="${destdir}/${3}.full.${today}.tar.${COMPRESS}"
  local destsnar="${destdir}/${3}.full.snar"

  # Move previous run to the "undo" directory
  if [ ! -d "${destdir}/undo" ]; then
    log "Creating undo/ directory."
    mkdir -p "${destdir}/undo"
  fi
  log "Moving old run into undo/ directory."
  mv ${destdir}/${3}.* ${destdir}/undo

  # Do the actual backup. Destination file name and snar are like
  # /backup/valeron/root.full.20090105.tar.bz2
  # /backup/valeron/root.full.snar
  log "Running tar..."
  tar -cf ${destfile} ${TAR_OPTS} ${COMPRESS_OPT} -g ${destsnar} $1

  if [ "$?" = "0" ]; then
    log "Removing undo/ directory."
    rm -rf "${destdir}/undo"
  else
    log "Error $?: Could not archive $2 - $3"
    return 1
  fi
  return 0
}


# Make an incremental backup of a directory, from full's snar file
# Usage: make_incr_backup source_dir hostname volumename
# Returns 0 on success, 1 on error, 2 if no previous full is found.
make_incr_backup()
{
  log "Making incremental backup of $2 - ${3}."

  local destdir="${REPODIR}/$2"
  local today=`date "+%Y%m%d"`
  local destfile="${destdir}/${3}.incr.${today}.tar.${COMPRESS}"
  local destsnar="${destdir}/${3}.incr.${today}.snar"
  local fullsnar="${destdir}/${3}.full.snar"

  # Test existence of full backup
  if [ ! -e $fullsnar ]; then
    log "Could not find catalog ${fullsnar}."
    return 2
  fi

  # Prepare the copy of the snar file
  cp "$fullsnar" "$destsnar"

  # Do the actual backup. Destination file name and snar are like
  # /backup/valeron/root.incr.20090105.tar.bz2
  # /backup/valeron/root.incr.20090105.snar
  log "Running tar..."
  tar -cf ${destfile} ${TAR_OPTS} ${COMPRESS_OPT} -g ${destsnar} $1

  if [ ! "$?" = "0" ]; then
    log "Error $?: Could not archive $2 - $3"
    return 1
  fi
  return 0
}


#######################################################################
#  FTP functions
#######################################################################

# Push everything in the directory given in $1 to the FTP server, under
# /FTP_DIR/$2/latest
ftp_push()
{
  log "Mirror $1 on FTP (${FTP_HOST})."
  local source="${1}"
  local target="${FTP_DIR}/${2}/latest"
  local command="mkdir -p ${target}; cd ${target}"
  command="${command}; mirror --reverse --only-newer --verbose ${source}"

  lftp -e "${command}; exit" ${FTP_HOST}
}

# Rotate old files on FTP
# Usage: ftp_rotate group
ftp_rotate()
{
  log "Rotating backups of $1 on FTP (${FTP_HOST})."
  local lastrun="run-${FTP_KEEP}"
  local target="${FTP_DIR}/${1}"
  local commands="mkdir -p ${target}; cd ${target}"

  # Build commands
  # Remove oldest run
  if [ "$FTP_KEEP" != "0" ]; then
    commands="$commands; rmdir ${lastrun}"

    # Move everything back
    for run in `seq $FTP_KEEP -1 2`; do
      local newer=$run
      let "newer -= 1"
      commands="$commands; mv run-$newer run-$run"
    done
    # Move "old latest" to run-1
    commands="$commands; mv latest run-1"
  else
    commands="$commands; rmdir latest"
  fi

  # Create "new latest" directory
  commands="$commands; mkdir latest; exit"

  # Run the commands on the FTP server
  lftp -e "$commands" $FTP_HOST
}
#######################################################################
#  Start here
#######################################################################

# Truncate summary file, start header blurb
echo "" > $SUMMARY
summary "Backup procedure started on `NOW`"

if [ -r "${CONFIG_FILE}" ]
then
  source "${CONFIG_FILE}"
else
  summary "Error: Unable to read configuration file ${CONFIG_FILE}. Aborting."
  set_critical
  finish
fi

if [ ! -r "${BACKUPTAB}" ]
then
  summary "Error: Unable to read ${BACKUPTAB}. Aborting."
  set_critical
  finish
fi

# TODO: Something cleaner, here...
DO_FULL_BACKUP="no"
case "$1" in
  --full|-f)
    DO_FULL_BACKUP="yes"
    ;;
  --help|-h)
    echo "Usage: ${0} [-f]"
    exit 0
    ;;
esac

# Discover which COMPRESS_OPT to use, from COMPRESS
case "$COMPRESS" in
  gz)
    COMPRESS_OPT="--gzip"
    ;;
  bz2)
    COMPRESS_OPT="--bzip2"
    ;;
  lzma)
    COMPRESS_OPT="--lzma"
    ;;
  *)
    summary "Unknown compression method: ${COMPRESS}. Falling back to gzip."
    COMPRESS="gz"
    COMPRESS_OPT="--gzip"
    ;;
esac

#######################################################################
#  Parse backuptab file, call backup functions for each line
#######################################################################

# Rotate FTP groups on full
if [ "$USE_FTP" = "yes" -a "$DO_FULL_BACKUP" = "yes" ]; then
  for group in `grep -v -E '^[[:space:]]*(#.*)?$' $BACKUPTAB | awk '{print $3}' | sort -u`
  do
    ftp_rotate $group
  done
fi

# Ignore empty and commented lines
grep -v -E '^[[:space:]]*(#.*)?$' $BACKUPTAB | tr -s [:space:]| while read line
do
  # split line in fields
  device=`echo $line|cut -d" " -f1`
  dosnap=`echo $line|cut -d" " -f2`
  group=`echo $line|cut -d" " -f3`
  volume=`echo $line|cut -d" " -f4`

  # Make and mount snapshot if needed.
  if [ "$dosnap" = "yes" ]; then
    # Split the device to find the VG and LV
    vg=`echo $device|cut -d"/" -f3`
    lv=`echo $device|cut -d"/" -f4`
    make_snapshot $vg $lv
    if [ "$?" != "0" ]; then
      summary "Could not take a snapshot of $device"
      set_error
      continue
      # Next one
    fi

    mount_snapshot $vg
    RETVAL="$?"
    if [ "$RETVAL" != "0" ]; then
      summary "Could not mount the snapshot of $device"
      set_error
      if [ "$RETVAL" = "100" ]; then
        summary "Could not remove the snapshot !"
        set_critical
        finish
      fi
      continue
      # Next one
    fi

    BACKUP_SOURCE="${SNAPSHOT_MOUNTPOINT}"
  else
    BACKUP_SOURCE="$device"
  fi

  # Make the backup
  if [ "$DO_FULL_BACKUP" = "no" ]; then
    make_incr_backup ${BACKUP_SOURCE} $group $volume
    RETVAL=$?
    if [ "$RETVAL" = "0" ]; then
      summary "INCREMENTAL backup of $device done on `NOW`."
    elif [ "$RETVAL" = "1" ]; then
      summary "Error during incremental backup of $device"
      set_error
    elif [ "$RETVAL" = "2" ]; then
      summary "Can't do an incremental backup without a full one being present."
      summary "Switching to full backup for $device"
      make_full_backup ${BACKUP_SOURCE} $group $volume
      if [ "$?" = "0" ]; then
        summary "FULL backup of $device done on `NOW`."
      else
        summary "Error during full backup of $device"
        set_error
      fi
    else
      summary "Unknown error during incremental backup of $device"
      set_error
    fi
  else # Do a full backup
    make_full_backup ${BACKUP_SOURCE} $group $volume
    if [ "$?" = "0" ]; then
      summary "FULL backup of $device done on `NOW`."
    else
      summary "Error during full backup of $device"
      set_error
    fi
  fi

  # Time to unmount and destroy the snapshot, if needed.
  # Any error here is critical, since the snapshot and the mountpoint are
  # the same for each device...
  if [ "$dosnap" = "yes" ]; then
    unmount_snapshot $vg
    if [ "$?" != "0" ]; then
      summary "Could not unmount snapshot !"
      set_critical
      finish
    fi

    remove_snapshot $vg
    if [ "$?" != "0" ]; then
      summary "Could not destroy the snapshot !"
      set_critical
      finish
    fi
  fi

done

# Push everything on the FTP
if [ "$USE_FTP" = "yes" ]; then
  summary ""
  for group in `grep -v -E '^[[:space:]]*(#.*)?$' $BACKUPTAB | awk '{print $3}' | sort -u`
  do
    ftp_push "$REPODIR/$group" $group
    summary "Mirrored ${group} to ${FTP_HOST}."
  done
fi


finish


