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

import json
import optparse
import os
import sys
import time
import urllib
from functools import cmp_to_key
from collections import defaultdict
import direct_juggler.juggler as dj
from kazoo.client import KazooClient

reload(sys)
sys.setdefaultencoding('utf8')

SERVICE_NAME = 'mysql_check_cluster_settings.%s'
DESCRIPTION_PREFIX = 'from: %s' % os.path.basename(__file__)
ALLDB_CONFIG_PATH = "/etc/yandex-direct/alldb-config.json"
with open(ALLDB_CONFIG_PATH, 'r') as fh:
    ALLDB_CONFIG = json.load(fh)['instances']

ZK_HOSTS = "ppc-zk-1.da.yandex.ru:2181,ppc-zk-2.da.yandex.ru:2181,ppc-zk-3.da.yandex.ru:2181"
ZK_TIMEOUT = 10
ZK_RETRY = {"max_tries": 6, "delay": 0.3, "max_jitter": 1, "backoff": 2, "ignore_expire": False}
ZK_LOCATION = "/direct/mysql_configs"
zkh = KazooClient(hosts=ZK_HOSTS, timeout=ZK_TIMEOUT, connection_retry=ZK_RETRY, command_retry=ZK_RETRY)

NODATA = "NODATA".lower()
#Параметры, которые будут игнорироваться при любом сравнении: уникальности и различии.
IGNORE_PARM = [ 'replicate_wild_do_table', 
                'replicate_ignore_table', 
                'hostname', 
                'slow_query_log_file', 
                'master_retry_count', 
                'replicate_ignore_db', 
                'general_log_file', 
                'innodb_buffer_pool_size',
                'timestamp',
                'thread_pool_size',
                'range_optimizer_max_mem_size',
                'expire_logs_days',
                'wsrep_start_position',
                'wsrep_sst_donor',
                'open_files_limit',
                'auto_increment_offset',
                'auto_increment_increment',
                'wsrep_sst_receive_address',
                'rpl_semi_sync_slave_enabled',
                'rpl_semi_sync_master_enabled',
                'wsrep_provider_options/pc.weight']
'''open_files_limit: if your OS limit is infinity value from the variable open_file_limit will be used, if OS limit not infinity, but larger than variable value: OS file limit will be used; if OS limit smaller than specified value; again OS file limit will be used.
auto_increment_offset на pxc задается динамически и разных, на обычном mysql одинаковый.
'''
#Параметры, которые игнорируется при сравнении на равенство и должны быть уникальными.
UNIQ_PARAM = [ 'server_id', 
               'server_uuid', 
               'report_host',
               #'wsrep_sst_receive_address',
               'wsrep_node_name',
               'wsrep_provider_options/base_host',
               'wsrep_provider_options/ist.recv_addr'
             ]

SCRIPT_NAME = os.path.basename(__file__)
USAGE = '''Скрипт для сравнения отличий между настройками реплик mysql. Для проверки 
следует задать имя инстанса через -i. Если не указывать, будут проверены все инстансы. Примеры:
    %s -i ppcdata8                (проверить только один инстанс)
    %s -i ppcdata1,ppcdata2       (проверить 2 инстанса)
    %s -j                         (проверить все инстансы и отправить результат в juggler)
''' % (SCRIPT_NAME, SCRIPT_NAME, SCRIPT_NAME)

''' Получаем список ключей из zookeeper. Принимает значение пути,
    по которому стоит посмотреть список ключей. В ответ возвращает
    список ключей. 
    ["/direct/mysql_configs/bmdata1.cnf/bmdata1-01e","/direct/mysql_configs/bmdata1.cnf/bmdata1-01i",
     "/direct/mysql_configs/bmdata1.cnf/bmdata1-standby01f","/direct/mysql_configs/bmdata2.cnf/bmdata2-01e"]
'''
def getZookeeperKeys(instance, exclude=None):
    return [
        child
        for child in zkh.get_children(os.path.join(ZK_LOCATION, instance))
        if child not in exclude
    ]


''' Ищем отличия в mysql инстансах. При сравнении игнорирутся параметры из IGNORE_PARM.
    Проверяются не уникальные параметры, например server_id, а так же уникальные, например 
    key_buffer_size'. Перед проверкой сравнивается возраст json объекта по 
    timestamp. Если он больше часа - то ключ удаляется. На вход принимаем:
    {u'gorynych.cnf': [u'ppcstandby05e'], 
     u'bmlink.cnf': [u'bmlink11i', u'bmlinkdb01f']}
    На выход выдаем:
    {u'instance': <name_instance>,        (Имя инстанса)
     u'bad_unique_keys': <list_bad_keys>, (Ключи, которые повторились)
     u'bad_double_keys': <list_bad_uniq>, (Ключи, которые не повторились)
     u'servers': <list_servers>           (Список машин)     
    }
      
'''
def findDifferentKeys(instance, list_servers):
    global zkh

    if opts.debug:
        print '[findDifferentKeys] instance name {0}'.format(instance)

    all_keys = [] # Список всех ключей с серверов
    unique_ids = defaultdict(list) # Ключи, которые не должны повторяться
    empty_servers = [] # Если ключ к моменту чтения пустой, то отсекаем его из выборки
    data_raw = {}

    for server in list_servers:
        if opts.debug:
            print '[findDifferentKeys] server name {0}'.format(server)

        node_path = os.path.join(ZK_LOCATION, instance, server)
        raw_data, _ = zkh.get(node_path)
        if not raw_data:
            if opts.debug:
                print '[findDifferentKeys] key {0} not found. Maybe it is deleted. Skip it'.format(server)
            empty_servers.append(server)
            continue

        data = json.loads(raw_data)
        data_raw[server] = data.copy()

        '''Удаляем записи старше часа. Если 'timestamp' нет - ключ не удаляется. 
           Если 'timestamp' есть и разница с текущим временем больше 1 часа - удаляем ключ. 
           Если 'timestamp' есть и разница с текущим временем меньше 1 часа - ключ не удаляется.
        '''
        current_time = time.time()
        config_time = data.get('timestamp', None)
        if not config_time:
            if opts.debug: 
                print '[findDifferentKeys] ignore delete. Not found parameter "timestamp" in {0}.'.format(node_path)
        else:
            delta_time = current_time - float(config_time)
            if opts.debug:
                print '[findDifferentKeys] age key {0} time current-modify={1}s'.format(node_path, delta_time)

            if delta_time > 3600:
                zkh.delete(node_path)
                if opts.debug:
                    print '[findDifferentKeys] deleted old key {0}'.format(node_path)

        #Ищем ключи содержащие в себе агрегированные строки с параметрами. Например wsrep_provider_options.
        removed_keys = []
        extend_values = []
        for param in data:
            multi_values = data[param].split(';')
            if len(multi_values) < 2:
                continue

            for ml in multi_values:
                if ml.find('=') != -1:
                    (name, value) = ml.split('=')
                else:
                    (name, value) = ml, ''
                extend_values.append(('/'.join([param, name.strip()]), value.strip()))

            removed_keys.append(param)

        data_raw[server].update(dict(extend_values))
        data.update(dict(extend_values))

        #Начинаем поиск расхождений. Исключаем несуществующие ключи и агрегированные строки с параметрами.
        for i in removed_keys:
            data.pop(i, None)

        for i in UNIQ_PARAM:
            if i in data:
                unique_ids[i].append(data[i])

        for i in IGNORE_PARM + UNIQ_PARAM:
            data.pop(i, None)

        for k, v in data.items():
            # удаляем ключи, которых не должно быть на этом сервере совсем (см. checkMysqlConfig)
            if v == NODATA:
                continue
            all_keys.append((k, v))

    list_servers = list(set(list_servers) - set(empty_servers))
    if opts.debug:
        print '[findDifferentKeys] empty keys for {0}'.format(empty_servers)

    all_keys = list(set(all_keys)) #Отсекаем дубликаты
    double_ids = defaultdict(list) # Ключи, которые должны повторяться
    for key, value in all_keys:
        double_ids[key].append(value)

    bad_double_keys = [dk for dk in double_ids if len(double_ids[dk]) > 1]
    bad_unique_keys = [
        uk for uk in unique_ids if not all(unique_ids[uk]) or len(set(unique_ids[uk])) < len(list_servers)
    ]
    double_keys = []
    for key in bad_double_keys:
        tmp = [key]
        for server in list_servers:
            tmp.append(data_raw[server].get(key, None))
        double_keys.append(tmp)

    unique_keys = []
    for key in bad_unique_keys:
        tmp = [key]
        for server in list_servers:
            tmp.append(data_raw[server].get(key, None))
        unique_keys.append(tmp)

    return {
        'instance': instance,
        'bad_unique_settings': unique_keys,
        'bad_settings': double_keys,
        'servers': list_servers
    }


def generateTable(diff):
    table = []
    title = "[{0}] Oh. Found diffent variable in replica cluster:".format(diff.get('instance'))
    table.append(title)
    diff['servers'].insert(0, 'variables \ hosts')
    head = '||' + ' | '.join(['{0:30.30}'.format(i) for i in diff['servers']]) + '||'
    sep = '=' * len(head)
    table.append(sep)
    table.append(head)
    table.append(sep)

    for name in ['bad_settings', 'bad_unique_settings']:
        if diff[name]:
            table.append('||' + '{0:^{1}}'.format(name, len(head) - 4) + '||')
            table.append(sep)

            for key in diff[name]:
                table.append('||' + ' | '.join(['{0:30.30}'.format(i) for i in key]) + '||')
                table.append(sep)

    return '\n'.join(table)  


def callList(option, opt, value, parser):
    value = [os.path.basename(v) for v in value.split(',')]
    setattr(parser.values, option.dest, value)


def parse_options():
    parser = optparse.OptionParser(usage=USAGE)
    parser.add_option(
        "-d", "--debug", action="store_true", dest="debug",
        help="вывод отладочной информации"
    )
    parser.add_option(
        "-j", "--juggler", action="store_true", dest="juggler",
        help="отправить результат в juggler и ничего не выводить"
    )
    parser.add_option(
        "--exclude", action="callback", dest="exclude",
        help="список исключаемых машин", type="string", callback=callList, default=""
    )
    parser.add_option(
        "-i", "--instances", action="callback", dest="instances",
        help="список инстансов для проверки", type="string", callback=callList, default=""
    )
    opts, extra = parser.parse_args()

    if opts.debug and opts.juggler:
        parser.error("do not use --debug with --juggler")

    return opts


if __name__ == '__main__':
    opts = parse_options()

    instances = []
    if opts.instances:
        instances = [instance for instance in opts.instances if instance in ALLDB_CONFIG]
    else:
        instances = [
            instance for instance, value in ALLDB_CONFIG.items()
            if value['project'] == 'direct' and value['type'] in ['mysql']
        ]

    zkh.start(ZK_TIMEOUT*3)
    instances = sorted(
        instances,
        key=cmp_to_key(
            lambda x, y: (
                (x > y) - (x < y)
                if x[:7] != 'ppcdata' or y[:7] != 'ppcdata' or len(x) == len(y)
                else len(x) - len(y)
            )
        )
    )

    for instance in instances:
        try:
            if not opts.juggler:
                print '### %s:' % instance

            zk_keys = getZookeeperKeys(instance, opts.exclude)
            diff = findDifferentKeys(instance, zk_keys)

            if opts.debug:
                print '[main] {0}'.format(diff)

            is_crit = len(diff.get('bad_settings')) != 0 or len(diff.get('bad_unique_settings')) != 0
            if opts.juggler:
                dj.queue_events([{
                    'service': SERVICE_NAME % instance,
                    'status': 'CRIT' if is_crit else 'OK',
                    'description': "; ".join([
                        DESCRIPTION_PREFIX,
                        'run this script for details' if is_crit else ''
                    ]),
                }])
            else:
                if not is_crit:
                    print "OK"
                else:
                    print "CRIT:"
                    print generateTable(diff)

        except Exception as e:
            if opts.debug: 
                raise
            elif opts.juggler:
                dj.queue_events([{
                    'service': SERVICE_NAME % instance,
                    'status': 'CRIT',
                    'description': "; ".join([
                        DESCRIPTION_PREFIX,
                        'run this script for details',
                        'unexpected exception: %s %s' % (type(e), e)
                    ]),
                }])
            else:
                print 'CRIT: unexpected exception: %s %s' % (type(e), e)

        finally:
            if not opts.juggler:
                print "-" * 40

