Passed
Pull Request — master (#284)
by Vinicius
07:43
created

kytos.core.api_server.APIServer._install_napp()   A

Complexity

Conditions 4

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 11
nop 3
dl 0
loc 17
rs 9.85
c 0
b 0
f 0
ccs 10
cts 10
cp 1
crap 4
1
"""Module used to handle a API Server."""
2 1
import json
3 1
import logging
4 1
import os
5 1
import shutil
6 1
import sys
7 1
import warnings
8 1
import zipfile
9 1
from datetime import datetime
10 1
from glob import glob
11 1
from http import HTTPStatus
12 1
from urllib.error import HTTPError, URLError
13 1
from urllib.request import urlopen, urlretrieve
14
15 1
from flask import Blueprint, Flask, jsonify, request, send_file
16 1
from flask.json import JSONEncoder
17 1
from flask_cors import CORS
18 1
from flask_socketio import SocketIO, join_room, leave_room
19 1
from werkzeug.exceptions import HTTPException
20
21 1
from kytos.core.auth import authenticated
22 1
from kytos.core.config import KytosConfig
23
24 1
LOG = logging.getLogger(__name__)
25
26
27
# pylint: disable=method-hidden,arguments-differ,consider-using-f-string
28 1
class CustomJSONEncoder(JSONEncoder):
29
    """CustomJSONEncoder."""
30
31 1
    def default(self, o):
32 1
        if isinstance(o, datetime):
33 1
            return o.strftime("%Y-%m-%dT%H:%M:%S")
34
        return JSONEncoder.default(self, o)
35
36
37 1
class APIServer:
38
    """Api server used to provide Kytos Controller routes."""
39
40
    #: tuple: Default Flask HTTP methods.
41 1
    DEFAULT_METHODS = ('GET',)
42 1
    _NAPP_PREFIX = "/api/{napp.username}/{napp.name}/"
43 1
    _CORE_PREFIX = "/api/kytos/core/"
44
45
    # pylint: disable=too-many-arguments
46 1
    def __init__(self, app_name, listen='0.0.0.0', port=8181,
47
                 napps_manager=None, napps_dir=None):
48
        """Start a Flask+SocketIO server.
49
50
        Require controller to get NApps dir and NAppsManager
51
52
        Args:
53
            app_name(string): String representing a App Name
54
            listen (string): host name used by api server instance
55
            port (int): Port number used by api server instance
56
            controller(kytos.core.controller): A controller instance.
57
        """
58 1
        dirname = os.path.dirname(os.path.abspath(__file__))
59 1
        self.napps_manager = napps_manager
60 1
        self.napps_dir = napps_dir
61
62 1
        self.flask_dir = os.path.join(dirname, '../web-ui')
63
64 1
        self.listen = listen
65 1
        self.port = port
66
67 1
        self.app = Flask(app_name, root_path=self.flask_dir,
68
                         static_folder="dist", static_url_path="/dist")
69 1
        self.app.json_encoder = CustomJSONEncoder
70 1
        self.server = SocketIO(self.app, async_mode='threading')
71 1
        self._enable_websocket_rooms()
72
        # ENABLE CROSS ORIGIN RESOURCE SHARING
73 1
        CORS(self.app)
74
75
        # Disable trailing slash
76 1
        self.app.url_map.strict_slashes = False
77
78
        # Update web-ui if necessary
79 1
        self.update_web_ui(force=False)
80
81 1
        @self.app.errorhandler(HTTPException)
82 1
        def handle_exception(exception):
83
            # pylint: disable=unused-variable, invalid-name
84
            """Return a json for HTTP errors.
85
86
            This handler allows NApps to return HTTP error messages
87
            by raising a HTTPException. When raising the Exception,
88
            create it with a description.
89
            """
90
            response = jsonify({
91
                'code': exception.code,
92
                'name': exception.name,
93
                'description': exception.description
94
            })
95
            return response, exception.code
96
97 1
    def _enable_websocket_rooms(self):
98 1
        socket = self.server
99 1
        socket.on_event('join', join_room)
100 1
        socket.on_event('leave', leave_room)
101
102 1
    def run(self):
103
        """Run the Flask API Server."""
104 1
        try:
105 1
            self.server.run(self.app, self.listen, self.port)
106 1
        except OSError as exception:
107 1
            msg = "Couldn't start API Server: {}".format(exception)
108 1
            LOG.critical(msg)
109 1
            sys.exit(msg)
110
111 1
    def register_rest_endpoint(self, url, function, methods):
112
        """Deprecate in favor of @rest decorator."""
113 1
        warnings.warn("From now on, use @rest decorator.", DeprecationWarning,
114
                      stacklevel=2)
115 1
        if url.startswith('/'):
116
            url = url[1:]
117 1
        self._start_endpoint(self.app, f'/kytos/{url}', function,
118
                             methods=methods)
119
120 1
    def start_api(self):
121
        """Start this APIServer instance API.
122
123
        Start /api/kytos/core/_shutdown/ and status/ endpoints, web UI.
124
        The '_shutdown' endpoint should not be public and is intended to
125
        shutdown the APIServer.
126
        """
127 1
        self.register_core_endpoint('_shutdown/', self.shutdown_api)
128 1
        self.register_core_endpoint('status/', self.status_api)
129 1
        self.register_core_endpoint('web/update/<version>/',
130
                                    self.update_web_ui,
131
                                    methods=['POST'])
132 1
        self.register_core_endpoint('web/update/',
133
                                    self.update_web_ui,
134
                                    methods=['POST'])
135
136 1
        self.register_core_napp_services()
137
138 1
        self._register_web_ui()
139
140 1
    def register_core_endpoint(self, rule, function, **options):
141
        """Register an endpoint with the URL /api/kytos/core/<rule>.
142
143
        Not used by NApps, but controller.
144
        """
145 1
        self._start_endpoint(self.app, self._CORE_PREFIX + rule, function,
146
                             **options)
147
148 1
    def _register_web_ui(self):
149
        """Register routes to the admin-ui homepage."""
150 1
        self.app.add_url_rule('/', self.web_ui.__name__, self.web_ui)
151 1
        self.app.add_url_rule('/index.html', self.web_ui.__name__, self.web_ui)
152 1
        self.app.add_url_rule('/ui/<username>/<napp_name>/<path:filename>',
153
                              self.static_web_ui.__name__, self.static_web_ui)
154 1
        self.app.add_url_rule('/ui/<path:section_name>',
155
                              self.get_ui_components.__name__,
156
                              self.get_ui_components)
157
158 1
    @staticmethod
159 1
    def status_api():
160
        """Display kytos status using the route ``/kytos/status/``."""
161 1
        return '{"response": "running"}', HTTPStatus.OK.value
162
163 1
    def stop_api_server(self):
164
        """Send a shutdown request to stop API Server."""
165 1
        try:
166 1
            url = f'http://127.0.0.1:{self.port}/api/kytos/core/_shutdown'
167 1
            with urlopen(url):
168 1
                pass
169
        except URLError:
170
            pass
171
172 1
    def shutdown_api(self):
173
        """Handle requests received to shutdown the API Server.
174
175
        This method must be called by kytos using the method
176
        stop_api_server, otherwise this request will be ignored.
177
        """
178 1
        allowed_host = ['127.0.0.1:'+str(self.port),
179
                        'localhost:'+str(self.port)]
180 1
        if request.host not in allowed_host:
181 1
            return "", HTTPStatus.FORBIDDEN.value
182
183 1
        self.server.stop()
184
185 1
        return 'API Server shutting down...', HTTPStatus.OK.value
186
187 1
    def static_web_ui(self, username, napp_name, filename):
188
        """Serve static files from installed napps."""
189 1
        path = f"{self.napps_dir}/{username}/{napp_name}/ui/{filename}"
190 1
        if os.path.exists(path):
191 1
            return send_file(path)
192 1
        return "", HTTPStatus.NOT_FOUND.value
193
194 1
    def get_ui_components(self, section_name):
195
        """Return all napps ui components from an specific section.
196
197
        The component name generated will have the following structure:
198
        {username}-{nappname}-{component-section}-{filename}`
199
200
        Args:
201
            section_name (str): Specific section name
202
203
        Returns:
204
            str: Json with a list of all components found.
205
206
        """
207 1
        section_name = '*' if section_name == "all" else section_name
208 1
        path = f"{self.napps_dir}/*/*/ui/{section_name}/*.kytos"
209 1
        components = []
210 1
        for name in glob(path):
211 1
            dirs_name = name.split('/')
212 1
            dirs_name.remove('ui')
213
214 1
            component_name = '-'.join(dirs_name[-4:]).replace('.kytos', '')
215 1
            url = f'ui/{"/".join(dirs_name[-4:])}'
216 1
            component = {'name': component_name, 'url': url}
217 1
            components.append(component)
218 1
        return jsonify(components)
219
220 1
    def web_ui(self):
221
        """Serve the index.html page for the admin-ui."""
222 1
        index_path = f"{self.flask_dir}/index.html"
223 1
        if os.path.exists(index_path):
224 1
            return send_file(index_path)
225 1
        return f"File '{index_path}' not found.", HTTPStatus.NOT_FOUND.value
226
227 1
    def update_web_ui(self, version='latest', force=True):
228
        """Update the static files for the Web UI.
229
230
        Download the latest files from the UI github repository and update them
231
        in the ui folder.
232
        The repository link is currently hardcoded here.
233
        """
234 1
        if version == 'latest':
235 1
            try:
236 1
                url = ('https://api.github.com/repos/kytos-ng/'
237
                       'ui/releases/latest')
238 1
                with urlopen(url) as response:
239 1
                    data = response.readlines()[0]
240 1
                    version = json.loads(data)['tag_name']
241 1
            except URLError:
242 1
                version = '1.4.1'
243
244 1
        repository = "https://github.com/kytos-ng/ui"
245 1
        uri = repository + f"/releases/download/{version}/latest.zip"
246
247 1
        if not os.path.exists(self.flask_dir) or force:
248
            # download from github
249 1
            try:
250 1
                LOG.info("Web update - Downloading UI from %s.", uri)
251 1
                package = urlretrieve(uri)[0]
252 1
            except HTTPError:
253 1
                LOG.error("Web update - Uri not found %s.", uri)
254 1
                return (f"Uri not found {uri}.",
255
                        HTTPStatus.INTERNAL_SERVER_ERROR.value)
256 1
            except URLError:
257 1
                LOG.error("Web update - Error accessing URL %s.", uri)
258 1
                return (f"Error accessing URL {uri}.",
259
                        HTTPStatus.INTERNAL_SERVER_ERROR.value)
260
261
            # test downloaded zip file
262 1
            with zipfile.ZipFile(package, 'r') as zip_ref:
263 1
                if zip_ref.testzip() is not None:
264 1
                    LOG.error("Web update - Zip file from %s "
265
                              "is corrupted.", uri)
266 1
                    return (f'Zip file from {uri} is corrupted.',
267
                            HTTPStatus.INTERNAL_SERVER_ERROR.value)
268
269
                # backup the old web-ui files and create a new web-ui folder
270
                # if there is no path to backup, zip.extractall will
271
                # create the path.
272 1
                if os.path.exists(self.flask_dir):
273 1
                    LOG.info("Web update - Performing UI backup.")
274 1
                    date = datetime.now().strftime("%Y%m%d%H%M%S")
275 1
                    shutil.move(self.flask_dir, f"{self.flask_dir}-{date}")
276 1
                    os.mkdir(self.flask_dir)
277
278
                # unzip and extract files to web-ui/*
279 1
                zip_ref.extractall(self.flask_dir)
280 1
                zip_ref.close()
281
282 1
            LOG.info("Web update - Updated")
283 1
            return "updated the web ui", HTTPStatus.OK.value
284
285
        LOG.error("Web update - Web UI was not updated")
286
        return ("Web ui was not updated",
287
                HTTPStatus.INTERNAL_SERVER_ERROR.value)
288
289
    # BEGIN decorator methods
290 1
    @staticmethod
291 1
    def decorate_as_endpoint(rule, **options):
292
        """Decorate methods as REST endpoints.
293
294
        Example for URL ``/api/myusername/mynapp/sayhello/World``:
295
296
        .. code-block:: python3
297
298
           from flask.json import jsonify
299
           from kytos.core.napps import rest
300
301
           @rest('sayhello/<string:name>')
302
           def say_hello(name):
303
               return jsonify({"data": f"Hello, {name}!"})
304
305
        ``@rest`` parameters are the same as Flask's ``@app.route``. You can
306
        also add ``methods=['POST']``, for example.
307
308
        As we don't have the NApp instance now, we store the parameters in a
309
        method attribute in order to add the route later, after we have both
310
        APIServer and NApp instances.
311
        """
312 1
        def store_route_params(function):
313
            """Store ``Flask`` ``@route`` parameters in a method attribute.
314
315
            There can be many @route decorators in a single function.
316
            """
317
            # To support any order: @classmethod, @rest or @rest, @classmethod
318
            # class and static decorators return a descriptor with the function
319
            # in __func__.
320 1
            if isinstance(function, (classmethod, staticmethod)):
321 1
                inner = function.__func__
322
            else:
323 1
                inner = function
324
            # Add route parameters
325 1
            if not hasattr(inner, 'route_params'):
326 1
                inner.route_params = []
327 1
            inner.route_params.append((rule, options))
328
            # Return the same function, now with "route_params" attribute
329 1
            return function
330 1
        return store_route_params
331
332 1
    @staticmethod
333 1
    def get_authenticate_options():
334
        """Return configuration options related to authentication."""
335 1
        options = KytosConfig().options['daemon']
336 1
        return options.authenticate_urls
337
338 1
    def authenticate_endpoints(self, napp):
339
        """Add authentication to defined REST endpoints.
340
341
        If any URL marked for authentication uses a function,
342
        that function will require authentication.
343
        """
344 1
        authenticate_urls = self.get_authenticate_options()
345 1
        for function in self._get_decorated_functions(napp):
346 1
            inner = getattr(function, '__func__', function)
347 1
            inner.authenticated = False
348 1
            for rule, _ in function.route_params:
349
                if inner.authenticated:
350
                    break
351
                absolute_rule = self.get_absolute_rule(rule, napp)
352
                for url in authenticate_urls:
353
                    if url in absolute_rule:
354
                        inner.authenticated = True
355
                        break
356
357 1
    def register_napp_endpoints(self, napp):
358
        """Add all NApp REST endpoints with @rest decorator.
359
360
        We are using Flask Blueprints to register these endpoints. Blueprints
361
        are essentially the Flask equivalent of Python modules and are used to
362
        keep related logic and assets grouped and separated from one another.
363
364
        URLs will be prefixed with ``/api/{username}/{napp_name}/``.
365
366
        Args:
367
            napp (Napp): Napp instance to register new endpoints.
368
        """
369
        # Create a Flask Blueprint for a specific NApp
370 1
        napp_blueprint = Blueprint(napp.napp_id, __name__)
371
372
        # Start all endpoints for this NApp
373 1
        for function in self._get_decorated_functions(napp):
374 1
            for rule, options in function.route_params:
375 1
                absolute_rule = self.get_absolute_rule(rule, napp)
376 1
                if getattr(function, 'authenticated', False):
377
                    function = authenticated(function)
378 1
                self._start_endpoint(napp_blueprint, absolute_rule,
379
                                     function, **options)
380
381
        # Register this Flask Blueprint in the Flask App
382 1
        self.app.register_blueprint(napp_blueprint)
383
384 1
    @staticmethod
385 1
    def _get_decorated_functions(napp):
386
        """Return ``napp``'s methods having the @rest decorator."""
387 1
        for name in dir(napp):
388 1
            if not name.startswith('_'):  # discarding private names
389 1
                pub_attr = getattr(napp, name)
390 1
                if callable(pub_attr) and hasattr(pub_attr, 'route_params'):
391 1
                    yield pub_attr
392
393 1
    @classmethod
394 1
    def get_absolute_rule(cls, rule, napp):
395
        """Prefix the rule, e.g. "flow" to "/api/user/napp/flow".
396
397
        This code is used by kytos-utils when generating an OpenAPI skel.
398
        """
399
        # Flask does require 2 slashes if specified, so we remove a starting
400
        # slash if applicable.
401 1
        relative_rule = rule[1:] if rule.startswith('/') else rule
402 1
        return cls._NAPP_PREFIX.format(napp=napp) + relative_rule
403
404
    # END decorator methods
405
406 1
    def _start_endpoint(self, app, rule, function, **options):
407
        """Start ``function``'s endpoint.
408
409
        Forward parameters to ``Flask.add_url_rule`` mimicking Flask
410
        ``@route`` decorator.
411
        """
412 1
        endpoint = options.pop('endpoint', None)
413 1
        app.add_url_rule(rule, endpoint, function, **options)
414 1
        LOG.info('Started %s - %s', rule,
415
                 ', '.join(options.get('methods', self.DEFAULT_METHODS)))
416
417 1
    def remove_napp_endpoints(self, napp):
418
        """Remove all decorated endpoints.
419
420
        Args:
421
            napp (Napp): Napp instance to look for rest-decorated methods.
422
        """
423 1
        prefix = self._NAPP_PREFIX.format(napp=napp)
424 1
        indexes = []
425 1
        endpoints = set()
426 1
        for index, rule in enumerate(self.app.url_map.iter_rules()):
427 1
            if rule.rule.startswith(prefix):
428 1
                endpoints.add(rule.endpoint)
429 1
                indexes.append(index)
430 1
                LOG.info('Stopped %s - %s', rule, ','.join(rule.methods))
431
432 1
        for endpoint in endpoints:
433 1
            self.app.view_functions.pop(endpoint)
434
435 1
        for index in reversed(indexes):
436
            # pylint: disable=protected-access
437 1
            self.app.url_map._rules.pop(index)
438
            # pylint: enable=protected-access
439
440
        # Remove the Flask Blueprint of this NApp from the Flask App
441 1
        self.app.blueprints.pop(napp.napp_id)
442
443 1
        LOG.info('The Rest endpoints from %s were disabled.', prefix)
444
445 1
    def register_core_napp_services(self):
446
        """
447
        Register /kytos/core/ services over NApps.
448
449
        It registers enable, disable, install, uninstall NApps that will
450
        be used by kytos-utils.
451
        """
452 1
        self.register_core_endpoint("napps/<username>/<napp_name>/enable",
453
                                    self._enable_napp)
454 1
        self.register_core_endpoint("napps/<username>/<napp_name>/disable",
455
                                    self._disable_napp)
456 1
        self.register_core_endpoint("napps/<username>/<napp_name>/install",
457
                                    self._install_napp)
458 1
        self.register_core_endpoint("napps/<username>/<napp_name>/uninstall",
459
                                    self._uninstall_napp)
460 1
        self.register_core_endpoint("napps_enabled",
461
                                    self._list_enabled_napps)
462 1
        self.register_core_endpoint("napps_installed",
463
                                    self._list_installed_napps)
464 1
        self.register_core_endpoint(
465
            "napps/<username>/<napp_name>/metadata/<key>",
466
            self._get_napp_metadata)
467
468 1
    def _enable_napp(self, username, napp_name):
469
        """
470
        Enable an installed NApp.
471
472
        :param username: NApps user name
473
        :param napp_name: NApp name
474
        :return: JSON content and return code
475
        """
476
        # Check if the NApp is installed
477 1
        if not self.napps_manager.is_installed(username, napp_name):
478 1
            return '{"response": "not installed"}', \
479
                   HTTPStatus.BAD_REQUEST.value
480
481
        # Check if the NApp is already been enabled
482 1
        if not self.napps_manager.is_enabled(username, napp_name):
483 1
            self.napps_manager.enable(username, napp_name)
484
485
        # Check if NApp is enabled
486 1
        if not self.napps_manager.is_enabled(username, napp_name):
487
            # If it is not enabled an admin user must check the log file
488 1
            return '{"response": "error"}', \
489
                   HTTPStatus.INTERNAL_SERVER_ERROR.value
490
491 1
        return '{"response": "enabled"}', HTTPStatus.OK.value
492
493 1
    def _disable_napp(self, username, napp_name):
494
        """
495
        Disable an installed NApp.
496
497
        :param username: NApps user name
498
        :param napp_name: NApp name
499
        :return: JSON content and return code
500
        """
501
        # Check if the NApp is installed
502 1
        if not self.napps_manager.is_installed(username, napp_name):
503 1
            return '{"response": "not installed"}', \
504
                   HTTPStatus.BAD_REQUEST.value
505
506
        # Check if the NApp is enabled
507 1
        if self.napps_manager.is_enabled(username, napp_name):
508 1
            self.napps_manager.disable(username, napp_name)
509
510
        # Check if NApp is still enabled
511 1
        if self.napps_manager.is_enabled(username, napp_name):
512
            # If it is still enabled an admin user must check the log file
513 1
            return '{"response": "error"}', \
514
                   HTTPStatus.INTERNAL_SERVER_ERROR.value
515
516 1
        return '{"response": "disabled"}', \
517
               HTTPStatus.OK.value
518
519 1
    def _install_napp(self, username, napp_name):
520
        # Check if the NApp is installed
521 1
        if self.napps_manager.is_installed(username, napp_name):
522 1
            return '{"response": "installed"}', HTTPStatus.OK.value
523
524 1
        napp = "{}/{}".format(username, napp_name)
525
526
        # Try to install and enable the napp
527 1
        try:
528 1
            if not self.napps_manager.install(napp, enable=True):
529
                # If it is not installed an admin user must check the log file
530 1
                return '{"response": "error"}', \
531
                       HTTPStatus.INTERNAL_SERVER_ERROR.value
532 1
        except HTTPError as exception:
533 1
            return '{"response": "error"}', exception.code
534
535 1
        return '{"response": "installed"}', HTTPStatus.OK.value
536
537 1
    def _uninstall_napp(self, username, napp_name):
538
        # Check if the NApp is installed
539 1
        if self.napps_manager.is_installed(username, napp_name):
540
            # Try to unload/uninstall the napp
541 1
            if not self.napps_manager.uninstall(username, napp_name):
542
                # If it is not uninstalled admin user must check the log file
543 1
                return '{"response": "error"}', \
544
                       HTTPStatus.INTERNAL_SERVER_ERROR.value
545
546 1
        return '{"response": "uninstalled"}', HTTPStatus.OK.value
547
548 1
    def _list_enabled_napps(self):
549
        """Sorted list of (username, napp_name) of enabled napps."""
550 1
        serialized_dict = json.dumps(
551
                            self.napps_manager.get_enabled_napps(),
552
                            default=lambda a: [a.username, a.name])
553
554 1
        return '{"napps": %s}' % serialized_dict, HTTPStatus.OK.value
555
556 1
    def _list_installed_napps(self):
557
        """Sorted list of (username, napp_name) of installed napps."""
558 1
        serialized_dict = json.dumps(
559
                            self.napps_manager.get_installed_napps(),
560
                            default=lambda a: [a.username, a.name])
561
562 1
        return '{"napps": %s}' % serialized_dict, HTTPStatus.OK.value
563
564 1
    def _get_napp_metadata(self, username, napp_name, key):
565
        """Get NApp metadata value.
566
567
        For safety reasons, only some keys can be retrieved:
568
        napp_dependencies, description, version.
569
570
        """
571 1
        valid_keys = ['napp_dependencies', 'description', 'version']
572
573 1
        if not self.napps_manager.is_installed(username, napp_name):
574 1
            return "NApp is not installed.", HTTPStatus.BAD_REQUEST.value
575
576 1
        if key not in valid_keys:
577 1
            return "Invalid key.", HTTPStatus.BAD_REQUEST.value
578
579 1
        data = self.napps_manager.get_napp_metadata(username, napp_name, key)
580 1
        serialized_dict = json.dumps({key: data})
581
582
        return '%s' % serialized_dict, HTTPStatus.OK.value
583