]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/dashboard/module.py
136252e01374c2efee6997f7964b2dbcf959d04b
[ceph.git] / ceph / src / pybind / mgr / dashboard / module.py
1 # -*- coding: utf-8 -*-
2 """
3 ceph dashboard mgr plugin (based on CherryPy)
4 """
5 from __future__ import absolute_import
6
7 import collections
8 import errno
9 import logging
10 import os
11 import socket
12 import ssl
13 import sys
14 import tempfile
15 import threading
16 import time
17
18 from mgr_module import MgrModule, MgrStandbyModule, Option, CLIWriteCommand
19 from mgr_util import get_default_addr, ServerConfigException, verify_tls_files, \
20 create_self_signed_cert
21
22 try:
23 import cherrypy
24 from cherrypy._cptools import HandlerWrapperTool
25 except ImportError:
26 # To be picked up and reported by .can_run()
27 cherrypy = None
28
29 from .services.sso import load_sso_db
30
31 if cherrypy is not None:
32 from .cherrypy_backports import patch_cherrypy
33 patch_cherrypy(cherrypy.__version__)
34
35 # pylint: disable=wrong-import-position
36 from . import mgr
37 from .controllers import generate_routes, json_error_page
38 from .grafana import push_local_dashboards
39 from .tools import NotificationQueue, RequestLoggingTool, TaskManager, \
40 prepare_url_prefix, str_to_bool
41 from .services.auth import AuthManager, AuthManagerTool, JwtManager
42 from .services.sso import SSO_COMMANDS, \
43 handle_sso_command
44 from .services.exception import dashboard_exception_handler
45 from .settings import options_command_list, options_schema_list, \
46 handle_option_command
47
48 from .plugins import PLUGIN_MANAGER
49 from .plugins import feature_toggles, debug # noqa # pylint: disable=unused-import
50
51
52 PLUGIN_MANAGER.hook.init()
53
54
55 # cherrypy likes to sys.exit on error. don't let it take us down too!
56 # pylint: disable=W0613
57 def os_exit_noop(*args):
58 pass
59
60
61 # pylint: disable=W0212
62 os._exit = os_exit_noop
63
64
65 logger = logging.getLogger(__name__)
66
67
68 class CherryPyConfig(object):
69 """
70 Class for common server configuration done by both active and
71 standby module, especially setting up SSL.
72 """
73
74 def __init__(self):
75 self._stopping = threading.Event()
76 self._url_prefix = ""
77
78 self.cert_tmp = None
79 self.pkey_tmp = None
80
81 def shutdown(self):
82 self._stopping.set()
83
84 @property
85 def url_prefix(self):
86 return self._url_prefix
87
88 @staticmethod
89 def update_cherrypy_config(config):
90 PLUGIN_MANAGER.hook.configure_cherrypy(config=config)
91 cherrypy.config.update(config)
92
93 # pylint: disable=too-many-branches
94 def _configure(self):
95 """
96 Configure CherryPy and initialize self.url_prefix
97
98 :returns our URI
99 """
100 server_addr = self.get_localized_module_option( # type: ignore
101 'server_addr', get_default_addr())
102 use_ssl = self.get_localized_module_option('ssl', True) # type: ignore
103 if not use_ssl:
104 server_port = self.get_localized_module_option('server_port', 8080) # type: ignore
105 else:
106 server_port = self.get_localized_module_option('ssl_server_port', 8443) # type: ignore
107
108 if server_addr is None:
109 raise ServerConfigException(
110 'no server_addr configured; '
111 'try "ceph config set mgr mgr/{}/{}/server_addr <ip>"'
112 .format(self.module_name, self.get_mgr_id())) # type: ignore
113 self.log.info('server: ssl=%s host=%s port=%d', 'yes' if use_ssl else 'no', # type: ignore
114 server_addr, server_port)
115
116 # Initialize custom handlers.
117 cherrypy.tools.authenticate = AuthManagerTool()
118 cherrypy.tools.plugin_hooks_filter_request = cherrypy.Tool(
119 'before_handler',
120 lambda: PLUGIN_MANAGER.hook.filter_request_before_handler(request=cherrypy.request),
121 priority=1)
122 cherrypy.tools.request_logging = RequestLoggingTool()
123 cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler,
124 priority=31)
125
126 cherrypy.log.access_log.propagate = False
127 cherrypy.log.error_log.propagate = False
128
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,
145 'tools.json_in.force': True,
146 'tools.plugin_hooks_filter_request.on': True,
147 }
148
149 if use_ssl:
150 # SSL initialization
151 cert = self.get_store("crt") # type: ignore
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
157 else:
158 cert_fname = self.get_localized_module_option('crt_file') # type: ignore
159
160 pkey = self.get_store("key") # type: ignore
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:
167 pkey_fname = self.get_localized_module_option('key_file') # type: ignore
168
169 verify_tls_files(cert_fname, pkey_fname)
170
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
179 config['server.ssl_module'] = 'builtin'
180 config['server.ssl_certificate'] = cert_fname
181 config['server.ssl_private_key'] = pkey_fname
182 config['server.ssl_context'] = context
183
184 self.update_cherrypy_config(config)
185
186 self._url_prefix = prepare_url_prefix(self.get_module_option( # type: ignore
187 'url_prefix', default=''))
188
189 uri = "{0}://{1}:{2}{3}/".format(
190 'https' if use_ssl else 'http',
191 socket.getfqdn(server_addr if server_addr != '::' else ''),
192 server_port,
193 self.url_prefix
194 )
195
196 return uri
197
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.
202
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:
209 self.log.info( # type: ignore
210 "Config not ready to serve, waiting: {0}".format(e)
211 )
212 # Poll until a non-errored config is present
213 self._stopping.wait(5)
214 else:
215 self.log.info("Configured CherryPy, starting engine...") # type: ignore
216 return uri
217
218
219 class Module(MgrModule, CherryPyConfig):
220 """
221 dashboard module entrypoint
222 """
223
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 },
241 {
242 "cmd": "dashboard grafana dashboards update",
243 "desc": "Push dashboards to Grafana",
244 "perm": "w",
245 },
246 ]
247 COMMANDS.extend(options_command_list())
248 COMMANDS.extend(SSO_COMMANDS)
249 PLUGIN_MANAGER.hook.register_commands()
250
251 MODULE_OPTIONS = [
252 Option(name='server_addr', type='str', default=get_default_addr()),
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=''),
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)
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(
272 lambda: collections.deque(maxlen=10))) # type: dict
273
274 def __init__(self, *args, **kwargs):
275 super(Module, self).__init__(*args, **kwargs)
276 CherryPyConfig.__init__(self)
277
278 mgr.init(self)
279
280 self._stopping = threading.Event()
281 self.shutdown_event = threading.Event()
282 self.ACCESS_CTRL_DB = None
283 self.SSO_DB = None
284 self.health_checks = {}
285
286 @classmethod
287 def can_run(cls):
288 if cherrypy is None:
289 return False, "Missing dependency: cherrypy"
290
291 if not os.path.exists(cls.get_frontend_path()):
292 return False, "Frontend assets not found: incomplete build?"
293
294 return True, ""
295
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')
300
301 def serve(self):
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
312 AuthManager.initialize()
313 load_sso_db()
314
315 uri = self.await_configuration()
316 if uri is None:
317 # We were shut down while waiting
318 return
319
320 # Publish the URI that others may use to access the service we're
321 # about to start serving
322 self.set_uri(uri)
323
324 mapper, parent_urls = generate_routes(self.url_prefix)
325
326 config = {}
327 for purl in parent_urls:
328 config[purl] = {
329 'request.dispatch': mapper
330 }
331
332 cherrypy.tree.mount(None, config=config)
333
334 PLUGIN_MANAGER.hook.setup()
335
336 cherrypy.engine.start()
337 NotificationQueue.start_queue()
338 TaskManager.init()
339 logger.info('Engine started.')
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 )
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')
356
357 def shutdown(self):
358 super(Module, self).shutdown()
359 CherryPyConfig.shutdown(self)
360 logger.info('Stopping engine...')
361 self.shutdown_event.set()
362
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
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', ''
404 if cmd['prefix'] == 'dashboard grafana dashboards update':
405 push_local_dashboards()
406 return 0, 'Grafana dashboards updated', ''
407
408 return (-errno.EINVAL, '', 'Command not found \'{0}\''
409 .format(cmd['prefix']))
410
411 def create_self_signed_cert(self):
412 cert, pkey = create_self_signed_cert('IT', 'ceph-dashboard')
413 self.set_store('crt', cert)
414 self.set_store('key', pkey)
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))
426
427 return self.__pool_stats
428
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
438
439 class 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()
444
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)
448
449 def serve(self):
450 uri = self.await_configuration()
451 if uri is None:
452 # We were shut down while waiting
453 return
454
455 module = self
456
457 class Root(object):
458 @cherrypy.expose
459 def default(self, *args, **kwargs):
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)
483 else:
484 status = module.get_module_option('standby_error_status_code', 500)
485 raise cherrypy.HTTPError(status, message="Keep on looking")
486
487 cherrypy.tree.mount(Root(), "{}/".format(self.url_prefix), {})
488 self.log.info("Starting engine...")
489 cherrypy.engine.start()
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...")