blob: cdc34dc3172a92838d6ea11ae9ea714ce5c412c5 [file] [log] [blame]
#
#
# Copyright (C) 2012 Google Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
"""Remote API test utilities.
"""
import logging
import re
import base64
import pycurl
from cStringIO import StringIO
from ganeti import errors
from ganeti import opcodes
from ganeti import http
from ganeti import server
from ganeti import utils
from ganeti import compat
from ganeti import luxi
from ganeti import rapi
import ganeti.http.server # pylint: disable=W0611
import ganeti.server.rapi
import ganeti.rapi.client
_URI_RE = re.compile(r"https://(?P<host>.*):(?P<port>\d+)(?P<path>/.*)")
class VerificationError(Exception):
"""Dedicated error class for test utilities.
This class is used to hide all of Ganeti's internal exception, so that
external users of these utilities don't have to integrate Ganeti's exception
hierarchy.
"""
def _GetOpById(op_id):
"""Tries to get an opcode class based on its C{OP_ID}.
"""
try:
return opcodes.OP_MAPPING[op_id]
except KeyError:
raise VerificationError("Unknown opcode ID '%s'" % op_id)
def _HideInternalErrors(fn):
"""Hides Ganeti-internal exceptions, see L{VerificationError}.
"""
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except (errors.GenericError, rapi.client.GanetiApiError), err:
raise VerificationError("Unhandled Ganeti error: %s" % err)
return wrapper
@_HideInternalErrors
def VerifyOpInput(op_id, data):
"""Verifies opcode parameters according to their definition.
@type op_id: string
@param op_id: Opcode ID (C{OP_ID} attribute), e.g. C{OP_CLUSTER_VERIFY}
@type data: dict
@param data: Opcode parameter values
@raise VerificationError: Parameter verification failed
"""
op_cls = _GetOpById(op_id)
try:
op = op_cls(**data) # pylint: disable=W0142
except TypeError, err:
raise VerificationError("Unable to create opcode instance: %s" % err)
try:
op.Validate(False)
except errors.OpPrereqError, err:
raise VerificationError("Parameter validation for opcode '%s' failed: %s" %
(op_id, err))
@_HideInternalErrors
def VerifyOpResult(op_id, result):
"""Verifies opcode results used in tests (e.g. in a mock).
@type op_id: string
@param op_id: Opcode ID (C{OP_ID} attribute), e.g. C{OP_CLUSTER_VERIFY}
@param result: Mocked opcode result
@raise VerificationError: Return value verification failed
"""
resultcheck_fn = _GetOpById(op_id).OP_RESULT
if not resultcheck_fn:
logging.warning("Opcode '%s' has no result type definition", op_id)
elif not resultcheck_fn(result):
raise VerificationError("Given result does not match result description"
" for opcode '%s': %s" % (op_id, resultcheck_fn))
def _GetPathFromUri(uri):
"""Gets the path and query from a URI.
"""
match = _URI_RE.match(uri)
if match:
return match.groupdict()["path"]
else:
return None
def _FormatHeaders(headers):
"""Formats HTTP headers.
@type headers: sequence of strings
@rtype: string
"""
assert compat.all(": " in header for header in headers)
return "\n".join(headers)
class FakeCurl:
"""Fake cURL object.
"""
def __init__(self, handler):
"""Initialize this class
@param handler: Request handler instance
"""
self._handler = handler
self._opts = {}
self._info = {}
def setopt(self, opt, value):
self._opts[opt] = value
def getopt(self, opt):
return self._opts.get(opt)
def unsetopt(self, opt):
self._opts.pop(opt, None)
def getinfo(self, info):
return self._info[info]
def perform(self):
method = self._opts[pycurl.CUSTOMREQUEST]
url = self._opts[pycurl.URL]
request_body = self._opts[pycurl.POSTFIELDS]
writefn = self._opts[pycurl.WRITEFUNCTION]
if pycurl.HTTPHEADER in self._opts:
baseheaders = _FormatHeaders(self._opts[pycurl.HTTPHEADER])
else:
baseheaders = ""
headers = http.ParseHeaders(StringIO(baseheaders))
if request_body:
headers[http.HTTP_CONTENT_LENGTH] = str(len(request_body))
if self._opts.get(pycurl.HTTPAUTH, 0) & pycurl.HTTPAUTH_BASIC:
try:
userpwd = self._opts[pycurl.USERPWD]
except KeyError:
raise errors.ProgrammerError("Basic authentication requires username"
" and password")
headers[http.HTTP_AUTHORIZATION] = \
"%s %s" % (http.auth.HTTP_BASIC_AUTH, base64.b64encode(userpwd))
path = _GetPathFromUri(url)
(code, _, resp_body) = \
self._handler.FetchResponse(path, method, headers, request_body)
self._info[pycurl.RESPONSE_CODE] = code
if resp_body is not None:
writefn(resp_body)
class _RapiMock:
"""Mocking out the RAPI server parts.
"""
def __init__(self, user_fn, luxi_client, reqauth=False):
"""Initialize this class.
@type user_fn: callable
@param user_fn: Function to authentication username
@param luxi_client: A LUXI client implementation
"""
self.handler = \
server.rapi.RemoteApiHandler(user_fn, reqauth, _client_cls=luxi_client)
def FetchResponse(self, path, method, headers, request_body):
"""This is a callback method used to fetch a response.
This method is called by the FakeCurl.perform method
@type path: string
@param path: Requested path
@type method: string
@param method: HTTP method
@type request_body: string
@param request_body: Request body
@type headers: mimetools.Message
@param headers: Request headers
@return: Tuple containing status code, response headers and response body
"""
req_msg = http.HttpMessage()
req_msg.start_line = \
http.HttpClientToServerStartLine(method, path, http.HTTP_1_0)
req_msg.headers = headers
req_msg.body = request_body
(_, _, _, resp_msg) = \
http.server.HttpResponder(self.handler)(lambda: (req_msg, None))
return (resp_msg.start_line.code, resp_msg.headers, resp_msg.body)
class _TestLuxiTransport:
"""Mocked LUXI transport.
Raises L{errors.RapiTestResult} for all method calls, no matter the
arguments.
"""
def __init__(self, record_fn, address, timeouts=None): # pylint: disable=W0613
"""Initializes this class.
"""
self._record_fn = record_fn
def Close(self):
pass
def Call(self, data):
"""Calls LUXI method.
In this test class the method is not actually called, but added to a list
of called methods and then an exception (L{errors.RapiTestResult}) is
raised. There is no return value.
"""
(method, _, _) = luxi.ParseRequest(data)
# Take a note of called method
self._record_fn(method)
# Everything went fine until here, so let's abort the test
raise errors.RapiTestResult
class _LuxiCallRecorder:
"""Records all called LUXI client methods.
"""
def __init__(self):
"""Initializes this class.
"""
self._called = set()
def Record(self, name):
"""Records a called function name.
"""
self._called.add(name)
def CalledNames(self):
"""Returns a list of called LUXI methods.
"""
return self._called
def __call__(self, address=None):
"""Creates an instrumented LUXI client.
The LUXI client will record all method calls (use L{CalledNames} to
retrieve them).
"""
return luxi.Client(transport=compat.partial(_TestLuxiTransport,
self.Record),
address=address)
def _TestWrapper(fn, *args, **kwargs):
"""Wrapper for ignoring L{errors.RapiTestResult}.
"""
try:
return fn(*args, **kwargs)
except errors.RapiTestResult:
# Everything was fine up to the point of sending a LUXI request
return NotImplemented
class InputTestClient:
"""Test version of RAPI client.
Instances of this class can be used to test input arguments for RAPI client
calls. See L{rapi.client.GanetiRapiClient} for available methods and their
arguments. Functions can return C{NotImplemented} if all arguments are
acceptable, but a LUXI request would be necessary to provide an actual return
value. In case of an error, L{VerificationError} is raised.
@see: An example on how to use this class can be found in
C{doc/examples/rapi_testutils.py}
"""
def __init__(self):
"""Initializes this class.
"""
username = utils.GenerateSecret()
password = utils.GenerateSecret()
def user_fn(wanted):
"""Called to verify user credentials given in HTTP request.
"""
assert username == wanted
return http.auth.PasswordFileUser(username, password,
[rapi.RAPI_ACCESS_WRITE])
self._lcr = _LuxiCallRecorder()
# Create a mock RAPI server
handler = _RapiMock(user_fn, self._lcr)
self._client = \
rapi.client.GanetiRapiClient("master.example.com",
username=username, password=password,
curl_factory=lambda: FakeCurl(handler))
def _GetLuxiCalls(self):
"""Returns the names of all called LUXI client functions.
"""
return self._lcr.CalledNames()
def __getattr__(self, name):
"""Finds method by name.
The method is wrapped using L{_TestWrapper} to produce the actual test
result.
"""
return _HideInternalErrors(compat.partial(_TestWrapper,
getattr(self._client, name)))