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