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