#!/usr/bin/env python
"""
Functional testing for genisys ui, api and toiler.

In order for it to work properly the genisys instance being tested is required
to have a section with path "genisys.functest-yaml" (note that "genisys" here
is not a root section but rather a first-level child of root). Section type
must be yaml. It should have two rules in specified order:

1. "testrule" -- selector "genisys.yandex-team.ru" (no matter what actual
   installation base url is). Config: `foo: '2015-11-29 16:41:27'`. Actual
   date doesn't matter much for the first run of the checker.
2. "DEFAULT" -- applies to all hosts. Config value -- `foo: 'default'`.

Checker fetches "testrule" config from the ui, compares the datetime with
current and does nothing if it's been changed recently (no more than 10 minutes
ago), checks that that value is being served by api (in two-way mode, for host
"genisys.yandex-team.ru" and for "yandex.ru" just to make sure rule order
applies) and if the datetime is more than an hour old, it changes it to current
datetime.

Note that checker may need to authorize on the service being tested in order
to request data from ui and to change it. Make sure that the user it authorizes
as has an edit access to rule "testrule" in section "genisys.functest-yaml".

Checker script is expected to be executed periodically, like once in five
minutes. It can be run in parallel (checking single genisys instance from
different hosts), but in that case additional care should be taken in order
for config edits of independent checkers not to clash. Shifting starting time
should suffice.
"""
# this comment is required for juggler to interpret the script as passive check
# Provides: genisys
import unittest
import logging
import io
from datetime import datetime

import yaml
import requests
from bs4 import BeautifulSoup
# this import is not being used per se, but it's required by bs4,
# so just in case we want importing it to fail early
import html5lib  # noqa


class CheckContext(unittest.TestCase):
    def __init__(self, ui_base_url, api_base_url, requests_session):
        self.ui_base_url = ui_base_url
        self.api_base_url = api_base_url
        self.rsession = requests_session
        self.logger = logging.getLogger('genisys.checker')
        super(CheckContext, self).__init__()

    def _get(self, url, **kwargs):
        self.logger.debug('requesting %r', url)
        kwargs.setdefault('allow_redirects', False)
        response = self.rsession.get(url, **kwargs)
        self.logger.debug('got status %d %s',
                          response.status_code, response.reason)
        response.raise_for_status()
        self.assertEquals(response.status_code, 200)
        return response

    def _get_html(self, url, **kwargs):
        response = self._get(url, **kwargs)
        self.assertEquals(response.headers['content-type'],
                          'text/html; charset=utf-8')
        return BeautifulSoup(response.content, 'html5lib')

    def _post_and_redirect(self, url, data, expected_redirect, **kwargs):
        self.logger.debug('posting to %r: %r', url, data)
        # this is required for flask_csrf to work when request is secure (ssl)
        kwargs.setdefault('headers', {}).setdefault('Referer', url)
        response = self.rsession.post(url, data, allow_redirects=False,
                                      **kwargs)
        self.logger.debug('got status %d %s',
                          response.status_code, response.reason)
        self.assertEquals(response.status_code, 302)
        location = response.headers['location']
        self.logger.debug('got redirect to %r', location)
        self.assertEquals(location, expected_redirect)
        return self._get_html(location)

    def ui_get_html(self, url, **kwargs):
        return self._get_html(self.ui_base_url + url, **kwargs)

    def ui_post_and_redirect(self, url, data, expected_redirect, **kwargs):
        return self._post_and_redirect(self.ui_base_url + url, data,
                                       self.ui_base_url + expected_redirect,
                                       **kwargs)

    def api_json(self, url, **kwargs):
        response = self._get(self.api_base_url + url, **kwargs)
        return response.json()


class Checker:
    CONFIG_KEY = 'foo'
    CONFIG_FMT = "%Y-%m-%d %H:%M:%S"
    CHANGE_CONFIG_EVERY = 60 * 60
    CHECK_NEW_CONFIG_AFTER = 10 * 60
    TEST_HOST = 'genisys.yandex-team.ru'
    OTHER_HOST = 'yandex.ru'
    RULE_URL = '/rules/genisys.functest-yaml/testrule'

    def __init__(self, ui_base_url, api_base_url, requests_session):
        self.ctx = CheckContext(ui_base_url, api_base_url, requests_session)

    def _get_utcnow(self):
        return datetime.utcnow()

    def _check(self):
        html = self.ctx.ui_get_html(self.RULE_URL)
        current_config = yaml.load(html.find('textarea',
                                             id='configfield').text)
        config_dt = datetime.strptime(current_config[self.CONFIG_KEY],
                                      self.CONFIG_FMT)
        now = self._get_utcnow()
        seconds_since_update = (now - config_dt).total_seconds()
        self.ctx.logger.info(
            "current config is %r, it's been changed %.1fs ago",
            current_config, seconds_since_update
        )

        if seconds_since_update < self.CHECK_NEW_CONFIG_AFTER:
            # the config has just been changed, let's let toiler some time
            # to process it and apis to update their caches
            self.ctx.logger.info("it's too early to either check new config "
                                 "applies or change config again")
            return True

        # it's time to check that new config is actually being served by api
        resp = self.ctx.api_json(
            '/v2/hosts/{}?fmt=json'.format(self.TEST_HOST)
        )
        self.ctx.assertEquals(resp['subsections']['genisys']['subsections']
                                  ['functest-yaml']['config'],
                              current_config)
        self.ctx.logger.info('current config is being served by api '
                             'as it should')
        # also check that rule order also works (config for other hosts
        # than our TEST_HOST must be different)
        resp = self.ctx.api_json(
            '/v2/hosts/{}?fmt=json'.format(self.OTHER_HOST)
        )
        self.ctx.assertNotEquals(resp['subsections']['genisys']['subsections']
                                     ['functest-yaml']['config'],
                                 current_config)
        self.ctx.logger.info('default rule applies')

        if seconds_since_update < self.CHANGE_CONFIG_EVERY:
            self.ctx.logger.info("it's too early to change config again")
            return True

        # time to change config
        params = {el.attrs['name']: el.attrs.get('value')
                  for el in html.select('#configform input')}
        oldrev = params['revision']
        new_config = {self.CONFIG_KEY: now.strftime(self.CONFIG_FMT)}
        self.ctx.logger.info('changing config to %r, current revision is %s',
                             new_config, oldrev)
        params['config'] = yaml.dump(new_config, default_flow_style=False)
        html = self.ctx.ui_post_and_redirect(
            self.RULE_URL, params, expected_redirect=self.RULE_URL
        )
        set_config = yaml.load(html.find('textarea', id='configfield').text)
        self.ctx.assertEquals(set_config, new_config)
        newrev = html.select('#configform #revision')[0].attrs['value']
        self.ctx.assertNotEquals(oldrev, newrev)
        self.ctx.logger.info('successfully changed a config, '
                             'new revision is %s', newrev)
        return True

    def check(self):
        try:
            self.ctx.logger.info('started')
            result = self._check()
            self.ctx.logger.info('finished successfully' if result
                                 else 'finished unsuccessfully')
            return result
        except:
            self.ctx.logger.exception('')
            return False


def main():
    import sys
    import argparse

    try:
        defaults = yaml.load(open('/opt/genisys/etc/production.conf', 'r'))['checker']
    except:
        defaults = {
            'oauth': None,
        }

    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawTextHelpFormatter
    )
    parser.add_argument('--ui', default='https://genisys.yandex-team.ru',
                        help='genisys UI base url. Defaults to '
                             'https://genisys.yandex-team.ru')
    parser.add_argument('--api', default='http://api.genisys.yandex-team.ru',
                        help='genisys API base url. Defaults to '
                             'http://api.genisys.yandex-team.ru')
    parser.add_argument('--oauth-token', default=defaults['oauth'],
                        help='OAuth token to authorize robot in UI. Defaults '
                             'to no authorization')
    ssl_group = parser.add_mutually_exclusive_group()
    ssl_group.add_argument('--insecure', action='store_true',
                           help="don't check server ssl certificate validity")
    ssl_group.add_argument('--ca-bundle', type=argparse.FileType('r'),
                           help="check server ssl certificate against this "
                                "CA bundle")
    debug_group = parser.add_mutually_exclusive_group()
    debug_group.add_argument('--debug', action='store_true',
                             help='be more verbose')
    debug_group.add_argument('--quiet', action='store_true',
                             help="don't write to stderr, only use exit code "
                                  "to indicate the result")
    debug_group.add_argument('--log-file', type=argparse.FileType('a'),
                             help='use verbose logging to a given file only')
    parser.add_argument('--nagios-format', action='store_true',
                        help='write result to stdout in nagios format')
    args = parser.parse_args()

    logging_handlers = []
    if args.nagios_format:
        logging_buffer = io.StringIO()
        handler = logging.StreamHandler(logging_buffer)
        handler.setFormatter(logging.Formatter('%(message)s'))
        logging_handlers.append(handler)

    logging_level = logging.INFO
    if args.log_file is not None:
        logging_level = logging.DEBUG
        logging_handlers.append(logging.StreamHandler(args.log_file))
    elif args.quiet:
        logging_handlers.append(logging.NullHandler())
    else:
        logging_handlers.append(logging.StreamHandler())
        if not args.debug:
            logging.getLogger('requests').setLevel(logging.WARNING)
        else:
            logging_level = logging.DEBUG

    logging.basicConfig(
        level=logging_level,
        handlers=logging_handlers,
        format='%(asctime)-15s %(name)s %(levelname)s %(message)s'
    )
    logging.captureWarnings(capture=True)

    rsession = requests.Session()
    rsession.allow_redirects = False
    if args.oauth_token is not None:
        rsession.headers['Authorization'] = 'OAuth {}'.format(args.oauth_token)
    if args.insecure:
        rsession.verify = False
    elif args.ca_bundle:
        rsession.verify = args.ca_bundle.name

    checker = Checker(args.ui, args.api, rsession)
    result = checker.check()

    if args.nagios_format:
        print('PASSIVE-CHECK:genisys_functional_check;{};{}'.format(
            0 if result else 2,
            'ok' if result else logging_buffer.getvalue().splitlines()[-1]
        ))
    else:
        sys.exit(0 if result else 1)


if __name__ == '__main__':
    main()
