Georg Pfuetzenreuter
776ebb9f54
Allow directories to be specified in suite and test configuration sections which will be linked to /srv/pillar on either the master or the minion. Signed-off-by: Georg Pfuetzenreuter <mail@georg-pfuetzenreuter.net>
324 lines
11 KiB
Python
Executable File
324 lines
11 KiB
Python
Executable File
#!/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 = False
|
|
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, pillar=None):
|
|
envmap = {'VAGRANT_VAGRANTFILE': vagrantfile, 'SCULLERY_BOX_NAME': box_name, 'SCULLERY_BOX_IMAGE': box_image,
|
|
'SCULLERY_MASTERS': masters, 'SCULLERY_MINIONS': minions, 'SCULLERY_BOOTSTRAP': bootstrap, 'SCULLERY_PILLAR': pillar}
|
|
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'])
|
|
test = suiteconf.get('test', None)
|
|
if test is not None:
|
|
testconf = tests[test]
|
|
pillar_test = testconf.get('pillar', None)
|
|
else:
|
|
pillar_test = None
|
|
pillar_suite = suiteconf.get('pillar', None)
|
|
if pillar_suite is None and pillar_test is None:
|
|
pillar = None
|
|
elif pillar_test:
|
|
pillar = pillar_test
|
|
elif pillar_suite:
|
|
pillar = pillar_suite
|
|
vagrant_env(box_name, box_image, minions, masters, box_file, box_bootstrap, pillar)
|
|
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:
|
|
if test is None:
|
|
_abort('Tests requested but not declared in suite configuration')
|
|
if not test in tests:
|
|
_abort('Specified test is not defined')
|
|
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()
|