413 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			413 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| # 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.
 | |
| 
 | |
| 
 | |
| """
 | |
| STARTING ASSUMPTIONS
 | |
| 
 | |
| On URIs:
 | |
| 
 | |
| The Redfish RESTful API is a "hypermedia API" by design.  This is to avoid
 | |
| building in restrictive assumptions to the data model that will make it
 | |
| difficult to adapt to future hardware implementations.  A hypermedia API avoids
 | |
| these assumptions by making the data model discoverable via links between
 | |
| resources.
 | |
| 
 | |
| A URI should be treated by the client as opaque, and thus should not be
 | |
| attempted to be understood or deconstructed by the client.  Only specific top
 | |
| level URIs (any URI in this sample code) may be assumed, and even these may be
 | |
| absent based upon the implementation (e.g. there might be no /rest/v1/Systems
 | |
| collection on something that doesn't have compute nodes.)
 | |
| 
 | |
| The other URIs must be discovered dynamically by following href links.  This is
 | |
| because the API will eventually be implemented on a system that breaks any
 | |
| existing data model "shape" assumptions we may make now.  In particular,
 | |
| clients should not make assumptions about the URIs for the resource members of
 | |
| a collection.  For instance, the URI of a collection member will NOT always be
 | |
| /rest/v1/.../collection/1, or 2.  On systems with multiple compute nodes per
 | |
| manager, a System collection member might be /rest/v1/Systems/C1N1.
 | |
| 
 | |
| This sounds very complicated, but in reality (as these examples demonstrate),
 | |
| if you are looking for specific items, the traversal logic isn't too
 | |
| complicated.
 | |
| 
 | |
| On Resource Model Traversal:
 | |
| 
 | |
| Although the resources in the data model are linked together, because of cross
 | |
| link references between resources, a client may not assume the resource model
 | |
| is a tree.  It is a graph instead, so any crawl of the data model should keep
 | |
| track of visited resources to avoid an infinite traversal loop.
 | |
| 
 | |
| A reference to another resource is any property called "href" no matter where
 | |
| it occurs in a resource.
 | |
| 
 | |
| An external reference to a resource outside the data model is referred to by a
 | |
| property called "extref".  Any resource referred to by extref should not be
 | |
| assumed to follow the conventions of the API.
 | |
| 
 | |
| On Resource Versions:
 | |
| 
 | |
| Each resource has a "Type" property with a value of the format Tyepname.x.y.z
 | |
| where
 | |
| * x = major version - incrementing this is a breaking change to the schema y =
 | |
| * minor version - incrementing this is a non-breaking additive change to the
 | |
| * schema z = errata - non-breaking change
 | |
| 
 | |
| Because all resources are versioned and schema also have a version, it is
 | |
| possible to design rules for "nearest" match (e.g. if you are interacting with
 | |
| multiple services using a common batch of schema files).  The mechanism is not
 | |
| prescribed, but a client should be prepared to encounter both older and newer
 | |
| versions of resource types.
 | |
| 
 | |
| On HTTP POST to create:
 | |
| 
 | |
| WHen POSTing to create a resource (e.g. create an account or session) the
 | |
| guarantee is that a successful response includes a "Location" HTTP header
 | |
| indicating the resource URI of the newly created resource.  The POST may also
 | |
| include a representation of the newly created object in a JSON response body
 | |
| but may not.  Do not assume the response body, but test it.  It may also be an
 | |
| ExtendedError object.
 | |
| 
 | |
| HTTP REDIRECT:
 | |
| 
 | |
| All clients must correctly handle HTTP redirect.  We (or Redfish) may
 | |
| eventually need to use redirection as a way to alias portions of the data
 | |
| model.
 | |
| 
 | |
| FUTURE:  Asynchronous tasks
 | |
| 
 | |
| In the future some operations may start asynchonous tasks.  In this case, the
 | |
| client should recognized and handle HTTP 202 if needed and the 'Location'
 | |
| header will point to a resource with task information and status.
 | |
| 
 | |
| JSON-SCHEMA:
 | |
| 
 | |
| The json-schema available at /rest/v1/Schemas governs the content of the
 | |
| resources, but keep in mind:
 | |
| * not every property in the schema is implemented in every implementation.
 | |
| * some properties are schemed to allow both null and anotehr type like string
 | |
| * or integer.
 | |
| 
 | |
| Robust client code should check both the existence and type of interesting
 | |
| properties and fail gracefully if expectations are not met.
 | |
| 
 | |
| GENERAL ADVICE:
 | |
| 
 | |
| Clients should always be prepared for:
 | |
| * unimplemented properties (e.g. a property doesn't apply in a particular case)
 | |
| * null values in some cases if the value of a property is not currently known
 | |
| * due to system conditions HTTP status codes other than 200 OK.  Can your code
 | |
| * handle an HTTP 500 Internal Server Error with no other info?  URIs are case
 | |
| * insensitive HTTP header names are case insensitive JSON Properties and Enum
 | |
| * values are case sensitive A client should be tolerant of any set of HTTP
 | |
| * headers the service returns
 | |
| 
 | |
| """
 | |
| 
 | |
| 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
 | |
| from redfish import types
 | |
| 
 | |
| 
 | |
| LOG = logging.getLogger('redfish')
 | |
| 
 | |
| 
 | |
| def connect(host, user, password):
 | |
|     return RedfishConnection(host, user, password)
 | |
| 
 | |
| 
 | |
| class RedfishConnection(object):
 | |
|     """Implements basic connection handling for Redfish APIs."""
 | |
| 
 | |
|     def __init__(self, host, user_name, password,
 | |
|                  auth_token=None, enforce_SSL=True):
 | |
|         """Initialize a connection to a Redfish service."""
 | |
|         super(RedfishConnection, self).__init__()
 | |
| 
 | |
|         self.user_name = user_name
 | |
|         self.password = password
 | |
|         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:
 | |
|             # TODO: if a token is returned by this call, cache it. However,
 | |
|             # the sample HTML does not include any token data, so it's unclear
 | |
|             # what we should do here.
 | |
|             LOG.debug('Initiating session with host %s', self.host)
 | |
|             auth_dict = {'Password': self.password, 'UserName': self.user_name}
 | |
|             response = self.rest_post(
 | |
|                     '/rest/v1/Sessions', None, json.dumps(auth_dict))
 | |
| 
 | |
|         # TODO: do some schema discovery here and cache the result
 | |
|         # self.schema = ...
 | |
|         LOG.info('Connection established to host %s', self.host)
 | |
| 
 | |
|     def _connect(self):
 | |
|         LOG.debug("Establishing connection to host %s", self.host)
 | |
|         url = urlparse(self.host)
 | |
|         if url.scheme == 'https':
 | |
|             # New in Python 2.7.9, SSL enforcement is defaulted on.
 | |
|             # It can be opted-out of, which might be useful for debugging
 | |
|             # some things. The below case is the Opt-Out condition and
 | |
|             # should be used with GREAT caution.
 | |
|             if (sys.version_info.major == 2
 | |
|                     and sys.version_info.minor == 7
 | |
|                     and sys.version_info.micro >= 9
 | |
|                     and self.enforce_SSL == False):
 | |
|                 cont = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
 | |
|                 cont.verify_mode = ssl.CERT_NONE
 | |
|                 self.connection = httplib.HTTPSConnection(
 | |
|                         host=url.netloc, strict=True, context=cont)
 | |
|             else:
 | |
|                 self.connection = httplib.HTTPSConnection(
 | |
|                         host=url.netloc, strict=True)
 | |
|         elif url.scheme == 'http':
 | |
|             self.connection = httplib.HTTPConnection(
 | |
|                     host=url.netloc, strict=True)
 | |
|         else:
 | |
|             raise exception.RedfishException(
 | |
|                     message='Unknown connection schema')
 | |
| 
 | |
|     def _op(self, operation, suburi, request_headers=None, request_body=None):
 | |
|         """
 | |
|         REST operation generic handler
 | |
| 
 | |
|         :param operation: GET, POST, etc
 | |
|         :param suburi: the URI path to the resource
 | |
|         :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()
 | |
|         request_headers['Content-Type'] = 'application/json'
 | |
| 
 | |
|         # if X-Auth-Token specified, supply it instead of basic auth
 | |
|         if self.auth_token is not None:
 | |
|             request_headers['X-Auth-Token'] = self.auth_token
 | |
|         # else use user_name/password and Basic Auth
 | |
|         elif self.user_name is not None and self.password is not None:
 | |
|             request_headers['Authorization'] = ("BASIC " + base64.b64encode(
 | |
|                     self.user_name + ":" + self.password))
 | |
|         # TODO: add support for other types of auth
 | |
| 
 | |
|         redir_count = 4
 | |
|         while redir_count:
 | |
|             # NOTE: Do not assume every HTTP operation will return a JSON body.
 | |
|             # For example, ExtendedError structures are only required for
 | |
|             # HTTP 400 errors and are optional elsewhere as they are mostly
 | |
|             # redundant for many of the other HTTP status code.  In particular,
 | |
|             # 200 OK responses should not have to return any body.
 | |
|             self.connection.request(operation, url.path,
 | |
|                     headers=request_headers, body=json.dumps(request_body))
 | |
|             resp = self.connection.getresponse()
 | |
|             body = resp.read()
 | |
|             # NOTE:  this makes sure the headers names are all lower case
 | |
|             # because HTTP says they are case insensitive
 | |
|             headers = dict((x.lower(), y) for x, y in resp.getheaders())
 | |
| 
 | |
|             # Follow HTTP redirect
 | |
|             if resp.status == 301 and 'location' in headers:
 | |
|                 url = urlparse(headers['location'])
 | |
|                 # TODO: cache these redirects
 | |
|                 LOG.debug("Following redirect to %s", headers['location'])
 | |
|                 redir_count -= 1
 | |
|             else:
 | |
|                 break
 | |
| 
 | |
|         response = dict()
 | |
|         try:
 | |
|             response = json.loads(body.decode('utf-8'))
 | |
|         except ValueError: # if it doesn't decode as json
 | |
|             # NOTE:  resources may return gzipped content, so try to decode
 | |
|             # as gzip (we should check the headers for Content-Encoding=gzip)
 | |
|             try:
 | |
|                 gzipper = gzip.GzipFile(fileobj=StringIO.StringIO(body))
 | |
|                 uncompressed_string = gzipper.read().decode('UTF-8')
 | |
|                 response = json.loads(uncompressed_string)
 | |
|             except:
 | |
|                 raise exception.RedfishException(message=
 | |
|                         'Failed to parse response as a JSON document, '
 | |
|                         'received "%s".' % body)
 | |
| 
 | |
|         self.status = resp.status
 | |
|         self.headers = headers
 | |
|         return response
 | |
| 
 | |
|     def rest_get(self, suburi, request_headers):
 | |
|         """REST GET
 | |
| 
 | |
|         :param: suburi
 | |
|         :param: request_headers
 | |
|         """
 | |
|         # NOTE:  be prepared for various HTTP responses including 500, 404, etc
 | |
|         return self._op('GET', suburi, request_headers, None)
 | |
| 
 | |
|     def rest_patch(self, suburi, request_headers, request_body):
 | |
|         """REST PATCH
 | |
| 
 | |
|         :param: suburi
 | |
|         :param: request_headers
 | |
|         :param: request_body
 | |
|         NOTE: this body is a dict, not a JSONPATCH document.
 | |
|               redfish does not follow IETF JSONPATCH standard
 | |
|               https://tools.ietf.org/html/rfc6902
 | |
|         """
 | |
|         # NOTE:  be prepared for various HTTP responses including 500, 404, 202
 | |
|         return self._op('PATCH', suburi, request_headers, request_body)
 | |
| 
 | |
|     def rest_put(self, suburi, request_headers, request_body):
 | |
|         """REST PUT
 | |
| 
 | |
|         :param: suburi
 | |
|         :param: request_headers
 | |
|         :param: request_body
 | |
|         """
 | |
|         # NOTE:  be prepared for various HTTP responses including 500, 404, 202
 | |
|         return self._op('PUT', suburi, request_headers, request_body)
 | |
| 
 | |
|     def rest_post(self, suburi, request_headers, request_body):
 | |
|         """REST POST
 | |
| 
 | |
|         :param: suburi
 | |
|         :param: request_headers
 | |
|         :param: request_body
 | |
|         """
 | |
|         # 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
 | |
|         # ExtendedError, or it may be empty.
 | |
|         return self._op('POST', suburi, request_headers, request_body)
 | |
| 
 | |
|     def rest_delete(self, suburi, request_headers):
 | |
|         """REST DELETE
 | |
| 
 | |
|         :param: suburi
 | |
|         :param: request_headers
 | |
|         """
 | |
|         # 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)
 | |
| 
 | |
|     def get_root(self):
 | |
|         return types.Root(self.rest_get('/rest/v1', {}), connection=self)
 | |
| 
 | |
| 
 | |
| 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])
 | |
| 
 | |
|     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)
 | |
| def get_type(obj):
 | |
|     typever = obj['Type']
 | |
|     typesplit = typever.split('.')
 | |
|     return typesplit[0] + '.' + typesplit[1]
 | |
| 
 | |
| 
 | |
| # checks HTTP response headers for specified operation (e.g. 'GET' or 'PATCH')
 | |
| def operation_allowed(headers_dict, operation):
 | |
|     if 'allow' in headers_dict:
 | |
|         if headers_dict['allow'].find(operation) != -1:
 | |
|             return True
 | |
|     return False
 | |
| 
 | |
| 
 | |
| # Message registry support
 | |
| # XXX not supported yet
 | |
| message_registries = {}
 | |
| 
 | |
| 
 | |
| # Build a list of decoded messages from the extended_error using the message
 | |
| # registries An ExtendedError JSON object is a response from the with its own
 | |
| # schema.  This function knows how to parse the ExtendedError object and, using
 | |
| # any loaded message registries, render an array of plain language strings that
 | |
| # represent the response.
 | |
| def render_extended_error_message_list(extended_error):
 | |
|     messages = []
 | |
|     if isinstance(extended_error, dict):
 | |
|         if 'Type' in extended_error and extended_error['Type'].startswith('ExtendedError.'):
 | |
|             for msg in extended_error['Messages']:
 | |
|                 MessageID = msg['MessageID']
 | |
|                 x = MessageID.split('.')
 | |
|                 registry = x[0]
 | |
|                 msgkey = x[len(x) - 1]
 | |
| 
 | |
|                 # if the correct message registry is loaded, do string resolution
 | |
|                 if registry in message_registries:
 | |
|                     if registry in message_registries and msgkey in message_registries[registry]['Messages']:
 | |
|                         msg_dict = message_registries[registry]['Messages'][msgkey]
 | |
|                         msg_str = MessageID + ':  ' + msg_dict['Message']
 | |
| 
 | |
|                         for argn in range(0, msg_dict['NumberOfArgs']):
 | |
|                             subst = '%' + str(argn+1)
 | |
|                             msg_str = msg_str.replace(subst, str(msg['MessageArgs'][argn]))
 | |
| 
 | |
|                         if 'Resolution' in msg_dict and msg_dict['Resolution'] != 'None':
 | |
|                             msg_str += '  ' + msg_dict['Resolution']
 | |
| 
 | |
|                         messages.append(msg_str)
 | |
|                 else: # no message registry, simply return the msg object in string form
 | |
|                     messages.append('No Message Registry Info:  '+ str(msg))
 | |
| 
 | |
|     return messages
 | |
| 
 | |
| 
 | |
| # Print a list of decoded messages from the extended_error using the message registries
 | |
| def print_extended_error(extended_error):
 | |
|     messages = render_extended_error_message_list(extended_error)
 | |
|     msgcnt = 0
 | |
|     for msg in messages:
 | |
|         print('\t' + msg)
 | |
|         msgcnt += 1
 | |
|     if msgcnt == 0: # add a spacer
 | |
|         print
 | 
