Passed
Pull Request — master (#375)
by Vinicius
06:16
created

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

Complexity

Conditions 8

Size

Total Lines 44
Code Lines 33

Duplication

Lines 0
Ratio 0 %

Importance

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