Sushy to adhere to the resource identifier portion of the spec

This patch is intended to make the handle of the resources
identification more flexible in the library. Prior to this patch the
library had some assumptions about the path of a given resource and
tried to build such paths within it. Turns out that it's not that
simple, the Redfish specification does allow things like resources from
a different authority to be registered in a different controller [0].
I quote:

"Resources within the same authority as the request URI shall be
represented according to the rules of path-absolute defined by that
specification. That is, they shall always start with a single forward
slash ("/"). Resources within a different authority as the request URI
shall start with a double-slash ("//") followed by the authority and
path to the resource."

That means that, members of a collection could
be either: "/redfish/v1/Systems/12345" or
"//another-service.com/redfish/v1/Systems/12345"

This breaks many of the assumptions we had before of how to handle these
resources from the controller.

So, this patch is basically making sushy more "dumb" about the location
of the resources by not trying to build the URI for it but instead just
relying on the URI returned from the members of a collection or other
attributes of a specific resource. One example here was the "target"
attribute of the #ComputerSystem.Reset reset action, prior to this patch
we needed to strip portions of the URI (the /redfish/v1) in order to fit
the model that we use to construct the URLs now, that's not needed
anymore.

Note that, this patch is non-backward compatible and changes the usage
of the library but, since we haven't had any release yet (so it's not
even alpha or beta, but completely under-development) it seems to be the
right time for such a change.

[0] http://redfish.dmtf.org/schemas/DSP0266_1.1.html#resource-identifier-property

Change-Id: Ia4211ebb3b99f6cc4bd695b5dbea2018d301de33
This commit is contained in:
Lucas Alvares Gomes 2017-03-22 11:55:36 +00:00
parent 39f9db1245
commit 392a4a6272
11 changed files with 100 additions and 88 deletions

View File

@ -17,14 +17,13 @@ To use sushy in a project:
LOG.setLevel(logging.DEBUG)
LOG.addHandler(logging.StreamHandler())
s = sushy.Sushy('http://localhost:8000/redfish/v1',
username='foo', password='bar')
s = sushy.Sushy('http://localhost:8000', username='foo', password='bar')
# Get the Redfish version
print(s.redfish_version)
# Instantiate a system object
sys_inst = s.get_system('437XR1138R2')
sys_inst = s.get_system('/redfish/v1/Systems/437XR1138R2')
# Using system collections
@ -124,8 +123,8 @@ setup it do:
sudo dnf install -y libvirt-devel
That's it, now you can test Sushy against the
``http://locahost:8000/redfish/v1`` endpoint.
That's it, now you can test Sushy against the ``http://locahost:8000``
endpoint.
Enabling SSL
@ -150,6 +149,6 @@ pointing to the certificate file when instantiating Sushy, for example:
import sushy
# Note the HTTP"S"
s = sushy.Sushy('https://localhost:8000/redfish/v1', verify='cert.pem', username='foo', password='bar')
s = sushy.Sushy('https://localhost:8000', verify='cert.pem', username='foo', password='bar')
.. _SSL: https://en.wikipedia.org/wiki/Secure_Sockets_Layer

View File

@ -15,9 +15,9 @@
import json
import logging
import os
import requests
from six.moves.urllib import parse
from sushy import exceptions
@ -58,7 +58,7 @@ class Connector(object):
if data is not None:
data = json.dumps(data)
url = os.path.join(self._url, path)
url = parse.urljoin(self._url, path)
# TODO(lucasagomes): We should mask the data to remove sensitive
# information
LOG.debug('Issuing a HTTP %(method)s request at %(url)s with '

View File

@ -14,6 +14,7 @@
# under the License.
from sushy import connector
from sushy import exceptions
from sushy.resources import base
from sushy.resources.system import system
@ -29,9 +30,29 @@ class Sushy(base.ResourceBase):
uuid = None
"""The Redfish system UUID"""
def __init__(self, url, username=None, password=None, verify=True):
def __init__(self, base_url, username=None, password=None,
root_prefix='/redfish/v1/', verify=True):
"""A class representing a RootService
:param base_url: The base URL to the Redfish controller. It
should include scheme and authority portion of the URL. For
example: https://mgmt.vendor.com
:param username: User account with admin/server-profile access
privilege
:param password: User account password
:param root_prefix: The default URL prefix. This part includes
the root service and version. Defaults to /redfish/v1
:param verify: Either a boolean value, a path to a CA_BUNDLE
file or directory with certificates of trusted CAs. If set to
True the driver will verify the host certificates; if False
the driver will ignore verifying the SSL certificate; if it's
a path the driver will use the specified certificate or one of
the certificates in the directory. Defaults to True.
"""
self._root_prefix = root_prefix
super(Sushy, self).__init__(
connector.Connector(url, username, password, verify))
connector.Connector(base_url, username, password, verify),
path=self._root_prefix)
def _parse_attributes(self):
self.identity = self.json.get('Id')
@ -39,13 +60,24 @@ class Sushy(base.ResourceBase):
self.redfish_version = self.json.get('RedfishVersion')
self.uuid = self.json.get('UUID')
def _get_system_collection_path(self):
"""Helper function to find the SystemCollection path"""
systems_col = self.json.get('Systems')
if not systems_col:
raise exceptions.MissingAttributeError(attribute='Systems',
resource=self._path)
return systems_col.get('@odata.id')
def get_system_collection(self):
"""Get the SystemCollection object
:raises: MissingAttributeError, if the collection attribute is
not found
:returns: a SystemCollection object
"""
return system.SystemCollection(self._conn,
redfish_version=self.redfish_version)
return system.SystemCollection(
self._conn, self._get_system_collection_path(),
redfish_version=self.redfish_version)
def get_system(self, identity):
"""Given the identity return a System object

View File

@ -113,12 +113,12 @@ class ResourceCollectionBase(ResourceBase):
def _parse_attributes(self):
self.name = self.json.get('Name')
self.members_identities = (
utils.get_members_ids(self.json.get('Members', [])))
utils.get_members_identities(self.json.get('Members', [])))
def get_member(self, identity):
"""Given the identity return a ``_resource_type`` object
:param identity: The identity of the ``_resource_type`` resource
:param identity: The identity of the ``_resource_type``
:returns: The ``_resource_type`` object
:raises: ResourceNotFoundError
"""

View File

@ -19,7 +19,6 @@ from sushy import exceptions
from sushy.resources import base
from sushy.resources.system import constants as sys_cons
from sushy.resources.system import mappings as sys_maps
from sushy import utils
LOG = logging.getLogger(__name__)
@ -77,12 +76,11 @@ class System(base.ResourceBase):
"""A class representing a ComputerSystem
:param connector: A Connector instance
:param identity: The id of the ComputerSystem
:param identity: The identity of the System resource
:param redfish_version: The version of RedFish. Used to construct
the object according to schema of the given version.
"""
super(System, self).__init__(connector, 'Systems/%s' % identity,
redfish_version)
super(System, self).__init__(connector, identity, redfish_version)
def _parse_attributes(self):
self.asset_tag = self.json.get('AssetTag')
@ -144,13 +142,13 @@ class System(base.ResourceBase):
def _get_reset_system_path(self):
reset_action = self._get_reset_action_element()
target_url = reset_action.get('target')
if not target_url:
target_uri = reset_action.get('target')
if not target_uri:
raise exceptions.MissingAttributeError(
attribute='Actions/ComputerSystem.Reset/target',
resource=self._path)
return utils.strip_redfish_base(target_url)
return target_uri
def reset_system(self, value):
"""Reset the system.
@ -165,11 +163,11 @@ class System(base.ResourceBase):
parameter='value', value=value, valid_values=valid_resets)
value = sys_maps.RESET_SYSTEM_VALUE_MAP_REV[value]
target_uri = self._get_reset_system_path()
path = self._get_reset_system_path()
# TODO(lucasagomes): Check the return code and response body ?
# Probably we should call refresh() as well.
self._conn.post(path, data={'ResetType': value})
self._conn.post(target_uri, data={'ResetType': value})
def get_allowed_system_boot_source_values(self):
"""Get the allowed values for changing the boot source.
@ -254,12 +252,13 @@ class SystemCollection(base.ResourceCollectionBase):
def _resource_type(self):
return System
def __init__(self, connector, redfish_version=None):
def __init__(self, connector, path, redfish_version=None):
"""A class representing a ComputerSystemCollection
:param connector: A Connector instance
:param path: The canonical path to the System collection resource
:param redfish_version: The version of RedFish. Used to construct
the object according to schema of the given version.
"""
super(SystemCollection, self).__init__(connector, 'Systems',
super(SystemCollection, self).__init__(connector, path,
redfish_version)

View File

@ -31,8 +31,9 @@ class SystemTestCase(base.TestCase):
with open('sushy/tests/unit/json_samples/system.json', 'r') as f:
self.conn.get.return_value.json.return_value = json.loads(f.read())
self.sys_inst = system.System(self.conn, '437XR1138R2',
redfish_version='1.0.2')
self.sys_inst = system.System(
self.conn, '/redfish/v1/Systems/437XR1138R2',
redfish_version='1.0.2')
def test__parse_attributes(self):
self.sys_inst._parse_attributes()
@ -117,7 +118,8 @@ class SystemTestCase(base.TestCase):
def test__get_reset_system_path(self):
value = self.sys_inst._get_reset_system_path()
expected = 'Systems/437XR1138R2/Actions/ComputerSystem.Reset'
expected = (
'/redfish/v1/Systems/437XR1138R2/Actions/ComputerSystem.Reset')
self.assertEqual(expected, value)
@mock.patch.object(system.System, '_get_reset_action_element',
@ -133,7 +135,7 @@ class SystemTestCase(base.TestCase):
def test_reset_system(self):
self.sys_inst.reset_system(sushy.RESET_FORCE_OFF)
self.sys_inst._conn.post.assert_called_once_with(
'Systems/437XR1138R2/Actions/ComputerSystem.Reset',
'/redfish/v1/Systems/437XR1138R2/Actions/ComputerSystem.Reset',
data={'ResetType': 'ForceOff'})
def test_reset_system_invalid_value(self):
@ -191,7 +193,7 @@ class SystemTestCase(base.TestCase):
enabled=sushy.BOOT_SOURCE_ENABLED_CONTINUOUS,
mode=sushy.BOOT_SOURCE_MODE_UEFI)
self.sys_inst._conn.patch.assert_called_once_with(
'Systems/437XR1138R2',
'/redfish/v1/Systems/437XR1138R2',
data={'Boot': {'BootSourceOverrideEnabled': 'Continuous',
'BootSourceOverrideTarget': 'Pxe',
'BootSourceOverrideMode': 'UEFI'}})
@ -201,7 +203,7 @@ class SystemTestCase(base.TestCase):
sushy.BOOT_SOURCE_TARGET_HDD,
enabled=sushy.BOOT_SOURCE_ENABLED_ONCE)
self.sys_inst._conn.patch.assert_called_once_with(
'Systems/437XR1138R2',
'/redfish/v1/Systems/437XR1138R2',
data={'Boot': {'BootSourceOverrideEnabled': 'Once',
'BootSourceOverrideTarget': 'Hdd'}})
@ -225,27 +227,28 @@ class SystemCollectionTestCase(base.TestCase):
with open('sushy/tests/unit/json_samples/'
'system_collection.json', 'r') as f:
self.conn.get.return_value.json.return_value = json.loads(f.read())
self.sys_col = system.SystemCollection(self.conn,
redfish_version='1.0.2')
self.sys_col = system.SystemCollection(
self.conn, '/redfish/v1/Systems', redfish_version='1.0.2')
def test__parse_attributes(self):
self.sys_col._parse_attributes()
self.assertEqual('1.0.2', self.sys_col.redfish_version)
self.assertEqual('Computer System Collection', self.sys_col.name)
self.assertEqual(('437XR1138R2',), self.sys_col.members_identities)
self.assertEqual(('/redfish/v1/Systems/437XR1138R2',),
self.sys_col.members_identities)
@mock.patch.object(system, 'System', autospec=True)
def test_get_member(self, mock_system):
self.sys_col.get_member('437XR1138R2')
self.sys_col.get_member('/redfish/v1/Systems/437XR1138R2')
mock_system.assert_called_once_with(
self.sys_col._conn, '437XR1138R2',
self.sys_col._conn, '/redfish/v1/Systems/437XR1138R2',
redfish_version=self.sys_col.redfish_version)
@mock.patch.object(system, 'System', autospec=True)
def test_get_members(self, mock_system):
members = self.sys_col.get_members()
mock_system.assert_called_once_with(
self.sys_col._conn, '437XR1138R2',
self.sys_col._conn, '/redfish/v1/Systems/437XR1138R2',
redfish_version=self.sys_col.redfish_version)
self.assertIsInstance(members, list)
self.assertEqual(1, len(members))

View File

@ -127,7 +127,7 @@ class ResourceCollectionBaseTestCase(base.TestCase):
def test_get_members(self):
# | GIVEN |
# setting some valid member identities
# setting some valid member paths
member_ids = ('1', '2')
self.test_resource_collection.members_identities = member_ids
# | WHEN |

View File

@ -26,7 +26,7 @@ class ConnectorTestCase(base.TestCase):
def setUp(self):
super(ConnectorTestCase, self).setUp()
self.conn = connector.Connector(
'http://foo.bar:1234/redfish/v1', username='user',
'http://foo.bar:1234', username='user',
password='pass', verify=True)
self.data = {'fake': 'data'}
self.headers = {'X-Fake': 'header'}
@ -58,7 +58,7 @@ class ConnectorTestCase(base.TestCase):
headers=headers)
mock_session.assert_called_once_with()
fake_session.request.assert_called_once_with(
'GET', 'http://foo.bar:1234/redfish/v1/fake/path',
'GET', 'http://foo.bar:1234/fake/path',
data='{"fake": "data"}')
self.assertEqual(expected_headers, fake_session.headers)

View File

@ -18,6 +18,7 @@ import json
import mock
from sushy import connector
from sushy import exceptions
from sushy import main
from sushy.resources.system import system
from sushy.tests.unit import base
@ -31,14 +32,14 @@ class MainTestCase(base.TestCase):
self.conn = mock.Mock()
mock_connector.return_code = self.conn
self.root = main.Sushy(
'http://foo.bar:1234/redfish/v1', username='foo', password='bar',
'http://foo.bar:1234', username='foo', password='bar',
verify=True)
mock_connector.assert_called_once_with(
'http://foo.bar:1234/redfish/v1', 'foo', 'bar', True)
def test__parse_attributes(self):
with open('sushy/tests/unit/json_samples/root.json', 'r') as f:
self.root._json = json.loads(f.read())
mock_connector.assert_called_once_with(
'http://foo.bar:1234', 'foo', 'bar', True)
def test__parse_attributes(self):
self.root._parse_attributes()
self.assertEqual('RootService', self.root.identity)
self.assertEqual('Root Service', self.root.name)
@ -46,11 +47,23 @@ class MainTestCase(base.TestCase):
self.assertEqual('92384634-2938-2342-8820-489239905423',
self.root.uuid)
def test__get_system_collection_path(self):
self.assertEqual(
'/redfish/v1/Systems', self.root._get_system_collection_path())
def test__get_system_collection_path_missing_systems_attr(self):
self.root._json.pop('Systems')
self.assertRaisesRegex(
exceptions.MissingAttributeError,
'The attribute Systems is missing',
self.root._get_system_collection_path)
@mock.patch.object(system, 'SystemCollection', autospec=True)
def test_get_system_collection(self, mock_system_collection):
self.root.get_system_collection()
mock_system_collection.assert_called_once_with(
self.root._conn, redfish_version=self.root.redfish_version)
self.root._conn, '/redfish/v1/Systems',
redfish_version=self.root.redfish_version)
@mock.patch.object(system, 'System', autospec=True)
def test_get_system(self, mock_system):

View File

@ -28,23 +28,10 @@ class UtilsTestCase(base.TestCase):
self.assertEqual(expected, utils.revert_dictionary(source))
@mock.patch.object(utils.LOG, 'warning', autospec=True)
def test_get_members_ids(self, log_mock):
def test_get_members_identities(self, log_mock):
members = [{"@odata.id": "/redfish/v1/Systems/FOO"},
{"other_key": "/redfish/v1/Systems/FUN"},
{"@odata.id": "/redfish/v1/Systems/BAR/"}]
expected = ('FOO', 'BAR')
self.assertEqual(expected, utils.get_members_ids(members))
expected = ('/redfish/v1/Systems/FOO', '/redfish/v1/Systems/BAR')
self.assertEqual(expected, utils.get_members_identities(members))
self.assertEqual(1, log_mock.call_count)
def test_strip_redfish_base(self):
expected = 'Systems/1'
self.assertEqual(expected, utils.strip_redfish_base('Systems/1'))
self.assertEqual(expected, utils.strip_redfish_base('/Systems/1'))
self.assertEqual(expected,
utils.strip_redfish_base('/redfish/v1/Systems/1'))
self.assertEqual(expected,
utils.strip_redfish_base('redfish/v1/Systems/1'))
self.assertEqual(expected,
utils.strip_redfish_base('/redfish/v2/Systems/1'))
self.assertEqual(expected,
utils.strip_redfish_base('redfish/v2/Systems/1'))

View File

@ -14,7 +14,6 @@
# under the License.
import logging
import os
LOG = logging.getLogger(__name__)
@ -29,40 +28,20 @@ def revert_dictionary(dictionary):
return {v: k for k, v in dictionary.items()}
def get_members_ids(members):
def get_members_identities(members):
"""Extract and return a tuple of members identities
:param members: A list of members in JSON format
:returns: A tuple containing the members identities
:returns: A tuple containing the members paths
"""
members_list = []
for member in members:
identity = member.get('@odata.id')
if not identity:
path = member.get('@odata.id')
if not path:
LOG.warning('Could not find the \'@odata.id\' attribute for '
'member %s', member)
continue
members_list.append(os.path.basename(identity.rstrip('/')))
members_list.append(path.rstrip('/'))
return tuple(members_list)
def strip_redfish_base(path):
"""Strip redfish base 'redfish/v1/' from path
:param path: A string of redfish resource path
:returns: path without redfish base 'redfish/v1/'
"""
sub_path = path.lstrip('/')
# To support further redfish version, didn't hardcode to 'redfish/v1'
redfish_base_path = 'redfish/v'
if sub_path.startswith(redfish_base_path):
# Find next occurrence of '/' after redfish base path and strip the
# base path before it
sub_path = sub_path[sub_path.find('/', len(redfish_base_path)) + 1:]
return sub_path