#!/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-auth.log
    /var/log/xivo-confd.log
    /var/log/xivo-dird.log
    /var/log/xivo-provd.log
    /var/log/xivo-sysconfd.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)

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=""
ROLE=""
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_list <collector-fn> <path>...
# Run the given collector (collect_rotated or collect_dated) on each path.
collect_list() {
    local fn="$1"
    shift
    local log
    for log in "$@"; do
        "${fn}" "${log}"
    done
}

# 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 live_mtime

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

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

    # The day hasn't rolled to a dated file yet: it may still be in the live
    # file. This is not just today's log -- an idle/stopped service, or a run
    # before the first post-midnight event, leaves a past day in the live file
    # too. Fall back to it whenever it was last written on or after the start
    # of the requested day.
    if [ -e "${f}" ]; then
        live_mtime=$(stat -c '%Y' "${f}" 2>/dev/null || echo 0)
        if [ "${live_mtime}" -ge "${T0}" ]; then
            copy_file "${f}"
            return
        fi
    fi

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

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}"
}

# Detect the deployment role into the global ROLE, or abort. die must run in the
# main shell (not a $(...) subshell) for its exit to stop the script.
detect_role_or_die() {
    ROLE=$(detect_role)
    [ "${ROLE}" = "unknown" ] \
        && die "cannot detect deployment role (no /etc/docker/{xivo,compose,mds})"
}

collect_for_role() {
    local role="$1"
    case "${role}" in
        xivo)
            collect_list collect_rotated "${XIVO_LOGS[@]}"
            ;;
        xds)
            collect_list collect_rotated "${XDS_LOGS[@]}"
            ;;
        cc)
            collect_list collect_dated "${CC_LOGS[@]}"
            collect_dated "${CC_OPTIONAL_LOGS}" optional
            ;;
        ucaddon)
            collect_list collect_rotated "${XIVO_LOGS[@]}"
            collect_list collect_dated "${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"
    # machine-wide logs, present on every node regardless of role
    collect_list collect_rotated "${SYSTEM_LOGS[@]}"
    collect_atop
    collect_for_role "${role}"
}

run_stream() {
    detect_role_or_die

    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() {
    detect_role_or_die

    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 "$@"
