Passed
Pull Request — master (#74)
by macartur
09:51 queued 08:25
created

NAppsManager.disable()   A

Complexity

Conditions 3

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 3
c 3
b 1
f 0
dl 0
loc 9
rs 9.6666
1
"""Manage Network Application files."""
2
import json
3
import logging
4
import os
5
import re
6
import shutil
7
import sys
8
import tarfile
9
import tempfile
10
import urllib
11
from pathlib import Path
12
13
from jinja2 import Environment, FileSystemLoader
14
from kytos.utils.client import NAppsClient
15
from kytos.utils.config import KytosConfig
16
17
log = logging.getLogger(__name__)
18
19
20
class NAppsManager:
21
    """Deal with NApps at filesystem level and ask Kytos to (un)load NApps."""
22
23
    def __init__(self, controller=None):
24
        """If controller is not informed, the necessary paths must be.
25
26
        If ``controller`` is available, NApps will be (un)loaded at runtime and
27
        you don't need to inform the paths. Otherwise, you should inform the
28
        required paths for the methods called.
29
30
        Args:
31
            controller (kytos.Controller): Controller to (un)load NApps.
32
            install_path (str): Folder where NApps should be installed. If
33
                None, use the controller's configuration.
34
            enabled_path (str): Folder where enabled NApps are stored. If None,
35
                use the controller's configuration.
36
        """
37
        self._controller = controller
38
        self._config = KytosConfig().config
39
        self._installed = Path(self._config.get('napps', 'installed_path'))
40
        self._enabled = Path(self._config.get('napps', 'enabled_path'))
41
42
        self.user = None
43
        self.napp = None
44
45
    def set_napp(self, user, napp):
46
        """Set info about NApp.
47
48
        Args:
49
            user (str): NApps Server username.
50
            napp (str): NApp name.
51
        """
52
        self.user = user
53
        self.napp = napp
54
55
    @property
56
    def napp_id(self):
57
        """Identifier of NApp."""
58
        return '/'.join((self.user, self.napp))
59
60
    @staticmethod
61
    def _get_napps(napps_dir):
62
        """List of (username, napp_name) found in ``napps_dir``."""
63
        jsons = napps_dir.glob('*/*/kytos.json')
64
        return sorted(j.parts[-3:-1] for j in jsons)
65
66
    def get_enabled(self):
67
        """Sorted list of (username, napp_name) of enabled napps."""
68
        return self._get_napps(self._enabled)
69
70
    def get_installed(self):
71
        """Sorted list of (username, napp_name) of installed napps."""
72
        return self._get_napps(self._installed)
73
74
    def is_installed(self):
75
        """Whether a NApp is installed."""
76
        return (self.user, self.napp) in self.get_installed()
77
78
    def get_disabled(self):
79
        """Sorted list of (username, napp_name) of disabled napps.
80
81
        The difference of installed and enabled.
82
        """
83
        installed = set(self.get_installed())
84
        enabled = set(self.get_enabled())
85
        return sorted(installed - enabled)
86
87
    def get_napp_dependentices(self, user=None, napp=None):
88
        """Method used to get napp_dependencies from install NApp.
89
90
        Args:
91
            user(string)  A Username.
92
            napp(string): A NApp name.
93
        Returns:
94
            napps(list): List with tuples with Username and NApp name.
95
                         e.g. [('kytos'/'of_core'), ('kytos/of_l2ls')]
96
        """
97
        napps = self._get_napp_key(user, napp, 'napp_dependencies')
98
        return [tuple(napp.split('/')) for napp in napps]
99
100
    def get_description(self, user=None, napp=None):
101
        """Return the description from kytos.json."""
102
        return self._get_napp_key(user, napp, 'description')
103
104
    def _get_napp_key(self, user=None, napp=None, key=None):
105
        """Generic method used to return a value from kytos.json.
106
107
        Args:
108
            user (string): A Username.
109
            napp (string): A NApp name
110
            key (string): Key used to get the value within kytos.json.
111
        Returns:
112
            meta (object): Value stored in kytos.json.
113
        """
114
        if key is None:
115
            return ''
116
        if user is None:
117
            user = self.user
118
        if napp is None:
119
            napp = self.napp
120
        kj = self._installed / user / napp / 'kytos.json'
121
        try:
122
            with kj.open() as f:
123
                meta = json.load(f)
124
                return meta[key]
125
        except (FileNotFoundError, json.JSONDecodeError, KeyError):
126
            return ''
127
128
    def disable(self):
129
        """Disable a NApp if it is enabled."""
130
        enabled = self._enabled / self.user / self.napp
131
        try:
132
            enabled.unlink()
133
            if self._controller is not None:
134
                self._controller.unload_napp(self.user, self.napp)
135
        except FileNotFoundError:
136
            pass  # OK, it was already disabled
137
138
    def enable(self):
139
        """Enable a NApp if not already enabled.
140
141
        Raises:
142
            FileNotFoundError: If NApp is not installed.
143
            PermissionError: No filesystem permission to enable NApp.
144
        """
145
        enabled = self._enabled / self.user / self.napp
146
        installed = self._installed / self.user / self.napp
147
148
        if not installed.is_dir():
149
            raise FileNotFoundError('Install NApp {} first.'.format(
150
                self.napp_id))
151
        elif not enabled.exists():
152
            self._check_module(enabled.parent)
153
            try:
154
                # Create symlink
155
                enabled.symlink_to(installed)
156
                if self._controller is not None:
157
                    self._controller.load_napp(self.user, self.napp)
158
            except FileExistsError:
159
                pass  # OK, NApp was already enabled
160
            except PermissionError:
161
                raise PermissionError('Permission error on enabling NApp. Try '
162
                                      'with sudo.')
163
164
    def is_enabled(self):
165
        """Whether a NApp is enabled."""
166
        return (self.user, self.napp) in self.get_enabled()
167
168
    def uninstall(self):
169
        """Delete code inside NApp directory, if existent."""
170
        if self.is_installed():
171
            installed = self._installed / self.user / self.napp
172
            if installed.is_symlink():
173
                installed.unlink()
174
            else:
175
                shutil.rmtree(str(installed))
176
177
    @staticmethod
178
    def valid_name(username):
179
        """Check the validity of the given 'name'.
180
181
        The following checks are done:
182
        - name starts with a letter
183
        - name contains only letters, numbers or underscores
184
        """
185
        return username and re.match(r'[a-zA-Z][a-zA-Z0-9_]{2,}$', username)
186
187
    @staticmethod
188
    def render_template(templates_path, template_filename, context):
189
        """Render Jinja2 template for a NApp structure."""
190
        TEMPLATE_ENV = Environment(autoescape=False, trim_blocks=False,
191
                                   loader=FileSystemLoader(templates_path))
192
        return TEMPLATE_ENV.get_template(template_filename).render(context)
193
194
    @staticmethod
195
    def search(pattern):
196
        """Search all server NApps matching pattern.
197
198
        Args:
199
            pattern (str): Python regular expression.
200
        """
201
        def match(napp):
202
            """Whether a NApp metadata matches the pattern."""
203
            # WARNING: This will change for future versions, when 'author' will
204
            # be removed.
205
            username = napp.get('username', napp.get('author'))
206
207
            strings = ['{}/{}'.format(username, napp.get('name')),
208
                       napp.get('description')] + napp.get('tags')
209
            return any(pattern.match(string) for string in strings)
210
211
        napps = NAppsClient().get_napps()
212
        return [napp for napp in napps if match(napp)]
213
214
    def install_local(self):
215
        """Make a symlink in install folder to a local NApp.
216
217
        Raises:
218
            FileNotFoundError: If NApp is not found.
219
        """
220
        folder = self._get_local_folder()
221
        installed = self._installed / self.user / self.napp
222
        self._check_module(installed.parent)
223
        installed.symlink_to(folder.resolve())
224
225
    def _get_local_folder(self, root=None):
226
        """Return local NApp root folder.
227
228
        Search for kytos.json in _./_ folder and _./user/napp_.
229
230
        Args:
231
            root (pathlib.Path): Where to begin searching.
232
233
        Raises:
234
            FileNotFoundError: If there is no such local NApp.
235
236
        Return:
237
            pathlib.Path: NApp root folder.
238
        """
239
        if root is None:
240
            root = Path()
241
        for folders in ['.'], [self.user, self.napp]:
242
            kytos_json = root / Path(*folders) / 'kytos.json'
243
            if kytos_json.exists():
244
                with kytos_json.open() as f:
245
                    meta = json.load(f)
246
                    # WARNING: This will change in future versions, when
247
                    # 'author' will be removed.
248
                    username = meta.get('username', meta.get('author'))
249
                    if username == self.user and meta.get('name') == self.napp:
250
                        return kytos_json.parent
251
        raise FileNotFoundError('kytos.json not found.')
252
253
    def install_remote(self):
254
        """Download, extract and install NApp."""
255
        package, pkg_folder = None, None
256
        try:
257
            package = self._download()
258
            pkg_folder = self._extract(package)
259
            napp_folder = self._get_local_folder(pkg_folder)
260
            dst = self._installed / self.user / self.napp
261
            self._check_module(dst.parent)
262
            shutil.move(str(napp_folder), str(dst))
263
        finally:
264
            # Delete temporary files
265
            if package:
266
                Path(package).unlink()
267
            if pkg_folder and pkg_folder.exists():
268
                shutil.rmtree(str(pkg_folder))
269
270
    def _download(self):
271
        """Download NApp package from server.
272
273
        Raises:
274
            urllib.error.HTTPError: If download is not successful.
275
276
        Return:
277
            str: Downloaded temp filename.
278
        """
279
        repo = self._config.get('napps', 'repo')
280
        uri = os.path.join(repo, self.user, '{}-latest.napp'.format(self.napp))
281
        return urllib.request.urlretrieve(uri)[0]
282
283
    @staticmethod
284
    def _extract(filename):
285
        """Extract package to a temporary folder.
286
287
        Return:
288
            pathlib.Path: Temp dir with package contents.
289
        """
290
        tmp = tempfile.mkdtemp(prefix='kytos')
291
        with tarfile.open(filename, 'r:xz') as tar:
292
            tar.extractall(tmp)
293
        return Path(tmp)
294
295
    @classmethod
296
    def create_napp(cls):
297
        """Bootstrap a basic NApp strucutre for you to develop your NApp.
298
299
        This will create, on the current folder, a clean structure of a NAPP,
300
        filling some contents on this structure.
301
        """
302
        base = os.environ.get('VIRTUAL_ENV', '/')
303
304
        templates_path = os.path.join(base, 'etc', 'skel', 'kytos',
305
                                      'napp-structure', 'username', 'napp')
306
        username = None
307
        napp_name = None
308
        description = None
309
        print('--------------------------------------------------------------')
310
        print('Welcome to the bootstrap process of your NApp.')
311
        print('--------------------------------------------------------------')
312
        print('In order to answer both the username and the napp name,')
313
        print('You must follow this naming rules:')
314
        print(' - name starts with a letter')
315
        print(' - name contains only letters, numbers or underscores')
316
        print(' - at least three characters')
317
        print('--------------------------------------------------------------')
318
        print('')
319
        msg = 'Please, insert your NApps Server username: '
320
        while not cls.valid_name(username):
321
            username = input(msg)
322
323
        while not cls.valid_name(napp_name):
324
            napp_name = input('Please, insert your NApp name: ')
325
326
        msg = 'Please, insert a brief description for your NApp [optional]: '
327
        description = input(msg)
328
        if not description:
329
            description = \
330
                '# TODO: <<<< Insert here your NApp description >>>>'  # noqa
331
332
        context = {'username': username, 'napp': napp_name,
333
                   'description': description}
334
335
        #: Creating the directory structure (username/napp_name)
336
        os.makedirs(username, exist_ok=True)
337
        #: Creating ``__init__.py`` files
338
        with open(os.path.join(username, '__init__.py'), 'w'):
339
            pass
340
341
        os.makedirs(os.path.join(username, napp_name))
342
        with open(os.path.join(username, napp_name, '__init__.py'), 'w'):
343
            pass
344
345
        #: Creating the other files based on the templates
346
        templates = os.listdir(templates_path)
347
        templates.remove('__init__.py')
348
        for tmp in templates:
349
            fname = os.path.join(username, napp_name,
350
                                 tmp.rsplit('.template')[0])
351
            with open(fname, 'w') as file:
352
                content = cls.render_template(templates_path, tmp, context)
353
                file.write(content)
354
355
        msg = '\nCongratulations! Your NApp have been bootstrapped!\nNow you '
356
        msg += 'can go to the directory {}/{} and begin to code your NApp.'
357
        print(msg.format(username, napp_name))
358
        print('Have fun!')
359
360
    @staticmethod
361
    def _check_module(folder):
362
        """Create module folder with empty __init__.py if it doesn't exist.
363
364
        Args:
365
            folder (pathlib.Path): Module path.
366
        """
367
        if not folder.exists():
368
            folder.mkdir(parents=True, exist_ok=True, mode=0o755)
369
            (folder / '__init__.py').touch()
370
371
    @staticmethod
372
    def build_napp_package(napp_name):
373
        """Build the .napp file to be sent to the napps server.
374
375
        Args:
376
            napp_identifier (str): Identifier formatted as
377
                <username>/<napp_name>
378
379
        Return:
380
            file_payload (binary): The binary representation of the napp
381
                package that will be POSTed to the napp server.
382
        """
383
        ignored_extensions = ['.swp', '.pyc', '.napp']
384
        ignored_dirs = ['__pycache__']
385
        files = os.listdir()
386
        for filename in files:
387
            if os.path.isfile(filename) and '.' in filename and \
388
                    filename.rsplit('.', 1)[1] in ignored_extensions:
389
                files.remove(filename)
390
            elif os.path.isdir(filename) and filename in ignored_dirs:
391
                files.remove(filename)
392
393
        # Create the '.napp' package
394
        napp_file = tarfile.open(napp_name + '.napp', 'x:xz')
395
        for local_f in files:
396
            napp_file.add(local_f)
397
        napp_file.close()
398
399
        # Get the binary payload of the package
400
        file_payload = open(napp_name + '.napp', 'rb')
401
402
        # remove the created package from the filesystem
403
        os.remove(napp_name + '.napp')
404
405
        return file_payload
406
407
    @staticmethod
408
    def create_metadata(*args, **kwargs):
409
        """Generate the metadata to send the napp package."""
410
        json_filename = kwargs.get('json_filename', 'kytos.json')
411
        readme_filename = kwargs.get('readme_filename', 'README.rst')
412
        ignore_json = kwargs.get('ignore_json', False)
413
        metadata = {}
414
415
        if not ignore_json:
416
            try:
417
                with open(json_filename) as json_file:
418
                    metadata = json.load(json_file)
419
            except FileNotFoundError:
420
                print("ERROR: Could not access kytos.json file.")
421
                sys.exit(1)
422
423
        try:
424
            with open(readme_filename) as readme_file:
425
                metadata['readme'] = readme_file.read()
426
        except FileNotFoundError:
427
            metadata['readme'] = ''
428
429
        return metadata
430
431
    def upload(self, *args, **kwargs):
432
        """Create package and upload it to NApps Server.
433
434
        Raises:
435
            FileNotFoundError: If kytos.json is not found.
436
        """
437
        metadata = self.create_metadata(*args, **kwargs)
438
        package = self.build_napp_package(metadata.get('name'))
439
440
        NAppsClient().upload_napp(metadata, package)
441
442
    def delete(self):
443
        """Delete a NApp.
444
445
        Raises:
446
            requests.HTTPError: When there's a server error.
447
        """
448
        client = NAppsClient(self._config)
449
        client.delete(self.user, self.napp)
450