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

Proposed by Colin Watson on 2019-10-10
Status: Needs review
Proposed branch: ~cjwatson/launchpad:move-package
Merge into: launchpad:master
Diff against target: 832 lines (+316/-54)
11 files modified
lib/lp/code/vocabularies/sourcepackagerecipe.py (+2/-2)
lib/lp/soyuz/browser/archive.py (+2/-11)
lib/lp/soyuz/interfaces/archive.py (+4/-2)
lib/lp/soyuz/interfaces/packagecopyjob.py (+7/-2)
lib/lp/soyuz/model/archive.py (+10/-7)
lib/lp/soyuz/model/packagecopyjob.py (+15/-9)
lib/lp/soyuz/scripts/packagecopier.py (+63/-9)
lib/lp/soyuz/scripts/tests/test_copypackage.py (+45/-1)
lib/lp/soyuz/tests/test_archive.py (+125/-6)
lib/lp/soyuz/tests/test_packagecopyjob.py (+28/-3)
lib/lp/soyuz/vocabularies.py (+15/-2)
Reviewer Review Type Date Requested Status
Launchpad code reviewers 2019-10-10 Pending
Review via email: mp+373942@code.launchpad.net

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

To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/vocabularies/sourcepackagerecipe.py b/lib/lp/code/vocabularies/sourcepackagerecipe.py
2index 0c56101..31cf4bc 100644
3--- a/lib/lp/code/vocabularies/sourcepackagerecipe.py
4+++ b/lib/lp/code/vocabularies/sourcepackagerecipe.py
5@@ -1,4 +1,4 @@
6-# Copyright 2010-2018 Canonical Ltd. This software is licensed under the
7+# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
8 # GNU Affero General Public License version 3 (see the file LICENSE).
9
10 """Source Package Recipe vocabularies used in the lp/code modules."""
11@@ -22,8 +22,8 @@ from lp.services.webapp.vocabulary import (
12 IHugeVocabulary,
13 SQLObjectVocabularyBase,
14 )
15-from lp.soyuz.browser.archive import make_archive_vocabulary
16 from lp.soyuz.interfaces.archive import IArchiveSet
17+from lp.soyuz.vocabularies import make_archive_vocabulary
18
19
20 @implementer(IHugeVocabulary)
21diff --git a/lib/lp/soyuz/browser/archive.py b/lib/lp/soyuz/browser/archive.py
22index 6dd6cfe..3170ea0 100644
23--- a/lib/lp/soyuz/browser/archive.py
24+++ b/lib/lp/soyuz/browser/archive.py
25@@ -1,4 +1,4 @@
26-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
27+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
28 # GNU Affero General Public License version 3 (see the file LICENSE).
29
30 """Browser views for archive."""
31@@ -23,7 +23,6 @@ __all__ = [
32 'ArchiveView',
33 'ArchiveViewBase',
34 'EnableProcessorsMixin',
35- 'make_archive_vocabulary',
36 'PackageCopyingMixin',
37 'traverse_named_ppa',
38 ]
39@@ -175,6 +174,7 @@ from lp.soyuz.model.archive import (
40 )
41 from lp.soyuz.model.publishing import SourcePackagePublishingHistory
42 from lp.soyuz.scripts.packagecopier import check_copy_permissions
43+from lp.soyuz.vocabularies import make_archive_vocabulary
44
45
46 class ArchiveBadges(HasBadgeBase):
47@@ -1433,15 +1433,6 @@ class PackageCopyingMixin:
48 return True
49
50
51-def make_archive_vocabulary(archives):
52- terms = []
53- for archive in archives:
54- label = '%s [%s]' % (archive.displayname, archive.reference)
55- terms.append(SimpleTerm(archive, archive.reference, label))
56- terms.sort(key=lambda x: x.value.reference)
57- return SimpleVocabulary(terms)
58-
59-
60 class ArchivePackageCopyingView(ArchiveSourceSelectionFormView,
61 PackageCopyingMixin):
62 """Archive package copying view class.
63diff --git a/lib/lp/soyuz/interfaces/archive.py b/lib/lp/soyuz/interfaces/archive.py
64index d24e502..cc8767f 100644
65--- a/lib/lp/soyuz/interfaces/archive.py
66+++ b/lib/lp/soyuz/interfaces/archive.py
67@@ -1,4 +1,4 @@
68-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
69+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
70 # GNU Affero General Public License version 3 (see the file LICENSE).
71
72 """Archive interfaces."""
73@@ -1513,7 +1513,7 @@ class IArchiveView(IHasBuildRecords):
74 person, to_series=None, include_binaries=False,
75 sponsored=None, unembargo=False, auto_approve=False,
76 silent=False, from_pocket=None, from_series=None,
77- phased_update_percentage=None):
78+ phased_update_percentage=None, move=False):
79 """Copy a single named source into this archive.
80
81 Asynchronously copy a specific version of a named source to the
82@@ -1554,6 +1554,8 @@ class IArchiveView(IHasBuildRecords):
83 omitted, copy from any series with a matching version.
84 :param phased_update_percentage: the phased update percentage to
85 apply to the copied publication.
86+ :param move: if True, delete the source publication after copying it
87+ to the destination.
88
89 :raises NoSuchSourcePackageName: if the source name is invalid
90 :raises PocketNotFound: if the pocket name is invalid
91diff --git a/lib/lp/soyuz/interfaces/packagecopyjob.py b/lib/lp/soyuz/interfaces/packagecopyjob.py
92index 32ef2dc..043fff5 100644
93--- a/lib/lp/soyuz/interfaces/packagecopyjob.py
94+++ b/lib/lp/soyuz/interfaces/packagecopyjob.py
95@@ -1,4 +1,4 @@
96-# Copyright 2010-2013 Canonical Ltd. This software is licensed under the
97+# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
98 # GNU Affero General Public License version 3 (see the file LICENSE).
99
100 __metaclass__ = type
101@@ -130,7 +130,7 @@ class IPlainPackageCopyJobSource(IJobSource):
102 copy_policy=PackageCopyPolicy.INSECURE, requester=None,
103 sponsored=None, unembargo=False, auto_approve=False,
104 silent=False, source_distroseries=None, source_pocket=None,
105- phased_update_percentage=None):
106+ phased_update_percentage=None, move=False):
107 """Create a new `IPlainPackageCopyJob`.
108
109 :param package_name: The name of the source package to copy.
110@@ -162,6 +162,8 @@ class IPlainPackageCopyJobSource(IJobSource):
111 from any pocket with a matching version.
112 :param phased_update_percentage: The phased update percentage to
113 apply to the copied publication.
114+ :param move: If True, delete the source publication after copying it
115+ to the destination.
116 """
117
118 def createMultiple(target_distroseries, copy_tasks, requester,
119@@ -254,6 +256,9 @@ class IPlainPackageCopyJob(IRunnableJob):
120 phased_update_percentage = Int(
121 title=_("Phased update percentage"), required=False, readonly=True)
122
123+ move = Bool(
124+ title=_("Delete source after copy"), required=False, readonly=True)
125+
126 def addSourceOverride(override):
127 """Add an `ISourceOverride` to the metadata."""
128
129diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py
130index 6e430ec..b70fef4 100644
131--- a/lib/lp/soyuz/model/archive.py
132+++ b/lib/lp/soyuz/model/archive.py
133@@ -1,4 +1,4 @@
134-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
135+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
136 # GNU Affero General Public License version 3 (see the file LICENSE).
137
138 """Database class for table Archive."""
139@@ -1818,7 +1818,7 @@ class Archive(SQLBase):
140 person, to_series=None, include_binaries=False,
141 sponsored=None, unembargo=False, auto_approve=False,
142 silent=False, from_pocket=None, from_series=None,
143- phased_update_percentage=None):
144+ phased_update_percentage=None, move=False):
145 """See `IArchive`."""
146 # Asynchronously copy a package using the job system.
147 from lp.soyuz.scripts.packagecopier import check_copy_permissions
148@@ -1843,7 +1843,8 @@ class Archive(SQLBase):
149 from_pocket=from_pocket)
150 if series is None:
151 series = source.distroseries
152- check_copy_permissions(person, self, series, pocket, [source])
153+ check_copy_permissions(
154+ person, self, series, pocket, [source], move=move)
155
156 job_source = getUtility(IPlainPackageCopyJobSource)
157 job_source.create(
158@@ -1855,12 +1856,12 @@ class Archive(SQLBase):
159 sponsored=sponsored, unembargo=unembargo,
160 auto_approve=auto_approve, silent=silent,
161 source_distroseries=from_series, source_pocket=from_pocket,
162- phased_update_percentage=phased_update_percentage)
163+ phased_update_percentage=phased_update_percentage, move=move)
164
165 def copyPackages(self, source_names, from_archive, to_pocket,
166 person, to_series=None, from_series=None,
167 include_binaries=None, sponsored=None, unembargo=False,
168- auto_approve=False, silent=False):
169+ auto_approve=False, silent=False, move=False):
170 """See `IArchive`."""
171 from lp.soyuz.scripts.packagecopier import check_copy_permissions
172 sources = self._collectLatestPublishedSources(
173@@ -1869,7 +1870,8 @@ class Archive(SQLBase):
174 # Now do a mass check of permissions.
175 pocket = self._text_to_pocket(to_pocket)
176 series = self._text_to_series(to_series)
177- check_copy_permissions(person, self, series, pocket, sources)
178+ check_copy_permissions(
179+ person, self, series, pocket, sources, move=move)
180
181 # If we get this far then we can create the PackageCopyJob.
182 copy_tasks = []
183@@ -1888,7 +1890,8 @@ class Archive(SQLBase):
184 job_source.createMultiple(
185 copy_tasks, person, copy_policy=PackageCopyPolicy.MASS_SYNC,
186 include_binaries=include_binaries, sponsored=sponsored,
187- unembargo=unembargo, auto_approve=auto_approve, silent=silent)
188+ unembargo=unembargo, auto_approve=auto_approve, silent=silent,
189+ move=move)
190
191 def _collectLatestPublishedSources(self, from_archive, from_series,
192 source_names):
193diff --git a/lib/lp/soyuz/model/packagecopyjob.py b/lib/lp/soyuz/model/packagecopyjob.py
194index a7f41fd..5aa5e80 100644
195--- a/lib/lp/soyuz/model/packagecopyjob.py
196+++ b/lib/lp/soyuz/model/packagecopyjob.py
197@@ -1,4 +1,4 @@
198-# Copyright 2010-2016 Canonical Ltd. This software is licensed under the
199+# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
200 # GNU Affero General Public License version 3 (see the file LICENSE).
201
202 __metaclass__ = type
203@@ -275,7 +275,7 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
204 include_binaries, sponsored=None, unembargo=False,
205 auto_approve=False, silent=False,
206 source_distroseries=None, source_pocket=None,
207- phased_update_percentage=None):
208+ phased_update_percentage=None, move=False):
209 """Produce a metadata dict for this job."""
210 return {
211 'target_pocket': target_pocket.value,
212@@ -289,6 +289,7 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
213 source_distroseries.name if source_distroseries else None,
214 'source_pocket': source_pocket.value if source_pocket else None,
215 'phased_update_percentage': phased_update_percentage,
216+ 'move': move,
217 }
218
219 @classmethod
220@@ -298,14 +299,14 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
221 copy_policy=PackageCopyPolicy.INSECURE, requester=None,
222 sponsored=None, unembargo=False, auto_approve=False,
223 silent=False, source_distroseries=None, source_pocket=None,
224- phased_update_percentage=None):
225+ phased_update_percentage=None, move=False):
226 """See `IPlainPackageCopyJobSource`."""
227 assert package_version is not None, "No package version specified."
228 assert requester is not None, "No requester specified."
229 metadata = cls._makeMetadata(
230 target_pocket, package_version, include_binaries, sponsored,
231 unembargo, auto_approve, silent, source_distroseries,
232- source_pocket, phased_update_percentage)
233+ source_pocket, phased_update_percentage, move)
234 job = PackageCopyJob(
235 job_type=cls.class_job_type,
236 source_archive=source_archive,
237@@ -323,7 +324,7 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
238 @classmethod
239 def _composeJobInsertionTuple(cls, copy_policy, include_binaries, job_id,
240 copy_task, sponsored, unembargo,
241- auto_approve, silent):
242+ auto_approve, silent, move):
243 """Create an SQL fragment for inserting a job into the database.
244
245 :return: A string representing an SQL tuple containing initializers
246@@ -340,7 +341,7 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
247 ) = copy_task
248 metadata = cls._makeMetadata(
249 target_pocket, package_version, include_binaries, sponsored,
250- unembargo, auto_approve, silent)
251+ unembargo, auto_approve, silent, move=move)
252 data = (
253 cls.class_job_type, target_distroseries, copy_policy,
254 source_archive, target_archive, package_name, job_id,
255@@ -351,14 +352,15 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
256 def createMultiple(cls, copy_tasks, requester,
257 copy_policy=PackageCopyPolicy.INSECURE,
258 include_binaries=False, sponsored=None,
259- unembargo=False, auto_approve=False, silent=False):
260+ unembargo=False, auto_approve=False, silent=False,
261+ move=False):
262 """See `IPlainPackageCopyJobSource`."""
263 store = IMasterStore(Job)
264 job_ids = Job.createMultiple(store, len(copy_tasks), requester)
265 job_contents = [
266 cls._composeJobInsertionTuple(
267 copy_policy, include_binaries, job_id, task, sponsored,
268- unembargo, auto_approve, silent)
269+ unembargo, auto_approve, silent, move)
270 for job_id, task in zip(job_ids, copy_tasks)]
271 return bulk.create(
272 (PackageCopyJob.job_type, PackageCopyJob.target_distroseries,
273@@ -467,6 +469,10 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
274 return self.metadata.get('phased_update_percentage')
275
276 @property
277+ def move(self):
278+ return self.metadata.get('move', False)
279+
280+ @property
281 def requester_can_admin_target(self):
282 return self.target_archive.canAdministerQueue(
283 self.requester, self.getSourceOverride().component,
284@@ -659,7 +665,7 @@ class PlainPackageCopyJob(PackageCopyJobDerived):
285 sponsored=self.sponsored, packageupload=pu,
286 unembargo=self.unembargo,
287 phased_update_percentage=self.phased_update_percentage,
288- logger=self.logger)
289+ move=self.move, logger=self.logger)
290
291 # Add a PackageDiff for this new upload if it has ancestry.
292 if copied_publications and not ancestry.is_empty():
293diff --git a/lib/lp/soyuz/scripts/packagecopier.py b/lib/lp/soyuz/scripts/packagecopier.py
294index 3891af5..2e4a767 100644
295--- a/lib/lp/soyuz/scripts/packagecopier.py
296+++ b/lib/lp/soyuz/scripts/packagecopier.py
297@@ -1,4 +1,4 @@
298-# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
299+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
300 # GNU Affero General Public License version 3 (see the file LICENSE).
301
302 """Package copying utilities."""
303@@ -15,9 +15,15 @@ __all__ = [
304
305 import apt_pkg
306 from lazr.delegates import delegate_to
307-from zope.component import getUtility
308+from zope.component import (
309+ getAdapter,
310+ getUtility,
311+ )
312 from zope.security.proxy import removeSecurityProxy
313
314+from lp.app.interfaces.security import IAuthorization
315+from lp.registry.interfaces.role import IPersonRoles
316+from lp.registry.model.person import Person
317 from lp.services.database.bulk import load_related
318 from lp.soyuz.adapters.overrides import SourceOverride
319 from lp.soyuz.enums import SourcePackageFormat
320@@ -141,7 +147,8 @@ class CheckedCopy:
321 return {'status': BuildSetStatus.NEEDSBUILD}
322
323
324-def check_copy_permissions(person, archive, series, pocket, sources):
325+def check_copy_permissions(person, archive, series, pocket, sources,
326+ move=False):
327 """Check that `person` has permission to copy a package.
328
329 :param person: User attempting the upload.
330@@ -150,9 +157,12 @@ def check_copy_permissions(person, archive, series, pocket, sources):
331 :param pocket: Destination `Pocket`.
332 :param sources: Sequence of `SourcePackagePublishingHistory`s for the
333 packages to be copied.
334+ :param move: If True, also check whether `person` has permission to
335+ delete the sources.
336 :raises CannotCopy: If the copy is not allowed.
337 """
338 # Circular import.
339+ from lp.soyuz.model.archive import Archive
340 from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
341
342 if person is None:
343@@ -161,6 +171,12 @@ def check_copy_permissions(person, archive, series, pocket, sources):
344 if len(sources) > 1:
345 # Bulk-load the data we'll need from each source publication.
346 load_related(SourcePackageRelease, sources, ["sourcepackagereleaseID"])
347+ if move:
348+ # Bulk-load at least some of the data we'll need for permission
349+ # checks on each source archive. Not all of this is currently
350+ # preloadable.
351+ archives = load_related(Archive, sources, ["archiveID"])
352+ load_related(Person, archives, ["ownerID"])
353
354 # If there is a requester, check that they have upload permission into
355 # the destination (archive, component, pocket). This check is done
356@@ -191,6 +207,29 @@ def check_copy_permissions(person, archive, series, pocket, sources):
357 person, override.component, pocket, dest_series):
358 raise CannotCopy(reason)
359
360+ if move:
361+ roles = IPersonRoles(person)
362+ for source_archive in set(source.archive for source in sources):
363+ # XXX cjwatson 2019-10-09: Checking the archive rather than the
364+ # SPPH duplicates security adapter logic, which is unfortunate;
365+ # but too much of the logic required to use
366+ # DelegatedAuthorization-based adapters such as the one used for
367+ # launchpad.Edit on SPPH lives in
368+ # lp.services.webapp.authorization and is hard to use without a
369+ # full Zope interaction.
370+ source_archive_authz = getAdapter(
371+ source_archive, IAuthorization, "launchpad.Append")
372+ if not source_archive_authz.checkAuthenticated(roles):
373+ raise CannotCopy(
374+ "%s is not permitted to delete publications from %s." %
375+ (person.display_name, source_archive.displayname))
376+ for source in sources:
377+ if not source.archive.canModifySuite(
378+ source.distroseries, source.pocket):
379+ raise CannotCopy(
380+ "Cannot delete publications from suite '%s'" %
381+ source.distroseries.getSuite(source.pocket))
382+
383
384 class CopyChecker:
385 """Check copy candiates.
386@@ -388,7 +427,7 @@ class CopyChecker:
387 "different contents." % lf.libraryfile.filename)
388
389 def checkCopy(self, source, series, pocket, person=None,
390- check_permissions=True):
391+ check_permissions=True, move=False):
392 """Check if the source can be copied to the given location.
393
394 Check possible conflicting publications in the destination archive.
395@@ -407,13 +446,15 @@ class CopyChecker:
396 :param person: requester `IPerson`.
397 :param check_permissions: boolean indicating whether or not the
398 requester's permissions to copy should be checked.
399+ :param move: if True, also check whether `person` has permission to
400+ delete the source.
401
402 :raise CannotCopy when a copy is not allowed to be performed
403 containing the reason of the error.
404 """
405 if check_permissions:
406 check_copy_permissions(
407- person, self.archive, series, pocket, [source])
408+ person, self.archive, series, pocket, [source], move=move)
409
410 if series.distribution != self.archive.distribution:
411 raise CannotCopy(
412@@ -483,7 +524,7 @@ def do_copy(sources, archive, series, pocket, include_binaries=False,
413 send_email=False, strict_binaries=True, close_bugs=True,
414 create_dsd_job=True, announce_from_person=None, sponsored=None,
415 packageupload=None, unembargo=False, phased_update_percentage=None,
416- logger=None):
417+ move=False, logger=None):
418 """Perform the complete copy of the given sources incrementally.
419
420 Verifies if each copy can be performed using `CopyChecker` and
421@@ -532,6 +573,8 @@ def do_copy(sources, archive, series, pocket, include_binaries=False,
422 doing so.
423 :param phased_update_percentage: The phased update percentage to apply
424 to the copied publication.
425+ :param move: If True, delete the source publication after copying to the
426+ destination.
427 :param logger: An optional logger.
428
429 :raise CannotCopy when one or more copies were not allowed. The error
430@@ -554,7 +597,8 @@ def do_copy(sources, archive, series, pocket, include_binaries=False,
431 destination_series = series
432 try:
433 copy_checker.checkCopy(
434- source, destination_series, pocket, person, check_permissions)
435+ source, destination_series, pocket, person, check_permissions,
436+ move=move)
437 except CannotCopy as reason:
438 errors.append("%s (%s)" % (source.displayname, reason))
439 continue
440@@ -610,7 +654,8 @@ def do_copy(sources, archive, series, pocket, include_binaries=False,
441 override, close_bugs=close_bugs, create_dsd_job=create_dsd_job,
442 close_bugs_since_version=old_version, creator=creator,
443 sponsor=sponsor, packageupload=packageupload,
444- phased_update_percentage=phased_update_percentage, logger=logger)
445+ phased_update_percentage=phased_update_percentage, move=move,
446+ logger=logger)
447 if send_email and sub_copies:
448 mailer = PackageUploadMailer.forAction(
449 'accepted', person, source.sourcepackagerelease, [], [],
450@@ -639,7 +684,7 @@ def _do_direct_copy(source, archive, series, pocket, include_binaries,
451 override=None, close_bugs=True, create_dsd_job=True,
452 close_bugs_since_version=None, creator=None,
453 sponsor=None, packageupload=None,
454- phased_update_percentage=None, logger=None):
455+ phased_update_percentage=None, move=False, logger=None):
456 """Copy publishing records to another location.
457
458 Copy each item of the given list of `SourcePackagePublishingHistory`
459@@ -671,6 +716,8 @@ def _do_direct_copy(source, archive, series, pocket, include_binaries,
460 to be created.
461 :param phased_update_percentage: The phased update percentage to apply
462 to the copied publication.
463+ :param move: If True, delete the source publication after copying to the
464+ destination.
465 :param logger: An optional logger.
466
467 :return: a list of `ISourcePackagePublishingHistory` and
468@@ -742,4 +789,11 @@ def _do_direct_copy(source, archive, series, pocket, include_binaries,
469 # XXX cjwatson 2012-06-22 bug=869308: Fails to honour P-a-s.
470 source_copy.createMissingBuilds(logger=logger)
471
472+ if move:
473+ removal_comment = "Moved to %s" % series.getSuite(pocket)
474+ if archive != source.archive:
475+ removal_comment += " in %s" % archive.reference
476+ getUtility(IPublishingSet).requestDeletion(
477+ [source], creator, removal_comment=removal_comment)
478+
479 return copies
480diff --git a/lib/lp/soyuz/scripts/tests/test_copypackage.py b/lib/lp/soyuz/scripts/tests/test_copypackage.py
481index 29987b5..c9ab490 100644
482--- a/lib/lp/soyuz/scripts/tests/test_copypackage.py
483+++ b/lib/lp/soyuz/scripts/tests/test_copypackage.py
484@@ -1,4 +1,4 @@
485-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
486+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
487 # GNU Affero General Public License version 3 (see the file LICENSE).
488
489 __metaclass__ = type
490@@ -1700,6 +1700,50 @@ class TestDoDirectCopy(BaseDoCopyTests, TestCaseWithFactory):
491 self.assertIsInstance(copies[1], BinaryPackagePublishingHistory)
492 self.assertEqual("i386", copies[1].distroarchseries.architecturetag)
493
494+ def test_copy_without_move(self):
495+ # A copy with move=False (the default) leaves the source publication
496+ # intact.
497+ nobby, archive, source = self._setup_archive()
498+ target_archive = self.factory.makeArchive(
499+ distribution=self.test_publisher.ubuntutest)
500+ [copied_source] = do_copy(
501+ [source], target_archive, nobby, source.pocket,
502+ include_binaries=False, person=target_archive.owner,
503+ check_permissions=False, send_email=False)
504+ self.assertEqual(PackagePublishingStatus.PENDING, copied_source.status)
505+ self.assertEqual(PackagePublishingStatus.PENDING, source.status)
506+
507+ def test_copy_with_move(self):
508+ # A copy with move=True deletes the source publication.
509+ nobby, archive, source = self._setup_archive()
510+ target_archive = self.factory.makeArchive(
511+ distribution=self.test_publisher.ubuntutest)
512+ [copied_source] = do_copy(
513+ [source], target_archive, nobby, source.pocket,
514+ include_binaries=False, person=target_archive.owner,
515+ check_permissions=False, send_email=False, move=True)
516+ self.assertEqual(PackagePublishingStatus.PENDING, copied_source.status)
517+ self.assertEqual(PackagePublishingStatus.DELETED, source.status)
518+ self.assertEqual(
519+ "Moved to %s in %s" % (
520+ nobby.getSuite(source.pocket), target_archive.reference),
521+ source.removal_comment)
522+
523+ def test_copy_with_move_failure(self):
524+ # If a copy with move=True fails, then the source publication is
525+ # left intact.
526+ nobby, archive, source = self._setup_archive()
527+ self.test_publisher.getPubSource(
528+ sourcename=source.source_package_name,
529+ archive=nobby.main_archive, version="1.0-2",
530+ architecturehintlist="any")
531+ self.assertRaises(
532+ CannotCopy, do_copy,
533+ [source], archive, nobby, source.pocket,
534+ include_binaries=False, person=source.sourcepackagerelease.creator,
535+ check_permissions=False, send_email=False, move=True)
536+ self.assertEqual(PackagePublishingStatus.PENDING, source.status)
537+
538
539 class TestCopyBuildRecords(TestCaseWithFactory):
540 """Test handling of binaries and their build records when copying."""
541diff --git a/lib/lp/soyuz/tests/test_archive.py b/lib/lp/soyuz/tests/test_archive.py
542index 787e114..a5b0afd 100644
543--- a/lib/lp/soyuz/tests/test_archive.py
544+++ b/lib/lp/soyuz/tests/test_archive.py
545@@ -1,4 +1,4 @@
546-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
547+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
548 # GNU Affero General Public License version 3 (see the file LICENSE).
549
550 """Test Archive features."""
551@@ -2774,20 +2774,23 @@ class TestCopyPackage(TestCaseWithFactory):
552
553 layer = DatabaseFunctionalLayer
554
555- def _setup_copy_data(self, source_distribution=None, source_private=False,
556+ def _setup_copy_data(self, source_distribution=None, source_purpose=None,
557+ source_private=False, source_pocket=None,
558 target_purpose=None,
559 target_status=SeriesStatus.DEVELOPMENT,
560 same_distribution=False):
561 if target_purpose is None:
562 target_purpose = ArchivePurpose.PPA
563 source_archive = self.factory.makeArchive(
564- distribution=source_distribution, private=source_private)
565+ distribution=source_distribution, purpose=source_purpose,
566+ private=source_private)
567 target_distribution = (
568 source_archive.distribution if same_distribution else None)
569 target_archive = self.factory.makeArchive(
570 distribution=target_distribution, purpose=target_purpose)
571 source = self.factory.makeSourcePackagePublishingHistory(
572- archive=source_archive, status=PackagePublishingStatus.PUBLISHED)
573+ archive=source_archive, pocket=source_pocket,
574+ status=PackagePublishingStatus.PUBLISHED)
575 with person_logged_in(source_archive.owner):
576 source_name = source.source_package_name
577 version = source.source_package_version
578@@ -2830,7 +2833,8 @@ class TestCopyPackage(TestCaseWithFactory):
579 include_binaries=False,
580 sponsored=sponsored,
581 copy_policy=PackageCopyPolicy.INSECURE,
582- phased_update_percentage=30))
583+ phased_update_percentage=30,
584+ move=False))
585
586 def test_copyPackage_disallows_non_primary_archive_uploaders(self):
587 # If copying to a primary archive and you're not an uploader for
588@@ -3028,6 +3032,63 @@ class TestCopyPackage(TestCaseWithFactory):
589 self.assertEqual(source.distroseries, copy_job.source_distroseries)
590 self.assertEqual(source.pocket, copy_job.source_pocket)
591
592+ def test_copyPackage_move(self):
593+ # Passing move=True causes copyPackage to create a copy job that
594+ # will delete the source publication after copying.
595+ (source, source_archive, source_name, target_archive, to_pocket,
596+ to_series, version) = self._setup_copy_data(
597+ source_distribution=self.factory.makeDistribution())
598+ with person_logged_in(target_archive.owner):
599+ target_archive.newComponentUploader(source_archive.owner, "main")
600+ with person_logged_in(source_archive.owner):
601+ target_archive.copyPackage(
602+ source_name, version, source_archive, to_pocket.name,
603+ to_series=to_series.name, include_binaries=True,
604+ person=source_archive.owner, move=True)
605+
606+ # There should be one copy job, with move=True set.
607+ job_source = getUtility(IPlainPackageCopyJobSource)
608+ copy_job = job_source.getActiveJobs(target_archive).one()
609+ self.assertTrue(copy_job.move)
610+
611+ def test_copyPackage_move_without_permission(self):
612+ # Passing move=True checks that the user is permitted to delete the
613+ # source publication.
614+ (source, source_archive, source_name, target_archive, to_pocket,
615+ to_series, version) = self._setup_copy_data(
616+ source_distribution=self.factory.makeDistribution())
617+ with person_logged_in(target_archive.owner):
618+ expected_error = (
619+ "%s is not permitted to delete publications from %s." % (
620+ target_archive.owner.display_name,
621+ source_archive.displayname))
622+ self.assertRaisesWithContent(
623+ CannotCopy, expected_error, target_archive.copyPackage,
624+ source_name, version, source_archive, to_pocket.name,
625+ to_series=to_series.name, include_binaries=True,
626+ person=target_archive.owner, move=True)
627+
628+ def test_copyPackage_move_from_immutable_suite(self):
629+ # Passing move=True checks that the source suite can be modified.
630+ (source, source_archive, source_name, target_archive, to_pocket,
631+ to_series, version) = self._setup_copy_data(
632+ source_distribution=self.factory.makeDistribution(),
633+ source_purpose=ArchivePurpose.PRIMARY,
634+ source_pocket=PackagePublishingPocket.RELEASE)
635+ with person_logged_in(target_archive.owner):
636+ target_archive.newComponentUploader(source_archive.owner, "main")
637+ removeSecurityProxy(source.distroseries).status = (
638+ SeriesStatus.SUPPORTED)
639+ with person_logged_in(source_archive.owner):
640+ expected_error = (
641+ "Cannot delete publications from suite '%s'" % (
642+ source.distroseries.getSuite(source.pocket)))
643+ self.assertRaisesWithContent(
644+ CannotCopy, expected_error, target_archive.copyPackage,
645+ source_name, version, source_archive, to_pocket.name,
646+ to_series=to_series.name, include_binaries=True,
647+ person=source_archive.owner, move=True)
648+
649 def test_copyPackages_with_single_package(self):
650 (source, source_archive, source_name, target_archive, to_pocket,
651 to_series, version) = self._setup_copy_data()
652@@ -3056,7 +3117,8 @@ class TestCopyPackage(TestCaseWithFactory):
653 target_pocket=to_pocket,
654 include_binaries=False,
655 sponsored=sponsored,
656- copy_policy=PackageCopyPolicy.MASS_SYNC))
657+ copy_policy=PackageCopyPolicy.MASS_SYNC,
658+ move=False))
659
660 def test_copyPackages_with_multiple_packages(self):
661 # PENDING and PUBLISHED packages should both be copied.
662@@ -3273,6 +3335,63 @@ class TestCopyPackage(TestCaseWithFactory):
663 copy_job = job_source.getActiveJobs(target_archive).one()
664 self.assertEqual(to_pocket, copy_job.target_pocket)
665
666+ def test_copyPackages_move(self):
667+ # Passing move=True causes copyPackages to create copy jobs that
668+ # will delete the source publication after copying.
669+ (source, source_archive, source_name, target_archive, to_pocket,
670+ to_series, version) = self._setup_copy_data(
671+ source_distribution=self.factory.makeDistribution())
672+ with person_logged_in(target_archive.owner):
673+ target_archive.newComponentUploader(source_archive.owner, "main")
674+ with person_logged_in(source_archive.owner):
675+ target_archive.copyPackages(
676+ [source_name], source_archive, to_pocket.name,
677+ to_series=to_series.name, include_binaries=True,
678+ person=source_archive.owner, move=True)
679+
680+ # There should be one copy job, with move=True set.
681+ job_source = getUtility(IPlainPackageCopyJobSource)
682+ copy_job = job_source.getActiveJobs(target_archive).one()
683+ self.assertTrue(copy_job.move)
684+
685+ def test_copyPackages_move_without_permission(self):
686+ # Passing move=True checks that the user is permitted to delete the
687+ # source publication.
688+ (source, source_archive, source_name, target_archive, to_pocket,
689+ to_series, version) = self._setup_copy_data(
690+ source_distribution=self.factory.makeDistribution())
691+ with person_logged_in(target_archive.owner):
692+ expected_error = (
693+ "%s is not permitted to delete publications from %s." % (
694+ target_archive.owner.display_name,
695+ source_archive.displayname))
696+ self.assertRaisesWithContent(
697+ CannotCopy, expected_error, target_archive.copyPackages,
698+ [source_name], source_archive, to_pocket.name,
699+ to_series=to_series.name, include_binaries=True,
700+ person=target_archive.owner, move=True)
701+
702+ def test_copyPackages_move_from_immutable_suite(self):
703+ # Passing move=True checks that the source suite can be modified.
704+ (source, source_archive, source_name, target_archive, to_pocket,
705+ to_series, version) = self._setup_copy_data(
706+ source_distribution=self.factory.makeDistribution(),
707+ source_purpose=ArchivePurpose.PRIMARY,
708+ source_pocket=PackagePublishingPocket.RELEASE)
709+ with person_logged_in(target_archive.owner):
710+ target_archive.newComponentUploader(source_archive.owner, "main")
711+ removeSecurityProxy(source.distroseries).status = (
712+ SeriesStatus.SUPPORTED)
713+ with person_logged_in(source_archive.owner):
714+ expected_error = (
715+ "Cannot delete publications from suite '%s'" % (
716+ source.distroseries.getSuite(source.pocket)))
717+ self.assertRaisesWithContent(
718+ CannotCopy, expected_error, target_archive.copyPackages,
719+ [source_name], source_archive, to_pocket.name,
720+ to_series=to_series.name, include_binaries=True,
721+ person=source_archive.owner, move=True)
722+
723
724 class TestgetAllPublishedBinaries(TestCaseWithFactory):
725
726diff --git a/lib/lp/soyuz/tests/test_packagecopyjob.py b/lib/lp/soyuz/tests/test_packagecopyjob.py
727index 68c1f95..6817c10 100644
728--- a/lib/lp/soyuz/tests/test_packagecopyjob.py
729+++ b/lib/lp/soyuz/tests/test_packagecopyjob.py
730@@ -1,4 +1,4 @@
731-# Copyright 2010-2018 Canonical Ltd. This software is licensed under the
732+# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
733 # GNU Affero General Public License version 3 (see the file LICENSE).
734
735 """Tests for sync package jobs."""
736@@ -220,7 +220,7 @@ class PlainPackageCopyJobTests(TestCaseWithFactory, LocalTestHelper):
737 package_version="1.0-1", include_binaries=False,
738 copy_policy=PackageCopyPolicy.MASS_SYNC,
739 requester=requester, sponsored=sponsored,
740- phased_update_percentage=20)
741+ phased_update_percentage=20, move=True)
742 self.assertProvides(job, IPackageCopyJob)
743 self.assertEqual(archive1.id, job.source_archive_id)
744 self.assertEqual(archive1, job.source_archive)
745@@ -230,11 +230,12 @@ class PlainPackageCopyJobTests(TestCaseWithFactory, LocalTestHelper):
746 self.assertEqual(PackagePublishingPocket.RELEASE, job.target_pocket)
747 self.assertEqual("foo", job.package_name)
748 self.assertEqual("1.0-1", job.package_version)
749- self.assertEqual(False, job.include_binaries)
750+ self.assertFalse(job.include_binaries)
751 self.assertEqual(PackageCopyPolicy.MASS_SYNC, job.copy_policy)
752 self.assertEqual(requester, job.requester)
753 self.assertEqual(sponsored, job.sponsored)
754 self.assertEqual(20, job.phased_update_percentage)
755+ self.assertTrue(job.move)
756
757 def test_createMultiple_creates_one_job_per_copy(self):
758 mother = self.factory.makeDistroSeriesParent()
759@@ -1726,6 +1727,30 @@ class PlainPackageCopyJobTests(TestCaseWithFactory, LocalTestHelper):
760 1, archive.getPublishedOnDiskBinaries(
761 status=PackagePublishingStatus.PENDING).count())
762
763+ def test_move(self):
764+ # A job with move=True deletes the old publication after copying it.
765+ source_archive = self.factory.makeArchive(
766+ self.distroseries.distribution)
767+ target_archive = self.factory.makeArchive(
768+ self.distroseries.distribution)
769+ spph = self.publisher.getPubSource(
770+ distroseries=self.distroseries, sourcename="moveme",
771+ archive=source_archive)
772+ with person_logged_in(target_archive.owner):
773+ target_archive.newComponentUploader(source_archive.owner, "main")
774+ job = self.createCopyJobForSPPH(
775+ spph, source_archive, target_archive,
776+ requester=source_archive.owner, move=True)
777+ self.runJob(job)
778+ self.assertEqual(JobStatus.COMPLETED, job.status)
779+ new_spph = target_archive.getPublishedSources(name="moveme").one()
780+ self.assertEqual(PackagePublishingStatus.PENDING, new_spph.status)
781+ self.assertEqual(PackagePublishingStatus.DELETED, spph.status)
782+ self.assertEqual(
783+ "Moved to %s in %s" % (
784+ self.distroseries.name, target_archive.reference),
785+ spph.removal_comment)
786+
787
788 class TestViaCelery(TestCaseWithFactory):
789 """PackageCopyJob runs under Celery."""
790diff --git a/lib/lp/soyuz/vocabularies.py b/lib/lp/soyuz/vocabularies.py
791index b4f40b5..bcaf16e 100644
792--- a/lib/lp/soyuz/vocabularies.py
793+++ b/lib/lp/soyuz/vocabularies.py
794@@ -1,4 +1,4 @@
795-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the GNU
796+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the GNU
797 # Affero General Public License version 3 (see the file LICENSE).
798
799 """Soyuz vocabularies."""
800@@ -8,6 +8,7 @@ __metaclass__ = type
801 __all__ = [
802 'ComponentVocabulary',
803 'FilteredDistroArchSeriesVocabulary',
804+ 'make_archive_vocabulary',
805 'PackageReleaseVocabulary',
806 'PPAVocabulary',
807 ]
808@@ -18,7 +19,10 @@ from storm.locals import (
809 )
810 from zope.component import getUtility
811 from zope.interface import implementer
812-from zope.schema.vocabulary import SimpleTerm
813+from zope.schema.vocabulary import (
814+ SimpleTerm,
815+ SimpleVocabulary,
816+ )
817 from zope.security.interfaces import Unauthorized
818
819 from lp.registry.model.distroseries import DistroSeries
820@@ -150,3 +154,12 @@ class PPAVocabulary(SQLObjectVocabularyBase):
821 search_clause)
822 return self._table.select(
823 clause, orderBy=self._orderBy, clauseTables=self._clauseTables)
824+
825+
826+def make_archive_vocabulary(archives):
827+ terms = []
828+ for archive in archives:
829+ label = '%s [%s]' % (archive.displayname, archive.reference)
830+ terms.append(SimpleTerm(archive, archive.reference, label))
831+ terms.sort(key=lambda x: x.value.reference)
832+ return SimpleVocabulary(terms)

Subscribers

People subscribed via source and target branches