#! /bin/bash
# Copyright 2019 Cumulus Networks, Inc.  All Rights Reserved.

# before sourcing
declare -r prefix=config-restore

# Source the file with the common variables and functions
. /usr/lib/cumulus/config-util-common

mkdir -p $TMPDIR $rundir
chmod 700 $TMPDIR $rundir

# This script lists, diffs, and restores backups of all of the config files
# (in the debian packaging sense) on a Cumulus Linux switch.

declare -r restoredir=$TMPDIR/restore
declare -i need_confirm=0 test_only=0 diff_en=0 force_en=0 info_en=0
declare -i list_contents=0
declare -i auto_backup=1
declare archive_name altdir diff_file

# cleanup any temp files we create, etc., then fall through so we exit
# with the value we expect
# We never cleanup the archive, either in TMPDIR or archivedir,
# since we can't know their state for sure.
declare -g exclude_file_str=""
declare -r cfg_file=/etc/cumulus/config_restore.conf

# parsing /etc/cumulus/config_restore.conf to check if any
# files/directories are mentioned
parse_cfg_file() {
    if [[ -e $cfg_file ]]
    then
        include_line=$(grep -E "^(\s*)exclude_file_dirs(\s*):" $cfg_file | tail -n 1)
        exclude_file_dir=""

        if [ ! -z "$include_line" ]; then
            files=$(echo $include_line | cut -d ":" -f 2- )

            cd $restoredir
            for file in $files; do
                trimmed_file=$(echo $file | awk '{$1=$1};1')
                trimmed_file=$(echo $trimmed_file | sed -e 's/^\///')
                exclude_file_str=$(echo $exclude_file_str " --exclude=$trimmed_file ")
                echo "Excluded file from restore: "$trimmed_file
            done
        fi
    fi
}

cleanup()
{
    debug Cleaning up
    cd /
    rm -f $lockfile $control_base 2>/dev/null
    rm -fr $TMPDIR 2>/dev/null
}

main()
{
    local exval=0
    pname=${0##*/}

    options "$@"

    checkperms

    runlock

    trap cleanup EXIT INT TERM

    verbose getting backup system info

    get_show_control

    # This is for any directories that get implictly created,
    # such as when config files outside /etc are in a deep tree
    umask 022
    if [ $diff_en -ne 0 -o $list_contents -ne 0 ]; then
        show_diffs
    else
        [ $info_en -ne 0 ] && echo '' # set off info from rest
        if [ $need_confirm -eq 1 -a $force_en -eq 0 ]; then
            notify -n "Do you want to proceed with risky restore [yN]? "
            # with netd, it's not possible to confirm, don't try, it will hang
            [ $quiet_en -gt 2 -o ! -t 0 ] &&
                error Aborting restore because -q given or not tty
            read answer
            case "$answer" in
        y|Y|yes|Yes) ;;
        *) error Aborting restore;;
            esac
        fi
        verbose Restoring backup
        do_restore
        exval=$?
    fi

    debug Completed
    return $exval
}


usagemsg='Usage: [-BDdhiLlqTv] [-b backup|-N|-n num] [-a dir] [-F file]'

usage()
{
    [ -n "$1" ] && warn "Unsupported option(s): $1"
    echo "$pname: $usagemsg"
    exit 1
}

help()
{
    echo -e "$usagemsg"
    cat << EOF
    -a alternate_dir: Restore backup to alternate directory
    -B No Backup prior to restore
    -b filename: Name of configuration backup to restore
    -D: Show differences between backup and current
    -d: Display debugging information
    -f: Force.  Do not ask for confirmations
    -F filename: Show differences for only this file (with -D)
    -h: Display this help message
    -i: Display information about the configuration backup
    -L: List files in configuration backup
    -l: List configuration backup archives
    -N: Use Newest (most recent) configuration backup for restore
    -n number: Specify backup archive by number (shown by -l)
    -q: Quiet mode, no messages except errors
    -T: Test mode.  Do not restore files, just show what would be done
    -v: Verbose; display status messages
    Note: Either -b filename or -N must be given
EOF
}


options()
{
    declare dohelp=0 use_newest_archive=0 list=0 badopts Option
    declare -i num_archive=0 arch_err=0
    # we'll report errors ourselves
    while getopts ":a:Bb:DdF:fhiLlNn:qTv" Option; do
       case $Option in
       h) dohelp=1 ;;
       a) altdir="$OPTARG" ;;
       B) auto_backup=0 ;;
       b) archive_name="$OPTARG" ;;
       N) use_newest_archive=1 ;;
       n) num_archive="$OPTARG" ;;
       d) debug_en=1 ;;
       D) diff_en=1 ;;
       F) diff_file="$OPTARG" ;;
       f) force_en=1 ;;
       i) info_en=1 ;;
       L) list_contents=1 ;;
       l) list=1 ;;
       q) (( quiet_en++ )) ;; # can be repeated
       T) test_only=1 ;;
       v) verbose_en=1 ;;
       *|?) badopts="$badopts -$OPTARG" ;;
       esac
    done
    shift $((OPTIND - 1))

    [ $# -ne 0 ] && {
        warn Extra arguments \""$*"\" ignored
        [ -z "$archive_name" ] && warn Did you forget -b"?"
    }
    [ -n "$badopts" ] && usage "$badopts"
    [ $dohelp -eq 1 ] && { help ; exit 0 ; }
    if [ -n "$archive_name" ]; then
        if [ $num_archive -gt 0 -o $use_newest_archive -eq 1 ]; then
            arch_err=1
        fi
    elif [ $num_archive -gt 0 -a $use_newest_archive -eq 1 ]; then
        arch_err=1
    fi
    [ $arch_err -eq 1 ] && error Only one of -b, -N, or -n can be used

    # we have to read the config before possibly looking
    # for the most recent config file, since the archivedir
    # can be changed in the config file.  Same for listing
    read_config

    if [ $list -eq 1 ]; then
        list_archives
        exit $?
    fi

    if [ "$use_newest_archive" -eq 1 ]; then
      find_newest_archive
    elif [ -z "$archive_name" -a $num_archive -eq 0 ]; then
        warn You must use either -N, -n number or -b filename
        usage "$badopts"
    fi
    [ $num_archive -gt 0 ] && find_num_archive $num_archive
    case "$archive_name" in
    /*|*/*) ;;
    *) archive_name=$archivedir/$archive_name ;; # allow dir-relative names
    esac

    [ $test_only -ne 0 ] && {
       [ -n "$altdir" ] && warn The option -a "$altdir" is ignored with -T
       [ "$diff_en" -eq 1 ] && warn The option -D option is useless with -T
    }

    if [ ! -f "$archive_name" ]; then
        warn $archive_name : does not exist. Exiting.
        exit 1
    fi

}

# find the newest archive file; error out if none found
find_newest_archive()
{
    mkdir -p $TMPDIR
    list=$TMPDIR/tmp-${$}-newest
    # We want C sorting, regardless of customer language
    LANG=C LC_ALL=C /bin/ls $archivedir/${cfgbackup}* 2>/dev/null | \
        tail -1 > $list
    if [ -s $list ]; then
        archive_name=$(cat $list 2>/dev/null)
        [ -z "$archive_name" ] &&
        error No Configuration archives found for -N
        [ $quiet_en -eq 0 ] &&
        notify Using newest config archive: "\n\t${archive_name##*/}"
    else
        error No Configuration archives found for -N
    fi

}

find_num_archive()
{
    file="$(egrep -l '^'$1'$' $archivedir/.Numbers/$cfgbackup* 2>/dev/null)"
    if [ -e "$file" ]; then
        archive_name="${file##*/}"
        verbose Backup number $1 is "$archive_name"
    elif [ -n "$file" ]; then
        error Something is wrong, multiple backups found matching number "$1"
    else
        error No backup found matching number "$1"
    fi
}

list_archives()
{
    declare -a backups
    declare -A numbers tags descriptions
    [ -d "$archivedir" ] || {
        warn No configuration backups
        return 1
    }
    (cd "$archivedir"
    backups=( $( LANG=C LC_ALL=C /bin/ls -1 "${cfgbackup}"*) )
    for i in "${!backups[@]}"; do
        [ -e .Tags/"${backups[$i]}" ] &&
            tags["${backups[$i]}"]="$(cat .Tags/"${backups[$i]}")"
        [ -e .Descriptions/"${backups[$i]}" ] &&
            descriptions["${backups[$i]}"]="$(cat .Descriptions/"${backups[$i]}")"
        [ -e .Numbers/"${backups[$i]}" ] &&
            numbers["${backups[$i]}"]="$(cat .Numbers/"${backups[$i]}")"
    done

    printf "%s\t%s\t%s\t%s\n" "#" Type Description Name
    for i in "${!backups[@]}"; do
        printf "%s\t%s\t%s\t%s\n" "${numbers["${backups[$i]}"]}" \
                "${tags["${backups[$i]}"]}" \
                "${descriptions["${backups[$i]}"]}" "${backups[$i]}"
    done
    )
}

# Get the control information from the backup, sanity check
# ask for confirmation if there are apparent problems.
get_show_control()
{
    local backup_plat platform vers osvers rval bifile=${control_base#/}
    tar -C $TMPDIR -xf "$archive_name" --xform="s,$bifile,Backup_info," run
    rval=$?
    [ $rval -ne 0 ] && warn Extracting information returned status $rval

    pushd $TMPDIR >& /dev/null

    if [ -s Backup_info ]; then
        vers=$(sed -n 's/^Backup_version=//p' Backup_info)
        if [ -n "$vers" -a "$vers" -ge 0 ] 2>/dev/null; then
            [ "$vers" -gt "$config_version" ] && {
                need_confirm=1
                warn "Backup is version $vers, our version is $config_version"
            }
        else
            warn Unable to determine version that created backup
        fi
    else
        warn "No backup information, is this a config backup?"
        need_confirm=1
    fi

    [ "$info_en" -ne 0 ] && egrep 'Backup created' Backup_info

    backup_plat=$(sed -n 's/^Platform: //p' Backup_info)
    platform=$(platform-detect)

    if [ "$platform" != "$backup_plat" ] ; then
        need_confirm=1
        warn "Backup created on platform $backup_plat, current is $platform.
\tThis may cause problems"
    else
        [ $info_en -ne 0 ] && echo "Backup created on platform $backup_plat"
    fi

    [ "$info_en" -ne 0 ] && sed -n '/^Serial Number: /p' Backup_info

    vers=$(sed -n 's/^OS-Version: //p' Backup_info)
    osvers="$(sed -n -e 's/\~.*//' -e s/VERSION_ID=//p /etc/os-release 2>/dev/null)"
    if [ -z "$osvers" ]; then
        warn Unable to determine current version of Cumulus Linux, backup is $vers
        need_confirm=1
    else
        [ "$vers" != "$osvers" ] && {
            need_confirm=1
            warn "Backup created on Cumulus Linux $vers, current is $osvers.
\tThis may cause problems"
        }
    fi

    sed -e '1,/^=== Package List/d' -e '/^=== Package List End/,$d' \
        Backup_info > Backup_pkgs
    get_installed_pkgs
    comm -23 Backup_pkgs $pkglist > Backup_only
    comm -13 Backup_pkgs $pkglist > Current_only
    [ -s Backup_only ] && {
        warn Packages in backup but no longer installed:
        sed 's/^/   /' Backup_only
        echo ''
    }
    [ -s Current_only ] && {
        if [ -s Backup_only ]; then
            warn Packages now installed but not in backup:
            sed 's/^/   /' Current_only
            echo ''
            [ $quiet_en -lt 2 ] && need_confirm=1
        else
            warn No information available for packages in backup.
            [ $quiet_en -lt 2 ] && need_confirm=1
        fi
    }

    popd >& /dev/null
}

show_diffs()
{
    declare f files

    ### note to self, when doing diffs, use something like this:
    debug Showing differences in "$archive_name"
    mkdir -p $restoredir
    cd $restoredir || error Unable to create temporary directory
    tar -x -b 64 -f "$archive_name" --exclude='run/*'

    rval=$?
    [ $rval -ne 0 ] && warn extracting configuration backup exited with $rval
    files=$(echo *)
    case "$files" in
    '*')  error No files were found in backup ;;
    esac
    pushd $restore >& /dev/null
    # Skip files Only in /, because we exclude many files
    # for only in the backup, indicate that, and turn into pathnames
    diffopts="-urd --no-dereference "
    [ $list_contents -eq 1 ] && diffopts="$diffopts -q"
    # the second line of sed commands is to turn the -q output
    # into just a list of filenames.
    if [ -n "$diff_file" ]; then
        case "$diff_file" in
        /*) diff_file="${diff_file#/}" ;;
        esac
        if [ -e "$diff_file" ]; then
            diff $diffopts /"$diff_file" "$diff_file" | tee Diffout
        elif [ $quiet_en -eq 0 ]; then
            notify File "$diff_file" not found in backup
            echo '' > Diffout # prevent message below
        fi
    else
        for f in *; do diff $diffopts /"$f" "$f"; done |
            sed -e '/^Only in \//d' -e 's/Only in/& backup:/' \
        -e 's/^Files //' -e '/ differ$/s/ .*//' \
        -e "/^Only in/s/$f: /$f\//" -e '/^diff -urd /d' | tee Diffout
    fi
    [ -s Diffout -o $quiet_en -ne 0 ] || notify No differences found
    popd >& /dev/null
}


# clean up old archive files (never oldest or newest)
# if more than $maxbackups.
# Gets directory to check as an argument
# Never removes oldest or newest archive
# from oldest to newest until fewer than maxbackups
# This is called prior to creating the backup; the newest at
# this point will be from the previous run.
cleanfiles()
{
    declare list file files numfiles

    debug Check number of archives in $dir

    list=$TMPDIR/tmp-${$}-files
    /bin/ls $dir/${prefix}* > $list 2>/dev/null
    read numfiles file <<< $(wc -l $list)

    if [ $numfiles -ge $maxbackups ]; then
        declare oldest newest files
        declare -i nfiles=$numfiles
        oldest=$(head -1 $list)
        newest=$(tail -1 $list)
        while read file ; do
            [ "$file" = "$oldest" -o "$file" = "$newest" ] &&
                continue
            files="$file $files"
            (( nfiles-- ))
            [ $nfiles -lt $maxbackups ] && break
        done < $list
        [ -n "$files" ] && {
            verbose $dir had $numfiles backup archives, removing: "$files"
            rm -f $files
        }
    else
        debug $dir only had $numfiles backup archives, no cleanup
    fi
}


do_restore()
{
    local rval rsync_opts restored_list

    # No need to create a new backup if restoring to alternate dir
    # Also not done if caller handles their own backup or otherwise
    # doesn't want it.
    if [ -z "$altdir" -a $auto_backup -eq 1 ]; then
        [ $quiet_en -ne 1 ] &&
            notify Creating new configuration backup prior to restoring
        config-backup -q -t Pre-restore # only errors
    fi

    verbose Restoring from $archive_name

    mkdir -p $restoredir
    cd $restoredir || error Unable to create temporary directory
    parse_cfg_file
    tar -x -b 64 -f "$archive_name" --exclude='run/*' --exclude='etc/fstab' --exclude='etc/cumulus/config_restore.conf' $exclude_file_str
    rval=$?
    [ $rval -ne 0 ] && warn confguration backup restore exited with $rval

    [ $test_only -eq 1 ] && rsync_opts="$rsync_opts -n"

    # because rsync will try to set the perms of / to those of .,
    # and . was implicit when we made the backup, set the permissions
    # of . to be the same as / first.
    # Should handle for dirs other than /etc also...
    chmod --reference=/ .
    restored_list=$TMPDIR/restored_files
    if [ -n "$altdir" ]; then
        mkdir -p "$altdir" 2>/dev/null
        restdir="$altdir"
        rsync_opts="$rsync_opts --compare-dest=/"
    else
        restdir=/
    fi
    rsync -ac --info=NAME1 $rsync_opts . "$restdir" > $restored_list
    (( rval += $? ))
    sed -i '/.*\/$/d' $restored_list
    if [ -s $restored_list ]; then
        if [ -n "$altdir" -a $test_only -eq 0 ]; then
            notify Restored files to alternate directory $altdir
            # empty dirs get created, so get rid of them.
            find "$altdir" -depth -type d -print0 | \
                xargs -0 rmdir -p 2>/dev/null
        else
            [ $verbose_en -eq 1 -o $quiet_en -eq 0 ] && {
                if [ $test_only -eq 0 ]; then
                    notify Restored files:
                else
                    notify Files that would be restored if -T not given:
                fi
                sed 's/^/    /' $restored_list
            }
            [ $test_only -eq 0 ] && {
                warn You may need to reboot or restart affected services
                # Make sure that things are as we expect
                diff -r --no-dereference . / | \
                    grep -v '^Only in /' > $TMPDIR/FinalDiff
                [ -s $TMPDIR/FinalDiff ] && {
                    warn There are unexpected differences after the restore:
                    cat $TMPDIR/FinalDiff
                    (( rval++ ))
                }
            }
        fi
    else
        warn No files were restored
    fi

    return $rval
}

main "$@"

exit $?
