From 8bf25f2a56167b0fa0676c44eb28367cbad94d32 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 5 Oct 2017 09:29:59 +0200 Subject: [PATCH 001/117] fix compose handling and fix container issue with using container instead of url --- examples/rhscl_maria/example1.py | 2 +- moduleframework/common.py | 28 +++++++-------------- moduleframework/dockerlinter.py | 15 ++++------- moduleframework/helpers/container_helper.py | 4 --- moduleframework/helpers/rpm_helper.py | 6 +++-- 5 files changed, 19 insertions(+), 36 deletions(-) diff --git a/examples/rhscl_maria/example1.py b/examples/rhscl_maria/example1.py index a171865..a1cecca 100644 --- a/examples/rhscl_maria/example1.py +++ b/examples/rhscl_maria/example1.py @@ -5,7 +5,7 @@ from avocado import Test import time -WAIT_TIME=10 +WAIT_TIME=15 class OneMachine(module_framework.ContainerAvocadoTest): """ diff --git a/moduleframework/common.py b/moduleframework/common.py index 30ffc54..e2e2057 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -84,12 +84,13 @@ DEFAULTNSPAWNTIMEOUT = 10 def get_compose_url_modular_release(): - release = os.environ.get("MTF_FEDORA_RELEASE") or "26" + default_release = "27" + release = os.environ.get("MTF_FEDORA_RELEASE") or default_release if release == "master": - release = "26" - compose_url = os.environ.get("MTF_BASE_COMPOSE_URL") or \ - "https://kojipkgs.fedoraproject.org/compose/latest-Fedora-Modular-%s/compose/Server/%s/os" \ - % (release, ARCH) + release = default_release + + base_url = "https://kojipkgs.fedoraproject.org/compose/latest-Fedora-Modular-{}/compose/Server/{}/os" + compose_url = os.environ.get("MTF_COMPOSE_BASE") or base_url.format(release, ARCH) return compose_url def is_debug(): @@ -260,20 +261,9 @@ def get_compose_url(): :return: str """ - compose_url = os.environ.get('COMPOSEURL') - if not compose_url: - readconfig = CommonFunctions() - readconfig.loadconfig() - try: - if readconfig.config.get("compose-url"): - compose_url = readconfig.config.get("compose-url") - elif readconfig.config['module']['rpm'].get("repo"): - compose_url = readconfig.config['module']['rpm'].get("repo") - else: - compose_url = readconfig.config['module']['rpm'].get("repos")[0] - except AttributeError: - return None - return compose_url + readconfig = get_config() + compose_url = os.environ.get('COMPOSEURL') or readconfig.get("compose-url") + return [compose_url] if compose_url else [] def get_modulemdurl(): diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 9a9d456..06c81dd 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -122,8 +122,7 @@ def _get_structure_as_dict(self): print("Dockerfile tag %s is not parsed by MTF" % key) def get_docker_env(self): - if ENV in self.docker_dict and self.docker_dict[ENV]: - return self.docker_dict[ENV] + return self.docker_dict.get(ENV) def get_docker_specific_env(self, env_name=None): """ @@ -142,9 +141,8 @@ def get_docker_expose(self): :return: list of PORTS """ ports_list = [] - if EXPOSE in self.docker_dict and self.docker_dict[EXPOSE]: - for p in self.docker_dict[EXPOSE]: - ports_list.append(int(p)) + for p in self.docker_dict.get(EXPOSE,[]): + ports_list.append(int(p)) return ports_list def get_docker_labels(self): @@ -152,9 +150,7 @@ def get_docker_labels(self): Function returns docker labels :return: label dictionary """ - if LABEL in self.docker_dict and self.docker_dict[LABEL]: - return self.docker_dict[LABEL] - return None + return self.docker_dict.get(LABEL,{}) def get_specific_label(self, label_name=None): """ @@ -172,5 +168,4 @@ def check_baseruntime(self): Function returns docker labels :return: label dictionary """ - if FROM in self.docker_dict: - return [x for x in self.docker_dict[FROM] if "baseruntime/baseruntime" in x] + return [x for x in self.docker_dict.get(FROM,[]) if "baseruntime/baseruntime" in x] diff --git a/moduleframework/helpers/container_helper.py b/moduleframework/helpers/container_helper.py index 993517d..13fb3fe 100644 --- a/moduleframework/helpers/container_helper.py +++ b/moduleframework/helpers/container_helper.py @@ -49,10 +49,6 @@ def __init__(self): if "docker=" in self.icontainer: self.jmeno = self.icontainer[7:] self.tarbased = False - elif "docker.io" in self.info['container']: - # Trusted source - self.tarbased = False - self.jmeno = self.icontainer else: # untrusted source self.tarbased = False diff --git a/moduleframework/helpers/rpm_helper.py b/moduleframework/helpers/rpm_helper.py index f16d9ad..1aef665 100644 --- a/moduleframework/helpers/rpm_helper.py +++ b/moduleframework/helpers/rpm_helper.py @@ -105,10 +105,10 @@ def setRepositoriesAndWhatToInstall(self, repos=[], whattooinstall=None): if repos: self.repos = repos else: - self.repos += self.get_url() + self.repos += get_compose_url() or self.get_url() # add also all dependent modules repositories if it is module # TODO: removed this dependency search - if self.is_it_module: + if not get_compose_url() and self.is_it_module: depend_repos = [get_compose_url_modular_release()] #for dep in self.moduledeps: # latesturl = pdc_data.get_repo_url(dep, self.moduledeps[dep]) @@ -117,6 +117,8 @@ def setRepositoriesAndWhatToInstall(self, repos=[], whattooinstall=None): #map(self.__addModuleDependency, depend_repos) self.repos += depend_repos #map(self.__addModuleDependency, self.repos) + # make self.repos unique in case there is more repos (faster dnf operations) + self.repos = list(set(self.repos)) if whattooinstall: self.whattoinstallrpm = " ".join(set(whattooinstall)) else: From dc97473c5bac243c3742151b8a926725d690f4e3 Mon Sep 17 00:00:00 2001 From: Petr Hracek Date: Thu, 5 Oct 2017 12:54:01 +0200 Subject: [PATCH 002/117] spec: fix URL Signed-off-by: Petr "Stone" Hracek --- meta-test-family.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meta-test-family.spec b/meta-test-family.spec index e86b602..8341b77 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -7,7 +7,7 @@ Summary: Tool to test components of a modular Fedora License: GPLv2+ URL: https://github.com/fedora-modularity/meta-test-family -Source0: https://codeload.github.com/fedora-modularity/%{name}/tar.gz/%{name}-%{version}.tar.gz +Source0: %{url}/archive/%{version}/%{name}-%{version}.tar.gz BuildArch: noarch # Exlcude ppc64: there is no docker package on ppc64 # https://bugzilla.redhat.com/show_bug.cgi?id=1465176 From 6cabdaa0f6efd487384885c1b59250243c3415b0 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 6 Oct 2017 08:27:48 +0200 Subject: [PATCH 003/117] back to original timeout library --- examples/testing-module/simpleTest.py | 2 +- moduleframework/timeoutlib.py | 29 +++++++-------------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/examples/testing-module/simpleTest.py b/examples/testing-module/simpleTest.py index 879fd96..f0ca682 100644 --- a/examples/testing-module/simpleTest.py +++ b/examples/testing-module/simpleTest.py @@ -23,7 +23,7 @@ from moduleframework import module_framework import os - +import warnings class simpleTests(module_framework.AvocadoTest): """ diff --git a/moduleframework/timeoutlib.py b/moduleframework/timeoutlib.py index 3c998ed..5e7c3c6 100644 --- a/moduleframework/timeoutlib.py +++ b/moduleframework/timeoutlib.py @@ -21,18 +21,11 @@ import signal import time -import logging -from common import print_info - -log = logging.getLogger('avocado.test') - class Timeout(object): def __init__(self, retry, timeout): self.retry = retry self.timeout = timeout - log.debug("Started timeout period: ", timeout) - log.debug("Number of remainig retry: ", retry) def __enter__(self): def timeout_handler(signum, frame): @@ -45,11 +38,9 @@ def timeout_handler(signum, frame): signal.alarm(self.timeout) def __exit__(self, type, value, traceback): - log.debug("Time were exceeded") signal.alarm(0) signal.signal(signal.SIGALRM, self.orig_sighand) - class NOPTimeout(object): def __init__(self, *args, **kwargs): pass @@ -60,9 +51,8 @@ def __enter__(self): def __exit__(self, *args, **kwargs): pass - class Retry(object): - def __init__(self, attempts=1, timeout=None, exceptions=(Exception,), error=None, inverse=False, delay=None): + def __init__(self, attempts = 1, timeout = None, exceptions = (Exception,), error = None, inverse = False, delay = None): """ Try to run things ATTEMPTS times, at max, each attempt must not exceed TIMEOUT seconds. Restart only when one of EXCEPTIONS is raised, all other exceptions will just bubble up. @@ -84,13 +74,12 @@ def __init__(self, attempts=1, timeout=None, exceptions=(Exception,), error=None self.failed_attempts = 0 self.timeouts_triggered = 0 - def handle_failure(self, start_time, exc): + def handle_failure(self, start_time): if __debug__: self.failed_attempts += 1 + self.attempts -= 1 - log.debug("Remaining attempts: ", self.attempts) if self.attempts == 0: - print_info("Original exeption:", exc) raise self.error # Before the next iteration sleep $delay seconds. It's the @@ -115,8 +104,7 @@ def __wrap(*args, **kwargs): while True: if delay is not None: - log.debug("Sleeping for delay:", delay) - time.sleep(delay) + time.sleep(delay) with self.timeout_wrapper(self, self.timeout): start_time = time.time() @@ -132,9 +120,7 @@ def __wrap(*args, **kwargs): # Handle exceptions we are expected to catch, by logging a failed # attempt, and checking the number of attempts. - delay = self.handle_failure(start_time, e) - log.debug("Exception were catch:", e) - log.debug("Continue to next round") + delay = self.handle_failure(start_time) continue except Exception as e: @@ -144,7 +130,6 @@ def __wrap(*args, **kwargs): self.failed_attempts += 1 raise e - delay = self.handle_failure(start_time, "") - - return __wrap + delay = self.handle_failure(start_time) + return __wrap \ No newline at end of file From ff6fe09399544d193ae4c68ac0743ebaab4a033e Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 6 Oct 2017 08:37:46 +0200 Subject: [PATCH 004/117] test module uses this config, after fixing composeurl handling, if there is bad link, causes error --- docs/example-config.yaml | 4 +++- examples/testing-module/simpleTest.py | 2 +- moduleframework/helpers/nspawn_helper.py | 1 - moduleframework/timeoutlib.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/example-config.yaml b/docs/example-config.yaml index b41f0e6..2988937 100644 --- a/docs/example-config.yaml +++ b/docs/example-config.yaml @@ -14,8 +14,10 @@ name: memcached # MANDATORY (or compose-url) modulemd-url: https://src.fedoraproject.org/modules/memcached/raw/master/f/memcached.yaml # final compose done by pungi (contain also modulemd files for modules) can suppy also previous part +# if you use compose, url,repo,repos in module->rpm will be ignored, and there will not be added more repos +# be carefull when using compose url # env var: COMPOSEURL -compose-url: url_to_compose_in done in fedora +compose-url: https://kojipkgs.fedoraproject.org/compose/latest-Fedora-Modular-26/compose/Server/x86_64/os/ # variables what could be used in test service: port: 11211 diff --git a/examples/testing-module/simpleTest.py b/examples/testing-module/simpleTest.py index f0ca682..879fd96 100644 --- a/examples/testing-module/simpleTest.py +++ b/examples/testing-module/simpleTest.py @@ -23,7 +23,7 @@ from moduleframework import module_framework import os -import warnings + class simpleTests(module_framework.AvocadoTest): """ diff --git a/moduleframework/helpers/nspawn_helper.py b/moduleframework/helpers/nspawn_helper.py index 66664e8..48a6110 100644 --- a/moduleframework/helpers/nspawn_helper.py +++ b/moduleframework/helpers/nspawn_helper.py @@ -26,7 +26,6 @@ import time import hashlib -from moduleframework.timeoutlib import Retry from moduleframework.common import * from moduleframework.exceptions import * from moduleframework.helpers.rpm_helper import RpmHelper diff --git a/moduleframework/timeoutlib.py b/moduleframework/timeoutlib.py index 5e7c3c6..c51b6a0 100644 --- a/moduleframework/timeoutlib.py +++ b/moduleframework/timeoutlib.py @@ -132,4 +132,4 @@ def __wrap(*args, **kwargs): delay = self.handle_failure(start_time) - return __wrap \ No newline at end of file + return __wrap From f4565b27dfb0a2497ea3f7982639f08e8657eabe Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 6 Oct 2017 09:24:48 +0200 Subject: [PATCH 005/117] pep8 change --- moduleframework/timeoutlib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/moduleframework/timeoutlib.py b/moduleframework/timeoutlib.py index c51b6a0..c495e0e 100644 --- a/moduleframework/timeoutlib.py +++ b/moduleframework/timeoutlib.py @@ -41,6 +41,7 @@ def __exit__(self, type, value, traceback): signal.alarm(0) signal.signal(signal.SIGALRM, self.orig_sighand) + class NOPTimeout(object): def __init__(self, *args, **kwargs): pass @@ -51,8 +52,9 @@ def __enter__(self): def __exit__(self, *args, **kwargs): pass + class Retry(object): - def __init__(self, attempts = 1, timeout = None, exceptions = (Exception,), error = None, inverse = False, delay = None): + def __init__(self, attempts=1, timeout=None, exceptions=(Exception,), error=None, inverse=False, delay=None): """ Try to run things ATTEMPTS times, at max, each attempt must not exceed TIMEOUT seconds. Restart only when one of EXCEPTIONS is raised, all other exceptions will just bubble up. From f0ee128340e0ff4d81ae8ce86632430dc92d3f7f Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 6 Oct 2017 11:02:28 +0200 Subject: [PATCH 006/117] Hidden feature for install packages from default module via ENVVAR, for further purposes, should not be used now --- moduleframework/common.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/moduleframework/common.py b/moduleframework/common.py index e2e2057..29c6f05 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -82,6 +82,7 @@ # time in seconds DEFAULTRETRYTIMEOUT = 30 DEFAULTNSPAWNTIMEOUT = 10 +MODULE_DEFAULT_PROFILE="default" def get_compose_url_modular_release(): default_release = "27" @@ -153,6 +154,17 @@ def print_debug(*args): if is_debug(): print_info(*args) + +def get_if_install_default_profile(): + """ + Return the **MTF_INSTALL_DEFAULT** envvar. + + :return: bool + """ + reuse = os.environ.get('MTF_INSTALL_DEFAULT') + return bool(reuse) + + def is_recursive_download(): """ Return the **MTF_RECURSIVE_DOWNLOAD** envvar. @@ -427,15 +439,20 @@ def getPackageList(self, profile=None): :return: list of packages (rpms) """ package_list = [] + mddata = self.getModulemdYamlconfig() if not profile: if 'packages' in self.config: packages_rpm = self.config.get('packages',{}).get('rpms', []) packages_profiles = [] for profile_in_conf in self.config.get('packages',{}).get('profiles',[]): - packages_profiles += self.getModulemdYamlconfig()['data']['profiles'][profile_in_conf]['rpms'] + packages_profiles += mddata['data']['profiles'][profile_in_conf]['rpms'] package_list += packages_rpm + packages_profiles + if get_if_install_default_profile(): + profile_append = mddata.get('data',{})\ + .get('profiles',{}).get(MODULE_DEFAULT_PROFILE,{}).get('rpms',[]) + package_list += profile_append else: - package_list += self.getModulemdYamlconfig()['data']['profiles'][profile]['rpms'] + package_list += mddata['data']['profiles'][profile].get('rpms',[]) print_info("PCKGs to install inside module:", package_list) return package_list From ba9b977e92ae7f0ec9fe81a379c17f7e6f32e592 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 6 Oct 2017 12:55:40 +0200 Subject: [PATCH 007/117] fix issue with bad exit code of mtf command --- examples/testing-module/Makefile | 2 ++ tools/mtf | 3 +++ tools/run-them.sh | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/testing-module/Makefile b/examples/testing-module/Makefile index 892da8f..1aca06e 100644 --- a/examples/testing-module/Makefile +++ b/examples/testing-module/Makefile @@ -77,6 +77,8 @@ check-memcached-both: prepare-docker prepare-nspawn cd ../memcached; MODULE=docker $(CMD) sanity1.py cd ../memcached; MODULE=nspawn $(CMD) sanity1.py +check-minimal-config-rpm-noenvvar: prepare-nspawn + MTF_REMOTE_REPOS= DOCKERFILE= MODULE=nspawn $(CMD) $(TESTS) check: check-docker diff --git a/tools/mtf b/tools/mtf index 8e540d9..ec90bfe 100755 --- a/tools/mtf +++ b/tools/mtf @@ -34,6 +34,9 @@ while [[ $# -gt 0 ]]; do shift done +# preserve exist status, mtf command should finish same as testing. avocado run $AVOCADO_ARGS +EXIT_STATUS=$? mtf-log-parser $JSON_LOG rm -f $JSON_LOG +exit $EXIT_STATUS \ No newline at end of file diff --git a/tools/run-them.sh b/tools/run-them.sh index 288f79f..b5d2b20 100755 --- a/tools/run-them.sh +++ b/tools/run-them.sh @@ -41,7 +41,7 @@ export MODULE_TESTS="*.py *.sh" export AVDIR=~/avocado mkdir -p $AVDIR export XUFILE="$AVDIR/out.xunit" -export AVOCADOCMD="avocado run --xunit $XUFILE --show-job-log" +export AVOCADOCMD="mtf --xunit $XUFILE --show-job-log" export RESULTTOOLS=0 #export MTF_RECURSIVE_DOWNLOAD=yes From 5b9627df464aba9aeb74de9b3eba65b95c56a680 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 6 Oct 2017 14:40:04 +0200 Subject: [PATCH 008/117] partial change of backward compatibility wait for exit state fix issue on fedora-26 --wait and -r for systemd-run causes never ending wait fixed --- moduleframework/helpers/nspawn_helper.py | 66 ++++++++++++++++++++---- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/moduleframework/helpers/nspawn_helper.py b/moduleframework/helpers/nspawn_helper.py index 48a6110..4688fed 100644 --- a/moduleframework/helpers/nspawn_helper.py +++ b/moduleframework/helpers/nspawn_helper.py @@ -25,6 +25,8 @@ import glob import time import hashlib +import string +import random from moduleframework.common import * from moduleframework.exceptions import * @@ -57,6 +59,7 @@ def __init__(self): self.jmeno = self.moduleName self.chrootpath = os.path.abspath(self.baseprefix + self.jmeno) self.__default_command_sleep = 2 + self.__systemd_wait_support = False def __machined_restart(self): """ @@ -84,6 +87,7 @@ def setUp(self): self.__prepareSetup() self._callSetupFromConfig() self.__bootMachine() + self.__systemd_wait_support = self.__run_systemdrun_decide() def __is_killed(self): for foo in range(DEFAULTRETRYTIMEOUT): @@ -229,6 +233,37 @@ def start(self, command="/bin/true"): self.status() trans_dict["GUESTPACKAGER"] = self.get_packager() + def __run_systemdrun_decide(self): + return "--wait" in self.runHost("systemd-run --help",verbose=is_debug()).stdout + + def __systemctl_wait_until_finish(self, machine, unit): + """ + Wait until service is finished and return exit state + + :param machine: + :param unit: + :return: + """ + retcode = 0 + while True: + output = [x.strip() for x in + self.runHost("systemctl show -M {} {}".format(machine, unit), + verbose=False).stdout.split("\n")] + if is_debug(): + print_debug(output) + retcode = int([x[-1] for x in output if "ExecMainStatus=" in x][0]) + if not ("SubState=exited" in output or "SubState=failed" in output): + time.sleep(0.1) + else: + break + self.runHost("systemctl -M {} stop {}".format(machine, unit), + verbose=is_debug(), + ignore_status=True) + return retcode + + def __systemd_generate_unit_name(self): + return ''.join(random.choice(string.ascii_lowercase) for _ in range(10)) + def __run_systemdrun(self, command, internal_background=False, **kwargs): """ Run command inside nspawn module type. It uses systemd-run. @@ -239,26 +274,39 @@ def __run_systemdrun(self, command, internal_background=False, **kwargs): :return: avocado.process.run """ self.__machined_restart() - lpath = "/var/tmp" - add_wait_var = "--wait" add_sleep_infinite = "" + unit_name = self.__systemd_generate_unit_name() + lpath = "/var/tmp/{}".format(unit_name) + if self.__systemd_wait_support: + add_wait_var = "--wait" + else: + # keep service exist after it finish, to be able to read exit code + add_wait_var = "-r" if internal_background: - add_wait_var="" + add_wait_var = "" add_sleep_infinite = "&& sleep infinity" + opts = " --unit {unitname} {wait} -M {machine}".format(wait=add_wait_var, + machine=self.jmeno, + unitname=unit_name + ) try: - comout = self.runHost("""systemd-run {wait} -M {machine} /bin/bash -c "({comm})>{pin}/stdout 2>{pin}/stderr {sleep}" """.format( - wait=add_wait_var, - machine=self.jmeno, + comout = self.runHost("""systemd-run {opts} /bin/bash -c "({comm})>{pin}.stdout 2>{pin}.stderr {sleep}" """.format( + opts=opts, comm=sanitize_cmd(command), pin=lpath, - sleep=add_sleep_infinite), + sleep=add_sleep_infinite, + ), **kwargs) if not internal_background: - with open("{chroot}{pin}/stdout".format(chroot=self.chrootpath, pin=lpath), 'r') as content_file: + if not self.__systemd_wait_support: + comout.exit_status = self.__systemctl_wait_until_finish(self.jmeno,unit_name) + with open("{chroot}{pin}.stdout".format(chroot=self.chrootpath, pin=lpath), 'r') as content_file: comout.stdout = content_file.read() - with open("{chroot}{pin}/stderr".format(chroot=self.chrootpath, pin=lpath), 'r') as content_file: + with open("{chroot}{pin}.stderr".format(chroot=self.chrootpath, pin=lpath), 'r') as content_file: comout.stderr = content_file.read() comout.command = command + os.remove("{chroot}{pin}.stdout".format(chroot=self.chrootpath, pin=lpath)) + os.remove("{chroot}{pin}.stderr".format(chroot=self.chrootpath, pin=lpath)) print_debug(comout) return comout except process.CmdError as e: From c59847c65a0c000ab1255311e790506ffc23e587 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Mon, 9 Oct 2017 16:22:10 +0200 Subject: [PATCH 009/117] add comment and link to bugzilla --- moduleframework/helpers/nspawn_helper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/moduleframework/helpers/nspawn_helper.py b/moduleframework/helpers/nspawn_helper.py index 4688fed..f0b75c1 100644 --- a/moduleframework/helpers/nspawn_helper.py +++ b/moduleframework/helpers/nspawn_helper.py @@ -239,6 +239,8 @@ def __run_systemdrun_decide(self): def __systemctl_wait_until_finish(self, machine, unit): """ Wait until service is finished and return exit state + It workarounds issue: https://bugzilla.redhat.com/show_bug.cgi?id=1499877 + After it will be fixed, this can be removed :param machine: :param unit: From e71c3bf5acc22ada270ee2428046f0c4692daeff Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Tue, 10 Oct 2017 11:29:33 +0200 Subject: [PATCH 010/117] linter: check Red Hat's and Fedora's images Fixes #132 Signed-off-by: Tomas Tomecek --- Makefile | 9 + examples/linter/f26-etcd/config.yaml | 7 + examples/linter/f26-flannel/config.yaml | 7 + examples/linter/rhscl-nginx/config.yaml | 7 + examples/linter/rhscl-postgresql/config.yaml | 7 + examples/linter/tools/Dockerfile | 59 ++++ examples/linter/tools/config.yaml | 7 + examples/linter/tools/root/README.md | 267 +++++++++++++++++++ 8 files changed, 370 insertions(+) create mode 100644 examples/linter/f26-etcd/config.yaml create mode 100644 examples/linter/f26-flannel/config.yaml create mode 100644 examples/linter/rhscl-nginx/config.yaml create mode 100644 examples/linter/rhscl-postgresql/config.yaml create mode 100644 examples/linter/tools/Dockerfile create mode 100644 examples/linter/tools/config.yaml create mode 100644 examples/linter/tools/root/README.md diff --git a/Makefile b/Makefile index 7950cad..75112b2 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,17 @@ all: install check check: make -C examples/testing-module check +check-linter: + @# don't use $(shell ) -- it messes out output + cd examples/linter/tools && PYTHONPATH=${PWD} MODULE=docker ${PWD}/tools/mtf -l + cd examples/linter/rhscl-postgresql && PYTHONPATH=${PWD} MODULE=docker ${PWD}/tools/mtf -l + cd examples/linter/rhscl-nginx && PYTHONPATH=${PWD} MODULE=docker ${PWD}/tools/mtf -l + cd examples/linter/f26-etcd && PYTHONPATH=${PWD} MODULE=docker ${PWD}/tools/mtf -l + cd examples/linter/f26-flannel && PYTHONPATH=${PWD} MODULE=docker ${PWD}/tools/mtf -l + travis: make -C examples/testing-module travis + cd examples/linter/tools && PYTHONPATH=${PWD} MODULE=docker ${PWD}/tools/mtf -l .PHONY: clean diff --git a/examples/linter/f26-etcd/config.yaml b/examples/linter/f26-etcd/config.yaml new file mode 100644 index 0000000..26052b1 --- /dev/null +++ b/examples/linter/f26-etcd/config.yaml @@ -0,0 +1,7 @@ +document: meta-test +version: 1 +name: etcd +module: + docker: + start: "docker run -it -d" + container: registry.fedoraproject.org/f26/etcd diff --git a/examples/linter/f26-flannel/config.yaml b/examples/linter/f26-flannel/config.yaml new file mode 100644 index 0000000..18f8939 --- /dev/null +++ b/examples/linter/f26-flannel/config.yaml @@ -0,0 +1,7 @@ +document: meta-test +version: 1 +name: flannel +module: + docker: + start: "docker run -it -d" + container: registry.fedoraproject.org/f26/flannel diff --git a/examples/linter/rhscl-nginx/config.yaml b/examples/linter/rhscl-nginx/config.yaml new file mode 100644 index 0000000..1535e3b --- /dev/null +++ b/examples/linter/rhscl-nginx/config.yaml @@ -0,0 +1,7 @@ +document: meta-test +version: 1 +name: etcd +module: + docker: + start: "docker run -it -d" + container: registry.access.redhat.com/rhscl/nginx-18-rhel7 diff --git a/examples/linter/rhscl-postgresql/config.yaml b/examples/linter/rhscl-postgresql/config.yaml new file mode 100644 index 0000000..595e21d --- /dev/null +++ b/examples/linter/rhscl-postgresql/config.yaml @@ -0,0 +1,7 @@ +document: meta-test +version: 1 +name: etcd +module: + docker: + start: "docker run -it -d -e POSTGRESQL_USER=test -e POSTGRESQL_PASSWORD=testt -e POSTGRESQL_DATABASE=db" + container: registry.access.redhat.com/rhscl/postgresql-95-rhel7 diff --git a/examples/linter/tools/Dockerfile b/examples/linter/tools/Dockerfile new file mode 100644 index 0000000..5b1d5ce --- /dev/null +++ b/examples/linter/tools/Dockerfile @@ -0,0 +1,59 @@ +FROM registry.fedoraproject.org/fedora:26 +LABEL maintainer "Tomas Tomecek \"ttomecek@redhat.com\"" + +ENV NAME=tools VERSION=0 RELEASE=2 ARCH=x86_64 +LABEL com.redhat.component="$NAME" \ + name="$FGC/$NAME" \ + version="$VERSION" \ + release="$RELEASE.$DISTTAG" \ + architecture="$ARCH" \ + run="docker run -it --name NAME --privileged --ipc=host --net=host --pid=host -e HOST=/host -e NAME=NAME -e IMAGE=IMAGE -v /run:/run -v /var/log:/var/log -v /etc/machine-id:/etc/machine-id -v /etc/localtime:/etc/localtime -v /:/host IMAGE" \ + summary="container with all the management tools you miss in Atomic Host" + +# vim-minimal conflicts with vim-enhanced +RUN dnf remove -y vim-minimal && dnf install -y \ + bash-completion \ + bc \ + bind-utils \ + blktrace \ + crash \ + e2fsprogs \ + ethtool \ + file \ + gcc \ + gdb \ + git-core \ + glibc-utils \ + gomtree \ + htop \ + hwloc \ + iotop \ + iproute \ + iputils \ + less \ + ltrace \ + mailx \ + net-tools \ + netsniff-ng \ + nmap-ncat \ + numactl \ + numactl-devel \ + parted \ + pciutils \ + perf \ + procps-ng \ + psmisc \ + screen \ + sos \ + strace \ + sysstat \ + tcpdump \ + tmux \ + vim-enhanced \ + xfsprogs \ + && dnf clean all + +# FIXME: current go-md2man can't convert tables :< +COPY ./root/ / + +CMD ["/usr/bin/bash"] diff --git a/examples/linter/tools/config.yaml b/examples/linter/tools/config.yaml new file mode 100644 index 0000000..42db5d6 --- /dev/null +++ b/examples/linter/tools/config.yaml @@ -0,0 +1,7 @@ +document: meta-test +version: 1 +name: tools +module: + docker: + start: "docker run -it -d" + container: candidate-registry.fedoraproject.org/f26/tools:latest diff --git a/examples/linter/tools/root/README.md b/examples/linter/tools/root/README.md new file mode 100644 index 0000000..fe8e338 --- /dev/null +++ b/examples/linter/tools/root/README.md @@ -0,0 +1,267 @@ +% tools (1) Container Image Pages +% Tomas Tomecek +% September 11th, 2017 + +# NAME +tools - container with all the management tools you miss in Atomic Host + + +# DESCRIPTION +You find plenty of well-known tools within this container. Here comes the full list: + +| Package | Summary | Executables | +| --------------- | ------------------------------------------------------------------------------------------- | ---------------------------------- | +| bash-completion | Programmable completion for Bash | | +| bc | GNU's bc (a numeric processing language) and dc (a calculator) | /usr/bin/bc | +| | | /usr/bin/dc | +| bind-utils | Utilities for querying DNS name servers | /usr/bin/arpaname | +| | | /usr/bin/delv | +| | | /usr/bin/dig | +| | | /usr/bin/host | +| | | /usr/bin/nslookup | +| | | /usr/bin/nsupdate | +| | | /usr/sbin/ddns-confgen | +| | | /usr/sbin/dnssec-checkds | +| | | /usr/sbin/dnssec-coverage | +| | | /usr/sbin/dnssec-dsfromkey | +| | | /usr/sbin/dnssec-importkey | +| | | /usr/sbin/dnssec-keyfromlabel | +| | | /usr/sbin/dnssec-keygen | +| | | /usr/sbin/dnssec-keymgr | +| | | /usr/sbin/dnssec-revoke | +| | | /usr/sbin/dnssec-settime | +| | | /usr/sbin/dnssec-signzone | +| | | /usr/sbin/dnssec-verify | +| | | /usr/sbin/genrandom | +| | | /usr/sbin/isc-hmac-fixup | +| | | /usr/sbin/named-checkzone | +| | | /usr/sbin/named-compilezone | +| | | /usr/sbin/nsec3hash | +| | | /usr/sbin/tsig-keygen | +| blktrace | Utilities for performing block layer IO tracing in the Linux kernel | /usr/bin/blkiomon | +| | | /usr/bin/blkparse | +| | | /usr/bin/blkrawverify | +| | | /usr/bin/blktrace | +| | | /usr/bin/bno_plot.py | +| | | /usr/bin/btrace | +| | | /usr/bin/btrecord | +| | | /usr/bin/btreplay | +| | | /usr/bin/btt | +| | | /usr/bin/verify_blkparse | +| crash | Kernel analysis utility for live systems, netdump, diskdump, kdump, LKCD or mcore dumpfiles | /usr/bin/crash | +| e2fsprogs | Utilities for managing ext2, ext3, and ext4 filesystems | /usr/bin/chattr | +| | | /usr/bin/lsattr | +| | | /usr/sbin/badblocks | +| | | /usr/sbin/debugfs | +| | | /usr/sbin/dumpe2fs | +| | | /usr/sbin/e2freefrag | +| | | /usr/sbin/e2fsck | +| | | /usr/sbin/e2image | +| | | /usr/sbin/e2label | +| | | /usr/sbin/e2undo | +| | | /usr/sbin/e4crypt | +| | | /usr/sbin/e4defrag | +| | | /usr/sbin/filefrag | +| | | /usr/sbin/fsck.ext2 | +| | | /usr/sbin/fsck.ext3 | +| | | /usr/sbin/fsck.ext4 | +| | | /usr/sbin/fuse2fs | +| | | /usr/sbin/logsave | +| | | /usr/sbin/mke2fs | +| | | /usr/sbin/mkfs.ext2 | +| | | /usr/sbin/mkfs.ext3 | +| | | /usr/sbin/mkfs.ext4 | +| | | /usr/sbin/mklost+found | +| | | /usr/sbin/resize2fs | +| | | /usr/sbin/tune2fs | +| ethtool | Settings tool for Ethernet NICs | /usr/sbin/ethtool | +| file | A utility for determining file types | /usr/bin/file | +| gcc | Various compilers (C, C++, Objective-C, Java, ...) | /usr/bin/c89 | +| | | /usr/bin/c99 | +| | | /usr/bin/cc | +| | | /usr/bin/gcc | +| | | /usr/bin/gcc-ar | +| | | /usr/bin/gcc-nm | +| | | /usr/bin/gcc-ranlib | +| | | /usr/bin/gcov | +| | | /usr/bin/gcov-tool | +| | | /usr/bin/x86_64-redhat-linux-gcc | +| | | /usr/bin/x86_64-redhat-linux-gcc-7 | +| gdb | A stub package for GNU source-level debugger | /usr/bin/gcore | +| | | /usr/bin/gdb | +| | | /usr/bin/gstack | +| | | /usr/bin/pstack | +| git-core | Core package of git with minimal functionality | /usr/bin/git | +| | | /usr/bin/git-receive-pack | +| | | /usr/bin/git-shell | +| | | /usr/bin/git-upload-archive | +| | | /usr/bin/git-upload-pack | +| glibc-utils | Development utilities from GNU C library | /usr/bin/memusage | +| | | /usr/bin/memusagestat | +| | | /usr/bin/mtrace | +| | | /usr/bin/pcprofiledump | +| | | /usr/bin/xtrace | +| gomtree | Go CLI tool for mtree support | /usr/bin/gomtree | +| htop | Interactive process viewer | /usr/bin/htop | +| hwloc | Portable Hardware Locality - portable abstraction of hierarchical architectures | /usr/bin/hwloc-annotate | +| | | /usr/bin/hwloc-assembler | +| | | /usr/bin/hwloc-assembler-remote | +| | | /usr/bin/hwloc-bind | +| | | /usr/bin/hwloc-calc | +| | | /usr/bin/hwloc-compress-dir | +| | | /usr/bin/hwloc-diff | +| | | /usr/bin/hwloc-distances | +| | | /usr/bin/hwloc-distrib | +| | | /usr/bin/hwloc-gather-topology | +| | | /usr/bin/hwloc-info | +| | | /usr/bin/hwloc-ls | +| | | /usr/bin/hwloc-patch | +| | | /usr/bin/hwloc-ps | +| | | /usr/bin/lstopo-no-graphics | +| | | /usr/sbin/hwloc-dump-hwdata | +| iotop | Top like utility for I/O | /usr/sbin/iotop | +| iproute | Advanced IP routing and network device configuration tools | /usr/sbin/arpd | +| | | /usr/sbin/bridge | +| | | /usr/sbin/ctstat | +| | | /usr/sbin/devlink | +| | | /usr/sbin/genl | +| | | /usr/sbin/ifcfg | +| | | /usr/sbin/ifstat | +| | | /usr/sbin/ip | +| | | /usr/sbin/lnstat | +| | | /usr/sbin/nstat | +| | | /usr/sbin/routef | +| | | /usr/sbin/routel | +| | | /usr/sbin/rtacct | +| | | /usr/sbin/rtmon | +| | | /usr/sbin/rtpr | +| | | /usr/sbin/rtstat | +| | | /usr/sbin/ss | +| | | /usr/sbin/tipc | +| iputils | Network monitoring tools including ping | /usr/bin/ping | +| | | /usr/bin/tracepath | +| | | /usr/sbin/arping | +| | | /usr/sbin/clockdiff | +| | | /usr/sbin/ifenslave | +| | | /usr/sbin/ping | +| | | /usr/sbin/ping6 | +| | | /usr/sbin/rdisc | +| | | /usr/sbin/tracepath | +| | | /usr/sbin/tracepath6 | +| less | A text file browser similar to more, but better | /usr/bin/less | +| | | /usr/bin/lessecho | +| | | /usr/bin/lesskey | +| | | /usr/bin/lesspipe.sh | +| ltrace | Tracks runtime library calls from dynamically linked executables | /usr/bin/ltrace | +| mailx | Enhanced implementation of the mailx command | /usr/bin/Mail | +| | | /usr/bin/nail | +| net-tools | Basic networking tools | /usr/bin/netstat | +| | | /usr/sbin/arp | +| | | /usr/sbin/ether-wake | +| | | /usr/sbin/ifconfig | +| | | /usr/sbin/ipmaddr | +| | | /usr/sbin/iptunnel | +| | | /usr/sbin/mii-diag | +| | | /usr/sbin/mii-tool | +| | | /usr/sbin/nameif | +| | | /usr/sbin/plipconfig | +| | | /usr/sbin/route | +| | | /usr/sbin/slattach | +| netsniff-ng | Packet sniffing beast | /usr/sbin/astraceroute | +| | | /usr/sbin/bpfc | +| | | /usr/sbin/curvetun | +| | | /usr/sbin/flowtop | +| | | /usr/sbin/ifpps | +| | | /usr/sbin/mausezahn | +| | | /usr/sbin/netsniff-ng | +| | | /usr/sbin/trafgen | +| nmap-ncat | Nmap's Netcat replacement | /usr/bin/nc | +| | | /usr/bin/ncat | +| numactl | Library for tuning for Non Uniform Memory Access machines | /usr/bin/memhog | +| | | /usr/bin/migratepages | +| | | /usr/bin/migspeed | +| | | /usr/bin/numactl | +| | | /usr/bin/numademo | +| | | /usr/bin/numastat | +| numactl-devel | Development package for building Applications that use numa | | +| parted | The GNU disk partition manipulation program | | +| pciutils | PCI bus related utilities | /usr/sbin/update-pciids | +| perf | Performance monitoring for the Linux kernel | /usr/bin/perf | +| procps-ng | System and process monitoring utilities | /usr/bin/free | +| | | /usr/bin/pgrep | +| | | /usr/bin/pidof | +| | | /usr/bin/pkill | +| | | /usr/bin/pmap | +| | | /usr/bin/ps | +| | | /usr/bin/pwdx | +| | | /usr/bin/skill | +| | | /usr/bin/slabtop | +| | | /usr/bin/snice | +| | | /usr/bin/tload | +| | | /usr/bin/top | +| | | /usr/bin/uptime | +| | | /usr/bin/vmstat | +| | | /usr/bin/w | +| | | /usr/bin/watch | +| | | /usr/sbin/pidof | +| | | /usr/sbin/sysctl | +| psmisc | Utilities for managing processes on your system | /usr/bin/killall | +| | | /usr/bin/peekfd | +| | | /usr/bin/prtstat | +| | | /usr/bin/pstree | +| | | /usr/bin/pstree.x11 | +| | | /usr/sbin/fuser | +| screen | A screen manager that supports multiple logins on one terminal | /usr/bin/screen | +| sos | A set of tools to gather troubleshooting information from a system | /usr/sbin/sosreport | +| strace | Tracks and displays system calls associated with a running process | /usr/bin/strace | +| | | /usr/bin/strace-log-merge | +| sysstat | Collection of performance monitoring tools for Linux | /usr/bin/cifsiostat | +| | | /usr/bin/iostat | +| | | /usr/bin/mpstat | +| | | /usr/bin/pidstat | +| | | /usr/bin/sadf | +| | | /usr/bin/sar | +| | | /usr/bin/tapestat | +| tcpdump | A network traffic monitoring tool | /usr/sbin/tcpdump | +| | | /usr/sbin/tcpslice | +| tmux | A terminal multiplexer | /usr/bin/tmux | +| vim-enhanced | A version of the VIM editor which includes recent enhancements | /usr/bin/rvim | +| | | /usr/bin/vim | +| | | /usr/bin/vimdiff | +| | | /usr/bin/vimtutor | +| xfsprogs | Utilities for managing the XFS filesystem | /usr/sbin/fsck.xfs | +| | | /usr/sbin/mkfs.xfs | +| | | /usr/sbin/xfs_admin | +| | | /usr/sbin/xfs_bmap | +| | | /usr/sbin/xfs_copy | +| | | /usr/sbin/xfs_db | +| | | /usr/sbin/xfs_estimate | +| | | /usr/sbin/xfs_freeze | +| | | /usr/sbin/xfs_fsr | +| | | /usr/sbin/xfs_growfs | +| | | /usr/sbin/xfs_info | +| | | /usr/sbin/xfs_io | +| | | /usr/sbin/xfs_logprint | +| | | /usr/sbin/xfs_mdrestore | +| | | /usr/sbin/xfs_metadump | +| | | /usr/sbin/xfs_mkfile | +| | | /usr/sbin/xfs_ncheck | +| | | /usr/sbin/xfs_quota | +| | | /usr/sbin/xfs_repair | +| | | /usr/sbin/xfs_rtcp | + + +# USAGE +You should invoke this container using `atomic` command: + +``` +$ atomic run f26/tools +``` + + +# SECURITY IMPLICATIONS +This container runs as a super-privileged container: it has full root access. + + +# HISTORY +Release 1: initial release From f419cd5afba8776f4523c7a81181f7a456ea3267 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 3 Oct 2017 12:15:49 +0200 Subject: [PATCH 011/117] Check for is FROM first Signed-off-by: Petr "Stone" Hracek --- moduleframework/dockerlinter.py | 13 +++++++++---- moduleframework/tools/modulelint.py | 2 ++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 06c81dd..c582bec 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -163,9 +163,14 @@ def get_specific_label(self, label_name=None): label_list = self.get_docker_labels() return [label_name in label_list] - def check_baseruntime(self): + def check_from_is_first(self): """ - Function returns docker labels - :return: label dictionary + Function checks if FROM directive is really first directive. + Function ignores whitespaces and comments. + :return: True if FROM is first, False if FROM is not first directive """ - return [x for x in self.docker_dict.get(FROM,[]) if "baseruntime/baseruntime" in x] + with open(self.dockerfile) as f: + lines = f.readlines() + lines = [x for x in lines if x.isspace()] + lines = [x for x in lines if x.strip().startswith("#")] + return lines diff --git a/moduleframework/tools/modulelint.py b/moduleframework/tools/modulelint.py index 5212a98..69f8461 100644 --- a/moduleframework/tools/modulelint.py +++ b/moduleframework/tools/modulelint.py @@ -70,6 +70,8 @@ def test_run_or_usage_label_exists(self): label_found = self.dp.get_specific_label("usage") self.assertTrue(label_found) + def test_from_is_first_directive(self): + self.assertTrue(self.dp.check_from_is_first()) class DockerLint(container_avocado_test.ContainerAvocadoTest): From 58910c6b5d224e0c190fcb52cbe8c5db8ea076af Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Wed, 4 Oct 2017 09:50:59 +0200 Subject: [PATCH 012/117] Linter for help.md file Signed-off-by: Petr "Stone" Hracek --- meta-test-family.spec | 1 + moduleframework/dockerlinter.py | 5 +- moduleframework/helpfile_linter.py | 66 +++++++++++++++++++ .../tools/{__init__.py => helpmd_lint.py} | 25 +++++-- requirements.txt | 1 + 5 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 moduleframework/helpfile_linter.py rename moduleframework/tools/{__init__.py => helpmd_lint.py} (63%) diff --git a/meta-test-family.spec b/meta-test-family.spec index 8341b77..a8cccad 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -22,6 +22,7 @@ Requires: docker Requires: python2-pdc-client Requires: python2-modulemd Requires: python2-dockerfile-parse +Requires: python-mistune Provides: modularity-testing-framework = %{version}-%{release} Obsoletes: modularity-testing-framework < 0.5.18-2 diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index c582bec..048ce88 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -170,7 +170,6 @@ def check_from_is_first(self): :return: True if FROM is first, False if FROM is not first directive """ with open(self.dockerfile) as f: - lines = f.readlines() - lines = [x for x in lines if x.isspace()] - lines = [x for x in lines if x.strip().startswith("#")] + lines = [x for x in f.readlines() if not x.isspace()] + lines = [x for x in lines if not x.strip().startswith("#")] return lines diff --git a/moduleframework/helpfile_linter.py b/moduleframework/helpfile_linter.py new file mode 100644 index 0000000..7657121 --- /dev/null +++ b/moduleframework/helpfile_linter.py @@ -0,0 +1,66 @@ +from __future__ import print_function + +import mistune +import os +from moduleframework import common + +HELP_MD = "help.md" + + +def get_help_md_file(dir_name): + helpmd_file = os.path.join(os.path.abspath(dir_name), "help", HELP_MD) + if not os.path.exists(helpmd_file): + helpmd_file = None + common.print_debug("help.md should exists in the %s directory." % dir_name) + return helpmd_file + + +class HelpMDLinter(object): + """ + Class checks a Help.md file + + It requires only directory with help.md file. + """ + + help_md = None + + def __init__(self, dir_name="../"): + help_md_file = get_help_md_file(dir_name) + if help_md_file: + renderer = mistune.Renderer(escape=False, + hard_wrap=False, + parse_block_html=False, + parse_inline_html=False) + md = mistune.Markdown(renderer=renderer) + with open(help_md_file, 'r') as f: + self.help_md = md.parse(f.read()) + else: + self.help_md = None + print(self.help_md) + + def get_image_name(self): + pass + + def get_maintainer(self): + pass + + def get_date(self): + pass + + def get_name(self): + pass + + def get_description(self): + pass + + def get_usage(self): + pass + + def get_environment_variables(self): + pass + + def get_labels(self): + pass + + def get_security_implications(self): + pass diff --git a/moduleframework/tools/__init__.py b/moduleframework/tools/helpmd_lint.py similarity index 63% rename from moduleframework/tools/__init__.py rename to moduleframework/tools/helpmd_lint.py index 6d224aa..8902cdb 100644 --- a/moduleframework/tools/__init__.py +++ b/moduleframework/tools/helpmd_lint.py @@ -18,16 +18,31 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # -# Copied from: https://github.com/fedora-modularity/check_compose/blob/master/check_compose.py -# # Authors: Jan Scotka # +from __future__ import print_function +import os from moduleframework import module_framework +from moduleframework import helpfile_linter +from moduleframework import dockerlinter + -class ModulelintSanity(module_framework.AvocadoTest): +class DockerFileLinter(module_framework.AvocadoTest): """ :avocado: enable + """ - def testPass(self): - pass \ No newline at end of file + + dp = None + + def setUp(self): + # it is not intended just for docker, but just docker packages are + # actually properly signed + self.dp = dockerlinter.DockerfileLinter() + self.helpmd = helpfile_linter.HelpMDLinter() + if self.dp.dockerfile is None: + self.skip() + + def test_helpmd_exists(self): + self.assertTrue(self.helpmd) diff --git a/requirements.txt b/requirements.txt index 4167436..d2cdac4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ dockerfile-parse modulemd netifaces pdc-client +mistune \ No newline at end of file From b774c5a9eeb44a17239cf8892288e941aff1cc34 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Thu, 5 Oct 2017 09:36:53 +0200 Subject: [PATCH 013/117] help.md sanity checker Signed-off-by: Petr "Stone" Hracek --- moduleframework/dockerlinter.py | 4 +- moduleframework/helpfile_linter.py | 50 ++++-------- moduleframework/tools/dockerlint.py | 112 +++++++++++++++++++++++++++ moduleframework/tools/helpmd_lint.py | 27 ++++++- moduleframework/tools/modulelint.py | 81 ------------------- 5 files changed, 157 insertions(+), 117 deletions(-) create mode 100644 moduleframework/tools/dockerlint.py diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 048ce88..ba3be8a 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -51,8 +51,8 @@ class DockerfileLinter(object): def __init__(self, dir_name="../"): dockerfile = get_docker_file(dir_name) if dockerfile: - self.dfp = DockerfileParser(path=os.path.dirname(dockerfile)) self.dockerfile = dockerfile + self.dfp = DockerfileParser(path=os.path.dirname(dockerfile)) self._get_structure_as_dict() else: self.dfp = None @@ -133,7 +133,7 @@ def get_docker_specific_env(self, env_name=None): if env_name is None: return [] env_list = self.get_docker_env() - return [env_name in env_list] + return env_name in env_list def get_docker_expose(self): """ diff --git a/moduleframework/helpfile_linter.py b/moduleframework/helpfile_linter.py index 7657121..1e97942 100644 --- a/moduleframework/helpfile_linter.py +++ b/moduleframework/helpfile_linter.py @@ -1,6 +1,5 @@ from __future__ import print_function -import mistune import os from moduleframework import common @@ -27,40 +26,25 @@ class HelpMDLinter(object): def __init__(self, dir_name="../"): help_md_file = get_help_md_file(dir_name) if help_md_file: - renderer = mistune.Renderer(escape=False, - hard_wrap=False, - parse_block_html=False, - parse_inline_html=False) - md = mistune.Markdown(renderer=renderer) with open(help_md_file, 'r') as f: - self.help_md = md.parse(f.read()) + lines = f.readlines() + # Count with all lines which begins with # + self.help_md = [x.strip() for x in lines if x.startswith('#')] + # Count with all lines which begins with % + self.help_md.extend([x.strip() for x in lines if x.startswith('%')]) else: self.help_md = None - print(self.help_md) - def get_image_name(self): - pass - - def get_maintainer(self): - pass - - def get_date(self): - pass - - def get_name(self): - pass - - def get_description(self): - pass - - def get_usage(self): - pass - - def get_environment_variables(self): - pass - - def get_labels(self): - pass + def get_image_maintainer_name(self, name): + name = '% %s' % name + if name.upper() in self.help_md: + return True + else: + return False - def get_security_implications(self): - pass + def get_tag(self, name): + name = '# %s' % name + if name.upper() in self.help_md: + return True + else: + return False diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py new file mode 100644 index 0000000..4768390 --- /dev/null +++ b/moduleframework/tools/dockerlint.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# +# Meta test family (MTF) is a tool to test components of a modular Fedora: +# https://docs.pagure.org/modularity/ +# Copyright (C) 2017 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Authors: Jan Scotka +# +from __future__ import print_function +import os + +from moduleframework import module_framework +from moduleframework import dockerlinter +from moduleframework.avocado_testers import container_avocado_test + + +class DockerFileLinter(module_framework.AvocadoTest): + """ + :avocado: enable + + """ + + dp = None + + def setUp(self): + # it is not intended just for docker, but just docker packages are + # actually properly signed + self.dp = dockerlinter.DockerfileLinter() + if self.dp.dockerfile is None: + self.skip() + + def test_architecture_in_env_and_label_exists(self): + self.assertTrue(self.dp.get_docker_specific_env("ARCH=")) + self.assertTrue(self.dp.get_specific_label("architecture")) + + def test_name_in_env_and_label_exists(self): + self.assertTrue(self.dp.get_docker_specific_env("NAME=")) + self.assertTrue(self.dp.get_specific_label("name")) + + def test_maintainer_label_exists(self): + self.assertTrue(self.dp.get_specific_label("maintainer")) + + def test_release_label_exists(self): + self.assertTrue(self.dp.get_specific_label("release")) + + def test_version_label_exists(self): + self.assertTrue(self.dp.get_specific_label("version")) + + def test_com_redhat_component_label_exists(self): + self.assertTrue(self.dp.get_specific_label("com.redhat.component")) + + def test_summary_label_exists(self): + self.assertTrue(self.dp.get_specific_label("summary")) + + def test_run_or_usage_label_exists(self): + label_found = True + run = self.dp.get_specific_label("run") + if not run: + label_found = self.dp.get_specific_label("usage") + self.assertTrue(label_found) + + def test_from_is_first_directive(self): + self.assertTrue(self.dp.check_from_is_first()) + + def test_from_correct_format(self): + self.assertTrue(self.dp.check_from_format()) + + +class DockerLint(container_avocado_test.ContainerAvocadoTest): + """ + :avocado: enable + """ + + def testBasic(self): + self.start() + self.assertTrue("bin" in self.run("ls /").stdout) + + def testContainerIsRunning(self): + """ + Function tests whether container is running + :return: + """ + self.start() + self.assertIn(self.backend.jmeno.rsplit("/")[-1], self.runHost("docker ps").stdout) + + def testLabels(self): + """ + Function tests whether labels are set in modulemd YAML file properly. + :return: + """ + llabels = self.getConfigModule().get('labels') + if llabels is None or len(llabels) == 0: + print("No labels defined in config to check") + self.cancel() + for key in self.getConfigModule()['labels']: + aaa = self.checkLabel(key, self.getConfigModule()['labels'][key]) + print(">>>>>> ", aaa, key) + self.assertTrue(aaa) diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index 8902cdb..546401e 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -28,7 +28,7 @@ from moduleframework import dockerlinter -class DockerFileLinter(module_framework.AvocadoTest): +class HelpMDLinter(module_framework.AvocadoTest): """ :avocado: enable @@ -46,3 +46,28 @@ def setUp(self): def test_helpmd_exists(self): self.assertTrue(self.helpmd) + + def test_helpmd_image_name(self): + container_name = self.dp.get_docker_specific_env("NAME") + if container_name: + self.assertTrue(self.helpmd.get_image_maintainer_name(container_name.split('=')[1])) + + def test_helpmd_maintainer_name(self): + maintainer_name = self.dp.get_specific_label("maintainer") + if maintainer_name: + self.assertTrue(self.helpmd.get_image_maintainer_name(maintainer_name)) + + def test_helpmd_name(self): + self.assertTrue(self.helpmd.get_tag("NAME")) + + def test_helpmd_description(self): + self.assertTrue(self.helpmd.get_tag("DESCRIPTION")) + + def test_helpmd_usage(self): + self.assertTrue(self.helpmd.get_tag("USAGE")) + + def test_helpmd_environment_variables(self): + self.assertTrue(self.helpmd.get_tag("ENVIRONMENT VARIABLES")) + + def test_helpmd_security_implications(self): + self.assertTrue(self.helpmd.get_tag("SECURITY IMPLICATIONS")) diff --git a/moduleframework/tools/modulelint.py b/moduleframework/tools/modulelint.py index 69f8461..0f6db68 100644 --- a/moduleframework/tools/modulelint.py +++ b/moduleframework/tools/modulelint.py @@ -21,89 +21,8 @@ # Authors: Jan Scotka # from __future__ import print_function -import os from moduleframework import module_framework -from moduleframework import dockerlinter -from moduleframework.avocado_testers import container_avocado_test - - -class DockerFileLinter(module_framework.AvocadoTest): - """ - :avocado: enable - - """ - - dp = None - - def setUp(self): - # it is not intended just for docker, but just docker packages are - # actually properly signed - self.dp = dockerlinter.DockerfileLinter() - if self.dp.dockerfile is None: - self.skip() - - def test_architecture_in_env_and_label_exists(self): - self.assertTrue(self.dp.get_docker_specific_env("ARCH=")) - self.assertTrue(self.dp.get_specific_label("architecture")) - - def test_name_in_env_and_label_exists(self): - self.assertTrue(self.dp.get_docker_specific_env("NAME=")) - self.assertTrue(self.dp.get_specific_label("name")) - - def test_release_label_exists(self): - self.assertTrue(self.dp.get_specific_label("release")) - - def test_version_label_exists(self): - self.assertTrue(self.dp.get_specific_label("version")) - - def test_com_redhat_component_label_exists(self): - self.assertTrue(self.dp.get_specific_label("com.redhat.component")) - - def test_summary_label_exists(self): - self.assertTrue(self.dp.get_specific_label("summary")) - - def test_run_or_usage_label_exists(self): - label_found = True - run = self.dp.get_specific_label("run") - if not run: - label_found = self.dp.get_specific_label("usage") - self.assertTrue(label_found) - - def test_from_is_first_directive(self): - self.assertTrue(self.dp.check_from_is_first()) - - -class DockerLint(container_avocado_test.ContainerAvocadoTest): - """ - :avocado: enable - """ - - def testBasic(self): - self.start() - self.assertTrue("bin" in self.run("ls /").stdout) - - def testContainerIsRunning(self): - """ - Function tests whether container is running - :return: - """ - self.start() - self.assertIn(self.backend.jmeno.rsplit("/")[-1], self.runHost("docker ps").stdout) - - def testLabels(self): - """ - Function tests whether labels are set in modulemd YAML file properly. - :return: - """ - llabels = self.getConfigModule().get('labels') - if llabels is None or len(llabels) == 0: - print("No labels defined in config to check") - self.cancel() - for key in self.getConfigModule()['labels']: - aaa = self.checkLabel(key, self.getConfigModule()['labels'][key]) - print(">>>>>> ", aaa, key) - self.assertTrue(aaa) class ModuleLintSigning(module_framework.AvocadoTest): From 49a699bdcc110c0fc3348720e5a2591c79b49edd Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Thu, 5 Oct 2017 14:45:36 +0200 Subject: [PATCH 014/117] Fix problems found during review Signed-off-by: Petr "Stone" Hracek --- moduleframework/dockerlinter.py | 2 +- moduleframework/tools/dockerlint.py | 19 ++++++++++++------- moduleframework/tools/helpmd_lint.py | 3 ++- requirements.txt | 1 - 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index ba3be8a..64d381d 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -141,7 +141,7 @@ def get_docker_expose(self): :return: list of PORTS """ ports_list = [] - for p in self.docker_dict.get(EXPOSE,[]): + for p in self.docker_dict.get(EXPOSE, []): ports_list.append(int(p)) return ports_list diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index 4768390..62df77e 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -43,9 +43,18 @@ def setUp(self): if self.dp.dockerfile is None: self.skip() + def _test_for_env_and_label(self, docker_env, docker_label, env=True): + label_found = True + if env: + label = self.dp.get_specific_env(docker_env) + else: + label = self.dp.get_specific_label(docker_env) + if not label: + label_found = self.dp.get_specific_label(docker_label) + return label_found + def test_architecture_in_env_and_label_exists(self): - self.assertTrue(self.dp.get_docker_specific_env("ARCH=")) - self.assertTrue(self.dp.get_specific_label("architecture")) + self.assertTrue(self._test_for_env_and_label("ARCH=", "architecture")) def test_name_in_env_and_label_exists(self): self.assertTrue(self.dp.get_docker_specific_env("NAME=")) @@ -67,11 +76,7 @@ def test_summary_label_exists(self): self.assertTrue(self.dp.get_specific_label("summary")) def test_run_or_usage_label_exists(self): - label_found = True - run = self.dp.get_specific_label("run") - if not run: - label_found = self.dp.get_specific_label("usage") - self.assertTrue(label_found) + self.assertTrue(self._test_for_env_and_label("run", "usage", env=False)) def test_from_is_first_directive(self): self.assertTrue(self.dp.check_from_is_first()) diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index 546401e..08bd614 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -70,4 +70,5 @@ def test_helpmd_environment_variables(self): self.assertTrue(self.helpmd.get_tag("ENVIRONMENT VARIABLES")) def test_helpmd_security_implications(self): - self.assertTrue(self.helpmd.get_tag("SECURITY IMPLICATIONS")) + if self.dp.get_docker_expose(): + self.assertTrue(self.helpmd.get_tag("SECURITY IMPLICATIONS")) diff --git a/requirements.txt b/requirements.txt index d2cdac4..4167436 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,3 @@ dockerfile-parse modulemd netifaces pdc-client -mistune \ No newline at end of file From d5d81224ebd82b4fe34d3e6c0a83c0935017143f Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 9 Oct 2017 09:40:51 +0200 Subject: [PATCH 015/117] New help.md fixes Signed-off-by: Petr "Stone" Hracek --- examples/testing-module/Dockerfile | 4 +-- examples/testing-module/help.md | 44 ++++++++++++++++++++++++++++ moduleframework/dockerlinter.py | 22 +++++++------- moduleframework/helpfile_linter.py | 32 ++++++++++---------- moduleframework/tools/dockerlint.py | 14 ++------- moduleframework/tools/helpmd_lint.py | 9 +++--- 6 files changed, 80 insertions(+), 45 deletions(-) create mode 100644 examples/testing-module/help.md diff --git a/examples/testing-module/Dockerfile b/examples/testing-module/Dockerfile index 14872d4..14c8eff 100644 --- a/examples/testing-module/Dockerfile +++ b/examples/testing-module/Dockerfile @@ -8,7 +8,7 @@ FROM baseruntime/baseruntime:latest # * 11211 ENV NAME=memcached ARCH=x86_64 -LABEL MAINTAINER "Petr Hracek" +LABEL maintainer "Petr Hracek" LABEL summary="High Performance, Distributed Memory Object Cache" \ name="$FGC/$NAME" \ version="0" \ @@ -36,4 +36,4 @@ EXPOSE 11211 # memcached will be run under standard user on Fedora USER 1000 -CMD ["/files/memcached.sh"] \ No newline at end of file +CMD ["/files/memcached.sh"] diff --git a/examples/testing-module/help.md b/examples/testing-module/help.md new file mode 100644 index 0000000..0f8683e --- /dev/null +++ b/examples/testing-module/help.md @@ -0,0 +1,44 @@ +% MEMCACHED(1) Container Image Pages +% Petr Hracek +% February 6, 2017 + +# NAME +{{ spec.envvars.name }} - {{ spec.description }} + +# DESCRIPTION +Memcached is a high-performance, distributed memory object caching system, generic in nature, but intended for use in speeding up dynamic web applications by alleviating database load. + +The container itself consists of: + - fedora/{{ config.os.version }} base image + - {{ spec.envvars.name }} RPM package + +Files added to the container during docker build include: /files/memcached.sh + +# USAGE +To get the memcached container image on your local system, run the following: + + docker pull docker.io/modularitycontainers/{{ spec.envvars.name }} + + +# ENVIRONMENT VARIABLES + +The image recognizes the following environment variables that you can set +during initialization be passing `-e VAR=VALUE` to the Docker run command. + +| Variable name | Description | +| :----------------------- | ----------------------------------------------------------- | +| `MEMCACHED_DEBUG_MODE` | Increases verbosity for server and client. Parameter is -vv | +| `MEMCACHED_CACHE_SIZE` | Sets the size of RAM to use for item storage (in megabytes) | +| `MEMCACHED_CONNECTIONS` | The max simultaneous connections; default is 1024 | +| `MEMCACHED_THREADS` | Sets number of threads to use to process incoming requests | + + +# SECURITY IMPLICATIONS +Lists of security-related attributes that are opened to the host. + +-p 11211:11211 + Opens container port 11211 and maps it to the same port on the host. + +# SEE ALSO +Memcached page + diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 64d381d..4174060 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -52,8 +52,9 @@ def __init__(self, dir_name="../"): dockerfile = get_docker_file(dir_name) if dockerfile: self.dockerfile = dockerfile - self.dfp = DockerfileParser(path=os.path.dirname(dockerfile)) - self._get_structure_as_dict() + with open(self.dockerfile, "r") as f: + self.dfp = DockerfileParser(fileobj=f) + self._get_structure_as_dict() else: self.dfp = None self.dockerfile = None @@ -101,16 +102,13 @@ def _get_structure_as_dict(self): FROM: self._get_general, RUN: self._get_general} + self.docker_dict[LABEL] = {} + for label in self.dfp.labels: + self.docker_dict[LABEL][label] = self.dfp.labels[label] for struct in self.dfp.structure: key = struct["instruction"] val = struct["value"] - if key == LABEL: - if key not in self.docker_dict: - self.docker_dict[key] = {} - value = functions[key](val) - if value is not None: - self.docker_dict[key].update(value) - else: + if key != LABEL: if key not in self.docker_dict: self.docker_dict[key] = [] try: @@ -133,7 +131,7 @@ def get_docker_specific_env(self, env_name=None): if env_name is None: return [] env_list = self.get_docker_env() - return env_name in env_list + return [x for x in env_list if env_name in x] def get_docker_expose(self): """ @@ -150,7 +148,7 @@ def get_docker_labels(self): Function returns docker labels :return: label dictionary """ - return self.docker_dict.get(LABEL,{}) + return self.docker_dict.get(LABEL, {}) def get_specific_label(self, label_name=None): """ @@ -161,7 +159,7 @@ def get_specific_label(self, label_name=None): if label_name is None: return [] label_list = self.get_docker_labels() - return [label_name in label_list] + return [label_list[key] for key in label_list.keys() if label_name == key] def check_from_is_first(self): """ diff --git a/moduleframework/helpfile_linter.py b/moduleframework/helpfile_linter.py index 1e97942..6185628 100644 --- a/moduleframework/helpfile_linter.py +++ b/moduleframework/helpfile_linter.py @@ -6,11 +6,12 @@ HELP_MD = "help.md" -def get_help_md_file(dir_name): - helpmd_file = os.path.join(os.path.abspath(dir_name), "help", HELP_MD) +def get_help_md_file(dockerfile): + helpmd_file = os.path.join(os.path.dirname(dockerfile), HELP_MD) + common.print_debug("help.md path is %s." % helpmd_file) if not os.path.exists(helpmd_file): helpmd_file = None - common.print_debug("help.md should exists in the %s directory." % dir_name) + common.print_debug("help.md should exists in the %s directory." % os.path.abspath(dockerfile)) return helpmd_file @@ -23,8 +24,8 @@ class HelpMDLinter(object): help_md = None - def __init__(self, dir_name="../"): - help_md_file = get_help_md_file(dir_name) + def __init__(self, dockerfile=None): + help_md_file = get_help_md_file(dockerfile) if help_md_file: with open(help_md_file, 'r') as f: lines = f.readlines() @@ -35,16 +36,17 @@ def __init__(self, dir_name="../"): else: self.help_md = None - def get_image_maintainer_name(self, name): - name = '% %s' % name - if name.upper() in self.help_md: - return True - else: - return False + def get_image_name(self, name): + name = '%% %s' % name + tag_exists = [x for x in self.help_md if name.upper() in x] + return tag_exists + + def get_maintainer_name(self, name): + name = '%% %s' % name + tag_exists = [x for x in self.help_md if name.startswith(x)] + return tag_exists def get_tag(self, name): name = '# %s' % name - if name.upper() in self.help_md: - return True - else: - return False + tag_exists = [x for x in self.help_md if name.upper() in x] + return tag_exists diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index 62df77e..6cd9b91 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -46,7 +46,7 @@ def setUp(self): def _test_for_env_and_label(self, docker_env, docker_label, env=True): label_found = True if env: - label = self.dp.get_specific_env(docker_env) + label = self.dp.get_docker_specific_env(docker_env) else: label = self.dp.get_specific_label(docker_env) if not label: @@ -81,8 +81,8 @@ def test_run_or_usage_label_exists(self): def test_from_is_first_directive(self): self.assertTrue(self.dp.check_from_is_first()) - def test_from_correct_format(self): - self.assertTrue(self.dp.check_from_format()) + #def test_from_correct_format(self): + # self.assertTrue(self.dp.check_from_format()) class DockerLint(container_avocado_test.ContainerAvocadoTest): @@ -94,14 +94,6 @@ def testBasic(self): self.start() self.assertTrue("bin" in self.run("ls /").stdout) - def testContainerIsRunning(self): - """ - Function tests whether container is running - :return: - """ - self.start() - self.assertIn(self.backend.jmeno.rsplit("/")[-1], self.runHost("docker ps").stdout) - def testLabels(self): """ Function tests whether labels are set in modulemd YAML file properly. diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index 08bd614..2050f71 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -21,7 +21,6 @@ # Authors: Jan Scotka # from __future__ import print_function -import os from moduleframework import module_framework from moduleframework import helpfile_linter @@ -40,7 +39,7 @@ def setUp(self): # it is not intended just for docker, but just docker packages are # actually properly signed self.dp = dockerlinter.DockerfileLinter() - self.helpmd = helpfile_linter.HelpMDLinter() + self.helpmd = helpfile_linter.HelpMDLinter(dockerfile=self.dp.dockerfile) if self.dp.dockerfile is None: self.skip() @@ -48,14 +47,14 @@ def test_helpmd_exists(self): self.assertTrue(self.helpmd) def test_helpmd_image_name(self): - container_name = self.dp.get_docker_specific_env("NAME") + container_name = self.dp.get_docker_specific_env("NAME=") if container_name: - self.assertTrue(self.helpmd.get_image_maintainer_name(container_name.split('=')[1])) + self.assertTrue(self.helpmd.get_image_name(container_name[0].split('=')[1])) def test_helpmd_maintainer_name(self): maintainer_name = self.dp.get_specific_label("maintainer") if maintainer_name: - self.assertTrue(self.helpmd.get_image_maintainer_name(maintainer_name)) + self.assertTrue(self.helpmd.get_maintainer_name(maintainer_name[0])) def test_helpmd_name(self): self.assertTrue(self.helpmd.get_tag("NAME")) From 8a2b65457d012b659067e8eed38e1f571e4a95eb Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 9 Oct 2017 13:47:26 +0200 Subject: [PATCH 016/117] several fixes based on comment from PR. Signed-off-by: Petr "Stone" Hracek --- moduleframework/dockerlinter.py | 72 +++++++++++++++++++++++----- moduleframework/helpfile_linter.py | 15 ++---- moduleframework/tools/dockerlint.py | 17 +++---- moduleframework/tools/helpmd_lint.py | 5 +- 4 files changed, 77 insertions(+), 32 deletions(-) diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 4174060..3471f71 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -15,7 +15,8 @@ PORTS = "PORTS" FROM = "FROM" RUN = "RUN" - +USER = "USER" +INSTRUCT = "instruction" def get_string(value): return ast.literal_eval(value) @@ -45,7 +46,7 @@ class DockerfileLinter(object): dockerfile = None oc_template = None - dfp = {} + dfp_structure = {} docker_dict = {} def __init__(self, dir_name="../"): @@ -54,6 +55,7 @@ def __init__(self, dir_name="../"): self.dockerfile = dockerfile with open(self.dockerfile, "r") as f: self.dfp = DockerfileParser(fileobj=f) + self.dfp_structure = self.dfp.structure self._get_structure_as_dict() else: self.dfp = None @@ -100,13 +102,15 @@ def _get_structure_as_dict(self): VOLUME: self._get_volume, LABEL: self._get_label, FROM: self._get_general, - RUN: self._get_general} + RUN: self._get_general, + USER: self._get_general, + } self.docker_dict[LABEL] = {} for label in self.dfp.labels: self.docker_dict[LABEL][label] = self.dfp.labels[label] for struct in self.dfp.structure: - key = struct["instruction"] + key = struct[INSTRUCT] val = struct["value"] if key != LABEL: if key not in self.docker_dict: @@ -114,8 +118,7 @@ def _get_structure_as_dict(self): try: ret_val = functions[key](val) for v in ret_val: - if v not in self.docker_dict[key]: - self.docker_dict[key].append(v) + self.docker_dict[key].append(v) except KeyError: print("Dockerfile tag %s is not parsed by MTF" % key) @@ -164,10 +167,57 @@ def get_specific_label(self, label_name=None): def check_from_is_first(self): """ Function checks if FROM directive is really first directive. - Function ignores whitespaces and comments. :return: True if FROM is first, False if FROM is not first directive """ - with open(self.dockerfile) as f: - lines = [x for x in f.readlines() if not x.isspace()] - lines = [x for x in lines if not x.strip().startswith("#")] - return lines + if self.dfp_structure[0].get('instruction') == 'FROM': + return True + else: + return False + + def check_from_directive_is_valid(self): + """ + Function checks if FROM directive contains valid format like is specified here + http://docs.projectatomic.io/container-best-practices/#_line_rule_section + Regular expression is: ^[a-z0-9.]+(\/[a-z0-9\D.]+)+$ + Example registry: + registry.fedoraproject.org/f26/etcd + registry.fedoraproject.org/f26/flannel + registry.access.redhat.com/rhscl/nginx-18-rhel7 + registry.access.redhat.com/rhel7/rhel-tools + registry.access.redhat.com/rhscl/postgresql-95-rhel7 + + :return: + """ + correct_format = False + struct = self.dfp_structure[0] + if struct.get(INSTRUCT) == 'FROM': + p = re.compile("^[a-z0-9.]+(\/[a-z0-9\D.]+)+$") + if p.search(struct.get('value')) is not None: + correct_format = True + return correct_format + + def check_two_dnf_run_sections(self): + value = 0 + for struct in self.dfp_structure: + if struct.get(INSTRUCT) == RUN: + value += 1 + if int(value) > 1: + return False + return True + + def check_user_number(self): + """ + Function checks if user contains valid number, not string + :return: True if contains valid number and if it does not exit + False if contains not valid number + """ + try: + user = self.docker_dict[USER] + except KeyError: + return True + try: + for u in user: + int(u) + except ValueError: + return False + return True diff --git a/moduleframework/helpfile_linter.py b/moduleframework/helpfile_linter.py index 6185628..49a0e12 100644 --- a/moduleframework/helpfile_linter.py +++ b/moduleframework/helpfile_linter.py @@ -6,15 +6,6 @@ HELP_MD = "help.md" -def get_help_md_file(dockerfile): - helpmd_file = os.path.join(os.path.dirname(dockerfile), HELP_MD) - common.print_debug("help.md path is %s." % helpmd_file) - if not os.path.exists(helpmd_file): - helpmd_file = None - common.print_debug("help.md should exists in the %s directory." % os.path.abspath(dockerfile)) - return helpmd_file - - class HelpMDLinter(object): """ Class checks a Help.md file @@ -25,8 +16,9 @@ class HelpMDLinter(object): help_md = None def __init__(self, dockerfile=None): - help_md_file = get_help_md_file(dockerfile) - if help_md_file: + help_md_file = os.path.join(os.path.dirname(dockerfile), HELP_MD) + common.print_debug("help.md path is %s." % help_md_file) + if os.path.exists(help_md_file): with open(help_md_file, 'r') as f: lines = f.readlines() # Count with all lines which begins with # @@ -34,6 +26,7 @@ def __init__(self, dockerfile=None): # Count with all lines which begins with % self.help_md.extend([x.strip() for x in lines if x.startswith('%')]) else: + common.print_debug("help.md should exists in the %s directory." % os.path.dirname(dockerfile)) self.help_md = None def get_image_name(self, name): diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index 6cd9b91..b9b77c5 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -54,7 +54,7 @@ def _test_for_env_and_label(self, docker_env, docker_label, env=True): return label_found def test_architecture_in_env_and_label_exists(self): - self.assertTrue(self._test_for_env_and_label("ARCH=", "architecture")) + self.assertTrue(self.dp.get_specific_label("architecture")) def test_name_in_env_and_label_exists(self): self.assertTrue(self.dp.get_docker_specific_env("NAME=")) @@ -81,8 +81,14 @@ def test_run_or_usage_label_exists(self): def test_from_is_first_directive(self): self.assertTrue(self.dp.check_from_is_first()) - #def test_from_correct_format(self): - # self.assertTrue(self.dp.check_from_format()) + def test_from_directive_is_valid(self): + self.assertTrue(self.dp.check_from_directive_is_valid()) + + def test_user_format(self): + self.assertTrue(self.dp.check_user_number()) + + def test_two_run_instructions(self): + self.assertTrue(self.dp.check_two_dnf_run_sections()) class DockerLint(container_avocado_test.ContainerAvocadoTest): @@ -90,10 +96,6 @@ class DockerLint(container_avocado_test.ContainerAvocadoTest): :avocado: enable """ - def testBasic(self): - self.start() - self.assertTrue("bin" in self.run("ls /").stdout) - def testLabels(self): """ Function tests whether labels are set in modulemd YAML file properly. @@ -105,5 +107,4 @@ def testLabels(self): self.cancel() for key in self.getConfigModule()['labels']: aaa = self.checkLabel(key, self.getConfigModule()['labels'][key]) - print(">>>>>> ", aaa, key) self.assertTrue(aaa) diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index 2050f71..401dcb7 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -65,8 +65,9 @@ def test_helpmd_description(self): def test_helpmd_usage(self): self.assertTrue(self.helpmd.get_tag("USAGE")) - def test_helpmd_environment_variables(self): - self.assertTrue(self.helpmd.get_tag("ENVIRONMENT VARIABLES")) + # TODO enable once we have implementation of result WARN + #def test_helpmd_environment_variables(self): + # self.assertTrue(self.helpmd.get_tag("ENVIRONMENT VARIABLES")) def test_helpmd_security_implications(self): if self.dp.get_docker_expose(): From b9d16763a39d7a14cc7647e07e82b032ee89cc0f Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 10 Oct 2017 12:07:55 +0200 Subject: [PATCH 017/117] Add check for presence help.md Signed-off-by: Petr "Stone" Hracek --- moduleframework/dockerlinter.py | 46 +++++++++++++++++++---------- moduleframework/module_framework.py | 3 -- moduleframework/tools/dockerlint.py | 8 ++--- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 3471f71..47d0741 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -16,6 +16,8 @@ FROM = "FROM" RUN = "RUN" USER = "USER" +COPY = "COPY" +ADD = "ADD" INSTRUCT = "instruction" def get_string(value): @@ -64,7 +66,7 @@ def __init__(self, dir_name="../"): def _get_general(self, value): """ Function returns exposes as field. - It is used for RUN, EXPOSE and FROM + It is used for RUN, EXPOSE, USER, COPY, ADD and FROM :param value: :return: """ @@ -104,6 +106,8 @@ def _get_structure_as_dict(self): FROM: self._get_general, RUN: self._get_general, USER: self._get_general, + COPY: self._get_general, + ADD: self._get_general, } self.docker_dict[LABEL] = {} @@ -196,7 +200,19 @@ def check_from_directive_is_valid(self): correct_format = True return correct_format - def check_two_dnf_run_sections(self): + def check_chained_run_command(self): + """ + Function checks if Dockerfile does not contain more RUN commands in two or more rows. + BAD examples: + FROM fedora + RUN ls / + RUN cd / + GOOD example: + FROM fedora + RUN ls / && cd / + :return: True if Dockerfile does not contain RUN instructions in rows + False if Dockerfile contains more RUN instructions in rows + """ value = 0 for struct in self.dfp_structure: if struct.get(INSTRUCT) == RUN: @@ -205,19 +221,17 @@ def check_two_dnf_run_sections(self): return False return True - def check_user_number(self): + def check_helpmd_is_present(self): """ - Function checks if user contains valid number, not string - :return: True if contains valid number and if it does not exit - False if contains not valid number + Function checks if helpmd. is present in COPY or ADD directives + :return: True if help.md is present + False if help.md is not specified in Dockerfile """ - try: - user = self.docker_dict[USER] - except KeyError: - return True - try: - for u in user: - int(u) - except ValueError: - return False - return True + helpmd_present = False + for c in self.docker_dict[COPY]: + if "help.md" in c: + helpmd_present = True + for a in self.docker_dict[ADD]: + if "help.md" in a: + helpmd_present = True + return helpmd_present diff --git a/moduleframework/module_framework.py b/moduleframework/module_framework.py index 8839627..244c271 100644 --- a/moduleframework/module_framework.py +++ b/moduleframework/module_framework.py @@ -27,9 +27,6 @@ """ from moduleframework.avocado_testers.avocado_test import * -from moduleframework.avocado_testers.container_avocado_test import ContainerAvocadoTest -from moduleframework.avocado_testers.nspawn_avocado_test import NspawnAvocadoTest -from moduleframework.avocado_testers.rpm_avocado_test import RpmAvocadoTest PROFILE = None diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index b9b77c5..458b8bc 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -84,11 +84,11 @@ def test_from_is_first_directive(self): def test_from_directive_is_valid(self): self.assertTrue(self.dp.check_from_directive_is_valid()) - def test_user_format(self): - self.assertTrue(self.dp.check_user_number()) + def test_chained_run_command(self): + self.assertTrue(self.dp.check_chained_run_command()) - def test_two_run_instructions(self): - self.assertTrue(self.dp.check_two_dnf_run_sections()) + def test_helpmd_is_present(self): + self.assertTrue(self.dp.check_helpmd_is_present()) class DockerLint(container_avocado_test.ContainerAvocadoTest): From 342e3ecfc473e04558972502d412916f890b89b5 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 10 Oct 2017 13:32:21 +0200 Subject: [PATCH 018/117] Use WARNING in case of ENVIRONMENT VARIABLES are not set in help.md Signed-off-by: Petr "Stone" Hracek --- moduleframework/module_framework.py | 6 +++++- moduleframework/mtf_environment.py | 2 +- moduleframework/tools/helpmd_lint.py | 11 +++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/moduleframework/module_framework.py b/moduleframework/module_framework.py index 244c271..990d843 100644 --- a/moduleframework/module_framework.py +++ b/moduleframework/module_framework.py @@ -26,7 +26,11 @@ what you should use for your tests (inherited) """ -from moduleframework.avocado_testers.avocado_test import * +from moduleframework.avocado_testers.avocado_test import AvocadoTest, get_backend +from moduleframework.avocado_testers.container_avocado_test import ContainerAvocadoTest +from moduleframework.avocado_testers.nspawn_avocado_test import NspawnAvocadoTest +from moduleframework.avocado_testers.rpm_avocado_test import RpmAvocadoTest +from moduleframework.exceptions import ModuleFrameworkException PROFILE = None diff --git a/moduleframework/mtf_environment.py b/moduleframework/mtf_environment.py index e9d3c85..cfac6fb 100644 --- a/moduleframework/mtf_environment.py +++ b/moduleframework/mtf_environment.py @@ -24,7 +24,7 @@ """ Module to setup and cleanup the test environment. """ -from moduleframework.module_framework import * +from moduleframework.common import * from moduleframework.environment_prepare.docker_prepare import EnvDocker from moduleframework.environment_prepare.rpm_prepare import EnvRpm from moduleframework.environment_prepare.nspawn_prepare import EnvNspawn diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index 401dcb7..c763269 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -22,9 +22,9 @@ # from __future__ import print_function -from moduleframework import module_framework from moduleframework import helpfile_linter from moduleframework import dockerlinter +from moduleframework import module_framework class HelpMDLinter(module_framework.AvocadoTest): @@ -65,9 +65,12 @@ def test_helpmd_description(self): def test_helpmd_usage(self): self.assertTrue(self.helpmd.get_tag("USAGE")) - # TODO enable once we have implementation of result WARN - #def test_helpmd_environment_variables(self): - # self.assertTrue(self.helpmd.get_tag("ENVIRONMENT VARIABLES")) + def test_helpmd_environment_variables(self): + env_variables = self.helpmd.get_tag("ENVIRONMENT VARIABLES") + if not env_variables: + self.log.warn("help.md file does not contain section ENVIRONMENT VARIABLES") + # In order to report warning, test has to report with True always + self.assertTrue(True) def test_helpmd_security_implications(self): if self.dp.get_docker_expose(): From 9527b32859cfe586dd776546d03b680e2724b06d Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Wed, 11 Oct 2017 10:39:05 +0200 Subject: [PATCH 019/117] add tests for RUN instructions. One for dnf part and the other one for the rest Signed-off-by: Petr "Stone" Hracek --- moduleframework/dockerlinter.py | 34 ++++++++++++++++++++++++----- moduleframework/tools/dockerlint.py | 7 ++++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 47d0741..74b5b01 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -20,6 +20,7 @@ ADD = "ADD" INSTRUCT = "instruction" + def get_string(value): return ast.literal_eval(value) @@ -200,9 +201,32 @@ def check_from_directive_is_valid(self): correct_format = True return correct_format - def check_chained_run_command(self): + def check_chained_run_dnf_commands(self): + """ + Function checks if Dockerfile does not contain more `RUN dnf` commands + in more then one row. + BAD examples: + FROM fedora + RUN dnf install foobar1 + RUN dnf clean all + GOOD example: + FROM fedora + RUN dnf install foobar1 && dnf clean all + :return: True if Dockerfile contains RUN dnf instructions in one row + False if Dockerfile contains RUN dnf instructions in more rows + """ + value = 0 + for struct in self.dfp_structure: + if struct.get(INSTRUCT) == RUN and "dnf" in struct.get("value"): + value += 1 + if int(value) > 1: + return False + return True + + def check_chained_run_rest_commands(self): """ - Function checks if Dockerfile does not contain more RUN commands in two or more rows. + Function checks if Dockerfile does not contain more `RUN` commands, + except RUN dnf, in more then one row. BAD examples: FROM fedora RUN ls / @@ -210,12 +234,12 @@ def check_chained_run_command(self): GOOD example: FROM fedora RUN ls / && cd / - :return: True if Dockerfile does not contain RUN instructions in rows - False if Dockerfile contains more RUN instructions in rows + :return: True if Dockerfile contains RUN instructions, except dnf, in one row + False if Dockerfile contains RUN instructions, except dnf, in more rows """ value = 0 for struct in self.dfp_structure: - if struct.get(INSTRUCT) == RUN: + if struct.get(INSTRUCT) == RUN and "dnf" not in struct.get("value"): value += 1 if int(value) > 1: return False diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index 458b8bc..bd1505e 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -84,8 +84,11 @@ def test_from_is_first_directive(self): def test_from_directive_is_valid(self): self.assertTrue(self.dp.check_from_directive_is_valid()) - def test_chained_run_command(self): - self.assertTrue(self.dp.check_chained_run_command()) + def test_chained_run_dnf_commands(self): + self.assertTrue(self.dp.check_chained_run_dnf_commands()) + + def test_chained_run_rest_commands(self): + self.assertTrue(self.dp.check_chained_run_rest_commands()) def test_helpmd_is_present(self): self.assertTrue(self.dp.check_helpmd_is_present()) From e20ba7afda8e97551e6bc8f71716d58316269ab1 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Wed, 11 Oct 2017 11:03:18 +0200 Subject: [PATCH 020/117] Skip help.md for now if it does not exist Signed-off-by: Petr "Stone" Hracek --- moduleframework/helpfile_linter.py | 8 ++++++-- moduleframework/tools/helpmd_lint.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/moduleframework/helpfile_linter.py b/moduleframework/helpfile_linter.py index 49a0e12..1cf5873 100644 --- a/moduleframework/helpfile_linter.py +++ b/moduleframework/helpfile_linter.py @@ -16,7 +16,11 @@ class HelpMDLinter(object): help_md = None def __init__(self, dockerfile=None): - help_md_file = os.path.join(os.path.dirname(dockerfile), HELP_MD) + if dockerfile is None: + dir_name = os.getcwd() + else: + dir_name = os.path.dirname(dockerfile) + help_md_file = os.path.join(dir_name, HELP_MD) common.print_debug("help.md path is %s." % help_md_file) if os.path.exists(help_md_file): with open(help_md_file, 'r') as f: @@ -26,7 +30,7 @@ def __init__(self, dockerfile=None): # Count with all lines which begins with % self.help_md.extend([x.strip() for x in lines if x.startswith('%')]) else: - common.print_debug("help.md should exists in the %s directory." % os.path.dirname(dockerfile)) + common.print_debug("help.md should exists in the %s directory." % dir_name) self.help_md = None def get_image_name(self, name): diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index c763269..339396b 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -42,6 +42,8 @@ def setUp(self): self.helpmd = helpfile_linter.HelpMDLinter(dockerfile=self.dp.dockerfile) if self.dp.dockerfile is None: self.skip() + if self.helpmd is None: + self.skip() def test_helpmd_exists(self): self.assertTrue(self.helpmd) From 96e867c7193c75b563a83f3d32fcbd4efd564558 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 12 Oct 2017 08:52:46 +0200 Subject: [PATCH 021/117] create snapshot before calling setup from config, because machine does not have root directory --- moduleframework/helpers/nspawn_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moduleframework/helpers/nspawn_helper.py b/moduleframework/helpers/nspawn_helper.py index 48a6110..0e5c60f 100644 --- a/moduleframework/helpers/nspawn_helper.py +++ b/moduleframework/helpers/nspawn_helper.py @@ -82,6 +82,7 @@ def setUp(self): print_info("name of CHROOT directory:", self.chrootpath) self.setRepositoriesAndWhatToInstall() self.__prepareSetup() + self.__create_snaphot() self._callSetupFromConfig() self.__bootMachine() @@ -201,7 +202,6 @@ def __bootMachine(self): :return: None """ - self.__create_snaphot() print_debug("starting NSPAWN") nspawncont = process.SubProcess( "systemd-nspawn --machine=%s -bD %s" % From 04440e45e032f978f1c054f3139dcd945757a202 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Thu, 12 Oct 2017 13:08:07 +0200 Subject: [PATCH 022/117] script which generate easy template --- meta-test-family.spec | 1 + moduleframework/mtf_init.py | 134 ++++++++++++++++++++++++++++++++++++ setup.py | 1 + 3 files changed, 136 insertions(+) create mode 100644 moduleframework/mtf_init.py diff --git a/meta-test-family.spec b/meta-test-family.spec index a8cccad..48986f5 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -49,6 +49,7 @@ install -d -p -m 755 %{buildroot}%{_datadir}/%{framework_name} %{_bindir}/mtf-env-set %{_bindir}/mtf-env-clean %{_bindir}/mtf-log-parser +%{_bindir}/mtf-init %{python2_sitelib}/moduleframework/ %{python2_sitelib}/meta_test_family-*.egg-info/ %{_datadir}/moduleframework/ diff --git a/moduleframework/mtf_init.py b/moduleframework/mtf_init.py new file mode 100644 index 0000000..046f0c8 --- /dev/null +++ b/moduleframework/mtf_init.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Script generates super easy template of test for module docker +# Purpose of this script is to generate needed files to start testing +# Author Petr Sklenar psklenar@gmail.com +# +# Meta test family (MTF) is a tool to test components of a modular Fedora: +# https://docs.pagure.org/modularity/ +# Copyright (C) 2017 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +from optparse import OptionParser +import os.path + +def get_options(): + parser = OptionParser(usage="usage: %prog [options]", + version="%prog 1.0") + parser.add_option("-n", "--name", + default="name", + dest="name", + help="Name of module for testing") + parser.add_option("-c", "--container", + dest="container", + default="docker.io/modularitycontainers/memcached", + action="store", + help="Specify container path, example: docker.io/modularitycontainers/memcached") + (options, args) = parser.parse_args() + + return options + +class Template(object): + def __init__(self, name, container): + self.name = name + self.container = container + + def set_content_config_yaml(self): + self.filePathConfig = 'config.yaml' + configYaml = """# this is generated config.yaml with minimum stuff +--- +document: meta-test +version: 1 +name: {name} +default_module: docker +module: + docker: + container: {container} +""".format(name=self.name, container=self.container) + self.configYaml = configYaml + + def set_content_test_py(self): + self.filePathTest = 'test.py' + test = '''#!/usr/bin/python + +# test example +# start test: "sudo mtf" + +from avocado import main +from avocado.core import exceptions +from moduleframework import module_framework + +class Smoke1(module_framework.AvocadoTest): + """ + :avocado: enable + """ + + def test_uname(self): + self.start() + self.run("uname | grep Linux") + + def test_echo(self): + self.start() + self.runHost("echo test | grep test") + +if __name__ == '__main__': + main() + +''' + self.test = test + + def confirm(self): + gogo = raw_input("Continue? yes/no\n") + if gogo == 'yes': + exit_condition = 0 + return exit_condition + elif gogo == "no": + exit_condition = 1 + exit(1) + return exit_condition + else: + print "Please answer with yes or no." + return 2 + + def check_file(self): + if os.path.isfile(self.filePathConfig) and os.path.isfile(self.filePathTest): + print("!!! File exists, rewrite?") + continue1=2 + while continue1 is 2: + continue1=self.confirm() + if continue1 is 1: + return False + return True + + def save(self): + f1 = open(self.filePathConfig,'w') + f1.write(self.configYaml) + f1.close() + + f2 = open(self.filePathTest,'w') + f2.write(self.test) + f2.close() + +def main(): + options=get_options() + resobj = Template(options.name, options.container) + resobj.set_content_config_yaml() + resobj.set_content_test_py() + if not resobj.check_file(): + print("do nothing") + exit(1) + resobj.save() + print("Done, to run test:\n\tsudo mtf test.py") \ No newline at end of file diff --git a/setup.py b/setup.py index e160e0d..aa5c3ce 100755 --- a/setup.py +++ b/setup.py @@ -81,6 +81,7 @@ def get_dir(system_path=None, virtual_path=None): 'mtf-env-set = moduleframework.mtf_environment:mtfenvset', 'mtf-env-clean = moduleframework.mtf_environment:mtfenvclean', 'mtf-log-parser = moduleframework.mtf_log_parser:main', + 'mtf-init = moduleframework.mtf_init:main', ] }, setup_requires=[], From 586ed2a6bb56a7f9ce2806685bd37b74c20c4048 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 12 Oct 2017 13:26:27 +0200 Subject: [PATCH 023/117] raise error in case of compatibility (error has to be raised explicitly) --- moduleframework/helpers/nspawn_helper.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/moduleframework/helpers/nspawn_helper.py b/moduleframework/helpers/nspawn_helper.py index fd02015..0a12ebf 100644 --- a/moduleframework/helpers/nspawn_helper.py +++ b/moduleframework/helpers/nspawn_helper.py @@ -251,8 +251,8 @@ def __systemctl_wait_until_finish(self, machine, unit): output = [x.strip() for x in self.runHost("systemctl show -M {} {}".format(machine, unit), verbose=False).stdout.split("\n")] - if is_debug(): - print_debug(output) + #if is_debug(): + # print_debug(output) retcode = int([x[-1] for x in output if "ExecMainStatus=" in x][0]) if not ("SubState=exited" in output or "SubState=failed" in output): time.sleep(0.1) @@ -275,6 +275,8 @@ def __run_systemdrun(self, command, internal_background=False, **kwargs): :param kwargs: dict parameters passed to avocado.process.run :return: avocado.process.run """ + if not kwargs: + kwargs = {} self.__machined_restart() add_sleep_infinite = "" unit_name = self.__systemd_generate_unit_name() @@ -310,6 +312,8 @@ def __run_systemdrun(self, command, internal_background=False, **kwargs): os.remove("{chroot}{pin}.stdout".format(chroot=self.chrootpath, pin=lpath)) os.remove("{chroot}{pin}.stderr".format(chroot=self.chrootpath, pin=lpath)) print_debug(comout) + if not self.__systemd_wait_support and kwargs.get("ignore_status") and comout.exit_status != 0: + raise process.CmdError(comout.command, comout) return comout except process.CmdError as e: raise CmdExc("Command in SYSTEMD-RUN failed: %s" % command, e) From a48192f5f22b6e4c215f8236a3d592ffef1bcca3 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Thu, 12 Oct 2017 13:05:48 +0200 Subject: [PATCH 024/117] Fix some logging issues and yum checks Signed-off-by: Petr "Stone" Hracek --- moduleframework/common.py | 30 +++++++++++++++++++++----- moduleframework/dockerlinter.py | 32 +++++++--------------------- moduleframework/tools/dockerlint.py | 7 ++++-- moduleframework/tools/helpmd_lint.py | 7 +++++- 4 files changed, 44 insertions(+), 32 deletions(-) diff --git a/moduleframework/common.py b/moduleframework/common.py index 29c6f05..5a9beae 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -53,6 +53,7 @@ hostpackager = subprocess.check_output([PACKAGER_COMMAND], shell=True).strip() guestpackager = hostpackager ARCH = "x86_64" +DOCKERFILE = "Dockerfile" __persistent_config = None @@ -370,7 +371,6 @@ def get_url(self): """ return self.info.get("url") - def getArch(self): """ Get system architecture. @@ -399,7 +399,6 @@ def runHost(self, command="ls /", **kwargs): % (trans_dict, command)) return process.run("%s" % formattedcommand, **kwargs) - def get_test_dependencies(self): """ Get test dependencies from a configuration file @@ -430,7 +429,6 @@ def installTestDependencies(self, packages=None): except process.CmdError as e: raise CmdExc("Installation failed; Do you have permission to do that?", e) - def getPackageList(self, profile=None): """ Return list of packages what has to be installed inside module @@ -497,7 +495,6 @@ def getModulemdYamlconfig(self, urllink=None): self.modulemdConf = link return link - def getIPaddr(self): """ Return protocol (IP or IPv6) address on a guest machine. @@ -580,7 +577,6 @@ def stop(self, command="/bin/true"): command = self.info.get('stop') or command self.run(command, shell=True, ignore_bg_processes=True, verbose=is_not_silent()) - def install_packages(self, packages=None): """ Install packages in config (by config or via parameter) @@ -614,6 +610,7 @@ def tearDown(self): else: print_info("TearDown phase skipped.") + def get_config(): """ Read the module's configuration file. @@ -676,6 +673,7 @@ def list_modules_from_config(): modulelist = get_config().get("module").keys() return modulelist + def get_backend_list(): """ Get backends @@ -685,6 +683,7 @@ def get_backend_list(): base_module_list = ["rpm", "nspawn", "docker"] return base_module_list + def get_module_type(): """ Get which module are you actually using. @@ -719,3 +718,24 @@ def get_module_type_base(): if parent not in get_backend_list(): raise ModuleFrameworkException("As parent is allowed just base type: %s" % get_backend_list) return parent + + +def get_docker_file(dir_name="../"): + """ + Function returns full path to dockerfile. + :param dir_name: dir_name, where should be Dockerfile located + :return: full_path to Dockerfile + """ + fromenv = os.environ.get("DOCKERFILE") + if fromenv: + dockerfile = fromenv + dir_name = os.getcwd() + else: + dir_name = os.path.abspath(dir_name) + dockerfile = DOCKERFILE + dockerfile = os.path.join(dir_name, dockerfile) + + if not os.path.exists(dockerfile): + dockerfile = None + print_debug("Dockerfile should exists in the %s directory." % dir_name) + return dockerfile diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 74b5b01..81594cf 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -1,13 +1,11 @@ from __future__ import print_function -import os import re import ast from dockerfile_parse import DockerfileParser -import common +from moduleframework.common import get_docker_file # Dockerfile path -DOCKERFILE = "Dockerfile" EXPOSE = "EXPOSE" VOLUME = "VOLUME" LABEL = "LABEL" @@ -25,22 +23,6 @@ def get_string(value): return ast.literal_eval(value) -def get_docker_file(dir_name): - fromenv = os.environ.get("DOCKERFILE") - if fromenv: - dockerfile = fromenv - dir_name = os.getcwd() - else: - dir_name = os.path.abspath(dir_name) - dockerfile = DOCKERFILE - dockerfile = os.path.join(dir_name, dockerfile) - - if not os.path.exists(dockerfile): - dockerfile = None - common.print_debug("Dockerfile should exists in the %s directory." % dir_name) - return dockerfile - - class DockerfileLinter(object): """ Class checks a Dockerfile @@ -203,7 +185,7 @@ def check_from_directive_is_valid(self): def check_chained_run_dnf_commands(self): """ - Function checks if Dockerfile does not contain more `RUN dnf` commands + Function checks if Dockerfile does not contain more `RUN dnf/yum` commands in more then one row. BAD examples: FROM fedora @@ -217,8 +199,9 @@ def check_chained_run_dnf_commands(self): """ value = 0 for struct in self.dfp_structure: - if struct.get(INSTRUCT) == RUN and "dnf" in struct.get("value"): - value += 1 + if struct.get(INSTRUCT) == RUN: + if "dnf" in struct.get("value") or "yum" in struct.get("value"): + value += 1 if int(value) > 1: return False return True @@ -239,8 +222,9 @@ def check_chained_run_rest_commands(self): """ value = 0 for struct in self.dfp_structure: - if struct.get(INSTRUCT) == RUN and "dnf" not in struct.get("value"): - value += 1 + if struct.get(INSTRUCT) == RUN: + if "dnf" not in struct.get("value") and "yum" not in struct.get("value"): + value += 1 if int(value) > 1: return False return True diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index bd1505e..af12171 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -21,11 +21,12 @@ # Authors: Jan Scotka # from __future__ import print_function -import os +import os from moduleframework import module_framework from moduleframework import dockerlinter from moduleframework.avocado_testers import container_avocado_test +from moduleframework.common import get_docker_file class DockerFileLinter(module_framework.AvocadoTest): @@ -41,6 +42,8 @@ def setUp(self): # actually properly signed self.dp = dockerlinter.DockerfileLinter() if self.dp.dockerfile is None: + dir_name = os.getcwd() + self.log.info("Dockerfile was not found in %s directory." % dir_name) self.skip() def _test_for_env_and_label(self, docker_env, docker_label, env=True): @@ -106,7 +109,7 @@ def testLabels(self): """ llabels = self.getConfigModule().get('labels') if llabels is None or len(llabels) == 0: - print("No labels defined in config to check") + self.log.info("No labels defined in config to check") self.cancel() for key in self.getConfigModule()['labels']: aaa = self.checkLabel(key, self.getConfigModule()['labels'][key]) diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index 339396b..2ad01bc 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -22,9 +22,11 @@ # from __future__ import print_function +import os from moduleframework import helpfile_linter from moduleframework import dockerlinter from moduleframework import module_framework +from moduleframework.common import get_docker_file class HelpMDLinter(module_framework.AvocadoTest): @@ -39,10 +41,13 @@ def setUp(self): # it is not intended just for docker, but just docker packages are # actually properly signed self.dp = dockerlinter.DockerfileLinter() - self.helpmd = helpfile_linter.HelpMDLinter(dockerfile=self.dp.dockerfile) if self.dp.dockerfile is None: + dir_name = os.getcwd() + self.log.info("Dockerfile was not found in %s directory." % dir_name) self.skip() + self.helpmd = helpfile_linter.HelpMDLinter(dockerfile=self.dp.dockerfile) if self.helpmd is None: + self.log.info("help.md file was not found in Dockerfile directory") self.skip() def test_helpmd_exists(self): From f9007e781cf04f35c81d35d6900408639d954916 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 16 Oct 2017 13:10:47 +0200 Subject: [PATCH 025/117] Update documentation and use absolute path Signed-off-by: Petr "Stone" Hracek --- docs/user_guide/environment_variables.rst | 1 + moduleframework/common.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/user_guide/environment_variables.rst b/docs/user_guide/environment_variables.rst index 7d12672..19642ce 100644 --- a/docs/user_guide/environment_variables.rst +++ b/docs/user_guide/environment_variables.rst @@ -24,6 +24,7 @@ Environment variables allow to overwrite some values of a module configuration f - **MTF_REUSE=yes** uses the same module between tests. It speeds up test execution. It can cause side effects. - **MTF_REMOTE_REPOS=yes** disables downloading of Koji packages and creating a local repo, and speeds up test execution. - **MTF_DISABLE_MODULE=yes** disables module handling to use nonmodular test mode (see `multihost tests`_ as an example). +- **DOCKERFILE=" Date: Tue, 17 Oct 2017 12:41:00 +0200 Subject: [PATCH 026/117] Bump new release Signed-off-by: Petr "Stone" Hracek --- meta-test-family.spec | 7 +++++-- setup.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/meta-test-family.spec b/meta-test-family.spec index a8cccad..f2c6a0b 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -1,8 +1,8 @@ %global framework_name moduleframework Name: meta-test-family -Version: 0.7.4 -Release: 2%{?dist} +Version: 0.7.5 +Release: 1%{?dist} Summary: Tool to test components of a modular Fedora License: GPLv2+ @@ -55,6 +55,9 @@ install -d -p -m 755 %{buildroot}%{_datadir}/%{framework_name} %changelog +* Tue Oct 17 2017 Petr Hracek 0.7.5-1 +- new upstream release + * Wed Oct 04 2017 Petr Hracek 0.7.4-2 - fix shebang from two python files diff --git a/setup.py b/setup.py index e160e0d..de01e08 100755 --- a/setup.py +++ b/setup.py @@ -63,7 +63,7 @@ def get_dir(system_path=None, virtual_path=None): setup( name='meta-test-family', - version="0.7.3", + version="0.7.5", description='Tool to test components fo a modular Fedora.', keywords='modules,containers,testing,framework', author='Jan Scotka', From d05ee2d9b8a45094e2be0dafeaad38b58179a6d6 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Tue, 17 Oct 2017 13:12:33 +0200 Subject: [PATCH 027/117] add argparse, move test.py into templates --- examples/template/test.py | 25 +++++++ moduleframework/mtf_init.py | 136 +++++++++++++++++++----------------- 2 files changed, 97 insertions(+), 64 deletions(-) create mode 100644 examples/template/test.py diff --git a/examples/template/test.py b/examples/template/test.py new file mode 100644 index 0000000..b7e4d44 --- /dev/null +++ b/examples/template/test.py @@ -0,0 +1,25 @@ +#!/usr/bin/python + +# this is template showed in tool mtf-init +# try mtf-init to create basic config.yaml +# start test by: "sudo mtf" + +from avocado import main +from avocado.core import exceptions +from moduleframework import module_framework + +class Smoke1(module_framework.AvocadoTest): + """ + :avocado: enable + """ + + def test_uname(self): + self.start() + self.run("uname | grep Linux") + + def test_echo(self): + self.start() + self.runHost("echo test | grep test") + +if __name__ == '__main__': + main() diff --git a/moduleframework/mtf_init.py b/moduleframework/mtf_init.py index 046f0c8..0ee9e29 100644 --- a/moduleframework/mtf_init.py +++ b/moduleframework/mtf_init.py @@ -22,24 +22,50 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # -from optparse import OptionParser +import argparse import os.path +import logging +import sys +import yaml + +logger = logging.getLogger("mtf-init") + +# path fot templates test.py: +TEMPLATE_TEST = '/usr/share/moduleframework/examples/template/test.py' + + +def set_logging(level=logging.INFO): + global logger + logger.setLevel(level) + + handler = logging.StreamHandler(sys.stderr) + handler.setLevel(logging.DEBUG) + + formatter = logging.Formatter( + '%(asctime)s %(levelname)-6s %(message)s', '%H:%M:%S') + handler.setFormatter(formatter) + logger.addHandler(handler) + + mekk_logger = logging.getLogger("mekk.xmind") + null_handler = logging.NullHandler() + mekk_logger.addHandler(null_handler) + + +def cli(): + parser = argparse.ArgumentParser( + description="Create template of your first test!", + ) + parser.add_argument('--verbose', '-v', action='store_true', default=False) + parser.add_argument("--name", "-n", action="store", default="name not given", help='Name of module for testing') + parser.add_argument("--container", "-c", action="store", required=True, + help='Specify container path, example: docker.io/modularitycontainers/memcached') + + args = parser.parse_args() + + set_logging(level=logging.DEBUG if args.verbose else logging.INFO) + + return args -def get_options(): - parser = OptionParser(usage="usage: %prog [options]", - version="%prog 1.0") - parser.add_option("-n", "--name", - default="name", - dest="name", - help="Name of module for testing") - parser.add_option("-c", "--container", - dest="container", - default="docker.io/modularitycontainers/memcached", - action="store", - help="Specify container path, example: docker.io/modularitycontainers/memcached") - (options, args) = parser.parse_args() - - return options class Template(object): def __init__(self, name, container): @@ -48,54 +74,32 @@ def __init__(self, name, container): def set_content_config_yaml(self): self.filePathConfig = 'config.yaml' - configYaml = """# this is generated config.yaml with minimum stuff ---- -document: meta-test -version: 1 -name: {name} -default_module: docker -module: - docker: - container: {container} -""".format(name=self.name, container=self.container) - self.configYaml = configYaml + data = {"document" : "meta-test", + "version" : "1", + "name" : "xxx", + "default_module" : "docker", + "module" : {"docker" : {"container" : "xxx"}}} + data['name'] = self.name + data['module']['docker']['container'] = self.container + self.configYaml = yaml.dump(data) + logger.debug("{0}\n{1}".format(self.filePathConfig, self.configYaml)) def set_content_test_py(self): + # local name of the file: self.filePathTest = 'test.py' - test = '''#!/usr/bin/python - -# test example -# start test: "sudo mtf" - -from avocado import main -from avocado.core import exceptions -from moduleframework import module_framework - -class Smoke1(module_framework.AvocadoTest): - """ - :avocado: enable - """ + # use it from examples/template directory + with open(TEMPLATE_TEST,'r') as file: + self.test = file.read() + file.close() - def test_uname(self): - self.start() - self.run("uname | grep Linux") - - def test_echo(self): - self.start() - self.runHost("echo test | grep test") - -if __name__ == '__main__': - main() - -''' - self.test = test + logger.debug("{0}\n{1}".format(self.filePathTest, self.test)) def confirm(self): gogo = raw_input("Continue? yes/no\n") - if gogo == 'yes': + if gogo.lower() == 'yes': exit_condition = 0 return exit_condition - elif gogo == "no": + elif gogo.lower() == "no": exit_condition = 1 exit(1) return exit_condition @@ -114,21 +118,25 @@ def check_file(self): return True def save(self): - f1 = open(self.filePathConfig,'w') - f1.write(self.configYaml) - f1.close() + with open(self.filePathConfig,'w') as f1: + f1.write(self.configYaml) + logger.debug("{0} was changed".format(self.filePathConfig)) + f1.close() + + with open(self.filePathTest,'w') as f2: + f2.write(self.test) + logger.debug("{0} was changed".format(self.filePathTest)) + f2.close() - f2 = open(self.filePathTest,'w') - f2.write(self.test) - f2.close() def main(): - options=get_options() - resobj = Template(options.name, options.container) + args = cli() + logger.debug("Options: name={0}, container={1}".format(args.name, args.container)) + resobj = Template(args.name, args.container) resobj.set_content_config_yaml() resobj.set_content_test_py() if not resobj.check_file(): print("do nothing") exit(1) resobj.save() - print("Done, to run test:\n\tsudo mtf test.py") \ No newline at end of file + print("Done, to run test:\n\tsudo mtf test.py") From 2bc94b6b6d32c9936f61091616a60d7b234d0e4b Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 13 Oct 2017 15:55:26 +0200 Subject: [PATCH 028/117] nspawn operation moved to low level library not depenedent on mtf structure it is similar what does conu for docker containers --- Makefile | 4 +- meta-test-family.spec | 1 + moduleframework/common.py | 35 +- moduleframework/compose_info.py | 2 - moduleframework/dockerlinter.py | 6 +- .../environment_prepare/docker_prepare.py | 3 +- .../environment_prepare/nspawn_prepare.py | 3 +- .../environment_prepare/rpm_prepare.py | 2 +- moduleframework/exceptions.py | 5 +- moduleframework/helpers/nspawn_helper.py | 329 +--------- moduleframework/helpfile_linter.py | 2 +- moduleframework/mtf_environment.py | 2 +- moduleframework/mtf_generator.py | 5 +- moduleframework/pdc_data.py | 17 +- moduleframework/setup.py | 78 --- moduleframework/tools/__init__.py | 34 + moduleframework/version.py | 21 - mtf/__init__.py | 0 mtf/backend/__init__.py | 0 mtf/backend/nspawn.py | 585 ++++++++++++++++++ mtf/common/__init__.py | 1 + mtf/exceptions/__init__.py | 1 + mtf/meta-test/__init__.py | 1 + 23 files changed, 691 insertions(+), 446 deletions(-) delete mode 100644 moduleframework/setup.py create mode 100644 moduleframework/tools/__init__.py delete mode 100644 moduleframework/version.py create mode 100644 mtf/__init__.py create mode 100644 mtf/backend/__init__.py create mode 100644 mtf/backend/nspawn.py create mode 100644 mtf/common/__init__.py create mode 100644 mtf/exceptions/__init__.py create mode 100644 mtf/meta-test/__init__.py diff --git a/Makefile b/Makefile index 75112b2..00fdf6d 100644 --- a/Makefile +++ b/Makefile @@ -22,12 +22,12 @@ travis: .PHONY: clean clean: - @python setup.py clean + pip uninstall . git clean -fd rm -rf build/html install: clean - @python setup.py install + pip install -U . source: clean @python setup.py sdist diff --git a/meta-test-family.spec b/meta-test-family.spec index 967ad78..94cf7ff 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -51,6 +51,7 @@ install -d -p -m 755 %{buildroot}%{_datadir}/%{framework_name} %{_bindir}/mtf-log-parser %{_bindir}/mtf-init %{python2_sitelib}/moduleframework/ +%{python2_sitelib}/mtf/ %{python2_sitelib}/meta_test_family-*.egg-info/ %{_datadir}/moduleframework/ diff --git a/moduleframework/common.py b/moduleframework/common.py index 62b9840..429458b 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -24,6 +24,8 @@ Custom configuration and debugging library. """ +from __future__ import print_function + import netifaces import socket import os @@ -31,12 +33,9 @@ import yaml import subprocess import copy -import warnings - +import sys from avocado.utils import process - -from moduleframework.exceptions import * -from moduleframework.compose_info import ComposeParser +from moduleframework.exceptions import ModuleFrameworkException, ConfigExc, CmdExc defroutedev = netifaces.gateways().get('default').values( )[0][1] if netifaces.gateways().get('default') else "lo" @@ -136,7 +135,7 @@ def print_info(*args): "brackets { } in your code, please use double brackets {{ }}." "Possible values in trans_dict are: %s" % trans_dict) - print >> sys.stderr, result + print(result, file=sys.stderr) def print_debug(*args): @@ -241,6 +240,19 @@ def sanitize_cmd(cmd): return cmd +def translate_cmd(cmd, translation_dict=None): + if not translation_dict: + return cmd + try: + formattedcommand = cmd.format(**translation_dict) + except KeyError: + raise ModuleFrameworkException( + "Command is formatted by using trans_dict. If you want to use " + "brackets { } in your code, please use {{ }}. Possible values " + "in trans_dict are: %s. \nBAD COMMAND: %s" + % (translation_dict, cmd)) + return formattedcommand + def get_profile(): """ Return a profile name. @@ -389,15 +401,8 @@ def runHost(self, command="ls /", **kwargs): ** kwargs: avocado process.run params like: shell, ignore_status, verbose :return: avocado.process.run """ - try: - formattedcommand = command.format(**trans_dict) - except KeyError: - raise ModuleFrameworkException( - "Command is formatted by using trans_dict. If you want to use " - "brackets { } in your code, please use {{ }}. Possible values " - "in trans_dict are: %s. \nBAD COMMAND: %s" - % (trans_dict, command)) - return process.run("%s" % formattedcommand, **kwargs) + + return process.run("%s" % translate_cmd(command, translation_dict=trans_dict), **kwargs) def get_test_dependencies(self): """ diff --git a/moduleframework/compose_info.py b/moduleframework/compose_info.py index c1d8f13..119cc2d 100644 --- a/moduleframework/compose_info.py +++ b/moduleframework/compose_info.py @@ -27,8 +27,6 @@ module files """ -import yaml -import urllib import xml.etree.ElementTree import gzip import tempfile diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 81594cf..12d76ab 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -1,9 +1,7 @@ -from __future__ import print_function - import re import ast from dockerfile_parse import DockerfileParser -from moduleframework.common import get_docker_file +from moduleframework.common import get_docker_file, print_info # Dockerfile path EXPOSE = "EXPOSE" @@ -107,7 +105,7 @@ def _get_structure_as_dict(self): for v in ret_val: self.docker_dict[key].append(v) except KeyError: - print("Dockerfile tag %s is not parsed by MTF" % key) + print_info("Dockerfile tag %s is not parsed by MTF" % key) def get_docker_env(self): return self.docker_dict.get(ENV) diff --git a/moduleframework/environment_prepare/docker_prepare.py b/moduleframework/environment_prepare/docker_prepare.py index b5488d9..4e98d95 100644 --- a/moduleframework/environment_prepare/docker_prepare.py +++ b/moduleframework/environment_prepare/docker_prepare.py @@ -26,7 +26,8 @@ """ from avocado.utils import service -from moduleframework.common import * +from moduleframework.common import print_info, CommonFunctions +import os class EnvDocker(CommonFunctions): diff --git a/moduleframework/environment_prepare/nspawn_prepare.py b/moduleframework/environment_prepare/nspawn_prepare.py index 0013e33..29d24f7 100644 --- a/moduleframework/environment_prepare/nspawn_prepare.py +++ b/moduleframework/environment_prepare/nspawn_prepare.py @@ -25,7 +25,8 @@ module for environment setup and cleanup, to be able to split action for ansible, more steps instead of one complex """ -from moduleframework.common import * +import os +from moduleframework.common import CommonFunctions, print_info, is_not_silent selinux_state_file="/var/tmp/mtf_selinux_state" setseto = "Permissive" diff --git a/moduleframework/environment_prepare/rpm_prepare.py b/moduleframework/environment_prepare/rpm_prepare.py index 77fa1e1..75ffaba 100644 --- a/moduleframework/environment_prepare/rpm_prepare.py +++ b/moduleframework/environment_prepare/rpm_prepare.py @@ -25,7 +25,7 @@ module for environment setup and cleanup, to be able to split action for ansible, more steps instead of one complex """ -from moduleframework.common import * +from moduleframework.common import CommonFunctions, print_info class EnvRpm(CommonFunctions): diff --git a/moduleframework/exceptions.py b/moduleframework/exceptions.py index 38775e7..4726702 100644 --- a/moduleframework/exceptions.py +++ b/moduleframework/exceptions.py @@ -24,7 +24,7 @@ Custom exceptions library. """ -from __future__ import print_function +import common import sys import linecache @@ -44,7 +44,8 @@ def __init__(self, *args, **kwargs): filename = f.f_code.co_filename linecache.checkcache(filename) line = linecache.getline(filename, lineno, f.f_globals) - print("-----------\n| EXCEPTION IN: {} \n| LINE: {}, {} \n| ERROR: {}\n-----------".format(filename, lineno, line.strip(), exc_obj)) + common.print_info("-----------\n| EXCEPTION IN: {} \n| LINE: {}, {} \n| ERROR: {}\n-----------". + format(filename, lineno, line.strip(), exc_obj)) class NspawnExc(ModuleFrameworkException): diff --git a/moduleframework/helpers/nspawn_helper.py b/moduleframework/helpers/nspawn_helper.py index 0a12ebf..a5675b6 100644 --- a/moduleframework/helpers/nspawn_helper.py +++ b/moduleframework/helpers/nspawn_helper.py @@ -20,17 +20,14 @@ # Authors: Petr Hracek # -import shutil -import re -import glob import time import hashlib -import string -import random +import os -from moduleframework.common import * -from moduleframework.exceptions import * +from moduleframework.common import BASEPATHDIR, translate_cmd, \ + get_if_reuse, trans_dict, print_info, is_debug, get_if_do_cleanup from moduleframework.helpers.rpm_helper import RpmHelper +from mtf.backend.nspawn import Image, Container class NspawnHelper(RpmHelper): @@ -49,27 +46,19 @@ def __init__(self): """ super(NspawnHelper, self).__init__() self.baseprefix = os.path.join(BASEPATHDIR, "chroot_") - self.__selinuxState = None time.time() actualtime = time.time() - self.chrootpath_baseimage = "" + self.chrootpath_baseimage = os.path.abspath(self.baseprefix + + self.moduleName + + "_image_" + + hashlib.md5(" ".join(self.repos)).hexdigest()) if not get_if_reuse(): self.jmeno = "%s_%r" % (self.moduleName, actualtime) else: self.jmeno = self.moduleName self.chrootpath = os.path.abspath(self.baseprefix + self.jmeno) - self.__default_command_sleep = 2 - self.__systemd_wait_support = False - def __machined_restart(self): - """ - Machined is not reliable well, restart it whenever you want. - :return: None - """ - #return self.runHost("systemctl restart systemd-machined", verbose=is_debug(), ignore_status=True) - # remove restarting when used systemd-run - pass def setUp(self): """ @@ -84,141 +73,14 @@ def setUp(self): trans_dict["ROOT"] = self.chrootpath print_info("name of CHROOT directory:", self.chrootpath) self.setRepositoriesAndWhatToInstall() - self.__prepareSetup() - self.__create_snaphot() + self.__image_base = Image(location=self.chrootpath_baseimage, packageset=self.getPackageList(),repos=self.repos, ignore_installed=True) + self.__image = self.__image_base.create_snapshot(self.chrootpath) + self.__container = Container(image=self.__image, name=self.jmeno) self._callSetupFromConfig() - self.__bootMachine() - self.__systemd_wait_support = self.__run_systemdrun_decide() - - def __is_killed(self): - for foo in range(DEFAULTRETRYTIMEOUT): - time.sleep(1) - out = self.runHost("machinectl status %s" % self.jmeno, verbose=is_debug(), ignore_status=True) - if out.exit_status != 0: - print_debug("NSPAWN machine %s stopped" % self.jmeno) - return True - raise NspawnExc("Unable to stop machine %s within %d" % (self.jmeno, DEFAULTRETRYTIMEOUT)) - - def __is_booted(self): - for foo in range(DEFAULTRETRYTIMEOUT): - time.sleep(1) - out = self.runHost("machinectl status %s" % self.jmeno, verbose=is_debug(), ignore_status=True) - if "systemd-logind" in out.stdout: - time.sleep(2) - print_debug("NSPAWN machine %s booted" % self.jmeno) - return True - raise NspawnExc("Unable to start machine %s within %d" % (self.jmeno, DEFAULTRETRYTIMEOUT)) - - def __create_snaphot(self): - """ - Internal method, do not use it anyhow - - :return: None - """ - - if get_if_do_cleanup(): - # delete directory with same same (in case used option DO NOT CLEANUP) - if os.path.exists(self.chrootpath): - shutil.rmtree(self.chrootpath, ignore_errors=True) - # copy files from base image directory to working copy (instead of overlay) - if self.chrootpath_baseimage != self.chrootpath and \ - not os.path.exists(os.path.join(self.chrootpath, "usr")): - self.runHost("cp -rf %s %s" % (self.chrootpath_baseimage, self.chrootpath)) - - def __prepareSetup(self): - """ - Internal method, do not use it anyhow - - :return: None - """ - self.chrootpath_baseimage = os.path.abspath(self.baseprefix + - self.moduleName + - "_image_" + - hashlib.md5(" ".join(self.repos)).hexdigest()) - if not os.path.exists(os.path.join(self.chrootpath_baseimage, "usr")): - repos_to_use = "" - counter = 0 - for repo in self.repos: - counter = counter + 1 - repos_to_use += " --repofrompath %s%d,%s" % ( - self.moduleName, counter, repo) - try: - self.runHost( - ("%s install --nogpgcheck --setopt=install_weak_deps=False " - "--installroot %s --allowerasing --disablerepo=* --enablerepo=%s* %s %s") % - (trans_dict["HOSTPACKAGER"], self.chrootpath_baseimage, self.moduleName, repos_to_use, self.whattoinstallrpm), verbose=is_not_silent()) - except Exception as e: - raise NspawnExc( - "ERROR: Unable to install packages %s\n original exeption:\n%s\n" % - (self.whattoinstallrpm, str(e))) - # COPY yum repository inside NSPAW, to be able to do installations - insiderepopath = os.path.join(self.chrootpath_baseimage, self.yumrepo[1:]) - try: - os.makedirs(os.path.dirname(insiderepopath)) - except: - pass - counter = 0 - f = open(insiderepopath, 'w') - for repo in self.repos: - counter = counter + 1 - add = """[%s%d] -name=%s%d -baseurl=%s -enabled=1 -gpgcheck=0 - -""" % (self.moduleName, counter, self.moduleName, counter, repo) - f.write(add) - f.close() - - # shutil.copy(self.yumrepo, insiderepopath) - # self.runHost("sed s/enabled=0/enabled=1/ -i %s" % insiderepopath, ignore_status=True) - for repo in self.repos: - if "file:///" in repo: - src = repo[7:] - srcto = os.path.join(self.chrootpath_baseimage, src[1:]) - try: - os.makedirs(os.path.dirname(srcto)) - except Exception as e: - print_debug(e, "Unable to create DIR (already created)", srcto) - pass - try: - shutil.copytree(src, srcto) - except Exception as e: - print_debug(e, "Unable to copy files from:", src, "to:", srcto) - pass - pkipath = "/etc/pki/rpm-gpg" - pkipath_ch = os.path.join(self.chrootpath_baseimage, pkipath[1:]) - try: - os.makedirs(pkipath_ch) - except BaseException: - pass - for filename in glob.glob(os.path.join(pkipath, '*')): - shutil.copy(filename, pkipath_ch) - print_info("repo prepared:", insiderepopath, open(insiderepopath, 'r').read()) - else: - print_info("Base image for NSPAWN already exist: %s" % self.chrootpath_baseimage) - - def __bootMachine(self): - """ - Internal function. - Start machine via nspawn and wait untill booted. - - :return: None - """ - print_debug("starting NSPAWN") - nspawncont = process.SubProcess( - "systemd-nspawn --machine=%s -bD %s" % - (self.jmeno, self.chrootpath), verbose=is_debug()) - nspawncont.start() - self.__is_booted() - print_info("machine: %s started" % self.jmeno) - - trans_dict["GUESTIPADDR"] = trans_dict["HOSTIPADDR"] - self.ipaddr = trans_dict["GUESTIPADDR"] + self.__container.boot_machine() def run (self, command, **kwargs): - return self.__run_systemdrun(command, **kwargs) + return self.__container.execute(command=translate_cmd(command, translation_dict=trans_dict), **kwargs) def start(self, command="/bin/true"): """ @@ -229,140 +91,10 @@ def start(self, command="/bin/true"): :return: None """ command = self.info.get('start') or command - self.__run_systemdrun(command, internal_background=False, ignore_bg_processes=True, verbose=is_debug()) + self.run(command, internal_background=False, ignore_bg_processes=True, verbose=is_debug()) self.status() trans_dict["GUESTPACKAGER"] = self.get_packager() - def __run_systemdrun_decide(self): - return "--wait" in self.runHost("systemd-run --help",verbose=is_debug()).stdout - - def __systemctl_wait_until_finish(self, machine, unit): - """ - Wait until service is finished and return exit state - It workarounds issue: https://bugzilla.redhat.com/show_bug.cgi?id=1499877 - After it will be fixed, this can be removed - - :param machine: - :param unit: - :return: - """ - retcode = 0 - while True: - output = [x.strip() for x in - self.runHost("systemctl show -M {} {}".format(machine, unit), - verbose=False).stdout.split("\n")] - #if is_debug(): - # print_debug(output) - retcode = int([x[-1] for x in output if "ExecMainStatus=" in x][0]) - if not ("SubState=exited" in output or "SubState=failed" in output): - time.sleep(0.1) - else: - break - self.runHost("systemctl -M {} stop {}".format(machine, unit), - verbose=is_debug(), - ignore_status=True) - return retcode - - def __systemd_generate_unit_name(self): - return ''.join(random.choice(string.ascii_lowercase) for _ in range(10)) - - def __run_systemdrun(self, command, internal_background=False, **kwargs): - """ - Run command inside nspawn module type. It uses systemd-run. - since Fedora 26 there is important --wait option - - :param command: str command to be executed - :param kwargs: dict parameters passed to avocado.process.run - :return: avocado.process.run - """ - if not kwargs: - kwargs = {} - self.__machined_restart() - add_sleep_infinite = "" - unit_name = self.__systemd_generate_unit_name() - lpath = "/var/tmp/{}".format(unit_name) - if self.__systemd_wait_support: - add_wait_var = "--wait" - else: - # keep service exist after it finish, to be able to read exit code - add_wait_var = "-r" - if internal_background: - add_wait_var = "" - add_sleep_infinite = "&& sleep infinity" - opts = " --unit {unitname} {wait} -M {machine}".format(wait=add_wait_var, - machine=self.jmeno, - unitname=unit_name - ) - try: - comout = self.runHost("""systemd-run {opts} /bin/bash -c "({comm})>{pin}.stdout 2>{pin}.stderr {sleep}" """.format( - opts=opts, - comm=sanitize_cmd(command), - pin=lpath, - sleep=add_sleep_infinite, - ), - **kwargs) - if not internal_background: - if not self.__systemd_wait_support: - comout.exit_status = self.__systemctl_wait_until_finish(self.jmeno,unit_name) - with open("{chroot}{pin}.stdout".format(chroot=self.chrootpath, pin=lpath), 'r') as content_file: - comout.stdout = content_file.read() - with open("{chroot}{pin}.stderr".format(chroot=self.chrootpath, pin=lpath), 'r') as content_file: - comout.stderr = content_file.read() - comout.command = command - os.remove("{chroot}{pin}.stdout".format(chroot=self.chrootpath, pin=lpath)) - os.remove("{chroot}{pin}.stderr".format(chroot=self.chrootpath, pin=lpath)) - print_debug(comout) - if not self.__systemd_wait_support and kwargs.get("ignore_status") and comout.exit_status != 0: - raise process.CmdError(comout.command, comout) - return comout - except process.CmdError as e: - raise CmdExc("Command in SYSTEMD-RUN failed: %s" % command, e) - - def __run_machinectl(self, command, **kwargs): - """ - Run command inside nspawn module type. It uses machinectl shell command. - It need few workarounds, that's why it the code seems so strange - - TODO: workaround because machinedctl is unable to behave like ssh. It is bug - systemd-run should be used, but in F-25 it does not contain --wait option - - :param command: str command to be executed - :param kwargs: dict parameters passed to avocado.process.run - :return: avocado.process.run - """ - self.__machined_restart() - lpath = "/var/tmp" - if not kwargs: - kwargs = {} - should_ignore = kwargs.get("ignore_status") - kwargs["ignore_status"] = True - comout = self.runHost("""machinectl shell root@{machine} /bin/bash -c "({comm})>{pin}/stdout 2>{pin}/stderr; echo $?>{pin}/retcode; sleep {defaultsleep}" """.format( - machine=self.jmeno, - comm=sanitize_cmd(command), - pin=lpath, - defaultsleep=self.__default_command_sleep ), - **kwargs) - if comout.exit_status != 0: - raise NspawnExc("This command should not fail anyhow inside NSPAWN:", sanitize_cmd(command)) - try: - kwargs["verbose"] = is_not_silent() - b = self.runHost( - 'bash -c "cat {chroot}{pin}/stdout; cat {chroot}{pin}/stderr > /dev/stderr; exit `cat {chroot}{pin}/retcode`"'.format( - chroot=self.chrootpath, - pin=lpath), - **kwargs) - finally: - comout.stdout = b.stdout - comout.stderr = b.stderr - comout.exit_status = b.exit_status - removesworkaround = re.search('[^(]*\((.*)\)[^)]*', comout.command) - if removesworkaround: - comout.command = removesworkaround.group(1) - if comout.exit_status == 0 or should_ignore: - return comout - else: - raise process.CmdError(comout.command, comout) - def selfcheck(self): """ Test if default command will pass, it is more important for nspawn, because it happens that @@ -370,7 +102,7 @@ def selfcheck(self): :return: avocado.process.run """ - return self.run().stdout + return self.run(command="/bin/true").stdout def copyTo(self, src, dest): """ @@ -380,9 +112,7 @@ def copyTo(self, src, dest): :param dest: destination file on module :return: None """ - self.runHost( - " machinectl copy-to %s %s %s" % - (self.jmeno, src, dest), timeout=DEFAULTPROCESSTIMEOUT, ignore_bg_processes=True, verbose=is_not_silent()) + self.__container.copy_to(src, dest) def copyFrom(self, src, dest): """ @@ -392,9 +122,7 @@ def copyFrom(self, src, dest): :param dest: destination file on host :return: None """ - self.runHost( - " machinectl copy-from %s %s %s" % - (self.jmeno, src, dest), timeout=DEFAULTPROCESSTIMEOUT, ignore_bg_processes=True, verbose=is_not_silent()) + self.__container.copy_from(src, dest) def tearDown(self): """ @@ -404,28 +132,13 @@ def tearDown(self): """ if get_if_do_cleanup() and not get_if_reuse(): try: - self.stop() - except Exception as stopexception: - print_info("Stop action caused exception. It should not happen.", - stopexception) + self.__container.stop() + except: pass - self.__machined_restart() try: - self.runHost("machinectl poweroff %s" % self.jmeno, verbose=is_not_silent()) - self.__is_killed() - except Exception as poweroffex: - print_info("Unable to stop machine via poweroff, terminating", poweroffex) - try: - self.runHost("machinectl terminate %s" % self.jmeno, ignore_status=True) - self.__is_killed() - except Exception as poweroffexterm: - print_info("Unable to stop machine via terminate, STRANGE", poweroffexterm) - time.sleep(DEFAULTRETRYTIMEOUT) - pass + self.__container.rm() + except: pass - self._callCleanupFromConfig() - if os.path.exists(self.chrootpath): - shutil.rmtree(self.chrootpath, ignore_errors=True) else: print_info("tearDown skipped", "running nspawn: %s" % self.jmeno) print_info("To connect to a machine use:", diff --git a/moduleframework/helpfile_linter.py b/moduleframework/helpfile_linter.py index 1cf5873..296d596 100644 --- a/moduleframework/helpfile_linter.py +++ b/moduleframework/helpfile_linter.py @@ -1,7 +1,7 @@ from __future__ import print_function import os -from moduleframework import common +import common HELP_MD = "help.md" diff --git a/moduleframework/mtf_environment.py b/moduleframework/mtf_environment.py index cfac6fb..eb4f234 100644 --- a/moduleframework/mtf_environment.py +++ b/moduleframework/mtf_environment.py @@ -24,7 +24,7 @@ """ Module to setup and cleanup the test environment. """ -from moduleframework.common import * +from moduleframework.common import get_module_type_base, print_info from moduleframework.environment_prepare.docker_prepare import EnvDocker from moduleframework.environment_prepare.rpm_prepare import EnvRpm from moduleframework.environment_prepare.nspawn_prepare import EnvNspawn diff --git a/moduleframework/mtf_generator.py b/moduleframework/mtf_generator.py index ba98895..755757e 100644 --- a/moduleframework/mtf_generator.py +++ b/moduleframework/mtf_generator.py @@ -33,8 +33,7 @@ .. _Multiline Bash snippet tests: ../user_guide/how_to_write_conf_file#multiline-bash-snippet-tests """ -from __future__ import print_function -from moduleframework.common import CommonFunctions +from common import print_info, CommonFunctions class TestGenerator(CommonFunctions): @@ -89,7 +88,7 @@ def test_%s(self): self.output = self.output + \ ' self.%s(""" %s """, shell=%r)\n' % ( method, line, method == "runHost") - print("Added test (runmethod: %s): %s" % (method, testname)) + print_info("Added test (runmethod: %s): %s" % (method, testname)) def main(): diff --git a/moduleframework/pdc_data.py b/moduleframework/pdc_data.py index ab10765..3a504d8 100644 --- a/moduleframework/pdc_data.py +++ b/moduleframework/pdc_data.py @@ -28,10 +28,15 @@ Construct parameters for automatization (CIs) """ -import yaml import re +import yaml +import os from avocado import utils -from common import * + +from common import print_info, DEFAULTRETRYCOUNT, DEFAULTRETRYTIMEOUT, \ + get_if_remoterepos, get_compose_url_modular_release, MODULEFILE, print_debug,\ + is_debug, ARCH, is_recursive_download, trans_dict, BASEPATHDIR +from moduleframework import exceptions from pdc_client import PDCClient from timeoutlib import Retry @@ -115,14 +120,14 @@ def __getDataFromPdc(self): pdc_query['variant_version'] = self.stream if self.version: pdc_query['variant_release'] = self.version - @Retry(attempts=DEFAULTRETRYCOUNT,timeout=DEFAULTRETRYTIMEOUT,error=PDCExc("Could not query PDC server")) + @Retry(attempts=DEFAULTRETRYCOUNT,timeout=DEFAULTRETRYTIMEOUT,error=exceptions.PDCExc("Could not query PDC server")) def retry_tmpfunc(): # Using develop=True to not authenticate to the server pdc_session = PDCClient(PDC_SERVER, ssl_verify=True, develop=True) return pdc_session(**pdc_query) mod_info = retry_tmpfunc() if not mod_info or "results" not in mod_info.keys() or not mod_info["results"]: - raise PDCExc("QUERY: %s is not available on PDC" % pdc_query) + raise exceptions.PDCExc("QUERY: %s is not available on PDC" % pdc_query) self.pdcdata = mod_info["results"][-1] self.modulemd = yaml.load(self.pdcdata["modulemd"]) @@ -246,7 +251,7 @@ def download_tagged(self,dirname): print_debug("DOWNLOADING: %s" % foo) @Retry(attempts=DEFAULTRETRYCOUNT * 10, timeout=DEFAULTRETRYTIMEOUT * 60, delay=DEFAULTRETRYTIMEOUT, - error=KojiExc( + error=exceptions.KojiExc( "RETRY: Unbale to fetch package from koji after %d attempts" % (DEFAULTRETRYCOUNT * 10))) def tmpfunc(): a = utils.process.run( @@ -257,7 +262,7 @@ def tmpfunc(): print_debug( 'UNABLE TO DOWNLOAD package (intended for other architectures, GOOD):', a.command) else: - raise KojiExc( + raise exceptions.KojiExc( 'UNABLE TO DOWNLOAD package (KOJI issue, BAD):', a.command) tmpfunc() diff --git a/moduleframework/setup.py b/moduleframework/setup.py deleted file mode 100644 index 5bb56a9..0000000 --- a/moduleframework/setup.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Meta test family (MTF) is a tool to test components of a modular Fedora: -# https://docs.pagure.org/modularity/ -# Copyright (C) 2017 Red Hat, Inc. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# he Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -# -# Authors: Jan Scotka -# - -from __future__ import print_function -import glob -from moduleframework import module_framework -from avocado import utils - - -class Module(module_framework.CommonFunctions): - - whattoinstall = None - baseruntimeyaml = None - - def __init__(self): - self.loadconfig() - self.yamlconfig = self.getModulemdYamlconfig() - self.profile = module_framework.PROFILE if module_framework.PROFILE else "default" - if self.yamlconfig: - self.whattoinstall = self.yamlconfig['data']['profiles'][self.profile] - self.rootdir = "/tmp/tmpmodule1" - self.rpmsrepo = self.rootdir + "/rpms" - self.rpmsinstalled = self.rootdir + "/installed" - utils.process.run("mkdir -p %s" % self.rootdir) - utils.process.run("mkdir -p %s" % self.rpmsrepo) - utils.process.run("mkdir -p %s" % self.rpmsinstalled) - self.baseruntimeyaml = self.getModulemdYamlconfig( - "https://raw.githubusercontent.com/fedora-modularity/check_modulemd/develop/examples-modulemd/base-runtime.yaml") - - def CreateLocalRepo(self): - allmodulerpms = None - allbasertrpms = None - if self.whattoinstall: - allmodulerpms = " ".join(self.whattoinstall['rpms']) - if self.baseruntimeyaml: - allbasertrpms = " ".join(self.baseruntimeyaml['data'][ - 'profiles']['default']['rpms']) - if allbasertrpms is not None and allmodulerpms is not None: - utils.process.run( - "yumdownloader --destdir=%s --resolve %s %s" % - (self.rpmsrepo, allmodulerpms, allbasertrpms)) - utils.process.run( - "cd %s; createrepo --database %s" % - (self.rpmsrepo, self.rpmsrepo), shell=True) - print("file://%s" % self.rpmsrepo) - - def CreateContainer(self): - localfiles = glob.glob('%s/*.rpm' % self.rpmsrepo) - if localfiles and self.rpmsinstalled: - utils.process.run( - "dnf -y install --disablerepo=* --allowerasing --installroot=%s %s" % - (self.rpmsinstalled, " ".join(localfiles))) - print("file://%s" % self.rpmsrepo) - - -m = Module() -m.CreateLocalRepo() -m.CreateContainer() diff --git a/moduleframework/tools/__init__.py b/moduleframework/tools/__init__.py new file mode 100644 index 0000000..115cecc --- /dev/null +++ b/moduleframework/tools/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# +# Meta test family (MTF) is a tool to test components of a modular Fedora: +# https://docs.pagure.org/modularity/ +# Copyright (C) 2017 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copied from: https://github.com/fedora-modularity/check_compose/blob/master/check_compose.py +# +# Authors: Jan Scotka +# + +from moduleframework import module_framework + +class Basic(module_framework.AvocadoTest): + """ + :avocado: enable + """ + + def test(self): + self.start() \ No newline at end of file diff --git a/moduleframework/version.py b/moduleframework/version.py deleted file mode 100644 index 5f42bb1..0000000 --- a/moduleframework/version.py +++ /dev/null @@ -1,21 +0,0 @@ -import os - -SPECFILEPATH = os.path.abspath( - # Path to SPECFILE - os.path.join( - os.path.dirname(__file__), - "..", - "meta-test-family.spec" - )) - - -def version_func(): - with open(SPECFILEPATH, 'r') as infile: - for line in infile.readlines(): - if "Version: " in line: - return line[16:].strip() - raise BaseException( - "Unable to read Version string from specfile:", SPECFILEPATH) - - -VERSION = version_func() diff --git a/mtf/__init__.py b/mtf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mtf/backend/__init__.py b/mtf/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mtf/backend/nspawn.py b/mtf/backend/nspawn.py new file mode 100644 index 0000000..680ca3f --- /dev/null +++ b/mtf/backend/nspawn.py @@ -0,0 +1,585 @@ +# -*- coding: utf-8 -*- +# +# This Modularity Testing Framework helps you to write tests for modules +# Copyright (C) 2017 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Authors: Jan Scotka +# + +""" +Low level library handling Systemd nspawn containers and images +""" + +import os +import logging +import shutil +import glob +import random +import string +import time +import re + +from avocado import Test +from avocado.utils import process +from mtf import common +from mtf import exceptions + +DEFAULT_RETRYTIMEOUT = 30 +DEFAULT_SLEEP = 1 +base_package_set = ["systemd"] + +is_debug_low = common.is_debug +if is_debug_low(): + logging.basicConfig(level=logging.DEBUG) + + +def generate_unique_name(size=10): + return ''.join(random.choice(string.ascii_lowercase) for _ in range(size)) + +class Image(object): + """ + It represents image object for Nspawn virtualization + Actually it is directory + """ + logger = logging.getLogger("Image") + def __init__(self, repos, packageset, location, installed=False, packager="dnf -y", + name="unique", ignore_installed=False): + self.repos = repos + self.packageset = list(set(packageset + base_package_set)) + self.location = location + self.packager = packager + self.name = name + baserepodir=os.path.join("/etc", "yum.repos.d") + # allow to fake environment in ubuntu (for Travis) + if not os.path.exists(baserepodir): + baserepodir="/var/tmp" + self.yumrepo = os.path.join(baserepodir, "%s.repo" % self.name) + if installed: + pass + else: + try: + self.__install() + except exceptions.NspawnExc as e: + if ignore_installed: + pass + else: + raise e + + def create_snapshot(self, destination): + """ + returns Image object with copyied files from base image + + :param destination: directory where to crete copy + :return: Image + """ + self.logger.debug("Create Snapshot: %s -> %s" % (self.location, destination)) + # copytree somethimes fails, it is not reliable in case of copy of system + # shutil.copytree(self.location, destination) + # cp will do better work + process.run("cp -rf %s %s" % (self.location, destination)) + return self.__class__(repos=self.repos, packageset=self.packageset, + location=destination, installed=True, + packager=self.packager, name=self.name) + + def __install(self): + """ + Internal method for installing packages to chroot and set repositories. + + :return: None + """ + self.logger.debug("Install system to direcory: %s" % self.location) + if not os.path.exists(os.path.join(self.location, "usr")): + if not os.path.exists(self.location): + os.makedirs(self.location) + repos_to_use = "" + counter = 0 + for repo in self.repos: + counter = counter + 1 + repos_to_use += " --repofrompath %s%d,%s" % ( + self.name, counter, repo) + self.logger.debug("Install packages: %s" % self.packageset) + self.logger.debug("Repositories: %s" % self.repos) + process.run("%s install --nogpgcheck --setopt=install_weak_deps=False " + "--installroot %s --allowerasing --disablerepo=* --enablerepo=%s* %s %s" % + (self.packager, self.location, self.name, + repos_to_use, " ".join(self.packageset)), + verbose=is_debug_low()) + insiderepopath = os.path.join(self.location, self.yumrepo[1:]) + if not os.path.exists(os.path.dirname(insiderepopath)): + os.makedirs(os.path.dirname(insiderepopath)) + counter = 0 + with open(insiderepopath, 'w') as f: + for repo in self.repos: + counter = counter + 1 + add = """[%s%d] + name=%s%d + baseurl=%s + enabled=1 + gpgcheck=0 + + """ % (self.name, counter, self.name, counter, repo) + f.write(add) + for repo in self.repos: + if "file:///" in repo: + src = repo[7:] + srcto = os.path.join(self.location, src[1:]) + if not os.path.exists(os.makedirs(os.path.dirname(srcto))): + os.makedirs(os.path.dirname(srcto)) + shutil.copytree(src, srcto) + pkipath = "/etc/pki/rpm-gpg" + pkipath_ch = os.path.join(self.location, pkipath[1:]) + if not os.path.exists(pkipath_ch): + os.makedirs(pkipath_ch) + for filename in glob.glob(os.path.join(pkipath, '*')): + shutil.copy(filename, pkipath_ch) + else: + raise exceptions.NspawnExc("Directory %s already in use" % self.location) + + def get_location(self): + """ + return directory location + + :return: str + """ + return self.location + + def rmi(self): + shutil.rmtree(self.location) + +class Container(object): + """ + It represents nspawn container virtualization with + methods for start/run/execute commands inside + + """ + logger = logging.getLogger("Container") + __systemd_wait_support = False + __default_command_sleep = 2 + __alternative_boot = False + + def __init__(self, image, name=None): + """ + + :param image: Image object + :param name: optional, use unique name for generating containers in case not given, some name is generated + """ + self.image = image + self.name = name or generate_unique_name() + self.location = self.image.get_location() + self.__systemd_wait_support = self._run_systemdrun_decide() + + def __machined_restart(self): + # this is removed, it was important for crappy machinectl shell handling + #self.logger.debug("restart systemd-machined") + #return process.run("systemctl restart systemd-machined", verbose=is_debug_low(), ignore_status=True) + pass + + def __is_killed(self): + for foo in range(DEFAULT_RETRYTIMEOUT): + time.sleep(DEFAULT_SLEEP) + out = process.run("machinectl status %s" % self.name, ignore_status=True, verbose=is_debug_low()) + if out.exit_status != 0: + return True + raise exceptions.NspawnExc("Unable to stop machine %s within %d" % (self.name, DEFAULT_RETRYTIMEOUT)) + + def __is_booted(self): + for foo in range(DEFAULT_RETRYTIMEOUT): + time.sleep(DEFAULT_SLEEP) + out = process.run("machinectl status %s" % self.name, ignore_status=True, verbose=is_debug_low()) + if not self.__alternative_boot: + if "systemd-logind" in out.stdout: + time.sleep(DEFAULT_SLEEP) + return True + else: + if "Unit: machine" in out.stdout: + time.sleep(DEFAULT_SLEEP) + return True + raise exceptions.NspawnExc("Unable to start machine %s within %d" % (self.name, DEFAULT_RETRYTIMEOUT)) + + def boot_machine(self, nspawn_add_option_list=[], boot_cmd="", wait_finish=False): + """ + start machine via -b option (full boot, default) or + via boot_cmd (usefull with wait_finish=True option) + + :param nspawn_add_option_list: list - additional nspawn parameters + :param boot_cmd: std - command with aruments for starting + :param wait_finish: - bool - wait to process finish (by default it just wait for creting systemd unit and boot) + :return: process.Subprocess object + """ + self.logger.debug("starting NSPAWN") + bootmachine = "" + bootmachine_cmd = "" + if boot_cmd: + self.__alternative_boot = True + bootmachine_cmd = boot_cmd + process.run("systemctl reset-failed machine-%s.scope" % self.name, + ignore_status=True, verbose=is_debug_low()) + else: + bootmachine = "-b" + command = "systemd-nspawn --machine=%s %s %s -D %s %s" % \ + (self.name, " ".join(nspawn_add_option_list), bootmachine, self.location, bootmachine_cmd) + self.logger.debug("Start command: %s" % command) + nspawncont = process.SubProcess(command) + self.logger.info("machine: %s starting" % self.name) + if wait_finish: + nspawncont.wait() + else: + nspawncont.start() + self.__is_booted() + self.logger.info("machine: %s starting finished" % self.name) + return nspawncont + + def execute(self, command, **kwargs): + """ + execute command inside container, it hides what method will be used + + :param command: str + :param kwargs: pass thru to avocado.process.run command + :return: process object + """ + return self.run_systemdrun(command, **kwargs) + + def _run_systemdrun_decide(self): + """ + Internal method + decide if it is possible to use --wait option to systemd + + :return: + """ + return "--wait" in process.run("systemd-run --help", verbose=is_debug_low()).stdout + + def __systemctl_wait_until_finish(self, machine, unit): + """ + Internal method + workaround for systemd-run without --wait option + + :param machine: + :param unit: + :return: + """ + while True: + output = [x.strip() for x in + process.run("systemctl show -M {} {}".format(machine, unit), + verbose=is_debug_low()).stdout.split("\n")] + retcode = int([x[-1] for x in output if "ExecMainStatus=" in x][0]) + if not ("SubState=exited" in output or "SubState=failed" in output): + time.sleep(0.1) + else: + break + process.run("systemctl -M {} stop {}".format(machine, unit), ignore_status=True, verbose=is_debug_low()) + return retcode + + def run_systemdrun(self, command, internal_background=False, **kwargs): + """ + execute command via systemd-run inside container + + :param command: + :param internal_background: + :param kwargs: + :return: + """ + if not kwargs: + kwargs = {} + self.__machined_restart() + add_sleep_infinite = "" + unit_name = generate_unique_name() + lpath = "/var/tmp/{}".format(unit_name) + if self.__systemd_wait_support: + add_wait_var = "--wait" + else: + # keep service exist after it finish, to be able to read exit code + add_wait_var = "-r" + if internal_background: + add_wait_var = "" + add_sleep_infinite = "&& sleep infinity" + opts = " --unit {unitname} {wait} -M {machine}".format(wait=add_wait_var, + machine=self.name, + unitname=unit_name + ) + try: + comout = process.run("""systemd-run {opts} /bin/bash -c "({comm})>{pin}.stdout 2>{pin}.stderr {sleep}" """.format( + opts=opts, comm=common.sanitize_cmd(command), pin=lpath, sleep=add_sleep_infinite), + **kwargs) + if not internal_background: + if not self.__systemd_wait_support: + comout.exit_status = self.__systemctl_wait_until_finish(self.name,unit_name) + with open("{chroot}{pin}.stdout".format(chroot=self.location, pin=lpath), 'r') as content_file: + comout.stdout = content_file.read() + with open("{chroot}{pin}.stderr".format(chroot=self.location, pin=lpath), 'r') as content_file: + comout.stderr = content_file.read() + comout.command = command + os.remove("{chroot}{pin}.stdout".format(chroot=self.location, pin=lpath)) + os.remove("{chroot}{pin}.stderr".format(chroot=self.location, pin=lpath)) + self.logger.debug(comout) + if not self.__systemd_wait_support and kwargs.get("ignore_status") and comout.exit_status != 0: + raise process.CmdError(comout.command, comout) + return comout + except process.CmdError as e: + raise e + + def run_machinectl(self, command, **kwargs): + """ + execute command via machinectl shell inside container + + :param command: + :param kwargs: + :return: + """ + self.__machined_restart() + lpath = "/var/tmp" + if not kwargs: + kwargs = {} + should_ignore = kwargs.get("ignore_status") + kwargs["ignore_status"] = True + comout = process.run("""machinectl shell root@{machine} /bin/bash -c "({comm})>{pin}/stdout 2>{pin}/stderr; echo $?>{pin}/retcode; sleep {defaultsleep}" """.format( + machine=self.name, comm=common.sanitize_cmd(command), pin=lpath, + defaultsleep=self.__default_command_sleep ), **kwargs) + if comout.exit_status != 0: + raise exceptions.NspawnExc("This command should not fail anyhow inside NSPAWN:", command) + try: + kwargs["verbose"] = False + b = process.run( + 'bash -c "cat {chroot}{pin}/stdout; cat {chroot}{pin}/stderr > /dev/stderr; exit `cat {chroot}{pin}/retcode`"'.format( + chroot=self.location, + pin=lpath), + **kwargs) + finally: + comout.stdout = b.stdout + comout.stderr = b.stderr + comout.exit_status = b.exit_status + removesworkaround = re.search('[^(]*\((.*)\)[^)]*', comout.command) + if removesworkaround: + comout.command = removesworkaround.group(1) + if comout.exit_status == 0 or should_ignore: + return comout + else: + raise process.CmdError(comout.command, comout) + + def selfcheck(self): + """ + Test if default command will pass, it is more important for nspawn, because it happens that + it does not returns anything + + :return: avocado.process.run + """ + return self.execute("true") + + def copy_to(self, src, dest): + """ + Copy file to module from host + + :param src: source file on host + :param dest: destination file on module + :return: None + """ + self.logger.debug("copy files (inside) from: %s to: %s" % (src, dest)) + process.run( + " machinectl copy-to %s %s %s" % + (self.name, src, dest), timeout=DEFAULT_RETRYTIMEOUT, verbose=is_debug_low()) + + def copy_from(self, src, dest): + """ + Copy file from module to host + + :param src: source file on module + :param dest: destination file on host + :return: None + """ + self.logger.debug("copy files (outside) from: %s to: %s" % (src, dest)) + process.run( + " machinectl copy-from %s %s %s" % + (self.name, src, dest), timeout=DEFAULT_RETRYTIMEOUT, verbose=is_debug_low()) + + def stop(self): + """ + Stop the nspawn container + + :return: + """ + self.logger.debug("Stop") + self.__machined_restart() + try: + if not self.__alternative_boot: + process.run("machinectl poweroff %s" % self.name, verbose=is_debug_low()) + else: + try: + process.run("systemctl kill --kill-who=all -s9 machine-%s.scope" % self.name, + ignore_status=True, verbose=is_debug_low()) + except Exception: + pass + try: + process.run("systemctl reset-failed machine-%s.scope" % self.name, + ignore_status=True, verbose=is_debug_low()) + except Exception: + pass + self.__is_killed() + except BaseException as poweroffex: + self.logger.debug("Unable to stop machine via poweroff, terminating : %s" % poweroffex) + try: + process.run("machinectl terminate %s" % self.name, ignore_status=True, verbose=is_debug_low()) + self.__is_killed() + except BaseException as poweroffexterm: + self.logger.debug("Unable to stop machine via terminate, STRANGE: %s" % poweroffexterm) + time.sleep(DEFAULT_RETRYTIMEOUT) + pass + pass + + def rm(self): + """ + Remove container image via image method + + :return: + """ + self.logger.debug("Remove") + self.image.rmi() + + +# ====================== Self Tests ====================== + +class testImage(Test): + """ + Test Image class for folders and nspawn installation to dirs + """ + loc1 = "/tmp/dddd1" + loc2 = "/tmp/dddd2" + + def setUp(self): + # cleanup dirs, to ensure that it will pass + # it raises error in case of existing and not used installed=True as option + process.run("rm -rf %s %s" % (self.loc1, self.loc2), ignore_status=True) + self.i1=Image(repos=["http://ftp.fi.muni.cz/pub/linux/fedora/linux/releases/26/Everything/x86_64/os/"], + packageset=["bash"], + location=self.loc1) + + def test_basic(self): + assert self.loc1 == self.i1.get_location() + assert os.path.exists(os.path.join(self.i1.get_location(),"usr")) + self.i2 = self.i1.create_snapshot(self.loc2) + assert self.loc2 == self.i2.get_location() + assert os.path.exists(os.path.join(self.i2.get_location(), "usr")) + self.i2.rmi() + + def tearDown(self): + try: + self.i1.rmi() + except: + pass + try: + self.i2.rmi() + except: + pass + + +class testContainer(Test): + """ + It tests Container object and his abilities to run various commands + """ + c1 = None + cname = "contA" + def setUp(self): + loc1 = "/tmp/dddd1" + self.i1 = Image(repos=["http://ftp.fi.muni.cz/pub/linux/fedora/linux/releases/26/Everything/x86_64/os/"], + packageset=["bash", "systemd"], location=loc1, ignore_installed=True) + + def test_basic(self): + self.c1 = Container(image=self.i1, name=self.cname) + self.c1.boot_machine() + assert "sbin" in self.c1.execute(command="ls /").stdout + + + def test_basic_noname(self): + self.c1 = Container(image=self.i1) + self.c1.boot_machine() + assert "sbin" in self.c1.execute(command="ls /").stdout + + def test_basic_systemd_run(self): + self.c1 = Container(image=self.i1, name=self.cname) + self.c1.boot_machine() + assert "sbin" in self.c1.run_systemdrun(command="ls /").stdout + + def test_basic_systemd_run_no_wait(self): + class ContainerNoWait(Container): + def _run_systemdrun_decide(self): + return False + self.c1 = ContainerNoWait(image=self.i1, name=self.cname) + self.c1.boot_machine() + assert "sbin" in self.c1.run_systemdrun(command="ls /").stdout + + + def BAD_test_basic_machinectl_shell(self): + # this test is able to break machine (lock machinectl) + self.c1 = Container(image=self.i1, name=self.cname) + self.c1.boot_machine() + assert "sbin" in self.c1.run_machinectl(command="ls /").stdout + + + def test_copy(self): + self.c1 = Container(image=self.i1, name=self.cname) + self.c1.boot_machine() + ff1 = "/tmp/ee" + ff2 = "/tmp/eee" + process.run("rm -f %s %s" % (ff1, ff2), ignore_status=True) + self.c1.execute("rm -f %s %s" %(ff1, ff2), ignore_status=True) + process.run("echo outside > %s" % ff1, shell=True) + assert "outside" in process.run("cat %s" % ff1).stdout + self.c1.copy_to(ff1, ff2) + assert "outside" in self.c1.execute("cat %s" % ff2).stdout + self.c1.execute("echo inside > %s" % ff1) + assert "inside" in self.c1.execute("cat %s" % ff1).stdout + self.c1.copy_from(ff1, ff2) + assert "inside" in process.run("cat %s" % ff2).stdout + + def test_boot_command(self): + self.c1 = Container(image=self.i1, name=self.cname) + self.c1.boot_machine(boot_cmd="sleep 100") + assert "sleep 100" in process.run("machinectl status %s" % self.cname).stdout + self.c1.stop() + try: + process.run("machinectl status %s" % self.cname) + except: + pass + else: + assert False + + def test_boot_command_wait(self): + self.c1 = Container(image=self.i1, name=self.cname) + sleep_time = 10 + t_before = time.time() + self.c1.boot_machine(boot_cmd="sleep %s" % sleep_time, wait_finish=True) + t_after = time.time() + assert (t_after - t_before) > sleep_time + assert (t_after - t_before) < sleep_time*1.5 + try: + process.run("machinectl status %s" % self.cname) + except: + pass + else: + assert False + + + def test_container_additional_options(self): + self.c1 = Container(image=self.i1, name=self.cname) + self.c1.boot_machine(nspawn_add_option_list=["--private-network"]) + assert "sbin" in self.c1.execute(command="ls /").stdout + ifaces = self.c1.execute(command="cat /proc/net/dev") + print ifaces + assert "lo:" in ifaces.stdout + assert len(ifaces.stdout.split("\n")) > 2 + assert len(ifaces.stdout.split("\n"))<=4 + + def tearDown(self): + self.c1.stop() \ No newline at end of file diff --git a/mtf/common/__init__.py b/mtf/common/__init__.py new file mode 100644 index 0000000..3c97c9e --- /dev/null +++ b/mtf/common/__init__.py @@ -0,0 +1 @@ +from moduleframework.common import * \ No newline at end of file diff --git a/mtf/exceptions/__init__.py b/mtf/exceptions/__init__.py new file mode 100644 index 0000000..acf6061 --- /dev/null +++ b/mtf/exceptions/__init__.py @@ -0,0 +1 @@ +from moduleframework.exceptions import * \ No newline at end of file diff --git a/mtf/meta-test/__init__.py b/mtf/meta-test/__init__.py new file mode 100644 index 0000000..df9953c --- /dev/null +++ b/mtf/meta-test/__init__.py @@ -0,0 +1 @@ +from moduleframework.module_framework import * \ No newline at end of file From bb4b3f225efc9e92800748c107a730f5f0983c4b Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 18 Oct 2017 07:56:14 +0200 Subject: [PATCH 029/117] add function to run script on remote machine add test for run_file function --- examples/testing-module/run_remote_script.py | 49 +++++++++++++++++++ .../avocado_testers/avocado_test.py | 12 +++++ moduleframework/common.py | 42 ++++++++++++++++ mtf/backend/nspawn.py | 10 +--- mtf/common/__init__.py | 2 +- mtf/exceptions/__init__.py | 2 +- mtf/meta-test/__init__.py | 2 +- 7 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 examples/testing-module/run_remote_script.py diff --git a/examples/testing-module/run_remote_script.py b/examples/testing-module/run_remote_script.py new file mode 100644 index 0000000..4420d6f --- /dev/null +++ b/examples/testing-module/run_remote_script.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# +# Meta test family (MTF) is a tool to test components of a modular Fedora: +# https://docs.pagure.org/modularity/ +# Copyright (C) 2017 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Authors: Jan Scotka +# + +from moduleframework import module_framework +from tempfile import mktemp +import os + +class remoteBinary(module_framework.AvocadoTest): + """ + :avocado: enable + """ + + def testInsideModule(self): + self.start() + scriptfile = mktemp() + script="""#!/bin/bash +echo stdoutput +echo erroutput >&2 +echo $@ + """ + with open(scriptfile, "w") as text_file: + text_file.write(script) + outputprocess = self.run_file(scriptfile) + self.assertIn("stdoutput", outputprocess.stdout) + self.assertIn("erroutput", outputprocess.stderr) + outputprocess = self.run_file(scriptfile, "Hallo", "World") + self.assertIn("stdoutput", outputprocess.stdout) + self.assertIn("Hallo World", outputprocess.stdout) + os.remove(scriptfile) diff --git a/moduleframework/avocado_testers/avocado_test.py b/moduleframework/avocado_testers/avocado_test.py index 8e8b549..a4e1472 100644 --- a/moduleframework/avocado_testers/avocado_test.py +++ b/moduleframework/avocado_testers/avocado_test.py @@ -252,6 +252,18 @@ def getModuleDependencies(self): """ return self.backend.getModuleDependencies() + def run_file(self, *args, **kwargs): + """ + run script or binary inside module + + :param filename: filename to copy to module + :param args: pass this args as cmdline args to run binary + :param kwargs: pass thru to avocado process.run + :return: avocado process.run object + """ + return self.backend.run_file(*args, **kwargs) + + def get_backend(): """ Return proper module backend, set by config by default_module section, or defined via diff --git a/moduleframework/common.py b/moduleframework/common.py index 429458b..cebb8cc 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -34,6 +34,8 @@ import subprocess import copy import sys +import random +import string from avocado.utils import process from moduleframework.exceptions import ModuleFrameworkException, ConfigExc, CmdExc @@ -84,6 +86,9 @@ DEFAULTNSPAWNTIMEOUT = 10 MODULE_DEFAULT_PROFILE="default" +def generate_unique_name(size=10): + return ''.join(random.choice(string.ascii_lowercase) for _ in range(size)) + def get_compose_url_modular_release(): default_release = "27" release = os.environ.get("MTF_FEDORA_RELEASE") or default_release @@ -615,6 +620,43 @@ def tearDown(self): else: print_info("TearDown phase skipped.") + def copyTo(self, src, dest): + """ + Copy file to module from host + + :param src: source file on host + :param dest: destination file on module + :return: None + """ + if src is not dest: + self.run("cp -rf %s %s" % (src, dest)) + + def copyFrom(self, src, dest): + """ + Copy file from module to host + + :param src: source file on module + :param dest: destination file on host + :return: None + """ + if src is not dest: + self.run("cp -rf %s %s" % (src, dest)) + + def run_file(self,filename, *args, **kwargs): + """ + run script or binary inside module + :param filename: filename to copy to module + :param args: pass this args as cmdline args to run binary + :param kwargs: pass thru to avocado process.run + :return: avocado process.run object + """ + dest = "/tmp/%s" % generate_unique_name() + self.copyTo(filename, dest) + self.run("chmod a+x %s" % dest) + parameters = "" + if args: + parameters = " " + " ".join(args) + return self.run(dest + parameters, **kwargs) def get_config(): """ diff --git a/mtf/backend/nspawn.py b/mtf/backend/nspawn.py index 680ca3f..6253360 100644 --- a/mtf/backend/nspawn.py +++ b/mtf/backend/nspawn.py @@ -28,8 +28,6 @@ import logging import shutil import glob -import random -import string import time import re @@ -46,10 +44,6 @@ if is_debug_low(): logging.basicConfig(level=logging.DEBUG) - -def generate_unique_name(size=10): - return ''.join(random.choice(string.ascii_lowercase) for _ in range(size)) - class Image(object): """ It represents image object for Nspawn virtualization @@ -178,7 +172,7 @@ def __init__(self, image, name=None): :param name: optional, use unique name for generating containers in case not given, some name is generated """ self.image = image - self.name = name or generate_unique_name() + self.name = name or common.generate_unique_name() self.location = self.image.get_location() self.__systemd_wait_support = self._run_systemdrun_decide() @@ -296,7 +290,7 @@ def run_systemdrun(self, command, internal_background=False, **kwargs): kwargs = {} self.__machined_restart() add_sleep_infinite = "" - unit_name = generate_unique_name() + unit_name = common.generate_unique_name() lpath = "/var/tmp/{}".format(unit_name) if self.__systemd_wait_support: add_wait_var = "--wait" diff --git a/mtf/common/__init__.py b/mtf/common/__init__.py index 3c97c9e..c1c5911 100644 --- a/mtf/common/__init__.py +++ b/mtf/common/__init__.py @@ -1 +1 @@ -from moduleframework.common import * \ No newline at end of file +from moduleframework.common import * diff --git a/mtf/exceptions/__init__.py b/mtf/exceptions/__init__.py index acf6061..022848a 100644 --- a/mtf/exceptions/__init__.py +++ b/mtf/exceptions/__init__.py @@ -1 +1 @@ -from moduleframework.exceptions import * \ No newline at end of file +from moduleframework.exceptions import * diff --git a/mtf/meta-test/__init__.py b/mtf/meta-test/__init__.py index df9953c..80135ca 100644 --- a/mtf/meta-test/__init__.py +++ b/mtf/meta-test/__init__.py @@ -1 +1 @@ -from moduleframework.module_framework import * \ No newline at end of file +from moduleframework.module_framework import * From 07401f8d8969aa836d3c68916678b66a4bb4424e Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 18 Oct 2017 08:32:16 +0200 Subject: [PATCH 030/117] test for exception return in case of failed command, check ret code and raised exception --- examples/testing-module/run_remote_script.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/examples/testing-module/run_remote_script.py b/examples/testing-module/run_remote_script.py index 4420d6f..6cfcbc3 100644 --- a/examples/testing-module/run_remote_script.py +++ b/examples/testing-module/run_remote_script.py @@ -22,6 +22,7 @@ # from moduleframework import module_framework +from avocado.utils import process from tempfile import mktemp import os @@ -30,7 +31,7 @@ class remoteBinary(module_framework.AvocadoTest): :avocado: enable """ - def testInsideModule(self): + def test_outputs(self): self.start() scriptfile = mktemp() script="""#!/bin/bash @@ -47,3 +48,18 @@ def testInsideModule(self): self.assertIn("stdoutput", outputprocess.stdout) self.assertIn("Hallo World", outputprocess.stdout) os.remove(scriptfile) + + def test_exit_code(self): + self.start() + scriptfile = mktemp() + ecode = 15 + script = """#!/bin/bash +exit {} +""".format(ecode) + with open(scriptfile, "w") as text_file: + text_file.write(script) + outputprocess = self.run_file(scriptfile, ignore_status=True) + self.assertEqual(ecode, outputprocess.exit_status) + + self.assertRaises(process.CmdError, self.run_file, scriptfile) + os.remove(scriptfile) From d9cbaea4e1de75d55cfd993833f48459871b52a9 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 18 Oct 2017 09:57:26 +0200 Subject: [PATCH 031/117] there is sometimes problem to do chmod, so run it via bash --- examples/testing-module/Makefile | 2 +- examples/testing-module/run_remote_script.py | 10 +++++----- moduleframework/avocado_testers/avocado_test.py | 4 ++-- moduleframework/common.py | 7 ++++--- mtf/backend/nspawn.py | 2 +- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/examples/testing-module/Makefile b/examples/testing-module/Makefile index 1aca06e..b68c054 100644 --- a/examples/testing-module/Makefile +++ b/examples/testing-module/Makefile @@ -1,4 +1,4 @@ -CMD=avocado run +CMD=mtf TESTS=$(shell ls *.py *.sh ../../moduleframework/tools/*.py) SIMPLE=simpleTest.py export MTF_REMOTE_REPOS=yes diff --git a/examples/testing-module/run_remote_script.py b/examples/testing-module/run_remote_script.py index 6cfcbc3..335f65d 100644 --- a/examples/testing-module/run_remote_script.py +++ b/examples/testing-module/run_remote_script.py @@ -41,10 +41,10 @@ def test_outputs(self): """ with open(scriptfile, "w") as text_file: text_file.write(script) - outputprocess = self.run_file(scriptfile) + outputprocess = self.run_script(scriptfile) self.assertIn("stdoutput", outputprocess.stdout) self.assertIn("erroutput", outputprocess.stderr) - outputprocess = self.run_file(scriptfile, "Hallo", "World") + outputprocess = self.run_script(scriptfile, "Hallo", "World") self.assertIn("stdoutput", outputprocess.stdout) self.assertIn("Hallo World", outputprocess.stdout) os.remove(scriptfile) @@ -58,8 +58,8 @@ def test_exit_code(self): """.format(ecode) with open(scriptfile, "w") as text_file: text_file.write(script) - outputprocess = self.run_file(scriptfile, ignore_status=True) + outputprocess = self.run_script(scriptfile, ignore_status=True) self.assertEqual(ecode, outputprocess.exit_status) - - self.assertRaises(process.CmdError, self.run_file, scriptfile) + + self.assertRaises(process.CmdError, self.run_script, scriptfile) os.remove(scriptfile) diff --git a/moduleframework/avocado_testers/avocado_test.py b/moduleframework/avocado_testers/avocado_test.py index a4e1472..a9b60fc 100644 --- a/moduleframework/avocado_testers/avocado_test.py +++ b/moduleframework/avocado_testers/avocado_test.py @@ -252,7 +252,7 @@ def getModuleDependencies(self): """ return self.backend.getModuleDependencies() - def run_file(self, *args, **kwargs): + def run_script(self, *args, **kwargs): """ run script or binary inside module @@ -261,7 +261,7 @@ def run_file(self, *args, **kwargs): :param kwargs: pass thru to avocado process.run :return: avocado process.run object """ - return self.backend.run_file(*args, **kwargs) + return self.backend.run_script(*args, **kwargs) def get_backend(): diff --git a/moduleframework/common.py b/moduleframework/common.py index cebb8cc..42512b5 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -36,6 +36,7 @@ import sys import random import string +import time from avocado.utils import process from moduleframework.exceptions import ModuleFrameworkException, ConfigExc, CmdExc @@ -642,7 +643,7 @@ def copyFrom(self, src, dest): if src is not dest: self.run("cp -rf %s %s" % (src, dest)) - def run_file(self,filename, *args, **kwargs): + def run_script(self,filename, *args, **kwargs): """ run script or binary inside module :param filename: filename to copy to module @@ -652,11 +653,11 @@ def run_file(self,filename, *args, **kwargs): """ dest = "/tmp/%s" % generate_unique_name() self.copyTo(filename, dest) - self.run("chmod a+x %s" % dest) + #self.run("bash %s" % dest) parameters = "" if args: parameters = " " + " ".join(args) - return self.run(dest + parameters, **kwargs) + return self.run("bash " + dest + parameters, **kwargs) def get_config(): """ diff --git a/mtf/backend/nspawn.py b/mtf/backend/nspawn.py index 6253360..2f60069 100644 --- a/mtf/backend/nspawn.py +++ b/mtf/backend/nspawn.py @@ -319,7 +319,7 @@ def run_systemdrun(self, command, internal_background=False, **kwargs): os.remove("{chroot}{pin}.stdout".format(chroot=self.location, pin=lpath)) os.remove("{chroot}{pin}.stderr".format(chroot=self.location, pin=lpath)) self.logger.debug(comout) - if not self.__systemd_wait_support and kwargs.get("ignore_status") and comout.exit_status != 0: + if not self.__systemd_wait_support and not kwargs.get("ignore_status") and comout.exit_status != 0: raise process.CmdError(comout.command, comout) return comout except process.CmdError as e: From 4d54b38c37130625de41867c3b2e60116e89d595 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 18 Oct 2017 10:15:38 +0200 Subject: [PATCH 032/117] mistake in os.path.exist (there were makedirs by mistake) --- mtf/backend/nspawn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mtf/backend/nspawn.py b/mtf/backend/nspawn.py index 2f60069..2041b56 100644 --- a/mtf/backend/nspawn.py +++ b/mtf/backend/nspawn.py @@ -131,7 +131,7 @@ def __install(self): if "file:///" in repo: src = repo[7:] srcto = os.path.join(self.location, src[1:]) - if not os.path.exists(os.makedirs(os.path.dirname(srcto))): + if not os.path.exists(os.path.dirname(srcto)): os.makedirs(os.path.dirname(srcto)) shutil.copytree(src, srcto) pkipath = "/etc/pki/rpm-gpg" From ff5d46b3640f5c0023886b980316e79598fdd50c Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 18 Oct 2017 11:17:24 +0200 Subject: [PATCH 033/117] change test for decorators to generic one, and change self.skip to self.cancel() --- examples/testing-module/skipTest.py | 6 +++--- moduleframework/avocado_testers/container_avocado_test.py | 2 +- moduleframework/avocado_testers/nspawn_avocado_test.py | 2 +- moduleframework/avocado_testers/rpm_avocado_test.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/testing-module/skipTest.py b/examples/testing-module/skipTest.py index 195c76a..a849133 100644 --- a/examples/testing-module/skipTest.py +++ b/examples/testing-module/skipTest.py @@ -35,17 +35,17 @@ class SkipTest(module_framework.AvocadoTest): def testGccSkippedInsideTest(self): # rewrite it to calling cancell, it was not in production of avocado, # but it is fixed. - if "gcc" not in self.getActualProfile(): + if True: self.cancel() self.start() self.run("gcc -v") - @skipIf(common.get_profile() == "default") + @skipIf(False) def testDecoratorNotSkippedForDefault(self): self.start() self.run("echo for default profile") - @skipUnless(common.get_profile() == "gcc") + @skipUnless(False) def testDecoratorSkip(self): self.start() self.run("gcc -v") diff --git a/moduleframework/avocado_testers/container_avocado_test.py b/moduleframework/avocado_testers/container_avocado_test.py index 000fe02..13ba293 100644 --- a/moduleframework/avocado_testers/container_avocado_test.py +++ b/moduleframework/avocado_testers/container_avocado_test.py @@ -35,7 +35,7 @@ class ContainerAvocadoTest(AvocadoTest): def setUp(self): if get_module_type_base() != "docker": - self.skip("Docker specific test") + self.cancel("Docker specific test") super(ContainerAvocadoTest, self).setUp() def checkLabel(self, key, value): diff --git a/moduleframework/avocado_testers/nspawn_avocado_test.py b/moduleframework/avocado_testers/nspawn_avocado_test.py index bbbae3b..ecb3f46 100644 --- a/moduleframework/avocado_testers/nspawn_avocado_test.py +++ b/moduleframework/avocado_testers/nspawn_avocado_test.py @@ -32,7 +32,7 @@ class NspawnAvocadoTest(AvocadoTest): def setUp(self): if get_module_type_base() != "nspawn": - self.skip("Nspawn specific test") + self.cancel("Nspawn specific test") super(NspawnAvocadoTest, self).setUp() diff --git a/moduleframework/avocado_testers/rpm_avocado_test.py b/moduleframework/avocado_testers/rpm_avocado_test.py index a6d1698..2c4cc4b 100644 --- a/moduleframework/avocado_testers/rpm_avocado_test.py +++ b/moduleframework/avocado_testers/rpm_avocado_test.py @@ -34,7 +34,7 @@ class RpmAvocadoTest(AvocadoTest): def setUp(self): if get_module_type_base() != "rpm": - self.skip("Rpm specific test") + self.cancel("Rpm specific test") super(RpmAvocadoTest, self).setUp() From 5bfe5f8869ad3cef94b6fbcfe3e55063eec52772 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 18 Oct 2017 13:00:57 +0200 Subject: [PATCH 034/117] fix multihost regression, caused by code cleanup --- moduleframework/helpers/nspawn_helper.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/moduleframework/helpers/nspawn_helper.py b/moduleframework/helpers/nspawn_helper.py index a5675b6..6318607 100644 --- a/moduleframework/helpers/nspawn_helper.py +++ b/moduleframework/helpers/nspawn_helper.py @@ -48,10 +48,7 @@ def __init__(self): self.baseprefix = os.path.join(BASEPATHDIR, "chroot_") time.time() actualtime = time.time() - self.chrootpath_baseimage = os.path.abspath(self.baseprefix + - self.moduleName + - "_image_" + - hashlib.md5(" ".join(self.repos)).hexdigest()) + self.chrootpath_baseimage = "" if not get_if_reuse(): self.jmeno = "%s_%r" % (self.moduleName, actualtime) else: @@ -73,6 +70,11 @@ def setUp(self): trans_dict["ROOT"] = self.chrootpath print_info("name of CHROOT directory:", self.chrootpath) self.setRepositoriesAndWhatToInstall() + # never move this line to __init__ this localtion can change before setUp (set repositories) + self.chrootpath_baseimage = os.path.abspath(self.baseprefix + + self.moduleName + + "_image_" + + hashlib.md5(" ".join(self.repos)).hexdigest()) self.__image_base = Image(location=self.chrootpath_baseimage, packageset=self.getPackageList(),repos=self.repos, ignore_installed=True) self.__image = self.__image_base.create_snapshot(self.chrootpath) self.__container = Container(image=self.__image, name=self.jmeno) From f2b2f8ec82646a478b019d4acf47ed4f5b6b2a41 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 18 Oct 2017 14:40:45 +0200 Subject: [PATCH 035/117] systemd test examples - testing fedora or centos via nspawn --- examples/mtf_systemd_test/Makefile | 9 +++ examples/mtf_systemd_test/example1.py | 100 ++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 examples/mtf_systemd_test/Makefile create mode 100644 examples/mtf_systemd_test/example1.py diff --git a/examples/mtf_systemd_test/Makefile b/examples/mtf_systemd_test/Makefile new file mode 100644 index 0000000..4e33995 --- /dev/null +++ b/examples/mtf_systemd_test/Makefile @@ -0,0 +1,9 @@ +test: + avocado run example1.py + rm -rf /tmp/dddd1 + REPOS=http://ftp.fi.muni.cz/pub/linux/centos/7/os/x86_64/ avocado run example1.py + rm -rf /tmp/dddd1 + +all: test + +.PHONY: all \ No newline at end of file diff --git a/examples/mtf_systemd_test/example1.py b/examples/mtf_systemd_test/example1.py new file mode 100644 index 0000000..482ebfc --- /dev/null +++ b/examples/mtf_systemd_test/example1.py @@ -0,0 +1,100 @@ +from avocado import Test +from mtf.backend.nspawn import Image, Container +from avocado.utils import process +import os + +# Centos Hack. it does not support wait athought fedora support it () +# [stderr] Failed to start transient service unit: Cannot set property AddRef, or unknown property. +# force disable wait support +Container._run_systemdrun_decide = lambda x:False + +if os.environ.get("REPOS"): + repo = os.environ.get("REPOS").split(";") +else: + repo = ["http://ftp.fi.muni.cz/pub/linux/fedora/linux/releases/26/Everything/x86_64/os/"] +packages = ["bash", "iproute", "passwd"] + +class testSystemd1(Test): + c1 = None + cname = "contA" + sname = "nonexistingservice" + exitcode = 2 + def setUp(self): + loc1 = "/tmp/dddd1" + self.i1 = Image(repos=repo, packageset=packages, location=loc1, ignore_installed=True) + self.c1 = Container(image=self.i1, name=self.cname) + self.c1.boot_machine() + + def test_basic(self): + self.assertIn("sbin",self.c1.execute(command="ls /").stdout) + + def test_status(self): + self.assertIn("systemd-logind", self.c1.execute(command="systemctl status").stdout) + self.assertNotIn("gnome",self.c1.execute(command="systemctl status").stdout) + + def test_exception(self): + self.assertRaises(process.CmdError, self.c1.execute, "badcommand") + self.assertRaises(process.CmdError, self.c1.execute, "exit %s" % self.exitcode) + self.assertEqual(self.exitcode,self.c1.execute(command = "exit %s" % self.exitcode, ignore_status=True).exit_status) + + def test_nonexisting_service_start(self): + self.assertEqual(5, self.c1.execute(command="systemctl start %s" % self.sname, ignore_status=True).exit_status) + + def test_nonexisting_service_status(self): + self.assertEqual(4, self.c1.execute(command="systemctl status %s" % self.sname, ignore_status=True).exit_status) + + def test_nonexisting_service_stop(self): + self.assertEqual(5, self.c1.execute(command="systemctl stop %s" % self.sname, ignore_status=True).exit_status) + + def test_nonexisting_action(self): + self.assertEqual(1, self.c1.execute(command="systemctl %s" % self.sname, ignore_status=True).exit_status) + + def tearDown(self): + self.c1.stop() + +class testSystemd2(Test): + """ + It tests Container object and his abilities to run various commands + """ + c1 = None + cname = "contA" + def setUp(self): + loc1 = "/tmp/dddd1" + self.i1 = Image(repos=repo, packageset=packages, location=loc1, ignore_installed=True) + + def test_basic(self): + self.c1 = Container(image=self.i1, name=self.cname) + self.assertIn("sbin", self.c1.boot_machine(boot_cmd="ls /", wait_finish=True).get_stdout()) + self.c1.boot_machine(boot_cmd="""bash -c "echo redhat | passwd --stdin" """, wait_finish=True) + self.c1.boot_machine() + self.assertIn("sbin",self.c1.execute(command="ls /").stdout) + + def tearDown(self): + self.c1.stop() + + +class testSystemdMultihost(Test): + c1 = None + c2 = None + loc1 = "/tmp/dddd1" + loc2 = "/tmp/dddd2" + loc3 = "/tmp/dddd3" + + def setUp(self): + self.i1 = Image(repos=repo, packageset=packages, location=self.loc1, ignore_installed=True) + self.c1 = Container(image=self.i1.create_snapshot(destination=self.loc2), name=self.loc2.split('/')[-1]) + self.c1.boot_machine() + self.c2 = Container(image=self.i1.create_snapshot(destination=self.loc3), name=self.loc3.split('/')[-1]) + self.c2.boot_machine() + + def test_basic(self): + process.run("machinectl status %s" % self.loc2.split('/')[-1]) + process.run("machinectl status %s" % self.loc3.split('/')[-1]) + self.c1.stop() + self.c2.stop() + self.assertRaises(process.CmdError, process.run, "machinectl status %s" % self.loc2.split('/')[-1]) + self.assertRaises(process.CmdError, process.run, "machinectl status %s" % self.loc3.split('/')[-1]) + + def tearDown(self): + self.c1.rm() + self.c2.rm() From 9f7a60dc3a1cb9ac5f64974e88d71a58fac443d4 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 17 Oct 2017 12:09:31 +0200 Subject: [PATCH 036/117] Fixes #142 Fix tracebacks for COPY and ADD directives Signed-off-by: Petr "Stone" Hracek --- moduleframework/dockerlinter.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 12d76ab..da6c4ab 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -234,10 +234,16 @@ def check_helpmd_is_present(self): False if help.md is not specified in Dockerfile """ helpmd_present = False - for c in self.docker_dict[COPY]: - if "help.md" in c: - helpmd_present = True - for a in self.docker_dict[ADD]: - if "help.md" in a: - helpmd_present = True + try: + for c in self.docker_dict[COPY]: + if "help.md" in c: + helpmd_present = True + except KeyError: + print("Tag COPY is not present in Dockerfile") + try: + for a in self.docker_dict[ADD]: + if "help.md" in a: + helpmd_present = True + except KeyError: + print("Tag ADD is not present in Dockerfile") return helpmd_present From ef1305f6003bc0b71165c371d96adf3c25c56790 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 17 Oct 2017 12:13:59 +0200 Subject: [PATCH 037/117] Fix error in case help_md does not exist Signed-off-by: Petr "Stone" Hracek --- moduleframework/dockerlinter.py | 18 +++++++----------- moduleframework/helpfile_linter.py | 6 ++++++ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index da6c4ab..98a6c52 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -1,5 +1,6 @@ import re import ast + from dockerfile_parse import DockerfileParser from moduleframework.common import get_docker_file, print_info @@ -234,16 +235,11 @@ def check_helpmd_is_present(self): False if help.md is not specified in Dockerfile """ helpmd_present = False - try: - for c in self.docker_dict[COPY]: - if "help.md" in c: - helpmd_present = True - except KeyError: - print("Tag COPY is not present in Dockerfile") - try: - for a in self.docker_dict[ADD]: - if "help.md" in a: + for instruction in [COPY, ADD]: + try: + helpmd = [help for help in self.docker_dict[instruction] if "help.md" in help] + if helpmd: helpmd_present = True - except KeyError: - print("Tag ADD is not present in Dockerfile") + except KeyError: + print_info("Instruction %s is not present in Dockerfile", instruction) return helpmd_present diff --git a/moduleframework/helpfile_linter.py b/moduleframework/helpfile_linter.py index 296d596..ce13da2 100644 --- a/moduleframework/helpfile_linter.py +++ b/moduleframework/helpfile_linter.py @@ -35,15 +35,21 @@ def __init__(self, dockerfile=None): def get_image_name(self, name): name = '%% %s' % name + if not self.help_md: + return False tag_exists = [x for x in self.help_md if name.upper() in x] return tag_exists def get_maintainer_name(self, name): name = '%% %s' % name + if not self.help_md: + return False tag_exists = [x for x in self.help_md if name.startswith(x)] return tag_exists def get_tag(self, name): name = '# %s' % name + if not self.help_md: + return False tag_exists = [x for x in self.help_md if name.upper() in x] return tag_exists From 66a74251fffa1d71fd75392b9e299e2aff8887de Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 24 Oct 2017 08:39:29 +0200 Subject: [PATCH 038/117] Manual page for Meta-Test-Family Signed-off-by: Petr "Stone" Hracek --- man/mtf-env-clean.1 | 19 +++++++++++++++++++ man/mtf-env-set.1 | 20 ++++++++++++++++++++ man/mtf.1 | 27 +++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 man/mtf-env-clean.1 create mode 100644 man/mtf-env-set.1 create mode 100644 man/mtf.1 diff --git a/man/mtf-env-clean.1 b/man/mtf-env-clean.1 new file mode 100644 index 0000000..aa3da5a --- /dev/null +++ b/man/mtf-env-clean.1 @@ -0,0 +1,19 @@ +.\" Copyright Petr Hracek, 2017 +.\" +.\" This page is distributed under GPL. +.\" +.TH mtf-env-clean 1 2017-11-01 "" "Linux User's Manual" +.SH NAME +mtf-env-set \- Part of Meta-Test-Family package. It cleans environment for testing containers. + +.SH SYNOPSIS +\fBmtf-env-clean cleans environment for testing containers + +.SH DESCRIPTION +\fBmtf-env-clean\fP is the binary file for cleaning environment of Meta-Test-Family. It stops docker service. + +.SH NOTES +\fBmtf-env-clean\fP is useful for users who don't want to care about environment and their clean up. + +.SH AUTHORS +Petr Hracek, (man page) diff --git a/man/mtf-env-set.1 b/man/mtf-env-set.1 new file mode 100644 index 0000000..03982b4 --- /dev/null +++ b/man/mtf-env-set.1 @@ -0,0 +1,20 @@ +.\" Copyright Petr Hracek, 2017 +.\" +.\" This page is distributed under GPL. +.\" +.TH mtf-env-set 1 2017-11-01 "" "Linux User's Manual" +.SH NAME +mtf-env-set \- Part of Meta-Test-Family package. It prepares environment for testing containers. + +.SH SYNOPSIS +\fBmtf-env-set prepares environment for testing containers + +.SH DESCRIPTION +\fBmtf-env-set\fP is the main binary file of Meta-Test-Family. It installs tests dependencies, docker service + and starts docker service. + +.SH NOTES +\fBmtf-env-set\fP is useful for users who don't want to care about environment and their settings. + +.SH AUTHORS +Petr Hracek, (man page) diff --git a/man/mtf.1 b/man/mtf.1 new file mode 100644 index 0000000..a6252c7 --- /dev/null +++ b/man/mtf.1 @@ -0,0 +1,27 @@ +.\" Copyright Petr Hracek, 2017 +.\" +.\" This page is distributed under GPL. +.\" +.TH mtf 1 2017-11-01 "" "Linux User's Manual" +.SH NAME +mtf \- Meta-Test-Family tests container images and modules with user defined tests written in Python and/or linters +provided by meta-test-family package. + +.SH SYNOPSIS +\fBmtf runs tests for container images + +\fBmtf [-l, --linters] + +.SH DESCRIPTION +\fBmtf\fP is the main binary file of Meta-Test-Family. + +.SH OPTIONS +.TP +.B \-l, --linters +Executes linters provided by Meta-Test-Family package. + +.SH NOTES +Once \fBmtf\fP finishes it shows logs from failed tests. + +.SH AUTHORS +Petr Hracek, (man page) From 58d33fcc9dbfc60fc6542e4708dfe13b5c2fea5d Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 24 Oct 2017 09:06:02 +0200 Subject: [PATCH 039/117] Add mtf-generator man page. Signed-off-by: Petr "Stone" Hracek --- man/mtf-env-set.1 | 2 +- man/mtf-generator.1 | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 man/mtf-generator.1 diff --git a/man/mtf-env-set.1 b/man/mtf-env-set.1 index 03982b4..62dc1f1 100644 --- a/man/mtf-env-set.1 +++ b/man/mtf-env-set.1 @@ -10,7 +10,7 @@ mtf-env-set \- Part of Meta-Test-Family package. It prepares environment for tes \fBmtf-env-set prepares environment for testing containers .SH DESCRIPTION -\fBmtf-env-set\fP is the main binary file of Meta-Test-Family. It installs tests dependencies, docker service +\fBmtf-env-set\fP is the binary file of Meta-Test-Family. It installs tests dependencies, docker service and starts docker service. .SH NOTES diff --git a/man/mtf-generator.1 b/man/mtf-generator.1 new file mode 100644 index 0000000..747998a --- /dev/null +++ b/man/mtf-generator.1 @@ -0,0 +1,16 @@ +.\" Copyright Petr Hracek, 2017 +.\" +.\" This page is distributed under GPL. +.\" +.TH mtf-generator 1 2017-11-01 "" "Linux User's Manual" +.SH NAME +mtf-generator \- Meta-Test-Family generates tests written in \fBconfig.yaml\fP file. + +.SH SYNOPSIS +\fBmtf-generator generates tests written \fBconfig.yaml\fP file for using by \fBmtf\fP binary. + +.SH DESCRIPTION +\fBmtf-generator\fP is the binary file of Meta-Test-Family package which generates tests from configuration file + +.SH AUTHORS +Petr Hracek, (man page) From 9c68930b8da1f24d5285c6c6dce887af74d15b32 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 24 Oct 2017 13:06:15 +0200 Subject: [PATCH 040/117] Bump version to 0.7.6 Signed-off-by: Petr "Stone" Hracek --- meta-test-family.spec | 5 ++++- setup.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/meta-test-family.spec b/meta-test-family.spec index 94cf7ff..71c2902 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -1,7 +1,7 @@ %global framework_name moduleframework Name: meta-test-family -Version: 0.7.5 +Version: 0.7.6 Release: 1%{?dist} Summary: Tool to test components of a modular Fedora @@ -57,6 +57,9 @@ install -d -p -m 755 %{buildroot}%{_datadir}/%{framework_name} %changelog +* Tue Oct 24 2017 Petr Hracek 0.7.6-1 +- new upstream release + * Tue Oct 17 2017 Petr Hracek 0.7.5-1 - new upstream release diff --git a/setup.py b/setup.py index c9de4b0..07bc527 100755 --- a/setup.py +++ b/setup.py @@ -63,7 +63,7 @@ def get_dir(system_path=None, virtual_path=None): setup( name='meta-test-family', - version="0.7.5", + version="0.7.6", description='Tool to test components fo a modular Fedora.', keywords='modules,containers,testing,framework', author='Jan Scotka', From f1530f90388ae2bdd42295d5386cab195929a44e Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Tue, 24 Oct 2017 14:38:43 +0200 Subject: [PATCH 041/117] testsuite for mtf-init --- examples/testing-module/Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/testing-module/Makefile b/examples/testing-module/Makefile index b68c054..93d5977 100644 --- a/examples/testing-module/Makefile +++ b/examples/testing-module/Makefile @@ -66,7 +66,6 @@ check-docker-scl-multi: cd ../rhscl_maria; MODULE=docker mtf-env-set cd ../rhscl_maria; MODULE=docker $(CMD) *.py - travis: MODULE=docker $(CMD) $(TESTS) @@ -80,6 +79,11 @@ check-memcached-both: prepare-docker prepare-nspawn check-minimal-config-rpm-noenvvar: prepare-nspawn MTF_REMOTE_REPOS= DOCKERFILE= MODULE=nspawn $(CMD) $(TESTS) +check-mtf-init: + $(eval TEMPDIR := $(shell mktemp -d)) + cd $(TEMPDIR); mtf-init --name NAME1 --container docker.io/modularitycontainers/memcached + cd $(TEMPDIR); grep 'class Smoke1' test.py ; grep 'default_module' config.yaml; sudo mtf test.py + rm -rf "$(TEMPDIR)" check: check-docker From 10fb17d1c54d0bd6db0aaa9a07723a5894dbab5c Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Tue, 24 Oct 2017 14:53:28 +0200 Subject: [PATCH 042/117] docs into RTD --- docs/user_guide/environment_setup.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/user_guide/environment_setup.rst b/docs/user_guide/environment_setup.rst index 890e289..97a775d 100644 --- a/docs/user_guide/environment_setup.rst +++ b/docs/user_guide/environment_setup.rst @@ -30,3 +30,10 @@ The environment configuration scripts should be executed in the same directory w - to execute tests run ``MODULE=docker avocado run your.test.py`` - to cleanup environment ``MODULE=docker mtf-env-clean`` +Test Creation +~~~~~~~~~~~~~~~ + +There is script which generates easy template of test for module docker as example. + + - to create template for module docker ``mtf-init --name your_name --container path_to_your_container`` + From 68bac74042268d4a90d8704c915d7cce2a8d60b3 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Tue, 24 Oct 2017 14:57:04 +0200 Subject: [PATCH 043/117] modify how it works images and store rendered output --- docs/howitworks.dia | Bin 5096 -> 6282 bytes docs/howitworks.png | Bin 101480 -> 133713 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/howitworks.dia b/docs/howitworks.dia index 82aef4af871ab0cf1dfa460247219845dfdddd05..e77b66b5c537d9b3f529269b235eef395173fb1e 100644 GIT binary patch literal 6282 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 RD+)XX{84>MGv*G#e*reGFkk=x literal 5096 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

=nThG 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%0DE%m5RzXjIn8JU@whM^edrXvWM_tDLy3pW_1 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 Uyz9L#@^50+mNPBVMXtgB23=w-W&i*H literal 101480 zcmeFZcU;c@|2BNu5e-o!rO?pQPFj*wg!Z1=gSM6;(UL;isI*JFw3DVL?TPlFNJYEr zIDM}BkNdv={rz=4uJiHzeu{LS@AvEV9LMuGj@R4&#L>+o=z^UpUmK%aLw$ zVVCrehXsL50j#m!OeC@&T_u{uU00MZvQqt8y(_Z+dE|MX83C3%t+uR*J7cv@UVgHl zoZPH*-#rFdY3Z6zaTohCaiO#^rv`RODt{QZ?d94Q8(FQUOOwvWX{_NX~PXz8Dev`dJlaBcH-tmSJ;#a~C zN$NesuLgT^g^6DY=R<1${ci7l4l?4O-;@2{xA;GDE$(eyym;|J^{}YhqTj3(b~<+D zxp66hVD46E-S_O}OEJR_J8f)i3at6SWob#=XX`qZK;O|>Ue_wVe0 z`YjBwmwt8@>TV)i<{d%hm;+A$ZUmU@ODzN;as}H zTY0yJO8Uhqt9p#7sHpZFpiRKLrKC*#_(6C4c*~DpGhOMgy*4$qwY70AgM%JlEADS8l9uaRixnFb{!Bs_ceEH5uFE{<^_+~kALUX#B+ zI+Dp5rLSGfyu0?t+1c5#d&5oU1(cVpDe{A1HYTu}hdzvv#^2S0uK zl%LOE6(;wbnNn*1fdiu>BN3dZPoHkSdHC?*ra8^0XOoxLepc>fOiD@`r;iQ^kt9)6 zRAl4eI8>pvW5*7uhNbU4?9?aMaUYgPj~-=Uc)h;vA#Y)5$hcto{Zrh7oSgYz!_|4Y zxvYtJEljqt_^`6E?cKZ)#g004k6~Y~aOaOb%_F;a@5ZN^yjxpcZ@Pw_JU$$ zeEf7T@2x>$DUyy6r56bar}pmMtEt&nAIuQG))m2XlZ3HyYtvnToMz(CrAwDAEiDi4 zSj?*2lqZQ4unPO-8?S0MuO&t~k?3q^$5yIsYMK-pdWcb4U;lNnC`D&hT-*hcVwZWN zr2+vbVtJJ~v>3QU#b~B8Rfp@{kFcYBrW8`IL|vanMV;x@P*HKj((3DfWm8UCUtee& ztq&$ABV+a%tn|{)4u0~4jB)HIF6PgywrXYh`aCpJ;oSO}Nm_>2 z7*C%T@LYG1YWPy_zUx@!-8E`T$_pebyAtyu7?7 z-{0lupYka~Uio}Lbk|UI)9gv{NlmpV=6tLD4mq-fNO~paUguN0yu7UVMyCAkTHy$B z&3BIaQ?cCY%N_sGmxni{r>DP$IM2)!6cnT*yPVyI>%BrEr=VamVz1dO#N5%|o|dW> zp-dsgd^IB5b7R%d&rcy*@MBXGmDDC~xvWgo-F@xJ@$=m?H&iKy{qt{str(;h5GX(i zkbg%d_51g4pVXa*OF1;Dl${;oigu&8G(Te((TvoL^s!J*?TSld`aq?4;|A-yOHxwt zQM36Ucm;^HfyPaRaOP=bq-K|lsX}nIFS*yoYJvOTxwSvD4z{*8)YMGfv>IFeNF$=6 zWTd4%R(_B77g%j>ZjzCaW$9PapLhO|bF)6;c*GzgXCy7^BTA>r-70}s-aJVB-<5)h z(u8#Ntj3QYd4^6Fh^ZvbY)a#EJy{W9FI-j^v1_lz?;6>k%2Se8QaZ?3)7Mu#cuVNk zV9HDcii~cFqh_{26|%~#CF&AMUx5|P0=t8Q!@orPuHh_@Y_R_*Z-Q#NntGO=g1o#D zb8UHfxpuy};=o1Ku3RPk>)3#AdjJM*_=kjq;2m?dBe+eRr#{`cpf;;ei+J@)&-pd} zIyUx%;gdF><-b3_@=1^#<@Ko^uBofjD|5*aZ_FAyv>(Zw)Mbo<9~T!lwk4QC;>BwE zu-@C-+umKUhKkr@>d<4(%ulpLrz$7!%bk$eTF>*`aFc3?zZ4k3i74|4x#&w)TT^pZ z+uq(@CWuzmPU`NLGFQAGN}V?+gM{Z`i4!|xO>66uKBHT=T8&2#yF5ZQls{r(V#-Wg zqTXB%R#Sd^HkrbpUq$ftmzY)6m;Hcre<{h)alc^*mv*y^P|6j zol#m!>O(_A!^e*wKYWnN_H^z$_Vo}oHQ8~IP}hvy++6gvw{JP~>`53;v9hK_MLiUK zc<;@dlMcNFRy_gEtP|&7`cN>ppp7n}(iPlgYQD08Z|ntx&z+-`dcq)~ck6uKpOr@k zj-|P>MQzLACL{d1SXCx0d3tqmnu>;Ia%w6fG<0!u!`;g2JGw2;f$sfW;qG&T#VZSw z->0X`-ImUrK3!W~eG={C*SD&1vI4BlE>h~=fq}`1i7!|~{1~OYr|0kH#+sIvmiLgc zu`v@9Q+#~xk9V^zaNn^(4*B+QZg>PAVi{gnge&NYb<++cgXSqp8Ufm%byvEf9X6oh}^oe zW5)xO!aQ9d`}e_Qe}{H1GosSjqcfx3k~4^(@>yP6;}c@pjvS&U{vrjc%*eRgB1vlB-Hy}kiRiC6x|hU`hVz5X8jPG~n0!J3qgytgZF9i*dAn{(ONrBJ9o;<}acb zbNew^{CxI|^dU-0#c+-<6?fMjlG1eF6jM+)H%~FwjEPH57IK>Dbj$EPytTe?UH8j1 z0^!p-rVmM~*tc(o_Gen6RuD&wSdNT0#8v*ETF`r@O^uCbI5;xm+MYdqS~YBoLfZL> zxXjgd%lNDg~ISof-}tyNM|vR0qJxG+CI z{V8t8fGRPBwl9nj5{il)mg)t-3R&JCcKAb2Q>^hEK_O){B1-tq|DypA@#Z;7(cECRJ z!DTn=pWsG4HrHK|ho}msWQlQcb|_cqfHIdazZSMLLc8<{K@uXG06qRRg>xe-Xa%j0 zFdF~p%|R)}U0=O^{atqU5q@)u*MNQdj|pa&G=*WhD0N$!L8Ur2shXy!+H(XOb0v(! z?#>+{AtC&Gzu;h+ANq6s57xg@hstLeH>S-DA(c*?GPa{2RW&J%yXZ@O=#Z%UpC2e@ z=u$W99vc=0ALX0CA9HeYuv|fpAFHUU9u_@y^5h$GIDLq_&%W z_Ss9tFCef>V)J(scQc>;ojU+B8^pFg`<&%^VPWCKyvt}^(3NY~$Vf z&WF!M&j}3+a|Uz?388RuaK+f|TU77I_2Y#Ox>I6eVnWSrtk7m)UmdcfIj%M4T*q5& zAu-~syenPZYkm1?-vUNGLHuMz?R0(^XVBL$ahcc{6YK6zu~znfm3W6sV> zv;GUHEhi_ZtE)S48Iy?VP0SqOO^3&R<`Ulxg5}3!uWrWfzN=zh@SL;yXKlk)-b8;%k$)&oaIDPNd)X*p@E%n-5kEDoC zPQFay9~|uUM*=I27-()CKbXDTTj{kmRC?#il`9V(JosAfE@b}c#8dJD^o^RD8kG2# z=kH*`BctVK<>n4zt`=}^JmZE-@G9uB{eZNMqZtxyM|h{XaAA2h97)T zd=5&9j6B)qc!{p`oFu2^_j5`Ds7%_U)$= z{sruSvWr2ALDa>zwvD<`_oKO$RrrR@(oZfdfFZij8mc1r2?r-9T;MHah_iEHaPW21 z8YD0>34wMVWgC~FB#-Nih`2`bG&EGyV>!FH*cI?Z++#U5Jzd)(%J_TaJ_}U0Jku5> zHMK9LcYY!=8x^kfG>(0I?zJ{6WMVRof_T=~a-eV)!Gr%zjE(W%8Z6qjWA|BiYHDgO z?ZT@UFTMft_Vo0`ppVoBl$DW@xpnJSKtMoucehz8>=efW@5Gq>jB$7xK_NI%Rhhx0RJWo3ULAb}QvxKU9s!s`~> zCq_pP_>wcIsHl`U&x!ycIlnSKj4Iz`!*lkmMumr~K=9K7V(C8d^yOB;?k^>GkZk!Z@{WA)fF*6QMALbo^uQZ+aF{84>s5Qqvb{(FG)|5G5CW%GX*2=N?Nh!Q!Q) zWun;=e0=B+;|qb{Y@kOcS!VEo9-~kFeb@ha3iaZ%+3m3_?&yG-x~09DQqp(k22-1= z(XWNkT~^N|$)UiSm16WF5^^^n%`c+GtII+N_*>&7?G7>u1B$lbC(0 zA042oNJD&JG`Ncfj{1Xf_<)w40A^#BO}2HET7ZcoJVriexmDh7g147kc!Z6vX>0rW zd%TI3mKKRhwW;`4tANP|xT3gf#*(bR&<5;-oE+sQBLE57Aj<3%YSh)Mj}sG39;m7x zKX4!jY)iJi;Umy{CXcYuBjnj*JXrLV| z?>WQHP9>F_mv<~9e?roSqjlK!&yPMnCxtJasmh4$_mc}O*J6s? z|K2KW+D5p?492}iN=BwGMez-4SU9J4H~P4i81=%)ha2kZ;m@D1EzjT7(|h$Rc*MRK z1mpbg-;K@900`F=6l{w2MT?-;VIk$GQCRN$=&f=-2Sm+JtYym{#G=o~%j>o{m4l(6 zUpyv~<}!5_y>B(@IZHtdEsKxrl`EHJWJ-#P5|WaL7Yzt=VaAGjqB%0pqJvqg0jLAX zQupsocM{)peSIAhNCMJ{n!0Y>-fGQuF@;3VNAU2Yn3zHA3n*dzGMC}$Y5(vNT0XPZ ze>1mn-Q%OtLbpwct4MJNC?Xf&1whcgcrZKr>}TrJ(!l{C(yb#tA>e3Y&fm$zBu`E+ zO57b(ySwT=9#>zBztmhd!6h0M6p|ANevHO;2Fj+U%pJ@XxHVuFKW7U1U>0_E?Hm7I zmE!_@P9O1(0Hc>LUj`1D19bol%ZeGGXC~^!$LB0R0aT=J|S3Eq$ zRD1Ssfb{}6n6xhjLY-e&xcTpzF5{Y>^!D~1W>-V`r4#okn){nsUsiN>=5!#H6vooF zm*;zOO`5=8pj19o(qiZmxDHG$=JM-Ac#|$zc?Pfbe{pfbIi&XfF7#v;$7nR`G5?f?x)_ z=Wo!F)wi|X9{%uf+qP}UMRet?;O)^Y$B(}pZwk-S$oYU&1}mkUbT!p<64S1LmLcXX z5i)W8N3=n7=Xga$V`k3~JWrsPAy`aGq2Za^M$q_19X)mom8YjE4h<7wAku2gyNq5T3P40Sy$$@ z^`)i71{Hb9-PYrdvg}epsQkIgjXwkElR#7TkB?FvKCD`~=e8*-J@TCgBOZn)jP(!V zuTY>Lp$-Na1H*u;xS_0k9^}b!b*e0q?}4PL_j83AjXyp+O-jSXapuf#bpF7=^uMD* zEQob+yOEv2^~BDIl&7fQM({Ll82NMrAs)u9&&9}ybWci6)%;mszd9n--qSN_E1a5| z3XXlr^B0JzYcE9#Vq;@buz`&-iSG$0$a4MF*t`oWb%dxZ@2$w4gkRM}j^Xcc^&V4! z`O{az03wBI*v@iuokGjSFoJfEHqobhO<7Vy&+wzDnARY)4|ykxio9o0q>; zd|+z#Pbk6EhfxqfcW!R(6@(^97E5}NVYqTM4Ad-P(CsxurQrYwkyv*1%(FHlzGTOZ z)cCAVAhbBO@}WnptS(tqzf5#BpW*<3{3qi0ym>Q<;b3TJNWx?JlkKGCZXF$+)>mQ! zpf|I#vk@8qwAs3p1Oh+3v7I?9l9le9y|J9!N5EP!QBm4eMHU)<@~-#VtEo|6M19U4D`zg>}?g^mlf(409j8k?+w` zmNB?CMI|K>ugx-l|BskQ))uE1W(VFq62JKzEQ#&#hh3*nCxV7`SpGE(dc;~CcqC{W z7SX4&atneIzTYFe37!BY zmTriq3oJ&d%Y1x5KqJ(Mvhs4frR{`IKM*Qn=%l02>9DV8Gh=|u=99H$MIl8xxN)x6 zZC(o#3ZpSZ%!Ax9_&ttM_VL5~bhDc`9l)rel)V;lvIHag@ZrOM_j-UtlWs}Gtj zgTQ~YVdTc^?kxSbe1C>>_R>m9xjff{=noH1fB*ixu4TlB0xAdq(!5I<81_qn)B+Q! z(M+VIL?Du$p8gu`5@`o&?!SxLj*Du+a10~?XiP~RxAt}&`ckiKMxk)trcQ*%;2G=6Gj*2)=wZ1%mj;^&ik`HRLW@_AC9znq;K|x*J-T3b; zVp30!{@>NyMdffKibNQ33L!$x57g{J;ShDs6J{KP5_4exe$>;-&DHN9R(j98Nj=NY z4|Lk~`E#-TcmR?Bch;9lLLkf_F*{X3!Gk!76tp8s*p2VYihgx=c49z*iluSY37@e! z8Z3c8k$FPria;-HG(WR{|9(`%#l=NT>t$tSXfM70-VX`X=^v}$QRTf0-QFnYV1@;& zD4B0{qtcmi4*d!b%t~J?y)bkB!mem$B*>X#z0hUxU+`OV!^89AO*BMG`JUT74zl<+ zp-+wp%^}ZPTU*oB-Wt6-0;v&cJ~*f|*r#|T<6Y`mK}eJshQT5&%?|v;158=!+L@i6pEaD4rA1D%c7h z_(Ni1=(xGFprGYy2QPPYbwRxl;O8%Mn7r2ZN(>A0-|g&{DMmBARO~x4|#4c zvmSLt1)C@j;1&f}_%T}Ael&q#28oi=(!h&yM%T2p&oeXE_4X2}43Ky0&>@I>dT$EE z>mgKrZ^3!|Bu}^1>(`RPu>W<$m#m!J26`nlO`wpMrKM{~_r6ToL4*+`9&n<}%*=nL zK92%7{0n;~gohSYzW;g90V^vxx~*HjS-1BEe;mOQo0f)b{c&?K(**EC5Xb#^X{genzUtpvx% z8>y&-K73eq za8k{cLB>7?m>FC8wpiO}C#0 zD}XVIPwel%MJhmieTWZ*MkFpEfXq7CK22?2wnXAuEU#ZDc z&><|06fm`F#!wicWKJ|6-L>l>S`Y#;de%OQ0QLlTyl0OuI4@k3|Lkj&-cTTJ0hlN- zm$}&4<$$*V$JNV~EU(d#tyUuntX$$aZ9h^&l=K0aIJ65NI5qKrx_2u*DdEiZq z8z6hIf&@a!HY9eeb@VcjS07ih$dqF0i?m!9L?D>IV~17avD-kOpc^qw9}~J|TpGh1 zi}H-+b8vPBx9a5J;J)(v^uO}ugizQGvH^WgNl|exIXO+6pxw5`l+FO)Bp)AXY1w#;m3Q)GYr3HGL|G!w1 zAC%Z!elSZb=`mDfcXM&-b7(vKUheMhg-xmV(JpZlfq{Vtt43!|0d`K#10*Cxt_!C4 z3skx7+qa`?oIV0PG*DF)S5zc?_H4*en!|^8?cV*R*rC3vY8d&4sTus;>|n7&pPiwh z;f))gfEz{b%<0^?k!{hDgnw}dj2DDnR#sMC-uCM12Ys(1B6<_AgrkmQyDy>Vbae@^ zv!`QY(7gWs`QvPB+gs+kfCPj>0@)er#&7s^&Yv$uHV~ZzfLr5r$XTH7yX(t2Z{N-W z%%J|SV%oJI#eBVI&mPDRopp80K>fnP%V@dY^Wc`=zI}_5MVwt9AQS^&y~ zeE>yH=F+9q^z{0gnlhL94~>m8kmd*8-6lrmix;Yx$5AgqfO2qh^6~M3wg#Jm2>`>i zl$4ZSxf>ceFkl6cCtw*Y3cRD}FM#9o2vYBP(0xR`5u*lZOw*XCC`&^_kloBjh@?J{ z5B3;S2?qF~0xNYWsKxV00~nz;&{aV}%i;-Z^~W2{_|5@HIOhi|M}CW zj7RiKoo8{I-~*Y;N)r+jy)cggKT2Tc@%yk|dH*&O((Y1b^exlR|lg7M}uhc6Yymy@F9% zSNEk-f3bs^=@xc#FE6y9&87^+w~2|D*i5J!IzE4n3k4^9PztsTwnXedv(MD`@5=I# zGU3=p@U73E?`4E<2cL5x9w04L$<(>Kp}|jS6}*L%Urr8B;vxtL9|~n=jM8wmQG6Jj>1uC(rsRgPLCOyzZlWW$#fRe9v19VnNHr2h zBFJpV?Ndo1GT9PAC7<$vBgbFKZM5!ylpo~_@CJ|SR3ZpcI2BVhzx9NNCT>>}lt zm&cd52vOUILS0>5-oo0NgE0ZhP-uHZ#1Sc3@WiZ%NIWJVr0D}CxA~DvQnILQY>DUu zCwyF7TyDrCvagfOFD@p8f>{WoXirz?mbXw*ImDQN6bx-g(dEk;K-2vGTM$&2Hhw!Y zd=8p|>g*p>v62`lSToQjo$P_lf%%o?5xl^m6K$`rljP**i#kqy^4>*?2}uMz8ZB*! zLDhDya4SPYJ0iX%r9n;5($Jt2wlx6qLxqD9L;RS0^QW-a0odY`LH=@_Jb4onFs6NQ z9FSL|A|q$<6HJ(*^jVn75u#eT#{Q6B0O^Sd0nE_U)D#^RHP@Rn`uD4sx{3-v4-e5~ z1wkT^n)B+_tB@*)gbs=_?XhFGfsy)hO>i}Jb#(}urKP3QKBAuM!h(X`5Uvnxh<~Kc zpQWWC^i%9P{zYIw045Kp;}5*OHRwFJol~`2|KsD4U&V>yL&{!MOCw38p54gipR7iZFg_YGin*pLofT}MdB7#u{ zS|_*~0H&^<9{9*$VoYBGOk7+9SEiPw_ZG@*a`HRNt~3FwZWJOlb@e;rjW6(9Ajdy_ z{;Zi}REtPLwfeoVK)P=q_)%Ou434OD6<9$`5En02L&}8Cf%vqtu;7FR;Op0~H8s*# zu6)NbLW3-aKMN947>8yqdMLaknBq<6M`}TAo#o@}=R@kgMBCQ{F$?(#EPKbO5U>@f?WE7yn~+!SM`5&rrVXi89yQ26JsqRkndb{o zdKMNIBp~FB)RMx~0JO#HstZCB7ZR!@{`x17_Q=SG$MC{L@+9UDM);?ND$&r;m|i7; zQN(bBgXm+5JPH7sdlP?+5m5X!HzQ-!V-%wZ|5_k)Tt;(5(>Ur~|G+?fT^(WwYEudd zqldV@l2SXaw5rMwABTTSoMF+m*%C3JV-!UGPb{n;>M^jG)V~yQ0>uEysyRaG3@7L8 z%#0-r64o@2Ax~jMZ+tTQgpaHEiEbn zfd^5R=+he5brX$9{I$L_2RFBbQH>uamVGPapFw;Prns;PKR-}43Sj*xY?h`d0b*{8-{xKRkke!E5fBuFtPG0V z58w0fJbm$EsM58)fv#fU{Jo1a3;H1|$z#A~>yo-W_Fm8V(If z63t3avBb{$dS8kUV+sFk62LgDi4GIZj6OSe?NXGFO#CWn(=W~T@|*)RqcPf8X!}0{ zCa{G(+e^4H7>Q$TXgh!nrIcxNgrT)H(MBlMfTUqXkWsX!siwnSPa%cAt-!VcoqjJP zI(1wqQRVjuL4#71htznTq_5ITBGD1%N}mwm93^>Bco#|b9ypK~`h;FAisB$OwG7+w zbM~hg$6(_^^IRSt9>$j?qFA4ZIvwW|f&hK4G&4J^K(g{ZtCBSlIG@o+T1G}m9_xIC zgmgn5m0X_0KQQo3Xh}&4 zua_Ri+SKKV;e{1AFMyZRUjL2%1phEJV%-)AlICdT7@pgvfb2jdq=FNZlQ6VMLCnn0 z53TK=`HYW1nQtxoYl%8=iA3(|)r3%@VovcJmR2QsP7V$dMk3PeN~c+vY+5%V+HDvC z_*l^T?~MOrV=?MVHK16rC8CJ?gy5QQ$fH|dCjq^3Ix!!Ge%J_u9ge;#L7DmlD25cIt1O-b<*3C^MF?6K{4AcXqw4@{%l9gY>I}*`L zUQqmoe_1{?AYdQkDR%ZirA80FQ|He|E2SkQNRt?Dh84j0P632?kxf-a0p+=glR@{GO(ZoKtxp$@B?a?_bmQ{o&#sA0}5dsFk+T41WisppyktiFqGq~3AStX z@6VrOV>Q+cXASNdtlitTBVrcV3HnDvLjyR@PMIA1QpZZvVua z2aFdV_4f7gFtEXYTGQPfb`-OWI$d;Rq^rC80BY&+1s73F4Dd9en9>KEnVSCTxGu4` zaBH#Sdb2ZzaoFlDpcI2~O38HrlvrDJFl&czi`8VO9i7}y|W@|4(AVuY_Xi&UWa+ja^MsQDaECw>}Sq+ zuKj5l9E>?SIX)g49SzBkXkP}D#{5}gJM5jDoQ%}~Z3t6}xAzASWa#wCS0m%o(xSjj z;iAB6BUGw1P}aG*+faL}t1q54Erj1vOH&h{vEWjcI|@HEJ3Bi$IXUK6e2?2UjfswK zA~qrzLJ(DhLqof6RypH@0{)SO#k71qni$4oL^dPN6uwsvy0m76#|nDJ?)^t3NigB+=bKaJ9U<|pHk??e{fem(jj6erHcVMX#n`~$ zJ-?h#E{+>$YY)h82^f@|uhGZCE-3oLpu^|S9`P5>WVV5>u3%zTM+*k645qrFouFGc zC%6bycx|<=&zg6ob>aFVg1k?wMUXdwahYcf47>$!HRg5Ti~{Y#1wqC!1iFX#>lN~7 z&7udh?Uv5VzYH&gj@cI@iBKX=TI^mJKc^NtOGm*D{y4+2ODd@&Rc?86<9vr^LV7yV zz^00hW#MHjL%^@wc|X5alGF10QN(9tc!21UpGNPA>_OKT5!j<|33x z&A|tno<3EJ^?6JuPJDwp*gSsWTxdJsAn%nY{Nf1u3HAdfcEvhL`jv7aVPWuZINI64 zS5M?iv50vaAY4qS-7RK__G(PNQ&Us4f6MOt)PoogsXNFW)81uiX%}~Q05Gs&qX>OO zYjaknUU?(OtBxOsX=uFG{@{qozmq?kzO#Yb3^XBn_WgZ;DH=OkvDB-AlL)Vy#?@B; zbbtPw>R2q(4Grey`t1uB)eh0n@O`yBJoTmgymqbMqqa%?5=Z})eFmB^AX$g2k$%7&?950YY*nFXMtItpo*qpK)GXGF zMak9G-GG*kWDN=oT>Cvvs{L?p8xTnLuPf)Us+nh!guEd?0Pgukrg8gL598p#m-p{u zKL@S`dNsx|c z{~E8>q9Euq26M2n!DLc}X@KW@07)U%(NDwbIgZVo=H}Mlu#|!Z5-=NhpE<*IF_5q6 zq}s*Hm(4!KUUA*zZjYW1Bpke8bjGe zyv&woUPRMyU)Iy}#PE0i&YUsz++m^&3pV-8yUzmy+8D%NxE%o-1$>f~roWEqt=Wc6 zli#eB4oF(cFFahm_SixHWAwo^^z_6N5MYp@KBPIXAuSwUH!;uRH?Wrm(tym+s(R5M}VL3Jj&rH#c9MP-8O@C_cnx z3;1hlAxLBny3m39-U4Br15*Qrta)~C(-|%<)46>(a8?~a-rUw^Rx#KoOa^HXV9wGs z>R1#8;zTQseJ2q!92`GTpt$wQD6CbKmEr0M4>AAndTj{I8oa?EA)14%(+zEGUcxXD z5y9H5bO!t*U^tHaT$Pn&<>Y)@bP>lo9O@OIf1k>5s^`Dk602V}$np(21WyoJly%Q( zk%@{+J|Ua+ zsj~wduv(rvm4Fd{{FcP@FzX&Eq*>_m=gM|UF%(j4I8gE|BqYU@ESD>!<6_FJhEWaIT8S1d*?t zgtUGMrJ0xaH#AXT7O=D6m`Y_&3mw2xqyJ9P>>lFkE#ls7kQ|4u7~dX)5=OO zQ&Up|gA)S}RgzadA$ldFz2C(E(BkPmSYOX_g7`9kk~8jE0JP@i<@Gf9D%;zNhgyT{ ztKYLKDk{7g9{!}6Tv+i5u|IGQ!(~DF%NAg_H}UZ}DmVP|Cnx~*^!nUufNu>Z`=I1U zdd}N3K#$7awROaQ4oC_VQ~tYm+;`dz)j=pil|NRQrZxO;hJ&Mp!p8C~$K}iQ@;zF> z;Q)AA$I12BG0Wl&Z5@8fz&$uNGt>AOJGB7<0tPGf7+Q$*SSyXOB3e)cHuQHs9cqUh z(6ng|_-573aqL($Edwl&+By27!oo!(<3S-r>qq|%O@4Uv!IO~Yv4l67%)f$3;V?Su z9E{|+RlHu964yw*QYSV7$5PbG=_b)aUR01lKmbRX8LrH~7aHJU<9Qr|f#EYCkn^Av zoFd?|x{DG88Db)j-_F<`odDr?YGx)^_fmD5qsd>QHnK4SWwT47EJ~Gx=*#4}-JMnn z5P0>ai0bkN_6bH-J@5s#r?(M0_vIE%LHe^?zikhA43lP%GPyP#9o;Q>fVR^RjCL67ddFx1&v&TpI zJ|%4@jJ(;KRoPSm)B~{HbL8T%FIb}ta5@;gRn`ytR`YHUG8Y%5U*`@7}$}JK+ucxOUcbf}^Xz z+o)D9V?R!xI(1fn>;;goEA%mcf4LwiIk-d&S8Ina zPmGKxb!7zv2I5RF&d#V7du?qZw=X|FqIi>)8g@7{JM`< zu=E{Ro1x!eqJA(dC3&?ag69or9pV_e%ueRp5qEKqvT{dV*1o8nIP?b~@CSyEIv)=r$0Y53rV-}IDx){S}K2g1#YJ$7zWVcggjM0 zozl?O-m`P(V|uZYI2_c~Mz;0h}k1>9=Hn7`B%R2Px0S@sO@PJ`wFokJ@%uAo4!6+ca z$7i*kM%y}E?fWt&=Fp)-*)@7Ykr5Hi(Lz~RYJs`FRAm;Z35$WG{?)^_#X25$sL%^G z#SZ>k&9+xTo^~nn(AY7n;~l+#L(qIs&mN&3@;XdhMFA}&rBsxW+3j1svN)|QuqG@l zjOvn1835Z#KgdZq&MXI&p^d1iQB##m?E^7pUrb8PVek)Jp9l=h1Z4-EUZzr5#RP>K zD@kOTYV{T6VUu0P#+A^0ZEUo}c0$tw69{c}r=CB&yNN=YT?n-X1_SZf@>NmudqlQC+~KzX60f zemw^r3l6LwKYoCrg70YyJRSycU@?C`KVhetLyX2iN%%Nif&1bV*8b5q@h3o^R#*&- zZNUB|NI|WaM7PHO_drd>UiEHx^+B0>~A$f{E@F504%6X#6nKt_|Z64K=l3 zw1hXvjH5@70yIKK!^D)*PMuW}0ZTomQux-s|IH>4m@Xpu(5FDt<5WJt{?cB~%ExYz z{WuF%0tl+QF@g#UD$*&#;rG}_7~Vzcaa`99*9OlZiUXJ*lw1@UPrxx9ok3y?3Jrw` z9aJCuSnVAhkc>u0M{!zXd~6K<4v)KcA4=TR(&~p$0!gp4s|yujV|^WC(mduc5%H%b z6z|&h5YKbGxl9fc9>yCSHig`$>ce7UWW)f0r@PNJ(wy6eFAvCql{L4-Zj_UY)-~90iq?Z?C-NUg9lq1@8l-q~687 z3AhKW-kH;iMLxR&Yye)U1y#YA(y_I~hS=3*B1n7W2nEr{7#$Xtgze7EY_-Nu%YT_& zm6IE`6~?u|8G#q@4D`L}>1ihqCZ|({ugPob>p=_z{(20i8fGJ$s%)VB3m?e%ybF`> z6}!@BcCarB3gUDW<~h7vVVgE@OFx6P#?Xz6%=%iro#3}09R-XJiZVP=S9HmkZf#NK zcKv3*m36WFs?9ANY+7GgP+bay5E&ac$?N3kh@AqV5y237)D6Z<_$|PCl0K%w ztOpIs(SVmY=8%%6KZq;)@sq9-^iVmI3_0F(e!SVHH!5*$Loi@${K#-sZTi{9+zgXDo+0`}8z zYavzZwh#-*i+;K-?b<$0p)h=ca%iKxM7xsJ2_P7NW9J|UQ>uEz*J?enc4 zrIbpm^t$ne(nB1lLlqi5#6w=cZu1a-ufy&CXXPFqgi-r7Q>WM-B@IXM5C-vG)|Qq; zF%u>O+P^;#pNNL6<`uhu-{m=wv5x}cv_ly_Y(`!C zr!L03Z4U$mT`jFM%O3YAK6r?K1D2eg+(E?2^9%R!><(C@5ozcGaGPoXnby}&;u#b$ z)gW&%B9GY$V*)C8+XsNzVBIo0kE=O0Hz5$@b^4?vTT5FT07X_k2R#MFH`K<_(Fdt^ z1PZDtMaq{C9#q4NF>|EGYH>6l%H_PiFFg9YckaYN{Q!?U&2S$=Wl`zkhO5KR{j^{d z3?>kbjg32TMv`bRd-P~u;n&hTZ79_p9q7%kyf=C>G;u)Q*w7H7hN3FFB2EEgl8=s# zMu9FUSV9{Vrn?3JE-5K#X_@Blzn8CaAlKvysF*gLsl+f(K$`BZ#w@fP{!lWOCMol?T6(S!BDP+^sn zmErHP0d}}~vm?Jv7WwidQ2EeladXZuwy23jqwL&4HMgYo?#zdk6yq=+OoM8Ij)7_VW?tWNCGDRp zj^HcsfR#4Aj+7)+c??{dKmVHNT<0a&f~=H~kU;3%u2`x2^ywFnw!(luro2=fsmiYu zzN9$vpnK#%ctk{zQEdQLT;Q+2)^GA*^g@_&!OF8zkTj&|LAA&~<$tW7j?eU}q~v>a z1uIWEm}P2f6RW5^Qq{2ag8cl%2X*KVb-)1BVk%U`60C9e8T3ank7Zjh(U{B8Vt_#! ztn-gRXCjzAWp+b9f${a zv@a{)c&j=z83@~1cHfVQXznkK8E}=g(j>FWXMEBs8wPoBkdAIJLMp~`2wq7I3?EG% zcDy2Ae)}tq{Dh?lMG;q`f1LLA{VNV{{U1NxfApT22xUNg$`I}!+(Es)8)|G|Yp~1l@kK){iDU?|R`_>AYppYn#y2FAHZ zWXD&##IU2qI$XyOWxYX!RvK5fw!4zPW7T=Km??~yvGGMOad%5d$ zMMbA9B>{(^&O1&?P6jIL+hdS`B7J~Cd_BDYRy;dMXgFvF+N)`d`7<*{PS@YYNqMoj zBriwn*kQmwIO#6zNlf8hL01GKtKio!L&Zb(alSr$ni8NEV7m+1kTo|wJNp)Al>nEh z8`2J^;C(YP4w3A<$|9wBXo*29P-qYX9pF5ALKAVlO3-&fS61@t6VIiClDWN-!Rr5Q zYz(|YJVYiBr|W{zct;r)-OkSdx6th%RYqR(iD#XWaC{+px6R-ql5nN3p5UTxpI zb35!6uyJr--&$8Zbeei9TCrNU$d(ay1en0PM>@@5`Cx~u9VKTXT!TXm51a_6FM@3m z2#4Ry(g`PJr-p}N8l3MNxUOA?WyT;FA>!VIMke8Sni*T)ZG{s#uT zW$iig79Cq}ElV?x8I4s~*ab>rOX$H%iYn#AOvP=RHAg7AGtx6KYyrYkU-73Tj(;=a z0E}E`&RE_zKBBT9(+s+xlUIKo69VG6p!ud@3@}ec#U_+!$kzJxCD|W=1g5RmFsW7p zLE*G24jjk+5rYM1J&_x9I|xn%g_lvr7%5;2jDIk4=#oGAs%NLIQzSQN{lzdjnPzYx zc%6ND=}P)|MKcr$$a*|Kxw+Wc2XHwUbr{6l^2e>_F^6u!i=<%BD*Xcii5`xcR0_uP zRK2x*7c&QlA_)!-Yg~)|+gd=wUE8#492;rkdSX^oTH=43&eA-sCb;ORTcb}k+vYQOj|C)J(M?q zSrAXdi25asUErWjhwk1Y=3i_mDk|zTf4g!65mN|Hb6YFT&(72iRGq+JkWn^Y%2sez z6FvUcuriT@Q(b~buh7S5ConNFkju?)hT#;chK7c&`1Y06)$SN>oH)TyFC$>)P2^bE zabNhiD7|(^f?&5Xt`H8aH4>o-h-ZblI4!Q>@fREoOrlx|fDo$=W>`;89IE5A4lTR5 zI6VQV0mMK@vB%56KvDwQ1Mf@OYILfcKI@}LBSF3KR$T zC-@w`mkl(1{r%e|Jv>A^{7*1tYUFGuct3dX|0KPu-y@9oQOHBHJ+-v~g@r(5CvFnKj0-V8teGxZWgrii=Q36e0zKEYbeW`sb^x1Ttk+^=0 z-;*bs!@dj{hf!3j9l4gAp>W|z7_d!YUXO~1IQ#ON*bjgXV$}PR2mAY{-fi>tMzUum zB{7rnNs50*<;1?4|L3ml&I`RHJ*Bfw!$~SJD z#pwnD2|0Oqh7=tE#6ubsmb9gwFI>tv_dZDD+0X%=gqSCt2z0nK)4Y{xv-h9LCOk?GJS#jUskmugrRh`divPx!R-%ot)}_dT>vPiI3)9vTJ)&|`*V zz9%)C9B?LN7T2I3MTFSIPzq~H+XlX54*^)zMC7R(i0$5di|6t2BPeAiCZ~S}&B057 z@$fvJr=facru&pceco*OX0fKwU1x}#il&$*xC_$*n0-p zJqB)9nuvtEp%~wBbi8`yiZ;&7Kv0RemTckIN+W?sfglV4RB*)Y(kRZ3f@8r0GxT8x zu7RV(AV&jxl8H7PLefPDo zp#eS#2|rd^RAWFaxEsLCJ=||gFKjC*BQt@^x>Vz}xpJ95?8S@YcxVG|z}h;)93D90 z^Ca#|+Wz`>;j;z=CwQMc#J8ZN#q(g7&l*VLfB-`J(oJ}f5b*GsV$SxdK{doe!lQ$- z%ixc^BB~|8R)7JIkMP6;wu;hob8%h6b1v{beh#HgSxs#YPqH!B(b95TU$(<>2v}SL z>oDcx821fT)s2bh+tPT9;RvMX+`bv+2{C61eA{(ii%Z8q6I7}Oeje>__cnpScJ#rmw`0Mqh0rK z+U{h0VWA)a4kacgi@r^q#FRdzy8=53rJ}CxaRRc?&rgSoh5-J8@@b3d4oO8tN~UKM z-*LNsDH$322}l{gl!Szx1W|f&%8#E6TwTvGJ!4x&>GhK}J^yRPL;BRoM(k69#7WnB zptJ1+@Wj$MnngIl!s6>^OvV*hdrRb=&@!cXFVsLd-*FF5X=8deJp?_< zPj)T^M;Rn*fQh%=0~QB`0d2_(K7p)?=2MAI%+sS|v$HMMv>+HD4vv{zM;4NeA&Gk& zAC@~3E1QN!1P;`K$ATXVc?*z9WMqL+ao=WUW(V=Go6miHD<}^DEnKXuw)Xb&va%Xw zE{Lx@o168rY87X5wYbs;tc;Aq+YBc@h}@uHjpNs_tB4| z=PgEVHvJrz*vWYbO`y3(>|Qwz8`D;FQ(1}tmgJx|KE~% zYA2OUwI!iKDr6|^s1%jZL?lClA%&1+h@A{gh$Mwf8KP2NLntJLgviu{3@`Cg5&b{k zoO7;!*SUVDm$bjb^Q>pBd)@cF^hJNF&aj+UfA?XFlY00#+qPqKc_l1}H2|*U+5pSs0Uj3?C-4stbBU<#DK|t@&mdOrlJ<0~;7@WS2j$@Ax&g@VNRc zqO~M(^qNd0JBV%};q%-(Y7Az3Iv9tb@`Z>1$B$0Ui9FC3oP)g5oF948Q#CUDLx1H# zDGa)85i=rqnXNi%a*X||%J1@^^yN#@;t#y&dOZ!AzoIjpMSWT26|Y|pCT<8p;4lC~ z2ghzy{`*Pjl(8r}nFkum6%+p1hm?2nmh zM8qVjQLB&d-`iBM2>-cwu~o~K1atXK5KjN)%ZaZ#X~^Zmq0%GCM*QqDYnGuVnj3^t zrwIHyz|*6{(FgPN;a#^OAuQhe&kFI->fhg+)vQwn{VlboNT{|ubQAg>ym~fe~}{%_p!P6@7oJlTJ@JwrwbM`nUWN=x}!iW z+93y+6D(}j89l{!ucL(4YX<>_!UPQZ`YGm+t4X4qw(x+)=W<^yEWEsQq|v=JNm$jT z(f`!37?h*jrLVI5`$+%%`Sn>iOGJ?Ad{E_Ob(3Jm(pKPoTjqL%G_({5LYw7Y&wf+> zD2tx5N}*sX(u&UJje^pg?}vk%kI#G2$TK1$zNTwwG#z*$=l=b(oO?CyJ~!Te zBRc3cu8r!Ie5R2!UonUG@MWDx17WdMD5+idrn+5Cpc^`s->@M3X$o=RNo$@w``9OvLq>V zTV(-)%_ceVv2`f_qEGUdE7;PrDTUm;JZ(=(ABESVaK01`l7L5?l9X+ykVCLnP9uL3 zkdv*WpGfv{@o{Gn9oi8wtjC^$?>~P0TYMw}eCGW5016)hrO!=z4?375*I>$b{CNNP z!qaNM8XETQ+s98qEdR;(adBXJ_ipy+v<1}prk`mUTbE~iJ6u1T`1^Wi<&u&*Q1B<%PsR|HuE+N-z4Z9NkHr{h&bRs+YX(`Eg zF4~T--@hZ~oLNGJKsHW10+M^0 zV%HtSgW}{@eLb9#tv5LZlKqLHbXR!KR_MlN8a%##|M#e$<^RhC(E0`42Kq4?FW8rS zi)N(#+;cBr9Opu5dinAtX9Pj#ktfF-k}b+tzTt}|1z##MhHPRo zPL9qY2}SCn41~TlM-gpqzn-re%vS&UVGxn`wXO~&)v1O1ckMcI@E~q|v%S4t45m9d zbwKKK_N?-Dz@kHk50|jWXg)ecaD$)%a~3b2WiZ{r0avl3h@{#4mU9VmAh%p#ySbdz zEG@%5ELi6*U1BJ?P2+oNXjpl6+R{ZsW1*j)yTO}f*(AVVV_e_$dca83SNs7f8F8GX zPJKHH>Upzgf5CqWE})P2h)G0{K5-H}G&dHpfYqN<($N`kZt|k(%z4$@`(@m5Gwu6~ zE&PPT60nTSZD>Nl+snZXSk*z{tAb1JWgDw9kQ?E{NEAUemLp;^>&dT7(^_@v%CVb~ z*u}?As07Ey0|_f;t*Q2UH^Wyg7%xN190>0c=HNnY!voIxk0Z6m3$ITW&H<4<+`6aw z9y5yfrGbI0jbOSmkeDCV1OowXM*Rado2%)2WaFBXx88nZ0g=j4mX0YiPH+LpZKOZQ z?e!kL`tYGcHv-K{-qLm~L=#t1@*fFYbZK1z(L4Xag>lC#K?A7JXqKk+e618pD-dF2 zNZpH->B{Q8`K?tqhYty^#xF)>+X-jq7izl7BZfl!4hZ-LtCApRlmJ*8>wGXH%UEh4 zLA66gnkY(iso>yju)^qxPQ3R&-%?u5)EHeh;(lAEiD02yoS2X+v|lU$Bk}#d@SjM145UU5Ym`5-U1gScMD~Z(H@-LIcAi zn|vP+-hZnJN#3-epe!hpUS5ZJpbu;86SWt=aGGDVzk&VC7LLhVtQT%^^f=Ic+Xo{C zINFph90dgV;zdGD;Gd!ND|uv8h~ql6{@5Rb<6Gbw4h};w76OKQ>P7zs@@gLnU)bWU z(MZH3z5f>(Chb>XAl5C>tR|4v6DPZscO;f8aJl3f{O{|-7yN9Lh%+l!)&Wc62sAj} z8gL1ejCcqL?X7L-f+Ui70%02z_->#fh(d=$8Cm~HA3D*Rp??`y)Doqx%fkiqBoTN3 z&^(%8n{u4wi;N#!D{p)z`7!3x38p)5(NVj73#6pU}q(fC_^%<5B@`i4sy*xvrbpr z<3pm1L_joLCBNxryD|0L2y(UFy?eKMc<}v>r``j|!AZu52=o-U=~$SwHL{yWPTAZ6 z$kTC+m}Ej)&ds$`+CUyRbk_~=R(cwDs+Zx3a?`1k)7tN!U-i~Fk;fT=Y_6k#Nb;b; za2;2UI-HuIO}M&hvgA}PhiraaQ7nEW%oEZaVeq;?!K({WlrWEd^5n@n3M@`dxB3!1 zVG)@0GWB!jSMc9X4@ndOF|>q_FaFt6 zTE4UyBksMhHmMf2J|be6y`%}5RjZEAH{$jWK6KLC8ohWKP|sYm)|+92kWN+ehS51v zP+muaxsWu5){5jN3@w74=yj}SzUIf>{CqU|v#FPK{;(v-Qiw;mv0B>>D1t>n{tz>K z(4Zx}Tmb=r1 z*t@s?Hj!uQ>l+^v(?^pIH6hk~)z`!4&KZpy`3wA;ANY{!^ydQ=gy5m zfZemFT?GtS8Ep9l3s`$G!<&VUY|kgG1a0#FH=H~`FAI>2RRCnTxws&Ffm}*0IvXmI z;`-PBe7-V1m%<1HWq_{k8?F{g*bC8V;NKS_ytGZUR^kDm7Hd;Ueg3)Z0!Qlp*hWh&HXU|d))gU3TYUGfi&H@t4zygd^ zDUU9}Di~zuO^tmtskGzb<3Y}>H26@khs^YJCyhP3cu0HkWwX#gM%x51HmcVY4W1H` zXgosCxVk$y%<=JA&GpiRz@3xo18jSGME*}Z3MWFaZY^816JB!4Z4h}{yHA_Ov<^<8 zPXwsQdWuJ)`B)ib43!q4QBel3cV41grR7$?OI*HzpxE+7T1PT{{r$)0GMYeGx_r51F6T=N3l^_LL|p{3t=2Nz$&*`!LgLk~pd=S^ zUcBfj;qnv?^8VzE5?U^Rs9DI0Ti>9X6iWR}1=9FF6@*BIoF`AzC4**OR1!$(EOPB2gSwi4$E07z@gYc&jw6@=+ zkZC1=L`}%WjYW7zq>@G_pC!D?t<;pd4W2DXLA;a&&~UWC8<@8vfa}}TSz%dKLo9$xwdrgMG-OXQ$c7H zUISWL=2CJg=77FQ{5f!h!Fa{Vo~L_v5ya|J4t;fkpfPlQ2PqyZ3OSfdu9u4yv#_oE zuuc+UkHT!ipge2w3o{{Z@Ek>H^w?LF+tLa`g~DvwF7hm&M+1a7$P)k8dXq$@?l4~T z%viQfE*C;!EXdp`=mn^K(h7(T!fZ(3@~njqq(U6!zZCxhWr081C})?p4y98l(@8AG zR(6XVw7vP9mhB(9pp&5Qo<-BacI;RMA!SP94*B8yI)0t<5MyIi0U4TPh>nhu0M4oG zPm5PL>-W@1)}d3UR-p*bwJK zG8Aw|J9%k^n)DB^LpY1ymaLKYBg|&E%B^6w|WLOBhjp49q+7In0Q{HMJ@MIm1}XO4n^ z;L0MGI4k6E&5{Rq?{*LH7~K zT3F!JFXiA+%pv`eIJmiW7hrRG$V!Tf6+_9>wSW*?2~;E6$*5U{;Kq811V)YkJgX(m zHc38qtc`>giNe9GEI_Ph7yhrOLdVI+>;yet< zLJBzr1(P(|UnsbJn{jN&|I;X>IRWTk+u0cm7_hZ){+d;*L}g!c@;G2uje`7qx{w(h zm6tBLYs7p4EP)?HUO_ROoF*nw@osdZfIFy5moM*|#!3u_%>x~Krn|drZm*q3A#L`saDTHtyF-Bpu=YH!^!^Grcf+%Y@_h5sI24&z=K3IMI{xW z(4ptvO;ZyF9x`#74%ixo68qa^h-E8{oTB_wCzIG)>zv+96(0 zLr|lN9Z*k78%lI)K0W~#z-ZAJM&u*j+nLKYViEJdI{%^y4#!#$NR;tPq&0e)@o{m1 zpI^Sn$qCKH{hKP5#i+vv4bs=gc3-pac=gHy>Z+^X;>G`>r^9C7-d;;n?9pgx zSxlUG6eP>_BY$z|(D>7ec;#^WF~haALUU;dLD&RXORl8>MIh*|tbFkJaq9|#Mr0Px z7~d2w6W223n_g=yEQE001D@GQr%%r^AbrH^X3M^Pt|1{;V5|~sVecZg!x@rx*%ZS) z;;=h@FZTOuL1)+y>1k`$%(;?|d>O%u|=`rJVG z{mD8VZX~vh+ntzb4)4OqLa)%&uT&7YjMU8EB93N!Y{>2U@erB1BU zokm-&F_yx82)tnMPTYt&efnw+jje>gh#J zde7t48TdJ}ThERYQqDyl@2Rx+U@`s)u3qUjRUOMkmQ8Jtmt#i(a}=Y5JyqHdoj^w( zhTE%7>s%bt_aqrBGu~>$`gLOk6*J9fbC6nbyWWnuL6z*!lBKK zr+zJ2g6w$6FSIlpXqc~C*Tk`>And(bCeBe5lza4WcXU(~h{7Nf`f9$I$#7G4(IfH> zvQDU`Kq1aty0qUbB?0+X;~D&^m~PMcv4)(z=C? zs+Jk6jq1JA9nA=slvES#?6wESM9x=Sp93-pMnG#R0qmcsBap9t0W04=!q3L0UCqPf z)2Bt7c@85yVcjqx0Btd8`swI-`s~^JP+mc(g&fZ`iUhVWGzl-SKTQd_fPt5SG`9sF zQxZ@zaSvzQE)+d`W(J}jwP>Ew!b{uq7>cbZ!cMi!*0rT%j8SScGyHma3(-viF(1 zBh$fYfy>}8fRt|^LtFu$=vTAcVnz26%c?{Zv4CXip?3X3X>@#Hqk?gGz)ba?G0}QI zhaJH)swh5@o0gqUGwvV-o%#|q>Z^yySr|pDqvq+v3{99N;&h`$6MQKt@8Hf|9_y;W zQO(@TePdxBSvoB5`|14hNj5el(Dr{bRDB!gBci^`80V`*l;1T)y3_#ET1{H zo3BxYTiu&)qe6F>YLrN~$GGUs@u-+QumUapTm?pawi7@t(R`NBW+0`eSi+j0;-Pra z#%O!?GiN;M(TN5%rNDDRLTW4)q&tQiK3Av(l@)fgqTX~CAFgKw}u ztHEXanLOW4iy1wI4c_=!vtHs##W?_N0r$wcM2bFBWEmP1>gZsiEFFD(qPq|Ct*8yc z%ykS#k93UU3v~Fjm`~3ul2>90OPlh%G+aT42(;N;0PF?rw6CS0up0Xzhqa)VGrOHFv8R)d{4#6j3SwmS01lzVLH!;(!U zo@vhFGnz4@pYQn@57PsjPC#(PkAJyAqLQBf{KbnyAe^J?0O2eb8x$Ck-0bmVQe_2T z7dicBe(l^&lf#*@vzPJ?uDN)fSy4p}-f@U9dhHwrD@ugNb>!(gBRKFx0TBqCZNKZ7 znKO0kHs{ek51$<)1_+pJeWTDC9nu)5R-sY|v4MP=1mU^?2jccixen*mKssPL*jQU9 zg#`^Xh2Kr9w0vC+JUfc2#bA%=%18C`rJpJ*0HYYaS{m)ZORjZS87%)B>8V^{=eq@r4JBy;Tfy!ztqs9F7lqwUZ+VJiX7({$>*` zAz<;VNzV_31^HIId9#92!Zg04uzmkQMQLRPz9wWAPuZ9Txkql%wt?V4E;Wgd(KRw! z25Tem3L9GOnaBPU{On>Q-Bi< zSja-O{L%V{uplQw!Y#en+^}wRoM@p`)CcD@IOdJ}Vkk^De9)5t` zQLYvj?pa_Lml`U>f%yS{o5OL$rs-b>Rv2Oe_r4OAF{$$w70AKjqB6WVW~L#cQ|eBOQ(^abTj0@e!m>G;txNUpYpo^)}a!dQBb@cyT0}S;XT*>;O`I+XZ7CI;yLvT(7LfVQ`YK|JG=& z;#uMrS@JAB97V|2&F3#>;kV43o`A%#6K8OHxZiGD^0Dc&8UIF&p;n>V*X8hKwLyA% zac0X;q^0?{FR5UOC!b8V_uTO31s$^GjEbFY)^cm|d%I`8{-*J~xso>rc)D~T3Kx+% z2aj0?A39LJUfiEgDu@YzNk;TPPIQHTfaHDO#Jl1E$`?U{pBi= zaQ@(KC%nV9Y+?3Aa##K7mJW{mB5ti`j~=5)Mt7TMEUZ2Z8UFb38X5#p07&D8mAkm6 zq$E{3dh*67oNY|IeW36NUg(3So4L8hVgYCs7CC!=I^ZYKHB??!5 z5zPgh)5fWcOm~bmj``v0jt3*HH_puYrrW9I$nM2n@{5a)kWv~O$36|#3kTQ8;<#s+ zpU0$0?F9zkH20<)D~`FGl%`qwP&HNqIw?Tmya}OplSkK(URM|w0Ki6>gCcaoHjSt~ zd-^?pORR@Aj96@LnltN>$jdpQRj}7mCZn39fFDD(cWT8;*gU;M>c;}v7F3HZ(2POZ zk^A;xUuaLd%iXSB`x~1V>(9YBT{f#-7mfbaKdQ9IdO>2uSu5^N zDc<2UvZgbJ`{k|2(ho6cyD4Zl8STV zAu_GH%`L(aL3$UK@#i(WWZ^{NlcnN4>?oZ7WuK|k6?L+PWn7HA^0`V-fG zt54VeiQc5YQ1Iiw>n}f(2T2EW-uxdt25AM$(f6gCj(Km)TYD}0`JXqd(`RHVw9DXW z|D7pd5$7MK(Q<>jW6sprL5~g;z(ossA4AO&@y9^N$GlIUuFfA$<{{%3#qi9DZ`TqI zed9aFHH*gnkUXxSN(SVB8U?hJmwy65<0xqXW#$bgB z&Cpp#E2()@y7)Nn0s=p}dnw)%70Vb?p-Y31swwFSI9+l_Zxl=#g2BU)r)byo%BS#1+ zEt4-Ag8xuGQQE@nAip3h0i8JstI^qcIF?F2of$vYr@8T_`@FOyz*7BcEl%y{7rcMt$~Ap@srq2 zDI)tmL5ft!_!9DBU|V4+Z00kQm;d&A^WE$XXEU*rr3Qz~*L=pnE+|jpDl##^LO?fxx2>w&qnF_wvb83|11AmTiZ><_nBMEaHav;+M=ld zjPQEzecL&+f`W#xid}lizJzcE5Q9f5J&@V+=eH6b-BCXmzpz1S)@#ZgbwRmP*|3}$ z_nZsnTDNQ^kmk^X))Qb=0+(J1OWcr)u1;-whvYGCZcb&lptK9O-x-MwxAV^Wg4T`X zFUNA#N!HRK{QLGywK4-L+&7QK=vf zr@PoiXtQqR?Lo1};6lzr<3vlD;~OyJ4TThxTe#`xfK6PB4n#%GS+b&W>1A?~{w)}AaYJ)zxYqp=G$kD! znRA}ej(cw@nj>>*zDqiNk{A8cwbLL*iqCT1Q2OlIJ_{>K$5K&-t_(}aQB+X9XLMn1dgxRjoo|XnzN#VvrRg5Va$Q^ZQ7_grx4~{PSC|Az+$G`MemB<*}Bi!6^X%8L z5>-vj0nZH#4Nw0VGk{*j@{flcU0um2tp9XZQQ06KNL`A?YelS%48n=hr1z|XE~#s# z+<11+c~b8d@{z0#(>thB~jbsB( z`11j)=oRvF=Ab2SqDo8&q=&k|968riTi>j0nZNtOX}m1Jh zIz}x#=qdXZAu?uHty+8hYLFOfR+ts%r$tpy%6RLz=TAk({-~&SsgFu3mOpr&VfoE_ z{atT^$`2)qS8mIF5DxS*w5casjZd`>=Em{kP z-ER7yJ+;y;Altp%GMIXCtBN?wh}LObQF1RvAO?+kNmqEG+xY(}Y7xXun6PYkkMfJ! zs*ko>yD!q#$=*BFDPk`U;{Q|a`_}UBs7IJ*@X*zL)B{Rgy%Re)|Lz60Q?+~)PkGv- z(*l~=hd+0RxM*p8nyaa8Xru{lW(-~+`@07aR5nZIeQc^yasc*h9;wVw*`j}EYF(J$ zRUd)`95uL|>EHlu2@A-@AJDTQzXgaBRb=nC{?{madD67lTXf{R)~iR4(gtl~6Vp4oyxO5MknEZm zkNEjG1TGYmyAX=dkp-hzj_vU{+DAOKG?Z)Sqnyv?KJ@?%QlV|(-8U_puhsX`chPZ%Y z0P5gRroC+i%G3?cXEs9Yy|-AAWbW8A#L~L0chhnd7O*k={ylHikk>j~%fM*No`8tZ$rAp1G&VG*c)G)>gnL702Z31s`Wcr{Ed0j*@; zhvCKw#U8=z0`7n9%q~CFVN&TfFW@a~ir;xhdh#FPK6bzJP|H|}pU9!eQT0%~)9S-( z-S-z%lP2!rhjmU~9lkf{Ae~WED}jcmZjo}+YjwSqx3<`06ph^<>gwWHJT~}}>wBJ4 zAdrS40&%@n5SM&-Q#v;}d_QZwC`-Zwy21vWANl>l6Y)iJ&nAPsSB9e ziB*)D`jAKTeFZoc7sA3?LOyM(B>hW2Kc@>ViP>ghx~9GqtEn$HyT*pB$T*TD6 zb~SK5n^RgE_qZbQI~6qjEed`-TT=p(PPk=}-h{Q^TeLxCB3J{O!P#aJKgZ^R3YR{8 zYF*|W(q+dSY(>r=@5E|~h-$t~L+7)hI40+0)A@CKt#5ad_l!8@Rwcudh9*o3h{~O2 z3YQR6Dz2!aN!ycvOm}+YlJ%CkA`VErTY@z!{N(gnZ`wBC7vi%TV}m| zaXmgbp$*Gfi}pjw9<5|7M4;osC*W+n=Vt`C-vIpynjU>Ld|FJzfMsDg1`7#hz|$5O z$wc=Fz24JAg9Y@20)`IyJ{>*{=+P4#iCzMn5X>L)Ctx2Fs}udh_0rk1OPLM8^bk4= zT*oYK2+U~vwrwO`2AxKC1SRA7QGx@2SHFLM5vnr%;*^+VEk+RExwG{~xz~aPbZv~k zF633v#iub$-{BqY!;L#*_V364V&4Sa@g__Y{lPhTr7K0f-QCUNYdcglvvr6q6 zA(vBoCM6{W-r-18iCuj7aySUp!*F@e;w(_v2e$pbnlr1#&)d7LfW-<1l|0_rW_`RT z?mi_BFpPg6%F7c38cjEQ=#Wr9xG=39(zm;*hUTf9l`TT)^7F}eJ0twC8E3twcURsj zl-S)hgA846dx?V)v8!nMQd^tN$nb>=-_aRh&HdSVdFalQ-c5g+%%*NE9@V+ncAuV( zrhn9s>n0w1w>s9lY=D*60Mjw9G z@$=i>y$XJPJYVOVxYlezLfx#-PcTo4zHs3}RFtIbqHF)Zd_)acK5IV(FxUwf0a9X` zO1+J|ZlC#h0&KCQ=gzKTb>q=s_ofH{x}ZAq$M;EBi$Ih4eo^eQW znuNp>5LPb77+?pTjk0AY%7I+*PC{J?W7w5pNN{9pR!*txB9Y9&us$VaH^xCb!%OIt zB8q02*K>GXocWkD9r6xe5TQ@}aM-?`Ke^K|rM-e=9(bti*|SBaO6jxq zkMrJ<{$rIKbe3~C?9p~nxr)*Y4fco;oZy8zA^Z<41IEt#;{!p)2o-=z6M%tc8muTa??W_dE?^dapQItUu@p}KZm`}^lKTicJ6-P{?^ ze!!qX;;kfLJipw9xhq|ur+AXP{hK~rVlw1^Hx*_JUdQU^jkI5h%7jG zL_-G;KBghsbMQWS*Bof5tIBzw5F%*x)FUw9@iWd3PuC%|T6w&r9Wd07iTZuRMc!I3#`IEJv9C2$0C(ntpyi z30Djz3J?xmRT`lvmY4G;82tsnzqJ+-0k~TEPO+Kb$lI4o<35rw;w=ln=v((*iD+{B~t6$Im(%caTJ)h8r9I z$H5Xj2DSGOdR?5;YHgILEYbfWiIAwMczUuAp^wuO8R9>C_G=ytPbnrkngiuGm;q~l z`SS0agS|0ng0*hTL}Z%JZ$5v%mkV``bcD=`cgMdF^<=CCj%#?NK|ERj7+spfPA~<( zf1g)DWa@!Fg2iNMS&bbkEgt4~Q9i?=cuxJmqKI?mKdr56$RTan^Ltqbj0OW#1Tqz8 zTN05dX*d=CvhftVkU`Tu*gtAjbU5ai{vhToZZcM0AiInyF)p)aiQE`)4en%)Cl^O( zB>T{1{-Jo@)4-*;f(*%8;f29KT!SM&=?$Cfv?E-ba=!OQJA3bMS#hT2)nl$pYY*u<-ou|id_dR=sZw;VB}kD$ zGIk0#V7x5YATXsMi1O!HG00Rx*gB!>Mg-GTFAL}O+mutE6gU`$Zy-;=#z|RsKCT1rN2@Pw1nej*gOcxb!E+(jGSf#gsKu; zU+4;Uj5QUQ|IT6^atu1Cu@tYTb^-_d##fIufeF+k*5P@ak&*|uZnYM$R}YnQ@U;x3 zpw_N{$NxHh%57A$3OP@oc9U2~o6WnUD&x3Q3MDJou3%dTv$+#FjP(#9j*?osIw(lU zp|n)?Cm8>=JkY6o2SFJ*hG(PoPoSVnEmL)G`UsCF$CC} z5|&(Bf!%MJ%ijE-{qGtl}9yFu}PxzTR+e6#Ykaq33O7e3SynYZ0=eXGkZ<4OS3% zJ)7m6CoMwpm(i*q_$i3EhTJOnF*|0voOGpCC`?PO3Y07gBK9tSC+Y~$mQBp%rL_n> zb7qhfBNxRSGAW5aEp`F7T}+C)on#qFqq{yLX2`{HXhNF~9b~!mss#&NvZaK$-U5Sw zm1{4&3TY8a9k1m{wxXb=uirtEp8@Yo^RGUEKp=U5H<$orV!a$^mcJs0^mFGFLnH35 z9{#^v0DvADkEfr&(8ul)cs>fRVQT%=DpU>H6RXd0-CGhq9wDEObYFaYOf8{AK@jNq zw~@fb=pYdEe5vlWaP{vL@Sf7P`sf`hoYZNL;Nkm9*Gh}>J#5)yibQ~DuC zAic{)#~rr~bB8Er`rvdzpu&T=96N@tea1F2Ig%Z4Q+^Rq6LX8b{BT)WXN|!*SpEUZ znwWg2vL*{*H*^QO04~GCi8>3{83O*!TT^Gupr%{kIsXztk1zT!Cb?usd`rN9a}~bV zA2Nv1Kz9?}p}G7i2^rE<=4d>AoauP??Kg&w@aY`!R%aU}o#{v>+9;7HXbnlc$2{zfqJ)^IgQo^C6_S?$ zdE|oFDIi(+@GRp4L}u)Pa3SK083}m^J6yDsnc|#}9s{F^dvD36tudC=*S~xJ{?f;( zE?gU@Dy(OYY38=(LT)2@gE4G}9UI6Mx@c1ImWu1iL%I1b3?hsKcNW!^W5*`>P(0ds zq89`%huuON$~t8909U{*F;a;q!yl?37eSlOGDGJ)w3cihQEHFgy{{tarWI-jDHJ~m;UiW~T7$m`dK@Zuh-lZD`{-PGm2J<3Q>KkV42p->P3FsjQADl3*#ocLBM1ltlu~zv5E)J6qeEY-nq- ztF5p99L)qMJ^|wI9fw?sB-eZe)jJfoo&2qhLV8)vF)3Z{;D=pH}8&h__KuthU_>KZT0g$i+4bx zA8Bg3f((StCGY(cr^0bba!DWCRZ}+hvP2%wn+7HPUZ%E3zCbIi@As^xlpEytWTJdo zoA(ry+bCUorS;t@Rk>%+wIo|KYDrhpjwBsnY+(;o)pROVlt46`(Zt58_I2cWv~PYM z?TaV)GatsU{qfciy2rJ1%r72jHg=W=EuYQ6I^;Bxl;`mMtQ++E`=9P>axdb;yLUn; zI8OhMo0ojQFRw>|5go-#n*WQXq#&9Pjd(>x!D)+r19w2S1^A+4t3r;YkudBW_mjBK zoi`6PvBB5-+=jJ5$NqcSeXul68<&5ou0BF;#;QQ&4lnVr>4OmKG2st96J z!e6m^u>(U)j(_U*xQXQdqF=-^EvgIzNor|Q|M?TeWDXA1;Sr^d;P{sj&dhfm%p7eaA~BN=--y+j3eLyKUy zre|#;j~o-Rr?or((_H3%PTozxRzZ9RknEhkzN4hO-Nt2X#49zQ8y7Mzi5kAbcr9WR;ve|u-#Ra-f(2AL8Vr+a{I!Y#b8szo+8Z=5gE3)f5w{)76ksU-q zLWwbg!m!oJj$S|@d|j>+GtCg4Na_p1E0+xKT@sw&{8plEeBtRwj|zZ4tmk(8As zS~v8a@t9LEL~zuz>muIePBKhdm;U~>xE!B7b!1H2jveRe-lig|QEC`8PHhvVvPn@n z)q<^IZ-1lX9Zjk#aNE%iucU^h(zo8)zi;2XOKBFX= z#X=4%hen%xQKy)QCi|SaS&=8QgZKcKZVXTT`SZK4Y+FP`pB+7NyM)+J6_H}niH)Z4ACjydz zlSii>A-~W4yv?z7uQ9=G1fI2tSIGAwmXQEyF+tq_@wp#jEFNnq=8)dX59tLD?RCnAQ|Ibc(bJ!v{D-P4 z*?xuc_-rpE!wpMFWcD6at{WZwe>Xb3&orjL)(w+S?Ui^BOKc`3b=hlzYrygqE5u?I zG#J!m@7l)(>in&?`HYYg@8n-2phiSq)Dqskx@x|B6wsh*;3wVcCap2AaxZ6v5p~OJ zYP$94k$U`ficK)a(Xg z4B{xdzXSfwcX&Y_t`NF#hKlV+O&2*VmL< zkTohAs~>G##~T&L4Ut4;cp8Xa)^wwpV2I>m6)vhAvxi4_IkG!;_wECzpEX~OJ)(ky z6CVJ9keSPb#ZZ5H1}ae_^TljBNw>D0I)ya-87){@TLX=D5CB3*6%|O}B>#ZRw-c-; z4py96kaX~1j#HeI?~8A*ueB(yI6>WYcw>he*F1|iACty8&gw7T|K$!%=W9jR%gM#c zLvvyAbB19jC^{}yWjZpTx<;d=P-Xb=-Z)Ly?5wPkPc^X~5Pd~QadEUWHXcJfH({O? zaAVWBq_fs@r{qrEH{kHJUfy?~RdA(yIe)u+DY&f2C}h6W<<+&85o%y)hB&*B zgb>i`Pc1MA_APLoHEXh&|Md$KPS{?~ve1%;hmXyrt~DAl@VNRJ7jXyse8|f`P1DS~ zJX}uFOtmC^F^lL&d&bIas?-x>@8h})Y^(Q%Pg7UlW3%PYtnKaaGBhywO{&y;mdvJw z!>n0X7-zeTtkcB0&$M=7*QL$x)@ta|;+#t+SbI%eeV~N}wa!4-Y=~n1X;o-dIY0JuKVnNsLH)+(mxCrKT18*_c z8pLqlBS+0Gv_*J!Lw9W9t<80lgcaK)We;yD5gT9vqKEuUWi`shx%-+(;+bxD?$ojH zWppxxvzeLm9Uz8jHjG|Mt)^*uGW$vABT2_i)%*P$3H~$JTJ^|kE^GYbb_kZ?x5k_G za@5=2dFDUX3PPJUZ9>)FAN)F=s{k6s>j1V2z7Syh!16$oP7{uL-LNp4o{kL}7Q2U!9zAccb)5Ii`|kS{zZ4cO zg^%yoe$I9PA;^Mu`-j)UF~u`u59QLsrBR9k)i9x4T_W+vrfx<)1Oq;SQ+&YQNc(+8 zM9L|XCec&z91x~w*SaEz2B9TRVPpfOPd{&2CK>Cp0W4_4VF9yg(tLsmw zZm@=dbE3e-)irL`J3nnuR8gq_faC&4{~u4i3@=V2;dMpD z^-SeEE4x9sBb%Z4p&NnCD;cc2y?sumUb`dHSgLoL2HDq8nxAiV(qt&y5_B8jGf6m7 zaY&)*AQAJ6@a%o=&e^C%c~`eU23^1Y8qlouQD>VjZ$+yLiG*NE?K;=3{SjSmoR+`5 zGKq>6rZOx3shVX{dhkNS$w@5-?26jGo7r5o-@akYePz|R6g+dm7hMCEFC$0f?o(4g z;)GlJSW(D%`win6VK&_ejuF3tq(-6m)dDzDxexah$HM(bE+En>;rW6+QgMV=YL{Kq zi{0I!FwboyB?|%Rb!`xcw4blHE9aLEWCo^g5kirvom#bFf9kUyCO%FNvjdELpxl>_|UC3`pvl-?`#x#f9%4F&ns;!kIvBFs}WWYwJGMnAwl1kbet1tOP4Mc|s z@qTpR%WnW3X;zUlUG%Tq4BeLgjBmUaJbN6qbk2S{=-8F%5}mf32eseYvAn}PFtE!rw$z==gj$U?h@&?-sXScHbUWKOCKNp z@B8;Zv5(y|ViO~D4cfPi*oD6yk%K1r{1UG(Mq_r9a=l4y+{HOkd`>NfF+%b}-Ss^-{{ zsK=TqUzRP_bi=P;3$xqYxxWC8q2FLKWXPNkJ;xm%B(KyKZr`~h(lr*1P_>EJP!w^& zhy7nh1xjCvR$^v`ac8y+U1j949YwdDA78)v(8DWB))c3?(ZJ}crUrhFCEHnpqnaSD zAOHPVgotRW8f~OC#ZV9w74GM}+D1HcH6ZbZ%md_5a{s=3+qO|;A)Li!$$D39h>q|E z=^_ZT(WP)IRmn&8)@#-GLR2D5*9B5CpF<#Peuz)5@JcF8*dD-o5D(ZT7X_Kkb>YhP4!v~+Q2 z>~&$-%ysKj-Ncd8jB2Cg7ZR5bN8Dq`}aSGLVPb_;Q`;#f>+92gN zrH_lA1ynLKqhu3R6-B0y>Yjb>*fIH{5wT63 zrFg8Ng@Ygv`xDA@>db$3`;RgnHtZ<3Xp@kX9rPhg?N~{BvLSZ4XqIs4QZNqgtaT<= z7SvDnaa{Y^cRmd^jN?dXaj=~y-U-~zw#-$LgHJ5KPPQWbeOps=oY{|=$1cJ!dWCZpMLxnGc131& zV;rb0_ITkD5&d*^dzl3;B6fm50rWM|yPX^^(5z4GrKl1|vthO`pC3-4c^Z z{RSr6@9Ua+y2p{TqJ~b$0c2UEZLpn}8MCz$k(_xwZ+z3nsmUhqH40m#ZNC1{p*{kP z@QptshPCAPmVItxQ^{i(LZ-B=rk~xuSSI#mO4}WIYVkJDtv?}SIH8C-KwaNCCv*4i z2{^se4k2k)IJmCq5AEQ>(_U&4pd^Gw@S{6|rDax^>}IqK6lfVOGdjKlMFIt0P0%tX z07p=0rXD;Pxp!}VrkYLVdO0K~=k$d@Ob)0p@NS?CXA1Rl;~mcQQnlji6i{$f5&DzT`Ym00mREuwPFZruUGfZ; zZ2Q7oN*mU1`sdMM*A2H-ChgcU85er)5D5L9T~NE~HTT^wnT&hUp?eID;B;N7Za#AN zWCFdwn0617@$)_2o*0J9;XJpNVa3nHRZeN^KT4ePalBGH?CWUW zzhnAU;Bf`rs)=8mKIgvW^kJ`frNeu45+DL7wQha>RM@zrP#`Sw-|)($ojdPQk?-g=+Go+CLh-P+ z7I%(1p@2i7N2h*lS2V+mMx5G1W-iJiu0~*PaS+qpOhzYR%gpYlTN_NK!V4Km>di(V z-NK|h3LuoV{&f0(0bpOjjOT))@*FKYVYh(BpXiSd$uQ-jwUbkNx4jRiYSPArKdwtc z@-^rZ72hcWp_o6Dv&`I&o*dgIj!lMW3@CHeW1!?W9L?Z!P(XadBLBO}Cm5+T7HG{A zorVkB|h|$)mi78_85a+kcjr8e?_EFFZUUaghPAFry^_`w?4-$VnUS(vozP zXCM^j#j8;0&{Zd+1zU$Zf@AnYvXZAN51;pRI&lub6Nq3x-8f#cpKn83S}?%jBFSx> zPaY}!8-wh4lQ0=DjwyEVgMcMjR+#3&StXJbn{l=c6d&e18cwv3FQ1FN|%auPS|rUM6Nd3uVI2N&*1Ou0)~#G-HV-TKX)d;eQrPW@1NzqLXC z{tnZo6&4lk*t*ro#AMOC8zx4)G0s6M$(`G`cWtBOSK#fyTE9_LG#R=LkFrF0qvHbQ zT5b*^4qXRqKuJ@Rc5I1#+#*4Ji2+lRq)d1MLm;c=Wt40}n~ZS=ZrfO(V#7tNlf>x7 z{nmWLM>EmVZ&mnk!8UQsOb-w9F=Jk0YX<*rJ9)N(IK{wQI=P`MN()j9-YE|l#-iVX z1s9Ml;fh7ptG8W&lA`@#i@a;slES8Gr916UNPwY8s*)HJ13^>+cO+S+KaML8|Gl4#y`)WP<8lpD!-Qq&4QRGrp{XTSJh4*Yj1B4s7f==E;qgp zCISGQP?KUKBSoDlbfNc88cxnrKQ=hZ40TC7q z$XIgJSShy_>!L*eQK=0#XZJ}xnPjioW?#gGb_+Rc;=q@d@8*1{d<~TA1f>A3f{=vy^pfsFLW@7RKC4bW#IT{ueKB) zoZaT8#s6{vI`kS*t5j_+TLjeZonKMN3J%~SYM3FpIk`rz*$vx% zBh~a2nF5`0Fu3pj@pK$NY0^>=q{j4PLE6R7Jy{bIiM)&vFP<=^Q?UX(0Nch}f6_22iz- zw)wv`Rqp-!I=G8^^K<9EaW1S|MkOs*>|a0$G3BbSO|3EijurrU6%{wVZN?^#9j(O+ zc?ok&T-BDAmKj|q9=YF%+YrEQxCs~(2mZt{TfQ++6^Rg!?)!|x6l-uWd}j*G2bW8e zx8QO^dq+y(`wlcRS~$!8EYgO)n8wk1oT)ZdU6}?bF;}^y0Hxye)gx{39mr>?5m>qM zp7oLYGJoH5V3jVE0dkUnFLhhc1)zXON`%7+&eFdf&2{3$&1>t^_B~(i<0I~pfr^NZ zRv@t+Wg!8P+bb<4j8w^f;AklJz?OhMJGI7YDsYHS(v{RyU{c>w&Z)E=*vx!oo$O`Z zzB>;wH3ubaQL$fhY6dGR}bCZer)svu`JQ&-UR z?%Z>vtib)DiuAnn4lN%Q@zDIMOGSiOm%e$N!QYu6<;LA|400iB10W(3ra?{OFWIg! zeSe29l!zo*3Zbm?B;dfd7C*KW0CN${9$aSlfLY#S+90RNZG7vMq zWqI~j$C*!0+}euO1BA(6Hx3Bqrz`t;ELgA`R2&;?sTs4cb42#S@}3FKVLOxaNgStL zFXC%92c4HHXtb;IQ0&QWx2FCB+f!x3}b}g=fS=J zTt4g3g;JIxJ6sYFYbQN?`0&w(KhyWo(kef{Tg!oZ579JGC8Pf)7d2cE_Lsjon-+R^ zAC=-w4i4oPSq1#-`q6}ROZHjpI=ib)5AhO&nWdbw&vq4&ElPPvHv~TjPRx923K=}v z`t;muNtG~q1QdwW|DHfSz>owX1#yMAm}*J^V3al~e{$SzfALXyoBouk<7drUPyc1u zWqEk8HGGWi$y>+%Qb~!n+4c%{j-r*n&tuCuCdFh}@O*QlTN$yy`34lkPmZZd1MHCk zSmI}Bh>Rj^S-cl>gh$fSe$zPs4-xS*4c}a*K}O-mD-aVz!%pz9k4S0ciW{oV4t>(G zf7l6dgB5`8lAh+c3XexBuS=zS?|$O~Yy5&_fe3kyu}T0+J3w>sd< zLnc$;zI^-EvC;Jz%8OXU8-(J_rlcSd{-dUXY^yy{jAi_z1>%~XP4vo?ftHYx6igLfH zKR&YDQuPzh=#r&Nr9_Y3O4#0}rp82i#-g9^zGa3laGKN-Kst}RWH3@9a4gZoUMYC} zbKvH&sVfME$Z~ijr56hBBXAsUj zS>{h?zLN#O7;wT->w>ru3@$x$;d$TflgZCWwb_a%C-4PH@~geQ;n2jd9lviMygKk> zS?eE|-qd!!s~Rz2qLxOaj?jNcN$s%Q;IQDj8uA{<)S=H+7*}z7!~Qh`?Kh;(G$^#3 z&ulcw-$_?TM?xUt;+jD|#rnC9{DZmQnUmY>PzsO;8Gd6Tq79QE1Z8KF^wEa%AiisY z_!cNrzL5yher!R0#Xnr3J?)sqd=jpqfzokNc|zZLmcjj7F;fi@7jdBZYt&~vimu#l z6r~U~BS(!Qz?YvlGr{QFf(wAR+-tU;&0Klr8qY#|dDVt16KH1OLYo%FbSD|F-Hxtk zy}NX|OGP0LK;F^m(^W2M7+hR8R@WDE|Cp<|ciC?RVPju6n$J?fa0@81nI@IjG`+)S zL2f)$H{vrMlx4S+>yUXuQ^2&4T&#Kd9c><$7lY5 zAQ14g!N|qMsniU9NC>7Y>AZohYsY+sQXWfD8qebr-I|r0lmz?82rPup{hLS94tY?$ zaL-3*+7pJZXdbN>1o7cg=_>bXy3YKXTTdoz{_)&vqqMZNVCZnfshZ$MCx8^m%BO|x z){d-#X{APbhEh~i+o~G7F^z`mVZb}jSPBU)F>>H{B*k4lXI@x7yLOSbV=eVGpd)SV zq`N{+&)%3B&Yrp0u$CJ~I}0@*Q0Vhnbnw~#q~!1z_R=EoGI99x>SlKV!Z-aVQyc5JCVzLtb?@4h znL5N8Dgs0{<2~0+R7&2X0P8Mg5g6F|v*z!nIM!v+sLH)y=j!^5-VRj;5Tf5Q7*|O_ zT+RKMQ4I>Cq_QS);^V_(DFJhs6WJS(TnSia4|8yJc5WxY&zxRm%X50GL%MfbRKQl$ zSIAc*14e5pT|b^f<4bqPimsHcR{Aec{~V35&fUTZ-xQ&rrii7G)sFmgFhOk>X3Jb3 zKo(Mr^~|Kj6k$DhB{^V6f>bbONxB#sO9q2rb^mSyg$2^>QZ5>MTCu-TMgS07v01}V zNJ&lAnfG6<{fnHnQlf!sL=(3_0CQLsqH322hRD$63GO1WZb312uRSZlbmy`6RiN>s zR|733k$^ZcqKR2?0n*Ikudl!H(4kiU)!Y}NmJ*By4YIU4bK}N;HTR?$?B=!qYVNnZ zXQ9b#B}RF=2$Y#&5d=*sftiDUZS$c|w8e5-NPv{~oT@2Wh;ELc|05~TeX&BtWgq~~ zwRL?w{_aZnuj0PTh07pz?RiY)3b?&?R{~v2BtXD6eeAi8r(S7LpFXnUE@s78&2@jJ z_5*tUW5ovhCg&lmsQU#_kE-sD8?-RuGK zH`n{?E^m9#Oy%l>Uoq!CppOuY89TLlhM-(1J1|1|uj{-U0V#svra8i1mH`C5-wKuv ziGbIY45rPcQ$wN_UNVn6wvb3Il}BGn$%mrm-#vrRP`3-1t;i}d@hC4=596)CQGyHI z9$g@vl7PQ#SOkQW&`>_6O}mC%E#U@oiLf^CnV#;lF#>Nr32|v3HtUL7k<4Z2)ljlX zxZzJ2k<-s1j3_T|fZHN-0k zH}stm)`uvzO|0~LCE`BO<^PGQ#A^ldbe8$-|dKeh;dd_bOOcrNU^7)DavD%VpUg?0+f`1 zjdB7pJi^qoR8hcGWkR92zJTEzLEKCj6L8+RtzG-}db%@QCvAy_x}`i^{}9N0*tDr()ITiOVX@0ilFBHUAaubY^C3WG0t_Hv!oVn zCn@SK(izqd3WismqYDfKBlKIYV}g-7)V^k433_3or4Nn+4~^&iM%Ow`9YVdOy6JSG zrvz=V)9!!P4{EeYTYI1oWpL+O5SV_4ZpzAOL+8|AM02~Vc%ti9(^L~W1E&i7KYA!B z#oE-;l^CPhwrg+Q9Ac&IpDT?gGSLJoluUdEUNDP{8<0_B@D{VbN_i2yZ0?-t6yN zG>Y2HWb5L#KVK)W43|)fUuGS~!sqLSSz5h6hW1RtV;xKUn!2oQ73zCn$K4vg1CeJI#qZnw`DZ zHXA2Itf#h>9iLBm;BiBHPvkSt6zPi>EI##kpa-()y^z%fnCe`s_NK^Wj`Q8mKAk4e zI3t%(cP}$(XJ%x7|nohcO78o_0;`4avI8?4;?~*|QkiyFM z!7+ypojQ541-t?dY|a`C13fVHyrj^#Zz|-iEyIFjTVM~khX3US;yJ()m3Ye)OZXQQ z)uQ!3rS6z#dw)DX<}kCJVZvvF;iE^lG8N->GvU61?Jg6$!oDgaFqPJgVo&-H$j}D= z9JW0$TbM${LVZ~O^ulMN{PiU<-HOR81=p@2s1(N+nW_PBq1a(ugxor4i)p~pz{iUM z${mubK02yeS9jYu;5CYlLB_^#T@yg~5NcW2zcEsq93LSi`XvGY&kl5q=ZpP{wb-3} zUk{Q^d%ZzN`94|4s!v(>NO}5?*VN+{soh4;Xg_YTTFiqu&mO9q$L7gRn)$xr8=vC& zmGx-&HsDPFcI()=^JuI?vz}}1Hv$yU3()Quur%0d`)~t>?${pB48m{XW$Ar{@Mf8l zbtbKNzh|HMDCi3rY3Z1E2a`L@cv3Y0OHqmgeF3#q%=Zp3-!HKBPdsd%K_RV1 z3DFZ9Ez9&DOcPA{FqpH-pX9Ebk7M|ZEprT^{I;JyG4+AJ)U>V2OT33(ID7Uc z$TDKnLp_*DKJTrga}GQ!2wm}dioDo5t}Qf8>8VpSL%0Lku5L2P)>ePH$%8L+3?6*( zeqo*;mS7qEzroKQica{p1SCcSRTBH+T&TXmQxru~6|OtW}{>$XK12 z+@E~sS1W^3FYIAtnbBdA;O5X!tRLvI#2ba}raw$+J0Md%efRiNQ*_d2X2}UaL?(Lf zC&bk>TpiX=C7wPKWM*dIk{jYWi2nU~PR<%0yNF}pPYeM^ZD{Vwql&|G0kK{*D-PnnsB-y+7)HJE3Ol-qAgbweQdKFetIs@Jy3 z4}C?2nYuc+8h=q6JG&Mt3gB&o+FM8?CFTB`x9}3wNd#?RJCD_A zyYBj6kh{L)tgZX7KhfCO8>4Pi8c@FF^yqPlNB)sp)IOHK3;gS zcf6WBAl!w)QYOuc!Au9iw^saTqPd&Lf|IjAlR zpB(VWy6^q##H;nEztTgTXnSM2EL>slO~>@49W!XyRKI?m3=Pld#iB*l>$cpVF?CzW z$1Q7ZTHsUk`}Je}A|6SP>5-3(d2Ms-y3;5^JWVsRnkgrflK$COdQP8vWVf!xDfFAl zk^pNz!+ANkUag$C?Le6J$dNfUzqW4K5_{gtuuID9?Q#Lj@tLQ)Uf3DqY z)ZT>RFYTCWJJV_LC}*iPqw`P1?4CMJ=VZ1lrT4x2_tVcw)N8mDWMW%v*?Kc)dz$l% zKMilQ`ZlCI-Cq~}DAS~SrpX(Z?_HPV20Zw3a3LVPIg}aWCN;p0_HP&l( ze#7ItX1CMCS!Yj8k$@S!>$c-=x=!Ej_4yNz7{`YdDah1hq@%0ZamlCfF<$6FPq3vkdk>gqPLID5C=*RfMur- z)m!P~^Z3%5{vHFynT6>dWsRGfkFPH_2l5IEy%!JMb8`dSB)_Xa)=_|%M%ct- zEw0@B8!QQtqxW(J)Qtab-z!oAPSiZBmnP0z)ZwD(oxt{nl6R#JeCv1IK^8Iz5*MbB zF{q85ao&JBo;lc(Olr5la%LAk^08R+%RzHJm*iOXE>aRr4C-uo6Rdk4dbeMqeJ(3Y z;w5=A>v8t(7aMH%y?Xi5V!UeN3Gi#Il7XZCsyHHWAuAl6&ojy3b(SghGq*thncbES zpLldj1pVONdT6#S3Sj}xtm+wEGW75~*XrwM>C#>gqH0D^QfdO!Pup2MO;iThHmfQt zk85{bBifw`?5cmrG}H^`J~g4Nb>!-bo*m~+92NiB-El~`R50D)6YHC*GMB_1Z*6!x zX4keyt#)4u-n-n~<`V9jW3uP;%-o-5L(W`3HW`EKUyMIa>fb>$M%4o{*f6_S`TjyC+AUZl_Ukmodt9UO z(*OldnSuSpiGxf`TB~+{Ul}4##|E85`i~=kA+CyXIJ(hK3Q4&UN?Bo;z`9vp)JK+KmXzBIalzO{@y?Jaei;* z4m%gzf?k935S;=OUfkQcpuLYn>Yi!)he408Y-`=XiX~Ax^CXKAsJ9K(1!a323g|xD z=T@rkqhrquAzRIc#7)~?&bu)as^LUy@-an}^XWM)_4j&r3u4lq@?g`lj+vjmwD&sg zy8c~lbJ>zM`7 zg_jNFx|fNUCG`G%ryX+tE>9At@+l=iX{2=qc~!DZde1S8+=0hu7pL@s)K?WjTc2E= zxFj{_$E?85nrTy`_e5*=?K^_4fGbvfCW#QW25%q{PfcCdQ$TSS-$be^Q+hvp>e)@F zPoBJ0?fv$H|5^G54kvc7Zlh<Ca=C`S zm0rsnYEqF!r&|DWjj!J6dCzku^ zA$X8#%j3|=)2HwGFp}w55-L|i@aAs3z9ZxfSjE0chM5h{p2%7pI+XtVncY4V$JFZU zDf!f!rQ;vZrDMxtOtJjH;P_qqTX3KVW+VwB1XT$bN342ItXS%X z0RedcZ6I&Mx>W>+eI^D#SwFPsT^dQp*!61QY~+co?wEQ4XgAy~FBtn?L6splE_mAU z<1EWvjZB1yCzT=8(PN(T&080Y8t{qvzhBLWmMb55bwA&+TGR{c7$VP2<4 zqNfH09lh&bb8M*^Q6WzI$##2YDs&MO!M0%p+*6=)iaY{i>~{vKuVVNeekCri>UTEx z9x)>RYg^^zui2^>+9X~sT(IxQ?B8S8k25#i9lG;a%DI@;|1?!?JLB5*%awo%JJ*^W zSNiYiqyOA$Tg=HT4mQ7Yk4}4Pd+T%b*WaVB{>qwY_tkbyRgk~k!b2m<7cPNuYqR`Wq-hzR?;%J1ZP%_H;jws??8Bj&HLeWYY}h@@&Agj?n!?rf zKeT3E{x!~M$LH1_;a?|c-d^>gt!wV@HyU^D1UVLe?!aF0c>&tpg!7$yuA=U$2$i-A z&f1UQqo{Shd9080@(YVu#<+Bbwyc0_+b9;|!m^&Omu4QEn*>PPVSNlxi>YAL;(it>-;@hFJmZ{yv=CW008OCJOdwdj+yJA zlK{RJ{f9AoM%11>CZ871Zx&?e3Z9C+caqTs%5?5<2sdVkyzFp-^s(O52 zPuNtfknY^Qs~dM6)eJ%J54mQ7_m4qeo?or?ZjC5d8`ZMmXZi4iBW=k%_?5%MC6t{6 zacyWW8tHxe)O+`S!x!eWpXHIS2p#H^muXrxw?jwliG#LSHRI41Z*Cd&x~TV|in48wi2qJbZo?|V+BuAxEA8HI@2c{mB2+kqm7@2(o3uZ(?XO0)3*fJYtDWlQLT>k|~!w~JPWc{}9#y%@%2qH#gvNpfQY-7wzF z{n0a}(2LZ5La+)Po{mPk8xM@yuRN(QFkY`Vy?r)e2**PyqTn<+`TTFJ1 zebY~#bnor$xT_jJ%V7bfr6wWtf%W81wFPPxV7w&ykr2aZ1aHq)HaLNUkC9P{$tX+3 zP#OMZ{pxkd8OZrbN%PyJC==VCJt{h9J`5i|P?50x(&SiaHjV&JbLXBudQ>v)k&diD zjoO|T6}^bhb0s6~XiTv2nkDGb7TT~xqUl{_=k8vjzPd8yR92Qsn)XIF9tdb#lC`^u z+BVFT&^~o~wM|cUaISa4#-=9ZCo*@AnSN=@To_`b#T5YJleRJ27g;8VbGn5v%7wRX zje6M#_r%Q=ryS;)?R2597ZpRCX zXdM`|;2f?ud;4QO_w0BQh7uVKVp2q!ZD@E4^R8aKC^n`#I2c#vQ=d>S(YopD>~+Xm ze_)Hz=+U`4m%uA4y}g6WnH+XOjYmdIPHra{(_=-4pI;F8>s5EvuYNyY^mK%5fh>Eg z+{rS=r(IN$&&jliNa^%IJq#3{Ki^I|xr_=~S`ZD8K3fFZGE`W0w&vwa475I>y;&t1 z+DH@xu2D^X)E$$;kQEF~{&Xs3a;9My=Trx5$Ds26-7;Alm7ukjp z2?;NWp5cMMmG?)}7W7iPl*Ys26~2t78@)Pz^C3gxT3_tT-4Gdg_)*3E?x(4J3=RMJ zUZmtXHD3065fKBQv#?RCCsfKD=sK6n7jIi^-Ys-?p0o;XL^)Ew2BC1_y(k!y?$y&d zIJ@B1yCGTfKP_({Sp3e+~ikJT}Qp}Q;uQ<=`q_SQjnjg3nG{@XTf zO3cmeDY$uho(pVQC*+~=XG;bR?(v$_peUzJQxOs_T<`-MW+>TLGn8^*d}60Dvzjl@ zW{QN~wXdI_$fa@VyUJlgQS$ilTIr|f&xQd>un@Y@eS)HAKs6OxiFv!aa5NyOL^$d4 zE@Ncl54=3qB+hO5rhJ5rq7&bAjn;09D*>xP|cF`Ip%d1%KYB>kZ@bYc{y|<(W%MTgGUOr@)=i ze|9kh%-&C*mLq8-IOTX)R*uET2g@bQf+OBfTxfC%inNhDi-+~sD557X}GC?@d)LzIld%xPO#~f)@ z`7#9w!PLy`$7D0jR*QhWvro>w%Cp)^VMqXZlI^3n(SimvGwz!eLgU_kNF?NNsUG~j z;yNJm&OtXjgxV#@^|RJOI+&K8jtik^r(Tt5oKFpj3=e(NbhCqj_!N4zV?hY}S?IVt z49!GhOuLK&C*jxtfbXlPCnfmPzl08ce*gYz1Y%-iTO3lrWgRF}?f;WcMQ6Uf!0kmm z9hIr<6{WS?g`H87Wg2RchYs}|xfl+Z3dzlbom{wq!3NycTIc)X<5rs_)`kNxSfl91 zkLEi<5(~nBSF=%oR3TB)Gmi{r-yXV;&@M(w(rOD|x&+8i57#XlpIGs7=L4EH4N)@g z-AOG%Fg7`zK3nYl03fI=8&rJf4l6$PjNZM*Nu2L%o8y(L>pYITZtO(!dP$cvNTkQ<3zE$>t0pR3sJ3@^b3*=>So z12CFl>h$s_J_BAWY1sAHg{+E5j%PxrH*Uf2QnX7$jV0ctcr$F+d9=fX$_f#!1^hxl zhzoTXv3k}b7M?)6DazK4vfMlc!wsH$uH%cgQ{Y_w_L*@P_d=^&UHi4Ty!90FrqIMbkD($@7){1>Jy|kxw(g^R1x9f#oXuvz_y8cr9?3_D{so;3(nMSAH;xb&HMN4UuN=ZSmhwN9&kI8x8P!e<|Y+6 ztKuI0cHxA{YeeDPAY)+UP<34T0!^iid?)!5W`^)Plv=hHXL=>}hE&JLk~I zwURe!(s8w!Ss)S?q`!)eG^#ao3>F@B$X6LD`y1BA0plqZ6?D4e=C4)~EHHQ=am8A* z8B#FsrJL$0#39Ug@}YJ@+U-@CVMOM}9I zN=F<>F(|eHF0D>Q1;=~Bn>2LpW+@24+9)Tr6~j&)-JGGo6@y*C0Hl5YlfkwtiNv5H z@;F?hf628aV7WeX147hp<@jS&u*>G4$ujksDZ>{tbXHhgoJJV7vS(m-8FfL=m?LG`fE#Z|<_XZMLGiS)KMEKq2;2}Mj}{zCSzIy#(RV^J(!!3h zKE~uOjYyR4O?(>vk|^6Hg;Z5d>;9>Hf#@N{=>*6NbrI;^OXAcM6oxNfzXpOx=RZb2 zN6B>OSX1539Xrn8M#T3b!aWv;%Y-*-Tn)f5)>iJhrh?mzoqY*dx7mnu;lc_?Ei{mS zhfEyEmWL(_@r9;5a}7}vY6G1tW!$Zf-@`Lk#=uHuSM0{a2ZyQ~ju@f#*+ubn@Nd7t zyRWL|^0Nk;YkCIwx3yjYgMeXZg%QdW(hBm|j)(q7;)Pu;{7w$IzJTTmL?-n_DUt4eiij3-HC=`NLR)96Hnc{$l)-?8IE@Tz}*C z?eS@+4NfS@mq3H1I-P(J*r$HjiRmZg5cH&2{Hvy}zE#D9x5fw>1S58p@LK~I@MyPP zurWK>la)U(i{d7x;H0Ei95-rcmp=nyu16-~AoifUR(A-j2bGm6M~-Z4S0gK}?8Hdu zkP+=`1fk0o>9&7G>PqDMWTRwk&$` zME$dc;(3D8nxCI$8*dRyZz6^ufTQTq0|@p?J)ENUxbQq-WQ*0am06shxz;w%jYT`Rr& zMe!l-A_N@&@9$JRiZup{3qhHNDBm}%zoB8L&o17ZmZ;m0OiW!IVcQS6-kfZ2cg&($ zq#6()uEeUng5e(`Z0cqn0clB{QKOhx!kQzQ>B7t1y8sj!LL=4*;@%wyc&^^Tb9=Ou zx@i-*j;0xZz+d2WLIU6=fb-&C5|XjG!`aWHLX0Ot;Bg33ocdL5)b=>VH%3&zUxJ`yC@1K`DtatXEetGN%gakRbLK9?w{z!eos=J6 z+&L2<05bh-9P>H2LqDqHO&!jZ>WhPtd^{g-ZuQdUBDgTQp#p+RpPkZ=8!K%z0boWuoarGfCrc%{@)ot^IAQ8 zL^)F)^}xFI>%-R$l-!6*2A%3RZ{FaMgypXu-U94Eley)lTULa#MwFj?sh{XwMhA)d zm|kr@6^&tnw2JJ*?#HL5Lf&5E4@Qi4oI8^J0FSG+KWw&1LM`5J^k@bU%=ll@UxuE0 z(t_g5CZ^4xxxno_G4tom8~i7Ze$jezXk|tOi^{E{{ zd2)Hg`f-X8wbcA6xu*Nab?7IXh`hL>UYr49<6U)+j<++KYhZDU#B1<&^}5B2>q|%P zA_qEbXxy^Z{y5^Ha==EnE?w3^5&QkDsXv@3DX_GI5OkSt5aQ(V&fOWHg&l7Io`OE? zCr@sxKYmzTrNch~tVJaZm)>X!%Pj_68)y^yo)#WHbypoi&1f{KtOwMKB z(3c3UZ5bEAKC`c^+Z7#c)UV$XqS1>{fq9wP5LsKdvNNfgCm4CZag7_p#(Q_{Sczy5 z)WNi|5tdMN-?Z<}gwLIRcD zG4KOotUG@U21{D)HHz|6EluDXUX!)o3hS2mZnx~+34C@w8#h(tObkdFVD%l>>sbBG zK#u!px!c=_^FJ4dKX5_`r7%4(x{HEBIZP&o6oIfyLLQeGVJy#5)#Jw_ns3Km-_!(N z<=DOK(QdFf!4q6uBP$3(zg=8!IfsxN+34sgxvo{#0IzFnH%%X@TnT!`=nf^7_$l@= zR85to8>_0R>E+=uq-;VKlj_5UvCr?j$cy;gCOFtsdao5bQT}(?qEG_LbXi)6Mg_Z{TM-iO_uXon@AQ+_J zMY`RqX!zj#glYS0?JX=s-{oc}8OlyF4coIM=S;D4;xbds_q^?=AKC)GDS!U_3WPV< z9QM2UNJ-~xZ{>n#LwAkFnB>6j-M$RY0OpCq&^2uMdG+SakQS>_tFd%b58|p^<*qF? z?s|=%0(TmXmZ0~EXA&)1U|`#0r2o;Q12Q)6j{#(Ym>A7dwVSEDr4Yu0 zu2ntWXqW?Wl~4ilH1=sPp(anSo|h6SL;_#|Yvr?Ql|oRK+~AE|v(xC0*n+g^$qH$B z-p7Ctu=$-kw2AU=-Rksr9E>6fhz#@0BLIw)5T{FU|6y|gWeLCFtQVUX1^^}a+$ou!Kxv%bf7^=jkqGwgjiUwkxhagI3J#a4QReV}h-wx%IH!){NJ z@YtPvO^nVZCB4Ut38KWqEDqxKI1Lz(5&pQvpN^H9{`05#d;gi2uOC9wegElG7VB+2 zzuHo;aZu}_8;gb;vj~{$1yuaF5vP zoXYOJ=zsRf;jo+vt4Zp+P3J8&buj>jNej}a8?JWh~17oiuN}y}c1y zbb7e9ZJ35j$K9_X%XT(5ulwNWj8N*zm4o9Gzkfb?@uFH!<0o$-jU%F>S}~Ho-wJ&&;MCX4H-o1tm0I^?fuhqx_4g?b{zzRP;48#8mMR%|<}8 zi}w;wS8eQ!60-Jtp|a^u7N=1?YgTQKjv-eWW`fkjmIU4~$HRRtbtAEeSAXa9N-$o| zGF%-54j6jow!gcX?O9t%kYvD2PQd4FEeT!B>4B}3Y;fZ4)2(zf%L`Qw{3>2h0mAY% z-rW@^ZHE4%I%#9+f8BE>N3_j>3q>CZBH?RL$GA$+7)4AlcP^D*@oXK-%&e%YYNaqp zOm#74H+NwOp#h^47Bf-H8|dk=_6U)308>%du8rL-6P#f<54cHfOJvwEiX{*KIxXL7 z)E0WB$SaH)a}^-TV+tnZz~tO2Dv}ZWztl}+CjFrOo9b#EMOg&L3@jm={DNLBEiLh0 zJrS1@(PcVJ1hX*STY4Af=MSQXhE)eBC<)-KuP+_-MMBhThpWAz10C6YAWbq-KAGZo zIBOEQb>_}jBUtu(!)DejjaS>EC;SWcp=o)(2+vXrd^^4H`&uN?@t%`vto1QYI99Eq zqu=4foc`bB9-3elsn_B(ksft;HQsi&ikQ1f;gN|Aa~^qY=MtFf^?^A_yOs zTO9I{GsWMA@udX;wvBv41-+0l^}~ICP-u}OAqXS$9S`4U?1@3kDY;GA?6z9AV1YAk zpKkdfQ0UREMorH(RYXdl8dPSSO@Se7=OT2tPaFcf#BHKDRk4{nuo6W=T}ox?t1+JcfXttYFYZN zcaKbZtg~EP@*2TFQx`3Q<}!+E)XvtHm6PitE4?%MnlnV#aULK&JxBVsHAVeP3-8>! z$KLO`Sc_qjuxL?)pXrCBQ>W~yKM?YCzTtD^^y#Z8qft5LNbYsxap3dN^pt%qOE9Bo z;}O`Wu4P1!Wl9HR&lSWOs(9yvrC=evoeADAp1X@KDIghST|DwTXADZjE)nwi5CB&Y zXmp9l5SFY)RpHvaLN0Rgt-gxBg0d5xwF(Q(wX9d6u7f&=qXn4t zly({#4Rq&pLB)P>agRxBKPAeODa$YOw%OUis1bDsL2#H-NIeBw%1VbYHNqdrUZ$|%6pzh1Ua7UqX3ff#>)7pyClA#n z^HR#@TKO)2=pjmdlFD1h5u|~E>%=`!!Q!D$Fwf-+lAIlqZg1Jn=alse9#<*-Z22lG z-jpB4T`-%4eq2#C5_CZ&t?0Xl>%eaM@rGWfjvp_1@ZdXa3bWogfg;gbu)J-|&~CJ; z53ma6U+0Rw8pVs?Slh|SAZFPcAHM;-^RFJ4V|YCAxmoW%^9UVFm-Z)~wY8r%#L76R zt!20cMTdZuy2ue2R-v^RJ=(vh;1KJ6dE4+WMUT}E+B4LJn{G->j02@Xdcrl=g2!*e zm3|*f{dOcVQMr3}HU{glRf@1l@B{**+o|r+{aOG!%bm~p#%&;LqD4#mXaYM(DHRYf z5+2}LoTOS(Q57ID)CyKNN^V1TCQX5EHiJ05qMQoFa?VN>V|Q zw9=0R`1qbjT}@q2^AM#zrA|~)->|b43VObi9P2&UV-%9Gu_BCq*uAp{LjJ z=#iSHGS9txci4zz2m&fiY*;U+rM*XpQyt5QHL6%vjG>P%Fhhv`4(Wl~xK7xDcF%`A zqRv=`I0oiv`SK{0$=n3$Up4QW4|+}7ZDnO;{P~f|CkA#oXK5HtZPX=ilw#;KAiU^G zFzLfqCBdR?bvaBWN50@G?R9`RQTE-ZPp}NSUS+p$Cr?c!*R~ucKRR>Q=nTFM`bU|? z47Xq^DBK!4OV_{0DS%L;7i-PfhUz_qY%u4KAHL*OtxGSXlTtI(g0-x?=|z?TT*eRA)t|hv8RTP zVP#4XBKw@BTydytI{Vbw6%~Up?rGde$qF8BCmO(hi(jwYwX4_i<;nNzQ6ZslBS=Nx@U-_E_lPcDh$ldB(voPfH;zwq*IUCK zFtyq{CMb~7nA#e!OxA?Q zpaUs%1k^3rYnz?}#0d_TP?yNa8=~fqq(l=jA(}+CL{e0}Tn0SzH;T3POux+*6(=DN zo(Ys7zP^4P6V%F7;){w3wn)~$e(em)H*==A&SLbi4ebvc4TB&;^=HI&?dlfdgKxu(Av zSFHehuV1$g>$Xc9e*1Pi;Iq@CkrBfmlO5dw!HqYKu5$6Q4UXQw-&|iGhi?D%>kEwD zmHJzX>ta@{DBWaZs5X&$MHpvwFYTrGg#(VJ}I$+z{3exFP5I`e@Y70 z`a3uV{CF}ajcp!v{0#&_TGAb_GD0*xW?uZlCFF+N{{rEQ zc6JLPZG@Q_asfz5KxOc--aQ&!hs)j0vjZalL>4+uXM1J@mc&CLd2q8U|3J5FO>; zkoWAFrU@>FoIpryHC5HU`}SQMWG1))z1j4}f8jFlIbZbHCMDnwtwuj};ksPx<)H}q z2rt^!*s23d`6zwn9_RrNandCdFy{eJlHsMa^v~PzV~f?E2>VVaX)P=-iHvlmH5h}gQ7TQp>p zakurntMjYHNK|HqP`Fx}YF8)u_UC&&mG z@sqxp@-ypZ?weUjJClEf7IOp*8x(?ACPIN=l2qHH`(R^niOa%;y{p`9eTlk!dEz*S zTcl3wdDQ7V=)_(3nEh*8ClovQeE{)>>P{G63~82^C%oELi&IFT z=V49F#I|kF0BJ1jAORmy8ZmetNQ1+HyvK;s@mEYU4dpAE=y4NZDhq+Kj)$-M*2mU?WFgW{jMU5a_qz| zY`4~NdMoQD&=bOdBcZt_o07|T_;4efmht*Qrs&f*i`|D%e|_1zQByXH(v)rlhrUBh z^0h^f&SVc8dwVnhl$0;d%-?qYysxOf@X=}8#!{~BH}2KYXF8dj%r+xS$UEZCNOoJX z`2qg-luM@MXO{*`{Vvl_&Scs6dht@fgks|ybKt*o^l2;X_&e#U(W8f?$7&1 zX^mLMBbcHyK2NOt-PH6_rCfReq0-Yc^h?nU3bSS0Sh$z2a!i=EE7|TAZ`v&gW8=_? zj`cVKirWcD$0QQK3lAn&7h2Z8WLT;aqTM@B>;m%NTP+$e`Z|HeYnv8yPRDe$*piZx zo7b;zpWT~(L{-IUirfxiLst`YnK;tuZa5`A3zBbiUzkLdMi~uuBdTIS!@hu*s`8G- zHH;f@kkN6Fv~0I-`xtZGlWqw4ojVnCy^h1m&C$|2OytZNxBr-_%hgMg?oLSF+5ZDF zg28>+xZsymgEVht^cyDL}TU#8fVYKjN^#i$QVCvSQ#Gog;p z#?c$KvW@`Iio5XC)z{I{hdJxX z!r|x74>E1``K^H(NWphnUIiINLf~imx4$NrGEZ-?G|b$Qxnua$&_elh{-J!Oba6|# z5{QnSecy|VcjG6G1XOHVMMS8w7~RVcyquK= z@&mLtL@ioboiH_Z!)vdYL3bZK*tKup60DsnO*_ODp`ohq`}8+OtfN#RS_#5I`|$3a z(4$$MSh_PVZd}q-N~-X{IPOBhMYkPD6Ernlr%!J$Xe-L1ww}ST3?DmO`8p1Pvu4g@ zz6MfVwjQJl%zv0Rl9c?TQkaVcQ3DJ#4L7uAr_D@ zN8u$>*-q5At&NNRLuT84$&x5T5z6o;FG_$=zX8gcTnV;gVRX%sFZwBm(*pe6ZKC7^ z3u4&x>BG50Sme)}yxoI7Jd=ECJ$ruo`0-#|92=hIFIeCUcSuO(ylY;%e7Sd?@9Emj zG~754Y-6N}`hHcJ8>aAuSX^|fT*ev%ApxOGgyUp;;28pO?L>*2N%Grw6(?a=40j;4D7z~J`6eiRHF82TVtWQsA6Ig^#O0VTl9tPy)* z=PA!_9U3KCnR$0T1kXuB4CAxb_UL3Max-v%gNldUXz*nwS`asFLUi*$P$K%SS;J_R zM$Iu|Fd1Vy5yZj4luv%x#t}SSAV23XpS~flZ`VP zHXGAhXfmUFeA(l6o4a$aY14{fyO3~RcU~05lnUy1dL?KYm~If4TU@%-yy9w$$+z_e zgKhq8NXBhWPF_G9q}c@o?dTx0YodHqk%TDN0YPnxj3hx5`U$#l+AYwOFD3U;vINu? zRw!)0ro%cIViqqN6^zriuQFjho>;>*{r(+9p$t)IX!wzt>;@exT}qE3zIXq^{|>9{ zS!l=Jn}=_LTh*Lf%s2iot$rB@52tA3;C_l>VFLNl8*UyqkeEWB*cvd?4F}crvv;e) zLHpAf0V&GrRjrPTh4H1-Mj$19Dw$=oO0g_|7nf-hd}(vG{VETl4Tn{k*@3L4Qdz~t zL)H5&%OA|6F9>D_xtc#<1M)ta0KBWOP|sFWRG>emi&{9u#`A-_hmT+Z8u#-XzQu}A z@5G}uWXRJD_W}k2_$^kKKJs0R^i(&G;~au}c|p(tEgDSHy?7m7g<0e9tAAWI_$`!RNfq&b_$CrI-i|MK4==D$rNyKJkG`O|(?;w2*hN-c0Oozas za|1GR8n6sAYQ^O6+qc!lyD69*89Bo$hUN->yf#Vm{BV<;4Ug>X{DU@OMlWOIn6XDN z@F~50dpQ`K$DlHw5D>X*YS>$88*6LK#5c4~z;a_6hadm`l({!NhwTgSG3_>CMw5qV zECXX_p)D;VQ{CgHKspozikPx@il>T^BaGd(mW09=&*)WwsADZFpsFnG`Usx|gFO^5 zUA7D@oSsNkX7}>+_(cP5ThsN=dn9bJnpYluNvY{brs??=KfK3R-*NNsIZxwb71Kn; z-RQKSBY3HzuwUbri>8`5d$dt&j+eO|tNc0%Zhu~nTt|yaTELHcaAtX9+S#*Tq5gx@ zTq)u%UtW()m(QSR#i)*4StgU-<%%7z^0>4j-9FO(eQBtUkCP2-c_#&8w-1uUu*2ov@&!n+ci^-MuENUFPxQ?N2`UOp++6p=e;#F(u{NvAYWg z+%75UuA)-3{N@ptf*UtZ&^O+GEp`fqoDa$326vaw$fhvf4z8qfrgbvl;;r+=^9Gzc zb_`8ZI&B@OTLWKn3A#AXF8dkL=`JlzxpYY+I8ceioqa9usHl@uu-B?Nx(FG3&oP0K zy>9BpN!Pl#vMr9qj*#J|#hc(Q^wKjrghaYPiGrKIrmQ(O6 zDpPfv)XwC=BHhGStjgUmi(Weyj}te8Z9VW1RX*Ft5bPql?3|HW?!svFFcXt*pL{mk z_N&C=+;yamjyo;Trs7T7wX=Ctcr*B0$GX?AXS%o$1%VHk2;ELx!9U3u^~9q$d@%t%G@!-@;E%?1Jw)g@tp#=yU^_ID1yQjRbhQJm|xgiJZx~ z#*Nm;?^Qgpb7laBXIfxrd<)YN4B%GxPzq5{Q&S^NlG!2Nz|?grT3SX-p*9CX?m!cv zm{Ze06$e0O_GJ8vF@rTJ6sRsI{HjT0Nz!8aa&+gGEHRtYPYyLL3@NmBa&(b|jiVZs zR3-%3f`toNJxV7EDB>`CHk9=HMKQl-OE;O%Yr8pF`MTJXO-uwqB-7jR8-SsDFW}Pj zS+hEJ-0s1{FAnZ)m74$vD0`wCH6KWQ`pA(%Wt(7vs3Uk4zpa_f_Ca`AsQ!r4;jD6J zYY(tEYVZznVHsLLF3^T2`~|E+Wy(yZl}xFh&-X5W^r4Ey(|J1YC8sCvPpy)=Ds z6Ff=a$jW*^BZ=nACwZ0wLJ`~8K*&Qtz&vf7t4EtZ)#+XEo{osJ!KLPAW)4MDyJ5~S zkP@?BziTG%$A_KfXWYFjB4_#)(Ob4CF~4AbRLgig5g99aQZED`+QmU;MTNisF5p={ zxj0HqCSbyyan;AzqyZMU z8Wk=FWI!<7`6=D%;gB z8%71PW|KttKTbw{TroQzQX@rH0Yf4+=RnYDBe+3oUWb3w?>{1*rR{{5Soy#X`M)Db4Z z;*x?Q)4y#-@A~~+A^~68>8mCoMb#+4BW)xV8+tGIki-8ax38-6lN~bOWkxA>f-`iP{*%Ka=XOLO~0mU7cfI`@3B-I#@e&t{?TH5yQ z-ef9s0*5P$GOLbMM_TK~7XMT4z{u4R6l1U(yc)lJL3)w@G-K*56!=`Gx2w%)7H+i2 zJO2x7-(~b=nX@p{iM0lq61c$3i2RYx_<4KheY(Op+h6I{DhrEXltx~4)zz7QwdfzU zrXmiuF`h4ak`HjrRzzr?3(ACWhVX@0ASL9jyIfnLy6jtl?DA%*drrll2E^qv5sFzU zy$it7;n-NwgWkgGd9=A)k ze}7^p^~%+{jX!>LmY3(2GOwe#&T`0a4@=mFyow4X!5izKL8ETgK{JH{m6(Zz6~_;$=MuObWJ%PYNtdS z>Ax@ZJ=IiD+;C-ix|_1Lj&Mrwdk11`Y0gI^OJ`ao^ITNUp!Pw($&> zmUgeb9YpEIWg{;!n8pZ>z88*0`)H?P8c+7BZ6qx6JFXLnv}E9=P8Dl|JO{_4!Qe&t zV6E@JpD9u7TJGIj#T+K+2vVcu(AfV+71~KT0Wz2>lzMc_ZY;re0zsC+AU6i%9Y3%35Ia|JSXGT-l2f_;^dH&29ihEYqB!aFEAJ#g5 zXIX}?blmRUM2;}OuB8clmMeIfIAtwZL>7K}aTUi5yT*RDmsHVh42ZH^+|L7};Gu9H zAWldCz#P+nc>CTgFE24am5JveukE$B4|8M$oK{=&J110#o6$ry9;Jx)lix@z=|9o( z6_-D_-y}QImexR8`y3XSF=NkyZuqJ%ON86sp#r6fv{m4wQy zG#Emr2&s%E4Tgkzjg5*Dp%g07_j|m1@9)_A+y8v)INmX>^{nT)@9Vy<^E%J-3KPG| zPS1ppOLz@1&4L;Vo&>`*ps6~RlcPc5#t(y*rfL{1h=qwO_L2u6-GX(AdAOaX@0`!a z=i1w2A+P?6+x{Fxf)gBi0=*-jj=z8Yq~YFrZ7m4bEMhWxx$h>=96yJn50DecSPY^P z+P5C2#RmGZGN{*SycqRIf}D84>nC>C(nMz@yNqkm zHr4D|zM84C9R&Z+*J6$w84PF9mG%R+E-(Q=FiesX6B%FL zxb_!~4LN~^)x8eA0*;1)`rg}ezYFZEwgO`ZLZ`JzKHa^p4LEl4r1G!fC zY9C&~BM?Nd=I&AFQSpq?0*nMJ3TYb`nJ9jBtX%9T10ni&d&9FSRsUDDH%~qN$f7lp z$H6~_pRE{&*FDJIF?ybC`-N=6O#FaeSJLsO_g}Esm9z1%) z{`t6o-#`%^je&tGvO*F>Rx};ed{ppF|D( zRGGxP23U?ACxMqUAUbJUNeHoQ0#V1XV=e{ zP!26W<33_&U~tcmdWO-$Vjj~BWjGj31a1Om$L`!Ie(*qq*C7w(=UfCmXuB;<^@DIBICI8-G_tc< zzyn}en1duyq0wst=YqAea@DFOt4BdWqm6@hXv(SS94WpuB2A+)GjPTs2yZdd=!Jj% zXaYj+!LAEaH?xY%Cx>JKlp3!|$PIeotd@ke2(4ZcXtp~A66b4m1l_-%KYlQXjTvme zu`7OMNg%twRR2ejmX=l;^W8{=Nj!HC=y^f?UVkU|HrC}UbnY;bkCgN0N*X+fb-mWD zBQ);Ww-4wyoc)b3tn_y3?;nv=4Yx+o&4Dlb9MS|9Tg*k`xVInhAIM;TPPGD_H*e)! z;SqVk%Ah-#@p|$w`ic3mJyU5QWM*Ut0&v#pXDh%)`X_!IkiheZ>Nxf~i$ll_*?r*zME?Qi;>ksDYs#Csyc$PWHdtKMcl8XYu7GUpOnPH(D z*cW1yP+Sc6&1vU6n6a9gi&`ddddP{x5n{bZN}?@pTyEgJr+qrovvNhm3zd6@_biGoRK3$^R zs6vl6V-=g{|DRp0k>$BAt##8i6q6bbPVR5;;=Zzy(n?p?xMwRihq?j@{S5usK`T}n zE|-c*_LUL7)fXT)!h~dQBn1`3Z&|mQ{riR|Qi9v`?K*ek!o;dBW+LwfDmaBFXbayA zYQqSY0?R|bc28Pb8Z4qiL_UefHf@m!T<5XU<<-rld6Y z(csztj2&}sLPG0~1CLEV&|6C@&*#CfZ1X3Z>n3T*rnd4Py~Vb_hck`1>l^fc=KPd& zxZJ+h*3Rz5)2CY9%p1fbohLeBb1iLa3Ql(I%Cd}LWK5*;%}Hl^RoS?^FQ=Nr#2Qu6 zxN~P~)LXaS#)%A{f_^$gJXQw#4hl=sycBrzeb-$#RQw)iiicEA_%viG5UlRm?oXg8PI4Gcg0-c^kF1OM zL*Ex7-(oq_BbYi&&N|FK z!T-@i44&6Dep}XV_VHGNKOhe!5{=lp6}mPm42z-oPOT^#bK`8i1>`R7Ksc8KtVX88ISm7e2vNRZpsIQKZ3q~2${L*q^#ZEkU1O3~#`E?-R^`VzI>_T`bo`XUEkaTd-rO;i)jq?XAK-$*;l*K213#O2M-$2 z2!j(pICa=s4G=t(tdb zDev-uDa4S)F9%F)flby2qeYXTuuo;cmtc@A!G1+EH)%7E6|$}9w`u0J1}{HI!{C&h zFyp-gNT=zZ9=Th;eSesQWmjKyI}$MKL~|Ez9H_60%V(Y@GJdbcLnxe6zz25gUO3c) zLV02VCv5HfIuaF_5WMaOzX6J*cj8l-tA1lsn7`(>C0@-M{Kzlo% zt)s17b(m4$;?&Rqe5I8uMbZWFiW|v7wF=XC%tGx1Tf%04|IbA?4?o?qdc7_s`-Ve< ze2m7jI@XDz-~$IJOgX*54eDq-FhoJQgzO1)Z*N$Il7s_u@vyyqV@Ovx@PU2-C>h0& zgk;Ch;ol)zDz+T}9r{lWcLcEXtePIRKQeNcaz;r!Gseg*iD8KGV58}^tOXBqkAQZG zt{C?zta#s1YYU~HKikGWYbUQNIzlJK$kEEBUgHG~(jr$$QBkj1lV?FyTw>YHVC;l1**?^1|G0yMAM}@hTk!-NmmKUH27> z4-U^YzmZ$q+iiPWUF}N?M6Pu^^G)>=tgVAey07m;7l@}XHpWR&Mc0*<4Y~_w&00?; zB6^oIK-@*SCYxqC|f^xkp! z)32=+&zRJ>#>M68l`B9SHRIlq^a4&4Vtt)zyE>4@M)$if2{TKqAMo$oI)QiZJyVY% zT2Je&emF^7%&OCKchkJwu>)D;Q%K#U4c{rm(5Lao4GvSfX4aL`x(B1e-~d*9^DN3? zC?@mf^?aoLndpi-08QzbQ`5O)Gs%L10thKQ6P_*h7-bvu2~>>{ECFt)D!g59A5e zyTPTE=Bw)-6qIrFETHg3bTj|e4;2jnclIydCo&m9y}1fAqb!&*f9aAdt+qfZ=;eoq z)TP`}paWVe(DmSYeA(LF#yge)WNeL`NaNu|QZ$KZ9KS5y+%%ik7-8t^yJFiV4#kug zJ92Ord^?#->^%SU3;4#CctAYRQm6A>$ zzTLK3oNILcId$et1H`p<+kgmIvFAzsSvbsLW{Dz!QW~Kglwaiu*vztJ=OpFsXz;;v zCq3%Pb#|^iKk)S;4s(-fY(AhS{Qg3XVP?<#zG(Uw0T@OAk0>k066*y#vvDm*U$$d4 zy|Fj29bdg#v3&V<4`dAikH|*f0;WcDen&Cv?n55YEAI5^u5Z+Nt`&}5e)U_sW=%$a zG!$jFbHJWoZCnGpm$W^l88w(&BR7C7uIn@n_?PZ-y>~9%Fy>29-zquNs~h&)#nCmd znG

$b`D^%SDjD+JZwG;%Fzh_V*5K?{{nMhH^Yv1@_XY*8^>Wuw@#obSHim!s7>v zI*ny}F*eXQ@t9df8PyZWld)#Wah|n(y074mcOH+wR-5=%ZJI$U-{4Sj-5X8ZkCC&d zO|t=2)l(%_rtdm7b1>d>cto`FhZ+-e+CbCpLb}K~k2hGgUN`r8F!)B#knLg9XU*!@ zW*OD*{p-ftY^~eIo;?fYveOB9_Yl6On;i|PTy5zwWe<0jbh~W`LIOrp#1bO({%n}h zPPnk~W%ugC;jfArEpGmGOKI{f76t^!hL12(@u&C*6@!bV!WkDh%0vYg(%Bv_OAn8J zH~*F;)al|B_iYUgSA!Ehm+OWWb)~s|v+!K;1GvvML(ACA%}J-MpE3U;^^_A3(w!|W z#rw06LphB&C~|Ao9G3MWN4C;A?>=VcOhbCgVO|}7Od2qNl@7Mo90fo}sRH`J-%&uf)_0WVSe`O zFb4KAbttGHGX}mBf*iLjp^Bpwm%HAj`SD2bI(9!(%7*-6)crK}dCst#>?PulSQ#;quVzj2 zlyp%+8q)K5+;d4vc0qv;pgt=S>$%>CI{0zPkN-G-f2C^;u@Yp?({z-c zo*TB$MK7n$n|CBEY|78)B`ws4)b|+BF?*Rj!m;)rXDn>jvcJ$^mFJfKO&Zr}9n5T+Hb$<6>^SPTgWo2aW{22wRuPWa>KT2=V=iTylC*xz> zE(>?NI5P@*Dkr@__I?Es?|k{xvAaS;*HVEl3>!_2^E&HR1 zPvo8w9Wck-@b9wg3n<*ou3q#1I?oAj);E`(f-YV93et;il){h9cgn5lX;d~eOS%g? zeydg2>eu8%`hTcogB8p7`6JF!6UWuxPQx{U-{35VEYC^5fgTWh1&S92qMiR3sczcN z^AkZ9%OH?1&;7%HhlzR6ylXof1K4u(VbHTNn@Se8{d)&&Lo-~JH|=1_mIZbLjfD4C zTM!kK1?u_wHFB7Meq_MtS}s?92J@$J3n{0_R7G5G&8{{#qWEahQ_FYdJRbYhi4%89 zkJp$7eTXL0CLt-Uhr-ukkbFg8Ka%CBG>v=4qcA|v9RbfxNMj5(+1_NeQ04fGE1=;U zv{)Zy1Y4K5d$!7>uC9HPmlxo3>}zk*z0y(!?cu2OniGhWmLl=!b;hu@chm8Q3)i|* z@ydXrY$oAy7{jt)eluuM$nM<&J3*Xk0>;jSiw0_L+^o#VQDBUr&|zZ4Re;0PMmd31 z9W{Zl*ZAw@djwNUoDUY~AQ#9A(*z4#UNM>5O%^En`DJCjc>cTyp&fR|IW#pX@0bg6F`ROyH5fWnpm1mCFWXbL6p$~CK3`(UeC{dxAhc};K*8HLA` z^NkD(2T-0fYnJVlDFhTmOD>BSYyVLus;`pPo<&uqqqFstE=4ogYN5EXJ_k%3Z)287~jj;#4kMk5MB{dX`vRqd4ZrjVq330 z^(zCmZCjM;*I5WC8nw3J?=CQ0MlC>^)Zegzkheo$c6bLpo#86tp07Oey}q7|s;jDM z3f0J#mfwe^#}jX!cp&dOMY8rtX~Nn!fxAxAjfY-Yy|mx$vF;%9o2jTUr2AP{2h$=F zbCBBSd|9qOIE;w+b!$;AtB8u1FThcSf&g0DKe)KGXhTst5*bN>L;q_nR1O?R=tehR zbOB>-s39j_e6~LpJJnYg&Yf$MRkMVkQczN|XwDo|bo;LQETo@FodoDf=iBFPdLXUh zT)75+*hW(Wz(mADNaabTBD<8w;zseIl#HNhHLT$ zRIPNL&&0=n?wlH3oC88c2FuHOx9VM2hZ`cf4WCO2HPCc%L~cn%O$gc7m7GjUgogD+ z=;D??KGF>us;cU%BIWmdN|~9w&1e>@rOrq2TBr)BHEP+&0y0_c|@(7sI@3^lGMuEPo9*7A)& zNg$sMB|G~IrQK}jUpRB-K4dc3unX3$`aBW|4RnCZJPh^+!@WgjV?#tp%bo4Vc=(@#FEx zJ;ws4^91pMfKQHq(P%k{B)J+Aa6;w)+an@sf*I>uCooE4lHsQM4>7~9eI@^+iQGxd z!%Ef8keAv?r2!L90UdCAyEbCCxQp+3>sB+KufnnG*I$#(aR8^Jb51Hw8%@WEYs=5e zgY!-?alLl2*!fmbao!TYwVS|?%)D=mR+k(HP5oXzzE-Sj?Ct-3`I56P!rT3)2i_#9 zda2*lNBjBC8RfQdn|zaH-k_TKQDHZ&Z%(uhjHX&p%0z~6f39^uL&F1;)FI#>K{;I( zMmu$>o+@2;S?gK?Rq&`SkU^`1l6|b=G4EU(P;;-lk`fV9dm@&fNA5bpioe=mG-vi~ zuf(G_$622mdc(mwu$QT4h@YMJQ^V@D_MS6QPiXzMCl~WXbP1(cBugZW z<&3_;vL%QAksllqa%$>8*1Aq3C!a8N#^Sk_(f^YRFyo$+(f2{7v9WonSbW`Ud5AQw zC@(MF)uZ7Fbz6;XzZam}r~`S!{_)e*vMXXy1l?4!rtLyJ#7M?n0nxqrxoH9bsVu>NEKrd}19s zJK{myT~&3*wmh+mi+}sugcGl4#bsUZko)QMyAy*ROd-BmvW!?rBr!<7?F=4my3(x8 z_`yD2FFYDIxiN|PKZr@suSoWFfbkoNz)7z7>@fAL8yzM1O{8Z?>nkQaB3qF^{rG(w znp+g{84Fbc&|`DX=Z!tJK#YoNYK1RzadIydX>$vSSpIyt`x zB{|`wAj6u;BScRFm2i?44;2S`ZfT`>B8&BX}#{zke%CX~R7wdV;FxoEP{zjb!y}p9d^u?%nUaMds+p{~9eL ziK|9E^L~qeTJctTm`nhJW8f2f0|!TugX6n-bK{Bk(!4k&ogqWYlRNkA+Z<=va(Kcf z6QGuiP-g|!DG`^(MT}MKP!Kgq@xR)P2KriEZN4N!LQz1kX^GhaS|$K5psI8>s4!_= z*s19gM!?!2K}AeFWolv~A}9@_d=kK&W6A9DI7V3X{O2E@Z^ky4(Y?S8P-s@g{wZT8 z?0nEa6VLyCac$tBK}t%m->^{gGBf+(y+|9??ZxE+IuyrETl*kzmX_$ifvt|AzVB!0mqgUk4gof zegw%^DhadxrYJc*e)vKrBs1l+_fK_7yRm-hG0nA(N-sV*=&A0eC{SoiD4ZA4Wqe+1 zx7c6i)WAe;u5}dRNM9w}Jnc4Yl%}J1jM>RHnwcdfM;rSbw(#yNz-OMdV1d|(u()BU zAjVfMPagszc?Yyx&bG~*n159EU7Aw!xxi@oGlyLAPu{q(scD~w`#uHj3EMOJh-g+- zdk%`EOTTvEjCSkEab{jX735TBErsmdxg_k*q&x%96_(Ky{8z7f(CMo>E#5 z=2mFvu$=nU)TE0yPD#?9`W|h-+ibSDcl{Gkl7nw?*Ri`MY#K_g)I4&l?Bsg7Qw*TS zv#8nPwjpK&Wmrg!-PUfrl@&Db10@xEJmsrTqjj2w7{K7n5@Y-=yuAf~V3z&+XC%MK zv9;5DpX%XmG0(qTpPKLc%80t7GBQLj>uaoRs6_@NMtsgsJ>^F`+J{k;Ha6E{WLB}v z4X5`*!z(c|<`c;`D9x~e1IaO$9Xdi&&j-{>>?PYwLP|n2T(nsn&9JFSK#pYH3%onF zq1pZE)1NX(DFDcO;3gn&8?ox2)W_OYdT`$)$0-o}{e|WI@?%6C&mn1zwnayY1SVdH znhC3V9heYRkniPlKhfHHp&AgFL5-yLbLSDAzV$yl>6=I1D#a7I+ZLQ;?|5wZa%`fs*a@ZT_na9S`pj-NL6%W!b;t=5M?x^AR13{C+>wU9m$E5vHJprEzSm z2g-I%&~Gm?Ksm*!2F_134*7zOsfET|?H7fO4Xe}vPno?}p<+UKgTS>uXkt0YCwHpp z!wKU%H{n)Jw6Oue6aC~0dUw^}=7Ibm1AqT=epDc7CUFR`n-`(5Ju2*i-Wn0q2!Hwv zXJO|43oXV=Y>X#dx$^Pbbz|2N;D>N5Uk1MLl=zlVa%#mYd|ysu{5HeIVS)vgY~Qn| zWv<~tGav+lc_J5yj(ihq2;>(NgZ7(i8ed5%q_1rE3 zFYpjPJ4ynn8()oQk!5}UP2gQeNRpQ;IX{2Clw`D`E5}1`9@%=}KRdN-hPAU^(Em51 zh}%%OI|iSmnQDL%0;HFu07mCh{0Rl9MAxv^Bvr>~kQ|mIrq!5Dn@mHj9MIH4wOC=; zGsMbib_40@9Y_%3v>4j4OScCFA*a|(9YOX-+wa}{U{trmmm#pfC$~)~<#^%@#Hwr1;~JStdVeQS=~SL`0ws9E+yF*$wWHE?s(}VHnZ_q?)09IL-a@kfmCyp_dy^ zoGAZBeTIn$+(c$*byQGTekp3;>yN2l@UL5mU)9c?Fdm7yNz2}nzv6=I*2K1uoh|f~ z2t-gPD4`qxd?TVIVUyAcN#5rdIX%Ws*9n%%PL^C{fhzT}g0h+F^AoSqE?y^%;1Oq4CAcU2C!h70Y116EPZ%CIB|2`#4SwM zAaaw^kle_n@$BXNtzx51JhE^dG%h3r!9w}9D|z*p^P6|1q9zAXUAo2R7$b-Q%~2F^ z_jOHAw{E6Gu=&}dW~|0wv5O8Xt5$XW=bv{x&ISHxLB@@f0k=@lb9$7n7fIscOm0As zzPV2xKc*ODq{>}Qaxv(@(RQ4GDP)QQ?Ks8=D1B!;qf$?T-l8ikIMUBbE6IqmCx||U4+pjqHC)r?*DdI*P zKUsbT2Fe0t?iC#0%a#SsIlJ+P1%p)@c@Lr>pa>M4vhs41!=pERiK3;7u7k&q3wY** zNvr?g5=+%^#h#-_OL%IezLb<#7T;^9FvLnd&_k1$NosXbG_Is#P5YRx0^vKi_@PQBgIw zJRtBuT_93q-Zb|5{3ft6gCHle<;W1RRsnrLa>#wBk3a6JNW7)7B$r0kERz4*ue%wq zh}p-3`}aRU4}}q?X5s4{lxsMAxI0NSIdZo1Lr1-lE^lNlehF?65IHTClOzZwt~O{wFyA90zIvcG9|w*B7)&-O{2Uy!&X}K~ogmi3Y5q1z&Sg;7Qwr zBa2;Ms4(rO2~Pz%#JeAXDC{rXgTnCD2$#~kncQ{W2h>k4?Zu1kJ_|XW5J*FO!A~t| zRtr5okQ6d1c3O~p4@Nis=CGg)q&pQ`mz7^tPe5^|IS$`t=1c}5g?Ka)fJJc~VYAwy zTg0`sJdwyFM|OA+mUWr-ca^P#MarB5t-s37O0od zH65-l@0>cccU4b%h9r5tU>h(Zq1U0T;SiO{dyRi{ZCEf0*i`Wl*hVLxt)Or(0P`Z3 zV&RZXV86)hsn6Uj01|SV;i~c%wnP&hNb1<&6%Ub@LXBuIYIz_9Gz=SV6t6e}gN`=s z+I78{Fz4d@w}1Wx4y!SLb^rc~`+Kwz2Cy#>&}5zOnzOr?QK)?*GlKhcbaP_(=fFVe zVm(#FMgVyb5W*QK{&wbz*^djg&fp*Zao7GDm0Rnk0(yM(>b!5^c%BSRW0VA~{3(z? zRDZl&w56?RBwM|D+SIB31J3fVL>8WpfYz~Sl}6W~XMlF$Y^=yY`v$nZ6CHPDe{Cfu%|9+I&!S!Z9>5y817<@$r)< z|6O%6$72y6hRFh?v+5*1J?e-g#;;V=((E{BvmIWZBn%H7A0C%*a%8tQJ%7as)qULk2E}yt-JERR{4D+ux zk<1RXqd~b_bF@Y8-mL_>Dc6rGdN^UqE5nEt+C9yAztq9xYrv7EX>x!n#X8x|8+3~! z2Ag&`JFGZ}9C`ePnY+F-#pW?|iR8a&Ap(*)gvJo7@?>q7-h&1OAC=?S;yJL2vKpCj zeFVD-z%YUcnh2_b>d&9sE2yntP9T5o?OVoZhaWr$d}Hk${=W)QvAY&ULdhP0w*|ec z6)l{9Ibs2zZUD1^jv<!o){}+Hx`>A>6c)Uku8)Idc(~ z{A&n3v{?8mFtL1sLVx%8H`eEPD^F{rBlf9&o0&WItLwTj?IA-}uUH`wr3&D;SwpgE zpJ2~SYXo1)UysIg48-|8XaHAM##z9=;tsKbF=G(Y?)XnSHJv;U7>f(9bt}&?ss)A^6%_G+6{FrX1Fr;0-Nchb5p2YNImUU?$c4Bng(E|8Yl6K7hYsG zhCN+y36h<=0HjFL0jCZW4F|0q>PK+_=Ar>Kpp_RUEN=Za?EWZGpyTDG!3=UrGepMh zdE>)SE;EO3CJlicLN9^<{qj&hwI)R&Y0c{^WFTZ4%2Tee{@C+eR%lFHBS!9uy1Adj zcG6rCM}A`FLlK1tF#1A#$r)>d{7&7DneB{Hgg5%j96C+F@< zJFI5gV-o9rBfjNb!=ElKKQ-GPK27)Fi^yuevzdLyy&w#5llgrF9IS%)7dhQX9C*q9 z*?RjhWFdzMn}*w8ac0#O4R6PR;VOuIy$v(%@@6?2)j~4!>-DYWZrOZx+{dTW#0(J9 zwo$~m^j6&?v@HN)>!9RRPSt3yit)-9zx)PE+VjO}37pV0qk3!lYynfc=5UYIR9lYm zDJ0sbB_&%RaAg?$rbwMXPfQ$qcf`dFaIMVStk^QJrlYRr>a5E*I4Cx*=oAzWIpp12 zd#{A>V}OD;`vohVh>ur?+MuT9%cQO)J(kfxNKsv=-(GZ$<*T!^Q%MlNrnUgGtz+mD z`DRTr%Z5ffjQ9w%4Z6c~@E&pRmlOMe$Lj48E7^$tWGDY%={;B9xsfgBq)Y=N@HV~4n`E^oZqSm#t1_ zj-zIzZE4%kcDPDqZ0Ek812)Xk#S7VN@PNv)%>fppRIu2##och*T?%WCPaodor-OBR zr|5`XLEE<%-`vo}RsT~o&N>R`?cuX>R2Xc@yf@z$xW|~1C$PqCnMMK4zzujQjoOq* z8t52$w!6aDBkbOhM4g!UYuz+=?C9LQx)gGOo0>A&kFdB zB9ctE9kM`Q<>k|-j%TAbs$QMy;PB|mhGvHI6o!+H)x5quCiWRfj>*4;-81ITcLaUa z8RmhL(S)WRtZ7(Fq`dqcC|7%A)1LYP;02B@E(`4K@4H&g8}N6=2;5nKO_HMbWEHH3_=80W=8e z9+okl*3Dv<{S{QW9J@Uxuihy*aza;Ci6gq3pRus!NaOMyW`)n5GiN~0o(9Y1g{f!% zrGW_R!@*%!kv6z9S!>E=Ss=t6ujmRAb&na{4y|lAvh4@TeUFRRhnim1&2`iR{AnYA zFyB(omH+vvtncQwOBT#m9HuSb)9{Sb4DYV{E>_&#);?p2yv;D#j#V!T&Rn`wsdgyh zWS3`5maV&NoI7FINoG}WjzD*03>jJnxtLrp6ZKhP{S`9w^915PORKYx@nZ4Xe@_1g zHpO5`gDVB75+-a*HfO;p;_?;d22@3Ne0pm!&aBBlY2Yc2^_nojUXi@&W51>{`H|WS z_Z{u1HtA5w>^=3P=$!*#Hn_Cy&;srX1wtI1b7|jn3bki1U(z~1N)($qRnccn|Cp+i zn+)YHFAK3h*yqIfb+LM%KYU1G<&hl1BfCiU>NfT6mm%s`WCqQ%{3|+x`JqkW(y&Ol zdPaWl9!1iwTvr2<)^q2hDkn6l!8|dR>DYX2|7r2huGCKV=X$5g#!uaiJ@x!ek#}&P zyH0C@1_dUnNG4}btr~0^tMRybul=QdH`FzAPo>xI(Y7S!)M4#n8XXfI9kOUT!d!B4 z1=BNnk9+8z|Jc9niWrz`pda*Qbxl+Ttn74GcXv>Z!8@H>m!8_rSq0~DoISoJUwS|+ zrI2ZKX=~%UmF7+padwZ}7#^U1YQ8(%^X2<2T3y}{Lxu|W%K$1s z)rD7npG8oSo?b={NIC-$xji31Z(J!ZMRXAsT_529*S6wT`8M&2iv3eoe{Y%pN z_eu|0W6YmwxJzj7~&Mhc#m^kqxNI%cA;#(RqnR^JtNQ_^5ZO=(d zO3=<4=JwDGhwZ6D^heql29A+}mu&GW2A_cB@hPxvjEq-$hkhG22S$GRxb*N9&0Jf%VU^%@uS(g`*JhnweaZWYr_$k( zIGpe$?Mb(hkxP%mkhuM8a+A%6=%4M^QlrD2bx7ZRDWP zt+#+8h}JAXn!`r!shnOY zTVuT(RmgX*z$Flvj42noqxLH8GqI8$(A^U2J@Cth6~p16(TMEz51kU0p% zHJR=96^GIEWi4Z-P&AWxt){n9ys&^&Sw>oZj5To7GVa8Ad-7*%wCGFz84nk zvuN)%1A-xBrcV!Msq#G>9j-Kv>QYzm;KPR%&8i8PFVAI;cP#)O`$vp4`v z-OI*7&knINb1Mb9?3|0;hY8nCX2~L)HS6HIPG8;sCl>(jFBunPNMwqI7l&t5HPeDu zvuiKlNbv<71Z|RG!%|`Yi5-&281g4`uWEZFz_=(8GZJkpiW8B`sd+O zZY3qXfc=jLCutF^&ZtYv9kOWyfmD|%50EavDW-H|0k@Za{HqYW`Z&GXV$QNa?Lw%3 zM#7K%OSv6S)Ib@P2GR>?hxweIw(rg^G>tg4lVfd+ z*+uY5ZnD>|+J)b0i&18_zol9@+`Exa7_i1b=y!bf<)aUX+`+LZ!yhk9%gNb&*DXCklf~1uoB{bRI|X}jM)B21xzudWfB$p?Ye93nj7|?98k3{B~t7$bG&5yK51E+650267g#W3Z@FLn zc_B18kXc{FOHx-oKgZMA^6Q5Sd{=-SL@4?rMot=kQf1r@5VE4)ny!N4O$pN*eb#A9|EGKq$U!SWnR9!W$zA`G?hQZ(eN-4hGcy2OfXR2-EuCdGqYiLGCO zXRmO`WAr(?pGIq5QJTUrok4L&p@^uatV|zrHmD?=u03t zfMlrumJ|60K_z7Sm8p{^{L}H&q(3$5wRI-xlgBm$o(9@=a_XaNxhDTL{kv^<0Zr4F z^`8=h+JVT+6ou{;romG&_L?2#Fra&-&d{Omb_*7$U(PS1gqCj?%ZvlELp%}q#wZ^t zS~F%|I(brdb$DSEuuF$O1GTiky8<6o7vJvXoOG?so}n|VnbJVLWvrZ9-(C!_hm;c& z64FdEG{2BDBt0MMvR-RiuW2n54)&{@7VdTVInh0(cj1Ly9x1EF^>tk%Jx}^~8*dfl+yA|e}$`OV0-Pc+vw4d?X>@#gk<4Ul2`F7hB3*O+I zYxKBwG$L*XV!B;TpsA#p{zk_I;V@Gho3@v7QzCY6AJ|=W`uxnð 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 S%KJwAWxHR`(qBn79sduTO~DcX From 99bc65020c8c62c10fc2bef3a6149c7ed3a61c40 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 25 Oct 2017 09:38:39 +0200 Subject: [PATCH 044/117] typos --- docs/user_guide/environment_setup.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user_guide/environment_setup.rst b/docs/user_guide/environment_setup.rst index 97a775d..4a1fe20 100644 --- a/docs/user_guide/environment_setup.rst +++ b/docs/user_guide/environment_setup.rst @@ -33,7 +33,7 @@ The environment configuration scripts should be executed in the same directory w Test Creation ~~~~~~~~~~~~~~~ -There is script which generates easy template of test for module docker as example. +There is a script called mtf-init which generates easy template of test for module docker as example. - to create template for module docker ``mtf-init --name your_name --container path_to_your_container`` From 2e39eda2729d600e0ef509fe15b5153fdb3672ef Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 25 Oct 2017 13:29:28 +0200 Subject: [PATCH 045/117] remove workarounds and add rpm to base package set workaround remove module dependency function and remove moduledeps variable --- moduleframework/common.py | 4 +-- moduleframework/helpers/nspawn_helper.py | 2 +- moduleframework/helpers/rpm_helper.py | 34 +++---------------- moduleframework/module_framework.py | 2 +- .../{exceptions.py => mtfexceptions.py} | 15 +------- moduleframework/pdc_data.py | 33 ++++++++---------- moduleframework/timeoutlib.py | 6 ++-- mtf/backend/nspawn.py | 12 +++---- mtf/exceptions/__init__.py | 1 - mtf/mtfexceptions/__init__.py | 1 + 10 files changed, 33 insertions(+), 77 deletions(-) rename moduleframework/{exceptions.py => mtfexceptions.py} (83%) delete mode 100644 mtf/exceptions/__init__.py create mode 100644 mtf/mtfexceptions/__init__.py diff --git a/moduleframework/common.py b/moduleframework/common.py index 42512b5..8bdc958 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -36,9 +36,8 @@ import sys import random import string -import time from avocado.utils import process -from moduleframework.exceptions import ModuleFrameworkException, ConfigExc, CmdExc +from moduleframework.mtfexceptions import ModuleFrameworkException, ConfigExc, CmdExc defroutedev = netifaces.gateways().get('default').values( )[0][1] if netifaces.gateways().get('default') else "lo" @@ -326,7 +325,6 @@ def __init__(self, *args, **kwargs): self.arch = None self.sys_arch = None self.dependencylist = {} - self.moduledeps = {} self.is_it_module = False self.packager = None # general use case is to have forwarded services to host (so thats why it is same) diff --git a/moduleframework/helpers/nspawn_helper.py b/moduleframework/helpers/nspawn_helper.py index 6318607..bd4b1f0 100644 --- a/moduleframework/helpers/nspawn_helper.py +++ b/moduleframework/helpers/nspawn_helper.py @@ -75,7 +75,7 @@ def setUp(self): self.moduleName + "_image_" + hashlib.md5(" ".join(self.repos)).hexdigest()) - self.__image_base = Image(location=self.chrootpath_baseimage, packageset=self.getPackageList(),repos=self.repos, ignore_installed=True) + self.__image_base = Image(location=self.chrootpath_baseimage, packageset=self.whattoinstallrpm, repos=self.repos, ignore_installed=True) self.__image = self.__image_base.create_snapshot(self.chrootpath) self.__container = Container(image=self.__image, name=self.jmeno) self._callSetupFromConfig() diff --git a/moduleframework/helpers/rpm_helper.py b/moduleframework/helpers/rpm_helper.py index 1aef665..a36d898 100644 --- a/moduleframework/helpers/rpm_helper.py +++ b/moduleframework/helpers/rpm_helper.py @@ -22,8 +22,6 @@ from moduleframework import pdc_data from moduleframework.common import * -from moduleframework.exceptions import * - class RpmHelper(CommonFunctions): """ @@ -43,26 +41,10 @@ def __init__(self): if not os.path.exists(baserepodir): baserepodir="/var/tmp" self.yumrepo = os.path.join(baserepodir, "%s.repo" % self.moduleName) - self.whattoinstallrpm = "" + self.whattoinstallrpm = [] self.bootstrappackages = [] self.repos = [] - def __setModuleDependencies(self): - if self.is_it_module: - if not get_if_remoterepos(): - temprepositories = self.getModulemdYamlconfig()\ - .get("data",{}).get("dependencies",{}).get("requires",{}) - temprepositories_cycle = dict(temprepositories) - for x in temprepositories_cycle: - pdc = pdc_data.PDCParser() - pdc.setLatestPDC(x, temprepositories_cycle[x]) - temprepositories.update(pdc.generateDepModules()) - self.moduledeps = temprepositories - print_info("Detected module dependencies:", self.moduledeps) - else: - self.moduledeps = {"base-runtime": "master"} - print_info("Remote repositories are enabled", self.moduledeps) - def getURL(self): """ Return semicolon separated string of repositories what will be used, could be simialr to URL param, @@ -81,7 +63,6 @@ def setUp(self): :return: None """ - #self.setModuleDependencies() self.setRepositoriesAndWhatToInstall() self._callSetupFromConfig() self.__prepare() @@ -110,21 +91,14 @@ def setRepositoriesAndWhatToInstall(self, repos=[], whattooinstall=None): # TODO: removed this dependency search if not get_compose_url() and self.is_it_module: depend_repos = [get_compose_url_modular_release()] - #for dep in self.moduledeps: - # latesturl = pdc_data.get_repo_url(dep, self.moduledeps[dep]) - # depend_repos.append(latesturl) - # self.__addModuleDependency(url=latesturl, name = dep, stream = self.moduledeps[dep]) - #map(self.__addModuleDependency, depend_repos) self.repos += depend_repos - #map(self.__addModuleDependency, self.repos) - # make self.repos unique in case there is more repos (faster dnf operations) self.repos = list(set(self.repos)) if whattooinstall: - self.whattoinstallrpm = " ".join(set(whattooinstall)) + self.whattoinstallrpm = list(set(whattooinstall)) else: - self.bootstrappackages = pdc_data.getBasePackageSet(modulesDict=self.moduledeps, + self.bootstrappackages = pdc_data.getBasePackageSet(modulesDict=None, isModule=self.is_it_module, isContainer=False) - self.whattoinstallrpm = " ".join(set(self.getPackageList() + self.bootstrappackages)) + self.whattoinstallrpm = list(set(self.getPackageList() + self.bootstrappackages)) def __prepare(self): """ diff --git a/moduleframework/module_framework.py b/moduleframework/module_framework.py index 990d843..2e9aeb6 100644 --- a/moduleframework/module_framework.py +++ b/moduleframework/module_framework.py @@ -30,7 +30,7 @@ from moduleframework.avocado_testers.container_avocado_test import ContainerAvocadoTest from moduleframework.avocado_testers.nspawn_avocado_test import NspawnAvocadoTest from moduleframework.avocado_testers.rpm_avocado_test import RpmAvocadoTest -from moduleframework.exceptions import ModuleFrameworkException +from moduleframework.mtfexceptions import ModuleFrameworkException PROFILE = None diff --git a/moduleframework/exceptions.py b/moduleframework/mtfexceptions.py similarity index 83% rename from moduleframework/exceptions.py rename to moduleframework/mtfexceptions.py index 4726702..996c21b 100644 --- a/moduleframework/exceptions.py +++ b/moduleframework/mtfexceptions.py @@ -24,10 +24,6 @@ Custom exceptions library. """ -import common -import sys -import linecache - class ModuleFrameworkException(Exception): """ @@ -36,16 +32,7 @@ class ModuleFrameworkException(Exception): """ def __init__(self, *args, **kwargs): super(ModuleFrameworkException, self).__init__( - 'EXCEPTION MTF: ', *args, **kwargs) - exc_type, exc_obj, tb = sys.exc_info() - if tb is not None: - f = tb.tb_frame - lineno = tb.tb_lineno - filename = f.f_code.co_filename - linecache.checkcache(filename) - line = linecache.getline(filename, lineno, f.f_globals) - common.print_info("-----------\n| EXCEPTION IN: {} \n| LINE: {}, {} \n| ERROR: {}\n-----------". - format(filename, lineno, line.strip(), exc_obj)) + 'EXCEPTION MTF: ' + str(args), **kwargs) class NspawnExc(ModuleFrameworkException): diff --git a/moduleframework/pdc_data.py b/moduleframework/pdc_data.py index 3a504d8..653638a 100644 --- a/moduleframework/pdc_data.py +++ b/moduleframework/pdc_data.py @@ -36,7 +36,7 @@ from common import print_info, DEFAULTRETRYCOUNT, DEFAULTRETRYTIMEOUT, \ get_if_remoterepos, get_compose_url_modular_release, MODULEFILE, print_debug,\ is_debug, ARCH, is_recursive_download, trans_dict, BASEPATHDIR -from moduleframework import exceptions +from moduleframework import mtfexceptions from pdc_client import PDCClient from timeoutlib import Retry @@ -56,23 +56,20 @@ def getBasePackageSet(modulesDict=None, isModule=True, isContainer=False): out = [] brmod = "base-runtime" brmod_profiles = ["container", "baseimage"] + brmod_stream = "master" BASEPACKAGESET_WORKAROUND = ["systemd"] BASEPACKAGESET_WORKAROUND_NOMODULE = BASEPACKAGESET_WORKAROUND + ["dnf"] pdc = None basepackageset = [] if isModule: - # TODO: workaround, when disabled local compose repos - if not modulesDict: - modulesDict[brmod] = "master" - if modulesDict.get(brmod): - print_info("Searching for packages base package set inside %s" % brmod) - pdc = PDCParser() - pdc.setLatestPDC(brmod, modulesDict[brmod]) - for pr in brmod_profiles: - if pdc.getmoduleMD()['data']['profiles'].get(pr): - basepackageset = pdc.getmoduleMD( - )['data']['profiles'][pr]['rpms'] - break + print_info("Searching for packages base package set inside %s" % brmod) + pdc = PDCParser() + pdc.setLatestPDC(brmod, brmod_stream) + for pr in brmod_profiles: + if pdc.getmoduleMD()['data']['profiles'].get(pr): + basepackageset = pdc.getmoduleMD( + )['data']['profiles'][pr]['rpms'] + break if isContainer: out = basepackageset else: @@ -82,7 +79,7 @@ def getBasePackageSet(modulesDict=None, isModule=True, isContainer=False): out = basepackageset else: out = basepackageset + BASEPACKAGESET_WORKAROUND_NOMODULE - print_info("ALL packages to install:", out) + print_info("Base packages to install:", out) return out def get_repo_url(wmodule="base-runtime", wstream="master", fake=False): @@ -120,14 +117,14 @@ def __getDataFromPdc(self): pdc_query['variant_version'] = self.stream if self.version: pdc_query['variant_release'] = self.version - @Retry(attempts=DEFAULTRETRYCOUNT,timeout=DEFAULTRETRYTIMEOUT,error=exceptions.PDCExc("Could not query PDC server")) + @Retry(attempts=DEFAULTRETRYCOUNT, timeout=DEFAULTRETRYTIMEOUT, error=mtfexceptions.PDCExc("Could not query PDC server")) def retry_tmpfunc(): # Using develop=True to not authenticate to the server pdc_session = PDCClient(PDC_SERVER, ssl_verify=True, develop=True) return pdc_session(**pdc_query) mod_info = retry_tmpfunc() if not mod_info or "results" not in mod_info.keys() or not mod_info["results"]: - raise exceptions.PDCExc("QUERY: %s is not available on PDC" % pdc_query) + raise mtfexceptions.PDCExc("QUERY: %s is not available on PDC" % pdc_query) self.pdcdata = mod_info["results"][-1] self.modulemd = yaml.load(self.pdcdata["modulemd"]) @@ -251,7 +248,7 @@ def download_tagged(self,dirname): print_debug("DOWNLOADING: %s" % foo) @Retry(attempts=DEFAULTRETRYCOUNT * 10, timeout=DEFAULTRETRYTIMEOUT * 60, delay=DEFAULTRETRYTIMEOUT, - error=exceptions.KojiExc( + error=mtfexceptions.KojiExc( "RETRY: Unbale to fetch package from koji after %d attempts" % (DEFAULTRETRYCOUNT * 10))) def tmpfunc(): a = utils.process.run( @@ -262,7 +259,7 @@ def tmpfunc(): print_debug( 'UNABLE TO DOWNLOAD package (intended for other architectures, GOOD):', a.command) else: - raise exceptions.KojiExc( + raise mtfexceptions.KojiExc( 'UNABLE TO DOWNLOAD package (KOJI issue, BAD):', a.command) tmpfunc() diff --git a/moduleframework/timeoutlib.py b/moduleframework/timeoutlib.py index c495e0e..dc330ef 100644 --- a/moduleframework/timeoutlib.py +++ b/moduleframework/timeoutlib.py @@ -57,7 +57,7 @@ class Retry(object): def __init__(self, attempts=1, timeout=None, exceptions=(Exception,), error=None, inverse=False, delay=None): """ Try to run things ATTEMPTS times, at max, each attempt must not exceed TIMEOUT seconds. - Restart only when one of EXCEPTIONS is raised, all other exceptions will just bubble up. + Restart only when one of EXCEPTIONS is raised, all other mtfexceptions will just bubble up. When the maximal number of attempts is reached, raise ERROR. Wait DELAY seconds between attempts. When INVERSE is True, successfull return of wrapped code is considered as a failure. @@ -120,13 +120,13 @@ def __wrap(*args, **kwargs): if self.inverse: return True - # Handle exceptions we are expected to catch, by logging a failed + # Handle mtfexceptions we are expected to catch, by logging a failed # attempt, and checking the number of attempts. delay = self.handle_failure(start_time) continue except Exception as e: - # Handle all other exceptions, by logging a failed attempt and + # Handle all other mtfexceptions, by logging a failed attempt and # re-raising the exception, effectively killing the loop. if __debug__: self.failed_attempts += 1 diff --git a/mtf/backend/nspawn.py b/mtf/backend/nspawn.py index 2041b56..9969874 100644 --- a/mtf/backend/nspawn.py +++ b/mtf/backend/nspawn.py @@ -34,7 +34,7 @@ from avocado import Test from avocado.utils import process from mtf import common -from mtf import exceptions +from mtf import mtfexceptions DEFAULT_RETRYTIMEOUT = 30 DEFAULT_SLEEP = 1 @@ -67,7 +67,7 @@ def __init__(self, repos, packageset, location, installed=False, packager="dnf - else: try: self.__install() - except exceptions.NspawnExc as e: + except mtfexceptions.NspawnExc as e: if ignore_installed: pass else: @@ -141,7 +141,7 @@ def __install(self): for filename in glob.glob(os.path.join(pkipath, '*')): shutil.copy(filename, pkipath_ch) else: - raise exceptions.NspawnExc("Directory %s already in use" % self.location) + raise mtfexceptions.NspawnExc("Directory %s already in use" % self.location) def get_location(self): """ @@ -188,7 +188,7 @@ def __is_killed(self): out = process.run("machinectl status %s" % self.name, ignore_status=True, verbose=is_debug_low()) if out.exit_status != 0: return True - raise exceptions.NspawnExc("Unable to stop machine %s within %d" % (self.name, DEFAULT_RETRYTIMEOUT)) + raise mtfexceptions.NspawnExc("Unable to stop machine %s within %d" % (self.name, DEFAULT_RETRYTIMEOUT)) def __is_booted(self): for foo in range(DEFAULT_RETRYTIMEOUT): @@ -202,7 +202,7 @@ def __is_booted(self): if "Unit: machine" in out.stdout: time.sleep(DEFAULT_SLEEP) return True - raise exceptions.NspawnExc("Unable to start machine %s within %d" % (self.name, DEFAULT_RETRYTIMEOUT)) + raise mtfexceptions.NspawnExc("Unable to start machine %s within %d" % (self.name, DEFAULT_RETRYTIMEOUT)) def boot_machine(self, nspawn_add_option_list=[], boot_cmd="", wait_finish=False): """ @@ -343,7 +343,7 @@ def run_machinectl(self, command, **kwargs): machine=self.name, comm=common.sanitize_cmd(command), pin=lpath, defaultsleep=self.__default_command_sleep ), **kwargs) if comout.exit_status != 0: - raise exceptions.NspawnExc("This command should not fail anyhow inside NSPAWN:", command) + raise mtfexceptions.NspawnExc("This command should not fail anyhow inside NSPAWN:", command) try: kwargs["verbose"] = False b = process.run( diff --git a/mtf/exceptions/__init__.py b/mtf/exceptions/__init__.py deleted file mode 100644 index 022848a..0000000 --- a/mtf/exceptions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from moduleframework.exceptions import * diff --git a/mtf/mtfexceptions/__init__.py b/mtf/mtfexceptions/__init__.py new file mode 100644 index 0000000..ad65883 --- /dev/null +++ b/mtf/mtfexceptions/__init__.py @@ -0,0 +1 @@ +from moduleframework.mtfexceptions import * From 50a03dff0f64babbb0fa69ab862aa6a009ed795e Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Thu, 26 Oct 2017 13:14:13 +0200 Subject: [PATCH 046/117] Couple updates. Signed-off-by: Petr "Stone" Hracek --- man/mtf-env-clean.1 | 22 ++++++++++++++++++---- man/mtf-env-set.1 | 17 +++++++++++------ man/mtf.1 | 3 +++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/man/mtf-env-clean.1 b/man/mtf-env-clean.1 index aa3da5a..aab04df 100644 --- a/man/mtf-env-clean.1 +++ b/man/mtf-env-clean.1 @@ -2,18 +2,32 @@ .\" .\" This page is distributed under GPL. .\" -.TH mtf-env-clean 1 2017-11-01 "" "Linux User's Manual" +.TH MTF-ENV-CLEAN 1 2017-11-01 "" "Linux User's Manual" .SH NAME -mtf-env-set \- Part of Meta-Test-Family package. It cleans environment for testing containers. +mtf-env-clean \- cleans environment for testing containers. .SH SYNOPSIS -\fBmtf-env-clean cleans environment for testing containers +\fIMODULE=docker\/\fR +.B mtf-env-clean + +\fIMODULE=rpm\/\fR +.B mtf-env-clean + +\fIMODULE=nspawn\/\fR +.B mtf-env-clean .SH DESCRIPTION +.PP \fBmtf-env-clean\fP is the binary file for cleaning environment of Meta-Test-Family. It stops docker service. +.PP +\fIMODULE=docker\/\fR cleans docker service environment. .SH NOTES -\fBmtf-env-clean\fP is useful for users who don't want to care about environment and their clean up. +\fBmtf-env-clean\fP mtf-env-set is useful for people who don't want to clean the environment +manually and wish to use this executable to do the job. .SH AUTHORS Petr Hracek, (man page) + +.SH "SEE ALSO" +Full documentation at: \ No newline at end of file diff --git a/man/mtf-env-set.1 b/man/mtf-env-set.1 index 62dc1f1..8c8f86f 100644 --- a/man/mtf-env-set.1 +++ b/man/mtf-env-set.1 @@ -2,19 +2,24 @@ .\" .\" This page is distributed under GPL. .\" -.TH mtf-env-set 1 2017-11-01 "" "Linux User's Manual" +.TH MTF-ENV-SET 1 2017-11-01 "" "Linux User's Manual" .SH NAME -mtf-env-set \- Part of Meta-Test-Family package. It prepares environment for testing containers. +mtf-env-set \- prepares environment for testing containers. .SH SYNOPSIS -\fBmtf-env-set prepares environment for testing containers +.B mtf-env-set .SH DESCRIPTION -\fBmtf-env-set\fP is the binary file of Meta-Test-Family. It installs tests dependencies, docker service - and starts docker service. +.PP +\fBmtf-env-clean\fP is the binary file of Meta-Test-Family package. It installs tests dependencies, docker service +and starts docker service. .SH NOTES -\fBmtf-env-set\fP is useful for users who don't want to care about environment and their settings. +\fBmtf-env-set\fP mtf-env-set is useful for people who don't want to set the environment manually +and wish to use this executable to do the job. .SH AUTHORS Petr Hracek, (man page) + +.SH "SEE ALSO" +Full documentation at: \ No newline at end of file diff --git a/man/mtf.1 b/man/mtf.1 index a6252c7..7959a6c 100644 --- a/man/mtf.1 +++ b/man/mtf.1 @@ -25,3 +25,6 @@ Once \fBmtf\fP finishes it shows logs from failed tests. .SH AUTHORS Petr Hracek, (man page) + +.SH SEE ALSO +Full documentation at: Date: Tue, 17 Oct 2017 12:13:59 +0200 Subject: [PATCH 047/117] Fix error in case help_md does not exist Signed-off-by: Petr "Stone" Hracek --- moduleframework/tools/dockerlint.py | 5 ++++- moduleframework/tools/helpmd_lint.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index af12171..9677c80 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -94,7 +94,10 @@ def test_chained_run_rest_commands(self): self.assertTrue(self.dp.check_chained_run_rest_commands()) def test_helpmd_is_present(self): - self.assertTrue(self.dp.check_helpmd_is_present()) + helpmd_present = self.dp.check_helpmd_is_present() + if not helpmd_present: + self.log.warn("help.md is not present in Dockerfile") + self.assertTrue(True) class DockerLint(container_avocado_test.ContainerAvocadoTest): diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index 2ad01bc..b901f50 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -46,9 +46,9 @@ def setUp(self): self.log.info("Dockerfile was not found in %s directory." % dir_name) self.skip() self.helpmd = helpfile_linter.HelpMDLinter(dockerfile=self.dp.dockerfile) - if self.helpmd is None: + if self.helpmd.help_md is None: self.log.info("help.md file was not found in Dockerfile directory") - self.skip() + self.skip("help.md file was not found in Dockerfile directory") def test_helpmd_exists(self): self.assertTrue(self.helpmd) From 224eb1675a9a7d3623008c49ef3350ccce406198 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Thu, 26 Oct 2017 14:07:50 +0200 Subject: [PATCH 048/117] Implement func mark_as_warn Signed-off-by: Petr "Stone" Hracek --- moduleframework/avocado_testers/avocado_test.py | 13 +++++++++++++ moduleframework/helpfile_linter.py | 7 +++++-- moduleframework/module_framework.py | 3 --- moduleframework/tools/dockerlint.py | 6 +----- moduleframework/tools/helpmd_lint.py | 6 +----- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/moduleframework/avocado_testers/avocado_test.py b/moduleframework/avocado_testers/avocado_test.py index a9b60fc..027f393 100644 --- a/moduleframework/avocado_testers/avocado_test.py +++ b/moduleframework/avocado_testers/avocado_test.py @@ -263,6 +263,19 @@ def run_script(self, *args, **kwargs): """ return self.backend.run_script(*args, **kwargs) + def mark_as_warn(self, func, *args, **kwargs): + """ + run function which you would like to mark as WARN + :param func: function for run + :param args: pass this args to run function + :param kwargs: pass thru to avocado process.run + :return: returns either PASS or WARN + """ + try: + func(*args, **kwargs) + except AssertionError as e: + self.log.warn("Warning raised: %s" % e) + def get_backend(): """ diff --git a/moduleframework/helpfile_linter.py b/moduleframework/helpfile_linter.py index ce13da2..1ecdc7b 100644 --- a/moduleframework/helpfile_linter.py +++ b/moduleframework/helpfile_linter.py @@ -49,7 +49,10 @@ def get_maintainer_name(self, name): def get_tag(self, name): name = '# %s' % name + tag_found = True if not self.help_md: + common.print_info("help md does not exist.") return False - tag_exists = [x for x in self.help_md if name.upper() in x] - return tag_exists + if not [x for x in self.help_md if name.upper() in x]: + tag_found = False + return tag_found diff --git a/moduleframework/module_framework.py b/moduleframework/module_framework.py index 2e9aeb6..ed40a0c 100644 --- a/moduleframework/module_framework.py +++ b/moduleframework/module_framework.py @@ -47,6 +47,3 @@ def skipTestIf(value, text="Test not intended for this module profile"): if value: raise ModuleFrameworkException( "DEPRECATED, don't use this skip, use self.cancel() inside test function, or self.skip() in setUp()") - - - diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index 9677c80..60436a9 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -26,7 +26,6 @@ from moduleframework import module_framework from moduleframework import dockerlinter from moduleframework.avocado_testers import container_avocado_test -from moduleframework.common import get_docker_file class DockerFileLinter(module_framework.AvocadoTest): @@ -94,10 +93,7 @@ def test_chained_run_rest_commands(self): self.assertTrue(self.dp.check_chained_run_rest_commands()) def test_helpmd_is_present(self): - helpmd_present = self.dp.check_helpmd_is_present() - if not helpmd_present: - self.log.warn("help.md is not present in Dockerfile") - self.assertTrue(True) + self.mark_as_warn(self.assertTrue, self.dp.check_helpmd_is_present()) class DockerLint(container_avocado_test.ContainerAvocadoTest): diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index b901f50..4da423b 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -73,11 +73,7 @@ def test_helpmd_usage(self): self.assertTrue(self.helpmd.get_tag("USAGE")) def test_helpmd_environment_variables(self): - env_variables = self.helpmd.get_tag("ENVIRONMENT VARIABLES") - if not env_variables: - self.log.warn("help.md file does not contain section ENVIRONMENT VARIABLES") - # In order to report warning, test has to report with True always - self.assertTrue(True) + self.mark_as_warn(self.assertTrue, self.helpmd.get_tag("ENVIRONMENT VARIABLES")) def test_helpmd_security_implications(self): if self.dp.get_docker_expose(): From cccdf7a7de8617bea3b0106e73b957211134aaf8 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 30 Oct 2017 12:09:53 +0100 Subject: [PATCH 049/117] man page updates based on #151 PR Signed-off-by: Petr "Stone" Hracek --- man/mtf-env-clean.1 | 11 +++++++-- man/mtf-env-set.1 | 23 ++++++++++++++++-- man/mtf-generator.1 | 9 ++++--- man/mtf.1 | 59 +++++++++++++++++++++++++++++++++++++++------ 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/man/mtf-env-clean.1 b/man/mtf-env-clean.1 index aab04df..d10ac4b 100644 --- a/man/mtf-env-clean.1 +++ b/man/mtf-env-clean.1 @@ -18,9 +18,16 @@ mtf-env-clean \- cleans environment for testing containers. .SH DESCRIPTION .PP -\fBmtf-env-clean\fP is the binary file for cleaning environment of Meta-Test-Family. It stops docker service. +\fBmtf-env-clean\fP is the binary file for cleaning environment of Meta-Test-Family. + +.PP +\fIMODULE=docker\/\fR stops docker service. + +.PP +\fIMODULE=rpm\/\fR does not do cleanup as it can destroy this machine. + .PP -\fIMODULE=docker\/\fR cleans docker service environment. +\fIMODULE=nspawn\/\fR switches back SELinux to original state. .SH NOTES \fBmtf-env-clean\fP mtf-env-set is useful for people who don't want to clean the environment diff --git a/man/mtf-env-set.1 b/man/mtf-env-set.1 index 8c8f86f..d6f4c5b 100644 --- a/man/mtf-env-set.1 +++ b/man/mtf-env-set.1 @@ -7,12 +7,31 @@ mtf-env-set \- prepares environment for testing containers. .SH SYNOPSIS +\fIMODULE=docker\/\fR .B mtf-env-set +\fIMODULE=rpm\/\fR +.B mtf-env-set + +\fIMODULE=nspawn\/\fR +.B mtf-env-set + + .SH DESCRIPTION .PP -\fBmtf-env-clean\fP is the binary file of Meta-Test-Family package. It installs tests dependencies, docker service -and starts docker service. +\fBmtf-env-set\fP is the binary file of Meta-Test-Family package. + +.PP +\fIMODULE=docker\/\fR It installs tests dependencies, docker service +and starts docker service for testing containers + +.PP +\fIMODULE=rpm\/\fR installs RPM dependencies on this machine. + +.PP +\fIMODULE=nspawn\/\fR installs RPM dependencies. If environment variable MTF_SKIP_DISABLING_SELINUX is +set, it disables SELinux. It installs systemd-container package on this machine. + .SH NOTES \fBmtf-env-set\fP mtf-env-set is useful for people who don't want to set the environment manually diff --git a/man/mtf-generator.1 b/man/mtf-generator.1 index 747998a..5d188b2 100644 --- a/man/mtf-generator.1 +++ b/man/mtf-generator.1 @@ -4,13 +4,16 @@ .\" .TH mtf-generator 1 2017-11-01 "" "Linux User's Manual" .SH NAME -mtf-generator \- Meta-Test-Family generates tests written in \fBconfig.yaml\fP file. +mtf-generator \- generates tests written in \fBconfig.yaml\fP file. .SH SYNOPSIS -\fBmtf-generator generates tests written \fBconfig.yaml\fP file for using by \fBmtf\fP binary. +.B +mtf .SH DESCRIPTION -\fBmtf-generator\fP is the binary file of Meta-Test-Family package which generates tests from configuration file +\fBmtf-generator\fP is the binary file of Meta-Test-Family package. +It generates tests written \fBconfig.yaml\fP file for using by \fBmtf\fP binary. .SH AUTHORS Petr Hracek, (man page) + diff --git a/man/mtf.1 b/man/mtf.1 index 7959a6c..bf65639 100644 --- a/man/mtf.1 +++ b/man/mtf.1 @@ -4,16 +4,61 @@ .\" .TH mtf 1 2017-11-01 "" "Linux User's Manual" .SH NAME -mtf \- Meta-Test-Family tests container images and modules with user defined tests written in Python and/or linters -provided by meta-test-family package. +mtf \- runs tests for container images .SH SYNOPSIS -\fBmtf runs tests for container images - -\fBmtf [-l, --linters] +[\fI\,VARIABLES\/\fT] +.B +mtf +[\fI\,OPTIONS\/\fR] PYTHON_TESTS .SH DESCRIPTION -\fBmtf\fP is the main binary file of Meta-Test-Family. +\fBmtf\fP is the main binary file of Meta-Test-Family. It tests container images and modules with user defined tests written in Python and/or linters +provided by meta-test-family package. +It runs PYTHON_TESTS in avocado framework. + +.SH VARIABLES +.TP +.B AVOCADO_LOG_DEBUG=yes +enables avocado debug output. +.TP +.B DEBUG=yes +enables debugging mode to test output. +.TP +.B CONFIG +defines the module configuration file. It defaults to +.B config.yaml +.TP +.B MODULE +defines tested module type, if +.B default-module +is not set in +.B config.yaml. +.TP +.B URL to container. +E.g. URL=docker.io/modularitycontainers/haproxy if MODULE=docker +.TP +.B MODULEMDURL +overwrites the location of a moduleMD file. +.TP +.B COMPOSEURL +overwrites the location of a compose Pungi build. +.TP +.B MTF_SKIP_DISABLING_SELINUX=yes +does not disable SELinux. In nspawn type on Fedora 25 SELinux should be disabled, because it does not work well with SELinux enabled, this option allows to not do that. +.TP +.B MTF_DO_NOT_CLEANUP=yes +does not clean up module after tests execution (a machine remains running). +.TP +.B MTF_REUSE=yes +uses the same module between tests. It speeds up test execution. It can cause side effects. +.TP +.B MTF_REMOTE_REPOS=yes +disables downloading of Koji packages and creating a local repo, and speeds up test execution. +.TP +.B MTF_DISABLE_MODULE=yes +disables module handling to use nonmodular test mode. + .SH OPTIONS .TP @@ -27,4 +72,4 @@ Once \fBmtf\fP finishes it shows logs from failed tests. Petr Hracek, (man page) .SH SEE ALSO -Full documentation at: \ No newline at end of file From fc59300200304ef210d64ea0d60b582d103d6954 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 30 Oct 2017 12:22:56 +0100 Subject: [PATCH 050/117] Package man pages Signed-off-by: Petr "Stone" Hracek --- MANIFEST.in | 1 + meta-test-family.spec | 4 ++++ setup.py | 11 +++++++++++ 3 files changed, 16 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 22efa61..ccb0d07 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,3 +10,4 @@ recursive-include docs * recursive-include examples * recursive-include tools * recursive-include distro * +recursive-include man * diff --git a/meta-test-family.spec b/meta-test-family.spec index e86b602..0377842 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -42,6 +42,10 @@ install -d -p -m 755 %{buildroot}%{_datadir}/%{framework_name} %files %license LICENSE +%{_mandir}/man1/mtf.1* +%{_mandir}/man1/mtf-env-clean.1* +%{_mandir}/man1/mtf-env-set.1* +%{_mandir}/man1/mtf-generator.1* %{_bindir}/mtf %{_bindir}/mtf-cmd %{_bindir}/mtf-generator diff --git a/setup.py b/setup.py index e160e0d..2c1d3ff 100755 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ def get_dir(system_path=None, virtual_path=None): system_path = [] return os.path.join(*(['/'] + system_path)) + data_files = {} paths = ['docs', 'examples', 'tools'] @@ -61,6 +62,16 @@ def get_dir(system_path=None, virtual_path=None): ['usr', 'share', 'moduleframework', root])] = [ os.path.join(root, f) for f in files] +paths = ['man'] + +for path in paths: + for root, dirs, files in os.walk(path, followlinks=True): + print(root, dirs, files) + data_files[ + get_dir( + ['usr', 'share', 'man', 'man1'])] = [ + os.path.join(root, f) for f in files] + setup( name='meta-test-family', version="0.7.3", From 15e8a84101482fc19fb54e0e4a9b018deee619b8 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 30 Oct 2017 09:58:55 +0100 Subject: [PATCH 051/117] Rename function to assert_to_warn Signed-off-by: Petr "Stone" Hracek --- examples/mtf-linters/Dockerfile | 39 +++++++++++++++++++ examples/mtf-linters/config.yaml | 37 ++++++++++++++++++ examples/mtf-linters/help.md | 31 +++++++++++++++ examples/testing-module/Makefile | 4 ++ examples/testing-module/help.md | 13 ------- .../avocado_testers/avocado_test.py | 4 +- moduleframework/tools/dockerlint.py | 2 +- moduleframework/tools/helpmd_lint.py | 2 +- 8 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 examples/mtf-linters/Dockerfile create mode 100644 examples/mtf-linters/config.yaml create mode 100644 examples/mtf-linters/help.md diff --git a/examples/mtf-linters/Dockerfile b/examples/mtf-linters/Dockerfile new file mode 100644 index 0000000..75d0726 --- /dev/null +++ b/examples/mtf-linters/Dockerfile @@ -0,0 +1,39 @@ +FROM baseruntime/baseruntime:latest + +# memcached image for OpenShift. +# +# Environment: +# * $CACHE_SIZE - Cache size for memcached +# Ports: +# * 11211 + +ENV NAME=memcached ARCH=x86_64 +LABEL maintainer "Petr Hracek" +LABEL summary="High Performance, Distributed Memory Object Cache" \ + name="$FGC/$NAME" \ + version="0" \ + release="1.$DISTTAG" \ + architecture="$ARCH" \ + com.redhat.component=$NAME \ + usage="docker run -p 11211:11211 f26/memcached" \ + help="Runs memcached, which listens on port 11211. No dependencies. See Help File below for more details." \ + description="memcached is a high-performance, distributed memory object caching system, generic in nature, but intended for use in speeding up dynamic web applications by alleviating database load." \ + io.k8s.description="memcached is a high-performance, distributed memory object caching system, generic in nature, but intended for use in speeding up dynamic web applications by alleviating database load." \ + io.k8s.diplay-name="Memcached 1.4 " \ + io.openshift.expose-services="11211:memcached" \ + io.openshift.tags="memcached" + +COPY repos/* /etc/yum.repos.d/ +RUN microdnf --nodocs --enablerepo memcached install memcached && \ + microdnf -y clean all + + +ADD files /files +ADD README.md / + +EXPOSE 11211 + +# memcached will be run under standard user on Fedora +USER 1000 + +CMD ["/files/memcached.sh"] diff --git a/examples/mtf-linters/config.yaml b/examples/mtf-linters/config.yaml new file mode 100644 index 0000000..d551115 --- /dev/null +++ b/examples/mtf-linters/config.yaml @@ -0,0 +1,37 @@ +document: meta-test +version: 1 +name: memcached +modulemd-url: https://src.fedoraproject.org/modules/memcached/raw/master/f/memcached.yaml +service: + port: 11211 +packages: + rpms: + - memcached + - perl-Carp +testdependencies: + rpms: + - nc +module: + docker: + start: "docker run -it -e CACHE_SIZE=128 -p 11211:11211" + labels: + description: "memcached is a high-performance, distributed memory" + io.k8s.description: "memcached is a high-performance, distributed memory" + source: https://github.com/container-images/memcached.git + container: docker.io/modularitycontainers/memcached + rpm: + start: systemctl start memcached + stop: systemctl stop memcached + status: systemctl status memcached + url: https://kojipkgs.fedoraproject.org/compose/latest-Fedora-Modular-26/compose/Server/x86_64/os/ +test: + processrunning: + - 'ls /proc/*/exe -alh | grep memcached' +testhost: + selfcheck: + - 'echo errr | nc localhost 11211' + - 'echo set AAA 0 4 2 | nc localhost 11211' + - 'echo get AAA | nc localhost 11211' + selcheckError: + - 'echo errr | nc localhost 11211 |grep ERROR' + diff --git a/examples/mtf-linters/help.md b/examples/mtf-linters/help.md new file mode 100644 index 0000000..b79f79e --- /dev/null +++ b/examples/mtf-linters/help.md @@ -0,0 +1,31 @@ +% MEMCACHED(1) Container Image Pages +% Petr Hracek +% February 6, 2017 + +# NAME +{{ spec.envvars.name }} - {{ spec.description }} + +# DESCRIPTION +Memcached is a high-performance, distributed memory object caching system, generic in nature, but intended for use in speeding up dynamic web applications by alleviating database load. + +The container itself consists of: + - fedora/{{ config.os.version }} base image + - {{ spec.envvars.name }} RPM package + +Files added to the container during docker build include: /files/memcached.sh + +# USAGE +To get the memcached container image on your local system, run the following: + + docker pull docker.io/modularitycontainers/{{ spec.envvars.name }} + + +# SECURITY IMPLICATIONS +Lists of security-related attributes that are opened to the host. + +-p 11211:11211 + Opens container port 11211 and maps it to the same port on the host. + +# SEE ALSO +Memcached page + diff --git a/examples/testing-module/Makefile b/examples/testing-module/Makefile index 93d5977..7f5ae16 100644 --- a/examples/testing-module/Makefile +++ b/examples/testing-module/Makefile @@ -85,6 +85,10 @@ check-mtf-init: cd $(TEMPDIR); grep 'class Smoke1' test.py ; grep 'default_module' config.yaml; sudo mtf test.py rm -rf "$(TEMPDIR)" +check-mtf-linters: + cd ../mtf-linters; MODULE=docker mtf-env-set + cd ../mtf-linters; MODULE=docker $(CMD) -l + check: check-docker all: check diff --git a/examples/testing-module/help.md b/examples/testing-module/help.md index 0f8683e..b79f79e 100644 --- a/examples/testing-module/help.md +++ b/examples/testing-module/help.md @@ -20,19 +20,6 @@ To get the memcached container image on your local system, run the following: docker pull docker.io/modularitycontainers/{{ spec.envvars.name }} -# ENVIRONMENT VARIABLES - -The image recognizes the following environment variables that you can set -during initialization be passing `-e VAR=VALUE` to the Docker run command. - -| Variable name | Description | -| :----------------------- | ----------------------------------------------------------- | -| `MEMCACHED_DEBUG_MODE` | Increases verbosity for server and client. Parameter is -vv | -| `MEMCACHED_CACHE_SIZE` | Sets the size of RAM to use for item storage (in megabytes) | -| `MEMCACHED_CONNECTIONS` | The max simultaneous connections; default is 1024 | -| `MEMCACHED_THREADS` | Sets number of threads to use to process incoming requests | - - # SECURITY IMPLICATIONS Lists of security-related attributes that are opened to the host. diff --git a/moduleframework/avocado_testers/avocado_test.py b/moduleframework/avocado_testers/avocado_test.py index 027f393..39a1064 100644 --- a/moduleframework/avocado_testers/avocado_test.py +++ b/moduleframework/avocado_testers/avocado_test.py @@ -263,12 +263,12 @@ def run_script(self, *args, **kwargs): """ return self.backend.run_script(*args, **kwargs) - def mark_as_warn(self, func, *args, **kwargs): + def assert_to_warn(self, func, *args, **kwargs): """ run function which you would like to mark as WARN :param func: function for run :param args: pass this args to run function - :param kwargs: pass thru to avocado process.run + :param kwargs: pass this args to run function :return: returns either PASS or WARN """ try: diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index 60436a9..7a6f21e 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -93,7 +93,7 @@ def test_chained_run_rest_commands(self): self.assertTrue(self.dp.check_chained_run_rest_commands()) def test_helpmd_is_present(self): - self.mark_as_warn(self.assertTrue, self.dp.check_helpmd_is_present()) + self.assert_to_warn(self.assertTrue, self.dp.check_helpmd_is_present()) class DockerLint(container_avocado_test.ContainerAvocadoTest): diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index 4da423b..fae0426 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -73,7 +73,7 @@ def test_helpmd_usage(self): self.assertTrue(self.helpmd.get_tag("USAGE")) def test_helpmd_environment_variables(self): - self.mark_as_warn(self.assertTrue, self.helpmd.get_tag("ENVIRONMENT VARIABLES")) + self.assert_to_warn(self.assertTrue, self.helpmd.get_tag("ENVIRONMENT VARIABLES")) def test_helpmd_security_implications(self): if self.dp.get_docker_expose(): From 1db6a0cb7ba49307c816e7b33971c14048679af0 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 30 Oct 2017 15:13:59 +0100 Subject: [PATCH 052/117] Couple updates based on PR. Signed-off-by: Petr "Stone" Hracek --- man/mtf-env-clean.1 | 7 ++++--- man/mtf-env-set.1 | 2 +- man/mtf-generator.1 | 6 +++--- man/mtf.1 | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/man/mtf-env-clean.1 b/man/mtf-env-clean.1 index d10ac4b..202e793 100644 --- a/man/mtf-env-clean.1 +++ b/man/mtf-env-clean.1 @@ -18,16 +18,17 @@ mtf-env-clean \- cleans environment for testing containers. .SH DESCRIPTION .PP -\fBmtf-env-clean\fP is the binary file for cleaning environment of Meta-Test-Family. +\fBmtf-env-clean\fP is a binary file used for cleaning environment after usage of Meta-Test-Family. .PP \fIMODULE=docker\/\fR stops docker service. .PP -\fIMODULE=rpm\/\fR does not do cleanup as it can destroy this machine. +\fIMODULE=rpm\/\fR does not do any cleanup, +as that could potentially uninstall essential packages and therefore damage this machine. .PP -\fIMODULE=nspawn\/\fR switches back SELinux to original state. +\fIMODULE=nspawn\/\fR switches SELinux back to original state. .SH NOTES \fBmtf-env-clean\fP mtf-env-set is useful for people who don't want to clean the environment diff --git a/man/mtf-env-set.1 b/man/mtf-env-set.1 index d6f4c5b..fecd1d9 100644 --- a/man/mtf-env-set.1 +++ b/man/mtf-env-set.1 @@ -19,7 +19,7 @@ mtf-env-set \- prepares environment for testing containers. .SH DESCRIPTION .PP -\fBmtf-env-set\fP is the binary file of Meta-Test-Family package. +\fBmtf-env-set\fP is a binary file used for setting environment before usage of Meta-Test-Family. .PP \fIMODULE=docker\/\fR It installs tests dependencies, docker service diff --git a/man/mtf-generator.1 b/man/mtf-generator.1 index 5d188b2..5fb9e73 100644 --- a/man/mtf-generator.1 +++ b/man/mtf-generator.1 @@ -4,15 +4,15 @@ .\" .TH mtf-generator 1 2017-11-01 "" "Linux User's Manual" .SH NAME -mtf-generator \- generates tests written in \fBconfig.yaml\fP file. +mtf-generator \- generates code for tests written in \fBconfig.yaml\fP file. .SH SYNOPSIS .B mtf .SH DESCRIPTION -\fBmtf-generator\fP is the binary file of Meta-Test-Family package. -It generates tests written \fBconfig.yaml\fP file for using by \fBmtf\fP binary. +\fBmtf-generator\fP is a binary file included in Meta-Test-Family package. +It generates code for tests written in \fBconfig.yaml\fP file for usage by \fBmtf\fP binary. .SH AUTHORS Petr Hracek, (man page) diff --git a/man/mtf.1 b/man/mtf.1 index bf65639..2245979 100644 --- a/man/mtf.1 +++ b/man/mtf.1 @@ -13,9 +13,9 @@ mtf [\fI\,OPTIONS\/\fR] PYTHON_TESTS .SH DESCRIPTION -\fBmtf\fP is the main binary file of Meta-Test-Family. It tests container images and modules with user defined tests written in Python and/or linters +\fBmtf\fP is a main binary file of Meta-Test-Family. It tests container images and/or modules with user defined tests written in Python and/or linters provided by meta-test-family package. -It runs PYTHON_TESTS in avocado framework. +It runs PYTHON_TESTS using avocado framework as test runner. .SH VARIABLES .TP From 8cd40dafd52149dbb94d25e76bd352353ac0a21a Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 30 Oct 2017 15:31:12 +0100 Subject: [PATCH 053/117] Split linters into more classes Signed-off-by: Petr "Stone" Hracek --- moduleframework/tools/dockerlint.py | 52 +++++++++++++++++++--------- moduleframework/tools/helpmd_lint.py | 3 +- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index 7a6f21e..f45533b 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -28,9 +28,44 @@ from moduleframework.avocado_testers import container_avocado_test -class DockerFileLinter(module_framework.AvocadoTest): +class DockerInstructions(module_framework.AvocadoTest): """ :avocado: enable + :avocado: tags=sanity + + """ + + dp = None + + def setUp(self): + # it is not intended just for docker, but just docker packages are + # actually properly signed + self.dp = dockerlinter.DockerfileLinter() + if self.dp.dockerfile is None: + dir_name = os.getcwd() + self.log.info("Dockerfile was not found in %s directory." % dir_name) + self.skip() + + def test_from_is_first_directive(self): + self.assertTrue(self.dp.check_from_is_first()) + + def test_from_directive_is_valid(self): + self.assertTrue(self.dp.check_from_directive_is_valid()) + + def test_chained_run_dnf_commands(self): + self.assertTrue(self.dp.check_chained_run_dnf_commands()) + + def test_chained_run_rest_commands(self): + self.assertTrue(self.dp.check_chained_run_rest_commands()) + + def test_helpmd_is_present(self): + self.assert_to_warn(self.assertTrue, self.dp.check_helpmd_is_present()) + + +class DockerLabels(module_framework.AvocadoTest): + """ + :avocado: enable + :avocado: sanity """ @@ -80,21 +115,6 @@ def test_summary_label_exists(self): def test_run_or_usage_label_exists(self): self.assertTrue(self._test_for_env_and_label("run", "usage", env=False)) - def test_from_is_first_directive(self): - self.assertTrue(self.dp.check_from_is_first()) - - def test_from_directive_is_valid(self): - self.assertTrue(self.dp.check_from_directive_is_valid()) - - def test_chained_run_dnf_commands(self): - self.assertTrue(self.dp.check_chained_run_dnf_commands()) - - def test_chained_run_rest_commands(self): - self.assertTrue(self.dp.check_chained_run_rest_commands()) - - def test_helpmd_is_present(self): - self.assert_to_warn(self.assertTrue, self.dp.check_helpmd_is_present()) - class DockerLint(container_avocado_test.ContainerAvocadoTest): """ diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index fae0426..28a4c8c 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -29,9 +29,10 @@ from moduleframework.common import get_docker_file -class HelpMDLinter(module_framework.AvocadoTest): +class DockerHelpSanity(module_framework.AvocadoTest): """ :avocado: enable + :avocado: optional """ From 0094f680efe03c1acac065e6e592c15e13bf4c03 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 30 Oct 2017 15:35:50 +0100 Subject: [PATCH 054/117] Documentation about linters Signed-off-by: Petr "Stone" Hracek --- docs/user_guide/mtf_linters.rst | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/user_guide/mtf_linters.rst diff --git a/docs/user_guide/mtf_linters.rst b/docs/user_guide/mtf_linters.rst new file mode 100644 index 0000000..269a524 --- /dev/null +++ b/docs/user_guide/mtf_linters.rst @@ -0,0 +1,8 @@ +Linters +================= + +MTF provides a set of linters for checking containers + +Dockerfile linters +~~~~~~~~~~~~~~~~~~ + From 4d8b7d2e4f87484297a0083834623b079421341c Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 31 Oct 2017 10:03:31 +0100 Subject: [PATCH 055/117] Bump version to 0.7.7 Signed-off-by: Petr "Stone" Hracek --- meta-test-family.spec | 5 ++++- setup.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/meta-test-family.spec b/meta-test-family.spec index 8e291da..8ab14a0 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -1,7 +1,7 @@ %global framework_name moduleframework Name: meta-test-family -Version: 0.7.6 +Version: 0.7.7 Release: 1%{?dist} Summary: Tool to test components of a modular Fedora @@ -61,6 +61,9 @@ install -d -p -m 755 %{buildroot}%{_datadir}/%{framework_name} %changelog +* Tue Oct 31 2017 Petr Hracek 0.7.7-1 +- new upstream release + * Tue Oct 24 2017 Petr Hracek 0.7.6-1 - new upstream release diff --git a/setup.py b/setup.py index b63536c..fdf574f 100755 --- a/setup.py +++ b/setup.py @@ -74,7 +74,7 @@ def get_dir(system_path=None, virtual_path=None): setup( name='meta-test-family', - version="0.7.6", + version="0.7.7", description='Tool to test components fo a modular Fedora.', keywords='modules,containers,testing,framework', author='Jan Scotka', From 602bc1ce5e6ea8d8092f737d94aa23053980a779 Mon Sep 17 00:00:00 2001 From: Petr Hracek Date: Tue, 31 Oct 2017 10:14:02 +0100 Subject: [PATCH 056/117] Update setup.py --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index fdf574f..235c26a 100755 --- a/setup.py +++ b/setup.py @@ -66,7 +66,6 @@ def get_dir(system_path=None, virtual_path=None): for path in paths: for root, dirs, files in os.walk(path, followlinks=True): - print(root, dirs, files) data_files[ get_dir( ['usr', 'share', 'man', 'man1'])] = [ From 3b7db4befc395c4c1ae11fde7eeb64c098e22ac3 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 31 Oct 2017 10:23:45 +0100 Subject: [PATCH 057/117] Update name classes Signed-off-by: Petr "Stone" Hracek --- moduleframework/tools/dockerlint.py | 4 ++-- moduleframework/tools/helpmd_lint.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index f45533b..e300f5e 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -28,7 +28,7 @@ from moduleframework.avocado_testers import container_avocado_test -class DockerInstructions(module_framework.AvocadoTest): +class DockerInstructionsTests(module_framework.AvocadoTest): """ :avocado: enable :avocado: tags=sanity @@ -62,7 +62,7 @@ def test_helpmd_is_present(self): self.assert_to_warn(self.assertTrue, self.dp.check_helpmd_is_present()) -class DockerLabels(module_framework.AvocadoTest): +class DockerLabelsTests(module_framework.AvocadoTest): """ :avocado: enable :avocado: sanity diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index 28a4c8c..ccb00b8 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -29,7 +29,7 @@ from moduleframework.common import get_docker_file -class DockerHelpSanity(module_framework.AvocadoTest): +class HelpFileSanity(module_framework.AvocadoTest): """ :avocado: enable :avocado: optional From 93b09990795eea1999d33d951126758f8966da17 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 31 Oct 2017 09:55:06 +0100 Subject: [PATCH 058/117] Update RTD. Use sphinx-build-2 Signed-off-by: Petr "Stone" Hracek --- Makefile.docs | 2 +- docs/conf.py | 2 -- moduleframework/dockerlinter.py | 17 ++++++++++------- setup.py | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Makefile.docs b/Makefile.docs index 0fed393..262d472 100644 --- a/Makefile.docs +++ b/Makefile.docs @@ -3,7 +3,7 @@ # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = sphinx-build +SPHINXBUILD = sphinx-build-2 PAPER = BUILDDIR = build diff --git a/docs/conf.py b/docs/conf.py index a918447..9da5ab8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,8 +22,6 @@ mtf_dir = os.path.abspath('..') sys.path.insert(0, mtf_dir) -#from moduleframework import version as mtf_version - # Determine if this script is running inside RTD build environment on_rtd = os.environ.get('READTHEDOCS', None) == 'True' diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 98a6c52..d6329af 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -184,15 +184,18 @@ def check_from_directive_is_valid(self): def check_chained_run_dnf_commands(self): """ - Function checks if Dockerfile does not contain more `RUN dnf/yum` commands - in more then one row. + Function checks if Dockerfile does not contain more `RUN dnf/yum` commands in more then one row. + BAD examples: - FROM fedora - RUN dnf install foobar1 - RUN dnf clean all + ~~~~~~~~~~~~ + FROM fedora + RUN dnf install foobar1 + RUN dnf clean all + GOOD example: - FROM fedora - RUN dnf install foobar1 && dnf clean all + ~~~~~~~~~~~~ + FROM fedora + RUN dnf install foobar1 && dnf clean all :return: True if Dockerfile contains RUN dnf instructions in one row False if Dockerfile contains RUN dnf instructions in more rows """ diff --git a/setup.py b/setup.py index 235c26a..c0678a3 100755 --- a/setup.py +++ b/setup.py @@ -94,7 +94,7 @@ def get_dir(system_path=None, virtual_path=None): 'mtf-init = moduleframework.mtf_init:main', ] }, - setup_requires=[], + setup_requires=open('requirements.txt').read().splitlines(), classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Console', From cc4bcb157b3fd528b382acee1e3812a66dcca582 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 7 Nov 2017 15:43:36 +0100 Subject: [PATCH 059/117] Update docstring Signed-off-by: Petr "Stone" Hracek --- moduleframework/dockerlinter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index d6329af..61543a8 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -184,7 +184,9 @@ def check_from_directive_is_valid(self): def check_chained_run_dnf_commands(self): """ - Function checks if Dockerfile does not contain more `RUN dnf/yum` commands in more then one row. + This function checks that there are no consecutive + RUN commands executing dnf/yum in the Dockerfile, + as these need to be chained. BAD examples: ~~~~~~~~~~~~ From 5b4fdc51a3423ad6c8e57942b4b79d1bbe69ccd9 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 12 Sep 2017 13:10:09 +0200 Subject: [PATCH 060/117] Add suport for check nodocs and clean_all --- examples/testing-module/Dockerfile | 4 +- moduleframework/tools/modulelint.py | 121 ++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/examples/testing-module/Dockerfile b/examples/testing-module/Dockerfile index 14c8eff..c81c311 100644 --- a/examples/testing-module/Dockerfile +++ b/examples/testing-module/Dockerfile @@ -24,8 +24,8 @@ LABEL summary="High Performance, Distributed Memory Object Cache" \ io.openshift.tags="memcached" COPY repos/* /etc/yum.repos.d/ -RUN microdnf --nodocs --enablerepo memcached install memcached && \ - microdnf -y clean all +RUN dnf --nodocs --enablerepo memcached install memcached && \ + dnf -y clean all ADD files /files diff --git a/moduleframework/tools/modulelint.py b/moduleframework/tools/modulelint.py index 0f6db68..bf1dd25 100644 --- a/moduleframework/tools/modulelint.py +++ b/moduleframework/tools/modulelint.py @@ -23,6 +23,127 @@ from __future__ import print_function from moduleframework import module_framework +from moduleframework import dockerlinter +from moduleframework.avocado_testers import container_avocado_test + + +class DockerFileLinter(module_framework.AvocadoTest): + """ + :avocado: enable + + """ + + dp = None + + def setUp(self): + # it is not intended just for docker, but just docker packages are + # actually properly signed + self.dp = dockerlinter.DockerfileLinter() + if self.dp.dockerfile is None: + self.skip() + + def test_architecture_in_env_and_label_exists(self): + self.assertTrue(self.dp.get_docker_specific_env("ARCH=")) + self.assertTrue(self.dp.get_specific_label("architecture")) + + def test_name_in_env_and_label_exists(self): + self.assertTrue(self.dp.get_docker_specific_env("NAME=")) + self.assertTrue(self.dp.get_specific_label("name")) + + def test_release_label_exists(self): + self.assertTrue(self.dp.get_specific_label("release")) + + def test_version_label_exists(self): + self.assertTrue(self.dp.get_specific_label("version")) + + def test_com_redhat_component_label_exists(self): + self.assertTrue(self.dp.get_specific_label("com.redhat.component")) + + def test_summary_label_exists(self): + self.assertTrue(self.dp.get_specific_label("summary")) + + def test_run_or_usage_label_exists(self): + label_found = True + run = self.dp.get_specific_label("run") + if not run: + label_found = self.dp.get_specific_label("usage") + self.assertTrue(label_found) + + +class DockerfileLinterInContainer(container_avocado_test.ContainerAvocadoTest): + """ + :avocado: enable + + """ + + def test_docker_nodocs(self): + self.start() + installed_pkgs = self.run("rpm -qa --qf '%{{NAME}}\n'", verbose=False).stdout + # This returns a list of packages defined in config.yaml for testing + # e.g. ["bash", "rpm", "memcached"] in case of memcached + defined_pkgs = self.backend.getPackageList() + list_pkg = set(installed_pkgs).intersection(set(defined_pkgs)) + for pkg in list_pkg: + all_docs = self.run("rpm -qd %s" % pkg, verbose=False).stdout + for doc in all_docs.strip().split('\n'): + self.assertNotEqual(0, self.run("test -e %s" % doc, ignore_status=True).exit_status) + + def test_docker_clean_all(self): + """ + This test checks if size of /var/cache/ is bigger then + 150000 taken by command du -hsb /var/cache/ + + :return: return True if size is less then 150000 + return False is size is bigger then 150000 + """ + self.start() + pkg_mgr = "yum" + # Detect distro in image + distro = self.run("cat /etc/os-release").stdout + + if 'NAME=Fedora' in distro: + pkg_mgr = "dnf" + # Look, whether we have solv files in /var/cache//*.solv + # dnf|yum clean all deletes the file *.solv + ret = self.run("du -shb /var/cache/%s/" % pkg_mgr, ignore_status=True) + (size, directory) = ret.stdout.strip().split() + if int(size) < 150000: + correct_size = True + else: + correct_size = False + self.assertTrue(correct_size) + + +class DockerLint(container_avocado_test.ContainerAvocadoTest): + """ + :avocado: enable + """ + + def test_basic(self): + self.start() + self.assertTrue("bin" in self.run("ls /").stdout) + + def test_container_is_running(self): + """ + Function tests whether container is running + :return: + """ + self.start() + self.assertIn(self.backend.jmeno.rsplit("/")[-1], self.runHost("docker ps").stdout) + + def test_labels(self): + """ + Function tests whether labels are set in modulemd YAML file properly. + :return: + """ + llabels = self.getConfigModule().get('labels') + if llabels is None or len(llabels) == 0: + print("No labels defined in config to check") + self.cancel() + for key in self.getConfigModule()['labels']: + aaa = self.checklabel(key, self.getConfigModule()['labels'][key]) + print(">>>>>> ", aaa, key) + self.assertTrue(aaa) class ModuleLintSigning(module_framework.AvocadoTest): From efe121cdad0e6dabf7d2421815ef628505faff69 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 10 Oct 2017 11:37:59 +0200 Subject: [PATCH 061/117] doc test is splitted into two tests. One is for whole image and second one is related only for install RPMs by RUN command Signed-off-by: Petr "Stone" Hracek --- moduleframework/dockerlinter.py | 14 ++++ moduleframework/tools/dockerlint.py | 69 ++++++++++++++++ moduleframework/tools/modulelint.py | 121 ---------------------------- 3 files changed, 83 insertions(+), 121 deletions(-) diff --git a/moduleframework/dockerlinter.py b/moduleframework/dockerlinter.py index 98a6c52..04ce584 100644 --- a/moduleframework/dockerlinter.py +++ b/moduleframework/dockerlinter.py @@ -228,6 +228,20 @@ def check_chained_run_rest_commands(self): return False return True + def check_clean_all_command(self): + """ + This function checks whether every RUN instruction containing a dnf/yum operation ends with a "dnf/yum clean all". + + :return: True if every dnf/yum instruction contains a cleanup step + False otherwise + """ + for struct in self.dfp_structure: + if struct.get(INSTRUCT) == RUN: + if "dnf" in struct.get("value") or "yum" in struct.get("value"): + if "clean all" in struct.get("value"): + return True + return False + def check_helpmd_is_present(self): """ Function checks if helpmd. is present in COPY or ADD directives diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index e300f5e..70191b7 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -116,6 +116,75 @@ def test_run_or_usage_label_exists(self): self.assertTrue(self._test_for_env_and_label("run", "usage", env=False)) +class DockerfileLinterInContainer(container_avocado_test.ContainerAvocadoTest): + """ + :avocado: enable + + """ + + def _file_to_check(self, doc_file_list): + test_failed = False + for doc in doc_file_list.split('\n'): + exit_status = self.run("test -e %s" % doc, ignore_status=True).exit_status + if int(exit_status) == 0: + self.log.debug("%s doc file exists in container" % doc) + test_failed = True + return test_failed + + def test_all_nodocs(self): + self.start() + all_docs = self.run("rpm -qad", verbose=False).stdout + test_failed = self._file_to_check(all_docs) + if test_failed: + self.log.warn("Documentation files exist in container. They are installed by Platform or by RUN commands.") + self.assertTrue(True) + + def test_installed_docs(self): + """ + This test checks whether no docs are installed by RUN dnf command + :return: FAILED in case we found some docs + PASS in case there is no doc file found + """ + self.start() + # Double brackets has to by used because of trans_dict. + # 'EXCEPTION MTF: ', 'Command is formatted by using trans_dict. + # If you want to use brackets { } in your code, please use {{ }}. + installed_pkgs = self.run("rpm -qa --qf '%{{NAME}}\n'", verbose=False).stdout + defined_pkgs = self.backend.getPackageList() + list_pkg = set(installed_pkgs).intersection(set(defined_pkgs)) + test_failed = False + for pkg in list_pkg: + pkg_doc = self.run("rpm -qd %s" % pkg, verbose=False).stdout + if self._file_to_check(pkg_doc): + test_failed = True + self.assertFalse(test_failed) + + def test_docker_clean_all(self): + """ + This test checks if size of /var/cache/ is bigger then + 150000 taken by command du -hsb /var/cache/ + + :return: return True if size is less then 150000 + return False is size is bigger then 150000 + """ + self.start() + pkg_mgr = "yum" + # Detect distro in image + distro = self.run("cat /etc/os-release").stdout + + if 'NAME=Fedora' in distro: + pkg_mgr = "dnf" + # Look, whether we have solv files in /var/cache//*.solv + # dnf|yum clean all deletes the file *.solv + ret = self.run("du -shb /var/cache/%s/" % pkg_mgr, ignore_status=True) + (size, directory) = ret.stdout.strip().split() + if int(size) < 10000: + correct_size = True + else: + correct_size = False + self.assertTrue(correct_size) + + class DockerLint(container_avocado_test.ContainerAvocadoTest): """ :avocado: enable diff --git a/moduleframework/tools/modulelint.py b/moduleframework/tools/modulelint.py index bf1dd25..0f6db68 100644 --- a/moduleframework/tools/modulelint.py +++ b/moduleframework/tools/modulelint.py @@ -23,127 +23,6 @@ from __future__ import print_function from moduleframework import module_framework -from moduleframework import dockerlinter -from moduleframework.avocado_testers import container_avocado_test - - -class DockerFileLinter(module_framework.AvocadoTest): - """ - :avocado: enable - - """ - - dp = None - - def setUp(self): - # it is not intended just for docker, but just docker packages are - # actually properly signed - self.dp = dockerlinter.DockerfileLinter() - if self.dp.dockerfile is None: - self.skip() - - def test_architecture_in_env_and_label_exists(self): - self.assertTrue(self.dp.get_docker_specific_env("ARCH=")) - self.assertTrue(self.dp.get_specific_label("architecture")) - - def test_name_in_env_and_label_exists(self): - self.assertTrue(self.dp.get_docker_specific_env("NAME=")) - self.assertTrue(self.dp.get_specific_label("name")) - - def test_release_label_exists(self): - self.assertTrue(self.dp.get_specific_label("release")) - - def test_version_label_exists(self): - self.assertTrue(self.dp.get_specific_label("version")) - - def test_com_redhat_component_label_exists(self): - self.assertTrue(self.dp.get_specific_label("com.redhat.component")) - - def test_summary_label_exists(self): - self.assertTrue(self.dp.get_specific_label("summary")) - - def test_run_or_usage_label_exists(self): - label_found = True - run = self.dp.get_specific_label("run") - if not run: - label_found = self.dp.get_specific_label("usage") - self.assertTrue(label_found) - - -class DockerfileLinterInContainer(container_avocado_test.ContainerAvocadoTest): - """ - :avocado: enable - - """ - - def test_docker_nodocs(self): - self.start() - installed_pkgs = self.run("rpm -qa --qf '%{{NAME}}\n'", verbose=False).stdout - # This returns a list of packages defined in config.yaml for testing - # e.g. ["bash", "rpm", "memcached"] in case of memcached - defined_pkgs = self.backend.getPackageList() - list_pkg = set(installed_pkgs).intersection(set(defined_pkgs)) - for pkg in list_pkg: - all_docs = self.run("rpm -qd %s" % pkg, verbose=False).stdout - for doc in all_docs.strip().split('\n'): - self.assertNotEqual(0, self.run("test -e %s" % doc, ignore_status=True).exit_status) - - def test_docker_clean_all(self): - """ - This test checks if size of /var/cache/ is bigger then - 150000 taken by command du -hsb /var/cache/ - - :return: return True if size is less then 150000 - return False is size is bigger then 150000 - """ - self.start() - pkg_mgr = "yum" - # Detect distro in image - distro = self.run("cat /etc/os-release").stdout - - if 'NAME=Fedora' in distro: - pkg_mgr = "dnf" - # Look, whether we have solv files in /var/cache//*.solv - # dnf|yum clean all deletes the file *.solv - ret = self.run("du -shb /var/cache/%s/" % pkg_mgr, ignore_status=True) - (size, directory) = ret.stdout.strip().split() - if int(size) < 150000: - correct_size = True - else: - correct_size = False - self.assertTrue(correct_size) - - -class DockerLint(container_avocado_test.ContainerAvocadoTest): - """ - :avocado: enable - """ - - def test_basic(self): - self.start() - self.assertTrue("bin" in self.run("ls /").stdout) - - def test_container_is_running(self): - """ - Function tests whether container is running - :return: - """ - self.start() - self.assertIn(self.backend.jmeno.rsplit("/")[-1], self.runHost("docker ps").stdout) - - def test_labels(self): - """ - Function tests whether labels are set in modulemd YAML file properly. - :return: - """ - llabels = self.getConfigModule().get('labels') - if llabels is None or len(llabels) == 0: - print("No labels defined in config to check") - self.cancel() - for key in self.getConfigModule()['labels']: - aaa = self.checklabel(key, self.getConfigModule()['labels'][key]) - print(">>>>>> ", aaa, key) - self.assertTrue(aaa) class ModuleLintSigning(module_framework.AvocadoTest): From 143862edab997911fa122f643073ce5476591259 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Wed, 8 Nov 2017 15:25:25 +0100 Subject: [PATCH 062/117] Check specific file extensions in /var/cache/yum|dnf directories Signed-off-by: Petr "Stone" Hracek --- moduleframework/helpers/container_helper.py | 2 +- moduleframework/tools/dockerlint.py | 52 ++++++++++++++------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/moduleframework/helpers/container_helper.py b/moduleframework/helpers/container_helper.py index 13fb3fe..dba2e34 100644 --- a/moduleframework/helpers/container_helper.py +++ b/moduleframework/helpers/container_helper.py @@ -22,6 +22,7 @@ import json from moduleframework.common import * +from moduleframework.mtfexceptions import ContainerExc class ContainerHelper(CommonFunctions): @@ -125,7 +126,6 @@ def __load_inspect_json(self): "docker inspect %s" % self.jmeno, verbose=is_not_silent()).stdout)[0]["Config"] - def start(self, args="-it -d", command="/bin/bash"): """ start the docker container diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index 70191b7..4b37cd1 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -124,7 +124,7 @@ class DockerfileLinterInContainer(container_avocado_test.ContainerAvocadoTest): def _file_to_check(self, doc_file_list): test_failed = False - for doc in doc_file_list.split('\n'): + for doc in doc_file_list: exit_status = self.run("test -e %s" % doc, ignore_status=True).exit_status if int(exit_status) == 0: self.log.debug("%s doc file exists in container" % doc) @@ -155,34 +155,54 @@ def test_installed_docs(self): test_failed = False for pkg in list_pkg: pkg_doc = self.run("rpm -qd %s" % pkg, verbose=False).stdout - if self._file_to_check(pkg_doc): + if self._file_to_check(pkg_doc.split('\n')): test_failed = True self.assertFalse(test_failed) + def _check_container_files(self, exts, pkg_mgr): + found_files = False + file_list = [] + for ext in exts: + ret = self.run("for i in /var/cache/%s/**/*.%s" % (pkg_mgr, ext), ignore_status=True) + file_list.extend(ret.stdout.split('\n')) + if self._file_to_check(file_list): + found_files = True + return found_files + + def _dnf_clean_all(self): + """ + Function checks if files with relevant extensions exist in /var/cache/dnf directory + :return: True if at least one file exists + False if no file exists + """ + exts = ["solv", "solvx", "xml.gz", "rpm"] + return self._check_container_files(exts, "dnf") + + def _yum_clean_all(self): + """ + Function checks if files with relevant extensions exist in /var/cache/dnf directory + :return: True if at least one file exists + False if no file exists + """ + # extensions are taken from https://github.com/rpm-software-management/yum/blob/master/yum/__init__.py#L2854 + exts = ['rpm', 'sqlite', 'sqlite.bz2', 'xml.gz', 'asc', 'mirrorlist.txt', 'cachecookie', 'xml'] + return self._check_container_files(exts, "yum") + def test_docker_clean_all(self): """ - This test checks if size of /var/cache/ is bigger then - 150000 taken by command du -hsb /var/cache/ + This test checks if `dnf/yum clean all` was called in image - :return: return True if size is less then 150000 - return False is size is bigger then 150000 + :return: return True if clean all is called + return False if clean all is not called """ self.start() - pkg_mgr = "yum" # Detect distro in image distro = self.run("cat /etc/os-release").stdout if 'NAME=Fedora' in distro: - pkg_mgr = "dnf" - # Look, whether we have solv files in /var/cache//*.solv - # dnf|yum clean all deletes the file *.solv - ret = self.run("du -shb /var/cache/%s/" % pkg_mgr, ignore_status=True) - (size, directory) = ret.stdout.strip().split() - if int(size) < 10000: - correct_size = True + self.assertFalse(self._dnf_clean_all()) else: - correct_size = False - self.assertTrue(correct_size) + self.assertFalse(self._yum_clean_all()) class DockerLint(container_avocado_test.ContainerAvocadoTest): From c666efdd6272c06ca8a18a0df7a9d02c6f67aaf9 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Wed, 8 Nov 2017 16:16:50 +0100 Subject: [PATCH 063/117] Rewrite dnf/yum clean all functions Signed-off-by: Petr "Stone" Hracek --- moduleframework/tools/dockerlint.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index 4b37cd1..fd8adf1 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -163,7 +163,15 @@ def _check_container_files(self, exts, pkg_mgr): found_files = False file_list = [] for ext in exts: - ret = self.run("for i in /var/cache/%s/**/*.%s" % (pkg_mgr, ext), ignore_status=True) + dir_with_ext = "/var/cache/{pkg_mgr}/**/*.{ext}".format(pkg_mgr=pkg_mgr, ext=ext) + # Some images does not contain find command and therefore we have to use for or ls. + ret = self.run('shopt -s globstar && for i in {dir}; do printf "%s\\n" "$i" ; done'.format( + dir=dir_with_ext), + ignore_status=True) + # we did not find any file with an extension. + # TODO I don't how to detect failure or empty files. + if ret.stdout.strip() == dir_with_ext: + continue file_list.extend(ret.stdout.split('\n')) if self._file_to_check(file_list): found_files = True @@ -198,8 +206,7 @@ def test_docker_clean_all(self): self.start() # Detect distro in image distro = self.run("cat /etc/os-release").stdout - - if 'NAME=Fedora' in distro: + if 'Fedora' in distro: self.assertFalse(self._dnf_clean_all()) else: self.assertFalse(self._yum_clean_all()) From edef8e213693eb3795d7c3b10792915f36c6da2e Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Thu, 9 Nov 2017 11:01:53 +0100 Subject: [PATCH 064/117] Fix problem with paramters Signed-off-by: Petr "Stone" Hracek --- moduleframework/tools/dockerlint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index fd8adf1..7166fc1 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -134,7 +134,7 @@ def _file_to_check(self, doc_file_list): def test_all_nodocs(self): self.start() all_docs = self.run("rpm -qad", verbose=False).stdout - test_failed = self._file_to_check(all_docs) + test_failed = self._file_to_check(all_docs.split('\n')) if test_failed: self.log.warn("Documentation files exist in container. They are installed by Platform or by RUN commands.") self.assertTrue(True) From c93c5a82ceba42a73d051d9a28b358adb8f68257 Mon Sep 17 00:00:00 2001 From: Petr Hracek Date: Tue, 31 Oct 2017 10:14:02 +0100 Subject: [PATCH 065/117] Update setup.py --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index fdf574f..235c26a 100755 --- a/setup.py +++ b/setup.py @@ -66,7 +66,6 @@ def get_dir(system_path=None, virtual_path=None): for path in paths: for root, dirs, files in os.walk(path, followlinks=True): - print(root, dirs, files) data_files[ get_dir( ['usr', 'share', 'man', 'man1'])] = [ From 1e1eb6e954fbb94e9887935248a5a46d357b3153 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 6 Nov 2017 15:47:27 +0100 Subject: [PATCH 066/117] testing containers in OpenShift Signed-off-by: Petr "Stone" Hracek --- Makefile | 2 +- .../avocado_testers/avocado_test.py | 4 + .../avocado_testers/openshift_avocado_test.py | 54 ++++ moduleframework/common.py | 15 +- .../environment_prepare/openshift_prepare.py | 120 +++++++++ moduleframework/helpers/container_helper.py | 2 +- moduleframework/helpers/openshift_helper.py | 239 ++++++++++++++++++ moduleframework/mtf_environment.py | 3 + 8 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 moduleframework/avocado_testers/openshift_avocado_test.py create mode 100644 moduleframework/environment_prepare/openshift_prepare.py create mode 100644 moduleframework/helpers/openshift_helper.py diff --git a/Makefile b/Makefile index 00fdf6d..cc606f6 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ travis: clean: pip uninstall . - git clean -fd + #git clean -fd rm -rf build/html install: clean diff --git a/moduleframework/avocado_testers/avocado_test.py b/moduleframework/avocado_testers/avocado_test.py index 39a1064..e0b1d9d 100644 --- a/moduleframework/avocado_testers/avocado_test.py +++ b/moduleframework/avocado_testers/avocado_test.py @@ -34,6 +34,7 @@ from moduleframework.helpers.container_helper import ContainerHelper from moduleframework.helpers.nspawn_helper import NspawnHelper from moduleframework.helpers.rpm_helper import RpmHelper +from moduleframework.helpers.openshift_helper import OpenShiftHelper # INTERFACE CLASS FOR GENERAL TESTS OF MODULES @@ -292,6 +293,9 @@ def get_backend(): return RpmHelper() elif parent == 'nspawn': return NspawnHelper() + elif parent == 'openshift': + return OpenShiftHelper() + # To keep backward compatibility. This method could be used by pure avocado tests and is already used get_correct_backend = get_backend diff --git a/moduleframework/avocado_testers/openshift_avocado_test.py b/moduleframework/avocado_testers/openshift_avocado_test.py new file mode 100644 index 0000000..360f603 --- /dev/null +++ b/moduleframework/avocado_testers/openshift_avocado_test.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# +# This Modularity Testing Framework helps you to write tests for modules +# Copyright (C) 2017 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Authors: Petr Hracek +# + +from moduleframework.module_framework import AvocadoTest +from moduleframework.common import get_module_type_base + + +# INTERFACE CLASSES FOR SPECIFIC MODULE TESTS +class OpenShiftAvocadoTest(AvocadoTest): + """ + Class for writing tests specific just for DOCKER + derived from AvocadoTest class. + + :avocado: disable + """ + + def setUp(self): + if get_module_type_base() != "openshift": + self.cancel("OpenShift specific test") + super(OpenShiftAvocadoTest, self).setUp() + + def checkLabel(self, key, value): + """ + check label of docker image, expect key value (could be read from config file) + + :param key: str + :param value: str + :return: bool + """ + if key in self.backend.containerInfo['Labels'] and ( + value in self.backend.containerInfo['Labels'][key]): + return True + return False + + diff --git a/moduleframework/common.py b/moduleframework/common.py index 8bdc958..b15b5b8 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -504,6 +504,7 @@ def getModulemdYamlconfig(self, urllink=None): self.modulemdConf = link return link + def getIPaddr(self): """ Return protocol (IP or IPv6) address on a guest machine. @@ -513,6 +514,9 @@ def getIPaddr(self): :return: str """ + print_info(os.environ.get('MODULE')) + if 'openshift' == os.environ.get('MODULE'): + return trans_dict['GUESTIPADDR'] return self.ipaddr def _callSetupFromConfig(self): @@ -641,7 +645,7 @@ def copyFrom(self, src, dest): if src is not dest: self.run("cp -rf %s %s" % (src, dest)) - def run_script(self,filename, *args, **kwargs): + def run_script(self, filename, *args, **kwargs): """ run script or binary inside module :param filename: filename to copy to module @@ -657,6 +661,7 @@ def run_script(self,filename, *args, **kwargs): parameters = " " + " ".join(args) return self.run("bash " + dest + parameters, **kwargs) + def get_config(): """ Read the module's configuration file. @@ -697,8 +702,8 @@ def get_config(): raise ConfigExc("No module in yaml config defined") # copy rpm section to nspawn, in case not defined explicitly # make it backward compatible - if xcfg.get("module",{}).get("rpm") and not xcfg.get("module",{}).get("nspawn"): - xcfg["module"]["nspawn"] = copy.deepcopy(xcfg.get("module",{}).get("rpm")) + if xcfg.get("module", {}).get("rpm") and not xcfg.get("module", {}).get("nspawn"): + xcfg["module"]["nspawn"] = copy.deepcopy(xcfg.get("module", {}).get("rpm")) __persistent_config = xcfg return xcfg except IOError: @@ -726,7 +731,7 @@ def get_backend_list(): :return: list """ - base_module_list = ["rpm", "nspawn", "docker"] + base_module_list = ["rpm", "nspawn", "docker", "openshift"] return base_module_list @@ -757,7 +762,7 @@ def get_module_type_base(): module_type = get_module_type() parent = module_type if module_type not in get_backend_list(): - parent = get_config().get("module",{}).get(module_type, {}).get("parent") + parent = get_config().get("module", {}).get(module_type, {}).get("parent") if not parent: raise ModuleFrameworkException("Module (%s) does not provide parent backend parameter (there are: %s)" % (module_type, get_backend_list())) diff --git a/moduleframework/environment_prepare/openshift_prepare.py b/moduleframework/environment_prepare/openshift_prepare.py new file mode 100644 index 0000000..0938a2f --- /dev/null +++ b/moduleframework/environment_prepare/openshift_prepare.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# +# Meta test family (MTF) is a tool to test components of a modular Fedora: +# https://docs.pagure.org/modularity/ +# Copyright (C) 2017 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Authors: Petr Hracek +# + +""" +module for OpenShift environment setup and cleanup +""" + +import os +from moduleframework.common import CommonFunctions +from moduleframework import common + +selinux_state_file="/var/tmp/mtf_selinux_state" +setseto = "Permissive" + + +class EnvOpenShift(CommonFunctions): + + def prepare_env(self): + common.print_info('Loaded config for name: {}'.format(self.config['name'])) + self.__prepare_selinux() + self.__start_openshift_cluster() + + def cleanup_env(self): + self.__stop_openshift_cluster() + + def __prepare_selinux(self): + # disable selinux by default if not turned off + if not os.environ.get('MTF_SKIP_DISABLING_SELINUX'): + # https://github.com/fedora-modularity/meta-test-family/issues/53 + # workaround because systemd nspawn is now working well in F-26 + if not os.path.exists(selinux_state_file): + common.print_info("Disabling selinux") + actual_state = self.runHost("getenforce", ignore_status=True).stdout.strip() + with open(selinux_state_file, 'w') as openfile: + openfile.write(actual_state) + if setseto not in actual_state: + self.runHost("setenforce %s" % setseto, + verbose=common.is_not_silent(), + sudo=True) + + def __cleanup(self): + if not os.environ.get('MTF_SKIP_DISABLING_SELINUX'): + common.print_info("Turning back selinux to previous state") + actual_state = self.runHost("getenforce", ignore_status=True).stdout.strip() + if os.path.exists(selinux_state_file): + common.print_info("Turning back selinux to previous state") + with open(selinux_state_file, 'r') as openfile: + stored_state = openfile.readline() + if stored_state != actual_state: + self.runHost("setenforce %s" % stored_state, + ignore_status=True, + verbose=common.is_not_silent(), + sudo=True) + os.remove(selinux_state_file) + else: + common.print_info("Selinux state is not stored, skipping.") + + def __oc_status(self): + oc_status = self.runHost("oc status", ignore_status=True, verbose=common.is_not_silent()) + common.print_debug(oc_status.stdout) + common.print_debug(oc_status.stderr) + return oc_status.exit_status + + def __install_env(self): + """ + Internal method, do not use it anyhow + + :return: None + """ + if os.environ.get('OPENSHIFT_LOCAL'): + if not os.path.exists('/usr/bin/oc'): + self.installTestDependencies(['origin', 'origin-clients']) + + def __start_openshift_cluster(self): + """ + Internal method, do not use it anyhow. It starts OpenShift cluster + + :return: None + """ + + if os.environ.get('OPENSHIFT_LOCAL'): + if int(self.__oc_status()) == 0: + common.print_info("Seems like OpenShift is already started.") + else: + common.print_info("Starting OpenShift") + self.runHost("oc cluster up", verbose=common.is_not_silent()) + + def __stop_openshift_cluster(self): + """ + Internal method, do not use it anyhow. It stops OpenShift cluster + + :return: None + """ + if os.environ.get('OPENSHIFT_LOCAL'): + if int(self.__oc_status()) == 0: + common.print_info("Stopping OpenShift") + self.runHost("oc cluster down", verbose=common.is_not_silent()) + else: + common.print_info("OpenShift is already stopped.") + diff --git a/moduleframework/helpers/container_helper.py b/moduleframework/helpers/container_helper.py index 13fb3fe..596214e 100644 --- a/moduleframework/helpers/container_helper.py +++ b/moduleframework/helpers/container_helper.py @@ -94,7 +94,7 @@ def tearDown(self): :return: None """ - super(ContainerHelper,self).tearDown() + super(ContainerHelper, self).tearDown() if get_if_do_cleanup(): print_info("To run a command inside a container execute: ", "docker exec %s /bin/bash" % self.docker_id) diff --git a/moduleframework/helpers/openshift_helper.py b/moduleframework/helpers/openshift_helper.py new file mode 100644 index 0000000..9a96696 --- /dev/null +++ b/moduleframework/helpers/openshift_helper.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +# +# This Modularity Testing Framework helps you to write tests for modules +# Copyright (C) 2017 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Authors: Petr Hracek +# + +import json +import os +from moduleframework import common +from moduleframework.common import CommonFunctions +from moduleframework.mtfexceptions import ContainerExc, ConfigExc + + +class OpenShiftHelper(CommonFunctions): + """ + Basic Helper class for Docker container module type + + :avocado: disable + """ + + def __init__(self): + """ + set basic object variables + """ + super(OpenShiftHelper, self).__init__() + self.name = None + self.docker_id = None + self.icontainer = self.get_url() + self.containerInfo = None + if not self.icontainer: + raise ConfigExc("No container image specified in the configuration file or environment variable.") + if "docker=" in self.icontainer: + self.container_name = self.icontainer[7:] + else: + # untrusted source + self.container_name = self.icontainer + # application name is taken from docker.io/modularitycontainer/memcached + self.app_name = self.container_name.split('/')[-1] + self.app_ip = None + + common.print_info(self.icontainer) + common.print_info(self.container_name) + common.print_info(self.app_name) + + def get_container_url(self): + """ + It returns actual URL link string to container, It is same as URL + + :return: str + """ + return self.icontainer + + def get_docker_instance_name(self): + """ + Return docker instance name what will be used inside docker as docker image name + :return: str + """ + return self.container_name + + def _app_exists(self): + common.print_info("OpenShift app_exists") + oc_status = self.runHost("oc status", ignore_status=True) + if 'dc/%s' % self.app_name in oc_status.stdout: + common.print_info("Application already exists.") + return True + oc_services = self.runHost("oc get services -o json") + json_svc = self._convert_string_to_json(oc_services.stdout) + common.print_info(json_svc["items"]) + if not json_svc["items"]: + common.print_info("itesms are empty") + return False + return True + + def _convert_string_to_json(self, string): + json_output = json.loads(string) + common.print_info(json_output) + return json_output + + def _remove_apps_from_openshift_namespaces(self, oc_service="svc"): + # Check status of svc/dc/is + oc_status = self.runHost("oc status %s" % oc_service, ignore_status=True) + common.print_info(oc_status.stdout) + # If application exists in svc / dc / is namespace, then remove it + oc_delete = self.runHost("oc delete %s/%s" % (oc_service, self.app_name), ignore_status=True) + common.print_info(oc_delete.stdout) + + def _app_remove(self): + if self._app_exists(): + common.print_info("Application exists") + # TODO get info from oc status and delete relevat svc/dc/is + for ns in ['svc', 'dc', 'is']: + self._remove_apps_from_openshift_namespaces(ns) + + def _create_app(self): + common.print_info("Create_app in OpenShift") + # Switching to system user + #self._openshift_login(oc_user='system', oc_passwd='admin') + common.print_debug(self.container_name, self.app_name) + oc_new_app = self.runHost("oc new-app --docker-image=%s --name=%s" % (self.container_name, + self.app_name), + ignore_status=True) + # Switching back to developer user + #self._openshift_login() + common.print_info(oc_new_app) + + def setUp(self): + """ + It is called by child class and it is same methof as Avocado/Unittest has. It prepares environment + for docker testing + * start docker if not + * pull docker image + * setup environment from config + * run and store identification + + :return: None + """ + self.icontainer = self.get_url() + self.containerInfo = self.__load_inspect_json() + + def _openshift_login(self, oc_ip="127.0.0.1", oc_user='developer', oc_passwd='developer', env=False): + if env: + if 'OPENSHIFT_IP' in os.environ: + oc_ip = os.environ.get('OPENSHIFT_IP') + if 'OPENSHIFT_USER' in os.environ: + oc_user = os.environ.get('OPENSHIFT_USER') + if 'OPENSHIFT_PWD' in os.environ: + oc_passwd = os.environ.get('OPENSHIFT_PWD') + + oc_output = self.runHost("oc login %s:8443 --username=%s --password=%s" % (oc_ip, + oc_user, + oc_passwd), + verbose=common.is_not_silent()) + common.print_debug(oc_output.stderr) + common.print_debug(oc_output.stdout) + return oc_output.exit_status + + def tearDown(self): + """ + Cleanup environment and call also cleanup from config + + :return: None + """ + super(OpenShiftHelper, self).tearDown() + if common.get_if_do_cleanup(): + common.print_info("To run a command inside a container execute: ", + "docker exec %s /bin/bash" % self.docker_id) + + def __load_inspect_json(self): + """ + Load json data from docker inspect command + + :return: dict + """ + return json.loads( + self.runHost( + "docker inspect %s" % + self.container_name, verbose=common.is_not_silent()).stdout)[0]["Config"] + + def _get_ip_instance(self): + """ + Function return IP address of OpenShift POD. + :return: + """ + oc_get_service = self.runHost("oc get service -o json") + service = self._convert_string_to_json(oc_get_service.stdout) + try: + common.print_info(service["items"]) + common.print_info(service["items"][0]) + common.print_info(service["items"][0]["spec"]) + self.app_ip = service["items"][0]["spec"]["clusterIP"] + common.trans_dict['GUESTIPADDR'] = self.app_ip + common.print_info(common.trans_dict) + return True + except KeyError as e: + common.print_info(e.message) + return False + + def start(self): + """ + start the OpenShift application + + :param args: Do not use it directly (It is defined in config.yaml) + :param command: Do not use it directly (It is defined in config.yaml) + :return: None + """ + common.print_info("OpenShift start") + if not self._app_exists(): + self._create_app() + self._get_ip_instance() + + def stop(self): + """ + Stop the docker container + + :return: None + """ + if self.status(): + try: + self._app_remove() + except Exception as e: + common.print_debug(e, "OpenShift application already removed") + pass + + def status(self): + """ + get status if OpenShift is running + + :return: bool + """ + if self._app_exists(): + return True + else: + return False + + def run(self, command="ls /", **kwargs): + """ + Run command inside module, all params what allows avocado are passed inside shell,ignore_status, etc. + + :param command: str + :param kwargs: dict + :return: avocado.process.run + """ + return self.runHost("%s" % common.sanitize_cmd(command), **kwargs) diff --git a/moduleframework/mtf_environment.py b/moduleframework/mtf_environment.py index eb4f234..3d9e42a 100644 --- a/moduleframework/mtf_environment.py +++ b/moduleframework/mtf_environment.py @@ -28,6 +28,7 @@ from moduleframework.environment_prepare.docker_prepare import EnvDocker from moduleframework.environment_prepare.rpm_prepare import EnvRpm from moduleframework.environment_prepare.nspawn_prepare import EnvNspawn +from moduleframework.environment_prepare.openshift_prepare import EnvOpenShift module_name = get_module_type_base() @@ -39,6 +40,8 @@ env = EnvRpm() elif module_name == "nspawn": env = EnvNspawn() +elif module_name == "openshift": + env = EnvOpenShift() def mtfenvset(): From 79a6d8a1e7575f619d07aa40739e106f72582a85 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Thu, 9 Nov 2017 10:06:52 +0100 Subject: [PATCH 067/117] Add more docu stuff and some fixes. Signed-off-by: Petr "Stone" Hracek --- docs/user_guide/environment_setup.rst | 7 ++- docs/user_guide/environment_variables.rst | 4 ++ .../avocado_testers/openshift_avocado_test.py | 15 ----- moduleframework/helpers/openshift_helper.py | 59 +++++++++++++------ 4 files changed, 50 insertions(+), 35 deletions(-) diff --git a/docs/user_guide/environment_setup.rst b/docs/user_guide/environment_setup.rst index 4a1fe20..61b0151 100644 --- a/docs/user_guide/environment_setup.rst +++ b/docs/user_guide/environment_setup.rst @@ -21,13 +21,18 @@ Manual Setup - No any configuration needed +**OpenShift** + + - Install OpenShift if not installed and if environment variable ``OPENSHIFT_LOCAL`` is specified. + - if ``OPENSHIFT_LOCAL`` variable is specified, the it starts an OpenShift by command ``oc cluster up`` or stops it by command ``oc cluster down``. + Automated Setup ~~~~~~~~~~~~~~~ The environment configuration scripts should be executed in the same directory where the tests are, otherwise the environment variable **CONFIG** should be set. - to setup environment run ``MODULE=docker mtf-env-set`` - - to execute tests run ``MODULE=docker avocado run your.test.py`` + - to execute tests run ``MODULE=docker mtf your.test.py`` - to cleanup environment ``MODULE=docker mtf-env-clean`` Test Creation diff --git a/docs/user_guide/environment_variables.rst b/docs/user_guide/environment_variables.rst index 19642ce..cdec63e 100644 --- a/docs/user_guide/environment_variables.rst +++ b/docs/user_guide/environment_variables.rst @@ -25,6 +25,10 @@ Environment variables allow to overwrite some values of a module configuration f - **MTF_REMOTE_REPOS=yes** disables downloading of Koji packages and creating a local repo, and speeds up test execution. - **MTF_DISABLE_MODULE=yes** disables module handling to use nonmodular test mode (see `multihost tests`_ as an example). - **DOCKERFILE=" Date: Thu, 9 Nov 2017 12:37:23 +0100 Subject: [PATCH 068/117] Use command oc get and stdout instead of parsing json. Signed-off-by: Petr "Stone" Hracek --- docs/user_guide/environment_setup.rst | 2 +- docs/user_guide/environment_variables.rst | 8 +- docs/user_guide/index.rst | 5 +- .../avocado_testers/openshift_avocado_test.py | 2 +- moduleframework/common.py | 2 - moduleframework/helpers/openshift_helper.py | 86 ++++++++++--------- 6 files changed, 56 insertions(+), 49 deletions(-) diff --git a/docs/user_guide/environment_setup.rst b/docs/user_guide/environment_setup.rst index 61b0151..3416d4b 100644 --- a/docs/user_guide/environment_setup.rst +++ b/docs/user_guide/environment_setup.rst @@ -24,7 +24,7 @@ Manual Setup **OpenShift** - Install OpenShift if not installed and if environment variable ``OPENSHIFT_LOCAL`` is specified. - - if ``OPENSHIFT_LOCAL`` variable is specified, the it starts an OpenShift by command ``oc cluster up`` or stops it by command ``oc cluster down``. + - if ``OPENSHIFT_LOCAL`` variable is specified, then it starts an OpenShift by command ``oc cluster up`` or stops it by command ``oc cluster down``. Automated Setup ~~~~~~~~~~~~~~~ diff --git a/docs/user_guide/environment_variables.rst b/docs/user_guide/environment_variables.rst index cdec63e..ea42e6e 100644 --- a/docs/user_guide/environment_variables.rst +++ b/docs/user_guide/environment_variables.rst @@ -25,10 +25,10 @@ Environment variables allow to overwrite some values of a module configuration f - **MTF_REMOTE_REPOS=yes** disables downloading of Koji packages and creating a local repo, and speeds up test execution. - **MTF_DISABLE_MODULE=yes** disables module handling to use nonmodular test mode (see `multihost tests`_ as an example). - **DOCKERFILE=" Date: Tue, 14 Nov 2017 10:00:24 +0100 Subject: [PATCH 069/117] Fixes based on the PR comments. Signed-off-by: Petr "Stone" Hracek --- moduleframework/common.py | 70 ++++++++-- .../environment_prepare/openshift_prepare.py | 39 +----- moduleframework/helpers/container_helper.py | 2 +- moduleframework/helpers/openshift_helper.py | 120 +++++++++++------- moduleframework/helpers/rpm_helper.py | 1 + moduleframework/module_framework.py | 1 + 6 files changed, 140 insertions(+), 93 deletions(-) diff --git a/moduleframework/common.py b/moduleframework/common.py index bc1410a..84f5e7b 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -86,9 +86,11 @@ DEFAULTNSPAWNTIMEOUT = 10 MODULE_DEFAULT_PROFILE="default" + def generate_unique_name(size=10): return ''.join(random.choice(string.ascii_lowercase) for _ in range(size)) + def get_compose_url_modular_release(): default_release = "27" release = os.environ.get("MTF_FEDORA_RELEASE") or default_release @@ -99,6 +101,7 @@ def get_compose_url_modular_release(): compose_url = os.environ.get("MTF_COMPOSE_BASE") or base_url.format(release, ARCH) return compose_url + def is_debug(): """ Return the **DEBUG** envvar. @@ -117,6 +120,47 @@ def is_not_silent(): return is_debug() +def get_openshift_local(): + """ + Return the **OPENSHIFT_LOCAL** envvar. + :return: bool + """ + return bool(os.environ.get('OPENSHIFT_LOCAL')) + + +def get_openshift_ip(): + """ + Return the **OPENSHIFT_IP** envvar or None. + :return: OpenShift IP or None + """ + try: + return os.environ.get('OPENSHIFT_IP') + except KeyError: + return None + + +def get_openshift_user(): + """ + Return the **OPENSHIFT_USER** envvar or None. + :return: OpenShift User or None + """ + try: + return os.environ.get('OPENSHIFT_USER') + except KeyError: + return None + + +def get_openshift_passwd(): + """ + Return the **OPENSHIFT_PASSWORD** envvar or None. + :return: OpenShift password or None + """ + try: + return os.environ.get('OPENSHIFT_PASSWORD') + except KeyError: + return None + + def print_info(*args): """ Print information from the expected stdout and @@ -178,6 +222,7 @@ def is_recursive_download(): """ return bool(os.environ.get("MTF_RECURSIVE_DOWNLOAD")) + def get_if_do_cleanup(): """ Return the **MTF_DO_NOT_CLEANUP** envvar. @@ -187,6 +232,7 @@ def get_if_do_cleanup(): cleanup = os.environ.get('MTF_DO_NOT_CLEANUP') return not bool(cleanup) + def get_if_reuse(): """ Return the **MTF_REUSE** envvar. @@ -196,6 +242,7 @@ def get_if_reuse(): reuse = os.environ.get('MTF_REUSE') return bool(reuse) + def get_if_remoterepos(): """ Return the **MTF_REMOTE_REPOS** envvar. @@ -223,8 +270,8 @@ def sanitize_text(text, replacement="_", invalid_chars=["/", ";", "&", ">", "<", invalid_chars=["/", ";", "&", ">", "<", "|"] - :param (str): text to sanitize - :param (str): replacement char, default: "_" + :param replacement: text to sanitize + :param invalid_chars: replacement char, default: "_" :return: str """ for char in invalid_chars: @@ -237,7 +284,7 @@ def sanitize_cmd(cmd): """ Escape apostrophes in a command line. - :param (str): command to sanitize + :param cmd: command to sanitize :return: str """ if '"' in cmd: @@ -258,6 +305,7 @@ def translate_cmd(cmd, translation_dict=None): % (translation_dict, cmd)) return formattedcommand + def get_profile(): """ Return a profile name. @@ -340,9 +388,9 @@ def loadconfig(self): """ # we have to copy object. because there is just one global object, to improve performance self.config = copy.deepcopy(get_config()) - self.info = self.config.get("module",{}).get(get_module_type_base()) + self.info = self.config.get("module", {}).get(get_module_type_base()) # if there is inheritance join both dictionary - self.info.update(self.config.get("module",{}).get(get_module_type())) + self.info.update(self.config.get("module", {}).get(get_module_type())) if not self.info: raise ConfigExc("There is no section for (module: -> %s:) in the configuration file." % get_module_type_base()) @@ -401,7 +449,7 @@ def runHost(self, command="ls /", **kwargs): """ Run commands on a host. - :param (str): command to exectute + :param common: command to exectute ** kwargs: avocado process.run params like: shell, ignore_status, verbose :return: avocado.process.run """ @@ -451,15 +499,15 @@ def getPackageList(self, profile=None): if 'packages' in self.config: packages_rpm = self.config.get('packages',{}).get('rpms', []) packages_profiles = [] - for profile_in_conf in self.config.get('packages',{}).get('profiles',[]): + for profile_in_conf in self.config.get('packages', {}).get('profiles', []): packages_profiles += mddata['data']['profiles'][profile_in_conf]['rpms'] package_list += packages_rpm + packages_profiles if get_if_install_default_profile(): - profile_append = mddata.get('data',{})\ - .get('profiles',{}).get(MODULE_DEFAULT_PROFILE,{}).get('rpms',[]) + profile_append = mddata.get('data', {})\ + .get('profiles', {}).get(MODULE_DEFAULT_PROFILE, {}).get('rpms', []) package_list += profile_append else: - package_list += mddata['data']['profiles'][profile].get('rpms',[]) + package_list += mddata['data']['profiles'][profile].get('rpms', []) print_info("PCKGs to install inside module:", package_list) return package_list @@ -513,8 +561,6 @@ def getIPaddr(self): :return: str """ - if 'openshift' == os.environ.get('MODULE'): - return trans_dict['GUESTIPADDR'] return self.ipaddr def _callSetupFromConfig(self): diff --git a/moduleframework/environment_prepare/openshift_prepare.py b/moduleframework/environment_prepare/openshift_prepare.py index 0938a2f..90ba6c6 100644 --- a/moduleframework/environment_prepare/openshift_prepare.py +++ b/moduleframework/environment_prepare/openshift_prepare.py @@ -37,44 +37,11 @@ class EnvOpenShift(CommonFunctions): def prepare_env(self): common.print_info('Loaded config for name: {}'.format(self.config['name'])) - self.__prepare_selinux() self.__start_openshift_cluster() def cleanup_env(self): self.__stop_openshift_cluster() - def __prepare_selinux(self): - # disable selinux by default if not turned off - if not os.environ.get('MTF_SKIP_DISABLING_SELINUX'): - # https://github.com/fedora-modularity/meta-test-family/issues/53 - # workaround because systemd nspawn is now working well in F-26 - if not os.path.exists(selinux_state_file): - common.print_info("Disabling selinux") - actual_state = self.runHost("getenforce", ignore_status=True).stdout.strip() - with open(selinux_state_file, 'w') as openfile: - openfile.write(actual_state) - if setseto not in actual_state: - self.runHost("setenforce %s" % setseto, - verbose=common.is_not_silent(), - sudo=True) - - def __cleanup(self): - if not os.environ.get('MTF_SKIP_DISABLING_SELINUX'): - common.print_info("Turning back selinux to previous state") - actual_state = self.runHost("getenforce", ignore_status=True).stdout.strip() - if os.path.exists(selinux_state_file): - common.print_info("Turning back selinux to previous state") - with open(selinux_state_file, 'r') as openfile: - stored_state = openfile.readline() - if stored_state != actual_state: - self.runHost("setenforce %s" % stored_state, - ignore_status=True, - verbose=common.is_not_silent(), - sudo=True) - os.remove(selinux_state_file) - else: - common.print_info("Selinux state is not stored, skipping.") - def __oc_status(self): oc_status = self.runHost("oc status", ignore_status=True, verbose=common.is_not_silent()) common.print_debug(oc_status.stdout) @@ -87,7 +54,7 @@ def __install_env(self): :return: None """ - if os.environ.get('OPENSHIFT_LOCAL'): + if common.get_openshift_local(): if not os.path.exists('/usr/bin/oc'): self.installTestDependencies(['origin', 'origin-clients']) @@ -98,7 +65,7 @@ def __start_openshift_cluster(self): :return: None """ - if os.environ.get('OPENSHIFT_LOCAL'): + if common.get_openshift_local(): if int(self.__oc_status()) == 0: common.print_info("Seems like OpenShift is already started.") else: @@ -111,7 +78,7 @@ def __stop_openshift_cluster(self): :return: None """ - if os.environ.get('OPENSHIFT_LOCAL'): + if common.get_openshift_local(): if int(self.__oc_status()) == 0: common.print_info("Stopping OpenShift") self.runHost("oc cluster down", verbose=common.is_not_silent()) diff --git a/moduleframework/helpers/container_helper.py b/moduleframework/helpers/container_helper.py index 596214e..895a96b 100644 --- a/moduleframework/helpers/container_helper.py +++ b/moduleframework/helpers/container_helper.py @@ -22,6 +22,7 @@ import json from moduleframework.common import * +from moduleframework.mtfexceptions import ContainerExc class ContainerHelper(CommonFunctions): @@ -125,7 +126,6 @@ def __load_inspect_json(self): "docker inspect %s" % self.jmeno, verbose=is_not_silent()).stdout)[0]["Config"] - def start(self, args="-it -d", command="/bin/bash"): """ start the docker container diff --git a/moduleframework/helpers/openshift_helper.py b/moduleframework/helpers/openshift_helper.py index 4938404..c090190 100644 --- a/moduleframework/helpers/openshift_helper.py +++ b/moduleframework/helpers/openshift_helper.py @@ -24,11 +24,11 @@ import os import time from moduleframework import common -from moduleframework.common import CommonFunctions -from moduleframework.mtfexceptions import ContainerExc, ConfigExc +from moduleframework.helpers.container_helper import ContainerHelper +from moduleframework.mtfexceptions import ConfigExc -class OpenShiftHelper(CommonFunctions): +class OpenShiftHelper(ContainerHelper): """ Basic Helper class for OpenShift container module type @@ -41,8 +41,8 @@ def __init__(self): """ super(OpenShiftHelper, self).__init__() self.name = None - self.docker_id = None self.icontainer = self.get_url() + self.pod_id = None if not self.icontainer: raise ConfigExc("No container image specified in the configuration file or environment variable.") if "docker=" in self.icontainer: @@ -80,19 +80,46 @@ def _app_exists(self): if 'dc/%s' % self.app_name in oc_status.stdout: common.print_info("Application already exists.") return True - oc_services = self.runHost("oc get services -o json") - json_svc = self._convert_string_to_json(oc_services.stdout) - if not json_svc["items"]: + oc_services = self.runHost("oc get services -o json", ignore_status=True).stdout + oc_services = self._convert_string_to_json(oc_services) + # Check if 'items' in json output is empty or not + if not oc_services: + return False + # check if 'items', which is not empty, in json output contains app_name + if not self._check_app_in_json(oc_services, self.app_name): return False return True - def _convert_string_to_json(self, string): + def _check_app_in_json(self, json_output, app_name): """ - It converts a string to json format - :param string: String to format to json - :return: json output + Function checks if json_output contains container with specified name + + + :param json_output: json output from an OpenShift command + :param app_name: an application which should be checked + :return: True if the application exists + False if the application does not exist """ - return json.loads(string) + try: + labels = json_output.get('metadata').get('labels') + if labels.get('app') == app_name: + # In metadata dictionary and name is stored pod_name + self.pod_id = json_output.get('metadata').get('name') + return True + except KeyError: + return False + + def _convert_string_to_json(self, inp_string): + """ + It converts a string to json format and returns first item in items. + :param inp_string: String to format to json + :return: items from OpenShift output + """ + try: + items = json.loads(inp_string) + return items.get('items') + except TypeError: + return None def _remove_apps_from_openshift_namespaces(self, oc_service="svc"): """ @@ -100,15 +127,18 @@ def _remove_apps_from_openshift_namespaces(self, oc_service="svc"): :param oc_service: Service from which we would like to remove application """ # Check status of svc/dc/is - oc_get = self.runHost("oc get %s" % oc_service, ignore_status=True).stdout + oc_get = self.runHost("oc get %s -o json" % oc_service, ignore_status=True).stdout + oc_get = self._convert_string_to_json(oc_get) # The output is like # dovecot 172.30.1.1:5000/myproject/dovecot latest 15 minutes ago # memcached 172.30.1.1:5000/myproject/memcached latest 13 minutes ago - app_found = [x for x in oc_get.split('\n') if x.startswith(self.app_name)] + app_found = self._check_app_in_json(oc_get, self.app_name) if app_found: # If application exists in svc / dc / is namespace, then remove it - oc_delete = self.runHost("oc delete %s/%s" % (oc_service, self.app_name), ignore_status=True) + oc_delete = self.runHost("oc delete %s %s" % (oc_service, self.app_name), + ignore_status=True, + verbose=common.is_not_silent()) def _app_remove(self): """ @@ -124,8 +154,8 @@ def _create_app(self): It creates an application in OpenShift environment """ # Switching to system user - oc_new_app = self.runHost("oc new-app %s --name=%s" % (self.container_name, - self.app_name), + oc_new_app = self.runHost("oc new-app -l mtf_testing=true %s --name=%s" % (self.container_name, + self.app_name), ignore_status=True) common.print_info(oc_new_app.stdout) time.sleep(1) @@ -138,14 +168,17 @@ def _verify_pod(self): """ pod_initiated = False for x in range(0, 20): - pod_state = self.runHost("oc get pods", ignore_status=True) - pod_state = pod_state.stdout.split('\n') + # We need wait a second before pod is really initiated. + time.sleep(1) + pod_state = self.runHost("oc get pods -o json", + ignore_status=True, + verbose=common.is_not_silent()) + pod_state = self._convert_string_to_json(pod_state.stdout) for pod in pod_state: - if pod.startswith(self.app_name): - if "Running" in pod and "deploy" not in pod: + if self._check_app_in_json(pod, self.app_name): + if pod.get('status', {}).get('phase') == "Running": pod_initiated = True break - time.sleep(1) if pod_initiated: break return pod_initiated @@ -169,22 +202,17 @@ def _openshift_login(self, oc_ip="127.0.0.1", oc_user='developer', oc_passwd='de :param oc_ip: an IP where is an OpenShift environment running :param oc_user: an username under which we can login to OpenShift environment :param oc_passwd: a password for specific username - :param env: + :param env: is used for specification OpenShift IP, user and password, otherwise defaults are used :return: """ if env: - if 'OPENSHIFT_IP' in os.environ: - oc_ip = os.environ.get('OPENSHIFT_IP') - if 'OPENSHIFT_USER' in os.environ: - oc_user = os.environ.get('OPENSHIFT_USER') - if 'OPENSHIFT_PASSWORD' in os.environ: - oc_passwd = os.environ.get('OPENSHIFT_PASSWORD') - + oc_ip = common.get_openshift_ip() + oc_user = common.get_openshift_user() + oc_passwd = common.get_openshift_passwd() oc_output = self.runHost("oc login %s:8443 --username=%s --password=%s" % (oc_ip, oc_user, oc_passwd), verbose=common.is_not_silent()) - common.print_debug(oc_output.stdout) return oc_output.exit_status def tearDown(self): @@ -194,9 +222,6 @@ def tearDown(self): :return: None """ super(OpenShiftHelper, self).tearDown() - if common.get_if_do_cleanup(): - # TODO will be implemented later on. I have to find a usecase what to remove - pass def _get_ip_instance(self): """ @@ -207,9 +232,10 @@ def _get_ip_instance(self): oc_get_service = self.runHost("oc get service -o json") service = self._convert_string_to_json(oc_get_service.stdout) try: - for svc in service["items"]: - if "clusterIP" in svc.get("spec"): - common.trans_dict['GUESTIPADDR'] = svc.get("spec").get("clusterIP") + for svc in service: + if svc.get('metadata').get('labels').get('app') == self.app_name: + self.ipaddr = svc.get('spec').get("clusterIP") + common.trans_dict['GUESTIPADDR'] = self.ipaddr return True except KeyError as e: common.print_info(e.message) @@ -218,6 +244,16 @@ def _get_ip_instance(self): common.print_info(e.message) return False + def getIPaddr(self): + """ + Return protocol (IP or IPv6) address on a POD OpenShift instance. + + It returns IP address of POD instance + + :return: str + """ + return self.ipaddr + def start(self): """ starts the OpenShift application @@ -230,8 +266,7 @@ def start(self): self._create_app() # Verify application is really deploy and prepared for testing. self._verify_pod() - # TODO fix in case IP is not present. We should failed. - self._get_ip_instance() + self._get_ip_instance() def stop(self): """ @@ -248,14 +283,11 @@ def stop(self): def status(self): """ - get status of an OpenShift + get status of an application in OpenShift environment :return: bool """ - if self._app_exists(): - return True - else: - return False + return self._app_exists() def run(self, command="ls /", **kwargs): """ diff --git a/moduleframework/helpers/rpm_helper.py b/moduleframework/helpers/rpm_helper.py index a36d898..2691738 100644 --- a/moduleframework/helpers/rpm_helper.py +++ b/moduleframework/helpers/rpm_helper.py @@ -23,6 +23,7 @@ from moduleframework import pdc_data from moduleframework.common import * + class RpmHelper(CommonFunctions): """ Class for testing "modules" on local machine (host) directly. It could be used for scheduling tests for diff --git a/moduleframework/module_framework.py b/moduleframework/module_framework.py index ed40a0c..78be9aa 100644 --- a/moduleframework/module_framework.py +++ b/moduleframework/module_framework.py @@ -30,6 +30,7 @@ from moduleframework.avocado_testers.container_avocado_test import ContainerAvocadoTest from moduleframework.avocado_testers.nspawn_avocado_test import NspawnAvocadoTest from moduleframework.avocado_testers.rpm_avocado_test import RpmAvocadoTest +from moduleframework.avocado_testers.openshift_avocado_test import OpenShiftAvocadoTest from moduleframework.mtfexceptions import ModuleFrameworkException PROFILE = None From 67d8ef4a479e71159b8acfd81719ae81b9b187a9 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 14 Nov 2017 10:10:38 +0100 Subject: [PATCH 070/117] Better check if application exists Signed-off-by: Petr "Stone" Hracek --- moduleframework/helpers/openshift_helper.py | 29 +++++++++------------ 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/moduleframework/helpers/openshift_helper.py b/moduleframework/helpers/openshift_helper.py index c090190..daddfaa 100644 --- a/moduleframework/helpers/openshift_helper.py +++ b/moduleframework/helpers/openshift_helper.py @@ -55,14 +55,6 @@ def __init__(self): self.app_ip = None common.print_debug(self.icontainer, self.app_name) - def get_container_url(self): - """ - It returns actual URL link string to container, It is same as URL - - :return: str - """ - return self.icontainer - def get_docker_instance_name(self): """ Return docker instance name what will be used inside docker as docker image name @@ -76,8 +68,8 @@ def _app_exists(self): :return: True, application exists False, application does not exist """ - oc_status = self.runHost("oc status", ignore_status=True) - if 'dc/%s' % self.app_name in oc_status.stdout: + oc_status = self.runHost("oc get dc %s -o json" % self.app_name, ignore_status=True) + if int(oc_status.exit_status) == 0: common.print_info("Application already exists.") return True oc_services = self.runHost("oc get services -o json", ignore_status=True).stdout @@ -133,12 +125,12 @@ def _remove_apps_from_openshift_namespaces(self, oc_service="svc"): # dovecot 172.30.1.1:5000/myproject/dovecot latest 15 minutes ago # memcached 172.30.1.1:5000/myproject/memcached latest 13 minutes ago - app_found = self._check_app_in_json(oc_get, self.app_name) - if app_found: - # If application exists in svc / dc / is namespace, then remove it - oc_delete = self.runHost("oc delete %s %s" % (oc_service, self.app_name), - ignore_status=True, - verbose=common.is_not_silent()) + for item in oc_get: + if self._check_app_in_json(item, self.app_name): + # If application exists in svc / dc / is namespace, then remove it + oc_delete = self.runHost("oc delete %s %s" % (oc_service, self.app_name), + ignore_status=True, + verbose=common.is_not_silent()) def _app_remove(self): """ @@ -222,6 +214,11 @@ def tearDown(self): :return: None """ super(OpenShiftHelper, self).tearDown() + try: + self._app_remove() + except Exception as e: + common.print_info(e, "OpenShift application already removed") + pass def _get_ip_instance(self): """ From 6fa73d9effba6f01ad667edd714ccff93e556557 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 14 Nov 2017 12:33:15 +0100 Subject: [PATCH 071/117] Support run command. Signed-off-by: Petr "Stone" Hracek --- moduleframework/helpers/openshift_helper.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/moduleframework/helpers/openshift_helper.py b/moduleframework/helpers/openshift_helper.py index daddfaa..5244d97 100644 --- a/moduleframework/helpers/openshift_helper.py +++ b/moduleframework/helpers/openshift_helper.py @@ -288,10 +288,13 @@ def status(self): def run(self, command="ls /", **kwargs): """ - Run command inside module, all params what allows avocado are passed inside shell,ignore_status, etc. + Run command inside OpenShift POD, all params what allows avocado are passed inside shell,ignore_status, etc. + https://docs.openshift.com/container-platform/3.6/dev_guide/executing_remote_commands.html :param command: str :param kwargs: dict :return: avocado.process.run """ - return self.runHost("%s" % common.sanitize_cmd(command), **kwargs) + return self.runHost("oc exec %s %s" % (self.pod_id, + common.sanitize_cmd(command)), + **kwargs) From 925017ebe182f4ef8bf019d8a1feb4d3b9dc2309 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Wed, 15 Nov 2017 14:26:27 +0100 Subject: [PATCH 072/117] Updating docstring and adding pod functions Signed-off-by: Petr "Stone" Hracek --- moduleframework/helpers/openshift_helper.py | 66 ++++++++++++--------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/moduleframework/helpers/openshift_helper.py b/moduleframework/helpers/openshift_helper.py index 5244d97..ae158a4 100644 --- a/moduleframework/helpers/openshift_helper.py +++ b/moduleframework/helpers/openshift_helper.py @@ -43,6 +43,7 @@ def __init__(self): self.name = None self.icontainer = self.get_url() self.pod_id = None + self._pod_status = None if not self.icontainer: raise ConfigExc("No container image specified in the configuration file or environment variable.") if "docker=" in self.icontainer: @@ -55,13 +56,6 @@ def __init__(self): self.app_ip = None common.print_debug(self.icontainer, self.app_name) - def get_docker_instance_name(self): - """ - Return docker instance name what will be used inside docker as docker image name - :return: str - """ - return self.container_name - def _app_exists(self): """ It checks if an application already exists in OpenShift environment @@ -152,6 +146,26 @@ def _create_app(self): common.print_info(oc_new_app.stdout) time.sleep(1) + def _get_pod_status(self): + """ + This method checks if the POD is running within OpenShift environment. + :return: True if POD is running with status "Running" + False all other statuses + """ + pod_initiated = False + pod_state = self.runHost("oc get pods -o json", + ignore_status=True, + verbose=common.is_not_silent()) + + pod_state = self._convert_string_to_json(pod_state.stdout) + for pod in pod_state: + if self._check_app_in_json(pod, self.app_name): + self._pod_status = pod.get('status').get('phase') + if self._pod_status == "Running": + pod_initiated = True + break + return pod_initiated + def _verify_pod(self): """ It verifies if an application POD is initiated and ready for testing @@ -162,30 +176,19 @@ def _verify_pod(self): for x in range(0, 20): # We need wait a second before pod is really initiated. time.sleep(1) - pod_state = self.runHost("oc get pods -o json", - ignore_status=True, - verbose=common.is_not_silent()) - pod_state = self._convert_string_to_json(pod_state.stdout) - for pod in pod_state: - if self._check_app_in_json(pod, self.app_name): - if pod.get('status', {}).get('phase') == "Running": - pod_initiated = True - break - if pod_initiated: + if self._get_pod_status(): break return pod_initiated def setUp(self): """ It is called by child class and it is same methof as Avocado/Unittest has. It prepares environment - for docker testing - * start docker if not - * pull docker image + for OpenShift testing * setup environment from config - * run and store identification :return: None """ + self._callSetupFromConfig() self.icontainer = self.get_url() def _openshift_login(self, oc_ip="127.0.0.1", oc_user='developer', oc_passwd='developer', env=False): @@ -222,7 +225,8 @@ def tearDown(self): def _get_ip_instance(self): """ - It gets and IP address of an application from of OpenShift POD. + This method verifies that we can obtain an IP address of the application + deployed within OpenShift. :return: True: getting IP address was successful False: getting IP address was not successful """ @@ -255,7 +259,6 @@ def start(self): """ starts the OpenShift application - :param args: Do not use it directly (It is defined in config.yaml) :param command: Do not use it directly (It is defined in config.yaml) :return: None """ @@ -267,11 +270,12 @@ def start(self): def stop(self): """ - Stops the OpenShift + This method checks if the application is deployed within OpenShift environment + and removes service, deployment config and imagestream from OpenShift. :return: None """ - if self.status(): + if self._app_exists(): try: self._app_remove() except Exception as e: @@ -280,11 +284,17 @@ def stop(self): def status(self): """ - get status of an application in OpenShift environment + Function returns whether the application exists + and is Running in OpenShift environment - :return: bool + :return: True application exists + False application does not exist. """ - return self._app_exists() + status = False + if self._app_exists(): + if self._get_pod_status(): + status = True + return status def run(self, command="ls /", **kwargs): """ From 5e4a5f2fbabb00fb58d90f2e9cc4963f94101c72 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 16 Nov 2017 13:14:40 +0100 Subject: [PATCH 073/117] add some tags to modulelint tests, to be able to filter them --- moduleframework/tools/__init__.py | 1 + moduleframework/tools/check_compose.py | 1 + moduleframework/tools/dockerlint.py | 6 ++++-- moduleframework/tools/helpmd_lint.py | 2 +- moduleframework/tools/modulelint.py | 5 ++++- moduleframework/tools/rpmvalidation.py | 3 ++- 6 files changed, 13 insertions(+), 5 deletions(-) diff --git a/moduleframework/tools/__init__.py b/moduleframework/tools/__init__.py index 115cecc..8906e36 100644 --- a/moduleframework/tools/__init__.py +++ b/moduleframework/tools/__init__.py @@ -28,6 +28,7 @@ class Basic(module_framework.AvocadoTest): """ :avocado: enable + :avocado: tags=sanity,general,fedora,rhel """ def test(self): diff --git a/moduleframework/tools/check_compose.py b/moduleframework/tools/check_compose.py index 13b5799..26773f5 100644 --- a/moduleframework/tools/check_compose.py +++ b/moduleframework/tools/check_compose.py @@ -33,6 +33,7 @@ class ComposeTest(module_framework.NspawnAvocadoTest): Validate overall module compose. :avocado: enable + :avocado: tags=sanity,rhel,fedora,compose_test,module """ def test_component_profile_installability(self): diff --git a/moduleframework/tools/dockerlint.py b/moduleframework/tools/dockerlint.py index 7166fc1..9315570 100644 --- a/moduleframework/tools/dockerlint.py +++ b/moduleframework/tools/dockerlint.py @@ -31,7 +31,7 @@ class DockerInstructionsTests(module_framework.AvocadoTest): """ :avocado: enable - :avocado: tags=sanity + :avocado: tags=sanity,rhel,fedora,docker,docker_instruction_test """ @@ -65,7 +65,7 @@ def test_helpmd_is_present(self): class DockerLabelsTests(module_framework.AvocadoTest): """ :avocado: enable - :avocado: sanity + :avocado: tags=sanity,rhel,fedora,docker,docker_labels_test """ @@ -119,6 +119,7 @@ def test_run_or_usage_label_exists(self): class DockerfileLinterInContainer(container_avocado_test.ContainerAvocadoTest): """ :avocado: enable + :avocado: tags=sanity,rhel,fedora,docker,docker_lint_inside_test """ @@ -215,6 +216,7 @@ def test_docker_clean_all(self): class DockerLint(container_avocado_test.ContainerAvocadoTest): """ :avocado: enable + :avocado: tags=sanity,rhel,fedora,docker,docker_labels_inspect_test """ def testLabels(self): diff --git a/moduleframework/tools/helpmd_lint.py b/moduleframework/tools/helpmd_lint.py index ccb00b8..964b1c8 100644 --- a/moduleframework/tools/helpmd_lint.py +++ b/moduleframework/tools/helpmd_lint.py @@ -32,7 +32,7 @@ class HelpFileSanity(module_framework.AvocadoTest): """ :avocado: enable - :avocado: optional + :avocado: tags=optional,rhel,fedora,docker,helpmd_sanity_test """ diff --git a/moduleframework/tools/modulelint.py b/moduleframework/tools/modulelint.py index 0f6db68..2402246 100644 --- a/moduleframework/tools/modulelint.py +++ b/moduleframework/tools/modulelint.py @@ -28,7 +28,7 @@ class ModuleLintSigning(module_framework.AvocadoTest): """ :avocado: disable - :avocado: tags=WIP + :avocado: tags=WIP,rhel,fedora,docker,module,package_signing_test """ def setUp(self): @@ -53,7 +53,10 @@ def test(self): class ModuleLintPackagesCheck(module_framework.AvocadoTest): """ + Check if packages what are expected to be installed all installed + :avocado: enable + :avocado: tags=sanity,rhel,fedora,docker,module,package_installed_test """ def test(self): diff --git a/moduleframework/tools/rpmvalidation.py b/moduleframework/tools/rpmvalidation.py index 9f110cf..4ec5ad1 100644 --- a/moduleframework/tools/rpmvalidation.py +++ b/moduleframework/tools/rpmvalidation.py @@ -35,6 +35,7 @@ class rpmvalidation(module_framework.AvocadoTest): http://refspecs.linuxfoundation.org/FHS_3.0/fhs/index.html :avocado: enable + :avocado: tags=sanity,rhel,fedora,docker,module,rpmvalidation_test """ fhs_base_paths_workaound = [ '/var/kerberos', @@ -93,7 +94,7 @@ def _compare_fhs(self, filepath): self.log.info("%s not found in %s" % (filepath, self.fhs_base_paths)) return False - def test(self): + def testPaths(self): self.start() allpackages = filter(bool, self.run("rpm -qa").stdout.split("\n")) common.print_debug(allpackages) From e830eefab2192af833c0e6941bc14c9e71da0f3e Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 16 Nov 2017 14:30:53 +0100 Subject: [PATCH 074/117] fix profile handling --- moduleframework/common.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/moduleframework/common.py b/moduleframework/common.py index 0ec6776..a7564bd 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -200,8 +200,8 @@ def get_if_install_default_profile(): :return: bool """ - reuse = os.environ.get('MTF_INSTALL_DEFAULT') - return bool(reuse) + envvar = os.environ.get('MTF_INSTALL_DEFAULT') + return bool(envvar) def is_recursive_download(): @@ -305,10 +305,8 @@ def get_profile(): :return: str """ - profile = os.environ.get('PROFILE') - if not profile: - profile = "default" - return profile + + return os.environ.get('PROFILE') or MODULE_DEFAULT_PROFILE def get_url(): @@ -494,7 +492,7 @@ def getPackageList(self, profile=None): package_list += packages_rpm + packages_profiles if get_if_install_default_profile(): profile_append = mddata.get('data', {})\ - .get('profiles', {}).get(MODULE_DEFAULT_PROFILE, {}).get('rpms', []) + .get('profiles', {}).get(get_profile(), {}).get('rpms', []) package_list += profile_append else: package_list += mddata['data']['profiles'][profile].get('rpms', []) From f2cb485cb3a9f35ce0a9c70b19eb29be9300ffce Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 9 Nov 2017 07:01:38 +0100 Subject: [PATCH 075/117] test metadata support tool for MTF documentation about scope/out of scope call autopep8 tool, to improve code quality to metadata branch remove own implementation print_debug - it is fixed in devel branch adapt Tomas's suggested changes and more docs to example full config --- .travis.yml | 3 +- mtf/metadata/Makefile | 25 ++ mtf/metadata/README.md | 157 ++++++++ mtf/metadata/__init__.py | 4 + .../tests/fedora_specifictest.sh | 3 + .../general-component/tests/generaltest.py | 1 + .../general-component/tests/metadata.yaml | 70 ++++ .../tests/sanity/SSSSSS/metadata.yaml | 3 + .../tests/sanity/generaltest.py | 1 + .../tests/sanity/metadata.yaml | 10 + .../examples/general-simple/metadata.yaml | 5 + mtf/metadata/examples/mtf-clean/tests/all.py | 50 +++ .../examples/mtf-clean/tests/dockerlinter.py | 33 ++ .../examples/mtf-clean/tests/metadata.yaml | 2 + mtf/metadata/examples/mtf-clean/tests/none.py | 50 +++ mtf/metadata/examples/mtf-clean/tests/some.py | 55 +++ .../examples/mtf-component/tests/all.py | 50 +++ .../mtf-component/tests/dockerlinter.py | 33 ++ .../mtf-component/tests/metadata.yaml | 7 + .../examples/mtf-component/tests/none.py | 50 +++ .../examples/mtf-component/tests/some.py | 55 +++ mtf/metadata/setup.py | 84 ++++ mtf/metadata/tmet/__init__.py | 0 mtf/metadata/tmet/agregator.py | 54 +++ mtf/metadata/tmet/common.py | 374 ++++++++++++++++++ mtf/metadata/tmet/filter.py | 62 +++ mtf/metadata/tmet/selftests.py | 197 +++++++++ mtf/{meta-test => metatest}/__init__.py | 0 28 files changed, 1437 insertions(+), 1 deletion(-) create mode 100644 mtf/metadata/Makefile create mode 100644 mtf/metadata/README.md create mode 100644 mtf/metadata/__init__.py create mode 100644 mtf/metadata/examples/general-component/tests/fedora_specifictest.sh create mode 100644 mtf/metadata/examples/general-component/tests/generaltest.py create mode 100644 mtf/metadata/examples/general-component/tests/metadata.yaml create mode 100644 mtf/metadata/examples/general-component/tests/sanity/SSSSSS/metadata.yaml create mode 100644 mtf/metadata/examples/general-component/tests/sanity/generaltest.py create mode 100644 mtf/metadata/examples/general-component/tests/sanity/metadata.yaml create mode 100644 mtf/metadata/examples/general-simple/metadata.yaml create mode 100644 mtf/metadata/examples/mtf-clean/tests/all.py create mode 100644 mtf/metadata/examples/mtf-clean/tests/dockerlinter.py create mode 100644 mtf/metadata/examples/mtf-clean/tests/metadata.yaml create mode 100644 mtf/metadata/examples/mtf-clean/tests/none.py create mode 100644 mtf/metadata/examples/mtf-clean/tests/some.py create mode 100644 mtf/metadata/examples/mtf-component/tests/all.py create mode 100644 mtf/metadata/examples/mtf-component/tests/dockerlinter.py create mode 100644 mtf/metadata/examples/mtf-component/tests/metadata.yaml create mode 100644 mtf/metadata/examples/mtf-component/tests/none.py create mode 100644 mtf/metadata/examples/mtf-component/tests/some.py create mode 100644 mtf/metadata/setup.py create mode 100644 mtf/metadata/tmet/__init__.py create mode 100644 mtf/metadata/tmet/agregator.py create mode 100644 mtf/metadata/tmet/common.py create mode 100644 mtf/metadata/tmet/filter.py create mode 100644 mtf/metadata/tmet/selftests.py rename mtf/{meta-test => metatest}/__init__.py (100%) diff --git a/.travis.yml b/.travis.yml index 162adf6..fe957b6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ services: before_install: - sudo apt-get -y install curl python-software-properties software-properties-common python-pip python-dev build-essential git make - - sudo apt-get -y install libkrb5-dev netcat mysql-client-5.5 + - sudo apt-get -y install libkrb5-dev netcat mysql-client-5.5 python-pytest - sudo pip install avocado-framework - pip install avocado-framework @@ -24,5 +24,6 @@ script: - sudo make travis - sudo make -C examples/testing-module check-real-rpm-destructive - sudo make -C examples/testing-module check-docker-scl-multi-travis + - sudo make -C mtf/metadata check after_script: sudo cat ~/avocado/job-results/latest/job.log diff --git a/mtf/metadata/Makefile b/mtf/metadata/Makefile new file mode 100644 index 0000000..f9a18f4 --- /dev/null +++ b/mtf/metadata/Makefile @@ -0,0 +1,25 @@ +PYTHONSITE=/usr/lib/python2.7/site-packages + +all: check + +integrationtests: install + cd examples/general-component/tests; tmet-agregator + cd examples/general-component/tests; tmet-agregator |grep -o 60 + cd examples/general-component/tests; tmet-filter + cd examples/general-component/tests; tmet-filter | grep 'sanity/generaltest.py' + +unittests: + py.test tmet/selftests.py + +check: install unittests integrationtests + +clean: + pip uninstall . + +install: clean + pip install -U . + +source: clean + @python setup.py sdist + +.PHONY: check \ No newline at end of file diff --git a/mtf/metadata/README.md b/mtf/metadata/README.md new file mode 100644 index 0000000..60d6c6a --- /dev/null +++ b/mtf/metadata/README.md @@ -0,0 +1,157 @@ +# Upstream Test Metadata PoC +This project defines file strucuture and tooling to work with general test metadata. + + +## User Stories +* __US1:__ Schedule/__Filter__ testsets based on some filters because you have limited resources(eg. not have enought time, just tests what does not need network, because you are offine) + * I want to select some cases based on tags + * I want to select some cases based on relevancy +* __US2:__ Find/__Agregate__ uncovered parts in testsuite to write new tests, or report status of testsuite to managers + * I want to see acual coverage for component + * I want to see uncovered parts +* __US3:__ General format allows us more, Describing tests inside source code is `programming lang` specific and harder to handle + * I want cover simple usecases in as simplest way as possible + * I want to be able to write complex structure as well + * I want to have it human readable + * I want to see some examples + +## Scope of project +* filter test set for frameworks +* create test coverage report + +### What we covers +* __Test case filtering__, based on relevancy and tags +* __Test coverage__ document and analysis. +* __Example__ [component](examples/general-component/tests) +* __Tooling__ to work with this example, to show abilities +* __Modular__ various tools can define own items for each test and parse it how you want. + +## Out of scope +* __NO schedule tests__ - it is in scope of framework: (eg. avocado, unittest, py.test, restraint) and also it is part of [Invoking test initiative](https://fedoraproject.org/wiki/Changes/InvokingTests) +* __NO dependency solving__ - each framework has own dependency solving (eg. python pypi deps, rpm dependencies in specfile) + * optional scope: it can be part of scope here after some discussion, but just as an optional feature +* __NO test linking__ - each framework has to known how to interpret filtered tests format (eg. set of: local files, python classes, URLs) + * optional scope: could be transform formats to format of selected framework (backend) (eg. download tests from URLs and store it locally) + +## How it works +* Tree structure of metadata splitted to two types +* can use one metadata file, or split metadata file to each test or use combination of both solution. +* Two type of `metadata.yaml` + * __general__ - fully descriptive file for writing general info about component and testing and whatever you want - [metadata.yaml](examples/general-component/tests/metadata.yaml) + * __test__ - basic metadata for test, it has same value as any test in `tests` element [metadata.yaml](examples/general-component/tests/sanity/metadata.yaml) + + +## Installation +``` +sudo make install +``` + +## Self-Check +``` +sudo make check +``` + +## Usage + * Two tools: `tmet-filter` and `tmet-agregator` + * Swithc to example directory `examples/general-component/tests` and try them + +## Config examples + +### Simple config +As output there will be two tests independent on backend framework + +``` +document: test-metadata +subtype: general +import_tests: + - "/bin/true" + - "/bin/false" +``` + +### Simple Config with MTF Linters +tag filetrs with imported test and enabled MTF modulelint and import all tests (relatively to base dir) + +``` +document: test-metadata +subtype: general +enable_lint: True +tag_filters: + - "add,-rem" + - "dockerfilelint" +import_tests: + - "*.py" +``` + +### Configs just for tests +when you want to have metadata to each test put similar config to directory (coverage inclueded) +``` +document: test-metadata +subtype: test +source: generaltest.py +relevancy: + - rule 1 + - rule 2 +description: some general test doing +envvars: + ATOMIC: link to atomic container +``` + +### Complex config with coverage + see [example component](examples/general-component/tests/metadata.yaml) + +#### Example output of commands + +``` +$ tmet-agregator +50% +``` + +``` +$ tmet-agregator -a md +# Coverage for: tests + +## Description +Not given + + +## Tests +* general + * by: generaltest.py + * description: some general test doing +* networking/use_tcp (MISSING coverage) + * description: desc of not covered, missing source +* networking/use_udp (MISSING coverage) + * description: desc of not covered, missing source +* options/extend_test + * by: https://github.com/fedora-modularity/meta-test-family.git + * description: some general test doing verbose test +* options/fedora_test + * by: fedora_specifictest.py + * description: some general test doing verbose test +* options/new_option (MISSING coverage) + * description: desc of not covered, missing source +* sanity + * by: generaltest.py + * description: some general test doing +* sanity/SSSSSS (MISSING coverage) + * description: some general test doing + +## Overall Coverage: 50% +``` + +``` +$ tmet-filter +file://general/generaltest.py file://networking/use_tcp/ file://networking/use_udp/ https://github.com/fedora-modularity/meta-test-family.git file://options/fedora_test/fedora_specifictest.py file://options/new_option/ file://sanity/generaltest.py file://sanity/SSSSSS/ +``` + +``` +$ tmet-filter --help +usage: tmet-filter [-h] [-r RELEVANCY] [-t TAGS] + +Filter and print tests + +optional arguments: + -h, --help show this help message and exit + -r RELEVANCY apply relevancy filtering, expect environment specification + -t TAGS apply tags filtering, expect tags in DNF form +``` diff --git a/mtf/metadata/__init__.py b/mtf/metadata/__init__.py new file mode 100644 index 0000000..f6ae7a3 --- /dev/null +++ b/mtf/metadata/__init__.py @@ -0,0 +1,4 @@ +""" +tmet - Test METadata library, whole library is exported as module interface +""" +from tmet import * diff --git a/mtf/metadata/examples/general-component/tests/fedora_specifictest.sh b/mtf/metadata/examples/general-component/tests/fedora_specifictest.sh new file mode 100644 index 0000000..3395065 --- /dev/null +++ b/mtf/metadata/examples/general-component/tests/fedora_specifictest.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +true diff --git a/mtf/metadata/examples/general-component/tests/generaltest.py b/mtf/metadata/examples/general-component/tests/generaltest.py new file mode 100644 index 0000000..2ae2839 --- /dev/null +++ b/mtf/metadata/examples/general-component/tests/generaltest.py @@ -0,0 +1 @@ +pass diff --git a/mtf/metadata/examples/general-component/tests/metadata.yaml b/mtf/metadata/examples/general-component/tests/metadata.yaml new file mode 100644 index 0000000..9eaeb1e --- /dev/null +++ b/mtf/metadata/examples/general-component/tests/metadata.yaml @@ -0,0 +1,70 @@ +# document identifier +document: test-metadata +# there are two types: full config is "general",for simple test metadata use "test" +subtype: general +# enable linters for selected module type (not supported for generic class) +enable_lint: False +# Import tests by path glob or by name, without complex features for coverage mapping +import_tests: + - "*.py" + - "/bin/true" +# DNF form of tags: Lines are logical OR, "," is logical AND, "-" is logical NOT +tag_filters: + - tier1 + - optional,-fedora + - dockerlinter + - dockerfilelinter +# Coverage test mapping. Describe which part are covered and what are not covered (does not contain "source") +# important are: +# leafs what contains test +# nonleafs means coverage path mapping +tests: + general: + # path in coverage mapping + source: generaltest.py + # code of test, where it lives (filesystem or some URL depending on backend (what is supported) + relevancy: + # relevancy descriptiont (NOT implemented now) + - rule 1 + - rule 2 + description: some general test doing + # description of test + backend: mtf + # which backend to use and group tests (you can filter tests for one backend) + options: + fedora_test: + source: fedora_specifictest.sh + description: some general test doing verbose test + backend: general + extend_test: + source: https://github.com/fedora-modularity/meta-test-family.git + description: some general test doing verbose test + backend: general + test_new_option: + description: desc of not covered, missing source + networking: + test_use_tcp: + description: desc of not covered, missing source + backend: general + test_use_udp: + description: desc of not covered, missing source + backend: general + +# Test Sets, +# when you would like to schedule just part of tests and have varisou relevancy tag filters, use test_sets +# Not implemented for now +test_sets: + fedora: + tests: + - general + - fedora_test + all: + tests: + - "*" + tag_filters: + test_options: + tests: + - options/* + fedora: + tests: + - general diff --git a/mtf/metadata/examples/general-component/tests/sanity/SSSSSS/metadata.yaml b/mtf/metadata/examples/general-component/tests/sanity/SSSSSS/metadata.yaml new file mode 100644 index 0000000..b0d5344 --- /dev/null +++ b/mtf/metadata/examples/general-component/tests/sanity/SSSSSS/metadata.yaml @@ -0,0 +1,3 @@ +document: test-metadata +subtype: test +description: some general test doing diff --git a/mtf/metadata/examples/general-component/tests/sanity/generaltest.py b/mtf/metadata/examples/general-component/tests/sanity/generaltest.py new file mode 100644 index 0000000..2ae2839 --- /dev/null +++ b/mtf/metadata/examples/general-component/tests/sanity/generaltest.py @@ -0,0 +1 @@ +pass diff --git a/mtf/metadata/examples/general-component/tests/sanity/metadata.yaml b/mtf/metadata/examples/general-component/tests/sanity/metadata.yaml new file mode 100644 index 0000000..f670a89 --- /dev/null +++ b/mtf/metadata/examples/general-component/tests/sanity/metadata.yaml @@ -0,0 +1,10 @@ +document: test-metadata +subtype: test +source: generaltest.py +relevancy: + - rule 1 + - rule 2 +description: some general test doing +envvars: + ATOMIC: link to atomic container +backend: general diff --git a/mtf/metadata/examples/general-simple/metadata.yaml b/mtf/metadata/examples/general-simple/metadata.yaml new file mode 100644 index 0000000..6d7bb5d --- /dev/null +++ b/mtf/metadata/examples/general-simple/metadata.yaml @@ -0,0 +1,5 @@ +document: test-metadata +subtype: general +import_tests: + - "/bin/true" + - "/bin/false" diff --git a/mtf/metadata/examples/mtf-clean/tests/all.py b/mtf/metadata/examples/mtf-clean/tests/all.py new file mode 100644 index 0000000..586c413 --- /dev/null +++ b/mtf/metadata/examples/mtf-clean/tests/all.py @@ -0,0 +1,50 @@ +from mtf.metatest import AvocadoTest + + +class Add1(AvocadoTest): + """ + :avocado: enable + :avocado: tags=add + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass + + +class Add2(AvocadoTest): + """ + :avocado: enable + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + """ + :avocado: tags=add + """ + pass + + +class Add3(AvocadoTest): + """ + :avocado: enable + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass diff --git a/mtf/metadata/examples/mtf-clean/tests/dockerlinter.py b/mtf/metadata/examples/mtf-clean/tests/dockerlinter.py new file mode 100644 index 0000000..16385a1 --- /dev/null +++ b/mtf/metadata/examples/mtf-clean/tests/dockerlinter.py @@ -0,0 +1,33 @@ +from mtf.metatest import AvocadoTest + + +class DockerFileLint(AvocadoTest): + """ + :avocado: enable + :avocado: tags=dockerfilelint,docker,rhel,fedora + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass + + +class DockerLint(AvocadoTest): + """ + :avocado: enable + :avocado: tags=dockerlint,docker,rhel,fedora + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass diff --git a/mtf/metadata/examples/mtf-clean/tests/metadata.yaml b/mtf/metadata/examples/mtf-clean/tests/metadata.yaml new file mode 100644 index 0000000..30e1fd7 --- /dev/null +++ b/mtf/metadata/examples/mtf-clean/tests/metadata.yaml @@ -0,0 +1,2 @@ +document: test-metadata +subtype: general diff --git a/mtf/metadata/examples/mtf-clean/tests/none.py b/mtf/metadata/examples/mtf-clean/tests/none.py new file mode 100644 index 0000000..3a23983 --- /dev/null +++ b/mtf/metadata/examples/mtf-clean/tests/none.py @@ -0,0 +1,50 @@ +from mtf.metatest import AvocadoTest + + +class Rem1(AvocadoTest): + """ + :avocado: enable + :avocado: tags=rem + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass + + +class Rem2(AvocadoTest): + """ + :avocado: enable + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + """ + :avocado: tags=rem + """ + pass + + +class Rem3(AvocadoTest): + """ + :avocado: disable + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass diff --git a/mtf/metadata/examples/mtf-clean/tests/some.py b/mtf/metadata/examples/mtf-clean/tests/some.py new file mode 100644 index 0000000..d958379 --- /dev/null +++ b/mtf/metadata/examples/mtf-clean/tests/some.py @@ -0,0 +1,55 @@ +from mtf.metatest import AvocadoTest + + +class Add1(AvocadoTest): + """ + :avocado: enable + :avocado: tags=add + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass + + +class AddPart(AvocadoTest): + """ + :avocado: enable + :avocado: tags=add + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def testAdd(self): + pass + + def testBad(self): + """ + :avocado: tags=rem + """ + pass + + +class Rem2(AvocadoTest): + """ + :avocado: enable + :avocado: tags=rem + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass diff --git a/mtf/metadata/examples/mtf-component/tests/all.py b/mtf/metadata/examples/mtf-component/tests/all.py new file mode 100644 index 0000000..586c413 --- /dev/null +++ b/mtf/metadata/examples/mtf-component/tests/all.py @@ -0,0 +1,50 @@ +from mtf.metatest import AvocadoTest + + +class Add1(AvocadoTest): + """ + :avocado: enable + :avocado: tags=add + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass + + +class Add2(AvocadoTest): + """ + :avocado: enable + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + """ + :avocado: tags=add + """ + pass + + +class Add3(AvocadoTest): + """ + :avocado: enable + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass diff --git a/mtf/metadata/examples/mtf-component/tests/dockerlinter.py b/mtf/metadata/examples/mtf-component/tests/dockerlinter.py new file mode 100644 index 0000000..16385a1 --- /dev/null +++ b/mtf/metadata/examples/mtf-component/tests/dockerlinter.py @@ -0,0 +1,33 @@ +from mtf.metatest import AvocadoTest + + +class DockerFileLint(AvocadoTest): + """ + :avocado: enable + :avocado: tags=dockerfilelint,docker,rhel,fedora + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass + + +class DockerLint(AvocadoTest): + """ + :avocado: enable + :avocado: tags=dockerlint,docker,rhel,fedora + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass diff --git a/mtf/metadata/examples/mtf-component/tests/metadata.yaml b/mtf/metadata/examples/mtf-component/tests/metadata.yaml new file mode 100644 index 0000000..a5ec764 --- /dev/null +++ b/mtf/metadata/examples/mtf-component/tests/metadata.yaml @@ -0,0 +1,7 @@ +document: test-metadata +subtype: general +tag_filters: + - "add,-rem" + - "dockerfilelint" +import_tests: + - "*.py" diff --git a/mtf/metadata/examples/mtf-component/tests/none.py b/mtf/metadata/examples/mtf-component/tests/none.py new file mode 100644 index 0000000..3a23983 --- /dev/null +++ b/mtf/metadata/examples/mtf-component/tests/none.py @@ -0,0 +1,50 @@ +from mtf.metatest import AvocadoTest + + +class Rem1(AvocadoTest): + """ + :avocado: enable + :avocado: tags=rem + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass + + +class Rem2(AvocadoTest): + """ + :avocado: enable + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + """ + :avocado: tags=rem + """ + pass + + +class Rem3(AvocadoTest): + """ + :avocado: disable + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass diff --git a/mtf/metadata/examples/mtf-component/tests/some.py b/mtf/metadata/examples/mtf-component/tests/some.py new file mode 100644 index 0000000..d958379 --- /dev/null +++ b/mtf/metadata/examples/mtf-component/tests/some.py @@ -0,0 +1,55 @@ +from mtf.metatest import AvocadoTest + + +class Add1(AvocadoTest): + """ + :avocado: enable + :avocado: tags=add + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass + + +class AddPart(AvocadoTest): + """ + :avocado: enable + :avocado: tags=add + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def testAdd(self): + pass + + def testBad(self): + """ + :avocado: tags=rem + """ + pass + + +class Rem2(AvocadoTest): + """ + :avocado: enable + :avocado: tags=rem + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass diff --git a/mtf/metadata/setup.py b/mtf/metadata/setup.py new file mode 100644 index 0000000..dfaafe3 --- /dev/null +++ b/mtf/metadata/setup.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013-2014 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Authors: Jan Scotka + +import os +import sys + +try: + from setuptools import setup, find_packages +except ImportError: + from distutils.core import setup + +# copy from https://github.com/avocado-framework/avocado/blob/master/setup.py +VIRTUAL_ENV = hasattr(sys, 'real_prefix') + + +def get_dir(system_path=None, virtual_path=None): + """ + Retrieve VIRTUAL_ENV friendly path + :param system_path: Relative system path + :param virtual_path: Overrides system_path for virtual_env only + :return: VIRTUAL_ENV friendly path + """ + if virtual_path is None: + virtual_path = system_path + if VIRTUAL_ENV: + if virtual_path is None: + virtual_path = [] + return os.path.join(*virtual_path) + else: + if system_path is None: + system_path = [] + return os.path.join(*(['/'] + system_path)) + +data_files = {} + + +setup( + name='tmet', + version="0.0.1", + description='Test METadata for tests (filter, agregate metadata)', + keywords='metadata,test', + author='Jan Scotka', + author_email='jscotka@redhat.com', + url='https://None', + license='GPLv2+', + packages=find_packages(), + include_package_data=True, + data_files=data_files.items(), + entry_points={ + 'console_scripts': [ + 'tmet-filter = tmet.filter:main', + 'tmet-agregator = tmet.agregator:main', + ] + }, + setup_requires=[], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Topic :: Software Development', + ], + install_requires=[] +) diff --git a/mtf/metadata/tmet/__init__.py b/mtf/metadata/tmet/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mtf/metadata/tmet/agregator.py b/mtf/metadata/tmet/agregator.py new file mode 100644 index 0000000..963a383 --- /dev/null +++ b/mtf/metadata/tmet/agregator.py @@ -0,0 +1,54 @@ + +""" +Create agregation report (wiki style) + +""" + +import argparse +import common +import os + + +def get_options(): + parser = argparse.ArgumentParser(description='Create Coverage reports') + parser.add_argument('-a', dest='action', default="statistic", + help='print to stdout in selected format') + args = parser.parse_args() + return args + + +def print_md_file(meta): + items = meta.get_coverage() + output = ["# Coverage for: %s" % os.path.basename(os.getcwd()), ""] + output += ["## Description", meta.base_element.get(common.DESC) or "Not given", "", ""] + output += ["## Tests"] + for key in sorted(items): + if items[key].get(common.SOURCE): + output += ["* %s" % key] + output += [" * by: %s" % items[key].get(common.SOURCE)] + output += [" * description: %s" % items[key].get(common.DESC)] + + else: + output += ["* %s (MISSING coverage)" % key] + output += [" * description: %s" % items[key].get(common.DESC)] + output += ["", "## Overall Coverage: %s" % statistic(meta)] + return output + + +def statistic(meta): + items = meta.get_coverage() + counter = 0 + all = len(items) + for key, value in items.iteritems(): + if value.get(common.SOURCE): + counter += 1 + return "%s%%" % (counter * 100 / all) + + +def main(): + options = get_options() + meta = common.MetadataLoader() + if options.action == "statistic": + print statistic(meta) + elif options.action == "md": + print "\n".join(print_md_file(meta)) diff --git a/mtf/metadata/tmet/common.py b/mtf/metadata/tmet/common.py new file mode 100644 index 0000000..46564de --- /dev/null +++ b/mtf/metadata/tmet/common.py @@ -0,0 +1,374 @@ +from __future__ import print_function +import yaml +import os +import glob +from mtf.common import print_debug +from avocado.utils import process +from urlparse import urlparse + +""" +Basic classes for metatadata handling, it contains classes derived from general metadata parser +for example for MTF testcases + +for usage and deeper understand see examples unittests in selftest.py file +""" + +MFILENAME = "metadata.yaml" +DESC = "description" +SOURCE = "source" +TESTE = "tests" +TESTC = "tests_coverage" +DOCUMENT = "document" +DOCUMENT_TYPES = ["metadata", "test-metadata", "tmet"] +SUBTYPE = "subtype" +SUBTYPE_G = "general" +SUBTYPE_T = "test" +BACKEND = "backend" +TAGS = "tags" +RELEVANCY = "relevancy" +DEPENDENCIES = "deps" +COVPATH = "coverage_path" +MODULELINT = "enable_lint" +IMPORT_TESTS = "import_tests" +TAG_FILETERS = "tag_filters" + + +def logic_formula(statement, filters, op_negation="-", op_and=",", op_or=None): + """ + disjunctive normla form statement parser https://en.wikipedia.org/wiki/Disjunctive_normal_form + + :param statement: + :param filters: + :param op_negation: + :param op_and: + :param op_or: + :return: + """ + def logic_simple(simple): + key = simple + value = True + if key.startswith(op_negation): + key = key[len(op_negation):] + value = False + return key, value + + def logic_and(normalform): + dictset = {} + for one in normalform.split(op_and): + k, v = logic_simple(one) + dictset[k] = v + return dictset + + def logic_filter(actual_tag_list, tag_filter): + # TODO: try to replace this part with http://www.sympy.org + statement_or = False + # if no tags in test then add this test to set (same behaviour as avocado --tag...empty) + # print actual_tag_list, tag_filter + if not actual_tag_list: + statement_or = True + actualinput = logic_and(actual_tag_list) + for onefilter in tag_filter: + filterinput = logic_and(onefilter) + statement_and = True + for key in filterinput: + if filterinput.get(key) != bool(actualinput.get(key)): + statement_and = False + break + if statement_and: + statement_or = True + break + return statement_or + + if op_or: + filters = filters.split(op_or) + return logic_filter(statement, filters) + + +class MetadataLoader(object): + """ + General class for parsing test metadata from metadata.yaml files + """ + base_element = {} + backends = ["generic", "general"] + # in filter there will be items like: {"relevancy": None, "tags": None} + filter_list = [None] + + def __init__(self, location=".", linters=False, **kwargs): + self.location = os.path.abspath(location) + self._load_recursive() + if linters or self.base_element.get(MODULELINT): + self._import_linters() + if IMPORT_TESTS in self.base_element: + for testglob in self.base_element.get(IMPORT_TESTS): + self._import_tests(os.path.join(self.location, testglob)) + if TAG_FILETERS in self.base_element: + self.add_filter(tags=self.base_element.get(TAG_FILETERS)) + + def _import_tests(self, testglob, pathlenght=0): + """ + import tests based on file path glob like "*.py" + + :param testglob: string + :param pathlenght: lenght of path for coverage usage (by default full path from glob is used) + :return: + """ + pathglob = testglob if testglob.startswith(os.pathsep) else os.path.join(self.location, testglob) + print_debug("Import tests: %s" % pathglob) + for testfile in glob.glob(pathglob): + test = {SOURCE: testfile, DESC: "Imported tests by path: %s" % pathglob} + self._insert_to_test_tree(testfile.strip(os.sep).split(os.sep)[pathlenght:], + test) + + def _import_linters(self): + """ + Import linters if any for backend type + + :return: + """ + raise NotImplementedError + + def get_metadata(self): + """ + get whole metadata loaded object + :return: dict + """ + return self.base_element + + def load_yaml(self, location): + """ + internal method for loading data from yaml file + + :param location: + :return: + """ + print_debug("Loading metadata from file: %s" % location) + with open(location, 'r') as ymlfile: + xcfg = yaml.load(ymlfile.read()) + if xcfg.get(DOCUMENT) not in DOCUMENT_TYPES: + raise BaseException("bad yaml file: item (%s)", xcfg.get(DOCUMENT)) + else: + return xcfg + + def _insert_to_coverage(self, path_list, test): + """ + translate test with path to coverage mapping TESTC + + :param path_list: how to store coverage + :param test: dict object representing test + :return: + """ + coverage_key = "/".join(path_list) + print_debug("insert to coverage %s to %s" % (test, coverage_key)) + # add test coverage key + # add backend key if does not exist + self.base_element[TESTC][coverage_key] = test + self.base_element[TESTC][coverage_key][COVPATH] = coverage_key + if BACKEND not in self.base_element[TESTC][coverage_key]: + self.base_element[TESTC][coverage_key][BACKEND] = self.backends[0] + + def _parse_base_coverage(self, base=None, path=[]): + """ + RECURSIVE internal method for parsing coverage in GENERAL metadata yaml tests: key in file + + :param base: + :param path: + :return: + """ + base = base or self.base_element.get(TESTE, {}) + if DESC in base: + if path: + self._insert_to_coverage(path, base) + else: + for key, value in base.iteritems(): + self._parse_base_coverage(base=value, path=path + [key]) + + def _insert_to_test_tree(self, path_list, test): + """ + Internal method to insert test to tests: dict object, used by simple metadata files + + :param path_list: where to store item (based on FS path) + :param test: test object + :return: + """ + actualelem = self.base_element[TESTE] + # sanitize testpath with test for these tests what are not fully qualified files + # append directory locaiton to source + if SOURCE in test \ + and not urlparse(test.get(SOURCE)).scheme \ + and not test.get(SOURCE).startswith(os.pathsep): + test[SOURCE] = os.path.join(*(path_list + [test[SOURCE]])) + print_debug("source testpath extended %s" % test[SOURCE]) + previous_item = None + link_previous = None + # Next code create full dictionary path to test if does not exist. + # like ['a','b','c'] creates {'a':{'b':{'c':{}}}} + for item in path_list: + if actualelem.get(item) is None: + actualelem[item] = dict() + link_previous = actualelem + previous_item = item + actualelem = actualelem[item] + link_previous[previous_item] = test + self._insert_to_coverage(path_list, test) + return self.base_element + + def _load_recursive(self): + """ + Internal method to parse all metadata files + It uses os.walk to find all files recursively + + :return: + """ + allfiles = [] + location = self.location + for root, sub_folders, files in os.walk(location): + if MFILENAME in files: + allfiles.append(os.path.join(root, MFILENAME)) + elem_element = {} + if allfiles: + elem_element = self.load_yaml(allfiles[0]) + if elem_element.get(SUBTYPE) == SUBTYPE_G: + # this code cannont cause traceback because default value is {} or it loads yaml + allfiles = allfiles[1:] + self.base_element = elem_element + else: + self.base_element = {} + if TESTC not in self.base_element: + self.base_element[TESTC] = dict() + if TESTE not in self.base_element: + self.base_element[TESTE] = dict() + self._parse_base_coverage() + for item in allfiles: + self._insert_to_test_tree(os.path.dirname(item)[len(location):].split("/")[1:], + self.load_yaml(item)) + + def get_coverage(self): + """ + return coverage elemetn + :return: dict + """ + return self.base_element.get(TESTC) + + def get_backends(self): + """ + List of all backends mentioned in metadata file + :return: list + """ + return set([x.get(BACKEND) for x in self.get_coverage().values()]) + + def filter_relevancy(self, tests, envrion_description): + """ + apply relevancy filtering, actually just a stub, returns everything + Not implemented + + :param tests: list of tests + :param envrion_description: enviroment description + :return: + """ + return tests + + def filter_tags(self, tests, tag_list): + """ + filter tags based on tags in metadata files for test + + :param tests: + :param tag_list: + :return: + """ + output = [] + for test in tests: + test_tags = test.get(TAGS, "") + if logic_formula(test_tags, tag_list): + output.append(test) + return output + + def add_filter(self, tags=[], relevancy={}): + """ + You can define multiple filters and apply them, + :param tags: + :param relevancy: + :return: + """ + addedfilter = {RELEVANCY: relevancy, TAGS: tags} + if self.filter_list[-1] is None: + self.filter_list = self.filter_list[:-1] + self.filter_list.append(addedfilter) + + def apply_filters(self): + output = self.backend_tests() + for infilter in self.filter_list: + if infilter: + if infilter.get(TAGS): + output = self.filter_tags(output, infilter.get(TAGS)) + if infilter.get(RELEVANCY): + output = self.filter_relevancy(output, infilter.get(RELEVANCY)) + return output + + def backend_tests(self): + cov = self.get_coverage() + return [cov[x] for x in cov if cov[x].get(BACKEND) in self.backends and SOURCE in cov[x]] + + def backend_passtrought_args(self): + return self.get_filters() + + def get_filters(self): + return self.filter_list + + +class MetadataLoaderMTF(MetadataLoader): + """ + metadata specific class for MTF (avocado) tests + """ + import moduleframework.tools + MTF_LINTER_PATH = os.path.dirname(moduleframework.tools.__file__) + listcmd = "avocado list" + backends = ["mtf", "avocado"] + + def _import_tests(self, testglob, pathlenght=0): + pathglob = testglob if testglob.startswith(os.pathsep) else os.path.join(self.location, testglob) + print_debug("Import by pathglob: %s" % pathglob) + tests_cmd = process.run("%s %s" % (self.listcmd, pathglob), shell=True, verbose=False, ignore_status=True) + tests = tests_cmd.stdout.splitlines() + if tests_cmd.exit_status != 0: + raise BaseException("unbale to import tests (avocado list) via location: %s" % pathglob) + for testurl in tests: + if testurl and len(testurl) > 1: + testlinesplitted = testurl.split(" ") + testfile = " ".join(testlinesplitted[1:]) + testtype = testlinesplitted[0] + print_debug("\t%s" % testfile) + test = {SOURCE: testfile, + DESC: "Imported (%s) tests by path: %s" % (testtype, pathglob), + "avocado_test_type": testtype + } + self._insert_to_test_tree(testfile.strip(os.sep).split(os.sep)[pathlenght:], + test) + + def _import_linters(self): + self._import_tests(os.path.join(self.MTF_LINTER_PATH, "*.py"), pathlenght=-3) + + def __avcado_tag_args(self, tag_list, defaultparam="--filter-by-tags-include-empty"): + output = [] + for tag in tag_list: + output.append("--filter-by-tags=%s" % tag) + if output: + output.append(defaultparam) + return " ".join(output) + + def filter_tags(self, tests, tag_list): + output = [] + for test in tests: + cmd = process.run("%s %s %s" % (self.listcmd, self.__avcado_tag_args(tag_list), test[SOURCE]), + shell=True, verbose=False) + if len(cmd.stdout) > 10: + output.append(test) + return output + + +def get_backend_class(backend): + if backend == "mtf": + out = MetadataLoaderMTF + else: + out = MetadataLoader + print_debug("Backend is: %s" % out) + return out diff --git a/mtf/metadata/tmet/filter.py b/mtf/metadata/tmet/filter.py new file mode 100644 index 0000000..74b46dc --- /dev/null +++ b/mtf/metadata/tmet/filter.py @@ -0,0 +1,62 @@ +""" +Filter testcases based on various parameters + +""" +import argparse +import common + + +def get_options(): + parser = argparse.ArgumentParser(description='Filter and print tests') + parser.add_argument('-r', dest='relevancy', + help='apply relevancy filtering, expect environment specification') + parser.add_argument( + '-t', + dest='tags', + action="append", + help='apply tags filtering, expect tags in DNF form (expressions in one option means AND, more -t means OR)') + parser.add_argument('-b', dest='backend', + help='output for selected backend') + parser.add_argument('--location', dest='location', default='.', + help='output for selected backend') + parser.add_argument('--linters', dest='linters', action='store_true', + help='output for selected backend') + parser.add_argument('tests', nargs='*', help='import tests for selected backed') + + args = parser.parse_args() + return args + + +def main(): + options = get_options() + output = filtertests(backend=options.backend, + location=options.location, + linters=options.linters, + tests=options.tests, + tags=options.tags, + relevancy=options.relevancy + ) + print " ".join([x[common.SOURCE] for x in output]) + + +def filtertests(backend, location, linters, tests, tags, relevancy): + """ + Basic method to use it for wrapping inside another python code, + allows apply tag filters and relevancy + + :param backend: + :param location: + :param linters: + :param tests: + :param tags: + :param relevancy: + :return: + """ + meta = common.get_backend_class(backend)(location=location, + linters=linters, + backend=backend) + if tests: + for test in tests: + meta._import_tests(test) + meta.add_filter(tags=tags, relevancy=relevancy) + return meta.apply_filters() diff --git a/mtf/metadata/tmet/selftests.py b/mtf/metadata/tmet/selftests.py new file mode 100644 index 0000000..019bfc3 --- /dev/null +++ b/mtf/metadata/tmet/selftests.py @@ -0,0 +1,197 @@ +from common import MetadataLoader, MetadataLoaderMTF, SOURCE, print_debug +from filter import filtertests +import yaml + +__TC_GENERAL_COMPONENT = "examples/general-component/tests" +__TC_MTF_COMPOMENT = "examples/mtf-clean/tests" +__TC_MTF_CONF = "examples/mtf-component/tests" +__TC_GENERAL_CONF = "examples/general-simple" + + +def test_loader(): + """ + Test general backend loader for complex case + :return: + """ + mt = MetadataLoader(location=__TC_GENERAL_COMPONENT) + print_debug(yaml.dump(mt.get_metadata())) + print_debug(mt.get_backends()) + assert 'sanity/generaltest.py' in [x[SOURCE] for x in mt.backend_tests()] + + +def test_mtf_metadata_linters_and_tests_noconfig(): + """ + Test linter only for MTF loader, using no config + :return: + """ + mt = MetadataLoaderMTF(location=__TC_MTF_COMPOMENT, linters=True) + # print yaml.dump(mt.get_metadata()) + # print mt.backend_passtrought_args() + # print mt.apply_filters() + case_justlinters_nofilter = len(mt.apply_filters()) + print_debug(case_justlinters_nofilter) + mt._import_tests("*.py") + case_lintersanstests_nofilter = len(mt.apply_filters()) + print_debug(case_lintersanstests_nofilter) + mt.add_filter(tags=["add"]) + case_lintersanstests_filter1 = len(mt.apply_filters()) + print_debug(case_lintersanstests_filter1) + mt.add_filter(tags=["-add"]) + case_lintersanstests_filter2 = len(mt.apply_filters()) + print_debug(case_lintersanstests_filter2) + + assert case_justlinters_nofilter > 20 + assert case_lintersanstests_nofilter > case_justlinters_nofilter + assert case_lintersanstests_filter1 > case_justlinters_nofilter + assert case_lintersanstests_filter1 < case_lintersanstests_nofilter + assert case_lintersanstests_filter1 > case_lintersanstests_filter2 + assert case_lintersanstests_filter2 < case_justlinters_nofilter + # print [v[SOURCE] for v in mt.backend_tests()] + # mt.apply_filters() + + +def test_filter_mtf_justlintes(): + """ + test load just linter with simple config + :return: + """ + out = filtertests(backend="mtf", + location=__TC_MTF_COMPOMENT, + linters=True, + tests=[], + tags=[], + relevancy="") + print_debug(out) + assert len(out) > 20 + + +def test_filter_mtf_nothing(): + """ + use config what loads no tests + :return: + """ + out = filtertests(backend="mtf", + location=__TC_MTF_COMPOMENT, + linters=False, + tests=[], + tags=[], + relevancy="") + print_debug(out) + assert len(out) == 0 + + +def test_filter_mtf_justtests(): + """ + tests load configu and just tests there + """ + out = filtertests(backend="mtf", + location=__TC_MTF_COMPOMENT, + linters=False, + tests=["*.py"], + tags=[], + relevancy="") + print_debug(out) + assert len(out) == 11 + + +def test_filter_mtf_filtered_tests_add(): + """ + tests load config with filters + :return: + """ + out = filtertests(backend="mtf", + location=__TC_MTF_COMPOMENT, + linters=False, + tests=["*.py"], + tags=["add"], + relevancy="") + print_debug(out) + assert len(out) == 6 + + +def test_filter_mtf_filtered_notadd(): + """ + tests load config with filters + :return: + """ + out = filtertests(backend="mtf", + location=__TC_MTF_COMPOMENT, + linters=False, + tests=["*.py"], + tags=["-add"], + relevancy="") + print_debug(out) + assert len(out) == 6 + + +def test_filter_mtf_filtered_rem(): + """ + tests load config with filters + + :return: + """ + out = filtertests(backend="mtf", + location=__TC_MTF_COMPOMENT, + linters=False, + tests=["*.py"], + tags=["rem"], + relevancy="") + print_debug(out) + assert len(out) == 5 + + +def test_filter_general(): + """ + tests load config with filters, for general module + :return: + """ + out = filtertests(backend=None, + location=__TC_GENERAL_COMPONENT, + linters=False, + tests=[], + tags=[], + relevancy="") + print_debug(out) + assert len(out) == 5 + + +def test_mtf_config(): + """ + test real life example and check if proper tests were filtered based on config file + :return: + """ + out = filtertests(backend="mtf", + location=__TC_MTF_CONF, + linters=False, + tests=[], + tags=[], + relevancy="") + tests = [x[SOURCE] for x in out] + print_debug(tests) + assert len(tests) == 6 + assert "Rem" not in " ".join(tests) + assert "Add" in " ".join(tests) + assert "DockerFileLint" in " ".join(tests) + + +def test_general_config(): + """ + test loading general config and check number of tests, linters disabled + :return: + """ + out = (filtertests(backend=None, + location=__TC_GENERAL_CONF, + linters=False, + tests=[], + tags=[], + relevancy="")) + print_debug(out) + assert len(out) == 2 + + +# test_loader() +# test_mtf_metadata_linters_only() +# test_filter_mtf() +# test_filter_general() +# test_mtf_config() +# test_general_config() diff --git a/mtf/meta-test/__init__.py b/mtf/metatest/__init__.py similarity index 100% rename from mtf/meta-test/__init__.py rename to mtf/metatest/__init__.py From f0ff77bd24cfe5735977c53f1778d96c5bfffd02 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 22 Nov 2017 10:41:00 +0100 Subject: [PATCH 076/117] remove avocado html plugin from python dependencies, it is not important for mtf anyhow --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4167436..64b479b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ PyYAML avocado-framework -avocado-framework-plugin-result-html dockerfile-parse modulemd netifaces From bd5614a8b8ab9c6896d143000530e9be9bc4308a Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 22 Nov 2017 11:26:48 +0100 Subject: [PATCH 077/117] revert back to using python setup.py for package installation --- .travis.yml | 2 +- Makefile | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index fe957b6..2e2b55d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,7 @@ before_install: - sudo pip install avocado-framework - pip install avocado-framework -install: sudo make install +install: sudo make install_pip script: - sudo make -C examples/testing-module check-inheritance diff --git a/Makefile b/Makefile index 6464af6..a589226 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ NAME=moduleframework INSTALLPATH=/usr/share/$(NAME) PYTHONSITE=/usr/lib/python2.7/site-packages -all: install check +all: install_pip check check: make -C examples/testing-module check @@ -21,14 +21,21 @@ travis: .PHONY: clean -clean: +clean_pip: pip uninstall . - #git clean -fd - rm -rf build/html + rm -rf build/* dist/* -install: clean + +install_pip: clean_pip pip install -U . +clean: + @python setup.py clean + rm -rf build/* dist/* + +install: clean + @python setup.py install + source: clean @python setup.py sdist From ad70ac89aba336470a0b9871144f4a1e4062dfb0 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 22 Nov 2017 13:44:42 +0100 Subject: [PATCH 078/117] man mtf page is generated --- .gitignore | 5 +- .gitmodules | 4 ++ .travis.yml | 5 ++ MANIFEST.in | 1 + build_manpages | 1 + man/mtf.1 | 98 +++++++++++++------------------- moduleframework/mtf_scheduler.py | 12 +++- setup.cfg | 4 ++ setup.py | 21 ++++++- 9 files changed, 85 insertions(+), 66 deletions(-) create mode 160000 build_manpages diff --git a/.gitignore b/.gitignore index 464e916..4df851f 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,7 @@ ENV/ # Generated files by framework examples/baseruntime/generated.py -examples/memcached/generated.py \ No newline at end of file +examples/memcached/generated.py + +# Generated man page +man \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 7d49d0b..459aded 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "tools/check_modulemd"] path = tools/check_modulemd url = https://github.com/fedora-modularity/check_modulemd + +[submodule "build_manpages"] + path = build_manpages + url = https://github.com/praiskup/build_manpages diff --git a/.travis.yml b/.travis.yml index fe957b6..1fc19c9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,8 @@ language: python + +before_script: + - git submodule update --init + python: - "2.7" @@ -25,5 +29,6 @@ script: - sudo make -C examples/testing-module check-real-rpm-destructive - sudo make -C examples/testing-module check-docker-scl-multi-travis - sudo make -C mtf/metadata check + - "make PYTHON=python${TRAVIS_PYTHON_VERSION} COVERAGE=true check && python$TRAVIS_PYTHON_VERSION setup.py install --root $PWD/i" after_script: sudo cat ~/avocado/job-results/latest/job.log diff --git a/MANIFEST.in b/MANIFEST.in index ccb0d07..d380ac3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,3 +11,4 @@ recursive-include examples * recursive-include tools * recursive-include distro * recursive-include man * +recursive-include build_manpages *.py diff --git a/build_manpages b/build_manpages new file mode 160000 index 0000000..44d1548 --- /dev/null +++ b/build_manpages @@ -0,0 +1 @@ +Subproject commit 44d1548b21490de18af92c59fa29ccf7ce19d87b diff --git a/man/mtf.1 b/man/mtf.1 index 2245979..b7bbece 100644 --- a/man/mtf.1 +++ b/man/mtf.1 @@ -1,75 +1,55 @@ -.\" Copyright Petr Hracek, 2017 -.\" -.\" This page is distributed under GPL. -.\" -.TH mtf 1 2017-11-01 "" "Linux User's Manual" +.TH mtf_scheduler.py "1" Manual .SH NAME -mtf \- runs tests for container images - +mtf_scheduler.py \- mtf - Tool to test components for a modular Fedora. .SH SYNOPSIS -[\fI\,VARIABLES\/\fT] -.B -mtf -[\fI\,OPTIONS\/\fR] PYTHON_TESTS - +.B mtf_scheduler.py +[options] local_tests .SH DESCRIPTION -\fBmtf\fP is a main binary file of Meta-Test-Family. It tests container images and/or modules with user defined tests written in Python and/or linters -provided by meta-test-family package. -It runs PYTHON_TESTS using avocado framework as test runner. +unknown arguments are forwarded to avocado: +.br + optionally use additional avocado param like \-\-show\-job\-log, see avocado action \-\-help +.SH OPTIONS -.SH VARIABLES -.TP -.B AVOCADO_LOG_DEBUG=yes -enables avocado debug output. -.TP -.B DEBUG=yes -enables debugging mode to test output. -.TP -.B CONFIG -defines the module configuration file. It defaults to -.B config.yaml -.TP -.B MODULE -defines tested module type, if -.B default-module -is not set in -.B config.yaml. -.TP -.B URL to container. -E.g. URL=docker.io/modularitycontainers/haproxy if MODULE=docker -.TP -.B MODULEMDURL -overwrites the location of a moduleMD file. .TP -.B COMPOSEURL -overwrites the location of a compose Pungi build. +\fB\-\-linter\fR, \fB\-l\fR +adds additional compose checks + .TP -.B MTF_SKIP_DISABLING_SELINUX=yes -does not disable SELinux. In nspawn type on Fedora 25 SELinux should be disabled, because it does not work well with SELinux enabled, this option allows to not do that. +\fB\-\-setup\fR +Setup by mtfenvset + .TP -.B MTF_DO_NOT_CLEANUP=yes -does not clean up module after tests execution (a machine remains running). +\fB\-\-action\fR \fI\,ACTION\/\fR +Action for avocado, see avocado \-\-help for subcommands + .TP -.B MTF_REUSE=yes -uses the same module between tests. It speeds up test execution. It can cause side effects. +\fB\-\-xunit\fR \fI\,XUNIT\/\fR +Enable xUnit result format and write it to FILE. Use \- to redirect to the standard output. + .TP -.B MTF_REMOTE_REPOS=yes -disables downloading of Koji packages and creating a local repo, and speeds up test execution. +\fB\-\-module\fR \fI\,MODULE\/\fR +Module type, like: docker, nspawn or rpm + .TP -.B MTF_DISABLE_MODULE=yes -disables module handling to use nonmodular test mode. +\fB\-\-debug\fR +more logging +.TP +\fB\-\-config\fR \fI\,CONFIG\/\fR +defines the module configuration file -.SH OPTIONS .TP -.B \-l, --linters -Executes linters provided by Meta-Test-Family package. +\fB\-\-url\fR \fI\,URL\/\fR +URL overrides the value of module.docker.container or module.rpm.repo. -.SH NOTES -Once \fBmtf\fP finishes it shows logs from failed tests. +.TP +\fB\-\-modulemdurl\fR \fI\,MODULEMDURL\/\fR +overwrites the location of a moduleMD file .SH AUTHORS -Petr Hracek, (man page) - -.SH SEE ALSO -Full documentation at: \ No newline at end of file +.B meta\-test\-family +was written by Jan Scotka . +.SH DISTRIBUTION +The latest version of meta\-test\-family may be downloaded from +.UR https://github.com/fedora\-modularity/meta\-test\-family +.UE diff --git a/moduleframework/mtf_scheduler.py b/moduleframework/mtf_scheduler.py index ca53aad..634a0c4 100755 --- a/moduleframework/mtf_scheduler.py +++ b/moduleframework/mtf_scheduler.py @@ -34,16 +34,18 @@ def cli(): - file_name = os.path.basename(sys.argv[0]) + # for right name of man; man page generator needs it: script_name differs, its defined in setup.py + script_name = "mtf" parser = argparse.ArgumentParser( # TODO + prog="{0}".format(script_name), description=textwrap.dedent('''\ unknown arguments are forwarded to avocado: optionally use additional avocado param like --show-job-log, see avocado action --help'''), formatter_class=argparse.RawTextHelpFormatter, # epilog(with http link) is used in some error msg too: epilog="see http://meta-test-family.readthedocs.io for more info", - usage=("{0} [options] local_tests".format(file_name)), + usage="{0} [options] local_tests".format(script_name), ) parser.add_argument("--linter", "-l", action="store_true", default=False, help='adds additional compose checks') @@ -51,6 +53,8 @@ def cli(): default=False, help='Setup by mtfenvset') parser.add_argument("--action", action="store", default='run', help='Action for avocado, see avocado --help for subcommands') + # Solely for the purpose of manpage generator, copy&paste from setup.py + parser.man_short_description = "tool to test components for a modular Fedora." # parameters tights to avocado group_avocado = parser.add_argument_group( @@ -123,7 +127,7 @@ def cli(): "{2}".format(os.environ.get('MODULE'), common.get_backend_list(), parser.epilog)) common.print_debug("MODULE={0}".format(os.environ.get('MODULE'))) - return args, unknown + return args, unknown, parser class AvocadoStart(object): @@ -210,6 +214,8 @@ def show_error(self): delimiter = "-------------------------" os.remove(self.json_tmppath) +# for man page generator +_, _, man = cli() def main(): common.print_debug('verbose/debug mode') diff --git a/setup.cfg b/setup.cfg index b88034e..f432c26 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,6 @@ [metadata] description-file = README.md + +[build_manpages] +manpages = + man/mtf.1:object=man:pyfile=moduleframework/mtf_scheduler.py \ No newline at end of file diff --git a/setup.py b/setup.py index 98ed53e..a7faac4 100755 --- a/setup.py +++ b/setup.py @@ -28,10 +28,20 @@ except ImportError: from distutils.core import setup +from setuptools.command.build_py import build_py +from setuptools.command.install import install +try: + sys.path = [os.path.join(os.getcwd(), 'build_manpages')] + sys.path + from build_manpages.build_manpages import build_manpages, get_build_py_cmd, get_install_cmd +except: + print("=======================================") + print("Use 'git submodule update --init' first") + print("=======================================") + raise + # copy from https://github.com/avocado-framework/avocado/blob/master/setup.py VIRTUAL_ENV = hasattr(sys, 'real_prefix') - def get_dir(system_path=None, virtual_path=None): """ Retrieve VIRTUAL_ENV friendly path @@ -74,7 +84,7 @@ def get_dir(system_path=None, virtual_path=None): setup( name='meta-test-family', version="0.7.7", - description='Tool to test components fo a modular Fedora.', + description='Tool to test components for a modular Fedora.', keywords='modules,containers,testing,framework', author='Jan Scotka', author_email='jscotka@redhat.com', @@ -104,5 +114,10 @@ def get_dir(system_path=None, virtual_path=None): 'Programming Language :: Python', 'Topic :: Software Development', ], - install_requires=open('requirements.txt').read().splitlines() + install_requires=open('requirements.txt').read().splitlines(), + cmdclass={ + 'build_manpages': build_manpages, + 'build_py': get_build_py_cmd(build_py), + 'install': get_install_cmd(install), + }, ) From d3c5b90484691af67db7be4ebe70cee604659945 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 22 Nov 2017 14:25:42 +0100 Subject: [PATCH 079/117] have parser in function --- moduleframework/mtf_scheduler.py | 19 ++++++++++++------- setup.cfg | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/moduleframework/mtf_scheduler.py b/moduleframework/mtf_scheduler.py index 634a0c4..c059413 100755 --- a/moduleframework/mtf_scheduler.py +++ b/moduleframework/mtf_scheduler.py @@ -33,7 +33,7 @@ from moduleframework import common -def cli(): +def mtfparser(): # for right name of man; man page generator needs it: script_name differs, its defined in setup.py script_name = "mtf" parser = argparse.ArgumentParser( @@ -75,9 +75,12 @@ def cli(): help='URL overrides the value of module.docker.container or module.rpm.repo.') group.add_argument("--modulemdurl", action="store", help='overwrites the location of a moduleMD file') + return parser + +def cli(): # unknown options are forwarded to avocado run - args, unknown = parser.parse_known_args() + args, unknown = mtfparser().parse_known_args() # uses additional arguments, set up variable asap, its used afterwards: if args.debug: @@ -123,11 +126,11 @@ def cli(): os.environ.get('MODULE'), args.module)) else: # TODO: what to do here? whats the defaults value for MODULE, do I know it? - common.print_info("MODULE={0} ; we support {1} \n === expecting your magic, enjoy! === \n" - "{2}".format(os.environ.get('MODULE'), common.get_backend_list(), parser.epilog)) + common.print_info("MODULE={0} ; we support {1} \n === expecting your magic, enjoy! === ".format( + os.environ.get('MODULE'), common.get_backend_list())) common.print_debug("MODULE={0}".format(os.environ.get('MODULE'))) - return args, unknown, parser + return args, unknown class AvocadoStart(object): @@ -214,8 +217,6 @@ def show_error(self): delimiter = "-------------------------" os.remove(self.json_tmppath) -# for man page generator -_, _, man = cli() def main(): common.print_debug('verbose/debug mode') @@ -234,3 +235,7 @@ def main(): # when there is any need, change general method or create specific one: returncode = a.avocado_general() exit(returncode) + + + + diff --git a/setup.cfg b/setup.cfg index f432c26..48d0c39 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,4 +3,4 @@ description-file = README.md [build_manpages] manpages = - man/mtf.1:object=man:pyfile=moduleframework/mtf_scheduler.py \ No newline at end of file + man/mtf.1:function=mtfparser:pyfile=moduleframework/mtf_scheduler.py \ No newline at end of file From f52e11b610a8485ac045d49ad778063901709639 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 22 Nov 2017 14:42:29 +0100 Subject: [PATCH 080/117] needed setup, parser in function --- man/mtf.1 | 6 +++--- setup.cfg | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/man/mtf.1 b/man/mtf.1 index b7bbece..5542be1 100644 --- a/man/mtf.1 +++ b/man/mtf.1 @@ -1,8 +1,8 @@ -.TH mtf_scheduler.py "1" Manual +.TH mtf "1" Manual .SH NAME -mtf_scheduler.py \- mtf - Tool to test components for a modular Fedora. +mtf \- tool to test components for a modular Fedora. .SH SYNOPSIS -.B mtf_scheduler.py +.B mtf [options] local_tests .SH DESCRIPTION unknown arguments are forwarded to avocado: diff --git a/setup.cfg b/setup.cfg index 48d0c39..2e9ab12 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,4 +3,4 @@ description-file = README.md [build_manpages] manpages = - man/mtf.1:function=mtfparser:pyfile=moduleframework/mtf_scheduler.py \ No newline at end of file + man/mtf.1:function=mtfparser:module=moduleframework.mtf_scheduler \ No newline at end of file From 6a6f71630784a3626ebb3f86d333a324d86292cf Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 22 Nov 2017 14:45:25 +0100 Subject: [PATCH 081/117] new line --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4df851f..d1e583b 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,4 @@ examples/baseruntime/generated.py examples/memcached/generated.py # Generated man page -man \ No newline at end of file +man From 087a33002df003f729243a93b73320e4e283d2fa Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 22 Nov 2017 15:43:01 +0100 Subject: [PATCH 082/117] not needed --- .travis.yml | 1 - setup.cfg | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1fc19c9..d41f4ee 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,6 +29,5 @@ script: - sudo make -C examples/testing-module check-real-rpm-destructive - sudo make -C examples/testing-module check-docker-scl-multi-travis - sudo make -C mtf/metadata check - - "make PYTHON=python${TRAVIS_PYTHON_VERSION} COVERAGE=true check && python$TRAVIS_PYTHON_VERSION setup.py install --root $PWD/i" after_script: sudo cat ~/avocado/job-results/latest/job.log diff --git a/setup.cfg b/setup.cfg index 2e9ab12..6b1c9ad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,4 +3,4 @@ description-file = README.md [build_manpages] manpages = - man/mtf.1:function=mtfparser:module=moduleframework.mtf_scheduler \ No newline at end of file + man/mtf.1:function=mtfparser:module=moduleframework.mtf_scheduler From 9f328a7db672250a97060bf99111478232434530 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 22 Nov 2017 15:46:33 +0100 Subject: [PATCH 083/117] delete file --- man/mtf.1 | 55 -------------------------------- moduleframework/mtf_scheduler.py | 2 +- 2 files changed, 1 insertion(+), 56 deletions(-) delete mode 100644 man/mtf.1 diff --git a/man/mtf.1 b/man/mtf.1 deleted file mode 100644 index 5542be1..0000000 --- a/man/mtf.1 +++ /dev/null @@ -1,55 +0,0 @@ -.TH mtf "1" Manual -.SH NAME -mtf \- tool to test components for a modular Fedora. -.SH SYNOPSIS -.B mtf -[options] local_tests -.SH DESCRIPTION -unknown arguments are forwarded to avocado: -.br - optionally use additional avocado param like \-\-show\-job\-log, see avocado action \-\-help -.SH OPTIONS - -.TP -\fB\-\-linter\fR, \fB\-l\fR -adds additional compose checks - -.TP -\fB\-\-setup\fR -Setup by mtfenvset - -.TP -\fB\-\-action\fR \fI\,ACTION\/\fR -Action for avocado, see avocado \-\-help for subcommands - -.TP -\fB\-\-xunit\fR \fI\,XUNIT\/\fR -Enable xUnit result format and write it to FILE. Use \- to redirect to the standard output. - -.TP -\fB\-\-module\fR \fI\,MODULE\/\fR -Module type, like: docker, nspawn or rpm - -.TP -\fB\-\-debug\fR -more logging - -.TP -\fB\-\-config\fR \fI\,CONFIG\/\fR -defines the module configuration file - -.TP -\fB\-\-url\fR \fI\,URL\/\fR -URL overrides the value of module.docker.container or module.rpm.repo. - -.TP -\fB\-\-modulemdurl\fR \fI\,MODULEMDURL\/\fR -overwrites the location of a moduleMD file - -.SH AUTHORS -.B meta\-test\-family -was written by Jan Scotka . -.SH DISTRIBUTION -The latest version of meta\-test\-family may be downloaded from -.UR https://github.com/fedora\-modularity/meta\-test\-family -.UE diff --git a/moduleframework/mtf_scheduler.py b/moduleframework/mtf_scheduler.py index c059413..bcdb84e 100755 --- a/moduleframework/mtf_scheduler.py +++ b/moduleframework/mtf_scheduler.py @@ -54,7 +54,7 @@ def mtfparser(): parser.add_argument("--action", action="store", default='run', help='Action for avocado, see avocado --help for subcommands') # Solely for the purpose of manpage generator, copy&paste from setup.py - parser.man_short_description = "tool to test components for a modular Fedora." + parser.man_short_description = "tool to test components for a modular Fedora" # parameters tights to avocado group_avocado = parser.add_argument_group( From e8b8836fe472705ffa88669f1d9d8bacd6a4378a Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 22 Nov 2017 15:58:52 +0100 Subject: [PATCH 084/117] empty commit to start tests --- moduleframework/mtf_scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moduleframework/mtf_scheduler.py b/moduleframework/mtf_scheduler.py index bcdb84e..c059413 100755 --- a/moduleframework/mtf_scheduler.py +++ b/moduleframework/mtf_scheduler.py @@ -54,7 +54,7 @@ def mtfparser(): parser.add_argument("--action", action="store", default='run', help='Action for avocado, see avocado --help for subcommands') # Solely for the purpose of manpage generator, copy&paste from setup.py - parser.man_short_description = "tool to test components for a modular Fedora" + parser.man_short_description = "tool to test components for a modular Fedora." # parameters tights to avocado group_avocado = parser.add_argument_group( From eccecf9fbfd5dcccaf1cbb81f6ee7cd035add819 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Thu, 23 Nov 2017 13:04:24 +0100 Subject: [PATCH 085/117] some info about VARIABLES --- moduleframework/mtf_scheduler.py | 33 ++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/moduleframework/mtf_scheduler.py b/moduleframework/mtf_scheduler.py index c059413..677e60f 100755 --- a/moduleframework/mtf_scheduler.py +++ b/moduleframework/mtf_scheduler.py @@ -36,16 +36,29 @@ def mtfparser(): # for right name of man; man page generator needs it: script_name differs, its defined in setup.py script_name = "mtf" + description = \ +""" +VARIABLES + + CONFIG defines the module configuration file. It defaults to config.yaml + + MODULE defines tested module type, if default-module is not set in config.yaml. + + URL to container. + E.g. URL=docker.io/modularitycontainers/haproxy if MODULE=docker + + MODULEMDURL + overwrites the location of a moduleMD file. + + and more at http://meta-test-family.readthedocs.io/en/latest/user_guide/environment_variables.html +""" parser = argparse.ArgumentParser( # TODO prog="{0}".format(script_name), - description=textwrap.dedent('''\ - unknown arguments are forwarded to avocado: - optionally use additional avocado param like --show-job-log, see avocado action --help'''), + description=description, formatter_class=argparse.RawTextHelpFormatter, - # epilog(with http link) is used in some error msg too: epilog="see http://meta-test-family.readthedocs.io for more info", - usage="{0} [options] local_tests".format(script_name), + usage="[VARIABLES] {0} [options] local_tests".format(script_name), ) parser.add_argument("--linter", "-l", action="store_true", default=False, help='adds additional compose checks') @@ -53,8 +66,16 @@ def mtfparser(): default=False, help='Setup by mtfenvset') parser.add_argument("--action", action="store", default='run', help='Action for avocado, see avocado --help for subcommands') + # Solely for the purpose of manpage generator, copy&paste from setup.py - parser.man_short_description = "tool to test components for a modular Fedora." + parser.man_short_description = \ +""" +tool to test components for a modular Fedora. + +mtf is a main binary file of Meta-Test-Family. + +It tests container images and/or modules with user defined tests using avocado framework as test runner. +""" # parameters tights to avocado group_avocado = parser.add_argument_group( From 6b8f85ee307c359db5ea0500c19912c9aae8fb9d Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Thu, 23 Nov 2017 15:51:58 +0100 Subject: [PATCH 086/117] trying travis --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index a7faac4..ca653b9 100755 --- a/setup.py +++ b/setup.py @@ -31,8 +31,10 @@ from setuptools.command.build_py import build_py from setuptools.command.install import install try: + a = sys.path sys.path = [os.path.join(os.getcwd(), 'build_manpages')] + sys.path from build_manpages.build_manpages import build_manpages, get_build_py_cmd, get_install_cmd + sys.path = a except: print("=======================================") print("Use 'git submodule update --init' first") From 63d34220ad85f3a9c9a8c8150ef1db2d1c8f48d2 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 24 Nov 2017 14:16:34 +0100 Subject: [PATCH 087/117] script to run containers in taskotron --- tools/run_them_containers_taskotron.sh | 77 ++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100755 tools/run_them_containers_taskotron.sh diff --git a/tools/run_them_containers_taskotron.sh b/tools/run_them_containers_taskotron.sh new file mode 100755 index 0000000..2f30b60 --- /dev/null +++ b/tools/run_them_containers_taskotron.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# -*- coding: utf-8 -*- +# +# Meta test family (MTF) is a tool to test components of a modular Fedora: +# https://docs.pagure.org/modularity/ +# Copyright (C) 2017 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Authors: Jan Scotka +# + + +function inst_env(){ + dnf install -y python-pip make docker httpd git python2-avocado fedpkg python2-avocado-plugins-output-html \ + pdc-client python2-modulemd python-netifaces python2-dockerfile-parse + pip install PyYAML behave +# it should not fail anyhow + true +# pip install --upgrade avocado-framework avocado-framework-plugin-result-html + +} + +function installdeps(){ + DEPS="requirements.sh" + echo "INSTALL TEST DEPENDENCY IF ANY FILE: $DEPS exist" + if [ -e $DEPS ]; then + sh $DEPS + fi +} + +function runtests(){ + echo "RUN MAKE TEST" + make test +} + +function schedule(){ + set -x + local RESULTTOOLS=0 + local RESULT=0 + + inst_env + RESULTTOOLS=$(($RESULTTOOLS+$?)) + + installdeps + RESULT=$(($RESULT+$?)) + runtests + RESULT=$(($RESULT+$?)) + + if [ "$RESULTTOOLS" -ne 0 ]; then + return 2 + fi + + if [ "$RESULT" -eq 0 ]; then + # return code what means PASS + return 0 + else + # return code what means that some part of infra failed + return 125 + fi + set +x +} + +schedule +exit $? From 45cb74141a492ee4265b4883a200abe716e9c354 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Fri, 24 Nov 2017 14:57:30 +0100 Subject: [PATCH 088/117] add version as its needed for man page generator --- moduleframework/mtf_scheduler.py | 6 ++++++ setup.py | 2 -- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/moduleframework/mtf_scheduler.py b/moduleframework/mtf_scheduler.py index 677e60f..74d1abb 100755 --- a/moduleframework/mtf_scheduler.py +++ b/moduleframework/mtf_scheduler.py @@ -66,6 +66,8 @@ def mtfparser(): default=False, help='Setup by mtfenvset') parser.add_argument("--action", action="store", default='run', help='Action for avocado, see avocado --help for subcommands') + parser.add_argument("--version", action="store_true", + default=False, help='show version and exit') # Solely for the purpose of manpage generator, copy&paste from setup.py parser.man_short_description = \ @@ -103,6 +105,10 @@ def cli(): # unknown options are forwarded to avocado run args, unknown = mtfparser().parse_known_args() + if args.version: + print "0.7.7" + exit(0) + # uses additional arguments, set up variable asap, its used afterwards: if args.debug: os.environ['DEBUG'] = 'yes' diff --git a/setup.py b/setup.py index ca653b9..a7faac4 100755 --- a/setup.py +++ b/setup.py @@ -31,10 +31,8 @@ from setuptools.command.build_py import build_py from setuptools.command.install import install try: - a = sys.path sys.path = [os.path.join(os.getcwd(), 'build_manpages')] + sys.path from build_manpages.build_manpages import build_manpages, get_build_py_cmd, get_install_cmd - sys.path = a except: print("=======================================") print("Use 'git submodule update --init' first") From ace8d7532fadab0fdd0480d7693001e1d2d21310 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 9 Nov 2017 07:01:38 +0100 Subject: [PATCH 089/117] fixing all issues --- mtf/metadata/tmet/filter.py | 15 +++++++++++---- mtf/metadata/tmet/selftests.py | 20 ++++++++++---------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/mtf/metadata/tmet/filter.py b/mtf/metadata/tmet/filter.py index 74b46dc..53d910f 100644 --- a/mtf/metadata/tmet/filter.py +++ b/mtf/metadata/tmet/filter.py @@ -21,6 +21,8 @@ def get_options(): help='output for selected backend') parser.add_argument('--linters', dest='linters', action='store_true', help='output for selected backend') + parser.add_argument('--nofilters', dest='nofilters', action='store_true', + help='disable all filters in config file and show all tests for backend') parser.add_argument('tests', nargs='*', help='import tests for selected backed') args = parser.parse_args() @@ -34,12 +36,13 @@ def main(): linters=options.linters, tests=options.tests, tags=options.tags, - relevancy=options.relevancy + relevancy=options.relevancy, + applyfilters=not options.nofilters ) print " ".join([x[common.SOURCE] for x in output]) -def filtertests(backend, location, linters, tests, tags, relevancy): +def filtertests(backend, location, linters, tests, tags, relevancy, applyfilters=True): """ Basic method to use it for wrapping inside another python code, allows apply tag filters and relevancy @@ -58,5 +61,9 @@ def filtertests(backend, location, linters, tests, tags, relevancy): if tests: for test in tests: meta._import_tests(test) - meta.add_filter(tags=tags, relevancy=relevancy) - return meta.apply_filters() + + if applyfilters: + meta.add_filter(tags=tags, relevancy=relevancy) + return meta.apply_filters() + else: + return meta.backend_tests() diff --git a/mtf/metadata/tmet/selftests.py b/mtf/metadata/tmet/selftests.py index 019bfc3..4a71a9e 100644 --- a/mtf/metadata/tmet/selftests.py +++ b/mtf/metadata/tmet/selftests.py @@ -28,24 +28,24 @@ def test_mtf_metadata_linters_and_tests_noconfig(): # print yaml.dump(mt.get_metadata()) # print mt.backend_passtrought_args() # print mt.apply_filters() - case_justlinters_nofilter = len(mt.apply_filters()) + case_justlinters_nofilter = mt.apply_filters() print_debug(case_justlinters_nofilter) mt._import_tests("*.py") - case_lintersanstests_nofilter = len(mt.apply_filters()) + case_lintersanstests_nofilter = mt.apply_filters() print_debug(case_lintersanstests_nofilter) mt.add_filter(tags=["add"]) - case_lintersanstests_filter1 = len(mt.apply_filters()) + case_lintersanstests_filter1 = mt.apply_filters() print_debug(case_lintersanstests_filter1) mt.add_filter(tags=["-add"]) - case_lintersanstests_filter2 = len(mt.apply_filters()) + case_lintersanstests_filter2 = mt.apply_filters() print_debug(case_lintersanstests_filter2) - assert case_justlinters_nofilter > 20 - assert case_lintersanstests_nofilter > case_justlinters_nofilter - assert case_lintersanstests_filter1 > case_justlinters_nofilter - assert case_lintersanstests_filter1 < case_lintersanstests_nofilter - assert case_lintersanstests_filter1 > case_lintersanstests_filter2 - assert case_lintersanstests_filter2 < case_justlinters_nofilter + assert len(case_justlinters_nofilter) > 20 + assert len(case_lintersanstests_nofilter) > len(case_justlinters_nofilter) + assert len(case_lintersanstests_filter1) < len(case_justlinters_nofilter) + assert len(case_lintersanstests_filter1) < len(case_lintersanstests_nofilter) + assert len(case_lintersanstests_filter1) > len(case_lintersanstests_filter2) + assert len(case_lintersanstests_filter2) < len(case_justlinters_nofilter) # print [v[SOURCE] for v in mt.backend_tests()] # mt.apply_filters() From db47283dc85611d40921b4b7e578ecb0f34bd41b Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Mon, 27 Nov 2017 14:55:09 +0100 Subject: [PATCH 090/117] removed unused imports --- moduleframework/mtf_scheduler.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/moduleframework/mtf_scheduler.py b/moduleframework/mtf_scheduler.py index 74d1abb..fea080b 100755 --- a/moduleframework/mtf_scheduler.py +++ b/moduleframework/mtf_scheduler.py @@ -25,8 +25,6 @@ import os import moduleframework import tempfile -import textwrap -import sys import json from avocado.utils import process From 7a4eb371b103bc2960de418cca5264a2d32b7082 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Tue, 28 Nov 2017 13:31:06 +0100 Subject: [PATCH 091/117] fix copr builds --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 98ed53e..363ef9e 100755 --- a/setup.py +++ b/setup.py @@ -94,7 +94,7 @@ def get_dir(system_path=None, virtual_path=None): 'mtf = moduleframework.mtf_scheduler:main', ] }, - setup_requires=open('requirements.txt').read().splitlines(), + setup_requires=[], classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Console', From 491e532eb5c368ed0a7eea98cc8e8e924ff2be8e Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Tue, 28 Nov 2017 13:44:22 +0100 Subject: [PATCH 092/117] add all variables --- moduleframework/mtf_scheduler.py | 35 +++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/moduleframework/mtf_scheduler.py b/moduleframework/mtf_scheduler.py index fea080b..13d5068 100755 --- a/moduleframework/mtf_scheduler.py +++ b/moduleframework/mtf_scheduler.py @@ -38,17 +38,38 @@ def mtfparser(): """ VARIABLES - CONFIG defines the module configuration file. It defaults to config.yaml + AVOCADO_LOG_DEBUG=yes enables avocado debug output. - MODULE defines tested module type, if default-module is not set in config.yaml. + DEBUG=yes enables debugging mode to test output. - URL to container. - E.g. URL=docker.io/modularitycontainers/haproxy if MODULE=docker + CONFIG defines the module configuration file. It defaults to config.yaml. - MODULEMDURL - overwrites the location of a moduleMD file. + MODULE defines tested module type, if default-module is not set in config.yaml. - and more at http://meta-test-family.readthedocs.io/en/latest/user_guide/environment_variables.html + =docker uses the docker section of config.yaml. + =rpm uses the rpm section of config.yaml and tests RPMs directly on a host. + =nspawn tests RPMs in a virtual environment with systemd-nspawn. + + URL overrides the value of module.docker.container or module.rpm.repo. + The URL should correspond to the MODULE variable, for example: + URL=docker.io/modularitycontainers/haproxy if MODULE=docker + URL=https://phracek.fedorapeople.org/haproxy-module-repo # if MODULE=nspawn or MODULE=rpm + + MODULEMDURL overwrites the location of a moduleMD file. + + COMPOSEURL overwrites the location of a compose Pungi build. + + MTF_SKIP_DISABLING_SELINUX=yes + does not disable SELinux. In nspawn type on Fedora 25 SELinux should be disabled, + because it does not work well with SELinux enabled. + + MTF_DO_NOT_CLEANUP=yes does not clean up module after tests execution. + + MTF_REUSE=yes uses the same module between tests. It speeds up test execution. + + MTF_REMOTE_REPOS=yes disables downloading of Koji packages and creating a local repo. + + MTF_DISABLE_MODULE=yes disables module handling to use nonmodular test mode. """ parser = argparse.ArgumentParser( # TODO From 7c6fb7a1c88e45a4712027905837f32acf12decc Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Tue, 28 Nov 2017 13:47:23 +0100 Subject: [PATCH 093/117] remove mtf-log-parser from specfile --- meta-test-family.spec | 1 - 1 file changed, 1 deletion(-) diff --git a/meta-test-family.spec b/meta-test-family.spec index 8ab14a0..b70e7d3 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -52,7 +52,6 @@ install -d -p -m 755 %{buildroot}%{_datadir}/%{framework_name} %{_bindir}/mtf-generator %{_bindir}/mtf-env-set %{_bindir}/mtf-env-clean -%{_bindir}/mtf-log-parser %{_bindir}/mtf-init %{python2_sitelib}/moduleframework/ %{python2_sitelib}/mtf/ From 2dee2720aa77a8d6baf52c75fbacb86c687eba30 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 29 Nov 2017 12:52:57 +0100 Subject: [PATCH 094/117] man page is generated now --- man/mtf-generator.1 | 25 +++++++++++-------------- moduleframework/mtf_generator.py | 12 ++++++++++++ setup.cfg | 1 + 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/man/mtf-generator.1 b/man/mtf-generator.1 index 5fb9e73..4bc4d84 100644 --- a/man/mtf-generator.1 +++ b/man/mtf-generator.1 @@ -1,19 +1,16 @@ -.\" Copyright Petr Hracek, 2017 -.\" -.\" This page is distributed under GPL. -.\" -.TH mtf-generator 1 2017-11-01 "" "Linux User's Manual" +.TH mtf-generator "1" Manual .SH NAME -mtf-generator \- generates code for tests written in \fBconfig.yaml\fP file. - +mtf-generator \- Its is a binary file included in Meta-Test-Family package. It generates code for tests written in config.yaml file for usage by mtf binary. .SH SYNOPSIS -.B -mtf - +.B mtf-generator +[-h] .SH DESCRIPTION -\fBmtf-generator\fP is a binary file included in Meta-Test-Family package. -It generates code for tests written in \fBconfig.yaml\fP file for usage by \fBmtf\fP binary. +generates code for tests written in config.yaml file. .SH AUTHORS -Petr Hracek, (man page) - +.B meta\-test\-family +was written by Jan Scotka . +.SH DISTRIBUTION +The latest version of meta\-test\-family may be downloaded from +.UR https://github.com/fedora\-modularity/meta\-test\-family +.UE diff --git a/moduleframework/mtf_generator.py b/moduleframework/mtf_generator.py index 755757e..67b008c 100644 --- a/moduleframework/mtf_generator.py +++ b/moduleframework/mtf_generator.py @@ -34,8 +34,18 @@ """ from common import print_info, CommonFunctions +import argparse +def cliparser(): + parser = argparse.ArgumentParser( + prog="mtf-generator", + description="generates code for tests written in config.yaml file.", + epilog="see http://meta-test-family.readthedocs.io for more info", + ) + parser.man_short_description = "Its is a binary file included in Meta-Test-Family package. It generates code for tests written in config.yaml file for usage by mtf binary." + return parser + class TestGenerator(CommonFunctions): def __init__(self): """ @@ -95,6 +105,8 @@ def main(): """ Creates ``tests/generated.py`` file . """ + # this tool doesn't use any options, so adding only --help + cliparser().parse_args() config = TestGenerator() configout = open('generated.py', 'w') configout.write(config.output) diff --git a/setup.cfg b/setup.cfg index 6b1c9ad..4f8608e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,3 +4,4 @@ description-file = README.md [build_manpages] manpages = man/mtf.1:function=mtfparser:module=moduleframework.mtf_scheduler + man/mtf-generator.1:function=cliparser:module=moduleframework.mtf_generator From c628c648572ca6f2a3e2c871641103f90312f140 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 29 Nov 2017 15:54:54 +0100 Subject: [PATCH 095/117] man page is generated now --- man/mtf-env-clean.1 | 41 ------------------------- man/mtf-env-set.1 | 44 --------------------------- moduleframework/mtf_environment.py | 49 ++++++++++++++++++++++++++++-- setup.cfg | 3 ++ 4 files changed, 50 insertions(+), 87 deletions(-) delete mode 100644 man/mtf-env-clean.1 delete mode 100644 man/mtf-env-set.1 diff --git a/man/mtf-env-clean.1 b/man/mtf-env-clean.1 deleted file mode 100644 index 202e793..0000000 --- a/man/mtf-env-clean.1 +++ /dev/null @@ -1,41 +0,0 @@ -.\" Copyright Petr Hracek, 2017 -.\" -.\" This page is distributed under GPL. -.\" -.TH MTF-ENV-CLEAN 1 2017-11-01 "" "Linux User's Manual" -.SH NAME -mtf-env-clean \- cleans environment for testing containers. - -.SH SYNOPSIS -\fIMODULE=docker\/\fR -.B mtf-env-clean - -\fIMODULE=rpm\/\fR -.B mtf-env-clean - -\fIMODULE=nspawn\/\fR -.B mtf-env-clean - -.SH DESCRIPTION -.PP -\fBmtf-env-clean\fP is a binary file used for cleaning environment after usage of Meta-Test-Family. - -.PP -\fIMODULE=docker\/\fR stops docker service. - -.PP -\fIMODULE=rpm\/\fR does not do any cleanup, -as that could potentially uninstall essential packages and therefore damage this machine. - -.PP -\fIMODULE=nspawn\/\fR switches SELinux back to original state. - -.SH NOTES -\fBmtf-env-clean\fP mtf-env-set is useful for people who don't want to clean the environment -manually and wish to use this executable to do the job. - -.SH AUTHORS -Petr Hracek, (man page) - -.SH "SEE ALSO" -Full documentation at: \ No newline at end of file diff --git a/man/mtf-env-set.1 b/man/mtf-env-set.1 deleted file mode 100644 index fecd1d9..0000000 --- a/man/mtf-env-set.1 +++ /dev/null @@ -1,44 +0,0 @@ -.\" Copyright Petr Hracek, 2017 -.\" -.\" This page is distributed under GPL. -.\" -.TH MTF-ENV-SET 1 2017-11-01 "" "Linux User's Manual" -.SH NAME -mtf-env-set \- prepares environment for testing containers. - -.SH SYNOPSIS -\fIMODULE=docker\/\fR -.B mtf-env-set - -\fIMODULE=rpm\/\fR -.B mtf-env-set - -\fIMODULE=nspawn\/\fR -.B mtf-env-set - - -.SH DESCRIPTION -.PP -\fBmtf-env-set\fP is a binary file used for setting environment before usage of Meta-Test-Family. - -.PP -\fIMODULE=docker\/\fR It installs tests dependencies, docker service -and starts docker service for testing containers - -.PP -\fIMODULE=rpm\/\fR installs RPM dependencies on this machine. - -.PP -\fIMODULE=nspawn\/\fR installs RPM dependencies. If environment variable MTF_SKIP_DISABLING_SELINUX is -set, it disables SELinux. It installs systemd-container package on this machine. - - -.SH NOTES -\fBmtf-env-set\fP mtf-env-set is useful for people who don't want to set the environment manually -and wish to use this executable to do the job. - -.SH AUTHORS -Petr Hracek, (man page) - -.SH "SEE ALSO" -Full documentation at: \ No newline at end of file diff --git a/moduleframework/mtf_environment.py b/moduleframework/mtf_environment.py index 3d9e42a..e20cb8e 100644 --- a/moduleframework/mtf_environment.py +++ b/moduleframework/mtf_environment.py @@ -24,6 +24,8 @@ """ Module to setup and cleanup the test environment. """ +import argparse +from moduleframework.common import ModuleFrameworkException from moduleframework.common import get_module_type_base, print_info from moduleframework.environment_prepare.docker_prepare import EnvDocker from moduleframework.environment_prepare.rpm_prepare import EnvRpm @@ -31,8 +33,39 @@ from moduleframework.environment_prepare.openshift_prepare import EnvOpenShift -module_name = get_module_type_base() -print_info("Setting environment for module: {} ".format(module_name)) +def cliparser_mtfenvset(): + # method needs to be without parameter due to man generator + script_name="mtf-env-set" + parser = argparse.ArgumentParser( + description='Its is a binary file used for setting environment before usage of Meta-Test-Family. See documentation at: http://meta-test-family.readthedocs.io/en/latest/user_guide/environment_setup.html', + formatter_class=argparse.RawTextHelpFormatter, + prog=script_name, + epilog="see http://meta-test-family.readthedocs.io for more info", + usage="MODULE=[choose_module_type] {0}".format(script_name), + ) + parser.man_short_description = "prepares environment for testing containers" + return parser + + +def cliparser_mtfenvclean(): + # method needs to be without parameter due to man generator + script_name="mtf-env-clean" + parser = argparse.ArgumentParser( + description='Its is a binary file used for setting environment before usage of Meta-Test-Family. See documentation at: http://meta-test-family.readthedocs.io/en/latest/user_guide/environment_setup.html', + formatter_class=argparse.RawTextHelpFormatter, + prog=script_name, + epilog="see http://meta-test-family.readthedocs.io for more info", + usage="MODULE=[choose_module_type] {0}".format(script_name), + ) + parser.man_short_description = "prepares environment for testing containers" + return parser + + +try: + module_name = get_module_type_base() + print_info("Setting environment for module: {} ".format(module_name)) +except ModuleFrameworkException as e: + module_name = None if module_name == "docker": env = EnvDocker() @@ -45,12 +78,24 @@ def mtfenvset(): + cliparser_mtfenvset().parse_args() + try: + get_module_type_base() + except ModuleFrameworkException as e: + print_info(e) + exit(1) print_info("Preparing environment ...") # cleanup_env exists in more forms for backend : EnvDocker/EnvRpm/EnvNspawn env.prepare_env() def mtfenvclean(): + cliparser_mtfenvclean().parse_args() + try: + get_module_type_base() + except ModuleFrameworkException as e: + print_info(e) + exit(1) # cleanup_env exists in more forms for backend: EnvDocker/EnvRpm/EnvNspawn env.cleanup_env() print_info("All clean") diff --git a/setup.cfg b/setup.cfg index 4f8608e..ce82ca7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,3 +5,6 @@ description-file = README.md manpages = man/mtf.1:function=mtfparser:module=moduleframework.mtf_scheduler man/mtf-generator.1:function=cliparser:module=moduleframework.mtf_generator + man/mtf-env-set.1:function=cliparser_mtfenvset:module=moduleframework.mtf_environment + man/mtf-env-clean.1:function=cliparser_mtfenvclean:module=moduleframework.mtf_environment + From f9a569975695287ead66505d0bc987c449a7b3a9 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 29 Nov 2017 15:57:40 +0100 Subject: [PATCH 096/117] man page is generated now --- man/mtf-generator.1 | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 man/mtf-generator.1 diff --git a/man/mtf-generator.1 b/man/mtf-generator.1 deleted file mode 100644 index 4bc4d84..0000000 --- a/man/mtf-generator.1 +++ /dev/null @@ -1,16 +0,0 @@ -.TH mtf-generator "1" Manual -.SH NAME -mtf-generator \- Its is a binary file included in Meta-Test-Family package. It generates code for tests written in config.yaml file for usage by mtf binary. -.SH SYNOPSIS -.B mtf-generator -[-h] -.SH DESCRIPTION -generates code for tests written in config.yaml file. - -.SH AUTHORS -.B meta\-test\-family -was written by Jan Scotka . -.SH DISTRIBUTION -The latest version of meta\-test\-family may be downloaded from -.UR https://github.com/fedora\-modularity/meta\-test\-family -.UE From 971d7c585a809e403be57d5ce27f3848e4349481 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Wed, 29 Nov 2017 16:02:06 +0100 Subject: [PATCH 097/117] add cool comments --- moduleframework/mtf_environment.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/moduleframework/mtf_environment.py b/moduleframework/mtf_environment.py index e20cb8e..7ca3854 100644 --- a/moduleframework/mtf_environment.py +++ b/moduleframework/mtf_environment.py @@ -61,6 +61,7 @@ def cliparser_mtfenvclean(): return parser +# to handle start without MODULE set; the whole module is called by man page generator for argparsers try: module_name = get_module_type_base() print_info("Setting environment for module: {} ".format(module_name)) @@ -79,6 +80,7 @@ def cliparser_mtfenvclean(): def mtfenvset(): cliparser_mtfenvset().parse_args() + # to handle start without MODULE set try: get_module_type_base() except ModuleFrameworkException as e: @@ -91,6 +93,7 @@ def mtfenvset(): def mtfenvclean(): cliparser_mtfenvclean().parse_args() + # to handle start without MODULE set try: get_module_type_base() except ModuleFrameworkException as e: From 35d69afb21eb611eab17866abd016089d4843df3 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 29 Nov 2017 17:53:52 +0100 Subject: [PATCH 098/117] create clean commit based on PR#172 --- docs/user_guide/environment_variables.rst | 1 + meta-test-family.spec | 2 + moduleframework/common.py | 81 ++-- moduleframework/helpers/container_helper.py | 2 +- moduleframework/helpers/nspawn_helper.py | 6 +- moduleframework/helpers/rpm_helper.py | 14 +- moduleframework/pdc_data.py | 393 +++++++++++------- .../pdc_msg_module_info_reader.py | 44 +- setup.py | 9 +- tools/run-them.sh | 6 +- 10 files changed, 347 insertions(+), 211 deletions(-) rename tools/taskotron-msg-reader.py => moduleframework/pdc_msg_module_info_reader.py (69%) diff --git a/docs/user_guide/environment_variables.rst b/docs/user_guide/environment_variables.rst index ea42e6e..3ac7139 100644 --- a/docs/user_guide/environment_variables.rst +++ b/docs/user_guide/environment_variables.rst @@ -29,6 +29,7 @@ Environment variables allow to overwrite some values of a module configuration f - **OPENSHIFT_IP=openshift_ip_address** uses this IP address for connecting to an OpenShift environment. - **OPENSHIFT_USER=developer** uses this ``USER`` name for login to an OpenShift environment. - **OPENSHIFT_PASSWORD=developer** uses this ``PASSWORD`` name for login to an OpenShift environment. +- **MTF_ODCS=[yes|openIDCtoken_string]** enable ODCS for compose creation. Token has to be placed or it tries contact openIDC token via your web browser. **Experimental feature** .. _multihost tests: https://github.com/fedora-modularity/meta-test-family/tree/devel/examples/multios_testing diff --git a/meta-test-family.spec b/meta-test-family.spec index b70e7d3..64f551e 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -23,6 +23,7 @@ Requires: python2-pdc-client Requires: python2-modulemd Requires: python2-dockerfile-parse Requires: python-mistune +Requires: python2-odcs-client Provides: modularity-testing-framework = %{version}-%{release} Obsoletes: modularity-testing-framework < 0.5.18-2 @@ -53,6 +54,7 @@ install -d -p -m 755 %{buildroot}%{_datadir}/%{framework_name} %{_bindir}/mtf-env-set %{_bindir}/mtf-env-clean %{_bindir}/mtf-init +%{_bindir}/mtf-pdc-module-info-reader %{python2_sitelib}/moduleframework/ %{python2_sitelib}/mtf/ %{python2_sitelib}/meta_test_family-*.egg-info/ diff --git a/moduleframework/common.py b/moduleframework/common.py index a7564bd..95233b1 100644 --- a/moduleframework/common.py +++ b/moduleframework/common.py @@ -36,6 +36,7 @@ import sys import random import string +import requests from avocado.utils import process from moduleframework.mtfexceptions import ModuleFrameworkException, ConfigExc, CmdExc @@ -84,24 +85,13 @@ # time in seconds DEFAULTRETRYTIMEOUT = 30 DEFAULTNSPAWNTIMEOUT = 10 -MODULE_DEFAULT_PROFILE="default" - +MODULE_DEFAULT_PROFILE = "default" +TRUE_VALUES_DICT = ['yes', 'YES', 'yes', 'True', 'true', 'ok', 'OK'] def generate_unique_name(size=10): return ''.join(random.choice(string.ascii_lowercase) for _ in range(size)) -def get_compose_url_modular_release(): - default_release = "27" - release = os.environ.get("MTF_FEDORA_RELEASE") or default_release - if release == "master": - release = default_release - - base_url = "https://kojipkgs.fedoraproject.org/compose/latest-Fedora-Modular-{}/compose/Server/{}/os" - compose_url = os.environ.get("MTF_COMPOSE_BASE") or base_url.format(release, ARCH) - return compose_url - - def is_debug(): """ Return the **DEBUG** envvar. @@ -243,6 +233,49 @@ def get_if_remoterepos(): return bool(remote_repos) +def get_odcs_auth(): + """ + use ODCS for creating composes as URL parameter + It enables this feature in case MTF_ODCS envvar is set + MTF_ODCS=yes -- use openidc and token for your user + MTF_ODCS=OIDC_token_string -- use this token for authentication + + :envvar MTF_ODCS: yes or token + :return: + """ + odcstoken = os.environ.get('MTF_ODCS') + + # in case you dont have token enabled, try to ask for openidc via web browser + if odcstoken in TRUE_VALUES_DICT: + # to not have hard dependency on openidc (use just when using ODCS without defined token) + import openidc_client + id_provider = 'https://id.fedoraproject.org/openidc/' + # Get the auth token using the OpenID client. + oidc = openidc_client.OpenIDCClient( + 'odcs', + id_provider, + {'Token': 'Token', 'Authorization': 'Authorization'}, + 'odcs-authorizer', + 'notsecret', + ) + + scopes = [ + 'openid', + 'https://id.fedoraproject.org/scope/groups', + 'https://pagure.io/odcs/new-compose', + 'https://pagure.io/odcs/renew-compose', + 'https://pagure.io/odcs/delete-compose', + ] + try: + odcstoken = oidc.get_token(scopes, new_token=True) + except requests.exceptions.HTTPError as e: + print_info(e.response.text) + raise ModuleFrameworkException("Unable to get token via OpenIDC for your user") + if odcstoken and len(odcstoken)<10: + raise ModuleFrameworkException("Unable to parse token for ODCS, token is too short: %s" % odcstoken) + return odcstoken + + def get_if_module(): """ Return the **MTF_DISABLE_MODULE** envvar. @@ -352,19 +385,17 @@ class CommonFunctions(object): """ config = None modulemdConf = None + component_name = None + source = None + arch = None + sys_arch = None + dependencylist = {} + is_it_module = False + packager = None + # general use case is to have forwarded services to host (so thats why it is same) + ipaddr = trans_dict["HOSTIPADDR"] def __init__(self, *args, **kwargs): - self.config = None - self.modulemdConf = None - self.moduleName = None - self.source = None - self.arch = None - self.sys_arch = None - self.dependencylist = {} - self.is_it_module = False - self.packager = None - # general use case is to have forwarded services to host (so thats why it is same) - self.ipaddr = trans_dict["HOSTIPADDR"] trans_dict["GUESTARCH"] = self.getArch() self.loadconfig() @@ -388,7 +419,7 @@ def loadconfig(self): else: pass - self.moduleName = sanitize_text(self.config['name']) + self.component_name = sanitize_text(self.config['name']) self.source = self.config.get('source') self.set_url() diff --git a/moduleframework/helpers/container_helper.py b/moduleframework/helpers/container_helper.py index 895a96b..c39203b 100644 --- a/moduleframework/helpers/container_helper.py +++ b/moduleframework/helpers/container_helper.py @@ -151,7 +151,7 @@ def start(self, args="-it -d", command="/bin/bash"): if self.status() is False: raise ContainerExc( "Container %s (for module %s) is not running, probably DEAD immediately after start (ID: %s)" % ( - self.jmeno, self.moduleName, self.docker_id)) + self.jmeno, self.component_name, self.docker_id)) trans_dict["GUESTPACKAGER"] = self.get_packager() def stop(self): diff --git a/moduleframework/helpers/nspawn_helper.py b/moduleframework/helpers/nspawn_helper.py index bd4b1f0..d1243b1 100644 --- a/moduleframework/helpers/nspawn_helper.py +++ b/moduleframework/helpers/nspawn_helper.py @@ -50,9 +50,9 @@ def __init__(self): actualtime = time.time() self.chrootpath_baseimage = "" if not get_if_reuse(): - self.jmeno = "%s_%r" % (self.moduleName, actualtime) + self.jmeno = "%s_%r" % (self.component_name, actualtime) else: - self.jmeno = self.moduleName + self.jmeno = self.component_name self.chrootpath = os.path.abspath(self.baseprefix + self.jmeno) @@ -72,7 +72,7 @@ def setUp(self): self.setRepositoriesAndWhatToInstall() # never move this line to __init__ this localtion can change before setUp (set repositories) self.chrootpath_baseimage = os.path.abspath(self.baseprefix + - self.moduleName + + self.component_name + "_image_" + hashlib.md5(" ".join(self.repos)).hexdigest()) self.__image_base = Image(location=self.chrootpath_baseimage, packageset=self.whattoinstallrpm, repos=self.repos, ignore_installed=True) diff --git a/moduleframework/helpers/rpm_helper.py b/moduleframework/helpers/rpm_helper.py index 2691738..e62550b 100644 --- a/moduleframework/helpers/rpm_helper.py +++ b/moduleframework/helpers/rpm_helper.py @@ -41,7 +41,7 @@ def __init__(self): # allow to fake environment in ubuntu (for Travis) if not os.path.exists(baserepodir): baserepodir="/var/tmp" - self.yumrepo = os.path.join(baserepodir, "%s.repo" % self.moduleName) + self.yumrepo = os.path.join(baserepodir, "%s.repo" % self.component_name) self.whattoinstallrpm = [] self.bootstrappackages = [] self.repos = [] @@ -69,7 +69,7 @@ def setUp(self): self.__prepare() def __addModuleDependency(self, url, name=None, stream="master"): - name = name if name else self.moduleName + name = name if name else self.component_name if name in self.dependencylist: self.dependencylist[name]['urls'].append(url) else: @@ -90,9 +90,11 @@ def setRepositoriesAndWhatToInstall(self, repos=[], whattooinstall=None): self.repos += get_compose_url() or self.get_url() # add also all dependent modules repositories if it is module # TODO: removed this dependency search - if not get_compose_url() and self.is_it_module: - depend_repos = [get_compose_url_modular_release()] - self.repos += depend_repos + if self.is_it_module and not get_compose_url(): + # inside this code we don't know anything about modules, this leads to + # generic repositories in pdc_data.PDCParserGeneral + pdcsolver = pdc_data.PDCParserGeneral(self.component_name) + self.repos += [pdcsolver.get_repo()] self.repos = list(set(self.repos)) if whattooinstall: self.whattoinstallrpm = list(set(whattooinstall)) @@ -117,7 +119,7 @@ def __prepare(self): enabled=1 gpgcheck=0 -""" % (self.moduleName, counter, self.moduleName, counter, repo) +""" % (self.component_name, counter, self.component_name, counter, repo) f.write(add) f.close() self.install_packages() diff --git a/moduleframework/pdc_data.py b/moduleframework/pdc_data.py index 653638a..fc49cb3 100644 --- a/moduleframework/pdc_data.py +++ b/moduleframework/pdc_data.py @@ -28,159 +28,102 @@ Construct parameters for automatization (CIs) """ -import re +import warnings import yaml import os -from avocado import utils - +import sys +from avocado.utils import process from common import print_info, DEFAULTRETRYCOUNT, DEFAULTRETRYTIMEOUT, \ - get_if_remoterepos, get_compose_url_modular_release, MODULEFILE, print_debug,\ - is_debug, ARCH, is_recursive_download, trans_dict, BASEPATHDIR + get_if_remoterepos, MODULEFILE, print_debug,\ + is_debug, ARCH, is_recursive_download, trans_dict, BASEPATHDIR, get_odcs_auth from moduleframework import mtfexceptions from pdc_client import PDCClient from timeoutlib import Retry - -PDC_SERVER = "https://pdc.fedoraproject.org/rest_api/v1/unreleasedvariants" - -def getBasePackageSet(modulesDict=None, isModule=True, isContainer=False): - """ - Get list of base packages (for bootstrapping of various module types) - It is used internally, you should not use it in case you don't know where to use it. - - :param modulesDict: dictionary of dependent modules - :param isModule: bool is module - :param isContainer: bool is contaner? - :return: list of packages to install - """ - # nspawn container need to install also systemd to be able to boot - out = [] - brmod = "base-runtime" - brmod_profiles = ["container", "baseimage"] - brmod_stream = "master" - BASEPACKAGESET_WORKAROUND = ["systemd"] - BASEPACKAGESET_WORKAROUND_NOMODULE = BASEPACKAGESET_WORKAROUND + ["dnf"] - pdc = None - basepackageset = [] - if isModule: - print_info("Searching for packages base package set inside %s" % brmod) - pdc = PDCParser() - pdc.setLatestPDC(brmod, brmod_stream) - for pr in brmod_profiles: - if pdc.getmoduleMD()['data']['profiles'].get(pr): - basepackageset = pdc.getmoduleMD( - )['data']['profiles'][pr]['rpms'] - break - if isContainer: - out = basepackageset - else: - out = basepackageset + BASEPACKAGESET_WORKAROUND - else: - if isContainer: - out = basepackageset - else: - out = basepackageset + BASEPACKAGESET_WORKAROUND_NOMODULE - print_info("Base packages to install:", out) - return out - -def get_repo_url(wmodule="base-runtime", wstream="master", fake=False): - """ - Return URL location of rpm repository. - It reads data from PDC and construct url locator. - It is used to solve repos for dependent modules (eg. memcached is dependent on perl and baseruntime) - - :param wmodule: module name - :param wstream: module stream - :param fake: - :return: str - """ - if fake: - return "http://mirror.vutbr.cz/fedora/releases/25/Everything/x86_64/os/" - else: - tmp_pdc = PDCParser() - tmp_pdc.setLatestPDC(wmodule, wstream) - return tmp_pdc.generateRepoUrl() +try: + from odcs.client.odcs import ODCS, AuthMech +except: + print_info("ODCS library cannot be imported. ODCS is not supported") -class PDCParser(): +PDC_SERVER = "https://pdc.fedoraproject.org/rest_api/v1/unreleasedvariants" +ODCS_URL = "https://odcs.fedoraproject.org" +DEFAULT_MODULE_STREAM = "master" +BASE_REPO_URL = "https://kojipkgs.fedoraproject.org/compose/latest-Fedora-Modular-{}/compose/Server/{}/os" + +def get_module_nsv(name=None, stream=None, version=None): + name = name or os.environ.get('MODULE_NAME') + stream = stream or os.environ.get('MODULE_STREAM') or DEFAULT_MODULE_STREAM + version = version or os.environ.get('MODULE_VERSION') + return {'name':name, 'stream':stream, 'version':version} + + +def get_base_compose(): + default_release = "27" + release = os.environ.get("MTF_FEDORA_RELEASE") or default_release + if release == "master": + release = default_release + compose_url = os.environ.get("MTF_COMPOSE_BASE") or BASE_REPO_URL.format(release, ARCH) + return compose_url + +class PDCParserGeneral(): """ - Class for parsing PDC data via some setters line setFullVersion, setViaFedMsg, setLatestPDC + Generic class for parsing PDC data (get repo leads to fedora official composes) """ - - def __getDataFromPdc(self): - """ - Internal method, do not use it - - :return: None - """ - pdc_query = { 'variant_id' : self.name, 'active': True } - if self.stream: - pdc_query['variant_version'] = self.stream - if self.version: - pdc_query['variant_release'] = self.version - @Retry(attempts=DEFAULTRETRYCOUNT, timeout=DEFAULTRETRYTIMEOUT, error=mtfexceptions.PDCExc("Could not query PDC server")) - def retry_tmpfunc(): - # Using develop=True to not authenticate to the server - pdc_session = PDCClient(PDC_SERVER, ssl_verify=True, develop=True) - return pdc_session(**pdc_query) - mod_info = retry_tmpfunc() - if not mod_info or "results" not in mod_info.keys() or not mod_info["results"]: - raise mtfexceptions.PDCExc("QUERY: %s is not available on PDC" % pdc_query) - self.pdcdata = mod_info["results"][-1] - self.modulemd = yaml.load(self.pdcdata["modulemd"]) - - def setFullVersion(self, nvr): - """ - Set parameters of class via name-stream-version string - Taskotron uses this format - - :param nvr: - :return: None + name = None + stream = None + version = None + pdcdata = None + modulemd = None + moduledeps = None + + def __init__(self, name, stream=None, version=None): """ - self.name, self.stream, self.version = re.search( - "(.*)-(.*)-(.*)", nvr).groups() - self.__getDataFromPdc() - - def setViaFedMsg(self, yamlinp): - """ - Sets parameters via RAW fedora message from message bus - used by internal CI - - :param yamlinp: yaml input string - :return: - """ - raw = yaml.load(yamlinp) - self.name = raw["msg"]["name"] - self.stream = raw["msg"]["stream"] - self.version = raw["msg"]["version"] - self.__getDataFromPdc() - - def setLatestPDC(self, name, stream="master", version=""): - """ - Most flexible method how to set name stream version for search + Set basic parametrs, module names, streams, versions :param name: name of module :param stream: optional :param version: optional :return: """ - self.name = name - self.stream = stream - self.version = version - self.__getDataFromPdc() + modulensv = get_module_nsv(name=name, stream=stream, version=version) + self.name = modulensv['name'] + self.stream = modulensv['stream'] + self.version = modulensv['version'] + + def __getDataFromPdc(self): + """ + Internal method, do not use it - def generateRepoUrl(self): + :return: None + """ + if not self.pdcdata: + pdc_query = { 'variant_id' : self.name, 'active': True } + if self.stream: + pdc_query['variant_version'] = self.stream + if self.version: + pdc_query['variant_release'] = self.version + @Retry(attempts=DEFAULTRETRYCOUNT, timeout=DEFAULTRETRYTIMEOUT, error=mtfexceptions.PDCExc("Could not query PDC server")) + def retry_tmpfunc(): + # Using develop=True to not authenticate to the server + pdc_session = PDCClient(PDC_SERVER, ssl_verify=True, develop=True) + print print_debug(pdc_session, pdc_query) + return pdc_session(**pdc_query) + mod_info = retry_tmpfunc() + if not mod_info or "results" not in mod_info.keys() or not mod_info["results"]: + raise mtfexceptions.PDCExc("QUERY: %s is not available on PDC" % pdc_query) + self.pdcdata = mod_info["results"][-1] + self.modulemd = yaml.load(self.pdcdata["modulemd"]) + return self.pdcdata + + + def get_repo(self): """ Return string of generated repository located on fedora koji :return: str """ - # rpmrepo = "http://kojipkgs.fedoraproject.org/repos/%s/latest/%s" % ( - # self.pdcdata["koji_tag"] + "-build", ARCH) - if get_if_remoterepos(): - rpmrepo = get_compose_url_modular_release() - return rpmrepo - else: - return self.createLocalRepoFromKoji() + return get_base_compose() + def generateGitHash(self): """ @@ -191,8 +134,12 @@ def generateGitHash(self): return self.getmoduleMD()['data']['xmd']['mbs']['commit'] def getmoduleMD(self): + self.__getDataFromPdc() return self.modulemd + def get_pdc_info(self): + return self.__getDataFromPdc() + def generateModuleMDFile(self): """ Store moduleMD file locally from PDC to tempmodule.yaml file @@ -214,25 +161,40 @@ def generateParams(self): :return: list """ output = [] - output.append("URL=%s" % self.generateRepoUrl()) + output.append("URL=%s" % self.get_repo()) output.append("MODULEMDURL=%s" % self.generateModuleMDFile()) output.append("MODULE=%s" % "nspawn") return output + def __get_module_requires(self): + return self.getmoduleMD().get("data", {}).get("dependencies", {}).get("requires", {}) + def generateDepModules(self): - x = self.getmoduleMD() - out = {} - if x["data"].get("dependencies") and x["data"]["dependencies"].get("requires"): - deps = x["data"]["dependencies"]["requires"] - for dep in deps: - a = PDCParser() - a.setLatestPDC(dep, deps[dep]) - out.update(a.generateDepModules()) - out.update(deps) + if self.moduledeps is None: + rootdepdict = {} + self.__generateDepModules_solver(parentdict=rootdepdict) + self.moduledeps = rootdepdict + return self.moduledeps + + def __generateDepModules_solver(self, parentdict): + deps = self.__get_module_requires() + print_debug("tree traverse from %s: %s"% (self.name, deps)) + for dep in deps: + if dep not in parentdict: + parentdict[dep] = deps[dep] + a = PDCParser(dep, deps[dep]) + a.__generateDepModules_solver(parentdict=parentdict) + + def get_module_identifier(self): + if self.version: + return "%s-%s-%s" % (self.name, self.stream, self.version) + elif self.stream: + return "%s-%s" % (self.name, self.stream) else: - out = {} - return out + return "%s-%s" % (self.name, "master") + +class PDCParserKoji(PDCParserGeneral): def download_tagged(self,dirname): """ Downloads packages to directory, based on koji tags @@ -242,7 +204,7 @@ def download_tagged(self,dirname): :return: None """ print_info("DOWNLOADING ALL packages for %s_%s_%s" % (self.name, self.stream, self.version)) - for foo in utils.process.run("koji list-tagged --quiet %s" % self.pdcdata["koji_tag"], verbose=is_debug()).stdout.split("\n"): + for foo in process.run("koji list-tagged --quiet %s" % self.get_pdc_info()["koji_tag"], verbose=is_debug()).stdout.split("\n"): pkgbouid = foo.strip().split(" ")[0] if len(pkgbouid) > 4: print_debug("DOWNLOADING: %s" % foo) @@ -251,7 +213,7 @@ def download_tagged(self,dirname): error=mtfexceptions.KojiExc( "RETRY: Unbale to fetch package from koji after %d attempts" % (DEFAULTRETRYCOUNT * 10))) def tmpfunc(): - a = utils.process.run( + a = process.run( "cd %s; koji download-build %s -a %s -a noarch" % (dirname, pkgbouid, ARCH), shell=True, verbose=is_debug(), ignore_status=True) if a.exit_status == 1: @@ -265,7 +227,7 @@ def tmpfunc(): tmpfunc() print_info("DOWNLOADING finished") - def createLocalRepoFromKoji(self): + def get_repo(self): """ Return string of generated repository located LOCALLY It downloads all tagged packages and creates repo via createrepo @@ -273,7 +235,7 @@ def createLocalRepoFromKoji(self): :return: str """ dir_prefix = BASEPATHDIR - utils.process.run("{HOSTPACKAGER} install createrepo koji".format( + process.run("{HOSTPACKAGER} install createrepo koji".format( **trans_dict), ignore_status=True) if is_recursive_download(): dirname = os.path.join(dir_prefix,"localrepo_recursive") @@ -284,16 +246,147 @@ def createLocalRepoFromKoji(self): if os.path.exists(os.path.join(absdir,"repodata","repomd.xml")): pass else: - os.mkdir(absdir) + if not os.path.exists(absdir): + os.mkdir(absdir) self.download_tagged(absdir) if is_recursive_download(): allmodules = self.generateDepModules() for mo in allmodules: - localrepo = PDCParser() - localrepo.setLatestPDC(mo, allmodules[mo]) + localrepo = PDCParserKoji(mo, allmodules[mo]) localrepo.download_tagged(dirname) - utils.process.run( + process.run( "cd %s; createrepo -v %s" % (absdir, absdir), shell=True, verbose=is_debug()) return "file://%s" % absdir + + +class PDCParserODCS(PDCParserGeneral): + compose_type = "module" + auth_token = get_odcs_auth() + + def get_repo(self): + odcs = ODCS(ODCS_URL, auth_mech=AuthMech.OpenIDC, openidc_token=self.auth_token) + print_debug("ODCS Starting module composing") + compose_builder = odcs.new_compose(self.get_module_identifier(), self.compose_type) + timeout_time=600 + print_debug("ODCS Module compose started, timeout set to %ss" % timeout_time) + compose_state = odcs.wait_for_compose(compose_builder["id"], timeout=timeout_time) + if compose_state["state_name"] == "done": + print_info("ODCS Compose done, URL with repo file", compose_state["result_repofile"]) + return compose_state["result_repofile"] + else: + raise mtfexceptions.PDCExc("ODCS: Failed to generate compose for module: %s" % + self.get_module_identifier()) + +def getBasePackageSet(modulesDict=None, isModule=True, isContainer=False): + """ + Get list of base packages (for bootstrapping of various module types) + It is used internally, you should not use it in case you don't know where to use it. + + :param modulesDict: dictionary of dependent modules + :param isModule: bool is module + :param isContainer: bool is contaner? + :return: list of packages to install + """ + # nspawn container need to install also systemd to be able to boot + out = [] + BASEPACKAGESET_WORKAROUND = ["systemd"] + BASEPACKAGESET_WORKAROUND_NOMODULE = BASEPACKAGESET_WORKAROUND + ["dnf"] + # https://pagure.io/fedora-kickstarts/blob/f27/f/fedora-modular-container-base.ks + BASE_MODULAR_CONTAINER = ["rootfiles", "tar", "vim-minimal", "dnf", "dnf-yum", "sssd-client"] + # https://pagure.io/fedora-kickstarts/blob/f27/f/fedora-modular-container-common.ks + BASE_MODULAR = ["fedora-modular-release", "bash", "coreutils-single", "glibc-minimal-langpack", + "libcrypt", "rpm", "shadow-utils", "sssd-client", "util-linux"] + if isModule: + if isContainer: + out = BASE_MODULAR_CONTAINER + else: + + out = BASE_MODULAR + BASEPACKAGESET_WORKAROUND + else: + if isContainer: + out = [] + else: + out = BASEPACKAGESET_WORKAROUND_NOMODULE + print_info("Base packages to install:", out) + return out + + +def get_repo_url(wmodule="base-runtime", wstream="master"): + """ + Return URL location of rpm repository. + It reads data from PDC and construct url locator. + It is used to solve repos for dependent modules (eg. memcached is dependent on perl and baseruntime) + + :param wmodule: module name + :param wstream: module stream + :param fake: + :return: str + """ + + tmp_pdc = PDCParser(wmodule, wstream) + return tmp_pdc.get_repo() + + +PDCParser = PDCParserGeneral +if get_odcs_auth(): + PDCParser = PDCParserODCS +elif not get_if_remoterepos(): + PDCParser = PDCParserKoji + +def test_PDC_general_base_runtime(): + print_info(sys._getframe().f_code.co_name) + parser = PDCParserGeneral("base-runtime", "master") + assert not parser.generateDepModules() + assert "module-" in parser.get_pdc_info()["koji_tag"] + print_info(parser.get_repo()) + assert BASE_REPO_URL[:30] in parser.get_repo() + print_info(parser.generateParams()) + assert "MODULE=nspawn" in " ".join(parser.generateParams()) + print_info("URL=%s" % BASE_REPO_URL[:30]) + assert "URL=%s" % BASE_REPO_URL[:30] in " ".join(parser.generateParams()) + +def test_PDC_general_nodejs(): + print_info(sys._getframe().f_code.co_name) + parser = PDCParserGeneral("nodejs", "8") + deps = parser.generateDepModules() + print_info(deps) + assert 'platform' in deps + assert 'host' in deps + assert 'python2' in deps + assert 'python3' in deps + +def test_PDC_koji_nodejs(): + global BASEPATHDIR + BASEPATHDIR = "." + + print_info(sys._getframe().f_code.co_name) + parser = PDCParserKoji("nodejs", "8") + deps = parser.generateDepModules() + print_info(deps) + assert 'platform' in deps + assert 'host' in deps + assert 'python2' in deps + assert 'python3' in deps + print_info(parser.get_repo()) + assert "file://" in parser.get_repo() + assert os.path.abspath(BASEPATHDIR) in parser.get_repo() + assert "MODULE=nspawn" in " ".join(parser.generateParams()) + assert "URL=file://" in " ".join(parser.generateParams()) + # TODO: this subtest is too slow, commented out + #global is_recursive_download + #is_recursive_download = lambda: True + #print_info(parser.get_repo()) + +def test_PDC_ODCS_nodejs(): + print_info(sys._getframe().f_code.co_name) + parser = PDCParserODCS("nodejs", "8") + # TODO: need to setup MTF_ODCS variable with odcs token, and ODCS version at least 0.1.2 + # or your user will be asked to for token interactively + #print_info(parser.get_repo()) + + + + +#test_PDC_ODCS_nodejs() \ No newline at end of file diff --git a/tools/taskotron-msg-reader.py b/moduleframework/pdc_msg_module_info_reader.py similarity index 69% rename from tools/taskotron-msg-reader.py rename to moduleframework/pdc_msg_module_info_reader.py index 972f432..b0e4de6 100755 --- a/tools/taskotron-msg-reader.py +++ b/moduleframework/pdc_msg_module_info_reader.py @@ -23,46 +23,54 @@ # from moduleframework.pdc_data import PDCParser -from optparse import OptionParser +from argparse import ArgumentParser +import yaml +import re -if __name__ == '__main__': - parser = OptionParser() - parser.add_option( +def cli(): + parser = ArgumentParser() + parser.add_argument( "-f", "--file", dest="filename", help="file with message to read fedora message bus", default=None) - parser.add_option( + parser.add_argument( "-r", "--release", dest="release", help="Use release in format name-stream-version as input", default=None) - parser.add_option("-l", "--latest", dest="latest", + parser.add_argument("-l", "--latest", dest="latest", help="Use latest bits, build by MBS and stored in PDC") - parser.add_option( + parser.add_argument( "--commit", dest="commit", action="store_true", - default=False, help="print git commit hash of exact version of module") + return parser.parse_args() - a = PDCParser() - (options, args) = parser.parse_args() + +def main(): + options = cli() + name = None + stream = None + version = None if options.filename: flh = open(options.filename) stdinput = "".join(flh.readlines()).strip() + raw = yaml.load(stdinput) + name = raw["msg"]["name"] + stream = raw["msg"]["stream"] + version = raw["msg"]["version"] flh.close() - a.setViaFedMsg(stdinput) elif options.release: - a.setFullVersion(options.release) + name, stream, version = re.search( + "(.*)-(.*)-(.*)", options.release).groups() elif options.latest: - a.setLatestPDC(options.latest) - else: - raise Exception(parser.print_help()) - + name = options.latest + pdc_solver = PDCParser(name, stream, version) if options.commit: - print a.generateGitHash() + print pdc_solver.generateGitHash() else: - print " ".join(a.generateParams()) + print " ".join(pdc_solver.generateParams()) diff --git a/setup.py b/setup.py index 580ee55..f121aee 100755 --- a/setup.py +++ b/setup.py @@ -23,13 +23,10 @@ import os import sys -try: - from setuptools import setup, find_packages -except ImportError: - from distutils.core import setup - +from setuptools import setup, find_packages from setuptools.command.build_py import build_py from setuptools.command.install import install + try: sys.path = [os.path.join(os.getcwd(), 'build_manpages')] + sys.path from build_manpages.build_manpages import build_manpages, get_build_py_cmd, get_install_cmd @@ -42,6 +39,7 @@ # copy from https://github.com/avocado-framework/avocado/blob/master/setup.py VIRTUAL_ENV = hasattr(sys, 'real_prefix') + def get_dir(system_path=None, virtual_path=None): """ Retrieve VIRTUAL_ENV friendly path @@ -102,6 +100,7 @@ def get_dir(system_path=None, virtual_path=None): 'mtf-env-clean = moduleframework.mtf_environment:mtfenvclean', 'mtf-init = moduleframework.mtf_init:main', 'mtf = moduleframework.mtf_scheduler:main', + 'mtf-pdc-module-info-reader = moduleframework.pdc_msg_module_info_reader:main', ] }, setup_requires=[], diff --git a/tools/run-them.sh b/tools/run-them.sh index b5d2b20..dab62eb 100755 --- a/tools/run-them.sh +++ b/tools/run-them.sh @@ -48,11 +48,11 @@ export RESULTTOOLS=0 function getparams_int(){ ADDIT="$1" if [ "$PARSEITEMTYPE" = "" -o "$PARSEITEMTYPE" = "fedmsg" ]; then - python $MTF_PATH/tools/taskotron-msg-reader.py -f $PARSEITEM $ADDIT + mtf-pdc-module-info-reader -f $PARSEITEM $ADDIT elif [ "$PARSEITEMTYPE" = "taskotron" -o "$PARSEITEMTYPE" = "pdc" ]; then - python $MTF_PATH/tools/taskotron-msg-reader.py -r $PARSEITEM $ADDIT + mtf-pdc-module-info-reader -r $PARSEITEM $ADDIT elif [ -z $ADDIT -a "$PARSEITEMTYPE" = "compose" ]; then - python $MTF_PATH/tools/compose_info_parser.py -c $PARSEITEM -m $MODULENAME + mtf-pdc-module-info-reader -c $PARSEITEM -m $MODULENAME fi } From 40c1d26c33b44e873d1ec9689f54daec5bfe7470 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 30 Nov 2017 08:41:37 +0100 Subject: [PATCH 099/117] remove print function, typo --- moduleframework/pdc_data.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/moduleframework/pdc_data.py b/moduleframework/pdc_data.py index fc49cb3..0fd9cec 100644 --- a/moduleframework/pdc_data.py +++ b/moduleframework/pdc_data.py @@ -106,7 +106,7 @@ def __getDataFromPdc(self): def retry_tmpfunc(): # Using develop=True to not authenticate to the server pdc_session = PDCClient(PDC_SERVER, ssl_verify=True, develop=True) - print print_debug(pdc_session, pdc_query) + print_debug(pdc_session, pdc_query) return pdc_session(**pdc_query) mod_info = retry_tmpfunc() if not mod_info or "results" not in mod_info.keys() or not mod_info["results"]: @@ -343,6 +343,7 @@ def test_PDC_general_base_runtime(): print_info(parser.get_repo()) assert BASE_REPO_URL[:30] in parser.get_repo() print_info(parser.generateParams()) + assert len(parser.generateParams()) == 3 assert "MODULE=nspawn" in " ".join(parser.generateParams()) print_info("URL=%s" % BASE_REPO_URL[:30]) assert "URL=%s" % BASE_REPO_URL[:30] in " ".join(parser.generateParams()) From ce999b44fdb996fb5c9004aed4964c66b2e5dd9e Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 30 Nov 2017 11:32:03 +0100 Subject: [PATCH 100/117] fix vagrant file, fix odcs format of repo, expected dir not repofile --- Vagrantfile | 3 ++- moduleframework/pdc_data.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Vagrantfile b/Vagrantfile index 506d27e..c47fe8c 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -49,7 +49,8 @@ Vagrant.configure(2) do |config| dnf install -y make docker httpd git python2-avocado python2-avocado-plugins-output-html \ python-netifaces redhat-rpm-config python2-devel python-gssapi krb5-devel cd /opt/meta-test-family - make install + git submodule update --init + make install_pip make -C examples/testing-module $TARGET cp -r /root/avocado /var/www/html/ chmod -R a+x /var/www/html/ diff --git a/moduleframework/pdc_data.py b/moduleframework/pdc_data.py index 0fd9cec..eab3442 100644 --- a/moduleframework/pdc_data.py +++ b/moduleframework/pdc_data.py @@ -28,14 +28,13 @@ Construct parameters for automatization (CIs) """ -import warnings import yaml import os import sys from avocado.utils import process from common import print_info, DEFAULTRETRYCOUNT, DEFAULTRETRYTIMEOUT, \ get_if_remoterepos, MODULEFILE, print_debug,\ - is_debug, ARCH, is_recursive_download, trans_dict, BASEPATHDIR, get_odcs_auth + is_debug, ARCH, is_recursive_download, trans_dict, get_odcs_auth from moduleframework import mtfexceptions from pdc_client import PDCClient from timeoutlib import Retry @@ -267,14 +266,16 @@ class PDCParserODCS(PDCParserGeneral): def get_repo(self): odcs = ODCS(ODCS_URL, auth_mech=AuthMech.OpenIDC, openidc_token=self.auth_token) - print_debug("ODCS Starting module composing") + print_debug("ODCS Starting module composing: %s" % odcs, + "%s compose for: %s" % (self.compose_type, self.get_module_identifier())) compose_builder = odcs.new_compose(self.get_module_identifier(), self.compose_type) timeout_time=600 print_debug("ODCS Module compose started, timeout set to %ss" % timeout_time) compose_state = odcs.wait_for_compose(compose_builder["id"], timeout=timeout_time) if compose_state["state_name"] == "done": - print_info("ODCS Compose done, URL with repo file", compose_state["result_repofile"]) - return compose_state["result_repofile"] + compose = "{compose}/{arch}/os".format(compose=compose_state["result_repo"], arch=ARCH) + print_info("ODCS Compose done, URL with repo file", compose) + return compose else: raise mtfexceptions.PDCExc("ODCS: Failed to generate compose for module: %s" % self.get_module_identifier()) @@ -385,7 +386,8 @@ def test_PDC_ODCS_nodejs(): parser = PDCParserODCS("nodejs", "8") # TODO: need to setup MTF_ODCS variable with odcs token, and ODCS version at least 0.1.2 # or your user will be asked to for token interactively - #print_info(parser.get_repo()) + if get_odcs_auth(): + print_info(parser.get_repo()) From a5df879161b9bae67f89ff60eab82caebab0d634 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 30 Nov 2017 13:13:47 +0100 Subject: [PATCH 101/117] remove regression, pdc_data needs to import BASEPATHDIR --- moduleframework/pdc_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moduleframework/pdc_data.py b/moduleframework/pdc_data.py index eab3442..567633d 100644 --- a/moduleframework/pdc_data.py +++ b/moduleframework/pdc_data.py @@ -33,7 +33,7 @@ import sys from avocado.utils import process from common import print_info, DEFAULTRETRYCOUNT, DEFAULTRETRYTIMEOUT, \ - get_if_remoterepos, MODULEFILE, print_debug,\ + get_if_remoterepos, BASEPATHDIR, MODULEFILE, print_debug,\ is_debug, ARCH, is_recursive_download, trans_dict, get_odcs_auth from moduleframework import mtfexceptions from pdc_client import PDCClient From bc6bb6a9d83dfd9622c172d6482489559ad6f646 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Thu, 30 Nov 2017 13:22:40 +0100 Subject: [PATCH 102/117] trying to clean man-page-generator --- .gitmodules | 3 --- MANIFEST.in | 1 - setup.cfg | 6 ------ setup.py | 18 ++---------------- 4 files changed, 2 insertions(+), 26 deletions(-) diff --git a/.gitmodules b/.gitmodules index 459aded..9ea7acb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,6 +2,3 @@ path = tools/check_modulemd url = https://github.com/fedora-modularity/check_modulemd -[submodule "build_manpages"] - path = build_manpages - url = https://github.com/praiskup/build_manpages diff --git a/MANIFEST.in b/MANIFEST.in index d380ac3..ccb0d07 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,4 +11,3 @@ recursive-include examples * recursive-include tools * recursive-include distro * recursive-include man * -recursive-include build_manpages *.py diff --git a/setup.cfg b/setup.cfg index ce82ca7..a30cfbc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,4 @@ [metadata] description-file = README.md -[build_manpages] -manpages = - man/mtf.1:function=mtfparser:module=moduleframework.mtf_scheduler - man/mtf-generator.1:function=cliparser:module=moduleframework.mtf_generator - man/mtf-env-set.1:function=cliparser_mtfenvset:module=moduleframework.mtf_environment - man/mtf-env-clean.1:function=cliparser_mtfenvclean:module=moduleframework.mtf_environment diff --git a/setup.py b/setup.py index 580ee55..410941c 100755 --- a/setup.py +++ b/setup.py @@ -28,20 +28,11 @@ except ImportError: from distutils.core import setup -from setuptools.command.build_py import build_py -from setuptools.command.install import install -try: - sys.path = [os.path.join(os.getcwd(), 'build_manpages')] + sys.path - from build_manpages.build_manpages import build_manpages, get_build_py_cmd, get_install_cmd -except: - print("=======================================") - print("Use 'git submodule update --init' first") - print("=======================================") - raise # copy from https://github.com/avocado-framework/avocado/blob/master/setup.py VIRTUAL_ENV = hasattr(sys, 'real_prefix') + def get_dir(system_path=None, virtual_path=None): """ Retrieve VIRTUAL_ENV friendly path @@ -114,10 +105,5 @@ def get_dir(system_path=None, virtual_path=None): 'Programming Language :: Python', 'Topic :: Software Development', ], - install_requires=open('requirements.txt').read().splitlines(), - cmdclass={ - 'build_manpages': build_manpages, - 'build_py': get_build_py_cmd(build_py), - 'install': get_install_cmd(install), - }, + install_requires=open('requirements.txt').read().splitlines() ) From e749bfbfa43dcfc2ed76f0bc102e3156026c16f2 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Thu, 30 Nov 2017 13:25:15 +0100 Subject: [PATCH 103/117] trying to clean man-page-generator --- .travis.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 585c1bb..b1507f6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,5 @@ language: python -before_script: - - git submodule update --init - python: - "2.7" From ac641ecd3558dd9ca9e342c1bfea3b1ef3b22088 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Thu, 30 Nov 2017 13:47:15 +0100 Subject: [PATCH 104/117] add test for mtf-pdc-module-info-reader tool and enable it in travis --- .travis.yml | 4 +++- examples/testing-module/Makefile | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 585c1bb..9b59d56 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,14 +20,16 @@ before_install: install: sudo make install_pip script: + - sudo make travis - sudo make -C examples/testing-module check-inheritance - sudo make -C examples/testing-module check-default-config - sudo make -C examples/testing-module check-pure-docker - sudo make -C examples/testing-module check-exceptions - sudo make -C examples/testing-module check-test-mtf-bin-modulelint - - sudo make travis - sudo make -C examples/testing-module check-real-rpm-destructive - sudo make -C examples/testing-module check-docker-scl-multi-travis + - sudo make -C examples/testing-module check-mtf-pdc-module-info-reader - sudo make -C mtf/metadata check + after_script: sudo cat ~/avocado/job-results/latest/job.log diff --git a/examples/testing-module/Makefile b/examples/testing-module/Makefile index c46048b..9dd6201 100644 --- a/examples/testing-module/Makefile +++ b/examples/testing-module/Makefile @@ -99,6 +99,13 @@ check-mtf-options: cd $(TEMPDIR); grep 'MODULE=docker' log rm -rf "$(TEMPDIR)" +check-mtf-pdc-module-info-reader: + mtf-pdc-module-info-reader -r testmodule-master-20170926102903 + mtf-pdc-module-info-reader -r testmodule-master-20170926102903 --commit | grep 9107dcf53f6201a01b8c8d18493aae0175bcfb19 + mtf-pdc-module-info-reader -r testmodule-master-20170926102903 | grep 'MODULE=nspawn' + mtf-pdc-module-info-reader -r testmodule-master-20170926102903 | grep 'MODULEMDURL=file:/' + mtf-pdc-module-info-reader -r testmodule-master-20170926102903 | grep 'URL=https://koj' + check: check-docker all: check From fbcc6eedece7aab06283d3f1f942692920e007f8 Mon Sep 17 00:00:00 2001 From: Petr Sklenar Date: Thu, 30 Nov 2017 14:05:55 +0100 Subject: [PATCH 105/117] trying to clean man-page-generator --- .gitignore | 2 -- .gitmodules | 3 +++ build_manpages | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) delete mode 160000 build_manpages diff --git a/.gitignore b/.gitignore index d1e583b..e47e765 100644 --- a/.gitignore +++ b/.gitignore @@ -98,5 +98,3 @@ ENV/ examples/baseruntime/generated.py examples/memcached/generated.py -# Generated man page -man diff --git a/.gitmodules b/.gitmodules index 9ea7acb..459aded 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = tools/check_modulemd url = https://github.com/fedora-modularity/check_modulemd +[submodule "build_manpages"] + path = build_manpages + url = https://github.com/praiskup/build_manpages diff --git a/build_manpages b/build_manpages deleted file mode 160000 index 44d1548..0000000 --- a/build_manpages +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 44d1548b21490de18af92c59fa29ccf7ce19d87b From 6d35c9f8e366ce59bb31e44b4952d2d733b5b597 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 1 Dec 2017 11:46:37 +0100 Subject: [PATCH 106/117] dependencies in vagrant, travis and for local installation to file --- .travis.yml | 6 +----- Vagrantfile | 4 +--- requirements.sh | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 8 deletions(-) create mode 100755 requirements.sh diff --git a/.travis.yml b/.travis.yml index 9b59d56..bce19c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,10 +12,7 @@ services: - docker before_install: - - sudo apt-get -y install curl python-software-properties software-properties-common python-pip python-dev build-essential git make - - sudo apt-get -y install libkrb5-dev netcat mysql-client-5.5 python-pytest - - sudo pip install avocado-framework - - pip install avocado-framework + - sudo ./requirements.sh install: sudo make install_pip @@ -31,5 +28,4 @@ script: - sudo make -C examples/testing-module check-mtf-pdc-module-info-reader - sudo make -C mtf/metadata check - after_script: sudo cat ~/avocado/job-results/latest/job.log diff --git a/Vagrantfile b/Vagrantfile index c47fe8c..b31dd2e 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -45,11 +45,9 @@ Vagrant.configure(2) do |config| set -x TARGET=#{ENV['TARGET']} test -z "$TARGET" && TARGET=check-docker - - dnf install -y make docker httpd git python2-avocado python2-avocado-plugins-output-html \ - python-netifaces redhat-rpm-config python2-devel python-gssapi krb5-devel cd /opt/meta-test-family git submodule update --init + ./requirements.sh make install_pip make -C examples/testing-module $TARGET cp -r /root/avocado /var/www/html/ diff --git a/requirements.sh b/requirements.sh new file mode 100755 index 0000000..b2ecb9c --- /dev/null +++ b/requirements.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +GENERICPACKAGES="make git curl" +RPMPACKAGES="httpd python2-avocado python2-avocado-plugins-output-html + python-netifaces redhat-rpm-config python2-devel python-gssapi krb5-devel" +APTPACKAGES="python-software-properties software-properties-common python-pip python-dev + build-essential libkrb5-dev netcat mysql-client-5.5 python-pytest" +PIPPACKAGES="avocado-framework" + +if [ -e /usr/bin/dnf ]; then + dnf -y install $GENERICPACKAGES $RPMPACKAGES +elif [ -e /usr/bin/yum ]; then + yum -y install $GENERICPACKAGES $RPMPACKAGES +else + apt-get -y install $GENERICPACKAGES $APTPACKAGES + pip install $PIPPACKAGES +fi From a641f3e2905cbdb64854c7c381d9117f608a62d1 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 1 Dec 2017 12:13:08 +0100 Subject: [PATCH 107/117] run also without sudo, to improve pip for avocado --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index bce19c1..5cc078e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,7 @@ services: before_install: - sudo ./requirements.sh + - ./requirements.sh install: sudo make install_pip From 1fd6cb23d75ada47490a45ab7a0bfc11545cf6b5 Mon Sep 17 00:00:00 2001 From: jscotka Date: Fri, 1 Dec 2017 12:48:15 +0100 Subject: [PATCH 108/117] Revert "remove all manpages" --- .gitignore | 2 ++ .travis.yml | 3 ++ MANIFEST.in | 1 + build_manpages | 1 + man/mtf-env-clean.1 | 41 +++++++++++++++++++++++ man/mtf-env-set.1 | 44 +++++++++++++++++++++++++ man/mtf-generator.1 | 19 +++++++++++ moduleframework/mtf_environment.py | 52 ++---------------------------- moduleframework/mtf_generator.py | 12 ------- setup.cfg | 4 ++- setup.py | 22 ++++++++++--- 11 files changed, 133 insertions(+), 68 deletions(-) create mode 160000 build_manpages create mode 100644 man/mtf-env-clean.1 create mode 100644 man/mtf-env-set.1 create mode 100644 man/mtf-generator.1 diff --git a/.gitignore b/.gitignore index e47e765..d1e583b 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,5 @@ ENV/ examples/baseruntime/generated.py examples/memcached/generated.py +# Generated man page +man diff --git a/.travis.yml b/.travis.yml index 1fa0e05..9b59d56 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,8 @@ language: python +before_script: + - git submodule update --init + python: - "2.7" diff --git a/MANIFEST.in b/MANIFEST.in index ccb0d07..d380ac3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,3 +11,4 @@ recursive-include examples * recursive-include tools * recursive-include distro * recursive-include man * +recursive-include build_manpages *.py diff --git a/build_manpages b/build_manpages new file mode 160000 index 0000000..44d1548 --- /dev/null +++ b/build_manpages @@ -0,0 +1 @@ +Subproject commit 44d1548b21490de18af92c59fa29ccf7ce19d87b diff --git a/man/mtf-env-clean.1 b/man/mtf-env-clean.1 new file mode 100644 index 0000000..202e793 --- /dev/null +++ b/man/mtf-env-clean.1 @@ -0,0 +1,41 @@ +.\" Copyright Petr Hracek, 2017 +.\" +.\" This page is distributed under GPL. +.\" +.TH MTF-ENV-CLEAN 1 2017-11-01 "" "Linux User's Manual" +.SH NAME +mtf-env-clean \- cleans environment for testing containers. + +.SH SYNOPSIS +\fIMODULE=docker\/\fR +.B mtf-env-clean + +\fIMODULE=rpm\/\fR +.B mtf-env-clean + +\fIMODULE=nspawn\/\fR +.B mtf-env-clean + +.SH DESCRIPTION +.PP +\fBmtf-env-clean\fP is a binary file used for cleaning environment after usage of Meta-Test-Family. + +.PP +\fIMODULE=docker\/\fR stops docker service. + +.PP +\fIMODULE=rpm\/\fR does not do any cleanup, +as that could potentially uninstall essential packages and therefore damage this machine. + +.PP +\fIMODULE=nspawn\/\fR switches SELinux back to original state. + +.SH NOTES +\fBmtf-env-clean\fP mtf-env-set is useful for people who don't want to clean the environment +manually and wish to use this executable to do the job. + +.SH AUTHORS +Petr Hracek, (man page) + +.SH "SEE ALSO" +Full documentation at: \ No newline at end of file diff --git a/man/mtf-env-set.1 b/man/mtf-env-set.1 new file mode 100644 index 0000000..fecd1d9 --- /dev/null +++ b/man/mtf-env-set.1 @@ -0,0 +1,44 @@ +.\" Copyright Petr Hracek, 2017 +.\" +.\" This page is distributed under GPL. +.\" +.TH MTF-ENV-SET 1 2017-11-01 "" "Linux User's Manual" +.SH NAME +mtf-env-set \- prepares environment for testing containers. + +.SH SYNOPSIS +\fIMODULE=docker\/\fR +.B mtf-env-set + +\fIMODULE=rpm\/\fR +.B mtf-env-set + +\fIMODULE=nspawn\/\fR +.B mtf-env-set + + +.SH DESCRIPTION +.PP +\fBmtf-env-set\fP is a binary file used for setting environment before usage of Meta-Test-Family. + +.PP +\fIMODULE=docker\/\fR It installs tests dependencies, docker service +and starts docker service for testing containers + +.PP +\fIMODULE=rpm\/\fR installs RPM dependencies on this machine. + +.PP +\fIMODULE=nspawn\/\fR installs RPM dependencies. If environment variable MTF_SKIP_DISABLING_SELINUX is +set, it disables SELinux. It installs systemd-container package on this machine. + + +.SH NOTES +\fBmtf-env-set\fP mtf-env-set is useful for people who don't want to set the environment manually +and wish to use this executable to do the job. + +.SH AUTHORS +Petr Hracek, (man page) + +.SH "SEE ALSO" +Full documentation at: \ No newline at end of file diff --git a/man/mtf-generator.1 b/man/mtf-generator.1 new file mode 100644 index 0000000..5fb9e73 --- /dev/null +++ b/man/mtf-generator.1 @@ -0,0 +1,19 @@ +.\" Copyright Petr Hracek, 2017 +.\" +.\" This page is distributed under GPL. +.\" +.TH mtf-generator 1 2017-11-01 "" "Linux User's Manual" +.SH NAME +mtf-generator \- generates code for tests written in \fBconfig.yaml\fP file. + +.SH SYNOPSIS +.B +mtf + +.SH DESCRIPTION +\fBmtf-generator\fP is a binary file included in Meta-Test-Family package. +It generates code for tests written in \fBconfig.yaml\fP file for usage by \fBmtf\fP binary. + +.SH AUTHORS +Petr Hracek, (man page) + diff --git a/moduleframework/mtf_environment.py b/moduleframework/mtf_environment.py index 7ca3854..3d9e42a 100644 --- a/moduleframework/mtf_environment.py +++ b/moduleframework/mtf_environment.py @@ -24,8 +24,6 @@ """ Module to setup and cleanup the test environment. """ -import argparse -from moduleframework.common import ModuleFrameworkException from moduleframework.common import get_module_type_base, print_info from moduleframework.environment_prepare.docker_prepare import EnvDocker from moduleframework.environment_prepare.rpm_prepare import EnvRpm @@ -33,40 +31,8 @@ from moduleframework.environment_prepare.openshift_prepare import EnvOpenShift -def cliparser_mtfenvset(): - # method needs to be without parameter due to man generator - script_name="mtf-env-set" - parser = argparse.ArgumentParser( - description='Its is a binary file used for setting environment before usage of Meta-Test-Family. See documentation at: http://meta-test-family.readthedocs.io/en/latest/user_guide/environment_setup.html', - formatter_class=argparse.RawTextHelpFormatter, - prog=script_name, - epilog="see http://meta-test-family.readthedocs.io for more info", - usage="MODULE=[choose_module_type] {0}".format(script_name), - ) - parser.man_short_description = "prepares environment for testing containers" - return parser - - -def cliparser_mtfenvclean(): - # method needs to be without parameter due to man generator - script_name="mtf-env-clean" - parser = argparse.ArgumentParser( - description='Its is a binary file used for setting environment before usage of Meta-Test-Family. See documentation at: http://meta-test-family.readthedocs.io/en/latest/user_guide/environment_setup.html', - formatter_class=argparse.RawTextHelpFormatter, - prog=script_name, - epilog="see http://meta-test-family.readthedocs.io for more info", - usage="MODULE=[choose_module_type] {0}".format(script_name), - ) - parser.man_short_description = "prepares environment for testing containers" - return parser - - -# to handle start without MODULE set; the whole module is called by man page generator for argparsers -try: - module_name = get_module_type_base() - print_info("Setting environment for module: {} ".format(module_name)) -except ModuleFrameworkException as e: - module_name = None +module_name = get_module_type_base() +print_info("Setting environment for module: {} ".format(module_name)) if module_name == "docker": env = EnvDocker() @@ -79,26 +45,12 @@ def cliparser_mtfenvclean(): def mtfenvset(): - cliparser_mtfenvset().parse_args() - # to handle start without MODULE set - try: - get_module_type_base() - except ModuleFrameworkException as e: - print_info(e) - exit(1) print_info("Preparing environment ...") # cleanup_env exists in more forms for backend : EnvDocker/EnvRpm/EnvNspawn env.prepare_env() def mtfenvclean(): - cliparser_mtfenvclean().parse_args() - # to handle start without MODULE set - try: - get_module_type_base() - except ModuleFrameworkException as e: - print_info(e) - exit(1) # cleanup_env exists in more forms for backend: EnvDocker/EnvRpm/EnvNspawn env.cleanup_env() print_info("All clean") diff --git a/moduleframework/mtf_generator.py b/moduleframework/mtf_generator.py index 67b008c..755757e 100644 --- a/moduleframework/mtf_generator.py +++ b/moduleframework/mtf_generator.py @@ -34,18 +34,8 @@ """ from common import print_info, CommonFunctions -import argparse -def cliparser(): - parser = argparse.ArgumentParser( - prog="mtf-generator", - description="generates code for tests written in config.yaml file.", - epilog="see http://meta-test-family.readthedocs.io for more info", - ) - parser.man_short_description = "Its is a binary file included in Meta-Test-Family package. It generates code for tests written in config.yaml file for usage by mtf binary." - return parser - class TestGenerator(CommonFunctions): def __init__(self): """ @@ -105,8 +95,6 @@ def main(): """ Creates ``tests/generated.py`` file . """ - # this tool doesn't use any options, so adding only --help - cliparser().parse_args() config = TestGenerator() configout = open('generated.py', 'w') configout.write(config.output) diff --git a/setup.cfg b/setup.cfg index a30cfbc..6b1c9ad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,6 @@ [metadata] description-file = README.md - +[build_manpages] +manpages = + man/mtf.1:function=mtfparser:module=moduleframework.mtf_scheduler diff --git a/setup.py b/setup.py index 8a56f9f..f121aee 100755 --- a/setup.py +++ b/setup.py @@ -23,11 +23,18 @@ import os import sys -try: - from setuptools import setup, find_packages -except ImportError: - from distutils.core import setup +from setuptools import setup, find_packages +from setuptools.command.build_py import build_py +from setuptools.command.install import install +try: + sys.path = [os.path.join(os.getcwd(), 'build_manpages')] + sys.path + from build_manpages.build_manpages import build_manpages, get_build_py_cmd, get_install_cmd +except: + print("=======================================") + print("Use 'git submodule update --init' first") + print("=======================================") + raise # copy from https://github.com/avocado-framework/avocado/blob/master/setup.py VIRTUAL_ENV = hasattr(sys, 'real_prefix') @@ -106,5 +113,10 @@ def get_dir(system_path=None, virtual_path=None): 'Programming Language :: Python', 'Topic :: Software Development', ], - install_requires=open('requirements.txt').read().splitlines() + install_requires=open('requirements.txt').read().splitlines(), + cmdclass={ + 'build_manpages': build_manpages, + 'build_py': get_build_py_cmd(build_py), + 'install': get_install_cmd(install), + }, ) From 6dbb03469aa052764443ab9db9d264c4f951328e Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 1 Dec 2017 13:46:33 +0100 Subject: [PATCH 109/117] revert generated man pages code --- .gitignore | 2 -- .travis.yml | 3 --- setup.cfg | 6 ------ setup.py | 18 +----------------- 4 files changed, 1 insertion(+), 28 deletions(-) delete mode 100644 setup.cfg diff --git a/.gitignore b/.gitignore index d1e583b..e47e765 100644 --- a/.gitignore +++ b/.gitignore @@ -98,5 +98,3 @@ ENV/ examples/baseruntime/generated.py examples/memcached/generated.py -# Generated man page -man diff --git a/.travis.yml b/.travis.yml index 9b59d56..1fa0e05 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,5 @@ language: python -before_script: - - git submodule update --init - python: - "2.7" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 6b1c9ad..0000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[metadata] -description-file = README.md - -[build_manpages] -manpages = - man/mtf.1:function=mtfparser:module=moduleframework.mtf_scheduler diff --git a/setup.py b/setup.py index f121aee..9432d98 100755 --- a/setup.py +++ b/setup.py @@ -24,17 +24,6 @@ import sys from setuptools import setup, find_packages -from setuptools.command.build_py import build_py -from setuptools.command.install import install - -try: - sys.path = [os.path.join(os.getcwd(), 'build_manpages')] + sys.path - from build_manpages.build_manpages import build_manpages, get_build_py_cmd, get_install_cmd -except: - print("=======================================") - print("Use 'git submodule update --init' first") - print("=======================================") - raise # copy from https://github.com/avocado-framework/avocado/blob/master/setup.py VIRTUAL_ENV = hasattr(sys, 'real_prefix') @@ -113,10 +102,5 @@ def get_dir(system_path=None, virtual_path=None): 'Programming Language :: Python', 'Topic :: Software Development', ], - install_requires=open('requirements.txt').read().splitlines(), - cmdclass={ - 'build_manpages': build_manpages, - 'build_py': get_build_py_cmd(build_py), - 'install': get_install_cmd(install), - }, + install_requires=open('requirements.txt').read().splitlines() ) From edaeca3afdef8c6bc244a92859d1571520c179ad Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Fri, 1 Dec 2017 14:20:36 +0100 Subject: [PATCH 110/117] remove mtf manpage --- meta-test-family.spec | 1 - 1 file changed, 1 deletion(-) diff --git a/meta-test-family.spec b/meta-test-family.spec index 64f551e..04f91b6 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -44,7 +44,6 @@ install -d -p -m 755 %{buildroot}%{_datadir}/%{framework_name} %files %license LICENSE -%{_mandir}/man1/mtf.1* %{_mandir}/man1/mtf-env-clean.1* %{_mandir}/man1/mtf-env-set.1* %{_mandir}/man1/mtf-generator.1* From ec092bb10bcdbd9ec21031e0a03d1a4a9b974f0e Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Mon, 4 Dec 2017 09:32:44 +0100 Subject: [PATCH 111/117] dependencies for testing in nicer and cleaner format for various distros --- requirements.sh | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/requirements.sh b/requirements.sh index b2ecb9c..0c4d59a 100755 --- a/requirements.sh +++ b/requirements.sh @@ -1,11 +1,45 @@ #!/bin/bash -GENERICPACKAGES="make git curl" -RPMPACKAGES="httpd python2-avocado python2-avocado-plugins-output-html - python-netifaces redhat-rpm-config python2-devel python-gssapi krb5-devel" -APTPACKAGES="python-software-properties software-properties-common python-pip python-dev - build-essential libkrb5-dev netcat mysql-client-5.5 python-pytest" -PIPPACKAGES="avocado-framework" +GENERICPACKAGES=" +curl +git +make +python-pip +" +RPMPACKAGES=" +fedpkg +httpd +koji +krb5-devel +nc +pdc-client +python-devel +python-gssapi +python-netifaces +python2-avocado +python2-avocado-plugins-output-html +python2-devel +python2-dockerfile-parse +python2-modulemd +python2-odcs-client +python2-pytest +redhat-rpm-config +" + +APTPACKAGES=" +build-essential +libkrb5-dev +mysql-client-5.5 +netcat +python-dev +python-pytest +python-software-properties +software-properties-common +" + +PIPPACKAGES=" +avocado-framework +" if [ -e /usr/bin/dnf ]; then dnf -y install $GENERICPACKAGES $RPMPACKAGES From 190c7b33e61afda931576cd4a6c845c963c75d4e Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Mon, 4 Dec 2017 16:37:45 +0100 Subject: [PATCH 112/117] metadata: fix generic test filters added integration tests for tags for generic framework --- mtf/metadata/Makefile | 12 ++++++++- .../general-component/tests/generaltest.py | 6 ++++- .../general-component/tests/metadata.yaml | 3 +++ .../general-component/tests/simple.py | 5 ++++ mtf/metadata/examples/mtf-clean/tests/all.py | 26 +++---------------- .../examples/mtf-clean/tests/dockerlinter.py | 18 +++---------- mtf/metadata/examples/mtf-clean/tests/none.py | 26 +++---------------- mtf/metadata/examples/mtf-clean/tests/some.py | 26 +++---------------- .../examples/mtf-component/tests/all.py | 26 +++---------------- .../mtf-component/tests/dockerlinter.py | 18 +++---------- .../examples/mtf-component/tests/none.py | 26 +++---------------- .../examples/mtf-component/tests/some.py | 26 +++---------------- mtf/metadata/requirements.txt | 2 ++ mtf/metadata/setup.py | 2 +- mtf/metadata/tmet/common.py | 24 ++++++++++++++--- mtf/metadata/tmet/selftests.py | 14 ++++++++-- 16 files changed, 89 insertions(+), 171 deletions(-) create mode 100644 mtf/metadata/examples/general-component/tests/simple.py create mode 100644 mtf/metadata/requirements.txt diff --git a/mtf/metadata/Makefile b/mtf/metadata/Makefile index f9a18f4..b1d3535 100644 --- a/mtf/metadata/Makefile +++ b/mtf/metadata/Makefile @@ -4,9 +4,19 @@ all: check integrationtests: install cd examples/general-component/tests; tmet-agregator - cd examples/general-component/tests; tmet-agregator |grep -o 60 + cd examples/general-component/tests; tmet-agregator |grep -o 63 cd examples/general-component/tests; tmet-filter cd examples/general-component/tests; tmet-filter | grep 'sanity/generaltest.py' + cd examples/general-component/tests; tmet-filter -b generic | grep 'sanity/generaltest.py' + cd examples/general-component/tests; tmet-filter -b generic | grep meta-test-family.git + cd examples/general-component/tests; tmet-filter -b generic | grep fedora_specific + cd examples/general-component/tests; tmet-filter -b generic -t nonsense | grep -v fedora_specific + cd examples/general-component/tests; tmet-filter -b generic -t nonsense | grep -v meta-test-family.git + cd examples/general-component/tests; tmet-filter -b generic -t tag1 | grep fedora_specific + cd examples/general-component/tests; tmet-filter -b generic -t tag1 | grep -v meta-test-family.git + cd examples/general-component/tests; tmet-filter -b generic -t optional | grep meta-test-family.git + cd examples/general-component/tests; tmet-filter -b generic -t optional | grep -v fedora_specific + unittests: py.test tmet/selftests.py diff --git a/mtf/metadata/examples/general-component/tests/generaltest.py b/mtf/metadata/examples/general-component/tests/generaltest.py index 2ae2839..9dfc0ef 100644 --- a/mtf/metadata/examples/general-component/tests/generaltest.py +++ b/mtf/metadata/examples/general-component/tests/generaltest.py @@ -1 +1,5 @@ -pass +from avocado import Test + +class X1(Test): + def test(self): + pass diff --git a/mtf/metadata/examples/general-component/tests/metadata.yaml b/mtf/metadata/examples/general-component/tests/metadata.yaml index 9eaeb1e..b5849dd 100644 --- a/mtf/metadata/examples/general-component/tests/metadata.yaml +++ b/mtf/metadata/examples/general-component/tests/metadata.yaml @@ -34,10 +34,13 @@ tests: options: fedora_test: source: fedora_specifictest.sh + # how to specify tags in generic tests + tags: tier1,tier2,tag1 description: some general test doing verbose test backend: general extend_test: source: https://github.com/fedora-modularity/meta-test-family.git + tags: optional description: some general test doing verbose test backend: general test_new_option: diff --git a/mtf/metadata/examples/general-component/tests/simple.py b/mtf/metadata/examples/general-component/tests/simple.py new file mode 100644 index 0000000..d1ee52c --- /dev/null +++ b/mtf/metadata/examples/general-component/tests/simple.py @@ -0,0 +1,5 @@ +from avocado import Test + +class X2(Test): + def test(self): + pass diff --git a/mtf/metadata/examples/mtf-clean/tests/all.py b/mtf/metadata/examples/mtf-clean/tests/all.py index 586c413..23724ea 100644 --- a/mtf/metadata/examples/mtf-clean/tests/all.py +++ b/mtf/metadata/examples/mtf-clean/tests/all.py @@ -1,33 +1,21 @@ -from mtf.metatest import AvocadoTest +from avocado import Test -class Add1(AvocadoTest): +class Add1(Test): """ :avocado: enable :avocado: tags=add """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass -class Add2(AvocadoTest): +class Add2(Test): """ :avocado: enable """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): """ :avocado: tags=add @@ -35,16 +23,10 @@ def test(self): pass -class Add3(AvocadoTest): +class Add3(Test): """ :avocado: enable """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass diff --git a/mtf/metadata/examples/mtf-clean/tests/dockerlinter.py b/mtf/metadata/examples/mtf-clean/tests/dockerlinter.py index 16385a1..0cf538e 100644 --- a/mtf/metadata/examples/mtf-clean/tests/dockerlinter.py +++ b/mtf/metadata/examples/mtf-clean/tests/dockerlinter.py @@ -1,33 +1,21 @@ -from mtf.metatest import AvocadoTest +from avocado import Test -class DockerFileLint(AvocadoTest): +class DockerFileLint(Test): """ :avocado: enable :avocado: tags=dockerfilelint,docker,rhel,fedora """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass -class DockerLint(AvocadoTest): +class DockerLint(Test): """ :avocado: enable :avocado: tags=dockerlint,docker,rhel,fedora """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass diff --git a/mtf/metadata/examples/mtf-clean/tests/none.py b/mtf/metadata/examples/mtf-clean/tests/none.py index 3a23983..151c3bf 100644 --- a/mtf/metadata/examples/mtf-clean/tests/none.py +++ b/mtf/metadata/examples/mtf-clean/tests/none.py @@ -1,33 +1,21 @@ -from mtf.metatest import AvocadoTest +from avocado import Test -class Rem1(AvocadoTest): +class Rem1(Test): """ :avocado: enable :avocado: tags=rem """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass -class Rem2(AvocadoTest): +class Rem2(Test): """ :avocado: enable """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): """ :avocado: tags=rem @@ -35,16 +23,10 @@ def test(self): pass -class Rem3(AvocadoTest): +class Rem3(Test): """ :avocado: disable """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass diff --git a/mtf/metadata/examples/mtf-clean/tests/some.py b/mtf/metadata/examples/mtf-clean/tests/some.py index d958379..b680160 100644 --- a/mtf/metadata/examples/mtf-clean/tests/some.py +++ b/mtf/metadata/examples/mtf-clean/tests/some.py @@ -1,34 +1,22 @@ -from mtf.metatest import AvocadoTest +from avocado import Test -class Add1(AvocadoTest): +class Add1(Test): """ :avocado: enable :avocado: tags=add """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass -class AddPart(AvocadoTest): +class AddPart(Test): """ :avocado: enable :avocado: tags=add """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def testAdd(self): pass @@ -39,17 +27,11 @@ def testBad(self): pass -class Rem2(AvocadoTest): +class Rem2(Test): """ :avocado: enable :avocado: tags=rem """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass diff --git a/mtf/metadata/examples/mtf-component/tests/all.py b/mtf/metadata/examples/mtf-component/tests/all.py index 586c413..23724ea 100644 --- a/mtf/metadata/examples/mtf-component/tests/all.py +++ b/mtf/metadata/examples/mtf-component/tests/all.py @@ -1,33 +1,21 @@ -from mtf.metatest import AvocadoTest +from avocado import Test -class Add1(AvocadoTest): +class Add1(Test): """ :avocado: enable :avocado: tags=add """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass -class Add2(AvocadoTest): +class Add2(Test): """ :avocado: enable """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): """ :avocado: tags=add @@ -35,16 +23,10 @@ def test(self): pass -class Add3(AvocadoTest): +class Add3(Test): """ :avocado: enable """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass diff --git a/mtf/metadata/examples/mtf-component/tests/dockerlinter.py b/mtf/metadata/examples/mtf-component/tests/dockerlinter.py index 16385a1..0cf538e 100644 --- a/mtf/metadata/examples/mtf-component/tests/dockerlinter.py +++ b/mtf/metadata/examples/mtf-component/tests/dockerlinter.py @@ -1,33 +1,21 @@ -from mtf.metatest import AvocadoTest +from avocado import Test -class DockerFileLint(AvocadoTest): +class DockerFileLint(Test): """ :avocado: enable :avocado: tags=dockerfilelint,docker,rhel,fedora """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass -class DockerLint(AvocadoTest): +class DockerLint(Test): """ :avocado: enable :avocado: tags=dockerlint,docker,rhel,fedora """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass diff --git a/mtf/metadata/examples/mtf-component/tests/none.py b/mtf/metadata/examples/mtf-component/tests/none.py index 3a23983..151c3bf 100644 --- a/mtf/metadata/examples/mtf-component/tests/none.py +++ b/mtf/metadata/examples/mtf-component/tests/none.py @@ -1,33 +1,21 @@ -from mtf.metatest import AvocadoTest +from avocado import Test -class Rem1(AvocadoTest): +class Rem1(Test): """ :avocado: enable :avocado: tags=rem """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass -class Rem2(AvocadoTest): +class Rem2(Test): """ :avocado: enable """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): """ :avocado: tags=rem @@ -35,16 +23,10 @@ def test(self): pass -class Rem3(AvocadoTest): +class Rem3(Test): """ :avocado: disable """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass diff --git a/mtf/metadata/examples/mtf-component/tests/some.py b/mtf/metadata/examples/mtf-component/tests/some.py index d958379..b680160 100644 --- a/mtf/metadata/examples/mtf-component/tests/some.py +++ b/mtf/metadata/examples/mtf-component/tests/some.py @@ -1,34 +1,22 @@ -from mtf.metatest import AvocadoTest +from avocado import Test -class Add1(AvocadoTest): +class Add1(Test): """ :avocado: enable :avocado: tags=add """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass -class AddPart(AvocadoTest): +class AddPart(Test): """ :avocado: enable :avocado: tags=add """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def testAdd(self): pass @@ -39,17 +27,11 @@ def testBad(self): pass -class Rem2(AvocadoTest): +class Rem2(Test): """ :avocado: enable :avocado: tags=rem """ - def setUp(self): - pass - - def tearDown(self, *args, **kwargs): - pass - def test(self): pass diff --git a/mtf/metadata/requirements.txt b/mtf/metadata/requirements.txt new file mode 100644 index 0000000..d3656e6 --- /dev/null +++ b/mtf/metadata/requirements.txt @@ -0,0 +1,2 @@ +avocado-framework +pytest \ No newline at end of file diff --git a/mtf/metadata/setup.py b/mtf/metadata/setup.py index dfaafe3..18e5819 100644 --- a/mtf/metadata/setup.py +++ b/mtf/metadata/setup.py @@ -80,5 +80,5 @@ def get_dir(system_path=None, virtual_path=None): 'Programming Language :: Python', 'Topic :: Software Development', ], - install_requires=[] + install_requires=open('requirements.txt').read().splitlines() ) diff --git a/mtf/metadata/tmet/common.py b/mtf/metadata/tmet/common.py index 46564de..cac55d4 100644 --- a/mtf/metadata/tmet/common.py +++ b/mtf/metadata/tmet/common.py @@ -1,10 +1,11 @@ from __future__ import print_function import yaml import os +import sys import glob -from mtf.common import print_debug from avocado.utils import process from urlparse import urlparse +from warnings import warn """ Basic classes for metatadata handling, it contains classes derived from general metadata parser @@ -32,6 +33,14 @@ IMPORT_TESTS = "import_tests" TAG_FILETERS = "tag_filters" +def print_debug(*args): + """ + Own implementation of print_debug, to not be dependent on MTF anyhow inside metadata + """ + if os.environ.get("DEBUG"): + for arg in args: + print(arg, file=sys.stderr) + def logic_formula(statement, filters, op_negation="-", op_and=",", op_or=None): """ @@ -81,6 +90,8 @@ def logic_filter(actual_tag_list, tag_filter): if op_or: filters = filters.split(op_or) + elif isinstance(filters,str): + filters = [filters] return logic_filter(statement, filters) @@ -319,8 +330,12 @@ class MetadataLoaderMTF(MetadataLoader): """ metadata specific class for MTF (avocado) tests """ - import moduleframework.tools - MTF_LINTER_PATH = os.path.dirname(moduleframework.tools.__file__) + try: + import moduleframework.tools + MTF_LINTER_PATH = os.path.dirname(moduleframework.tools.__file__) + except: + warn("MTF library not installed, linters are ignored") + MTF_LINTER_PATH = None listcmd = "avocado list" backends = ["mtf", "avocado"] @@ -345,7 +360,8 @@ def _import_tests(self, testglob, pathlenght=0): test) def _import_linters(self): - self._import_tests(os.path.join(self.MTF_LINTER_PATH, "*.py"), pathlenght=-3) + if self.MTF_LINTER_PATH: + self._import_tests(os.path.join(self.MTF_LINTER_PATH, "*.py"), pathlenght=-3) def __avcado_tag_args(self, tag_list, defaultparam="--filter-by-tags-include-empty"): output = [] diff --git a/mtf/metadata/tmet/selftests.py b/mtf/metadata/tmet/selftests.py index 4a71a9e..6f7266e 100644 --- a/mtf/metadata/tmet/selftests.py +++ b/mtf/metadata/tmet/selftests.py @@ -1,4 +1,4 @@ -from common import MetadataLoader, MetadataLoaderMTF, SOURCE, print_debug +from common import MetadataLoader, MetadataLoaderMTF, SOURCE, print_debug, logic_formula from filter import filtertests import yaml @@ -152,7 +152,7 @@ def test_filter_general(): tags=[], relevancy="") print_debug(out) - assert len(out) == 5 + assert len(out) == 6 def test_mtf_config(): @@ -188,6 +188,15 @@ def test_general_config(): print_debug(out) assert len(out) == 2 +def test_logic_formula_parser(): + assert logic_formula('tag1', ['tag1']) + assert not logic_formula('tag2', ['tag1']) + assert logic_formula('tag1,tag2', ['tag1']) + assert logic_formula('', ['tag1']) + assert logic_formula('tag1', ['tag1','tag2']) + assert not logic_formula('tag3', ['tag1', 'tag2']) + assert logic_formula('tag3,tag1', ['tag1', 'tag2']) + assert logic_formula('tag3,tag2,tag1', ['tag1', 'tag2']) # test_loader() # test_mtf_metadata_linters_only() @@ -195,3 +204,4 @@ def test_general_config(): # test_filter_general() # test_mtf_config() # test_general_config() +# test_logic_formula_parser() From b175a8f056b725ba697e8e6ea7b60bb447e6d6e9 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Mon, 4 Dec 2017 12:58:03 +0100 Subject: [PATCH 113/117] support for metadata to mtf tool --- .travis.yml | 1 + examples/memcached/Makefile | 2 ++ examples/memcached/metadata.yaml | 7 ++++ examples/test_metadata_loader/Makefile | 16 +++++++++ examples/test_metadata_loader/metadata.yaml | 8 +++++ examples/test_metadata_loader/not_schedule.sh | 3 ++ examples/test_metadata_loader/simple.py | 16 +++++++++ examples/testing-module/Makefile | 8 +++++ moduleframework/mtf_scheduler.py | 36 +++++++++++-------- 9 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 examples/memcached/Makefile create mode 100644 examples/memcached/metadata.yaml create mode 100644 examples/test_metadata_loader/Makefile create mode 100644 examples/test_metadata_loader/metadata.yaml create mode 100755 examples/test_metadata_loader/not_schedule.sh create mode 100644 examples/test_metadata_loader/simple.py diff --git a/.travis.yml b/.travis.yml index 3ed786c..50433fe 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,5 +25,6 @@ script: - sudo make -C examples/testing-module check-docker-scl-multi-travis - sudo make -C examples/testing-module check-mtf-pdc-module-info-reader - sudo make -C mtf/metadata check + - sudo make -C examples/testing-module check-mtf-metadata after_script: sudo cat ~/avocado/job-results/latest/job.log diff --git a/examples/memcached/Makefile b/examples/memcached/Makefile new file mode 100644 index 0000000..a532906 --- /dev/null +++ b/examples/memcached/Makefile @@ -0,0 +1,2 @@ +test: + mtf --module=docker --metadata diff --git a/examples/memcached/metadata.yaml b/examples/memcached/metadata.yaml new file mode 100644 index 0000000..d005aa0 --- /dev/null +++ b/examples/memcached/metadata.yaml @@ -0,0 +1,7 @@ +document: test-metadata +subtype: general +enable_lint: True +tag_filters: + - "docker_labels_inspect_test" +import_tests: + - "*.py" diff --git a/examples/test_metadata_loader/Makefile b/examples/test_metadata_loader/Makefile new file mode 100644 index 0000000..c9a2e86 --- /dev/null +++ b/examples/test_metadata_loader/Makefile @@ -0,0 +1,16 @@ +LOGFILE=test_output.log + +prepare-docker: + MODULE=docker URL=fedora mtf-env-set + +test: prepare-docker + MODULE=docker URL=fedora mtf --metadata 2>&1 | tee $(LOGFILE) + grep DockerLint $(LOGFILE) + grep Add1 $(LOGFILE) + grep /bin/true $(LOGFILE) + grep "3/3" $(LOGFILE) + grep -v not_schedule $(LOGFILE) + grep -v rpmvalidation $(LOGFILE) + MODULE=docker URL=fedora mtf --metadata *.sh 2>&1 | tee $(LOGFILE) && true + grep "4/4" $(LOGFILE) + diff --git a/examples/test_metadata_loader/metadata.yaml b/examples/test_metadata_loader/metadata.yaml new file mode 100644 index 0000000..91ef523 --- /dev/null +++ b/examples/test_metadata_loader/metadata.yaml @@ -0,0 +1,8 @@ +document: test-metadata +subtype: general +enable_lint: True +tag_filters: + - "docker_labels_inspect_test" +import_tests: + - "*.py" + - "/bin/true" diff --git a/examples/test_metadata_loader/not_schedule.sh b/examples/test_metadata_loader/not_schedule.sh new file mode 100755 index 0000000..d87f29e --- /dev/null +++ b/examples/test_metadata_loader/not_schedule.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exit 1 diff --git a/examples/test_metadata_loader/simple.py b/examples/test_metadata_loader/simple.py new file mode 100644 index 0000000..1a6fa1c --- /dev/null +++ b/examples/test_metadata_loader/simple.py @@ -0,0 +1,16 @@ +from mtf.metatest import AvocadoTest + + +class Add1(AvocadoTest): + """ + :avocado: enable + """ + + def setUp(self): + pass + + def tearDown(self, *args, **kwargs): + pass + + def test(self): + pass diff --git a/examples/testing-module/Makefile b/examples/testing-module/Makefile index 9dd6201..6f11916 100644 --- a/examples/testing-module/Makefile +++ b/examples/testing-module/Makefile @@ -76,6 +76,7 @@ check-memcached-both: prepare-docker prepare-nspawn cd ../memcached; MODULE=docker $(CMD) sanity1.py cd ../memcached; MODULE=nspawn $(CMD) sanity1.py + check-minimal-config-rpm-noenvvar: prepare-nspawn MTF_REMOTE_REPOS= DOCKERFILE= MODULE=nspawn $(CMD) $(TESTS) @@ -106,6 +107,13 @@ check-mtf-pdc-module-info-reader: mtf-pdc-module-info-reader -r testmodule-master-20170926102903 | grep 'MODULEMDURL=file:/' mtf-pdc-module-info-reader -r testmodule-master-20170926102903 | grep 'URL=https://koj' +check-mtf-metadata: + make -C ../test_metadata_loader test + +check-memcached-metadata-loader: prepare-docker + make -C ../memcached test + + check: check-docker all: check diff --git a/moduleframework/mtf_scheduler.py b/moduleframework/mtf_scheduler.py index 13d5068..c24b54b 100755 --- a/moduleframework/mtf_scheduler.py +++ b/moduleframework/mtf_scheduler.py @@ -29,6 +29,8 @@ from avocado.utils import process from moduleframework import common +from mtf.metadata.tmet.filter import filtertests +from mtf.metadata.tmet import common as metadata_common def mtfparser(): @@ -87,6 +89,10 @@ def mtfparser(): help='Action for avocado, see avocado --help for subcommands') parser.add_argument("--version", action="store_true", default=False, help='show version and exit') + parser.add_argument("--metadata", action="store_true", + default=False, help="""load configuration for test sets from metadata file + (https://github.com/fedora-modularity/meta-test-family/blob/devel/mtf/metadata/README.md)""") + # Solely for the purpose of manpage generator, copy&paste from setup.py parser.man_short_description = \ @@ -180,19 +186,15 @@ def cli(): class AvocadoStart(object): - def __init__(self, args, unknown): - - # its used for filepath in loadCli: - self.json_tmppath = None - site_libs = os.path.dirname(moduleframework.__file__) - mtf_tools = "tools" + tests = [] + json_tmppath = None + additionalAvocadoArg = '' + def __init__(self, args, unknown): # choose between TESTS and ADDITIONAL ENVIRONMENT from options - self.tests = '' if args.linter: - self.tests = ( - "{SITE_LIB}/{MTF_TOOLS}/*.py".format(SITE_LIB=site_libs, MTF_TOOLS=mtf_tools)) - self.additionalAvocadoArg = '' + self.tests.append( + "{MTF_TOOLS}/*.py".format(MTF_TOOLS=metadata_common.MetadataLoaderMTF.MTF_LINTER_PATH)) self.args = args for param in unknown: @@ -200,15 +202,21 @@ def __init__(self, args, unknown): # http://avocado-framework.readthedocs.io/en/52.0/WritingTests.html#categorizing-tests if os.path.exists(param): # this is list of tests in local file - self.tests += " {0} ".format(param) + self.tests.append(param) else: # this is additional avocado param self.additionalAvocadoArg += " {0} ".format(param) - + if self.args.metadata: + common.print_info("Using Metadata loader for tests and filtering") + metadata_tests = filtertests(backend="mtf", location=os.getcwd(), linters=False, tests=[], tags=[], relevancy="") + tests_dict = [x[metadata_common.SOURCE] for x in metadata_tests] + self.tests += tests_dict + common.print_debug("Loaded tests via metadata file: %s" % tests_dict) common.print_debug("tests = {0}".format(self.tests)) common.print_debug("additionalAvocadoArg = {0}".format( self.additionalAvocadoArg)) + def avocado_run(self): self.json_tmppath = tempfile.mktemp() avocado_args = "--json {JSON_LOG}".format( @@ -220,7 +228,7 @@ def avocado_run(self): # run avocado with right cmd arguments bash = process.run("{AVOCADO} {a} {b}".format( - AVOCADO=avocadoAction, a=self.additionalAvocadoArg, b=self.tests), shell=True, ignore_status=True) + AVOCADO=avocadoAction, a=self.additionalAvocadoArg, b=" ".join(self.tests)), shell=True, ignore_status=True) common.print_info(bash.stdout, bash.stderr) common.print_debug("Command used: ", bash.command) return bash.exit_status @@ -232,7 +240,7 @@ def avocado_general(self): avocadoAction = "avocado {ACTION} {AVOCADO_ARGS}".format( ACTION=self.args.action, AVOCADO_ARGS=avocado_args) bash = process.run("{AVOCADO} {a} {b}".format( - AVOCADO=avocadoAction, a=self.additionalAvocadoArg, b=self.tests), shell=True, ignore_status=True) + AVOCADO=avocadoAction, a=self.additionalAvocadoArg, b=" ".join(self.tests)), shell=True, ignore_status=True) common.print_info(bash.stdout, bash.stderr) common.print_debug("Command used: ", bash.command) return bash.exit_status From 45fa26bd8fda717094a21ced27dd0bb70b66c28d Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Tue, 5 Dec 2017 10:06:34 +0100 Subject: [PATCH 114/117] add vagrant file for metadata --- mtf/metadata/Vagrantfile | 56 +++++++++++++++++++++++++++++++++++ mtf/metadata/requirements.txt | 3 +- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 mtf/metadata/Vagrantfile diff --git a/mtf/metadata/Vagrantfile b/mtf/metadata/Vagrantfile new file mode 100644 index 0000000..26459b9 --- /dev/null +++ b/mtf/metadata/Vagrantfile @@ -0,0 +1,56 @@ +# vi: set ft=ruby : +# -*- coding: utf-8 -*- +# +# Meta test family (MTF) is a tool to test components of a modular Fedora: +# https://docs.pagure.org/modularity/ +# Copyright (C) 2017 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# he Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Authors: Jan Scotka +# + +Vagrant.configure(2) do |config| + + config.vm.box = "fedora/26-cloud-base" + config.vm.synced_folder ".", "/opt/metadata" + config.vm.network "private_network", ip: "192.168.50.10" + config.vm.hostname = "metadatatesting" + config.vm.post_up_message = "Machine is prepared or you, to test PoC for metadata, examples stored in /opt/metadata" + + config.vm.provider "libvirt" do |libvirt| + libvirt.memory = 1024 + libvirt.nested = true + libvirt.cpu_mode = "host-model" + end + + config.vm.provider "virtualbox" do |virtualbox| + virtualbox.memory = 1024 + end + + config.vm.provision "shell", inline: <<-SHELL + set -ex + dnf -y install python-pip python2-pip + cd /opt/metadata + + make install + + # if you want to test linters distributed by MTF, selftest is rely on that + dnf -y copr enable phracek/meta-test-family-devel + dnf -y install meta-test-family || true + + make check + SHELL +end diff --git a/mtf/metadata/requirements.txt b/mtf/metadata/requirements.txt index d3656e6..be32f7e 100644 --- a/mtf/metadata/requirements.txt +++ b/mtf/metadata/requirements.txt @@ -1,2 +1,3 @@ avocado-framework -pytest \ No newline at end of file +pytest +PyYAML \ No newline at end of file From 4de4082b4374f7af739dee51532da90408816b8d Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Tue, 5 Dec 2017 11:34:14 +0100 Subject: [PATCH 115/117] add vagrant file --- mtf/metadata/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mtf/metadata/requirements.txt b/mtf/metadata/requirements.txt index be32f7e..ca52ff6 100644 --- a/mtf/metadata/requirements.txt +++ b/mtf/metadata/requirements.txt @@ -1,3 +1,3 @@ avocado-framework pytest -PyYAML \ No newline at end of file +PyYAML From fb1c37749b4bc28ef5d5bd09748034308349e6d5 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 6 Dec 2017 09:30:29 +0100 Subject: [PATCH 116/117] remove mtf-env-clean from runthem script, it is on not good place and cleanup of env is not important to have it ther --- tools/run-them.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/run-them.sh b/tools/run-them.sh index dab62eb..b8aefc2 100755 --- a/tools/run-them.sh +++ b/tools/run-them.sh @@ -145,8 +145,6 @@ else fi TESTRESULT=$? -MODULE=nspawn mtf-env-clean - if [ "$RESULTTOOLS" -ne 0 ]; then exit 2 fi From 5a552157f9064fd7f141eebee8a18b0034080e56 Mon Sep 17 00:00:00 2001 From: Jan Scotka Date: Wed, 6 Dec 2017 09:34:52 +0100 Subject: [PATCH 117/117] Automatic commit of package [meta-test-family] release [0.7.8-1]. Created by command: /usr/bin/tito tag --- .tito/packages/meta-test-family | 2 +- meta-test-family.spec | 177 +++++++++++++++++++++++++++++++- setup.py | 2 +- 3 files changed, 178 insertions(+), 3 deletions(-) diff --git a/.tito/packages/meta-test-family b/.tito/packages/meta-test-family index 5955df4..64108fe 100644 --- a/.tito/packages/meta-test-family +++ b/.tito/packages/meta-test-family @@ -1 +1 @@ -0.7.3-1 ./ +0.7.8-1 ./ diff --git a/meta-test-family.spec b/meta-test-family.spec index 04f91b6..1d3f2d3 100644 --- a/meta-test-family.spec +++ b/meta-test-family.spec @@ -1,7 +1,7 @@ %global framework_name moduleframework Name: meta-test-family -Version: 0.7.7 +Version: 0.7.8 Release: 1%{?dist} Summary: Tool to test components of a modular Fedora @@ -61,6 +61,181 @@ install -d -p -m 755 %{buildroot}%{_datadir}/%{framework_name} %changelog +* Wed Dec 06 2017 Jan Scotka 0.7.8-1 +- remove mtf-env-clean from runthem script, it is on not good place and cleanup + of env is not important to have it ther (jscotka@redhat.com) +- add vagrant file (jscotka@redhat.com) +- add vagrant file for metadata (jscotka@redhat.com) +- support for metadata to mtf tool (jscotka@redhat.com) +- metadata: fix generic test filters (jscotka@redhat.com) +- dependencies for testing in nicer and cleaner format for various distros + (jscotka@redhat.com) +- remove mtf manpage (jscotka@redhat.com) +- revert generated man pages code (jscotka@redhat.com) +- Revert "remove all manpages" (scottyh@post.cz) +- run also without sudo, to improve pip for avocado (jscotka@redhat.com) +- dependencies in vagrant, travis and for local installation to file + (jscotka@redhat.com) +- trying to clean man-page-generator (psklenar@redhat.com) +- add test for mtf-pdc-module-info-reader tool and enable it in travis + (jscotka@redhat.com) +- trying to clean man-page-generator (psklenar@redhat.com) +- trying to clean man-page-generator (psklenar@redhat.com) +- remove regression, pdc_data needs to import BASEPATHDIR (jscotka@redhat.com) +- fix vagrant file, fix odcs format of repo, expected dir not repofile + (jscotka@redhat.com) +- remove print function, typo (jscotka@redhat.com) +- create clean commit based on PR#172 (jscotka@redhat.com) +- add cool comments (psklenar@redhat.com) +- man page is generated now (psklenar@redhat.com) +- man page is generated now (psklenar@redhat.com) +- man page is generated now (psklenar@redhat.com) +- remove mtf-log-parser from specfile (jscotka@redhat.com) +- add all variables (psklenar@redhat.com) +- fix copr builds (jscotka@redhat.com) +- removed unused imports (psklenar@redhat.com) +- fixing all issues (jscotka@redhat.com) +- add version as its needed for man page generator (psklenar@redhat.com) +- script to run containers in taskotron (jscotka@redhat.com) +- trying travis (psklenar@redhat.com) +- some info about VARIABLES (psklenar@redhat.com) +- empty commit to start tests (psklenar@redhat.com) +- delete file (psklenar@redhat.com) +- not needed (psklenar@redhat.com) +- new line (psklenar@redhat.com) +- needed setup, parser in function (psklenar@redhat.com) +- have parser in function (psklenar@redhat.com) +- man mtf page is generated (psklenar@redhat.com) +- revert back to using python setup.py for package installation + (jscotka@redhat.com) +- remove avocado html plugin from python dependencies, it is not important for + mtf anyhow (jscotka@redhat.com) +- test metadata support tool for MTF (jscotka@redhat.com) +- fix profile handling (jscotka@redhat.com) +- add some tags to modulelint tests, to be able to filter them + (jscotka@redhat.com) +- Updating docstring and adding pod functions (phracek@redhat.com) +- Support run command. (phracek@redhat.com) +- Better check if application exists (phracek@redhat.com) +- Fixes based on the PR comments. (phracek@redhat.com) +- Use command oc get and stdout instead of parsing json. (phracek@redhat.com) +- Add more docu stuff and some fixes. (phracek@redhat.com) +- testing containers in OpenShift (phracek@redhat.com) +- Update setup.py (phracek@redhat.com) +- Fix problem with paramters (phracek@redhat.com) +- Rewrite dnf/yum clean all functions (phracek@redhat.com) +- Check specific file extensions in /var/cache/yum|dnf directories + (phracek@redhat.com) +- doc test is splitted into two tests. One is for whole image and second one is + related only for install RPMs by RUN command (phracek@redhat.com) +- Add suport for check nodocs and clean_all (phracek@redhat.com) +- Update docstring (phracek@redhat.com) +- Update RTD. Use sphinx-build-2 (phracek@redhat.com) +- Update name classes (phracek@redhat.com) +- Update setup.py (phracek@redhat.com) +- Bump version to 0.7.7 (phracek@redhat.com) +- Documentation about linters (phracek@redhat.com) +- Split linters into more classes (phracek@redhat.com) +- Couple updates based on PR. (phracek@redhat.com) +- Rename function to assert_to_warn (phracek@redhat.com) +- Package man pages (phracek@redhat.com) +- man page updates based on #151 PR (phracek@redhat.com) +- Implement func mark_as_warn (phracek@redhat.com) +- Fix error in case help_md does not exist (phracek@redhat.com) +- Couple updates. (phracek@redhat.com) +- remove workarounds and add rpm to base package set workaround + (jscotka@redhat.com) +- typos (psklenar@redhat.com) +- modify how it works images and store rendered output (jscotka@redhat.com) +- docs into RTD (psklenar@redhat.com) +- testsuite for mtf-init (psklenar@redhat.com) +- Bump version to 0.7.6 (phracek@redhat.com) +- Add mtf-generator man page. (phracek@redhat.com) +- Manual page for Meta-Test-Family (phracek@redhat.com) +- Fix error in case help_md does not exist (phracek@redhat.com) +- Fixes #142 Fix tracebacks for COPY and ADD directives (phracek@redhat.com) +- systemd test examples - testing fedora or centos via nspawn + (jscotka@redhat.com) +- fix multihost regression, caused by code cleanup (jscotka@redhat.com) +- change test for decorators to generic one, and change self.skip to + self.cancel() (jscotka@redhat.com) +- mistake in os.path.exist (there were makedirs by mistake) + (jscotka@redhat.com) +- there is sometimes problem to do chmod, so run it via bash + (jscotka@redhat.com) +- test for exception return in case of failed command, check ret code and + raised exception (jscotka@redhat.com) +- add function to run script on remote machine (jscotka@redhat.com) +- nspawn operation moved to low level library not depenedent on mtf structure + (jscotka@redhat.com) +- add argparse, move test.py into templates (psklenar@redhat.com) +- Bump new release (phracek@redhat.com) +- Update documentation and use absolute path (phracek@redhat.com) +- Fix some logging issues and yum checks (phracek@redhat.com) +- raise error in case of compatibility (error has to be raised explicitly) + (jscotka@redhat.com) +- script which generate easy template (psklenar@redhat.com) +- create snapshot before calling setup from config, because machine does not + have root directory (jscotka@redhat.com) +- Skip help.md for now if it does not exist (phracek@redhat.com) +- add tests for RUN instructions. One for dnf part and the other one for the + rest (phracek@redhat.com) +- Use WARNING in case of ENVIRONMENT VARIABLES are not set in help.md + (phracek@redhat.com) +- Add check for presence help.md (phracek@redhat.com) +- several fixes based on comment from PR. (phracek@redhat.com) +- New help.md fixes (phracek@redhat.com) +- Fix problems found during review (phracek@redhat.com) +- help.md sanity checker (phracek@redhat.com) +- Linter for help.md file (phracek@redhat.com) +- Check for is FROM first (phracek@redhat.com) +- linter: check Red Hat's and Fedora's images (ttomecek@redhat.com) +- add comment and link to bugzilla (jscotka@redhat.com) +- partial change of backward compatibility (jscotka@redhat.com) +- fix issue with bad exit code of mtf command (jscotka@redhat.com) +- Hidden feature for install packages from default module via ENVVAR, for + further purposes, should not be used now (jscotka@redhat.com) +- pep8 change (jscotka@redhat.com) +- test module uses this config, after fixing composeurl handling, if there is + bad link, causes error (jscotka@redhat.com) +- back to original timeout library (jscotka@redhat.com) +- spec: fix URL (phracek@redhat.com) +- fix compose handling and fix container issue with using container instead of + url (jscotka@redhat.com) +- Remove shebang from two python files (phracek@redhat.com) +- Fix shebangs and so (phracek@redhat.com) +- avocado could say 'FAIL' too (psklenar@redhat.com) +- typo (jscotka@redhat.com) +- repair typo in config.yaml and add call of mtf-set-env to makefile + (jscotka@redhat.com) +- better name of the file (psklenar@redhat.com) +- move main into site package (psklenar@redhat.com) +- new line fix (psklenar@redhat.com) +- function add, not so many spaces (psklenar@redhat.com) +- new line (psklenar@redhat.com) +- new tool avocado_log_json.py (psklenar@redhat.com) +- mtf summary (psklenar@redhat.com) +- add sample output, to see what you can expect (jscotka@redhat.com) +- add internal usage test as class of simpleTest.py (jscotka@redhat.com) +- add usage tests and improve doc (jscotka@redhat.com) +- Revert "add usage tests and improve doc" (jscotka@redhat.com) +- add usage tests and improve doc (jscotka@redhat.com) +- improv base avocado class to not skip modules with proper backend (parent) + (jscotka@redhat.com) +- repaired submodule for check_modulemd (jscotka@redhat.com) +- revert back submodule (jscotka@redhat.com) +- example how S2I image can be tested with build process (jscotka@redhat.com) +- Update dockerlint a bit according to Container:Guidelines + (phracek@redhat.com) +- remov baseruntime from Makefile (jscotka@redhat.com) +- remove python docker requirements, cause trouble in taskotron for shell test: + (jscotka@redhat.com) +- move this important testcase to the end, cause sometimes error + (jscotka@redhat.com) +- function removed, have to remove from nspawn helper (jscotka@redhat.com) +- taskotron - fix issue with missing base compose repo, when disabled local + koji cloning (jscotka@redhat.com) + * Tue Oct 31 2017 Petr Hracek 0.7.7-1 - new upstream release diff --git a/setup.py b/setup.py index 9432d98..fa947d7 100755 --- a/setup.py +++ b/setup.py @@ -70,7 +70,7 @@ def get_dir(system_path=None, virtual_path=None): setup( name='meta-test-family', - version="0.7.7", + version="0.7.8", description='Tool to test components for a modular Fedora.', keywords='modules,containers,testing,framework', author='Jan Scotka',