#!/usr/bin/python3
"""
Copyright 2023, Georg Pfuetzenreuter

Licensed under the EUPL, Version 1.2 or - as soon they will be approved by the European Commission - subsequent versions of the EUPL (the "Licence").
You may not use this work except in compliance with the Licence.
An English copy of the Licence is shipped in a file called LICENSE along with this applications source code.
You may obtain copies of the Licence in any of the official languages at https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12.

---

Scullery - a SaltStack testing tool.
"""

from argparse import ArgumentParser
from configparser import ConfigParser
import logging
import os
import sys
from pytest import ExitCode

argparser = ArgumentParser()
config = ConfigParser()
env = os.environ.copy()

arggroup = argparser.add_mutually_exclusive_group()
argparser.add_argument('--debug', help='Print extremely verbose output', action='store_const', dest='loglevel', const=logging.DEBUG, default=logging.INFO)
argparser.add_argument('--config', help='Specify the configuration file to use', default='{}/scullery.ini'.format(os.getcwd()))
argparser.add_argument('--env', help='Write environment file for direct use of Vagrant', action='store_true')
argparser.add_argument('--suite', help='Specify the suite to run', required=True)
arggroup.add_argument('--stop', help='Stop running machines', action='store_true')
arggroup.add_argument('--test', help='Start machines and run tests', action='store_true')
arggroup.add_argument('--status', help='Get Vagrant deployment status', action='store_true')
arggroup.add_argument('--refresh', help='Re-sync files and re-run bootstrap scripts', action='store_true')
argparser.add_argument('--force-stop', help='Invoke Vagrant destruction without having detected any running VM\'s', action='store_true')

args = argparser.parse_args()
configfile = args.config

vmprefix = 'scullery'
cwd = os.getcwd()
sshfile='{}/.scullery_ssh'.format(cwd)
envfile='{}/.scullery_env'.format(cwd)
vagfile='Vagrantfile-Template'

# replaced in scullery.spec
is_packaged = True
if is_packaged:
    vagfile='/usr/share/scullery/{}'.format(vagfile)
else:
    try:
        me = __file__
    except NameError:
        me = sys.argv[0]
    vagfile='{}/{}'.format(os.path.abspath(os.path.dirname(me)), vagfile)

def _abort(msg):
    log.error(msg)
    sys.exit(1)

def _config():
    configmap = {'boxes': {}, 'suites': {}, 'tests': {}}
    if not 'box' in config.sections():
        _abort('No "box" section found in the configuration file')
    multis = {'boxes': {'prefix': 'box.', 'singular': 'box'}, 'suites': {'prefix': 'suite.', 'singular': 'suite'}, 'tests': {'prefix': 'test.', 'singular': 'test'}}
    for multi, multiconf in multis.items():
        lowconf = [section for section in config.sections() if section.startswith(multiconf['prefix'])]
        for section in lowconf:
            collection = section.replace(multiconf['prefix'], '')
            configmap[multi][collection] = {}
            for option in config.options(section):
                if option in ['masters', 'minions']:
                    value = config.getint(section, option)
                else:
                    value = config.get(section, option)
                configmap[multi][collection][option] = value
        onemulti = multiconf['singular']
        if onemulti in config.sections():
            for option in config.options(onemulti):
                for collection in configmap[multi]:
                    if not option in configmap[multi][collection]:
                        configmap[multi][collection][option] = config.get(onemulti, option)
        if multi in ['boxes', 'suites']:
            if not len(lowconf):
                _abort('No {} configured'.format(multi))
    log.debug('Config map: {}'.format(str(configmap)))
    return configmap

def _vagrant(quiet=False):
    return vagrant.Vagrant(quiet_stdout=False, quiet_stderr=quiet)

def genvms(flavor, amount):
    vms = []
    for i in range(amount):
        vms.append('{}-{}{}'.format(vmprefix, flavor, i))
    return vms

def _setenv(envmap, dump=False):
    if dump:
        log.debug('Writing environment variable file')
        fh = open(envfile, 'w')
    for variable, value in envmap.items():
        if value is not None:
            if isinstance(value, list):
                value = ','.join(value)
            env[variable] = value
            if dump:
                fh.write(f'{variable}={value}\n')
        elif variable in env:
            del env[variable]
    if dump:
        fh.close()

def vagrant_env(box_name, box_image, minions=None, masters=None, vagrantfile=None, bootstrap=None):
    envmap = {'VAGRANT_VAGRANTFILE': vagrantfile, 'SCULLERY_BOX_NAME': box_name, 'SCULLERY_BOX_IMAGE': box_image,
              'SCULLERY_MASTERS': masters, 'SCULLERY_MINIONS': minions, 'SCULLERY_BOOTSTRAP': bootstrap}
    log.debug('Environment variable map: {}'.format(str(envmap)))
    _setenv(envmap, args.env)
    v.env = env

def vagrant_isup(suite):
    ok = 0
    nok = 0
    statuses = v.status()
    total = len(statuses)
    for status in statuses:
        if status.state == 'running':
            ok += 1
        else:
            nok +=1
    if ok == total:
        return True, None
    elif nok == total:
        return False, True
    else:
        return False, False

def vagrant_sshconfig(outfile):
    try:
        ssh_config = v.ssh_config()
    except Exception as myerror:
        log.exception(myerror)
        log.error('Unable to fetch SSH configuration')
    with open(outfile, 'w') as fh:
        fh.write(ssh_config)

def _saltcmd(target):
    if target == 'local':
        saltcmd = 'salt-call --local'
    else:
        saltcmd = 'salt -t10 {}'.format(target)
    return saltcmd

def runping(target):
    saltcmd = _saltcmd(target)
    sshout = v.ssh(command='sudo {} test.ping'.format(saltcmd))
    log.info('\n{}\n'.format(str(sshout)))
    return sshout

def runapply(state, target):
    saltcmd = _saltcmd(target)
    sshout = v.ssh(command='sudo {} state.apply {}'.format(saltcmd, state))
    log.info('\n{}\n'.format(str(sshout)))

def runtests(payload, hosts):
    vagrant_sshconfig(sshfile)
    testresult = pytest.main(['--verbose', '--hosts={}'.format(','.join(hosts)), '--ssh-config={}'.format(sshfile), payload])
    log.debug('Test result is {}'.format(str(testresult.value)))
    if testresult == ExitCode.OK:
        log.debug('Test succeeded')
    else:
        log.warning('Tests failed')
        return False
    return True

def _cleanup():
    for file in [envfile, sshfile]:
        if os.path.isfile(file):
            log.debug('Removing {}'.format(file))
            os.remove(file)


def main_interactive():
    configmap = _config()
    boxes = configmap['boxes']
    suites = configmap['suites']
    tests = configmap['tests']
    suite = args.suite
    if suite not in suites:
        _abort('No suite named {}'.format(suite))
    suiteconf = configmap['suites'][suite]
    box = suiteconf.get('box', None)
    if box is None:
        _abort('Specified suite does not reference a box')
    boxconf = configmap['boxes'].get(box, None)
    if boxconf is None:
        _abort('Suite references an undefined box')
    box_name = boxconf.get('name', None)
    box_image = boxconf.get('image', None)
    box_file = boxconf.get('file', vagfile)
    if None in [box_name, box_image, box_file]:
        _abort('Box configuration is incomplete')
    box_bootstrap = boxconf.get('bootstrap', None)
    minions = None
    masters = None
    if suiteconf.get('minions', 0) > 0:
        minions = genvms('minion', suiteconf['minions'])
    if suiteconf.get('masters', 0) > 0:
        masters = genvms('master', suiteconf['masters'])
    vagrant_env(box_name, box_image, minions, masters, box_file, box_bootstrap)
    if args.status:
        log.info('Status report: {}'.format(v.status()))
        return True
    status = vagrant_isup(suite)
    if status[0] is True and status[1] is None or args.force_stop:
        if True in [args.stop, args.force_stop]:
            log.info('Destroying machines ...')
            v.destroy()
            if vagrant_isup(suite)[0] is False:
                log.debug('OK')
            else:
                _abort('Destruction failed')
        elif not args.refresh and not args.test:
            log.info('Deployment is already running')
        elif args.refresh:
            log.info('Deployment is running, initiating refresh ...')
            _cleanup()
            v.provision()
            vagrant_sshconfig(sshfile)
    elif status[0] is False:
        if status[1] is True:
            log.debug('Deployment is not running')
        elif status[1] is False:
            log.warning('Deployment is in an inconsistent state, destroying ...')
            try:
                v.destroy()
            except Exception as myerror:
                log.exception(myerror)
                _abort('Unhandled error')

        if args.stop is False and args.force_stop is False:
            log.info('Launching {} ...'.format(suite))
            v.up()
            if vagrant_isup(suite)[0] is True:
                log.debug('OK')
            else:
                _abort('Start failed')

    if args.stop:
        _cleanup()

    if args.test:
        test = suiteconf.get('test', None)
        if test is None:
            _abort('Tests requested but not declared in suite configuration')
        if not test in tests:
            _abort('Specified test is not defined')
        testconf = tests[test]
        if not 'test' in testconf:
            _abort('Incomplete test configuration')

        if 'apply' in testconf:
            log.debug('state.apply requested')
            if masters is not None:
                target = 'scullery-*'
                count = 0
                while not runping(target):
                    if count == 5:
                        _abort('Unable to reach minions')
                    count += 1
            else:
                target = 'local'
            runapply(testconf['apply'], target)
        else:
            log.warning('No state.apply requested')

        log.info('Initiating tests ...')
        runtests(testconf['test'], minions)

logging.basicConfig(format='%(asctime)s %(levelname)s - %(funcName)s: %(message)s', datefmt='%H:%M:%S')
log = logging.getLogger('scullery')

if __name__ == '__main__':
    log.setLevel(args.loglevel)
    log.debug(args)
    if args.loglevel == logging.WARNING:
        quiet_stderr = True
    else:
        quiet_stderr = False
    log.debug('Vagrant stderr: {}'.format(str(quiet_stderr)))

try:
    import vagrant
except ImportError as myerror:
    _abort('Could not load python-vagrant')

if args.test:
    try:
        import pytest
    except ImportError as myerror:
        _abort('Could not load pytest')

if os.path.isfile(configfile):
    config.read(configfile)
else:
    _abort('Unable to locate configuration file at {}'.format(configfile))

if __name__ == '__main__':
    v = _vagrant(quiet_stderr)
    main_interactive()
else:
    v = _vagrant()
