← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:git-builder-constraints into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:git-builder-constraints into launchpad:master.

Commit message:
Add API/UI for setting builder constraints on Git repositories

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/433671

Using a `DelimitedListWidget` for this is pretty ugly (it just gives you a text area and splits what you enter on whitespace), but it's functional, and this is only visible to admins anyway.

I believe the `launchpad.Admin` permission on repositories was previously unused, so I repurposed it for this since some builder resources will be restricted.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:git-builder-constraints into launchpad:master.
diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml
index d0e3d9a..658bc83 100644
--- a/lib/lp/code/browser/configure.zcml
+++ b/lib/lp/code/browser/configure.zcml
@@ -876,6 +876,12 @@
         template="../../app/templates/generic-edit.pt"/>
     <browser:page
         for="lp.code.interfaces.gitrepository.IGitRepository"
+        class="lp.code.browser.gitrepository.GitRepositoryEditBuilderConstraintsView"
+        permission="launchpad.Admin"
+        name="+builder-constraints"
+        template="../../app/templates/generic-edit.pt"/>
+    <browser:page
+        for="lp.code.interfaces.gitrepository.IGitRepository"
         class="lp.code.browser.gitrepository.GitRepositoryEditView"
         permission="launchpad.Edit"
         name="+edit"
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index da7dc76..bc941d7 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -55,6 +55,7 @@ from lp.app.browser.launchpadform import (
 from lp.app.errors import NotFoundError, UnexpectedFormData
 from lp.app.vocabularies import InformationTypeVocabulary
 from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
+from lp.app.widgets.textwidgets import DelimitedListWidget
 from lp.charms.browser.hascharmrecipes import HasCharmRecipesViewMixin
 from lp.code.browser.branch import CodeEditOwnerMixin
 from lp.code.browser.branchmergeproposal import (
@@ -270,6 +271,7 @@ class GitRepositoryEditMenu(NavigationMenu):
         "activity",
         "access_tokens",
         "webhooks",
+        "builder_constraints",
         "delete",
     ]
 
@@ -303,6 +305,11 @@ class GitRepositoryEditMenu(NavigationMenu):
         text = "Manage webhooks"
         return Link("+webhooks", text, icon="edit")
 
+    @enabled_with_permission("launchpad.Admin")
+    def builder_constraints(self):
+        text = "Set builder constraints"
+        return Link("+builder-constraints", text, icon="edit")
+
     @enabled_with_permission("launchpad.Edit")
     def delete(self):
         text = "Delete repository"
@@ -626,7 +633,10 @@ class GitRepositoryEditFormView(LaunchpadEditFormView):
             normally editable through the interface.
             """
 
-            use_template(IGitRepository, include=["default_branch"])
+            use_template(
+                IGitRepository,
+                include=["builder_constraints", "default_branch"],
+            )
             information_type = copy_field(
                 IGitRepository["information_type"],
                 readonly=False,
@@ -776,6 +786,15 @@ class GitRepositoryEditReviewerView(GitRepositoryEditFormView):
         return {"reviewer": self.context.code_reviewer}
 
 
+class GitRepositoryEditBuilderConstraintsView(GitRepositoryEditFormView):
+    """A view to set builder constraints."""
+
+    field_names = ["builder_constraints"]
+    custom_widget_builder_constraints = CustomWidgetFactory(
+        DelimitedListWidget, height=5
+    )
+
+
 class GitRepositoryEditView(CodeEditOwnerMixin, GitRepositoryEditFormView):
     """The main view for editing repository attributes."""
 
diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py
index 9261c4b..23b7891 100644
--- a/lib/lp/code/browser/tests/test_gitrepository.py
+++ b/lib/lp/code/browser/tests/test_gitrepository.py
@@ -33,6 +33,7 @@ from zope.formlib.itemswidgets import ItemDisplayWidget
 from zope.publisher.interfaces import NotFound
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.enums import InformationType
 from lp.app.errors import UnexpectedFormData
@@ -71,6 +72,7 @@ from lp.testing import (
     StormStatementRecorder,
     TestCaseWithFactory,
     admin_logged_in,
+    login_celebrity,
     login_person,
     logout,
     person_logged_in,
@@ -1070,6 +1072,42 @@ class TestGitRepositoryEditReviewerView(TestCaseWithFactory):
         self.assertEqual(modified_date, repository.date_last_modified)
 
 
+class TestGitRepositoryEditBuilderConstraintsView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_unauthorized(self):
+        # A non-commercial-admin user cannot administer a Git repository.
+        self.useFixture(FakeLogger())
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=owner)
+        repository_url = canonical_url(repository, rootsite="code")
+        browser = self.getViewBrowser(repository, user=owner)
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Set builder constraints"
+        )
+        self.assertRaises(
+            Unauthorized,
+            self.getUserBrowser,
+            repository_url + "/+builder-constraints",
+            user=owner,
+        )
+
+    def test_commercial_admin(self):
+        # A commercial admin can set builder constraints on a Git
+        # repository.
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=owner)
+        commercial_admin = login_celebrity("commercial_admin")
+        browser = self.getViewBrowser(repository, user=commercial_admin)
+        browser.getLink("Set builder constraints").click()
+        browser.getControl("Builder constraints").value = "gpu\nlarge"
+        browser.getControl("Change Git Repository").click()
+
+        login_person(owner)
+        self.assertEqual(["gpu", "large"], repository.builder_constraints)
+
+
 class TestGitRepositoryEditView(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index f28603c..035facb 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -828,7 +828,8 @@
         interface="lp.app.interfaces.launchpad.IPrivacy
                    lp.code.interfaces.gitrepository.IGitRepositoryView
                    lp.code.interfaces.gitrepository.IGitRepositoryModerateAttributes
-                   lp.code.interfaces.gitrepository.IGitRepositoryEditableAttributes" />
+                   lp.code.interfaces.gitrepository.IGitRepositoryEditableAttributes
+                   lp.code.interfaces.gitrepository.IGitRepositoryAdminAttributes" />
     <require
         permission="launchpad.Moderate"
         interface="lp.code.interfaces.gitrepository.IGitRepositoryModerate"
@@ -841,6 +842,9 @@
     <require
         permission="launchpad.ExpensiveRequest"
         interface="lp.code.interfaces.gitrepository.IGitRepositoryExpensiveRequest"/>
+    <require
+        permission="launchpad.Admin"
+        set_schema="lp.code.interfaces.gitrepository.IGitRepositoryAdminAttributes" />
   </class>
   <adapter
       for="lp.code.interfaces.gitrepository.IGitRepository"
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index f78cd09..93c8553 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -307,16 +307,6 @@ class IGitRepositoryView(IHasRecipes):
         )
     )
 
-    builder_constraints = List(
-        title=_("Builder constraints"),
-        required=False,
-        readonly=True,
-        value_type=TextLine(),
-        description=_(
-            "Builder resource tags required by builds of this repository."
-        ),
-    )
-
     def getClonedFrom():
         """Returns from which repository the given repo is a clone from."""
 
@@ -1250,6 +1240,26 @@ class IGitRepositoryEdit(IWebhookTarget, IAccessTokenTarget):
         """
 
 
+class IGitRepositoryAdminAttributes(Interface):
+    """`IGitRepository` attributes that can be edited by admins.
+
+    These attributes need launchpad.View to see, and launchpad.Admin to change.
+    """
+
+    builder_constraints = exported(
+        List(
+            title=_("Builder constraints"),
+            required=False,
+            readonly=False,
+            value_type=TextLine(),
+            description=_(
+                "Builder resource tags required by builds of this repository."
+            ),
+        ),
+        as_of="devel",
+    )
+
+
 # XXX cjwatson 2015-01-19 bug=760849: "beta" is a lie to get WADL
 # generation working.  Individual attributes must set their version to
 # "devel".
@@ -1261,6 +1271,7 @@ class IGitRepository(
     IGitRepositoryEditableAttributes,
     IGitRepositoryEdit,
     IGitRepositoryExpensiveRequest,
+    IGitRepositoryAdminAttributes,
 ):
     """A Git repository."""
 
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 0363da5..ec2b04b 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -6601,6 +6601,46 @@ class TestGitRepositoryWebservice(TestCaseWithFactory):
             response.body,
         )
 
+    def test_builder_constraints_commercial_admin(self):
+        # A commercial admin can change a repository's builder constraints.
+        repository_db = self.factory.makeGitRepository()
+        commercial_admin = getUtility(
+            ILaunchpadCelebrities
+        ).commercial_admin.teamowner
+        webservice = webservice_for_person(
+            commercial_admin, permission=OAuthPermission.WRITE_PUBLIC
+        )
+        webservice.default_api_version = "devel"
+        with person_logged_in(ANONYMOUS):
+            repository_url = api_url(repository_db)
+        response = webservice.patch(
+            repository_url,
+            "application/json",
+            json.dumps({"builder_constraints": ["gpu"]}),
+        )
+        self.assertEqual(209, response.status)
+        with person_logged_in(ANONYMOUS):
+            self.assertEqual(["gpu"], repository_db.builder_constraints)
+
+    def test_builder_constraints_owner(self):
+        # The owner of a repository cannot change its builder constraints
+        # (unless they're also a (commercial) admin).
+        repository_db = self.factory.makeGitRepository()
+        webservice = webservice_for_person(
+            repository_db.owner, permission=OAuthPermission.WRITE_PUBLIC
+        )
+        webservice.default_api_version = "devel"
+        with person_logged_in(ANONYMOUS):
+            repository_url = api_url(repository_db)
+        response = webservice.patch(
+            repository_url,
+            "application/json",
+            json.dumps({"builder_constraints": ["gpu"]}),
+        )
+        self.assertEqual(401, response.status)
+        with person_logged_in(ANONYMOUS):
+            self.assertIsNone(repository_db.builder_constraints)
+
 
 class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
     """Test GitRepository macaroon issuing and verification."""
diff --git a/lib/lp/code/security.py b/lib/lp/code/security.py
index d6852ed..3a85550 100644
--- a/lib/lp/code/security.py
+++ b/lib/lp/code/security.py
@@ -50,8 +50,8 @@ from lp.registry.interfaces.distributionsourcepackage import (
 from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.product import IProduct
 from lp.security import (
-    AdminByAdminsTeam,
     AdminByBuilddAdmin,
+    AdminByCommercialTeamOrAdmins,
     OnlyBazaarExpertsAndAdmins,
     OnlyVcsImportsAndAdmins,
 )
@@ -296,9 +296,14 @@ class ModerateGitRepository(EditGitRepository):
         return user.in_commercial_admin
 
 
-class AdminGitRepository(AdminByAdminsTeam):
-    """The admins can administer Git repositories."""
+class AdminGitRepository(AdminByCommercialTeamOrAdmins):
+    """Restrict changing builder constraints on Git repositories.
 
+    The security of some parts of the build farm depends on these settings,
+    so they can only be changed by (commercial) admins.
+    """
+
+    permission = "launchpad.Admin"
     usedfor = IGitRepository