From 57e3ba1af8c6a939cab18d0255a26f6a1eca0d49 Mon Sep 17 00:00:00 2001 From: Yuiko Takada Date: Mon, 7 Dec 2015 11:49:12 +0900 Subject: [PATCH] Migrate Tempest tests into Ironic tree By using Tempest External Plugin, Tempest tests no longer need to live in Tempest tree. This patch set migrates Tempest tests from Tempest tree to Ironic. Change-Id: Ic52806987dae9f9df561ebd662f12c3445d0e2af --- ironic_tempest_plugin/README.rst | 24 +- ironic_tempest_plugin/clients.py | 39 ++ ironic_tempest_plugin/common/__init__.py | 0 ironic_tempest_plugin/common/waiters.py | 48 +++ .../services/baremetal/__init__.py | 0 .../services/baremetal/base.py | 204 ++++++++++ .../services/baremetal/v1/__init__.py | 0 .../services/baremetal/v1/json/__init__.py | 0 .../baremetal/v1/json/baremetal_client.py | 349 ++++++++++++++++++ .../tests/api/admin/__init__.py | 0 ironic_tempest_plugin/tests/api/admin/base.py | 202 ++++++++++ .../tests/api/admin/test_api_discovery.py | 43 +++ .../tests/api/admin/test_chassis.py | 85 +++++ .../tests/api/admin/test_drivers.py | 39 ++ .../tests/api/admin/test_nodes.py | 169 +++++++++ .../tests/api/admin/test_nodestates.py | 59 +++ .../tests/api/admin/test_ports.py | 266 +++++++++++++ .../tests/api/admin/test_ports_negative.py | 339 +++++++++++++++++ .../tests/scenario/baremetal_manager.py | 178 +++++++++ .../scenario/test_baremetal_basic_ops.py | 131 +++++++ 20 files changed, 2171 insertions(+), 4 deletions(-) create mode 100644 ironic_tempest_plugin/clients.py create mode 100644 ironic_tempest_plugin/common/__init__.py create mode 100644 ironic_tempest_plugin/common/waiters.py create mode 100644 ironic_tempest_plugin/services/baremetal/__init__.py create mode 100644 ironic_tempest_plugin/services/baremetal/base.py create mode 100644 ironic_tempest_plugin/services/baremetal/v1/__init__.py create mode 100644 ironic_tempest_plugin/services/baremetal/v1/json/__init__.py create mode 100644 ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py create mode 100644 ironic_tempest_plugin/tests/api/admin/__init__.py create mode 100644 ironic_tempest_plugin/tests/api/admin/base.py create mode 100644 ironic_tempest_plugin/tests/api/admin/test_api_discovery.py create mode 100644 ironic_tempest_plugin/tests/api/admin/test_chassis.py create mode 100644 ironic_tempest_plugin/tests/api/admin/test_drivers.py create mode 100644 ironic_tempest_plugin/tests/api/admin/test_nodes.py create mode 100644 ironic_tempest_plugin/tests/api/admin/test_nodestates.py create mode 100644 ironic_tempest_plugin/tests/api/admin/test_ports.py create mode 100644 ironic_tempest_plugin/tests/api/admin/test_ports_negative.py create mode 100644 ironic_tempest_plugin/tests/scenario/baremetal_manager.py create mode 100644 ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py 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()