Test Failed
Pull Request — master (#566)
by
unknown
05:34 queued 01:30
created

tabpy.tabpy_server.app.app.TabPyApp._validate_transfer_protocol_settings()   A

Complexity

Conditions 4

Size

Total Lines 29
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 4.1054

Importance

Changes 0
Metric Value
eloc 22
dl 0
loc 29
ccs 13
cts 16
cp 0.8125
rs 9.352
c 0
b 0
f 0
cc 4
nop 1
crap 4.1054
1 1
import concurrent.futures
2 1
import configparser
3 1
import logging
4 1
import multiprocessing
5 1
import os
6 1
import shutil
7 1
import signal
8 1
import sys
9 1
import tabpy
10 1
from tabpy.tabpy import __version__
11 1
from tabpy.tabpy_server.app.app_parameters import ConfigParameters, SettingsParameters
12 1
from tabpy.tabpy_server.app.util import parse_pwd_file
13 1
from tabpy.tabpy_server.management.state import TabPyState
14 1
from tabpy.tabpy_server.management.util import _get_state_from_file
15 1
from tabpy.tabpy_server.psws.callbacks import init_model_evaluator, init_ps_server
16 1
from tabpy.tabpy_server.psws.python_service import PythonService, PythonServiceHandler
17 1
from tabpy.tabpy_server.handlers import (
18
    EndpointHandler,
19
    EndpointsHandler,
20
    EvaluationPlaneHandler,
21
    EvaluationPlaneDisabledHandler,
22
    QueryPlaneHandler,
23
    ServiceInfoHandler,
24
    StatusHandler,
25
    UploadDestinationHandler,
26
)
27 1
import tornado
28
import secure
29
30 1
31
logger = logging.getLogger(__name__)
32
secure_headers = secure.Secure()
33 1
34
35
def _init_asyncio_patch():
36
    """
37
    Select compatible event loop for Tornado 5+.
38
    As of Python 3.8, the default event loop on Windows is `proactor`,
39
    however Tornado requires the old default "selector" event loop.
40
    As Tornado has decided to leave this to users to set, MkDocs needs
41 1
    to set it. See https://github.com/tornadoweb/tornado/issues/2608.
42
    """
43
    if sys.platform.startswith("win") and sys.version_info >= (3, 8):
44
        import asyncio
45
        try:
46
            from asyncio import WindowsSelectorEventLoopPolicy
47
        except ImportError:
48
            pass  # Can't assign a policy which doesn't exist.
49
        else:
50
            if not isinstance(asyncio.get_event_loop_policy(), WindowsSelectorEventLoopPolicy):
51
                asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
52 1
53
54
class TabPyApp:
55
    """
56
    TabPy application class for keeping context like settings, state, etc.
57 1
    """
58 1
59 1
    settings = {}
60 1
    subdirectory = ""
61 1
    tabpy_state = None
62
    python_service = None
63 1
    credentials = {}
64 1
65 1
    def __init__(self, config_file):
66
        if config_file is None:
67
            config_file = os.path.join(
68
                os.path.dirname(__file__), os.path.pardir, "common", "default.conf"
69 1
            )
70 1
71 1
        if os.path.isfile(config_file):
72 1
            try:
73 1
                from logging import config
74 1
                config.fileConfig(config_file, disable_existing_loggers=False)
75
            except KeyError:
76 1
                logging.basicConfig(level=logging.DEBUG)
77
78 1
        self._parse_config(config_file)
79
80
    def run(self):
81
        application = self._create_tornado_web_app()
82
        max_request_size = (
83
            int(self.settings[SettingsParameters.MaxRequestSizeInMb]) * 1024 * 1024
84
        )
85
        logger.info(f"Setting max request size to {max_request_size} bytes")
86
87
        init_model_evaluator(self.settings, self.tabpy_state, self.python_service)
88
89
        protocol = self.settings[SettingsParameters.TransferProtocol]
90
        ssl_options = None
91
        if protocol == "https":
92
            ssl_options = {
93
                "certfile": self.settings[SettingsParameters.CertificateFile],
94
                "keyfile": self.settings[SettingsParameters.KeyFile],
95
            }
96
            hsts_value = (secure.StrictTransportSecurity().include_subdomains().preload().max_age(2592000))
97
98
secure_headers = secure.Secure(hsts=hsts_value)
99
        elif protocol != "http":
100
            msg = f"Unsupported transfer protocol {protocol}."
101
            logger.critical(msg)
102
            raise RuntimeError(msg)
103
104
        settings = {}
105
        if self.settings[SettingsParameters.GzipEnabled] is True:
106
            settings["decompress_request"] = True
107
        application.listen(
108
            self.settings[SettingsParameters.Port],
109
            ssl_options=ssl_options,
110
            max_buffer_size=max_request_size,
111
            max_body_size=max_request_size,
112
            **settings,
113
        )
114
115
        logger.info(
116 1
            "Web service listening on port "
117 1
            f"{str(self.settings[SettingsParameters.Port])}"
118 1
        )
119
        tornado.ioloop.IOLoop.instance().start()
120 1
121
    def _create_tornado_web_app(self):
122
        class TabPyTornadoApp(tornado.web.Application):
123
            is_closing = False
124 1
125
            def signal_handler(self, signal, _):
126
                logger.critical(f"Exiting on signal {signal}...")
127
                self.is_closing = True
128
129 1
            def try_exit(self):
130 1
                if self.is_closing:
131
                    tornado.ioloop.IOLoop.instance().stop()
132
                    logger.info("Shutting down TabPy...")
133 1
134
        logger.info("Initializing TabPy...")
135 1
        tornado.ioloop.IOLoop.instance().run_sync(
136
            lambda: init_ps_server(self.settings, self.tabpy_state)
137
        )
138
        logger.info("Done initializing TabPy.")
139
140 1
        executor = concurrent.futures.ThreadPoolExecutor(
141 1
            max_workers=multiprocessing.cpu_count()
142
        )
143
144
        # initialize Tornado application
145
        _init_asyncio_patch()
146
        application = TabPyTornadoApp(
147
            [
148
                (
149
                    self.subdirectory + r"/query/([^/]+)",
150
                    QueryPlaneHandler,
151
                    dict(app=self),
152
                ),
153
                (self.subdirectory + r"/status", StatusHandler, dict(app=self)),
154
                (self.subdirectory + r"/info", ServiceInfoHandler, dict(app=self)),
155
                (self.subdirectory + r"/endpoints", EndpointsHandler, dict(app=self)),
156
                (
157
                    self.subdirectory + r"/endpoints/([^/]+)?",
158
                    EndpointHandler,
159
                    dict(app=self),
160
                ),
161
                (
162
                    self.subdirectory + r"/evaluate",
163
                    EvaluationPlaneHandler if self.settings[SettingsParameters.EvaluateEnabled]
164
                    else EvaluationPlaneDisabledHandler,
165
                    dict(executor=executor, app=self),
166
                ),
167
                (
168
                    self.subdirectory + r"/configurations/endpoint_upload_destination",
169
                    UploadDestinationHandler,
170
                    dict(app=self),
171
                ),
172
                (
173
                    self.subdirectory + r"/(.*)",
174
                    tornado.web.StaticFileHandler,
175
                    dict(
176
                        path=self.settings[SettingsParameters.StaticPath],
177
                        default_filename="index.html",
178
                    ),
179
                ),
180 1
            ],
181 1
            debug=False,
182
            **self.settings,
183 1
        )
184 1
185
        signal.signal(signal.SIGINT, application.signal_handler)
186 1
        tornado.ioloop.PeriodicCallback(application.try_exit, 500).start()
187
188 1
        signal.signal(signal.SIGINT, application.signal_handler)
189 1
        tornado.ioloop.PeriodicCallback(application.try_exit, 500).start()
190
191 1
        return application
192
193
    def _set_parameter(self, parser, settings_key, config_key, default_val, parse_function):
194
        key_is_set = False
195
196 1
        if (
197 1
            config_key is not None
198 1
            and parser.has_section("TabPy")
199 1
            and parser.has_option("TabPy", config_key)
200 1
        ):
201
            if parse_function is None:
202
                parse_function = parser.get
203
            self.settings[settings_key] = parse_function("TabPy", config_key)
204
            key_is_set = True
205
            logger.debug(
206 1
                f"Parameter {settings_key} set to "
207 1
                f'"{self.settings[settings_key]}" '
208 1
                "from config file or environment variable"
209 1
            )
210
211
        if not key_is_set and default_val is not None:
212
            self.settings[settings_key] = default_val
213
            key_is_set = True
214
            logger.debug(
215 1
                f"Parameter {settings_key} set to "
216 1
                f'"{self.settings[settings_key]}" '
217
                "from default value"
218 1
            )
219
220
        if not key_is_set:
221
            logger.debug(f"Parameter {settings_key} is not set")
222
223
    def _parse_config(self, config_file):
224
        """Provide consistent mechanism for pulling in configuration.
225
226
        Attempt to retain backward compatibility for
227
        existing implementations by grabbing port
228
        setting from CLI first.
229
230
        Take settings in the following order:
231
232
        1. CLI arguments if present
233
        2. config file
234
        3. OS environment variables (for ease of
235
           setting defaults if not present)
236
        4. current defaults if a setting is not present in any location
237
238
        Additionally provide similar configuration capabilities in between
239 1
        config file and environment variables.
240 1
        For consistency use the same variable name in the config file as
241 1
        in the os environment.
242 1
        For naming standards use all capitals and start with 'TABPY_'
243 1
        """
244
        self.settings = {}
245 1
        self.subdirectory = ""
246
        self.tabpy_state = None
247 1
        self.python_service = None
248 1
        self.credentials = {}
249
250 1
        pkg_path = os.path.dirname(tabpy.__file__)
251 1
252 1
        parser = configparser.ConfigParser(os.environ)
253 1
        logger.info(f"Parsing config file {config_file}")
254 1
255 1
        file_exists = False
256
        if os.path.isfile(config_file):
257
            try:
258
                with open(config_file, 'r') as f:
259 1
                    parser.read_string(f.read())
260 1
                    file_exists = True
261
            except Exception:
262
                pass
263
264
        if not file_exists:
265 1
            logger.warning(
266
                f"Unable to open config file {config_file}, "
267
                "using default settings."
268
            )
269
270
        settings_parameters = [
271
            (SettingsParameters.Port, ConfigParameters.TABPY_PORT, 9004, None),
272
            (SettingsParameters.ServerVersion, None, __version__, None),
273
            (SettingsParameters.EvaluateEnabled, ConfigParameters.TABPY_EVALUATE_ENABLE,
274
             True, parser.getboolean),
275
            (SettingsParameters.EvaluateTimeout, ConfigParameters.TABPY_EVALUATE_TIMEOUT,
276
             30, parser.getfloat),
277
            (SettingsParameters.UploadDir, ConfigParameters.TABPY_QUERY_OBJECT_PATH,
278
             os.path.join(pkg_path, "tmp", "query_objects"), None),
279
            (SettingsParameters.TransferProtocol, ConfigParameters.TABPY_TRANSFER_PROTOCOL,
280
             "http", None),
281
            (SettingsParameters.CertificateFile, ConfigParameters.TABPY_CERTIFICATE_FILE,
282
             None, None),
283
            (SettingsParameters.KeyFile, ConfigParameters.TABPY_KEY_FILE, None, None),
284
            (SettingsParameters.StateFilePath, ConfigParameters.TABPY_STATE_PATH,
285
             os.path.join(pkg_path, "tabpy_server"), None),
286
            (SettingsParameters.StaticPath, ConfigParameters.TABPY_STATIC_PATH,
287
             os.path.join(pkg_path, "tabpy_server", "static"), None),
288
            (ConfigParameters.TABPY_PWD_FILE, ConfigParameters.TABPY_PWD_FILE, None, None),
289
            (SettingsParameters.LogRequestContext, ConfigParameters.TABPY_LOG_DETAILS,
290
             "false", None),
291
            (SettingsParameters.MaxRequestSizeInMb, ConfigParameters.TABPY_MAX_REQUEST_SIZE_MB,
292 1
             100, None),
293 1
            (SettingsParameters.GzipEnabled, ConfigParameters.TABPY_GZIP_ENABLE,
294
             True, parser.getboolean),
295 1
        ]
296 1
297
        for setting, parameter, default_val, parse_function in settings_parameters:
298
            self._set_parameter(parser, setting, parameter, default_val, parse_function)
299 1
300
        if not os.path.exists(self.settings[SettingsParameters.UploadDir]):
301
            os.makedirs(self.settings[SettingsParameters.UploadDir])
302
303 1
        # set and validate transfer protocol
304
        self.settings[SettingsParameters.TransferProtocol] = self.settings[
305
            SettingsParameters.TransferProtocol
306
        ].lower()
307 1
308
        self._validate_transfer_protocol_settings()
309
310
        # if state.ini does not exist try and create it - remove
311
        # last dependence on batch/shell script
312 1
        self.settings[SettingsParameters.StateFilePath] = os.path.realpath(
313
            os.path.normpath(
314 1
                os.path.expanduser(self.settings[SettingsParameters.StateFilePath])
315 1
            )
316 1
        )
317
        state_config, self.tabpy_state = self._build_tabpy_state()
318
319 1
        self.python_service = PythonServiceHandler(PythonService())
320
        self.settings["compress_response"] = True
321
        self.settings[SettingsParameters.StaticPath] = os.path.abspath(
322
            self.settings[SettingsParameters.StaticPath]
323
        )
324
        logger.debug(
325 1
            f"Static pages folder set to "
326 1
            f'"{self.settings[SettingsParameters.StaticPath]}"'
327
        )
328
329 1
        # Set subdirectory from config if applicable
330 1
        if state_config.has_option("Service Info", "Subdirectory"):
331 1
            self.subdirectory = "/" + state_config.get("Service Info", "Subdirectory")
332
333
        # If passwords file specified load credentials
334
        if ConfigParameters.TABPY_PWD_FILE in self.settings:
335 1
            if not self._parse_pwd_file():
336 1
                msg = (
337
                    "Failed to read passwords file "
338 1
                    f"{self.settings[ConfigParameters.TABPY_PWD_FILE]}"
339
                )
340
                logger.critical(msg)
341
                raise RuntimeError(msg)
342 1
        else:
343 1
            logger.info(
344
                "Password file is not specified: " "Authentication is not enabled"
345 1
            )
346
347
        features = self._get_features()
348 1
        self.settings[SettingsParameters.ApiVersions] = {"v1": {"features": features}}
349
350
        self.settings[SettingsParameters.LogRequestContext] = (
351
            self.settings[SettingsParameters.LogRequestContext].lower() != "false"
352
        )
353 1
        call_context_state = (
354
            "enabled"
355 1
            if self.settings[SettingsParameters.LogRequestContext]
356 1
            else "disabled"
357
        )
358
        logger.info(f"Call context logging is {call_context_state}")
359
360
    def _validate_transfer_protocol_settings(self):
361 1
        if SettingsParameters.TransferProtocol not in self.settings:
362
            msg = "Missing transfer protocol information."
363 1
            logger.critical(msg)
364 1
            raise RuntimeError(msg)
365
366 1
        protocol = self.settings[SettingsParameters.TransferProtocol]
367 1
368 1
        if protocol == "http":
369 1
            return
370
371 1
        if protocol != "https":
372
            msg = f"Unsupported transfer protocol: {protocol}"
373
            logger.critical(msg)
374
            raise RuntimeError(msg)
375
376 1
        self._validate_cert_key_state(
377
            "The parameter(s) {} must be set.",
378 1
            SettingsParameters.CertificateFile in self.settings,
379
            SettingsParameters.KeyFile in self.settings,
380
        )
381
        cert = self.settings[SettingsParameters.CertificateFile]
382
383 1
        self._validate_cert_key_state(
384
            "The parameter(s) {} must point to " "an existing file.",
385 1
            os.path.isfile(cert),
386
            os.path.isfile(self.settings[SettingsParameters.KeyFile]),
387 1
        )
388
        tabpy.tabpy_server.app.util.validate_cert(cert)
389
390
    @staticmethod
391 1
    def _validate_cert_key_state(msg, cert_valid, key_valid):
392 1
        cert_and_key_param = (
393 1
            f"{ConfigParameters.TABPY_CERTIFICATE_FILE} and "
394 1
            f"{ConfigParameters.TABPY_KEY_FILE}"
395 1
        )
396 1
        https_error = "Error using HTTPS: "
397 1
        err = None
398 1
        if not cert_valid and not key_valid:
399
            err = https_error + msg.format(cert_and_key_param)
400 1
        elif not cert_valid:
401 1
            err = https_error + msg.format(ConfigParameters.TABPY_CERTIFICATE_FILE)
402 1
        elif not key_valid:
403
            err = https_error + msg.format(ConfigParameters.TABPY_KEY_FILE)
404 1
405 1
        if err is not None:
406
            logger.critical(err)
407
            raise RuntimeError(err)
408
409 1
    def _parse_pwd_file(self):
410 1
        succeeded, self.credentials = parse_pwd_file(
411 1
            self.settings[ConfigParameters.TABPY_PWD_FILE]
412
        )
413 1
414
        if succeeded and len(self.credentials) == 0:
415 1
            logger.error("No credentials found")
416 1
            succeeded = False
417
418
        return succeeded
419 1
420 1
    def _get_features(self):
421
        features = {}
422
423
        # Check for auth
424
        if ConfigParameters.TABPY_PWD_FILE in self.settings:
425 1
            features["authentication"] = {
426 1
                "required": True,
427 1
                "methods": {"basic-auth": {}},
428
            }
429 1
430 1
        features["evaluate_enabled"] = self.settings[SettingsParameters.EvaluateEnabled]
431 1
        features["gzip_enabled"] = self.settings[SettingsParameters.GzipEnabled]
432 1
        return features
433 1
434
    def _build_tabpy_state(self):
435
        pkg_path = os.path.dirname(tabpy.__file__)
436
        state_file_dir = self.settings[SettingsParameters.StateFilePath]
437
        state_file_path = os.path.join(state_file_dir, "state.ini")
438
        if not os.path.isfile(state_file_path):
439
            state_file_template_path = os.path.join(
440
                pkg_path, "tabpy_server", "state.ini.template"
441
            )
442
            logger.debug(
443 1
                f"File {state_file_path} not found, creating from "
444 1
                f"template {state_file_template_path}..."
445 1
            )
446
            shutil.copy(state_file_template_path, state_file_path)
447
448
        logger.info(f"Loading state from state file {state_file_path}")
449
        tabpy_state = _get_state_from_file(state_file_dir)
450
        return tabpy_state, TabPyState(config=tabpy_state, settings=self.settings)
451