]>
Commit | Line | Data |
---|---|---|
11fdf7f2 TL |
1 | # -*- coding: utf-8 -*- |
2 | # pylint: disable=W0212,too-many-return-statements | |
3 | from __future__ import absolute_import | |
4 | ||
5 | import json | |
6 | import logging | |
7 | from collections import namedtuple | |
8 | import time | |
9 | ||
10 | import requests | |
11 | import six | |
12 | from teuthology.exceptions import CommandFailedError | |
13 | ||
e306af50 | 14 | from tasks.mgr.mgr_test_case import MgrTestCase |
11fdf7f2 TL |
15 | |
16 | ||
17 | log = logging.getLogger(__name__) | |
18 | ||
19 | ||
20 | class DashboardTestCase(MgrTestCase): | |
21 | MGRS_REQUIRED = 2 | |
22 | MDSS_REQUIRED = 1 | |
23 | REQUIRE_FILESYSTEM = True | |
24 | CLIENTS_REQUIRED = 1 | |
25 | CEPHFS = False | |
26 | ||
27 | _session = None # type: requests.sessions.Session | |
28 | _token = None | |
29 | _resp = None # type: requests.models.Response | |
30 | _loggedin = False | |
31 | _base_uri = None | |
32 | ||
33 | AUTO_AUTHENTICATE = True | |
34 | ||
35 | AUTH_ROLES = ['administrator'] | |
36 | ||
37 | @classmethod | |
9f95a23c TL |
38 | def create_user(cls, username, password, roles=None, |
39 | force_password=True, cmd_args=None): | |
40 | """ | |
41 | :param username: The name of the user. | |
42 | :type username: str | |
43 | :param password: The password. | |
44 | :type password: str | |
45 | :param roles: A list of roles. | |
46 | :type roles: list | |
47 | :param force_password: Force the use of the specified password. This | |
48 | will bypass the password complexity check. Defaults to 'True'. | |
49 | :type force_password: bool | |
50 | :param cmd_args: Additional command line arguments for the | |
51 | 'ac-user-create' command. | |
52 | :type cmd_args: None | list[str] | |
53 | """ | |
11fdf7f2 TL |
54 | try: |
55 | cls._ceph_cmd(['dashboard', 'ac-user-show', username]) | |
56 | cls._ceph_cmd(['dashboard', 'ac-user-delete', username]) | |
57 | except CommandFailedError as ex: | |
58 | if ex.exitstatus != 2: | |
59 | raise ex | |
60 | ||
9f95a23c TL |
61 | user_create_args = [ |
62 | 'dashboard', 'ac-user-create', username, password | |
63 | ] | |
64 | if force_password: | |
65 | user_create_args.append('--force-password') | |
66 | if cmd_args: | |
67 | user_create_args.extend(cmd_args) | |
68 | cls._ceph_cmd(user_create_args) | |
69 | ||
70 | if roles: | |
71 | set_roles_args = ['dashboard', 'ac-user-set-roles', username] | |
72 | for idx, role in enumerate(roles): | |
73 | if isinstance(role, str): | |
74 | set_roles_args.append(role) | |
75 | else: | |
76 | assert isinstance(role, dict) | |
77 | rolename = 'test_role_{}'.format(idx) | |
78 | try: | |
79 | cls._ceph_cmd(['dashboard', 'ac-role-show', rolename]) | |
80 | cls._ceph_cmd(['dashboard', 'ac-role-delete', rolename]) | |
81 | except CommandFailedError as ex: | |
82 | if ex.exitstatus != 2: | |
83 | raise ex | |
84 | cls._ceph_cmd(['dashboard', 'ac-role-create', rolename]) | |
85 | for mod, perms in role.items(): | |
86 | args = ['dashboard', 'ac-role-add-scope-perms', rolename, mod] | |
87 | args.extend(perms) | |
88 | cls._ceph_cmd(args) | |
89 | set_roles_args.append(rolename) | |
90 | cls._ceph_cmd(set_roles_args) | |
11fdf7f2 TL |
91 | |
92 | @classmethod | |
93 | def login(cls, username, password): | |
94 | if cls._loggedin: | |
95 | cls.logout() | |
96 | cls._post('/api/auth', {'username': username, 'password': password}) | |
9f95a23c | 97 | cls._assertEq(cls._resp.status_code, 201) |
11fdf7f2 TL |
98 | cls._token = cls.jsonBody()['token'] |
99 | cls._loggedin = True | |
100 | ||
101 | @classmethod | |
102 | def logout(cls): | |
103 | if cls._loggedin: | |
104 | cls._post('/api/auth/logout') | |
9f95a23c | 105 | cls._assertEq(cls._resp.status_code, 200) |
11fdf7f2 TL |
106 | cls._token = None |
107 | cls._loggedin = False | |
108 | ||
109 | @classmethod | |
110 | def delete_user(cls, username, roles=None): | |
111 | if roles is None: | |
112 | roles = [] | |
113 | cls._ceph_cmd(['dashboard', 'ac-user-delete', username]) | |
114 | for idx, role in enumerate(roles): | |
115 | if isinstance(role, dict): | |
116 | cls._ceph_cmd(['dashboard', 'ac-role-delete', 'test_role_{}'.format(idx)]) | |
117 | ||
118 | @classmethod | |
9f95a23c TL |
119 | def RunAs(cls, username, password, roles=None, force_password=True, |
120 | cmd_args=None, login=True): | |
11fdf7f2 TL |
121 | def wrapper(func): |
122 | def execute(self, *args, **kwargs): | |
9f95a23c TL |
123 | self.create_user(username, password, roles, |
124 | force_password, cmd_args) | |
125 | if login: | |
126 | self.login(username, password) | |
11fdf7f2 | 127 | res = func(self, *args, **kwargs) |
9f95a23c TL |
128 | if login: |
129 | self.logout() | |
11fdf7f2 TL |
130 | self.delete_user(username, roles) |
131 | return res | |
9f95a23c | 132 | |
11fdf7f2 | 133 | return execute |
9f95a23c | 134 | |
11fdf7f2 TL |
135 | return wrapper |
136 | ||
137 | @classmethod | |
138 | def set_jwt_token(cls, token): | |
139 | cls._token = token | |
140 | ||
141 | @classmethod | |
142 | def setUpClass(cls): | |
143 | super(DashboardTestCase, cls).setUpClass() | |
144 | cls._assign_ports("dashboard", "ssl_server_port") | |
145 | cls._load_module("dashboard") | |
146 | cls._base_uri = cls._get_uri("dashboard").rstrip('/') | |
147 | ||
148 | if cls.CEPHFS: | |
149 | cls.mds_cluster.clear_firewall() | |
150 | ||
151 | # To avoid any issues with e.g. unlink bugs, we destroy and recreate | |
152 | # the filesystem rather than just doing a rm -rf of files | |
153 | cls.mds_cluster.mds_stop() | |
154 | cls.mds_cluster.mds_fail() | |
155 | cls.mds_cluster.delete_all_filesystems() | |
156 | cls.fs = None # is now invalid! | |
157 | ||
158 | cls.fs = cls.mds_cluster.newfs(create=True) | |
159 | cls.fs.mds_restart() | |
160 | ||
161 | # In case some test messed with auth caps, reset them | |
162 | # pylint: disable=not-an-iterable | |
163 | client_mount_ids = [m.client_id for m in cls.mounts] | |
164 | for client_id in client_mount_ids: | |
165 | cls.mds_cluster.mon_manager.raw_cluster_cmd_result( | |
166 | 'auth', 'caps', "client.{0}".format(client_id), | |
167 | 'mds', 'allow', | |
168 | 'mon', 'allow r', | |
169 | 'osd', 'allow rw pool={0}'.format(cls.fs.get_data_pool_name())) | |
170 | ||
171 | # wait for mds restart to complete... | |
172 | cls.fs.wait_for_daemons() | |
173 | ||
174 | cls._token = None | |
175 | cls._session = requests.Session() | |
176 | cls._resp = None | |
177 | ||
178 | cls.create_user('admin', 'admin', cls.AUTH_ROLES) | |
179 | if cls.AUTO_AUTHENTICATE: | |
180 | cls.login('admin', 'admin') | |
181 | ||
182 | def setUp(self): | |
9f95a23c | 183 | super(DashboardTestCase, self).setUp() |
11fdf7f2 TL |
184 | if not self._loggedin and self.AUTO_AUTHENTICATE: |
185 | self.login('admin', 'admin') | |
186 | self.wait_for_health_clear(20) | |
187 | ||
188 | @classmethod | |
189 | def tearDownClass(cls): | |
190 | super(DashboardTestCase, cls).tearDownClass() | |
191 | ||
192 | # pylint: disable=inconsistent-return-statements | |
193 | @classmethod | |
194 | def _request(cls, url, method, data=None, params=None): | |
195 | url = "{}{}".format(cls._base_uri, url) | |
196 | log.info("Request %s to %s", method, url) | |
197 | headers = {} | |
198 | if cls._token: | |
199 | headers['Authorization'] = "Bearer {}".format(cls._token) | |
200 | ||
201 | if method == 'GET': | |
202 | cls._resp = cls._session.get(url, params=params, verify=False, | |
203 | headers=headers) | |
204 | elif method == 'POST': | |
205 | cls._resp = cls._session.post(url, json=data, params=params, | |
206 | verify=False, headers=headers) | |
207 | elif method == 'DELETE': | |
208 | cls._resp = cls._session.delete(url, json=data, params=params, | |
209 | verify=False, headers=headers) | |
210 | elif method == 'PUT': | |
211 | cls._resp = cls._session.put(url, json=data, params=params, | |
212 | verify=False, headers=headers) | |
213 | else: | |
214 | assert False | |
215 | try: | |
216 | if not cls._resp.ok: | |
217 | # Output response for easier debugging. | |
218 | log.error("Request response: %s", cls._resp.text) | |
219 | content_type = cls._resp.headers['content-type'] | |
220 | if content_type == 'application/json' and cls._resp.text and cls._resp.text != "": | |
221 | return cls._resp.json() | |
222 | return cls._resp.text | |
223 | except ValueError as ex: | |
224 | log.exception("Failed to decode response: %s", cls._resp.text) | |
225 | raise ex | |
226 | ||
227 | @classmethod | |
228 | def _get(cls, url, params=None): | |
229 | return cls._request(url, 'GET', params=params) | |
230 | ||
231 | @classmethod | |
232 | def _view_cache_get(cls, url, retries=5): | |
233 | retry = True | |
234 | while retry and retries > 0: | |
235 | retry = False | |
236 | res = cls._get(url) | |
237 | if isinstance(res, dict): | |
238 | res = [res] | |
239 | for view in res: | |
240 | assert 'value' in view | |
241 | if not view['value']: | |
242 | retry = True | |
243 | retries -= 1 | |
244 | if retries == 0: | |
245 | raise Exception("{} view cache exceeded number of retries={}" | |
246 | .format(url, retries)) | |
247 | return res | |
248 | ||
249 | @classmethod | |
250 | def _post(cls, url, data=None, params=None): | |
251 | cls._request(url, 'POST', data, params) | |
252 | ||
253 | @classmethod | |
254 | def _delete(cls, url, data=None, params=None): | |
255 | cls._request(url, 'DELETE', data, params) | |
256 | ||
257 | @classmethod | |
258 | def _put(cls, url, data=None, params=None): | |
259 | cls._request(url, 'PUT', data, params) | |
260 | ||
261 | @classmethod | |
262 | def _assertEq(cls, v1, v2): | |
263 | if not v1 == v2: | |
264 | raise Exception("assertion failed: {} != {}".format(v1, v2)) | |
265 | ||
266 | @classmethod | |
267 | def _assertIn(cls, v1, v2): | |
268 | if v1 not in v2: | |
269 | raise Exception("assertion failed: {} not in {}".format(v1, v2)) | |
270 | ||
271 | @classmethod | |
272 | def _assertIsInst(cls, v1, v2): | |
273 | if not isinstance(v1, v2): | |
274 | raise Exception("assertion failed: {} not instance of {}".format(v1, v2)) | |
275 | ||
276 | # pylint: disable=too-many-arguments | |
277 | @classmethod | |
278 | def _task_request(cls, method, url, data, timeout): | |
279 | res = cls._request(url, method, data) | |
9f95a23c | 280 | cls._assertIn(cls._resp.status_code, [200, 201, 202, 204, 400, 403, 404]) |
11fdf7f2 TL |
281 | |
282 | if cls._resp.status_code == 403: | |
283 | return None | |
284 | ||
285 | if cls._resp.status_code != 202: | |
286 | log.info("task finished immediately") | |
287 | return res | |
288 | ||
289 | cls._assertIn('name', res) | |
290 | cls._assertIn('metadata', res) | |
291 | task_name = res['name'] | |
292 | task_metadata = res['metadata'] | |
293 | ||
294 | retries = int(timeout) | |
295 | res_task = None | |
296 | while retries > 0 and not res_task: | |
297 | retries -= 1 | |
298 | log.info("task (%s, %s) is still executing", task_name, | |
299 | task_metadata) | |
300 | time.sleep(1) | |
301 | _res = cls._get('/api/task?name={}'.format(task_name)) | |
302 | cls._assertEq(cls._resp.status_code, 200) | |
303 | executing_tasks = [task for task in _res['executing_tasks'] if | |
304 | task['metadata'] == task_metadata] | |
305 | finished_tasks = [task for task in _res['finished_tasks'] if | |
306 | task['metadata'] == task_metadata] | |
307 | if not executing_tasks and finished_tasks: | |
308 | res_task = finished_tasks[0] | |
309 | ||
310 | if retries <= 0: | |
311 | raise Exception("Waiting for task ({}, {}) to finish timed out. {}" | |
312 | .format(task_name, task_metadata, _res)) | |
313 | ||
314 | log.info("task (%s, %s) finished", task_name, task_metadata) | |
315 | if res_task['success']: | |
316 | if method == 'POST': | |
317 | cls._resp.status_code = 201 | |
318 | elif method == 'PUT': | |
319 | cls._resp.status_code = 200 | |
320 | elif method == 'DELETE': | |
321 | cls._resp.status_code = 204 | |
322 | return res_task['ret_value'] | |
323 | else: | |
324 | if 'status' in res_task['exception']: | |
325 | cls._resp.status_code = res_task['exception']['status'] | |
326 | else: | |
327 | cls._resp.status_code = 500 | |
328 | return res_task['exception'] | |
329 | ||
330 | @classmethod | |
331 | def _task_post(cls, url, data=None, timeout=60): | |
332 | return cls._task_request('POST', url, data, timeout) | |
333 | ||
334 | @classmethod | |
335 | def _task_delete(cls, url, timeout=60): | |
336 | return cls._task_request('DELETE', url, None, timeout) | |
337 | ||
338 | @classmethod | |
339 | def _task_put(cls, url, data=None, timeout=60): | |
340 | return cls._task_request('PUT', url, data, timeout) | |
341 | ||
342 | @classmethod | |
343 | def cookies(cls): | |
344 | return cls._resp.cookies | |
345 | ||
346 | @classmethod | |
347 | def jsonBody(cls): | |
348 | return cls._resp.json() | |
349 | ||
350 | @classmethod | |
351 | def reset_session(cls): | |
352 | cls._session = requests.Session() | |
353 | ||
354 | def assertSubset(self, data, biggerData): | |
355 | for key, value in data.items(): | |
356 | self.assertEqual(biggerData[key], value) | |
357 | ||
358 | def assertJsonBody(self, data): | |
359 | body = self._resp.json() | |
360 | self.assertEqual(body, data) | |
361 | ||
362 | def assertJsonSubset(self, data): | |
363 | self.assertSubset(data, self._resp.json()) | |
364 | ||
365 | def assertSchema(self, data, schema): | |
366 | try: | |
367 | return _validate_json(data, schema) | |
368 | except _ValError as e: | |
369 | self.assertEqual(data, str(e)) | |
370 | ||
371 | def assertSchemaBody(self, schema): | |
372 | self.assertSchema(self.jsonBody(), schema) | |
373 | ||
374 | def assertBody(self, body): | |
375 | self.assertEqual(self._resp.text, body) | |
376 | ||
377 | def assertStatus(self, status): | |
378 | if isinstance(status, list): | |
379 | self.assertIn(self._resp.status_code, status) | |
380 | else: | |
381 | self.assertEqual(self._resp.status_code, status) | |
382 | ||
383 | def assertHeaders(self, headers): | |
384 | for name, value in headers.items(): | |
385 | self.assertIn(name, self._resp.headers) | |
386 | self.assertEqual(self._resp.headers[name], value) | |
387 | ||
388 | def assertError(self, code=None, component=None, detail=None): | |
389 | body = self._resp.json() | |
390 | if code: | |
391 | self.assertEqual(body['code'], code) | |
392 | if component: | |
393 | self.assertEqual(body['component'], component) | |
394 | if detail: | |
395 | self.assertEqual(body['detail'], detail) | |
396 | ||
397 | @classmethod | |
398 | def _ceph_cmd(cls, cmd): | |
399 | res = cls.mgr_cluster.mon_manager.raw_cluster_cmd(*cmd) | |
400 | log.info("command result: %s", res) | |
401 | return res | |
402 | ||
9f95a23c TL |
403 | @classmethod |
404 | def _ceph_cmd_result(cls, cmd): | |
405 | exitstatus = cls.mgr_cluster.mon_manager.raw_cluster_cmd_result(*cmd) | |
406 | log.info("command exit status: %d", exitstatus) | |
407 | return exitstatus | |
408 | ||
11fdf7f2 TL |
409 | def set_config_key(self, key, value): |
410 | self._ceph_cmd(['config-key', 'set', key, value]) | |
411 | ||
412 | def get_config_key(self, key): | |
413 | return self._ceph_cmd(['config-key', 'get', key]) | |
414 | ||
9f95a23c TL |
415 | @classmethod |
416 | def _cmd(cls, args): | |
417 | return cls.mgr_cluster.admin_remote.run(args=args) | |
418 | ||
11fdf7f2 TL |
419 | @classmethod |
420 | def _rbd_cmd(cls, cmd): | |
9f95a23c | 421 | args = ['rbd'] |
11fdf7f2 | 422 | args.extend(cmd) |
9f95a23c | 423 | cls._cmd(args) |
11fdf7f2 TL |
424 | |
425 | @classmethod | |
426 | def _radosgw_admin_cmd(cls, cmd): | |
9f95a23c | 427 | args = ['radosgw-admin'] |
11fdf7f2 | 428 | args.extend(cmd) |
9f95a23c | 429 | cls._cmd(args) |
11fdf7f2 TL |
430 | |
431 | @classmethod | |
432 | def _rados_cmd(cls, cmd): | |
433 | args = ['rados'] | |
434 | args.extend(cmd) | |
9f95a23c | 435 | cls._cmd(args) |
11fdf7f2 TL |
436 | |
437 | @classmethod | |
438 | def mons(cls): | |
9f95a23c | 439 | out = cls.ceph_cluster.mon_manager.raw_cluster_cmd('quorum_status') |
11fdf7f2 TL |
440 | j = json.loads(out) |
441 | return [mon['name'] for mon in j['monmap']['mons']] | |
442 | ||
443 | @classmethod | |
444 | def find_object_in_list(cls, key, value, iterable): | |
445 | """ | |
446 | Get the first occurrence of an object within a list with | |
447 | the specified key/value. | |
448 | :param key: The name of the key. | |
449 | :param value: The value to search for. | |
450 | :param iterable: The list to process. | |
451 | :return: Returns the found object or None. | |
452 | """ | |
453 | for obj in iterable: | |
454 | if key in obj and obj[key] == value: | |
455 | return obj | |
456 | return None | |
457 | ||
458 | ||
459 | class JLeaf(namedtuple('JLeaf', ['typ', 'none'])): | |
460 | def __new__(cls, typ, none=False): | |
461 | if typ == str: | |
462 | typ = six.string_types | |
463 | return super(JLeaf, cls).__new__(cls, typ, none) | |
464 | ||
465 | ||
466 | JList = namedtuple('JList', ['elem_typ']) | |
467 | ||
468 | JTuple = namedtuple('JList', ['elem_typs']) | |
469 | ||
470 | ||
471 | class JObj(namedtuple('JObj', ['sub_elems', 'allow_unknown', 'none', 'unknown_schema'])): | |
472 | def __new__(cls, sub_elems, allow_unknown=False, none=False, unknown_schema=None): | |
473 | """ | |
474 | :type sub_elems: dict[str, JAny | JLeaf | JList | JObj | type] | |
475 | :type allow_unknown: bool | |
476 | :type none: bool | |
477 | :type unknown_schema: int, str, JAny | JLeaf | JList | JObj | |
478 | :return: | |
479 | """ | |
480 | return super(JObj, cls).__new__(cls, sub_elems, allow_unknown, none, unknown_schema) | |
481 | ||
482 | ||
483 | JAny = namedtuple('JAny', ['none']) | |
484 | ||
485 | ||
486 | class _ValError(Exception): | |
487 | def __init__(self, msg, path): | |
488 | path_str = ''.join('[{}]'.format(repr(p)) for p in path) | |
489 | super(_ValError, self).__init__('In `input{}`: {}'.format(path_str, msg)) | |
490 | ||
491 | ||
492 | # pylint: disable=dangerous-default-value,inconsistent-return-statements | |
493 | def _validate_json(val, schema, path=[]): | |
494 | """ | |
495 | >>> d = {'a': 1, 'b': 'x', 'c': range(10)} | |
496 | ... ds = JObj({'a': int, 'b': str, 'c': JList(int)}) | |
497 | ... _validate_json(d, ds) | |
498 | True | |
499 | """ | |
500 | if isinstance(schema, JAny): | |
501 | if not schema.none and val is None: | |
502 | raise _ValError('val is None', path) | |
503 | return True | |
504 | if isinstance(schema, JLeaf): | |
505 | if schema.none and val is None: | |
506 | return True | |
507 | if not isinstance(val, schema.typ): | |
508 | raise _ValError('val not of type {}'.format(schema.typ), path) | |
509 | return True | |
510 | if isinstance(schema, JList): | |
511 | if not isinstance(val, list): | |
512 | raise _ValError('val="{}" is not a list'.format(val), path) | |
513 | return all(_validate_json(e, schema.elem_typ, path + [i]) for i, e in enumerate(val)) | |
514 | if isinstance(schema, JTuple): | |
515 | return all(_validate_json(val[i], typ, path + [i]) | |
516 | for i, typ in enumerate(schema.elem_typs)) | |
517 | if isinstance(schema, JObj): | |
518 | if val is None and schema.none: | |
519 | return True | |
520 | elif val is None: | |
521 | raise _ValError('val is None', path) | |
522 | if not hasattr(val, 'keys'): | |
523 | raise _ValError('val="{}" is not a dict'.format(val), path) | |
524 | missing_keys = set(schema.sub_elems.keys()).difference(set(val.keys())) | |
525 | if missing_keys: | |
526 | raise _ValError('missing keys: {}'.format(missing_keys), path) | |
527 | unknown_keys = set(val.keys()).difference(set(schema.sub_elems.keys())) | |
528 | if not schema.allow_unknown and unknown_keys: | |
529 | raise _ValError('unknown keys: {}'.format(unknown_keys), path) | |
530 | result = all( | |
531 | _validate_json(val[key], sub_schema, path + [key]) | |
532 | for key, sub_schema in schema.sub_elems.items() | |
533 | ) | |
534 | if unknown_keys and schema.allow_unknown and schema.unknown_schema: | |
535 | result += all( | |
536 | _validate_json(val[key], schema.unknown_schema, path + [key]) | |
537 | for key in unknown_keys | |
538 | ) | |
539 | return result | |
540 | if schema in [str, int, float, bool, six.string_types]: | |
541 | return _validate_json(val, JLeaf(schema), path) | |
542 | ||
543 | assert False, str(path) |