]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/dashboard/module.py
import quincy beta 17.1.0
[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
22from mgr_module import CLIWriteCommand, HandleCommandResult, MgrModule, \
20effc67 23 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
11fdf7f2
TL
420 def handle_command(self, inbuf, cmd):
421 # pylint: disable=too-many-return-statements
cd265ab1 422 res = handle_option_command(cmd, inbuf)
11fdf7f2
TL
423 if res[0] != -errno.ENOSYS:
424 return res
425 res = handle_sso_command(cmd)
426 if res[0] != -errno.ENOSYS:
427 return res
428 if cmd['prefix'] == 'dashboard set-jwt-token-ttl':
429 self.set_module_option('jwt_token_ttl', str(cmd['seconds']))
430 return 0, 'JWT token TTL updated', ''
431 if cmd['prefix'] == 'dashboard get-jwt-token-ttl':
432 ttl = self.get_module_option('jwt_token_ttl', JwtManager.JWT_TOKEN_TTL)
433 return 0, str(ttl), ''
81eedcae
TL
434 if cmd['prefix'] == 'dashboard grafana dashboards update':
435 push_local_dashboards()
436 return 0, 'Grafana dashboards updated', ''
11fdf7f2
TL
437
438 return (-errno.EINVAL, '', 'Command not found \'{0}\''
439 .format(cmd['prefix']))
440
20effc67
TL
441 def notify(self, notify_type: NotifyType, notify_id):
442 NotificationQueue.new_notification(str(notify_type), notify_id)
11fdf7f2
TL
443
444 def get_updated_pool_stats(self):
445 df = self.get('df')
446 pool_stats = {p['id']: p['stats'] for p in df['pools']}
447 now = time.time()
448 for pool_id, stats in pool_stats.items():
449 for stat_name, stat_val in stats.items():
450 self.__pool_stats[pool_id][stat_name].append((now, stat_val))
c07f9fc5 451
11fdf7f2 452 return self.__pool_stats
c07f9fc5 453
adb31ebb
TL
454 def config_notify(self):
455 """
456 This method is called whenever one of our config options is changed.
457 """
458 PLUGIN_MANAGER.hook.config_notify()
459
460 def refresh_health_checks(self):
461 self.set_health_checks(self.health_checks)
462
c07f9fc5 463
11fdf7f2
TL
464class StandbyModule(MgrStandbyModule, CherryPyConfig):
465 def __init__(self, *args, **kwargs):
466 super(StandbyModule, self).__init__(*args, **kwargs)
467 CherryPyConfig.__init__(self)
468 self.shutdown_event = threading.Event()
c07f9fc5 469
11fdf7f2
TL
470 # We can set the global mgr instance to ourselves even though
471 # we're just a standby, because it's enough for logging.
472 mgr.init(self)
c07f9fc5 473
11fdf7f2
TL
474 def serve(self):
475 uri = self.await_configuration()
476 if uri is None:
477 # We were shut down while waiting
478 return
c07f9fc5 479
11fdf7f2 480 module = self
c07f9fc5 481
11fdf7f2 482 class Root(object):
c07f9fc5 483 @cherrypy.expose
92f5a8d4 484 def default(self, *args, **kwargs):
eafe8130
TL
485 if module.get_module_option('standby_behaviour', 'redirect') == 'redirect':
486 active_uri = module.get_active_uri()
487 if active_uri:
488 module.log.info("Redirecting to active '%s'", active_uri)
489 raise cherrypy.HTTPRedirect(active_uri)
490 else:
491 template = """
492 <html>
493 <!-- Note: this is only displayed when the standby
494 does not know an active URI to redirect to, otherwise
495 a simple redirect is returned instead -->
496 <head>
497 <title>Ceph</title>
498 <meta http-equiv="refresh" content="{delay}">
499 </head>
500 <body>
501 No active ceph-mgr instance is currently running
502 the dashboard. A failover may be in progress.
503 Retrying in {delay} seconds...
504 </body>
505 </html>
506 """
507 return template.format(delay=5)
11fdf7f2 508 else:
eafe8130
TL
509 status = module.get_module_option('standby_error_status_code', 500)
510 raise cherrypy.HTTPError(status, message="Keep on looking")
11fdf7f2
TL
511
512 cherrypy.tree.mount(Root(), "{}/".format(self.url_prefix), {})
513 self.log.info("Starting engine...")
31f18b77 514 cherrypy.engine.start()
11fdf7f2
TL
515 self.log.info("Engine started...")
516 # Wait for shutdown event
517 self.shutdown_event.wait()
518 self.shutdown_event.clear()
519 cherrypy.engine.stop()
520 self.log.info("Engine stopped.")
521
522 def shutdown(self):
523 CherryPyConfig.shutdown(self)
524
525 self.log.info("Stopping engine...")
526 self.shutdown_event.set()
527 self.log.info("Stopped engine...")