Passed
Pull Request — master (#903)
by Beraldo
54s
created

APIServer.register_core_napp_services()   A

Complexity

Conditions 1

Size

Total Lines 22
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 16
nop 1
dl 0
loc 22
ccs 0
cts 0
cp 0
crap 2
rs 9.6
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
from urllib.request import urlopen, urlretrieve
14 1
15 1
from flask import Flask, jsonify, request, send_file
16 1
from flask_cors import CORS
17
from flask_socketio import SocketIO, join_room, leave_room
18
19 1
20
class APIServer:
21
    """Api server used to provide Kytos Controller routes."""
22
23 1
    #: tuple: Default Flask HTTP methods.
24 1
    DEFAULT_METHODS = ('GET',)
25 1
    _NAPP_PREFIX = "/api/{napp.username}/{napp.name}/"
26
    _CORE_PREFIX = "/api/kytos/core/"
27 1
28
    # pylint: disable=too-many-arguments
29
    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 1
            listen (string): host name used by api server instance
38 1
            port (int): Port number used by api server instance
39 1
            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
        self.napps_dir = napps_dir
44 1
45
        self.flask_dir = os.path.join(dirname, '../web-ui')
46 1
        self.log = logging.getLogger('api_server')
47 1
48
        self.listen = listen
49 1
        self.port = port
50
51
        self.app = Flask(app_name, root_path=self.flask_dir,
52 1
                         static_folder="dist", static_url_path="/dist")
53
        self.server = SocketIO(self.app, async_mode='threading')
54
        self._enable_websocket_rooms()
55 1
        # ENABLE CROSS ORIGIN RESOURCE SHARING
56 1
        CORS(self.app)
57
58 1
        # Disable trailing slash
59 1
        self.app.url_map.strict_slashes = False
60 1
61 1
        # Update web-ui if necessary
62
        self.update_web_ui(force=False)
63 1
64
    def _enable_websocket_rooms(self):
65
        socket = self.server
66
        socket.on_event('join', join_room)
67
        socket.on_event('leave', leave_room)
68
69
    def run(self):
70
        """Run the Flask API Server."""
71
        try:
72 1
            self.server.run(self.app, self.listen, self.port)
73
        except OSError as exception:
74 1
            msg = "Couldn't start API Server: {}".format(exception)
75
            self.log.critical(msg)
76 1
            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
        if url.startswith('/'):
83
            url = url[1:]
84
        self._start_endpoint(f'/kytos/{url}', function, methods=methods)
85 1
86 1
    def start_api(self):
87 1
        """Start this APIServer instance API.
88
89
        Start /api/kytos/core/shutdown/ and status/ endpoints, web UI.
90 1
        """
91
        self.register_core_endpoint('shutdown/', self.shutdown_api)
92
        self.register_core_endpoint('status/', self.status_api)
93 1
        self.register_core_endpoint('web/update/<version>/',
94
                                    self.update_web_ui,
95 1
                                    methods=['POST'])
96
        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 1
        """Register an endpoint with the URL /api/kytos/core/<rule>.
106 1
107
        Not used by NApps, but controller.
108 1
        """
109
        self._start_endpoint(self._CORE_PREFIX + rule, function, **options)
110
111
    def _register_web_ui(self):
112 1
        """Register routes to the admin-ui homepage."""
113
        self.app.add_url_rule('/', self.web_ui.__name__, self.web_ui)
114
        self.app.add_url_rule('/index.html', self.web_ui.__name__, self.web_ui)
115
        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
    @staticmethod
122
    def status_api():
123
        """Display kytos status using the route ``/kytos/status/``."""
124
        return '{"response": "running"}', HTTPStatus.CREATED.value
125 1
126
    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
    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 1
        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 1
        return 'Server shutting down...', HTTPStatus.OK.value
148
149
    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
    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 1
            dirs_name = name.split('/')
174
            dirs_name.remove('ui')
175
176
            component_name = '-'.join(dirs_name[-4:]).replace('.kytos', '')
177 1
            url = f'ui/{"/".join(dirs_name[-4:])}'
178
            component = {'name': component_name, 'url': url}
179
            components.append(component)
180
        return jsonify(components)
181
182
    def web_ui(self):
183
        """Serve the index.html page for the admin-ui."""
184 1
        return send_file(f"{self.flask_dir}/index.html")
185 1
186 1
    def update_web_ui(self, version='latest', force=True):
187 1
        """Update the static files for the Web UI.
188 1
189 1
        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
                url = 'https://api.github.com/repos/kytos/ui/releases/latest'
196 1
                response = urlopen(url)
197
                data = response.readlines()[0]
198 1
                version = json.loads(data)['tag_name']
199 1
            except URLError:
200
                version = '1.1.1'
201
202
        repository = "https://github.com/kytos/ui"
203
        uri = repository + f"/releases/download/{version}/latest.zip"
204 1
205
        if not os.path.exists(self.flask_dir) or force:
206 1
            # download from github
207
            try:
208
                package = urlretrieve(uri)[0]
209
            except HTTPError:
210 1
                return f"Uri not found {uri}."
211
212
            # test downloaded zip file
213
            zip_ref = zipfile.ZipFile(package, 'r')
214
215
            if zip_ref.testzip() is not None:
216 1
                return f'Zip file from {uri} is corrupted.'
217 1
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 1
224
            # unzip and extract files to web-ui/*
225
            zip_ref.extractall(self.flask_dir)
226
            zip_ref.close()
227
228
        return "updated the web ui"
229
230
    # BEGIN decorator methods
231
232
    @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 1
               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 1
        """
254 1
        def store_route_params(function):
255
            """Store ``Flask`` ``@route`` parameters in a method attribute.
256 1
257
            There can be many @route decorators in a single function.
258 1
            """
259 1
            # To support any order: @classmethod, @rest or @rest, @classmethod
260 1
            # 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
            if not hasattr(inner, 'route_params'):
268
                inner.route_params = []
269
            inner.route_params.append((rule, options))
270
            # Return the same function, now with "route_params" attribute
271
            return function
272
        return store_route_params
273 1
274 1
    def register_napp_endpoints(self, napp):
275 1
        """Add all NApp REST endpoints with @rest decorator.
276 1
277
        URLs will be prefixed with ``/api/{username}/{napp_name}/``.
278 1
279
        Args:
280
            napp (Napp): Napp instance to register new endpoints.
281 1
        """
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
        for name in dir(napp):
291
            if not name.startswith('_'):  # discarding private names
292
                pub_attr = getattr(napp, name)
293
                if callable(pub_attr) and hasattr(pub_attr, 'route_params'):
294
                    yield pub_attr
295 1
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 1
        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
        relative_rule = rule[1:] if rule.startswith('/') else rule
305
        return cls._NAPP_PREFIX.format(napp=napp) + relative_rule
306 1
307 1
    # END decorator methods
308 1
309
    def _start_endpoint(self, rule, function, **options):
310
        """Start ``function``'s endpoint.
311 1
312
        Forward parameters to ``Flask.add_url_rule`` mimicking Flask
313
        ``@route`` decorator.
314
        """
315
        endpoint = options.pop('endpoint', None)
316
        self.app.add_url_rule(rule, endpoint, function, **options)
317 1
        self.log.info('Started %s - %s', rule,
318 1
                      ', '.join(options.get('methods', self.DEFAULT_METHODS)))
319 1
320 1
    def remove_napp_endpoints(self, napp):
321 1
        """Remove all decorated endpoints.
322 1
323 1
        Args:
324 1
            napp (Napp): Napp instance to look for rest-decorated methods.
325
        """
326 1
        prefix = self._NAPP_PREFIX.format(napp=napp)
327 1
        indexes = []
328
        endpoints = set()
329 1
        for index, rule in enumerate(self.app.url_map.iter_rules()):
330
            if rule.rule.startswith(prefix):
331 1
                endpoints.add(rule.endpoint)
332
                indexes.append(index)
333
                self.log.info('Stopped %s - %s', rule, ','.join(rule.methods))
334 1
335
        for endpoint in endpoints:
336
            self.app.view_functions.pop(endpoint)
337
338
        for index in reversed(indexes):
339
            # pylint: disable=protected-access
340
            self.app.url_map._rules.pop(index)
341
            # pylint: enable=protected-access
342
343
        self.log.info('The Rest endpoints from %s were disabled.', prefix)
344
345
    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
        self.register_core_endpoint("napps/<username>/<napp_name>/enable",
353
                                    self._enable_napp)
354
        self.register_core_endpoint("napps/<username>/<napp_name>/disable",
355
                                    self._disable_napp)
356
        self.register_core_endpoint("napps/<username>/<napp_name>/install",
357
                                    self._install_napp)
358
        self.register_core_endpoint("napps/<username>/<napp_name>/uninstall",
359
                                    self._uninstall_napp)
360
        self.register_core_endpoint("napps_enabled",
361
                                    self._list_enabled_napps)
362
        self.register_core_endpoint("napps_installed",
363
                                    self._list_installed_napps)
364
        self.register_core_endpoint(
365
            "napps/<username>/<napp_name>/metadata/<key>",
366
            self._get_napp_metadata)
367
368
    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
    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
    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 the napp
427
        if not self.napps_manager.install(napp, enable=False):
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
    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
    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
    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
    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