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