← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:ui-ocirecipe-args into launchpad:master.

Commit message:
UI to manage build ARGs for OCI recipes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/389930
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:ui-ocirecipe-args into launchpad:master.
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index c2dceb2..6b5b338 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -19,22 +19,32 @@ __all__ = [
     'OCIRecipeView',
     ]
 
+
 from lazr.restful.interface import (
     copy_field,
     use_template,
     )
 from zope.component import getUtility
 from zope.formlib.form import FormFields
+<<<<<<< lib/lp/oci/browser/ocirecipe.py
 from zope.formlib.widget import (
     DisplayWidget,
     renderElement,
     )
+=======
+from zope.formlib.textwidgets import TextAreaWidget
+from zope.formlib.widget import CustomWidgetFactory
+>>>>>>> lib/lp/oci/browser/ocirecipe.py
 from zope.interface import Interface
 from zope.schema import (
     Bool,
     Choice,
     List,
+<<<<<<< lib/lp/oci/browser/ocirecipe.py
     Password,
+=======
+    Text,
+>>>>>>> lib/lp/oci/browser/ocirecipe.py
     TextLine,
     ValidationError,
     )
@@ -242,6 +252,11 @@ class OCIRecipeView(LaunchpadView):
         else:
             return "Built on request"
 
+    @property
+    def build_args(self):
+        return "\n".join(
+            "%s=%s" % (k, v) for k, v in self.context.build_args.items())
+
 
 def builds_for_recipe(recipe):
     """A list of interesting builds.
@@ -676,13 +691,51 @@ class IOCIRecipeEditSchema(Interface):
         "description",
         "git_ref",
         "build_file",
+        "build_args",
         "build_daily",
         "require_virtualized",
         "allow_internet",
         ])
 
 
-class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin):
+class OCIRecipeFormMixin:
+    """Mixin with common processing for both edit and add views."""
+    custom_widget_build_args = CustomWidgetFactory(
+        TextAreaWidget, height=5, width=100)
+
+    def createBuildArgsField(self):
+        """Create a form field for OCIRecipe.build_args attribute."""
+        if IOCIRecipe.providedBy(self.context):
+            default = "\n".join(
+                "%s=%s" % (k, v) for k, v in self.context.build_args.items())
+        else:
+            default = ""
+        return FormFields(Text(
+            __name__='build_args',
+            title=u'Extra build ARG variables',
+            description=("One per line. Each ARG should be in the format "
+                         "of ARG_KEY=arg_value."),
+            default=default,
+            required=False, readonly=False))
+
+    def validateBuildArgs(self, data):
+        field_value = data.get('build_args')
+        if not field_value:
+            return
+        build_args = {}
+        for i, line in enumerate(field_value.split("\n")):
+            if '=' not in line:
+                msg = ("'%s' at line %s is not a valid KEY=value pair." %
+                       (line, i + 1))
+                self.setFieldError("build_args", str(msg))
+                return
+            k, v = line.split('=', 1)
+            build_args[k] = v
+        data['build_args'] = build_args
+
+
+class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
+                       OCIRecipeFormMixin):
     """View for creating OCI recipes."""
 
     page_title = label = "Create a new OCI recipe"
@@ -706,6 +759,7 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin):
     def setUpFields(self):
         """See `LaunchpadFormView`."""
         super(OCIRecipeAddView, self).setUpFields()
+        self.form_fields += self.createBuildArgsField()
         self.form_fields += self.createEnabledProcessors(
             getUtility(IProcessorSet).getAll(),
             "The architectures that this OCI recipe builds for. Some "
@@ -745,6 +799,7 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin):
                     "There is already an OCI recipe owned by %s in %s with "
                     "this name." % (
                         owner.display_name, self.context.display_name))
+        self.validateBuildArgs(data)
 
     @action("Create OCI recipe", name="create")
     def create_action(self, action, data):
@@ -752,7 +807,8 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin):
             name=data["name"], registrant=self.user, owner=data["owner"],
             oci_project=self.context, git_ref=data["git_ref"],
             build_file=data["build_file"], description=data["description"],
-            build_daily=data["build_daily"], processors=data["processors"])
+            build_daily=data["build_daily"], build_args=data["build_args"],
+            processors=data["processors"])
         self.next_url = canonical_url(recipe)
 
 
@@ -794,7 +850,8 @@ class OCIRecipeAdminView(BaseOCIRecipeEditView):
     field_names = ("require_virtualized", "allow_internet")
 
 
-class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin):
+class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
+                        OCIRecipeFormMixin):
     """View for editing OCI recipes."""
 
     @property
@@ -816,6 +873,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin):
     def setUpFields(self):
         """See `LaunchpadFormView`."""
         super(OCIRecipeEditView, self).setUpFields()
+        self.form_fields += self.createBuildArgsField()
         self.form_fields += self.createEnabledProcessors(
             self.context.available_processors,
             "The architectures that this OCI recipe builds for. Some "
@@ -855,7 +913,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin):
                         # This processor is restricted and currently
                         # enabled. Leave it untouched.
                         data["processors"].append(processor)
-
+        self.validateBuildArgs(data)
 
 class OCIRecipeDeleteView(BaseOCIRecipeEditView):
     """View for deleting OCI recipes."""
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index 8b17168..d910065 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -215,6 +215,26 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
             "Build schedule:\nBuilt on request\nEdit OCI recipe\n",
             MatchesTagText(content, "build-schedule"))
 
+    def test_create_new_recipe_with_build_args(self):
+        oci_project = self.factory.makeOCIProject()
+        [git_ref] = self.factory.makeGitRefs()
+        browser = self.getViewBrowser(
+            oci_project, view_name="+new-recipe", user=self.person)
+        browser.getControl(name="field.name").value = "recipe-name"
+        browser.getControl("Description").value = "Recipe description"
+        browser.getControl("Git repository").value = (
+            git_ref.repository.identity)
+        browser.getControl("Git branch").value = git_ref.path
+        browser.getControl("Extra build ARG variables").value = (
+            "VAR1=10\nVAR2=20")
+        browser.getControl("Create OCI recipe").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("recipe-name", extract_text(content.h1))
+        self.assertThat(
+            "Build ARG:\nVAR1=10\nVAR2=20",
+            MatchesTagText(content, "build-args"))
+
     def test_create_new_recipe_users_teams_as_owner_options(self):
         # Teams that the user is in are options for the OCI recipe owner.
         self.factory.makeTeam(
@@ -466,6 +486,47 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView):
         login_person(self.person)
         self.assertRecipeProcessors(recipe, ["386", "amd64"])
 
+    def test_edit_build_args(self):
+        self.setUpDistroSeries()
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person,
+            oci_project=oci_project, build_args={"VAR1": "xxx", "VAR2": "uu"})
+        browser = self.getViewBrowser(
+            recipe, view_name="+edit", user=recipe.owner)
+        args = browser.getControl(name="field.build_args")
+        self.assertContentEqual("VAR1=xxx\r\nVAR2=uu", args.value)
+        args.value = "VAR=aa\nANOTHER_VAR=bbb"
+        browser.getControl("Update OCI recipe").click()
+        login_person(self.person)
+        IStore(recipe).reload(recipe)
+        self.assertEqual(
+            {"VAR": "aa", "ANOTHER_VAR": "bbb"}, recipe.build_args)
+
+    def test_edit_build_args_invalid_content(self):
+        self.setUpDistroSeries()
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person,
+            oci_project=oci_project, build_args={"VAR1": "xxx", "VAR2": "uu"})
+        browser = self.getViewBrowser(
+            recipe, view_name="+edit", user=recipe.owner)
+        args = browser.getControl(name="field.build_args")
+        self.assertContentEqual("VAR1=xxx\r\nVAR2=uu", args.value)
+        args.value = "VAR=aa\nmessed up text"
+        browser.getControl("Update OCI recipe").click()
+
+        # Error message should be shown.
+        content = find_main_content(browser.contents)
+        self.assertIn(
+            "'messed up text' at line 2 is not a valid KEY=value pair.",
+            extract_text(content))
+
+        # Assert that recipe still have the original build_args.
+        login_person(self.person)
+        IStore(recipe).reload(recipe)
+        self.assertEqual({"VAR1": "xxx", "VAR2": "uu"}, recipe.build_args)
+
     def test_edit_with_invisible_processor(self):
         # It's possible for existing recipes to have an enabled processor
         # that's no longer usable with the current distroseries, which will
@@ -705,6 +766,37 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
             """ % (oci_project_name, oci_project_display),
             self.getMainText(build.recipe))
 
+    def test_index_with_build_args(self):
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution)
+        oci_project_name = oci_project.name
+        oci_project_display = oci_project.display_name
+        [ref] = self.factory.makeGitRefs(
+            owner=self.person, target=self.person, name="recipe-repository",
+            paths=["refs/heads/master"])
+        recipe = self.makeOCIRecipe(
+            oci_project=oci_project, git_ref=ref, build_file="Dockerfile",
+            build_args={"VAR1": "123", "VAR2": "XXX"})
+        build = self.makeBuild(
+            recipe=recipe, status=BuildStatus.FULLYBUILT,
+            duration=timedelta(minutes=30))
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            %s OCI project
+            recipe-name
+            .*
+            OCI recipe information
+            Owner: Test Person
+            OCI project: %s
+            Source: ~test-person/\\+git/recipe-repository:master
+            Build file path: Dockerfile
+            Build schedule: Built on request
+            Build ARG: VAR1=123 VAR2=XXX
+            Latest builds
+            Status When complete Architecture
+            Successfully built 30 minutes ago 386
+            """ % (oci_project_name, oci_project_display),
+            self.getMainText(build.recipe))
+
     def test_index_success_with_buildlog(self):
         # The build log is shown if it is there.
         build = self.makeBuild(
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index c433825..9e8edad 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -387,6 +387,13 @@ class IOCIRecipeEditableAttributes(IHasOwner):
         required=True,
         readonly=False))
 
+    build_args = exported(Dict(
+        title=_("Build ARG variables"),
+        description=_("The dictionary of ARG variables to be used when "
+                      "building this recipe."),
+        required=False,
+        readonly=False))
+
     build_daily = exported(Bool(
         title=_("Build daily"),
         required=True,
@@ -433,7 +440,7 @@ class IOCIRecipeSet(Interface):
     def new(name, registrant, owner, oci_project, git_ref, build_file,
             description=None, official=False, require_virtualized=True,
             build_daily=False, processors=None, date_created=DEFAULT,
-            allow_internet=True):
+            allow_internet=True, build_args=None):
         """Create an IOCIRecipe."""
 
     def exists(owner, oci_project, name):
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 2a83ccc..c3c2ad2 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -14,6 +14,7 @@ __all__ = [
 
 from lazr.lifecycle.event import ObjectCreatedEvent
 import pytz
+from storm.databases.postgres import JSON
 from storm.expr import (
     And,
     Desc,
@@ -146,6 +147,8 @@ class OCIRecipe(Storm, WebhookTargetMixin):
     git_path = Unicode(name="git_path", allow_none=True)
     build_file = Unicode(name="build_file", allow_none=False)
 
+    _build_args = JSON(name="build_args", allow_none=True)
+
     require_virtualized = Bool(name="require_virtualized", default=True,
                                allow_none=False)
 
@@ -156,7 +159,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
     def __init__(self, name, registrant, owner, oci_project, git_ref,
                  description=None, official=False, require_virtualized=True,
                  build_file=None, build_daily=False, date_created=DEFAULT,
-                 allow_internet=True):
+                 allow_internet=True, build_args=None):
         if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE):
             raise OCIRecipeFeatureDisabled()
         super(OCIRecipe, self).__init__()
@@ -173,6 +176,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
         self.date_last_modified = date_created
         self.git_ref = git_ref
         self.allow_internet = allow_internet
+        self.build_args = build_args or {}
 
     def __repr__(self):
         return "<OCIRecipe ~%s/%s/+oci/%s/+recipe/%s>" % (
@@ -188,6 +192,15 @@ class OCIRecipe(Storm, WebhookTargetMixin):
         """See `IOCIProject.setOfficialRecipe` method."""
         return self._official
 
+    @property
+    def build_args(self):
+        return self._build_args or {}
+
+    @build_args.setter
+    def build_args(self, value):
+        assert value is None or isinstance(value, dict)
+        self._build_args = {k: str(v) for k, v in (value or {}).items()}
+
     def destroySelf(self):
         """See `IOCIRecipe`."""
         # XXX twom 2019-11-26 This needs to expand as more build artifacts
@@ -538,7 +551,7 @@ class OCIRecipeSet:
     def new(self, name, registrant, owner, oci_project, git_ref, build_file,
             description=None, official=False, require_virtualized=True,
             build_daily=False, processors=None, date_created=DEFAULT,
-            allow_internet=True):
+            allow_internet=True, build_args=None):
         """See `IOCIRecipeSet`."""
         if not registrant.inTeam(owner):
             if owner.is_team:
@@ -560,7 +573,7 @@ class OCIRecipeSet:
         oci_recipe = OCIRecipe(
             name, registrant, owner, oci_project, git_ref, description,
             official, require_virtualized, build_file, build_daily,
-            date_created, allow_internet)
+            date_created, allow_internet, build_args)
         store.add(oci_recipe)
 
         if processors is None:
diff --git a/lib/lp/oci/model/ocirecipebuildbehaviour.py b/lib/lp/oci/model/ocirecipebuildbehaviour.py
index fce6dd3..1a5f78e 100644
--- a/lib/lp/oci/model/ocirecipebuildbehaviour.py
+++ b/lib/lp/oci/model/ocirecipebuildbehaviour.py
@@ -1,4 +1,4 @@
-# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """An `IBuildFarmJobBehaviour` for `OCIRecipeBuild`.
@@ -96,6 +96,7 @@ class OCIRecipeBuildBehaviour(SnapProxyMixin, BuildFarmJobBehaviourBase):
                 logger=logger))
 
         args['build_file'] = build.recipe.build_file
+        args['build_args'] = build.recipe.build_args
 
         if build.recipe.git_ref is not None:
             args["git_repository"] = (
diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
index 21a6939..2de9c29 100644
--- a/lib/lp/oci/templates/ocirecipe-index.pt
+++ b/lib/lp/oci/templates/ocirecipe-index.pt
@@ -64,6 +64,14 @@
           <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
         </dd>
       </dl>
+      <dl id="build-args"
+          tal:define="build_args view/build_args"
+          tal:condition="build_args">
+        <dt>Build ARG:</dt>
+        <dd>
+          <pre><span tal:replace="build_args"/></pre>
+        </dd>
+      </dl>
     </div>
 
     <h2>Latest builds</h2>
diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt
index e5f2686..13c93c3 100644
--- a/lib/lp/oci/templates/ocirecipe-new.pt
+++ b/lib/lp/oci/templates/ocirecipe-new.pt
@@ -32,6 +32,9 @@
         <tal:widget define="widget nocall:view/widgets/build_daily">
           <metal:block use-macro="context/@@launchpad_form/widget_row" />
         </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/build_args">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
         <tal:widget define="widget nocall:view/widgets/processors">
           <metal:block use-macro="context/@@launchpad_form/widget_row" />
         </tal:widget>
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index ad67a7a..faa8862 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -58,6 +58,7 @@ from lp.services.database.constants import (
     ONE_DAY_AGO,
     UTC_NOW,
     )
+from lp.services.database.interfaces import IStore
 from lp.services.database.sqlbase import flush_database_caches
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.runner import JobRunner
@@ -664,6 +665,43 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory):
             [recipe3, recipe1, recipe2],
             list(oci_project.searchRecipes(u"a")))
 
+    def test_build_args_dict(self):
+        args = {"MY_VERSION": "1.0.3", "ANOTHER_VERSION": "2.9.88"}
+        recipe = self.factory.makeOCIRecipe(build_args=args)
+        # Force fetch it from database
+        store = IStore(recipe)
+        store.invalidate(recipe)
+        self.assertEqual(args, recipe.build_args)
+
+    def test_build_args_not_dict(self):
+        invalid_build_args_set = [
+            [1, 2, 3],
+            "some string",
+            123,
+        ]
+        for invalid_build_args in invalid_build_args_set:
+            self.assertRaises(
+                AssertionError, self.factory.makeOCIRecipe,
+                build_args=invalid_build_args)
+
+    def test_build_args_flatten_dict(self):
+        # Makes sure we only store one level of key=pair, flattening to
+        # string every value.
+        args = {
+            "VAR1": {b"something": [1, 2, 3]},
+            "VAR2": 123,
+            "VAR3": "A string",
+        }
+        recipe = self.factory.makeOCIRecipe(build_args=args)
+        # Force fetch it from database
+        store = IStore(recipe)
+        store.invalidate(recipe)
+        self.assertEqual({
+            "VAR1": "{'something': [1, 2, 3]}",
+            "VAR2": "123",
+            "VAR3": "A string",
+        }, recipe.build_args)
+
 
 class TestOCIRecipeProcessors(TestCaseWithFactory):
 
@@ -987,7 +1025,8 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
             oci_project = self.factory.makeOCIProject(
                 registrant=self.person)
             recipe = self.factory.makeOCIRecipe(
-                oci_project=oci_project)
+                oci_project=oci_project,
+                build_args={"VAR_A": "123"})
             url = api_url(recipe)
 
         ws_recipe = self.load_from_api(url)
@@ -1006,6 +1045,7 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
                 git_ref_link=Equals(self.getAbsoluteURL(recipe.git_ref)),
                 description=Equals(recipe.description),
                 build_file=Equals(recipe.build_file),
+                build_args=Equals({"VAR_A": "123"}),
                 build_daily=Equals(recipe.build_daily)
                 )))
 
@@ -1070,6 +1110,7 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
             "owner": person_url,
             "git_ref": git_ref_url,
             "build_file": "./Dockerfile",
+            "build_args": {"VAR": "VAR VALUE"},
             "description": "My recipe"}
 
         resp = self.webservice.named_post(oci_project_url, "newRecipe", **obj)
@@ -1087,6 +1128,7 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
                 description=Equals(obj["description"]),
                 owner_link=Equals(self.getAbsoluteURL(self.person)),
                 registrant_link=Equals(self.getAbsoluteURL(self.person)),
+                build_args=Equals({"VAR": "VAR VALUE"})
             )))
 
     def test_api_create_oci_recipe_non_legitimate_user(self):
diff --git a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
index 3fdf8b4..e6247fe 100644
--- a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
+++ b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
@@ -111,6 +111,7 @@ class MakeOCIBuildMixin:
                 build = self.factory.makeOCIRecipeBuild(
                     recipe=recipe, **kwargs)
         build.recipe.git_ref = git_ref
+        build.recipe.build_args = {"BUILD_VAR": "123"}
 
         job = IBuildFarmJobBehaviour(build)
         builder = MockBuilder()
@@ -242,6 +243,7 @@ class TestAsyncOCIRecipeBuildBehaviour(MakeOCIBuildMixin, TestCaseWithFactory):
             "archives": Equals(expected_archives),
             "arch_tag": Equals("i386"),
             "build_file": Equals(job.build.recipe.build_file),
+            "build_args": Equals({"BUILD_VAR": "123"}),
             "build_url": Equals(canonical_url(job.build)),
             "fast_cleanup": Is(True),
             "git_repository": Equals(ref.repository.git_https_url),
@@ -272,6 +274,7 @@ class TestAsyncOCIRecipeBuildBehaviour(MakeOCIBuildMixin, TestCaseWithFactory):
             "archives": Equals(expected_archives),
             "arch_tag": Equals("i386"),
             "build_file": Equals(job.build.recipe.build_file),
+            "build_args": Equals({"BUILD_VAR": "123"}),
             "build_url": Equals(canonical_url(job.build)),
             "fast_cleanup": Is(True),
             "git_repository": Equals(ref.repository.git_https_url),
diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py
index 2454662..e72a4cf 100644
--- a/lib/lp/registry/interfaces/ociproject.py
+++ b/lib/lp/registry/interfaces/ociproject.py
@@ -33,6 +33,7 @@ from zope.interface import Interface
 from zope.schema import (
     Bool,
     Datetime,
+    Dict,
     Int,
     Text,
     TextLine,
@@ -164,6 +165,13 @@ class IOCIProjectLegitimate(Interface):
                 "branch that defines how to build the recipe."),
             constraint=path_does_not_escape,
             required=True),
+        build_args=Dict(
+            title=_("Build ARGs to be used when building the recipe"),
+            description=_(
+                "A dict of VARIABLE=VALUE to be used as ARG when building "
+                "the recipe."
+            ),
+            required=False),
         description=Text(
             title=_("Description for this recipe."),
             description=_("A short description of this recipe."),
@@ -174,7 +182,7 @@ class IOCIProjectLegitimate(Interface):
     @operation_for_version("devel")
     def newRecipe(name, registrant, owner, git_ref, build_file,
                   description=None, build_daily=False,
-                  require_virtualized=True):
+                  require_virtualized=True, build_args=None):
         """Create an IOCIRecipe for this project."""
 
 
diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py
index 28b3dc0..c27f2c6 100644
--- a/lib/lp/registry/model/ociproject.py
+++ b/lib/lp/registry/model/ociproject.py
@@ -130,7 +130,7 @@ class OCIProject(BugTargetBase, StormBase):
 
     def newRecipe(self, name, registrant, owner, git_ref,
                   build_file, description=None, build_daily=False,
-                  require_virtualized=True):
+                  require_virtualized=True, build_args=None):
         return getUtility(IOCIRecipeSet).new(
             name=name,
             registrant=registrant,
@@ -138,6 +138,7 @@ class OCIProject(BugTargetBase, StormBase):
             oci_project=self,
             git_ref=git_ref,
             build_file=build_file,
+            build_args=build_args,
             description=description,
             require_virtualized=require_virtualized,
             build_daily=build_daily,
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index efc5c57..a1de3ac 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -4999,7 +4999,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
                       oci_project=None, git_ref=None, description=None,
                       official=False, require_virtualized=True,
                       build_file=None, date_created=DEFAULT,
-                      allow_internet=True):
+                      allow_internet=True, build_args=None):
         """Make a new OCIRecipe."""
         if name is None:
             name = self.getUniqueString(u"oci-recipe-name")
@@ -5026,7 +5026,8 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             official=official,
             require_virtualized=require_virtualized,
             date_created=date_created,
-            allow_internet=allow_internet)
+            allow_internet=allow_internet,
+            build_args=build_args)
 
     def makeOCIRecipeArch(self, recipe=None, processor=None):
         """Make a new OCIRecipeArch."""

References