#!/bin/bash
# Collect a day's XiVO logs into a tarball; from a xivo node, also gather every remote node over SSH.

set -u

readonly progname="${0##*/}"

readonly SYSTEM_LOGS="/var/log/syslog"

readonly XIVO_LOGS="/var/log/asterisk/full
/var/log/nginx/xivo.access.log
/var/log/xivo-agentd.log
/var/log/xivo-ctid/xivo-ctid.log
/var/log/xivo-web-interface/xivo.log"

readonly XDS_LOGS="/var/log/asterisk/full"

readonly CC_LOGS="/var/log/xivocc/xuc/xuc.log
/var/log/xivocc/recording-server/recording-server.log"

readonly CC_OPTIONAL_LOGS="/var/log/xivocc/xuc/xuc_ami.log"

readonly SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new)

readonly XDS_SSH_KEY="/root/.ssh/rsync_xds"

DATE=""
DATE_ISO=""
TICKET=""
DEST=""
COLLECT_DIR=""
STREAM=0
T0=0
T1=0
MISSING=()

usage() {
    cat <<EOF
Usage: ${progname} <YYYYMMDD> [ticket number]

Collect the XiVO logs of the given day into a directory and a tarball.

  YYYYMMDD       day to collect logs for (e.g. 20260610)
  ticket number  optional; logs go to /var/local/issue<ticket number>/<YYYYMMDD>
                 when omitted a /var/local/xivo-collect-logs-<YYYYMMDD>.XXXX
                 directory is used (printed at the end)

On a xivo or ucaddon node the media servers and the xivocc are discovered from
the local database and their logs are collected over SSH (as root). Each node's
logs land under its own <node>/ sub-directory, all packed into one tarball.
EOF
}

msg() {
    echo "$@" >&2
}

die() {
    echo "${progname}: $*" >&2
    exit 1
}

note_missing() {
    MISSING+=("$1")
}

copy_file() {
    local f="$1" rel err
    rel="${f#/var/log/}"
    err=$(cd /var/log && cp -a --parents "${rel}" "${COLLECT_DIR}/" 2>&1)
    if [ -e "${COLLECT_DIR}/${rel}" ]; then
        msg "  + ${f}"
    else
        note_missing "${f} (copy failed: ${err})"
    fi
}

# Reads "mtime<TAB>path" lines sorted by ascending mtime on stdin and echoes the
# paths of the files that can hold logs for the [t0, t1) day window. A rotated
# file covers the day if its own mtime is after the start of the day (t0) and the
# previous (older) file rotated before the end of the day (t1).
select_rotated() {
    local t0="$1" t1="$2"
    local mtime path prev_mtime=0
    while IFS=$'\t' read -r mtime path; do
        if [ "${mtime}" -gt "${t0}" ] && [ "${prev_mtime}" -lt "${t1}" ]; then
            printf '%s\n' "${path}"
        fi
        prev_mtime="${mtime}"
    done
}

collect_rotated() {
    local base="$1"
    local optional="${2:-}"
    local candidates=() selected=0 path

    shopt -s nullglob
    candidates=( "${base}" "${base}".* )
    shopt -u nullglob

    if [ "${#candidates[@]}" -eq 0 ]; then
        [ -n "${optional}" ] || note_missing "${base} (not found)"
        return
    fi

    while IFS= read -r path; do
        [ -n "${path}" ] || continue
        copy_file "${path}"
        selected=1
    done < <(stat -c '%Y	%n' "${candidates[@]}" 2>/dev/null \
                 | sort -n | select_rotated "${T0}" "${T1}")

    if [ "${selected}" -eq 0 ] && [ -z "${optional}" ]; then
        note_missing "${base} (no rotated file covering ${DATE})"
    fi
}

collect_log_list() {
    local log
    while IFS= read -r log; do
        [ -n "${log}" ] && collect_rotated "${log}"
    done <<< "$1"
}

# logback-style logs: live <name>.log, rotated <name>.YYYY-MM-DD.log.gz.
# Given the live log path and an ISO date, echo the expected rotated filename.
dated_name() {
    local f="$1" iso="$2"
    local dir base
    dir="${f%/*}"
    base="${f##*/}"
    base="${base%.log}"
    printf '%s/%s.%s.log.gz\n' "${dir}" "${base}" "${iso}"
}

collect_dated() {
    local f="$1"
    local optional="${2:-}"
    local dated today

    dated=$(dated_name "${f}" "${DATE_ISO}")

    if [ -e "${dated}" ]; then
        copy_file "${dated}"
        return
    fi

    # today's logs haven't rolled yet: they are still in the live file
    today=$(date +%Y%m%d)
    if [ "${DATE}" = "${today}" ] && [ -e "${f}" ]; then
        copy_file "${f}"
        return
    fi

    [ -n "${optional}" ] || note_missing "${dated} (not found)"
}

collect_dated_list() {
    local log
    while IFS= read -r log; do
        [ -n "${log}" ] && collect_dated "${log}"
    done <<< "$1"
}

collect_atop() {
    local matches=()
    shopt -s nullglob
    matches=( /var/log/atop/*"${DATE}"* )
    shopt -u nullglob

    if [ "${#matches[@]}" -eq 0 ]; then
        note_missing "/var/log/atop/atop_${DATE} (not found)"
        return
    fi
    local f
    for f in "${matches[@]}"; do
        copy_file "${f}"
    done
}

# Map the three "docker dir present" flags (1/0) to a deployment role.
role_from_flags() {
    local has_xivo="$1" has_cc="$2" has_mds="$3"
    if [ "${has_mds}" -eq 1 ]; then
        echo "xds"
    elif [ "${has_xivo}" -eq 1 ] && [ "${has_cc}" -eq 1 ]; then
        echo "ucaddon"
    elif [ "${has_xivo}" -eq 1 ]; then
        echo "xivo"
    elif [ "${has_cc}" -eq 1 ]; then
        echo "cc"
    else
        echo "unknown"
    fi
}

detect_role() {
    local has_xivo=0 has_cc=0 has_mds=0
    [ -d /etc/docker/xivo ] && has_xivo=1
    [ -d /etc/docker/compose ] && has_cc=1
    [ -d /etc/docker/mds ] && has_mds=1

    role_from_flags "${has_xivo}" "${has_cc}" "${has_mds}"
}

collect_for_role() {
    local role="$1"
    case "${role}" in
        xivo)
            collect_log_list "${XIVO_LOGS}"
            collect_atop
            ;;
        xds)
            collect_log_list "${XDS_LOGS}"
            collect_atop
            ;;
        cc)
            collect_dated_list "${CC_LOGS}"
            collect_dated "${CC_OPTIONAL_LOGS}" optional
            ;;
        ucaddon)
            collect_log_list "${XIVO_LOGS}"
            collect_atop
            collect_dated_list "${CC_LOGS}"
            collect_dated "${CC_OPTIONAL_LOGS}" optional
            ;;
    esac
}

discover_mds() {
    psql -U asterisk -Atc \
        "select voip_ip from mediaserver where name != 'default' order by name" 2>/dev/null
}

discover_xivocc() {
    psql -U asterisk -Atc \
        "select host from accesswebservice where name = 'xivows'" 2>/dev/null | head -n1
}

collect_remote() {
    local kind="$1" host="$2"
    local extra_opts=("${@:3}")
    local dir="${DEST}/${kind}-${host}"
    local tarball="${dir}.tar.gz"
    local errfile="${dir}.err"

    msg "Collecting ${kind} logs from ${host} ..."
    mkdir -p "${dir}"

    if ssh "${SSH_OPTS[@]}" "${extra_opts[@]}" "root@${host}" \
            xivo-collect-logs "${DATE}" --stream > "${tarball}" 2>"${errfile}"; then
        if tar -xzf "${tarball}" -C "${dir}" 2>/dev/null; then
            rm -f "${tarball}" "${errfile}"
        else
            note_missing "${kind} ${host}: could not extract collected logs"
            rm -f "${tarball}" "${errfile}"
            rmdir "${dir}" 2>/dev/null
        fi
    else
        local reason
        reason=$(tail -n 1 "${errfile}" 2>/dev/null)
        [ -n "${reason}" ] || reason="ssh connection failed"
        note_missing "${kind} ${host}: ${reason}"
        rm -f "${tarball}" "${errfile}"
        rmdir "${dir}" 2>/dev/null
    fi
}

collect_remote_mds() {
    local ip found=0
    while IFS= read -r ip; do
        [ -n "${ip}" ] || continue
        collect_remote mds "${ip}" -i "${XDS_SSH_KEY}"
        found=1
    done < <(discover_mds)
    [ "${found}" -eq 1 ] || msg "No media server found in the database."
}

collect_remote_xivocc() {
    local host
    host=$(discover_xivocc)
    if [ -z "${host}" ]; then
        note_missing "xivocc (no 'xivows' host in accesswebservice)"
        return
    fi
    collect_remote xivocc "${host}"
}

# Accept exactly 8 digits (YYYYMMDD).
is_valid_date_format() {
    [[ "$1" =~ ^[0-9]{8}$ ]]
}

# Convert YYYYMMDD to YYYY-MM-DD.
iso_from_date() {
    local d="$1"
    printf '%s-%s-%s\n' "${d:0:4}" "${d:4:2}" "${d:6:2}"
}

parse_date() {
    is_valid_date_format "${DATE}" || die "invalid date '${DATE}', expected YYYYMMDD"
    DATE_ISO=$(iso_from_date "${DATE}")
    T0=$(date -d "${DATE_ISO}" +%s 2>/dev/null) \
        || die "invalid date '${DATE}'"
    T1=$((T0 + 86400))
}

prepare_dest() {
    if [ -n "${TICKET}" ]; then
        DEST="/var/local/issue${TICKET}/${DATE}"
        mkdir -p "${DEST}" || die "cannot create ${DEST}"
    else
        DEST=$(mktemp -d "/var/local/xivo-collect-logs-${DATE}.XXXXXX") \
            || die "cannot create a temporary directory"
    fi
}

report_missing() {
    if [ "${#MISSING[@]}" -gt 0 ]; then
        msg ""
        msg "Could not collect the following (check manually / via scp if on another host):"
        local item
        for item in "${MISSING[@]}"; do
            msg "  - ${item}"
        done
    fi
}

parse_args() {
    local args=()
    while [ "$#" -gt 0 ]; do
        case "$1" in
            -h|--help) usage; exit 0 ;;
            --stream)  STREAM=1; shift ;;
            --)        shift; while [ "$#" -gt 0 ]; do args+=("$1"); shift; done ;;
            -*)        usage >&2; die "unknown option '$1'" ;;
            *)         args+=("$1"); shift ;;
        esac
    done

    if [ "${#args[@]}" -lt 1 ] || [ "${#args[@]}" -gt 2 ]; then
        usage >&2
        exit 1
    fi
    DATE="${args[0]}"
    TICKET="${args[1]:-}"
}

collect_local() {
    local role="$1"
    collect_log_list "${SYSTEM_LOGS}"
    collect_for_role "${role}"
}

run_stream() {
    local role
    role=$(detect_role)
    [ "${role}" = "unknown" ] \
        && die "cannot detect deployment role (no /etc/docker/{xivo,compose,mds})"

    DEST=$(mktemp -d "/tmp/xivo-collect-logs-${DATE}.XXXXXX") \
        || die "cannot create a temporary directory"
    COLLECT_DIR="${DEST}"

    msg "Collecting day ${DATE} (role ${role}) ..."
    collect_local "${role}"

    tar -czf - -C "${DEST}" . 2>/dev/null
    local status=$?
    rm -rf "${DEST}"

    report_missing
    return "${status}"
}

run_normal() {
    local role
    role=$(detect_role)
    [ "${role}" = "unknown" ] \
        && die "cannot detect deployment role (no /etc/docker/{xivo,compose,mds})"

    prepare_dest

    msg "Detected role : ${role}"
    msg "Collecting day: ${DATE}"
    msg "Destination   : ${DEST}"
    msg ""

    case "${role}" in
        xivo|ucaddon)
            COLLECT_DIR="${DEST}/xivo"
            mkdir -p "${COLLECT_DIR}"
            collect_local "${role}"
            collect_remote_mds
            [ "${role}" = "ucaddon" ] || collect_remote_xivocc
            ;;
        *)
            COLLECT_DIR="${DEST}"
            collect_local "${role}"
            ;;
    esac

    local archive="${DEST}.tar.gz"
    if tar -czf "${archive}" -C "${DEST}" . 2>/dev/null; then
        msg ""
        msg "Archive       : ${archive}"
    else
        archive=""
    fi

    msg ""
    msg "Logs collected in: ${DEST}"
    [ -n "${archive}" ] && msg "Tarball ready    : ${archive}"

    report_missing
}

main() {
    parse_args "$@"

    [ "$(id -u)" -eq 0 ] || die "must be run as root to read /var/log"

    parse_date

    if [ "${STREAM}" -eq 1 ]; then
        run_stream
    else
        run_normal
    fi
}

main "$@"
