]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/dashboard/tests/__init__.py
import quincy beta 17.1.0
[ceph.git] / ceph / src / pybind / mgr / dashboard / tests / __init__.py
CommitLineData
11fdf7f2
TL
1# -*- coding: utf-8 -*-
2# pylint: disable=too-many-arguments
11fdf7f2 3
20effc67 4import contextlib
11fdf7f2 5import json
9f95a23c 6import logging
11fdf7f2
TL
7import threading
8import time
20effc67
TL
9from typing import Any, Dict, List, Optional
10from unittest import mock
f67539c2 11from unittest.mock import Mock
11fdf7f2
TL
12
13import cherrypy
14from cherrypy._cptools import HandlerWrapperTool
15from cherrypy.test import helper
18d92ca7 16from mgr_module import HandleCommandResult
20effc67 17from orchestrator import HostSpec, InventoryHost
f67539c2 18from pyfakefs import fake_filesystem
11fdf7f2 19
a4b75251 20from .. import mgr
f67539c2 21from ..controllers import generate_controller_routes, json_error_page
a4b75251 22from ..controllers._version import APIVersion
18d92ca7 23from ..module import Module
f67539c2 24from ..plugins import PLUGIN_MANAGER, debug, feature_toggles # noqa
11fdf7f2
TL
25from ..services.auth import AuthManagerTool
26from ..services.exception import dashboard_exception_handler
f67539c2 27from ..tools import RequestLoggingTool
92f5a8d4
TL
28
29PLUGIN_MANAGER.hook.init()
30PLUGIN_MANAGER.hook.register_commands()
31
11fdf7f2 32
9f95a23c
TL
33logger = logging.getLogger('tests')
34
35
18d92ca7
TL
36class ModuleTestClass(Module):
37 """Dashboard module subclass for testing the module methods."""
38
39 def __init__(self) -> None:
40 pass
41
42 def _unconfigure_logging(self) -> None:
43 pass
44
45
11fdf7f2
TL
46class CmdException(Exception):
47 def __init__(self, retcode, message):
48 super(CmdException, self).__init__(message)
49 self.retcode = retcode
50
51
11fdf7f2
TL
52class KVStoreMockMixin(object):
53 CONFIG_KEY_DICT = {}
54
55 @classmethod
56 def mock_set_module_option(cls, attr, val):
57 cls.CONFIG_KEY_DICT[attr] = val
58
59 @classmethod
60 def mock_get_module_option(cls, attr, default=None):
61 return cls.CONFIG_KEY_DICT.get(attr, default)
62
63 @classmethod
64 def mock_kv_store(cls):
65 cls.CONFIG_KEY_DICT.clear()
66 mgr.set_module_option.side_effect = cls.mock_set_module_option
67 mgr.get_module_option.side_effect = cls.mock_get_module_option
68 # kludge below
69 mgr.set_store.side_effect = cls.mock_set_module_option
70 mgr.get_store.side_effect = cls.mock_get_module_option
71
72 @classmethod
73 def get_key(cls, key):
74 return cls.CONFIG_KEY_DICT.get(key, None)
75
76
18d92ca7 77# pylint: disable=protected-access
11fdf7f2 78class CLICommandTestMixin(KVStoreMockMixin):
18d92ca7
TL
79 _dashboard_module = ModuleTestClass()
80
11fdf7f2
TL
81 @classmethod
82 def exec_cmd(cls, cmd, **kwargs):
18d92ca7
TL
83 inbuf = kwargs['inbuf'] if 'inbuf' in kwargs else None
84 cmd_dict = {'prefix': 'dashboard {}'.format(cmd)}
85 cmd_dict.update(kwargs)
86
87 result = HandleCommandResult(*cls._dashboard_module._handle_command(inbuf, cmd_dict))
88
89 if result.retval < 0:
90 raise CmdException(result.retval, result.stderr)
91 try:
92 return json.loads(result.stdout)
93 except ValueError:
94 return result.stdout
11fdf7f2
TL
95
96
9f95a23c
TL
97class FakeFsMixin(object):
98 fs = fake_filesystem.FakeFilesystem()
99 f_open = fake_filesystem.FakeFileOpen(fs)
100 f_os = fake_filesystem.FakeOsModule(fs)
f67539c2 101 builtins_open = 'builtins.open'
9f95a23c
TL
102
103
11fdf7f2 104class ControllerTestCase(helper.CPWebCase):
eafe8130
TL
105 _endpoints_cache = {}
106
11fdf7f2 107 @classmethod
a4b75251 108 def setup_controllers(cls, ctrl_classes, base_url='', cp_config: Dict[str, Any] = None):
11fdf7f2
TL
109 if not isinstance(ctrl_classes, list):
110 ctrl_classes = [ctrl_classes]
111 mapper = cherrypy.dispatch.RoutesDispatcher()
112 endpoint_list = []
113 for ctrl in ctrl_classes:
a4b75251
TL
114 ctrl._cp_config = {
115 'tools.dashboard_exception_handler.on': True,
116 'tools.authenticate.on': False
117 }
118 if cp_config:
119 ctrl._cp_config.update(cp_config)
11fdf7f2 120 inst = ctrl()
eafe8130
TL
121
122 # We need to cache the controller endpoints because
123 # BaseController#endpoints method is not idempontent
124 # and a controller might be needed by more than one
125 # unit test.
126 if ctrl not in cls._endpoints_cache:
127 ctrl_endpoints = ctrl.endpoints()
128 cls._endpoints_cache[ctrl] = ctrl_endpoints
129
130 ctrl_endpoints = cls._endpoints_cache[ctrl]
131 for endpoint in ctrl_endpoints:
11fdf7f2
TL
132 endpoint.inst = inst
133 endpoint_list.append(endpoint)
134 endpoint_list = sorted(endpoint_list, key=lambda e: e.url)
135 for endpoint in endpoint_list:
136 generate_controller_routes(endpoint, mapper, base_url)
137 if base_url == '':
138 base_url = '/'
139 cherrypy.tree.mount(None, config={
140 base_url: {'request.dispatch': mapper}})
141
f67539c2
TL
142 _request_logging = False
143
144 @classmethod
145 def setUpClass(cls):
146 super().setUpClass()
11fdf7f2
TL
147 cherrypy.tools.authenticate = AuthManagerTool()
148 cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler,
149 priority=31)
150 cherrypy.config.update({
151 'error_page.default': json_error_page,
152 'tools.json_in.on': True,
153 'tools.json_in.force': False
154 })
92f5a8d4 155 PLUGIN_MANAGER.hook.configure_cherrypy(config=cherrypy.config)
11fdf7f2 156
f67539c2
TL
157 if cls._request_logging:
158 cherrypy.tools.request_logging = RequestLoggingTool()
159 cherrypy.config.update({'tools.request_logging.on': True})
160
161 @classmethod
162 def tearDownClass(cls):
163 if cls._request_logging:
164 cherrypy.config.update({'tools.request_logging.on': False})
165
a4b75251 166 def _request(self, url, method, data=None, headers=None, version=APIVersion.DEFAULT):
11fdf7f2
TL
167 if not data:
168 b = None
f67539c2 169 if version:
a4b75251 170 h = [('Accept', version.to_mime_type()),
f67539c2
TL
171 ('Content-Length', '0')]
172 else:
173 h = None
11fdf7f2
TL
174 else:
175 b = json.dumps(data)
f67539c2 176 if version is not None:
a4b75251 177 h = [('Accept', version.to_mime_type()),
f67539c2
TL
178 ('Content-Type', 'application/json'),
179 ('Content-Length', str(len(b)))]
180
181 else:
182 h = [('Content-Type', 'application/json'),
183 ('Content-Length', str(len(b)))]
184
eafe8130
TL
185 if headers:
186 h = headers
11fdf7f2
TL
187 self.getPage(url, method=method, body=b, headers=h)
188
a4b75251 189 def _get(self, url, headers=None, version=APIVersion.DEFAULT):
f67539c2 190 self._request(url, 'GET', headers=headers, version=version)
11fdf7f2 191
a4b75251 192 def _post(self, url, data=None, version=APIVersion.DEFAULT):
f67539c2 193 self._request(url, 'POST', data, version=version)
11fdf7f2 194
a4b75251 195 def _delete(self, url, data=None, version=APIVersion.DEFAULT):
f67539c2 196 self._request(url, 'DELETE', data, version=version)
11fdf7f2 197
a4b75251 198 def _put(self, url, data=None, version=APIVersion.DEFAULT):
f67539c2 199 self._request(url, 'PUT', data, version=version)
11fdf7f2 200
a4b75251 201 def _task_request(self, method, url, data, timeout, version=APIVersion.DEFAULT):
f67539c2 202 self._request(url, method, data, version=version)
11fdf7f2
TL
203 if self.status != '202 Accepted':
204 logger.info("task finished immediately")
205 return
206
9f95a23c 207 res = self.json_body()
11fdf7f2
TL
208 self.assertIsInstance(res, dict)
209 self.assertIn('name', res)
210 self.assertIn('metadata', res)
211
212 task_name = res['name']
213 task_metadata = res['metadata']
214
20effc67 215 thread = Waiter(task_name, task_metadata, self, version)
11fdf7f2
TL
216 thread.start()
217 status = thread.ev.wait(timeout)
218 if not status:
219 # timeout expired
220 thread.abort = True
221 thread.join()
222 raise Exception("Waiting for task ({}, {}) to finish timed out"
223 .format(task_name, task_metadata))
224 logger.info("task (%s, %s) finished", task_name, task_metadata)
225 if thread.res_task['success']:
226 self.body = json.dumps(thread.res_task['ret_value'])
20effc67 227 self._set_success_status(method)
11fdf7f2 228 else:
20effc67
TL
229 if 'status' in thread.res_task['exception']:
230 self.status = thread.res_task['exception']['status']
231 else:
232 self.status = 500
233 self.body = json.dumps(thread.res_task['exception'])
234
235 def _set_success_status(self, method):
236 if method == 'POST':
237 self.status = '201 Created'
238 elif method == 'PUT':
239 self.status = '200 OK'
240 elif method == 'DELETE':
241 self.status = '204 No Content'
11fdf7f2 242
a4b75251 243 def _task_post(self, url, data=None, timeout=60, version=APIVersion.DEFAULT):
f67539c2 244 self._task_request('POST', url, data, timeout, version=version)
11fdf7f2 245
a4b75251 246 def _task_delete(self, url, timeout=60, version=APIVersion.DEFAULT):
f67539c2 247 self._task_request('DELETE', url, None, timeout, version=version)
11fdf7f2 248
a4b75251 249 def _task_put(self, url, data=None, timeout=60, version=APIVersion.DEFAULT):
f67539c2 250 self._task_request('PUT', url, data, timeout, version=version)
11fdf7f2 251
9f95a23c 252 def json_body(self):
11fdf7f2
TL
253 body_str = self.body.decode('utf-8') if isinstance(self.body, bytes) else self.body
254 return json.loads(body_str)
255
9f95a23c 256 def assertJsonBody(self, data, msg=None): # noqa: N802
11fdf7f2 257 """Fail if value != self.body."""
9f95a23c 258 json_body = self.json_body()
11fdf7f2
TL
259 if data != json_body:
260 if msg is None:
261 msg = 'expected body:\n%r\n\nactual body:\n%r' % (
262 data, json_body)
263 self._handlewebError(msg)
264
9f95a23c
TL
265 def assertInJsonBody(self, data, msg=None): # noqa: N802
266 json_body = self.json_body()
11fdf7f2
TL
267 if data not in json_body:
268 if msg is None:
269 msg = 'expected %r to be in %r' % (data, json_body)
270 self._handlewebError(msg)
f67539c2
TL
271
272
273class Stub:
274 """Test class for returning predefined values"""
275
276 @classmethod
277 def get_mgr_no_services(cls):
278 mgr.get = Mock(return_value={})
279
280
281class RgwStub(Stub):
282
283 @classmethod
284 def get_daemons(cls):
285 mgr.get = Mock(return_value={'services': {'rgw': {'daemons': {
286 '5297': {
287 'addr': '192.168.178.3:49774/1534999298',
288 'metadata': {
289 'frontend_config#0': 'beast port=8000',
290 'id': 'daemon1',
522d829b 291 'realm_name': 'realm1',
f67539c2
TL
292 'zonegroup_name': 'zonegroup1',
293 'zone_name': 'zone1'
294 }
295 },
296 '5398': {
297 'addr': '[2001:db8:85a3::8a2e:370:7334]:49774/1534999298',
298 'metadata': {
299 'frontend_config#0': 'civetweb port=8002',
300 'id': 'daemon2',
522d829b 301 'realm_name': 'realm2',
f67539c2
TL
302 'zonegroup_name': 'zonegroup2',
303 'zone_name': 'zone2'
304 }
305 }
306 }}}})
307
308 @classmethod
309 def get_settings(cls):
310 settings = {
f67539c2
TL
311 'RGW_API_ACCESS_KEY': 'fake-access-key',
312 'RGW_API_SECRET_KEY': 'fake-secret-key',
313 }
314 mgr.get_module_option = Mock(side_effect=settings.get)
20effc67
TL
315
316
317# pylint: disable=protected-access
318class Waiter(threading.Thread):
319 def __init__(self, task_name, task_metadata, tc, version):
320 super(Waiter, self).__init__()
321 self.task_name = task_name
322 self.task_metadata = task_metadata
323 self.ev = threading.Event()
324 self.abort = False
325 self.res_task = None
326 self.tc = tc
327 self.version = version
328
329 def run(self):
330 running = True
331 while running and not self.abort:
332 logger.info("task (%s, %s) is still executing", self.task_name,
333 self.task_metadata)
334 time.sleep(1)
335 self.tc._get('/api/task?name={}'.format(self.task_name), version=self.version)
336 res = self.tc.json_body()
337 for task in res['finished_tasks']:
338 if task['metadata'] == self.task_metadata:
339 # task finished
340 running = False
341 self.res_task = task
342 self.ev.set()
343
344
345@contextlib.contextmanager
346def patch_orch(available: bool, missing_features: Optional[List[str]] = None,
347 hosts: Optional[List[HostSpec]] = None,
348 inventory: Optional[List[dict]] = None):
349 with mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') as instance:
350 fake_client = mock.Mock()
351 fake_client.available.return_value = available
352 fake_client.get_missing_features.return_value = missing_features
353
354 if hosts is not None:
355 fake_client.hosts.list.return_value = hosts
356
357 if inventory is not None:
358 def _list_inventory(hosts=None, refresh=False): # pylint: disable=unused-argument
359 inv_hosts = []
360 for inv_host in inventory:
361 if hosts is None or inv_host['name'] in hosts:
362 inv_hosts.append(InventoryHost.from_json(inv_host))
363 return inv_hosts
364 fake_client.inventory.list.side_effect = _list_inventory
365
366 instance.return_value = fake_client
367 yield fake_client