rapi.testutils: Add utility to format HTTP headers
[ganeti-local] / lib / rapi / testutils.py
1 #
2 #
3
4 # Copyright (C) 2012 Google Inc.
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19 # 02110-1301, USA.
20
21
22 """Remote API test utilities.
23
24 """
25
26 import logging
27 import re
28 import base64
29 import pycurl
30 from cStringIO import StringIO
31
32 from ganeti import errors
33 from ganeti import opcodes
34 from ganeti import http
35 from ganeti import server
36 from ganeti import utils
37 from ganeti import compat
38 from ganeti import luxi
39 from ganeti import rapi
40
41 import ganeti.http.server # pylint: disable=W0611
42 import ganeti.server.rapi
43 import ganeti.rapi.client
44
45
46 _URI_RE = re.compile(r"https://(?P<host>.*):(?P<port>\d+)(?P<path>/.*)")
47
48
49 class VerificationError(Exception):
50   """Dedicated error class for test utilities.
51
52   This class is used to hide all of Ganeti's internal exception, so that
53   external users of these utilities don't have to integrate Ganeti's exception
54   hierarchy.
55
56   """
57
58
59 def _GetOpById(op_id):
60   """Tries to get an opcode class based on its C{OP_ID}.
61
62   """
63   try:
64     return opcodes.OP_MAPPING[op_id]
65   except KeyError:
66     raise VerificationError("Unknown opcode ID '%s'" % op_id)
67
68
69 def _HideInternalErrors(fn):
70   """Hides Ganeti-internal exceptions, see L{VerificationError}.
71
72   """
73   def wrapper(*args, **kwargs):
74     try:
75       return fn(*args, **kwargs)
76     except (errors.GenericError, rapi.client.GanetiApiError), err:
77       raise VerificationError("Unhandled Ganeti error: %s" % err)
78
79   return wrapper
80
81
82 @_HideInternalErrors
83 def VerifyOpInput(op_id, data):
84   """Verifies opcode parameters according to their definition.
85
86   @type op_id: string
87   @param op_id: Opcode ID (C{OP_ID} attribute), e.g. C{OP_CLUSTER_VERIFY}
88   @type data: dict
89   @param data: Opcode parameter values
90   @raise VerificationError: Parameter verification failed
91
92   """
93   op_cls = _GetOpById(op_id)
94
95   try:
96     op = op_cls(**data) # pylint: disable=W0142
97   except TypeError, err:
98     raise VerificationError("Unable to create opcode instance: %s" % err)
99
100   try:
101     op.Validate(False)
102   except errors.OpPrereqError, err:
103     raise VerificationError("Parameter validation for opcode '%s' failed: %s" %
104                             (op_id, err))
105
106
107 @_HideInternalErrors
108 def VerifyOpResult(op_id, result):
109   """Verifies opcode results used in tests (e.g. in a mock).
110
111   @type op_id: string
112   @param op_id: Opcode ID (C{OP_ID} attribute), e.g. C{OP_CLUSTER_VERIFY}
113   @param result: Mocked opcode result
114   @raise VerificationError: Return value verification failed
115
116   """
117   resultcheck_fn = _GetOpById(op_id).OP_RESULT
118
119   if not resultcheck_fn:
120     logging.warning("Opcode '%s' has no result type definition", op_id)
121   elif not resultcheck_fn(result):
122     raise VerificationError("Given result does not match result description"
123                             " for opcode '%s': %s" % (op_id, resultcheck_fn))
124
125
126 def _GetPathFromUri(uri):
127   """Gets the path and query from a URI.
128
129   """
130   match = _URI_RE.match(uri)
131   if match:
132     return match.groupdict()["path"]
133   else:
134     return None
135
136
137 def _FormatHeaders(headers):
138   """Formats HTTP headers.
139
140   @type headers: sequence of strings
141   @rtype: string
142
143   """
144   assert compat.all(": " in header for header in headers)
145   return "\n".join(headers)
146
147
148 class FakeCurl:
149   """Fake cURL object.
150
151   """
152   def __init__(self, handler):
153     """Initialize this class
154
155     @param handler: Request handler instance
156
157     """
158     self._handler = handler
159     self._opts = {}
160     self._info = {}
161
162   def setopt(self, opt, value):
163     self._opts[opt] = value
164
165   def getopt(self, opt):
166     return self._opts.get(opt)
167
168   def unsetopt(self, opt):
169     self._opts.pop(opt, None)
170
171   def getinfo(self, info):
172     return self._info[info]
173
174   def perform(self):
175     method = self._opts[pycurl.CUSTOMREQUEST]
176     url = self._opts[pycurl.URL]
177     request_body = self._opts[pycurl.POSTFIELDS]
178     writefn = self._opts[pycurl.WRITEFUNCTION]
179
180     if pycurl.HTTPHEADER in self._opts:
181       baseheaders = _FormatHeaders(self._opts[pycurl.HTTPHEADER])
182     else:
183       baseheaders = ""
184
185     headers = http.ParseHeaders(StringIO(baseheaders))
186
187     if request_body:
188       headers[http.HTTP_CONTENT_LENGTH] = str(len(request_body))
189
190     if self._opts.get(pycurl.HTTPAUTH, 0) & pycurl.HTTPAUTH_BASIC:
191       try:
192         userpwd = self._opts[pycurl.USERPWD]
193       except KeyError:
194         raise errors.ProgrammerError("Basic authentication requires username"
195                                      " and password")
196
197       headers[http.HTTP_AUTHORIZATION] = \
198         "%s %s" % (http.auth.HTTP_BASIC_AUTH, base64.b64encode(userpwd))
199
200     path = _GetPathFromUri(url)
201     (code, _, resp_body) = \
202       self._handler.FetchResponse(path, method, headers, request_body)
203
204     self._info[pycurl.RESPONSE_CODE] = code
205     if resp_body is not None:
206       writefn(resp_body)
207
208
209 class _RapiMock:
210   """Mocking out the RAPI server parts.
211
212   """
213   def __init__(self, user_fn, luxi_client):
214     """Initialize this class.
215
216     @type user_fn: callable
217     @param user_fn: Function to authentication username
218     @param luxi_client: A LUXI client implementation
219
220     """
221     self.handler = \
222       server.rapi.RemoteApiHandler(user_fn, _client_cls=luxi_client)
223
224   def FetchResponse(self, path, method, headers, request_body):
225     """This is a callback method used to fetch a response.
226
227     This method is called by the FakeCurl.perform method
228
229     @type path: string
230     @param path: Requested path
231     @type method: string
232     @param method: HTTP method
233     @type request_body: string
234     @param request_body: Request body
235     @type headers: mimetools.Message
236     @param headers: Request headers
237     @return: Tuple containing status code, response headers and response body
238
239     """
240     req_msg = http.HttpMessage()
241     req_msg.start_line = \
242       http.HttpClientToServerStartLine(method, path, http.HTTP_1_0)
243     req_msg.headers = headers
244     req_msg.body = request_body
245
246     (_, _, _, resp_msg) = \
247       http.server.HttpResponder(self.handler)(lambda: (req_msg, None))
248
249     return (resp_msg.start_line.code, resp_msg.headers, resp_msg.body)
250
251
252 class _TestLuxiTransport:
253   """Mocked LUXI transport.
254
255   Raises L{errors.RapiTestResult} for all method calls, no matter the
256   arguments.
257
258   """
259   def __init__(self, record_fn, address, timeouts=None): # pylint: disable=W0613
260     """Initializes this class.
261
262     """
263     self._record_fn = record_fn
264
265   def Close(self):
266     pass
267
268   def Call(self, data):
269     """Calls LUXI method.
270
271     In this test class the method is not actually called, but added to a list
272     of called methods and then an exception (L{errors.RapiTestResult}) is
273     raised. There is no return value.
274
275     """
276     (method, _, _) = luxi.ParseRequest(data)
277
278     # Take a note of called method
279     self._record_fn(method)
280
281     # Everything went fine until here, so let's abort the test
282     raise errors.RapiTestResult
283
284
285 class _LuxiCallRecorder:
286   """Records all called LUXI client methods.
287
288   """
289   def __init__(self):
290     """Initializes this class.
291
292     """
293     self._called = set()
294
295   def Record(self, name):
296     """Records a called function name.
297
298     """
299     self._called.add(name)
300
301   def CalledNames(self):
302     """Returns a list of called LUXI methods.
303
304     """
305     return self._called
306
307   def __call__(self):
308     """Creates an instrumented LUXI client.
309
310     The LUXI client will record all method calls (use L{CalledNames} to
311     retrieve them).
312
313     """
314     return luxi.Client(transport=compat.partial(_TestLuxiTransport,
315                                                 self.Record))
316
317
318 def _TestWrapper(fn, *args, **kwargs):
319   """Wrapper for ignoring L{errors.RapiTestResult}.
320
321   """
322   try:
323     return fn(*args, **kwargs)
324   except errors.RapiTestResult:
325     # Everything was fine up to the point of sending a LUXI request
326     return NotImplemented
327
328
329 class InputTestClient:
330   """Test version of RAPI client.
331
332   Instances of this class can be used to test input arguments for RAPI client
333   calls. See L{rapi.client.GanetiRapiClient} for available methods and their
334   arguments. Functions can return C{NotImplemented} if all arguments are
335   acceptable, but a LUXI request would be necessary to provide an actual return
336   value. In case of an error, L{VerificationError} is raised.
337
338   @see: An example on how to use this class can be found in
339     C{doc/examples/rapi_testutils.py}
340
341   """
342   def __init__(self):
343     """Initializes this class.
344
345     """
346     username = utils.GenerateSecret()
347     password = utils.GenerateSecret()
348
349     def user_fn(wanted):
350       """Called to verify user credentials given in HTTP request.
351
352       """
353       assert username == wanted
354       return http.auth.PasswordFileUser(username, password,
355                                         [rapi.RAPI_ACCESS_WRITE])
356
357     self._lcr = _LuxiCallRecorder()
358
359     # Create a mock RAPI server
360     handler = _RapiMock(user_fn, self._lcr)
361
362     self._client = \
363       rapi.client.GanetiRapiClient("master.example.com",
364                                    username=username, password=password,
365                                    curl_factory=lambda: FakeCurl(handler))
366
367   def _GetLuxiCalls(self):
368     """Returns the names of all called LUXI client functions.
369
370     """
371     return self._lcr.CalledNames()
372
373   def __getattr__(self, name):
374     """Finds method by name.
375
376     The method is wrapped using L{_TestWrapper} to produce the actual test
377     result.
378
379     """
380     return _HideInternalErrors(compat.partial(_TestWrapper,
381                                               getattr(self._client, name)))