From 60fca54634421ebe578f04e354070331dff62c05 Mon Sep 17 00:00:00 2001 From: Mark Goddard Date: Mon, 3 Aug 2015 18:20:45 -0400 Subject: [PATCH] Initial commit Implements: blueprint bareon-functional-testing Change-Id: Ibf8a1f858e155871f8957669c085481373d26680 --- .gitignore | 19 ++ LICENSE | 176 +++++++++++++ README.rst | 9 + .../ramdisk-func-test.conf.sample | 41 +++ ramdisk_func_test/__init__.py | 0 ramdisk_func_test/base.py | 125 +++++++++ ramdisk_func_test/environment.py | 249 ++++++++++++++++++ ramdisk_func_test/network.py | 114 ++++++++ ramdisk_func_test/node.py | 179 +++++++++++++ .../templates/bareon_config.template | 5 + ramdisk_func_test/templates/network.xml | 12 + ramdisk_func_test/utils.py | 126 +++++++++ ramdisk_func_test/webserver/__init__.py | 0 ramdisk_func_test/webserver/server.py | 159 +++++++++++ ramdisk_func_test/webserver/stubfile | 1 + setup.py | 40 +++ tools/cleanup.sh | 22 ++ tools/setup_rsync.py | 84 ++++++ tox.ini | 24 ++ 19 files changed, 1385 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 etc/ramdisk-func-test/ramdisk-func-test.conf.sample create mode 100644 ramdisk_func_test/__init__.py create mode 100644 ramdisk_func_test/base.py create mode 100644 ramdisk_func_test/environment.py create mode 100644 ramdisk_func_test/network.py create mode 100644 ramdisk_func_test/node.py create mode 100644 ramdisk_func_test/templates/bareon_config.template create mode 100644 ramdisk_func_test/templates/network.xml create mode 100644 ramdisk_func_test/utils.py create mode 100644 ramdisk_func_test/webserver/__init__.py create mode 100755 ramdisk_func_test/webserver/server.py create mode 100644 ramdisk_func_test/webserver/stubfile create mode 100644 setup.py create mode 100644 tools/cleanup.sh create mode 100644 tools/setup_rsync.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ecfa440 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.venv +*.pyc + +# vim swap files +.*.swp + +# services' runtime files +*.log +*.pid + +build +dist + +*.egg +.testrepository +.tox +.idea +.DS_Store +*.egg-info diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..68c771a --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..31c0b15 --- /dev/null +++ b/README.rst @@ -0,0 +1,9 @@ +A functional testing framework used for ramdisk-based deployment tools, +e.g. bareon (https://wiki.openstack.org/wiki/Bareon) + +Provides an API to: +- create virtual nodes from template +- create virtual networks +- execute commands on the nodes +- transfer files to/from nodes +- cleanup resources diff --git a/etc/ramdisk-func-test/ramdisk-func-test.conf.sample b/etc/ramdisk-func-test/ramdisk-func-test.conf.sample new file mode 100644 index 0000000..e203aa3 --- /dev/null +++ b/etc/ramdisk-func-test/ramdisk-func-test.conf.sample @@ -0,0 +1,41 @@ +[DEFAULT] + +# Path where virtualized node disks will be stored +#ramdisk_func_test_workdir = /tmp/ramdisk-func-test/ + +# Time to wait slave node to boot (seconds) +#node_boot_timeout=360 + +# A path where images from DIB will be build. Expected +# build artifacts are: kernel, ramdisk, ramdisk_key +#image_build_dir = /tmp/rft_image_build + +#A path where mock web-server will take tenant images . +#tenant_images_dir = /tmp/rft_golden_images + +# Name of kernel image +#kernel = vmlinuz + +# Name of ramdisk image +#ramdisk = initramfs + +# Name of private ssh key to access ramdisk +#ramdisk_key = fuel_key + +# URL of qemu server +#qemu_url = qemu:///system + +# Head octets for libvirt network (choose free one). +#libvirt_net_head_octets = 192.168 + +# Libvirt network DHCP range start. +#libvirt_net_range_start = 100 + +# Libvirt network DHCP range end. +#libvirt_net_range_end = 254 + +# Libvirt machine type (see 'qemu-system-x86_64 -machine help') +# libvirt_machine_type='' + +# Path to pxelinux.0 file +# pxelinux = /usr/share/syslinux/pxelinux.0 diff --git a/ramdisk_func_test/__init__.py b/ramdisk_func_test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ramdisk_func_test/base.py b/ramdisk_func_test/base.py new file mode 100644 index 0000000..27ca5bb --- /dev/null +++ b/ramdisk_func_test/base.py @@ -0,0 +1,125 @@ +# +# Copyright 2016 Cray Inc., All Rights Reserved +# +# 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 logging +import uuid +import os +import sys + +import libvirt +import jinja2 + +from oslo_config import cfg + +import utils + + +def _setup_config(): + cfg.CONF([], default_config_files=[ + "/etc/ramdisk-func-test/ramdisk-func-test.conf"]) + + +def _setup_loggging(): + for pair in [ + 'paramiko=WARN', + 'ironic.openstack.common=WARN', + ]: + mod, _sep, level_name = pair.partition('=') + logger = logging.getLogger(mod) + # NOTE(AAzza) in python2.6 Logger.setLevel doesn't convert string name + # to integer code. + if sys.version_info < (2, 7): + level = logging.getLevelName(level_name) + logger.setLevel(level) + else: + logger.setLevel(level_name) + + +_setup_config() +_setup_loggging() + +opts = [ + cfg.StrOpt('qemu_url', + help='URL of qemu server.', + default="qemu:///system"), +] +CONF = cfg.CONF +CONF.register_opts(opts) + +LOG = logging.getLogger(__name__) + +ABS_PATH = os.path.dirname(os.path.abspath(__file__)) + + +class LibvirtBase(object): + """Generic wrapper for libvirt domain objects.""" + libvirt = libvirt.open(CONF.qemu_url) + + def __init__(self, templ_engine): + super(LibvirtBase, self).__init__() + self.templ_engine = templ_engine + # Initialized in child classes + self.name = None + self.domain = None + + def _generate_name(self, base): + short_uid = str(uuid.uuid4())[:8] + # Same string hardcoded in tools/cleanup.sh + return "rft-{0}-{1}".format(base, short_uid) + + def start(self): + LOG.debug("Starting domain %s" % self.name) + self.domain.create() + + def stop(self): + LOG.debug("Stopping domain %s" % self.name) + self.domain.destroy() + + def reboot(self): + LOG.debug("Rebooting domain %s" % self.name) + self.domain.reboot() + + def kill(self): + LOG.debug("Killing domain %s" % self.name) + calls = ( + "destroy", + "undefine" + ) + for call in calls: + try: + getattr(self.domain, call)() + except Exception as err: + LOG.warning("Error during domain '{0}' call:\n{1}".format( + call, err.message + )) + + +class TemplateEngine(object): + def __init__(self, node_templates): + super(TemplateEngine, self).__init__() + loader = jinja2.FileSystemLoader([ + node_templates, + os.path.join(ABS_PATH, "templates") + ]) + self._jinja = jinja2.Environment(loader=loader) + + # Custom template callbacks + self._jinja.globals['empty_disk'] = utils.create_empty_disk + self._jinja.globals['disk_from_base'] = utils.create_disk_from_base + self._jinja.globals['get_rand_mac'] = utils.get_random_mac + + def render_template(self, template_name, **kwargs): + template = self._jinja.get_template(template_name) + return template.render(**kwargs) diff --git a/ramdisk_func_test/environment.py b/ramdisk_func_test/environment.py new file mode 100644 index 0000000..03ce2f5 --- /dev/null +++ b/ramdisk_func_test/environment.py @@ -0,0 +1,249 @@ +# +# Copyright 2016 Cray Inc., All Rights Reserved +# +# 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 shutil +import subprocess +import json +import logging +import time +import sh + +from oslo_config import cfg + +from ramdisk_func_test import utils +from ramdisk_func_test.base import TemplateEngine +from ramdisk_func_test.base import ABS_PATH +from ramdisk_func_test.network import Network +from ramdisk_func_test.node import Node + + +opts = [ + cfg.StrOpt('image_build_dir', + default="/tmp/rft_image_build", + help='A path where images from DIB will be build. Expected ' + 'build artifacts are: kernel, ramdisk, ramdisk_key'), + cfg.StrOpt('tenant_images_dir', + default="/tmp/rft_golden_images", + help='A path where mock web-server will take tenant images '), + cfg.StrOpt('kernel', + default='vmlinuz', + help='Name of kernel image'), + cfg.StrOpt('ramdisk', + default='initramfs', + help='Name of ramdisk image'), + cfg.StrOpt('ramdisk_key', + default='fuel_key', + help='Name of private ssh key to access ramdisk'), + # NOTE(oberezovskyi): path from Centos 7 taken as default + cfg.StrOpt('pxelinux', + default='/usr/share/syslinux/pxelinux.0', + help='Path to pxelinux.0 file') +] + +CONF = cfg.CONF +CONF.register_opts(opts) +CONF.import_opt('ramdisk_func_test_workdir', 'ramdisk_func_test.utils') + +LOG = logging.getLogger(__name__) + + +class Environment(object): + HTTP_PORT = "8011" + + def __init__(self, node_templates): + super(Environment, self).__init__() + self.templ_eng = TemplateEngine(node_templates) + + self.node = None + self.network = None + self.webserver = None + self.tenant_images_dir = None + self.rsync_dir = None + self.image_mount_point = None + + def setupclass(self): + """Global setup - single for all tests""" + self.network = Network(self.templ_eng) + self.network.start() + + self.tenant_images_dir = CONF.tenant_images_dir + + self._setup_webserver() + self._check_rsync() + self._setup_pxe() + + def setup(self, node_template, deploy_config): + """Per-test setup""" + ssh_key_path = os.path.join(CONF.image_build_dir, CONF.ramdisk_key) + self.node = Node(self.templ_eng, + node_template, + self.network.name, + ssh_key_path) + + self.add_pxe_config_for_current_node() + self.network.add_node(self.node) + + path = self._save_provision_json_for_node(deploy_config) + + self.node.start() + self.node.wait_for_callback() + + self.node.put_file(path, '/tmp/provision.json') + + def teardown(self): + """Per-test teardown""" + self.network.remove_node(self.node) + self.node.kill() + self._delete_node_workdir(self.node) + + def teardownclass(self): + """Global tear down - single for all tests""" + LOG.info("Tearing down Environment class...") + self._teardown_webserver() + self._teardown_rsync() + + self.network.kill() + self._delete_workdir() + + def _setup_pxe(self): + LOG.info("Setting up PXE configuration/images") + tftp_root = self.network.tftp_root + img_build = CONF.image_build_dir + utils.copy_file(CONF.pxelinux, tftp_root) + utils.copy_file(os.path.join(img_build, CONF.kernel), tftp_root) + utils.copy_file(os.path.join(img_build, CONF.ramdisk), tftp_root) + + def add_pxe_config_for_current_node(self): + LOG.info("Setting up PXE configuration file fo node {0}".format( + self.node.name)) + + tftp_root = self.network.tftp_root + + pxe_config = self.templ_eng.render_template( + 'bareon_config.template', + kernel=CONF.kernel, + ramdisk=CONF.ramdisk, + deployment_id=self.node.name, + api_url="http://{0}:{1}".format(self.network.address, + self.HTTP_PORT) + ) + + pxe_path = os.path.join(tftp_root, "pxelinux.cfg") + utils.ensure_tree(pxe_path) + + conf_path = os.path.join(pxe_path, '01-{0}'.format( + self.node.mac.replace(':', '-'))) + with open(conf_path, 'w') as f: + f.write(pxe_config) + + def _setup_webserver(self, port=HTTP_PORT): + + LOG.info("Starting stub webserver (at IP {0} port {1}, path to tenant " + "images folder is '{2}')".format(self.network.address, + port, + self.tenant_images_dir)) + + # TODO(max_lobur) make webserver singletone + self.webserver = subprocess.Popen( + ['python', + os.path.join(ABS_PATH, 'webserver/server.py'), + self.network.address, port, self.tenant_images_dir], shell=False) + + def get_url_for_image(self, image_name, source_type): + if source_type == 'swift': + return self._get_swift_tenant_image_url(image_name) + elif source_type == 'rsync': + return self._get_rsync_tenant_image_url(image_name) + else: + raise Exception("Unknown deploy_driver") + + def get_url_for_stub_image(self): + return "http://{0}:{1}/fake".format(self.network.address, + self.HTTP_PORT) + + def _get_swift_tenant_image_url(self, image_name): + return ("http://{0}:{1}/tenant_images/" + "{2}".format(self.network.address, self.HTTP_PORT, image_name)) + + def _get_rsync_tenant_image_url(self, image_name): + url = "{0}::ironic_rsync/{1}/".format(self.network.address, + image_name) + image_path = os.path.join(self.tenant_images_dir, image_name) + if os.path.exists(image_path): + image_mount_point = os.path.join(self.rsync_dir, image_name) + self.image_mount_point = image_mount_point + utils.ensure_tree(image_mount_point) + sh.sudo.mount('-o', 'loop,ro', image_path, image_mount_point) + if not os.path.exists('{0}/etc/passwd'.format( + image_mount_point)): + raise Exception('Mounting of image did not happen') + else: + raise Exception("There is no such file '{0}' in '{1}'".format( + image_name, self.tenant_images_dir)) + return url + + def _save_provision_json_for_node(self, deploy_config): + prov_json = json.dumps(deploy_config) + path = os.path.join(self.node.workdir, "provision.json") + with open(path, "w") as f: + f.write(prov_json) + return path + + def _teardown_webserver(self): + LOG.info("Stopping stub web server ...") + self.webserver.terminate() + + for i in range(0, 15): + if self.webserver.poll() is not None: + LOG.info("Stub web server has stopped.") + return + time.sleep(1) + LOG.warning("Cannot terminate web server in 15 sec!") + + def _delete_workdir(self): + LOG.info("Deleting workdir {0}".format(CONF.ramdisk_func_test_workdir)) + shutil.rmtree(CONF.ramdisk_func_test_workdir) + + def _delete_node_workdir(self, node): + wdir = node.workdir + LOG.info("Deleting node workdir {0}".format(wdir)) + shutil.rmtree(wdir) + + def _check_rsync(self): + + rsync_config_path = "/etc/rsyncd.conf" + rsync_ironic_section_name = 'ironic_rsync' + + if not utils._pid_of('rsync'): + raise Exception('No rsync process is running') + + if os.path.exists(rsync_config_path): + cfg = utils.read_config(rsync_config_path) + else: + raise Exception('No rsyncd config file found at {0}'.format( + rsync_config_path + )) + + if rsync_ironic_section_name in cfg.sections(): + self.rsync_dir = cfg.get(rsync_ironic_section_name, 'path') + else: + raise Exception('There is no ironic section ({0}) in rsync ' + 'config file'.format(rsync_ironic_section_name)) + + def _teardown_rsync(self): + if self.image_mount_point: + sh.sudo.umount(self.image_mount_point) + sh.rmdir(self.image_mount_point) diff --git a/ramdisk_func_test/network.py b/ramdisk_func_test/network.py new file mode 100644 index 0000000..f7639fa --- /dev/null +++ b/ramdisk_func_test/network.py @@ -0,0 +1,114 @@ +# +# Copyright 2016 Cray Inc., All Rights Reserved +# +# 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 random +import logging +import libvirt + +from oslo_config import cfg + +from ramdisk_func_test import utils +from ramdisk_func_test.base import LibvirtBase + +LOG = logging.getLogger(__name__) + +opts = [ + cfg.StrOpt('libvirt_net_head_octets', + default="192.168", + help='Head octets for libvirt network (choose free one).'), + cfg.IntOpt('libvirt_net_range_start', + default=100, + help='Libvirt network DHCP range start.'), + cfg.IntOpt('libvirt_net_range_end', + default=254, + help='Libvirt network DHCP range end.') +] + +CONF = cfg.CONF +CONF.register_opts(opts) +CONF.import_opt('ramdisk_func_test_workdir', 'ramdisk_func_test.utils') + + +class Network(LibvirtBase): + def __init__(self, templ_engine): + super(Network, self).__init__(templ_engine) + + self.name = self._generate_name("net") + head_octets = CONF.libvirt_net_head_octets + free_net = self._find_free_libvirt_network(head_octets) + self.address = "{0}.1".format(free_net) + range_start = '{0}.{1}'.format(free_net, CONF.libvirt_net_range_start) + range_end = '{0}.{1}'.format(free_net, CONF.libvirt_net_range_end) + + self.tftp_root = os.path.join(CONF.ramdisk_func_test_workdir, + 'tftp_root') + utils.ensure_tree(self.tftp_root) + + xml = self.templ_engine.render_template( + 'network.xml', + name=self.name, + bridge=self._generate_name("br"), + address=self.address, + tftp_root=self.tftp_root, + range_start=range_start, + range_end=range_end) + self.domain = self._define_domain(xml) + + def _define_domain(self, xml): + self.libvirt.networkDefineXML(xml) + dom = self.libvirt.networkLookupByName(self.name) + return dom + + def add_node(self, node): + LOG.info("Adding {0} node to {1} network".format( + node.name, self.name + )) + # TODO(lobur): take IP from DHCP instead + ip = "{0}.{1}".format(self.address[:-2], + random.randint(CONF.libvirt_net_range_start, + CONF.libvirt_net_range_end)) + self.domain.update( + libvirt.VIR_NETWORK_UPDATE_COMMAND_ADD_LAST, + libvirt.VIR_NETWORK_SECTION_IP_DHCP_HOST, + -1, + ''.format( + mac=node.mac, + name=node.name, + ip=ip)) + node.ip = ip + + def remove_node(self, node): + LOG.info("Removing {0} node from {1} network".format( + node.name, self.name + )) + self.domain.update( + libvirt.VIR_NETWORK_UPDATE_COMMAND_DELETE, + libvirt.VIR_NETWORK_SECTION_IP_DHCP_HOST, + -1, + ''.format( + mac=node.mac, + name=node.name, + ip=node.ip)) + node.ip = None + + def _find_free_libvirt_network(self, head): + existing_nets = [n.XMLDesc() for n in self.libvirt.listAllNetworks()] + for addr in range(254): + pattern = '{0}.{1}'.format(head, addr) + unique = all([pattern not in net_xml for net_xml in existing_nets]) + if unique: + return pattern + raise Exception("Cannot find free libvirt net in {0}".format(head)) diff --git a/ramdisk_func_test/node.py b/ramdisk_func_test/node.py new file mode 100644 index 0000000..9671262 --- /dev/null +++ b/ramdisk_func_test/node.py @@ -0,0 +1,179 @@ +# +# Copyright 2016 Cray Inc., All Rights Reserved +# +# 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 logging +import paramiko +from time import time +from time import sleep +from contextlib import contextmanager +from lxml import etree + +from oslo_config import cfg + +from ramdisk_func_test import utils +from ramdisk_func_test.base import LibvirtBase + +opts = [ + cfg.IntOpt('node_boot_timeout', + help='Time to wait slave node to boot (seconds)', + default=360), + cfg.StrOpt('libvirt_machine_type', + default='', + help='Libvirt machine type (apply if it is not set in ' + 'template)'), + +] +CONF = cfg.CONF +CONF.register_opts(opts) +CONF.import_opt('ramdisk_func_test_workdir', 'ramdisk_func_test.utils') + +LOG = logging.getLogger(__name__) + + +class Node(LibvirtBase): + + def __init__(self, templ_engine, template, network, key): + super(Node, self).__init__(templ_engine) + + self.name = self._generate_name('node') + self.workdir = os.path.join(CONF.ramdisk_func_test_workdir, self.name) + self.network = network + self.mac = utils.get_random_mac() + self.ip = None + self.ssh_login = "root" + self.ssh_key = key + self.console_log = os.path.join(self.workdir, "console.log") + + xml = self.templ_engine.render_template( + template, + mac_addr=self.mac, + network_name=network, + node_name=self.name, + console_log=self.console_log) + + if CONF.libvirt_machine_type: + xml_tree = etree.fromstring(xml) + type_element = xml_tree.find(r'.//os/type') + if 'machine' not in type_element.keys(): + type_element.set('machine', CONF.libvirt_machine_type) + xml = etree.tostring(xml_tree) + + self.domain = self._define_domain(xml) + + def _define_domain(self, xml): + self.libvirt.defineXML(xml) + dom = self.libvirt.lookupByName(self.name) + return dom + + def put_file(self, src, dst): + LOG.info("Putting {0} file to {1} at {2} node".format( + src, dst, self.name + )) + with self._connect_ssh() as ssh: + sftp = ssh.open_sftp() + sftp.put(src, dst) + + def get_file(self, src, dst): + LOG.info("Getting {0} file from {1} node".format( + src, self.name + )) + with self._connect_ssh() as ssh: + sftp = ssh.open_sftp() + sftp.get(src, dst) + + def read_file(self, partition, file, part_type='ext4'): + out, ret_code = self.run_cmd( + 'mount -t {part_type} {partition} /mnt ' + '&& cat /mnt/{file} ' + '&& umount /mnt'.format(**locals())) + return out + + def run_cmd(self, cmd, check_ret_code=False, get_bareon_log=False): + LOG.info("Running '{0}' command on {1} node".format( + cmd, self.name + )) + with self._connect_ssh() as ssh: + (stdin, stdout, stderr) = ssh.exec_command(cmd) + out = stdout.read() + err = stderr.read() + ret_code = stdout.channel.recv_exit_status() + + if err: + LOG.info("{0} cmd {1} stderr below {0}".format("#"*40, cmd)) + LOG.error(err) + LOG.info("{0} end cmd {1} stderr {0}".format("#"*40, cmd)) + + if get_bareon_log: + LOG.info("{0} bareon log below {0}".format("#"*40)) + out, rc = self.run_cmd('cat /var/log/bareon.log') + LOG.info(out) + LOG.info("{0} end bareon log {0}".format("#"*40)) + + if check_ret_code and ret_code: + raise Exception("bareon returned non-zero code: " + "{0}".format(ret_code)) + + return out, ret_code + + def wait_for_boot(self): + LOG.info("Waiting {0} node to boot".format( + self.name)) + utils.wait_net_service(self.ip, 22, timeout=CONF.node_boot_timeout) + + def wait_for_callback(self): + + callback_path = os.path.join(CONF.ramdisk_func_test_workdir, + self.name, 'callback') + timeout = CONF.node_boot_timeout + end = time() + timeout + + while time() < end: + if os.path.exists(callback_path): + LOG.info("Callback from node '{0}' received.".format( + self.name)) + return + sleep(1) + + raise Exception("Timeout expired") + + @contextmanager + def _connect_ssh(self): + try: + ssh = paramiko.SSHClient() + # -oStrictHostKeyChecking=no + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(self.ip, + username=self.ssh_login, + key_filename=self.ssh_key, + look_for_keys=0) + yield ssh + finally: + ssh.close() + + def reboot_to_hdd(self): + xml = self.domain.XMLDesc() + + xml_tree = etree.fromstring(xml) + xml_tree.find(r'.//os/boot').set('dev', 'hd') + + updated_xml = etree.tostring(xml_tree) + + self.domain = self._define_domain(updated_xml) + self.stop() + self.start() + + LOG.info("Boot device for node '{0}' has changed to hdd, node is " + "rebooting.".format(self.name)) diff --git a/ramdisk_func_test/templates/bareon_config.template b/ramdisk_func_test/templates/bareon_config.template new file mode 100644 index 0000000..68ee917 --- /dev/null +++ b/ramdisk_func_test/templates/bareon_config.template @@ -0,0 +1,5 @@ +default deploy +label deploy +kernel {{ kernel }} +append initrd={{ ramdisk }} rootdelay=15 text nofb nomodeset vga=normal deployment_id={{ deployment_id }} api-url={{ api_url }} +ipappend 2 \ No newline at end of file diff --git a/ramdisk_func_test/templates/network.xml b/ramdisk_func_test/templates/network.xml new file mode 100644 index 0000000..87c7f4a --- /dev/null +++ b/ramdisk_func_test/templates/network.xml @@ -0,0 +1,12 @@ + + {{ name }} + + + + + + + + + + diff --git a/ramdisk_func_test/utils.py b/ramdisk_func_test/utils.py new file mode 100644 index 0000000..50e7274 --- /dev/null +++ b/ramdisk_func_test/utils.py @@ -0,0 +1,126 @@ +# +# Copyright 2016 Cray Inc., All Rights Reserved +# +# 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 logging +import shutil +import random +import socket +from time import time +from time import sleep + +from oslo_config import cfg +from subprocess import check_output + +import ConfigParser + + +opts = [ + cfg.StrOpt('ramdisk_func_test_workdir', + help='Path where virtualized node disks will be stored.', + default="/tmp/ramdisk-func-test/"), +] +CONF = cfg.CONF +CONF.register_opts(opts) + +LOG = logging.getLogger(__name__) + + +def ensure_tree(path): + if not os.path.exists(path): + os.makedirs(path) + + +def _build_disk_path(node_name, disk_name): + workdir = CONF.ramdisk_func_test_workdir + node_disks_path = os.path.join(workdir, node_name, "disks") + ensure_tree(node_disks_path) + + path = "{disks}/{disk_name}.img".format(disks=node_disks_path, + disk_name=disk_name) + return path + + +def create_empty_disk(node_name, disk_name, size): + path = _build_disk_path(node_name, disk_name) + cmd = ["/usr/bin/qemu-img", "create", "-f", "raw", path, size] + LOG.info(check_output(cmd)) + return path + + +def create_disk_from_base(node_name, disk_name, base_image_path): + path = _build_disk_path(node_name, disk_name) + shutil.copy(base_image_path, path) + return path + + +def copy_file(source_file, dest_dir): + ensure_tree(dest_dir) + shutil.copy(source_file, dest_dir) + + +def get_random_mac(): + rnd = lambda: random.randint(0, 255) + return "52:54:00:%02x:%02x:%02x" % (rnd(), rnd(), rnd()) + + +def wait_net_service(ip, port, timeout, try_interval=2): + """Wait for network service to appear""" + LOG.info("Waiting for IP {0} port {1} to start".format(ip, port)) + s = socket.socket() + s.settimeout(try_interval) + end = time() + timeout + while time() < end: + try: + s.connect((ip, port)) + except socket.timeout: + # cannot connect after timeout + continue + except socket.error: + # cannot connect immediately (e.g. no route) + # wait timeout before next try + sleep(try_interval) + continue + else: + # success! + s.close() + return + + raise Exception("Timeout expired") + + +class FakeGlobalSectionHead(object): + def __init__(self, fp): + self.fp = fp + self.sechead = '[global]\n' + + def readline(self): + if self.sechead: + try: + return self.sechead + finally: + self.sechead = None + else: + return self.fp.readline() + + +def read_config(path): + cfg = ConfigParser.ConfigParser() + cfg.readfp(FakeGlobalSectionHead(open(path))) + return cfg + + +def _pid_of(name): + return check_output(["pidof", name]).rstrip() diff --git a/ramdisk_func_test/webserver/__init__.py b/ramdisk_func_test/webserver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ramdisk_func_test/webserver/server.py b/ramdisk_func_test/webserver/server.py new file mode 100755 index 0000000..3518535 --- /dev/null +++ b/ramdisk_func_test/webserver/server.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python +# +# Copyright 2016 Cray Inc., All Rights Reserved +# +# 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 SimpleHTTPServer +import SocketServer +import logging +import signal +import sys +import traceback +import re + +from oslo_config import cfg + +from ramdisk_func_test.base import ABS_PATH + + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) +logging.basicConfig(filename='/tmp/mock-web-server.log', + level=logging.DEBUG, + format='%(asctime)s %(message)s', + datefmt='%m/%d/%Y %I:%M:%S %p') + + +class MyRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): + + path_to_images_folder = None + + @classmethod + def _set_path_to_images_folder(cls, path): + cls.path_to_images_folder = path + + def do_GET(self): + + LOG.info("Got GET request: {0} ".format(self.path)) + fake_check = re.match(r'/fake', self.path) + tenant_images_check = re.match(r'/tenant_images/', self.path) + + if fake_check is not None: + LOG.info("This is 'fake' request.") + self.path = os.path.join(ABS_PATH, 'webserver', 'stubfile') + elif tenant_images_check is not None: + LOG.info("This is 'tenant-images' request: {0} ".format(self.path)) + tenant_images_name = re.match( + r'/tenant_images/(.*)', self.path).group(1) + self.path = os.path.join( + self.path_to_images_folder, tenant_images_name) + + return SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self) + + def do_POST(self): + + callback_check = re.search( + r'/v1/nodes/([^/]*)/vendor_passthru', self.path) + + if callback_check is not None: + callback_file_path = os.path.join( + CONF.ramdisk_func_test_workdir, callback_check.group(1), + 'callback') + open(callback_file_path, 'a').close() + LOG.info("Got callback: {0} ".format(self.path)) + + self.path = os.path.join(ABS_PATH, 'webserver', 'stubfile') + return SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self) + + def send_head(self): + """Common code for GET and HEAD commands. + + This sends the response code and MIME headers. + + Return value is either a file object (which has to be copied + to the output file by the caller unless the command was HEAD, + and must be closed by the caller under all circumstances), or + None, in which case the caller has nothing further to do. + + """ + f = None + path = self.path + ctype = self.guess_type(path) + try: + # Always read in binary mode. Opening files in text mode may cause + # newline translations, making the actual size of the content + # transmitted *less* than the content-length! + f = open(path, 'rb') + except IOError: + self.send_error(404, "File not found ({0})".format(path)) + return None + + if self.command == 'POST': + self.send_response(202) + else: + self.send_response(200) + + self.send_header("Content-type", ctype) + fs = os.fstat(f.fileno()) + self.send_header("Content-Length", str(fs[6])) + self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) + self.end_headers() + return f + + +Handler = MyRequestHandler + +httpd = None + + +def signal_term_handler(s, f): + LOG.info("ramdisk-func-test stub web server terminating ...") + try: + httpd.server_close() + except Exception: + LOG.error("Cannot close server!") + sys.exit(1) + LOG.info("ramdisk-func-test stub web server has terminated.") + sys.exit(0) + +signal.signal(signal.SIGTERM, signal_term_handler) + +if __name__ == "__main__": + + try: + host = sys.argv[1] + port = int(sys.argv[2]) + path_to_images_folder = sys.argv[3] + except IndexError: + LOG.error("Mock web-server cannot get enough valid parameters!") + exit(1) + + Handler._set_path_to_images_folder(path_to_images_folder) + + try: + SocketServer.TCPServer.allow_reuse_address = True + httpd = SocketServer.TCPServer((host, port), Handler) + except Exception: + LOG.error("="*80) + LOG.error("Cannot start: {0}".format(traceback.format_exc())) + exit(1) + + LOG.info("="*80) + LOG.info("ramdisk-func-test stub webserver started at {0}:{1} " + "(tenant-images path is '{2}')".format(host, port, + path_to_images_folder)) + + httpd.serve_forever() diff --git a/ramdisk_func_test/webserver/stubfile b/ramdisk_func_test/webserver/stubfile new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/ramdisk_func_test/webserver/stubfile @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..aca4343 --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +# +# Copyright 2016 Cray Inc., All Rights Reserved +# +# 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 setuptools import setup + +setup( + name='ramdisk-func-test', + version='0.1.0', + packages=['ramdisk_func_test'], + classifiers=[ + 'Programming Language :: Python :: 2.7', + ], + install_requires=[ + 'stevedore>=1.3.0,<1.4.0', # Not used. Prevents pip dependency conflict. + # This corresponds to openstack global-requirements.txt + 'oslo.config>=1.9.3,<1.10.0', + 'Jinja2==2.7.3', + 'paramiko', + 'pyyaml', + 'sh', + ], + url='', + license='Apache License, Version 2.0', + author='', + author_email='openstack-dev@lists.openstack.org', + description='A functional testing framework used for ramdisk-based ' + 'deployment tools' +) diff --git a/tools/cleanup.sh b/tools/cleanup.sh new file mode 100644 index 0000000..7f7557e --- /dev/null +++ b/tools/cleanup.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +NODE_PATTERN="rft-node" +NET_PATTERN="rft-net" +WORKDIR="/tmp/ramdisk-func-test" + +for node in $(virsh list --all | awk '{print $2}' | grep $NODE_PATTERN); +do +virsh destroy $node; +virsh undefine $node; +done + +for net in $(virsh net-list --all | awk '{print $1}' | grep $NET_PATTERN); +do +virsh net-destroy $net; +virsh net-undefine $net; +done + +sudo rm -rf $WORKDIR + +# TODO: clean webserver daemons + diff --git a/tools/setup_rsync.py b/tools/setup_rsync.py new file mode 100644 index 0000000..3c3c80d --- /dev/null +++ b/tools/setup_rsync.py @@ -0,0 +1,84 @@ +# Needs to be run from root e.g. sudo python tools/setup_rsync.py +# +# Copyright 2016 Cray Inc., All Rights Reserved +# +# 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 os.path +import distutils.spawn +import ConfigParser +import logging +import subprocess +import shlex + +from ramdisk_func_test import utils + +LOG = logging.getLogger(__name__) + +RSYNC_CFG_PATH = "/etc/rsyncd.conf" +RSYNC_DIR = '/tmp/ironic_rsync' +RSYNC_STUB_FILE = os.path.join(RSYNC_DIR, 'fake') +RSYNC_IRONIC_CONF_SEC = 'ironic_rsync' + + +def ensure_tree(path): + if not os.path.exists(path): + os.makedirs(path) + + +LOG.info("Checking rsync binary...") +rsync_binary_path = distutils.spawn.find_executable('rsync') +if not rsync_binary_path: + raise Exception('Cannot find rsync binary') + +LOG.info("Touching rsync config file ...") +open(RSYNC_CFG_PATH, 'a').close() + +LOG.info("Touching rsync_dir and rsync_stub_filename...") +ensure_tree(RSYNC_DIR) +os.chmod(RSYNC_DIR, 0777) +if not os.path.exists(RSYNC_STUB_FILE): + fake = open(RSYNC_STUB_FILE, 'w') + fake.write('{}') + fake.close() +os.chmod(RSYNC_STUB_FILE, 0777) + +LOG.info("Touching Ironic section in rsync config ...") +rsync_config = ConfigParser.ConfigParser() +rsync_config.read(RSYNC_CFG_PATH) +if RSYNC_IRONIC_CONF_SEC not in rsync_config.sections(): + rsync_config.add_section(RSYNC_IRONIC_CONF_SEC) + rsync_config.set(RSYNC_IRONIC_CONF_SEC, 'uid', 'root') + rsync_config.set(RSYNC_IRONIC_CONF_SEC, 'gid', 'root') + rsync_config.set(RSYNC_IRONIC_CONF_SEC, 'path', RSYNC_DIR) + rsync_config.set(RSYNC_IRONIC_CONF_SEC, 'read_only', 'true') + with open(RSYNC_CFG_PATH, 'wb') as configfile: + rsync_config.write(configfile) + LOG.info("Ironic section has added to rsync config.") +else: + LOG.info("Ironic section is already presenting in rsync config.") + if rsync_config.get(RSYNC_IRONIC_CONF_SEC, 'path') != RSYNC_DIR: + raise Exception('Path in existing ironic.conf section and script ' + 'setting mismatch') + +LOG.info("Starting rsync daemon if not started so far") +if not utils._pid_of('rsync'): + cmd = '{rsync_binary_path} --daemon --no-detach'.format( + rsync_binary_path=rsync_binary_path) + args = shlex.split(cmd) + p = subprocess.Popen(args) +else: + LOG.info("...Rsync process is already running.") + +exit(0) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..c948c3f --- /dev/null +++ b/tox.ini @@ -0,0 +1,24 @@ +[tox] +minversion = 1.6 +skipsdist = True +envlist = pep8 + +[testenv] +usedevelop = True +install_command = pip install --allow-external -U {opts} {packages} +setenv = VIRTUAL_ENV={envdir} + +[testenv:jenkins] +downloadcache = ~/cache/pip + +[testenv:pep8] +deps = hacking==0.10.2 +commands = + flake8 {posargs:ramdisk_func_test} + +[flake8] +ignore = E123,E226,H306 +exclude = .venv,.git,.tox,dist,doc,*egg,build,tools,docs +show-pep8 = True +show-source = True +count = True