#!/bin/bash
#
# microsoft-connected-cache - public command dispatcher for Microsoft Connected Cache.
#
# Installed as /usr/bin/microsoft-connected-cache by the microsoft-connected-cache
# package. It is the only supported entry point; the implementation scripts under
# PAYLOAD_DIR are package-private and are not a public interface.
#
# Exit codes (public contract):
#   0  success, or status found a healthy provisioned node
#   1  failure, or status found a degraded provisioned node
#   2  invalid command or arguments
#   3  MCC is not provisioned
#   4  root required, or required read permissions are missing
#   5  the operator declined a confirmation prompt; nothing was changed

set -u
set -o pipefail

PAYLOAD_DIR="/opt/microsoft/microsoft-connected-cache"
PACKAGE_NAME="microsoft-connected-cache"
MCC_CONTAINER="MCC"
# Written by deploymcc.sh after a successful provision and removed by the uninstall. Root-only.
PROVISIONED_MARKER="/etc/mccresourcecreation/.provisioned"
# A Docker engine is installed when any of these exists. A Docker CLI without one cannot answer.
DOCKER_ENGINE_PATHS="/usr/bin/dockerd /usr/sbin/dockerd /etc/systemd/system/docker.service /usr/lib/systemd/system/docker.service /lib/systemd/system/docker.service"

EXIT_OK=0
EXIT_FAILED=1
EXIT_USAGE=2
EXIT_NOT_PROVISIONED=3
EXIT_PERMISSION=4
EXIT_CANCELLED=5
# Exit code of the last payload script run_payload ran.
PAYLOAD_RC=0

err() { printf '%s\n' "$*" >&2; }

usage() {
    cat <<EOF
Microsoft Connected Cache

Usage: ${PACKAGE_NAME} <command> [arguments]

Commands:
  provision <key=value>...        Provision this host using portal-generated parameters.
  status                          Report provisioning, service, and container state.
  diagnostics                     Collect a diagnostic support bundle.
  configure-proxy <url> [cert]    Configure the outbound proxy, with optional TLS certificate.
  import-cert [--certName FILE]   Import a certificate.
  version                         Print the installed package version.
  uninstall                       Interactively remove the MCC deployment from this host.
  help                            Show this help.

Commands requiring root: provision, diagnostics, configure-proxy, import-cert, uninstall.

Exit codes:
  0 success / healthy    1 failure / degraded    2 invalid usage
  3 not provisioned      4 permission denied     5 cancelled, nothing changed

Removing the OS package does not remove an active MCC deployment. Run
'${PACKAGE_NAME} uninstall' first, then remove the package.
EOF
}

require_root() {
    if [ "$(id -u)" -ne 0 ]; then
        err "Error: '$1' must be run as root. Try: sudo ${PACKAGE_NAME} $1"
        exit "${EXIT_PERMISSION}"
    fi
}

require_payload() {
    if [ ! -d "${PAYLOAD_DIR}" ]; then
        err "Error: MCC payload directory not found at ${PAYLOAD_DIR}."
        err "The ${PACKAGE_NAME} package appears to be damaged. Reinstall it."
        exit "${EXIT_FAILED}"
    fi
    if [ ! -x "${PAYLOAD_DIR}/$1" ]; then
        err "Error: required script '$1' is missing or not executable in ${PAYLOAD_DIR}."
        err "The ${PACKAGE_NAME} package appears to be damaged. Reinstall it."
        exit "${EXIT_FAILED}"
    fi
}

# Runs a payload script from inside PAYLOAD_DIR.
#
# The payload scripts are the same ones the zip distribution ships, so they are
# run the way that distribution documents: from their own directory. The
# script's own exit code is kept in PAYLOAD_RC; the return value is normalized
# to EXIT_FAILED, because the implementation scripts' internal codes are not a
# public API. A command that maps a specific code does so from PAYLOAD_RC.
run_payload() {
    local script="$1"
    shift
    require_payload "${script}"
    ( cd "${PAYLOAD_DIR}" && "./${script}" "$@" )
    PAYLOAD_RC=$?
    if [ "${PAYLOAD_RC}" -ne 0 ]; then
        return "${EXIT_FAILED}"
    fi
    return "${EXIT_OK}"
}

have() { command -v "$1" >/dev/null 2>&1; }

docker_engine_installed() {
    local path
    for path in ${DOCKER_ENGINE_PATHS}; do
        [ -e "${path}" ] && return 0
    done
    return 1
}

# Reports whether the caller can query container state at all, so that a
# permission failure is never reported as "not provisioned".
docker_readable() {
    have docker || return 1
    docker ps -a >/dev/null 2>&1
}

container_exists() {
    docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$1"
}

container_running() {
    docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$1"
}

service_active() {
    have systemctl || return 1
    systemctl is-active --quiet "$1" 2>/dev/null
}

service_enabled_or_active() {
    have systemctl || return 1
    systemctl is-active --quiet "$1" 2>/dev/null && return 0
    systemctl is-enabled --quiet "$1" 2>/dev/null
}

# Provisioned detection uses the marker deploymcc.sh writes after a successful
# provision, and runtime state for hosts provisioned before the marker existed.
# The existence of an empty /etc directory does not imply provisioning, and the
# package creates those directories at install time. The marker is root-only,
# so other callers rely on runtime state.
#
# Returns 0 provisioned, 1 not provisioned, 4 undeterminable (fail closed).
is_provisioned() {
    local can_check=1

    [ -e "${PROVISIONED_MARKER}" ] && return 0

    if have systemctl; then
        can_check=0
        if service_enabled_or_active aziot-edged || service_enabled_or_active aziot-identityd; then
            return 0
        fi
    fi

    if have docker; then
        if docker_readable; then
            can_check=0
            if container_exists "${MCC_CONTAINER}"; then
                return 0
            fi
        elif docker_engine_installed; then
            # Docker is present but unreadable by this caller. Never downgrade
            # this to "not provisioned".
            return 4
        fi
        # Otherwise only the CLI is left, without an engine to hold containers.
    fi

    if [ "${can_check}" -ne 0 ]; then
        return 4
    fi
    return 1
}

cmd_version() {
    local v=""
    if have dpkg-query; then
        v=$(dpkg-query -W -f='${Version}' "${PACKAGE_NAME}" 2>/dev/null)
    fi
    if [ -z "${v}" ] && have rpm; then
        v=$(rpm -q --qf '%{VERSION}-%{RELEASE}' "${PACKAGE_NAME}" 2>/dev/null)
    fi
    if [ -z "${v}" ] && [ -r "${PAYLOAD_DIR}/packageVer.txt" ]; then
        # Fallback for an uninstalled or relocated payload.
        v="$(tr -d '[:space:]' < "${PAYLOAD_DIR}/packageVer.txt") (payload)"
    fi
    if [ -z "${v}" ]; then
        err "Error: unable to determine the installed ${PACKAGE_NAME} version."
        return "${EXIT_FAILED}"
    fi
    printf '%s %s\n' "${PACKAGE_NAME}" "${v}"
    return "${EXIT_OK}"
}

report() { printf '  %-22s %s\n' "$1" "$2"; }

cmd_status() {
    is_provisioned
    local prov=$?

    if [ "${prov}" -eq 4 ]; then
        err "Error: unable to determine MCC state. Required services or the container"
        err "runtime could not be queried. Re-run as root, or ensure this user can"
        err "access the Docker daemon and systemd."
        return "${EXIT_PERMISSION}"
    fi

    if [ "${prov}" -ne 0 ]; then
        echo "Microsoft Connected Cache: not provisioned"
        echo
        echo "The package is installed but this host has not been provisioned."
        echo "Run: sudo ${PACKAGE_NAME} provision <portal-generated parameters>"
        return "${EXIT_NOT_PROVISIONED}"
    fi

    local degraded=0

    echo "Microsoft Connected Cache: provisioned"
    echo
    echo "Services:"
    local svc
    for svc in aziot-edged aziot-identityd docker; do
        if ! have systemctl; then
            report "${svc}" "unknown (systemctl unavailable)"
            degraded=1
        elif service_active "${svc}"; then
            report "${svc}" "active"
        else
            report "${svc}" "INACTIVE"
            degraded=1
        fi
    done

    echo
    echo "Containers:"
    if ! docker_readable; then
        report "docker" "UNREADABLE (permission denied or daemon down)"
        degraded=1
    else
        local c
        for c in edgeAgent edgeHub "${MCC_CONTAINER}"; do
            if container_running "${c}"; then
                report "${c}" "running"
            elif container_exists "${c}"; then
                report "${c}" "STOPPED"
                degraded=1
            else
                report "${c}" "MISSING"
                degraded=1
            fi
        done
    fi

    echo
    if [ "${degraded}" -ne 0 ]; then
        echo "Overall: DEGRADED"
        echo "Collect diagnostics with: sudo ${PACKAGE_NAME} diagnostics"
        return "${EXIT_FAILED}"
    fi
    echo "Overall: healthy"
    return "${EXIT_OK}"
}

cmd_provision() {
    require_root provision
    if [ "$#" -eq 0 ]; then
        err "Error: 'provision' requires the portal-generated parameters."
        err "Usage: sudo ${PACKAGE_NAME} provision <key=value>..."
        return "${EXIT_USAGE}"
    fi
    run_payload deploymcc.sh "$@"
}

cmd_diagnostics() {
    require_root diagnostics
    run_payload collectmccdiagnostics.sh "$@"
}

cmd_configure_proxy() {
    require_root configure-proxy
    if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
        err "Error: 'configure-proxy' takes a proxy URL and an optional TLS certificate path."
        err "Usage: sudo ${PACKAGE_NAME} configure-proxy <url> [tls-certificate-path]"
        return "${EXIT_USAGE}"
    fi
    run_payload configureproxy.sh "$@"
}

cmd_import_cert() {
    require_root import-cert
    run_payload importCert.sh "$@"
}

cmd_uninstall() {
    require_root uninstall
    if [ "$#" -ne 0 ]; then
        err "Error: 'uninstall' takes no arguments."
        return "${EXIT_USAGE}"
    fi
    echo "This removes the MCC deployment from this host, including IoT Edge and the"
    echo "Moby container runtime. It does not remove the ${PACKAGE_NAME} OS package."
    echo
    run_payload uninstallmcc.sh && return "${EXIT_OK}"
    # uninstallmcc.sh exits 5 when the operator declines its confirmation.
    if [ "${PAYLOAD_RC}" -eq 5 ]; then
        return "${EXIT_CANCELLED}"
    fi
    return "${EXIT_FAILED}"
}

main() {
    if [ "$#" -eq 0 ]; then
        usage >&2
        exit "${EXIT_USAGE}"
    fi

    local command="$1"
    shift

    case "${command}" in
        provision)       cmd_provision "$@" ;;
        status)          cmd_status "$@" ;;
        diagnostics)     cmd_diagnostics "$@" ;;
        configure-proxy) cmd_configure_proxy "$@" ;;
        import-cert)     cmd_import_cert "$@" ;;
        uninstall)       cmd_uninstall "$@" ;;
        version|--version|-v) cmd_version ;;
        help|--help|-h)  usage ;;
        *)
            err "Error: unknown command '${command}'."
            err
            usage >&2
            exit "${EXIT_USAGE}"
            ;;
    esac
    exit $?
}

main "$@"
