Merge ~pappacena/launchpad:oci-api-create-recipe into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: ff742c693e21d53a35779f1aad946ed24df2ea8d
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:oci-api-create-recipe
Merge into: launchpad:master
Prerequisite: ~pappacena/launchpad:oci-project-api
Diff against target: 842 lines (+308/-47)
15 files modified
lib/lp/_schema_circular_imports.py (+1/-0)
lib/lp/oci/browser/ocirecipe.py (+9/-1)
lib/lp/oci/browser/tests/test_ocirecipe.py (+23/-0)
lib/lp/oci/browser/tests/test_ocirecipebuild.py (+7/-0)
lib/lp/oci/interfaces/ocirecipe.py (+13/-1)
lib/lp/oci/model/ocirecipe.py (+10/-3)
lib/lp/oci/tests/helpers.py (+5/-0)
lib/lp/oci/tests/test_ocirecipe.py (+135/-32)
lib/lp/oci/tests/test_ocirecipebuild.py (+11/-2)
lib/lp/oci/tests/test_ocirecipebuildbehaviour.py (+13/-2)
lib/lp/registry/configure.zcml (+3/-0)
lib/lp/registry/interfaces/ociproject.py (+49/-2)
lib/lp/registry/model/ociproject.py (+16/-0)
lib/lp/registry/tests/test_personmerge.py (+7/-2)
lib/lp/services/webhooks/tests/test_job.py (+6/-2)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+381065@code.launchpad.net

Commit message

API operation to create a new OCIRecipe from an existing OCIProject.

The feature is only enabled if we turn on the 'oci.recipe.create.enabled' feature flag.

Description of the change

This MP includes code from https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/380909. So, it's important to hold merges until the previous one is accepted and merged.

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) :
4171065... by Thiago F. Pappacena

Merge branch 'master' into oci-api-create-recipe

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
719b074... by Thiago F. Pappacena

Refactoring tests

222602a... by Thiago F. Pappacena

Removing unused parameter

d14a4c6... by Thiago F. Pappacena

Moving OCI_RECIPE_ALLOW_CREATE feature flag to OCIRecipe.__init__ and its view initializer

75e9047... by Thiago F. Pappacena

Renaming IOCIProjectPublicActions interface to IOCIProjectLegitimate

c979846... by Thiago F. Pappacena

OCIRecipe owner should be provided on creation when using API

5bc0691... by Thiago F. Pappacena

Adding build_daily on OCI Recipe creation API

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

cjwatson, it took a while to adjust all the tests that needed to `OCIRecipe.__init__` or `OCIRecipeAddView.initialize`, and those adjustments made the diff bigger, although it's just setting the correct feature flag where appropriate.

Anyway, I think it covers all requested changes. Let me know if you want to do another round of review.

d227d91... by Thiago F. Pappacena

Fixing tests to use feature flag

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Ah, I'm actually missing one of the requests. I'll push in some minutes.

fd4fa2e... by Thiago F. Pappacena

Actually using build_daily on OCIRecipe's create UI

a258de0... by Thiago F. Pappacena

Merge branch 'master' into oci-api-create-recipe

efc1f7f... by Thiago F. Pappacena

Merge branch 'master' into oci-api-create-recipe

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Now I think it's done, and ready for another round of review.

Sorry for the long diff, but most of the new changes are just adding the FeatureFixture on old tests.

Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
ff742c6... by Thiago F. Pappacena

Minor refactoring and adjusting constraing on OCIProject.newRecipe

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Thanks for the review. All requested changes were made. I'll top-approve this MP now.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
2index dd4e9d4..37369b4 100644
3--- a/lib/lp/_schema_circular_imports.py
4+++ b/lib/lp/_schema_circular_imports.py
5@@ -1103,6 +1103,7 @@ patch_entry_explicit_version(IWikiName, 'beta')
6
7 # IOCIProject
8 patch_collection_property(IOCIProject, 'series', IOCIProjectSeries)
9+patch_entry_return_type(IOCIProject, 'newRecipe', IOCIRecipe)
10
11 # IOCIRecipe
12 patch_collection_property(IOCIRecipe, 'builds', IOCIRecipeBuild)
13diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
14index 7d044f5..10e9d4b 100644
15--- a/lib/lp/oci/browser/ocirecipe.py
16+++ b/lib/lp/oci/browser/ocirecipe.py
17@@ -35,7 +35,9 @@ from lp.oci.interfaces.ocirecipe import (
18 IOCIRecipe,
19 IOCIRecipeSet,
20 NoSuchOCIRecipe,
21+ OCI_RECIPE_ALLOW_CREATE,
22 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
23+ OCIRecipeFeatureDisabled,
24 )
25 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
26 from lp.services.features import getFeatureFlag
27@@ -174,6 +176,11 @@ class OCIRecipeAddView(LaunchpadFormView):
28 )
29 custom_widget_git_ref = GitRefWidget
30
31+ def initialize(self):
32+ super(OCIRecipeAddView, self).initialize()
33+ if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE):
34+ raise OCIRecipeFeatureDisabled()
35+
36 @property
37 def cancel_url(self):
38 """See `LaunchpadFormView`."""
39@@ -205,7 +212,8 @@ class OCIRecipeAddView(LaunchpadFormView):
40 recipe = getUtility(IOCIRecipeSet).new(
41 name=data["name"], registrant=self.user, owner=data["owner"],
42 oci_project=self.context, git_ref=data["git_ref"],
43- build_file=data["build_file"], description=data["description"])
44+ build_file=data["build_file"], description=data["description"],
45+ build_daily=data["build_daily"])
46 self.next_url = canonical_url(recipe)
47
48
49diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
50index 540649f..bd87975 100644
51--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
52+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
53@@ -29,7 +29,9 @@ from lp.oci.browser.ocirecipe import (
54 OCIRecipeEditView,
55 OCIRecipeView,
56 )
57+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
58 from lp.services.database.constants import UTC_NOW
59+from lp.services.features.testing import FeatureFixture
60 from lp.services.propertycache import get_property_cache
61 from lp.services.webapp import canonical_url
62 from lp.services.webapp.servers import LaunchpadTestRequest
63@@ -62,6 +64,10 @@ class TestOCIRecipeNavigation(TestCaseWithFactory):
64
65 layer = DatabaseFunctionalLayer
66
67+ def setUp(self):
68+ super(TestOCIRecipeNavigation, self).setUp()
69+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
70+
71 def test_canonical_url(self):
72 owner = self.factory.makePerson(name="person")
73 distribution = self.factory.makeDistribution(name="distro")
74@@ -96,6 +102,10 @@ class BaseTestOCIRecipeView(BrowserTestCase):
75
76 class TestOCIRecipeAddView(BaseTestOCIRecipeView):
77
78+ def setUp(self):
79+ super(TestOCIRecipeAddView, self).setUp()
80+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
81+
82 def test_create_new_recipe_not_logged_in(self):
83 oci_project = self.factory.makeOCIProject()
84 self.assertRaises(
85@@ -151,6 +161,10 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
86
87 class TestOCIRecipeAdminView(BaseTestOCIRecipeView):
88
89+ def setUp(self):
90+ super(TestOCIRecipeAdminView, self).setUp()
91+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
92+
93 def test_unauthorized(self):
94 # A non-admin user cannot administer an OCI recipe.
95 login_person(self.person)
96@@ -199,6 +213,10 @@ class TestOCIRecipeAdminView(BaseTestOCIRecipeView):
97
98 class TestOCIRecipeEditView(BaseTestOCIRecipeView):
99
100+ def setUp(self):
101+ super(TestOCIRecipeEditView, self).setUp()
102+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
103+
104 def test_edit_recipe(self):
105 oci_project = self.factory.makeOCIProject()
106 oci_project_display = oci_project.display_name
107@@ -275,6 +293,10 @@ class TestOCIRecipeEditView(BaseTestOCIRecipeView):
108
109 class TestOCIRecipeDeleteView(BaseTestOCIRecipeView):
110
111+ def setUp(self):
112+ super(TestOCIRecipeDeleteView, self).setUp()
113+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
114+
115 def test_unauthorized(self):
116 # A user without edit access cannot delete an OCI recipe.
117 recipe = self.factory.makeOCIRecipe(
118@@ -326,6 +348,7 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
119 distroseries=self.distroseries, architecturetag="i386",
120 processor=processor)
121 self.factory.makeBuilder(virtualized=True)
122+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
123
124 def makeOCIRecipe(self, oci_project=None, **kwargs):
125 if oci_project is None:
126diff --git a/lib/lp/oci/browser/tests/test_ocirecipebuild.py b/lib/lp/oci/browser/tests/test_ocirecipebuild.py
127index ed3cb9b..72f29e9 100644
128--- a/lib/lp/oci/browser/tests/test_ocirecipebuild.py
129+++ b/lib/lp/oci/browser/tests/test_ocirecipebuild.py
130@@ -13,6 +13,8 @@ from storm.locals import Store
131 from testtools.matchers import StartsWith
132
133 from lp.buildmaster.enums import BuildStatus
134+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
135+from lp.services.features.testing import FeatureFixture
136 from lp.services.webapp import canonical_url
137 from lp.testing import (
138 BrowserTestCase,
139@@ -29,6 +31,10 @@ class TestCanonicalUrlForOCIRecipeBuild(TestCaseWithFactory):
140
141 layer = DatabaseFunctionalLayer
142
143+ def setUp(self):
144+ super(TestCanonicalUrlForOCIRecipeBuild, self).setUp()
145+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
146+
147 def test_canonical_url(self):
148 owner = self.factory.makePerson(name="person")
149 distribution = self.factory.makeDistribution(name="distro")
150@@ -51,6 +57,7 @@ class TestOCIRecipeBuildOperations(BrowserTestCase):
151
152 def setUp(self):
153 super(TestOCIRecipeBuildOperations, self).setUp()
154+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
155 self.build = self.factory.makeOCIRecipeBuild()
156 self.build_url = canonical_url(self.build)
157
158diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
159index 99a0719..ca70e38 100644
160--- a/lib/lp/oci/interfaces/ocirecipe.py
161+++ b/lib/lp/oci/interfaces/ocirecipe.py
162@@ -15,8 +15,10 @@ __all__ = [
163 'IOCIRecipeView',
164 'NoSourceForOCIRecipe',
165 'NoSuchOCIRecipe',
166+ 'OCI_RECIPE_ALLOW_CREATE',
167 'OCI_RECIPE_WEBHOOKS_FEATURE_FLAG',
168 'OCIRecipeBuildAlreadyPending',
169+ 'OCIRecipeFeatureDisabled',
170 'OCIRecipeNotOwner',
171 ]
172
173@@ -58,6 +60,16 @@ from lp.services.webhooks.interfaces import IWebhookTarget
174
175
176 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG = "oci.recipe.webhooks.enabled"
177+OCI_RECIPE_ALLOW_CREATE = 'oci.recipe.create.enabled'
178+
179+
180+@error_status(http_client.UNAUTHORIZED)
181+class OCIRecipeFeatureDisabled(Unauthorized):
182+ """Only certain users can create new LiveFS-related objects."""
183+
184+ def __init__(self):
185+ super(OCIRecipeFeatureDisabled, self).__init__(
186+ "You do not have permission to create new OCI recipe.")
187
188
189 @error_status(http_client.UNAUTHORIZED)
190@@ -255,7 +267,7 @@ class IOCIRecipeSet(Interface):
191
192 def new(name, registrant, owner, oci_project, git_ref, build_file,
193 description=None, official=False, require_virtualized=True,
194- date_created=DEFAULT):
195+ build_daily=False, date_created=DEFAULT):
196 """Create an IOCIRecipe."""
197
198 def exists(owner, oci_project, name):
199diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
200index 68f5e92..21a936b 100644
201--- a/lib/lp/oci/model/ocirecipe.py
202+++ b/lib/lp/oci/model/ocirecipe.py
203@@ -42,7 +42,9 @@ from lp.oci.interfaces.ocirecipe import (
204 IOCIRecipeSet,
205 NoSourceForOCIRecipe,
206 NoSuchOCIRecipe,
207+ OCI_RECIPE_ALLOW_CREATE,
208 OCIRecipeBuildAlreadyPending,
209+ OCIRecipeFeatureDisabled,
210 OCIRecipeNotOwner,
211 )
212 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
213@@ -62,6 +64,7 @@ from lp.services.database.stormexpr import (
214 Greatest,
215 NullsLast,
216 )
217+from lp.services.features import getFeatureFlag
218 from lp.services.webhooks.interfaces import IWebhookSet
219 from lp.services.webhooks.model import WebhookTargetMixin
220
221@@ -112,7 +115,9 @@ class OCIRecipe(Storm, WebhookTargetMixin):
222
223 def __init__(self, name, registrant, owner, oci_project, git_ref,
224 description=None, official=False, require_virtualized=True,
225- build_file=None, date_created=DEFAULT):
226+ build_file=None, build_daily=False, date_created=DEFAULT):
227+ if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE):
228+ raise OCIRecipeFeatureDisabled()
229 super(OCIRecipe, self).__init__()
230 self.name = name
231 self.registrant = registrant
232@@ -122,6 +127,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
233 self.build_file = build_file
234 self.official = official
235 self.require_virtualized = require_virtualized
236+ self.build_daily = build_daily
237 self.date_created = date_created
238 self.date_last_modified = date_created
239 self.git_ref = git_ref
240@@ -276,7 +282,7 @@ class OCIRecipeSet:
241
242 def new(self, name, registrant, owner, oci_project, git_ref, build_file,
243 description=None, official=False, require_virtualized=True,
244- date_created=DEFAULT):
245+ build_daily=False, date_created=DEFAULT):
246 """See `IOCIRecipeSet`."""
247 if not registrant.inTeam(owner):
248 if owner.is_team:
249@@ -297,7 +303,8 @@ class OCIRecipeSet:
250 store = IMasterStore(OCIRecipe)
251 oci_recipe = OCIRecipe(
252 name, registrant, owner, oci_project, git_ref, description,
253- official, require_virtualized, build_file, date_created)
254+ official, require_virtualized, build_file, build_daily,
255+ date_created)
256 store.add(oci_recipe)
257
258 return oci_recipe
259diff --git a/lib/lp/oci/tests/helpers.py b/lib/lp/oci/tests/helpers.py
260index dc0e82f..e199241 100644
261--- a/lib/lp/oci/tests/helpers.py
262+++ b/lib/lp/oci/tests/helpers.py
263@@ -12,6 +12,9 @@ import base64
264
265 from nacl.public import PrivateKey
266
267+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
268+from lp.services.features.testing import FeatureFixture
269+
270
271 class OCIConfigHelperMixin:
272
273@@ -25,3 +28,5 @@ class OCIConfigHelperMixin:
274 "oci",
275 registry_secrets_private_key=base64.b64encode(
276 bytes(self.private_key)).decode("UTF-8"))
277+ # Default feature flags for our tests
278+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
279diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
280index 3c60e7e..e15dd8e 100644
281--- a/lib/lp/oci/tests/test_ocirecipe.py
282+++ b/lib/lp/oci/tests/test_ocirecipe.py
283@@ -9,8 +9,8 @@ import base64
284 import json
285
286 from fixtures import FakeLogger
287-from six import string_types
288 from nacl.public import PrivateKey
289+from six import string_types
290 from storm.exceptions import LostObjectError
291 from testtools.matchers import (
292 ContainsDict,
293@@ -29,6 +29,7 @@ from lp.oci.interfaces.ocirecipe import (
294 IOCIRecipeSet,
295 NoSourceForOCIRecipe,
296 NoSuchOCIRecipe,
297+ OCI_RECIPE_ALLOW_CREATE,
298 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
299 OCIRecipeBuildAlreadyPending,
300 OCIRecipeNotOwner,
301@@ -60,6 +61,10 @@ class TestOCIRecipe(TestCaseWithFactory):
302
303 layer = DatabaseFunctionalLayer
304
305+ def setUp(self):
306+ super(TestOCIRecipe, self).setUp()
307+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
308+
309 def test_implements_interface(self):
310 target = self.factory.makeOCIRecipe()
311 with admin_logged_in():
312@@ -106,7 +111,8 @@ class TestOCIRecipe(TestCaseWithFactory):
313 def test_requestBuild_triggers_webhooks(self):
314 # Requesting a build triggers webhooks.
315 logger = self.useFixture(FakeLogger())
316- with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on"}):
317+ with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
318+ OCI_RECIPE_ALLOW_CREATE: 'on'}):
319 recipe = self.factory.makeOCIRecipe()
320 oci_arch = self.factory.makeOCIRecipeArch(recipe=recipe)
321 hook = self.factory.makeWebhook(
322@@ -153,7 +159,8 @@ class TestOCIRecipe(TestCaseWithFactory):
323
324 def test_related_webhooks_deleted(self):
325 owner = self.factory.makePerson()
326- with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on"}):
327+ with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
328+ OCI_RECIPE_ALLOW_CREATE: 'on'}):
329 recipe = self.factory.makeOCIRecipe(registrant=owner, owner=owner)
330 webhook = self.factory.makeWebhook(target=recipe)
331 with person_logged_in(recipe.owner):
332@@ -214,6 +221,10 @@ class TestOCIRecipeSet(TestCaseWithFactory):
333
334 layer = DatabaseFunctionalLayer
335
336+ def setUp(self):
337+ super(TestOCIRecipeSet, self).setUp()
338+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
339+
340 def test_implements_interface(self):
341 target_set = getUtility(IOCIRecipeSet)
342 with admin_logged_in():
343@@ -382,10 +393,12 @@ class TestOCIRecipeWebservice(TestCaseWithFactory):
344
345 def setUp(self):
346 super(TestOCIRecipeWebservice, self).setUp()
347- self.person = self.factory.makePerson(displayname="Test Person")
348+ self.person = self.factory.makePerson(
349+ displayname="Test Person")
350 self.webservice = webservice_for_person(
351 self.person, permission=OAuthPermission.WRITE_PUBLIC,
352 default_api_version="devel")
353+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
354
355 def getAbsoluteURL(self, target):
356 """Get the webservice absolute URL of the given object or relative
357@@ -401,38 +414,39 @@ class TestOCIRecipeWebservice(TestCaseWithFactory):
358
359 def test_api_get_oci_recipe(self):
360 with person_logged_in(self.person):
361- project = removeSecurityProxy(self.factory.makeOCIProject(
362- registrant=self.person))
363- recipe = removeSecurityProxy(self.factory.makeOCIRecipe(
364- oci_project=project))
365+ oci_project = self.factory.makeOCIProject(
366+ registrant=self.person)
367+ recipe = self.factory.makeOCIRecipe(
368+ oci_project=oci_project)
369 url = api_url(recipe)
370
371 ws_recipe = self.load_from_api(url)
372
373- recipe_abs_url = self.getAbsoluteURL(recipe)
374- self.assertThat(ws_recipe, ContainsDict(dict(
375- date_created=Equals(recipe.date_created.isoformat()),
376- date_last_modified=Equals(recipe.date_last_modified.isoformat()),
377- registrant_link=Equals(self.getAbsoluteURL(recipe.registrant)),
378- webhooks_collection_link=Equals(recipe_abs_url + "/webhooks"),
379- name=Equals(recipe.name),
380- owner_link=Equals(self.getAbsoluteURL(recipe.owner)),
381- oci_project_link=Equals(self.getAbsoluteURL(project)),
382- git_ref_link=Equals(self.getAbsoluteURL(recipe.git_ref)),
383- description=Equals(recipe.description),
384- build_file=Equals(recipe.build_file),
385- build_daily=Equals(recipe.build_daily)
386- )))
387+ with person_logged_in(self.person):
388+ recipe_abs_url = self.getAbsoluteURL(recipe)
389+ self.assertThat(ws_recipe, ContainsDict(dict(
390+ date_created=Equals(recipe.date_created.isoformat()),
391+ date_last_modified=Equals(recipe.date_last_modified.isoformat()),
392+ registrant_link=Equals(self.getAbsoluteURL(recipe.registrant)),
393+ webhooks_collection_link=Equals(recipe_abs_url + "/webhooks"),
394+ name=Equals(recipe.name),
395+ owner_link=Equals(self.getAbsoluteURL(recipe.owner)),
396+ oci_project_link=Equals(self.getAbsoluteURL(oci_project)),
397+ git_ref_link=Equals(self.getAbsoluteURL(recipe.git_ref)),
398+ description=Equals(recipe.description),
399+ build_file=Equals(recipe.build_file),
400+ build_daily=Equals(recipe.build_daily)
401+ )))
402
403 def test_api_patch_oci_recipe(self):
404 with person_logged_in(self.person):
405 distro = self.factory.makeDistribution(owner=self.person)
406- project = removeSecurityProxy(self.factory.makeOCIProject(
407- pillar=distro, registrant=self.person))
408+ oci_project = self.factory.makeOCIProject(
409+ pillar=distro, registrant=self.person)
410 # Only the owner should be able to edit.
411- recipe = removeSecurityProxy(self.factory.makeOCIRecipe(
412- oci_project=project, owner=self.person,
413- registrant=self.person))
414+ recipe = self.factory.makeOCIRecipe(
415+ oci_project=oci_project, owner=self.person,
416+ registrant=self.person)
417 url = api_url(recipe)
418
419 new_description = 'Some other description'
420@@ -450,13 +464,13 @@ class TestOCIRecipeWebservice(TestCaseWithFactory):
421 other_person = self.factory.makePerson()
422 with person_logged_in(other_person):
423 distro = self.factory.makeDistribution(owner=other_person)
424- project = removeSecurityProxy(self.factory.makeOCIProject(
425- pillar=distro, registrant=other_person))
426+ oci_project = self.factory.makeOCIProject(
427+ pillar=distro, registrant=other_person)
428 # Only the owner should be able to edit.
429- recipe = removeSecurityProxy(self.factory.makeOCIRecipe(
430- oci_project=project, owner=other_person,
431+ recipe = self.factory.makeOCIRecipe(
432+ oci_project=oci_project, owner=other_person,
433 registrant=other_person,
434- description="old description"))
435+ description="old description")
436 url = api_url(recipe)
437
438 new_description = 'Some other description'
439@@ -467,3 +481,92 @@ class TestOCIRecipeWebservice(TestCaseWithFactory):
440
441 ws_project = self.load_from_api(url)
442 self.assertEqual("old description", ws_project['description'])
443+
444+ def test_api_create_oci_recipe(self):
445+ with person_logged_in(self.person):
446+ distro = self.factory.makeDistribution(
447+ owner=self.person)
448+ oci_project = self.factory.makeOCIProject(
449+ pillar=distro, registrant=self.person)
450+ git_ref = self.factory.makeGitRefs()[0]
451+
452+ oci_project_url = api_url(oci_project)
453+ git_ref_url = api_url(git_ref)
454+ person_url = api_url(self.person)
455+
456+ obj = {
457+ "name": "my-recipe",
458+ "owner": person_url,
459+ "git_ref": git_ref_url,
460+ "build_file": "./Dockerfile",
461+ "description": "My recipe"}
462+
463+ resp = self.webservice.named_post(oci_project_url, "newRecipe", **obj)
464+ self.assertEqual(201, resp.status, resp.body)
465+
466+ new_obj_url = resp.getHeader("Location")
467+ ws_recipe = self.load_from_api(new_obj_url)
468+
469+ with person_logged_in(self.person):
470+ self.assertThat(ws_recipe, ContainsDict(dict(
471+ name=Equals(obj["name"]),
472+ oci_project_link=Equals(self.getAbsoluteURL(oci_project)),
473+ git_ref_link=Equals(self.getAbsoluteURL(git_ref)),
474+ build_file=Equals(obj["build_file"]),
475+ description=Equals(obj["description"]),
476+ owner_link=Equals(self.getAbsoluteURL(self.person)),
477+ registrant_link=Equals(self.getAbsoluteURL(self.person)),
478+ )))
479+
480+ def test_api_create_oci_recipe_non_legitimate_user(self):
481+ """Ensure that a non-legitimate user cannot create recipe using API"""
482+ self.pushConfig(
483+ 'launchpad', min_legitimate_karma=9999,
484+ min_legitimate_account_age=9999)
485+
486+ with person_logged_in(self.person):
487+ distro = self.factory.makeDistribution(
488+ owner=self.person)
489+ oci_project = self.factory.makeOCIProject(
490+ pillar=distro, registrant=self.person)
491+ git_ref = self.factory.makeGitRefs()[0]
492+
493+ oci_project_url = api_url(oci_project)
494+ git_ref_url = api_url(git_ref)
495+ person_url = api_url(self.person)
496+
497+ obj = {
498+ "name": "My recipe",
499+ "owner": person_url,
500+ "git_ref": git_ref_url,
501+ "build_file": "./Dockerfile",
502+ "description": "My recipe"}
503+
504+ resp = self.webservice.named_post(oci_project_url, "newRecipe", **obj)
505+ self.assertEqual(401, resp.status, resp.body)
506+
507+ def test_api_create_oci_recipe_is_disabled_by_feature_flag(self):
508+ """Ensure that OCI newRecipe API method returns HTTP 401 when the
509+ feature flag is not set."""
510+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: ''}))
511+
512+ with person_logged_in(self.person):
513+ distro = self.factory.makeDistribution(
514+ owner=self.person)
515+ oci_project = self.factory.makeOCIProject(
516+ pillar=distro, registrant=self.person)
517+ git_ref = self.factory.makeGitRefs()[0]
518+
519+ oci_project_url = api_url(oci_project)
520+ git_ref_url = api_url(git_ref)
521+ person_url = api_url(self.person)
522+
523+ obj = {
524+ "name": "My recipe",
525+ "owner": person_url,
526+ "git_ref": git_ref_url,
527+ "build_file": "./Dockerfile",
528+ "description": "My recipe"}
529+
530+ resp = self.webservice.named_post(oci_project_url, "newRecipe", **obj)
531+ self.assertEqual(401, resp.status, resp.body)
532diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
533index eec8850..e60a362 100644
534--- a/lib/lp/oci/tests/test_ocirecipebuild.py
535+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
536@@ -23,7 +23,10 @@ from lp.buildmaster.enums import BuildStatus
537 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
538 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
539 from lp.buildmaster.interfaces.processor import IProcessorSet
540-from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
541+from lp.oci.interfaces.ocirecipe import (
542+ OCI_RECIPE_ALLOW_CREATE,
543+ OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
544+ )
545 from lp.oci.interfaces.ocirecipebuild import (
546 IOCIRecipeBuild,
547 IOCIRecipeBuildSet,
548@@ -54,6 +57,7 @@ class TestOCIRecipeBuild(TestCaseWithFactory):
549
550 def setUp(self):
551 super(TestOCIRecipeBuild, self).setUp()
552+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
553 self.build = self.factory.makeOCIRecipeBuild()
554
555 def test_implements_interface(self):
556@@ -212,6 +216,10 @@ class TestOCIRecipeBuildSet(TestCaseWithFactory):
557
558 layer = DatabaseFunctionalLayer
559
560+ def setUp(self):
561+ super(TestOCIRecipeBuildSet, self).setUp()
562+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
563+
564 def test_implements_interface(self):
565 target = OCIRecipeBuildSet()
566 with admin_logged_in():
567@@ -241,7 +249,8 @@ class TestOCIRecipeBuildSet(TestCaseWithFactory):
568 distribution=distribution, status=SeriesStatus.CURRENT)
569 processor = getUtility(IProcessorSet).getByName("386")
570 self.useFixture(FeatureFixture({
571- "oci.build_series.%s" % distribution.name: distroseries.name}))
572+ "oci.build_series.%s" % distribution.name: distroseries.name,
573+ OCI_RECIPE_ALLOW_CREATE: 'on'}))
574 distro_arch_series = self.factory.makeDistroArchSeries(
575 distroseries=distroseries, architecturetag="i386",
576 processor=processor)
577diff --git a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
578index 58b6adc..1d9328b 100644
579--- a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
580+++ b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
581@@ -1,4 +1,4 @@
582-# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
583+# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
584 # GNU Affero General Public License version 3 (see the file LICENSE).
585
586 """Tests for `OCIRecipeBuildBehaviour`."""
587@@ -63,6 +63,7 @@ from lp.buildmaster.tests.snapbuildproxy import (
588 from lp.buildmaster.tests.test_buildfarmjobbehaviour import (
589 TestGetUploadMethodsMixin,
590 )
591+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
592 from lp.oci.model.ocirecipebuildbehaviour import OCIRecipeBuildBehaviour
593 from lp.registry.interfaces.series import SeriesStatus
594 from lp.services.config import config
595@@ -123,6 +124,10 @@ class TestOCIBuildBehaviour(TestCaseWithFactory):
596
597 layer = LaunchpadZopelessLayer
598
599+ def setUp(self):
600+ super(TestOCIBuildBehaviour, self).setUp()
601+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
602+
603 def test_provides_interface(self):
604 # OCIRecipeBuildBehaviour provides IBuildFarmJobBehaviour.
605 job = OCIRecipeBuildBehaviour(self.factory.makeOCIRecipeBuild())
606@@ -159,6 +164,7 @@ class TestAsyncOCIRecipeBuildBehaviour(MakeOCIBuildMixin, TestCaseWithFactory):
607 self.now = time.time()
608 self.useFixture(fixtures.MockPatch(
609 "time.time", return_value=self.now))
610+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
611
612 @defer.inlineCallbacks
613 def test_composeBuildRequest(self):
614@@ -350,7 +356,8 @@ class TestAsyncOCIRecipeBuildBehaviour(MakeOCIBuildMixin, TestCaseWithFactory):
615 distribution=distribution, status=SeriesStatus.CURRENT)
616 processor = getUtility(IProcessorSet).getByName("386")
617 self.useFixture(FeatureFixture({
618- "oci.build_series.%s" % distribution.name: distroseries.name}))
619+ "oci.build_series.%s" % distribution.name: distroseries.name,
620+ OCI_RECIPE_ALLOW_CREATE: 'on'}))
621 distro_arch_series = self.factory.makeDistroArchSeries(
622 distroseries=distroseries, architecturetag="i386",
623 processor=processor)
624@@ -401,6 +408,7 @@ class TestHandleStatusForOCIRecipeBuild(MakeOCIBuildMixin,
625 def setUp(self):
626 super(TestHandleStatusForOCIRecipeBuild, self).setUp()
627 self.useFixture(fixtures.FakeLogger())
628+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
629 self.build = self.makeBuild()
630 # For the moment, we require a builder for the build so that
631 # handleStatus_OK can get a reference to the slave.
632@@ -627,3 +635,6 @@ class TestHandleStatusForOCIRecipeBuild(MakeOCIBuildMixin,
633 class TestGetUploadMethodsForOCIRecipeBuild(
634 MakeOCIBuildMixin, TestGetUploadMethodsMixin, TestCaseWithFactory):
635 """IPackageBuild.getUpload-related methods work with OCI recipe builds."""
636+ def setUp(self):
637+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
638+ super(TestGetUploadMethodsForOCIRecipeBuild, self).setUp()
639diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
640index d5875c5..f3bcd9f 100644
641--- a/lib/lp/registry/configure.zcml
642+++ b/lib/lp/registry/configure.zcml
643@@ -745,6 +745,9 @@
644 permission="launchpad.Edit"
645 interface="lp.registry.interfaces.ociproject.IOCIProjectEdit"
646 set_schema="lp.registry.interfaces.ociproject.IOCIProjectEditableAttributes" />
647+ <require
648+ permission="launchpad.AnyLegitimatePerson"
649+ interface="lp.registry.interfaces.ociproject.IOCIProjectLegitimate"/>
650 </class>
651 <subscriber
652 for="lp.registry.interfaces.ociproject.IOCIProject zope.lifecycleevent.interfaces.IObjectModifiedEvent"
653diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py
654index b39c71f..8a3e734 100644
655--- a/lib/lp/registry/interfaces/ociproject.py
656+++ b/lib/lp/registry/interfaces/ociproject.py
657@@ -13,16 +13,23 @@ __all__ = [
658 ]
659
660 from lazr.restful.declarations import (
661+ call_with,
662 export_as_webservice_entry,
663+ export_factory_operation,
664 exported,
665+ operation_for_version,
666+ operation_parameters,
667+ REQUEST_USER,
668 )
669 from lazr.restful.fields import (
670 CollectionField,
671 Reference,
672 ReferenceChoice,
673 )
674+from lp.app.validators.path import path_does_not_escape
675 from zope.interface import Interface
676 from zope.schema import (
677+ Bool,
678 Datetime,
679 Int,
680 Text,
681@@ -32,12 +39,16 @@ from zope.schema import (
682 from lp import _
683 from lp.app.validators.name import name_validator
684 from lp.bugs.interfaces.bugtarget import IBugTarget
685+from lp.code.interfaces.gitref import IGitRef
686 from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
687 from lp.registry.interfaces.distribution import IDistribution
688 from lp.registry.interfaces.ociprojectname import IOCIProjectName
689 from lp.registry.interfaces.series import SeriesStatus
690 from lp.services.database.constants import DEFAULT
691-from lp.services.fields import PublicPersonChoice
692+from lp.services.fields import (
693+ PersonChoice,
694+ PublicPersonChoice,
695+ )
696
697
698 OCI_PROJECT_ALLOW_CREATE = 'oci.project.create.enabled'
699@@ -105,8 +116,44 @@ class IOCIProjectEdit(Interface):
700 """Creates a new `IOCIProjectSeries`."""
701
702
703+class IOCIProjectLegitimate(Interface):
704+ """IOCIProject methods that require launchpad.AnyLegitimatePerson
705+ permission.
706+ """
707+ @call_with(registrant=REQUEST_USER)
708+ @operation_parameters(
709+ name=TextLine(
710+ title=_("OCI Recipe name."),
711+ description=_("The name of the new OCI Recipe."),
712+ required=True),
713+ owner=PersonChoice(
714+ title=_("Person or team that owns the new OCI Recipe."),
715+ vocabulary="AllUserTeamsParticipationPlusSelf",
716+ required=True),
717+ git_ref=Reference(IGitRef, title=_("Git branch."), required=True),
718+ build_file=TextLine(
719+ title=_("Build file path."),
720+ description=_(
721+ "The relative path to the file within this recipe's "
722+ "branch that defines how to build the recipe."),
723+ constraint=path_does_not_escape,
724+ required=True),
725+ description=Text(
726+ title=_("Description for this recipe."),
727+ description=_("A short description of this recipe."),
728+ required=False),
729+ build_daily=Bool(
730+ title=_("Should this recipe be built daily?."), required=False))
731+ @export_factory_operation(Interface, [])
732+ @operation_for_version("devel")
733+ def newRecipe(name, registrant, owner, git_ref, build_file,
734+ description=None, build_daily=False,
735+ require_virtualized=True):
736+ """Create an IOCIRecipe for this project."""
737+
738+
739 class IOCIProject(IOCIProjectView, IOCIProjectEdit,
740- IOCIProjectEditableAttributes):
741+ IOCIProjectEditableAttributes, IOCIProjectLegitimate):
742 """A project containing Open Container Initiative recipes."""
743
744 export_as_webservice_entry(
745diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py
746index aa3a322..2967007 100644
747--- a/lib/lp/registry/model/ociproject.py
748+++ b/lib/lp/registry/model/ociproject.py
749@@ -25,6 +25,7 @@ from zope.interface import implementer
750 from zope.security.proxy import removeSecurityProxy
751
752 from lp.bugs.model.bugtarget import BugTargetBase
753+from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
754 from lp.registry.interfaces.distribution import IDistribution
755 from lp.registry.interfaces.ociproject import (
756 IOCIProject,
757@@ -107,6 +108,21 @@ class OCIProject(BugTargetBase, StormBase):
758 bugtargetname = display_name
759 bugtargetdisplayname = display_name
760
761+ def newRecipe(self, name, registrant, owner, git_ref,
762+ build_file, description=None, build_daily=False,
763+ require_virtualized=True):
764+ return getUtility(IOCIRecipeSet).new(
765+ name=name,
766+ registrant=registrant,
767+ owner=owner,
768+ oci_project=self,
769+ git_ref=git_ref,
770+ build_file=build_file,
771+ description=description,
772+ require_virtualized=require_virtualized,
773+ build_daily=build_daily,
774+ )
775+
776 def newSeries(self, name, summary, registrant,
777 status=SeriesStatus.DEVELOPMENT, date_created=DEFAULT):
778 """See `IOCIProject`."""
779diff --git a/lib/lp/registry/tests/test_personmerge.py b/lib/lp/registry/tests/test_personmerge.py
780index 890b421..02fefcc 100644
781--- a/lib/lp/registry/tests/test_personmerge.py
782+++ b/lib/lp/registry/tests/test_personmerge.py
783@@ -1,4 +1,4 @@
784-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
785+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
786 # GNU Affero General Public License version 3 (see the file LICENSE).
787
788 """Tests for merge_people."""
789@@ -18,7 +18,10 @@ from zope.security.proxy import removeSecurityProxy
790 from lp.app.enums import InformationType
791 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
792 from lp.code.interfaces.gitrepository import IGitRepositorySet
793-from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
794+from lp.oci.interfaces.ocirecipe import (
795+ IOCIRecipeSet,
796+ OCI_RECIPE_ALLOW_CREATE,
797+ )
798 from lp.registry.interfaces.accesspolicy import (
799 IAccessArtifactGrantSource,
800 IAccessPolicyGrantSource,
801@@ -664,6 +667,7 @@ class TestMergePeople(TestCaseWithFactory, KarmaTestMixin):
802 def test_merge_moves_oci_recipes(self):
803 # When person/teams are merged, oci recipes owned by the from
804 # person are moved.
805+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
806 duplicate = self.factory.makePerson()
807 mergee = self.factory.makePerson()
808 self.factory.makeOCIRecipe(registrant=duplicate, owner=duplicate)
809@@ -676,6 +680,7 @@ class TestMergePeople(TestCaseWithFactory, KarmaTestMixin):
810 def test_merge_with_duplicated_oci_recipes(self):
811 # If both the from and to people have oci recipes with the same
812 # name, merging renames the duplicate from the from person's side.
813+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
814 duplicate = self.factory.makePerson()
815 mergee = self.factory.makePerson()
816 [ref] = self.factory.makeGitRefs()
817diff --git a/lib/lp/services/webhooks/tests/test_job.py b/lib/lp/services/webhooks/tests/test_job.py
818index caf81ce..fde24da 100644
819--- a/lib/lp/services/webhooks/tests/test_job.py
820+++ b/lib/lp/services/webhooks/tests/test_job.py
821@@ -37,7 +37,10 @@ from zope.component import getUtility
822 from zope.security.proxy import removeSecurityProxy
823
824 from lp.app import versioninfo
825-from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
826+from lp.oci.interfaces.ocirecipe import (
827+ OCI_RECIPE_ALLOW_CREATE,
828+ OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
829+ )
830 from lp.services.database.interfaces import IStore
831 from lp.services.features.testing import FeatureFixture
832 from lp.services.job.interfaces.job import JobStatus
833@@ -358,7 +361,8 @@ class TestWebhookDeliveryJob(TestCaseWithFactory):
834 def test_oci_recipe__repr__(self):
835 # `WebhookDeliveryJob` objects for OCI recipes have an informative
836 # __repr__.
837- with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on"}):
838+ with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
839+ OCI_RECIPE_ALLOW_CREATE: 'on'}):
840 recipe = self.factory.makeOCIRecipe()
841 hook = self.factory.makeWebhook(target=recipe)
842 job = WebhookDeliveryJob.create(hook, 'test', payload={'foo': 'bar'})