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