#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (C) 2019 tribe29 GmbH - License: GNU General Public License v2
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
# conditions defined in the file COPYING, which is part of this source code package.

import typing

# Example outputs from agent:
# <<<heartbeat_crm>>>
# ============
# Last updated: Thu Jul  1 07:48:19 2010
# Current DC: mwp (118cc1e7-bbf3-4550-b820-cac372885be1)
# 2 Nodes configured.
# 2 Resources configured.
# ============
# Node: smwp (2395453b-d647-48ff-a908-a7cd76062265): online
# Node: mwp (118cc1e7-bbf3-4550-b820-cac372885be1): online
# Full list of resources:
# Resource Group: group_slapmaster
#     resource_virtip1  (ocf::heartbeat:IPaddr):  Started mwp
#     resource_virtip2  (ocf::heartbeat:IPaddr):  Started mwp
#     resource_pingnodes  (ocf::heartbeat:pingd): Started mwp
#     resource_slapmaster (ocf::heartbeat:OpenLDAP):  Started mwp
# resource_slapslave  (ocf::heartbeat:OpenLDAP):  Started smwp

# Nails down the DC to the node which is the DC during inventory. The check
# will report CRITICAL when another node becomes the DC during later checks.
# If set to "False" the check will be passed.
#
# Leave this option to be compatible with inventorized pre 1.2.5i6
heartbeat_crm_naildown = True

# Max age of "last updated"
#
# Leave this option to be compatible with inventorized pre 1.2.5i6
heartbeat_crm_default_max_age = 60

# Naildown the resources to the nodes which care about the resources during
# the inventory run
#
# Leave this option to be compatible with inventorized pre 1.2.5i6
heartbeat_crm_resources_naildown = True

# Holds a dict of settings which tell the inventory functions whether or not
# some options like the resources and DC role shal be nailed down to the
# node which holds these resources during inventory.
inventory_heartbeat_crm_rules = []

factory_settings["heartbeat_crm_default_levels"] = {
    "max_age": heartbeat_crm_default_max_age,
}

_FAILED_ACTIONS_KEY = "__failed_actions__"


def _sanitise_line(line: typing.Sequence[str]) -> typing.Sequence[str]:
    """Ensure consistency across different versions of pacemaker.
    - General information should not contain any leading punctuation
    - List elements are denoted with "_"
    Examples:
    >>> _sanitise_line(["a"])
    ['a']
    >>> _sanitise_line(["_*", "a"])
    ['a']
    >>> _sanitise_line(["_", "a"])
    ['_', 'a']
    >>> _sanitise_line(["_", "*", "a"])
    ['_', 'a']
    """
    leading_character = line[0]
    if leading_character == "_*":
        # lines are prefixed with _* in pacemaker versions >= 2, e.g.:
        # _* Current DC: ha02 (version 2.0.3-5.el8_2.1-4b1f869f0f)
        return line[1:]
    if leading_character != "_":
        return line
    if line[1] == "*":
        # list elements are prefixed with "_ *" (artibrary number of spaces)
        # in pacemaker versions >= 2, e.g.:
        # _  * qpid_lvm	(ocf::heartbeat:LVM-activate):	 Started cbgdevd01
        # the leading character to denote a list is changed to "_" for
        # consistency, and to recognise list elements during later parsing
        sanitised_line = list(line[2:])
        sanitised_line.insert(0, "_")
        return sanitised_line
    return line


def _title(title: str) -> str:
    """
    >>> _title("")
    ''
    >>> _title("Foo")
    'Foo'
    >>> _title("Foo:")
    'Foo'
    >>> _title("Foo: bar baz")
    'Foo'
    """
    return title.split(":", 1)[0]


def heartbeat_crm_parse_general(info):
    last_updated = None
    dc = None
    num_nodes = None
    num_resources = None
    for raw_line in info:
        line = _sanitise_line(raw_line)
        line_txt = " ".join(line)
        title = _title(line_txt)

        if title == "Last updated":
            if "Last change:" in line_txt:
                # Some versions seem to combine both lines
                last_updated = line_txt[: line_txt.index("Last change:")].split(": ")[1].strip()
            else:
                last_updated = " ".join(line[2:])
            continue

        if title == "Current DC":
            dc = line[2]
            continue

        if "nodes and" in line_txt and "resources configured" in line_txt:
            # Some versions put number of nodes and resources in one line
            num_nodes = int(line[0])
            num_resources = int(line[3])
            continue

        if "nodes configured" in line_txt.lower():
            num_nodes = int(line[0])
            continue

        if (
            "resources configured" in line_txt.lower()
            or "resource instances configured" in line_txt.lower()
        ):
            # outputs may vary:
            # pacemaker version < 2.0.3: 21 Resources configured.
            # pacemaker version = 2.0.3:  3 resource instances configured
            num_resources = int(line[0])

    return last_updated, dc, num_nodes, num_resources


#   .--CRM-----------------------------------------------------------------.
#   |                          ____ ____  __  __                           |
#   |                         / ___|  _ \|  \/  |                          |
#   |                        | |   | |_) | |\/| |                          |
#   |                        | |___|  _ <| |  | |                          |
#   |                         \____|_| \_\_|  |_|                          |
#   |                                                                      |
#   '----------------------------------------------------------------------'


def inventory_heartbeat_crm(info):
    # Use these lines to gather the inventory and perform this check:
    # ============
    # Last updated: Thu Jul  1 07:48:19 2010
    # Current DC: mwp (118cc1e7-bbf3-4550-b820-cac372885be1)
    # 2 Nodes configured.
    # 2 Resources configured.
    # ============
    #
    # - Naildown the DC or not.
    # - Check the number of nodes/resources
    # - Check the age of "last updated"
    settings = host_extra_conf_merged(host_name(), inventory_heartbeat_crm_rules)
    try:
        _last_updated, dc, num_nodes, num_resources = heartbeat_crm_parse_general(info)
    except Exception:  # pylint: disable=broad-except
        # In the case that CRM is not working, add it as a service and show the error later
        _last_updated, dc, num_nodes, num_resources = 0, 0, 0, 0
    params = {
        "num_nodes": num_nodes,
        "num_resources": num_resources,
    }
    if settings.get("naildown_dc", False):
        params["dc"] = dc
    return [(None, params)]


def _transform_heartbeat_crm(params):
    if isinstance(params, dict):
        _params = params.copy()
        _params.setdefault("show_failed_actions", False)
        return _params
    par_dict = {"max_age": params[0], "show_failed_actions": False}
    if params[1]:
        par_dict["dc"] = params[1]
    if params[2] > -1:
        par_dict["num_nodes"] = params[2]
    if params[3] > -1:
        par_dict["num_resources"] = params[3]
    return par_dict


def check_heartbeat_crm(_no_item, params, info):
    if not info:
        return

    first_line = " ".join(info[0])
    if (
        first_line.lower().startswith(("critical", "error:"))
        or "connection to cluster failed" in first_line.lower()
    ):
        yield 2, first_line
        return

    last_updated, dc, num_nodes, num_resources = heartbeat_crm_parse_general(info)

    # Convert old tuple params (pre 1.5.0p14)
    params = _transform_heartbeat_crm(params)

    # Check the freshness of the crm_mon output and terminate with CRITICAL
    # when too old information are found
    dt = utc_mktime(time.strptime(last_updated, "%a %b %d %H:%M:%S %Y"))
    now = time.time()
    delta = now - dt
    if delta > params["max_age"]:
        yield 2, "Ignoring reported data (Status output too old: %s)" % (
            get_age_human_readable(delta),
        )
        return

    # Check for correct DC when enabled
    if params.get("dc") is None or dc == params["dc"]:
        yield 0, "DC: %s" % (dc,)
    else:
        yield 2, "DC: %s (Expected %s)" % (dc, params["dc"])

    # Check for number of nodes when enabled
    if params["num_nodes"] is not None and num_nodes is not None:
        if num_nodes == params["num_nodes"]:
            yield 0, "Nodes: %d" % (num_nodes,)
        else:
            yield 2, "Nodes: %d (Expected %d)" % (num_nodes, params["num_nodes"])

    # Check for number of resources when enabled
    if params["num_resources"] is not None and num_resources is not None:
        if num_resources == params["num_resources"]:
            yield 0, "Resources: %d" % (num_resources,)
        else:
            yield 2, "Resources: %d (Expected %d)" % (num_resources, params["num_resources"])

    res = heartbeat_crm_parse_resources(info, show="all")
    failed_actions = res.get(_FAILED_ACTIONS_KEY, [])

    if params.get("show_failed_actions"):
        for action in failed_actions:
            yield 1, "Failed: %s" % (action,)


check_info["heartbeat_crm"] = {
    "check_function": check_heartbeat_crm,
    "inventory_function": inventory_heartbeat_crm,
    "service_description": "Heartbeat CRM General",
    "group": "heartbeat_crm",
    "default_levels_variable": "heartbeat_crm_default_levels",
}

# .
#   .--Resources-----------------------------------------------------------.
#   |            ____                                                      |
#   |           |  _ \ ___  ___  ___  _   _ _ __ ___ ___  ___              |
#   |           | |_) / _ \/ __|/ _ \| | | | '__/ __/ _ \/ __|             |
#   |           |  _ <  __/\__ \ (_) | |_| | | | (_|  __/\__ \             |
#   |           |_| \_\___||___/\___/ \__,_|_|  \___\___||___/             |
#   |                                                                      |
#   '----------------------------------------------------------------------'


def _join_lines(lines):
    """Join lines with the help of some helper characters.

    :param lines:
        List of strings

    :returns:
        List of joined strings

    Examples:

        >>> _join_lines(["* 1, 2,", "_ 3"])
        ['1, 2, 3']

        >>> _join_lines(["* 1, 2,", "_ 3,", "_ 4", "* 1, 2,", "_ 3"])
        ['1, 2, 3, 4', '1, 2, 3']

        >>> _join_lines(["1, 2, 3", "1, 2, 3", "* 1,", "_ 2, 3"])
        ['1, 2, 3', '1, 2, 3', '1, 2, 3']

        >>> _join_lines(["1, 2,", "  3"])
        ['1, 2, 3']

        >>> _join_lines([])
        []

    """
    joined = []
    line = ""
    for part in lines:
        if part.startswith("*"):
            if line:
                joined.append(line)
            line = part[2:]
        elif part.startswith(("_ ", "  ")):
            line += part[1:]
        else:
            if line:
                joined.append(line)
            line = part

    if line:
        joined.append(line)
    return joined


def heartbeat_crm_parse_resources(info, show="resources"):
    """

    :param info:
        An info list of lists from the check system.

    :param show:
        Can be either 'all', then it shows some additional information or
        'resources' then it shows only resources.

    :return:
        A dict of resources and possibly additional information (like error messages).
    """
    block_start = False
    resources = {}
    resource = ""
    list_start = False
    lines = []
    mode = "single"
    for raw_parts in info:
        parts = _sanitise_line(raw_parts)
        line = " ".join(parts)

        if "failed" in line.lower() and "actions" in line.lower():
            block_start = False
            list_start = True
        elif not block_start and line.lower() == "full list of resources:":
            block_start = True
            list_start = False
        elif list_start:
            lines.append(line)
            mode = "failedaction"
        elif block_start:
            if line.startswith("Resource Group:"):
                # Resource group
                resource = _title(parts[2])
                resources[resource] = []
                mode = "resourcegroup"
            elif line.startswith("Clone Set:"):
                # Clone set
                resource = _title(parts[2])
                resources[resource] = []
                mode = "cloneset"
            elif line.startswith("Master/Slave Set:"):
                # Master/Slave set
                resource = _title(parts[2])
                resources[resource] = []
                mode = "masterslaveset"
            elif line[0] == "_":
                # Cleanup inconsistent agent output in clone set lines
                if parts[0] != "_":
                    parts.insert(1, parts[0][1:])
                    parts[0] = "_"

                # Resource group or set member
                if mode == "resourcegroup":
                    resources[resource].append(parts[1:])
                elif mode == "cloneset":
                    if parts[1] == "Started:":
                        resources[resource].append([resource, "Clone", "Started", parts[3:-1]])
                elif mode == "masterslaveset":
                    if parts[1] == "Masters:":
                        resources[resource].append([resource, "Master", "Started", parts[3]])
                    if parts[1] == "Slaves:":
                        resources[resource].append([resource, "Slave", "Started", parts[3]])
            else:
                # Single resource
                resources[parts[0]] = [parts]

    if show == "all":
        resources[_FAILED_ACTIONS_KEY] = _join_lines(lines)

    return resources


def inventory_heartbeat_crm_resources(info):
    # Full list of resources:
    # Resource Group: group_slapmaster
    #     resource_virtip1  (ocf::heartbeat:IPaddr):  Started mwp
    #     resource_virtip2  (ocf::heartbeat:IPaddr):  Started mwp
    #     resource_pingnodes  (ocf::heartbeat:pingd): Started mwp
    #     resource_slapmaster (ocf::heartbeat:OpenLDAP):  Started mwp
    # resource_slapslave  (ocf::heartbeat:OpenLDAP):  Started smwp
    inventory = []
    settings = host_extra_conf_merged(host_name(), inventory_heartbeat_crm_rules)
    for name, resources in heartbeat_crm_parse_resources(info).items():
        # In naildown mode only resources which are started somewhere can be
        # inventorized
        if settings.get("naildown_resources", False) and resources[0][2] != "Stopped":
            inventory.append((name, '"%s"' % resources[0][3]))
        else:
            inventory.append((name, None))
    return inventory


def check_heartbeat_crm_resources(item, target_node, info):
    resources = None
    for name, this_resources in heartbeat_crm_parse_resources(info).items():
        if name == item:
            resources = this_resources

    if resources is None:
        return

    if not len(resources):
        yield 0, "no resources found"

    for resource in resources:
        yield 0, " ".join([isinstance(p, list) and ", ".join(p) or p for p in resource])

        if len(resource) == 3 and resource[2] != "Started":
            yield 2, 'Resource is in state "%s"' % (resource[2],)
        elif (
            target_node
            and target_node != resource[3]
            and resource[1] != "Slave"
            and resource[1] != "Clone"
        ):
            yield 2, "Expected node: %s" % (target_node,)


check_info["heartbeat_crm.resources"] = {
    "check_function": check_heartbeat_crm_resources,
    "inventory_function": inventory_heartbeat_crm_resources,
    "service_description": "Heartbeat CRM %s",
    "group": "heartbeat_crm_resources",
}
