1 # -*- coding: utf-8 -*-
3 ceph dashboard mgr plugin (based on CherryPy)
5 from __future__
import absolute_import
9 from distutils
.version
import StrictVersion
15 from uuid
import uuid4
16 from OpenSSL
import crypto
, SSL
17 from mgr_module
import MgrModule
, MgrStandbyModule
, Option
, CLIWriteCommand
18 from mgr_util
import get_default_addr
22 from cherrypy
._cptools
import HandlerWrapperTool
24 # To be picked up and reported by .can_run()
27 from .services
.sso
import load_sso_db
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
,\
40 def fixed_init(hc_self
, server
, sock
, makefile
=CP_fileobject
):
41 hc_self
.server
= server
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
47 HTTPConnection
.__init
__ = fixed_init
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
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
63 if 'COVERAGE_ENABLED' in os
.environ
:
65 __cov
= coverage
.Coverage(config_file
="{}/.coveragerc".format(os
.path
.dirname(__file__
)),
68 cherrypy
.engine
.subscribe('start', __cov
.start
)
69 cherrypy
.engine
.subscribe('after_request', __cov
.save
)
70 cherrypy
.engine
.subscribe('stop', __cov
.stop
)
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
, \
81 from .services
.exception
import dashboard_exception_handler
82 from .settings
import options_command_list
, options_schema_list
, \
85 from .plugins
import PLUGIN_MANAGER
86 from .plugins
import feature_toggles
# noqa # pylint: disable=unused-import
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
):
95 # pylint: disable=W0212
96 os
._exit
= os_exit_noop
99 class ServerConfigException(Exception):
103 class CherryPyConfig(object):
105 Class for common server configuration done by both active and
106 standby module, especially setting up SSL.
110 self
._stopping
= threading
.Event()
111 self
._url
_prefix
= ""
120 def url_prefix(self
):
121 return self
._url
_prefix
123 # pylint: disable=too-many-branches
124 def _configure(self
):
126 Configure CherryPy and initialize self.url_prefix
130 server_addr
= self
.get_localized_module_option(
131 'server_addr', get_default_addr())
132 ssl
= self
.get_localized_module_option('ssl', True)
134 server_port
= self
.get_localized_module_option('server_port', 8080)
136 server_port
= self
.get_localized_module_option('ssl_server_port', 8443)
138 if server_addr
is None:
139 raise ServerConfigException(
140 'no server_addr configured; '
141 'try "ceph config set mgr mgr/{}/{}/server_addr <ip>"'
142 .format(self
.module_name
, self
.get_mgr_id()))
143 self
.log
.info('server: ssl=%s host=%s port=%d', 'yes' if ssl
else 'no',
144 server_addr
, server_port
)
146 # Initialize custom handlers.
147 cherrypy
.tools
.authenticate
= AuthManagerTool()
148 cherrypy
.tools
.plugin_hooks
= cherrypy
.Tool(
150 lambda: PLUGIN_MANAGER
.hook
.filter_request_before_handler(request
=cherrypy
.request
),
152 cherrypy
.tools
.request_logging
= RequestLoggingTool()
153 cherrypy
.tools
.dashboard_exception_handler
= HandlerWrapperTool(dashboard_exception_handler
,
156 # Apply the 'global' CherryPy configuration.
158 'engine.autoreload.on': False,
159 'server.socket_host': server_addr
,
160 'server.socket_port': int(server_port
),
161 'error_page.default': json_error_page
,
162 'tools.request_logging.on': True,
163 'tools.gzip.on': True,
164 'tools.gzip.mime_types': [
165 # text/html and text/plain are the default types to compress
166 'text/html', 'text/plain',
167 # We also want JSON and JavaScript to be compressed
169 'application/javascript',
171 'tools.json_in.on': True,
172 'tools.json_in.force': False,
173 'tools.plugin_hooks.on': True,
178 cert
= self
.get_store("crt")
180 self
.cert_tmp
= tempfile
.NamedTemporaryFile()
181 self
.cert_tmp
.write(cert
.encode('utf-8'))
182 self
.cert_tmp
.flush() # cert_tmp must not be gc'ed
183 cert_fname
= self
.cert_tmp
.name
185 cert_fname
= self
.get_localized_module_option('crt_file')
187 pkey
= self
.get_store("key")
189 self
.pkey_tmp
= tempfile
.NamedTemporaryFile()
190 self
.pkey_tmp
.write(pkey
.encode('utf-8'))
191 self
.pkey_tmp
.flush() # pkey_tmp must not be gc'ed
192 pkey_fname
= self
.pkey_tmp
.name
194 pkey_fname
= self
.get_localized_module_option('key_file')
196 if not cert_fname
or not pkey_fname
:
197 raise ServerConfigException('no certificate configured')
198 if not os
.path
.isfile(cert_fname
):
199 raise ServerConfigException('certificate %s does not exist' % cert_fname
)
200 if not os
.path
.isfile(pkey_fname
):
201 raise ServerConfigException('private key %s does not exist' % pkey_fname
)
203 # Do some validations to the private key and certificate:
204 # - Check the type and format
205 # - Check the certificate expiration date
206 # - Check the consistency of the private key
207 # - Check that the private key and certificate match up
209 with
open(cert_fname
) as f
:
210 x509
= crypto
.load_certificate(crypto
.FILETYPE_PEM
, f
.read())
211 if x509
.has_expired():
213 'Certificate {} has been expired'.format(cert_fname
))
214 except (ValueError, crypto
.Error
) as e
:
215 raise ServerConfigException(
216 'Invalid certificate {}: {}'.format(cert_fname
, str(e
)))
218 with
open(pkey_fname
) as f
:
219 pkey
= crypto
.load_privatekey(crypto
.FILETYPE_PEM
, f
.read())
221 except (ValueError, crypto
.Error
) as e
:
222 raise ServerConfigException(
223 'Invalid private key {}: {}'.format(pkey_fname
, str(e
)))
225 context
= SSL
.Context(SSL
.TLSv1_METHOD
)
226 context
.use_certificate_file(cert_fname
, crypto
.FILETYPE_PEM
)
227 context
.use_privatekey_file(pkey_fname
, crypto
.FILETYPE_PEM
)
228 context
.check_privatekey()
229 except crypto
.Error
as e
:
231 'Private key {} and certificate {} do not match up: {}'.format(
232 pkey_fname
, cert_fname
, str(e
)))
234 config
['server.ssl_module'] = 'builtin'
235 config
['server.ssl_certificate'] = cert_fname
236 config
['server.ssl_private_key'] = pkey_fname
238 cherrypy
.config
.update(config
)
240 self
._url
_prefix
= prepare_url_prefix(self
.get_module_option('url_prefix',
243 uri
= "{0}://{1}:{2}{3}/".format(
244 'https' if ssl
else 'http',
245 socket
.getfqdn() if server_addr
== "::" else server_addr
,
252 def await_configuration(self
):
254 Block until configuration is ready (i.e. all needed keys are set)
255 or self._stopping is set.
257 :returns URI of configured webserver
259 while not self
._stopping
.is_set():
261 uri
= self
._configure
()
262 except ServerConfigException
as e
:
263 self
.log
.info("Config not ready to serve, waiting: {0}".format(
266 # Poll until a non-errored config is present
267 self
._stopping
.wait(5)
269 self
.log
.info("Configured CherryPy, starting engine...")
273 class Module(MgrModule
, CherryPyConfig
):
275 dashboard module entrypoint
280 'cmd': 'dashboard set-jwt-token-ttl '
281 'name=seconds,type=CephInt',
282 'desc': 'Set the JWT token TTL in seconds',
286 'cmd': 'dashboard get-jwt-token-ttl',
287 'desc': 'Get the JWT token TTL in seconds',
291 "cmd": "dashboard create-self-signed-cert",
292 "desc": "Create self signed certificate",
296 "cmd": "dashboard grafana dashboards update",
297 "desc": "Push dashboards to Grafana",
301 COMMANDS
.extend(options_command_list())
302 COMMANDS
.extend(SSO_COMMANDS
)
303 PLUGIN_MANAGER
.hook
.register_commands()
306 Option(name
='server_addr', type='str', default
=get_default_addr()),
307 Option(name
='server_port', type='int', default
=8080),
308 Option(name
='ssl_server_port', type='int', default
=8443),
309 Option(name
='jwt_token_ttl', type='int', default
=28800),
310 Option(name
='password', type='str', default
=''),
311 Option(name
='url_prefix', type='str', default
=''),
312 Option(name
='username', type='str', default
=''),
313 Option(name
='key_file', type='str', default
=''),
314 Option(name
='crt_file', type='str', default
=''),
315 Option(name
='ssl', type='bool', default
=True)
317 MODULE_OPTIONS
.extend(options_schema_list())
318 for options
in PLUGIN_MANAGER
.hook
.get_options() or []:
319 MODULE_OPTIONS
.extend(options
)
321 __pool_stats
= collections
.defaultdict(lambda: collections
.defaultdict(
322 lambda: collections
.deque(maxlen
=10)))
324 def __init__(self
, *args
, **kwargs
):
325 super(Module
, self
).__init
__(*args
, **kwargs
)
326 CherryPyConfig
.__init
__(self
)
330 self
._stopping
= threading
.Event()
331 self
.shutdown_event
= threading
.Event()
333 self
.ACCESS_CTRL_DB
= None
339 return False, "Missing dependency: cherrypy"
341 if not os
.path
.exists(cls
.get_frontend_path()):
342 return False, "Frontend assets not found: incomplete build?"
347 def get_frontend_path(cls
):
348 current_dir
= os
.path
.dirname(os
.path
.abspath(__file__
))
349 return os
.path
.join(current_dir
, 'frontend/dist')
352 AuthManager
.initialize()
355 uri
= self
.await_configuration()
357 # We were shut down while waiting
360 # Publish the URI that others may use to access the service we're
361 # about to start serving
364 mapper
, parent_urls
= generate_routes(self
.url_prefix
)
367 self
.url_prefix
or '/': {
368 'tools.staticdir.on': True,
369 'tools.staticdir.dir': self
.get_frontend_path(),
370 'tools.staticdir.index': 'index.html'
373 for purl
in parent_urls
:
375 'request.dispatch': mapper
377 cherrypy
.tree
.mount(None, config
=config
)
379 PLUGIN_MANAGER
.hook
.setup()
381 cherrypy
.engine
.start()
382 NotificationQueue
.start_queue()
384 logger
.info('Engine started.')
385 update_dashboards
= str_to_bool(
386 self
.get_module_option('GRAFANA_UPDATE_DASHBOARDS', 'False'))
387 if update_dashboards
:
388 logger
.info('Starting Grafana dashboard task')
390 'grafana/dashboards/update',
392 push_local_dashboards
,
393 kwargs
=dict(tries
=10, sleep
=60),
395 # wait for the shutdown event
396 self
.shutdown_event
.wait()
397 self
.shutdown_event
.clear()
398 NotificationQueue
.stop()
399 cherrypy
.engine
.stop()
400 logger
.info('Engine stopped')
403 super(Module
, self
).shutdown()
404 CherryPyConfig
.shutdown(self
)
405 logger
.info('Stopping engine...')
406 self
.shutdown_event
.set()
408 @CLIWriteCommand("dashboard set-ssl-certificate",
409 "name=mgr_id,type=CephString,req=false")
410 def set_ssl_certificate(self
, mgr_id
=None, inbuf
=None):
412 return -errno
.EINVAL
, '',\
413 'Please specify the certificate file with "-i" option'
414 if mgr_id
is not None:
415 self
.set_store('{}/crt'.format(mgr_id
), inbuf
)
417 self
.set_store('crt', inbuf
)
418 return 0, 'SSL certificate updated', ''
420 @CLIWriteCommand("dashboard set-ssl-certificate-key",
421 "name=mgr_id,type=CephString,req=false")
422 def set_ssl_certificate_key(self
, mgr_id
=None, inbuf
=None):
424 return -errno
.EINVAL
, '',\
425 'Please specify the certificate key file with "-i" option'
426 if mgr_id
is not None:
427 self
.set_store('{}/key'.format(mgr_id
), inbuf
)
429 self
.set_store('key', inbuf
)
430 return 0, 'SSL certificate key updated', ''
432 def handle_command(self
, inbuf
, cmd
):
433 # pylint: disable=too-many-return-statements
434 res
= handle_option_command(cmd
)
435 if res
[0] != -errno
.ENOSYS
:
437 res
= handle_sso_command(cmd
)
438 if res
[0] != -errno
.ENOSYS
:
440 if cmd
['prefix'] == 'dashboard set-jwt-token-ttl':
441 self
.set_module_option('jwt_token_ttl', str(cmd
['seconds']))
442 return 0, 'JWT token TTL updated', ''
443 if cmd
['prefix'] == 'dashboard get-jwt-token-ttl':
444 ttl
= self
.get_module_option('jwt_token_ttl', JwtManager
.JWT_TOKEN_TTL
)
445 return 0, str(ttl
), ''
446 if cmd
['prefix'] == 'dashboard create-self-signed-cert':
447 self
.create_self_signed_cert()
448 return 0, 'Self-signed certificate created', ''
449 if cmd
['prefix'] == 'dashboard grafana dashboards update':
450 push_local_dashboards()
451 return 0, 'Grafana dashboards updated', ''
453 return (-errno
.EINVAL
, '', 'Command not found \'{0}\''
454 .format(cmd
['prefix']))
456 def create_self_signed_cert(self
):
459 pkey
.generate_key(crypto
.TYPE_RSA
, 2048)
461 # create a self-signed cert
463 cert
.get_subject().O
= "IT"
464 cert
.get_subject().CN
= "ceph-dashboard"
465 cert
.set_serial_number(int(uuid4()))
466 cert
.gmtime_adj_notBefore(0)
467 cert
.gmtime_adj_notAfter(10*365*24*60*60)
468 cert
.set_issuer(cert
.get_subject())
469 cert
.set_pubkey(pkey
)
470 cert
.sign(pkey
, 'sha512')
472 cert
= crypto
.dump_certificate(crypto
.FILETYPE_PEM
, cert
)
473 self
.set_store('crt', cert
.decode('utf-8'))
475 pkey
= crypto
.dump_privatekey(crypto
.FILETYPE_PEM
, pkey
)
476 self
.set_store('key', pkey
.decode('utf-8'))
478 def notify(self
, notify_type
, notify_id
):
479 NotificationQueue
.new_notification(notify_type
, notify_id
)
481 def get_updated_pool_stats(self
):
483 pool_stats
= {p
['id']: p
['stats'] for p
in df
['pools']}
485 for pool_id
, stats
in pool_stats
.items():
486 for stat_name
, stat_val
in stats
.items():
487 self
.__pool
_stats
[pool_id
][stat_name
].append((now
, stat_val
))
489 return self
.__pool
_stats
492 class StandbyModule(MgrStandbyModule
, CherryPyConfig
):
493 def __init__(self
, *args
, **kwargs
):
494 super(StandbyModule
, self
).__init
__(*args
, **kwargs
)
495 CherryPyConfig
.__init
__(self
)
496 self
.shutdown_event
= threading
.Event()
498 # We can set the global mgr instance to ourselves even though
499 # we're just a standby, because it's enough for logging.
503 uri
= self
.await_configuration()
505 # We were shut down while waiting
513 active_uri
= module
.get_active_uri()
515 module
.log
.info("Redirecting to active '%s'", active_uri
)
516 raise cherrypy
.HTTPRedirect(active_uri
)
520 <!-- Note: this is only displayed when the standby
521 does not know an active URI to redirect to, otherwise
522 a simple redirect is returned instead -->
525 <meta http-equiv="refresh" content="{delay}">
528 No active ceph-mgr instance is currently running
529 the dashboard. A failover may be in progress.
530 Retrying in {delay} seconds...
534 return template
.format(delay
=5)
536 cherrypy
.tree
.mount(Root(), "{}/".format(self
.url_prefix
), {})
537 self
.log
.info("Starting engine...")
538 cherrypy
.engine
.start()
539 self
.log
.info("Engine started...")
540 # Wait for shutdown event
541 self
.shutdown_event
.wait()
542 self
.shutdown_event
.clear()
543 cherrypy
.engine
.stop()
544 self
.log
.info("Engine stopped.")
547 CherryPyConfig
.shutdown(self
)
549 self
.log
.info("Stopping engine...")
550 self
.shutdown_event
.set()
551 self
.log
.info("Stopped engine...")