models.py 16.7 KB
Newer Older
1
#   models.py - importer logic
2
#
Baptiste Beauplat's avatar
Baptiste Beauplat committed
3
4
#   This file is part of debexpo -
#   https://salsa.debian.org/mentors.debian.net-team/debexpo
5
6
#
#   Copyright © 2008 Jonny Lamb <jonny@debian.org>
7
#   Copyright © 2019 Baptiste Beauplat <lyknode@cilg.org>
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#
#   Permission is hereby granted, free of charge, to any person obtaining a
#   copy of this software and associated documentation files (the "Software"),
#   to deal in the Software without restriction, including without limitation
#   the rights to use, copy, modify, merge, publish, distribute, sublicense,
#   and/or sell copies of the Software, and to permit persons to whom the
#   Software is furnished to do so, subject to the following conditions:
#
#   The above copyright notice and this permission notice shall be included in
#   all copies or substantial portions of the Software.
#
#   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
#   FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
#   DEALINGS IN THE SOFTWARE.

27
from logging import getLogger
28

29
30
from os.path import join, exists, basename, isfile
from os import makedirs, unlink, stat
31
from glob import glob
32
from traceback import format_exc
33
from time import time
Baptiste Beauplat's avatar
Baptiste Beauplat committed
34

Baptiste Beauplat's avatar
Baptiste Beauplat committed
35
36
37
38
39
40
41
42
from django.db import transaction
from django.conf import settings
from django.core.validators import validate_email
from django.core.exceptions import ValidationError

from debexpo.packages.models import Distribution, PackageUpload, \
    SourcePackage, BinaryPackage
from debexpo.accounts.models import User
43
from debexpo.tools.debian.changes import Changes, ExceptionChanges
Baptiste Beauplat's avatar
Baptiste Beauplat committed
44
from debexpo.tools.debian.dsc import ExceptionDsc
45
46
from debexpo.tools.debian.origin import ExceptionOrigin
from debexpo.tools.clients import ExceptionClient
Baptiste Beauplat's avatar
Baptiste Beauplat committed
47
from debexpo.tools.debian.source import ExceptionSource
Baptiste Beauplat's avatar
Baptiste Beauplat committed
48
49
50
from debexpo.tools.debian.control import ExceptionControl
from debexpo.tools.debian.copyright import ExceptionCopyright
from debexpo.tools.debian.changelog import ExceptionChangelog
51
52
from debexpo.tools.files import ExceptionCheckSumedFile
from debexpo.tools.gnupg import ExceptionGnuPG
Baptiste Beauplat's avatar
Baptiste Beauplat committed
53
from debexpo.tools.email import Email
54
from debexpo.repository.models import Repository
55
from debexpo.plugins.models import PluginManager
56
from debexpo.tools.gitstorage import GitStorage
57

58
log = getLogger(__name__)
59

60
61
# Compression method supported by dpkg
DPKG_COMPRESSION_ALGO = ('bz2', 'gz', 'xz')
62
63


64
65
class ExceptionSpool(Exception):
    pass
66
67


68
69
class ExceptionSpoolUploadDenied(ExceptionSpool):
    pass
70
71


Baptiste Beauplat's avatar
Baptiste Beauplat committed
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class ExceptionImporter(Exception):
    def __init__(self, changes=None, error=None, details=None):
        self.changes = changes
        self.error = error
        self.details = details

    def __str__(self):
        return f'Failed to import {self.changes.source}: {self.error}\n' \
               f'{self.details}'


class ExceptionImporterRejected(ExceptionImporter):
    pass


87
88
89
90
91
92
93
class Spool():
    def __init__(self, spool):
        self.spool = spool
        self.queues = {
            'incoming': join(self.spool, 'incoming'),
            'processing': join(self.spool, 'processing'),
        }
94

95
96
97
98
99
100
        for queue in (self.queues.values()):
            if not exists(queue):
                try:
                    makedirs(queue)
                except OSError as e:
                    raise ExceptionSpool(e)
Baptiste Mouterde's avatar
Baptiste Mouterde committed
101

102
103
    def upload(self, name):
        name = basename(name)
Baptiste Mouterde's avatar
Baptiste Mouterde committed
104

105
106
107
        if not self._allowed_extention(name):
            raise ExceptionSpoolUploadDenied(
                'Filetype is not allowed on this server')
Baptiste Mouterde's avatar
Baptiste Mouterde committed
108

109
110
111
        if self._is_owned(name):
            raise ExceptionSpoolUploadDenied(
                'File already queued for importation')
112

113
        return open(join(self.queues['incoming'], name), 'wb')
114

115
116
    def _is_owned(self, name):
        if not exists(join(self.queues['incoming'], name)):
117
            return False
118

119
120
        for changes in self.get_all_changes('incoming'):
            # Only use valid changes
121
            try:
122
123
124
125
126
                changes.validate()
                changes.authenticate()
                changes.files.validate()
            except (ExceptionChanges, ExceptionCheckSumedFile, ExceptionGnuPG):
                pass
127
            else:
128
129
                if changes.owns(name):
                    return True
130

131
132
                if str(changes) == name:
                    return True
133

134
        return False
135

136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
    def _allowed_extention(self, filename):
        suffixes = [
            '.asc',
            '.buildinfo',
            '.changes',
            '.deb',
            '.diff.gz',
            '.dsc',
            '.udeb',
        ]

        for algo in DPKG_COMPRESSION_ALGO:
            suffixes.append('tar.{}'.format(algo))

        for suffix in suffixes:
            if filename.endswith(suffix):
152
153
154
155
                return True

        return False

156
157
    def get_all_changes(self, queue):
        changes = []
158

159
160
        for name in glob(join(self.queues[queue], '**', '*.changes'),
                         recursive=True):
161
162
163
164
165
166
167
168
            try:
                changes.append(Changes(name))
            except ExceptionChanges as e:
                log.warning(e)
                unlink(name)

        return changes

169
170
171
172
173
174
175
176
177
    def cleanup(self):
        for name in glob(join(self.queues['incoming'], '**', '*'),
                         recursive=True):
            if isfile(name):
                if not self._allowed_extention(name):
                    unlink(name)
                elif time() - stat(name).st_mtime > settings.QUEUE_EXPIRED_TIME:
                    unlink(name)

Baptiste Beauplat's avatar
Baptiste Beauplat committed
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
    def changes_to_process(self):
        for changes in self.get_all_changes('incoming'):
            changes.move(self.queues['processing'])

        return self.get_all_changes('processing')

    def get_queue_dir(self, queue):
        return self.queues[queue]

    def __str__(self):
        return self.spool


class Importer():
    """
    Class to handle the package that is uploaded and wants to be imported into
    the database.
    """
196
    def __init__(self, spool=None, skip_email=False,
197
                 skip_gpg=False):
Baptiste Beauplat's avatar
Baptiste Beauplat committed
198
199
200
201
202
203
204
205
206
207
208
209
        """
        Object constructor. Sets class fields to sane values.

        ``spool``
            Spool directory to process.
        ``skip_email``
            If this is set to true, send no email.
        ``skip_gpg``
            If this is set to true, disable gpg validation.
        """
        self.actually_send_email = not bool(skip_email)
        self.skip_gpg = skip_gpg
210
        self.repository = Repository(settings.REPOSITORY)
211
        self.git_storage_path = getattr(settings, 'GIT_STORAGE', None)
Baptiste Beauplat's avatar
Baptiste Beauplat committed
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230

        if spool:
            self.spool = Spool(spool)

    def process_spool(self):
        """
        Find all incoming uploads in the spool and process them
        """
        success = True

        for changes in self.spool.changes_to_process():
            try:
                upload = self.process_upload(changes)
            except ExceptionImporterRejected as e:
                success = False
                self._reject(e)

            # Unfortunatly, we cannot really test that since it is not supposed
            # to happen. Note that the _fail() method is covered by the tests.
231
            except Exception:  # pragma: no cover
Baptiste Beauplat's avatar
Baptiste Beauplat committed
232
233
                success = False
                self._fail(ExceptionImporterRejected(changes, 'Importer failed',
234
                                                     Exception(format_exc())))
Baptiste Beauplat's avatar
Baptiste Beauplat committed
235
236
237
238
239
            else:
                self._accept(upload)
            finally:
                changes.remove()

240
        self.repository.update()
241
242
        self.spool.cleanup()

Baptiste Beauplat's avatar
Baptiste Beauplat committed
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
        return success

    def _is_valid_email(self, email):
        if '<' in email and '>' in email:
            email = email.split('<')[1].split('>')[0]

        try:
            validate_email(email)
        except ValidationError:
            return False

        return True

    def send_email(self, template, error=None, upload=None,
                   notify_admins=False):
        recipients = []

        if not self.actually_send_email:
            log.info(f'Skipping email send: {template} {error}')
            return

        # That should not happen
        if bool(error) == bool(upload):  # pragma: no cover
            log.error(f'Trying to send an import email with error: {error} and'
                      f' upload {upload}')
            return

        email = Email(template)

        if error:
            user = getattr(error.changes, 'uploader', None)

            if user:
                if isinstance(user, User):
                    recipients.append(user.email)
                else:
                    if self._is_valid_email(user):
                        recipients.append(user)

            subject = f'{str(error.changes)}: REJECTED'

        if upload:
            recipients.append(upload.uploader.email)
            subject = f'{upload.package.name}_{upload.version}: ACCEPTED ' \
287
288
                      f'on {settings.SITE_NAME.split(".")[0]} ' \
                      f'({upload.distribution.name})'
Baptiste Beauplat's avatar
Baptiste Beauplat committed
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312

        if notify_admins:
            recipients.append(settings.DEFAULT_FROM_EMAIL)

        log.debug(f'Sending importer mail to {", ".join(recipients)}')
        email.send(subject, recipients,
                   upload=upload, error=error, settings=settings)

    def _accept(self, upload):
        log.info(f'Package {upload.package.name}_{upload.version} accepted '
                 f'into {upload.distribution.name}')
        self.send_email('email-importer-accept.html', upload=upload)

    def _fail(self, error):
        """
        Fail the upload by sending a reason for failure to the log and then
        remove all uploaded files.

        A package is `fail`ed if there is a problem with debexpo, **not** if
        there's something wrong with the package.

        ``error``
            Exception detailing why it failed.
        """
313
314
        log.critical(f'Importing {error.changes} failed with an unknown error:')
        log.critical(str(error))
Baptiste Beauplat's avatar
Baptiste Beauplat committed
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
        self.send_email('email-importer-fail.html', error, notify_admins=True)

    def _reject(self, error):
        """
        Reject the package by sending a reason for failure to the log and then
        remove all uploaded files.

        A package is `reject`ed if there is a problem with the package.

        ``error``
            Exception detailing why it failed.
        """
        log.error(error)
        self.send_email('email-importer-reject.html', error)

    @transaction.atomic
331
    def _create_db_entries(self, changes, source, plugins, git_ref):
Baptiste Beauplat's avatar
Baptiste Beauplat committed
332
333
334
335
        """
        Create entries in the Database for the package upload.
        """
        upload = PackageUpload.objects.create_from_changes(changes)
336
        upload.git_ref = git_ref
337
        upload.full_clean()
Baptiste Beauplat's avatar
Baptiste Beauplat committed
338
339
340
341
342
        upload.save()

        package = source.control.get_source_package()
        source_package = SourcePackage.objects.create_from_package(upload,
                                                                   package)
343
        source_package.full_clean()
Baptiste Beauplat's avatar
Baptiste Beauplat committed
344
345
346
347
348
        source_package.save()

        for package in source.control.get_binary_packages():
            binary_package = BinaryPackage.objects.create_from_package(upload,
                                                                       package)
349
            binary_package.full_clean()
Baptiste Beauplat's avatar
Baptiste Beauplat committed
350
351
            binary_package.save()

352
353
        for result in plugins.results:
            result.upload = upload
354
            result.full_clean()
355
            result.save()
Baptiste Beauplat's avatar
Baptiste Beauplat committed
356

357
358
            if result.plugin == 'debian-qa' and result.data:
                upload.package.in_debian = result.data.get('in_debian', False)
359
360
361
                upload.package.full_clean()
                upload.package.save()

362
        return upload
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385

#     def _overlap_with_other_distrib(self):
#         name = self.changes['Source']
#         version = self.changes['Version']
#         distribution = self.changes['Distribution']
#
#         package = meta.session.query(Package).filter_by(name=name).first()
#
#         if package:
#             if meta.session.query(PackageVersion) \
#                     .filter(PackageVersion.version == version,
#                             PackageVersion.distribution != distribution,
#                             PackageVersion.package == package).all():
#                 self._reject('An upload with the same version but '
#                              'different distribution exists on mentors.\n'
#                              'If you wish to upload this version for an '
#                              'other distribution, delete the old '
#                              'one.')
#
#                 return True
#
#         return False
#
Baptiste Beauplat's avatar
Baptiste Beauplat committed
386
387
388
389
390
391
392
    def process_upload(self, changes):
        """
        Actually start the import of the package.

        Do several environment sanity checks, move files into the right place,
        and then create the database entries for the imported package.
        """
393
        plugins = PluginManager()
Baptiste Beauplat's avatar
Baptiste Beauplat committed
394
395
396
397

        self._validate_changes(changes)
        self._validate_dsc(changes)
        source = self._validate_source(changes)
398
        plugins.run(changes, source)
Baptiste Beauplat's avatar
Baptiste Beauplat committed
399
400
401
402
        upload = self._accept_upload(changes, source, plugins)
        return upload

    def _accept_upload(self, changes, source, plugins):
403
        git_ref = None
404

Baptiste Beauplat's avatar
Baptiste Beauplat committed
405
        # Install source in git tree
406
407
408
409
        if self.git_storage_path:
            git_storage = GitStorage(self.git_storage_path,
                                     source.control.source['Source'])
            git_ref = git_storage.install(source)
410
411

        # Install to repository
412
        self.repository.install(changes)
413
414

        # Create DB entries
415
        upload = self._create_db_entries(changes, source, plugins, git_ref)
416

Baptiste Beauplat's avatar
Baptiste Beauplat committed
417
418
419
420
421
422
423
424
425
        return upload

    def _validate_changes(self, changes):
        # Check that all files referenced in the changelog are present and
        # match their checksum
        try:
            changes.validate()
            if not self.skip_gpg:
                changes.authenticate()
426
427
            if getattr(settings, 'CHECK_NEWER_UPLOAD', True):
                changes.assert_newer()
Baptiste Beauplat's avatar
Baptiste Beauplat committed
428
            changes.files.validate()
429
            changes.get_bugs()
Baptiste Beauplat's avatar
Baptiste Beauplat committed
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
        except (ExceptionChanges, ExceptionCheckSumedFile, ExceptionGnuPG) as e:
            raise ExceptionImporterRejected(changes, 'Changes is invalid', e)

    def _validate_dsc(self, changes):
        # Try parsing dsc file
        try:
            changes.parse_dsc()
        except ExceptionChanges as e:
            raise ExceptionImporterRejected(changes, 'Dsc failed to parse', e)

        # Validate dsc fields, gpg signature and files (including checksuming)
        dsc = changes.dsc

        try:
            dsc.validate()
            if not self.skip_gpg:
                dsc.authenticate()
447
            dsc.fetch_origin()
Baptiste Beauplat's avatar
Baptiste Beauplat committed
448
            dsc.files.validate()
449
450
        except (ExceptionDsc, ExceptionCheckSumedFile, ExceptionGnuPG,
                ExceptionOrigin, ExceptionClient) as e:
Baptiste Beauplat's avatar
Baptiste Beauplat committed
451
452
453
454
            raise ExceptionImporterRejected(changes, 'Dsc is invalid', e)

    def _validate_source(self, changes):
        # Instanciate the source package
Baptiste Beauplat's avatar
Baptiste Beauplat committed
455
        source = changes.get_source()
Baptiste Beauplat's avatar
Baptiste Beauplat committed
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496

        # Extract
        try:
            source.extract()
        except ExceptionSource as e:
            raise ExceptionImporterRejected(changes,
                                            'Failed to extract source package',
                                            e)

        # Parse control files (d/changelog, d/copyright and d/control)
        try:
            source.parse_control_files()
        except ExceptionSource as e:
            raise ExceptionImporterRejected(changes,
                                            'Source package is invalid',
                                            e)

        # And valid them
        try:
            source.changelog.validate()
            source.copyright.validate()
            source.control.validate()
        except (ExceptionChangelog, ExceptionCopyright, ExceptionControl) as e:
            raise ExceptionImporterRejected(changes,
                                            'Source package is invalid',
                                            e)

        # Validate distribution
        distribution = changes.distribution
        try:
            Distribution.objects.get(name=distribution)
        except Distribution.DoesNotExist:
            allowed = "\n".join(list(map(str, Distribution.objects.all())))
            raise ExceptionImporterRejected(
                changes, 'Invalid distribution',
                f'Distribution {distribution} is not supported on '
                f'mentors\n\n'
                f'List of supported distributions:\n\n'
                f'{allowed}')

        return source