Initial commit

Implements: blueprint bareon-functional-testing

Change-Id: Ibf8a1f858e155871f8957669c085481373d26680
This commit is contained in:
Mark Goddard 2015-08-03 18:20:45 -04:00 committed by Oleksandr Berezovskyi
parent 5ca6f8647c
commit 60fca54634
19 changed files with 1385 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -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

176
LICENSE Normal file
View File

@ -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.

9
README.rst Normal file
View File

@ -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

View File

@ -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

View File

125
ramdisk_func_test/base.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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,
'<host mac="{mac}" name="{name}" ip="{ip}" />'.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,
'<host mac="{mac}" name="{name}" ip="{ip}" />'.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))

179
ramdisk_func_test/node.py Normal file
View File

@ -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))

View File

@ -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

View File

@ -0,0 +1,12 @@
<network>
<name>{{ name }}</name>
<forward mode='nat'/>
<bridge name='{{ bridge }}' stp='on' delay='0' />
<ip address='{{ address }}' netmask='255.255.255.0'>
<tftp root='{{ tftp_root }}' />
<dhcp>
<range start="{{ range_start }}" end="{{ range_end }}" />
<bootp file='pxelinux.0' />
</dhcp>
</ip>
</network>

126
ramdisk_func_test/utils.py Normal file
View File

@ -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()

View File

View File

@ -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()

View File

@ -0,0 +1 @@
{}

40
setup.py Normal file
View File

@ -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'
)

22
tools/cleanup.sh Normal file
View File

@ -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

84
tools/setup_rsync.py Normal file
View File

@ -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)

24
tox.ini Normal file
View File

@ -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