Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / testutils.py @ 1fa6fcba

History | View | Annotate | Download (9.9 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
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, reqauth=False):
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, reqauth, _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, address=None):
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
                       address=address)
317

    
318

    
319
def _TestWrapper(fn, *args, **kwargs):
320
  """Wrapper for ignoring L{errors.RapiTestResult}.
321

322
  """
323
  try:
324
    return fn(*args, **kwargs)
325
  except errors.RapiTestResult:
326
    # Everything was fine up to the point of sending a LUXI request
327
    return NotImplemented
328

    
329

    
330
class InputTestClient:
331
  """Test version of RAPI client.
332

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

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

342
  """
343
  def __init__(self):
344
    """Initializes this class.
345

346
    """
347
    username = utils.GenerateSecret()
348
    password = utils.GenerateSecret()
349

    
350
    def user_fn(wanted):
351
      """Called to verify user credentials given in HTTP request.
352

353
      """
354
      assert username == wanted
355
      return http.auth.PasswordFileUser(username, password,
356
                                        [rapi.RAPI_ACCESS_WRITE])
357

    
358
    self._lcr = _LuxiCallRecorder()
359

    
360
    # Create a mock RAPI server
361
    handler = _RapiMock(user_fn, self._lcr)
362

    
363
    self._client = \
364
      rapi.client.GanetiRapiClient("master.example.com",
365
                                   username=username, password=password,
366
                                   curl_factory=lambda: FakeCurl(handler))
367

    
368
  def _GetLuxiCalls(self):
369
    """Returns the names of all called LUXI client functions.
370

371
    """
372
    return self._lcr.CalledNames()
373

    
374
  def __getattr__(self, name):
375
    """Finds method by name.
376

377
    The method is wrapped using L{_TestWrapper} to produce the actual test
378
    result.
379

380
    """
381
    return _HideInternalErrors(compat.partial(_TestWrapper,
382
                                              getattr(self._client, name)))