#!/bin/zsh # shellcheck shell=bash disable=SC2016,SC2046,SC2048,SC2086,SC2116,SC2128,SC2154,SC2155,SC2190,SC2207,SC2248,SC2296,SC2298,SC2299,SC2312 # SC2016: Single quotes are used explicitly to ignore parameter expansion. # SC2046: shellcheck is too quote conservative. # SC2048: shellcheck is too quote conservative. # SC2086: All instances of params are quoted where they need to be. # SC2116: Not a "Useless echo", we need it to expand the params correctly. # SC2128: shellcheck thinks ${query} is an array because of a previous local declaration. # SC2154: Used but not assigned: these values are set by the shell. # SC2155: Ignoring all return values in assignments. # SC2190: ZSH: associative array assignment "just works"(TM). # SC2207: "prefer mapfile or read -a" these are all ZSH-isms. # SC2248: shellcheck is too quote conservative. # SC2296: ZSH permits bracketed parameter expansions starting with (). # SC2298: ZSH permits nested parameter expansions. # SC2299: ZSH permits nested parameter expansions. # SC2312: No need to invoke commands separately. ############################################################################## # # This is a "library" of zsh(1) functions used by the Broadcast Tool & Die # Z-Shell scripts. All scripts that need these support functions should # "source" this file. # # This script is NOT intended to be "run" from the command line. # ############################################################################## zmodload zsh/datetime # This library complies with Semantic Versioning: http://semver.org/ # SC2034: We use these variables outside of this file. # SC2016: We do not want $Hash$ to expand. # shellcheck disable=SC2034,SC2016 { vLibRelease=0 vLibMajor=0 vLibMinor=2 vLibPatch=27 vLibHash='$Hash$' } # zsh_library_version returns a string containing a "dotted quad" of this # library's version number. function zsh_library_version() { printf "%d.%d.%d.%d-%s\n" \ "${vLibRelease}" \ "${vLibMajor}" \ "${vLibMinor}" \ "${vLibPatch}" \ "${${${vLibHash#*Hash: }//\$/}/Hash/prerelease}" } # sendNotification use the configured mailer to send email to one or more # addresses. This prefers to use msmtp(1). It will use postfix(1)'s sendmail if # msmtp is not found and if /etc/postfix/main.cf exists and has a "relayhost" # parameter configured. # # sendNotification sends no more than one notification for each type in 24 hours # (and let the recipients know that they will see no more than one per day). function sendNotification() { local myName="${1:?Who am I?}" ; shift local MAILTO="${1:?Need one or more email addresses to send to.}" ; shift local type="${1:?Need a notification type.}" ; shift local message="${1:?Need a message to send for the notification.}" ; shift local messagefile="${1}" # optional text filename to attach local -i returnValue=1 local my_domain_name ############ BEGIN external shell commands used in this function. ############ # This function uses these 11 external commands. # Look for them in their upper case, parameter expanded form. our_commands=( awk base64 cat cut getent grep hostname id tee unix2dos uuidgen ) # Find the executables we need; this uses some basic shell and a ZSH trick: # the (U) in the eval says to evaluate the parameter as all upper case # letters. This snippet generates shell parameters representing the upper case # equivalent of the command names and sets the parameter values to the full path # of the commands. # Refresh this segment in Emacs by marking the appropriate region (or the whole # buffer with C-xH) and replacing it with C-uM-|mk-ourCommands --func (shell-command-on-region). local C D # SC2048: shellcheck overly aggressive quote recommendation. # shellcheck disable=SC2048 for C in ${our_commands[*]} ; do # shellcheck disable=SC2154 # ZSH: ${path} is set by the shell. for D in ${path} ; do # shellcheck disable=SC2140,SC2086,SC2296 # we need the quotes, ZSH-specific expansion [[ -x "${D}/${C}" ]] && { eval "${(U)C//-/_}"="${D}/${C}" ; break ; } done # shellcheck disable=SC2296 # ZSH-specific expansion [[ -x $(eval print \$"${(U)C//-/_}") ]] || { print "Cannot find ${C}! Done."; return 1 ; } done unset our_commands C D ############# END external shell commands used in this function. ############# local MAILER_CONFIG="${MAILER_CONFIG:-/usr/local/etc/btd/conf.msmtp}" local MAILER MAIL_FROM local -a MAILER_ARGS # Look for msmtp(1) if the MAILER_CONFIG exists *and* is readable. if [[ -f "${MAILER_CONFIG}" ]] && [[ -r "${MAILER_CONFIG}" ]] ; then MAILER=$(command -v msmtp) ; MAILER="${MAILER:-/usr/bin/msmtp}" if [[ -n "${MAILER}" ]] && [[ -x "${MAILER}" ]] ; then MAILER_ARGS=( --read-recipients --read-envelope-from --file "${MAILER_CONFIG}" ) fi MAIL_FROM=$(${AWK} '/^user /{print $2}' "${MAILER_CONFIG}") fi # Look for sendmail(1) and a standard working postfix(1) configuration if # unable to find msmtp(1). if [[ -z "${MAILER_ARGS[*]}" ]] ; then if [[ -d /etc/postfix ]] ; then if [[ -x /usr/sbin/sendmail ]] && ${GREP} --quiet '^relayhost' /etc/postfix/main.cf ; then MAILER=/usr/sbin/sendmail # See sendmail(1) for these args. MAILER_ARGS=( -bm -t ) my_domain_name=$(${AWK} -F' ?= ?' '/^mydomain /{print $2}' /etc/postfix/main.cf) fi fi fi # Bail if unable to find a working MTA. if [[ -z "${MAILER_ARGS[*]}" ]] ; then logit "${0}" 0 "Unable to find a working and configured message transfer agent (MTA). Unable to continue." return ${returnValue} fi local hostName="$(${HOSTNAME} -f)" local PRETEND="${PRETEND}" local startTime="${EPOCHSECONDS}" # A notification was already sent for this ${type}, no need to continue. if notificationSent "${myName}" "${type}" "${startTime}" ${NOTIFICATIONS_QUIET_TIME_DURATION:-$((60 * 60 * 24))} ; then return 0 fi # Ensure we have a "From" address. if [[ -z "${MAIL_FROM}" ]] ; then local my_user_name=$(${GETENT} passwd $(${ID} -u) | ${CUT} -d: -f1) MAIL_FROM="${my_user_name}@${my_domain_name}" fi ( # MAILTO parameter expansion turns spaces and commas into single comma # separating email addresses. ${UNIX2DOS} < X-Script-Name: ${myName} X-zsh-library-version: $(zsh_library_version) MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="----------${startTime}" This is a multi-part message in MIME format. ------------${startTime} Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit Sent from ${hostName}. ${message} $([[ -z "${NOTIFICATIONS_IGNORE_QUIET_TIME}" ]] && print "Note that you will see only one notification per day for this condition.\n") You may see all notifications (including those for which no email was sent) by opening a terminal window on ${hostName} and entering (or pasting) the command: journalctl _COMM=logger SYSLOG_IDENTIFIER=notificationSent EOF if [[ -n "${messagefile}" ]] && [[ -s "${messagefile}" ]] ; then ${UNIX2DOS} < ${notifyDB} ; then logit "${0}" 0 "${theirName} WARNING: Unable to CREATE '${type}' notification at $(strftime %F\ %T ${notificationTime}) (${?})." return 4 fi fi # Scan the database for an existing notification. logit "${0}" 0 "${theirName} NOTICE: '${type}' notification created at $(strftime %F\ %T ${notificationTime})." while read -r notification ; do if [[ "${notification%:*}" == "${theirName}:${type}" ]] ; then foundPriorNotification=1 break fi done < "${notifyDB}" if ! ((foundPriorNotification)) ; then # This is a new notification for this ${theirName} and ${type}, # append it to the database. if ! printf '%s:%s:%d\n' "${theirName}" "${type}" "${notificationTime}" >> "${notifyDB}" ; then logit "${0}" 0 "${theirName} WARNING: Unable to APPEND '${type}' notification at $(strftime %F\ %T ${notificationTime}) (${?})." return 3 fi logit "${0}" 0 "${theirName} NOTICE: '${type}' notification added at $(strftime %F\ %T ${notificationTime})." return fi # This is an additional ${type} of notification for ${theirName}. if (( (notificationTime - quiet_time_duration) > ${notification##*:} )) || [[ -n "${NOTIFICATIONS_IGNORE_QUIET_TIME}" ]]; then # It has been longer than ${quiet_time_duration} since we last # notified, or are we ignoring quiet times. (Set shell parameter # NOTIFICATIONS_IGNORE_QUIET_TIME to any value to ignore "quiet # time" to send the notification.) if ${SED} -i -e "s/^${theirName}:${type}:.*$/${theirName}:${type}:${notificationTime}/" "${notifyDB}" ; then logit "${0}" 0 "${theirName} NOTICE: '${type}' notification UPDATED at $(strftime %F\ %T ${notificationTime})." else logit "${0}" 0 "${theirName} WARNING: Unable to UPDATE '${type}' notification at $(strftime %F\ %T ${notificationTime}) (${?})." return 2 fi else # It has been less than ${quiet_time_duration} since we last # notified, or we are honoring quiet time: we are done here. logit "${0}" 0 "${theirName} NOTICE: quiet time for notification '${type}' at $(strftime %F\ %T ${notificationTime}) ($(strftime %F\ %T ${notification##*:}))." fi return "${returnValue}" } # doSQL performs queries using the mysql(1) CLI command. doSQL makes mysql calls # look a bit more asthetically pleasing. The return value of this shell funtion # is the exit value of the mysql command invocation. function doSQL() { local -r statement="${1:?Need a database query to run.}" local useMyCNF=0 local -r usableMyCNF=4 local -r rdconfig="${RD_CONFIG:-/etc/rd.conf}" ############ BEGIN external shell commands used in this function. ############ # This function uses these 4 external commands. # Look for them in their upper case, parameter expanded form. typeset -a our_commands our_commands=( awk grep mysql sed ) # Find the executables we need; this uses some basic shell and a ZSH trick: # the (U) in the eval says to evaluate the parameter as all upper case # letters. This snippet generates shell parameters representing the upper case # equivalent of the command names and sets the parameter values to the full path # of the commands. # Refresh this segment in Emacs by marking the appropriate region (or the whole # buffer with C-xH) and replacing it with C-uM-|mk-ourCommands (shell-command-on-region). for C in ${our_commands[*]} ; do # shellcheck disable=SC2154 # ZSH: ${path} is set by the shell. for D in ${path} ; do # shellcheck disable=SC2140,SC2086 # we need the quotes [[ -x "${D}/${C}" ]] && { eval "${(U)C//-/_}"="${D}/${C}" ; break ; } done [[ -x $(eval print \$"${(U)C//-/_}") ]] || { print "Cannot find ${C}! Done."; return 1 ; } done unset our_commands ############# END external shell commands used in this function. ############# typeset -gx _DB_HOST _DB_USER _DB_PWD _DB_DATABASE _USE_MYCNF zmodload zsh/mapfile # Use the exported variables if we have been through this function # already. This applies to each running instance of scripts that # use this function. This helps prevent the need to look this # stuff up every time this function is called. if [[ -z "${_DB_HOST}" ]] ; then # Rivendell DB Details: Use credentials in ~/.my.cnf if it exists, # else get credentials from rd.conf if it exists, else from the # environment, else use defaults here. # BUG ALERT: this assumes the credentials in .my.cnf are relevant # to the Rivendell database. if [[ -r ~/.my.cnf ]] && [[ $(${GREP} -E -c '^(database|host|user|password)' ~/.my.cnf) -ge 4 ]] ; then # SC2164: This instance of "cd" is failsafe. # shellcheck disable=SC2164 cd -q # Sigh, mapfile cannot deal with path components. # SC2206: ZSH-ism # shellcheck disable=SC2206 myCnfLines=( ${mapfile[.my.cnf]} ) # Need to check for each of these parameters in .my.cnf in # order to be able to use it. for parm in database host password user ; do if echo ${myCnfLines[*]} | ${GREP} --quiet --ignore-case "${parm}" ; then (( useMyCNF++ )) fi done else # Horribly insecure, but this is the "Rivendell Way". if [[ -r "${rdconfig}" ]] ; then DB_HOST=$(${SED} -e '1,/^\[mySQL\]$/d' -e '/^\[/,$d' "${rdconfig}" | ${AWK} -F'=' '/^Hostname=/{print $2}') DB_USER=$(${SED} -e '1,/^\[mySQL\]$/d' -e '/^\[/,$d' "${rdconfig}" | ${AWK} -F'=' '/^Loginname=/{print $2}') DB_PASSWORD=$(${SED} -e '1,/^\[mySQL\]$/d' -e '/^\[/,$d' "${rdconfig}" | ${AWK} -F'=' '/^Password=/{print $2}') DB_DATABASE=$(${SED} -e '1,/^\[mySQL\]$/d' -e '/^\[/,$d' "${rdconfig}" | ${AWK} -F'=' '/^Database=/{print $2}') else # Last ditch effort to set the MySQL access credentials. # These are the "conventional" defaults that might otherwise # exist in /etc/rd.conf (and friends). DB_HOST="${RD_DB_HOST:-localhost}" DB_USER="${RD_DB_USER:-rduser}" DB_PASSWORD="${RD_DB_PASS:-letmein}" DB_DATABASE="${RD_DB_DATABASE:-Rivendell}" fi fi _DB_HOST="${DB_HOST}" _DB_USER="${DB_USER}" _DB_PWD="${DB_PASSWORD}" _DB_DATABASE="${DB_DATABASE}" _USE_MYCNF="${useMyCNF}" fi if (( _USE_MYCNF == usableMyCNF )) ; then ${MYSQL} -s -N -e "${statement}" else ${MYSQL} -s -N -B -u "${_DB_USER}" -p"${_DB_PWD}" -h "${_DB_HOST}" "${_DB_DATABASE}" -e "${statement}" fi } # rdDatabaseVersion returns the DB column from the Rivendell VERSION table. function rdDatabaseVersion() { doSQL "select DB from VERSION" } # getMyIPAddresses returns a string containing a Space-separated list of IP # addresses for this computer. The algorithm excludes IP addresses for the # loopback interface and virtual interfaces matching "virbr" or "tun". function getMyIPAddresses() { local -a ipAddresses local returnValue=0 ############ BEGIN external shell commands used in this function. ############ # This function uses these 4 external commands. # Look for them in their upper case, parameter expanded form. typeset -a our_commands our_commands=( awk grep ip sed ) # Find the executables we need; this uses some basic shell and a ZSH trick: # the (U) in the eval says to evaluate the parameter as all upper case # letters. This snippet generates shell parameters representing the upper case # equivalent of the command names and sets the parameter values to the full path # of the commands. # Refresh this segment in Emacs by marking the appropriate region (or the whole # buffer with C-xH) and replacing it with C-uM-|mk-ourCommands (shell-command-on-region). for C in ${our_commands[*]} ; do # shellcheck disable=SC2154 # ZSH: ${path} is set by the shell. for D in ${path} ; do # shellcheck disable=SC2140,SC2086 # we need the quotes [[ -x "${D}/${C}" ]] && { eval "${(U)C//-/_}"="${D}/${C}" ; break ; } done [[ -x $(eval print \$"${(U)C//-/_}") ]] || { print "Cannot find ${C}! Done."; return 1 ; } done unset our_commands ############# END external shell commands used in this function. ############# ipAddresses=( $(${IP} -o -4 addr show | ${GREP} -E -v '^(lo|virbr|tun)' | ${AWK} '{print $4}' | ${SED} -r -e 's,/[[:digit:]]+$,,') ) if (( ${#ipAddresses} )) ; then echo ${(j: :)ipAddresses} fi return "${returnValue}" } # rdGetStationName returns this hosts Rivendell STATION name. Return TRUE (Zero) # or FALSE (non-Zero) depending on whether the database query is successful. function rdGetStationName() { local -a myIP local stationName local returnValue=0 local HOSTNAME=$(command -v hostname) ; HOSTNAME="${HOSTNAME:-/bin/hostname}" local myHostname="$(${HOSTNAME} -s)" if ! okDatabaseStructure STATIONS:name+ipv4_address ; then echo "This version of ${0} is not compatible with Rivendell database version '$(rdDatabaseVersion)'." return 128 fi # Prefer an exact hostname match. stationName=$(doSQL "select NAME from STATIONS where NAME = '${myHostname}'") # Otherwise try all our relevant IP addresses. if [[ -z "${stationName}" ]] ; then for myIP in $(getMyIPAddresses) ; do stationName=$(doSQL "select NAME from STATIONS where IPV4_ADDRESS = '${myIP}'") [[ -n "${stationName}" ]] && { break ; } done if [[ -z "${stationName}" ]] ; then # If no match on the IP address, try a fuzzier hostname match. stationName=$(doSQL "select NAME from STATIONS where NAME like '%${myHostname}%'") if [[ -z "${stationName}" ]] ; then logit "${0}" 1 "Cannot determine my Rivendell 'STATION' name. Better fix that before continuing." returnValue=1 fi fi fi [[ -n "${stationName}" ]] && echo "${stationName}" return "${returnValue}" } # rdGetWebServer returns the hostname of the Rivendell Web Server (HTTP_STATION) # for this computer. function rdGetWebServer() { local returnValue=1 if ! okDatabaseStructure STATIONS:name+http_station ; then echo "This version of ${0} is not compatible with Rivendell database version '$(rdDatabaseVersion)'." return 128 fi if myStationName=$(rdGetStationName) ; then if myHTTPStationName=$(doSQL "select HTTP_STATION from STATIONS where NAME = '${myStationName}'") ; then returnValue=0 echo "${myHTTPStationName}" else logit "${0}" 1 "Could not determine the Rivendell Web Server for this workstation ('${myStationName}')." fi else logit "${0}" 1 "Could not determine my host name." fi return "${returnValue}" } # rdWebInvoke uses curl(1) and the Rivendell Web API to execute a command. # Unlike most other functions in this library, the arg 'verbose' *must* be set # to either '0' or a positive integer. function rdWebInvoke() { local verbose="${1}" ; shift local command="${1}" ; shift local -a curlArguments # SC2206: ZSH-ism # shellcheck disable=SC2206 curlArguments=( ${*} ) local CURL=$(command -v curl) ; CURL="${CURL:-/usr/bin/curl}" local curlVerbosity local -r httpServer="$(rdGetWebServer)" local -a response local responseLine result c local headerCount=1 local returnValue=1 local -r oIFS="${IFS}" if (( verbose )) ; then curlVerbosity="--verbose" else curlVerbosity="--silent" fi # We want each line as its own array element, so set IFS # accordingly. IFS=' ' # Invoke the curl(1) utility with a POST to the server and each "-d" # argument containing one attribute/value pair. We --include the # headers in the curl output so we can determine the HTTP response. # BUG ALERT: this code depends on the Rivendell username 'user' # existing with NO password. response=( $(${CURL} --include "${curlVerbosity}" -d LOGIN_NAME='user' -d PASSWORD='' -d "COMMAND=${command}" ${curlArguments:+'-d'} ${(zj: -d :)curlArguments} "http://${httpServer}/rd-bin/rdxport.cgi") ) returnValue="${?}" while read -r responseLine ; do if echo "${responseLine}" | grep --quiet --perl-regexp --ignore-case '^http/\d+\.\d+ 200 ok' ; then result=OK break elif [[ "${responseLine}" = 'ErrorString' ]] ; then # Strip the XML tag and extract just the "error string". result="${${responseLine%*}#*}" break fi done <<<"${response[*]}" # Calculate the end of the HTTP Response Header and strip if from # the output of curl(1). We use the fact that the HTTP Response # Header is separated from the command output by a blank line (an # empty array element). # SC2051: ZSH *does* support variables inside brace expansion. # shellcheck disable=SC2051 for c in {1..${#response}} ; do [[ -z "${${(f)response[c]}[2]}" ]] && { (( headerCount=c )) ; break ; } done shift ${headerCount} response if [[ "${result:-MISSING RESULT}" = 'OK' ]] ; then returnValue=0 echo "${response[*]}" fi IFS="${oIFS}" return "${returnValue}" } # rdListAllCarts uses the Rivendell Web API to list the cart and cut details for # the Carts in the provided Group name. # # Note that as of Rivendell version 2.10.3 ListCarts is broken if you # do not provide search parameters to the "query". This is fixed in # 2.15.1, but for now we *REQUIRE* a GROUP in which to list all CARTs. function rdListAllCarts() { local group="${1}" ; shift local verbose="${1:-0}" # List cart: Command 6: ListCarts local command=6 local -a curlArguments local -a result local returnValue curlArguments=("GROUP_NAME=${group}") result=$(rdWebInvoke "${verbose}" "${command}" ${curlArguments[*]}) returnValue="${?}" echo "${result}" return "${returnValue}" } # rdListCart uses the Rivendell Web API to list the Cart with the provided # number. Optionally, rdListCart includes the Cuts if the second argument, is 1 # (default 0). function rdListCart() { local cartNumber="${1}" ; shift local withCuts="${1:-0}" local verbose="${2:-0}" local command=7 local -a curlArguments local -a result local returnValue # List cart: Command 7: ListCart curlArguments=( "CART_NUMBER=${cartNumber}" "INCLUDE_CUTS=${withCuts}" ) result=$(rdWebInvoke "${verbose}" "${command}" ${curlArguments[*]}) returnValue="${?}" echo "${result}" return "${returnValue}" } # rdListCuts uses the Rivendell Web API to list all the Cuts for the specified # Cart. function rdListCuts() { local cartNumber="${1}" ; shift local verbose="${1:-0}" local command=9 local -a curlArguments local result local returnValue # List cuts: Command 9: ListCuts # BUG ALERT: this code depends on the Rivendell username 'user' existing with NO password. curlArguments=( "CART_NUMBER=${cartNumber}" ) result=$(rdWebInvoke "${verbose}" "${command}" ${curlArguments[*]}) returnValue="${?}" echo "${result}" return "${returnValue}" } # rdListCut uses the Rivendell Web API to list the contents of the named # Cart and Cut. function rdListCut() { local -r cart_number="${1}" shift local -r cut_number="${1}" shift local -r verbose="${1:-0}" local -r command=8 local -a curlArguments local result local returnValue # Command 22: ListLog, NAME: The name of the log curlArguments=( "CART_NUMBER=${cart_number}" "CUT_NUMBER=${cut_number}" ) result=$(rdWebInvoke "${verbose}" "${command}" ${curlArguments[*]}) returnValue="${?}" echo "${result}" return "${returnValue}" } # rdDropCart uses the Rivendell Web API to delete the Cart, all its Cuts, and # the actual audio file for the specified Cart. function rdDropCart() { local cartNumber="${1}" ; shift local verbose="${1:-0}" local command=13 local -a curlArguments local result local returnValue # Delete this cart (and all its cuts): Command 13: RemoveCart # BUG ALERT: this code depends on the Rivendell username 'user' existing with NO password. curlArguments=( "CART_NUMBER=${cartNumber}" ) result=$(rdWebInvoke "${verbose}" "${command}" ${curlArguments[*]}) returnValue="${?}" echo "${result}" return "${returnValue}" } # rdDropCut uses the Rivendell Web API to delete the specified Cut, and the # actual audio file. function rdDropCut() { local cartNumber="${1}" ; shift local cutNumber="${1}" ; shift local verbose="${1:-0}" local command=11 local -a curlArguments local response result local returnValue # Delete this cut: Command 11: RemoveCut curlArguments=( "CART_NUMBER=${cartNumber}" "CUT_NUMBER=${cutNumber}" ) result=( $(rdWebInvoke "${verbose}" "${command}" ${curlArguments[*]}) ) returnValue="${?}" [[ "${result:-MISSING RESULT}" = 'OK' ]] && returnValue=0 echo "${result:-MISSING RESULT}" return "${returnValue}" } # rdListServices use the Rivendell Web API to list all the Services. Optionally, # rdListServices lists only Services that have a valid VoiceTracking # configuration. function rdListServices() { local -r trackable="${1}" ; shift local -r verbose="${1:-0}" local -r command=21 local -a curlArguments local result local returnValue # List Services: Command 21: ListServices # BUG ALERT: this code depends on the Rivendell username 'user' existing with NO password. curlArguments=( "TRACKABLE=${trackable}" ) result=$(rdWebInvoke "${verbose}" "${command}" ${curlArguments[*]}) returnValue="${?}" echo "${result}" return "${returnValue}" } # rdListLog uses the Rivendell Web API to list all the contents of the named # Log. function rdListLog() { local -r log_name="${1}" shift local -r verbose="${1:-0}" local -r command=22 local -a curlArguments local result local returnValue # Command 22: ListLog, NAME: The name of the log curlArguments=( "NAME=${log_name}" ) result=$(rdWebInvoke "${verbose}" "${command}" ${curlArguments[*]}) returnValue="${?}" echo "${result}" return "${returnValue}" } # rdListGroups uses the Rivendell Web API to list all the Groups. function rdListGroups() { local -r verbose="${1:-0}" local -r command=4 local -a curlArguments local result local returnValue # List cuts: Command 9: ListCuts # BUG ALERT: this code depends on the Rivendell username 'user' existing with NO password. curlArguments=() result=$(rdWebInvoke "${verbose}" "${command}" ${curlArguments[*]}) returnValue="${?}" echo "${result}" return "${returnValue}" } # rdCartNumberFromTitle returns the Rivendell Cart number from the provided # title. The search in this function is for an exact match of the provided # title. See also, rdCartTitleFromPartial below. function rdCartNumberFromTitle() { local title="${1:?Need a CART TITLE string to search.}" ; shift local verbose="${1}" doSQL "select NUMBER from CART where TITLE = '${title}'" } # rdCartTitleFromPartial returns the Rivendell Cart title from the provided # partial title. Return value is that of doSQL(). function rdCartTitleFromPartial() { local title="${1:?Need a CART TITLE string to search.}" ; shift local verbose="${1}" doSQL "select TITLE from CART where TITLE like '%${title}%'" } # rdCartTitleFromNumber returns the Rivendell Cart title for the provided Cart # number. function rdCartTitleFromNumber() { local number="${1:?Need a CART NUMBER to look up.}" ; shift local verbose="${1}" doSQL "select TITLE from CART where NUMBER = ${number}" } # rdCartGroupFromNumber returns the Group name for the provided Cart number. function rdCartGroupFromNumber() { local number="${1:?Need a CART NUMBER to search.}" ; shift local verbose="${1}" doSQL "select GROUP_NAME from CART where NUMBER = ${number}" } # rdMaxCartNumberForGroup returns the highest Cart number for the provided # Group. function rdMaxCartNumberForGroup() { local group="${1:?Need a GROUP_NAME from which to get the next CART NUMBER.}" ; shift local verbose="${1}" if ! okDatabaseStructure GROUPS:name+default_high_cart "${verbose}" ; then echo "This version of ${0} is not compatible with Rivendell database version '$(rdDatabaseVersion)'." return 128 fi local maxCartNum=$(doSQL "select DEFAULT_HIGH_CART from GROUPS where NAME = '${group}'") echo "${maxCartNum}" } # rdGetNextCartNumber returns the next available Cart number in the provided # Group. function rdGetNextCartNumber() { local group="${1:?Need a GROUP_NAME from which to get the next CART NUMBER.}" ; shift local verbose="${1}" local currentCartNumber local returnValue=0 local defaultLowCart defaultHighCart nextAvailable nextCartNum useThisCartNumber local lastHigh=0 if ! okDatabaseStructure GROUPS:name+default_low_cart+default_high_cart,CART:number "${verbose}" ; then echo "This version of ${0} is not compatible with Rivendell database version '$(rdDatabaseVersion)'." return 128 fi # Get the next available cart number from the group if the group is # constrained with default low and default high numbers. read -r defaultLowCart defaultHighCart <<<$(doSQL "select DEFAULT_LOW_CART,DEFAULT_HIGH_CART from GROUPS where NAME = '${group}'") (( verbose )) && echo "defaultLowCart: ${defaultLowCart}, defaultHighCart: ${defaultHighCart}" >&2 if (( defaultLowCart )) ; then nextAvailable="${defaultLowCart}" while (( nextAvailable < defaultHighCart )) ; do # Try to find an unused cart number starting at # DEFAULT_LOW_CART. currentCartNumber=$(doSQL "select NUMBER from CART where NUMBER = ${nextAvailable}") if (( currentCartNumber )) ; then (( nextAvailable++ )) else useThisCartNumber="${nextAvailable}" break fi done if (( nextAvailable == defaultHighCart )) ; then # Return an error because we hit the end of the group range and # did not find a free CART number. useThisCartNumber=-1 returnValue=1 fi else # No default low cart in this group (i.e., there is no number # range for this group), so find the next available cart number # outside all group ranges. doSQL 'select DEFAULT_LOW_CART,DEFAULT_HIGH_CART from GROUPS where DEFAULT_LOW_CART > 0 order by DEFAULT_LOW_CART' | while read -r defaultLowCart defaultHighCart ; do # Skip this group if its range falls within a previous group # range (yes, this can happen). (( defaultLowCart < lastHigh )) && continue if (( nextAvailable > lastHigh && nextAvailable < defaultLowCart )) ; then # This query returns NULL if there are no assigned carts in # this range, else the highest assigned cart number in the # range. nextCartNum=$(doSQL "select max(NUMBER) from CART where NUMBER >= ${nextAvailable} and NUMBER < ${defaultLowCart}") if [[ -z "${nextCartNum}" ]] || [[ "${nextCartNum}" = 'NULL' ]] ; then # SC2030: ZSH: This is not modified in a subshell. # shellcheck disable=SC2030 useThisCartNumber="${nextAvailable}" break fi else # SC2030: ZSH: This is not modified in a subshell. # shellcheck disable=SC2030 (( nextAvailable = defaultHighCart + 1 )) fi lastHigh="${defaultHighCart}" done fi # SC2031: ZSH: This was not modified in a subshell. # shellcheck disable=SC2031 if (( useThisCartNumber > 0 )) ; then echo "${useThisCartNumber}" else echo "${0}: ERROR: Cannot find the next CART number for GROUP '${group}' (MAX=$(rdMaxCartNumberForGroup ${group}), Next=${nextAvailable})." >&2 fi return "${returnValue}" } # rdCreateEmptyCart creates an empty Cart in the Rivendell Library. function rdCreateEmptyCart() { local group="${1:?Need to specify a GROUP_NAME in which to create the new CART.}" ; shift local title="${1:?Need a CART TITLE string to search.}" ; shift local verbose="${1}" local -a query local newCartNumber local returnValue=0 if ! okDatabaseStructure CART:number+type+group_name+title+cut_quantity "${verbose}" ; then echo "This version of ${0} is not compatible with Rivendell database version '$(rdDatabaseVersion)'." return 128 fi if newCartNumber=$(rdGetNextCartNumber "${group}") ; then if (( newCartNumber > 0 )) ; then # Place the query clauses into an array, mostly for visibility. query=( "insert into CART" "(NUMBER, TYPE, GROUP_NAME, TITLE, CUT_QUANTITY)" "values" "(${newCartNumber}, 1, '${group}', '${title}', 0)" ) doSQL "${(j: :)query}" echo "${newCartNumber}" else echo "${0}: ERROR: Could not create a new CART ('${title}') in GROUP '${group}'." >&2 returnValue=1 fi fi return "${returnValue}" } # rdDropboxStatus determines the dropbox status for all dropboxes on this host. function rdDropboxStatus() { local -r myName="${1}" ; shift local -r interactive="${1}" ; shift local -r verbose="${1}" ############ BEGIN external shell commands used in this function. ############ # This function uses these 8 external commands. # Look for them in their upper case, parameter expanded form. local -a our_commands our_commands=( awk hostname kill pidof ps sed sendusr1 sudo ) # Find the executables we need; this uses some basic shell and a ZSH trick: # the (U) in the eval says to evaluate the parameter as all upper case # letters. This snippet generates shell parameters representing the upper case # equivalent of the command names and sets the parameter values to the full path # of the commands. # Refresh this segment in Emacs by marking the appropriate region (or the whole # buffer with C-xH) and replacing it with C-uM-|mk-ourCommands (shell-command-on-region). local C D for C in ${our_commands[*]} ; do # shellcheck disable=SC2154 # ZSH: ${path} is set by the shell. for D in ${path} ; do # shellcheck disable=SC2140,SC2086 # we need the quotes [[ -x "${D}/${C}" ]] && { eval "${(U)C//-/_}"="${D}/${C}" ; break ; } done [[ -x $(eval print \$"${(U)C//-/_}") ]] || { print "Cannot find ${C}! Done."; return 1 ; } done unset our_commands C D ############# END external shell commands used in this function. ############# local -a dropboxIDs local -a rdimportIDs local -a dropboxesNotRunning local -a extraneousRdimportIDs local -A rdimportPIDs local dropboxID dropboxPath if ! okDatabaseStructure DROPBOXES:id+station_name+path "${verbose}" ; then echo "This version of ${0} is not compatible with Rivendell database version '$(rdDatabaseVersion)'." return 128 fi # We are looking for dropboxes on *this* host. dropboxIDs=( $(doSQL "select ID from DROPBOXES where STATION_NAME = '$(${HOSTNAME} -s)' order by ID") ) # We better have running rdimport processes if we have configured # dropboxes. # SC1009: shellcheck cannot handle this, it seems to be a ZSH-ism # shellcheck disable=SC1009 if ((${#dropboxIDs})) ; then # The list of dropbox IDs for all currently running rdimport # processes. rdimportIDs=( $(${PS} ax --format 'args' | ${AWK} '/^\/usr\/bin\/rdimport\s?/{print $2}' | ${SED} -e 's,--persistent-dropbox-id=,,') ) # The same list *with* process IDs in an asssociatve arrray, # indexed by dropbox ID. rdimportPIDs=( $(${PS} ax --format 'pid,args' | ${AWK} '/^[[:digit:]]{1,}\s\/(usr\/)?bin\/rdimport\s?/{print $3 " " $1}' | ${SED} -e 's,--persistent-dropbox-id=,,') ) # Get both the list of missing rdimport process IDs and the list # of extraneous rdimport process IDs. # SC2206: Because this are ZSH arrays, I *want* words to be split. # shellcheck disable=SC2206 dropboxesNotRunning=( ${dropboxIDs:|rdimportIDs} ) # shellcheck disable=SC2206 extraneousRdimportIDs=( ${rdimportIDs:|dropboxIDs} ) if [[ -n "${extraneousRdimportIDs[*]}" ]] ; then local -r es=$( ((${#extraneousRdimportIDs[*]} > 1)) && printf 'es') local -r is_are=$( ((${#extraneousRdimportIDs[*]} > 1)) && printf 'is' || printf 'are') local -i d print "There ${is_are} ${#extraneousRdimportIDs[*]} 'zombie' dropbox${es} running." if ((interactive)) && getYesNo "Would you like to attempt to terminate the process${es} now?" ; then print "If prompted, type the password for '${USER}' (it will not be displayed for security purposes)" for d in ${extraneousRdimportIDs[*]} ; do ${SUDO} ${KILL} ${rdimportPIDs[${d}]} done fi fi if [[ -z "${dropboxesNotRunning[*]}" ]] ; then logit "${myName}" "${interactive}" "Yay! All ${#dropboxIDs[*]} Rivendell dropboxes are currently active and running." return fi # We have more configured dropboxes than running instances of # rdimport. TODO: deal with more running rdimport processes than # configured dropboxes. for dropboxID in ${dropboxesNotRunning[*]} ; do dropboxPath=$(doSQL "select PATH from DROPBOXES where ID=${dropboxID}") logit ${myName} ${interactive} "$(printf 'Dropbox ID %d (path spec %s) is not running. Will reset this dropbox.' ${dropboxID} ${dropboxPath})" rdResetDropbox "${myName}" ${dropboxID} ${interactive} ${verbose} done if rdservicePID=$(${PIDOF} 'rdservice') ; then if ! ${SENDUSR1} "${rdservicePID}" > /dev/null ; then logit ${myName} ${interactive} "Unable to send SIGUSR1 to rdservice. Please contact someone who can help." fi else logit ${myName} ${interactive} "Unable to discern the process ID of 'rdservice'. Please seek help from a higher authority..." fi else logit "${myName}" "${interactive}" "Found zero (0) dropboxes on this Rivendell workstation ('$(${HOSTNAME} -s)')." fi } # rdGetDropboxIDFromPath returns a DROPBOX numeric ID from the given path. # Parameters: # - the name of the caller # - a string representing a path prefix # - a boolean (0 or 1) indicating interactive # Returns: true (0) if the database structure is OK, else false # (non-zero) function rdGetDropboxIDFromPath() { local myName="${1}" ; shift local pathPrefix="${1}" ; shift local interactive="${1}" ; shift local verbose="${1}" local returnValue=1 # SC2178: "Variable was used as an array but is now assigned a string.", but in a *different* function! # shellcheck disable=SC2178 local query="select ID from DROPBOXES where PATH like '${pathPrefix}%'" local -a id if ! okDatabaseStructure DROPBOXES:id+path "${verbose}" ; then echo "This version of ${0} is not compatible with Rivendell database version '$(rdDatabaseVersion)'." return 128 fi # The query *should* return exactly one dropbox ID; deal with it # gracefully if it returns more than one. id=( $(doSQL "${query}") ) if (( ${#id} > 1 )) ; then logit "${myName}" "${interactive}" "Unable to determine the exact DROPBOX ID for ${pathPrefix}. Make sure this is a unique path." else if ((id[1])) ; then echo "${id[1]}" returnValue=0 else echo -1 fi fi return "${returnValue}" } # rdResetDropbox resets a dropbox by deleting entries in DROPBOX_PATHS. See also # rivendell-source-code/rdadmin/edit_dropbox.cpp:EditDropbox::resetData(). # shellcheck disable=SC2178 function rdResetDropbox() { local -r myName="${1}" ; shift local -r dropbox_id="${1}" ; shift local -r interactive="${1}" ; shift local -r verbose="${1}" # SC2178: "Variable was used as an array but is now assigned a # string.", but in a *different* function! # shellcheck disable=SC2178 local query="delete from DROPBOX_PATHS where DROPBOX_ID = ${dropbox_id}" if [[ -z "${dropbox_id}" ]] ; then logit "${myName}" "${interactive}" "Need a dropbox ID number to reset." return 1 fi if ! okDatabaseStructure DROPBOX_PATHS:dropbox_id "${verbose}" ; then echo "This version of ${0} is not compatible with Rivendell database version '$(rdDatabaseVersion)'." return 2 fi doSQL "${query}" return } # okDatabaseStructure determines whether the database structure matches our # expectation. # Parameters: # - comma-separated list of table:column[+column...] tuples # - (optional) verbose boolean # Returns: true (0) if the database structure is OK, false (1) if the # table does not exist, or false (2) if a column is not found in the # table. function okDatabaseStructure() { local -r tablesAndColumns="${1}" ; shift local -r verbose="${1:-0}" local schema # We need to make sure the Internal Field Separator (IFS) is a # for the command substitution below. local -r oIFS="${IFS}" ; IFS=' ' # Split the comma-separated list of # table1:colum+column+column,table2:column+column... into this ZSH # associative array. local -A tables tables=( $(echo ${${tablesAndColumns//,/ }//:/ }) ) # Check for the existence of each of these columns in these tables. for table in $(echo ${(k)tables}) ; do if ! schema=$(doSQL "SHOW CREATE TABLE \`${table}\`\\G" 2>/dev/null) ; then IFS="${oIFS}" ((verbose)) && echo "okDatabaseStructure: ERROR: unable to get a schema for table '${table}'" >&2 return 1 fi for column in $(echo ${tables[${table}]//+/ }) ; do if ! echo "${schema}" | grep --quiet --perl-regexp --ignore-case "\s+\`${(U)column}\`\s+" ; then IFS="${oIFS}" ((verbose)) && echo "okDatabaseStructure: ERROR: unable to find column '${column}' in table '${table}'" >&2 return 2 fi done done IFS="${oIFS}" return } # getYesNo() issues the given prompt on /dev/tty until the response # resembles an affirmative or a negative response. # Returns 0 ("success") for a "yes"-like response and 1 # ("non-success") for a "no"-like response to the given prompt. function getYesNo() { local -r our_prompt="${1}" local -i again=0 local response='' until [[ "${(L)response}" =~ ^(no*|y(es)*)$ ]] ; do ((again)) && print "Please respond with 'yes' (or 'y') or 'no' (or 'n')." > /dev/tty print "${our_prompt} [y or n] \c" > /dev/tty # response is actually used, but in a non-Bash way, see below. # shellcheck disable=SC2034 read -r response < /dev/tty ((again=1)) done [[ "${(L)response}" =~ ^n ]] && return 1 return 0 } # ms2HMS converts milliseconds to [HH:]MM:SS. function ms2HMS() { local milliseconds="${1:?Need some milliseconds}" typeset -Z 2 Hours Minutes Seconds (( Hours=milliseconds / 1000 / 60 / 60 % 24 )) (( Hours == 0 )) && unset Hours (( Minutes=milliseconds / 1000 / 60 % 60 )) (( Seconds=milliseconds / 1000 % 60 )) print "${Hours:+${Hours}:}${Minutes:+${Minutes}:}${Seconds}" } # logit logs a message to either syslog (non-interactive) or STDOUT # (interactive). function logit() { local caller="${1}" ; shift local interactive="${1}" ; shift local message="${1}" local LOGGER=$(command -v logger) ; LOGGER="${LOGGER:-/usr/bin/logger}" if (( interactive )) ; then print "${message}" >&2 else ${LOGGER} -t "${caller}" -p local7.notice -i "${message}" fi } # Local Variables: *** # mode:shell-script *** # indent-tabs-mode: f *** # sh-indentation: 2 *** # sh-basic-offset: 2 *** # sh-indent-for-do: 0 *** # sh-indent-after-do: + *** # sh-indent-comment: t *** # sh-indent-after-case: + *** # sh-indent-after-done: 0 *** # sh-indent-after-else: + *** # sh-indent-after-if: + *** # sh-indent-after-loop-construct: + *** # sh-indent-after-open: + *** # sh-indent-after-switch: + *** # sh-indent-for-case-alt: ++ *** # sh-indent-for-case-label: + *** # sh-indent-for-continuation: + *** # sh-indent-for-done: 0 *** # sh-indent-for-else: 0 *** # sh-indent-for-fi: 0 *** # sh-indent-for-then: 0 *** # End: ***