Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / testutils.py @ 303bc802

History | View | Annotate | Download (9.7 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 mimetools
29
import base64
30
import pycurl
31
from cStringIO import StringIO
32

    
33
from ganeti import errors
34
from ganeti import opcodes
35
from ganeti import http
36
from ganeti import server
37
from ganeti import utils
38
from ganeti import compat
39
from ganeti import luxi
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
class FakeCurl:
139
  """Fake cURL object.
140

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

145
    @param handler: Request handler instance
146

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

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

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

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

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

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

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

    
175
    headers = mimetools.Message(StringIO(baseheaders), 0)
176

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

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

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

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

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

    
198

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

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

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

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

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

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

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

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

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

    
239
    return (resp_msg.start_line.code, resp_msg.body)
240

    
241

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

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

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

252
    """
253
    self._record_fn = record_fn
254

    
255
  def Close(self):
256
    pass
257

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

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

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

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

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

    
274

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

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

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

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

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

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

294
    """
295
    return self._called
296

    
297
  def __call__(self, address=None):
298
    """Creates an instrumented LUXI client.
299

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

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

    
308

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

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

    
319

    
320
class InputTestClient:
321
  """Test version of RAPI client.
322

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

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

332
  """
333
  def __init__(self):
334
    """Initializes this class.
335

336
    """
337
    username = utils.GenerateSecret()
338
    password = utils.GenerateSecret()
339

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

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

    
348
    self._lcr = _LuxiCallRecorder()
349

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

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

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

361
    """
362
    return self._lcr.CalledNames()
363

    
364
  def __getattr__(self, name):
365
    """Finds method by name.
366

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

370
    """
371
    return _HideInternalErrors(compat.partial(_TestWrapper,
372
                                              getattr(self._client, name)))