root / lib / rapi / testutils.py @ 912b2278
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))) |