From 2a70e6e765a35097b4b3c111d387acb170103d53 Mon Sep 17 00:00:00 2001 From: Devananda van der Veen Date: Sat, 4 Apr 2015 08:44:22 -0700 Subject: [PATCH] Refactoring and adding Types Some big changes here: Rename connection.py to server.py Refactor about half of server.py into a new types.py module which builds classes for each resource type, and auto-builds links to fetch sub-resources from each type. Add examples/walk-chassis.py to demonstrate how to use the Root and Chassis classes to walk all the objects returned from /rest/v1/chassis/ Import oslo_log and start using it (more to do here, it's not working quite yet). --- examples/simple.py | 5 +- examples/walk-chassis.py | 58 ++++++++ redfish/__init__.py | 3 + redfish/exception.py | 6 +- redfish/{connection.py => server.py} | 125 ++++++----------- redfish/tests/test_redfish.py | 29 ++-- redfish/types.py | 197 +++++++++++++++++++++++++++ requirements.txt | 1 + 8 files changed, 322 insertions(+), 102 deletions(-) create mode 100644 examples/walk-chassis.py rename redfish/{connection.py => server.py} (78%) create mode 100644 redfish/types.py diff --git a/examples/simple.py b/examples/simple.py index 3d1efa3..91d34bd 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -1,3 +1,6 @@ from redfish import connection -server = connection \ No newline at end of file +host = '127.0.0.1' +user_name = 'Admin' +password = 'password' +server = connection.RedfishConnection(host, user_name, password) \ No newline at end of file diff --git a/examples/walk-chassis.py b/examples/walk-chassis.py new file mode 100644 index 0000000..900cfe6 --- /dev/null +++ b/examples/walk-chassis.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python + +#import logging +import sys +from oslo_config import cfg +from oslo_log import log as logging + +import redfish + +# Sets up basic logging for this module +#log_root = logging.getLogger('redfish') +#log_root.addHandler(logging.StreamHandler(sys.stdout)) +#log_root.setLevel(logging.DEBUG) + +CONF = cfg.CONF +logging.set_defaults(['redfish=DEBUG']) +logging.register_options(CONF) +#logging.setup(CONF, "redfish") + +# Connect to a redfish API endpoint +host = 'http://localhost' +user_name = '' +password = '' + +# This returns a RedfishConnection object, which implements +# the low-level HTTP methods like GET, PUT, etc +connection = redfish.server.connect(host, user_name, password) + +# From this connection, we can get the Root resource. +# Note that the root resource is somewhat special - you create it from +# the connection, but you create other resources from the root resource. +# (You don't strictly have to do this, but it's simpler.) +root = connection.get_root() + +print("\n") +print("ROOT CONTROLLER") +print("===============") +print(root) + + +# The Root class has well-defined top-level resources, such as +# chassis, systems, managers, sessions, etc... +chassis = root.get_chassis() + +print("\n") +print("CHASSIS DATA") +print("============") +print(chassis) +print("\n") +print("WALKING CHASSIS") +print("\n") +print("CHASSIS contains %d items" % len(chassis)) +print("\n") +for item in chassis: + print("SYSTEM") + print("======") + print(item) + print("\n") diff --git a/redfish/__init__.py b/redfish/__init__.py index 7290f2b..625d02f 100644 --- a/redfish/__init__.py +++ b/redfish/__init__.py @@ -14,6 +14,9 @@ import pbr.version +import redfish.server +import redfish.types + __version__ = pbr.version.VersionInfo( 'redfish').version_string() diff --git a/redfish/exception.py b/redfish/exception.py index 99644c7..b8a00f5 100644 --- a/redfish/exception.py +++ b/redfish/exception.py @@ -24,4 +24,8 @@ class RedfishException(Exception): except Excetion as e: LOG.exception('Error in string format operation') message = self.message - super(RedfishException, self).__init__(message) \ No newline at end of file + super(RedfishException, self).__init__(message) + + +class ObjectLoadException(RedfishException): + pass diff --git a/redfish/connection.py b/redfish/server.py similarity index 78% rename from redfish/connection.py rename to redfish/server.py index 3fe0b88..62f33f0 100644 --- a/redfish/connection.py +++ b/redfish/server.py @@ -120,17 +120,23 @@ import gzip import hashlib import httplib import json -import logging import ssl import StringIO import sys import urllib2 from urlparse import urlparse -from redfish import exception +from oslo_log import log as logging -LOG = logging.getLogger(__name__) -LOG.setLevel(logging.DEBUG) +from redfish import exception +from redfish import types + + +LOG = logging.getLogger('redfish') + + +def connect(host, user, password): + return RedfishConnection(host, user, password) class RedfishConnection(object): @@ -146,10 +152,15 @@ class RedfishConnection(object): self.auth_token = auth_token self.enforce_SSL = enforce_SSL + # context for the last status and header returned from a call + self.status = None + self.headers = None + # If the http schema wasn't specified, default to HTTPS if host[0:4] != 'http': host = 'https://' + host self.host = host + self._connect() if not self.auth_token: @@ -158,7 +169,7 @@ class RedfishConnection(object): # what we should do here. LOG.debug('Initiating session with host %s', self.host) auth_dict = {'Password': self.password, 'UserName': self.user_name} - (status, headers, response) = self.rest_post( + response = self.rest_post( '/rest/v1/Sessions', None, json.dumps(auth_dict)) # TODO: do some schema discovery here and cache the result @@ -200,10 +211,14 @@ class RedfishConnection(object): :param request_headers: optional dict of headers :param request_body: optional JSON body """ - + # ensure trailing slash + if suburi[-1:] != '/': + suburi = suburi + '/' url = urlparse(self.host + suburi) - if not isinstance(request_headers, dict): request_headers = dict() + if not isinstance(request_headers, dict): + request_headers = dict() + request_headers['Content-Type'] = 'application/json' # if X-Auth-Token specified, supply it instead of basic auth if self.auth_token is not None: @@ -253,7 +268,9 @@ class RedfishConnection(object): 'Failed to parse response as a JSON document, ' 'received "%s".' % body) - return resp.status, headers, response + self.status = resp.status + self.headers = headers + return response def rest_get(self, suburi, request_headers): """REST GET @@ -261,8 +278,6 @@ class RedfishConnection(object): :param: suburi :param: request_headers """ - if not isinstance(request_headers, dict): - request_headers = dict() # NOTE: be prepared for various HTTP responses including 500, 404, etc return self._op('GET', suburi, request_headers, None) @@ -276,9 +291,6 @@ class RedfishConnection(object): redfish does not follow IETF JSONPATCH standard https://tools.ietf.org/html/rfc6902 """ - if not isinstance(request_headers, dict): - request_headers = dict() - request_headers['Content-Type'] = 'application/json' # NOTE: be prepared for various HTTP responses including 500, 404, 202 return self._op('PATCH', suburi, request_headers, request_body) @@ -289,9 +301,6 @@ class RedfishConnection(object): :param: request_headers :param: request_body """ - if not isinstance(request_headers, dict): - request_headers = dict() - request_headers['Content-Type'] = 'application/json' # NOTE: be prepared for various HTTP responses including 500, 404, 202 return self._op('PUT', suburi, request_headers, request_body) @@ -302,9 +311,6 @@ class RedfishConnection(object): :param: request_headers :param: request_body """ - if not isinstance(request_headers, dict): - request_headers = dict() - request_headers['Content-Type'] = 'application/json' # NOTE: don't assume any newly created resource is included in the # # response. Only the Location header matters. # the response body may be the new resource, it may be an @@ -317,80 +323,27 @@ class RedfishConnection(object): :param: suburi :param: request_headers """ - if not isinstance(request_headers, dict): - request_headers = dict() # NOTE: be prepared for various HTTP responses including 500, 404 # NOTE: response may be an ExtendedError or may be empty return self._op('DELETE', suburi, request_headers, None) - # this is a generator that returns collection members - def collection(self, collection_uri, request_headers): - """ - collections are of two tupes: - - array of things that are fully expanded (details) - - array of URLs (links) - """ - # get the collection - status, headers, thecollection = self.rest_get( - collection_uri, request_headers) + def get_root(self): + return types.Root(self.rest_get('/rest/v1', {}), connection=self) - # TODO: commment this - while status < 300: - # verify expected type - # NOTE: Because of the Redfish standards effort, we have versioned - # many things at 0 in anticipation of them being ratified for - # version 1 at some point. So this code makes the (unguarranteed) - # assumption throughout that version 0 and 1 are both legitimate at - # this point. Don't write code requiring version 0 as we will bump - # to version 1 at some point. +class Version(object): + def __init__(self, string): + try: + buf = string.split('.') + if len(buf) < 2: + raise AttributeError + except AttributeError: + raise RedfishException(message="Failed to parse version string") + self.major = int(buf[0]) + self.minor = int(buf[1]) - # hint: don't limit to version 0 here as we will rev to 1.0 at - # some point hopefully with minimal changes - assert(get_type(thecollection) == 'Collection.0' or - get_type(thecollection) == 'Collection.1') - - # if this collection has inline items, return those - - # NOTE: Collections are very flexible in how the represent - # members. They can be inline in the collection as members of the - # 'Items' array, or they may be href links in the links/Members - # array. The could actually be both. We have to render it with - # the href links when an array contains PATCHable items because its - # complex to PATCH inline collection members. A client may wish - # to pass in a boolean flag favoring the href links vs. the Items in - # case a collection contains both. - - if 'Items' in thecollection: - # iterate items - for item in thecollection['Items']: - # if the item has a self uri pointer, supply that for convenience - memberuri = None - if 'links' in item and 'self' in item['links']: - memberuri = item['links']['self']['href'] - - # Read up on Python generator functions to understand what this does. - yield 200, None, item, memberuri - - # else walk the member links - elif 'links' in thecollection and 'Member' in thecollection['links']: - # iterate members - for memberuri in thecollection['links']['Member']: - # for each member return the resource indicated by the member link - status, headers, member = rest_get( - host, memberuri['href'], request_headers, user_name, password) - - # Read up on Python generator functions to understand what this does. - yield status, headers, member, memberuri['href'] - - # page forward if there are more pages in the collection - if 'links' in thecollection and 'NextPage' in thecollection['links']: - next_link_uri = collection_uri + '?page=' + str(thecollection['links']['NextPage']['page']) - status, headers, thecollection = rest_get(host, next_link_uri, request_headers, user_name, password) - - # else we are finished iterating the collection - else: - break + def __repr__(self): + return str(self.major) + '.' + str(self.minor) # return the type of an object (down to the major version, skipping minor, and errata) diff --git a/redfish/tests/test_redfish.py b/redfish/tests/test_redfish.py index 6371f19..b70b43b 100644 --- a/redfish/tests/test_redfish.py +++ b/redfish/tests/test_redfish.py @@ -26,7 +26,8 @@ import mock import ssl from redfish.tests import base -from redfish import connection +from redfish import server +from redfish import types def get_fake_params(host=None, user=None, pword=None): @@ -69,20 +70,20 @@ class TestRedfishConnection(base.TestCase): self.addCleanup(self.https_mock.stop) def test_create_ok(self): - con = connection.RedfishConnection(*get_fake_params()) + con = server.RedfishConnection(*get_fake_params()) self.assertEqual(1, self.https_mock.call_count) self.assertEqual(0, self.http_mock.call_count) def test_create_calls_https_connect(self): self.https_mock.side_effect = TestException() self.assertRaises(TestException, - connection.RedfishConnection, + server.RedfishConnection, *get_fake_params(host='https://fake')) def test_create_calls_http_connect(self): self.http_mock.side_effect = TestException() self.assertRaises(TestException, - connection.RedfishConnection, + server.RedfishConnection, *get_fake_params(host='http://fake')) # TODO: add test for unknown connection schema (eg, ftp://) @@ -96,14 +97,14 @@ class TestRedfishConnection(base.TestCase): # ssl_mock.assert_called_once_with(ssl.PROTOCOL_TLSv1) def test_get_ok(self): - con = connection.RedfishConnection(*get_fake_params()) - res = con.rest_get('/v1/test', '') - self.assertEqual(200, res[0]) + con = server.RedfishConnection(*get_fake_params()) + res = con.rest_get('/v1/test/', '') + self.assertEqual(200, con.status) # Headers ae lower cased when returned - self.assertIn('fake-header', res[1].keys()) - self.assertIn('foo', res[2].keys()) + self.assertIn('fake-header', con.headers.keys()) + self.assertIn('foo', res.keys()) self.con_mock.request.assert_called_with( - 'GET', '/v1/test', body='null', headers=mock.ANY) + 'GET', '/v1/test/', body='null', headers=mock.ANY) # TODO: add test for redirects @@ -114,8 +115,8 @@ class TestRedfishConnection(base.TestCase): def test_post_ok(self): body = '{"fake": "body"}' json_body = json.dumps(body) - con = connection.RedfishConnection(*get_fake_params()) - res = con.rest_post('/v1/test', '', body) - self.assertEqual(200, res[0]) + con = server.RedfishConnection(*get_fake_params()) + res = con.rest_post('/v1/test/', '', body) + self.assertEqual(200, con.status) self.con_mock.request.assert_called_with( - 'POST', '/v1/test', body=json_body, headers=mock.ANY) + 'POST', '/v1/test/', body=json_body, headers=mock.ANY) diff --git a/redfish/types.py b/redfish/types.py new file mode 100644 index 0000000..a31b8bc --- /dev/null +++ b/redfish/types.py @@ -0,0 +1,197 @@ +# 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. + + +""" +Redfish Resource Types +""" + +import base64 +import gzip +import hashlib +import httplib +import json +import ssl +import StringIO +import sys +import urllib2 +from urlparse import urlparse + +from oslo_log import log as logging +from redfish import exception + +LOG = logging.getLogger('redfish') + + +class Base(object): + def __init__(self, obj, connection=None): + self._conn = connection + """handle to the redfish connection""" + + self._attrs = [] + """list of discovered attributes""" + + self._links = [] + """list of linked resources""" + + # parse the individual resources, appending them to + # the list of object attributes + for k in obj.keys(): + ref = k.lower() + if ref in ["links", "oem", "items"]: + continue + setattr(self, ref, obj[k]) + self._attrs.append(ref) + + # make sure the required attributes are present + if not getattr(self, 'name', False): + raise ObjectLoadException( + "Failed to load object. Reason: could not determine name.") + if not getattr(self, 'type', False): + raise ObjectLoadException( + "Failed to load object. Reason: could not determine type.") + + if getattr(self, 'serviceversion', False): + self.type = self.type.replace('.' + self.serviceversion, '') + else: + # TODO: use a regex here to strip and store the version + # instead of assuming it is 7 chars long + self.type = self.type[:-7] + + # Lastly, parse the 'links' resource. + # Note that this may have different nested structure, depending on + # what type of resource this is, or what vendor it is. + # subclasses may follow this by parsing other resources / collections + self._parse_links(obj) + + def _parse_links(self, obj): + """Map linked resources to getter functions + + The root resource returns a dict of links to top-level resources + """ + def getter(connection, href): + def _get(): + return connection.rest_get(href, {}) + return _get + + for k in obj['links']: + ref = "get_" + k.lower() + self._links.append(ref) + href = obj['links'][k]['href'] + setattr(self, ref, getter(self._conn, href)) + + def __repr__(self): + """Return this object's _attrs as a dict""" + res = {} + for a in self._attrs: + res[a] = getattr(self, a) + return res + + def __str__(self): + """Return the string representation of this object's _attrs""" + return json.dumps(self.__repr__()) + + +class BaseCollection(Base): + """Base class for collection types""" + def __init__(self, obj, connection=None): + super(BaseCollection, self).__init__(obj, connection=connection) + self._parse_items(obj) + self._attrs.append('items') + + def _parse_links(self, obj): + """links are special on a chassis; dont parse them""" + pass + + def _parse_items(self, obj): + """Map linked items to getter methods + + The chassis resource returns a list of items and corresponding + link data in a separate entity. + """ + def getter(connection, href): + def _get(): + return connection.rest_get(href, {}) + return _get + + self.items = [] + self._item_getters = [] + + if 'links' in obj and 'Member' in obj['links']: + # NOTE: this assumes the lists are ordered the same + counter = 0 + for item in obj['links']['Member']: + self.items.append(obj['Items'][counter]) + self._item_getters.append( + getter(self._conn, item['href'])) + counter+=1 + elif 'Items' in obj: + # TODO: find an example of this format and make sure it works + for item in obj['Items']: + if 'links' in item and 'self' in item['links']: + href = item['links']['self']['href'] + self.items.append(item) + + # TODO: implement paging support + # if 'links' in obj and 'NextPage' in obj['links']: + # next_page = THIS_URI + '?page=' + str(obj['links']['NextPage']['page']) + # do something with next_page URI + + def __iter__(self): + for getter in self._item_getters: + yield getter() + + +class Root(Base): + """Root '/' resource class""" + def _parse_links(self, obj): + """Map linked resources to getter functions + + The root resource returns a dict of links to top-level resources + + TODO: continue implementing customizations for top-level resources + + """ + mapping = { + 'Systems': Systems, + 'Chassis': Chassis, + 'Managers': Base, + 'Schemas': Base, + 'Registries': Base, + 'Tasks': Base, + 'AccountService': Base, + 'Sessions': Base, + 'EventService': Base, + } + + def getter(connection, href, type): + def _get(): + return mapping[type](connection.rest_get(href, {}), self._conn) + return _get + + for k in obj['links']: + ref = "get_" + k.lower() + self._links.append(ref) + href = obj['links'][k]['href'] + setattr(self, ref, getter(self._conn, href, k)) + + +class Chassis(BaseCollection): + """Chassis resource class""" + def __len__(self): + return len(self.items) + + +class Systems(Base): + pass diff --git a/requirements.txt b/requirements.txt index 95137a6..1cbb598 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ # process, which may cause wedges in the gate later. pbr>=0.6,!=0.7,<1.0 +oslo.log>=1.0,<2.0 Babel>=1.3