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

'''
Применяет mysql-compare-grants ко всем инстансам, указанным в конфиге.

Конфиги читаем из /etc/lm-grants-multi, сначала lm-grants-multi.yaml,
если его нет - то lm-grants-multi-default.yaml (см. комментарии внутри).
'''

import subprocess
import os
import sys
from stat import *
from datetime import datetime
import argparse
import re
import time
import yaml

SCRIPT_NAME = sys.argv[0]
COMPARE_GRANTS_LOG_NAME = 'compare-grants.log'
CONFIGS_DIR = '/etc/lm-grants-multi'

class MyException(Exception):
    '''
    Класс для того чтобы ловить только свои исключения
    '''
    pass


def checkout_local_configs(conf, dest):
    '''
    Скачивает конфиг для mysql-compare-grants в указанную папку
    '''
    for key in 'private_key_path', 'url':
        if conf.get(key) is None:
            raise MyException(key + ' not found in checkout or empty.', 3)

    sys.stderr.write('trying to clean ' + dest + ' and checkout ' + conf['url'] + ' to it ...\n')
    sys.stderr.flush()
    try:
        subprocess.check_call(['rm', '-rf', dest], stdout=sys.stderr, stderr=sys.stderr)
        os.environ['SVN_SSH'] = 'ssh -i ' + conf['private_key_path']
        subprocess.check_call(['svn', 'checkout', conf['url'], dest], stdout=sys.stderr,
                              stderr=sys.stderr)
    except subprocess.CalledProcessError as exc:
        raise MyException('svn checkout exit with non-zero code.', exc.returncode)
    except OSError as exc:
        raise MyException('svn: ' + exc.strerror, exc.errno)


def load_config(directory):
    '''
    загружаем конфиг для lm-grants-multi
    '''
    # конфиги загружаем как в nginx - сначала пытаемся /etc/lm-grants-multi/lm-grants-multi.yaml
    # (его нет в пакете с применялкой, но могли подложить извне)
    # или из /etc/lm-grants-multi/lm-grants-multi-default.yaml (этот тащим с пакетом)
    for file_name in 'lm-grants-multi.yaml', 'lm-grants-multi-default.yaml':
        config_path = os.path.join(directory, file_name)
        if os.path.exists(config_path):
            try:
                return yaml.load(open(config_path), Loader=yaml.Loader)
            except yaml.YAMLError, exc:
                raise MyException('Error in confuguration file {}: '.format(config_path)
                                  + str(exc), 1)

    raise MyException("Configuration file not found.", 2)


def get_instances():
    '''
    Возвращает список инстансов на машине. (используем lm)
    '''
    path_to_lm = '/usr/local/bin/lm'
    try:
        proc = subprocess.Popen([path_to_lm, '--complete'], stdout=subprocess.PIPE,
                                stderr=sys.stderr)
    except (IOError, OSError) as exc:
        raise MyException(path_to_lm + ': ' + exc.strerror, exc.errno)
    exit_code = proc.wait()
    if exit_code != 0:
        raise MyException('lm exited with non-zero code', exit_code)
    return [x.strip() for x in proc.stdout.readlines() if x.strip()]


def find_instance_config(iconf, instance):
    '''
    Ищет подходящий конфиг для instance по регулярному выражению.
    Возвращает то с чем сматчилось и сам конфиг.
    '''
    for instance_re, instance_conf in iconf.items():
        if re.match(instance_re, instance):
            return instance_re, instance_conf
    return None, None


def check_user_params(user_params, mode, instance):
    '''
    Проверяет параметры запуска mysql-compare-grants. Удаляет из user_params противоречащие режиму работы.
    Падает при неразрешимых противоречиях. Возвращает кортеж ([список_параметров], 0) если нет противоречий.
    Иначе кортеж с именем инстанса и кодом ошибки.
    '''
    for illegal_opt in ('--log', '--grants-config', '--socket', '--user'):
        if illegal_opt in user_params:
            sys.stderr.write(SCRIPT_NAME + ': Error. In instance {}: '.format(instance)
                             + 'option {} not allowed. Instance skipped\n'.format(illegal_opt))
            return (instance, 50)

    bad_user_params_w = {
                       'view-instructions': ('--monrun', '--human', '--apply'),
                   }

    new_user_params = []
    for param in user_params:
        if param in bad_user_params_w[mode]:
            continue
        new_user_params.append(param)

    return (new_user_params, 0)


def process_instance(instance, global_config, failures, args):
    '''
    Применяет mysql-compare-grants к инстансу
    Возвращает изменились ли гранты в инстансе или нет.
    В случае ошибки записывает ошибку в failures и возвращает None
    '''
    sys.stderr.write('Trying to find config for instance ' + instance + '\n')

    inst_re, inst_conf = find_instance_config(global_config.get('instances', {}), instance)
    sys.stderr.write('Matched regex: ' + inst_re + ' : ' + str(inst_conf) + '\n')

    if inst_conf is None:
        sys.stderr.write(SCRIPT_NAME + ': Warning. '
                         + 'Configuration for ' + instance + ' not found. Instance skipped\n')
        failures.append((instance, 45))
        return None
    if inst_conf.get('grants_config') is None:
        sys.stderr.write(SCRIPT_NAME + ': Warning. '
                         + 'Instance - {}: '.format(instance)
                         + 'grants_config field not found. Instance skipped\n')
        failures.append((instance, 46))
        return None
    socket = '/var/run/mysqld.{}/mysqld.sock'.format(instance)
    log_file = '/var/log/mysql.{}/{}'.format(instance, COMPARE_GRANTS_LOG_NAME)
    local_config = os.path.join(global_config['grants_directory'],
                                inst_conf['grants_config'])

    path_to_compare_grants = '/usr/local/bin/mysql-compare-grants'

    grants_cmd = [path_to_compare_grants, '--grants-config', local_config,
                  '--socket', socket, '--user', 'root']

    if args.log is not None:
        grants_cmd += ['--log', log_file]

    user_params = inst_conf.get('compare_grants_opts', [])

    err_tuple = check_user_params(user_params, args.mode, instance)
    if err_tuple[-1] > 0:
        failures.append(err_tuple)
        # падаем, если есть параметры с указанием опций подключения и тд, которые сложно фильтровать
        return None
    else:
        # добавляем в итоговую команду только те параметры, которые не противоречат режиму работы
        grants_cmd += err_tuple[0]

    sys.stderr.write('Run grants cmd: ' + ' '.join(grants_cmd) + '\n')
    sys.stderr.flush()
    try:
        res = subprocess.check_output(grants_cmd, stderr=sys.stderr)
    except subprocess.CalledProcessError as exc:
        failures.append((instance, exc.returncode))
        return None
    except (IOError, OSError) as exc:
        raise MyException(path_to_compare_grants + ': ' + exc.strerror + '\n', exc.errno)
    return res


def monrun(global_config, status_file):
    '''
    Запускаем проверку. Проверяет что статус файл не старый и выводит его содержимое.
    Возвращает либо код ошибки либо 0.
    '''
    if global_config.get('max_status_file_age_seconds') is None:
        sys.stderr.write(SCRIPT_NAME + ': Error. '
                         + 'max_status_file_age_seconds not found in configuration file.\n')
        return 5
    try:
        status_file_age = time.time() - os.stat(status_file).st_mtime
    except OSError as exc:
        sys.stderr.write('2; Cannot open status file: ' + str(exc) + '\n')
        return 1

    try:
        max_status_file_age = int(global_config['max_status_file_age_seconds'])
    except ValueError:
        sys.stderr.write(SCRIPT_NAME + ': Error. '
                         + 'Not valid data in field max_status_file_age_seconds. '
                         + 'Expected int value\n')
        return 8

    if status_file_age > max_status_file_age:
        # Файл слишком долго не менялся
        sys.stderr.write(SCRIPT_NAME + ': Error. Too old status file.\n')
        return 6

    try:
        with open(status_file, 'r') as opened_file:
            print ''.join(opened_file.readlines()),
    except (IOError, OSError) as exc:
        sys.stderr.write(SCRIPT_NAME + ' Error. ' + status_file + ': ' + exc.strerror)
        return exc.errno

    return 0


def parse_args():
    '''
    Возвращает аргументы коммандной строки
    '''
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
    parser.add_argument('mode', choices=['monrun', 'apply-instance-opts', 'view-instructions'],
                        help='\n'.join([
                            'monrun',
                            '   Вывод для monrun из статус-файлика (для его генерации нужно запустить с apply-instance-opts).\n   Максимальный возраст файлика должен быть записан в конфиге.',
                            'apply-instance-opts',
                            '   Применить к описанным в конфиге инстансам опции из секции "compare_grants_opts".\n   Например, "compare_grants_opts: [--destructive]" для печати (но не применения)\n   инструкций для удаления/изменения существующих гратнов.\n   Для применения для конкретного инстанса нужно добавить еще и опцию "--apply".',
                            'view-instructions',
                            '   Вывести все инструкции которые собираемся применить (не меняет status-файлик).\n   Из compare_grants_opts будут исключены все деструктивные действия (--apply)'
                        ]))
    parser.add_argument('--log', help='Путь к глобальному файлу с логами. Также включает логирование mysql-compare-grants в\nдиректории соответствующих инстансов: /var/log/mysql.*/' + COMPARE_GRANTS_LOG_NAME)
    return parser.parse_args()


def main():
    '''
    Скачиваем конфиг для всех инстансов и применяем mysql-compare-grants для каждого
    После завершения записываем результат в статус-файл
    '''
    args = parse_args()
    status_file = '/var/spool/lm-grants-multi.status'

    if args.mode == 'apply-instance-opts':
        #очищаем статус-файл перед применением грантов
        try:
            with open(status_file, 'w') as f:
                f.write('1;' + SCRIPT_NAME + ' in progress ...')
        except (IOError, OSError) as exc:
            sys.stderr.write(SCRIPT_NAME + ' Error. ' + status_file + ': ' + exc.strerror + '\n')
            return exc.errno

    if args.log is not None:
        try:
            sys.stderr = open(args.log, 'a')
        except (IOError, OSError) as exc:
            sys.stderr.write(SCRIPT_NAME + ' Error. ' + args.log + ': ' + exc.strerror + '\n')
            return exc.errno

    sys.stderr.write('[{date}]: {scr_name}: '.format(date=datetime.now(),
                                                     scr_name=SCRIPT_NAME)
                     + 'started with args: {args}\n'.format(args=sys.argv[1:]))

    try:
        global_config = load_config(CONFIGS_DIR)
    except MyException as exc:
        message, errno = exc.args
        sys.stderr.write(SCRIPT_NAME + ': Error. ' + message + '\n')
        return errno

    if args.mode == 'monrun':
        errno = monrun(global_config, status_file)
        if errno == 0:
            if args.log is not None:
                sys.stderr.write('[{date}]: {scr_name}: '.format(date=datetime.now(),
                                                             scr_name=SCRIPT_NAME)
                                 + 'exited successfully.\n')
        return errno

    if global_config.get('grants_directory') is None:
        sys.stderr.write(SCRIPT_NAME + ': Error. '
                         + 'Field grants_directory not found in configuration file or empty.\n')
        return 7

    if 'checkout' in global_config:
        try:
            checkout_local_configs(global_config['checkout'],
                                   global_config.get('grants_directory'))
        except MyException as exc:
            message, errno = exc.args
            sys.stderr.write(SCRIPT_NAME + ' Error while checkout configs. ' + message + '\n')
            return errno
    else:
        sys.stderr.write(SCRIPT_NAME + ': no checkout command\n')

    try:
        lm_instances = get_instances()
    except MyException as exc:
        message, errno = exc.args
        sys.stderr.write(SCRIPT_NAME + ' Error. ' + message + '\n')
        return errno

    if 'instances' not in global_config:
        sys.stderr.write(SCRIPT_NAME + ': Warning. '
                         + 'Instances not found in configuration file.\n')

    failures = []
    summary_result = ''

    for instance in lm_instances:
        try:
            result = process_instance(instance, global_config, failures, args)
        except MyException as exc:
            message, errno = exc.args
            sys.stderr.write(SCRIPT_NAME + ': Error. ' + message + '\n')
            return errno
        if result is not None:
            if result:
                summary_result += 'For instance {}\n'.format(instance) + result

    if failures:
        # Имена инстансов на которых скрипт упал и коды ошибок можно достать из failures
        message = '2;Some mysql-compare-grants exited with non-zero code, ' \
                    + 'see /var/log/mysql.*/{} or {}'.format(COMPARE_GRANTS_LOG_NAME, args.log)
    elif summary_result:
        message = '1;Some grants changed, see /var/log/mysql.*/' + COMPARE_GRANTS_LOG_NAME
    else:
        message = '0;OK'

    if args.log is not None:
        sys.stderr.write('\n'.join(
            ['[{date}]: {scr_name}: finished with:'.format(
                date=datetime.now(),
                scr_name=SCRIPT_NAME),
             '\tfailures ' + str(failures),
             '\tgrants changed ' + str(bool(summary_result)),
             '\tmessage ' + message]) + '\n')

    sys.stderr.write('All instructions:\n' + summary_result  + '\n'
                     + 'Failures:\n' + str(failures) + '\n')

    # save to status-file /var/spool/lm-grants-multi.status
    if args.mode == 'apply-instance-opts':
        try:
            with open(status_file, 'w') as opened_status_file:
                opened_status_file.write(message + '\n')
        except (IOError, OSError) as exc:
            sys.stderr.write(SCRIPT_NAME + ': Error. ' + status_file + ': ' + exc.strerror + '\n')
            return exc.errno

    if args.log is not None:
        sys.stderr.write('[{date}]: {scr_name}: '.format(date=datetime.now(),
                                                         scr_name=SCRIPT_NAME)
                         + 'exited successfully.\n')
    return 0


if __name__ == '__main__':
    exit(main())
