]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/dashboard/rest_client.py
buildsys: switch source download to quincy
[ceph.git] / ceph / src / pybind / mgr / dashboard / rest_client.py
CommitLineData
11fdf7f2
TL
1# -*- coding: utf-8 -*-
2"""
3 * Copyright (c) 2017 SUSE LLC
4 *
5 * openATTIC is free software; you can redistribute it and/or modify it
6 * under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; version 2.
8 *
9 * This package is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13"""
14from __future__ import absolute_import
15
11fdf7f2 16import inspect
9f95a23c 17import logging
11fdf7f2 18import re
f67539c2 19
11fdf7f2 20import requests
522d829b 21from requests.auth import AuthBase
11fdf7f2 22from requests.exceptions import ConnectionError, InvalidURL, Timeout
f67539c2 23
9f95a23c 24from .settings import Settings
11fdf7f2
TL
25
26try:
27 from requests.packages.urllib3.exceptions import SSLError
28except ImportError:
9f95a23c
TL
29 from urllib3.exceptions import SSLError # type: ignore
30
522d829b 31from typing import List, Optional
9f95a23c 32
522d829b 33from mgr_util import build_url
9f95a23c
TL
34
35logger = logging.getLogger('rest_client')
11fdf7f2
TL
36
37
38class TimeoutRequestsSession(requests.Session):
39 """
40 Set timeout argument for all requests if this is not already done.
41 """
f67539c2 42
11fdf7f2
TL
43 def request(self, *args, **kwargs):
44 if ((args[8] if len(args) > 8 else None) is None) \
45 and kwargs.get('timeout') is None:
46 if Settings.REST_REQUESTS_TIMEOUT > 0:
47 kwargs['timeout'] = Settings.REST_REQUESTS_TIMEOUT
48 return super(TimeoutRequestsSession, self).request(*args, **kwargs)
49
50
51class RequestException(Exception):
52 def __init__(self,
53 message,
54 status_code=None,
55 content=None,
56 conn_errno=None,
57 conn_strerror=None):
58 super(RequestException, self).__init__(message)
59 self.status_code = status_code
60 self.content = content
61 self.conn_errno = conn_errno
62 self.conn_strerror = conn_strerror
63
64
65class BadResponseFormatException(RequestException):
66 def __init__(self, message):
67 super(BadResponseFormatException, self).__init__(
68 "Bad response format" if message is None else message, None)
69
70
71class _ResponseValidator(object):
72 """Simple JSON schema validator
73
74 This class implements a very simple validator for the JSON formatted
75 messages received by request responses from a RestClient instance.
76
77 The validator validates the JSON response against a "structure" string that
78 specifies the structure that the JSON response must comply. The validation
79 procedure raises a BadResponseFormatException in case of a validation
80 failure.
81
82 The structure syntax is given by the following grammar:
83
84 Structure ::= Level
85 Level ::= Path | Path '&' Level
86 Path ::= Step | Step '>'+ Path
87 Step ::= Key | '?' Key | '*' | '(' Level ')'
88 Key ::= <string> | Array+
89 Array ::= '[' <int> ']' | '[' '*' ']' | '[' '+' ']'
90
91 The symbols enclosed in ' ' are tokens of the language, and the + symbol
92 denotes repetition of of the preceding token at least once.
93
94 Examples of usage:
95
96 Example 1:
97 Validator args:
98 structure = "return > *"
99 response = { 'return': { ... } }
100
101 In the above example the structure will validate against any response
102 that contains a key named "return" in the root of the response
103 dictionary and its value is also a dictionary.
104
105 Example 2:
106 Validator args:
107 structure = "[*]"
108 response = [...]
109
110 In the above example the structure will validate against any response
111 that is an array of any size.
112
113 Example 3:
114 Validator args:
115 structure = "return[*]"
116 response = { 'return': [....] }
117
118 In the above example the structure will validate against any response
119 that contains a key named "return" in the root of the response
120 dictionary and its value is an array.
121
122 Example 4:
123 Validator args:
124 structure = "return[0] > token"
125 response = { 'return': [ { 'token': .... } ] }
126
127 In the above example the structure will validate against any response
128 that contains a key named "return" in the root of the response
129 dictionary and its value is an array, and the first element of the
130 array is a dictionary that contains the key 'token'.
131
132 Example 5:
133 Validator args:
134 structure = "return[0][*] > key1"
135 response = { 'return': [ [ { 'key1': ... } ], ...] }
136
137 In the above example the structure will validate against any response
138 that contains a key named "return" in the root of the response
139 dictionary where its value is an array, and the first value of this
140 array is also an array where all it's values must be a dictionary
141 containing a key named "key1".
142
143 Example 6:
144 Validator args:
145 structure = "return > (key1[*] & key2 & ?key3 > subkey)"
146 response = { 'return': { 'key1': [...], 'key2: .... } ] }
147
148 In the above example the structure will validate against any response
149 that contains a key named "return" in the root of the response
150 dictionary and its value is a dictionary that must contain a key named
151 "key1" that is an array, a key named "key2", and optionally a key named
152 "key3" that is a dictionary that contains a key named "subkey".
153
154 Example 7:
155 Validator args:
156 structure = "return >> roles[*]"
157 response = { 'return': { 'key1': { 'roles': [...] }, 'key2': { 'roles': [...] } } }
158
159 In the above example the structure will validate against any response
160 that contains a key named "return" in the root of the response
161 dictionary, and its value is a dictionary that for any key present in
162 the dictionary their value is also a dictionary that must contain a key
163 named 'roles' that is an array. Please note that you can use any
164 number of successive '>' to denote the level in the JSON tree that you
165 want to match next step in the path.
166
167 """
168
169 @staticmethod
170 def validate(structure, response):
171 if structure is None:
172 return
173
174 _ResponseValidator._validate_level(structure, response)
175
176 @staticmethod
177 def _validate_level(level, resp):
178 if not isinstance(resp, dict) and not isinstance(resp, list):
179 raise BadResponseFormatException(
180 "{} is neither a dict nor a list".format(resp))
181
182 paths = _ResponseValidator._parse_level_paths(level)
183 for path in paths:
184 path_sep = path.find('>')
185 if path_sep != -1:
186 level_next = path[path_sep + 1:].strip()
187 else:
188 path_sep = len(path)
9f95a23c 189 level_next = None # type: ignore
11fdf7f2
TL
190 key = path[:path_sep].strip()
191
192 if key == '*':
193 continue
194 elif key == '': # check all keys
9f95a23c 195 for k in resp.keys(): # type: ignore
11fdf7f2
TL
196 _ResponseValidator._validate_key(k, level_next, resp)
197 else:
198 _ResponseValidator._validate_key(key, level_next, resp)
199
200 @staticmethod
201 def _validate_array(array_seq, level_next, resp):
202 if array_seq:
203 if not isinstance(resp, list):
204 raise BadResponseFormatException(
205 "{} is not an array".format(resp))
206 if array_seq[0].isdigit():
207 idx = int(array_seq[0])
208 if len(resp) <= idx:
209 raise BadResponseFormatException(
210 "length of array {} is lower than the index {}".format(
211 resp, idx))
212 _ResponseValidator._validate_array(array_seq[1:], level_next,
213 resp[idx])
214 elif array_seq[0] == '*':
215 for r in resp:
216 _ResponseValidator._validate_array(array_seq[1:],
217 level_next, r)
218 elif array_seq[0] == '+':
219 if len(resp) < 1:
220 raise BadResponseFormatException(
221 "array should not be empty")
222 for r in resp:
223 _ResponseValidator._validate_array(array_seq[1:],
224 level_next, r)
225 else:
226 raise Exception(
227 "Response structure is invalid: only <int> | '*' are "
228 "allowed as array index arguments")
229 else:
230 if level_next:
231 _ResponseValidator._validate_level(level_next, resp)
232
233 @staticmethod
234 def _validate_key(key, level_next, resp):
235 array_access = [a.strip() for a in key.split("[")]
236 key = array_access[0]
237 if key:
238 optional = key[0] == '?'
239 if optional:
240 key = key[1:]
241 if key not in resp:
242 if optional:
243 return
244 raise BadResponseFormatException(
245 "key {} is not in dict {}".format(key, resp))
246 resp_next = resp[key]
247 else:
248 resp_next = resp
249 if len(array_access) > 1:
250 _ResponseValidator._validate_array(
251 [a[0:-1] for a in array_access[1:]], level_next, resp_next)
252 else:
253 if level_next:
254 _ResponseValidator._validate_level(level_next, resp_next)
255
256 @staticmethod
257 def _parse_level_paths(level):
9f95a23c 258 # type: (str) -> List[str]
11fdf7f2
TL
259 level = level.strip()
260 if level[0] == '(':
261 level = level[1:]
262 if level[-1] == ')':
263 level = level[:-1]
264
265 paths = []
266 lp = 0
267 nested = 0
268 for i, c in enumerate(level):
269 if c == '&' and nested == 0:
270 paths.append(level[lp:i].strip())
271 lp = i + 1
272 elif c == '(':
273 nested += 1
274 elif c == ')':
275 nested -= 1
276 paths.append(level[lp:].strip())
277 return paths
278
279
280class _Request(object):
281 def __init__(self, method, path, path_params, rest_client, resp_structure):
282 self.method = method
283 self.path = path
284 self.path_params = path_params
285 self.rest_client = rest_client
286 self.resp_structure = resp_structure
287
288 def _gen_path(self):
289 new_path = self.path
290 matches = re.finditer(r'\{(\w+?)\}', self.path)
291 for match in matches:
292 if match:
293 param_key = match.group(1)
294 if param_key in self.path_params:
295 new_path = new_path.replace(
296 match.group(0), self.path_params[param_key])
297 else:
298 raise RequestException(
299 'Invalid path. Param "{}" was not specified'
300 .format(param_key), None)
301 return new_path
302
303 def __call__(self,
304 req_data=None,
305 method=None,
306 params=None,
307 data=None,
9f95a23c
TL
308 raw_content=False,
309 headers=None):
11fdf7f2
TL
310 method = method if method else self.method
311 if not method:
312 raise Exception('No HTTP request method specified')
313 if req_data:
314 if method == 'get':
315 if params:
316 raise Exception('Ambiguous source of GET params')
317 params = req_data
318 else:
319 if data:
320 raise Exception('Ambiguous source of {} data'.format(
321 method.upper()))
322 data = req_data
323 resp = self.rest_client.do_request(method, self._gen_path(), params,
9f95a23c 324 data, raw_content, headers)
11fdf7f2
TL
325 if raw_content and self.resp_structure:
326 raise Exception("Cannot validate response in raw format")
327 _ResponseValidator.validate(self.resp_structure, resp)
328 return resp
329
330
331class RestClient(object):
522d829b
TL
332 def __init__(self,
333 host: str,
334 port: int,
335 client_name: Optional[str] = None,
336 ssl: bool = False,
337 auth: Optional[AuthBase] = None,
338 ssl_verify: bool = True) -> None:
11fdf7f2
TL
339 super(RestClient, self).__init__()
340 self.client_name = client_name if client_name else ''
341 self.host = host
342 self.port = port
343 self.base_url = build_url(
344 scheme='https' if ssl else 'http', host=host, port=port)
345 logger.debug("REST service base URL: %s", self.base_url)
346 self.headers = {'Accept': 'application/json'}
347 self.auth = auth
348 self.session = TimeoutRequestsSession()
349 self.session.verify = ssl_verify
350
351 def _login(self, request=None):
352 pass
353
354 def _is_logged_in(self):
355 pass
356
357 def _reset_login(self):
358 pass
359
360 def is_service_online(self, request=None):
361 pass
362
363 @staticmethod
364 def requires_login(func):
365 def func_wrapper(self, *args, **kwargs):
366 retries = 2
367 while True:
368 try:
369 if not self._is_logged_in():
370 self._login()
371 resp = func(self, *args, **kwargs)
372 return resp
373 except RequestException as e:
374 if isinstance(e, BadResponseFormatException):
375 raise e
376 retries -= 1
377 if e.status_code not in [401, 403] or retries == 0:
378 raise e
379 self._reset_login()
380
381 return func_wrapper
382
383 def do_request(self,
384 method,
385 path,
386 params=None,
387 data=None,
9f95a23c
TL
388 raw_content=False,
389 headers=None):
11fdf7f2
TL
390 url = '{}{}'.format(self.base_url, path)
391 logger.debug('%s REST API %s req: %s data: %s', self.client_name,
392 method.upper(), path, data)
9f95a23c
TL
393 request_headers = self.headers.copy()
394 if headers:
395 request_headers.update(headers)
11fdf7f2
TL
396 try:
397 if method.lower() == 'get':
398 resp = self.session.get(
9f95a23c 399 url, headers=request_headers, params=params, auth=self.auth)
11fdf7f2
TL
400 elif method.lower() == 'post':
401 resp = self.session.post(
402 url,
9f95a23c 403 headers=request_headers,
11fdf7f2
TL
404 params=params,
405 data=data,
406 auth=self.auth)
407 elif method.lower() == 'put':
408 resp = self.session.put(
409 url,
9f95a23c 410 headers=request_headers,
11fdf7f2
TL
411 params=params,
412 data=data,
413 auth=self.auth)
414 elif method.lower() == 'delete':
415 resp = self.session.delete(
416 url,
9f95a23c 417 headers=request_headers,
11fdf7f2
TL
418 params=params,
419 data=data,
420 auth=self.auth)
421 else:
422 raise RequestException('Method "{}" not supported'.format(
423 method.upper()), None)
424 if resp.ok:
425 logger.debug("%s REST API %s res status: %s content: %s",
426 self.client_name, method.upper(),
427 resp.status_code, resp.text)
428 if raw_content:
429 return resp.content
430 try:
431 return resp.json() if resp.text else None
432 except ValueError:
433 logger.error(
434 "%s REST API failed %s req while decoding JSON "
435 "response : %s",
436 self.client_name, method.upper(), resp.text)
437 raise RequestException(
438 "{} REST API failed request while decoding JSON "
439 "response: {}".format(self.client_name, resp.text),
440 resp.status_code, resp.text)
441 else:
442 logger.error(
443 "%s REST API failed %s req status: %s", self.client_name,
444 method.upper(), resp.status_code)
11fdf7f2
TL
445 from pprint import pformat as pf
446
447 raise RequestException(
448 "{} REST API failed request with status code {}\n"
449 "{}" # TODO remove
450 .format(self.client_name, resp.status_code, pf(
451 resp.content)),
f67539c2 452 self._handle_response_status_code(resp.status_code),
11fdf7f2
TL
453 resp.content)
454 except ConnectionError as ex:
455 if ex.args:
456 if isinstance(ex.args[0], SSLError):
457 errno = "n/a"
458 strerror = "SSL error. Probably trying to access a non " \
459 "SSL connection."
9f95a23c
TL
460 logger.error("%s REST API failed %s, SSL error (url=%s).",
461 self.client_name, method.upper(), ex.request.url)
11fdf7f2
TL
462 else:
463 try:
464 match = re.match(r'.*: \[Errno (-?\d+)\] (.+)',
465 ex.args[0].reason.args[0])
466 except AttributeError:
9f95a23c 467 match = None
11fdf7f2
TL
468 if match:
469 errno = match.group(1)
470 strerror = match.group(2)
471 logger.error(
9f95a23c 472 "%s REST API failed %s, connection error (url=%s): "
11fdf7f2 473 "[errno: %s] %s",
9f95a23c
TL
474 self.client_name, method.upper(), ex.request.url,
475 errno, strerror)
11fdf7f2
TL
476 else:
477 errno = "n/a"
478 strerror = "n/a"
479 logger.error(
9f95a23c
TL
480 "%s REST API failed %s, connection error (url=%s).",
481 self.client_name, method.upper(), ex.request.url)
11fdf7f2
TL
482 else:
483 errno = "n/a"
484 strerror = "n/a"
9f95a23c
TL
485 logger.error("%s REST API failed %s, connection error (url=%s).",
486 self.client_name, method.upper(), ex.request.url)
11fdf7f2
TL
487
488 if errno != "n/a":
489 ex_msg = (
490 "{} REST API cannot be reached: {} [errno {}]. "
491 "Please check your configuration and that the API endpoint"
492 " is accessible"
493 .format(self.client_name, strerror, errno))
494 else:
495 ex_msg = (
496 "{} REST API cannot be reached. Please check "
497 "your configuration and that the API endpoint is"
498 " accessible"
499 .format(self.client_name))
500 raise RequestException(
501 ex_msg, conn_errno=errno, conn_strerror=strerror)
502 except InvalidURL as ex:
503 logger.exception("%s REST API failed %s: %s", self.client_name,
504 method.upper(), str(ex))
505 raise RequestException(str(ex))
506 except Timeout as ex:
507 msg = "{} REST API {} timed out after {} seconds (url={}).".format(
508 self.client_name, ex.request.method, Settings.REST_REQUESTS_TIMEOUT,
509 ex.request.url)
510 logger.exception(msg)
511 raise RequestException(msg)
512
f67539c2
TL
513 @staticmethod
514 def _handle_response_status_code(status_code: int) -> int:
515 """
516 Method to be overridden by subclasses that need specific handling.
517 """
518 return status_code
519
11fdf7f2
TL
520 @staticmethod
521 def api(path, **api_kwargs):
522 def call_decorator(func):
523 def func_wrapper(self, *args, **kwargs):
524 method = api_kwargs.get('method', None)
525 resp_structure = api_kwargs.get('resp_structure', None)
f67539c2 526 args_name = inspect.getfullargspec(func).args
11fdf7f2 527 args_dict = dict(zip(args_name[1:], args))
f67539c2 528 for key, val in kwargs.items():
11fdf7f2
TL
529 args_dict[key] = val
530 return func(
531 self,
532 *args,
533 request=_Request(method, path, args_dict, self,
534 resp_structure),
535 **kwargs)
536
537 return func_wrapper
538
539 return call_decorator
540
541 @staticmethod
542 def api_get(path, resp_structure=None):
543 return RestClient.api(
544 path, method='get', resp_structure=resp_structure)
545
546 @staticmethod
547 def api_post(path, resp_structure=None):
548 return RestClient.api(
549 path, method='post', resp_structure=resp_structure)
550
551 @staticmethod
552 def api_put(path, resp_structure=None):
553 return RestClient.api(
554 path, method='put', resp_structure=resp_structure)
555
556 @staticmethod
557 def api_delete(path, resp_structure=None):
558 return RestClient.api(
559 path, method='delete', resp_structure=resp_structure)