]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/dashboard/module.py
import ceph quincy 17.2.4
[ceph.git] / ceph / src / pybind / mgr / dashboard / module.py
CommitLineData
11fdf7f2 1# -*- coding: utf-8 -*-
31f18b77 2"""
11fdf7f2 3ceph dashboard mgr plugin (based on CherryPy)
31f18b77 4"""
31f18b77 5import collections
11fdf7f2 6import errno
9f95a23c 7import logging
31f18b77 8import os
f91f0fd5
TL
9import ssl
10import sys
11fdf7f2
TL
11import tempfile
12import threading
13import time
18d92ca7 14from typing import TYPE_CHECKING, Optional
f6b5b4d7 15
18d92ca7
TL
16if TYPE_CHECKING:
17 if sys.version_info >= (3, 8):
18 from typing import Literal
19 else:
20 from typing_extensions import Literal
21
33c7a0ef
TL
22from mgr_module import CLIReadCommand, CLIWriteCommand, HandleCommandResult, \
23 MgrModule, MgrStandbyModule, NotifyType, Option, _get_localized_key
522d829b
TL
24from mgr_util import ServerConfigException, build_url, \
25 create_self_signed_cert, get_default_addr, verify_tls_files
f67539c2
TL
26
27from . import mgr
a4b75251 28from .controllers import Router, json_error_page
f67539c2
TL
29from .grafana import push_local_dashboards
30from .services.auth import AuthManager, AuthManagerTool, JwtManager
31from .services.exception import dashboard_exception_handler
522d829b 32from .services.rgw_client import configure_rgw_credentials
f67539c2
TL
33from .services.sso import SSO_COMMANDS, handle_sso_command
34from .settings import handle_option_command, options_command_list, options_schema_list
35from .tools import NotificationQueue, RequestLoggingTool, TaskManager, \
36 prepare_url_prefix, str_to_bool
11fdf7f2
TL
37
38try:
39 import cherrypy
40 from cherrypy._cptools import HandlerWrapperTool
41except ImportError:
42 # To be picked up and reported by .can_run()
43 cherrypy = None
44
45from .services.sso import load_sso_db
46
11fdf7f2 47if cherrypy is not None:
92f5a8d4
TL
48 from .cherrypy_backports import patch_cherrypy
49 patch_cherrypy(cherrypy.__version__)
a8e16298 50
11fdf7f2 51# pylint: disable=wrong-import-position
522d829b 52from .plugins import PLUGIN_MANAGER, debug, feature_toggles, motd # isort:skip # noqa E501 # pylint: disable=unused-import
92f5a8d4
TL
53
54PLUGIN_MANAGER.hook.init()
3efd9988
FG
55
56
11fdf7f2
TL
57# cherrypy likes to sys.exit on error. don't let it take us down too!
58# pylint: disable=W0613
59def os_exit_noop(*args):
60 pass
3efd9988 61
b32b8144 62
11fdf7f2
TL
63# pylint: disable=W0212
64os._exit = os_exit_noop
b32b8144 65
3efd9988 66
9f95a23c
TL
67logger = logging.getLogger(__name__)
68
69
11fdf7f2
TL
70class CherryPyConfig(object):
71 """
72 Class for common server configuration done by both active and
73 standby module, especially setting up SSL.
74 """
81eedcae 75
11fdf7f2
TL
76 def __init__(self):
77 self._stopping = threading.Event()
78 self._url_prefix = ""
3efd9988 79
11fdf7f2
TL
80 self.cert_tmp = None
81 self.pkey_tmp = None
3efd9988
FG
82
83 def shutdown(self):
11fdf7f2 84 self._stopping.set()
3efd9988 85
31f18b77 86 @property
11fdf7f2
TL
87 def url_prefix(self):
88 return self._url_prefix
31f18b77 89
92f5a8d4
TL
90 @staticmethod
91 def update_cherrypy_config(config):
92 PLUGIN_MANAGER.hook.configure_cherrypy(config=config)
93 cherrypy.config.update(config)
94
81eedcae 95 # pylint: disable=too-many-branches
11fdf7f2 96 def _configure(self):
31f18b77 97 """
11fdf7f2
TL
98 Configure CherryPy and initialize self.url_prefix
99
100 :returns our URI
31f18b77 101 """
9f95a23c 102 server_addr = self.get_localized_module_option( # type: ignore
494da23a 103 'server_addr', get_default_addr())
f91f0fd5
TL
104 use_ssl = self.get_localized_module_option('ssl', True) # type: ignore
105 if not use_ssl:
9f95a23c 106 server_port = self.get_localized_module_option('server_port', 8080) # type: ignore
31f18b77 107 else:
9f95a23c 108 server_port = self.get_localized_module_option('ssl_server_port', 8443) # type: ignore
31f18b77 109
11fdf7f2
TL
110 if server_addr is None:
111 raise ServerConfigException(
112 'no server_addr configured; '
113 'try "ceph config set mgr mgr/{}/{}/server_addr <ip>"'
9f95a23c 114 .format(self.module_name, self.get_mgr_id())) # type: ignore
f91f0fd5 115 self.log.info('server: ssl=%s host=%s port=%d', 'yes' if use_ssl else 'no', # type: ignore
11fdf7f2
TL
116 server_addr, server_port)
117
118 # Initialize custom handlers.
119 cherrypy.tools.authenticate = AuthManagerTool()
92f5a8d4 120 cherrypy.tools.plugin_hooks_filter_request = cherrypy.Tool(
11fdf7f2
TL
121 'before_handler',
122 lambda: PLUGIN_MANAGER.hook.filter_request_before_handler(request=cherrypy.request),
92f5a8d4 123 priority=1)
11fdf7f2
TL
124 cherrypy.tools.request_logging = RequestLoggingTool()
125 cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler,
126 priority=31)
127
9f95a23c
TL
128 cherrypy.log.access_log.propagate = False
129 cherrypy.log.error_log.propagate = False
130
11fdf7f2
TL
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',
f67539c2 144 'application/*+json',
11fdf7f2
TL
145 'application/javascript',
146 ],
147 'tools.json_in.on': True,
f91f0fd5 148 'tools.json_in.force': True,
92f5a8d4 149 'tools.plugin_hooks_filter_request.on': True,
31f18b77
FG
150 }
151
f91f0fd5 152 if use_ssl:
11fdf7f2 153 # SSL initialization
f67539c2 154 cert = self.get_localized_store("crt") # type: ignore
11fdf7f2
TL
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
b32b8144 160 else:
9f95a23c 161 cert_fname = self.get_localized_module_option('crt_file') # type: ignore
11fdf7f2 162
f67539c2 163 pkey = self.get_localized_store("key") # type: ignore
11fdf7f2
TL
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:
9f95a23c 170 pkey_fname = self.get_localized_module_option('key_file') # type: ignore
c07f9fc5 171
eafe8130 172 verify_tls_files(cert_fname, pkey_fname)
81eedcae 173
f91f0fd5
TL
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
11fdf7f2
TL
182 config['server.ssl_module'] = 'builtin'
183 config['server.ssl_certificate'] = cert_fname
184 config['server.ssl_private_key'] = pkey_fname
f91f0fd5 185 config['server.ssl_context'] = context
31f18b77 186
92f5a8d4 187 self.update_cherrypy_config(config)
31f18b77 188
9f95a23c
TL
189 self._url_prefix = prepare_url_prefix(self.get_module_option( # type: ignore
190 'url_prefix', default=''))
31f18b77 191
a4b75251
TL
192 if server_addr in ['::', '0.0.0.0']:
193 server_addr = self.get_mgr_ip() # type: ignore
522d829b
TL
194 base_url = build_url(
195 scheme='https' if use_ssl else 'http',
196 host=server_addr,
197 port=server_port,
11fdf7f2 198 )
522d829b 199 uri = f'{base_url}{self.url_prefix}/'
11fdf7f2 200 return uri
1adf2230 201
11fdf7f2
TL
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.
31f18b77 206
11fdf7f2
TL
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:
9f95a23c
TL
213 self.log.info( # type: ignore
214 "Config not ready to serve, waiting: {0}".format(e)
215 )
11fdf7f2
TL
216 # Poll until a non-errored config is present
217 self._stopping.wait(5)
218 else:
9f95a23c 219 self.log.info("Configured CherryPy, starting engine...") # type: ignore
11fdf7f2 220 return uri
31f18b77 221
31f18b77 222
18d92ca7
TL
223if TYPE_CHECKING:
224 SslConfigKey = Literal['crt', 'key']
225
226
11fdf7f2
TL
227class Module(MgrModule, CherryPyConfig):
228 """
229 dashboard module entrypoint
230 """
31f18b77 231
11fdf7f2
TL
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 },
81eedcae
TL
249 {
250 "cmd": "dashboard grafana dashboards update",
251 "desc": "Push dashboards to Grafana",
252 "perm": "w",
253 },
11fdf7f2
TL
254 ]
255 COMMANDS.extend(options_command_list())
256 COMMANDS.extend(SSO_COMMANDS)
257 PLUGIN_MANAGER.hook.register_commands()
258
259 MODULE_OPTIONS = [
494da23a 260 Option(name='server_addr', type='str', default=get_default_addr()),
11fdf7f2
TL
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),
11fdf7f2 264 Option(name='url_prefix', type='str', default=''),
11fdf7f2
TL
265 Option(name='key_file', type='str', default=''),
266 Option(name='crt_file', type='str', default=''),
eafe8130
TL
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)
11fdf7f2
TL
272 ]
273 MODULE_OPTIONS.extend(options_schema_list())
274 for options in PLUGIN_MANAGER.hook.get_options() or []:
275 MODULE_OPTIONS.extend(options)
276
20effc67
TL
277 NOTIFY_TYPES = [NotifyType.clog]
278
11fdf7f2 279 __pool_stats = collections.defaultdict(lambda: collections.defaultdict(
9f95a23c 280 lambda: collections.deque(maxlen=10))) # type: dict
31f18b77 281
11fdf7f2
TL
282 def __init__(self, *args, **kwargs):
283 super(Module, self).__init__(*args, **kwargs)
284 CherryPyConfig.__init__(self)
31f18b77 285
11fdf7f2 286 mgr.init(self)
31f18b77 287
11fdf7f2
TL
288 self._stopping = threading.Event()
289 self.shutdown_event = threading.Event()
11fdf7f2
TL
290 self.ACCESS_CTRL_DB = None
291 self.SSO_DB = None
adb31ebb 292 self.health_checks = {}
31f18b77 293
11fdf7f2
TL
294 @classmethod
295 def can_run(cls):
296 if cherrypy is None:
297 return False, "Missing dependency: cherrypy"
31f18b77 298
11fdf7f2 299 if not os.path.exists(cls.get_frontend_path()):
f67539c2
TL
300 return False, ("Frontend assets not found at '{}': incomplete build?"
301 .format(cls.get_frontend_path()))
31f18b77 302
11fdf7f2 303 return True, ""
31f18b77 304
11fdf7f2
TL
305 @classmethod
306 def get_frontend_path(cls):
307 current_dir = os.path.dirname(os.path.abspath(__file__))
20effc67
TL
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)
c07f9fc5 317
11fdf7f2 318 def serve(self):
f6b5b4d7
TL
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
11fdf7f2
TL
329 AuthManager.initialize()
330 load_sso_db()
31f18b77 331
11fdf7f2
TL
332 uri = self.await_configuration()
333 if uri is None:
334 # We were shut down while waiting
335 return
3efd9988
FG
336
337 # Publish the URI that others may use to access the service we're
338 # about to start serving
11fdf7f2 339 self.set_uri(uri)
c07f9fc5 340
a4b75251 341 mapper, parent_urls = Router.generate_routes(self.url_prefix)
c07f9fc5 342
eafe8130 343 config = {}
11fdf7f2
TL
344 for purl in parent_urls:
345 config[purl] = {
346 'request.dispatch': mapper
347 }
92f5a8d4 348
11fdf7f2 349 cherrypy.tree.mount(None, config=config)
c07f9fc5 350
11fdf7f2 351 PLUGIN_MANAGER.hook.setup()
c07f9fc5 352
11fdf7f2
TL
353 cherrypy.engine.start()
354 NotificationQueue.start_queue()
355 TaskManager.init()
356 logger.info('Engine started.')
81eedcae
TL
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 )
11fdf7f2
TL
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')
c07f9fc5 373
11fdf7f2
TL
374 def shutdown(self):
375 super(Module, self).shutdown()
376 CherryPyConfig.shutdown(self)
377 logger.info('Stopping engine...')
378 self.shutdown_event.set()
379
18d92ca7
TL
380 def _set_ssl_item(self, item_label: str, item_key: 'SslConfigKey' = 'crt',
381 mgr_id: Optional[str] = None, inbuf: Optional[str] = None):
494da23a 382 if inbuf is None:
18d92ca7
TL
383 return -errno.EINVAL, '', f'Please specify the {item_label} with "-i" option'
384
494da23a 385 if mgr_id is not None:
18d92ca7 386 self.set_store(_get_localized_key(mgr_id, item_key), inbuf)
494da23a 387 else:
18d92ca7
TL
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)
494da23a 394
f67539c2 395 @CLIWriteCommand("dashboard set-ssl-certificate-key")
18d92ca7
TL
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', ''
494da23a 410
522d829b
TL
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
33c7a0ef 420 @CLIWriteCommand("dashboard set-login-banner")
2a845540
TL
421 def set_login_banner(self, inbuf: str):
422 '''
423 Set the custom login banner read from -i <file>
424 '''
33c7a0ef
TL
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 )
2a845540 431 mgr.set_store('custom_login_banner', inbuf)
33c7a0ef
TL
432 return HandleCommandResult(stdout=f'{item_label} added')
433
434 @CLIReadCommand("dashboard get-login-banner")
435 def get_login_banner(self):
2a845540
TL
436 '''
437 Get the custom login banner text
438 '''
439 banner_text = mgr.get_store('custom_login_banner')
33c7a0ef
TL
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):
2a845540
TL
447 '''
448 Unset the custom login banner
449 '''
450 mgr.set_store('custom_login_banner', None)
33c7a0ef
TL
451 return HandleCommandResult(stdout='Login banner removed')
452
11fdf7f2
TL
453 def handle_command(self, inbuf, cmd):
454 # pylint: disable=too-many-return-statements
cd265ab1 455 res = handle_option_command(cmd, inbuf)
11fdf7f2
TL
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), ''
81eedcae
TL
467 if cmd['prefix'] == 'dashboard grafana dashboards update':
468 push_local_dashboards()
469 return 0, 'Grafana dashboards updated', ''
11fdf7f2
TL
470
471 return (-errno.EINVAL, '', 'Command not found \'{0}\''
472 .format(cmd['prefix']))
473
20effc67
TL
474 def notify(self, notify_type: NotifyType, notify_id):
475 NotificationQueue.new_notification(str(notify_type), notify_id)
11fdf7f2
TL
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))
c07f9fc5 484
11fdf7f2 485 return self.__pool_stats
c07f9fc5 486
adb31ebb
TL
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
c07f9fc5 496
11fdf7f2
TL
497class 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()
c07f9fc5 502
11fdf7f2
TL
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)
c07f9fc5 506
11fdf7f2
TL
507 def serve(self):
508 uri = self.await_configuration()
509 if uri is None:
510 # We were shut down while waiting
511 return
c07f9fc5 512
11fdf7f2 513 module = self
c07f9fc5 514
11fdf7f2 515 class Root(object):
c07f9fc5 516 @cherrypy.expose
92f5a8d4 517 def default(self, *args, **kwargs):
eafe8130
TL
518 if module.get_module_option('standby_behaviour', 'redirect') == 'redirect':
519 active_uri = module.get_active_uri()
2a845540
TL
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
eafe8130
TL
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)
11fdf7f2 548 else:
eafe8130
TL
549 status = module.get_module_option('standby_error_status_code', 500)
550 raise cherrypy.HTTPError(status, message="Keep on looking")
11fdf7f2
TL
551
552 cherrypy.tree.mount(Root(), "{}/".format(self.url_prefix), {})
553 self.log.info("Starting engine...")
31f18b77 554 cherrypy.engine.start()
11fdf7f2
TL
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...")