From 9e7594899c0525d78659e29dd484a55e77230749 Mon Sep 17 00:00:00 2001 From: Tatyana Leontovich Date: Tue, 17 May 2016 12:22:34 +0300 Subject: [PATCH] Initial commit with some basic code Also add some base helpers Change-Id: I173240c0fdbf4cc7355e80739d5d10df8e9dd054 --- .gitignore | 61 ++++ mcp_tests/__init__.py | 0 mcp_tests/base_test.py | 32 ++ mcp_tests/helpers/__init__.py | 0 mcp_tests/helpers/containers.py | 147 +++++++++ mcp_tests/helpers/mcp_tests_exceptions.py | 52 +++ mcp_tests/helpers/metaclasses.py | 27 ++ mcp_tests/helpers/ssh_manager.py | 341 ++++++++++++++++++++ mcp_tests/helpers/utils.py | 113 +++++++ mcp_tests/logger.py | 73 +++++ mcp_tests/models/__init__.py | 0 mcp_tests/models/manager.py | 25 ++ mcp_tests/requirements.txt | 6 + mcp_tests/service_tests/__init__.py | 0 mcp_tests/service_tests/test_mysql_image.py | 44 +++ mcp_tests/settings.py | 34 ++ mcp_tests/system_tests/__init__.py | 0 pytest.ini | 3 + tox.ini | 28 ++ 19 files changed, 986 insertions(+) create mode 100644 .gitignore create mode 100644 mcp_tests/__init__.py create mode 100644 mcp_tests/base_test.py create mode 100644 mcp_tests/helpers/__init__.py create mode 100644 mcp_tests/helpers/containers.py create mode 100644 mcp_tests/helpers/mcp_tests_exceptions.py create mode 100644 mcp_tests/helpers/metaclasses.py create mode 100644 mcp_tests/helpers/ssh_manager.py create mode 100644 mcp_tests/helpers/utils.py create mode 100644 mcp_tests/logger.py create mode 100644 mcp_tests/models/__init__.py create mode 100644 mcp_tests/models/manager.py create mode 100644 mcp_tests/requirements.txt create mode 100644 mcp_tests/service_tests/__init__.py create mode 100644 mcp_tests/service_tests/test_mysql_image.py create mode 100644 mcp_tests/settings.py create mode 100644 mcp_tests/system_tests/__init__.py create mode 100644 pytest.ini create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94c8627 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +include +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +local +lib64 +MANIFEST +TAGS + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.cache +.project +.pydevproject +.idea + +# Local example +example_local.py + +# Local settings +local_settings.py + +# Documentation +doc/_build/ + +# Logs +/logs +tests.log + +# Certs +/ca.crt +/ca.pem + +# Cache +/.cache diff --git a/mcp_tests/__init__.py b/mcp_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp_tests/base_test.py b/mcp_tests/base_test.py new file mode 100644 index 0000000..547df49 --- /dev/null +++ b/mcp_tests/base_test.py @@ -0,0 +1,32 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from mcp_tests.helpers.containers import ContainerEngine +from mcp_tests.helpers.ssh_manager import SSHManager + + +class TestBasic(object): + """Basic test case class for tests. + + """ + def __init__(self): + self._devops_config = None + + @property + def ssh_manager(self): + return SSHManager() + + @property + def container_engine(self): + return ContainerEngine() diff --git a/mcp_tests/helpers/__init__.py b/mcp_tests/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp_tests/helpers/containers.py b/mcp_tests/helpers/containers.py new file mode 100644 index 0000000..3f92d03 --- /dev/null +++ b/mcp_tests/helpers/containers.py @@ -0,0 +1,147 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import division + +from mcp_tests.logger import logger + + +class ContainerEngine(object): + def __init__(self, + remote=None, + image_name=None, + container_repo=None, + proxy_url=None, + user_id=0, + container_name=None, + dir_for_home='/var/home', + ): + self.remote = remote + self.container_repo = container_repo + self.repository_tag = 'latest' + self.proxy_url = proxy_url or "" + self.user_id = user_id + self.image_name = image_name + self.container_name = container_name + self.dir_for_home = dir_for_home + self.home_bind_path = '{0}/{1}'.format( + self.dir_for_home, self.container_name) + self.setup() + + def image_exists(self, tag='latest'): + cmd = "docker images | grep {0}| awk '{{print $1}}'".format( + self.image_name) + logger.info('Checking Docker images...') + result = self.remote.execute(cmd) + logger.debug(result) + existing_images = [line.strip().split() for line in result['stdout']] + return [self.container_repo, tag] in existing_images + + def pull_image(self): + # TODO: add possibility to load image from local path or + # remote link provided in settings, in order to speed up downloading + cmd = 'docker pull {0}'.format(self.container_repo) + logger.debug('Downloading Rally repository/image from registry...') + result = self.remote.execute(cmd) + logger.debug(result) + return self.image_exists() + + def run_container_command(self, command, in_background=False): + command = str(command).replace(r"'", r"'\''") + options = '' + if in_background: + options = '{0} -d'.format(options) + cmd = ("docker run {options} --user {user_id} --net=\"host\" -e " + "\"http_proxy={proxy_url}\" -e \"https_proxy={proxy_url}\" " + "-v {dir_for_home}:{home_bind_path} {container_repo}:{tag} " + "/bin/bash -c '{command}'".format( + options=options, + user_id=self.user_id, + proxy_url=self.proxy_url, + dir_for_home=self.dir_for_home, + home_bind_path=self.home_bind_path, + container_repo=self.container_repo, + tag=self.repository_tag, + command=command)) + logger.debug('Executing command "{0}" in Rally container {1}..'.format( + cmd, self.container_repo)) + result = self.remote.execute(cmd) + logger.debug(result) + return result + + def setup_utils(self): + utils = ['gawk', 'vim', 'curl'] + cmd = ('unset http_proxy https_proxy; apt-get update; ' + 'apt-get install -y {0}'.format(' '.join(utils))) + logger.debug('Installing utils "{0}" to the container...'.format( + utils)) + result = self.run_container_command(cmd) + assert(result['exit_code'] == 0, + 'Utils installation failed in container: ' + '{0}'.format(result)) + + def prepare_image(self): + self.setup_utils() + last_container_cmd = "docker ps -lq" + result = self.remote.execute(last_container_cmd) + assert(result['exit_code'] == 0, + "Unable to get last container ID: {0}!".format(result)) + last_container = ''.join([line.strip() for line in result['stdout']]) + commit_cmd = 'docker commit {0} {1}:ready'.format(last_container, + self.container_repo) + result = self.remote.execute(commit_cmd) + assert(result['exit_code'] == 0, + 'Commit to Docker image "{0}" failed: {1}.'.format( + self.container_repo, result)) + return self.image_exists(tag='ready') + + def setup_bash_alias(self): + alias_name = '{}_docker'.format(self.image_name) + check_alias_cmd = '. /root/.bashrc && alias {0}'.format(alias_name) + result = self.remote.execute(check_alias_cmd) + if result['exit_code'] == 0: + return + logger.debug('Creating bash alias for {} inside container...'.format( + self.image_name)) + create_alias_cmd = ("alias {alias_name}='docker run --user {user_id} " + "--net=\"host\" -e \"http_proxy={proxy_url}\" -t " + "-i -v {dir_for_home}:{home_bind_path} " + "{container_repo}:{tag} {image_name}'".format( + alias_name=alias_name, + user_id=self.user_id, + proxy_url=self.proxy_url, + dir_for_home=self.dir_for_home, + home_bind_path=self.home_bind_path, + container_repo=self.container_repo, + tag=self.repository_tag, + image_name=self.image_name)) + result = self.remote.execute('echo "{0}">> /root/.bashrc'.format( + create_alias_cmd)) + assert (result['exit_code'] == 0, + "Alias creation for running {0} from container failed: " + "{1}.".format(self.image_name, result)) + result = self.remote.execute(check_alias_cmd) + assert(result['exit_code'] == 0, + "Alias creation for running {} from container failed: " + "{1}.".format(self.image_name, result)) + + def setup(self): + if not self.image_exists(): + assert (self.pull_image(), + "Docker image for {} not found!".format(self.image_name)) + if not self.image_exists(tag='ready'): + assert(self.prepare_image(), + "Docker image for {} is not ready!".format(self.image_name)) + self.repository_tag = 'ready' + self.setup_bash_alias() diff --git a/mcp_tests/helpers/mcp_tests_exceptions.py b/mcp_tests/helpers/mcp_tests_exceptions.py new file mode 100644 index 0000000..891ba1c --- /dev/null +++ b/mcp_tests/helpers/mcp_tests_exceptions.py @@ -0,0 +1,52 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +class UnexpectedExitCode(Exception): + def __init__(self, command, ec, expected_ec, stdout=None, stderr=None): + """Exception for unexpected exit code after executing shell/ssh command + :param command: str - executed command + :param ec: int - actual exit code + :param expected_ec: list of integers - expected exit codes + :param stdout: str + :param stderr: str + """ + self.ec = ec + self.expected_ec = expected_ec + self.cmd = command + self.stdout = stdout + self.stderr = stderr + super(UnexpectedExitCode, self).__init__() + + def __str__(self): + message = "Command '{cmd:s}' returned unexpected exit code {code:d}," \ + " while waiting for {exp}".format(cmd=self.cmd, + code=self.ec, + exp=self.expected_ec) + if self.stdout: + message += "stdout: {}\n".format(self.stdout) + if self.stderr: + message += "stderr: {}\n".format(self.stderr) + return message + + +class VariableNotSet(Exception): + def __init__(self, variable_name, expected_value): + self.variable_name = variable_name + self.expected_value = expected_value + super(VariableNotSet, self).__init__() + + def __str__(self): + return "Variable {0} was not set in value {1}".format( + self.variable_name, self.expected_value) diff --git a/mcp_tests/helpers/metaclasses.py b/mcp_tests/helpers/metaclasses.py new file mode 100644 index 0000000..6e1e79b --- /dev/null +++ b/mcp_tests/helpers/metaclasses.py @@ -0,0 +1,27 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +class SingletonMeta(type): + """Metaclass for Singleton + + Main goals: not need to implement __new__ in singleton classes + """ + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super( + SingletonMeta, cls).__call__(*args, **kwargs) + return cls._instances[cls] diff --git a/mcp_tests/helpers/ssh_manager.py b/mcp_tests/helpers/ssh_manager.py new file mode 100644 index 0000000..3d8608a --- /dev/null +++ b/mcp_tests/helpers/ssh_manager.py @@ -0,0 +1,341 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import os +import posixpath +import re +import traceback + +from devops.helpers.helpers import wait +from devops.models.node import SSHClient +from paramiko import RSAKey +import six + +from mcp_tests.logger import logger +from mcp_tests.helpers.metaclasses import SingletonMeta +from mcp_tests.helpers import mcp_tests_exceptions +from mcp_tests.settings import SSH_NODE_CREDENTIALS + + +@six.add_metaclass(SingletonMeta) +class SSHManager(object): + + def __init__(self): + logger.debug('SSH_MANAGER: Run constructor SSHManager') + self.__connections = {} # Disallow direct type change and deletion + self.ip = None + self.port = None + self.login = None + self.password = None + + @property + def connections(self): + return self.__connections + + def initialize(self, ip, + login=SSH_NODE_CREDENTIALS['login'], + password=SSH_NODE_CREDENTIALS['password']): + """ It will be moved to __init__ + + :param ip: ip address of node + :param login: user name + :param password: password for user + :return: None + """ + self.ip = ip + self.port = 22 + self.login = login + self.password = password + + @staticmethod + def _connect(remote): + """ Check if connection is stable and return this one + + :param remote: + :return: + """ + try: + wait(lambda: remote.execute("cd ~")['exit_code'] == 0, timeout=20) + except Exception: + logger.info('SSHManager: Check for current ' + 'connection fails. Try to reconnect') + logger.debug(traceback.format_exc()) + remote.reconnect() + return remote + + def _get_keys(self): + keys = [] + remote = self.get_remote(self.ip) + key_string = '/root/.ssh/id_rsa' + with remote.open(key_string) as f: + keys.append(RSAKey.from_private_key(f)) + return keys + + def get_remote(self, ip, port=22): + """ Function returns remote SSH connection to node by ip address + + :param ip: IP of host + :param port: port for SSH + :return: SSHClient + """ + if (ip, port) not in self.connections: + logger.debug('SSH_MANAGER:Create new connection for ' + '{ip}:{port}'.format(ip=ip, port=port)) + + keys = self._get_keys() + ip = self.ip + username = self.login + password = self.password + + ssh_client = SSHClient( + host=ip, + port=port, + username=username, + password=password, + private_keys=keys + ) + + ssh_client.sudo_mode = True + self.connections[(ip, port)] = ssh_client + logger.debug('SSH_MANAGER:Return existed connection for ' + '{ip}:{port}'.format(ip=ip, port=port)) + logger.debug('SSH_MANAGER: Connections {0}'.format(self.connections)) + return self._connect(self.connections[(ip, port)]) + + def update_connection(self, ip, login=None, password=None, + keys=None, port=22): + """Update existed connection + + :param ip: host ip string + :param login: login string + :param password: password string + :param keys: list of keys + :param port: ssh port int + :return: None + """ + if (ip, port) in self.connections: + logger.info('SSH_MANAGER:Close connection for {ip}:{port}'.format( + ip=ip, port=port)) + self.connections[(ip, port)].clear() + logger.info('SSH_MANAGER:Create new connection for ' + '{ip}:{port}'.format(ip=ip, port=port)) + + self.connections[(ip, port)] = SSHClient( + host=ip, + port=port, + username=login, + password=password, + private_keys=keys if keys is not None else [] + ) + + def clean_all_connections(self): + for (ip, port), connection in self.connections.items(): + connection.clear() + logger.info('SSH_MANAGER:Close connection for {ip}:{port}'.format( + ip=ip, port=port)) + + def execute(self, ip, cmd, port=22): + remote = self.get_remote(ip=ip, port=port) + return remote.execute(cmd) + + def check_call(self, ip, cmd, port=22, verbose=False): + remote = self.get_remote(ip=ip, port=port) + return remote.check_call(cmd, verbose) + + def execute_on_remote(self, ip, cmd, port=22, err_msg=None, + jsonify=False, assert_ec_equal=None, + raise_on_assert=True): + """Execute ``cmd`` on ``remote`` and return result. + + :param ip: ip of host + :param port: ssh port + :param cmd: command to execute on remote host + :param err_msg: custom error message + :param assert_ec_equal: list of expected exit_code + :param raise_on_assert: Boolean + :return: dict + :raise: Exception + """ + if assert_ec_equal is None: + assert_ec_equal = [0] + + result = self.execute(ip=ip, port=port, cmd=cmd) + + result['stdout_str'] = ''.join(result['stdout']).strip() + result['stdout_len'] = len(result['stdout']) + result['stderr_str'] = ''.join(result['stderr']).strip() + result['stderr_len'] = len(result['stderr']) + + details_log = ( + "Host: {host}\n" + "Command: '{cmd}'\n" + "Exit code: {code}\n" + "STDOUT:\n{stdout}\n" + "STDERR:\n{stderr}".format( + host=ip, cmd=cmd, code=result['exit_code'], + stdout=result['stdout_str'], stderr=result['stderr_str'] + )) + + if result['exit_code'] not in assert_ec_equal: + error_msg = ( + err_msg or + "Unexpected exit_code returned: actual {0}, expected {1}." + "".format( + result['exit_code'], + ' '.join(map(str, assert_ec_equal)))) + log_msg = ( + "{0} Command: '{1}' " + "Details:\n{2}".format( + error_msg, cmd, details_log)) + logger.error(log_msg) + if raise_on_assert: + raise mcp_tests_exceptions.UnexpectedExitCode( + cmd, + result['exit_code'], + assert_ec_equal, + stdout=result['stdout_str'], + stderr=result['stderr_str']) + else: + logger.debug(details_log) + + if jsonify: + try: + result['stdout_json'] = \ + self._json_deserialize(result['stdout_str']) + except Exception: + error_msg = ( + "Unable to deserialize output of command" + " '{0}' on host {1}".format(cmd, ip)) + logger.error(error_msg) + raise Exception(error_msg) + + return result + + def execute_async_on_remote(self, ip, cmd, port=22): + remote = self.get_remote(ip=ip, port=port) + return remote.execute_async(cmd) + + @staticmethod + def _json_deserialize(json_string): + """ Deserialize json_string and return object + + :param json_string: string or list with json + :return: obj + :raise: Exception + """ + if isinstance(json_string, list): + json_string = ''.join(json_string) + + try: + obj = json.loads(json_string) + except Exception: + log_msg = "Unable to deserialize" + logger.error("{0}. Actual string:\n{1}".format(log_msg, + json_string)) + raise Exception(log_msg) + return obj + + def open_on_remote(self, ip, path, mode='r', port=22): + remote = self.get_remote(ip=ip, port=port) + return remote.open(path, mode) + + def upload_to_remote(self, ip, source, target, port=22): + remote = self.get_remote(ip=ip, port=port) + return remote.upload(source, target) + + def download_from_remote(self, ip, destination, target, port=22): + remote = self.get_remote(ip=ip, port=port) + return remote.download(destination, target) + + def exists_on_remote(self, ip, path, port=22): + remote = self.get_remote(ip=ip, port=port) + return remote.exists(path) + + def isdir_on_remote(self, ip, path, port=22): + remote = self.get_remote(ip=ip, port=port) + return remote.isdir(path) + + def isfile_on_remote(self, ip, path, port=22): + remote = self.get_remote(ip=ip, port=port) + return remote.isfile(path) + + def mkdir_on_remote(self, ip, path, port=22): + remote = self.get_remote(ip=ip, port=port) + return remote.mkdir(path) + + def rm_rf_on_remote(self, ip, path, port=22): + remote = self.get_remote(ip=ip, port=port) + return remote.rm_rf(path) + + def cond_upload(self, ip, source, target, port=22, condition='', + clean_target=False): + """ Upload files only if condition in regexp matches filenames + + :param ip: host ip + :param source: source path + :param target: destination path + :param port: ssh port + :param condition: regexp condition + :return: count of files + """ + + # remote = self.get_remote(ip=ip, port=port) + # maybe we should use SSHClient function. e.g. remote.isdir(target) + # we can move this function to some *_actions class + if self.isdir_on_remote(ip=ip, port=port, path=target): + target = posixpath.join(target, os.path.basename(source)) + + if clean_target: + self.rm_rf_on_remote(ip=ip, port=port, path=target) + self.mkdir_on_remote(ip=ip, port=port, path=target) + + source = os.path.expanduser(source) + if not os.path.isdir(source): + if re.match(condition, source): + self.upload_to_remote(ip=ip, port=port, + source=source, target=target) + logger.debug("File '{0}' uploaded to the remote folder" + " '{1}'".format(source, target)) + return 1 + else: + logger.debug("Pattern '{0}' doesn't match the file '{1}', " + "uploading skipped".format(condition, source)) + return 0 + + files_count = 0 + for rootdir, _, files in os.walk(source): + targetdir = os.path.normpath( + os.path.join( + target, + os.path.relpath(rootdir, source))).replace("\\", "/") + + self.mkdir_on_remote(ip=ip, port=port, path=targetdir) + + for entry in files: + local_path = os.path.join(rootdir, entry) + remote_path = posixpath.join(targetdir, entry) + if re.match(condition, local_path): + self.upload_to_remote(ip=ip, + port=port, + source=local_path, + target=remote_path) + files_count += 1 + logger.debug("File '{0}' uploaded to the " + "remote folder '{1}'".format(source, target)) + else: + logger.debug("Pattern '{0}' doesn't match the file '{1}', " + "uploading skipped".format(condition, + local_path)) + return files_count diff --git a/mcp_tests/helpers/utils.py b/mcp_tests/helpers/utils.py new file mode 100644 index 0000000..a2ebc46 --- /dev/null +++ b/mcp_tests/helpers/utils.py @@ -0,0 +1,113 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import os +import time +import yaml +import traceback + +from mcp_tests.logger import logger +from mcp_tests import settings + + +def get_test_method_name(): + raise NotImplementedError + + +def update_yaml(yaml_tree=None, yaml_value='', is_uniq=True, + yaml_file=settings.TIMESTAT_PATH_YAML): + + """Store/update a variable in YAML file. + yaml_tree - path to the variable in YAML file, will be created if absent, + yaml_value - value of the variable, will be overwritten if exists, + is_uniq - If false, add the unique two-digit suffix to the variable name. + """ + if yaml_tree is None: + yaml_tree = [] + yaml_data = {} + if os.path.isfile(yaml_file): + with open(yaml_file, 'r') as f: + yaml_data = yaml.load(f) + + # Walk through the 'yaml_data' dict, find or create a tree using + # sub-keys in order provided in 'yaml_tree' list + item = yaml_data + for n in yaml_tree[:-1]: + if n not in item: + item[n] = {} + item = item[n] + + if is_uniq: + last = yaml_tree[-1] + else: + # Create an uniq suffix in range '_00' to '_99' + for n in range(100): + last = str(yaml_tree[-1]) + '_' + str(n).zfill(2) + if last not in item: + break + + item[last] = yaml_value + with open(yaml_file, 'w') as f: + yaml.dump(yaml_data, f, default_flow_style=False) + + +class TimeStat(object): + """ Context manager for measuring the execution time of the code. + Usage: + with TimeStat([name],[is_uniq=True]): + """ + + def __init__(self, name=None, is_uniq=False): + if name: + self.name = name + else: + self.name = 'timestat' + self.is_uniq = is_uniq + self.begin_time = 0 + self.end_time = 0 + self.total_time = 0 + + def __enter__(self): + self.begin_time = time.time() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self.end_time = time.time() + self.total_time = self.end_time - self.begin_time + + # Create a path where the 'self.total_time' will be stored. + yaml_path = [] + + # There will be a list of one or two yaml subkeys: + # - first key name is the method name of the test + method_name = get_test_method_name() + if method_name: + yaml_path.append(method_name) + + # - second (subkey) name is provided from the decorator (the name of + # the just executed function), or manually. + yaml_path.append(self.name) + + try: + update_yaml(yaml_path, '{:.2f}'.format(self.total_time), + self.is_uniq) + except Exception: + logger.error("Error storing time statistic for {0}" + " {1}".format(yaml_path, traceback.format_exc())) + raise + + @property + def spent_time(self): + return time.time() - self.begin_time diff --git a/mcp_tests/logger.py b/mcp_tests/logger.py new file mode 100644 index 0000000..f7ff360 --- /dev/null +++ b/mcp_tests/logger.py @@ -0,0 +1,73 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import functools +import logging +import traceback +import os + +from mcp_tests.settings import LOGS_DIR + +if not os.path.exists(LOGS_DIR): + os.makedirs(LOGS_DIR) + +logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s - %(levelname)s %(filename)s:' + '%(lineno)d -- %(message)s', + filename=os.path.join(LOGS_DIR, 'tests.log'), + filemode='w') + +console = logging.StreamHandler() +console.setLevel(logging.INFO) +formatter = logging.Formatter('%(asctime)s - %(levelname)s %(filename)s:' + '%(lineno)d -- %(message)s') +console.setFormatter(formatter) + +logger = logging.getLogger(__name__) +logger.addHandler(console) + + +# suppress iso8601 and paramiko debug logging +class NoDebugMessageFilter(logging.Filter): + def filter(self, record): + return not record.levelno <= logging.DEBUG + +logging.getLogger('paramiko.transport').addFilter(NoDebugMessageFilter()) +logging.getLogger('paramiko.hostkeys').addFilter(NoDebugMessageFilter()) +logging.getLogger('iso8601.iso8601').addFilter(NoDebugMessageFilter()) + + +def debug(logger): + def wrapper(func): + @functools.wraps(func) + def wrapped(*args, **kwargs): + logger.debug( + "Calling: {} with args: {} {}".format( + func.__name__, args, kwargs + ) + ) + try: + result = func(*args, **kwargs) + logger.debug( + "Done: {} with result: {}".format(func.__name__, result)) + except BaseException as e: + logger.error( + '{func} raised: {exc!r}\n' + 'Traceback: {tb!s}'.format( + func=func.__name__, exc=e, tb=traceback.format_exc())) + raise + return result + return wrapped + return wrapper + +logwrap = debug(logger) diff --git a/mcp_tests/models/__init__.py b/mcp_tests/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp_tests/models/manager.py b/mcp_tests/models/manager.py new file mode 100644 index 0000000..d69ddea --- /dev/null +++ b/mcp_tests/models/manager.py @@ -0,0 +1,25 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from mcp_tests.base_test import TestBasic + + +class Manager(TestBasic): + """Manager class for tests.""" + + def __init__(self, config_file, cls): + super(Manager, self).__init__() + self._devops_config = None + self._start_time = 0 + self._context = cls diff --git a/mcp_tests/requirements.txt b/mcp_tests/requirements.txt new file mode 100644 index 0000000..1a67274 --- /dev/null +++ b/mcp_tests/requirements.txt @@ -0,0 +1,6 @@ +git+git://github.com/openstack/fuel-devops.git@2.9.20 +paramiko +six +requests>=2.2.0 +pytest>=2.9 +docker-py \ No newline at end of file diff --git a/mcp_tests/service_tests/__init__.py b/mcp_tests/service_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp_tests/service_tests/test_mysql_image.py b/mcp_tests/service_tests/test_mysql_image.py new file mode 100644 index 0000000..f91cc75 --- /dev/null +++ b/mcp_tests/service_tests/test_mysql_image.py @@ -0,0 +1,44 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import pytest +import traceback + +from mcp_tests.helpers import mcp_tests_exceptions +from mcp_tests.logger import logger +from mcp_tests import settings + + +class TestMysqlImage(object): + """Test class consits simple tests for mysql container""" + + @pytest.mark.mysql_base + def test_mysql_is_running(self): + """Start container from image, check if mysql is running + + Scenario: + 1. Get image from private registry + 2. Start container with it + 3. Check if mysql is running + 4. Destroy container + + """ + logger.info('Check if registry set {0}'.format( + settings.PRIVATE_REGISTRY)) + try: + if not settings.PRIVATE_REGISTRY: + raise mcp_tests_exceptions.VariableNotSet( + settings.PRIVATE_REGISTRY, 'localhost:5002/registry') + except mcp_tests_exceptions.VariableNotSet: + logger.error(traceback.format_exc()) diff --git a/mcp_tests/settings.py b/mcp_tests/settings.py new file mode 100644 index 0000000..e9414f2 --- /dev/null +++ b/mcp_tests/settings.py @@ -0,0 +1,34 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import os +import time + +_boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True, + '0': False, 'no': False, 'false': False, 'off': False} + + +def get_var_as_bool(name, default): + value = os.environ.get(name, '') + return _boolean_states.get(value.lower(), default) + +LOGS_DIR = os.environ.get('LOGS_DIR', os.getcwd()) +TIMESTAT_PATH_YAML = os.environ.get( + 'TIMESTAT_PATH_YAML', os.path.join( + LOGS_DIR, 'timestat_{}.yaml'.format(time.strftime("%Y%m%d")))) +SSH_NODE_CREDENTIALS = os.environ.get('SSH_NODE_CREDENTIALS', + {'login': 'test', 'password': 'test'}) + +PRIVATE_REGISTRY = os.environ.get('PRIVATE_REGISTRY') diff --git a/mcp_tests/system_tests/__init__.py b/mcp_tests/system_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..41a5e93 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = -vvv -s -p no:django -p no:ipdb --junit-xml=nosetests.xml +testpaths = mcp_tests diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..ac10f7e --- /dev/null +++ b/tox.ini @@ -0,0 +1,28 @@ +# Tox (http://tox.testrun.org/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +skipsdist = True +envlist = pep8, py27eters = True + +[testenv] +deps = -r{toxinidir}/mcp_tests/requirements.txt +usedevelop = False +commands = py.test + + +[testenv:pep8] +deps = flake8 +usedevelop = False +exclude = .venv,.git,.tox,.chache,.lib,dist,doc,*egg,build,local* +commands = + flake8 {posargs:.} + +[flake8] +ignore = H302,H802 +exclude = .venv,.git,.tox,dist,doc,*egg,build,local,./lib +show-pep8 = True +show-source = True +count = True