]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/dashboard/module.py
946c853f880d66330d91fa3940be94553d16f363
[ceph.git] / ceph / src / pybind / mgr / dashboard / module.py
1 # -*- coding: utf-8 -*-
2 """
3 ceph dashboard mgr plugin (based on CherryPy)
4 """
5 import collections
6 import errno
7 import logging
8 import os
9 import ssl
10 import sys
11 import tempfile
12 import threading
13 import time
14 from typing import TYPE_CHECKING, Optional
15
16 if TYPE_CHECKING:
17 if sys.version_info >= (3, 8):
18 from typing import Literal
19 else:
20 from typing_extensions import Literal
21
22 from mgr_module import CLIReadCommand, CLIWriteCommand, HandleCommandResult, \
23 MgrModule, MgrStandbyModule, NotifyType, Option, _get_localized_key
24 from mgr_util import ServerConfigException, build_url, \
25 create_self_signed_cert, get_default_addr, verify_tls_files
26
27 from . import mgr
28 from .controllers import Router, json_error_page
29 from .grafana import push_local_dashboards
30 from .services.auth import AuthManager, AuthManagerTool, JwtManager
31 from .services.exception import dashboard_exception_handler
32 from .services.rgw_client import configure_rgw_credentials
33 from .services.sso import SSO_COMMANDS, handle_sso_command
34 from .settings import handle_option_command, options_command_list, options_schema_list
35 from .tools import NotificationQueue, RequestLoggingTool, TaskManager, \
36 prepare_url_prefix, str_to_bool
37
38 try:
39 import cherrypy
40 from cherrypy._cptools import HandlerWrapperTool
41 except ImportError:
42 # To be picked up and reported by .can_run()
43 cherrypy = None
44
45 from .services.sso import load_sso_db
46
47 if cherrypy is not None:
48 from .cherrypy_backports import patch_cherrypy
49 patch_cherrypy(cherrypy.__version__)
50
51 # pylint: disable=wrong-import-position
52 from .plugins import PLUGIN_MANAGER, debug, feature_toggles, motd # isort:skip # noqa E501 # pylint: disable=unused-import
53
54 PLUGIN_MANAGER.hook.init()
55
56
57 # cherrypy likes to sys.exit on error. don't let it take us down too!
58 # pylint: disable=W0613
59 def os_exit_noop(*args):
60 pass
61
62
63 # pylint: disable=W0212
64 os._exit = os_exit_noop
65
66
67 logger = logging.getLogger(__name__)
68
69
70 class CherryPyConfig(object):
71 """
72 Class for common server configuration done by both active and
73 standby module, especially setting up SSL.
74 """
75
76 def __init__(self):
77 self._stopping = threading.Event()
78 self._url_prefix = ""
79
80 self.cert_tmp = None
81 self.pkey_tmp = None
82
83 def shutdown(self):
84 self._stopping.set()
85
86 @property
87 def url_prefix(self):
88 return self._url_prefix
89
90 @staticmethod
91 def update_cherrypy_config(config):
92 PLUGIN_MANAGER.hook.configure_cherrypy(config=config)
93 cherrypy.config.update(config)
94
95 # pylint: disable=too-many-branches
96 def _configure(self):
97 """
98 Configure CherryPy and initialize self.url_prefix
99
100 :returns our URI
101 """
102 server_addr = self.get_localized_module_option( # type: ignore
103 'server_addr', get_default_addr())
104 use_ssl = self.get_localized_module_option('ssl', True) # type: ignore
105 if not use_ssl:
106 server_port = self.get_localized_module_option('server_port', 8080) # type: ignore
107 else:
108 server_port = self.get_localized_module_option('ssl_server_port', 8443) # type: ignore
109
110 if server_addr is None:
111 raise ServerConfigException(
112 'no server_addr configured; '
113 'try "ceph config set mgr mgr/{}/{}/server_addr <ip>"'
114 .format(self.module_name, self.get_mgr_id())) # type: ignore
115 self.log.info('server: ssl=%s host=%s port=%d', 'yes' if use_ssl else 'no', # type: ignore
116 server_addr, server_port)
117
118 # Initialize custom handlers.
119 cherrypy.tools.authenticate = AuthManagerTool()
120 cherrypy.tools.plugin_hooks_filter_request = cherrypy.Tool(
121 'before_handler',
122 lambda: PLUGIN_MANAGER.hook.filter_request_before_handler(request=cherrypy.request),
123 priority=1)
124 cherrypy.tools.request_logging = RequestLoggingTool()
125 cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler,
126 priority=31)
127
128 cherrypy.log.access_log.propagate = False
129 cherrypy.log.error_log.propagate = False
130
131 # Apply the 'global' CherryPy configuration.
132 config = {
133 'engine.autoreload.on': False,
134 'server.socket_host': server_addr,
135 'server.socket_port': int(server_port),
136 'error_page.default': json_error_page,
137 'tools.request_logging.on': True,
138 'tools.gzip.on': True,
139 'tools.gzip.mime_types': [
140 # text/html and text/plain are the default types to compress
141 'text/html', 'text/plain',
142 # We also want JSON and JavaScript to be compressed
143 'application/json',
144 'application/*+json',
145 'application/javascript',
146 ],
147 'tools.json_in.on': True,
148 'tools.json_in.force': True,
149 'tools.plugin_hooks_filter_request.on': True,
150 }
151
152 if use_ssl:
153 # SSL initialization
154 cert = self.get_localized_store("crt") # type: ignore
155 if cert is not None:
156 self.cert_tmp = tempfile.NamedTemporaryFile()
157 self.cert_tmp.write(cert.encode('utf-8'))
158 self.cert_tmp.flush() # cert_tmp must not be gc'ed
159 cert_fname = self.cert_tmp.name
160 else:
161 cert_fname = self.get_localized_module_option('crt_file') # type: ignore
162
163 pkey = self.get_localized_store("key") # type: ignore
164 if pkey is not None:
165 self.pkey_tmp = tempfile.NamedTemporaryFile()
166 self.pkey_tmp.write(pkey.encode('utf-8'))
167 self.pkey_tmp.flush() # pkey_tmp must not be gc'ed
168 pkey_fname = self.pkey_tmp.name
169 else:
170 pkey_fname = self.get_localized_module_option('key_file') # type: ignore
171
172 verify_tls_files(cert_fname, pkey_fname)
173
174 # Create custom SSL context to disable TLS 1.0 and 1.1.
175 context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
176 context.load_cert_chain(cert_fname, pkey_fname)
177 if sys.version_info >= (3, 7):
178 context.minimum_version = ssl.TLSVersion.TLSv1_2
179 else:
180 context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
181
182 config['server.ssl_module'] = 'builtin'
183 config['server.ssl_certificate'] = cert_fname
184 config['server.ssl_private_key'] = pkey_fname
185 config['server.ssl_context'] = context
186
187 self.update_cherrypy_config(config)
188
189 self._url_prefix = prepare_url_prefix(self.get_module_option( # type: ignore
190 'url_prefix', default=''))
191
192 if server_addr in ['::', '0.0.0.0']:
193 server_addr = self.get_mgr_ip() # type: ignore
194 base_url = build_url(
195 scheme='https' if use_ssl else 'http',
196 host=server_addr,
197 port=server_port,
198 )
199 uri = f'{base_url}{self.url_prefix}/'
200 return uri
201
202 def await_configuration(self):
203 """
204 Block until configuration is ready (i.e. all needed keys are set)
205 or self._stopping is set.
206
207 :returns URI of configured webserver
208 """
209 while not self._stopping.is_set():
210 try:
211 uri = self._configure()
212 except ServerConfigException as e:
213 self.log.info( # type: ignore
214 "Config not ready to serve, waiting: {0}".format(e)
215 )
216 # Poll until a non-errored config is present
217 self._stopping.wait(5)
218 else:
219 self.log.info("Configured CherryPy, starting engine...") # type: ignore
220 return uri
221
222
223 if TYPE_CHECKING:
224 SslConfigKey = Literal['crt', 'key']
225
226
227 class Module(MgrModule, CherryPyConfig):
228 """
229 dashboard module entrypoint
230 """
231
232 COMMANDS = [
233 {
234 'cmd': 'dashboard set-jwt-token-ttl '
235 'name=seconds,type=CephInt',
236 'desc': 'Set the JWT token TTL in seconds',
237 'perm': 'w'
238 },
239 {
240 'cmd': 'dashboard get-jwt-token-ttl',
241 'desc': 'Get the JWT token TTL in seconds',
242 'perm': 'r'
243 },
244 {
245 "cmd": "dashboard create-self-signed-cert",
246 "desc": "Create self signed certificate",
247 "perm": "w"
248 },
249 {
250 "cmd": "dashboard grafana dashboards update",
251 "desc": "Push dashboards to Grafana",
252 "perm": "w",
253 },
254 ]
255 COMMANDS.extend(options_command_list())
256 COMMANDS.extend(SSO_COMMANDS)
257 PLUGIN_MANAGER.hook.register_commands()
258
259 MODULE_OPTIONS = [
260 Option(name='server_addr', type='str', default=get_default_addr()),
261 Option(name='server_port', type='int', default=8080),
262 Option(name='ssl_server_port', type='int', default=8443),
263 Option(name='jwt_token_ttl', type='int', default=28800),
264 Option(name='url_prefix', type='str', default=''),
265 Option(name='key_file', type='str', default=''),
266 Option(name='crt_file', type='str', default=''),
267 Option(name='ssl', type='bool', default=True),
268 Option(name='standby_behaviour', type='str', default='redirect',
269 enum_allowed=['redirect', 'error']),
270 Option(name='standby_error_status_code', type='int', default=500,
271 min=400, max=599)
272 ]
273 MODULE_OPTIONS.extend(options_schema_list())
274 for options in PLUGIN_MANAGER.hook.get_options() or []:
275 MODULE_OPTIONS.extend(options)
276
277 NOTIFY_TYPES = [NotifyType.clog]
278
279 __pool_stats = collections.defaultdict(lambda: collections.defaultdict(
280 lambda: collections.deque(maxlen=10))) # type: dict
281
282 def __init__(self, *args, **kwargs):
283 super(Module, self).__init__(*args, **kwargs)
284 CherryPyConfig.__init__(self)
285
286 mgr.init(self)
287
288 self._stopping = threading.Event()
289 self.shutdown_event = threading.Event()
290 self.ACCESS_CTRL_DB = None
291 self.SSO_DB = None
292 self.health_checks = {}
293
294 @classmethod
295 def can_run(cls):
296 if cherrypy is None:
297 return False, "Missing dependency: cherrypy"
298
299 if not os.path.exists(cls.get_frontend_path()):
300 return False, ("Frontend assets not found at '{}': incomplete build?"
301 .format(cls.get_frontend_path()))
302
303 return True, ""
304
305 @classmethod
306 def get_frontend_path(cls):
307 current_dir = os.path.dirname(os.path.abspath(__file__))
308 path = os.path.join(current_dir, 'frontend/dist')
309 if os.path.exists(path):
310 return path
311 else:
312 path = os.path.join(current_dir,
313 '../../../../build',
314 'src/pybind/mgr/dashboard',
315 'frontend/dist')
316 return os.path.abspath(path)
317
318 def serve(self):
319
320 if 'COVERAGE_ENABLED' in os.environ:
321 import coverage
322 __cov = coverage.Coverage(config_file="{}/.coveragerc"
323 .format(os.path.dirname(__file__)),
324 data_suffix=True)
325 __cov.start()
326 cherrypy.engine.subscribe('after_request', __cov.save)
327 cherrypy.engine.subscribe('stop', __cov.stop)
328
329 AuthManager.initialize()
330 load_sso_db()
331
332 uri = self.await_configuration()
333 if uri is None:
334 # We were shut down while waiting
335 return
336
337 # Publish the URI that others may use to access the service we're
338 # about to start serving
339 self.set_uri(uri)
340
341 mapper, parent_urls = Router.generate_routes(self.url_prefix)
342
343 config = {}
344 for purl in parent_urls:
345 config[purl] = {
346 'request.dispatch': mapper
347 }
348
349 cherrypy.tree.mount(None, config=config)
350
351 PLUGIN_MANAGER.hook.setup()
352
353 cherrypy.engine.start()
354 NotificationQueue.start_queue()
355 TaskManager.init()
356 logger.info('Engine started.')
357 update_dashboards = str_to_bool(
358 self.get_module_option('GRAFANA_UPDATE_DASHBOARDS', 'False'))
359 if update_dashboards:
360 logger.info('Starting Grafana dashboard task')
361 TaskManager.run(
362 'grafana/dashboards/update',
363 {},
364 push_local_dashboards,
365 kwargs=dict(tries=10, sleep=60),
366 )
367 # wait for the shutdown event
368 self.shutdown_event.wait()
369 self.shutdown_event.clear()
370 NotificationQueue.stop()
371 cherrypy.engine.stop()
372 logger.info('Engine stopped')
373
374 def shutdown(self):
375 super(Module, self).shutdown()
376 CherryPyConfig.shutdown(self)
377 logger.info('Stopping engine...')
378 self.shutdown_event.set()
379
380 def _set_ssl_item(self, item_label: str, item_key: 'SslConfigKey' = 'crt',
381 mgr_id: Optional[str] = None, inbuf: Optional[str] = None):
382 if inbuf is None:
383 return -errno.EINVAL, '', f'Please specify the {item_label} with "-i" option'
384
385 if mgr_id is not None:
386 self.set_store(_get_localized_key(mgr_id, item_key), inbuf)
387 else:
388 self.set_store(item_key, inbuf)
389 return 0, f'SSL {item_label} updated', ''
390
391 @CLIWriteCommand("dashboard set-ssl-certificate")
392 def set_ssl_certificate(self, mgr_id: Optional[str] = None, inbuf: Optional[str] = None):
393 return self._set_ssl_item('certificate', 'crt', mgr_id, inbuf)
394
395 @CLIWriteCommand("dashboard set-ssl-certificate-key")
396 def set_ssl_certificate_key(self, mgr_id: Optional[str] = None, inbuf: Optional[str] = None):
397 return self._set_ssl_item('certificate key', 'key', mgr_id, inbuf)
398
399 @CLIWriteCommand("dashboard create-self-signed-cert")
400 def set_mgr_created_self_signed_cert(self):
401 cert, pkey = create_self_signed_cert('IT', 'ceph-dashboard')
402 result = HandleCommandResult(*self.set_ssl_certificate(inbuf=cert))
403 if result.retval != 0:
404 return result
405
406 result = HandleCommandResult(*self.set_ssl_certificate_key(inbuf=pkey))
407 if result.retval != 0:
408 return result
409 return 0, 'Self-signed certificate created', ''
410
411 @CLIWriteCommand("dashboard set-rgw-credentials")
412 def set_rgw_credentials(self):
413 try:
414 configure_rgw_credentials()
415 except Exception as error:
416 return -errno.EINVAL, '', str(error)
417
418 return 0, 'RGW credentials configured', ''
419
420 @CLIWriteCommand("dashboard set-login-banner")
421 def set_login_banner(self, inbuf: str):
422 '''
423 Set the custom login banner read from -i <file>
424 '''
425 item_label = 'login banner file'
426 if inbuf is None:
427 return HandleCommandResult(
428 -errno.EINVAL,
429 stderr=f'Please specify the {item_label} with "-i" option'
430 )
431 mgr.set_store('custom_login_banner', inbuf)
432 return HandleCommandResult(stdout=f'{item_label} added')
433
434 @CLIReadCommand("dashboard get-login-banner")
435 def get_login_banner(self):
436 '''
437 Get the custom login banner text
438 '''
439 banner_text = mgr.get_store('custom_login_banner')
440 if banner_text is None:
441 return HandleCommandResult(stdout='No login banner set')
442 else:
443 return HandleCommandResult(stdout=banner_text)
444
445 @CLIWriteCommand("dashboard unset-login-banner")
446 def unset_login_banner(self):
447 '''
448 Unset the custom login banner
449 '''
450 mgr.set_store('custom_login_banner', None)
451 return HandleCommandResult(stdout='Login banner removed')
452
453 def handle_command(self, inbuf, cmd):
454 # pylint: disable=too-many-return-statements
455 res = handle_option_command(cmd, inbuf)
456 if res[0] != -errno.ENOSYS:
457 return res
458 res = handle_sso_command(cmd)
459 if res[0] != -errno.ENOSYS:
460 return res
461 if cmd['prefix'] == 'dashboard set-jwt-token-ttl':
462 self.set_module_option('jwt_token_ttl', str(cmd['seconds']))
463 return 0, 'JWT token TTL updated', ''
464 if cmd['prefix'] == 'dashboard get-jwt-token-ttl':
465 ttl = self.get_module_option('jwt_token_ttl', JwtManager.JWT_TOKEN_TTL)
466 return 0, str(ttl), ''
467 if cmd['prefix'] == 'dashboard grafana dashboards update':
468 push_local_dashboards()
469 return 0, 'Grafana dashboards updated', ''
470
471 return (-errno.EINVAL, '', 'Command not found \'{0}\''
472 .format(cmd['prefix']))
473
474 def notify(self, notify_type: NotifyType, notify_id):
475 NotificationQueue.new_notification(str(notify_type), notify_id)
476
477 def get_updated_pool_stats(self):
478 df = self.get('df')
479 pool_stats = {p['id']: p['stats'] for p in df['pools']}
480 now = time.time()
481 for pool_id, stats in pool_stats.items():
482 for stat_name, stat_val in stats.items():
483 self.__pool_stats[pool_id][stat_name].append((now, stat_val))
484
485 return self.__pool_stats
486
487 def config_notify(self):
488 """
489 This method is called whenever one of our config options is changed.
490 """
491 PLUGIN_MANAGER.hook.config_notify()
492
493 def refresh_health_checks(self):
494 self.set_health_checks(self.health_checks)
495
496
497 class StandbyModule(MgrStandbyModule, CherryPyConfig):
498 def __init__(self, *args, **kwargs):
499 super(StandbyModule, self).__init__(*args, **kwargs)
500 CherryPyConfig.__init__(self)
501 self.shutdown_event = threading.Event()
502
503 # We can set the global mgr instance to ourselves even though
504 # we're just a standby, because it's enough for logging.
505 mgr.init(self)
506
507 def serve(self):
508 uri = self.await_configuration()
509 if uri is None:
510 # We were shut down while waiting
511 return
512
513 module = self
514
515 class Root(object):
516 @cherrypy.expose
517 def default(self, *args, **kwargs):
518 if module.get_module_option('standby_behaviour', 'redirect') == 'redirect':
519 active_uri = module.get_active_uri()
520
521 if cherrypy.request.path_info.startswith('/api/prometheus_receiver'):
522 module.log.debug("Suppressed redirecting alert to active '%s'",
523 active_uri)
524 cherrypy.response.status = 204
525 return None
526
527 if active_uri:
528 module.log.info("Redirecting to active '%s'", active_uri)
529 raise cherrypy.HTTPRedirect(active_uri)
530 else:
531 template = """
532 <html>
533 <!-- Note: this is only displayed when the standby
534 does not know an active URI to redirect to, otherwise
535 a simple redirect is returned instead -->
536 <head>
537 <title>Ceph</title>
538 <meta http-equiv="refresh" content="{delay}">
539 </head>
540 <body>
541 No active ceph-mgr instance is currently running
542 the dashboard. A failover may be in progress.
543 Retrying in {delay} seconds...
544 </body>
545 </html>
546 """
547 return template.format(delay=5)
548 else:
549 status = module.get_module_option('standby_error_status_code', 500)
550 raise cherrypy.HTTPError(status, message="Keep on looking")
551
552 cherrypy.tree.mount(Root(), "{}/".format(self.url_prefix), {})
553 self.log.info("Starting engine...")
554 cherrypy.engine.start()
555 self.log.info("Engine started...")
556 # Wait for shutdown event
557 self.shutdown_event.wait()
558 self.shutdown_event.clear()
559 cherrypy.engine.stop()
560 self.log.info("Engine stopped.")
561
562 def shutdown(self):
563 CherryPyConfig.shutdown(self)
564
565 self.log.info("Stopping engine...")
566 self.shutdown_event.set()
567 self.log.info("Stopped engine...")