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
17 from mgr_module
import MgrModule
, MgrStandbyModule
, Option
, CLIWriteCommand
18 from mgr_util
import get_default_addr
, ServerConfigException
, verify_tls_files
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 CherryPyConfig(object):
101 Class for common server configuration done by both active and
102 standby module, especially setting up SSL.
106 self
._stopping
= threading
.Event()
107 self
._url
_prefix
= ""
116 def url_prefix(self
):
117 return self
._url
_prefix
119 # pylint: disable=too-many-branches
120 def _configure(self
):
122 Configure CherryPy and initialize self.url_prefix
126 server_addr
= self
.get_localized_module_option(
127 'server_addr', get_default_addr())
128 ssl
= self
.get_localized_module_option('ssl', True)
130 server_port
= self
.get_localized_module_option('server_port', 8080)
132 server_port
= self
.get_localized_module_option('ssl_server_port', 8443)
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
)
142 # Initialize custom handlers.
143 cherrypy
.tools
.authenticate
= AuthManagerTool()
144 cherrypy
.tools
.plugin_hooks
= cherrypy
.Tool(
146 lambda: PLUGIN_MANAGER
.hook
.filter_request_before_handler(request
=cherrypy
.request
),
148 cherrypy
.tools
.request_logging
= RequestLoggingTool()
149 cherrypy
.tools
.dashboard_exception_handler
= HandlerWrapperTool(dashboard_exception_handler
,
152 # Apply the 'global' CherryPy configuration.
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
165 'application/javascript',
167 'tools.json_in.on': True,
168 'tools.json_in.force': False,
169 'tools.plugin_hooks.on': True,
174 cert
= self
.get_store("crt")
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
181 cert_fname
= self
.get_localized_module_option('crt_file')
183 pkey
= self
.get_store("key")
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
190 pkey_fname
= self
.get_localized_module_option('key_file')
192 verify_tls_files(cert_fname
, pkey_fname
)
194 config
['server.ssl_module'] = 'builtin'
195 config
['server.ssl_certificate'] = cert_fname
196 config
['server.ssl_private_key'] = pkey_fname
198 cherrypy
.config
.update(config
)
200 self
._url
_prefix
= prepare_url_prefix(self
.get_module_option('url_prefix',
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
,
212 def await_configuration(self
):
214 Block until configuration is ready (i.e. all needed keys are set)
215 or self._stopping is set.
217 :returns URI of configured webserver
219 while not self
._stopping
.is_set():
221 uri
= self
._configure
()
222 except ServerConfigException
as e
:
223 self
.log
.info("Config not ready to serve, waiting: {0}".format(
226 # Poll until a non-errored config is present
227 self
._stopping
.wait(5)
229 self
.log
.info("Configured CherryPy, starting engine...")
233 class Module(MgrModule
, CherryPyConfig
):
235 dashboard module entrypoint
240 'cmd': 'dashboard set-jwt-token-ttl '
241 'name=seconds,type=CephInt',
242 'desc': 'Set the JWT token TTL in seconds',
246 'cmd': 'dashboard get-jwt-token-ttl',
247 'desc': 'Get the JWT token TTL in seconds',
251 "cmd": "dashboard create-self-signed-cert",
252 "desc": "Create self signed certificate",
256 "cmd": "dashboard grafana dashboards update",
257 "desc": "Push dashboards to Grafana",
261 COMMANDS
.extend(options_command_list())
262 COMMANDS
.extend(SSO_COMMANDS
)
263 PLUGIN_MANAGER
.hook
.register_commands()
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,
281 MODULE_OPTIONS
.extend(options_schema_list())
282 for options
in PLUGIN_MANAGER
.hook
.get_options() or []:
283 MODULE_OPTIONS
.extend(options
)
285 __pool_stats
= collections
.defaultdict(lambda: collections
.defaultdict(
286 lambda: collections
.deque(maxlen
=10)))
288 def __init__(self
, *args
, **kwargs
):
289 super(Module
, self
).__init
__(*args
, **kwargs
)
290 CherryPyConfig
.__init
__(self
)
294 self
._stopping
= threading
.Event()
295 self
.shutdown_event
= threading
.Event()
297 self
.ACCESS_CTRL_DB
= None
303 return False, "Missing dependency: cherrypy"
305 if not os
.path
.exists(cls
.get_frontend_path()):
306 return False, "Frontend assets not found: incomplete build?"
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')
316 AuthManager
.initialize()
319 uri
= self
.await_configuration()
321 # We were shut down while waiting
324 # Publish the URI that others may use to access the service we're
325 # about to start serving
328 mapper
, parent_urls
= generate_routes(self
.url_prefix
)
331 for purl
in parent_urls
:
333 'request.dispatch': mapper
335 cherrypy
.tree
.mount(None, config
=config
)
337 PLUGIN_MANAGER
.hook
.setup()
339 cherrypy
.engine
.start()
340 NotificationQueue
.start_queue()
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')
348 'grafana/dashboards/update',
350 push_local_dashboards
,
351 kwargs
=dict(tries
=10, sleep
=60),
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')
361 super(Module
, self
).shutdown()
362 CherryPyConfig
.shutdown(self
)
363 logger
.info('Stopping engine...')
364 self
.shutdown_event
.set()
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):
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
)
375 self
.set_store('crt', inbuf
)
376 return 0, 'SSL certificate updated', ''
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):
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
)
387 self
.set_store('key', inbuf
)
388 return 0, 'SSL certificate key updated', ''
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
:
395 res
= handle_sso_command(cmd
)
396 if res
[0] != -errno
.ENOSYS
:
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', ''
411 return (-errno
.EINVAL
, '', 'Command not found \'{0}\''
412 .format(cmd
['prefix']))
414 def create_self_signed_cert(self
):
417 pkey
.generate_key(crypto
.TYPE_RSA
, 2048)
419 # create a self-signed cert
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')
430 cert
= crypto
.dump_certificate(crypto
.FILETYPE_PEM
, cert
)
431 self
.set_store('crt', cert
.decode('utf-8'))
433 pkey
= crypto
.dump_privatekey(crypto
.FILETYPE_PEM
, pkey
)
434 self
.set_store('key', pkey
.decode('utf-8'))
436 def notify(self
, notify_type
, notify_id
):
437 NotificationQueue
.new_notification(notify_type
, notify_id
)
439 def get_updated_pool_stats(self
):
441 pool_stats
= {p
['id']: p
['stats'] for p
in df
['pools']}
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
))
447 return self
.__pool
_stats
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()
456 # We can set the global mgr instance to ourselves even though
457 # we're just a standby, because it's enough for logging.
461 uri
= self
.await_configuration()
463 # We were shut down while waiting
471 if module
.get_module_option('standby_behaviour', 'redirect') == 'redirect':
472 active_uri
= module
.get_active_uri()
474 module
.log
.info("Redirecting to active '%s'", active_uri
)
475 raise cherrypy
.HTTPRedirect(active_uri
)
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 -->
484 <meta http-equiv="refresh" content="{delay}">
487 No active ceph-mgr instance is currently running
488 the dashboard. A failover may be in progress.
489 Retrying in {delay} seconds...
493 return template
.format(delay
=5)
495 status
= module
.get_module_option('standby_error_status_code', 500)
496 raise cherrypy
.HTTPError(status
, message
="Keep on looking")
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.")
509 CherryPyConfig
.shutdown(self
)
511 self
.log
.info("Stopping engine...")
512 self
.shutdown_event
.set()
513 self
.log
.info("Stopped engine...")