Merge ~pappacena/launchpad:ui-ocirecipe-args into launchpad:master

Proposed by Thiago F. Pappacena
Status: Superseded
Proposed branch: ~pappacena/launchpad:ui-ocirecipe-args
Merge into: launchpad:master
Diff against target: 619 lines (+251/-14) (has conflicts)
12 files modified
lib/lp/oci/browser/ocirecipe.py (+62/-4)
lib/lp/oci/browser/tests/test_ocirecipe.py (+92/-0)
lib/lp/oci/interfaces/ocirecipe.py (+8/-1)
lib/lp/oci/model/ocirecipe.py (+16/-3)
lib/lp/oci/model/ocirecipebuildbehaviour.py (+2/-1)
lib/lp/oci/templates/ocirecipe-index.pt (+8/-0)
lib/lp/oci/templates/ocirecipe-new.pt (+3/-0)
lib/lp/oci/tests/test_ocirecipe.py (+43/-1)
lib/lp/oci/tests/test_ocirecipebuildbehaviour.py (+3/-0)
lib/lp/registry/interfaces/ociproject.py (+9/-1)
lib/lp/registry/model/ociproject.py (+2/-1)
lib/lp/testing/factory.py (+3/-2)
Conflict in lib/lp/oci/browser/ocirecipe.py
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+389930@code.launchpad.net

This proposal has been superseded by a proposal from 2020-08-27.

Commit message

UI to manage build ARGs for OCI recipes

To post a comment you must log in.

Unmerged commits

6619044... by Thiago F. Pappacena

Adding tests

0f6d016... by Thiago F. Pappacena

Merge branch 'ocirecipe-args' into ui-ocirecipe-args

7e10963... by Thiago F. Pappacena

Changing OCIRecipe.build_args datatype to jsonb

9554b98... by Thiago F. Pappacena

Adding build args field to recipe creation and index

3501818... by Thiago F. Pappacena

Adding form field to edit OCIRecipe.build_args

2bf5e96... by Thiago F. Pappacena

Merge branch 'ocirecipe-args' into ui-ocirecipe-args

b8398e4... by Thiago F. Pappacena

Changing OCIRecipe.build_args datatype to jsonb

0392538... by Thiago F. Pappacena

Changing OCIRecipe.build_args datatype to jsonb

16e88a9... by Thiago F. Pappacena

Add OCIRecipe.build_args as parameter to OCIRecipeBuildBehaviour

bf2c7b1... by Thiago F. Pappacena

Adding Docker ARG option to OCI Recipe

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
2index c2dceb2..6b5b338 100644
3--- a/lib/lp/oci/browser/ocirecipe.py
4+++ b/lib/lp/oci/browser/ocirecipe.py
5@@ -19,22 +19,32 @@ __all__ = [
6 'OCIRecipeView',
7 ]
8
9+
10 from lazr.restful.interface import (
11 copy_field,
12 use_template,
13 )
14 from zope.component import getUtility
15 from zope.formlib.form import FormFields
16+<<<<<<< lib/lp/oci/browser/ocirecipe.py
17 from zope.formlib.widget import (
18 DisplayWidget,
19 renderElement,
20 )
21+=======
22+from zope.formlib.textwidgets import TextAreaWidget
23+from zope.formlib.widget import CustomWidgetFactory
24+>>>>>>> lib/lp/oci/browser/ocirecipe.py
25 from zope.interface import Interface
26 from zope.schema import (
27 Bool,
28 Choice,
29 List,
30+<<<<<<< lib/lp/oci/browser/ocirecipe.py
31 Password,
32+=======
33+ Text,
34+>>>>>>> lib/lp/oci/browser/ocirecipe.py
35 TextLine,
36 ValidationError,
37 )
38@@ -242,6 +252,11 @@ class OCIRecipeView(LaunchpadView):
39 else:
40 return "Built on request"
41
42+ @property
43+ def build_args(self):
44+ return "\n".join(
45+ "%s=%s" % (k, v) for k, v in self.context.build_args.items())
46+
47
48 def builds_for_recipe(recipe):
49 """A list of interesting builds.
50@@ -676,13 +691,51 @@ class IOCIRecipeEditSchema(Interface):
51 "description",
52 "git_ref",
53 "build_file",
54+ "build_args",
55 "build_daily",
56 "require_virtualized",
57 "allow_internet",
58 ])
59
60
61-class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin):
62+class OCIRecipeFormMixin:
63+ """Mixin with common processing for both edit and add views."""
64+ custom_widget_build_args = CustomWidgetFactory(
65+ TextAreaWidget, height=5, width=100)
66+
67+ def createBuildArgsField(self):
68+ """Create a form field for OCIRecipe.build_args attribute."""
69+ if IOCIRecipe.providedBy(self.context):
70+ default = "\n".join(
71+ "%s=%s" % (k, v) for k, v in self.context.build_args.items())
72+ else:
73+ default = ""
74+ return FormFields(Text(
75+ __name__='build_args',
76+ title=u'Extra build ARG variables',
77+ description=("One per line. Each ARG should be in the format "
78+ "of ARG_KEY=arg_value."),
79+ default=default,
80+ required=False, readonly=False))
81+
82+ def validateBuildArgs(self, data):
83+ field_value = data.get('build_args')
84+ if not field_value:
85+ return
86+ build_args = {}
87+ for i, line in enumerate(field_value.split("\n")):
88+ if '=' not in line:
89+ msg = ("'%s' at line %s is not a valid KEY=value pair." %
90+ (line, i + 1))
91+ self.setFieldError("build_args", str(msg))
92+ return
93+ k, v = line.split('=', 1)
94+ build_args[k] = v
95+ data['build_args'] = build_args
96+
97+
98+class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
99+ OCIRecipeFormMixin):
100 """View for creating OCI recipes."""
101
102 page_title = label = "Create a new OCI recipe"
103@@ -706,6 +759,7 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin):
104 def setUpFields(self):
105 """See `LaunchpadFormView`."""
106 super(OCIRecipeAddView, self).setUpFields()
107+ self.form_fields += self.createBuildArgsField()
108 self.form_fields += self.createEnabledProcessors(
109 getUtility(IProcessorSet).getAll(),
110 "The architectures that this OCI recipe builds for. Some "
111@@ -745,6 +799,7 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin):
112 "There is already an OCI recipe owned by %s in %s with "
113 "this name." % (
114 owner.display_name, self.context.display_name))
115+ self.validateBuildArgs(data)
116
117 @action("Create OCI recipe", name="create")
118 def create_action(self, action, data):
119@@ -752,7 +807,8 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin):
120 name=data["name"], registrant=self.user, owner=data["owner"],
121 oci_project=self.context, git_ref=data["git_ref"],
122 build_file=data["build_file"], description=data["description"],
123- build_daily=data["build_daily"], processors=data["processors"])
124+ build_daily=data["build_daily"], build_args=data["build_args"],
125+ processors=data["processors"])
126 self.next_url = canonical_url(recipe)
127
128
129@@ -794,7 +850,8 @@ class OCIRecipeAdminView(BaseOCIRecipeEditView):
130 field_names = ("require_virtualized", "allow_internet")
131
132
133-class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin):
134+class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
135+ OCIRecipeFormMixin):
136 """View for editing OCI recipes."""
137
138 @property
139@@ -816,6 +873,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin):
140 def setUpFields(self):
141 """See `LaunchpadFormView`."""
142 super(OCIRecipeEditView, self).setUpFields()
143+ self.form_fields += self.createBuildArgsField()
144 self.form_fields += self.createEnabledProcessors(
145 self.context.available_processors,
146 "The architectures that this OCI recipe builds for. Some "
147@@ -855,7 +913,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin):
148 # This processor is restricted and currently
149 # enabled. Leave it untouched.
150 data["processors"].append(processor)
151-
152+ self.validateBuildArgs(data)
153
154 class OCIRecipeDeleteView(BaseOCIRecipeEditView):
155 """View for deleting OCI recipes."""
156diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
157index 8b17168..d910065 100644
158--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
159+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
160@@ -215,6 +215,26 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
161 "Build schedule:\nBuilt on request\nEdit OCI recipe\n",
162 MatchesTagText(content, "build-schedule"))
163
164+ def test_create_new_recipe_with_build_args(self):
165+ oci_project = self.factory.makeOCIProject()
166+ [git_ref] = self.factory.makeGitRefs()
167+ browser = self.getViewBrowser(
168+ oci_project, view_name="+new-recipe", user=self.person)
169+ browser.getControl(name="field.name").value = "recipe-name"
170+ browser.getControl("Description").value = "Recipe description"
171+ browser.getControl("Git repository").value = (
172+ git_ref.repository.identity)
173+ browser.getControl("Git branch").value = git_ref.path
174+ browser.getControl("Extra build ARG variables").value = (
175+ "VAR1=10\nVAR2=20")
176+ browser.getControl("Create OCI recipe").click()
177+
178+ content = find_main_content(browser.contents)
179+ self.assertEqual("recipe-name", extract_text(content.h1))
180+ self.assertThat(
181+ "Build ARG:\nVAR1=10\nVAR2=20",
182+ MatchesTagText(content, "build-args"))
183+
184 def test_create_new_recipe_users_teams_as_owner_options(self):
185 # Teams that the user is in are options for the OCI recipe owner.
186 self.factory.makeTeam(
187@@ -466,6 +486,47 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView):
188 login_person(self.person)
189 self.assertRecipeProcessors(recipe, ["386", "amd64"])
190
191+ def test_edit_build_args(self):
192+ self.setUpDistroSeries()
193+ oci_project = self.factory.makeOCIProject(pillar=self.distribution)
194+ recipe = self.factory.makeOCIRecipe(
195+ registrant=self.person, owner=self.person,
196+ oci_project=oci_project, build_args={"VAR1": "xxx", "VAR2": "uu"})
197+ browser = self.getViewBrowser(
198+ recipe, view_name="+edit", user=recipe.owner)
199+ args = browser.getControl(name="field.build_args")
200+ self.assertContentEqual("VAR1=xxx\r\nVAR2=uu", args.value)
201+ args.value = "VAR=aa\nANOTHER_VAR=bbb"
202+ browser.getControl("Update OCI recipe").click()
203+ login_person(self.person)
204+ IStore(recipe).reload(recipe)
205+ self.assertEqual(
206+ {"VAR": "aa", "ANOTHER_VAR": "bbb"}, recipe.build_args)
207+
208+ def test_edit_build_args_invalid_content(self):
209+ self.setUpDistroSeries()
210+ oci_project = self.factory.makeOCIProject(pillar=self.distribution)
211+ recipe = self.factory.makeOCIRecipe(
212+ registrant=self.person, owner=self.person,
213+ oci_project=oci_project, build_args={"VAR1": "xxx", "VAR2": "uu"})
214+ browser = self.getViewBrowser(
215+ recipe, view_name="+edit", user=recipe.owner)
216+ args = browser.getControl(name="field.build_args")
217+ self.assertContentEqual("VAR1=xxx\r\nVAR2=uu", args.value)
218+ args.value = "VAR=aa\nmessed up text"
219+ browser.getControl("Update OCI recipe").click()
220+
221+ # Error message should be shown.
222+ content = find_main_content(browser.contents)
223+ self.assertIn(
224+ "'messed up text' at line 2 is not a valid KEY=value pair.",
225+ extract_text(content))
226+
227+ # Assert that recipe still have the original build_args.
228+ login_person(self.person)
229+ IStore(recipe).reload(recipe)
230+ self.assertEqual({"VAR1": "xxx", "VAR2": "uu"}, recipe.build_args)
231+
232 def test_edit_with_invisible_processor(self):
233 # It's possible for existing recipes to have an enabled processor
234 # that's no longer usable with the current distroseries, which will
235@@ -705,6 +766,37 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
236 """ % (oci_project_name, oci_project_display),
237 self.getMainText(build.recipe))
238
239+ def test_index_with_build_args(self):
240+ oci_project = self.factory.makeOCIProject(
241+ pillar=self.distroseries.distribution)
242+ oci_project_name = oci_project.name
243+ oci_project_display = oci_project.display_name
244+ [ref] = self.factory.makeGitRefs(
245+ owner=self.person, target=self.person, name="recipe-repository",
246+ paths=["refs/heads/master"])
247+ recipe = self.makeOCIRecipe(
248+ oci_project=oci_project, git_ref=ref, build_file="Dockerfile",
249+ build_args={"VAR1": "123", "VAR2": "XXX"})
250+ build = self.makeBuild(
251+ recipe=recipe, status=BuildStatus.FULLYBUILT,
252+ duration=timedelta(minutes=30))
253+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
254+ %s OCI project
255+ recipe-name
256+ .*
257+ OCI recipe information
258+ Owner: Test Person
259+ OCI project: %s
260+ Source: ~test-person/\\+git/recipe-repository:master
261+ Build file path: Dockerfile
262+ Build schedule: Built on request
263+ Build ARG: VAR1=123 VAR2=XXX
264+ Latest builds
265+ Status When complete Architecture
266+ Successfully built 30 minutes ago 386
267+ """ % (oci_project_name, oci_project_display),
268+ self.getMainText(build.recipe))
269+
270 def test_index_success_with_buildlog(self):
271 # The build log is shown if it is there.
272 build = self.makeBuild(
273diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
274index c433825..9e8edad 100644
275--- a/lib/lp/oci/interfaces/ocirecipe.py
276+++ b/lib/lp/oci/interfaces/ocirecipe.py
277@@ -387,6 +387,13 @@ class IOCIRecipeEditableAttributes(IHasOwner):
278 required=True,
279 readonly=False))
280
281+ build_args = exported(Dict(
282+ title=_("Build ARG variables"),
283+ description=_("The dictionary of ARG variables to be used when "
284+ "building this recipe."),
285+ required=False,
286+ readonly=False))
287+
288 build_daily = exported(Bool(
289 title=_("Build daily"),
290 required=True,
291@@ -433,7 +440,7 @@ class IOCIRecipeSet(Interface):
292 def new(name, registrant, owner, oci_project, git_ref, build_file,
293 description=None, official=False, require_virtualized=True,
294 build_daily=False, processors=None, date_created=DEFAULT,
295- allow_internet=True):
296+ allow_internet=True, build_args=None):
297 """Create an IOCIRecipe."""
298
299 def exists(owner, oci_project, name):
300diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
301index 2a83ccc..c3c2ad2 100644
302--- a/lib/lp/oci/model/ocirecipe.py
303+++ b/lib/lp/oci/model/ocirecipe.py
304@@ -14,6 +14,7 @@ __all__ = [
305
306 from lazr.lifecycle.event import ObjectCreatedEvent
307 import pytz
308+from storm.databases.postgres import JSON
309 from storm.expr import (
310 And,
311 Desc,
312@@ -146,6 +147,8 @@ class OCIRecipe(Storm, WebhookTargetMixin):
313 git_path = Unicode(name="git_path", allow_none=True)
314 build_file = Unicode(name="build_file", allow_none=False)
315
316+ _build_args = JSON(name="build_args", allow_none=True)
317+
318 require_virtualized = Bool(name="require_virtualized", default=True,
319 allow_none=False)
320
321@@ -156,7 +159,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
322 def __init__(self, name, registrant, owner, oci_project, git_ref,
323 description=None, official=False, require_virtualized=True,
324 build_file=None, build_daily=False, date_created=DEFAULT,
325- allow_internet=True):
326+ allow_internet=True, build_args=None):
327 if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE):
328 raise OCIRecipeFeatureDisabled()
329 super(OCIRecipe, self).__init__()
330@@ -173,6 +176,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
331 self.date_last_modified = date_created
332 self.git_ref = git_ref
333 self.allow_internet = allow_internet
334+ self.build_args = build_args or {}
335
336 def __repr__(self):
337 return "<OCIRecipe ~%s/%s/+oci/%s/+recipe/%s>" % (
338@@ -188,6 +192,15 @@ class OCIRecipe(Storm, WebhookTargetMixin):
339 """See `IOCIProject.setOfficialRecipe` method."""
340 return self._official
341
342+ @property
343+ def build_args(self):
344+ return self._build_args or {}
345+
346+ @build_args.setter
347+ def build_args(self, value):
348+ assert value is None or isinstance(value, dict)
349+ self._build_args = {k: str(v) for k, v in (value or {}).items()}
350+
351 def destroySelf(self):
352 """See `IOCIRecipe`."""
353 # XXX twom 2019-11-26 This needs to expand as more build artifacts
354@@ -538,7 +551,7 @@ class OCIRecipeSet:
355 def new(self, name, registrant, owner, oci_project, git_ref, build_file,
356 description=None, official=False, require_virtualized=True,
357 build_daily=False, processors=None, date_created=DEFAULT,
358- allow_internet=True):
359+ allow_internet=True, build_args=None):
360 """See `IOCIRecipeSet`."""
361 if not registrant.inTeam(owner):
362 if owner.is_team:
363@@ -560,7 +573,7 @@ class OCIRecipeSet:
364 oci_recipe = OCIRecipe(
365 name, registrant, owner, oci_project, git_ref, description,
366 official, require_virtualized, build_file, build_daily,
367- date_created, allow_internet)
368+ date_created, allow_internet, build_args)
369 store.add(oci_recipe)
370
371 if processors is None:
372diff --git a/lib/lp/oci/model/ocirecipebuildbehaviour.py b/lib/lp/oci/model/ocirecipebuildbehaviour.py
373index fce6dd3..1a5f78e 100644
374--- a/lib/lp/oci/model/ocirecipebuildbehaviour.py
375+++ b/lib/lp/oci/model/ocirecipebuildbehaviour.py
376@@ -1,4 +1,4 @@
377-# Copyright 2019 Canonical Ltd. This software is licensed under the
378+# Copyright 2019-2020 Canonical Ltd. This software is licensed under the
379 # GNU Affero General Public License version 3 (see the file LICENSE).
380
381 """An `IBuildFarmJobBehaviour` for `OCIRecipeBuild`.
382@@ -96,6 +96,7 @@ class OCIRecipeBuildBehaviour(SnapProxyMixin, BuildFarmJobBehaviourBase):
383 logger=logger))
384
385 args['build_file'] = build.recipe.build_file
386+ args['build_args'] = build.recipe.build_args
387
388 if build.recipe.git_ref is not None:
389 args["git_repository"] = (
390diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
391index 21a6939..2de9c29 100644
392--- a/lib/lp/oci/templates/ocirecipe-index.pt
393+++ b/lib/lp/oci/templates/ocirecipe-index.pt
394@@ -64,6 +64,14 @@
395 <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
396 </dd>
397 </dl>
398+ <dl id="build-args"
399+ tal:define="build_args view/build_args"
400+ tal:condition="build_args">
401+ <dt>Build ARG:</dt>
402+ <dd>
403+ <pre><span tal:replace="build_args"/></pre>
404+ </dd>
405+ </dl>
406 </div>
407
408 <h2>Latest builds</h2>
409diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt
410index e5f2686..13c93c3 100644
411--- a/lib/lp/oci/templates/ocirecipe-new.pt
412+++ b/lib/lp/oci/templates/ocirecipe-new.pt
413@@ -32,6 +32,9 @@
414 <tal:widget define="widget nocall:view/widgets/build_daily">
415 <metal:block use-macro="context/@@launchpad_form/widget_row" />
416 </tal:widget>
417+ <tal:widget define="widget nocall:view/widgets/build_args">
418+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
419+ </tal:widget>
420 <tal:widget define="widget nocall:view/widgets/processors">
421 <metal:block use-macro="context/@@launchpad_form/widget_row" />
422 </tal:widget>
423diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
424index ad67a7a..faa8862 100644
425--- a/lib/lp/oci/tests/test_ocirecipe.py
426+++ b/lib/lp/oci/tests/test_ocirecipe.py
427@@ -58,6 +58,7 @@ from lp.services.database.constants import (
428 ONE_DAY_AGO,
429 UTC_NOW,
430 )
431+from lp.services.database.interfaces import IStore
432 from lp.services.database.sqlbase import flush_database_caches
433 from lp.services.features.testing import FeatureFixture
434 from lp.services.job.runner import JobRunner
435@@ -664,6 +665,43 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory):
436 [recipe3, recipe1, recipe2],
437 list(oci_project.searchRecipes(u"a")))
438
439+ def test_build_args_dict(self):
440+ args = {"MY_VERSION": "1.0.3", "ANOTHER_VERSION": "2.9.88"}
441+ recipe = self.factory.makeOCIRecipe(build_args=args)
442+ # Force fetch it from database
443+ store = IStore(recipe)
444+ store.invalidate(recipe)
445+ self.assertEqual(args, recipe.build_args)
446+
447+ def test_build_args_not_dict(self):
448+ invalid_build_args_set = [
449+ [1, 2, 3],
450+ "some string",
451+ 123,
452+ ]
453+ for invalid_build_args in invalid_build_args_set:
454+ self.assertRaises(
455+ AssertionError, self.factory.makeOCIRecipe,
456+ build_args=invalid_build_args)
457+
458+ def test_build_args_flatten_dict(self):
459+ # Makes sure we only store one level of key=pair, flattening to
460+ # string every value.
461+ args = {
462+ "VAR1": {b"something": [1, 2, 3]},
463+ "VAR2": 123,
464+ "VAR3": "A string",
465+ }
466+ recipe = self.factory.makeOCIRecipe(build_args=args)
467+ # Force fetch it from database
468+ store = IStore(recipe)
469+ store.invalidate(recipe)
470+ self.assertEqual({
471+ "VAR1": "{'something': [1, 2, 3]}",
472+ "VAR2": "123",
473+ "VAR3": "A string",
474+ }, recipe.build_args)
475+
476
477 class TestOCIRecipeProcessors(TestCaseWithFactory):
478
479@@ -987,7 +1025,8 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
480 oci_project = self.factory.makeOCIProject(
481 registrant=self.person)
482 recipe = self.factory.makeOCIRecipe(
483- oci_project=oci_project)
484+ oci_project=oci_project,
485+ build_args={"VAR_A": "123"})
486 url = api_url(recipe)
487
488 ws_recipe = self.load_from_api(url)
489@@ -1006,6 +1045,7 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
490 git_ref_link=Equals(self.getAbsoluteURL(recipe.git_ref)),
491 description=Equals(recipe.description),
492 build_file=Equals(recipe.build_file),
493+ build_args=Equals({"VAR_A": "123"}),
494 build_daily=Equals(recipe.build_daily)
495 )))
496
497@@ -1070,6 +1110,7 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
498 "owner": person_url,
499 "git_ref": git_ref_url,
500 "build_file": "./Dockerfile",
501+ "build_args": {"VAR": "VAR VALUE"},
502 "description": "My recipe"}
503
504 resp = self.webservice.named_post(oci_project_url, "newRecipe", **obj)
505@@ -1087,6 +1128,7 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
506 description=Equals(obj["description"]),
507 owner_link=Equals(self.getAbsoluteURL(self.person)),
508 registrant_link=Equals(self.getAbsoluteURL(self.person)),
509+ build_args=Equals({"VAR": "VAR VALUE"})
510 )))
511
512 def test_api_create_oci_recipe_non_legitimate_user(self):
513diff --git a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
514index 3fdf8b4..e6247fe 100644
515--- a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
516+++ b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
517@@ -111,6 +111,7 @@ class MakeOCIBuildMixin:
518 build = self.factory.makeOCIRecipeBuild(
519 recipe=recipe, **kwargs)
520 build.recipe.git_ref = git_ref
521+ build.recipe.build_args = {"BUILD_VAR": "123"}
522
523 job = IBuildFarmJobBehaviour(build)
524 builder = MockBuilder()
525@@ -242,6 +243,7 @@ class TestAsyncOCIRecipeBuildBehaviour(MakeOCIBuildMixin, TestCaseWithFactory):
526 "archives": Equals(expected_archives),
527 "arch_tag": Equals("i386"),
528 "build_file": Equals(job.build.recipe.build_file),
529+ "build_args": Equals({"BUILD_VAR": "123"}),
530 "build_url": Equals(canonical_url(job.build)),
531 "fast_cleanup": Is(True),
532 "git_repository": Equals(ref.repository.git_https_url),
533@@ -272,6 +274,7 @@ class TestAsyncOCIRecipeBuildBehaviour(MakeOCIBuildMixin, TestCaseWithFactory):
534 "archives": Equals(expected_archives),
535 "arch_tag": Equals("i386"),
536 "build_file": Equals(job.build.recipe.build_file),
537+ "build_args": Equals({"BUILD_VAR": "123"}),
538 "build_url": Equals(canonical_url(job.build)),
539 "fast_cleanup": Is(True),
540 "git_repository": Equals(ref.repository.git_https_url),
541diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py
542index 2454662..e72a4cf 100644
543--- a/lib/lp/registry/interfaces/ociproject.py
544+++ b/lib/lp/registry/interfaces/ociproject.py
545@@ -33,6 +33,7 @@ from zope.interface import Interface
546 from zope.schema import (
547 Bool,
548 Datetime,
549+ Dict,
550 Int,
551 Text,
552 TextLine,
553@@ -164,6 +165,13 @@ class IOCIProjectLegitimate(Interface):
554 "branch that defines how to build the recipe."),
555 constraint=path_does_not_escape,
556 required=True),
557+ build_args=Dict(
558+ title=_("Build ARGs to be used when building the recipe"),
559+ description=_(
560+ "A dict of VARIABLE=VALUE to be used as ARG when building "
561+ "the recipe."
562+ ),
563+ required=False),
564 description=Text(
565 title=_("Description for this recipe."),
566 description=_("A short description of this recipe."),
567@@ -174,7 +182,7 @@ class IOCIProjectLegitimate(Interface):
568 @operation_for_version("devel")
569 def newRecipe(name, registrant, owner, git_ref, build_file,
570 description=None, build_daily=False,
571- require_virtualized=True):
572+ require_virtualized=True, build_args=None):
573 """Create an IOCIRecipe for this project."""
574
575
576diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py
577index 28b3dc0..c27f2c6 100644
578--- a/lib/lp/registry/model/ociproject.py
579+++ b/lib/lp/registry/model/ociproject.py
580@@ -130,7 +130,7 @@ class OCIProject(BugTargetBase, StormBase):
581
582 def newRecipe(self, name, registrant, owner, git_ref,
583 build_file, description=None, build_daily=False,
584- require_virtualized=True):
585+ require_virtualized=True, build_args=None):
586 return getUtility(IOCIRecipeSet).new(
587 name=name,
588 registrant=registrant,
589@@ -138,6 +138,7 @@ class OCIProject(BugTargetBase, StormBase):
590 oci_project=self,
591 git_ref=git_ref,
592 build_file=build_file,
593+ build_args=build_args,
594 description=description,
595 require_virtualized=require_virtualized,
596 build_daily=build_daily,
597diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
598index efc5c57..a1de3ac 100644
599--- a/lib/lp/testing/factory.py
600+++ b/lib/lp/testing/factory.py
601@@ -4999,7 +4999,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
602 oci_project=None, git_ref=None, description=None,
603 official=False, require_virtualized=True,
604 build_file=None, date_created=DEFAULT,
605- allow_internet=True):
606+ allow_internet=True, build_args=None):
607 """Make a new OCIRecipe."""
608 if name is None:
609 name = self.getUniqueString(u"oci-recipe-name")
610@@ -5026,7 +5026,8 @@ class BareLaunchpadObjectFactory(ObjectFactory):
611 official=official,
612 require_virtualized=require_virtualized,
613 date_created=date_created,
614- allow_internet=allow_internet)
615+ allow_internet=allow_internet,
616+ build_args=build_args)
617
618 def makeOCIRecipeArch(self, recipe=None, processor=None):
619 """Make a new OCIRecipeArch."""