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