Passed
Push — master ( 0cbe43...3a39f1 )
by Humberto
02:16 queued 13s
created

kytos.core.api_server.APIServer.update_web_ui()   B

Complexity

Conditions 8

Size

Total Lines 43
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 8.7868

Importance

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