← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/bpph-phase into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/bpph-phase into lp:launchpad.

Commit message:
Implement BPPH.phased_update_percentage, published as a new Phased-Update-Percentage control field.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1100748 in Launchpad itself: "Support phased updates via Phased-Update-Percentage control field"
  https://bugs.launchpad.net/launchpad/+bug/1100748

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/bpph-phase/+merge/144154

== Summary ==

Bug 1100748: We need a way to distribute updates to subsets of our users in phases, using hints in the Packages file to smart clients such as update-manager.  We'll automatically monitor errors.ubuntu.com to detect regressions.

== Proposed fix ==

Add a phased_update_percentage column to BPPH (database patch already landed and QAed, awaiting deployment); support modifying this using changeOverride; publish it in Phased-Update-Percentage fields; show it in publishing history views.

== Implementation details ==

I needed to fiddle about a bit in archivepublisher to be able to support different Phased-Update-Percentage fields on different architectures, which seemed logical given the data model.  I could potentially have implemented correct support for differing priorities and sections across architectures too, but resisted the temptation.

== LOC Rationale ==

+83.  I have a ridiculous amount of LoC credit so hopefully this can be overlooked for now.

== Tests ==

bin/test -vvct lp.archivepublisher.tests.test_ftparchive -t lp.soyuz.tests.test_publish_archive_indexes -t lp.soyuz.tests.test_publishing -t lib/lp/soyuz/browser/tests/publishing-views.txt

== Demo and Q/A ==

Set a phased update percentage on a package on dogfood (preferably in a small pocket, i.e. non-RELEASE), run the publisher, check for appropriate Phased-Update-Percentage fields, and check https://dogfood.launchpad.net/ubuntu/DS/ARCH/PKG to see that the publishing history looks correct.
-- 
https://code.launchpad.net/~cjwatson/launchpad/bpph-phase/+merge/144154
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/bpph-phase into lp:launchpad.
=== modified file 'lib/lp/archivepublisher/model/ftparchive.py'
--- lib/lp/archivepublisher/model/ftparchive.py	2012-09-28 06:25:44 +0000
+++ lib/lp/archivepublisher/model/ftparchive.py	2013-01-21 17:01:26 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from collections import defaultdict
@@ -276,7 +276,8 @@
         """Fetch override information about all published binaries.
 
         The override information consists of tuples with 'binaryname',
-        'component', 'section' and 'priority' strings, in this order.
+        'architecture', 'component', 'section' and 'priority' strings and
+        'phased_update_percentage' integers, in this order.
 
         :param distroseries: target `IDistroSeries`
         :param pocket: target `PackagePublishingPocket`
@@ -296,6 +297,9 @@
             Join(BinaryPackageName,
                  BinaryPackageName.id ==
                      BinaryPackageRelease.binarypackagenameID),
+            Join(DistroArchSeries,
+                 DistroArchSeries.id ==
+                     BinaryPackagePublishingHistory.distroarchseriesID),
             )
 
         architectures_ids = [arch.id for arch in distroseries.architectures]
@@ -303,8 +307,10 @@
             return EmptyResultSet()
 
         result_set = store.using(*origins).find(
-            (BinaryPackageName.name, Component.name, Section.name,
-             BinaryPackagePublishingHistory.priority),
+            (BinaryPackageName.name, DistroArchSeries.architecturetag,
+             Component.name, Section.name,
+             BinaryPackagePublishingHistory.priority,
+             BinaryPackagePublishingHistory.phased_update_percentage),
             BinaryPackagePublishingHistory.archive == self.publisher.archive,
             BinaryPackagePublishingHistory.distroarchseriesID.is_in(
                 architectures_ids),
@@ -343,7 +349,8 @@
         Attributes which must be present in sourceoverrides are:
             drname, spname, cname, sname
         Attributes which must be present in binaryoverrides are:
-            drname, spname, cname, sname, priority
+            drname, spname, cname, sname, archtag, priority,
+            phased_update_percentage
 
         The binary priority will be mapped via the values in
         dbschema.py.
@@ -354,34 +361,37 @@
         # overrides[component][src/bin] = sets of tuples
         overrides = defaultdict(lambda: defaultdict(set))
 
-        def updateOverride(packagename, component, section, priority=None):
+        def updateOverride(packagename, component, section, archtag=None,
+                           priority=None, phased_update_percentage=None):
             """Generates and packs tuples of data required for overriding.
 
-            If priority is provided, it's a binary tuple; otherwise,
-            it's a source tuple.
+            If archtag is provided, it's a binary tuple; otherwise, it's a
+            source tuple.
 
-            Note that these tuples must contain /strings/, and not
-            objects, because they will be printed out verbatim into the
-            override files. This is why we use priority_displayed here,
-            and why we get the string names of the publication's foreign
-            keys to component, section, etc.
+            Note that these tuples must contain /strings/ (or integers in
+            the case of phased_update_percentage), and not objects, because
+            they will be printed out verbatim into the override files. This
+            is why we use priority_displayed here, and why we get the string
+            names of the publication's foreign keys to component, section,
+            etc.
             """
             if component != DEFAULT_COMPONENT:
                 section = "%s/%s" % (component, section)
 
             override = overrides[component]
             # We use sets in this structure to avoid generating
-            # duplicated overrides. This issue is an outcome of the fact
-            # that the PublishingHistory views select across all
-            # architectures -- and therefore we have N binaries for N
-            # archs.
-            if priority:
+            # duplicated overrides.
+            if archtag:
                 priority = priority.title.lower()
-                # We pick up debian-installer packages here
+                # We pick up debian-installer packages here, although they
+                # do not need phased updates.
                 if section.endswith("debian-installer"):
                     override['d-i'].add((packagename, priority, section))
                 else:
-                    override['bin'].add((packagename, priority, section))
+                    package_arch = "%s/%s" % (packagename, archtag)
+                    override['bin'].add((
+                        package_arch, priority, section,
+                        phased_update_percentage))
             else:
                 override['src'].add((packagename, section))
 
@@ -403,8 +413,7 @@
                 suite, component))
             self.generateOverrideForComponent(overrides, suite, component)
 
-    def generateOverrideForComponent(self, overrides, distroseries,
-                                     component):
+    def generateOverrideForComponent(self, overrides, suite, component):
         """Generates overrides for a specific component."""
         src_overrides = sorted(overrides[component]['src'])
         bin_overrides = sorted(overrides[component]['bin'])
@@ -412,43 +421,50 @@
 
         # Set up filepaths for the overrides we read
         extra_extra_overrides = os.path.join(self._config.miscroot,
-            "more-extra.override.%s.main" % distroseries)
+            "more-extra.override.%s.main" % suite)
         if not os.path.exists(extra_extra_overrides):
-            unpocketed_series = "-".join(distroseries.split('-')[:-1])
+            unpocketed_series = "-".join(suite.split('-')[:-1])
             extra_extra_overrides = os.path.join(self._config.miscroot,
                 "more-extra.override.%s.main" % unpocketed_series)
         # And for the overrides we write out
         main_override = os.path.join(self._config.overrideroot,
-                                     "override.%s.%s" %
-                                     (distroseries, component))
+                                     "override.%s.%s" % (suite, component))
         ef_override = os.path.join(self._config.overrideroot,
-                                   "override.%s.extra.%s" %
-                                   (distroseries, component))
+                                   "override.%s.extra.%s" % (suite, component))
         di_override = os.path.join(self._config.overrideroot,
                                    "override.%s.%s.debian-installer" %
-                                   (distroseries, component))
+                                   (suite, component))
         source_override = os.path.join(self._config.overrideroot,
                                        "override.%s.%s.src" %
-                                       (distroseries, component))
+                                       (suite, component))
 
         # Start to write the files out
         ef = open(ef_override, "w")
         f = open(main_override, "w")
-        for package, priority, section in bin_overrides:
-            origin = "\t".join([package, "Origin", "Ubuntu"])
-            bugs = "\t".join([package, "Bugs",
-                        "https://bugs.launchpad.net/ubuntu/+filebug";])
+        basic_override_seen = set()
+        for (package_arch, priority, section,
+             phased_update_percentage) in bin_overrides:
+            package = package_arch.split("/")[0]
+            if package not in basic_override_seen:
+                basic_override_seen.add(package)
+                f.write("\t".join((package, priority, section)))
+                f.write("\n")
 
-            f.write("\t".join((package, priority, section)))
-            f.write("\n")
-            # XXX: dsilvers 2006-08-23 bug=3900:
-            # This needs to be made databaseish and be actually managed within
-            # Launchpad. (Or else we need to change the ubuntu as appropriate
-            # and look for bugs addresses etc in launchpad.
-            ef.write(origin)
-            ef.write("\n")
-            ef.write(bugs)
-            ef.write("\n")
+                # XXX: dsilvers 2006-08-23 bug=3900:
+                # This needs to be made databaseish and be actually managed
+                # within Launchpad.  (Or else we need to change Ubuntu as
+                # appropriate and look for bugs addresses etc in Launchpad.)
+                ef.write("\t".join([package, "Origin", "Ubuntu"]))
+                ef.write("\n")
+                ef.write("\t".join([
+                    package, "Bugs",
+                    "https://bugs.launchpad.net/ubuntu/+filebug";]))
+                ef.write("\n")
+            if phased_update_percentage is not None:
+                ef.write("\t".join([
+                    package_arch, "Phased-Update-Percentage",
+                    str(phased_update_percentage)]))
+                ef.write("\n")
         f.close()
 
         if os.path.exists(extra_extra_overrides):

=== modified file 'lib/lp/archivepublisher/tests/test_ftparchive.py'
--- lib/lp/archivepublisher/tests/test_ftparchive.py	2012-08-20 09:23:49 +0000
+++ lib/lp/archivepublisher/tests/test_ftparchive.py	2013-01-21 17:01:26 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for ftparchive.py"""
@@ -137,10 +137,12 @@
             self._logger, self._config, self._dp, self._distribution,
             self._publisher)
 
-    def _publishDefaultOverrides(self, fa, component):
+    def _publishDefaultOverrides(self, fa, component,
+                                 phased_update_percentage=None):
         source_overrides = FakeSelectResult([('foo', component, 'misc')])
-        binary_overrides = FakeSelectResult(
-            [('foo', component, 'misc', PackagePublishingPriority.EXTRA)])
+        binary_overrides = FakeSelectResult([(
+            'foo', component, 'misc', 'i386', PackagePublishingPriority.EXTRA,
+            phased_update_percentage)])
         fa.publishOverrides('hoary-test', source_overrides, binary_overrides)
 
     def _publishDefaultFileLists(self, fa, component):
@@ -188,9 +190,10 @@
         published_binaries = fa.getBinariesForOverrides(
             hoary, PackagePublishingPocket.RELEASE)
         expectedBinaries = [
-            ('pmount', 'main', 'base', PackagePublishingPriority.EXTRA),
-            ('pmount', 'universe', 'editors',
-             PackagePublishingPriority.IMPORTANT),
+            ('pmount', 'hppa', 'main', 'base',
+             PackagePublishingPriority.EXTRA, None),
+            ('pmount', 'i386', 'universe', 'editors',
+             PackagePublishingPriority.IMPORTANT, None),
             ]
         self.assertEqual(expectedBinaries, list(published_binaries))
 
@@ -233,6 +236,18 @@
         with open(result_path) as result_file:
             self.assertIn("\t".join(sentinel), result_file.read().splitlines())
 
+    def test_publishOverrides_phase(self):
+        # Publications with a non-None phased update percentage produce
+        # Phased-Update-Percentage extra overrides.
+        fa = self._setUpFTPArchiveHandler()
+        self._publishDefaultOverrides(fa, 'main', phased_update_percentage=50)
+
+        path = os.path.join(self._overdir, "override.hoary-test.extra.main")
+        with open(path) as result_file:
+            self.assertIn(
+                "foo/i386\tPhased-Update-Percentage\t50",
+                result_file.read().splitlines())
+
     def test_getSourceFiles(self):
         # getSourceFiles returns a list of tuples containing:
         # (sourcename, filename, component)

=== removed file 'lib/lp/soyuz/browser/publishedpackage.py'
--- lib/lp/soyuz/browser/publishedpackage.py	2009-06-25 04:06:00 +0000
+++ lib/lp/soyuz/browser/publishedpackage.py	1970-01-01 00:00:00 +0000
@@ -1,49 +0,0 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-__metaclass__ = type
-
-__all__ = [
-    'PkgBuild',
-    'PkgVersion',
-    'DistroSeriesVersions',
-    'BinPackage',
-    ]
-
-class PkgBuild:
-
-    def __init__(self, id, processorfamilyname,
-                 distroarchseries):
-        self.id = id
-        self.processorfamilyname = processorfamilyname
-        self.distroarchseries = distroarchseries
-
-    def html(self):
-        return (
-            '<a href="/soyuz/packages/%s">%s</a>'
-            % (self.id, self.processorfamilyname))
-
-class PkgVersion:
-
-    def __init__(self, version):
-        self.version = version
-        self.builds = []
-
-    def buildlisthtml(self):
-        return ', '.join([ build.html() for build in self.builds ])
-
-class DistroSeriesVersions:
-
-    def __init__(self, distroseriesname):
-        self.distroseriesname = distroseriesname
-        self.versions = {}
-
-class BinPackage:
-
-    def __init__(self, name, summary, description):
-        self.name = name
-        self.summary = summary
-        self.description = description
-        self.distroseriess = {}
-
-

=== modified file 'lib/lp/soyuz/browser/publishing.py'
--- lib/lp/soyuz/browser/publishing.py	2012-11-26 08:40:20 +0000
+++ lib/lp/soyuz/browser/publishing.py	2013-01-21 17:01:26 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Browser views for Soyuz publishing records."""
@@ -190,6 +190,14 @@
 
         return removal_comment
 
+    @property
+    def phased_update_percentage(self):
+        """Return the formatted phased update percentage, or empty."""
+        if (self.is_binary and
+            self.context.phased_update_percentage is not None):
+            return u"%d%% of users" % self.context.phased_update_percentage
+        return u""
+
 
 class SourcePublishingRecordView(BasePublishingRecordView):
     """View class for `ISourcePackagePublishingHistory`."""

=== modified file 'lib/lp/soyuz/browser/tests/publishing-views.txt'
--- lib/lp/soyuz/browser/tests/publishing-views.txt	2012-09-27 02:53:00 +0000
+++ lib/lp/soyuz/browser/tests/publishing-views.txt	2013-01-21 17:01:26 +0000
@@ -1,4 +1,6 @@
-= Source Package Publishing Views =
+===============================
+Source Package Publishing Views
+===============================
 
 The default view for SourcePackagePublishingHistory offers a
 convenience property that can be used to display files that are
@@ -6,8 +8,6 @@
 files. The property returns a sorted list of dictionaries with URLs,
 filenames and filesizes.
 
-    >>> from zope.component import getMultiAdapter
-    >>> from lp.services.webapp.servers import LaunchpadTestRequest
     >>> from lp.testing import celebrity_logged_in
 
 We'll create SourcePackagePublishingHistory entries for
@@ -30,8 +30,7 @@
 If the publishing record does not include a removal comment, then
 the view property returns 'None provided.'
 
-    >>> view = getMultiAdapter(
-    ...    (foo_pub, LaunchpadTestRequest()), name="+listing-compact")
+    >>> view = create_initialized_view(foo_pub, "+listing-compact")
     >>> view.wasDeleted()
     True
     >>> print view.context.removal_comment
@@ -61,9 +60,7 @@
 
 for each file related with the alsa-utils source publication in ubuntu.
 
-    >>> view = getMultiAdapter(
-    ...    (alsa_pub, LaunchpadTestRequest()),
-    ...    name="+listing-archive-detailed")
+    >>> view = create_initialized_view(alsa_pub, "+listing-archive-detailed")
 
     >>> view.published_source_and_binary_files
     [{'url': u'http://launchpad.dev/ubuntutest/+archive/primary/+files/alsa-utils-test_666.dsc',
@@ -80,9 +77,8 @@
     >>> iceweasel_source_pub = cprov.archive.getPublishedSources(
     ...     u'iceweasel').first()
 
-    >>> ppa_source_view = getMultiAdapter(
-    ...     (iceweasel_source_pub, LaunchpadTestRequest()),
-    ...     name="+listing-archive-detailed")
+    >>> ppa_source_view = create_initialized_view(
+    ...     iceweasel_source_pub, "+listing-archive-detailed")
 
     >>> ppa_source_view.published_source_and_binary_files
     [{'url': u'http://launchpad.dev/~cprov/+archive/ppa/+files/firefox_0.9.2.orig.tar.gz',
@@ -104,9 +100,8 @@
 
 Continuing to use the 'iceweasel' source publication in Celso's PPA.
 
-    >>> source_details_view = getMultiAdapter(
-    ...     (iceweasel_source_pub, LaunchpadTestRequest()),
-    ...     name="+record-details")
+    >>> source_details_view = create_initialized_view(
+    ...     iceweasel_source_pub, "+record-details")
 
 We probe the 'is_source' and 'is_binary' properties.
 
@@ -122,9 +117,8 @@
 
     >>> iceweasel_binary_pub = iceweasel_source_pub.getPublishedBinaries()[0]
 
-    >>> binary_details_view = getMultiAdapter(
-    ...     (iceweasel_binary_pub, LaunchpadTestRequest()),
-    ...     name="+record-details")
+    >>> binary_details_view = create_initialized_view(
+    ...     iceweasel_binary_pub, "+record-details")
 
     >>> print binary_details_view.is_source
     False
@@ -157,8 +151,34 @@
     ...
     KeyError: 'key_not_there'
 
-
-== SourcePublishingRecordView ==
+The view knows how to render a publication's phased update percentage.
+
+    >>> print binary_details_view.phased_update_percentage
+
+    >>> login('celso.providelo@xxxxxxxxxxxxx')
+    >>> iceweasel_binary_pub_phased = iceweasel_binary_pub.changeOverride(
+    ...     new_phased_update_percentage=50)
+    >>> binary_details_view = create_initialized_view(
+    ...     iceweasel_binary_pub_phased, "+record-details")
+    >>> print binary_details_view.phased_update_percentage
+    50% of users
+
+BinaryPackagePublishingHistory:+listing-summary is included in
+DistroArchSeriesBinaryPackage:+index, showing a summary of each publication.
+It handles phased update percentages correctly.
+
+    >>> binary_summary_view = create_initialized_view(
+    ...     iceweasel_binary_pub, "+listing-summary")
+    >>> print binary_summary_view.phased_update_percentage
+
+    >>> binary_summary_view_phased = create_initialized_view(
+    ...     iceweasel_binary_pub_phased, "+listing-summary")
+    >>> print binary_summary_view_phased.phased_update_percentage
+    50% of users
+
+
+SourcePublishingRecordView
+==========================
 
 The SourcePublishingRecordView includes a build_status_summary property
 that returns a dict summary of the build status for the context record:
@@ -200,4 +220,3 @@
     >>> for build in src_pub_record_view.pending_builds:
     ...     print build.title
     i386 build of iceweasel 1.0 in ubuntu warty RELEASE
-

=== modified file 'lib/lp/soyuz/interfaces/distroarchseriesbinarypackagerelease.py'
--- lib/lp/soyuz/interfaces/distroarchseriesbinarypackagerelease.py	2013-01-07 02:40:55 +0000
+++ lib/lp/soyuz/interfaces/distroarchseriesbinarypackagerelease.py	2013-01-21 17:01:26 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Binary package release in Distribution Architecture Release interfaces."""
@@ -52,6 +52,11 @@
     component = Attribute("The component in which this package is "
         "published or None if it is not currently published.")
 
+    phased_update_percentage = Attribute(
+        "The percentage of users for whom this package should be recommended, "
+        "or None to publish the update for everyone or if it is not currently "
+        "published.")
+
     publishing_history = Attribute("Return a list of publishing "
         "records for this binary package release in this series "
         "and this architecture, of the distribution.")

=== modified file 'lib/lp/soyuz/interfaces/publishing.py'
--- lib/lp/soyuz/interfaces/publishing.py	2013-01-07 02:40:55 +0000
+++ lib/lp/soyuz/interfaces/publishing.py	2013-01-21 17:01:26 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Publishing interfaces."""
@@ -784,6 +784,12 @@
             title=_('The priority being published into'),
             required=False, readonly=False,
             )
+    phased_update_percentage = exported(
+        Int(
+            title=_('The percentage of users for whom this package should be '
+                    'recommended, or None to publish the update for everyone'),
+            required=False, readonly=True,
+            ))
     datepublished = exported(
         Datetime(
             title=_("Date Published"),
@@ -961,15 +967,21 @@
         # save manually looking up the priority name, but it doesn't work in
         # this case: the title is wrong, and tests fail when a string value
         # is passed over the webservice.
-        new_priority=TextLine(title=u"The new priority name."))
+        new_priority=TextLine(title=u"The new priority name."),
+        new_phased_update_percentage=Int(
+            title=u"The new phased update percentage."))
     @export_write_operation()
     @operation_for_version("devel")
     def changeOverride(new_component=None, new_section=None,
-                       new_priority=None):
-        """Change the component, section and/or priority of this publication.
+                       new_priority=None, new_phased_update_percentage=None):
+        """Change the component/section/priority/phase of this publication.
 
         It is changed only if the argument is not None.
 
+        Passing new_phased_update_percentage=100 has the effect of setting
+        the phased update percentage to None (i.e. recommended for all
+        users).
+
         Return the overridden publishing record, a
         `IBinaryPackagePublishingHistory`.
         """

=== modified file 'lib/lp/soyuz/model/distroarchseriesbinarypackagerelease.py'
--- lib/lp/soyuz/model/distroarchseriesbinarypackagerelease.py	2013-01-07 02:40:55 +0000
+++ lib/lp/soyuz/model/distroarchseriesbinarypackagerelease.py	2013-01-21 17:01:26 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Classes to represent binary package releases in a
@@ -156,6 +156,14 @@
             return None
         return pub.priority
 
+    @property
+    def phased_update_percentage(self):
+        """See `IDistroArchSeriesBinaryPackageRelease`."""
+        pub = self._latest_publishing_record()
+        if pub is None:
+            return None
+        return pub.phased_update_percentage
+
     # map the BinaryPackageRelease attributes up to this class so it
     # responds to the same interface
 

=== modified file 'lib/lp/soyuz/model/publishing.py'
--- lib/lp/soyuz/model/publishing.py	2013-01-03 00:16:08 +0000
+++ lib/lp/soyuz/model/publishing.py	2013-01-21 17:01:26 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -25,6 +25,7 @@
 import pytz
 from sqlobject import (
     ForeignKey,
+    IntCol,
     StringCol,
     )
 from storm.expr import (
@@ -955,6 +956,8 @@
     section = ForeignKey(foreignKey='Section', dbName='section')
     priority = EnumCol(dbName='priority', schema=PackagePublishingPriority)
     status = EnumCol(dbName='status', schema=PackagePublishingStatus)
+    phased_update_percentage = IntCol(
+        dbName='phased_update_percentage', notNull=False, default=None)
     scheduleddeletiondate = UtcDateTimeCol(default=None)
     datepublished = UtcDateTimeCol(default=None)
     datecreated = UtcDateTimeCol(default=UTC_NOW)
@@ -1095,6 +1098,8 @@
         fields.append('Size', bin_size)
         fields.append('MD5sum', bin_md5)
         fields.append('SHA1', bin_sha1)
+        fields.append(
+            'Phased-Update-Percentage', self.phased_update_percentage)
         fields.append('Description', bin_description)
         if bpr.user_defined_fields:
             fields.extend(bpr.user_defined_fields)
@@ -1207,14 +1212,15 @@
                 dominated.supersede(dominant, logger)
 
     def changeOverride(self, new_component=None, new_section=None,
-                       new_priority=None):
+                       new_priority=None, new_phased_update_percentage=None):
         """See `IBinaryPackagePublishingHistory`."""
 
         # Check we have been asked to do something
         if (new_component is None and new_section is None
-            and new_priority is None):
-            raise AssertionError("changeOverride must be passed a new"
-                                 "component, section and/or priority.")
+            and new_priority is None and new_phased_update_percentage is None):
+            raise AssertionError("changeOverride must be passed a new "
+                                 "component, section, priority and/or "
+                                 "phased_update_percentage.")
 
         # Check there is a change to make
         if new_component is None:
@@ -1229,10 +1235,20 @@
             new_priority = self.priority
         elif isinstance(new_priority, basestring):
             new_priority = name_priority_map[new_priority]
+        if new_phased_update_percentage is None:
+            new_phased_update_percentage = self.phased_update_percentage
+        elif (new_phased_update_percentage < 0 or
+              new_phased_update_percentage > 100):
+            raise ValueError(
+                "new_phased_update_percentage must be between 0 and 100 "
+                "(inclusive).")
+        elif new_phased_update_percentage == 100:
+            new_phased_update_percentage = None
 
         if (new_component == self.component and
             new_section == self.section and
-            new_priority == self.priority):
+            new_priority == self.priority and
+            new_phased_update_percentage == self.phased_update_percentage):
             return
 
         if new_component != self.component:
@@ -1264,7 +1280,8 @@
             component=new_component,
             section=new_section,
             priority=new_priority,
-            archive=self.archive)
+            archive=self.archive,
+            phased_update_percentage=new_phased_update_percentage)
 
     def copyTo(self, distroseries, pocket, archive):
         """See `BinaryPackagePublishingHistory`."""

=== modified file 'lib/lp/soyuz/templates/distroarchseriesbinarypackage-index.pt'
--- lib/lp/soyuz/templates/distroarchseriesbinarypackage-index.pt	2009-09-03 15:17:24 +0000
+++ lib/lp/soyuz/templates/distroarchseriesbinarypackage-index.pt	2013-01-21 17:01:26 +0000
@@ -33,6 +33,8 @@
            <th>Pocket</th>
            <th>Component</th>
            <th>Section</th>
+           <th>Priority</th>
+           <th>Phased updates</th>
            <th>Version</th>
          </tr>
        </thead>

=== modified file 'lib/lp/soyuz/templates/distroarchseriesbinarypackagerelease-portlet-details.pt'
--- lib/lp/soyuz/templates/distroarchseriesbinarypackagerelease-portlet-details.pt	2009-09-02 11:56:16 +0000
+++ lib/lp/soyuz/templates/distroarchseriesbinarypackagerelease-portlet-details.pt	2013-01-21 17:01:26 +0000
@@ -37,6 +37,12 @@
     <dd tal:content="context/priority/title" />
   </dl>
 
+  <dl tal:define="phased_update_percentage view/phased_update_percentage"
+      tal:condition="phased_update_percentage">
+    <dt>Phased update:</dt>
+    <dd tal:content="phased_update_percentage">50% of users</dd>
+  </dl>
+
   </div>
 
 </div>

=== modified file 'lib/lp/soyuz/templates/publishinghistory-macros.pt'
--- lib/lp/soyuz/templates/publishinghistory-macros.pt	2011-07-18 09:23:10 +0000
+++ lib/lp/soyuz/templates/publishinghistory-macros.pt	2013-01-21 17:01:26 +0000
@@ -24,6 +24,10 @@
     <td tal:content="context/pocket/title/fmt:lower">Release</td>
     <td tal:content="context/component/name">main</td>
     <td tal:content="context/section/name">web</td>
+    <tal:binary_history condition="view/is_binary">
+      <td tal:content="context/priority/title">Optional</td>
+      <td tal:content="view/phased_update_percentage">50% of users</td>
+    </tal:binary_history>
     <td>
       <a tal:content="version_name"
          tal:attributes="href version_url"

=== modified file 'lib/lp/soyuz/tests/test_publish_archive_indexes.py'
--- lib/lp/soyuz/tests/test_publish_archive_indexes.py	2011-12-14 12:30:28 +0000
+++ lib/lp/soyuz/tests/test_publish_archive_indexes.py	2013-01-21 17:01:26 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2010 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test native archive index generation for Soyuz."""
@@ -95,7 +95,8 @@
         pub_binaries = self.getPubBinaries(
             depends='biscuit', recommends='foo-dev', suggests='pyfoo',
             conflicts='old-foo', replaces='old-foo', provides='foo-master',
-            pre_depends='master-foo', enhances='foo-super', breaks='old-foo')
+            pre_depends='master-foo', enhances='foo-super', breaks='old-foo',
+            phased_update_percentage=50)
         pub_binary = pub_binaries[0]
         self.assertEqual(
             [u'Package: foo-bin',
@@ -119,6 +120,7 @@
              u'Size: 18',
              u'MD5sum: 008409e7feb1c24a6ccab9f6a62d24c5',
              u'SHA1: 30b7b4e583fa380772c5a40e428434628faef8cf',
+             u'Phased-Update-Percentage: 50',
              u'Description: Foo app is great',
              u' Well ...',
              u' it does nothing, though'],

=== modified file 'lib/lp/soyuz/tests/test_publishing.py'
--- lib/lp/soyuz/tests/test_publishing.py	2012-11-15 16:48:31 +0000
+++ lib/lp/soyuz/tests/test_publishing.py	2013-01-21 17:01:26 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test native publication workflow for Soyuz. """
@@ -309,6 +309,7 @@
                        architecturespecific=False,
                        builder=None,
                        component='main',
+                       phased_update_percentage=None,
                        with_debug=False, user_defined_fields=None):
         """Return a list of binary publishing records."""
         if distroseries is None:
@@ -345,7 +346,8 @@
                     breaks, BinaryPackageFormat.DDEB, version=version)
                 pub_binaries += self.publishBinaryInArchive(
                     binarypackagerelease_ddeb, archive.debug_archive, status,
-                    pocket, scheduleddeletiondate, dateremoved)
+                    pocket, scheduleddeletiondate, dateremoved,
+                    phased_update_percentage)
             else:
                 binarypackagerelease_ddeb = None
 
@@ -357,7 +359,7 @@
                 user_defined_fields=user_defined_fields)
             pub_binaries += self.publishBinaryInArchive(
                 binarypackagerelease, archive, status, pocket,
-                scheduleddeletiondate, dateremoved)
+                scheduleddeletiondate, dateremoved, phased_update_percentage)
             published_binaries.extend(pub_binaries)
             package_upload = self.addPackageUpload(
                 archive, distroseries, pocket,
@@ -450,7 +452,8 @@
         self, binarypackagerelease, archive,
         status=PackagePublishingStatus.PENDING,
         pocket=PackagePublishingPocket.RELEASE,
-        scheduleddeletiondate=None, dateremoved=None):
+        scheduleddeletiondate=None, dateremoved=None,
+        phased_update_percentage=None):
         """Return the corresponding BinaryPackagePublishingHistory."""
         distroarchseries = binarypackagerelease.build.distro_arch_series
 
@@ -474,7 +477,8 @@
                 dateremoved=dateremoved,
                 datecreated=UTC_NOW,
                 pocket=pocket,
-                archive=archive)
+                archive=archive,
+                phased_update_percentage=phased_update_percentage)
             if status == PackagePublishingStatus.PUBLISHED:
                 pub.datepublished = UTC_NOW
             pub_binaries.append(pub)
@@ -1689,6 +1693,11 @@
         if "new_priority" in kwargs:
             self.assertEqual(
                 kwargs["new_priority"], new_pub.priority.name.lower())
+        if "new_phased_update_percentage" in kwargs:
+            self.assertEqual(
+                kwargs["new_phased_update_percentage"],
+                new_pub.phased_update_percentage)
+        return new_pub
 
     def assertCannotOverride(self, **kwargs):
         self.assertRaises(OverrideError, self.setUpOverride, **kwargs)
@@ -1701,7 +1710,16 @@
         # BPPH.changeOverride changes the properties of binary publications.
         self.assertCanOverride(
             binary=True,
-            new_component="universe", new_section="misc", new_priority="extra")
+            new_component="universe", new_section="misc", new_priority="extra",
+            new_phased_update_percentage=90)
+
+    def test_set_and_clear_phased_update_percentage(self):
+        # new_phased_update_percentage=<integer> sets a phased update
+        # percentage; new_phased_update_percentage=100 clears it.
+        pub = self.assertCanOverride(
+            binary=True, new_phased_update_percentage=50)
+        new_pub = pub.changeOverride(new_phased_update_percentage=100)
+        self.assertIsNone(new_pub.phased_update_percentage)
 
     def test_no_change(self):
         # changeOverride does not create a new publication if the existing