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