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

Proposed by Ines Almeida
Status: Merged
Approved by: Ines Almeida
Approved revision: 862956bc947bf52b2c329ee1e4feb933d447721b
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~ines-almeida/launchpad:pro-enable-core18/update-models
Merge into: launchpad:master
Diff against target: 406 lines (+228/-0)
6 files modified
lib/lp/scripts/garbo.py (+32/-0)
lib/lp/scripts/tests/test_garbo.py (+42/-0)
lib/lp/snappy/interfaces/snap.py (+24/-0)
lib/lp/snappy/model/snap.py (+44/-0)
lib/lp/snappy/tests/test_snap.py (+84/-0)
lib/lp/testing/factory.py (+2/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+453932@code.launchpad.net

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.

Description of the change

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.

To post a comment you must log in.
Revision history for this message
Ines Almeida (ines-almeida) :
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
add27ba... by Ines Almeida

Prevent snap pro-enable test from failing once DB restriction patch is applied

Revision history for this message
Ines Almeida (ines-almeida) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/scripts/garbo.py b/lib/lp/scripts/garbo.py
2index d4fa136..61cd51b 100644
3--- a/lib/lp/scripts/garbo.py
4+++ b/lib/lp/scripts/garbo.py
5@@ -116,6 +116,8 @@ from lp.services.verification.model.logintoken import LoginToken
6 from lp.services.webapp.publisher import canonical_url
7 from lp.services.webhooks.interfaces import IWebhookJobSource
8 from lp.services.webhooks.model import WebhookJob
9+from lp.snappy.interfaces.snap import ISnapSet
10+from lp.snappy.model.snap import Snap
11 from lp.snappy.model.snapbuild import SnapFile
12 from lp.snappy.model.snapbuildjob import SnapBuildJobType
13 from lp.soyuz.enums import (
14@@ -2259,6 +2261,35 @@ class ArchiveFileDatePopulator(TunableLoop):
15 transaction.commit()
16
17
18+class SnapProEnablePopulator(TunableLoop):
19+ """Populates Snap.pro_enable."""
20+
21+ maximum_chunk_size = 100
22+
23+ def __init__(self, log, abort_time=None):
24+ super().__init__(log, abort_time)
25+ self.start_at = 1
26+ self.store = IPrimaryStore(Snap)
27+
28+ def findSnaps(self):
29+ snaps = self.store.find(
30+ Snap,
31+ Snap.id >= self.start_at,
32+ Snap._pro_enable == None,
33+ )
34+ return snaps.order_by(Snap.id)
35+
36+ def isDone(self):
37+ return self.findSnaps().is_empty()
38+
39+ def __call__(self, chunk_size):
40+ snaps = list(self.findSnaps()[:chunk_size])
41+ for snap in snaps:
42+ snap._pro_enable = getUtility(ISnapSet).inferProEnable(snap.source)
43+ self.start_at = snaps[-1].id + 1
44+ transaction.commit()
45+
46+
47 class BaseDatabaseGarbageCollector(LaunchpadCronScript):
48 """Abstract base class to run a collection of TunableLoops."""
49
50@@ -2595,6 +2626,7 @@ class DailyDatabaseGarbageCollector(BaseDatabaseGarbageCollector):
51 ScrubPOFileTranslator,
52 SnapBuildJobPruner,
53 SnapFilePruner,
54+ SnapProEnablePopulator,
55 SourcePackagePublishingHistoryFormatPopulator,
56 SuggestiveTemplatesCacheUpdater,
57 TeamMembershipPruner,
58diff --git a/lib/lp/scripts/tests/test_garbo.py b/lib/lp/scripts/tests/test_garbo.py
59index e2bc91b..726ba55 100644
60--- a/lib/lp/scripts/tests/test_garbo.py
61+++ b/lib/lp/scripts/tests/test_garbo.py
62@@ -60,6 +60,7 @@ from lp.code.model.codeimportresult import CodeImportResult
63 from lp.code.model.diff import Diff
64 from lp.code.model.gitjob import GitJob, GitRefScanJob
65 from lp.code.model.gitrepository import GitRepository
66+from lp.code.tests.helpers import GitHostingFixture
67 from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
68 from lp.oci.model.ocirecipebuild import OCIFile
69 from lp.registry.enums import BranchSharingPolicy, BugSharingPolicy, VCSType
70@@ -2586,6 +2587,47 @@ class TestGarbo(FakeAdapterMixin, TestCaseWithFactory):
71 self.assertEqual(1, rs.count())
72 self.assertEqual(archive_files[1], rs.one())
73
74+ def test_SnapProEnablePopulator(self):
75+ switch_dbuser("testadmin")
76+ refs = [self.factory.makeGitRefs()[0] for _ in range(4)]
77+ blobs = {
78+ ref.repository.getInternalPath(): blob
79+ for ref, blob in (
80+ (refs[0], b"name: test-snap\n"),
81+ (refs[1], b"name: test-snap\nbase: core\n"),
82+ (refs[2], b"name: test-snap\nbase: core18\n"),
83+ )
84+ }
85+ self.useFixture(
86+ GitHostingFixture()
87+ ).getBlob = lambda path, *args, **kwargs: blobs.get(path)
88+ old_snaps = [self.factory.makeSnap(git_ref=ref) for ref in refs]
89+ for snap in old_snaps:
90+ removeSecurityProxy(snap)._pro_enable = None
91+ try:
92+ Store.of(old_snaps[0]).flush()
93+ except IntegrityError:
94+ # Now enforced by DB NOT NULL constraint; backfilling is no
95+ # longer necessary.
96+ return
97+
98+ new_snaps = [
99+ self.factory.makeSnap(pro_enable=False),
100+ self.factory.makeSnap(pro_enable=True),
101+ ]
102+ transaction.commit()
103+
104+ self.runDaily()
105+
106+ # Old snap recipes are backfilled.
107+ self.assertIs(True, removeSecurityProxy(old_snaps[0])._pro_enable)
108+ self.assertIs(True, removeSecurityProxy(old_snaps[1])._pro_enable)
109+ self.assertIs(False, removeSecurityProxy(old_snaps[2])._pro_enable)
110+ self.assertIs(False, removeSecurityProxy(old_snaps[3])._pro_enable)
111+ # Other snap recipes are left alone.
112+ self.assertIs(False, removeSecurityProxy(new_snaps[0])._pro_enable)
113+ self.assertIs(True, removeSecurityProxy(new_snaps[1])._pro_enable)
114+
115
116 class TestGarboTasks(TestCaseWithFactory):
117 layer = LaunchpadZopelessLayer
118diff --git a/lib/lp/snappy/interfaces/snap.py b/lib/lp/snappy/interfaces/snap.py
119index 56f40d0..ae2e3df 100644
120--- a/lib/lp/snappy/interfaces/snap.py
121+++ b/lib/lp/snappy/interfaces/snap.py
122@@ -1177,6 +1177,18 @@ class ISnapAdminAttributes(Interface):
123 )
124 )
125
126+ pro_enable = exported(
127+ Bool(
128+ title=_("Enable Ubuntu Pro"),
129+ required=True,
130+ readonly=False,
131+ description=_(
132+ "Allow building this snap recipe using dependencies from "
133+ "Ubuntu Pro, if configured for the corresponding snap base."
134+ ),
135+ )
136+ )
137+
138 def subscribe(person, subscribed_by):
139 """Subscribe a person to this snap recipe."""
140
141@@ -1254,6 +1266,7 @@ class ISnapSet(Interface):
142 store_secrets=None,
143 store_channels=None,
144 project=None,
145+ pro_enable=None,
146 ):
147 """Create an `ISnap`."""
148
149@@ -1273,6 +1286,17 @@ class ISnapSet(Interface):
150 ):
151 """Whether or not the information type context is valid."""
152
153+ def inferProEnable(context):
154+ """Infer a backward-compatible setting of pro_enable.
155+
156+ New snap recipes only build using dependencies from Ubuntu Pro if
157+ explicitly configured to do so, but historically we enabled this by
158+ default for snap recipes based on "core" (i.e. Ubuntu Core 16). For
159+ backward compatibility, we continue doing this until we have
160+ self-service Pro enablement for snap recipes; see
161+ https://docs.google.com/document/d/19juEP2pOsww4t9Z-jtVZbJdUHgZqkmE9mRukGBQ8DHk.
162+ """
163+
164 @operation_parameters(
165 owner=Reference(IPerson, title=_("Owner"), required=True),
166 name=TextLine(title=_("Snap name"), required=True),
167diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
168index a9a0128..42c1cee 100644
169--- a/lib/lp/snappy/model/snap.py
170+++ b/lib/lp/snappy/model/snap.py
171@@ -390,6 +390,8 @@ class Snap(StormBase, WebhookTargetMixin):
172
173 _store_channels = JSON("store_channels", allow_none=True)
174
175+ _pro_enable = Bool(name="pro_enable", allow_none=True)
176+
177 def __init__(
178 self,
179 registrant,
180@@ -414,6 +416,7 @@ class Snap(StormBase, WebhookTargetMixin):
181 store_secrets=None,
182 store_channels=None,
183 project=None,
184+ pro_enable=False,
185 ):
186 """Construct a `Snap`."""
187 super().__init__()
188@@ -448,6 +451,7 @@ class Snap(StormBase, WebhookTargetMixin):
189 self.store_name = store_name
190 self.store_secrets = store_secrets
191 self.store_channels = store_channels
192+ self._pro_enable = pro_enable
193
194 def __repr__(self):
195 return "<Snap ~%s/+snap/%s>" % (self.owner.name, self.name)
196@@ -675,6 +679,18 @@ class Snap(StormBase, WebhookTargetMixin):
197 def store_channels(self, value):
198 self._store_channels = value or None
199
200+ # XXX ines-almeida 2023-10-18: Simplify this once the database column has
201+ # been backfilled.
202+ @property
203+ def pro_enable(self):
204+ if self._pro_enable is None:
205+ return getUtility(ISnapSet).inferProEnable(self.source)
206+ return self._pro_enable
207+
208+ @pro_enable.setter
209+ def pro_enable(self, value):
210+ self._pro_enable = value
211+
212 def getAllowedInformationTypes(self, user):
213 """See `ISnap`."""
214 if user_has_special_snap_access(user):
215@@ -1515,6 +1531,7 @@ class SnapSet:
216 store_secrets=None,
217 store_channels=None,
218 project=None,
219+ pro_enable=None,
220 ):
221 """See `ISnapSet`."""
222 if not registrant.inTeam(owner):
223@@ -1571,6 +1588,9 @@ class SnapSet:
224 ):
225 raise SnapPrivacyMismatch
226
227+ if pro_enable is None:
228+ pro_enable = self.inferProEnable(branch or git_ref)
229+
230 store = IPrimaryStore(Snap)
231 snap = Snap(
232 registrant,
233@@ -1595,6 +1615,7 @@ class SnapSet:
234 store_secrets=store_secrets,
235 store_channels=store_channels,
236 project=project,
237+ pro_enable=pro_enable,
238 )
239 store.add(snap)
240 snap._reconcileAccess()
241@@ -1632,6 +1653,29 @@ class SnapSet:
242
243 return True
244
245+ # XXX ines-almeida 2023-10-18: Remove this once we have self-service Pro
246+ # enablement for snap recipes.
247+ def inferProEnable(self, context):
248+ """See `ISnapSet`."""
249+ if context is None:
250+ # Recipe has been detached from its source.
251+ return False
252+
253+ try:
254+ snapcraft_data = self.getSnapcraftYaml(context)
255+ except (
256+ MissingSnapcraftYaml,
257+ CannotFetchSnapcraftYaml,
258+ CannotParseSnapcraftYaml,
259+ ):
260+ pass
261+ else:
262+ base = snapcraft_data.get("base")
263+ if base is None or base == "core":
264+ return True
265+
266+ return False
267+
268 def _getByName(self, owner, name):
269 return (
270 IStore(Snap)
271diff --git a/lib/lp/snappy/tests/test_snap.py b/lib/lp/snappy/tests/test_snap.py
272index 5acd69f..665d1d8 100644
273--- a/lib/lp/snappy/tests/test_snap.py
274+++ b/lib/lp/snappy/tests/test_snap.py
275@@ -15,6 +15,7 @@ import responses
276 import transaction
277 from fixtures import FakeLogger, MockPatch
278 from nacl.public import PrivateKey
279+from psycopg2 import IntegrityError
280 from pymacaroons import Macaroon
281 from storm.exceptions import LostObjectError
282 from storm.locals import Store
283@@ -276,6 +277,46 @@ class TestSnap(TestCaseWithFactory):
284 pass
285 self.assertSqlAttributeEqualsDate(snap, "date_last_modified", UTC_NOW)
286
287+ def test_pro_enable_value_for_existing_snaps(self):
288+ """For existing snaps without pro-enable values, the value is set as
289+ expected once called:
290+ - If snap has snapcraft.yaml file, and no base - True
291+ - If snap has snapcraft.yaml file, and is 'core'-based snap - True
292+ - Else, default to False
293+ """
294+
295+ refs = [self.factory.makeGitRefs()[0] for _ in range(4)]
296+ blobs = {
297+ ref.repository.getInternalPath(): blob
298+ for ref, blob in (
299+ (refs[0], b"name: test-snap\n"),
300+ (refs[1], b"name: test-snap\nbase: core\n"),
301+ (refs[2], b"name: test-snap\nbase: core18\n"),
302+ )
303+ }
304+ self.useFixture(
305+ GitHostingFixture()
306+ ).getBlob = lambda path, *args, **kwargs: blobs.get(path)
307+ snaps = [self.factory.makeSnap(git_ref=ref) for ref in refs]
308+ for snap in snaps:
309+ removeSecurityProxy(snap)._pro_enable = None
310+
311+ try:
312+ Store.of(snaps[0]).flush()
313+ except IntegrityError:
314+ # Now enforced by DB NOT NULL constraint; inferring a value is
315+ # no longer necessary.
316+ return
317+
318+ self.assertTrue(snaps[0].pro_enable) # Snap with no base
319+ self.assertTrue(removeSecurityProxy(snaps[0])._pro_enable)
320+ self.assertTrue(snaps[1].pro_enable) # Snap with 'core' base
321+ self.assertTrue(removeSecurityProxy(snaps[1])._pro_enable)
322+ self.assertFalse(snaps[2].pro_enable) # Snap with 'core18' base
323+ self.assertFalse(removeSecurityProxy(snaps[2])._pro_enable)
324+ self.assertFalse(snaps[3].pro_enable) # Snap without snapcraft.yaml
325+ self.assertFalse(removeSecurityProxy(snaps[3])._pro_enable)
326+
327 def makeBuildableDistroArchSeries(self, **kwargs):
328 das = self.factory.makeDistroArchSeries(**kwargs)
329 fake_chroot = self.factory.makeLibraryFileAlias(
330@@ -2248,6 +2289,7 @@ class TestSnapSet(TestCaseWithFactory):
331 owner=self.factory.makeTeam(owner=registrant),
332 distro_series=self.factory.makeDistroSeries(),
333 name=self.factory.getUniqueUnicode("snap-name"),
334+ pro_enable=False,
335 )
336 if branch is None and git_ref is None:
337 branch = self.factory.makeAnyBranch()
338@@ -3601,6 +3643,48 @@ class TestSnapProcessors(TestCaseWithFactory):
339 [self.default_procs[0], self.arm, hppa], snap.processors
340 )
341
342+ def test_pro_enabled_default_value_for_new_snap(self):
343+ """Snap pro_enable value defaults to False when creating a new Snap."""
344+
345+ git_ref = self.factory.makeGitRefs()[0]
346+ blob = b"name: test-snap\nbase: core18\n"
347+ self.useFixture(
348+ GitHostingFixture()
349+ ).getBlob = lambda path, *args, **kwargs: blob
350+
351+ components = self.makeSnapComponents(git_ref=git_ref)
352+ components["pro_enable"] = None
353+
354+ snap = getUtility(ISnapSet).new(**components)
355+ self.assertFalse(snap.pro_enable)
356+
357+ def test_inferProEnable(self):
358+ """inferProEnable returns expected bool value depending on context:
359+ - Context and snapcraft.yaml file exist, and no base - True
360+ - Context and snapcraft.yaml file exist, and base is 'core' - True
361+ - Else, default to False
362+ """
363+
364+ refs = [self.factory.makeGitRefs()[0] for _ in range(4)]
365+ blobs = {
366+ ref.repository.getInternalPath(): blob
367+ for ref, blob in (
368+ (refs[0], b"name: test-snap\n"),
369+ (refs[1], b"name: test-snap\nbase: core\n"),
370+ (refs[2], b"name: test-snap\nbase: core18\n"),
371+ )
372+ }
373+ self.useFixture(
374+ GitHostingFixture()
375+ ).getBlob = lambda path, *args, **kwargs: blobs.get(path)
376+
377+ inferProEnable = getUtility(ISnapSet).inferProEnable
378+ self.assertTrue(inferProEnable(refs[0])) # Snap with no base
379+ self.assertTrue(inferProEnable(refs[1])) # Snap with 'core' base
380+ self.assertFalse(inferProEnable(refs[2])) # Snap with 'core18' base
381+ self.assertFalse(inferProEnable(refs[3])) # Snap w/out snapcraft.yaml
382+ self.assertFalse(inferProEnable(None)) # Snap w/out ref or branch
383+
384
385 class TestSnapWebservice(TestCaseWithFactory):
386 layer = LaunchpadFunctionalLayer
387diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
388index bfc8744..0e9236e 100644
389--- a/lib/lp/testing/factory.py
390+++ b/lib/lp/testing/factory.py
391@@ -6208,6 +6208,7 @@ class LaunchpadObjectFactory(ObjectFactory):
392 store_secrets=None,
393 store_channels=None,
394 project=_DEFAULT,
395+ pro_enable=False,
396 ):
397 """Make a new Snap."""
398 assert information_type is None or private is None
399@@ -6288,6 +6289,7 @@ class LaunchpadObjectFactory(ObjectFactory):
400 store_secrets=store_secrets,
401 store_channels=store_channels,
402 project=project,
403+ pro_enable=pro_enable,
404 )
405 if is_stale is not None:
406 removeSecurityProxy(snap).is_stale = is_stale

Subscribers

People subscribed via source and target branches

to status/vote changes: