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