launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #24057
[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)