Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / testutils.py @ 0351944b

History | View | Annotate | Download (9.6 kB)

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
class FakeCurl:
138
  """Fake cURL object.
139

140
  """
141
  def __init__(self, handler):
142
    """Initialize this class
143

144
    @param handler: Request handler instance
145

146
    """
147
    self._handler = handler
148
    self._opts = {}
149
    self._info = {}
150

    
151
  def setopt(self, opt, value):
152
    self._opts[opt] = value
153

    
154
  def getopt(self, opt):
155
    return self._opts.get(opt)
156

    
157
  def unsetopt(self, opt):
158
    self._opts.pop(opt, None)
159

    
160
  def getinfo(self, info):
161
    return self._info[info]
162

    
163
  def perform(self):
164
    method = self._opts[pycurl.CUSTOMREQUEST]
165
    url = self._opts[pycurl.URL]
166
    request_body = self._opts[pycurl.POSTFIELDS]
167
    writefn = self._opts[pycurl.WRITEFUNCTION]
168

    
169
    if pycurl.HTTPHEADER in self._opts:
170
      baseheaders = "\n".join(self._opts[pycurl.HTTPHEADER])
171
    else:
172
      baseheaders = ""
173

    
174
    headers = http.ParseHeaders(StringIO(baseheaders))
175

    
176
    if request_body:
177
      headers[http.HTTP_CONTENT_LENGTH] = str(len(request_body))
178

    
179
    if self._opts.get(pycurl.HTTPAUTH, 0) & pycurl.HTTPAUTH_BASIC:
180
      try:
181
        userpwd = self._opts[pycurl.USERPWD]
182
      except KeyError:
183
        raise errors.ProgrammerError("Basic authentication requires username"
184
                                     " and password")
185

    
186
      headers[http.HTTP_AUTHORIZATION] = \
187
        "%s %s" % (http.auth.HTTP_BASIC_AUTH, base64.b64encode(userpwd))
188

    
189
    path = _GetPathFromUri(url)
190
    (code, _, resp_body) = \
191
      self._handler.FetchResponse(path, method, headers, request_body)
192

    
193
    self._info[pycurl.RESPONSE_CODE] = code
194
    if resp_body is not None:
195
      writefn(resp_body)
196

    
197

    
198
class _RapiMock:
199
  """Mocking out the RAPI server parts.
200

201
  """
202
  def __init__(self, user_fn, luxi_client):
203
    """Initialize this class.
204

205
    @type user_fn: callable
206
    @param user_fn: Function to authentication username
207
    @param luxi_client: A LUXI client implementation
208

209
    """
210
    self.handler = \
211
      server.rapi.RemoteApiHandler(user_fn, _client_cls=luxi_client)
212

    
213
  def FetchResponse(self, path, method, headers, request_body):
214
    """This is a callback method used to fetch a response.
215

216
    This method is called by the FakeCurl.perform method
217

218
    @type path: string
219
    @param path: Requested path
220
    @type method: string
221
    @param method: HTTP method
222
    @type request_body: string
223
    @param request_body: Request body
224
    @type headers: mimetools.Message
225
    @param headers: Request headers
226
    @return: Tuple containing status code, response headers and response body
227

228
    """
229
    req_msg = http.HttpMessage()
230
    req_msg.start_line = \
231
      http.HttpClientToServerStartLine(method, path, http.HTTP_1_0)
232
    req_msg.headers = headers
233
    req_msg.body = request_body
234

    
235
    (_, _, _, resp_msg) = \
236
      http.server.HttpResponder(self.handler)(lambda: (req_msg, None))
237

    
238
    return (resp_msg.start_line.code, resp_msg.headers, resp_msg.body)
239

    
240

    
241
class _TestLuxiTransport:
242
  """Mocked LUXI transport.
243

244
  Raises L{errors.RapiTestResult} for all method calls, no matter the
245
  arguments.
246

247
  """
248
  def __init__(self, record_fn, address, timeouts=None): # pylint: disable=W0613
249
    """Initializes this class.
250

251
    """
252
    self._record_fn = record_fn
253

    
254
  def Close(self):
255
    pass
256

    
257
  def Call(self, data):
258
    """Calls LUXI method.
259

260
    In this test class the method is not actually called, but added to a list
261
    of called methods and then an exception (L{errors.RapiTestResult}) is
262
    raised. There is no return value.
263

264
    """
265
    (method, _, _) = luxi.ParseRequest(data)
266

    
267
    # Take a note of called method
268
    self._record_fn(method)
269

    
270
    # Everything went fine until here, so let's abort the test
271
    raise errors.RapiTestResult
272

    
273

    
274
class _LuxiCallRecorder:
275
  """Records all called LUXI client methods.
276

277
  """
278
  def __init__(self):
279
    """Initializes this class.
280

281
    """
282
    self._called = set()
283

    
284
  def Record(self, name):
285
    """Records a called function name.
286

287
    """
288
    self._called.add(name)
289

    
290
  def CalledNames(self):
291
    """Returns a list of called LUXI methods.
292

293
    """
294
    return self._called
295

    
296
  def __call__(self):
297
    """Creates an instrumented LUXI client.
298

299
    The LUXI client will record all method calls (use L{CalledNames} to
300
    retrieve them).
301

302
    """
303
    return luxi.Client(transport=compat.partial(_TestLuxiTransport,
304
                                                self.Record))
305

    
306

    
307
def _TestWrapper(fn, *args, **kwargs):
308
  """Wrapper for ignoring L{errors.RapiTestResult}.
309

310
  """
311
  try:
312
    return fn(*args, **kwargs)
313
  except errors.RapiTestResult:
314
    # Everything was fine up to the point of sending a LUXI request
315
    return NotImplemented
316

    
317

    
318
class InputTestClient:
319
  """Test version of RAPI client.
320

321
  Instances of this class can be used to test input arguments for RAPI client
322
  calls. See L{rapi.client.GanetiRapiClient} for available methods and their
323
  arguments. Functions can return C{NotImplemented} if all arguments are
324
  acceptable, but a LUXI request would be necessary to provide an actual return
325
  value. In case of an error, L{VerificationError} is raised.
326

327
  @see: An example on how to use this class can be found in
328
    C{doc/examples/rapi_testutils.py}
329

330
  """
331
  def __init__(self):
332
    """Initializes this class.
333

334
    """
335
    username = utils.GenerateSecret()
336
    password = utils.GenerateSecret()
337

    
338
    def user_fn(wanted):
339
      """Called to verify user credentials given in HTTP request.
340

341
      """
342
      assert username == wanted
343
      return http.auth.PasswordFileUser(username, password,
344
                                        [rapi.RAPI_ACCESS_WRITE])
345

    
346
    self._lcr = _LuxiCallRecorder()
347

    
348
    # Create a mock RAPI server
349
    handler = _RapiMock(user_fn, self._lcr)
350

    
351
    self._client = \
352
      rapi.client.GanetiRapiClient("master.example.com",
353
                                   username=username, password=password,
354
                                   curl_factory=lambda: FakeCurl(handler))
355

    
356
  def _GetLuxiCalls(self):
357
    """Returns the names of all called LUXI client functions.
358

359
    """
360
    return self._lcr.CalledNames()
361

    
362
  def __getattr__(self, name):
363
    """Finds method by name.
364

365
    The method is wrapped using L{_TestWrapper} to produce the actual test
366
    result.
367

368
    """
369
    return _HideInternalErrors(compat.partial(_TestWrapper,
370
                                              getattr(self._client, name)))