← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:move-package into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:move-package into launchpad:master.

Commit message:
Add an atomic "move package" operation

Archive.copyPackage and Archive.copyPackages now take a move=True
argument, which causes the source publication to be deleted if the copy
succeeds.

This allows us to fix a long-standing problem with Ubuntu's
proposed-migration process: it needs to do a copy and delete when
migrating packages from devel-proposed to devel, but since the copy is
asynchronous it can fail without proposed-migration being aware of this,
leading to the package in question simply being removed.  Moving the
deletion into the copier avoids this problem.

LP: #1329052

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1329052 in Launchpad itself: "Atomic "move package" operation"
  https://bugs.launchpad.net/launchpad/+bug/1329052

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/373942
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:move-package into launchpad:master.
diff --git a/lib/lp/code/vocabularies/sourcepackagerecipe.py b/lib/lp/code/vocabularies/sourcepackagerecipe.py
index 0c56101..31cf4bc 100644
--- a/lib/lp/code/vocabularies/sourcepackagerecipe.py
+++ b/lib/lp/code/vocabularies/sourcepackagerecipe.py
@@ -1,4 +1,4 @@
-# Copyright 2010-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Source Package Recipe vocabularies used in the lp/code modules."""
@@ -22,8 +22,8 @@ from lp.services.webapp.vocabulary import (
     IHugeVocabulary,
     SQLObjectVocabularyBase,
     )
-from lp.soyuz.browser.archive import make_archive_vocabulary
 from lp.soyuz.interfaces.archive import IArchiveSet
+from lp.soyuz.vocabularies import make_archive_vocabulary
 
 
 @implementer(IHugeVocabulary)
diff --git a/lib/lp/soyuz/browser/archive.py b/lib/lp/soyuz/browser/archive.py
index 6dd6cfe..3170ea0 100644
--- a/lib/lp/soyuz/browser/archive.py
+++ b/lib/lp/soyuz/browser/archive.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Browser views for archive."""
@@ -23,7 +23,6 @@ __all__ = [
     'ArchiveView',
     'ArchiveViewBase',
     'EnableProcessorsMixin',
-    'make_archive_vocabulary',
     'PackageCopyingMixin',
     'traverse_named_ppa',
     ]
@@ -175,6 +174,7 @@ from lp.soyuz.model.archive import (
     )
 from lp.soyuz.model.publishing import SourcePackagePublishingHistory
 from lp.soyuz.scripts.packagecopier import check_copy_permissions
+from lp.soyuz.vocabularies import make_archive_vocabulary
 
 
 class ArchiveBadges(HasBadgeBase):
@@ -1433,15 +1433,6 @@ class PackageCopyingMixin:
         return True
 
 
-def make_archive_vocabulary(archives):
-    terms = []
-    for archive in archives:
-        label = '%s [%s]' % (archive.displayname, archive.reference)
-        terms.append(SimpleTerm(archive, archive.reference, label))
-    terms.sort(key=lambda x: x.value.reference)
-    return SimpleVocabulary(terms)
-
-
 class ArchivePackageCopyingView(ArchiveSourceSelectionFormView,
                                 PackageCopyingMixin):
     """Archive package copying view class.
diff --git a/lib/lp/soyuz/interfaces/archive.py b/lib/lp/soyuz/interfaces/archive.py
index d24e502..cc8767f 100644
--- a/lib/lp/soyuz/interfaces/archive.py
+++ b/lib/lp/soyuz/interfaces/archive.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Archive interfaces."""
@@ -1513,7 +1513,7 @@ class IArchiveView(IHasBuildRecords):
                     person, to_series=None, include_binaries=False,
                     sponsored=None, unembargo=False, auto_approve=False,
                     silent=False, from_pocket=None, from_series=None,
-                    phased_update_percentage=None):
+                    phased_update_percentage=None, move=False):
         """Copy a single named source into this archive.
 
         Asynchronously copy a specific version of a named source to the
@@ -1554,6 +1554,8 @@ class IArchiveView(IHasBuildRecords):
             omitted, copy from any series with a matching version.
         :param phased_update_percentage: the phased update percentage to
             apply to the copied publication.
+        :param move: if True, delete the source publication after copying it
+            to the destination.
 
         :raises NoSuchSourcePackageName: if the source name is invalid
         :raises PocketNotFound: if the pocket name is invalid
diff --git a/lib/lp/soyuz/interfaces/packagecopyjob.py b/lib/lp/soyuz/interfaces/packagecopyjob.py
index 32ef2dc..043fff5 100644
--- a/lib/lp/soyuz/interfaces/packagecopyjob.py
+++ b/lib/lp/soyuz/interfaces/packagecopyjob.py
@@ -1,4 +1,4 @@
-# Copyright 2010-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -130,7 +130,7 @@ class IPlainPackageCopyJobSource(IJobSource):
                copy_policy=PackageCopyPolicy.INSECURE, requester=None,
                sponsored=None, unembargo=False, auto_approve=False,
                silent=False, source_distroseries=None, source_pocket=None,
-               phased_update_percentage=None):
+               phased_update_percentage=None, move=False):
         """Create a new `IPlainPackageCopyJob`.
 
         :param package_name: The name of the source package to copy.
@@ -162,6 +162,8 @@ class IPlainPackageCopyJobSource(IJobSource):
             from any pocket with a matching version.
         :param phased_update_percentage: The phased update percentage to
             apply to the copied publication.
+        :param move: If True, delete the source publication after copying it
+            to the destination.
         """
 
     def createMultiple(target_distroseries, copy_tasks, requester,
@@ -254,6 +256,9 @@ class IPlainPackageCopyJob(IRunnableJob):
     phased_update_percentage = Int(
         title=_("Phased update percentage"), required=False, readonly=True)
 
+    move = Bool(
+        title=_("Delete source after copy"), required=False, readonly=True)
+
     def addSourceOverride(override):
         """Add an `ISourceOverride` to the metadata."""
 
diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py
index 6e430ec..b70fef4 100644
--- a/lib/lp/soyuz/model/archive.py
+++ b/lib/lp/soyuz/model/archive.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Database class for table Archive."""
@@ -1818,7 +1818,7 @@ class Archive(SQLBase):
                     person, to_series=None, include_binaries=False,
                     sponsored=None, unembargo=False, auto_approve=False,
                     silent=False, from_pocket=None, from_series=None,
-                    phased_update_percentage=None):
+                    phased_update_percentage=None, move=False):
         """See `IArchive`."""
         # Asynchronously copy a package using the job system.
         from lp.soyuz.scripts.packagecopier import check_copy_permissions
@@ -1843,7 +1843,8 @@ class Archive(SQLBase):
             from_pocket=from_pocket)
         if series is None:
             series = source.distroseries
-        check_copy_permissions(person, self, series, pocket, [source])
+        check_copy_permissions(
+            person, self, series, pocket, [source], move=move)
 
         job_source = getUtility(IPlainPackageCopyJobSource)
         job_source.create(
@@ -1855,12 +1856,12 @@ class Archive(SQLBase):
             sponsored=sponsored, unembargo=unembargo,
             auto_approve=auto_approve, silent=silent,
             source_distroseries=from_series, source_pocket=from_pocket,
-            phased_update_percentage=phased_update_percentage)
+            phased_update_percentage=phased_update_percentage, move=move)
 
     def copyPackages(self, source_names, from_archive, to_pocket,
                      person, to_series=None, from_series=None,
                      include_binaries=None, sponsored=None, unembargo=False,
-                     auto_approve=False, silent=False):
+                     auto_approve=False, silent=False, move=False):
         """See `IArchive`."""
         from lp.soyuz.scripts.packagecopier import check_copy_permissions
         sources = self._collectLatestPublishedSources(
@@ -1869,7 +1870,8 @@ class Archive(SQLBase):
         # Now do a mass check of permissions.
         pocket = self._text_to_pocket(to_pocket)
         series = self._text_to_series(to_series)
-        check_copy_permissions(person, self, series, pocket, sources)
+        check_copy_permissions(
+            person, self, series, pocket, sources, move=move)
 
         # If we get this far then we can create the PackageCopyJob.
         copy_tasks = []
@@ -1888,7 +1890,8 @@ class Archive(SQLBase):
         job_source.createMultiple(
             copy_tasks, person, copy_policy=PackageCopyPolicy.MASS_SYNC,
             include_binaries=include_binaries, sponsored=sponsored,
-            unembargo=unembargo, auto_approve=auto_approve, silent=silent)
+            unembargo=unembargo, auto_approve=auto_approve, silent=silent,
+            move=move)
 
     def _collectLatestPublishedSources(self, from_archive, from_series,
                                        source_names):
diff --git a/lib/lp/soyuz/model/packagecopyjob.py b/lib/lp/soyuz/model/packagecopyjob.py
index a7f41fd..5aa5e80 100644
--- a/lib/lp/soyuz/model/packagecopyjob.py
+++ b/lib/lp/soyuz/model/packagecopyjob.py
@@ -1,4 +1,4 @@
-# Copyright 2010-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -275,7 +275,7 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
                       include_binaries, sponsored=None, unembargo=False,
                       auto_approve=False, silent=False,
                       source_distroseries=None, source_pocket=None,
-                      phased_update_percentage=None):
+                      phased_update_percentage=None, move=False):
         """Produce a metadata dict for this job."""
         return {
             'target_pocket': target_pocket.value,
@@ -289,6 +289,7 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
                 source_distroseries.name if source_distroseries else None,
             'source_pocket': source_pocket.value if source_pocket else None,
             'phased_update_percentage': phased_update_percentage,
+            'move': move,
         }
 
     @classmethod
@@ -298,14 +299,14 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
                copy_policy=PackageCopyPolicy.INSECURE, requester=None,
                sponsored=None, unembargo=False, auto_approve=False,
                silent=False, source_distroseries=None, source_pocket=None,
-               phased_update_percentage=None):
+               phased_update_percentage=None, move=False):
         """See `IPlainPackageCopyJobSource`."""
         assert package_version is not None, "No package version specified."
         assert requester is not None, "No requester specified."
         metadata = cls._makeMetadata(
             target_pocket, package_version, include_binaries, sponsored,
             unembargo, auto_approve, silent, source_distroseries,
-            source_pocket, phased_update_percentage)
+            source_pocket, phased_update_percentage, move)
         job = PackageCopyJob(
             job_type=cls.class_job_type,
             source_archive=source_archive,
@@ -323,7 +324,7 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
     @classmethod
     def _composeJobInsertionTuple(cls, copy_policy, include_binaries, job_id,
                                   copy_task, sponsored, unembargo,
-                                  auto_approve, silent):
+                                  auto_approve, silent, move):
         """Create an SQL fragment for inserting a job into the database.
 
         :return: A string representing an SQL tuple containing initializers
@@ -340,7 +341,7 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
         ) = copy_task
         metadata = cls._makeMetadata(
             target_pocket, package_version, include_binaries, sponsored,
-            unembargo, auto_approve, silent)
+            unembargo, auto_approve, silent, move=move)
         data = (
             cls.class_job_type, target_distroseries, copy_policy,
             source_archive, target_archive, package_name, job_id,
@@ -351,14 +352,15 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
     def createMultiple(cls, copy_tasks, requester,
                        copy_policy=PackageCopyPolicy.INSECURE,
                        include_binaries=False, sponsored=None,
-                       unembargo=False, auto_approve=False, silent=False):
+                       unembargo=False, auto_approve=False, silent=False,
+                       move=False):
         """See `IPlainPackageCopyJobSource`."""
         store = IMasterStore(Job)
         job_ids = Job.createMultiple(store, len(copy_tasks), requester)
         job_contents = [
             cls._composeJobInsertionTuple(
                 copy_policy, include_binaries, job_id, task, sponsored,
-                unembargo, auto_approve, silent)
+                unembargo, auto_approve, silent, move)
             for job_id, task in zip(job_ids, copy_tasks)]
         return bulk.create(
                 (PackageCopyJob.job_type, PackageCopyJob.target_distroseries,
@@ -467,6 +469,10 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
         return self.metadata.get('phased_update_percentage')
 
     @property
+    def move(self):
+        return self.metadata.get('move', False)
+
+    @property
     def requester_can_admin_target(self):
         return self.target_archive.canAdministerQueue(
             self.requester, self.getSourceOverride().component,
@@ -659,7 +665,7 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
             sponsored=self.sponsored, packageupload=pu,
             unembargo=self.unembargo,
             phased_update_percentage=self.phased_update_percentage,
-            logger=self.logger)
+            move=self.move, logger=self.logger)
 
         # Add a PackageDiff for this new upload if it has ancestry.
         if copied_publications and not ancestry.is_empty():
diff --git a/lib/lp/soyuz/scripts/packagecopier.py b/lib/lp/soyuz/scripts/packagecopier.py
index 3891af5..2e4a767 100644
--- a/lib/lp/soyuz/scripts/packagecopier.py
+++ b/lib/lp/soyuz/scripts/packagecopier.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Package copying utilities."""
@@ -15,9 +15,15 @@ __all__ = [
 
 import apt_pkg
 from lazr.delegates import delegate_to
-from zope.component import getUtility
+from zope.component import (
+    getAdapter,
+    getUtility,
+    )
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.interfaces.security import IAuthorization
+from lp.registry.interfaces.role import IPersonRoles
+from lp.registry.model.person import Person
 from lp.services.database.bulk import load_related
 from lp.soyuz.adapters.overrides import SourceOverride
 from lp.soyuz.enums import SourcePackageFormat
@@ -141,7 +147,8 @@ class CheckedCopy:
             return {'status': BuildSetStatus.NEEDSBUILD}
 
 
-def check_copy_permissions(person, archive, series, pocket, sources):
+def check_copy_permissions(person, archive, series, pocket, sources,
+                           move=False):
     """Check that `person` has permission to copy a package.
 
     :param person: User attempting the upload.
@@ -150,9 +157,12 @@ def check_copy_permissions(person, archive, series, pocket, sources):
     :param pocket: Destination `Pocket`.
     :param sources: Sequence of `SourcePackagePublishingHistory`s for the
         packages to be copied.
+    :param move: If True, also check whether `person` has permission to
+        delete the sources.
     :raises CannotCopy: If the copy is not allowed.
     """
     # Circular import.
+    from lp.soyuz.model.archive import Archive
     from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
 
     if person is None:
@@ -161,6 +171,12 @@ def check_copy_permissions(person, archive, series, pocket, sources):
     if len(sources) > 1:
         # Bulk-load the data we'll need from each source publication.
         load_related(SourcePackageRelease, sources, ["sourcepackagereleaseID"])
+        if move:
+            # Bulk-load at least some of the data we'll need for permission
+            # checks on each source archive.  Not all of this is currently
+            # preloadable.
+            archives = load_related(Archive, sources, ["archiveID"])
+            load_related(Person, archives, ["ownerID"])
 
     # If there is a requester, check that they have upload permission into
     # the destination (archive, component, pocket). This check is done
@@ -191,6 +207,29 @@ def check_copy_permissions(person, archive, series, pocket, sources):
                 person, override.component, pocket, dest_series):
                 raise CannotCopy(reason)
 
+    if move:
+        roles = IPersonRoles(person)
+        for source_archive in set(source.archive for source in sources):
+            # XXX cjwatson 2019-10-09: Checking the archive rather than the
+            # SPPH duplicates security adapter logic, which is unfortunate;
+            # but too much of the logic required to use
+            # DelegatedAuthorization-based adapters such as the one used for
+            # launchpad.Edit on SPPH lives in
+            # lp.services.webapp.authorization and is hard to use without a
+            # full Zope interaction.
+            source_archive_authz = getAdapter(
+                source_archive, IAuthorization, "launchpad.Append")
+            if not source_archive_authz.checkAuthenticated(roles):
+                raise CannotCopy(
+                    "%s is not permitted to delete publications from %s." %
+                    (person.display_name, source_archive.displayname))
+        for source in sources:
+            if not source.archive.canModifySuite(
+                    source.distroseries, source.pocket):
+                raise CannotCopy(
+                    "Cannot delete publications from suite '%s'" %
+                    source.distroseries.getSuite(source.pocket))
+
 
 class CopyChecker:
     """Check copy candiates.
@@ -388,7 +427,7 @@ class CopyChecker:
                         "different contents." % lf.libraryfile.filename)
 
     def checkCopy(self, source, series, pocket, person=None,
-                  check_permissions=True):
+                  check_permissions=True, move=False):
         """Check if the source can be copied to the given location.
 
         Check possible conflicting publications in the destination archive.
@@ -407,13 +446,15 @@ class CopyChecker:
         :param person: requester `IPerson`.
         :param check_permissions: boolean indicating whether or not the
             requester's permissions to copy should be checked.
+        :param move: if True, also check whether `person` has permission to
+            delete the source.
 
         :raise CannotCopy when a copy is not allowed to be performed
             containing the reason of the error.
         """
         if check_permissions:
             check_copy_permissions(
-                person, self.archive, series, pocket, [source])
+                person, self.archive, series, pocket, [source], move=move)
 
         if series.distribution != self.archive.distribution:
             raise CannotCopy(
@@ -483,7 +524,7 @@ def do_copy(sources, archive, series, pocket, include_binaries=False,
             send_email=False, strict_binaries=True, close_bugs=True,
             create_dsd_job=True, announce_from_person=None, sponsored=None,
             packageupload=None, unembargo=False, phased_update_percentage=None,
-            logger=None):
+            move=False, logger=None):
     """Perform the complete copy of the given sources incrementally.
 
     Verifies if each copy can be performed using `CopyChecker` and
@@ -532,6 +573,8 @@ def do_copy(sources, archive, series, pocket, include_binaries=False,
         doing so.
     :param phased_update_percentage: The phased update percentage to apply
         to the copied publication.
+    :param move: If True, delete the source publication after copying to the
+        destination.
     :param logger: An optional logger.
 
     :raise CannotCopy when one or more copies were not allowed. The error
@@ -554,7 +597,8 @@ def do_copy(sources, archive, series, pocket, include_binaries=False,
             destination_series = series
         try:
             copy_checker.checkCopy(
-                source, destination_series, pocket, person, check_permissions)
+                source, destination_series, pocket, person, check_permissions,
+                move=move)
         except CannotCopy as reason:
             errors.append("%s (%s)" % (source.displayname, reason))
             continue
@@ -610,7 +654,8 @@ def do_copy(sources, archive, series, pocket, include_binaries=False,
             override, close_bugs=close_bugs, create_dsd_job=create_dsd_job,
             close_bugs_since_version=old_version, creator=creator,
             sponsor=sponsor, packageupload=packageupload,
-            phased_update_percentage=phased_update_percentage, logger=logger)
+            phased_update_percentage=phased_update_percentage, move=move,
+            logger=logger)
         if send_email and sub_copies:
             mailer = PackageUploadMailer.forAction(
                 'accepted', person, source.sourcepackagerelease, [], [],
@@ -639,7 +684,7 @@ def _do_direct_copy(source, archive, series, pocket, include_binaries,
                     override=None, close_bugs=True, create_dsd_job=True,
                     close_bugs_since_version=None, creator=None,
                     sponsor=None, packageupload=None,
-                    phased_update_percentage=None, logger=None):
+                    phased_update_percentage=None, move=False, logger=None):
     """Copy publishing records to another location.
 
     Copy each item of the given list of `SourcePackagePublishingHistory`
@@ -671,6 +716,8 @@ def _do_direct_copy(source, archive, series, pocket, include_binaries,
         to be created.
     :param phased_update_percentage: The phased update percentage to apply
         to the copied publication.
+    :param move: If True, delete the source publication after copying to the
+        destination.
     :param logger: An optional logger.
 
     :return: a list of `ISourcePackagePublishingHistory` and
@@ -742,4 +789,11 @@ def _do_direct_copy(source, archive, series, pocket, include_binaries,
     # XXX cjwatson 2012-06-22 bug=869308: Fails to honour P-a-s.
     source_copy.createMissingBuilds(logger=logger)
 
+    if move:
+        removal_comment = "Moved to %s" % series.getSuite(pocket)
+        if archive != source.archive:
+            removal_comment += " in %s" % archive.reference
+        getUtility(IPublishingSet).requestDeletion(
+            [source], creator, removal_comment=removal_comment)
+
     return copies
diff --git a/lib/lp/soyuz/scripts/tests/test_copypackage.py b/lib/lp/soyuz/scripts/tests/test_copypackage.py
index 29987b5..c9ab490 100644
--- a/lib/lp/soyuz/scripts/tests/test_copypackage.py
+++ b/lib/lp/soyuz/scripts/tests/test_copypackage.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -1700,6 +1700,50 @@ class TestDoDirectCopy(BaseDoCopyTests, TestCaseWithFactory):
         self.assertIsInstance(copies[1], BinaryPackagePublishingHistory)
         self.assertEqual("i386", copies[1].distroarchseries.architecturetag)
 
+    def test_copy_without_move(self):
+        # A copy with move=False (the default) leaves the source publication
+        # intact.
+        nobby, archive, source = self._setup_archive()
+        target_archive = self.factory.makeArchive(
+            distribution=self.test_publisher.ubuntutest)
+        [copied_source] = do_copy(
+            [source], target_archive, nobby, source.pocket,
+            include_binaries=False, person=target_archive.owner,
+            check_permissions=False, send_email=False)
+        self.assertEqual(PackagePublishingStatus.PENDING, copied_source.status)
+        self.assertEqual(PackagePublishingStatus.PENDING, source.status)
+
+    def test_copy_with_move(self):
+        # A copy with move=True deletes the source publication.
+        nobby, archive, source = self._setup_archive()
+        target_archive = self.factory.makeArchive(
+            distribution=self.test_publisher.ubuntutest)
+        [copied_source] = do_copy(
+            [source], target_archive, nobby, source.pocket,
+            include_binaries=False, person=target_archive.owner,
+            check_permissions=False, send_email=False, move=True)
+        self.assertEqual(PackagePublishingStatus.PENDING, copied_source.status)
+        self.assertEqual(PackagePublishingStatus.DELETED, source.status)
+        self.assertEqual(
+            "Moved to %s in %s" % (
+                nobby.getSuite(source.pocket), target_archive.reference),
+            source.removal_comment)
+
+    def test_copy_with_move_failure(self):
+        # If a copy with move=True fails, then the source publication is
+        # left intact.
+        nobby, archive, source = self._setup_archive()
+        self.test_publisher.getPubSource(
+            sourcename=source.source_package_name,
+            archive=nobby.main_archive, version="1.0-2",
+            architecturehintlist="any")
+        self.assertRaises(
+            CannotCopy, do_copy,
+            [source], archive, nobby, source.pocket,
+            include_binaries=False, person=source.sourcepackagerelease.creator,
+            check_permissions=False, send_email=False, move=True)
+        self.assertEqual(PackagePublishingStatus.PENDING, source.status)
+
 
 class TestCopyBuildRecords(TestCaseWithFactory):
     """Test handling of binaries and their build records when copying."""
diff --git a/lib/lp/soyuz/tests/test_archive.py b/lib/lp/soyuz/tests/test_archive.py
index 787e114..a5b0afd 100644
--- a/lib/lp/soyuz/tests/test_archive.py
+++ b/lib/lp/soyuz/tests/test_archive.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test Archive features."""
@@ -2774,20 +2774,23 @@ class TestCopyPackage(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
 
-    def _setup_copy_data(self, source_distribution=None, source_private=False,
+    def _setup_copy_data(self, source_distribution=None, source_purpose=None,
+                         source_private=False, source_pocket=None,
                          target_purpose=None,
                          target_status=SeriesStatus.DEVELOPMENT,
                          same_distribution=False):
         if target_purpose is None:
             target_purpose = ArchivePurpose.PPA
         source_archive = self.factory.makeArchive(
-            distribution=source_distribution, private=source_private)
+            distribution=source_distribution, purpose=source_purpose,
+            private=source_private)
         target_distribution = (
             source_archive.distribution if same_distribution else None)
         target_archive = self.factory.makeArchive(
             distribution=target_distribution, purpose=target_purpose)
         source = self.factory.makeSourcePackagePublishingHistory(
-            archive=source_archive, status=PackagePublishingStatus.PUBLISHED)
+            archive=source_archive, pocket=source_pocket,
+            status=PackagePublishingStatus.PUBLISHED)
         with person_logged_in(source_archive.owner):
             source_name = source.source_package_name
             version = source.source_package_version
@@ -2830,7 +2833,8 @@ class TestCopyPackage(TestCaseWithFactory):
             include_binaries=False,
             sponsored=sponsored,
             copy_policy=PackageCopyPolicy.INSECURE,
-            phased_update_percentage=30))
+            phased_update_percentage=30,
+            move=False))
 
     def test_copyPackage_disallows_non_primary_archive_uploaders(self):
         # If copying to a primary archive and you're not an uploader for
@@ -3028,6 +3032,63 @@ class TestCopyPackage(TestCaseWithFactory):
         self.assertEqual(source.distroseries, copy_job.source_distroseries)
         self.assertEqual(source.pocket, copy_job.source_pocket)
 
+    def test_copyPackage_move(self):
+        # Passing move=True causes copyPackage to create a copy job that
+        # will delete the source publication after copying.
+        (source, source_archive, source_name, target_archive, to_pocket,
+         to_series, version) = self._setup_copy_data(
+            source_distribution=self.factory.makeDistribution())
+        with person_logged_in(target_archive.owner):
+            target_archive.newComponentUploader(source_archive.owner, "main")
+        with person_logged_in(source_archive.owner):
+            target_archive.copyPackage(
+                source_name, version, source_archive, to_pocket.name,
+                to_series=to_series.name, include_binaries=True,
+                person=source_archive.owner, move=True)
+
+        # There should be one copy job, with move=True set.
+        job_source = getUtility(IPlainPackageCopyJobSource)
+        copy_job = job_source.getActiveJobs(target_archive).one()
+        self.assertTrue(copy_job.move)
+
+    def test_copyPackage_move_without_permission(self):
+        # Passing move=True checks that the user is permitted to delete the
+        # source publication.
+        (source, source_archive, source_name, target_archive, to_pocket,
+         to_series, version) = self._setup_copy_data(
+            source_distribution=self.factory.makeDistribution())
+        with person_logged_in(target_archive.owner):
+            expected_error = (
+                "%s is not permitted to delete publications from %s." % (
+                    target_archive.owner.display_name,
+                    source_archive.displayname))
+            self.assertRaisesWithContent(
+                CannotCopy, expected_error, target_archive.copyPackage,
+                source_name, version, source_archive, to_pocket.name,
+                to_series=to_series.name, include_binaries=True,
+                person=target_archive.owner, move=True)
+
+    def test_copyPackage_move_from_immutable_suite(self):
+        # Passing move=True checks that the source suite can be modified.
+        (source, source_archive, source_name, target_archive, to_pocket,
+         to_series, version) = self._setup_copy_data(
+            source_distribution=self.factory.makeDistribution(),
+            source_purpose=ArchivePurpose.PRIMARY,
+            source_pocket=PackagePublishingPocket.RELEASE)
+        with person_logged_in(target_archive.owner):
+            target_archive.newComponentUploader(source_archive.owner, "main")
+        removeSecurityProxy(source.distroseries).status = (
+            SeriesStatus.SUPPORTED)
+        with person_logged_in(source_archive.owner):
+            expected_error = (
+                "Cannot delete publications from suite '%s'" % (
+                    source.distroseries.getSuite(source.pocket)))
+            self.assertRaisesWithContent(
+                CannotCopy, expected_error, target_archive.copyPackage,
+                source_name, version, source_archive, to_pocket.name,
+                to_series=to_series.name, include_binaries=True,
+                person=source_archive.owner, move=True)
+
     def test_copyPackages_with_single_package(self):
         (source, source_archive, source_name, target_archive, to_pocket,
          to_series, version) = self._setup_copy_data()
@@ -3056,7 +3117,8 @@ class TestCopyPackage(TestCaseWithFactory):
             target_pocket=to_pocket,
             include_binaries=False,
             sponsored=sponsored,
-            copy_policy=PackageCopyPolicy.MASS_SYNC))
+            copy_policy=PackageCopyPolicy.MASS_SYNC,
+            move=False))
 
     def test_copyPackages_with_multiple_packages(self):
         # PENDING and PUBLISHED packages should both be copied.
@@ -3273,6 +3335,63 @@ class TestCopyPackage(TestCaseWithFactory):
         copy_job = job_source.getActiveJobs(target_archive).one()
         self.assertEqual(to_pocket, copy_job.target_pocket)
 
+    def test_copyPackages_move(self):
+        # Passing move=True causes copyPackages to create copy jobs that
+        # will delete the source publication after copying.
+        (source, source_archive, source_name, target_archive, to_pocket,
+         to_series, version) = self._setup_copy_data(
+            source_distribution=self.factory.makeDistribution())
+        with person_logged_in(target_archive.owner):
+            target_archive.newComponentUploader(source_archive.owner, "main")
+        with person_logged_in(source_archive.owner):
+            target_archive.copyPackages(
+                [source_name], source_archive, to_pocket.name,
+                to_series=to_series.name, include_binaries=True,
+                person=source_archive.owner, move=True)
+
+        # There should be one copy job, with move=True set.
+        job_source = getUtility(IPlainPackageCopyJobSource)
+        copy_job = job_source.getActiveJobs(target_archive).one()
+        self.assertTrue(copy_job.move)
+
+    def test_copyPackages_move_without_permission(self):
+        # Passing move=True checks that the user is permitted to delete the
+        # source publication.
+        (source, source_archive, source_name, target_archive, to_pocket,
+         to_series, version) = self._setup_copy_data(
+            source_distribution=self.factory.makeDistribution())
+        with person_logged_in(target_archive.owner):
+            expected_error = (
+                "%s is not permitted to delete publications from %s." % (
+                    target_archive.owner.display_name,
+                    source_archive.displayname))
+            self.assertRaisesWithContent(
+                CannotCopy, expected_error, target_archive.copyPackages,
+                [source_name], source_archive, to_pocket.name,
+                to_series=to_series.name, include_binaries=True,
+                person=target_archive.owner, move=True)
+
+    def test_copyPackages_move_from_immutable_suite(self):
+        # Passing move=True checks that the source suite can be modified.
+        (source, source_archive, source_name, target_archive, to_pocket,
+         to_series, version) = self._setup_copy_data(
+            source_distribution=self.factory.makeDistribution(),
+            source_purpose=ArchivePurpose.PRIMARY,
+            source_pocket=PackagePublishingPocket.RELEASE)
+        with person_logged_in(target_archive.owner):
+            target_archive.newComponentUploader(source_archive.owner, "main")
+        removeSecurityProxy(source.distroseries).status = (
+            SeriesStatus.SUPPORTED)
+        with person_logged_in(source_archive.owner):
+            expected_error = (
+                "Cannot delete publications from suite '%s'" % (
+                    source.distroseries.getSuite(source.pocket)))
+            self.assertRaisesWithContent(
+                CannotCopy, expected_error, target_archive.copyPackages,
+                [source_name], source_archive, to_pocket.name,
+                to_series=to_series.name, include_binaries=True,
+                person=source_archive.owner, move=True)
+
 
 class TestgetAllPublishedBinaries(TestCaseWithFactory):
 
diff --git a/lib/lp/soyuz/tests/test_packagecopyjob.py b/lib/lp/soyuz/tests/test_packagecopyjob.py
index 68c1f95..6817c10 100644
--- a/lib/lp/soyuz/tests/test_packagecopyjob.py
+++ b/lib/lp/soyuz/tests/test_packagecopyjob.py
@@ -1,4 +1,4 @@
-# Copyright 2010-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for sync package jobs."""
@@ -220,7 +220,7 @@ class PlainPackageCopyJobTests(TestCaseWithFactory, LocalTestHelper):
             package_version="1.0-1", include_binaries=False,
             copy_policy=PackageCopyPolicy.MASS_SYNC,
             requester=requester, sponsored=sponsored,
-            phased_update_percentage=20)
+            phased_update_percentage=20, move=True)
         self.assertProvides(job, IPackageCopyJob)
         self.assertEqual(archive1.id, job.source_archive_id)
         self.assertEqual(archive1, job.source_archive)
@@ -230,11 +230,12 @@ class PlainPackageCopyJobTests(TestCaseWithFactory, LocalTestHelper):
         self.assertEqual(PackagePublishingPocket.RELEASE, job.target_pocket)
         self.assertEqual("foo", job.package_name)
         self.assertEqual("1.0-1", job.package_version)
-        self.assertEqual(False, job.include_binaries)
+        self.assertFalse(job.include_binaries)
         self.assertEqual(PackageCopyPolicy.MASS_SYNC, job.copy_policy)
         self.assertEqual(requester, job.requester)
         self.assertEqual(sponsored, job.sponsored)
         self.assertEqual(20, job.phased_update_percentage)
+        self.assertTrue(job.move)
 
     def test_createMultiple_creates_one_job_per_copy(self):
         mother = self.factory.makeDistroSeriesParent()
@@ -1726,6 +1727,30 @@ class PlainPackageCopyJobTests(TestCaseWithFactory, LocalTestHelper):
             1, archive.getPublishedOnDiskBinaries(
                 status=PackagePublishingStatus.PENDING).count())
 
+    def test_move(self):
+        # A job with move=True deletes the old publication after copying it.
+        source_archive = self.factory.makeArchive(
+            self.distroseries.distribution)
+        target_archive = self.factory.makeArchive(
+            self.distroseries.distribution)
+        spph = self.publisher.getPubSource(
+            distroseries=self.distroseries, sourcename="moveme",
+            archive=source_archive)
+        with person_logged_in(target_archive.owner):
+            target_archive.newComponentUploader(source_archive.owner, "main")
+        job = self.createCopyJobForSPPH(
+            spph, source_archive, target_archive,
+            requester=source_archive.owner, move=True)
+        self.runJob(job)
+        self.assertEqual(JobStatus.COMPLETED, job.status)
+        new_spph = target_archive.getPublishedSources(name="moveme").one()
+        self.assertEqual(PackagePublishingStatus.PENDING, new_spph.status)
+        self.assertEqual(PackagePublishingStatus.DELETED, spph.status)
+        self.assertEqual(
+            "Moved to %s in %s" % (
+                self.distroseries.name, target_archive.reference),
+            spph.removal_comment)
+
 
 class TestViaCelery(TestCaseWithFactory):
     """PackageCopyJob runs under Celery."""
diff --git a/lib/lp/soyuz/vocabularies.py b/lib/lp/soyuz/vocabularies.py
index b4f40b5..bcaf16e 100644
--- a/lib/lp/soyuz/vocabularies.py
+++ b/lib/lp/soyuz/vocabularies.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the GNU
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the GNU
 # Affero General Public License version 3 (see the file LICENSE).
 
 """Soyuz vocabularies."""
@@ -8,6 +8,7 @@ __metaclass__ = type
 __all__ = [
     'ComponentVocabulary',
     'FilteredDistroArchSeriesVocabulary',
+    'make_archive_vocabulary',
     'PackageReleaseVocabulary',
     'PPAVocabulary',
     ]
@@ -18,7 +19,10 @@ from storm.locals import (
     )
 from zope.component import getUtility
 from zope.interface import implementer
-from zope.schema.vocabulary import SimpleTerm
+from zope.schema.vocabulary import (
+    SimpleTerm,
+    SimpleVocabulary,
+    )
 from zope.security.interfaces import Unauthorized
 
 from lp.registry.model.distroseries import DistroSeries
@@ -150,3 +154,12 @@ class PPAVocabulary(SQLObjectVocabularyBase):
             search_clause)
         return self._table.select(
             clause, orderBy=self._orderBy, clauseTables=self._clauseTables)
+
+
+def make_archive_vocabulary(archives):
+    terms = []
+    for archive in archives:
+        label = '%s [%s]' % (archive.displayname, archive.reference)
+        terms.append(SimpleTerm(archive, archive.reference, label))
+    terms.sort(key=lambda x: x.value.reference)
+    return SimpleVocabulary(terms)