diff --git a/ironic_tempest_plugin/README.rst b/ironic_tempest_plugin/README.rst index b6a6cf1b8e..a7fcc92fdc 100644 --- a/ironic_tempest_plugin/README.rst +++ b/ironic_tempest_plugin/README.rst @@ -1,6 +1,22 @@ -=============================================== -Tempest Integration of Ironic -=============================================== +===================== +Ironic tempest plugin +===================== -This directory contains Tempest tests to cover the Ironic project. +This directory contains Tempest tests to cover the Ironic project, +as well as a plugin to automatically load these tests into tempest. +See the tempest plugin docs for information on using it: +http://docs.openstack.org/developer/tempest/plugin.html#using-plugins + +To run all tests from this plugin, install ironic into your environment +and run:: + + $ tox -e all-plugin -- ironic + +To run a single test case, run with the test case name, for example:: + + $ tox -e all-plugin -- ironic_tempest_plugin.tests.scenario.test_baremetal_basic_ops.BaremetalBasicOps.test_baremetal_server_ops + +To run all tempest tests including this plugin, run:: + + $ tox -e all-plugin diff --git a/ironic_tempest_plugin/clients.py b/ironic_tempest_plugin/clients.py new file mode 100644 index 0000000000..70ce1340a6 --- /dev/null +++ b/ironic_tempest_plugin/clients.py @@ -0,0 +1,39 @@ +# 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 tempest import clients +from tempest.common import credentials_factory as common_creds +from tempest import config + +from ironic_tempest_plugin.services.baremetal.v1.json.baremetal_client import \ + BaremetalClient + + +CONF = config.CONF + +ADMIN_CREDS = common_creds.get_configured_credentials('identity_admin') + + +class Manager(clients.Manager): + def __init__(self, + credentials=ADMIN_CREDS, + service=None, + api_microversions=None): + super(Manager, self).__init__(credentials, service) + self.baremetal_client = BaremetalClient( + self.auth_provider, + CONF.baremetal.catalog_type, + CONF.identity.region, + endpoint_type=CONF.baremetal.endpoint_type, + **self.default_params_with_timeout_values) diff --git a/ironic_tempest_plugin/common/__init__.py b/ironic_tempest_plugin/common/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic_tempest_plugin/common/waiters.py b/ironic_tempest_plugin/common/waiters.py new file mode 100644 index 0000000000..1a79150652 --- /dev/null +++ b/ironic_tempest_plugin/common/waiters.py @@ -0,0 +1,48 @@ +# 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 time + +from tempest_lib.common.utils import misc as misc_utils +from tempest_lib import exceptions as lib_exc + + +def wait_for_bm_node_status(client, node_id, attr, status): + """Waits for a baremetal node attribute to reach given status. + + The client should have a show_node(node_uuid) method to get the node. + """ + _, node = client.show_node(node_id) + start = int(time.time()) + + while node[attr] != status: + time.sleep(client.build_interval) + _, node = client.show_node(node_id) + status_curr = node[attr] + if status_curr == status: + return + + if int(time.time()) - start >= client.build_timeout: + message = ('Node %(node_id)s failed to reach %(attr)s=%(status)s ' + 'within the required time (%(timeout)s s).' % + {'node_id': node_id, + 'attr': attr, + 'status': status, + 'timeout': client.build_timeout}) + message += ' Current state of %s: %s.' % (attr, status_curr) + caller = misc_utils.find_test_caller() + if caller: + message = '(%s) %s' % (caller, message) + raise lib_exc.TimeoutException(message) diff --git a/ironic_tempest_plugin/services/baremetal/__init__.py b/ironic_tempest_plugin/services/baremetal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic_tempest_plugin/services/baremetal/base.py b/ironic_tempest_plugin/services/baremetal/base.py new file mode 100644 index 0000000000..c2546d9349 --- /dev/null +++ b/ironic_tempest_plugin/services/baremetal/base.py @@ -0,0 +1,204 @@ +# 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 + +from oslo_serialization import jsonutils as json +import six +from six.moves.urllib import parse as urllib +from tempest_lib.common import rest_client + + +def handle_errors(f): + """A decorator that allows to ignore certain types of errors.""" + + @functools.wraps(f) + def wrapper(*args, **kwargs): + param_name = 'ignore_errors' + ignored_errors = kwargs.get(param_name, tuple()) + + if param_name in kwargs: + del kwargs[param_name] + + try: + return f(*args, **kwargs) + except ignored_errors: + # Silently ignore errors + pass + + return wrapper + + +class BaremetalClient(rest_client.RestClient): + """Base Tempest REST client for Ironic API.""" + + uri_prefix = '' + + def serialize(self, object_dict): + """Serialize an Ironic object.""" + + return json.dumps(object_dict) + + def deserialize(self, object_str): + """Deserialize an Ironic object.""" + + return json.loads(object_str) + + def _get_uri(self, resource_name, uuid=None, permanent=False): + """Get URI for a specific resource or object. + + :param resource_name: The name of the REST resource, e.g., 'nodes'. + :param uuid: The unique identifier of an object in UUID format. + :returns: Relative URI for the resource or object. + + """ + prefix = self.uri_prefix if not permanent else '' + + return '{pref}/{res}{uuid}'.format(pref=prefix, + res=resource_name, + uuid='/%s' % uuid if uuid else '') + + def _make_patch(self, allowed_attributes, **kwargs): + """Create a JSON patch according to RFC 6902. + + :param allowed_attributes: An iterable object that contains a set of + allowed attributes for an object. + :param **kwargs: Attributes and new values for them. + :returns: A JSON path that sets values of the specified attributes to + the new ones. + + """ + def get_change(kwargs, path='/'): + for name, value in six.iteritems(kwargs): + if isinstance(value, dict): + for ch in get_change(value, path + '%s/' % name): + yield ch + else: + if value is None: + yield {'path': path + name, + 'op': 'remove'} + else: + yield {'path': path + name, + 'value': value, + 'op': 'replace'} + + patch = [ch for ch in get_change(kwargs) + if ch['path'].lstrip('/') in allowed_attributes] + + return patch + + def _list_request(self, resource, permanent=False, **kwargs): + """Get the list of objects of the specified type. + + :param resource: The name of the REST resource, e.g., 'nodes'. + :param **kwargs: Parameters for the request. + :returns: A tuple with the server response and deserialized JSON list + of objects + + """ + uri = self._get_uri(resource, permanent=permanent) + if kwargs: + uri += "?%s" % urllib.urlencode(kwargs) + + resp, body = self.get(uri) + self.expected_success(200, resp['status']) + + return resp, self.deserialize(body) + + def _show_request(self, resource, uuid, permanent=False, **kwargs): + """Gets a specific object of the specified type. + + :param uuid: Unique identifier of the object in UUID format. + :returns: Serialized object as a dictionary. + + """ + if 'uri' in kwargs: + uri = kwargs['uri'] + else: + uri = self._get_uri(resource, uuid=uuid, permanent=permanent) + resp, body = self.get(uri) + self.expected_success(200, resp['status']) + + return resp, self.deserialize(body) + + def _create_request(self, resource, object_dict): + """Create an object of the specified type. + + :param resource: The name of the REST resource, e.g., 'nodes'. + :param object_dict: A Python dict that represents an object of the + specified type. + :returns: A tuple with the server response and the deserialized created + object. + + """ + body = self.serialize(object_dict) + uri = self._get_uri(resource) + + resp, body = self.post(uri, body=body) + self.expected_success(201, resp['status']) + + return resp, self.deserialize(body) + + def _delete_request(self, resource, uuid): + """Delete specified object. + + :param resource: The name of the REST resource, e.g., 'nodes'. + :param uuid: The unique identifier of an object in UUID format. + :returns: A tuple with the server response and the response body. + + """ + uri = self._get_uri(resource, uuid) + + resp, body = self.delete(uri) + self.expected_success(204, resp['status']) + return resp, body + + def _patch_request(self, resource, uuid, patch_object): + """Update specified object with JSON-patch. + + :param resource: The name of the REST resource, e.g., 'nodes'. + :param uuid: The unique identifier of an object in UUID format. + :returns: A tuple with the server response and the serialized patched + object. + + """ + uri = self._get_uri(resource, uuid) + patch_body = json.dumps(patch_object) + + resp, body = self.patch(uri, body=patch_body) + self.expected_success(200, resp['status']) + return resp, self.deserialize(body) + + @handle_errors + def get_api_description(self): + """Retrieves all versions of the Ironic API.""" + + return self._list_request('', permanent=True) + + @handle_errors + def get_version_description(self, version='v1'): + """Retrieves the desctription of the API. + + :param version: The version of the API. Default: 'v1'. + :returns: Serialized description of API resources. + + """ + return self._list_request(version, permanent=True) + + def _put_request(self, resource, put_object): + """Update specified object with JSON-patch.""" + uri = self._get_uri(resource) + put_body = json.dumps(put_object) + + resp, body = self.put(uri, body=put_body) + self.expected_success(202, resp['status']) + return resp, body diff --git a/ironic_tempest_plugin/services/baremetal/v1/__init__.py b/ironic_tempest_plugin/services/baremetal/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic_tempest_plugin/services/baremetal/v1/json/__init__.py b/ironic_tempest_plugin/services/baremetal/v1/json/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py new file mode 100644 index 0000000000..cea449a3ee --- /dev/null +++ b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py @@ -0,0 +1,349 @@ +# 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 ironic_tempest_plugin.services.baremetal import base + + +class BaremetalClient(base.BaremetalClient): + """Base Tempest REST client for Ironic API v1.""" + version = '1' + uri_prefix = 'v1' + + @base.handle_errors + def list_nodes(self, **kwargs): + """List all existing nodes.""" + return self._list_request('nodes', **kwargs) + + @base.handle_errors + def list_chassis(self): + """List all existing chassis.""" + return self._list_request('chassis') + + @base.handle_errors + def list_chassis_nodes(self, chassis_uuid): + """List all nodes associated with a chassis.""" + return self._list_request('/chassis/%s/nodes' % chassis_uuid) + + @base.handle_errors + def list_ports(self, **kwargs): + """List all existing ports.""" + return self._list_request('ports', **kwargs) + + @base.handle_errors + def list_node_ports(self, uuid): + """List all ports associated with the node.""" + return self._list_request('/nodes/%s/ports' % uuid) + + @base.handle_errors + def list_nodestates(self, uuid): + """List all existing states.""" + return self._list_request('/nodes/%s/states' % uuid) + + @base.handle_errors + def list_ports_detail(self, **kwargs): + """Details list all existing ports.""" + return self._list_request('/ports/detail', **kwargs) + + @base.handle_errors + def list_drivers(self): + """List all existing drivers.""" + return self._list_request('drivers') + + @base.handle_errors + def show_node(self, uuid): + """Gets a specific node. + + :param uuid: Unique identifier of the node in UUID format. + :return: Serialized node as a dictionary. + + """ + return self._show_request('nodes', uuid) + + @base.handle_errors + def show_node_by_instance_uuid(self, instance_uuid): + """Gets a node associated with given instance uuid. + + :param uuid: Unique identifier of the node in UUID format. + :return: Serialized node as a dictionary. + + """ + uri = '/nodes/detail?instance_uuid=%s' % instance_uuid + + return self._show_request('nodes', + uuid=None, + uri=uri) + + @base.handle_errors + def show_chassis(self, uuid): + """Gets a specific chassis. + + :param uuid: Unique identifier of the chassis in UUID format. + :return: Serialized chassis as a dictionary. + + """ + return self._show_request('chassis', uuid) + + @base.handle_errors + def show_port(self, uuid): + """Gets a specific port. + + :param uuid: Unique identifier of the port in UUID format. + :return: Serialized port as a dictionary. + + """ + return self._show_request('ports', uuid) + + @base.handle_errors + def show_port_by_address(self, address): + """Gets a specific port by address. + + :param address: MAC address of the port. + :return: Serialized port as a dictionary. + + """ + uri = '/ports/detail?address=%s' % address + + return self._show_request('ports', uuid=None, uri=uri) + + def show_driver(self, driver_name): + """Gets a specific driver. + + :param driver_name: Name of driver. + :return: Serialized driver as a dictionary. + """ + return self._show_request('drivers', driver_name) + + @base.handle_errors + def create_node(self, chassis_id=None, **kwargs): + """Create a baremetal node with the specified parameters. + + :param cpu_arch: CPU architecture of the node. Default: x86_64. + :param cpus: Number of CPUs. Default: 8. + :param local_gb: Disk size. Default: 1024. + :param memory_mb: Available RAM. Default: 4096. + :param driver: Driver name. Default: "fake" + :return: A tuple with the server response and the created node. + + """ + node = {'chassis_uuid': chassis_id, + 'properties': {'cpu_arch': kwargs.get('cpu_arch', 'x86_64'), + 'cpus': kwargs.get('cpus', 8), + 'local_gb': kwargs.get('local_gb', 1024), + 'memory_mb': kwargs.get('memory_mb', 4096)}, + 'driver': kwargs.get('driver', 'fake')} + + return self._create_request('nodes', node) + + @base.handle_errors + def create_chassis(self, **kwargs): + """Create a chassis with the specified parameters. + + :param description: The description of the chassis. + Default: test-chassis + :return: A tuple with the server response and the created chassis. + + """ + chassis = {'description': kwargs.get('description', 'test-chassis')} + + return self._create_request('chassis', chassis) + + @base.handle_errors + def create_port(self, node_id, **kwargs): + """Create a port with the specified parameters. + + :param node_id: The ID of the node which owns the port. + :param address: MAC address of the port. + :param extra: Meta data of the port. Default: {'foo': 'bar'}. + :param uuid: UUID of the port. + :return: A tuple with the server response and the created port. + + """ + port = {'extra': kwargs.get('extra', {'foo': 'bar'}), + 'uuid': kwargs['uuid']} + + if node_id is not None: + port['node_uuid'] = node_id + + if kwargs['address'] is not None: + port['address'] = kwargs['address'] + + return self._create_request('ports', port) + + @base.handle_errors + def delete_node(self, uuid): + """Deletes a node having the specified UUID. + + :param uuid: The unique identifier of the node. + :return: A tuple with the server response and the response body. + + """ + return self._delete_request('nodes', uuid) + + @base.handle_errors + def delete_chassis(self, uuid): + """Deletes a chassis having the specified UUID. + + :param uuid: The unique identifier of the chassis. + :return: A tuple with the server response and the response body. + + """ + return self._delete_request('chassis', uuid) + + @base.handle_errors + def delete_port(self, uuid): + """Deletes a port having the specified UUID. + + :param uuid: The unique identifier of the port. + :return: A tuple with the server response and the response body. + + """ + return self._delete_request('ports', uuid) + + @base.handle_errors + def update_node(self, uuid, **kwargs): + """Update the specified node. + + :param uuid: The unique identifier of the node. + :return: A tuple with the server response and the updated node. + + """ + node_attributes = ('properties/cpu_arch', + 'properties/cpus', + 'properties/local_gb', + 'properties/memory_mb', + 'driver', + 'instance_uuid') + + patch = self._make_patch(node_attributes, **kwargs) + + return self._patch_request('nodes', uuid, patch) + + @base.handle_errors + def update_chassis(self, uuid, **kwargs): + """Update the specified chassis. + + :param uuid: The unique identifier of the chassis. + :return: A tuple with the server response and the updated chassis. + + """ + chassis_attributes = ('description',) + patch = self._make_patch(chassis_attributes, **kwargs) + + return self._patch_request('chassis', uuid, patch) + + @base.handle_errors + def update_port(self, uuid, patch): + """Update the specified port. + + :param uuid: The unique identifier of the port. + :param patch: List of dicts representing json patches. + :return: A tuple with the server response and the updated port. + + """ + + return self._patch_request('ports', uuid, patch) + + @base.handle_errors + def set_node_power_state(self, node_uuid, state): + """Set power state of the specified node. + + :param node_uuid: The unique identifier of the node. + :state: desired state to set (on/off/reboot). + + """ + target = {'target': state} + return self._put_request('nodes/%s/states/power' % node_uuid, + target) + + @base.handle_errors + def validate_driver_interface(self, node_uuid): + """Get all driver interfaces of a specific node. + + :param uuid: Unique identifier of the node in UUID format. + + """ + + uri = '{pref}/{res}/{uuid}/{postf}'.format(pref=self.uri_prefix, + res='nodes', + uuid=node_uuid, + postf='validate') + + return self._show_request('nodes', node_uuid, uri=uri) + + @base.handle_errors + def set_node_boot_device(self, node_uuid, boot_device, persistent=False): + """Set the boot device of the specified node. + + :param node_uuid: The unique identifier of the node. + :param boot_device: The boot device name. + :param persistent: Boolean value. True if the boot device will + persist to all future boots, False if not. + Default: False. + + """ + request = {'boot_device': boot_device, 'persistent': persistent} + resp, body = self._put_request('nodes/%s/management/boot_device' % + node_uuid, request) + self.expected_success(204, resp.status) + return body + + @base.handle_errors + def get_node_boot_device(self, node_uuid): + """Get the current boot device of the specified node. + + :param node_uuid: The unique identifier of the node. + + """ + path = 'nodes/%s/management/boot_device' % node_uuid + resp, body = self._list_request(path) + self.expected_success(200, resp.status) + return body + + @base.handle_errors + def get_node_supported_boot_devices(self, node_uuid): + """Get the supported boot devices of the specified node. + + :param node_uuid: The unique identifier of the node. + + """ + path = 'nodes/%s/management/boot_device/supported' % node_uuid + resp, body = self._list_request(path) + self.expected_success(200, resp.status) + return body + + @base.handle_errors + def get_console(self, node_uuid): + """Get connection information about the console. + + :param node_uuid: Unique identifier of the node in UUID format. + + """ + + resp, body = self._show_request('nodes/states/console', node_uuid) + self.expected_success(200, resp.status) + return resp, body + + @base.handle_errors + def set_console_mode(self, node_uuid, enabled): + """Start and stop the node console. + + :param node_uuid: Unique identifier of the node in UUID format. + :param enabled: Boolean value; whether to enable or disable the + console. + + """ + + enabled = {'enabled': enabled} + resp, body = self._put_request('nodes/%s/states/console' % node_uuid, + enabled) + self.expected_success(202, resp.status) + return resp, body diff --git a/ironic_tempest_plugin/tests/api/admin/__init__.py b/ironic_tempest_plugin/tests/api/admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic_tempest_plugin/tests/api/admin/base.py b/ironic_tempest_plugin/tests/api/admin/base.py new file mode 100644 index 0000000000..4b7792f888 --- /dev/null +++ b/ironic_tempest_plugin/tests/api/admin/base.py @@ -0,0 +1,202 @@ +# 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 + +from tempest import config +from tempest import test +from tempest_lib.common.utils import data_utils +from tempest_lib import exceptions as lib_exc + +from ironic_tempest_plugin import clients + +CONF = config.CONF + + +# NOTE(adam_g): The baremetal API tests exercise operations such as enroll +# node, power on, power off, etc. Testing against real drivers (ie, IPMI) +# will require passing driver-specific data to Tempest (addresses, +# credentials, etc). Until then, only support testing against the fake driver, +# which has no external dependencies. +SUPPORTED_DRIVERS = ['fake'] + +# NOTE(jroll): resources must be deleted in a specific order, this list +# defines the resource types to clean up, and the correct order. +RESOURCE_TYPES = ['port', 'node', 'chassis'] + + +def creates(resource): + """Decorator that adds resources to the appropriate cleanup list.""" + + def decorator(f): + @functools.wraps(f) + def wrapper(cls, *args, **kwargs): + resp, body = f(cls, *args, **kwargs) + + if 'uuid' in body: + cls.created_objects[resource].add(body['uuid']) + + return resp, body + return wrapper + return decorator + + +class BaseBaremetalTest(test.BaseTestCase): + """Base class for Baremetal API tests.""" + + credentials = ['admin'] + + @classmethod + def skip_checks(cls): + super(BaseBaremetalTest, cls).skip_checks() + if CONF.baremetal.driver not in SUPPORTED_DRIVERS: + skip_msg = ('%s skipped as Ironic driver %s is not supported for ' + 'testing.' % + (cls.__name__, CONF.baremetal.driver)) + raise cls.skipException(skip_msg) + + @classmethod + def setup_clients(cls): + super(BaseBaremetalTest, cls).setup_clients() + cls.client = clients.Manager().baremetal_client + + @classmethod + def resource_setup(cls): + super(BaseBaremetalTest, cls).resource_setup() + + cls.driver = CONF.baremetal.driver + cls.power_timeout = CONF.baremetal.power_timeout + cls.created_objects = {} + for resource in RESOURCE_TYPES: + cls.created_objects[resource] = set() + + @classmethod + def resource_cleanup(cls): + """Ensure that all created objects get destroyed.""" + + try: + for resource in RESOURCE_TYPES: + uuids = cls.created_objects[resource] + delete_method = getattr(cls.client, 'delete_%s' % resource) + for u in uuids: + delete_method(u, ignore_errors=lib_exc.NotFound) + finally: + super(BaseBaremetalTest, cls).resource_cleanup() + + @classmethod + @creates('chassis') + def create_chassis(cls, description=None, expect_errors=False): + """Wrapper utility for creating test chassis. + + :param description: A description of the chassis. if not supplied, + a random value will be generated. + :return: Created chassis. + + """ + description = description or data_utils.rand_name('test-chassis') + resp, body = cls.client.create_chassis(description=description) + return resp, body + + @classmethod + @creates('node') + def create_node(cls, chassis_id, cpu_arch='x86', cpus=8, local_gb=10, + memory_mb=4096): + """Wrapper utility for creating test baremetal nodes. + + :param cpu_arch: CPU architecture of the node. Default: x86. + :param cpus: Number of CPUs. Default: 8. + :param local_gb: Disk size. Default: 10. + :param memory_mb: Available RAM. Default: 4096. + :return: Created node. + + """ + resp, body = cls.client.create_node(chassis_id, cpu_arch=cpu_arch, + cpus=cpus, local_gb=local_gb, + memory_mb=memory_mb, + driver=cls.driver) + + return resp, body + + @classmethod + @creates('port') + def create_port(cls, node_id, address, extra=None, uuid=None): + """Wrapper utility for creating test ports. + + :param address: MAC address of the port. + :param extra: Meta data of the port. If not supplied, an empty + dictionary will be created. + :param uuid: UUID of the port. + :return: Created port. + + """ + extra = extra or {} + resp, body = cls.client.create_port(address=address, node_id=node_id, + extra=extra, uuid=uuid) + + return resp, body + + @classmethod + def delete_chassis(cls, chassis_id): + """Deletes a chassis having the specified UUID. + + :param uuid: The unique identifier of the chassis. + :return: Server response. + + """ + + resp, body = cls.client.delete_chassis(chassis_id) + + if chassis_id in cls.created_objects['chassis']: + cls.created_objects['chassis'].remove(chassis_id) + + return resp + + @classmethod + def delete_node(cls, node_id): + """Deletes a node having the specified UUID. + + :param uuid: The unique identifier of the node. + :return: Server response. + + """ + + resp, body = cls.client.delete_node(node_id) + + if node_id in cls.created_objects['node']: + cls.created_objects['node'].remove(node_id) + + return resp + + @classmethod + def delete_port(cls, port_id): + """Deletes a port having the specified UUID. + + :param uuid: The unique identifier of the port. + :return: Server response. + + """ + + resp, body = cls.client.delete_port(port_id) + + if port_id in cls.created_objects['port']: + cls.created_objects['port'].remove(port_id) + + return resp + + def validate_self_link(self, resource, uuid, link): + """Check whether the given self link formatted correctly.""" + expected_link = "{base}/{pref}/{res}/{uuid}".format( + base=self.client.base_url, + pref=self.client.uri_prefix, + res=resource, + uuid=uuid) + self.assertEqual(expected_link, link) diff --git a/ironic_tempest_plugin/tests/api/admin/test_api_discovery.py b/ironic_tempest_plugin/tests/api/admin/test_api_discovery.py new file mode 100644 index 0000000000..bc8b6928ba --- /dev/null +++ b/ironic_tempest_plugin/tests/api/admin/test_api_discovery.py @@ -0,0 +1,43 @@ +# 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 tempest import test + +from ironic_tempest_plugin.tests.api.admin import base + + +class TestApiDiscovery(base.BaseBaremetalTest): + """Tests for API discovery features.""" + + @test.idempotent_id('a3c27e94-f56c-42c4-8600-d6790650b9c5') + def test_api_versions(self): + _, descr = self.client.get_api_description() + expected_versions = ('v1',) + versions = [version['id'] for version in descr['versions']] + + for v in expected_versions: + self.assertIn(v, versions) + + @test.idempotent_id('896283a6-488e-4f31-af78-6614286cbe0d') + def test_default_version(self): + _, descr = self.client.get_api_description() + default_version = descr['default_version'] + self.assertEqual(default_version['id'], 'v1') + + @test.idempotent_id('abc0b34d-e684-4546-9728-ab7a9ad9f174') + def test_version_1_resources(self): + _, descr = self.client.get_version_description(version='v1') + expected_resources = ('nodes', 'chassis', + 'ports', 'links', 'media_types') + + for res in expected_resources: + self.assertIn(res, descr) diff --git a/ironic_tempest_plugin/tests/api/admin/test_chassis.py b/ironic_tempest_plugin/tests/api/admin/test_chassis.py new file mode 100644 index 0000000000..edc872fde5 --- /dev/null +++ b/ironic_tempest_plugin/tests/api/admin/test_chassis.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# 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 six +from tempest import test +from tempest_lib.common.utils import data_utils +from tempest_lib import exceptions as lib_exc + +from ironic_tempest_plugin.tests.api.admin import base + + +class TestChassis(base.BaseBaremetalTest): + """Tests for chassis.""" + + @classmethod + def resource_setup(cls): + super(TestChassis, cls).resource_setup() + _, cls.chassis = cls.create_chassis() + + def _assertExpected(self, expected, actual): + # Check if not expected keys/values exists in actual response body + for key, value in six.iteritems(expected): + if key not in ('created_at', 'updated_at'): + self.assertIn(key, actual) + self.assertEqual(value, actual[key]) + + @test.idempotent_id('7c5a2e09-699c-44be-89ed-2bc189992d42') + def test_create_chassis(self): + descr = data_utils.rand_name('test-chassis') + _, chassis = self.create_chassis(description=descr) + self.assertEqual(chassis['description'], descr) + + @test.idempotent_id('cabe9c6f-dc16-41a7-b6b9-0a90c212edd5') + def test_create_chassis_unicode_description(self): + # Use a unicode string for testing: + # 'We ♡ OpenStack in Ukraine' + descr = u'В Україні ♡ OpenStack!' + _, chassis = self.create_chassis(description=descr) + self.assertEqual(chassis['description'], descr) + + @test.idempotent_id('c84644df-31c4-49db-a307-8942881f41c0') + def test_show_chassis(self): + _, chassis = self.client.show_chassis(self.chassis['uuid']) + self._assertExpected(self.chassis, chassis) + + @test.idempotent_id('29c9cd3f-19b5-417b-9864-99512c3b33b3') + def test_list_chassis(self): + _, body = self.client.list_chassis() + self.assertIn(self.chassis['uuid'], + [i['uuid'] for i in body['chassis']]) + + @test.idempotent_id('5ae649ad-22d1-4fe1-bbc6-97227d199fb3') + def test_delete_chassis(self): + _, body = self.create_chassis() + uuid = body['uuid'] + + self.delete_chassis(uuid) + self.assertRaises(lib_exc.NotFound, self.client.show_chassis, uuid) + + @test.idempotent_id('cda8a41f-6be2-4cbf-840c-994b00a89b44') + def test_update_chassis(self): + _, body = self.create_chassis() + uuid = body['uuid'] + + new_description = data_utils.rand_name('new-description') + _, body = (self.client.update_chassis(uuid, + description=new_description)) + _, chassis = self.client.show_chassis(uuid) + self.assertEqual(chassis['description'], new_description) + + @test.idempotent_id('76305e22-a4e2-4ab3-855c-f4e2368b9335') + def test_chassis_node_list(self): + _, node = self.create_node(self.chassis['uuid']) + _, body = self.client.list_chassis_nodes(self.chassis['uuid']) + self.assertIn(node['uuid'], [n['uuid'] for n in body['nodes']]) diff --git a/ironic_tempest_plugin/tests/api/admin/test_drivers.py b/ironic_tempest_plugin/tests/api/admin/test_drivers.py new file mode 100644 index 0000000000..c9319b6691 --- /dev/null +++ b/ironic_tempest_plugin/tests/api/admin/test_drivers.py @@ -0,0 +1,39 @@ +# Copyright 2014 NEC Corporation. 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 tempest import config +from tempest import test + +from ironic_tempest_plugin.tests.api.admin import base + +CONF = config.CONF + + +class TestDrivers(base.BaseBaremetalTest): + """Tests for drivers.""" + @classmethod + def resource_setup(cls): + super(TestDrivers, cls).resource_setup() + cls.driver_name = CONF.baremetal.driver + + @test.idempotent_id('5aed2790-7592-4655-9b16-99abcc2e6ec5') + def test_list_drivers(self): + _, drivers = self.client.list_drivers() + self.assertIn(self.driver_name, + [d['name'] for d in drivers['drivers']]) + + @test.idempotent_id('fb3287a3-c4d7-44bf-ae9d-1eef906d78ce') + def test_show_driver(self): + _, driver = self.client.show_driver(self.driver_name) + self.assertEqual(self.driver_name, driver['name']) diff --git a/ironic_tempest_plugin/tests/api/admin/test_nodes.py b/ironic_tempest_plugin/tests/api/admin/test_nodes.py new file mode 100644 index 0000000000..7f896ad440 --- /dev/null +++ b/ironic_tempest_plugin/tests/api/admin/test_nodes.py @@ -0,0 +1,169 @@ +# 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 six +from tempest import test +from tempest_lib.common.utils import data_utils +from tempest_lib import exceptions as lib_exc + +from ironic_tempest_plugin.common import waiters +from ironic_tempest_plugin.tests.api.admin import base + + +class TestNodes(base.BaseBaremetalTest): + """Tests for baremetal nodes.""" + + def setUp(self): + super(TestNodes, self).setUp() + + _, self.chassis = self.create_chassis() + _, self.node = self.create_node(self.chassis['uuid']) + + def _assertExpected(self, expected, actual): + # Check if not expected keys/values exists in actual response body + for key, value in six.iteritems(expected): + if key not in ('created_at', 'updated_at'): + self.assertIn(key, actual) + self.assertEqual(value, actual[key]) + + def _associate_node_with_instance(self): + self.client.set_node_power_state(self.node['uuid'], 'power off') + waiters.wait_for_bm_node_status(self.client, self.node['uuid'], + 'power_state', 'power off') + instance_uuid = data_utils.rand_uuid() + self.client.update_node(self.node['uuid'], + instance_uuid=instance_uuid) + self.addCleanup(self.client.update_node, + uuid=self.node['uuid'], instance_uuid=None) + return instance_uuid + + @test.idempotent_id('4e939eb2-8a69-4e84-8652-6fffcbc9db8f') + def test_create_node(self): + params = {'cpu_arch': 'x86_64', + 'cpus': '12', + 'local_gb': '10', + 'memory_mb': '1024'} + + _, body = self.create_node(self.chassis['uuid'], **params) + self._assertExpected(params, body['properties']) + + @test.idempotent_id('9ade60a4-505e-4259-9ec4-71352cbbaf47') + def test_delete_node(self): + _, node = self.create_node(self.chassis['uuid']) + + self.delete_node(node['uuid']) + + self.assertRaises(lib_exc.NotFound, self.client.show_node, + node['uuid']) + + @test.idempotent_id('55451300-057c-4ecf-8255-ba42a83d3a03') + def test_show_node(self): + _, loaded_node = self.client.show_node(self.node['uuid']) + self._assertExpected(self.node, loaded_node) + + @test.idempotent_id('4ca123c4-160d-4d8d-a3f7-15feda812263') + def test_list_nodes(self): + _, body = self.client.list_nodes() + self.assertIn(self.node['uuid'], + [i['uuid'] for i in body['nodes']]) + + @test.idempotent_id('85b1f6e0-57fd-424c-aeff-c3422920556f') + def test_list_nodes_association(self): + _, body = self.client.list_nodes(associated=True) + self.assertNotIn(self.node['uuid'], + [n['uuid'] for n in body['nodes']]) + + self._associate_node_with_instance() + + _, body = self.client.list_nodes(associated=True) + self.assertIn(self.node['uuid'], [n['uuid'] for n in body['nodes']]) + + _, body = self.client.list_nodes(associated=False) + self.assertNotIn(self.node['uuid'], [n['uuid'] for n in body['nodes']]) + + @test.idempotent_id('18c4ebd8-f83a-4df7-9653-9fb33a329730') + def test_node_port_list(self): + _, port = self.create_port(self.node['uuid'], + data_utils.rand_mac_address()) + _, body = self.client.list_node_ports(self.node['uuid']) + self.assertIn(port['uuid'], + [p['uuid'] for p in body['ports']]) + + @test.idempotent_id('72591acb-f215-49db-8395-710d14eb86ab') + def test_node_port_list_no_ports(self): + _, node = self.create_node(self.chassis['uuid']) + _, body = self.client.list_node_ports(node['uuid']) + self.assertEmpty(body['ports']) + + @test.idempotent_id('4fed270a-677a-4d19-be87-fd38ae490320') + def test_update_node(self): + props = {'cpu_arch': 'x86_64', + 'cpus': '12', + 'local_gb': '10', + 'memory_mb': '128'} + + _, node = self.create_node(self.chassis['uuid'], **props) + + new_p = {'cpu_arch': 'x86', + 'cpus': '1', + 'local_gb': '10000', + 'memory_mb': '12300'} + + _, body = self.client.update_node(node['uuid'], properties=new_p) + _, node = self.client.show_node(node['uuid']) + self._assertExpected(new_p, node['properties']) + + @test.idempotent_id('cbf1f515-5f4b-4e49-945c-86bcaccfeb1d') + def test_validate_driver_interface(self): + _, body = self.client.validate_driver_interface(self.node['uuid']) + core_interfaces = ['power', 'deploy'] + for interface in core_interfaces: + self.assertIn(interface, body) + + @test.idempotent_id('5519371c-26a2-46e9-aa1a-f74226e9d71f') + def test_set_node_boot_device(self): + self.client.set_node_boot_device(self.node['uuid'], 'pxe') + + @test.idempotent_id('9ea73775-f578-40b9-bc34-efc639c4f21f') + def test_get_node_boot_device(self): + body = self.client.get_node_boot_device(self.node['uuid']) + self.assertIn('boot_device', body) + self.assertIn('persistent', body) + self.assertTrue(isinstance(body['boot_device'], six.string_types)) + self.assertTrue(isinstance(body['persistent'], bool)) + + @test.idempotent_id('3622bc6f-3589-4bc2-89f3-50419c66b133') + def test_get_node_supported_boot_devices(self): + body = self.client.get_node_supported_boot_devices(self.node['uuid']) + self.assertIn('supported_boot_devices', body) + self.assertTrue(isinstance(body['supported_boot_devices'], list)) + + @test.idempotent_id('f63b6288-1137-4426-8cfe-0d5b7eb87c06') + def test_get_console(self): + _, body = self.client.get_console(self.node['uuid']) + con_info = ['console_enabled', 'console_info'] + for key in con_info: + self.assertIn(key, body) + + @test.idempotent_id('80504575-9b21-4670-92d1-143b948f9437') + def test_set_console_mode(self): + self.client.set_console_mode(self.node['uuid'], True) + + _, body = self.client.get_console(self.node['uuid']) + self.assertEqual(True, body['console_enabled']) + + @test.idempotent_id('b02a4f38-5e8b-44b2-aed2-a69a36ecfd69') + def test_get_node_by_instance_uuid(self): + instance_uuid = self._associate_node_with_instance() + _, body = self.client.show_node_by_instance_uuid(instance_uuid) + self.assertEqual(len(body['nodes']), 1) + self.assertIn(self.node['uuid'], [n['uuid'] for n in body['nodes']]) diff --git a/ironic_tempest_plugin/tests/api/admin/test_nodestates.py b/ironic_tempest_plugin/tests/api/admin/test_nodestates.py new file mode 100644 index 0000000000..7cd03f016d --- /dev/null +++ b/ironic_tempest_plugin/tests/api/admin/test_nodestates.py @@ -0,0 +1,59 @@ +# Copyright 2014 NEC Corporation. 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 oslo_utils import timeutils +from tempest import test +from tempest_lib import exceptions + +from ironic_tempest_plugin.tests.api.admin import base + + +class TestNodeStates(base.BaseBaremetalTest): + """Tests for baremetal NodeStates.""" + + @classmethod + def resource_setup(cls): + super(TestNodeStates, cls).resource_setup() + _, cls.chassis = cls.create_chassis() + _, cls.node = cls.create_node(cls.chassis['uuid']) + + def _validate_power_state(self, node_uuid, power_state): + # Validate that power state is set within timeout + if power_state == 'rebooting': + power_state = 'power on' + start = timeutils.utcnow() + while timeutils.delta_seconds( + start, timeutils.utcnow()) < self.power_timeout: + _, node = self.client.show_node(node_uuid) + if node['power_state'] == power_state: + return + message = ('Failed to set power state within ' + 'the required time: %s sec.' % self.power_timeout) + raise exceptions.TimeoutException(message) + + @test.idempotent_id('cd8afa5e-3f57-4e43-8185-beb83d3c9015') + def test_list_nodestates(self): + _, nodestates = self.client.list_nodestates(self.node['uuid']) + for key in nodestates: + self.assertEqual(nodestates[key], self.node[key]) + + @test.idempotent_id('fc5b9320-0c98-4e5a-8848-877fe5a0322c') + def test_set_node_power_state(self): + _, node = self.create_node(self.chassis['uuid']) + states = ["power on", "rebooting", "power off"] + for state in states: + # Set power state + self.client.set_node_power_state(node['uuid'], state) + # Check power state after state is set + self._validate_power_state(node['uuid'], state) diff --git a/ironic_tempest_plugin/tests/api/admin/test_ports.py b/ironic_tempest_plugin/tests/api/admin/test_ports.py new file mode 100644 index 0000000000..6ec7966489 --- /dev/null +++ b/ironic_tempest_plugin/tests/api/admin/test_ports.py @@ -0,0 +1,266 @@ +# 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 six +from tempest import test +from tempest_lib.common.utils import data_utils +from tempest_lib import exceptions as lib_exc + +from ironic_tempest_plugin.tests.api.admin import base + + +class TestPorts(base.BaseBaremetalTest): + """Tests for ports.""" + + def setUp(self): + super(TestPorts, self).setUp() + + _, self.chassis = self.create_chassis() + _, self.node = self.create_node(self.chassis['uuid']) + _, self.port = self.create_port(self.node['uuid'], + data_utils.rand_mac_address()) + + def _assertExpected(self, expected, actual): + # Check if not expected keys/values exists in actual response body + for key, value in six.iteritems(expected): + if key not in ('created_at', 'updated_at'): + self.assertIn(key, actual) + self.assertEqual(value, actual[key]) + + @test.idempotent_id('83975898-2e50-42ed-b5f0-e510e36a0b56') + def test_create_port(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + _, port = self.create_port(node_id=node_id, address=address) + + _, body = self.client.show_port(port['uuid']) + + self._assertExpected(port, body) + + @test.idempotent_id('d1f6b249-4cf6-4fe6-9ed6-a6e84b1bf67b') + def test_create_port_specifying_uuid(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + uuid = data_utils.rand_uuid() + + _, port = self.create_port(node_id=node_id, + address=address, uuid=uuid) + + _, body = self.client.show_port(uuid) + self._assertExpected(port, body) + + @test.idempotent_id('4a02c4b0-6573-42a4-a513-2e36ad485b62') + def test_create_port_with_extra(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + extra = {'str': 'value', 'int': 123, 'float': 0.123, + 'bool': True, 'list': [1, 2, 3], 'dict': {'foo': 'bar'}} + + _, port = self.create_port(node_id=node_id, address=address, + extra=extra) + + _, body = self.client.show_port(port['uuid']) + self._assertExpected(port, body) + + @test.idempotent_id('1bf257a9-aea3-494e-89c0-63f657ab4fdd') + def test_delete_port(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + _, port = self.create_port(node_id=node_id, address=address) + + self.delete_port(port['uuid']) + + self.assertRaises(lib_exc.NotFound, self.client.show_port, + port['uuid']) + + @test.idempotent_id('9fa77ab5-ce59-4f05-baac-148904ba1597') + def test_show_port(self): + _, port = self.client.show_port(self.port['uuid']) + self._assertExpected(self.port, port) + + @test.idempotent_id('7c1114ff-fc3f-47bb-bc2f-68f61620ba8b') + def test_show_port_by_address(self): + _, port = self.client.show_port_by_address(self.port['address']) + self._assertExpected(self.port, port['ports'][0]) + + @test.idempotent_id('bd773405-aea5-465d-b576-0ab1780069e5') + def test_show_port_with_links(self): + _, port = self.client.show_port(self.port['uuid']) + self.assertIn('links', port.keys()) + self.assertEqual(2, len(port['links'])) + self.assertIn(port['uuid'], port['links'][0]['href']) + + @test.idempotent_id('b5e91854-5cd7-4a8e-bb35-3e0a1314606d') + def test_list_ports(self): + _, body = self.client.list_ports() + self.assertIn(self.port['uuid'], + [i['uuid'] for i in body['ports']]) + # Verify self links. + for port in body['ports']: + self.validate_self_link('ports', port['uuid'], + port['links'][0]['href']) + + @test.idempotent_id('324a910e-2f80-4258-9087-062b5ae06240') + def test_list_with_limit(self): + _, body = self.client.list_ports(limit=3) + + next_marker = body['ports'][-1]['uuid'] + self.assertIn(next_marker, body['next']) + + @test.idempotent_id('8a94b50f-9895-4a63-a574-7ecff86e5875') + def test_list_ports_details(self): + node_id = self.node['uuid'] + + uuids = [ + self.create_port(node_id=node_id, + address=data_utils.rand_mac_address()) + [1]['uuid'] for i in range(0, 5)] + + _, body = self.client.list_ports_detail() + + ports_dict = dict((port['uuid'], port) for port in body['ports'] + if port['uuid'] in uuids) + + for uuid in uuids: + self.assertIn(uuid, ports_dict) + port = ports_dict[uuid] + self.assertIn('extra', port) + self.assertIn('node_uuid', port) + # never expose the node_id + self.assertNotIn('node_id', port) + # Verify self link. + self.validate_self_link('ports', port['uuid'], + port['links'][0]['href']) + + @test.idempotent_id('8a03f688-7d75-4ecd-8cbc-e06b8f346738') + def test_list_ports_details_with_address(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + self.create_port(node_id=node_id, address=address) + for i in range(0, 5): + self.create_port(node_id=node_id, + address=data_utils.rand_mac_address()) + + _, body = self.client.list_ports_detail(address=address) + self.assertEqual(1, len(body['ports'])) + self.assertEqual(address, body['ports'][0]['address']) + + @test.idempotent_id('9c26298b-1bcb-47b7-9b9e-8bdd6e3c4aba') + def test_update_port_replace(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + extra = {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'} + + _, port = self.create_port(node_id=node_id, address=address, + extra=extra) + + new_address = data_utils.rand_mac_address() + new_extra = {'key1': 'new-value1', 'key2': 'new-value2', + 'key3': 'new-value3'} + + patch = [{'path': '/address', + 'op': 'replace', + 'value': new_address}, + {'path': '/extra/key1', + 'op': 'replace', + 'value': new_extra['key1']}, + {'path': '/extra/key2', + 'op': 'replace', + 'value': new_extra['key2']}, + {'path': '/extra/key3', + 'op': 'replace', + 'value': new_extra['key3']}] + + self.client.update_port(port['uuid'], patch) + + _, body = self.client.show_port(port['uuid']) + self.assertEqual(new_address, body['address']) + self.assertEqual(new_extra, body['extra']) + + @test.idempotent_id('d7e7fece-6ed9-460a-9ebe-9267217e8580') + def test_update_port_remove(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + extra = {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'} + + _, port = self.create_port(node_id=node_id, address=address, + extra=extra) + + # Removing one item from the collection + self.client.update_port(port['uuid'], + [{'path': '/extra/key2', + 'op': 'remove'}]) + extra.pop('key2') + _, body = self.client.show_port(port['uuid']) + self.assertEqual(extra, body['extra']) + + # Removing the collection + self.client.update_port(port['uuid'], [{'path': '/extra', + 'op': 'remove'}]) + _, body = self.client.show_port(port['uuid']) + self.assertEqual({}, body['extra']) + + # Assert nothing else was changed + self.assertEqual(node_id, body['node_uuid']) + self.assertEqual(address, body['address']) + + @test.idempotent_id('241288b3-e98a-400f-a4d7-d1f716146361') + def test_update_port_add(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + _, port = self.create_port(node_id=node_id, address=address) + + extra = {'key1': 'value1', 'key2': 'value2'} + + patch = [{'path': '/extra/key1', + 'op': 'add', + 'value': extra['key1']}, + {'path': '/extra/key2', + 'op': 'add', + 'value': extra['key2']}] + + self.client.update_port(port['uuid'], patch) + + _, body = self.client.show_port(port['uuid']) + self.assertEqual(extra, body['extra']) + + @test.idempotent_id('5309e897-0799-4649-a982-0179b04c3876') + def test_update_port_mixed_ops(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + extra = {'key1': 'value1', 'key2': 'value2'} + + _, port = self.create_port(node_id=node_id, address=address, + extra=extra) + + new_address = data_utils.rand_mac_address() + new_extra = {'key1': 0.123, 'key3': {'cat': 'meow'}} + + patch = [{'path': '/address', + 'op': 'replace', + 'value': new_address}, + {'path': '/extra/key1', + 'op': 'replace', + 'value': new_extra['key1']}, + {'path': '/extra/key2', + 'op': 'remove'}, + {'path': '/extra/key3', + 'op': 'add', + 'value': new_extra['key3']}] + + self.client.update_port(port['uuid'], patch) + + _, body = self.client.show_port(port['uuid']) + self.assertEqual(new_address, body['address']) + self.assertEqual(new_extra, body['extra']) diff --git a/ironic_tempest_plugin/tests/api/admin/test_ports_negative.py b/ironic_tempest_plugin/tests/api/admin/test_ports_negative.py new file mode 100644 index 0000000000..850deae11c --- /dev/null +++ b/ironic_tempest_plugin/tests/api/admin/test_ports_negative.py @@ -0,0 +1,339 @@ +# 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 tempest import test +from tempest_lib.common.utils import data_utils +from tempest_lib import exceptions as lib_exc + +from ironic_tempest_plugin.tests.api.admin import base + + +class TestPortsNegative(base.BaseBaremetalTest): + """Negative tests for ports.""" + + def setUp(self): + super(TestPortsNegative, self).setUp() + + _, self.chassis = self.create_chassis() + _, self.node = self.create_node(self.chassis['uuid']) + + @test.attr(type=['negative']) + @test.idempotent_id('0a6ee1f7-d0d9-4069-8778-37f3aa07303a') + def test_create_port_malformed_mac(self): + node_id = self.node['uuid'] + address = 'malformed:mac' + + self.assertRaises(lib_exc.BadRequest, + self.create_port, node_id=node_id, address=address) + + @test.attr(type=['negative']) + @test.idempotent_id('30277ee8-0c60-4f1d-b125-0e51c2f43369') + def test_create_port_nonexsistent_node_id(self): + node_id = str(data_utils.rand_uuid()) + address = data_utils.rand_mac_address() + self.assertRaises(lib_exc.BadRequest, self.create_port, + node_id=node_id, address=address) + + @test.attr(type=['negative']) + @test.idempotent_id('029190f6-43e1-40a3-b64a-65173ba653a3') + def test_show_port_malformed_uuid(self): + self.assertRaises(lib_exc.BadRequest, self.client.show_port, + 'malformed:uuid') + + @test.attr(type=['negative']) + @test.idempotent_id('0d00e13d-e2e0-45b1-bcbc-55a6d90ca793') + def test_show_port_nonexistent_uuid(self): + self.assertRaises(lib_exc.NotFound, self.client.show_port, + data_utils.rand_uuid()) + + @test.attr(type=['negative']) + @test.idempotent_id('4ad85266-31e9-4942-99ac-751897dc9e23') + def test_show_port_by_mac_not_allowed(self): + self.assertRaises(lib_exc.BadRequest, self.client.show_port, + data_utils.rand_mac_address()) + + @test.attr(type=['negative']) + @test.idempotent_id('89a34380-3c61-4c32-955c-2cd9ce94da21') + def test_create_port_duplicated_port_uuid(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + uuid = data_utils.rand_uuid() + + self.create_port(node_id=node_id, address=address, uuid=uuid) + self.assertRaises(lib_exc.Conflict, self.create_port, node_id=node_id, + address=address, uuid=uuid) + + @test.attr(type=['negative']) + @test.idempotent_id('65e84917-733c-40ae-ae4b-96a4adff931c') + def test_create_port_no_mandatory_field_node_id(self): + address = data_utils.rand_mac_address() + + self.assertRaises(lib_exc.BadRequest, self.create_port, node_id=None, + address=address) + + @test.attr(type=['negative']) + @test.idempotent_id('bcea3476-7033-4183-acfe-e56a30809b46') + def test_create_port_no_mandatory_field_mac(self): + node_id = self.node['uuid'] + + self.assertRaises(lib_exc.BadRequest, self.create_port, + node_id=node_id, address=None) + + @test.attr(type=['negative']) + @test.idempotent_id('2b51cd18-fb95-458b-9780-e6257787b649') + def test_create_port_malformed_port_uuid(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + uuid = 'malformed:uuid' + + self.assertRaises(lib_exc.BadRequest, self.create_port, + node_id=node_id, address=address, uuid=uuid) + + @test.attr(type=['negative']) + @test.idempotent_id('583a6856-6a30-4ac4-889f-14e2adff8105') + def test_create_port_malformed_node_id(self): + address = data_utils.rand_mac_address() + self.assertRaises(lib_exc.BadRequest, self.create_port, + node_id='malformed:nodeid', address=address) + + @test.attr(type=['negative']) + @test.idempotent_id('e27f8b2e-42c6-4a43-a3cd-accff716bc5c') + def test_create_port_duplicated_mac(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + self.create_port(node_id=node_id, address=address) + self.assertRaises(lib_exc.Conflict, + self.create_port, node_id=node_id, + address=address) + + @test.attr(type=['negative']) + @test.idempotent_id('8907082d-ac5e-4be3-b05f-d072ede82020') + def test_update_port_by_mac_not_allowed(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + extra = {'key': 'value'} + + self.create_port(node_id=node_id, address=address, extra=extra) + + patch = [{'path': '/extra/key', + 'op': 'replace', + 'value': 'new-value'}] + + self.assertRaises(lib_exc.BadRequest, + self.client.update_port, address, + patch) + + @test.attr(type=['negative']) + @test.idempotent_id('df1ac70c-db9f-41d9-90f1-78cd6b905718') + def test_update_port_nonexistent(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + extra = {'key': 'value'} + + _, port = self.create_port(node_id=node_id, address=address, + extra=extra) + port_id = port['uuid'] + + _, body = self.client.delete_port(port_id) + + patch = [{'path': '/extra/key', + 'op': 'replace', + 'value': 'new-value'}] + self.assertRaises(lib_exc.NotFound, + self.client.update_port, port_id, patch) + + @test.attr(type=['negative']) + @test.idempotent_id('c701e315-aa52-41ea-817c-65c5ca8ca2a8') + def test_update_port_malformed_port_uuid(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + self.create_port(node_id=node_id, address=address) + + new_address = data_utils.rand_mac_address() + self.assertRaises(lib_exc.BadRequest, self.client.update_port, + uuid='malformed:uuid', + patch=[{'path': '/address', 'op': 'replace', + 'value': new_address}]) + + @test.attr(type=['negative']) + @test.idempotent_id('f8f15803-34d6-45dc-b06f-e5e04bf1b38b') + def test_update_port_add_nonexistent_property(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + _, port = self.create_port(node_id=node_id, address=address) + port_id = port['uuid'] + + self.assertRaises(lib_exc.BadRequest, self.client.update_port, port_id, + [{'path': '/nonexistent', ' op': 'add', + 'value': 'value'}]) + + @test.attr(type=['negative']) + @test.idempotent_id('898ec904-38b1-4fcb-9584-1187d4263a2a') + def test_update_port_replace_node_id_with_malformed(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + _, port = self.create_port(node_id=node_id, address=address) + port_id = port['uuid'] + + patch = [{'path': '/node_uuid', + 'op': 'replace', + 'value': 'malformed:node_uuid'}] + self.assertRaises(lib_exc.BadRequest, + self.client.update_port, port_id, patch) + + @test.attr(type=['negative']) + @test.idempotent_id('2949f30f-5f59-43fa-a6d9-4eac578afab4') + def test_update_port_replace_mac_with_duplicated(self): + node_id = self.node['uuid'] + address1 = data_utils.rand_mac_address() + address2 = data_utils.rand_mac_address() + + _, port1 = self.create_port(node_id=node_id, address=address1) + + _, port2 = self.create_port(node_id=node_id, address=address2) + port_id = port2['uuid'] + + patch = [{'path': '/address', + 'op': 'replace', + 'value': address1}] + self.assertRaises(lib_exc.Conflict, + self.client.update_port, port_id, patch) + + @test.attr(type=['negative']) + @test.idempotent_id('97f6e048-6e4f-4eba-a09d-fbbc78b77a77') + def test_update_port_replace_node_id_with_nonexistent(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + _, port = self.create_port(node_id=node_id, address=address) + port_id = port['uuid'] + + patch = [{'path': '/node_uuid', + 'op': 'replace', + 'value': data_utils.rand_uuid()}] + self.assertRaises(lib_exc.BadRequest, + self.client.update_port, port_id, patch) + + @test.attr(type=['negative']) + @test.idempotent_id('375022c5-9e9e-4b11-9ca4-656729c0c9b2') + def test_update_port_replace_mac_with_malformed(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + _, port = self.create_port(node_id=node_id, address=address) + port_id = port['uuid'] + + patch = [{'path': '/address', + 'op': 'replace', + 'value': 'malformed:mac'}] + + self.assertRaises(lib_exc.BadRequest, + self.client.update_port, port_id, patch) + + @test.attr(type=['negative']) + @test.idempotent_id('5722b853-03fc-4854-8308-2036a1b67d85') + def test_update_port_replace_nonexistent_property(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + _, port = self.create_port(node_id=node_id, address=address) + port_id = port['uuid'] + + patch = [{'path': '/nonexistent', ' op': 'replace', 'value': 'value'}] + + self.assertRaises(lib_exc.BadRequest, + self.client.update_port, port_id, patch) + + @test.attr(type=['negative']) + @test.idempotent_id('ae2696ca-930a-4a7f-918f-30ae97c60f56') + def test_update_port_remove_mandatory_field_mac(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + _, port = self.create_port(node_id=node_id, address=address) + port_id = port['uuid'] + + self.assertRaises(lib_exc.BadRequest, self.client.update_port, port_id, + [{'path': '/address', 'op': 'remove'}]) + + @test.attr(type=['negative']) + @test.idempotent_id('5392c1f0-2071-4697-9064-ec2d63019018') + def test_update_port_remove_mandatory_field_port_uuid(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + _, port = self.create_port(node_id=node_id, address=address) + port_id = port['uuid'] + + self.assertRaises(lib_exc.BadRequest, self.client.update_port, port_id, + [{'path': '/uuid', 'op': 'remove'}]) + + @test.attr(type=['negative']) + @test.idempotent_id('06b50d82-802a-47ef-b079-0a3311cf85a2') + def test_update_port_remove_nonexistent_property(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + _, port = self.create_port(node_id=node_id, address=address) + port_id = port['uuid'] + + self.assertRaises(lib_exc.BadRequest, self.client.update_port, port_id, + [{'path': '/nonexistent', 'op': 'remove'}]) + + @test.attr(type=['negative']) + @test.idempotent_id('03d42391-2145-4a6c-95bf-63fe55eb64fd') + def test_delete_port_by_mac_not_allowed(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + + self.create_port(node_id=node_id, address=address) + self.assertRaises(lib_exc.BadRequest, self.client.delete_port, address) + + @test.attr(type=['negative']) + @test.idempotent_id('0629e002-818e-4763-b25b-ae5e07b1cb23') + def test_update_port_mixed_ops_integrity(self): + node_id = self.node['uuid'] + address = data_utils.rand_mac_address() + extra = {'key1': 'value1', 'key2': 'value2'} + + _, port = self.create_port(node_id=node_id, address=address, + extra=extra) + port_id = port['uuid'] + + new_address = data_utils.rand_mac_address() + new_extra = {'key1': 'new-value1', 'key3': 'new-value3'} + + patch = [{'path': '/address', + 'op': 'replace', + 'value': new_address}, + {'path': '/extra/key1', + 'op': 'replace', + 'value': new_extra['key1']}, + {'path': '/extra/key2', + 'op': 'remove'}, + {'path': '/extra/key3', + 'op': 'add', + 'value': new_extra['key3']}, + {'path': '/nonexistent', + 'op': 'replace', + 'value': 'value'}] + + self.assertRaises(lib_exc.BadRequest, self.client.update_port, port_id, + patch) + + # patch should not be applied + _, body = self.client.show_port(port_id) + self.assertEqual(address, body['address']) + self.assertEqual(extra, body['extra']) diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py new file mode 100644 index 0000000000..3a03356a16 --- /dev/null +++ b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py @@ -0,0 +1,178 @@ +# Copyright 2012 OpenStack Foundation +# Copyright 2013 IBM Corp. +# 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 tempest.common import waiters +from tempest import config +from tempest.scenario import manager # noqa +import tempest.test +from tempest_lib import exceptions as lib_exc + +from ironic_tempest_plugin import clients + +CONF = config.CONF + + +# power/provision states as of icehouse +class BaremetalPowerStates(object): + """Possible power states of an Ironic node.""" + POWER_ON = 'power on' + POWER_OFF = 'power off' + REBOOT = 'rebooting' + SUSPEND = 'suspended' + + +class BaremetalProvisionStates(object): + """Possible provision states of an Ironic node.""" + NOSTATE = None + INIT = 'initializing' + ACTIVE = 'active' + BUILDING = 'building' + DEPLOYWAIT = 'wait call-back' + DEPLOYING = 'deploying' + DEPLOYFAIL = 'deploy failed' + DEPLOYDONE = 'deploy complete' + DELETING = 'deleting' + DELETED = 'deleted' + ERROR = 'error' + + +class BaremetalScenarioTest(manager.ScenarioTest): + + credentials = ['primary', 'admin'] + + @classmethod + def skip_checks(cls): + super(BaremetalScenarioTest, cls).skip_checks() + if not CONF.baremetal.driver_enabled: + msg = 'Ironic not available or Ironic compute driver not enabled' + raise cls.skipException(msg) + + @classmethod + def setup_clients(cls): + super(BaremetalScenarioTest, cls).setup_clients() + + cls.baremetal_client = clients.Manager().baremetal_client + + @classmethod + def resource_setup(cls): + super(BaremetalScenarioTest, cls).resource_setup() + # allow any issues obtaining the node list to raise early + cls.baremetal_client.list_nodes() + + def _node_state_timeout(self, node_id, state_attr, + target_states, timeout=10, interval=1): + if not isinstance(target_states, list): + target_states = [target_states] + + def check_state(): + node = self.get_node(node_id=node_id) + if node.get(state_attr) in target_states: + return True + return False + + if not tempest.test.call_until_true(check_state, timeout, interval): + msg = ("Timed out waiting for node %s to reach %s state(s) %s" % + (node_id, state_attr, target_states)) + raise lib_exc.TimeoutException(msg) + + def wait_provisioning_state(self, node_id, state, timeout): + self._node_state_timeout( + node_id=node_id, state_attr='provision_state', + target_states=state, timeout=timeout) + + def wait_power_state(self, node_id, state): + self._node_state_timeout( + node_id=node_id, state_attr='power_state', + target_states=state, timeout=CONF.baremetal.power_timeout) + + def wait_node(self, instance_id): + """Waits for a node to be associated with instance_id.""" + + def _get_node(): + node = None + try: + node = self.get_node(instance_id=instance_id) + except lib_exc.NotFound: + pass + return node is not None + + if (not tempest.test.call_until_true( + _get_node, CONF.baremetal.association_timeout, 1)): + msg = ('Timed out waiting to get Ironic node by instance id %s' + % instance_id) + raise lib_exc.TimeoutException(msg) + + def get_node(self, node_id=None, instance_id=None): + if node_id: + _, body = self.baremetal_client.show_node(node_id) + return body + elif instance_id: + _, body = self.baremetal_client.show_node_by_instance_uuid( + instance_id) + if body['nodes']: + return body['nodes'][0] + + def get_ports(self, node_uuid): + ports = [] + _, body = self.baremetal_client.list_node_ports(node_uuid) + for port in body['ports']: + _, p = self.baremetal_client.show_port(port['uuid']) + ports.append(p) + return ports + + def add_keypair(self): + self.keypair = self.create_keypair() + + def verify_connectivity(self, ip=None): + if ip: + dest = self.get_remote_client(ip) + else: + dest = self.get_remote_client(self.instance) + dest.validate_authentication() + + def boot_instance(self): + self.instance = self.create_server( + key_name=self.keypair['name']) + + self.wait_node(self.instance['id']) + self.node = self.get_node(instance_id=self.instance['id']) + + self.wait_power_state(self.node['uuid'], BaremetalPowerStates.POWER_ON) + + self.wait_provisioning_state( + self.node['uuid'], + [BaremetalProvisionStates.DEPLOYWAIT, + BaremetalProvisionStates.ACTIVE], + timeout=15) + + self.wait_provisioning_state(self.node['uuid'], + BaremetalProvisionStates.ACTIVE, + timeout=CONF.baremetal.active_timeout) + + waiters.wait_for_server_status(self.servers_client, + self.instance['id'], 'ACTIVE') + self.node = self.get_node(instance_id=self.instance['id']) + self.instance = (self.servers_client.show_server(self.instance['id']) + ['server']) + + def terminate_instance(self): + self.servers_client.delete_server(self.instance['id']) + self.wait_power_state(self.node['uuid'], + BaremetalPowerStates.POWER_OFF) + self.wait_provisioning_state( + self.node['uuid'], + BaremetalProvisionStates.NOSTATE, + timeout=CONF.baremetal.unprovision_timeout) diff --git a/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py b/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py new file mode 100644 index 0000000000..fcefd37919 --- /dev/null +++ b/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py @@ -0,0 +1,131 @@ +# +# Copyright 2014 Hewlett-Packard Development Company, L.P. +# +# 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 oslo_log import log as logging +from tempest.common import waiters +from tempest import config +from tempest import test + +from ironic_tempest_plugin.tests.scenario import baremetal_manager + +CONF = config.CONF + +LOG = logging.getLogger(__name__) + + +class BaremetalBasicOps(baremetal_manager.BaremetalScenarioTest): + """This smoke test tests the pxe_ssh Ironic driver. + + It follows this basic set of operations: + * Creates a keypair + * Boots an instance using the keypair + * Monitors the associated Ironic node for power and + expected state transitions + * Validates Ironic node's port data has been properly updated + * Verifies SSH connectivity using created keypair via fixed IP + * Associates a floating ip + * Verifies SSH connectivity using created keypair via floating IP + * Verifies instance rebuild with ephemeral partition preservation + * Deletes instance + * Monitors the associated Ironic node for power and + expected state transitions + """ + def rebuild_instance(self, preserve_ephemeral=False): + self.rebuild_server(server_id=self.instance['id'], + preserve_ephemeral=preserve_ephemeral, + wait=False) + + node = self.get_node(instance_id=self.instance['id']) + + # We should remain on the same node + self.assertEqual(self.node['uuid'], node['uuid']) + self.node = node + + waiters.wait_for_server_status( + self.servers_client, + server_id=self.instance['id'], + status='REBUILD', + ready_wait=False) + waiters.wait_for_server_status( + self.servers_client, + server_id=self.instance['id'], + status='ACTIVE') + + def verify_partition(self, client, label, mount, gib_size): + """Verify a labeled partition's mount point and size.""" + LOG.info("Looking for partition %s mounted on %s" % (label, mount)) + + # Validate we have a device with the given partition label + cmd = "/sbin/blkid | grep '%s' | cut -d':' -f1" % label + device = client.exec_command(cmd).rstrip('\n') + LOG.debug("Partition device is %s" % device) + self.assertNotEqual('', device) + + # Validate the mount point for the device + cmd = "mount | grep '%s' | cut -d' ' -f3" % device + actual_mount = client.exec_command(cmd).rstrip('\n') + LOG.debug("Partition mount point is %s" % actual_mount) + self.assertEqual(actual_mount, mount) + + # Validate the partition size matches what we expect + numbers = '0123456789' + devnum = device.replace('/dev/', '') + cmd = "cat /sys/block/%s/%s/size" % (devnum.rstrip(numbers), devnum) + num_bytes = client.exec_command(cmd).rstrip('\n') + num_bytes = int(num_bytes) * 512 + actual_gib_size = num_bytes / (1024 * 1024 * 1024) + LOG.debug("Partition size is %d GiB" % actual_gib_size) + self.assertEqual(actual_gib_size, gib_size) + + def get_flavor_ephemeral_size(self): + """Returns size of the ephemeral partition in GiB.""" + f_id = self.instance['flavor']['id'] + flavor = self.flavors_client.show_flavor(f_id)['flavor'] + ephemeral = flavor.get('OS-FLV-EXT-DATA:ephemeral') + if not ephemeral or ephemeral == 'N/A': + return None + return int(ephemeral) + + def validate_ports(self): + for port in self.get_ports(self.node['uuid']): + n_port_id = port['extra']['vif_port_id'] + body = self.ports_client.show_port(n_port_id) + n_port = body['port'] + self.assertEqual(n_port['device_id'], self.instance['id']) + self.assertEqual(n_port['mac_address'], port['address']) + + @test.idempotent_id('549173a5-38ec-42bb-b0e2-c8b9f4a08943') + @test.services('baremetal', 'compute', 'image', 'network') + def test_baremetal_server_ops(self): + self.add_keypair() + self.boot_instance() + self.validate_ports() + self.verify_connectivity() + if CONF.validation.connect_method == 'floating': + floating_ip = self.create_floating_ip(self.instance)['ip'] + self.verify_connectivity(ip=floating_ip) + + vm_client = self.get_remote_client(self.instance) + + # We expect the ephemeral partition to be mounted on /mnt and to have + # the same size as our flavor definition. + eph_size = self.get_flavor_ephemeral_size() + if eph_size: + self.verify_partition(vm_client, 'ephemeral0', '/mnt', eph_size) + # Create the test file + self.create_timestamp( + floating_ip, private_key=self.keypair['private_key']) + + self.terminate_instance()