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