#! /usr/bin/env python
#-*- coding: utf-8 -*-


import commands
import ConfigParser
import logging
import os
import pwd
import re
import signal
import sys
import json
import time
from optparse import OptionParser

UTIL_DIR = '/usr/lib/monrun/utils/'
if os.path.isdir(UTIL_DIR):
    sys.path.append(UTIL_DIR)

import gethostname


CACHE_DIR = '/var/cache/monrun'
PLUGIN_DIR = '/usr/lib/monrun/plugins/'

DEFAULT_CHECK_TYPE = 'other'

MONITORING_ERROR_LIST = []
FQDN_MESSAGE_LIST = []


# процесс загрузки плагинов
def load_plugin(dir_name):
    modulelist = []
    package_obj = None
    full_path = os.path.join(PLUGIN_DIR, dir_name)
    if os.path.exists(full_path):
        for file_name in os.listdir(full_path):
            # Обрежем расширение .py в имени файла
            # Пропустим файл __init__.py и все не-python файлы
            if file_name != "__init__.py" and file_name.endswith('.py'):
                # Загружаем модуль и добавляем его имя в список загруженных модулей
                module_name = file_name[: -3]
                package_obj = __import__(dir_name + "." + module_name)
                modulelist.append(module_name)
    return modulelist, package_obj


def getHostName():
    return gethostname.fqdn(errback=addFQDNMessage)


def addFQDNMessage(msg, err):
    FQDN_MESSAGE_LIST.append(str(msg))
    if err is not None:
        FQDN_MESSAGE_LIST.append(str(err))


def showMonitoringErrors():
    if MONITORING_ERROR_LIST:
        sys.stderr.write('Got following errors:\n')
        for error_message in MONITORING_ERROR_LIST:
            sys.stderr.write('\t' + error_message + '\n')


def showFQDNMessages():
    if FQDN_MESSAGE_LIST:
        sys.stderr.write('FQDN messages:\n')
        for msg in FQDN_MESSAGE_LIST:
            sys.stderr.write('\t' + msg + '\n')


class Check(object):
    def __init__(self, host, service, options):
        self.service = service
        self.send_for_host = host
        self.cache_time = None
        self.command = None
        self.execution_interval = None
        self.start_random_sleep = None
        self.execution_timeout = 300
        self.disable_notifications = 1
        self.type = DEFAULT_CHECK_TYPE
        self.docstring = "No description"
        # redefine default values
        for key, value in options.items():
            setattr(self, key, value)
        # execution_timeout is execution_interval/2, but not more than 300 seconds
        if "execution_interval" in options and "execution_timeout" not in options:
            self.execution_timeout = min(int(round(int(self.execution_interval) / 2.0)), 300)

        self.cache_file = "%s/%s_%s" % (CACHE_DIR, self.send_for_host, self.service)

    def describe(self):
        print "%s:" % self.send_for_host
        print "     %s - %s" % (self.service, self.docstring)
        print "command:"
        print "     %s" % self.command

    def load(self):
        if os.path.exists(self.cache_file) and self.expire() is False:
            with open(self.cache_file, 'r') as stream:
                content = stream.read()
            return Message.from_string(self.send_for_host, self.service, content)
        return None

    def expire(self):
        if self.cache_time is None and self.execution_interval is not None:
            if self.execution_interval <= 300:
                cache_time = 600 + int(self.execution_timeout)
            else:
                cache_time = 600 + int(self.execution_interval) + int(self.execution_timeout)
        elif self.cache_time is not None:
            cache_time = self.cache_time
        else:
            cache_time = 600
        if os.path.exists(self.cache_file):
            delta = time.time() - os.stat(self.cache_file).st_mtime
            if delta < int(cache_time):
                return False
        return True

    def save(self, content):
        try:
            pwd_monitor = pwd.getpwnam('monitor')
        except KeyError:
            return False
        login = pwd.getpwuid(os.getuid())[0]
        if login != 'monitor' and login != 'root':
            return False
        f = open(self.cache_file, 'w')
        if login == 'root':
            os.setegid(pwd_monitor[3])
            os.seteuid(pwd_monitor[2])
        f.write('%s' % content)
        f.close()
        if login == 'root':
            os.seteuid(0)
            os.setegid(0)
        return True

    def run(self, config):
        if self.command is None:
            raise Exception("Command is not set")

        h = signal.signal(signal.SIGPIPE, signal.SIG_DFL)
        stdout = commands.getoutput(self.command)
        signal.signal(signal.SIGPIPE, h)

        # плагины отвечающие за измение статуса по каким-то условиям, если статус менять не надо передаём следующему
        modulelist, package_obj = load_plugin("change")
        for modulename in modulelist:
            module_obj = getattr(package_obj, modulename)
            stdout = module_obj.change(self, stdout)
        self.save(stdout)

        message = Message.from_string(self.send_for_host, self.service, stdout)
        # escalation for monitoring checks
        if message.exit_code > 0:
            # if check failed and it depends on something
            # go through depends and disabled check if needed
            if self._disabled_by_depends(message.exit_code, config):
                # some checks in higher level are broken, disable current check
                message.exit_code = 0
                message.description = "%s (disabled by monrun escalation)" % message.description
                new_stdout = "PASSIVE-CHECK:%s;%d;%s" % (message.service, message.exit_code, message.description)
                self.save(new_stdout)
        return message

    def view(self):
        return self.load() or Message(self.send_for_host, self.service,
                                      exit_code=2, description="NO DATA (cache expired)")

    def get_manifest_check(self):
        execution_interval = 180
        if self.cache_time is not None and int(self.cache_time) == 0 and self.execution_interval is None:
            execution_interval = 60
        elif self.execution_interval is not None:
            execution_interval = self.execution_interval

        # job.start_random_sleep = self.start_random_sleep
        return {
            "check_script": "/usr/bin/monrun",
            "interval":  int(execution_interval),
            "timeout": int(self.execution_timeout),
            "run_always": True,
            "format": "json",
            "services": [self.service],
            "args": ["-r", self.service, "--host", self.send_for_host, "-f", "json", "--no-footer"]
        }

    def _disabled_by_depends(self, exit_code, config):
        # get view for each check, filter out "no data"
        for d in re.split("\s*,\s*", getattr(self, "depends", "")):
            d_check = config.get_check(d, self.send_for_host)
            if d_check is not None:
                tmp_out = d_check.view()

                # filter out no data
                if hasattr(tmp_out, 'description') and tmp_out.description == '2;NO DATA (cache expired)':
                    continue

                # if higher level check has worse exit_code than current check, disable current check
                if hasattr(tmp_out, 'exit_code'):
                    if tmp_out.exit_code >= exit_code:
                        return True
        # no higher level checks is broken
        return False


class Config(object):
    def __init__(self, conf_dir):
        self._config = self._read_config(conf_dir)

    @staticmethod
    def _read_config(config_dir):
        good_conf_files = []

        for file_name in os.listdir(config_dir):
            if os.path.splitext(file_name)[1] != ".conf":
                continue
            conf_file = os.path.join(config_dir, file_name)
            error = None
            try:
                # валидация чтобы выяснить, какие файлы будем использовать, а какие нет
                ConfigParser.ConfigParser().read(conf_file)
            except ConfigParser.MissingSectionHeaderError:
                error = 'No check defined in configuration file, file ignored: {0}'.format(conf_file)
            except ConfigParser.ParsingError:
                error = 'Configuration file contains parsing errors, file ignored: {0}'.format(conf_file)
            except Exception as e:
                error = str(e) + '\nIn file: {0}'.format(conf_file)
            else:
                good_conf_files.append(conf_file)

            if error:
                MONITORING_ERROR_LIST.append(error)

        parser = ConfigParser.ConfigParser()
        parser.read(good_conf_files)
        return parser

    def get_check_types(self):
        check_types = set()
        for section in self._config.sections():
            if self._config.has_option(section, 'type'):
                check_types.add(self._config.get(section, 'type') or DEFAULT_CHECK_TYPE)
            else:
                check_types.add(DEFAULT_CHECK_TYPE)
        return sorted(check_types)

    def _iter_known_checks(self):
        for section in sorted(self._config.sections()):
            if self._config.has_option(section, 'send_for_host'):
                host_name = self._config.get(section, 'send_for_host')
            else:
                host_name = getHostName()

            if self._config.has_option(section, 'service'):
                service_name = self._config.get(section, 'service')
            else:
                service_name = section
            options = dict()
            for option in self._config.options(section):
                if option not in ('send_for_host', 'service'):
                    options[option] = self._config.get(section, option)

            yield host_name, service_name, options

    def get_check(self, service, host):
        for host_name, service_name, options in self._iter_known_checks():
            if host_name == host and service_name == service:
                return Check(host_name, service_name, options)

        return None

    def all(self):
        return [
            Check(host_name, service_name, options)
            for host_name, service_name, options in self._iter_known_checks()
        ]

    def filter_checks_by_status(self, status_set):
        return [x for x in self.all() if x.view().exit_code in status_set]


class MessageFormatter(object):
    @staticmethod
    def format_human(messages_list):
        lines = []
        for message in messages_list:
            # green, orange, red
            color = {0: 32, 1: 33, 2: 31}.get(message.exit_code, 32)
            desc = "\033[%dm%s\033[0m" % (color, message.description)
            if message.send_for_host != getHostName():
                lines.append("%s: %s = %s" % (message.send_for_host, message.service, desc))
            else:
                lines.append("%s = %s" % (message.service, desc))

        return "\n".join(lines)

    @staticmethod
    def format_nagios(messages_list):
        return "\n".join(['PASSIVE-CHECK:%s;%d;%s' % (x.service, int(x.exit_code), x.description) for x in messages_list])

    @staticmethod
    def format_nagios_nsca31(messages_list):
        return "\n".join(['%s;%s;%d;%s' % (x.send_for_host, x.service, int(x.exit_code), x.description) for x in messages_list])

    @staticmethod
    def format_json(messages_list):
        return json.dumps({
            "events": [{
                "host": x.send_for_host,
                "service": x.service,
                "description": x.description,
                "status": {0: "OK", 1: "WARN", 2: "CRIT"}.get(x.exit_code, str(x.exit_code))
            } for x in messages_list]
        })

    @staticmethod
    def print_formatted(messages_list, format_name, prefix=''):
        attr = 'format_%s' % format_name
        if not hasattr(MessageFormatter, attr):
            raise Exception("Invalid format name {0!r}".format(attr))
        text = getattr(MessageFormatter, attr)(messages_list)
        if prefix:
            text = "".join(prefix + x for x in text.splitlines(True))

        print(text)


class Message(object):
    def __init__(self, host, service, exit_code, description):
        self.send_for_host = host
        self.description = description
        self.exit_code = exit_code
        self.service = service

    @classmethod
    def from_string(cls, host, service, content):
        re_result = re.match('^(PASSIVE-CHECK:[a-zA-Z0-9-_]+?;)?(?P<errcode>[0-9]);(?P<description>.*)$', content)
        if re_result is not None:
            groups = re_result.groupdict()
            exit_code = int(groups['errcode'])
            description = groups['description']
            if exit_code == 0:
                description = re.sub('^\s*[Oo][Kk]\s*$', 'OK', description)
            if not description:
                description = {0: "OK", 1: "Warning", 2: "Error"}.get(exit_code, "Unknown exit code")
            logging.warning("Output for service %s: %s;%s" % (service, exit_code, description))
        else:
            exit_code = 2
            description = "Can't parse output from script"
            logging.error("Problem with parsing output during execute check for service: %s. Content: %s " % (
                service, content))

        # sanitize
        description = description.replace(';', ',')

        return cls(
            host=host,
            service=service,
            exit_code=exit_code,
            description=description,
        )


def make_parser():
    parser = OptionParser(description="Monitoring runner", prog='monrun', version='0.1', usage='%prog')
    parser.add_option('-w', '--show-failed', action='store_true', dest='show_failed', default=False,
                      help='show only failed checks')
    parser.add_option('-a', '--warn', action='store_true', dest='show_warn', default=False, help='show warnings')
    parser.add_option('-c', '--crit', action='store_true', dest='show_crit', default=False, help='show criticals')
    parser.add_option('-r', '--run', type='string', dest='run', help='execute check')
    parser.add_option('-d', '--doc', type='string', dest='describe', help='show check documentation')

    formats = ', '.join(x[7:] for x in dir(MessageFormatter) if "format_" in x)
    help_formats = 'format for monitoring system. Available formats: %s' % formats
    parser.add_option('-f', type='string', dest='format', default='human', help=help_formats)

    parser.add_option('-v', '--view', type='string', dest='view', help='view check')
    parser.add_option('-t', '--check-type', type='string', dest='check_type_list',
                      help='view checks of the types specified (separated by commas)')
    parser.add_option('--host', type='string', dest='send_for_host', help='', default=getHostName())
    parser.add_option('--gen-jobs', action='store_true', dest='gen_jobs', default=False, help='')
    parser.add_option('--fqdn', action='store_true', dest='show_fqdn', default=False, help='show fqdn messages')

    parser.add_option('--conf-dir', dest='conf_dir', help='Directory for config files')
    parser.add_option('--log-file', dest='log_file', help='Log file path')
    parser.add_option('--manifest-dir', dest='manifest_dir', help='Juggler-client manifest directory')
    parser.add_option('--no-footer', action='store_true', dest='disable_footer', help="Don't print footer")
    return parser


def describe_command(config, host, service):
    # вытаскиваем из проверки docstring
    config.get_check(service, host).describe()


def view_command(config, host, service, format_name):
    m = config.get_check(service, host).view()
    MessageFormatter.print_formatted([m], format_name)


def gen_jobs_command(config, manifest_dir):
    manifest = {
        "version": 1,
        "checks": [x.get_manifest_check() for x in config.all()]
    }
    manifest_path = os.path.join(manifest_dir, "MANIFEST.json")
    with open(manifest_path, "w") as stream:
        json.dump(manifest, stream, indent=4, sort_keys=True)
    os.chmod(manifest_path, 0o644)


def run_check_command(config, host, service, format_name):
    possible_hosts = [host]
    if config.get_check(service, host) is None:
        possible_hosts = [check.send_for_host for check in config.all() if check.service == service]
        if not possible_hosts:
            raise AttributeError("Can't determine check '%s'" % service)
    for send_for_host in possible_hosts:
        check = config.get_check(service, send_for_host)
        if check.load() is None:
            # Если проверке не удалось загрузить кеш,
            # то перед её запуском отправится сообщение что она оттаймаутилась
            send_to_transport_plugins(
                Message(check.send_for_host, check.service, exit_code=2, description="NO DATA (timeout)")
            )

        message = check.run(config)
        MessageFormatter.print_formatted([message], format_name)
        send_to_transport_plugins(message)


def init_logger(log_file, log_level):
    handler = logging.FileHandler(log_file)
    handler.setFormatter(logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s'))
    logging.getLogger().addHandler(handler)
    logging.getLogger().setLevel(logging.ERROR)
    if log_level == "debug":
        logging.getLogger().setLevel(logging.DEBUG)
    elif log_level == "warning":
        logging.getLogger().setLevel(logging.WARNING)


def send_to_transport_plugins(message):
    # плагины для отправки(им передаётся строка и они с ней что хотят то делают, но изменения останутся только в плагине
    modulelist, package_obj = load_plugin("transport")
    for modulename in modulelist:
        module_obj = getattr(package_obj, modulename)
        module_obj.send(message.send_for_host, message.service, message.exit_code, message.description)


def try_get_option(config, section, option, default):
    if config.has_option(section, option):
        return config.get(section, option)
    return default


def dispatch_command(conf_dir, manifest_dir, options):
    conf = Config(conf_dir)
    if options.run is not None and options.send_for_host is not None:
        return run_check_command(conf, options.send_for_host, options.run, options.format)

    if options.describe is not None and options.send_for_host is not None:
        return describe_command(conf, options.send_for_host, service=options.describe)

    if options.view is not None and options.send_for_host is not None:
        return view_command(conf, options.send_for_host, options.view, options.format)

    if options.gen_jobs is True:
        return gen_jobs_command(conf, manifest_dir)

    # no valid command, printing the cache
    if options.show_warn or options.show_crit or options.show_failed:
        status_set = set()
        if options.show_warn:
            status_set.add(1)
        if options.show_crit:
            status_set.add(2)
        if options.show_failed:
            status_set.update((1, 2))
        checks_list = conf.filter_checks_by_status(status_set)
    else:
        checks_list = conf.all()
        if options.check_type_list is not None:
            allowed_types = set(options.check_type_list.split(','))
            checks_list = [x for x in checks_list if x.type in allowed_types]

    if not checks_list:
        print("No checks")
        return

    for check_type in sorted(set(x.type for x in checks_list)):
        print('Type: {0}'.format(check_type))
        MessageFormatter.print_formatted([x.view() for x in checks_list if x.type == check_type], options.format, prefix='\t')


def run():
    global CACHE_DIR, PLUGIN_DIR
    options, arguments = make_parser().parse_args()

    log_level = "error"
    log_file = '/var/log/monrun.log'
    conf_dir = '/etc/monrun/conf.d/'
    manifest_dir = '/etc/monrun'

    if os.path.isfile('/etc/monrun/general.conf'):
        general_config = ConfigParser.ConfigParser()
        general_config.read('/etc/monrun/general.conf')
        conf_dir = try_get_option(general_config, "main", "conf_dir", default=conf_dir)
        CACHE_DIR = try_get_option(general_config, "main", "cache_dir", default=CACHE_DIR)
        PLUGIN_DIR = try_get_option(general_config, "main", "plugin_dir", default=PLUGIN_DIR)
        log_file = try_get_option(general_config, "main", "log_file", default=log_file)
        log_level = try_get_option(general_config, "main", "log_level", default=log_level)
        manifest_dir = try_get_option(general_config, "main", "manifest_dir", default=manifest_dir)

    if options.conf_dir:
        conf_dir = options.conf_dir
    if options.log_file:
        log_file = options.log_file
    if options.manifest_dir:
        manifest_dir = options.manifest_dir
    if options.run is None:
        log_file = "/dev/null"

    if os.path.isdir(PLUGIN_DIR):
        sys.path.append(PLUGIN_DIR)

    init_logger(log_file, log_level)

    dispatch_command(conf_dir, manifest_dir, options)

    if not options.disable_footer:
        showMonitoringErrors()
        if options.show_fqdn is True:
            showFQDNMessages()


if __name__ == "__main__":
    run()
