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