1 # -*- coding: utf-8 -*-
3 ceph dashboard mgr plugin (based on CherryPy)
15 from typing
import TYPE_CHECKING
, Optional
16 from urllib
.parse
import urlparse
19 if sys
.version_info
>= (3, 8):
20 from typing
import Literal
22 from typing_extensions
import Literal
24 from mgr_module
import CLIReadCommand
, CLIWriteCommand
, HandleCommandResult
, \
25 MgrModule
, MgrStandbyModule
, NotifyType
, Option
, _get_localized_key
26 from mgr_util
import ServerConfigException
, build_url
, \
27 create_self_signed_cert
, get_default_addr
, verify_tls_files
30 from .controllers
import Router
, json_error_page
31 from .grafana
import push_local_dashboards
32 from .services
.auth
import AuthManager
, AuthManagerTool
, JwtManager
33 from .services
.exception
import dashboard_exception_handler
34 from .services
.rgw_client
import configure_rgw_credentials
35 from .services
.sso
import SSO_COMMANDS
, handle_sso_command
36 from .settings
import Settings
, handle_option_command
, options_command_list
, options_schema_list
37 from .tools
import NotificationQueue
, RequestLoggingTool
, TaskManager
, \
38 prepare_url_prefix
, str_to_bool
42 from cherrypy
._cptools
import HandlerWrapperTool
44 # To be picked up and reported by .can_run()
47 from .services
.sso
import load_sso_db
49 if cherrypy
is not None:
50 from .cherrypy_backports
import patch_cherrypy
51 patch_cherrypy(cherrypy
.__version
__)
53 # pylint: disable=wrong-import-position
54 from .plugins
import PLUGIN_MANAGER
, debug
, feature_toggles
, motd
# isort:skip # noqa E501 # pylint: disable=unused-import
56 PLUGIN_MANAGER
.hook
.init()
59 # cherrypy likes to sys.exit on error. don't let it take us down too!
60 # pylint: disable=W0613
61 def os_exit_noop(*args
):
65 # pylint: disable=W0212
66 os
._exit
= os_exit_noop
69 logger
= logging
.getLogger(__name__
)
72 class CherryPyConfig(object):
74 Class for common server configuration done by both active and
75 standby module, especially setting up SSL.
79 self
._stopping
= threading
.Event()
90 return self
._url
_prefix
93 def update_cherrypy_config(config
):
94 PLUGIN_MANAGER
.hook
.configure_cherrypy(config
=config
)
95 cherrypy
.config
.update(config
)
97 # pylint: disable=too-many-branches
100 Configure CherryPy and initialize self.url_prefix
104 server_addr
= self
.get_localized_module_option( # type: ignore
105 'server_addr', get_default_addr())
106 use_ssl
= self
.get_localized_module_option('ssl', True) # type: ignore
108 server_port
= self
.get_localized_module_option('server_port', 8080) # type: ignore
110 server_port
= self
.get_localized_module_option('ssl_server_port', 8443) # type: ignore
112 if server_addr
is None:
113 raise ServerConfigException(
114 'no server_addr configured; '
115 'try "ceph config set mgr mgr/{}/{}/server_addr <ip>"'
116 .format(self
.module_name
, self
.get_mgr_id())) # type: ignore
117 self
.log
.info('server: ssl=%s host=%s port=%d', 'yes' if use_ssl
else 'no', # type: ignore
118 server_addr
, server_port
)
120 # Initialize custom handlers.
121 cherrypy
.tools
.authenticate
= AuthManagerTool()
122 self
.configure_cors()
123 cherrypy
.tools
.plugin_hooks_filter_request
= cherrypy
.Tool(
125 lambda: PLUGIN_MANAGER
.hook
.filter_request_before_handler(request
=cherrypy
.request
),
127 cherrypy
.tools
.request_logging
= RequestLoggingTool()
128 cherrypy
.tools
.dashboard_exception_handler
= HandlerWrapperTool(dashboard_exception_handler
,
131 cherrypy
.log
.access_log
.propagate
= False
132 cherrypy
.log
.error_log
.propagate
= False
134 # Apply the 'global' CherryPy configuration.
136 'engine.autoreload.on': False,
137 'server.socket_host': server_addr
,
138 'server.socket_port': int(server_port
),
139 'error_page.default': json_error_page
,
140 'tools.request_logging.on': True,
141 'tools.gzip.on': True,
142 'tools.gzip.mime_types': [
143 # text/html and text/plain are the default types to compress
144 'text/html', 'text/plain',
145 # We also want JSON and JavaScript to be compressed
147 'application/*+json',
148 'application/javascript',
150 'tools.json_in.on': True,
151 'tools.json_in.force': True,
152 'tools.plugin_hooks_filter_request.on': True,
157 cert
= self
.get_localized_store("crt") # type: ignore
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
164 cert_fname
= self
.get_localized_module_option('crt_file') # type: ignore
166 pkey
= self
.get_localized_store("key") # type: ignore
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
173 pkey_fname
= self
.get_localized_module_option('key_file') # type: ignore
175 verify_tls_files(cert_fname
, pkey_fname
)
177 # Create custom SSL context to disable TLS 1.0 and 1.1.
178 context
= ssl
.create_default_context(ssl
.Purpose
.CLIENT_AUTH
)
179 context
.load_cert_chain(cert_fname
, pkey_fname
)
180 if sys
.version_info
>= (3, 7):
181 if Settings
.UNSAFE_TLS_v1_2
:
182 context
.minimum_version
= ssl
.TLSVersion
.TLSv1_2
184 context
.minimum_version
= ssl
.TLSVersion
.TLSv1_3
186 if Settings
.UNSAFE_TLS_v1_2
:
187 context
.options |
= ssl
.OP_NO_TLSv1 | ssl
.OP_NO_TLSv1_1
189 context
.options |
= ssl
.OP_NO_TLSv1 | ssl
.OP_NO_TLSv1_1 | ssl
.OP_NO_TLSv1_2
191 config
['server.ssl_module'] = 'builtin'
192 config
['server.ssl_certificate'] = cert_fname
193 config
['server.ssl_private_key'] = pkey_fname
194 config
['server.ssl_context'] = context
196 self
.update_cherrypy_config(config
)
198 self
._url
_prefix
= prepare_url_prefix(self
.get_module_option( # type: ignore
199 'url_prefix', default
=''))
201 if server_addr
in ['::', '0.0.0.0']:
202 server_addr
= self
.get_mgr_ip() # type: ignore
203 base_url
= build_url(
204 scheme
='https' if use_ssl
else 'http',
208 uri
= f
'{base_url}{self.url_prefix}/'
211 def await_configuration(self
):
213 Block until configuration is ready (i.e. all needed keys are set)
214 or self._stopping is set.
216 :returns URI of configured webserver
218 while not self
._stopping
.is_set():
220 uri
= self
._configure
()
221 except ServerConfigException
as e
:
222 self
.log
.info( # type: ignore
223 "Config not ready to serve, waiting: {0}".format(e
)
225 # Poll until a non-errored config is present
226 self
._stopping
.wait(5)
228 self
.log
.info("Configured CherryPy, starting engine...") # type: ignore
231 def configure_cors(self
):
233 Allow CORS requests if the cross_origin_url option is set.
235 cross_origin_url
= mgr
.get_localized_module_option('cross_origin_url', '')
237 cherrypy
.tools
.CORS
= cherrypy
.Tool('before_handler', self
.cors_tool
)
239 'tools.CORS.on': True,
241 self
.update_cherrypy_config(config
)
245 Handle both simple and complex CORS requests
247 Add CORS headers to each response. If the request is a CORS preflight
248 request swap out the default handler with a simple, single-purpose handler
249 that verifies the request and provides a valid CORS response.
251 req_head
= cherrypy
.request
.headers
252 resp_head
= cherrypy
.response
.headers
254 # Always set response headers necessary for 'simple' CORS.
255 req_header_cross_origin_url
= req_head
.get('Access-Control-Allow-Origin')
256 cross_origin_urls
= mgr
.get_localized_module_option('cross_origin_url', '')
257 cross_origin_url_list
= [url
.strip() for url
in cross_origin_urls
.split(',')]
258 if req_header_cross_origin_url
in cross_origin_url_list
:
259 resp_head
['Access-Control-Allow-Origin'] = req_header_cross_origin_url
260 resp_head
['Access-Control-Expose-Headers'] = 'GET, POST'
261 resp_head
['Access-Control-Allow-Credentials'] = 'true'
263 # Non-simple CORS preflight request; short-circuit the normal handler.
264 if cherrypy
.request
.method
== 'OPTIONS':
265 req_header_origin_url
= req_head
.get('Origin')
266 if req_header_origin_url
in cross_origin_url_list
:
267 resp_head
['Access-Control-Allow-Origin'] = req_header_origin_url
268 ac_method
= req_head
.get('Access-Control-Request-Method', None)
270 allowed_methods
= ['GET', 'POST', 'PUT']
275 'Access-Control-Allow-Origin'
278 if ac_method
and ac_method
in allowed_methods
:
279 resp_head
['Access-Control-Allow-Methods'] = ', '.join(allowed_methods
)
280 resp_head
['Access-Control-Allow-Headers'] = ', '.join(allowed_headers
)
282 resp_head
['Connection'] = 'keep-alive'
283 resp_head
['Access-Control-Max-Age'] = '3600'
285 # CORS requests should short-circuit the other tools.
286 cherrypy
.response
.body
= ''.encode('utf8')
287 cherrypy
.response
.status
= 200
288 cherrypy
.serving
.request
.handler
= None
290 # Needed to avoid the auth_tool check.
291 if cherrypy
.request
.config
.get('tools.sessions.on', False):
292 cherrypy
.session
['token'] = True
297 SslConfigKey
= Literal
['crt', 'key']
300 class Module(MgrModule
, CherryPyConfig
):
302 dashboard module entrypoint
307 'cmd': 'dashboard set-jwt-token-ttl '
308 'name=seconds,type=CephInt',
309 'desc': 'Set the JWT token TTL in seconds',
313 'cmd': 'dashboard get-jwt-token-ttl',
314 'desc': 'Get the JWT token TTL in seconds',
318 "cmd": "dashboard create-self-signed-cert",
319 "desc": "Create self signed certificate",
323 "cmd": "dashboard grafana dashboards update",
324 "desc": "Push dashboards to Grafana",
328 COMMANDS
.extend(options_command_list())
329 COMMANDS
.extend(SSO_COMMANDS
)
330 PLUGIN_MANAGER
.hook
.register_commands()
333 Option(name
='server_addr', type='str', default
=get_default_addr()),
334 Option(name
='server_port', type='int', default
=8080),
335 Option(name
='ssl_server_port', type='int', default
=8443),
336 Option(name
='jwt_token_ttl', type='int', default
=28800),
337 Option(name
='url_prefix', type='str', default
=''),
338 Option(name
='key_file', type='str', default
=''),
339 Option(name
='crt_file', type='str', default
=''),
340 Option(name
='ssl', type='bool', default
=True),
341 Option(name
='standby_behaviour', type='str', default
='redirect',
342 enum_allowed
=['redirect', 'error']),
343 Option(name
='standby_error_status_code', type='int', default
=500,
345 Option(name
='redirect_resolve_ip_addr', type='bool', default
=False),
346 Option(name
='cross_origin_url', type='str', default
=''),
348 MODULE_OPTIONS
.extend(options_schema_list())
349 for options
in PLUGIN_MANAGER
.hook
.get_options() or []:
350 MODULE_OPTIONS
.extend(options
)
352 NOTIFY_TYPES
= [NotifyType
.clog
]
354 __pool_stats
= collections
.defaultdict(lambda: collections
.defaultdict(
355 lambda: collections
.deque(maxlen
=10))) # type: dict
357 def __init__(self
, *args
, **kwargs
):
358 super(Module
, self
).__init
__(*args
, **kwargs
)
359 CherryPyConfig
.__init
__(self
)
363 self
._stopping
= threading
.Event()
364 self
.shutdown_event
= threading
.Event()
365 self
.ACCESS_CTRL_DB
= None
367 self
.health_checks
= {}
372 return False, "Missing dependency: cherrypy"
374 if not os
.path
.exists(cls
.get_frontend_path()):
375 return False, ("Frontend assets not found at '{}': incomplete build?"
376 .format(cls
.get_frontend_path()))
381 def get_frontend_path(cls
):
382 current_dir
= os
.path
.dirname(os
.path
.abspath(__file__
))
383 path
= os
.path
.join(current_dir
, 'frontend/dist')
384 if os
.path
.exists(path
):
387 path
= os
.path
.join(current_dir
,
389 'src/pybind/mgr/dashboard',
391 return os
.path
.abspath(path
)
395 if 'COVERAGE_ENABLED' in os
.environ
:
397 __cov
= coverage
.Coverage(config_file
="{}/.coveragerc"
398 .format(os
.path
.dirname(__file__
)),
401 cherrypy
.engine
.subscribe('after_request', __cov
.save
)
402 cherrypy
.engine
.subscribe('stop', __cov
.stop
)
404 AuthManager
.initialize()
407 uri
= self
.await_configuration()
409 # We were shut down while waiting
412 # Publish the URI that others may use to access the service we're
413 # about to start serving
416 mapper
, parent_urls
= Router
.generate_routes(self
.url_prefix
)
419 for purl
in parent_urls
:
421 'request.dispatch': mapper
424 cherrypy
.tree
.mount(None, config
=config
)
426 PLUGIN_MANAGER
.hook
.setup()
428 cherrypy
.engine
.start()
429 NotificationQueue
.start_queue()
431 logger
.info('Engine started.')
432 update_dashboards
= str_to_bool(
433 self
.get_module_option('GRAFANA_UPDATE_DASHBOARDS', 'False'))
434 if update_dashboards
:
435 logger
.info('Starting Grafana dashboard task')
437 'grafana/dashboards/update',
439 push_local_dashboards
,
440 kwargs
=dict(tries
=10, sleep
=60),
442 # wait for the shutdown event
443 self
.shutdown_event
.wait()
444 self
.shutdown_event
.clear()
445 NotificationQueue
.stop()
446 cherrypy
.engine
.stop()
447 logger
.info('Engine stopped')
450 super(Module
, self
).shutdown()
451 CherryPyConfig
.shutdown(self
)
452 logger
.info('Stopping engine...')
453 self
.shutdown_event
.set()
455 def _set_ssl_item(self
, item_label
: str, item_key
: 'SslConfigKey' = 'crt',
456 mgr_id
: Optional
[str] = None, inbuf
: Optional
[str] = None):
458 return -errno
.EINVAL
, '', f
'Please specify the {item_label} with "-i" option'
460 if mgr_id
is not None:
461 self
.set_store(_get_localized_key(mgr_id
, item_key
), inbuf
)
463 self
.set_store(item_key
, inbuf
)
464 return 0, f
'SSL {item_label} updated', ''
466 @CLIWriteCommand("dashboard set-ssl-certificate")
467 def set_ssl_certificate(self
, mgr_id
: Optional
[str] = None, inbuf
: Optional
[str] = None):
468 return self
._set
_ssl
_item
('certificate', 'crt', mgr_id
, inbuf
)
470 @CLIWriteCommand("dashboard set-ssl-certificate-key")
471 def set_ssl_certificate_key(self
, mgr_id
: Optional
[str] = None, inbuf
: Optional
[str] = None):
472 return self
._set
_ssl
_item
('certificate key', 'key', mgr_id
, inbuf
)
474 @CLIWriteCommand("dashboard create-self-signed-cert")
475 def set_mgr_created_self_signed_cert(self
):
476 cert
, pkey
= create_self_signed_cert('IT', 'ceph-dashboard')
477 result
= HandleCommandResult(*self
.set_ssl_certificate(inbuf
=cert
))
478 if result
.retval
!= 0:
481 result
= HandleCommandResult(*self
.set_ssl_certificate_key(inbuf
=pkey
))
482 if result
.retval
!= 0:
484 return 0, 'Self-signed certificate created', ''
486 @CLIWriteCommand("dashboard set-rgw-credentials")
487 def set_rgw_credentials(self
):
489 configure_rgw_credentials()
490 except Exception as error
:
491 return -errno
.EINVAL
, '', str(error
)
493 return 0, 'RGW credentials configured', ''
495 @CLIWriteCommand("dashboard set-login-banner")
496 def set_login_banner(self
, inbuf
: str):
498 Set the custom login banner read from -i <file>
500 item_label
= 'login banner file'
502 return HandleCommandResult(
504 stderr
=f
'Please specify the {item_label} with "-i" option'
506 mgr
.set_store('custom_login_banner', inbuf
)
507 return HandleCommandResult(stdout
=f
'{item_label} added')
509 @CLIReadCommand("dashboard get-login-banner")
510 def get_login_banner(self
):
512 Get the custom login banner text
514 banner_text
= mgr
.get_store('custom_login_banner')
515 if banner_text
is None:
516 return HandleCommandResult(stdout
='No login banner set')
518 return HandleCommandResult(stdout
=banner_text
)
520 @CLIWriteCommand("dashboard unset-login-banner")
521 def unset_login_banner(self
):
523 Unset the custom login banner
525 mgr
.set_store('custom_login_banner', None)
526 return HandleCommandResult(stdout
='Login banner removed')
528 def handle_command(self
, inbuf
, cmd
):
529 # pylint: disable=too-many-return-statements
530 res
= handle_option_command(cmd
, inbuf
)
531 if res
[0] != -errno
.ENOSYS
:
533 res
= handle_sso_command(cmd
)
534 if res
[0] != -errno
.ENOSYS
:
536 if cmd
['prefix'] == 'dashboard set-jwt-token-ttl':
537 self
.set_module_option('jwt_token_ttl', str(cmd
['seconds']))
538 return 0, 'JWT token TTL updated', ''
539 if cmd
['prefix'] == 'dashboard get-jwt-token-ttl':
540 ttl
= self
.get_module_option('jwt_token_ttl', JwtManager
.JWT_TOKEN_TTL
)
541 return 0, str(ttl
), ''
542 if cmd
['prefix'] == 'dashboard grafana dashboards update':
543 push_local_dashboards()
544 return 0, 'Grafana dashboards updated', ''
546 return (-errno
.EINVAL
, '', 'Command not found \'{0}\''
547 .format(cmd
['prefix']))
549 def notify(self
, notify_type
: NotifyType
, notify_id
):
550 NotificationQueue
.new_notification(str(notify_type
), notify_id
)
552 def get_updated_pool_stats(self
):
554 pool_stats
= {p
['id']: p
['stats'] for p
in df
['pools']}
556 for pool_id
, stats
in pool_stats
.items():
557 for stat_name
, stat_val
in stats
.items():
558 self
.__pool
_stats
[pool_id
][stat_name
].append((now
, stat_val
))
560 return self
.__pool
_stats
562 def config_notify(self
):
564 This method is called whenever one of our config options is changed.
566 PLUGIN_MANAGER
.hook
.config_notify()
568 def refresh_health_checks(self
):
569 self
.set_health_checks(self
.health_checks
)
572 class StandbyModule(MgrStandbyModule
, CherryPyConfig
):
573 def __init__(self
, *args
, **kwargs
):
574 super(StandbyModule
, self
).__init
__(*args
, **kwargs
)
575 CherryPyConfig
.__init
__(self
)
576 self
.shutdown_event
= threading
.Event()
578 # We can set the global mgr instance to ourselves even though
579 # we're just a standby, because it's enough for logging.
583 uri
= self
.await_configuration()
585 # We were shut down while waiting
592 def default(self
, *args
, **kwargs
):
593 if module
.get_module_option('standby_behaviour', 'redirect') == 'redirect':
594 active_uri
= module
.get_active_uri()
596 if cherrypy
.request
.path_info
.startswith('/api/prometheus_receiver'):
597 module
.log
.debug("Suppressed redirecting alert to active '%s'",
599 cherrypy
.response
.status
= 204
603 if module
.get_module_option('redirect_resolve_ip_addr'):
604 p_result
= urlparse(active_uri
)
605 hostname
= str(p_result
.hostname
)
606 fqdn_netloc
= p_result
.netloc
.replace(
607 hostname
, socket
.getfqdn(hostname
))
608 active_uri
= p_result
._replace
(netloc
=fqdn_netloc
).geturl()
610 module
.log
.info("Redirecting to active '%s'", active_uri
)
611 raise cherrypy
.HTTPRedirect(active_uri
)
615 <!-- Note: this is only displayed when the standby
616 does not know an active URI to redirect to, otherwise
617 a simple redirect is returned instead -->
620 <meta http-equiv="refresh" content="{delay}">
623 No active ceph-mgr instance is currently running
624 the dashboard. A failover may be in progress.
625 Retrying in {delay} seconds...
629 return template
.format(delay
=5)
631 status
= module
.get_module_option('standby_error_status_code', 500)
632 raise cherrypy
.HTTPError(status
, message
="Keep on looking")
634 cherrypy
.tree
.mount(Root(), "{}/".format(self
.url_prefix
), {})
635 self
.log
.info("Starting engine...")
636 cherrypy
.engine
.start()
637 self
.log
.info("Engine started...")
638 # Wait for shutdown event
639 self
.shutdown_event
.wait()
640 self
.shutdown_event
.clear()
641 cherrypy
.engine
.stop()
642 self
.log
.info("Engine stopped.")
645 CherryPyConfig
.shutdown(self
)
647 self
.log
.info("Stopping engine...")
648 self
.shutdown_event
.set()
649 self
.log
.info("Stopped engine...")