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