]> git.proxmox.com Git - ceph.git/blame_incremental - ceph/src/pybind/mgr/dashboard/module.py
bump version to 16.2.6-pve2
[ceph.git] / ceph / src / pybind / mgr / dashboard / module.py
... / ...
CommitLineData
1# -*- coding: utf-8 -*-
2"""
3ceph dashboard mgr plugin (based on CherryPy)
4"""
5from __future__ import absolute_import
6
7import collections
8import errno
9import logging
10import os
11import ssl
12import sys
13import tempfile
14import threading
15import time
16from typing import TYPE_CHECKING, Optional
17
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
26from mgr_util import ServerConfigException, build_url, \
27 create_self_signed_cert, 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.rgw_client import configure_rgw_credentials
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
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
49if cherrypy is not None:
50 from .cherrypy_backports import patch_cherrypy
51 patch_cherrypy(cherrypy.__version__)
52
53# pylint: disable=wrong-import-position
54from .plugins import PLUGIN_MANAGER, debug, feature_toggles, motd # isort:skip # noqa E501 # pylint: disable=unused-import
55
56PLUGIN_MANAGER.hook.init()
57
58
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
63
64
65# pylint: disable=W0212
66os._exit = os_exit_noop
67
68
69logger = logging.getLogger(__name__)
70
71
72class CherryPyConfig(object):
73 """
74 Class for common server configuration done by both active and
75 standby module, especially setting up SSL.
76 """
77
78 def __init__(self):
79 self._stopping = threading.Event()
80 self._url_prefix = ""
81
82 self.cert_tmp = None
83 self.pkey_tmp = None
84
85 def shutdown(self):
86 self._stopping.set()
87
88 @property
89 def url_prefix(self):
90 return self._url_prefix
91
92 @staticmethod
93 def update_cherrypy_config(config):
94 PLUGIN_MANAGER.hook.configure_cherrypy(config=config)
95 cherrypy.config.update(config)
96
97 # pylint: disable=too-many-branches
98 def _configure(self):
99 """
100 Configure CherryPy and initialize self.url_prefix
101
102 :returns our URI
103 """
104 server_addr = self.get_localized_module_option( # type: ignore
105 'server_addr', get_default_addr())
106 use_ssl = self.get_localized_module_option('ssl', True) # type: ignore
107 if not use_ssl:
108 server_port = self.get_localized_module_option('server_port', 8080) # type: ignore
109 else:
110 server_port = self.get_localized_module_option('ssl_server_port', 8443) # type: ignore
111
112 if server_addr == '::':
113 server_addr = self.get_mgr_ip() # type: ignore
114 if server_addr is None:
115 raise ServerConfigException(
116 'no server_addr configured; '
117 'try "ceph config set mgr mgr/{}/{}/server_addr <ip>"'
118 .format(self.module_name, self.get_mgr_id())) # type: ignore
119 self.log.info('server: ssl=%s host=%s port=%d', 'yes' if use_ssl else 'no', # type: ignore
120 server_addr, server_port)
121
122 # Initialize custom handlers.
123 cherrypy.tools.authenticate = AuthManagerTool()
124 cherrypy.tools.plugin_hooks_filter_request = cherrypy.Tool(
125 'before_handler',
126 lambda: PLUGIN_MANAGER.hook.filter_request_before_handler(request=cherrypy.request),
127 priority=1)
128 cherrypy.tools.request_logging = RequestLoggingTool()
129 cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler,
130 priority=31)
131
132 cherrypy.log.access_log.propagate = False
133 cherrypy.log.error_log.propagate = False
134
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',
148 'application/*+json',
149 'application/javascript',
150 ],
151 'tools.json_in.on': True,
152 'tools.json_in.force': True,
153 'tools.plugin_hooks_filter_request.on': True,
154 }
155
156 if use_ssl:
157 # SSL initialization
158 cert = self.get_localized_store("crt") # type: ignore
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
164 else:
165 cert_fname = self.get_localized_module_option('crt_file') # type: ignore
166
167 pkey = self.get_localized_store("key") # type: ignore
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:
174 pkey_fname = self.get_localized_module_option('key_file') # type: ignore
175
176 verify_tls_files(cert_fname, pkey_fname)
177
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
186 config['server.ssl_module'] = 'builtin'
187 config['server.ssl_certificate'] = cert_fname
188 config['server.ssl_private_key'] = pkey_fname
189 config['server.ssl_context'] = context
190
191 self.update_cherrypy_config(config)
192
193 self._url_prefix = prepare_url_prefix(self.get_module_option( # type: ignore
194 'url_prefix', default=''))
195
196 base_url = build_url(
197 scheme='https' if use_ssl else 'http',
198 host=server_addr,
199 port=server_port,
200 )
201 uri = f'{base_url}{self.url_prefix}/'
202 return uri
203
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.
208
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:
215 self.log.info( # type: ignore
216 "Config not ready to serve, waiting: {0}".format(e)
217 )
218 # Poll until a non-errored config is present
219 self._stopping.wait(5)
220 else:
221 self.log.info("Configured CherryPy, starting engine...") # type: ignore
222 return uri
223
224
225if TYPE_CHECKING:
226 SslConfigKey = Literal['crt', 'key']
227
228
229class Module(MgrModule, CherryPyConfig):
230 """
231 dashboard module entrypoint
232 """
233
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 },
251 {
252 "cmd": "dashboard grafana dashboards update",
253 "desc": "Push dashboards to Grafana",
254 "perm": "w",
255 },
256 ]
257 COMMANDS.extend(options_command_list())
258 COMMANDS.extend(SSO_COMMANDS)
259 PLUGIN_MANAGER.hook.register_commands()
260
261 MODULE_OPTIONS = [
262 Option(name='server_addr', type='str', default=get_default_addr()),
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),
266 Option(name='url_prefix', type='str', default=''),
267 Option(name='key_file', type='str', default=''),
268 Option(name='crt_file', type='str', default=''),
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)
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(
280 lambda: collections.deque(maxlen=10))) # type: dict
281
282 def __init__(self, *args, **kwargs):
283 super(Module, self).__init__(*args, **kwargs)
284 CherryPyConfig.__init__(self)
285
286 mgr.init(self)
287
288 self._stopping = threading.Event()
289 self.shutdown_event = threading.Event()
290 self.ACCESS_CTRL_DB = None
291 self.SSO_DB = None
292 self.health_checks = {}
293
294 @classmethod
295 def can_run(cls):
296 if cherrypy is None:
297 return False, "Missing dependency: cherrypy"
298
299 if not os.path.exists(cls.get_frontend_path()):
300 return False, ("Frontend assets not found at '{}': incomplete build?"
301 .format(cls.get_frontend_path()))
302
303 return True, ""
304
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')
309
310 def serve(self):
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
321 AuthManager.initialize()
322 load_sso_db()
323
324 uri = self.await_configuration()
325 if uri is None:
326 # We were shut down while waiting
327 return
328
329 # Publish the URI that others may use to access the service we're
330 # about to start serving
331 self.set_uri(uri)
332
333 mapper, parent_urls = generate_routes(self.url_prefix)
334
335 config = {}
336 for purl in parent_urls:
337 config[purl] = {
338 'request.dispatch': mapper
339 }
340
341 cherrypy.tree.mount(None, config=config)
342
343 PLUGIN_MANAGER.hook.setup()
344
345 cherrypy.engine.start()
346 NotificationQueue.start_queue()
347 TaskManager.init()
348 logger.info('Engine started.')
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 )
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')
365
366 def shutdown(self):
367 super(Module, self).shutdown()
368 CherryPyConfig.shutdown(self)
369 logger.info('Stopping engine...')
370 self.shutdown_event.set()
371
372 def _set_ssl_item(self, item_label: str, item_key: 'SslConfigKey' = 'crt',
373 mgr_id: Optional[str] = None, inbuf: Optional[str] = None):
374 if inbuf is None:
375 return -errno.EINVAL, '', f'Please specify the {item_label} with "-i" option'
376
377 if mgr_id is not None:
378 self.set_store(_get_localized_key(mgr_id, item_key), inbuf)
379 else:
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)
386
387 @CLIWriteCommand("dashboard set-ssl-certificate-key")
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', ''
402
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
412 def handle_command(self, inbuf, cmd):
413 # pylint: disable=too-many-return-statements
414 res = handle_option_command(cmd, inbuf)
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), ''
426 if cmd['prefix'] == 'dashboard grafana dashboards update':
427 push_local_dashboards()
428 return 0, 'Grafana dashboards updated', ''
429
430 return (-errno.EINVAL, '', 'Command not found \'{0}\''
431 .format(cmd['prefix']))
432
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))
443
444 return self.__pool_stats
445
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
455
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()
461
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)
465
466 def serve(self):
467 uri = self.await_configuration()
468 if uri is None:
469 # We were shut down while waiting
470 return
471
472 module = self
473
474 class Root(object):
475 @cherrypy.expose
476 def default(self, *args, **kwargs):
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)
500 else:
501 status = module.get_module_option('standby_error_status_code', 500)
502 raise cherrypy.HTTPError(status, message="Keep on looking")
503
504 cherrypy.tree.mount(Root(), "{}/".format(self.url_prefix), {})
505 self.log.info("Starting engine...")
506 cherrypy.engine.start()
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...")