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).
This commit is contained in:
Devananda van der Veen 2015-04-04 08:44:22 -07:00
parent c7cd80458c
commit 2a70e6e765
8 changed files with 322 additions and 102 deletions

View File

@ -1,3 +1,6 @@
from redfish import connection
server = connection
host = '127.0.0.1'
user_name = 'Admin'
password = 'password'
server = connection.RedfishConnection(host, user_name, password)

58
examples/walk-chassis.py Normal file
View File

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

View File

@ -14,6 +14,9 @@
import pbr.version
import redfish.server
import redfish.types
__version__ = pbr.version.VersionInfo(
'redfish').version_string()

View File

@ -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)
super(RedfishException, self).__init__(message)
class ObjectLoadException(RedfishException):
pass

View File

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

View File

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

197
redfish/types.py Normal file
View File

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

View File

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