]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/dashboard/module.py
bump version to 16.2.6-pve2
[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
522d829b
TL
26from mgr_util import ServerConfigException, build_url, \
27 create_self_signed_cert, get_default_addr, verify_tls_files
f67539c2
TL
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
522d829b 34from .services.rgw_client import configure_rgw_credentials
f67539c2
TL
35from .services.sso import SSO_COMMANDS, handle_sso_command
36from .settings import handle_option_command, options_command_list, options_schema_list
37from .tools import NotificationQueue, RequestLoggingTool, TaskManager, \
38 prepare_url_prefix, str_to_bool
11fdf7f2
TL
39
40try:
41 import cherrypy
42 from cherrypy._cptools import HandlerWrapperTool
43except ImportError:
44 # To be picked up and reported by .can_run()
45 cherrypy = None
46
47from .services.sso import load_sso_db
48
11fdf7f2 49if cherrypy is not None:
92f5a8d4
TL
50 from .cherrypy_backports import patch_cherrypy
51 patch_cherrypy(cherrypy.__version__)
a8e16298 52
11fdf7f2 53# pylint: disable=wrong-import-position
522d829b 54from .plugins import PLUGIN_MANAGER, debug, feature_toggles, motd # isort:skip # noqa E501 # pylint: disable=unused-import
92f5a8d4
TL
55
56PLUGIN_MANAGER.hook.init()
3efd9988
FG
57
58
11fdf7f2
TL
59# cherrypy likes to sys.exit on error. don't let it take us down too!
60# pylint: disable=W0613
61def os_exit_noop(*args):
62 pass
3efd9988 63
b32b8144 64
11fdf7f2
TL
65# pylint: disable=W0212
66os._exit = os_exit_noop
b32b8144 67
3efd9988 68
9f95a23c
TL
69logger = logging.getLogger(__name__)
70
71
11fdf7f2
TL
72class CherryPyConfig(object):
73 """
74 Class for common server configuration done by both active and
75 standby module, especially setting up SSL.
76 """
81eedcae 77
11fdf7f2
TL
78 def __init__(self):
79 self._stopping = threading.Event()
80 self._url_prefix = ""
3efd9988 81
11fdf7f2
TL
82 self.cert_tmp = None
83 self.pkey_tmp = None
3efd9988
FG
84
85 def shutdown(self):
11fdf7f2 86 self._stopping.set()
3efd9988 87
31f18b77 88 @property
11fdf7f2
TL
89 def url_prefix(self):
90 return self._url_prefix
31f18b77 91
92f5a8d4
TL
92 @staticmethod
93 def update_cherrypy_config(config):
94 PLUGIN_MANAGER.hook.configure_cherrypy(config=config)
95 cherrypy.config.update(config)
96
81eedcae 97 # pylint: disable=too-many-branches
11fdf7f2 98 def _configure(self):
31f18b77 99 """
11fdf7f2
TL
100 Configure CherryPy and initialize self.url_prefix
101
102 :returns our URI
31f18b77 103 """
9f95a23c 104 server_addr = self.get_localized_module_option( # type: ignore
494da23a 105 'server_addr', get_default_addr())
f91f0fd5
TL
106 use_ssl = self.get_localized_module_option('ssl', True) # type: ignore
107 if not use_ssl:
9f95a23c 108 server_port = self.get_localized_module_option('server_port', 8080) # type: ignore
31f18b77 109 else:
9f95a23c 110 server_port = self.get_localized_module_option('ssl_server_port', 8443) # type: ignore
31f18b77 111
b3b6e05e
TL
112 if server_addr == '::':
113 server_addr = self.get_mgr_ip() # type: ignore
11fdf7f2
TL
114 if server_addr is None:
115 raise ServerConfigException(
116 'no server_addr configured; '
117 'try "ceph config set mgr mgr/{}/{}/server_addr <ip>"'
9f95a23c 118 .format(self.module_name, self.get_mgr_id())) # type: ignore
f91f0fd5 119 self.log.info('server: ssl=%s host=%s port=%d', 'yes' if use_ssl else 'no', # type: ignore
11fdf7f2
TL
120 server_addr, server_port)
121
122 # Initialize custom handlers.
123 cherrypy.tools.authenticate = AuthManagerTool()
92f5a8d4 124 cherrypy.tools.plugin_hooks_filter_request = cherrypy.Tool(
11fdf7f2
TL
125 'before_handler',
126 lambda: PLUGIN_MANAGER.hook.filter_request_before_handler(request=cherrypy.request),
92f5a8d4 127 priority=1)
11fdf7f2
TL
128 cherrypy.tools.request_logging = RequestLoggingTool()
129 cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler,
130 priority=31)
131
9f95a23c
TL
132 cherrypy.log.access_log.propagate = False
133 cherrypy.log.error_log.propagate = False
134
11fdf7f2
TL
135 # Apply the 'global' CherryPy configuration.
136 config = {
137 'engine.autoreload.on': False,
138 'server.socket_host': server_addr,
139 'server.socket_port': int(server_port),
140 'error_page.default': json_error_page,
141 'tools.request_logging.on': True,
142 'tools.gzip.on': True,
143 'tools.gzip.mime_types': [
144 # text/html and text/plain are the default types to compress
145 'text/html', 'text/plain',
146 # We also want JSON and JavaScript to be compressed
147 'application/json',
f67539c2 148 'application/*+json',
11fdf7f2
TL
149 'application/javascript',
150 ],
151 'tools.json_in.on': True,
f91f0fd5 152 'tools.json_in.force': True,
92f5a8d4 153 'tools.plugin_hooks_filter_request.on': True,
31f18b77
FG
154 }
155
f91f0fd5 156 if use_ssl:
11fdf7f2 157 # SSL initialization
f67539c2 158 cert = self.get_localized_store("crt") # type: ignore
11fdf7f2
TL
159 if cert is not None:
160 self.cert_tmp = tempfile.NamedTemporaryFile()
161 self.cert_tmp.write(cert.encode('utf-8'))
162 self.cert_tmp.flush() # cert_tmp must not be gc'ed
163 cert_fname = self.cert_tmp.name
b32b8144 164 else:
9f95a23c 165 cert_fname = self.get_localized_module_option('crt_file') # type: ignore
11fdf7f2 166
f67539c2 167 pkey = self.get_localized_store("key") # type: ignore
11fdf7f2
TL
168 if pkey is not None:
169 self.pkey_tmp = tempfile.NamedTemporaryFile()
170 self.pkey_tmp.write(pkey.encode('utf-8'))
171 self.pkey_tmp.flush() # pkey_tmp must not be gc'ed
172 pkey_fname = self.pkey_tmp.name
173 else:
9f95a23c 174 pkey_fname = self.get_localized_module_option('key_file') # type: ignore
c07f9fc5 175
eafe8130 176 verify_tls_files(cert_fname, pkey_fname)
81eedcae 177
f91f0fd5
TL
178 # Create custom SSL context to disable TLS 1.0 and 1.1.
179 context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
180 context.load_cert_chain(cert_fname, pkey_fname)
181 if sys.version_info >= (3, 7):
182 context.minimum_version = ssl.TLSVersion.TLSv1_2
183 else:
184 context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
185
11fdf7f2
TL
186 config['server.ssl_module'] = 'builtin'
187 config['server.ssl_certificate'] = cert_fname
188 config['server.ssl_private_key'] = pkey_fname
f91f0fd5 189 config['server.ssl_context'] = context
31f18b77 190
92f5a8d4 191 self.update_cherrypy_config(config)
31f18b77 192
9f95a23c
TL
193 self._url_prefix = prepare_url_prefix(self.get_module_option( # type: ignore
194 'url_prefix', default=''))
31f18b77 195
522d829b
TL
196 base_url = build_url(
197 scheme='https' if use_ssl else 'http',
198 host=server_addr,
199 port=server_port,
11fdf7f2 200 )
522d829b 201 uri = f'{base_url}{self.url_prefix}/'
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
522d829b
TL
403 @CLIWriteCommand("dashboard set-rgw-credentials")
404 def set_rgw_credentials(self):
405 try:
406 configure_rgw_credentials()
407 except Exception as error:
408 return -errno.EINVAL, '', str(error)
409
410 return 0, 'RGW credentials configured', ''
411
11fdf7f2
TL
412 def handle_command(self, inbuf, cmd):
413 # pylint: disable=too-many-return-statements
cd265ab1 414 res = handle_option_command(cmd, inbuf)
11fdf7f2
TL
415 if res[0] != -errno.ENOSYS:
416 return res
417 res = handle_sso_command(cmd)
418 if res[0] != -errno.ENOSYS:
419 return res
420 if cmd['prefix'] == 'dashboard set-jwt-token-ttl':
421 self.set_module_option('jwt_token_ttl', str(cmd['seconds']))
422 return 0, 'JWT token TTL updated', ''
423 if cmd['prefix'] == 'dashboard get-jwt-token-ttl':
424 ttl = self.get_module_option('jwt_token_ttl', JwtManager.JWT_TOKEN_TTL)
425 return 0, str(ttl), ''
81eedcae
TL
426 if cmd['prefix'] == 'dashboard grafana dashboards update':
427 push_local_dashboards()
428 return 0, 'Grafana dashboards updated', ''
11fdf7f2
TL
429
430 return (-errno.EINVAL, '', 'Command not found \'{0}\''
431 .format(cmd['prefix']))
432
11fdf7f2
TL
433 def notify(self, notify_type, notify_id):
434 NotificationQueue.new_notification(notify_type, notify_id)
435
436 def get_updated_pool_stats(self):
437 df = self.get('df')
438 pool_stats = {p['id']: p['stats'] for p in df['pools']}
439 now = time.time()
440 for pool_id, stats in pool_stats.items():
441 for stat_name, stat_val in stats.items():
442 self.__pool_stats[pool_id][stat_name].append((now, stat_val))
c07f9fc5 443
11fdf7f2 444 return self.__pool_stats
c07f9fc5 445
adb31ebb
TL
446 def config_notify(self):
447 """
448 This method is called whenever one of our config options is changed.
449 """
450 PLUGIN_MANAGER.hook.config_notify()
451
452 def refresh_health_checks(self):
453 self.set_health_checks(self.health_checks)
454
c07f9fc5 455
11fdf7f2
TL
456class StandbyModule(MgrStandbyModule, CherryPyConfig):
457 def __init__(self, *args, **kwargs):
458 super(StandbyModule, self).__init__(*args, **kwargs)
459 CherryPyConfig.__init__(self)
460 self.shutdown_event = threading.Event()
c07f9fc5 461
11fdf7f2
TL
462 # We can set the global mgr instance to ourselves even though
463 # we're just a standby, because it's enough for logging.
464 mgr.init(self)
c07f9fc5 465
11fdf7f2
TL
466 def serve(self):
467 uri = self.await_configuration()
468 if uri is None:
469 # We were shut down while waiting
470 return
c07f9fc5 471
11fdf7f2 472 module = self
c07f9fc5 473
11fdf7f2 474 class Root(object):
c07f9fc5 475 @cherrypy.expose
92f5a8d4 476 def default(self, *args, **kwargs):
eafe8130
TL
477 if module.get_module_option('standby_behaviour', 'redirect') == 'redirect':
478 active_uri = module.get_active_uri()
479 if active_uri:
480 module.log.info("Redirecting to active '%s'", active_uri)
481 raise cherrypy.HTTPRedirect(active_uri)
482 else:
483 template = """
484 <html>
485 <!-- Note: this is only displayed when the standby
486 does not know an active URI to redirect to, otherwise
487 a simple redirect is returned instead -->
488 <head>
489 <title>Ceph</title>
490 <meta http-equiv="refresh" content="{delay}">
491 </head>
492 <body>
493 No active ceph-mgr instance is currently running
494 the dashboard. A failover may be in progress.
495 Retrying in {delay} seconds...
496 </body>
497 </html>
498 """
499 return template.format(delay=5)
11fdf7f2 500 else:
eafe8130
TL
501 status = module.get_module_option('standby_error_status_code', 500)
502 raise cherrypy.HTTPError(status, message="Keep on looking")
11fdf7f2
TL
503
504 cherrypy.tree.mount(Root(), "{}/".format(self.url_prefix), {})
505 self.log.info("Starting engine...")
31f18b77 506 cherrypy.engine.start()
11fdf7f2
TL
507 self.log.info("Engine started...")
508 # Wait for shutdown event
509 self.shutdown_event.wait()
510 self.shutdown_event.clear()
511 cherrypy.engine.stop()
512 self.log.info("Engine stopped.")
513
514 def shutdown(self):
515 CherryPyConfig.shutdown(self)
516
517 self.log.info("Stopping engine...")
518 self.shutdown_event.set()
519 self.log.info("Stopped engine...")