From 7212c9b545e3155c358c3fe44921535c9866174c Mon Sep 17 00:00:00 2001 From: Nathaniel Landau Date: Wed, 27 Oct 2021 11:16:49 -0400 Subject: [PATCH] use new script template --- .hooks/pre-commit.sh | 833 ++++++++++++++++++++---------- .vscode/shellscript.code-snippets | 24 +- 2 files changed, 569 insertions(+), 288 deletions(-) diff --git a/.hooks/pre-commit.sh b/.hooks/pre-commit.sh index 6c36417..49ec7b9 100755 --- a/.hooks/pre-commit.sh +++ b/.hooks/pre-commit.sh @@ -3,181 +3,237 @@ _mainScript_() { GITROOT=$(git rev-parse --show-toplevel 2>/dev/null) - _setPATH_ "/usr/local/opt/grep/libexec/gnubin" "/usr/local/bin" "${HOME}/bin" + _setPATH_ "/usr/local/bin" "${HOME}/bin" + LOGFILE="${HOME}/logs/$(_fileName_ "${GITROOT}")-$(basename "$0").log" _gitStopWords_() { - # DESC: Check if any specified words are found in the current diff. If found, the pre-commit fails - # ARGS: $1 (Required) - File to check - # OUTS: None - # USAGE: Call the function - # NOTE: Requires a file located at `~/.git_stop_words` containing one word per line. - - # Fail if any matching words are present in the diff - - STOP_WORD_FILE="${HOME}/.git_stop_words" - GIT_DIFF_TEMP="${TMP_DIR}/diff.txt" + # DESC: + # Check if any specified stop words are in the commit diff. If found, the pre-commit hook will exit with a non-zero exit code. + # ARGS: + # $1 (Required): Path to file + # OUTS: + # 0: Success + # 1: Failure + # USAGE: + # _gitStopWords_ "/path/to/file.sh" + # NOTE: + # Requires a file located at `~/.git_stop_words` containing one stopword per line. + + [[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}" + + local _gitDiffTmp="${TMP_DIR}/diff.txt" if [ -f "${STOP_WORD_FILE}" ]; then if [[ $(basename "${STOP_WORD_FILE}") == "$(basename "${1}")" ]]; then - debug "Don't check stop words file for stop words. Skipping $(basename "${1}")" + debug "$(basename "${1}"): Don't check stop words file for stop words." return 0 fi - debug "Checking for stop words" + debug "$(basename "${1}"): Checking for stop words..." # remove blank lines from stopwords file cat "${STOP_WORD_FILE}" | sed '/^$/d' >"${TMP_DIR}/pattern_file.txt" # Add diff to a temporary file - git diff --cached -- "${1}" | grep '^+' >"${GIT_DIFF_TEMP}" + git diff --cached -- "${1}" | grep '^+' >"${_gitDiffTmp}" - if grep --file="${TMP_DIR}/pattern_file.txt" "${GIT_DIFF_TEMP}"; then - error "Found git stop word in '$(basename "${1}")'" - _safeExit_ 1 + if grep --file="${TMP_DIR}/pattern_file.txt" "${_gitDiffTmp}"; then + return 1 + else + return 0 fi else - debug "Could not find git stopwords file expected at '${STOP_WORD_FILE}'. Continuing..." + notice "Could not find git stopwords file expected at '${STOP_WORD_FILE}'. Continuing..." + return 0 fi } _ignoreSymlinks_() { - # Ensure that no symlinks have been added to the repository. - - local gitIgnore="${GITROOT}/.gitignore" - local havesymlink=false - local f + # DESC: + # Ensures that no symlinks have been committed to the repository. If the symlink + # has not yet been staged, it will be added to the .gitignore file. + # ARGS: + # NONE + # OUTS: + # 0: Success + # 1: Failure + # USAGE: + # _ignoreSymlinks_ + + local _gitIgnore="${GITROOT}/.gitignore" + local _haveSymlink=false + local _f + + debug "Checking for symlinks..." # Work on files not yet staged - for f in $(git status --porcelain | grep '^??' | sed 's/^?? //'); do - if [ -L "${f}" ]; then - if ! grep "${f}" "${gitIgnore}"; then - if echo -e "\n${f}" >>"${gitIgnore}"; then - info "Added symlink '${f}' to .gitignore" + for _f in $(git status --porcelain | grep '^??' | sed 's/^?? //'); do + if [ -L "${_f}" ]; then + if ! grep "${_f}" "${_gitIgnore}"; then + if printf "\n%s" "${_f}" >>"${_gitIgnore}"; then + notice "Added unstaged symlink '${_f}' to .gitignore" else - fatal "Could not add symlink '${f}' to .gitignore" + fatal "Could not add symlink '${_f}' to .gitignore" fi fi - havesymlink=true + _haveSymlink=true fi done # Work on files that were mistakenly staged for f in $(git status --porcelain | grep '^A' | sed 's/^A //'); do - if [ -L "${f}" ]; then - if ! grep "${f}" "${gitIgnore}"; then - if echo -e "\n${f}" >>"${gitIgnore}"; then - info "Added symlink '${f}' to .gitignore" + if [ -L "${_f}" ]; then + if ! grep "${_f}" "${_gitIgnore}"; then + if printf "\n%s" "${_f}" >>"${_gitIgnore}"; then + notice "Added unstaged symlink '${_f}' to .gitignore" else - fatal "Could not add symlink '${f}' to .gitignore" + fatal "Could not add symlink '${_f}' to .gitignore" fi fi - havesymlink=true + _haveSymlink=true fi done - if ${havesymlink}; then - error "At least one symlink was added to the repo." - error "Commit aborted..." - _safeExit_ 1 + if [[ ${_haveSymlink} == true ]]; then + return 1 + else + return 0 fi } _lintYAML_() { + # DESC: + # Lint YAML files staged for commit. + # Requires either 'yaml-lint 'or 'yamllint' be installed. + # ARGS: + # $1 (Required): Path to file + # OUTS: + # 0: Success + # 1: Failure + # USAGE: + # _lintYAML_ "/path/to/file.yaml" + + [[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}" + + local _filename="$(_fileName_ "${1}")" + if command -v yaml-lint >/dev/null; then - debug "Linting YAML File" - if ! yaml-lint "${1}"; then - error "Error in ${1}" - _safeExit_ 1 + debug "${_filename}: Linting YAML..." + if yaml-lint "${1}"; then + return 0 else - success "yaml-lint passed: '${1}'" + return 1 fi elif command -v yamllint >/dev/null; then - debug "Linting YAML File" + debug "${_filename}: Linting YAML..." if [ -f "$(git rev-parse --show-toplevel)/.yamllint.yml" ]; then - if ! yamllint -c "$(git rev-parse --show-toplevel)/.yamllint.yml" "${1}"; then - error "YAML Error in ${1}" - _safeExit_ 1 + if yamllint -c "$(git rev-parse --show-toplevel)/.yamllint.yml" "${1}"; then + return 0 else - success "yamllint passed: '${1}'" + return 1 fi else - if ! yamllint "${1}"; then - error "YAML Error in ${1}" - _safeExit_ 1 + if yamllint "${1}"; then + return 0 else - success "yamllint passed: '${1}'" + return 1 fi fi else - notice "No YAML linter installed. Continuiing..." + notice "No YAML linter found. Continuing..." + return 0 fi } _lintShellscripts_() { + # DESC: + # Lint shell scripts staged for commit. + # ARGS: + # $1 (Required): Path to file + # OUTS: + # 0: Success + # 1: Failure + # stdout: + # USAGE: + # _lintShellscripts_ "/path/to/file.sh" + + [[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}" + + local _filename="$(_fileName_ "${1}")" + if command -v shellcheck >/dev/null; then - debug "Linting shellscript: ${1}" - if ! shellcheck --exclude=2016,2059,2001,2002,2148,1090,2162,2005,2034,2154,2086,2155,2181,2164,2120,2119,1083,1117,2207,1091 "${1}"; then - error "Error in ${1}" - _safeExit_ 1 + debug "${_filename}: Linting shellscript..." + if shellcheck --exclude=2016,2059,2001,2002,2148,1090,2162,2005,2034,2154,2086,2155,2181,2164,2120,2119,1083,1117,2207,1091 "${1}"; then + return 0 else - success "shellcheck passed: '${1}'" + return 1 fi else notice "Shellcheck not installed. Continuing..." + return 0 fi } _BATS_() { - local filename - - # Run BATS unit tests on individual files - filename="$(basename "${1}")" - filename="${filename%.*}" - [ -f "${GITROOT}/test/${filename}.bats" ] \ - && { - notice "Running ${filename}.bats" - if ! "${GITROOT}/test/${filename}.bats" -t; then - error "Error in ${1}" - _safeExit_ 1 - fi - } - unset filename + # DESC: + # Runs BATS unit tests on bash scripts. Requires bats to be installed. + # ARGS: + # $1 (Required): Path to bats test file + # OUTS: + # 0: Success + # 1: Failure + # USAGE: + # _BATS_ "/path/to/file.sh" + + [[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}" + + local _filename="$(_fileName_ "${1}")" + debug "${_filename}: Runing bats tests..." + if bats -t $1; then + return 0 + else + return 1 + fi } _lintAnsible_() { - - if ! command -v ansible-lint >/dev/null; then - notice "Found Ansible files but ansible-lint is not available. Continuing..." - return 0 - elif [[ "$(basename ${1})" =~ (^\.|^requirements|j2|vault\.yml|variables|meta|defaults?|inventory) ]]; then - # Don't lint files that are not Ansible playbooks - debug "won't ansible lint: ${1}" - return 0 - elif [[ ${1} =~ /(handlers|vars/|defaults/|meta/|molecule/|templates/|files/)/ ]]; then - # Don't lint in directory names that are not likely to contain Ansible playbooks - debug "Won't ansible lint: ${1}" + # DESC: + # Lint Ansible YMAL files staged for commit. Requires ansible-lint to be installed. + # ARGS: + # $1 (Required): Path to file + # OUTS: + # 0: Success + # 1: Failure + # USAGE: + # _lintAnsible_ "/path/to/file.yml" + + [[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}" + + local _filename="$(_fileName_ "${1}")" + + if ! command -v ansible-lint &>/dev/null; then + notice "ansible-lint not intstalled. Continuing..." return 0 fi - ANSIBLE_COMMAND="ansible-lint -vv --parseable-severity" + ANSIBLE_COMMAND="ansible-lint -vv --parseable-severity ${1}" if [ -f "$(git rev-parse --show-toplevel)/.ansible-lint.yml" ]; then - ANSIBLE_COMMAND="ansible-lint -p -c $(git rev-parse --show-toplevel)/.ansible-lint.yml" + ANSIBLE_COMMAND="ansible-lint -p -c $(git rev-parse --show-toplevel)/.ansible-lint.yml ${1}" fi - debug "Linting ansible file: ${1}" - if ! ${ANSIBLE_COMMAND} "${1}"; then - error "Ansible-lint error" - _safeExit_ 1 + debug "${_filename}: Linting ansible..." + if ${ANSIBLE_COMMAND}; then + return 0 else - success "ansible-lint passed: ${1}" + return 1 fi } # RUN SCRIPT LOGIC - # Attempt to discern if we are working on an repo that contains ansible files + # Attempt to discern if we are working on a repo that contains ansible files IS_ANSIBLE_REPO=false - if find "$(git rev-parse --show-toplevel)" -type f -mindepth 1 -maxdepth 1 \ + if find "$(git rev-parse --show-toplevel)" -mindepth 1 -maxdepth 1 -type f \ -name "inventory.yml" \ -o -name "ansible.cfg" \ -o -name ".ansible-lint.yml" &>/dev/null; then @@ -185,36 +241,87 @@ _mainScript_() { IS_ANSIBLE_REPO=true fi - _ignoreSymlinks_ + if ! _ignoreSymlinks_; then + notice "Found symlink in repository. Exiting..." + _safeExit_ 1 + fi while read -r STAGED_FILE; do - debug "Working on: ${STAGED_FILE}" + debug "$(_fileName_ "${STAGED_FILE}"): Linting..." + if [ -f "${STAGED_FILE}" ]; then - _gitStopWords_ "${STAGED_FILE}" + if _gitStopWords_ "${STAGED_FILE}"; then + info "$(_fileName_ "${STAGED_FILE}"): Passed stopwords lint" + else + notice "$(_fileName_ "${STAGED_FILE}"): Failed stopwords lint" + _safeExit_ 1 + fi + # YAML Linting if [[ ${STAGED_FILE} =~ \.(yaml|yml)$ ]]; then - _lintYAML_ "${STAGED_FILE}" - if [ "${IS_ANSIBLE_REPO}" = true ]; then - _lintAnsible_ "${STAGED_FILE}" + + if _lintYAML_ "${STAGED_FILE}"; then + info "$(_fileName_ "${STAGED_FILE}"): Passed yaml lint" + else + notice "$(_fileName_ "${STAGED_FILE}"): Failed yaml lint" + _safeExit_ 1 + fi + fi + + # Ansible Linting + # - Only run in Ansible repos + # - Only run on YAML files + # - Don't lint files that are not Ansible playbooks + # - Don't lint in directory names that are not likely to contain Ansible playbooks + if [[ ${IS_ANSIBLE_REPO} == true ]] \ + && [[ ${STAGED_FILE} =~ \.(yaml|yml)$ ]] \ + && [[ ! $(_fileName_ "${STAGED_FILE}") =~ (^\.|^requirements|j2|vault\.yml|variables|meta|defaults?|inventory) ]] \ + && [[ ! $(_filePath_ "${STAGED_FILE}") =~ /(handlers|vars/|defaults/|meta/|molecule/|templates/|files/)/ ]]; then + + if _lintAnsible_ "${STAGED_FILE}"; then + info "$(_fileName_ "${STAGED_FILE}"): Passed ansible-lint" + else + notice "$(_fileName_ "${STAGED_FILE}"): Failed ansible-lint" + _safeExit_ 1 fi fi + + # Shellscript Linting if [[ ${STAGED_FILE} =~ \.(bash|sh)$ || "$(head -n 1 "${STAGED_FILE}")" =~ ^#!.*bash$ ]]; then - _lintShellscripts_ "${STAGED_FILE}" + if _lintShellscripts_ "${STAGED_FILE}"; then + info "$(_fileName_ "${STAGED_FILE}"): Passed shellcheck" + else + notice "$(_fileName_ "${STAGED_FILE}"): Failed shellcheck" + _safeExit_ 1 + fi fi + + # Run BATS unit tests on individual files if STAGED_FILE.bats exists in test/ directory if [[ ${STAGED_FILE} =~ \.(sh|bash|bats|zsh)$ || "$(head -n 1 "${STAGED_FILE}")" =~ ^#!.*bash$ ]]; then - _BATS_ "${STAGED_FILE}" + + if [ -f "${GITROOT}/test/$(_fileBasename_ "${STAGED_FILE}").bats" ]; then + if _BATS_ "${GITROOT}/test/$(_fileBasename_ "${STAGED_FILE}").bats"; then + info "$(_fileName_ "${STAGED_FILE}"): BATS passed" + else + notice "$(_fileName_ "${STAGED_FILE}"): BATS failed" + _safeExit_ 1 + fi + fi fi fi done < <(git diff --cached --name-only --line-prefix="$(git rev-parse --show-toplevel)/") -} # end _mainScript_ +} +# end _mainScript_ # ################################## Flags and defaults # Script specific +STOP_WORD_FILE="${HOME}/.git_stop_words" +shopt -s nocasematch # Case insensitive matching -# Common +# Required variables LOGFILE="${HOME}/logs/$(basename "$0").log" QUIET=false LOGLEVEL=ERROR @@ -222,52 +329,95 @@ VERBOSE=false FORCE=false DRYRUN=false declare -a ARGS=() -NOW=$(LC_ALL=C date +"%m-%d-%Y %r") # Returns: 06-14-2015 10:34:40 PM -DATESTAMP=$(LC_ALL=C date +%Y-%m-%d) # Returns: 2015-06-14 -HOURSTAMP=$(LC_ALL=C date +%r) # Returns: 10:34:40 PM -TIMESTAMP=$(LC_ALL=C date +%Y%m%d_%H%M%S) # Returns: 20150614_223440 -LONGDATE=$(LC_ALL=C date +"%a, %d %b %Y %H:%M:%S %z") # Returns: Sun, 10 Jan 2016 20:47:53 -0500 -GMTDATE=$(LC_ALL=C date -u -R | sed 's/\+0000/GMT/') # Returns: Wed, 13 Jan 2016 15:55:29 GMT - -# ################################## Custom utility functions -_setPATH_() { - # DESC: Add directories to $PATH so script can find executables - # ARGS: $@ - One or more paths - # OUTS: $PATH - # USAGE: _setPATH_ "/usr/local/bin" "${HOME}/bin" "$(npm bin)" - local NEWPATH NEWPATHS USERPATH - - for USERPATH in "$@"; do - NEWPATHS+=("$USERPATH") - done - for NEWPATH in "${NEWPATHS[@]}"; do - if [ -d "${NEWPATH}" ]; then - if ! echo "${PATH}" | grep -Eq "(^|:)${NEWPATH}($|:)"; then - PATH="${NEWPATH}:${PATH}" - debug "Added '${NEWPATH}' to PATH" - else - debug "_setPATH_: '${NEWPATH}' already exists in PATH" - fi - else - debug "_setPATH_: can not find: ${NEWPATH}" - fi - done +# ################################## Custom utility functions (Pasted from repository) +_fileName_() { + # DESC: + # Get only the filename from a string + # ARGS: + # $1 (Required) - Input string + # OUTS: + # 0 - Success + # 1 - Failure + # stdout: Filename with extension + # USAGE: + # _fileName_ "some/path/to/file.txt" --> "file.txt" + # _fileName_ "some/path/to/file" --> "file" + [[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}" + printf "%s\n" "${1##*/}" } -# ################################## Common Functions for script template -_setColors_() { - # DESC: Sets colors use for alerts. - # ARGS: None - # OUTS: None - # USAGE: echo "${blue}Some text${reset}" - if tput setaf 1 &>/dev/null; then +_filePath_() { + # DESC: + # Finds the directory name from a file path. If it exists on filesystem, print + # absolute path. If a string, remove the filename and return the path + # ARGS: + # $1 (Required) - Input string path + # OUTS: + # 0 - Success + # 1 - Failure + # stdout: Directory path + # USAGE: + # _fileDir_ "some/path/to/file.txt" --> "some/path/to" + # CREDIT: + # https://github.com/labbots/bash-utility/ + + [[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}" + + local _tmp=${1} + + if [ -e "${_tmp}" ]; then + _tmp="$(dirname "$(realpath "${_tmp}")")" + else + [[ ${_tmp} != *[!/]* ]] && { printf '/\n' && return; } + _tmp="${_tmp%%"${_tmp##*[!/]}"}" + + [[ ${_tmp} != */* ]] && { printf '.\n' && return; } + _tmp=${_tmp%/*} && _tmp="${_tmp%%"${_tmp##*[!/]}"}" + fi + printf '%s' "${_tmp:-/}" +} + +_fileBasename_() { + # DESC: + # Gets the basename of a file from a file name + # ARGS: + # $1 (Required) - Input string path + # OUTS: + # 0 - Success + # 1 - Failure + # stdout: Filename basename (no extension or path) + # USAGE: + # _fileBasename_ "some/path/to/file.txt" --> "file" + + [[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}" + + local _file + local _basename + _file="${1##*/}" + _basename="${_file%.*}" + + printf "%s" "${_basename}" +} +# ################################## Functions required for this template to work + +_setColors_() { + # DESC: + # Sets colors use for alerts. + # ARGS: + # None + # OUTS: + # None + # USAGE: + # echo "${blue}Some text${reset}" + + if tput setaf 1 >/dev/null 2>&1; then bold=$(tput bold) underline=$(tput smul) reverse=$(tput rev) reset=$(tput sgr0) - if [[ $(tput colors) -ge 256 ]] 2>/dev/null; then + if [[ $(tput colors) -ge 256 ]] >/dev/null 2>&1; then white=$(tput setaf 231) blue=$(tput setaf 38) yellow=$(tput setaf 11) @@ -303,68 +453,79 @@ _setColors_() { } _alert_() { - # DESC: Controls all printing of messages to log files and stdout. - # ARGS: $1 (required) - The type of alert to print + # DESC: + # Controls all printing of messages to log files and stdout. + # ARGS: + # $1 (required) - The type of alert to print # (success, header, notice, dryrun, debug, warning, error, # fatal, info, input) # $2 (required) - The message to be printed to stdout and/or a log file # $3 (optional) - Pass '${LINENO}' to print the line number where the _alert_ was triggered - # OUTS: None - # USAGE: [ALERTTYPE] "[MESSAGE]" "${LINENO}" - # NOTES: The colors of each alert type are set in this function - # For specified alert types, the funcstac will be printed - - local function_name color - local alertType="${1}" - local message="${2}" - local line="${3:-}" # Optional line number - - if [[ -n ${line} && ${alertType} =~ ^(fatal|error) && ${FUNCNAME[2]} != "_trapCleanup_" ]]; then - message="${message} (line: ${line}) $(_functionStack_)" - elif [[ -n ${line} && ${FUNCNAME[2]} != "_trapCleanup_" ]]; then - message="${message} (line: ${line})" - elif [[ -z ${line} && ${alertType} =~ ^(fatal|error) && ${FUNCNAME[2]} != "_trapCleanup_" ]]; then - message="${message} $(_functionStack_)" + # OUTS: + # stdout: The message is printed to stdout + # log file: The message is printed to a log file + # USAGE: + # [_alertType] "[MESSAGE]" "${LINENO}" + # NOTES: + # - The colors of each alert type are set in this function + # - For specified alert types, the funcstac will be printed + + local _color + local _alertType="${1}" + local _message="${2}" + local _line="${3:-}" # Optional line number + + [[ $# -lt 2 ]] && fatal 'Missing required argument to _alert_' + + if [[ -n ${_line} && ${_alertType} =~ ^(fatal|error) && ${FUNCNAME[2]} != "_trapCleanup_" ]]; then + _message="${_message} ${gray}(line: ${_line}) $(_printFuncStack_)" + elif [[ -n ${_line} && ${FUNCNAME[2]} != "_trapCleanup_" ]]; then + _message="${_message} ${gray}(line: ${_line})" + elif [[ -z ${_line} && ${_alertType} =~ ^(fatal|error) && ${FUNCNAME[2]} != "_trapCleanup_" ]]; then + _message="${_message} ${gray}$(_printFuncStack_)" fi - if [[ ${alertType} =~ ^(error|fatal) ]]; then - color="${bold}${red}" - elif [ "${alertType}" == "info" ]; then - color="${gray}" - elif [ "${alertType}" == "warning" ]; then - color="${red}" - elif [ "${alertType}" == "success" ]; then - color="${green}" - elif [ "${alertType}" == "debug" ]; then - color="${purple}" - elif [ "${alertType}" == "header" ]; then - color="${bold}${tan}" - elif [ ${alertType} == "notice" ]; then - color="${bold}" - elif [ ${alertType} == "input" ]; then - color="${bold}${underline}" - elif [ "${alertType}" = "dryrun" ]; then - color="${blue}" + if [[ ${_alertType} =~ ^(error|fatal) ]]; then + _color="${bold}${red}" + elif [ "${_alertType}" == "info" ]; then + _color="${gray}" + elif [ "${_alertType}" == "warning" ]; then + _color="${red}" + elif [ "${_alertType}" == "success" ]; then + _color="${green}" + elif [ "${_alertType}" == "debug" ]; then + _color="${purple}" + elif [ "${_alertType}" == "header" ]; then + _color="${bold}${white}${underline}" + elif [ ${_alertType} == "notice" ]; then + _color="${bold}" + elif [ ${_alertType} == "input" ]; then + _color="${bold}${underline}" + elif [ "${_alertType}" = "dryrun" ]; then + _color="${blue}" else - color="" + _color="" fi _writeToScreen_() { - ("${QUIET}") && return 0 # Print to console when script is not 'quiet' - [[ ${VERBOSE} == false && ${alertType} =~ ^(debug|verbose) ]] && return 0 + [[ ${VERBOSE} == false && ${_alertType} =~ ^(debug|verbose) ]] && return 0 - if ! [[ -t 1 ]]; then # Don't use colors on non-recognized terminals - color="" + if ! [[ -t 1 || -z ${TERM:-} ]]; then # Don't use colors on non-recognized terminals + _color="" reset="" fi - echo -e "$(date +"%r") ${color}$(printf "[%7s]" "${alertType}") ${message}${reset}" + if [[ ${_alertType} == header ]]; then + printf "${_color}%s${reset}\n" "${_message}" + else + printf "${_color}[%7s] %s${reset}\n" "${_alertType}" "${_message}" + fi } _writeToScreen_ _writeToLog_() { - [[ ${alertType} == "input" ]] && return 0 + [[ ${_alertType} == "input" ]] && return 0 [[ ${LOGLEVEL} =~ (off|OFF|Off) ]] && return 0 if [ -z "${LOGFILE:-}" ]; then LOGFILE="$(pwd)/$(basename "$0").log" @@ -373,12 +534,9 @@ _alert_() { [[ ! -f ${LOGFILE} ]] && touch "${LOGFILE}" # Don't use colors in logs - if command -v gsed &>/dev/null; then - local cleanmessage="$(echo "${message}" | gsed -E 's/(\x1b)?\[(([0-9]{1,2})(;[0-9]{1,3}){0,2})?[mGK]//g')" - else - local cleanmessage="$(echo "${message}" | sed -E 's/(\x1b)?\[(([0-9]{1,2})(;[0-9]{1,3}){0,2})?[mGK]//g')" - fi - echo -e "$(date +"%b %d %R:%S") $(printf "[%7s]" "${alertType}") [$(/bin/hostname)] ${cleanmessage}" >>"${LOGFILE}" + local cleanmessage="$(echo "${_message}" | sed -E 's/(\x1b)?\[(([0-9]{1,2})(;[0-9]{1,3}){0,2})?[mGK]//g')" + # Print message to log file + printf "%s [%7s] %s %s\n" "$(date +"%b %d %R:%S")" "${_alertType}" "[$(/bin/hostname)]" "${cleanmessage}" >>"${LOGFILE}" } # Write specified log level data to logfile @@ -390,27 +548,27 @@ _alert_() { _writeToLog_ ;; INFO | info | Info) - if [[ ${alertType} =~ ^(error|fatal|warning|info|notice|success) ]]; then + if [[ ${_alertType} =~ ^(error|fatal|warning|info|notice|success) ]]; then _writeToLog_ fi ;; NOTICE | notice | Notice) - if [[ ${alertType} =~ ^(error|fatal|warning|notice|success) ]]; then + if [[ ${_alertType} =~ ^(error|fatal|warning|notice|success) ]]; then _writeToLog_ fi ;; WARN | warn | Warn) - if [[ ${alertType} =~ ^(error|fatal|warning) ]]; then + if [[ ${_alertType} =~ ^(error|fatal|warning) ]]; then _writeToLog_ fi ;; ERROR | error | Error) - if [[ ${alertType} =~ ^(error|fatal) ]]; then + if [[ ${_alertType} =~ ^(error|fatal) ]]; then _writeToLog_ fi ;; FATAL | fatal | Fatal) - if [[ ${alertType} =~ ^fatal ]]; then + if [[ ${_alertType} =~ ^fatal ]]; then _writeToLog_ fi ;; @@ -418,7 +576,7 @@ _alert_() { return 0 ;; *) - if [[ ${alertType} =~ ^(error|fatal) ]]; then + if [[ ${_alertType} =~ ^(error|fatal) ]]; then _writeToLog_ fi ;; @@ -433,48 +591,53 @@ info() { _alert_ info "${1}" "${2:-}"; } success() { _alert_ success "${1}" "${2:-}"; } dryrun() { _alert_ dryrun "${1}" "${2:-}"; } input() { _alert_ input "${1}" "${2:-}"; } -header() { _alert_ header "== ${1} ==" "${2:-}"; } +header() { _alert_ header "${1}" "${2:-}"; } debug() { _alert_ debug "${1}" "${2:-}"; } fatal() { _alert_ fatal "${1}" "${2:-}" _safeExit_ "1" } -_functionStack_() { - # DESC: Prints the function stack in use - # ARGS: None - # OUTS: Prints [function]:[file]:[line] - # NOTE: Does not print functions from the alert class +_printFuncStack_() { + # DESC: + # Prints the function stack in use. Used for debugging, and error reporting. + # ARGS: + # None + # OUTS: + # stdout: Prints [function]:[file]:[line] + # NOTE: + # Does not print functions from the alert class local _i - funcStackResponse=() + _funcStackResponse=() for ((_i = 1; _i < ${#BASH_SOURCE[@]}; _i++)); do case "${FUNCNAME[$_i]}" in "_alert_" | "_trapCleanup_" | fatal | error | warning | notice | info | debug | dryrun | header | success) continue ;; esac - funcStackResponse+=("${FUNCNAME[$_i]}:$(basename ${BASH_SOURCE[$_i]}):${BASH_LINENO[_i - 1]}") + _funcStackResponse+=("${FUNCNAME[$_i]}:$(basename ${BASH_SOURCE[$_i]}):${BASH_LINENO[_i - 1]}") done printf "( " - printf %s "${funcStackResponse[0]}" - printf ' < %s' "${funcStackResponse[@]:1}" + printf %s "${_funcStackResponse[0]}" + printf ' < %s' "${_funcStackResponse[@]:1}" printf ' )\n' } _safeExit_() { - # DESC: Cleanup and exit from a script - # ARGS: $1 (optional) - Exit code (defaults to 0) - # OUTS: None + # DESC: + # Cleanup and exit from a script + # ARGS: + # $1 (optional) - Exit code (defaults to 0) + # OUTS: + # None if [[ -d ${SCRIPT_LOCK:-} ]]; then if command rm -rf "${SCRIPT_LOCK}"; then debug "Removing script lock" else - warning "Script lock could not be removed. Try manually deleting ${tan}'${LOCK_DIR}'${red}" + warning "Script lock could not be removed. Try manually deleting ${tan}'${LOCK_DIR}'" fi fi if [[ -n ${TMP_DIR:-} && -d ${TMP_DIR:-} ]]; then if [[ ${1:-} == 1 && -n "$(ls "${TMP_DIR}")" ]]; then - # Do something here to save TMP_DIR on a non-zero script exit for debugging command rm -r "${TMP_DIR}" - debug "Removing temp directory" else command rm -r "${TMP_DIR}" debug "Removing temp directory" @@ -486,45 +649,61 @@ _safeExit_() { } _trapCleanup_() { - # DESC: Log errors and cleanup from script when an error is trapped - # ARGS: $1 - Line number where error was trapped - # $2 - Line number in function - # $3 - Command executing at the time of the trap - # $4 - Names of all shell functions currently in the execution call stack - # $5 - Scriptname - # $6 - $BASH_SOURCE - # OUTS: None - - local line=${1:-} # LINENO - local linecallfunc=${2:-} - local command="${3:-}" - local funcstack="${4:-}" - local script="${5:-}" - local sourced="${6:-}" - - funcstack="'$(echo "$funcstack" | sed -E 's/ / < /g')'" - - if [[ ${script##*/} == "${sourced##*/}" ]]; then - fatal "${7:-} command: '${command}' (line: ${line}) [func: $(_functionStack_)]" + # DESC: + # Log errors and cleanup from script when an error is trapped. Called by 'trap' + # ARGS: + # $1: Line number where error was trapped + # $2: Line number in function + # $3: Command executing at the time of the trap + # $4: Names of all shell functions currently in the execution call stack + # $5: Scriptname + # $6: $BASH_SOURCE + # USAGE: + # trap '_trapCleanup_ ${LINENO} ${BASH_LINENO} "${BASH_COMMAND}" "${FUNCNAME[*]}" "${0}" "${BASH_SOURCE[0]}"' EXIT INT TERM SIGINT SIGQUIT SIGTERM + # OUTS: + # Exits script with error code 1 + + local _line=${1:-} # LINENO + local _linecallfunc=${2:-} + local _command="${3:-}" + local _funcstack="${4:-}" + local _script="${5:-}" + local _sourced="${6:-}" + + if [[ "$(declare -f "fatal")" && "$(declare -f "_printFuncStack_")" ]]; then + _funcstack="'$(echo "${_funcstack}" | sed -E 's/ / < /g')'" + if [[ ${_script##*/} == "${_sourced##*/}" ]]; then + fatal "${7:-} command: '${_command}' (line: ${_line}) [func: $(_printFuncStack_)]" + else + fatal "${7:-} command: '${_command}' (func: ${_funcstack} called at line ${_linecallfunc} of '${_script##*/}') (line: ${_line} of '${_sourced##*/}') " + fi else - fatal "${7:-} command: '${command}' (func: ${funcstack} called at line ${linecallfunc} of '${script##*/}') (line: $line of '${sourced##*/}') " + printf "%s\n" "Fatal error trapped. Exiting..." fi - _safeExit_ "1" + if [ "$(declare -f "_safeExit_")" ]; then + _safeExit_ 1 + else + exit 1 + fi } _makeTempDir_() { - # DESC: Creates a temp directory to house temporary files - # ARGS: $1 (Optional) - First characters/word of directory name - # OUTS: $TMP_DIR - Temporary directory - # USAGE: _makeTempDir_ "$(basename "$0")" + # DESC: + # Creates a temp directory to house temporary files + # ARGS: + # $1 (Optional) - First characters/word of directory name + # OUTS: + # Sets $TMP_DIR variable to the path of the temp directory + # USAGE: + # _makeTempDir_ "$(basename "$0")" [ -d "${TMP_DIR:-}" ] && return 0 if [ -n "${1:-}" ]; then - TMP_DIR="${TMPDIR:-/tmp/}${1}.$RANDOM.$RANDOM.$$" + TMP_DIR="${TMPDIR:-/tmp/}${1}.${RANDOM}.${RANDOM}.$$" else - TMP_DIR="${TMPDIR:-/tmp/}$(basename "$0").$RANDOM.$RANDOM.$RANDOM.$$" + TMP_DIR="${TMPDIR:-/tmp/}$(basename "$0").${RANDOM}.${RANDOM}.${RANDOM}.$$" fi (umask 077 && mkdir "${TMP_DIR}") || { fatal "Could not create temporary directory! Exiting." @@ -533,62 +712,142 @@ _makeTempDir_() { } _acquireScriptLock_() { - # DESC: Acquire script lock - # ARGS: $1 (optional) - Scope of script execution lock (system or user) - # OUTS: $SCRIPT_LOCK - Path to the directory indicating we have the script lock - # NOTE: This lock implementation is extremely simple but should be reliable - # across all platforms. It does *not* support locking a script with - # symlinks or multiple hardlinks as there's no portable way of doing so. - # If the lock was acquired it's automatically released in _safeExit_() - - local LOCK_DIR + # DESC: + # Acquire script lock to prevent running the same script a second time before the + # first instance exits + # ARGS: + # $1 (optional) - Scope of script execution lock (system or user) + # OUTS: + # exports $SCRIPT_LOCK - Path to the directory indicating we have the script lock + # Exits script if lock cannot be acquired + # NOTE: + # If the lock was acquired it's automatically released in _safeExit_() + + local _lockDir if [[ ${1:-} == 'system' ]]; then - LOCK_DIR="${TMPDIR:-/tmp/}$(basename "$0").lock" + _lockDir="${TMPDIR:-/tmp/}$(basename "$0").lock" else - LOCK_DIR="${TMPDIR:-/tmp/}$(basename "$0").$UID.lock" + _lockDir="${TMPDIR:-/tmp/}$(basename "$0").$UID.lock" fi if command mkdir "${LOCK_DIR}" 2>/dev/null; then - readonly SCRIPT_LOCK="${LOCK_DIR}" - debug "Acquired script lock: ${tan}${SCRIPT_LOCK}${purple}" + readonly SCRIPT_LOCK="${_lockDir}" + debug "Acquired script lock: ${yellow}${SCRIPT_LOCK}${purple}" else - error "Unable to acquire script lock: ${tan}${LOCK_DIR}${red}" - fatal "If you trust the script isn't running, delete the lock dir" + if [ "$(declare -f "_safeExit_")" ]; then + error "Unable to acquire script lock: ${tan}${LOCK_DIR}${red}" + fatal "If you trust the script isn't running, delete the lock dir" + else + printf "%s\n" "ERROR: Could not acquire script lock. If you trust the script isn't running, delete: ${LOCK_DIR}" + exit 1 + fi + fi } +_setPATH_() { + # DESC: + # Add directories to $PATH so script can find executables + # ARGS: + # $@ - One or more paths + # OUTS: Adds items to $PATH + # USAGE: + # _setPATH_ "/usr/local/bin" "${HOME}/bin" "$(npm bin)" + + [[ $# == 0 ]] && fatal "Missing required argument to ${FUNCNAME[0]}" + + local _newPath + + for _newPath in "$@"; do + if [ -d "${_newPath}" ]; then + if ! echo "${PATH}" | grep -Eq "(^|:)${_newPath}($|:)"; then + if PATH="${_newPath}:${PATH}"; then + debug "Added '${_newPath}' to PATH" + else + return 1 + fi + else + debug "_setPATH_: '${_newPath}' already exists in PATH" + fi + else + debug "_setPATH_: can not find: ${_newPath}" + return 0 + fi + done + return 0 +} + +_useGNUutils_() { + # DESC: + # Add GNU utilities to PATH to allow consistent use of sed/grep/tar/etc. on MacOS + # ARGS: + # None + # OUTS: + # 0 if successful + # 1 if unsuccessful + # PATH: Adds GNU utilities to the path + # USAGE: + # # if ! _useGNUUtils_; then exit 1; fi + # NOTES: + # GNU utilities can be added to MacOS using Homebrew + + [ ! "$(declare -f "_setPATH_")" ] && fatal "${FUNCNAME[0]} needs function _setPATH_" + + if _setPATH_ \ + "/usr/local/opt/gnu-tar/libexec/gnubin" \ + "/usr/local/opt/coreutils/libexec/gnubin" \ + "/usr/local/opt/gnu-sed/libexec/gnubin" \ + "/usr/local/opt/grep/libexec/gnubin"; then + return 0 + else + return 1 + fi + +} + _parseOptions_() { + # DESC: + # Iterates through options passed to script and sets variables. Will break -ab into -a -b + # when needed and --foo=bar into --foo bar + # ARGS: + # $@ from command line + # OUTS: + # Sets array 'ARGS' containing all arguments passed to script that were not parsed as options + # USAGE: + # _parseOptions_ "$@" + # Iterate over options - # breaking -ab into -a -b when needed and --foo=bar into --foo bar - optstring=h - unset options + local _optstring=h + declare -a _options + local _c + local i while (($#)); do case $1 in # If option is of type -ab -[!-]?*) # Loop over each character starting with the second for ((i = 1; i < ${#1}; i++)); do - c=${1:i:1} - options+=("-$c") # Add current char to options + _c=${1:i:1} + _options+=("-${_c}") # Add current char to options # If option takes a required argument, and it's not the last char make # the rest of the string its argument - if [[ $optstring == *"$c:"* && ${1:i+1} ]]; then - options+=("${1:i+1}") + if [[ ${_optstring} == *"${_c}:"* && ${1:i+1} ]]; then + _options+=("${1:i+1}") break fi done ;; # If option is of type --foo=bar - --?*=*) options+=("${1%%=*}" "${1#*=}") ;; + --?*=*) _options+=("${1%%=*}" "${1#*=}") ;; # add --endopts for -- - --) options+=(--endopts) ;; + --) _options+=(--endopts) ;; # Otherwise, nothing special - *) options+=("$1") ;; + *) _options+=("$1") ;; esac shift done - set -- "${options[@]:-}" - unset options + set -- "${_options[@]:-}" + unset _options # Read the options and set stuff while [[ ${1:-} == -?* ]]; do @@ -597,7 +856,7 @@ _parseOptions_() { # Common options -h | --help) - _usage_ >&2 + _usage_ _safeExit_ ;; --loglevel) @@ -616,15 +875,27 @@ _parseOptions_() { shift break ;; - *) fatal "invalid option: '$1'." ;; + *) + if [ "$(declare -f "_safeExit_")" ]; then + fatal "invalid option: $1" + else + printf "%s\n" "Invalid option: $1" + exit 1 + fi + ;; esac shift done - ARGS+=("$@") # Store the remaining user input as arguments. + + if [[ -z ${*} || ${*} == null ]]; then + ARGS=() + else + ARGS+=("$@") # Store the remaining user input as arguments. + fi } _usage_() { - cat <