#!/bin/bash

# Report SATA backplane usage
#   * Report is created in /var/backup/sata_backplane_usage if different from
#     the previous report

# Programmers' notes: function call tree
#    +
#    |
#    +-- initialise
#    |
#    +-- generate_report
#    |
#    +-- finalise
#
# Utility functions called from various places:
#     ck_file ck_uint fct msg

# Function definitions in alphabetical order.  Execution begins after the last function definition.

#--------------------------
# Name: ck_file
# Purpose: for each file listed in the argument list: checks that it is
#   * reachable and exists
#   * is of the type specified (block special, ordinary file or directory)
#   * has the requested permission(s) for the user
#   * optionally, is absolute (begins with /)
# Usage: ck_file [ path <file_type>:<permissions>[:[a]] ] ...
#   where
#     file  is a file name (path)
#     file_type  is b (block special file), f (file) or d (directory)
#     permissions  is none or more of r, w and x
#     a  requests an absoluteness test (that the path begins with /)
#   Example: ck_file foo d:rwx:
# Outputs:
#   * For the first requested property each file does not have, a message to
#     stderr
#   * For the first detected programminng error, a message to
#     stderr
# Returns:
#   0 when all files have the requested properties
#   1 when at least one of the files have the requested properties
#   2 when a programming error is detected
#--------------------------
function ck_file {

    local absolute_flag buf file_name file_type perm perms retval

    # For each file ...
    # ~~~~~~~~~~~~~~~~~
    retval=0
    while [[ $# -gt 0 ]]
    do
        file_name=$1
        file_type=${2%%:*}
        buf=${2#$file_type:}
        perms=${buf%%:*}
        absolute=${buf#$perms:}
        [[ $absolute = $buf ]] && absolute=
        case $absolute in
            '' | a )
                ;;
            * )
                echo "ck_file: invalid absoluteness flag in '$2' specified for file '$file_name'" >&2
                return 2
        esac
        shift 2

        # Is the file reachable and does it exist?
        # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        case $file_type in
            b )
                if [[ ! -b $file_name ]]; then
                    echo "file '$file_name' is unreachable, does not exist or is not a block special file" >&2
                    retval=1
                    continue
                fi
                ;;
            f )
                if [[ ! -f $file_name ]]; then
                    echo "file '$file_name' is unreachable, does not exist or is not an ordinary file" >&2
                    retval=1
                    continue
                fi
                ;;
            d )
                if [[ ! -d $file_name ]]; then
                    echo "directory '$file_name' is unreachable, does not exist or is not a directory" >&2
                    retval=1
                    continue
                fi
                ;;
            * )
                echo "Programming error: ck_file: invalid file type '$file_type' specified for file '$file_name'" >&2
                return 2
        esac

        # Does the file have the requested permissions?
        # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        buf="$perms"
        while [[ $buf ]]
        do
            perm="${buf:0:1}"
            buf="${buf:1}"
            case $perm in
                r )
                    if [[ ! -r $file_name ]]; then
                        echo "$file_name: no read permission" >&2
                        retval=1
                        continue
                    fi
                    ;;
                w )
                    if [[ ! -w $file_name ]]; then
                        echo "$file_name: no write permission" >&2
                        retval=1
                        continue
                    fi
                    ;;
                x )
                    if [[ ! -x $file_name ]]; then
                        echo "$file_name: no execute permission" >&2
                        retval=1
                        continue
                    fi
                    ;;
                * )
                    echo "Programming error: ck_file: invalid permisssion '$perm' requested for file '$file_name'" >&2
                    return 2
            esac
        done

        # Does the file have the requested absoluteness?
        # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        if [[ $absolute = a && ${file_name:0:1} != / ]]; then
            echo "$file_name: does not begin with /" >&2
            retval=1
        fi

    done

    return $retval

}  #  end of function ck_file

#--------------------------
# Name: ck_uint
# Purpose: checks for a valid unsigned integer
# Usage: ck_uint <putative uint>
# Outputs: none
# Returns:
#   0 when $1 is a valid unsigned integer
#   1 otherwise
#--------------------------
function ck_uint {
    local regex='^[[:digit:]]+$'
    [[ $1 =~ $regex ]] && return 0 || return 1
}  #  end of function ck_uint

#--------------------------
# Name: fct
# Purpose: function call trace (for debugging)
# $1 - name of calling function
# $2 - message.  If it starts with "started" or "returning" then the output is prettily indented
#--------------------------
function fct {

    if [[ ! $debugging_flag ]]; then
        return 0
    fi

    fct_indent="${fct_indent:=}"

    case $2 in
        'started'* )
            fct_indent="$fct_indent  "
            msg D "$fct_indent$1: $2"
            ;;
        'returning'* )
            msg D "$fct_indent$1: $2"
            fct_indent="${fct_indent#  }"
            ;;
        * )
            msg D "$fct_indent$1: $2"
    esac

}  # end of function fct

#--------------------------
# Name: finalise
# Purpose: cleans up and exits
# Arguments:
#    $1  return value
# Return code (on exit):
#   The sum of zero plus
#      1 if any warnings
#      2 if any errors
#      4,8,16 unused
#      32 if terminated by a signal
#--------------------------
function finalise {
    fct "${FUNCNAME[0]}" 'started'
    finalising_flag=$true

    # Temporary directory removal
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~
    tmp_dir_regex="^/tmp/$my_name\..{6}$"
    [[ $tmp_dir_created_flag \
        && ${tmp_dir:-} =~ $tmp_dir_regex \
    ]] && rm -fr "$tmp_dir"

    # Interrupted?  Message and exit return value
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    my_retval=0
    if ck_uint "${1:-}" && (($1>128)); then
        ((my_retval+=32))
        case $1 in
            129 )
                buf=SIGHUP
                ;;
            130 )
                buf=SIGINT
                ;;
            131 )
                buf=SIGQUIT
                ;;
            132 )
                buf=SIGILL
                ;;
            134 )
                buf=SIGABRT
                ;;
            135 )
                buf=SIGBUS
                ;;
            136 )
                buf=SIGFPE
                ;;
            138 )
                buf=SIGUSR1
                ;;
            139 )
                buf=SIGSEGV
                ;;
            140 )
                buf=SIGUSR2
                ;;
            141 )
                buf=SIGPIPE
                ;;
            142 )
                buf=SIGALRM
                ;;
            143 )
                buf=SIGTERM
                ;;
            146 )
                buf=SIGCONT
                ;;
            147 )
                buf=SIGSTOP
                ;;
            148 )
                buf=SIGTSTP
                ;;
            149 )
                buf=SIGTTIN
                ;;
            150 )
                buf=SIGTTOU
                ;;
            151 )
                buf=SIGURG
                ;;
            152 )
                buf=SIGCPU
                ;;
            153 )
                buf=SIGXFSZ
                ;;
            154 )
                buf=SIGVTALRM
                ;;
            155 )
                buf=SIGPROF
                ;;
            * )
                msg E "${FUNCNAME[0]}: programming error: \$1 ($1) not serviced"
                ;;
        esac
        interrupt_flag=$true
        msg="Finalising on $buf"
        msg E "$msg"    # Returns because finalising_flag is set
    fi

    # Old log removal
    # ~~~~~~~~~~~~~~~
    buf=$(find "$log_dir" -name '*.log' -mtime +$log_retention -execdir rm {} + 2>&1)
    [[ $buf != '' ]] && msg W "Problem removing old logs: $buf"

    # Exit return value adjustment
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    if [[ $warning_flag ]]; then
        ((my_retval+=1))
    fi
    if [[ $error_flag ]]; then
        ((my_retval+=2))
    fi

    # Exit
    # ~~~~
    exit $my_retval
}  # end of function finalise

#--------------------------
# Name: generate_report
# Purpose:
#    * Generates SATA backplane usage report
# Usage: generate_report
# Global variable set: none
# Outputs: writes dated report file; removes if same as last one
# Returns:
#   0 on success and warning.  Does not return on error
#--------------------------
function generate_report {
    fct "${FUNCNAME[0]}" 'started'
    local ata buf cmd last_out_fn oIFS out out_dir out_fn
    declare -A ata

    #  Get the ATA number for each sdX
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    # Based on syntaxerror's script in
    # https://serverfault.com/questions/244944/linux-ata-errors-translating-to-a-device-name
    oIFS=$IFS
    while IFS=' ' read Path HostFull sdx
    do
        IFS=: h=($HostFull)
        HostMain=${h[0]}; HostMid=${h[1]}; HostSub=${h[2]}
        if echo $Path | grep -q '/usb[0-9]*/'; then
            msg I "Device $sdx is not an ATA device, it is a USB device"
        else
            ata["$sdx"]=ata$(< "$Path/host$HostMain/scsi_host/host$HostMain/unique_id").$HostMid$HostSub
        fi
    done < <(
        for i in /sys/block/sd*
        do
            readlink $i \
                | sed \
                    -e 's|\.\./devices|/sys/devices|' \
                    -e 's|/host[0-9]\{1,2\}/target| |' \
                    -e 's|/[0-9]\{1,2\}\(:[0-9]\)\{3\}/block/| |'
        done
    )
    IFS=$oIFS

    # Generate report data
    # ~~~~~~~~~~~~~~~~~~~~
    out=$'Backplane Socket  sdx ata      Serial\n'
    out+=$(
        while read backplane socket sdx serno
        do
            msg D "backplane: $backplane, socket: $socket, sdx: $sdx, serno: $serno"
            ((backplane=backplane-6+1))
            ((socket++))
            printf '%9s %6s %4s %8s %s\n' $backplane $socket $sdx ${ata[$sdx]} $serno
        done < <(
            lshw -class disk 2>&1 \
                | grep -E '^       (bus info|logical name|serial)' \
                | sed -e 's/^[[:space:]]*//' \
                | xargs -L 3 \
                | grep -Ev 'scsi@(0|1):0.0.0' \
                | sed -e 's/bus info: scsi@//' -e 's|logical name: /dev/||' \
                | sed -e 's/serial: //' -e 's/:/ /' -e 's/\.0\.0//' \
                | sort -n
            )
    )

    # Check reports directory
    # ~~~~~~~~~~~~~~~~~~~~~~~
    out_dir=/var/backup/sata_backplane_usage
    buf=$(ck_file "$out_dir" d:rwx 2>&1)
    [[ $buf != '' ]] && msg E "$buf"

    # Get name of most recent report
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    last_out_fn=$(ls -1rt "$out_dir" | tail -1)
    [[ $last_out_fn != '' ]] && last_out_fn=$out_dir/$last_out_fn

    # Write report to file
    # ~~~~~~~~~~~~~~~~~~~~
    out_fn=$out_dir/$(date +%Y-%m-%d@%H:%M:%S).report
    msg I "Writing report $out_fn"
    buf=$(echo "$out" > "$out_fn" 2>&1)
    [[ $buf != '' ]] && msg E "Writing report: $buf"

    # Remove report file if same as previous one
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    if [[ $last_out_fn != '' ]]; then
        cmd=(diff --brief "$last_out_fn" "$out_fn")
        buf=$("${cmd[@]}" 2>&1)
        if (($?==0)); then
            msg I "Removing report $out_fn because identical to the last report"
            buf=$(rm "$out_fn" 2>&1)
            [[ $buf != '' ]] && msg E "Removing $out_fn: $buf"
        elif (($?==1)); then
            msg W "SATA backplane usage changed.  Reports in $out_dir"
        elif (($?==2)); then
            msg E "${cmd[*]}: $buf"
        fi
    fi

    fct "${FUNCNAME[0]}" 'returning'
    return 0
}  #  end of function generate_report

#--------------------------
# Name: initialise
# Purpose: sets up environment, parses command line, reads config file
#--------------------------
function initialise {
    local args buf emsg msg_part opt_c_flag opt_l_flag
    local -r fn_date_format='%Y-%m-%d@%H:%M:%S'

    # Configure shell environment
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~
    export LANG=en_GB.UTF-8
    export LANGUAGE=en_GB.UTF-8
    for var_name in LC_ADDRESS LC_ALL LC_COLLATE LC_CTYPE LC_IDENTIFICATION \
        LC_MEASUREMENT LC_MESSAGES LC_MONETARY LC_NAME LC_NUMERIC LC_PAPER \
        LC_TELEPHONE LC_TIME
    do
        unset $var_name
    done

    export PATH=/usr/sbin:/sbin:/usr/bin:/bin
    IFS=$' \n\t'
    set -o nounset
    shopt -s extglob            # Enable extended pattern matching operators
    unset CDPATH                # Ensure cd behaves as expected
    umask 022

    # Initialise some global logic variables
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    readonly false=
    readonly true=true

    debugging_flag=$false
    error_flag=$false
    finalising_flag=$false
    interrupt_flag=$false
    logging_flag=$false
    tmp_dir_created_flag=$false
    warning_flag=$false

    # Initialise some global string variables
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    readonly log_date_format='+%H:%M:%S'
    readonly log_retention=28
    readonly msg_lf=$'\n    '
    readonly my_name=${0##*/}
    readonly script_ver=0.1

    # Set traps
    # ~~~~~~~~~
    trap 'finalise 129' 'HUP'
    trap 'finalise 130' 'INT'
    trap 'finalise 131' 'QUIT'
    trap 'finalise 132' 'ILL'
    trap 'finalise 134' 'ABRT'
    trap 'finalise 135' 'BUS'
    trap 'finalise 136' 'FPE'
    trap 'finalise 138' 'USR1'
    trap 'finalise 139' 'SEGV'
    trap 'finalise 140' 'USR2'
    trap 'finalise 141' 'PIPE'
    trap 'finalise 142' 'ALRM'
    trap 'finalise 143' 'TERM'
    trap 'finalise 146' 'CONT'
    trap 'finalise 147' 'STOP'
    trap 'finalise 148' 'TSTP'
    trap 'finalise 149' 'TTIN'
    trap 'finalise 150' 'TTOU'
    trap 'finalise 151' 'URG'
    trap 'finalise 152' 'XCPU'
    trap 'finalise 153' 'XFSZ'
    trap 'finalise 154' 'VTALRM'
    trap 'finalise 155' 'PROF'

    # Parse command line
    # ~~~~~~~~~~~~~~~~~~
    args=("$@")
    conf_fn=/usr/local/etc/report_sata_backplane_usage.conf
    emsg=
    log_dir=/var/log/${my_name%.sh}
    log_fn=$log_dir/$(date +$fn_date_format)
    opt_c_flag=$false
    opt_l_flag=$false
    while getopts :c:dhl:v opt "$@"
    do
        case $opt in
            c )
                conf_fn=$OPTARG
                opt_c_flag=$true
                ;;
            d )
                debugging_flag=$true
                ;;
            h )
                debugging_flag=$false
                usage verbose
                exit 0
                ;;
            l )
                log_fn=$OPTARG
                opt_l_flag=$true
                ;;
            v )
                echo "$script_name version $script_ver" >&2
                exit 0
                ;;
            : )
                emsg+=$msg_lf"Option $OPTARG must have an argument"
                [[ $OPTARG = c ]] && { opt_c_flag=$true; conf_fn=/bin/bash; }
                ;;
            * )
                emsg+=$msg_lf"Invalid option '-$OPTARG'"
        esac
    done

    # Check option arguments
    # ~~~~~~~~~~~~~~~~~~~~~~
    if [[ $opt_l_flag && $log_fn != /dev/tty ]]; then
        log_dir=${log_fn%/*}
        log_dir_realpath=$(readlink --canonicalize-missing -- "$log_dir" 2>&1)
        if (($?==0)); then
            buf=$(ck_file "$log_dir_realpath" d:rwx: 2>&1)
            [[ $buf != '' ]] && emsg+=$msg_lf"Invalid -l option value '$log_fn': $buf"
        else
           emsg+=$msg_lf"Invalid -l option value '$log_fn' ($log_dir_realpath)"
        fi
    fi

    # Test for mutually exclusive options
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    # There are no mutually exclusive options

    # Test for mandatory options not set
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    # There are no mandatory options

    # Test for extra arguments
    # ~~~~~~~~~~~~~~~~~~~~~~~~
    shift $(($OPTIND-1))
    if [[ $* != '' ]]; then
        emsg+=$msg_lf"Invalid extra argument(s) '$*'"
    fi

    # Report any command line errors
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    if [[ $emsg != '' ]]; then
        emsg+=$msg_lf'(use -h option for help)'
        msg E "$emsg"
    fi

    # Check the config file
    # ~~~~~~~~~~~~~~~~~~~~~
    buf=$(ck_file "$conf_fn" f:r: 2>&1)
    [[ $buf != '' ]] && msg E "$buf"

    # Set up logging
    # ~~~~~~~~~~~~~~
    if [[ ! $opt_l_flag ]]; then    # No -l option; directory not yet error trapped
        buf=$(ck_file "$log_dir" d:rwx: 2>&1)
        if [[ $buf = '' ]]; then
            log_fn=$log_dir/$(date +$fn_date_format).log
            buf=$(touch "$log_fn" 2>&1)
            if (($?>0)); then
                msg E "cannot create $log_fn: $buf"
            fi
            chmod 600 "$log_fn"
        else
            msg E "cannot create log: $buf"
        fi
    fi
    exec >>"$log_fn"
    exec 2>>"$log_fn"
    [[ $log_fn != /dev/tty ]] && logging_flag=$true

    fct 'initialise' 'started (this message delayed until logging initialised)'
    msg I "$(date '+%A %-d %B %Y %Z')"
    msg I "$my_name version $script_ver started (PID: $$, PPID: $PPID)"
    msg I "Command line:$msg_lf$0 $(printf '%q ' "${args[@]}" | sed "s/''//")"

    # Read config file
    # ~~~~~~~~~~~~~~~~
    buf=$(ck_file "$conf_fn" f:r 2>&1)
    [[ $buf != '' ]] && msg E "$buf"
    source "$conf_fn"
    emsg=

    # Report any errors
    # ~~~~~~~~~~~~~~~~~
    if [[ $emsg != '' ]]; then
        msg E "$conf_fn: ${emsg#, }"
    fi

    # Create temporary directory
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~
    # TODO: remove this code if tmp_dir not used
    # If the mktemplate is changed, tmp_dir_regex in the finalise function
    # may also need to be changed.
    buf=$(mktemp -d "/tmp/$my_name.XXXXXX" 2>&1)
    if (($?==0)); then
        tmp_dir=$buf
        tmp_dir_created_flag=$true
        chmod 700 "$tmp_dir"
    else
        msg E "Unable to create temporary directory:$buf"
    fi

    fct "${FUNCNAME[0]}" 'returning'
}  # end of function initialise

#--------------------------
# Name: msg
# Purpose: generalised messaging interface
# Arguments:
#    $1 class: E, I or W indicating Error, Information or Warning
#    $2 message text
# Global variables read:
#     my_name
# Global variables written:
#     error_flag
#     warning_flag
# Output: information messages to stdout; the rest to stderr
# Returns:
#   Does not return (calls finalise) when class is E for error
#   Otherwise returns 0
#--------------------------
function msg {
    local class level message_text prefix

    # Process arguments
    # ~~~~~~~~~~~~~~~~~
    class="${1:-}"
    message_text="${2:-}"

    # Class-dependent set-up
    # ~~~~~~~~~~~~~~~~~~~~~~
    case "$class" in
        D )
            [[ ! $debugging_flag ]] && return
            prefix='DEBUG: '
            ;;
        E )
            error_flag=$true
            level=err
            prefix='ERROR: '
            ;;
        I )
            prefix=
            level=info
            ;;
        W )
            warning_flag=$true
            level=warning
            prefix='WARN: '
            ;;
        * )
            msg E "msg: invalid class '$class': '$*'"
    esac

    # Output
    # ~~~~~~
    message_text="$prefix$message_text"
    [[ $class = E ]] && \
        logger --tag $my_name[$$] --priority syslog.$level -- "$message_text"
    [[ $logging_flag ]] && \
        message_text="$(date "$log_date_format") $message_text"
    if [[ $class = I ]]; then
        printf '%s\n' "$message_text"
    else
        printf '%s\n' " $message_text" >&2
    fi

    # Return or not
    # ~~~~~~~~~~~~~
    if [[ $class = E ]]; then
        [[ ! $finalising_flag ]] && finalise 1
    fi

    return 0
}  #  end of function msg

#--------------------------
# Name: usage
# Purpose: prints usage message
#--------------------------
function usage {
    fct "${FUNCNAME[0]}" 'started'
    local msg usage

    # Build the messages
    # ~~~~~~~~~~~~~~~~~~
    usage="usage: $my_name -c conf [-d] [-h] [-l log] [-v]"
    msg='  where:'
    msg+=$'\n    -c configuration file name'
    msg+=$'\n    -d debugging on'
    msg+=$'\n    -h prints this help and exits'
    msg+=$'\n    -l log file'
    msg+=$'\n        Use /dev/tty to log to screen'
    msg+=$'\n        '
    msg+="Default: log to $log_fn"
    msg+=$'\n    '"-v prints the script's version and exits"

    # Display the message(s)
    # ~~~~~~~~~~~~~~~~~~~~~~
    echo "$usage" >&2
    if [[ ${1:-} != 'verbose' ]]; then
        echo "(use -h for help)" >&2
    else
        echo "$msg" >&2
    fi

    fct "${FUNCNAME[0]}" 'returning'
}  # end of function usage

#--------------------------
# Name: main
# Purpose: where it all happens
#--------------------------
initialise "${@:-}"
generate_report
finalise 0
