~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/archiveuploader/uploadprocessor.py

  • Committer: Launchpad Patch Queue Manager
  • Date: 2011-12-22 04:55:30 UTC
  • mfrom: (14577.1.1 testfix)
  • Revision ID: launchpad@pqm.canonical.com-20111222045530-wki9iu6c0ysqqwkx
[r=wgrant][no-qa] Fix test_publisherconfig lpstorm import. Probably a
        silent conflict between megalint and apocalypse.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright 2009 Canonical Ltd.  This software is licensed under the
 
2
# GNU Affero General Public License version 3 (see the file LICENSE).
 
3
 
 
4
"""Code for 'processing' 'uploads'. Also see nascentupload.py.
 
5
 
 
6
Uploads are directories in the 'incoming' queue directory. They may have
 
7
arrived manually from a distribution contributor, via a poppy upload, or
 
8
they may have come from a build.
 
9
 
 
10
Within an upload, we may find no changes file, one, or several. One is
 
11
the usual number. To process the upload, we process each changes file
 
12
in turn. These changes files may be within a structure of sub-directories,
 
13
in which case we extract information from the names of these, to calculate
 
14
which distribution and which PPA are being uploaded to.
 
15
 
 
16
To process a changes file, we make checks such as that the other files
 
17
referenced by it are present, formatting is valid, signatures are correct,
 
18
checksums match, and that the .changes file represents an upload which makes
 
19
sense, eg. it is not a binary for which we have no source, or an older
 
20
version than already exists in the same target distroseries pocket.
 
21
 
 
22
Depending on the outcome of these checks, the changes file will either be
 
23
accepted (and the information from it, and the referenced files, imported
 
24
into the database) or it won't (and the database will be unchanged). If not
 
25
accepted, a changes file might be 'failed' or 'rejected', where failed
 
26
changes files are dropped silently, but rejected ones generate a rejection
 
27
email back to the uploader.
 
28
 
 
29
There are several valid reasons to fail (the changes file is so mangled
 
30
that we can't read who we should send a rejection to, or it's not correctly
 
31
signed, so we can't be sure a rejection wouldn't be spam (it may not have
 
32
been uploaded by who it says it was uploaded by). In practice, in the code
 
33
as it stands, we also consider the processing of a changes file to have
 
34
failed if it generates an unexpected exception, and there are some known
 
35
cases where it does this and a rejection would have been more useful
 
36
(see bug 35965).
 
37
 
 
38
Each upload directory is saved after processing, in case it is needed for
 
39
debugging purposes. This is done by moving it to a directory inside the queue
 
40
directory, beside incoming, named after the result - 'failed', 'rejected' or
 
41
'accepted'. Where there are no changes files, the upload is considered failed,
 
42
and where there is more than one changes file, the upload is assigned the
 
43
worst of the results from the various changes files found (in the order
 
44
above, failed being worst).
 
45
 
 
46
"""
 
47
 
 
48
__metaclass__ = type
 
49
 
 
50
import os
 
51
import shutil
 
52
import stat
 
53
import sys
 
54
 
 
55
from contrib.glock import GlobalLock
 
56
from sqlobject import SQLObjectNotFound
 
57
from zope.component import getUtility
 
58
 
 
59
from canonical.launchpad.webapp.errorlog import (
 
60
    ErrorReportingUtility,
 
61
    ScriptRequest,
 
62
    )
 
63
from lp.app.errors import NotFoundError
 
64
from lp.archiveuploader.nascentupload import (
 
65
    EarlyReturnUploadError,
 
66
    NascentUpload,
 
67
    UploadError,
 
68
    )
 
69
from lp.archiveuploader.uploadpolicy import (
 
70
    BuildDaemonUploadPolicy,
 
71
    UploadPolicyError,
 
72
    )
 
73
from lp.buildmaster.enums import BuildStatus
 
74
from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
 
75
from lp.code.interfaces.sourcepackagerecipebuild import (
 
76
    ISourcePackageRecipeBuild,
 
77
    )
 
78
from lp.registry.interfaces.distribution import IDistributionSet
 
79
from lp.registry.interfaces.person import IPersonSet
 
80
from lp.services.log.logger import BufferLogger
 
81
from lp.soyuz.interfaces.archive import (
 
82
    IArchiveSet,
 
83
    NoSuchPPA,
 
84
    )
 
85
 
 
86
 
 
87
__all__ = [
 
88
    'UploadProcessor',
 
89
    'parse_build_upload_leaf_name',
 
90
    'parse_upload_path',
 
91
    ]
 
92
 
 
93
UPLOAD_PATH_ERROR_TEMPLATE = (
 
94
"""Launchpad failed to process the upload path '%(upload_path)s':
 
95
 
 
96
%(path_error)s
 
97
 
 
98
It is likely that you have a configuration problem with dput/dupload.
 
99
%(extra_info)s
 
100
""")
 
101
 
 
102
 
 
103
def parse_build_upload_leaf_name(name):
 
104
    """Parse the leaf directory name of a build upload.
 
105
 
 
106
    :param name: Directory name.
 
107
    :return: Tuple with build farm job id.
 
108
    """
 
109
    (job_type, job_id_str) = name.split("-")[-2:]
 
110
    try:
 
111
        return (job_type, int(job_id_str))
 
112
    except TypeError:
 
113
        raise ValueError
 
114
 
 
115
 
 
116
class UploadStatusEnum:
 
117
    """Possible results from processing an upload.
 
118
 
 
119
    ACCEPTED: all goes well, we commit nascentupload's changes to the db
 
120
    REJECTED: nascentupload gives a well-formed rejection error,
 
121
              we send a rejection email and rollback.
 
122
    FAILED: nascentupload code raises an exception, no email, rollback
 
123
    """
 
124
    ACCEPTED = 'accepted'
 
125
    REJECTED = 'rejected'
 
126
    FAILED = 'failed'
 
127
 
 
128
 
 
129
class UploadPathError(Exception):
 
130
    """This exception happened when parsing the upload path."""
 
131
 
 
132
 
 
133
class PPAUploadPathError(Exception):
 
134
    """Exception when parsing a PPA upload path."""
 
135
 
 
136
 
 
137
class UploadProcessor:
 
138
    """Responsible for processing uploads. See module docstring."""
 
139
 
 
140
    def __init__(self, base_fsroot, dry_run, no_mails, builds, keep,
 
141
                 policy_for_distro, ztm, log):
 
142
        """Create a new upload processor.
 
143
 
 
144
        :param base_fsroot: Root path for queue to use
 
145
        :param dry_run: Run but don't commit changes to database
 
146
        :param no_mails: Don't send out any emails
 
147
        :param builds: Interpret leaf names as build ids
 
148
        :param keep: Leave the files in place, don't move them away
 
149
        :param policy_for_distro: callback to obtain Policy object for a
 
150
            distribution
 
151
        :param ztm: Database transaction to use
 
152
        :param log: Logger to use for reporting
 
153
        """
 
154
        self.base_fsroot = base_fsroot
 
155
        self.dry_run = dry_run
 
156
        self.keep = keep
 
157
        self.last_processed_upload = None
 
158
        self.log = log
 
159
        self.no_mails = no_mails
 
160
        self.builds = builds
 
161
        self._getPolicyForDistro = policy_for_distro
 
162
        self.ztm = ztm
 
163
 
 
164
    def processUploadQueue(self, leaf_name=None):
 
165
        """Search for uploads, and process them.
 
166
 
 
167
        Uploads are searched for in the 'incoming' directory inside the
 
168
        base_fsroot.
 
169
 
 
170
        This method also creates the 'incoming', 'accepted', 'rejected', and
 
171
        'failed' directories inside the base_fsroot if they don't yet exist.
 
172
        """
 
173
        try:
 
174
            self.log.debug("Beginning processing")
 
175
 
 
176
            for subdir in ["incoming", "accepted", "rejected", "failed"]:
 
177
                full_subdir = os.path.join(self.base_fsroot, subdir)
 
178
                if not os.path.exists(full_subdir):
 
179
                    self.log.debug("Creating directory %s" % full_subdir)
 
180
                    os.mkdir(full_subdir)
 
181
 
 
182
            fsroot = os.path.join(self.base_fsroot, "incoming")
 
183
            uploads_to_process = self.locateDirectories(fsroot)
 
184
            self.log.debug("Checked in %s, found %s"
 
185
                           % (fsroot, uploads_to_process))
 
186
            for upload in uploads_to_process:
 
187
                self.log.debug("Considering upload %s" % upload)
 
188
                if leaf_name is not None and upload != leaf_name:
 
189
                    self.log.debug("Skipping %s -- does not match %s" % (
 
190
                        upload, leaf_name))
 
191
                    continue
 
192
                try:
 
193
                    handler = UploadHandler.forProcessor(self, fsroot, upload)
 
194
                except CannotGetBuild, e:
 
195
                    self.log.warn(e)
 
196
                else:
 
197
                    handler.process()
 
198
        finally:
 
199
            self.log.debug("Rolling back any remaining transactions.")
 
200
            self.ztm.abort()
 
201
 
 
202
    def locateDirectories(self, fsroot):
 
203
        """Return a list of upload directories in a given queue.
 
204
 
 
205
        This method operates on the queue atomically, i.e. it suppresses
 
206
        changes in the queue directory, like new uploads, by acquiring
 
207
        the shared upload_queue lockfile while the directory are listed.
 
208
 
 
209
        :param fsroot: path to a 'queue' directory to be inspected.
 
210
 
 
211
        :return: a list of upload directories found in the queue
 
212
            alphabetically sorted.
 
213
        """
 
214
        # Protecting listdir by a lock ensures that we only get completely
 
215
        # finished directories listed. See lp.poppy.hooks for the other
 
216
        # locking place.
 
217
        lockfile_path = os.path.join(fsroot, ".lock")
 
218
        fsroot_lock = GlobalLock(lockfile_path)
 
219
        mode = stat.S_IMODE(os.stat(lockfile_path).st_mode)
 
220
 
 
221
        # XXX cprov 20081024 bug=185731: The lockfile permission can only be
 
222
        # changed by its owner. Since we can't predict which process will
 
223
        # create it in production systems we simply ignore errors when trying
 
224
        # to grant the right permission. At least, one of the process will
 
225
        # be able to do so.
 
226
        try:
 
227
            os.chmod(lockfile_path, mode | stat.S_IWGRP)
 
228
        except OSError, err:
 
229
            self.log.debug('Could not fix the lockfile permission: %s' % err)
 
230
 
 
231
        try:
 
232
            fsroot_lock.acquire(blocking=True)
 
233
            dir_names = os.listdir(fsroot)
 
234
        finally:
 
235
            # Skip lockfile deletion, see similar code in lp.poppy.hooks.
 
236
            fsroot_lock.release(skip_delete=True)
 
237
 
 
238
        sorted_dir_names = sorted(
 
239
            dir_name
 
240
            for dir_name in dir_names
 
241
            if os.path.isdir(os.path.join(fsroot, dir_name)))
 
242
 
 
243
        return sorted_dir_names
 
244
 
 
245
 
 
246
class UploadHandler:
 
247
    """Handler for processing a single upload."""
 
248
 
 
249
    def __init__(self, processor, fsroot, upload):
 
250
        """Constructor.
 
251
 
 
252
        :param processor: The `UploadProcessor` that requested processing the
 
253
            upload.
 
254
        :param fsroot: Path to the directory containing the upload directory
 
255
        :param upload: Name of the directory containing the upload.
 
256
        """
 
257
        self.processor = processor
 
258
        self.fsroot = fsroot
 
259
        self.upload = upload
 
260
        self.upload_path = os.path.join(self.fsroot, self.upload)
 
261
 
 
262
    @staticmethod
 
263
    def forProcessor(processor, fsroot, upload, build=None):
 
264
        """Instantiate an UploadHandler subclass for a given upload.
 
265
 
 
266
        :param processor: The `UploadProcessor` that requested processing the
 
267
            upload.
 
268
        :param fsroot: Path to the directory containing the upload directory
 
269
        :param upload: Name of the directory containing the upload.
 
270
        :param build: Optional; the build that produced the upload.
 
271
        """
 
272
        if processor.builds:
 
273
            # Upload directories contain build results,
 
274
            # directories are named after job ids.
 
275
            return BuildUploadHandler(processor, fsroot, upload, build)
 
276
        else:
 
277
            assert build is None
 
278
            return UserUploadHandler(processor, fsroot, upload)
 
279
 
 
280
    def locateChangesFiles(self):
 
281
        """Locate .changes files in the upload directory.
 
282
 
 
283
        Return .changes files sorted with *_source.changes first. This
 
284
        is important to us, as in an upload containing several changes files,
 
285
        it's possible the binary ones will depend on the source ones, so
 
286
        the source ones should always be considered first.
 
287
        """
 
288
        changes_files = []
 
289
 
 
290
        for dirpath, dirnames, filenames in os.walk(self.upload_path):
 
291
            relative_path = dirpath[len(self.upload_path) + 1:]
 
292
            for filename in filenames:
 
293
                if filename.endswith(".changes"):
 
294
                    changes_files.append(
 
295
                        os.path.join(relative_path, filename))
 
296
        return self.orderFilenames(changes_files)
 
297
 
 
298
    def processChangesFile(self, changes_file, logger=None):
 
299
        """Process a single changes file.
 
300
 
 
301
        This is done by obtaining the appropriate upload policy (according
 
302
        to command-line options and the value in the .distro file beside
 
303
        the upload, if present), creating a NascentUpload object and calling
 
304
        its process method.
 
305
 
 
306
        We obtain the context for this processing from the relative path,
 
307
        within the upload folder, of this changes file. This influences
 
308
        our creation both of upload policy and the NascentUpload object.
 
309
 
 
310
        See nascentupload.py for the gory details.
 
311
 
 
312
        Returns a value from UploadStatusEnum, or re-raises an exception
 
313
        from NascentUpload.
 
314
 
 
315
        :param changes_file: filename of the changes file to process.
 
316
        :param logger: logger to use for processing.
 
317
        :return: an `UploadStatusEnum` value
 
318
        """
 
319
        if logger is None:
 
320
            logger = self.processor.log
 
321
        # Calculate the distribution from the path within the upload
 
322
        # Reject the upload since we could not process the path,
 
323
        # Store the exception information as a rejection message.
 
324
        relative_path = os.path.dirname(changes_file)
 
325
        upload_path_error = None
 
326
        try:
 
327
            (distribution, suite_name,
 
328
             archive) = parse_upload_path(relative_path)
 
329
        except UploadPathError, e:
 
330
            # pick some defaults to create the NascentUpload() object.
 
331
            # We will be rejecting the upload so it doesn matter much.
 
332
            distribution = getUtility(IDistributionSet)['ubuntu']
 
333
            suite_name = None
 
334
            archive = distribution.main_archive
 
335
            upload_path_error = UPLOAD_PATH_ERROR_TEMPLATE % (
 
336
                dict(upload_path=relative_path, path_error=str(e),
 
337
                     extra_info=(
 
338
                         "Please update your dput/dupload configuration "
 
339
                         "and then re-upload.")))
 
340
        except PPAUploadPathError, e:
 
341
            # Again, pick some defaults but leave a hint for the rejection
 
342
            # emailer that it was a PPA failure.
 
343
            distribution = getUtility(IDistributionSet)['ubuntu']
 
344
            suite_name = None
 
345
            # XXX cprov 20071212: using the first available PPA is not exactly
 
346
            # fine because it can confuse the code that sends rejection
 
347
            # messages if it relies only on archive.purpose (which should be
 
348
            # enough). On the other hand if we set an arbitrary owner it
 
349
            # will break nascentupload ACL calculations.
 
350
            archive = distribution.getAllPPAs()[0]
 
351
            upload_path_error = UPLOAD_PATH_ERROR_TEMPLATE % (
 
352
                dict(upload_path=relative_path, path_error=str(e),
 
353
                     extra_info=(
 
354
                         "Please check the documentation at "
 
355
                         "https://help.launchpad.net/Packaging/PPA#Uploading "
 
356
                         "and update your configuration.")))
 
357
        logger.debug("Finding fresh policy")
 
358
        policy = self._getPolicyForDistro(distribution)
 
359
        policy.archive = archive
 
360
 
 
361
        # DistroSeries overriding respect the following precedence:
 
362
        #  1. process-upload.py command-line option (-r),
 
363
        #  2. upload path,
 
364
        #  3. changesfile 'Distribution' field.
 
365
        if suite_name is not None:
 
366
            policy.setDistroSeriesAndPocket(suite_name)
 
367
 
 
368
        # The path we want for NascentUpload is the path to the folder
 
369
        # containing the changes file (and the other files referenced by it).
 
370
        changesfile_path = os.path.join(self.upload_path, changes_file)
 
371
        try:
 
372
            upload = NascentUpload.from_changesfile_path(
 
373
                changesfile_path, policy, self.processor.log)
 
374
        except UploadError as e:
 
375
            # We failed to parse the changes file, so we have no key or
 
376
            # Changed-By to notify of the rejection. Just log it and
 
377
            # move on.
 
378
            # XXX wgrant 2011-09-29 bug=499438: With some refactoring we
 
379
            # could do better here: if we have a signature then we have
 
380
            # somebody to email, even if the rest of the file is
 
381
            # corrupt.
 
382
            logger.info(
 
383
                "Failed to parse changes file '%s': %s" % (
 
384
                    os.path.join(self.upload_path, changes_file),
 
385
                    str(e)))
 
386
            return UploadStatusEnum.REJECTED
 
387
 
 
388
        # Reject source upload to buildd upload paths.
 
389
        first_path = relative_path.split(os.path.sep)[0]
 
390
        if (first_path.isdigit() and
 
391
            policy.name != BuildDaemonUploadPolicy.name):
 
392
            error_message = (
 
393
                "Invalid upload path (%s) for this policy (%s)" %
 
394
                (relative_path, policy.name))
 
395
            upload.reject(error_message)
 
396
            logger.error(error_message)
 
397
 
 
398
        # Reject upload with path processing errors.
 
399
        if upload_path_error is not None:
 
400
            upload.reject(upload_path_error)
 
401
 
 
402
        # Store processed NascentUpload instance, mostly used for tests.
 
403
        self.processor.last_processed_upload = upload
 
404
 
 
405
        try:
 
406
            logger.info("Processing upload %s" % upload.changes.filename)
 
407
            result = UploadStatusEnum.ACCEPTED
 
408
 
 
409
            try:
 
410
                self._processUpload(upload)
 
411
            except UploadPolicyError, e:
 
412
                upload.reject("UploadPolicyError escaped upload.process: "
 
413
                              "%s " % e)
 
414
                logger.debug(
 
415
                    "UploadPolicyError escaped upload.process", exc_info=True)
 
416
            except (KeyboardInterrupt, SystemExit):
 
417
                raise
 
418
            except EarlyReturnUploadError:
 
419
                # An error occurred that prevented further error collection,
 
420
                # add this fact to the list of errors.
 
421
                upload.reject(
 
422
                    "Further error processing not possible because of "
 
423
                    "a critical previous error.")
 
424
            except Exception, e:
 
425
                # In case of unexpected unhandled exception, we'll
 
426
                # *try* to reject the upload. This may fail and cause
 
427
                # a further exception, depending on the state of the
 
428
                # nascentupload objects. In that case, we've lost nothing,
 
429
                # the new exception will be handled by the caller just like
 
430
                # the one we caught would have been, by failing the upload
 
431
                # with no email.
 
432
                logger.exception("Unhandled exception processing upload")
 
433
                upload.reject("Unhandled exception processing upload: %s" % e)
 
434
 
 
435
            # XXX julian 2007-05-25 bug=29744:
 
436
            # When bug #29744 is fixed (zopeless mails should only be sent
 
437
            # when transaction is committed) this will cause any emails sent
 
438
            # sent by do_reject to be lost.
 
439
            notify = True
 
440
            if self.processor.dry_run or self.processor.no_mails:
 
441
                notify = False
 
442
            if upload.is_rejected:
 
443
                result = UploadStatusEnum.REJECTED
 
444
                upload.do_reject(notify)
 
445
                self.processor.ztm.abort()
 
446
            else:
 
447
                successful = self._acceptUpload(upload, notify)
 
448
                if not successful:
 
449
                    result = UploadStatusEnum.REJECTED
 
450
                    logger.info(
 
451
                        "Rejection during accept. Aborting partial accept.")
 
452
                    self.processor.ztm.abort()
 
453
 
 
454
            if upload.is_rejected:
 
455
                logger.info("Upload was rejected:")
 
456
                for msg in upload.rejections:
 
457
                    logger.info("\t%s" % msg)
 
458
 
 
459
            if self.processor.dry_run:
 
460
                logger.info("Dry run, aborting transaction.")
 
461
                self.processor.ztm.abort()
 
462
            else:
 
463
                logger.info(
 
464
                    "Committing the transaction and any mails associated "
 
465
                    "with this upload.")
 
466
                self.processor.ztm.commit()
 
467
        except:
 
468
            self.processor.ztm.abort()
 
469
            raise
 
470
 
 
471
        return result
 
472
 
 
473
    def removeUpload(self, logger):
 
474
        """Remove an upload that has succesfully been processed.
 
475
 
 
476
        This includes moving the given upload directory and moving the
 
477
        matching .distro file, if it exists.
 
478
 
 
479
        :param logger: The logger to use for logging results.
 
480
        """
 
481
        if self.processor.keep or self.processor.dry_run:
 
482
            logger.debug("Keeping contents untouched")
 
483
            return
 
484
 
 
485
        logger.debug("Removing upload directory %s", self.upload_path)
 
486
        shutil.rmtree(self.upload_path)
 
487
 
 
488
        distro_filename = self.upload_path + ".distro"
 
489
        if os.path.isfile(distro_filename):
 
490
            logger.debug("Removing distro file %s", distro_filename)
 
491
            os.remove(distro_filename)
 
492
 
 
493
    def moveProcessedUpload(self, destination, logger):
 
494
        """Move or remove the upload depending on the status of the upload.
 
495
 
 
496
        :param destination: An `UploadStatusEnum` value.
 
497
        :param logger: The logger to use for logging results.
 
498
        """
 
499
        if destination == "accepted":
 
500
            self.removeUpload(logger)
 
501
        else:
 
502
            self.moveUpload(destination, logger)
 
503
 
 
504
    def moveUpload(self, subdir_name, logger):
 
505
        """Move the upload to the named subdir of the root, eg 'accepted'.
 
506
 
 
507
        This includes moving the given upload directory and moving the
 
508
        matching .distro file, if it exists.
 
509
 
 
510
        :param subdir_name: Name of the subdirectory to move to.
 
511
        :param logger: The logger to use for logging results.
 
512
        """
 
513
        if self.processor.keep or self.processor.dry_run:
 
514
            logger.debug("Keeping contents untouched")
 
515
            return
 
516
 
 
517
        pathname = os.path.basename(self.upload_path)
 
518
 
 
519
        target_path = os.path.join(
 
520
            self.processor.base_fsroot, subdir_name, pathname)
 
521
        logger.debug("Moving upload directory %s to %s" %
 
522
            (self.upload_path, target_path))
 
523
        shutil.move(self.upload_path, target_path)
 
524
 
 
525
        distro_filename = self.upload_path + ".distro"
 
526
        if os.path.isfile(distro_filename):
 
527
            target_path = os.path.join(self.processor.base_fsroot,
 
528
                                       subdir_name,
 
529
                                       os.path.basename(distro_filename))
 
530
            logger.debug("Moving distro file %s to %s" % (distro_filename,
 
531
                                                            target_path))
 
532
            shutil.move(distro_filename, target_path)
 
533
 
 
534
    @staticmethod
 
535
    def orderFilenames(fnames):
 
536
        """Order filenames, sorting *_source.changes before others.
 
537
 
 
538
        Aside from that, a standard string sort.
 
539
        """
 
540
 
 
541
        def sourceFirst(filename):
 
542
            return (not filename.endswith("_source.changes"), filename)
 
543
 
 
544
        return sorted(fnames, key=sourceFirst)
 
545
 
 
546
 
 
547
class UserUploadHandler(UploadHandler):
 
548
 
 
549
    def process(self):
 
550
        """Process an upload's changes files, and move it to a new directory.
 
551
 
 
552
        The destination directory depends on the result of the processing
 
553
        of the changes files. If there are no changes files, the result
 
554
        is 'failed', otherwise it is the worst of the results from the
 
555
        individual changes files, in order 'failed', 'rejected', 'accepted'.
 
556
        """
 
557
        changes_files = self.locateChangesFiles()
 
558
 
 
559
        results = set()
 
560
 
 
561
        for changes_file in changes_files:
 
562
            self.processor.log.debug(
 
563
                "Considering changefile %s" % changes_file)
 
564
            try:
 
565
                results.add(self.processChangesFile(
 
566
                    changes_file, self.processor.log))
 
567
            except (KeyboardInterrupt, SystemExit):
 
568
                raise
 
569
            except:
 
570
                info = sys.exc_info()
 
571
                message = (
 
572
                    'Exception while processing upload %s' % self.upload_path)
 
573
                properties = [('error-explanation', message)]
 
574
                request = ScriptRequest(properties)
 
575
                error_utility = ErrorReportingUtility()
 
576
                error_utility.raising(info, request)
 
577
                self.processor.log.error(
 
578
                    '%s (%s)' % (message, request.oopsid))
 
579
                results.add(UploadStatusEnum.FAILED)
 
580
        if len(results) == 0:
 
581
            destination = UploadStatusEnum.FAILED
 
582
        else:
 
583
            for destination in [
 
584
                UploadStatusEnum.FAILED, UploadStatusEnum.REJECTED,
 
585
                UploadStatusEnum.ACCEPTED]:
 
586
                if destination in results:
 
587
                    break
 
588
        self.moveProcessedUpload(destination, self.processor.log)
 
589
 
 
590
    def _getPolicyForDistro(self, distribution):
 
591
        return self.processor._getPolicyForDistro(distribution, None)
 
592
 
 
593
    def _processUpload(self, upload):
 
594
        upload.process(None)
 
595
 
 
596
    def _acceptUpload(self, upload, notify):
 
597
        return upload.do_accept(notify=notify, build=None)
 
598
 
 
599
 
 
600
class CannotGetBuild(Exception):
 
601
 
 
602
    """Attempting to retrieve the build for this upload failed."""
 
603
 
 
604
 
 
605
class BuildUploadHandler(UploadHandler):
 
606
 
 
607
    def __init__(self, processor, fsroot, upload, build=None):
 
608
        """Constructor.
 
609
 
 
610
        See `UploadHandler`.
 
611
        :build: Optional build that produced this upload.  If not supplied,
 
612
            will be retrieved using the id in the upload.
 
613
        :raises: CannotGetBuild if the build could not be retrieved.
 
614
        """
 
615
        super(BuildUploadHandler, self).__init__(processor, fsroot, upload)
 
616
        self.build = build
 
617
        if self.build is None:
 
618
            self.build = self._getBuild()
 
619
 
 
620
    def _getPolicyForDistro(self, distribution):
 
621
        return self.processor._getPolicyForDistro(distribution, self.build)
 
622
 
 
623
    def _processUpload(self, upload):
 
624
        upload.process(self.build)
 
625
 
 
626
    def _acceptUpload(self, upload, notify):
 
627
        return upload.do_accept(notify=notify, build=self.build)
 
628
 
 
629
    def _getBuild(self):
 
630
        try:
 
631
            job_type, job_id = parse_build_upload_leaf_name(self.upload)
 
632
        except ValueError:
 
633
            raise CannotGetBuild(
 
634
                "Unable to extract build id from leaf name %s, skipping." %
 
635
                self.upload)
 
636
        try:
 
637
            return getUtility(ISpecificBuildFarmJobSource, job_type).getByID(
 
638
                job_id)
 
639
        except NotFoundError:
 
640
            raise CannotGetBuild(
 
641
                "Unable to find %s with id %d. Skipping." %
 
642
                (job_type, job_id))
 
643
 
 
644
    def process(self):
 
645
        """Process an upload that is the result of a build.
 
646
 
 
647
        The name of the leaf is the build id of the build.
 
648
        Build uploads always contain a single package per leaf.
 
649
        """
 
650
        logger = BufferLogger()
 
651
        if self.build.status != BuildStatus.UPLOADING:
 
652
            self.processor.log.warn(
 
653
                "Expected build status to be 'UPLOADING', was %s. Ignoring." %
 
654
                self.build.status.name)
 
655
            return
 
656
        try:
 
657
            # The recipe may have been deleted so we need to flag that here
 
658
            # and will handle below. We check so that we don't go to the
 
659
            # expense of doing an unnecessary upload. We don't just exit here
 
660
            # because we want the standard cleanup to occur.
 
661
            recipe_deleted = (ISourcePackageRecipeBuild.providedBy(self.build)
 
662
                and self.build.recipe is None)
 
663
            if recipe_deleted:
 
664
                result = UploadStatusEnum.FAILED
 
665
            else:
 
666
                self.processor.log.debug("Build %s found" % self.build.id)
 
667
                [changes_file] = self.locateChangesFiles()
 
668
                logger.debug("Considering changefile %s" % changes_file)
 
669
                result = self.processChangesFile(changes_file, logger)
 
670
        except (KeyboardInterrupt, SystemExit):
 
671
            raise
 
672
        except:
 
673
            info = sys.exc_info()
 
674
            message = (
 
675
                'Exception while processing upload %s' % self.upload_path)
 
676
            properties = [('error-explanation', message)]
 
677
            request = ScriptRequest(properties)
 
678
            error_utility = ErrorReportingUtility()
 
679
            error_utility.raising(info, request)
 
680
            logger.error('%s (%s)' % (message, request.oopsid))
 
681
            result = UploadStatusEnum.FAILED
 
682
        if (result != UploadStatusEnum.ACCEPTED or
 
683
            not self.build.verifySuccessfulUpload()):
 
684
            self.build.status = BuildStatus.FAILEDTOUPLOAD
 
685
        if self.build.status != BuildStatus.FULLYBUILT:
 
686
            if recipe_deleted:
 
687
                # For a deleted recipe, no need to notify that uploading has
 
688
                # failed - we just log a warning.
 
689
                self.processor.log.warn(
 
690
                    "Recipe for build %s was deleted. Ignoring." %
 
691
                    self.upload)
 
692
            else:
 
693
                self.build.storeUploadLog(logger.getLogBuffer())
 
694
                self.build.notify(extra_info="Uploading build %s failed." %
 
695
                                  self.upload)
 
696
        else:
 
697
            self.build.notify()
 
698
        self.processor.ztm.commit()
 
699
        self.moveProcessedUpload(result, logger)
 
700
 
 
701
 
 
702
def _getDistributionAndSuite(parts, exc_type):
 
703
    """Return an `IDistribution` and a valid suite name for the given path.
 
704
 
 
705
 
 
706
    Helper function used within `parse_upload_path` for extracting and
 
707
    verifying the part of the upload path targeting a existing distribution
 
708
    and optionally one of its suite.
 
709
 
 
710
    It will fail with `AssertionError` if the given `parts` is not a list
 
711
    with one or two elements.
 
712
 
 
713
    :param parts: a list of path parts to be processed.
 
714
    :param exc_type: a specific Exception type that should be raised on
 
715
        errors.
 
716
 
 
717
    :return: a tuple containing a `IDistribution` and a suite name if it's
 
718
        appropriate. The suite name will be None if it wasn't present in the
 
719
        given path parts.
 
720
 
 
721
    :raises: the given `exc_type` if the corresponding distribution or suite
 
722
        could not be found.
 
723
    """
 
724
    # This assertion should never happens when this method is called from
 
725
    # 'parse_upload_path'.
 
726
    assert len(parts) <= 2, (
 
727
        "'%s' does not correspond to a [distribution[/suite]]."
 
728
        % '/'.join(parts))
 
729
 
 
730
    # Uploads with undefined distribution defaults to 'ubuntu'.
 
731
    if len(parts) == 0 or parts[0] is '':
 
732
        ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
 
733
        return (ubuntu, None)
 
734
 
 
735
    distribution_name = parts[0]
 
736
    distribution = getUtility(IDistributionSet).getByName(distribution_name)
 
737
    if distribution is None:
 
738
        raise exc_type(
 
739
            "Could not find distribution '%s'." % distribution_name)
 
740
 
 
741
    if len(parts) == 1:
 
742
        return (distribution, None)
 
743
 
 
744
    suite_name = parts[1]
 
745
    try:
 
746
        distribution.getDistroSeriesAndPocket(suite_name)
 
747
    except NotFoundError:
 
748
        raise exc_type("Could not find suite '%s'." % suite_name)
 
749
 
 
750
    return (distribution, suite_name)
 
751
 
 
752
 
 
753
def parse_upload_path(relative_path):
 
754
    """Locate the distribution and archive for the upload.
 
755
 
 
756
    We do this by analysing the path to which the user has uploaded,
 
757
    ie. the relative path within the upload folder to the changes file.
 
758
 
 
759
    The valid paths are:
 
760
    '' - default distro, ubuntu
 
761
    '<distroname>' - given distribution
 
762
    '~<personname>[/ppa_name]/<distroname>[/distroseriesname]' - given ppa,
 
763
    distribution and optionally a distroseries.  If ppa_name is not
 
764
    specified it will default to the one referenced by IPerson.archive.
 
765
 
 
766
    I raises UploadPathError if something was wrong when parsing it.
 
767
 
 
768
    On success it returns a tuple of IDistribution, suite-name,
 
769
    IArchive for the given path, where the second field can be None.
 
770
    """
 
771
    parts = relative_path.split(os.path.sep)
 
772
 
 
773
    first_path = parts[0]
 
774
 
 
775
    if (not first_path.startswith('~') and not first_path.isdigit()
 
776
        and len(parts) <= 2):
 
777
        # Distribution upload (<distro>[/distroseries]). Always targeted to
 
778
        # the corresponding primary archive.
 
779
        distribution, suite_name = _getDistributionAndSuite(
 
780
            parts, UploadPathError)
 
781
        archive = distribution.main_archive
 
782
 
 
783
    elif (first_path.startswith('~') and
 
784
          len(parts) >= 2 and
 
785
          len(parts) <= 4):
 
786
        # PPA upload (~<person>[/ppa_name]/<distro>[/distroseries]).
 
787
 
 
788
        # Skip over '~' from the person name.
 
789
        person_name = first_path[1:]
 
790
        person = getUtility(IPersonSet).getByName(person_name)
 
791
        if person is None:
 
792
            raise PPAUploadPathError(
 
793
                "Could not find person or team named '%s'." % person_name)
 
794
 
 
795
        ppa_name = parts[1]
 
796
 
 
797
        # Compatibilty feature for allowing unamed-PPA upload paths
 
798
        # for a certain period of time.
 
799
        if ppa_name == 'ubuntu':
 
800
            ppa_name = 'ppa'
 
801
            distribution_and_suite = parts[1:]
 
802
        else:
 
803
            distribution_and_suite = parts[2:]
 
804
 
 
805
        try:
 
806
            archive = person.getPPAByName(ppa_name)
 
807
        except NoSuchPPA:
 
808
            raise PPAUploadPathError(
 
809
                "Could not find a PPA named '%s' for '%s'."
 
810
                % (ppa_name, person_name))
 
811
 
 
812
        distribution, suite_name = _getDistributionAndSuite(
 
813
            distribution_and_suite, PPAUploadPathError)
 
814
 
 
815
    elif first_path.isdigit():
 
816
        # This must be a binary upload from a build slave.
 
817
        try:
 
818
            archive = getUtility(IArchiveSet).get(int(first_path))
 
819
        except SQLObjectNotFound:
 
820
            raise UploadPathError(
 
821
                "Could not find archive with id=%s." % first_path)
 
822
        distribution, suite_name = _getDistributionAndSuite(
 
823
            parts[1:], UploadPathError)
 
824
    else:
 
825
        # Upload path does not match anything we support.
 
826
        raise UploadPathError("Path format mismatch.")
 
827
 
 
828
    if not archive.enabled:
 
829
        raise PPAUploadPathError("%s is disabled." % archive.displayname)
 
830
 
 
831
    if archive.distribution != distribution:
 
832
        raise PPAUploadPathError(
 
833
            "%s only supports uploads to '%s' distribution."
 
834
            % (archive.displayname, archive.distribution.name))
 
835
 
 
836
    return (distribution, suite_name, archive)