← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ines-almeida/launchpad:pro-enable-core18/update-models into launchpad:master

 

Ines Almeida has proposed merging ~ines-almeida/launchpad:pro-enable-core18/update-models into launchpad:master.

Commit message:
Add new pro_enable attribute to Snap models, plus DB backfilling logic

This new bool will later determine whether a snap can use private dependencies.
Initially this attribute is NULL in the database, and will be backfilled daily, a chunk at a time. As such, we add both `_pro_enable` (the actual DB value) and `pro_enable` as a model property that determines the `pro_enable` value if one isn't set in the DB yet.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ines-almeida/launchpad/+git/launchpad/+merge/453932

For more context, see: https://docs.google.com/document/d/19juEP2pOsww4t9Z-jtVZbJdUHgZqkmE9mRukGBQ8DHk

This MP adds the `pro_enable` attribute to the model, and its DB backfilling logic.
What it doesn't add:
 - Browser interface to see or update this value
 - Any logic changes based on this value

This should be able to be merged and start back filling the DB, even if the value isn't at all used yet.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ines-almeida/launchpad:pro-enable-core18/update-models into launchpad:master.
diff --git a/lib/lp/scripts/garbo.py b/lib/lp/scripts/garbo.py
index 06da799..e08accd 100644
--- a/lib/lp/scripts/garbo.py
+++ b/lib/lp/scripts/garbo.py
@@ -116,6 +116,8 @@ from lp.services.verification.model.logintoken import LoginToken
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webhooks.interfaces import IWebhookJobSource
 from lp.services.webhooks.model import WebhookJob
+from lp.snappy.interfaces.snap import ISnapSet
+from lp.snappy.model.snap import Snap
 from lp.snappy.model.snapbuild import SnapFile
 from lp.snappy.model.snapbuildjob import SnapBuildJobType
 from lp.soyuz.enums import (
@@ -2259,6 +2261,35 @@ class ArchiveFileDatePopulator(TunableLoop):
         transaction.commit()
 
 
+class SnapProEnablePopulator(TunableLoop):
+    """Populates Snap.pro_enable."""
+
+    maximum_chunk_size = 100
+
+    def __init__(self, log, abort_time=None):
+        super().__init__(log, abort_time)
+        self.start_at = 1
+        self.store = IPrimaryStore(Snap)
+
+    def findSnaps(self):
+        snaps = self.store.find(
+            Snap,
+            Snap.id >= self.start_at,
+            Snap._pro_enable == None,
+        )
+        return snaps.order_by(Snap.id)
+
+    def isDone(self):
+        return self.findSnaps().is_empty()
+
+    def __call__(self, chunk_size):
+        snaps = list(self.findSnaps()[:chunk_size])
+        for snap in snaps:
+            snap._pro_enable = getUtility(ISnapSet).inferProEnable(snap.source)
+        self.start_at = snaps[-1].id + 1
+        transaction.commit()
+
+
 class BaseDatabaseGarbageCollector(LaunchpadCronScript):
     """Abstract base class to run a collection of TunableLoops."""
 
@@ -2595,6 +2626,7 @@ class DailyDatabaseGarbageCollector(BaseDatabaseGarbageCollector):
         ScrubPOFileTranslator,
         SnapBuildJobPruner,
         SnapFilePruner,
+        SnapProEnablePopulator,
         SourcePackagePublishingHistoryFormatPopulator,
         SuggestiveTemplatesCacheUpdater,
         TeamMembershipPruner,
diff --git a/lib/lp/scripts/tests/test_garbo.py b/lib/lp/scripts/tests/test_garbo.py
index 3599ab1..c2138b4 100644
--- a/lib/lp/scripts/tests/test_garbo.py
+++ b/lib/lp/scripts/tests/test_garbo.py
@@ -60,6 +60,7 @@ from lp.code.model.codeimportresult import CodeImportResult
 from lp.code.model.diff import Diff
 from lp.code.model.gitjob import GitJob, GitRefScanJob
 from lp.code.model.gitrepository import GitRepository
+from lp.code.tests.helpers import GitHostingFixture
 from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
 from lp.oci.model.ocirecipebuild import OCIFile
 from lp.registry.enums import BranchSharingPolicy, BugSharingPolicy, VCSType
@@ -2586,6 +2587,47 @@ class TestGarbo(FakeAdapterMixin, TestCaseWithFactory):
         self.assertEqual(1, rs.count())
         self.assertEqual(archive_files[1], rs.one())
 
+    def test_SnapProEnablePopulator(self):
+        switch_dbuser("testadmin")
+        refs = [self.factory.makeGitRefs()[0] for _ in range(4)]
+        blobs = {
+            ref.repository.getInternalPath(): blob
+            for ref, blob in (
+                (refs[0], b"name: test-snap\n"),
+                (refs[1], b"name: test-snap\nbase: core\n"),
+                (refs[2], b"name: test-snap\nbase: core18\n"),
+            )
+        }
+        self.useFixture(
+            GitHostingFixture()
+        ).getBlob = lambda path, *args, **kwargs: blobs.get(path)
+        old_snaps = [self.factory.makeSnap(git_ref=ref) for ref in refs]
+        for snap in old_snaps:
+            removeSecurityProxy(snap)._pro_enable = None
+        try:
+            Store.of(old_snaps[0]).flush()
+        except IntegrityError:
+            # Now enforced by DB NOT NULL constraint; backfilling is no
+            # longer necessary.
+            return
+
+        new_snaps = [
+            self.factory.makeSnap(pro_enable=False),
+            self.factory.makeSnap(pro_enable=True),
+        ]
+        transaction.commit()
+
+        self.runDaily()
+
+        # Old snap recipes are backfilled.
+        self.assertIs(True, removeSecurityProxy(old_snaps[0])._pro_enable)
+        self.assertIs(True, removeSecurityProxy(old_snaps[1])._pro_enable)
+        self.assertIs(False, removeSecurityProxy(old_snaps[2])._pro_enable)
+        self.assertIs(False, removeSecurityProxy(old_snaps[3])._pro_enable)
+        # Other snap recipes are left alone.
+        self.assertIs(False, removeSecurityProxy(new_snaps[0])._pro_enable)
+        self.assertIs(True, removeSecurityProxy(new_snaps[1])._pro_enable)
+
 
 class TestGarboTasks(TestCaseWithFactory):
     layer = LaunchpadZopelessLayer
diff --git a/lib/lp/snappy/interfaces/snap.py b/lib/lp/snappy/interfaces/snap.py
index 56f40d0..ae2e3df 100644
--- a/lib/lp/snappy/interfaces/snap.py
+++ b/lib/lp/snappy/interfaces/snap.py
@@ -1177,6 +1177,18 @@ class ISnapAdminAttributes(Interface):
         )
     )
 
+    pro_enable = exported(
+        Bool(
+            title=_("Enable Ubuntu Pro"),
+            required=True,
+            readonly=False,
+            description=_(
+                "Allow building this snap recipe using dependencies from "
+                "Ubuntu Pro, if configured for the corresponding snap base."
+            ),
+        )
+    )
+
     def subscribe(person, subscribed_by):
         """Subscribe a person to this snap recipe."""
 
@@ -1254,6 +1266,7 @@ class ISnapSet(Interface):
         store_secrets=None,
         store_channels=None,
         project=None,
+        pro_enable=None,
     ):
         """Create an `ISnap`."""
 
@@ -1273,6 +1286,17 @@ class ISnapSet(Interface):
     ):
         """Whether or not the information type context is valid."""
 
+    def inferProEnable(context):
+        """Infer a backward-compatible setting of pro_enable.
+
+        New snap recipes only build using dependencies from Ubuntu Pro if
+        explicitly configured to do so, but historically we enabled this by
+        default for snap recipes based on "core" (i.e. Ubuntu Core 16).  For
+        backward compatibility, we continue doing this until we have
+        self-service Pro enablement for snap recipes; see
+        https://docs.google.com/document/d/19juEP2pOsww4t9Z-jtVZbJdUHgZqkmE9mRukGBQ8DHk.
+        """
+
     @operation_parameters(
         owner=Reference(IPerson, title=_("Owner"), required=True),
         name=TextLine(title=_("Snap name"), required=True),
diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
index a9a0128..42c1cee 100644
--- a/lib/lp/snappy/model/snap.py
+++ b/lib/lp/snappy/model/snap.py
@@ -390,6 +390,8 @@ class Snap(StormBase, WebhookTargetMixin):
 
     _store_channels = JSON("store_channels", allow_none=True)
 
+    _pro_enable = Bool(name="pro_enable", allow_none=True)
+
     def __init__(
         self,
         registrant,
@@ -414,6 +416,7 @@ class Snap(StormBase, WebhookTargetMixin):
         store_secrets=None,
         store_channels=None,
         project=None,
+        pro_enable=False,
     ):
         """Construct a `Snap`."""
         super().__init__()
@@ -448,6 +451,7 @@ class Snap(StormBase, WebhookTargetMixin):
         self.store_name = store_name
         self.store_secrets = store_secrets
         self.store_channels = store_channels
+        self._pro_enable = pro_enable
 
     def __repr__(self):
         return "<Snap ~%s/+snap/%s>" % (self.owner.name, self.name)
@@ -675,6 +679,18 @@ class Snap(StormBase, WebhookTargetMixin):
     def store_channels(self, value):
         self._store_channels = value or None
 
+    # XXX ines-almeida 2023-10-18: Simplify this once the database column has
+    # been backfilled.
+    @property
+    def pro_enable(self):
+        if self._pro_enable is None:
+            return getUtility(ISnapSet).inferProEnable(self.source)
+        return self._pro_enable
+
+    @pro_enable.setter
+    def pro_enable(self, value):
+        self._pro_enable = value
+
     def getAllowedInformationTypes(self, user):
         """See `ISnap`."""
         if user_has_special_snap_access(user):
@@ -1515,6 +1531,7 @@ class SnapSet:
         store_secrets=None,
         store_channels=None,
         project=None,
+        pro_enable=None,
     ):
         """See `ISnapSet`."""
         if not registrant.inTeam(owner):
@@ -1571,6 +1588,9 @@ class SnapSet:
         ):
             raise SnapPrivacyMismatch
 
+        if pro_enable is None:
+            pro_enable = self.inferProEnable(branch or git_ref)
+
         store = IPrimaryStore(Snap)
         snap = Snap(
             registrant,
@@ -1595,6 +1615,7 @@ class SnapSet:
             store_secrets=store_secrets,
             store_channels=store_channels,
             project=project,
+            pro_enable=pro_enable,
         )
         store.add(snap)
         snap._reconcileAccess()
@@ -1632,6 +1653,29 @@ class SnapSet:
 
         return True
 
+    # XXX ines-almeida 2023-10-18: Remove this once we have self-service Pro
+    # enablement for snap recipes.
+    def inferProEnable(self, context):
+        """See `ISnapSet`."""
+        if context is None:
+            # Recipe has been detached from its source.
+            return False
+
+        try:
+            snapcraft_data = self.getSnapcraftYaml(context)
+        except (
+            MissingSnapcraftYaml,
+            CannotFetchSnapcraftYaml,
+            CannotParseSnapcraftYaml,
+        ):
+            pass
+        else:
+            base = snapcraft_data.get("base")
+            if base is None or base == "core":
+                return True
+
+        return False
+
     def _getByName(self, owner, name):
         return (
             IStore(Snap)
diff --git a/lib/lp/snappy/tests/test_snap.py b/lib/lp/snappy/tests/test_snap.py
index 253a91d..2fd4b0f 100644
--- a/lib/lp/snappy/tests/test_snap.py
+++ b/lib/lp/snappy/tests/test_snap.py
@@ -276,6 +276,44 @@ class TestSnap(TestCaseWithFactory):
             pass
         self.assertSqlAttributeEqualsDate(snap, "date_last_modified", UTC_NOW)
 
+    def test_pro_enable_value_for_existing_snaps(self):
+        """For existing snaps without pro-enable values, the value is set as
+        expected once called:
+         - If snap has snapcraft.yaml file, and no base - True
+         - If snap has snapcraft.yaml file, and is 'core'-based snap - True
+         - Else, default to False
+        """
+
+        # XXX ines-almeida 2023-10-18: If an `IntegrityError` is raised in this
+        # test, then pro_enable is now enforced by DB NOT NULL constraint;
+        # inferring a value is no longer necessary, and this test and
+        # `test_inferProEnable` below can be removed
+
+        refs = [self.factory.makeGitRefs()[0] for _ in range(4)]
+        blobs = {
+            ref.repository.getInternalPath(): blob
+            for ref, blob in (
+                (refs[0], b"name: test-snap\n"),
+                (refs[1], b"name: test-snap\nbase: core\n"),
+                (refs[2], b"name: test-snap\nbase: core18\n"),
+            )
+        }
+        self.useFixture(
+            GitHostingFixture()
+        ).getBlob = lambda path, *args, **kwargs: blobs.get(path)
+        snaps = [self.factory.makeSnap(git_ref=ref) for ref in refs]
+        for snap in snaps:
+            removeSecurityProxy(snap)._pro_enable = None
+
+        self.assertTrue(snaps[0].pro_enable)  # Snap with no base
+        self.assertTrue(removeSecurityProxy(snaps[0])._pro_enable)
+        self.assertTrue(snaps[1].pro_enable)  # Snap with 'core' base
+        self.assertTrue(removeSecurityProxy(snaps[1])._pro_enable)
+        self.assertFalse(snaps[2].pro_enable)  # Snap with 'core18' base
+        self.assertFalse(removeSecurityProxy(snaps[2])._pro_enable)
+        self.assertFalse(snaps[3].pro_enable)  # Snap without snapcraft.yaml
+        self.assertFalse(removeSecurityProxy(snaps[3])._pro_enable)
+
     def makeBuildableDistroArchSeries(self, **kwargs):
         das = self.factory.makeDistroArchSeries(**kwargs)
         fake_chroot = self.factory.makeLibraryFileAlias(
@@ -2248,6 +2286,7 @@ class TestSnapSet(TestCaseWithFactory):
             owner=self.factory.makeTeam(owner=registrant),
             distro_series=self.factory.makeDistroSeries(),
             name=self.factory.getUniqueUnicode("snap-name"),
+            pro_enable=False,
         )
         if branch is None and git_ref is None:
             branch = self.factory.makeAnyBranch()
@@ -3601,6 +3640,48 @@ class TestSnapProcessors(TestCaseWithFactory):
                 [self.default_procs[0], self.arm, hppa], snap.processors
             )
 
+    def test_pro_enabled_default_value_for_new_snap(self):
+        """Snap pro_enable value defaults to False when creating a new Snap."""
+
+        git_ref = self.factory.makeGitRefs()[0]
+        blob = b"name: test-snap\nbase: core18\n"
+        self.useFixture(
+            GitHostingFixture()
+        ).getBlob = lambda path, *args, **kwargs: blob
+
+        components = self.makeSnapComponents(git_ref=git_ref)
+        components["pro_enable"] = None
+
+        snap = getUtility(ISnapSet).new(**components)
+        self.assertFalse(snap.pro_enable)
+
+    def test_inferProEnable(self):
+        """inferProEnable returns expected bool value depending on context:
+        - Context and snapcraft.yaml file exist, and no base - True
+        - Context and snapcraft.yaml file exist, and base is 'core' - True
+        - Else, default to False
+        """
+
+        refs = [self.factory.makeGitRefs()[0] for _ in range(4)]
+        blobs = {
+            ref.repository.getInternalPath(): blob
+            for ref, blob in (
+                (refs[0], b"name: test-snap\n"),
+                (refs[1], b"name: test-snap\nbase: core\n"),
+                (refs[2], b"name: test-snap\nbase: core18\n"),
+            )
+        }
+        self.useFixture(
+            GitHostingFixture()
+        ).getBlob = lambda path, *args, **kwargs: blobs.get(path)
+
+        inferProEnable = getUtility(ISnapSet).inferProEnable
+        self.assertTrue(inferProEnable(refs[0]))  # Snap with no base
+        self.assertTrue(inferProEnable(refs[1]))  # Snap with 'core' base
+        self.assertFalse(inferProEnable(refs[2]))  # Snap with 'core18' base
+        self.assertFalse(inferProEnable(refs[3]))  # Snap w/out snapcraft.yaml
+        self.assertFalse(inferProEnable(None))  # Snap w/out ref or branch
+
 
 class TestSnapWebservice(TestCaseWithFactory):
     layer = LaunchpadFunctionalLayer
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 7605ed7..b3e6930 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -6209,6 +6209,7 @@ class LaunchpadObjectFactory(ObjectFactory):
         store_secrets=None,
         store_channels=None,
         project=_DEFAULT,
+        pro_enable=False,
     ):
         """Make a new Snap."""
         assert information_type is None or private is None
@@ -6289,6 +6290,7 @@ class LaunchpadObjectFactory(ObjectFactory):
             store_secrets=store_secrets,
             store_channels=store_channels,
             project=project,
+            pro_enable=pro_enable,
         )
         if is_stale is not None:
             removeSecurityProxy(snap).is_stale = is_stale

References