1 # -*- coding: utf-8 -*-
3 ceph dashboard mgr plugin (based on CherryPy)
5 from __future__
import absolute_import
17 from typing
import Optional
19 from mgr_module
import CLIWriteCommand
, MgrModule
, MgrStandbyModule
, Option
, _get_localized_key
20 from mgr_util
import ServerConfigException
, create_self_signed_cert
, \
21 get_default_addr
, verify_tls_files
24 from .controllers
import generate_routes
, json_error_page
25 from .grafana
import push_local_dashboards
26 from .services
.auth
import AuthManager
, AuthManagerTool
, JwtManager
27 from .services
.exception
import dashboard_exception_handler
28 from .services
.sso
import SSO_COMMANDS
, handle_sso_command
29 from .settings
import handle_option_command
, options_command_list
, options_schema_list
30 from .tools
import NotificationQueue
, RequestLoggingTool
, TaskManager
, \
31 prepare_url_prefix
, str_to_bool
35 from cherrypy
._cptools
import HandlerWrapperTool
37 # To be picked up and reported by .can_run()
40 from .services
.sso
import load_sso_db
42 if cherrypy
is not None:
43 from .cherrypy_backports
import patch_cherrypy
44 patch_cherrypy(cherrypy
.__version
__)
46 # pylint: disable=wrong-import-position
47 from .plugins
import PLUGIN_MANAGER
, debug
, feature_toggles
# noqa # pylint: disable=unused-import
49 PLUGIN_MANAGER
.hook
.init()
52 # cherrypy likes to sys.exit on error. don't let it take us down too!
53 # pylint: disable=W0613
54 def os_exit_noop(*args
):
58 # pylint: disable=W0212
59 os
._exit
= os_exit_noop
62 logger
= logging
.getLogger(__name__
)
65 class CherryPyConfig(object):
67 Class for common server configuration done by both active and
68 standby module, especially setting up SSL.
72 self
._stopping
= threading
.Event()
83 return self
._url
_prefix
86 def update_cherrypy_config(config
):
87 PLUGIN_MANAGER
.hook
.configure_cherrypy(config
=config
)
88 cherrypy
.config
.update(config
)
90 # pylint: disable=too-many-branches
93 Configure CherryPy and initialize self.url_prefix
97 server_addr
= self
.get_localized_module_option( # type: ignore
98 'server_addr', get_default_addr())
99 use_ssl
= self
.get_localized_module_option('ssl', True) # type: ignore
101 server_port
= self
.get_localized_module_option('server_port', 8080) # type: ignore
103 server_port
= self
.get_localized_module_option('ssl_server_port', 8443) # type: ignore
105 if server_addr
is None:
106 raise ServerConfigException(
107 'no server_addr configured; '
108 'try "ceph config set mgr mgr/{}/{}/server_addr <ip>"'
109 .format(self
.module_name
, self
.get_mgr_id())) # type: ignore
110 self
.log
.info('server: ssl=%s host=%s port=%d', 'yes' if use_ssl
else 'no', # type: ignore
111 server_addr
, server_port
)
113 # Initialize custom handlers.
114 cherrypy
.tools
.authenticate
= AuthManagerTool()
115 cherrypy
.tools
.plugin_hooks_filter_request
= cherrypy
.Tool(
117 lambda: PLUGIN_MANAGER
.hook
.filter_request_before_handler(request
=cherrypy
.request
),
119 cherrypy
.tools
.request_logging
= RequestLoggingTool()
120 cherrypy
.tools
.dashboard_exception_handler
= HandlerWrapperTool(dashboard_exception_handler
,
123 cherrypy
.log
.access_log
.propagate
= False
124 cherrypy
.log
.error_log
.propagate
= False
126 # Apply the 'global' CherryPy configuration.
128 'engine.autoreload.on': False,
129 'server.socket_host': server_addr
,
130 'server.socket_port': int(server_port
),
131 'error_page.default': json_error_page
,
132 'tools.request_logging.on': True,
133 'tools.gzip.on': True,
134 'tools.gzip.mime_types': [
135 # text/html and text/plain are the default types to compress
136 'text/html', 'text/plain',
137 # We also want JSON and JavaScript to be compressed
139 'application/*+json',
140 'application/javascript',
142 'tools.json_in.on': True,
143 'tools.json_in.force': True,
144 'tools.plugin_hooks_filter_request.on': True,
149 cert
= self
.get_localized_store("crt") # type: ignore
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
156 cert_fname
= self
.get_localized_module_option('crt_file') # type: ignore
158 pkey
= self
.get_localized_store("key") # type: ignore
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
165 pkey_fname
= self
.get_localized_module_option('key_file') # type: ignore
167 verify_tls_files(cert_fname
, pkey_fname
)
169 # Create custom SSL context to disable TLS 1.0 and 1.1.
170 context
= ssl
.create_default_context(ssl
.Purpose
.CLIENT_AUTH
)
171 context
.load_cert_chain(cert_fname
, pkey_fname
)
172 if sys
.version_info
>= (3, 7):
173 context
.minimum_version
= ssl
.TLSVersion
.TLSv1_2
175 context
.options |
= ssl
.OP_NO_TLSv1 | ssl
.OP_NO_TLSv1_1
177 config
['server.ssl_module'] = 'builtin'
178 config
['server.ssl_certificate'] = cert_fname
179 config
['server.ssl_private_key'] = pkey_fname
180 config
['server.ssl_context'] = context
182 self
.update_cherrypy_config(config
)
184 self
._url
_prefix
= prepare_url_prefix(self
.get_module_option( # type: ignore
185 'url_prefix', default
=''))
187 uri
= "{0}://{1}:{2}{3}/".format(
188 'https' if use_ssl
else 'http',
189 socket
.getfqdn(server_addr
if server_addr
!= '::' else ''),
196 def await_configuration(self
):
198 Block until configuration is ready (i.e. all needed keys are set)
199 or self._stopping is set.
201 :returns URI of configured webserver
203 while not self
._stopping
.is_set():
205 uri
= self
._configure
()
206 except ServerConfigException
as e
:
207 self
.log
.info( # type: ignore
208 "Config not ready to serve, waiting: {0}".format(e
)
210 # Poll until a non-errored config is present
211 self
._stopping
.wait(5)
213 self
.log
.info("Configured CherryPy, starting engine...") # type: ignore
217 class Module(MgrModule
, CherryPyConfig
):
219 dashboard module entrypoint
224 'cmd': 'dashboard set-jwt-token-ttl '
225 'name=seconds,type=CephInt',
226 'desc': 'Set the JWT token TTL in seconds',
230 'cmd': 'dashboard get-jwt-token-ttl',
231 'desc': 'Get the JWT token TTL in seconds',
235 "cmd": "dashboard create-self-signed-cert",
236 "desc": "Create self signed certificate",
240 "cmd": "dashboard grafana dashboards update",
241 "desc": "Push dashboards to Grafana",
245 COMMANDS
.extend(options_command_list())
246 COMMANDS
.extend(SSO_COMMANDS
)
247 PLUGIN_MANAGER
.hook
.register_commands()
250 Option(name
='server_addr', type='str', default
=get_default_addr()),
251 Option(name
='server_port', type='int', default
=8080),
252 Option(name
='ssl_server_port', type='int', default
=8443),
253 Option(name
='jwt_token_ttl', type='int', default
=28800),
254 Option(name
='url_prefix', type='str', default
=''),
255 Option(name
='key_file', type='str', default
=''),
256 Option(name
='crt_file', type='str', default
=''),
257 Option(name
='ssl', type='bool', default
=True),
258 Option(name
='standby_behaviour', type='str', default
='redirect',
259 enum_allowed
=['redirect', 'error']),
260 Option(name
='standby_error_status_code', type='int', default
=500,
263 MODULE_OPTIONS
.extend(options_schema_list())
264 for options
in PLUGIN_MANAGER
.hook
.get_options() or []:
265 MODULE_OPTIONS
.extend(options
)
267 __pool_stats
= collections
.defaultdict(lambda: collections
.defaultdict(
268 lambda: collections
.deque(maxlen
=10))) # type: dict
270 def __init__(self
, *args
, **kwargs
):
271 super(Module
, self
).__init
__(*args
, **kwargs
)
272 CherryPyConfig
.__init
__(self
)
276 self
._stopping
= threading
.Event()
277 self
.shutdown_event
= threading
.Event()
278 self
.ACCESS_CTRL_DB
= None
280 self
.health_checks
= {}
285 return False, "Missing dependency: cherrypy"
287 if not os
.path
.exists(cls
.get_frontend_path()):
288 return False, ("Frontend assets not found at '{}': incomplete build?"
289 .format(cls
.get_frontend_path()))
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')
300 if 'COVERAGE_ENABLED' in os
.environ
:
302 __cov
= coverage
.Coverage(config_file
="{}/.coveragerc"
303 .format(os
.path
.dirname(__file__
)),
306 cherrypy
.engine
.subscribe('after_request', __cov
.save
)
307 cherrypy
.engine
.subscribe('stop', __cov
.stop
)
309 AuthManager
.initialize()
312 uri
= self
.await_configuration()
314 # We were shut down while waiting
317 # Publish the URI that others may use to access the service we're
318 # about to start serving
321 mapper
, parent_urls
= generate_routes(self
.url_prefix
)
324 for purl
in parent_urls
:
326 'request.dispatch': mapper
329 cherrypy
.tree
.mount(None, config
=config
)
331 PLUGIN_MANAGER
.hook
.setup()
333 cherrypy
.engine
.start()
334 NotificationQueue
.start_queue()
336 logger
.info('Engine started.')
337 update_dashboards
= str_to_bool(
338 self
.get_module_option('GRAFANA_UPDATE_DASHBOARDS', 'False'))
339 if update_dashboards
:
340 logger
.info('Starting Grafana dashboard task')
342 'grafana/dashboards/update',
344 push_local_dashboards
,
345 kwargs
=dict(tries
=10, sleep
=60),
347 # wait for the shutdown event
348 self
.shutdown_event
.wait()
349 self
.shutdown_event
.clear()
350 NotificationQueue
.stop()
351 cherrypy
.engine
.stop()
352 logger
.info('Engine stopped')
355 super(Module
, self
).shutdown()
356 CherryPyConfig
.shutdown(self
)
357 logger
.info('Stopping engine...')
358 self
.shutdown_event
.set()
360 @CLIWriteCommand("dashboard set-ssl-certificate")
361 def set_ssl_certificate(self
,
362 mgr_id
: Optional
[str] = None,
363 inbuf
: Optional
[bytes
] = None):
365 return -errno
.EINVAL
, '',\
366 'Please specify the certificate file with "-i" option'
367 if mgr_id
is not None:
368 self
.set_store(_get_localized_key(mgr_id
, 'crt'), inbuf
.decode())
370 self
.set_store('crt', inbuf
.decode())
371 return 0, 'SSL certificate updated', ''
373 @CLIWriteCommand("dashboard set-ssl-certificate-key")
374 def set_ssl_certificate_key(self
,
375 mgr_id
: Optional
[str] = None,
376 inbuf
: Optional
[bytes
] = None):
378 return -errno
.EINVAL
, '',\
379 'Please specify the certificate key file with "-i" option'
380 if mgr_id
is not None:
381 self
.set_store(_get_localized_key(mgr_id
, 'key'), inbuf
.decode())
383 self
.set_store('key', inbuf
.decode())
384 return 0, 'SSL certificate key updated', ''
386 def handle_command(self
, inbuf
, cmd
):
387 # pylint: disable=too-many-return-statements
388 res
= handle_option_command(cmd
, inbuf
)
389 if res
[0] != -errno
.ENOSYS
:
391 res
= handle_sso_command(cmd
)
392 if res
[0] != -errno
.ENOSYS
:
394 if cmd
['prefix'] == 'dashboard set-jwt-token-ttl':
395 self
.set_module_option('jwt_token_ttl', str(cmd
['seconds']))
396 return 0, 'JWT token TTL updated', ''
397 if cmd
['prefix'] == 'dashboard get-jwt-token-ttl':
398 ttl
= self
.get_module_option('jwt_token_ttl', JwtManager
.JWT_TOKEN_TTL
)
399 return 0, str(ttl
), ''
400 if cmd
['prefix'] == 'dashboard create-self-signed-cert':
401 self
.create_self_signed_cert()
402 return 0, 'Self-signed certificate created', ''
403 if cmd
['prefix'] == 'dashboard grafana dashboards update':
404 push_local_dashboards()
405 return 0, 'Grafana dashboards updated', ''
407 return (-errno
.EINVAL
, '', 'Command not found \'{0}\''
408 .format(cmd
['prefix']))
410 def create_self_signed_cert(self
):
411 cert
, pkey
= create_self_signed_cert('IT', 'ceph-dashboard')
412 self
.set_store('crt', cert
)
413 self
.set_store('key', pkey
)
415 def notify(self
, notify_type
, notify_id
):
416 NotificationQueue
.new_notification(notify_type
, notify_id
)
418 def get_updated_pool_stats(self
):
420 pool_stats
= {p
['id']: p
['stats'] for p
in df
['pools']}
422 for pool_id
, stats
in pool_stats
.items():
423 for stat_name
, stat_val
in stats
.items():
424 self
.__pool
_stats
[pool_id
][stat_name
].append((now
, stat_val
))
426 return self
.__pool
_stats
428 def config_notify(self
):
430 This method is called whenever one of our config options is changed.
432 PLUGIN_MANAGER
.hook
.config_notify()
434 def refresh_health_checks(self
):
435 self
.set_health_checks(self
.health_checks
)
438 class StandbyModule(MgrStandbyModule
, CherryPyConfig
):
439 def __init__(self
, *args
, **kwargs
):
440 super(StandbyModule
, self
).__init
__(*args
, **kwargs
)
441 CherryPyConfig
.__init
__(self
)
442 self
.shutdown_event
= threading
.Event()
444 # We can set the global mgr instance to ourselves even though
445 # we're just a standby, because it's enough for logging.
449 uri
= self
.await_configuration()
451 # We were shut down while waiting
458 def default(self
, *args
, **kwargs
):
459 if module
.get_module_option('standby_behaviour', 'redirect') == 'redirect':
460 active_uri
= module
.get_active_uri()
462 module
.log
.info("Redirecting to active '%s'", active_uri
)
463 raise cherrypy
.HTTPRedirect(active_uri
)
467 <!-- Note: this is only displayed when the standby
468 does not know an active URI to redirect to, otherwise
469 a simple redirect is returned instead -->
472 <meta http-equiv="refresh" content="{delay}">
475 No active ceph-mgr instance is currently running
476 the dashboard. A failover may be in progress.
477 Retrying in {delay} seconds...
481 return template
.format(delay
=5)
483 status
= module
.get_module_option('standby_error_status_code', 500)
484 raise cherrypy
.HTTPError(status
, message
="Keep on looking")
486 cherrypy
.tree
.mount(Root(), "{}/".format(self
.url_prefix
), {})
487 self
.log
.info("Starting engine...")
488 cherrypy
.engine
.start()
489 self
.log
.info("Engine started...")
490 # Wait for shutdown event
491 self
.shutdown_event
.wait()
492 self
.shutdown_event
.clear()
493 cherrypy
.engine
.stop()
494 self
.log
.info("Engine stopped.")
497 CherryPyConfig
.shutdown(self
)
499 self
.log
.info("Stopping engine...")
500 self
.shutdown_event
.set()
501 self
.log
.info("Stopped engine...")