Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (9.8 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):
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)))