Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / testutils.py @ 5349519d

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
import ganeti.rpc.client as rpccl
40
from ganeti import rapi
41

    
42
import ganeti.http.server # pylint: disable=W0611
43
import ganeti.server.rapi
44
import ganeti.rapi.client
45

    
46

    
47
_URI_RE = re.compile(r"https://(?P<host>.*):(?P<port>\d+)(?P<path>/.*)")
48

    
49

    
50
class VerificationError(Exception):
51
  """Dedicated error class for test utilities.
52

53
  This class is used to hide all of Ganeti's internal exception, so that
54
  external users of these utilities don't have to integrate Ganeti's exception
55
  hierarchy.
56

57
  """
58

    
59

    
60
def _GetOpById(op_id):
61
  """Tries to get an opcode class based on its C{OP_ID}.
62

63
  """
64
  try:
65
    return opcodes.OP_MAPPING[op_id]
66
  except KeyError:
67
    raise VerificationError("Unknown opcode ID '%s'" % op_id)
68

    
69

    
70
def _HideInternalErrors(fn):
71
  """Hides Ganeti-internal exceptions, see L{VerificationError}.
72

73
  """
74
  def wrapper(*args, **kwargs):
75
    try:
76
      return fn(*args, **kwargs)
77
    except (errors.GenericError, rapi.client.GanetiApiError), err:
78
      raise VerificationError("Unhandled Ganeti error: %s" % err)
79

    
80
  return wrapper
81

    
82

    
83
@_HideInternalErrors
84
def VerifyOpInput(op_id, data):
85
  """Verifies opcode parameters according to their definition.
86

87
  @type op_id: string
88
  @param op_id: Opcode ID (C{OP_ID} attribute), e.g. C{OP_CLUSTER_VERIFY}
89
  @type data: dict
90
  @param data: Opcode parameter values
91
  @raise VerificationError: Parameter verification failed
92

93
  """
94
  op_cls = _GetOpById(op_id)
95

    
96
  try:
97
    op = op_cls(**data) # pylint: disable=W0142
98
  except TypeError, err:
99
    raise VerificationError("Unable to create opcode instance: %s" % err)
100

    
101
  try:
102
    op.Validate(False)
103
  except errors.OpPrereqError, err:
104
    raise VerificationError("Parameter validation for opcode '%s' failed: %s" %
105
                            (op_id, err))
106

    
107

    
108
@_HideInternalErrors
109
def VerifyOpResult(op_id, result):
110
  """Verifies opcode results used in tests (e.g. in a mock).
111

112
  @type op_id: string
113
  @param op_id: Opcode ID (C{OP_ID} attribute), e.g. C{OP_CLUSTER_VERIFY}
114
  @param result: Mocked opcode result
115
  @raise VerificationError: Return value verification failed
116

117
  """
118
  resultcheck_fn = _GetOpById(op_id).OP_RESULT
119

    
120
  if not resultcheck_fn:
121
    logging.warning("Opcode '%s' has no result type definition", op_id)
122
  elif not resultcheck_fn(result):
123
    raise VerificationError("Given result does not match result description"
124
                            " for opcode '%s': %s" % (op_id, resultcheck_fn))
125

    
126

    
127
def _GetPathFromUri(uri):
128
  """Gets the path and query from a URI.
129

130
  """
131
  match = _URI_RE.match(uri)
132
  if match:
133
    return match.groupdict()["path"]
134
  else:
135
    return None
136

    
137

    
138
def _FormatHeaders(headers):
139
  """Formats HTTP headers.
140

141
  @type headers: sequence of strings
142
  @rtype: string
143

144
  """
145
  assert compat.all(": " in header for header in headers)
146
  return "\n".join(headers)
147

    
148

    
149
class FakeCurl:
150
  """Fake cURL object.
151

152
  """
153
  def __init__(self, handler):
154
    """Initialize this class
155

156
    @param handler: Request handler instance
157

158
    """
159
    self._handler = handler
160
    self._opts = {}
161
    self._info = {}
162

    
163
  def setopt(self, opt, value):
164
    self._opts[opt] = value
165

    
166
  def getopt(self, opt):
167
    return self._opts.get(opt)
168

    
169
  def unsetopt(self, opt):
170
    self._opts.pop(opt, None)
171

    
172
  def getinfo(self, info):
173
    return self._info[info]
174

    
175
  def perform(self):
176
    method = self._opts[pycurl.CUSTOMREQUEST]
177
    url = self._opts[pycurl.URL]
178
    request_body = self._opts[pycurl.POSTFIELDS]
179
    writefn = self._opts[pycurl.WRITEFUNCTION]
180

    
181
    if pycurl.HTTPHEADER in self._opts:
182
      baseheaders = _FormatHeaders(self._opts[pycurl.HTTPHEADER])
183
    else:
184
      baseheaders = ""
185

    
186
    headers = http.ParseHeaders(StringIO(baseheaders))
187

    
188
    if request_body:
189
      headers[http.HTTP_CONTENT_LENGTH] = str(len(request_body))
190

    
191
    if self._opts.get(pycurl.HTTPAUTH, 0) & pycurl.HTTPAUTH_BASIC:
192
      try:
193
        userpwd = self._opts[pycurl.USERPWD]
194
      except KeyError:
195
        raise errors.ProgrammerError("Basic authentication requires username"
196
                                     " and password")
197

    
198
      headers[http.HTTP_AUTHORIZATION] = \
199
        "%s %s" % (http.auth.HTTP_BASIC_AUTH, base64.b64encode(userpwd))
200

    
201
    path = _GetPathFromUri(url)
202
    (code, _, resp_body) = \
203
      self._handler.FetchResponse(path, method, headers, request_body)
204

    
205
    self._info[pycurl.RESPONSE_CODE] = code
206
    if resp_body is not None:
207
      writefn(resp_body)
208

    
209

    
210
class _RapiMock:
211
  """Mocking out the RAPI server parts.
212

213
  """
214
  def __init__(self, user_fn, luxi_client, reqauth=False):
215
    """Initialize this class.
216

217
    @type user_fn: callable
218
    @param user_fn: Function to authentication username
219
    @param luxi_client: A LUXI client implementation
220

221
    """
222
    self.handler = \
223
      server.rapi.RemoteApiHandler(user_fn, reqauth, _client_cls=luxi_client)
224

    
225
  def FetchResponse(self, path, method, headers, request_body):
226
    """This is a callback method used to fetch a response.
227

228
    This method is called by the FakeCurl.perform method
229

230
    @type path: string
231
    @param path: Requested path
232
    @type method: string
233
    @param method: HTTP method
234
    @type request_body: string
235
    @param request_body: Request body
236
    @type headers: mimetools.Message
237
    @param headers: Request headers
238
    @return: Tuple containing status code, response headers and response body
239

240
    """
241
    req_msg = http.HttpMessage()
242
    req_msg.start_line = \
243
      http.HttpClientToServerStartLine(method, path, http.HTTP_1_0)
244
    req_msg.headers = headers
245
    req_msg.body = request_body
246

    
247
    (_, _, _, resp_msg) = \
248
      http.server.HttpResponder(self.handler)(lambda: (req_msg, None))
249

    
250
    return (resp_msg.start_line.code, resp_msg.headers, resp_msg.body)
251

    
252

    
253
class _TestLuxiTransport:
254
  """Mocked LUXI transport.
255

256
  Raises L{errors.RapiTestResult} for all method calls, no matter the
257
  arguments.
258

259
  """
260
  def __init__(self, record_fn, address, timeouts=None): # pylint: disable=W0613
261
    """Initializes this class.
262

263
    """
264
    self._record_fn = record_fn
265

    
266
  def Close(self):
267
    pass
268

    
269
  def Call(self, data):
270
    """Calls LUXI method.
271

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

276
    """
277
    (method, _, _) = rpccl.ParseRequest(data)
278

    
279
    # Take a note of called method
280
    self._record_fn(method)
281

    
282
    # Everything went fine until here, so let's abort the test
283
    raise errors.RapiTestResult
284

    
285

    
286
class _LuxiCallRecorder:
287
  """Records all called LUXI client methods.
288

289
  """
290
  def __init__(self):
291
    """Initializes this class.
292

293
    """
294
    self._called = set()
295

    
296
  def Record(self, name):
297
    """Records a called function name.
298

299
    """
300
    self._called.add(name)
301

    
302
  def CalledNames(self):
303
    """Returns a list of called LUXI methods.
304

305
    """
306
    return self._called
307

    
308
  def __call__(self, address=None):
309
    """Creates an instrumented LUXI client.
310

311
    The LUXI client will record all method calls (use L{CalledNames} to
312
    retrieve them).
313

314
    """
315
    return luxi.Client(transport=compat.partial(_TestLuxiTransport,
316
                                                self.Record),
317
                       address=address)
318

    
319

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

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

    
330

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

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

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

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

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

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

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

    
359
    self._lcr = _LuxiCallRecorder()
360

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

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

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

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

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

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

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