Ines Almeida has proposed merging ~ines-almeida/launchpad:webhook-patterns/update-models into launchpad:master.
Commit message: Update Webhook and CIBuild models with the new fields for webhook pattern filtering `ref_pattern` field and `checkGitRefPattern` endpoint added to Webhook model to enable filtering webhook triggers from git repository according to their git refs `git_refs` added to CIBuild model will be used to store the git refs that originate the builds Requested reviews: Launchpad code reviewers (launchpad-reviewers) For more details, see: https://code.launchpad.net/~ines-almeida/launchpad/+git/launchpad/+merge/446948 This is the preparation work needed to filter git repo webhooks: https://warthogs.atlassian.net/browse/LP-1151 This MP adds 3 main things: - `ref_pattern` field to Webhook model - to store the pattern used to filter webhooks - `checkGitRefPattern` endpoint to Webhook model - to check if a git ref matches the ref_pattern of a given webhook - `git_refs` field to CIBuild model - to store the git refs that originate a given build, to later be used to check against the webhooks ref_pattern The final functionality to actually filter webhooks according to the `ref_pattern` will be added in another MP. -- Your team Launchpad code reviewers is requested to review the proposed merge of ~ines-almeida/launchpad:webhook-patterns/update-models into launchpad:master.
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py index bac6181..52b7d33 100644 --- a/lib/lp/code/interfaces/cibuild.py +++ b/lib/lp/code/interfaces/cibuild.py @@ -105,6 +105,15 @@ class ICIBuildView(IPackageBuildView, IPrivacy): ) ) + git_refs = exported( + List( + TextLine(), + title=_("The git references that originated this CI Build."), + required=False, + readonly=True, + ) + ) + distro_arch_series = exported( Reference( IDistroArchSeries, @@ -274,6 +283,7 @@ class ICIBuildSet(ISpecificBuildFarmJobSource): distro_arch_series, stages, date_created=DEFAULT, + git_refs=None, ): """Create an `ICIBuild`.""" @@ -285,7 +295,9 @@ class ICIBuildSet(ISpecificBuildFarmJobSource): these Git commit IDs. """ - def requestBuild(git_repository, commit_sha1, distro_arch_series, stages): + def requestBuild( + git_repository, commit_sha1, distro_arch_series, stages, git_refs + ): """Request a CI build. This checks that the architecture is allowed and that there isn't diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py index 1cf5a19..0634d50 100644 --- a/lib/lp/code/model/cibuild.py +++ b/lib/lp/code/model/cibuild.py @@ -14,7 +14,16 @@ from operator import itemgetter from lazr.lifecycle.event import ObjectCreatedEvent from storm.databases.postgres import JSON -from storm.locals import Bool, DateTime, Desc, Int, Reference, Store, Unicode +from storm.locals import ( + Bool, + DateTime, + Desc, + Int, + List, + Reference, + Store, + Unicode, +) from storm.store import EmptyResultSet from zope.component import getUtility from zope.event import notify @@ -171,12 +180,14 @@ def determine_DASes_to_build(configuration, logger=None): def get_all_commits_for_paths(git_repository, paths): - return [ - ref.commit_sha1 - for ref in GitRef.findByReposAndPaths( - [(git_repository, ref_path) for ref_path in paths] - ).values() - ] + commits = {} + for ref in GitRef.findByReposAndPaths( + [(git_repository, ref_path) for ref_path in paths] + ).values(): + if ref.commit_sha1 not in commits: + commits[ref.commit_sha1] = [] + commits[ref.commit_sha1].append(ref.path) + return commits def parse_configuration(git_repository, blob): @@ -204,6 +215,7 @@ class CIBuild(PackageBuildMixin, StormBase): git_repository = Reference(git_repository_id, "GitRepository.id") commit_sha1 = Unicode(name="commit_sha1", allow_none=False) + git_refs = List(name="git_refs", allow_none=True) distro_arch_series_id = Int(name="distro_arch_series", allow_none=False) distro_arch_series = Reference( @@ -261,12 +273,14 @@ class CIBuild(PackageBuildMixin, StormBase): builder_constraints, stages, date_created=DEFAULT, + git_refs=None, ): """Construct a `CIBuild`.""" super().__init__() self.build_farm_job = build_farm_job self.git_repository = git_repository self.commit_sha1 = commit_sha1 + self.git_refs = git_refs self.distro_arch_series = distro_arch_series self.processor = processor self.virtualized = virtualized @@ -655,6 +669,7 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin): commit_sha1, distro_arch_series, stages, + git_refs=None, date_created=DEFAULT, ): """See `ICIBuildSet`.""" @@ -674,6 +689,7 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin): ), stages=stages, date_created=date_created, + git_refs=git_refs, ) store.add(cibuild) store.flush() @@ -712,7 +728,12 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin): ) is not None and self._isBuildableArchitectureAllowed(das) def requestBuild( - self, git_repository, commit_sha1, distro_arch_series, stages + self, + git_repository, + commit_sha1, + distro_arch_series, + stages, + git_refs=None, ): """See `ICIBuildSet`.""" pocket = PackagePublishingPocket.UPDATES @@ -726,17 +747,32 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin): CIBuild.distro_arch_series == distro_arch_series, ) if not result.is_empty(): + # We append the new git_refs to existing builds here to keep the + # git_refs list up-to-date, and potentially filter git repository + # webhooks by their git refs if the status of the build changes + if git_refs: + for cibuild in result: + if cibuild.git_refs is None: + cibuild.git_refs = [] + cibuild.git_refs.extend(git_refs) raise CIBuildAlreadyRequested build = self.new( - git_repository, commit_sha1, distro_arch_series, stages + git_repository, commit_sha1, distro_arch_series, stages, git_refs ) build.queueBuild() notify(ObjectCreatedEvent(build)) return build def _tryToRequestBuild( - self, git_repository, commit_sha1, configuration, das, stages, logger + self, + git_repository, + commit_sha1, + configuration, + das, + stages, + logger, + git_refs=None, ): try: if logger is not None: @@ -746,7 +782,9 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin): das.distroseries.name, das.architecturetag, ) - build = self.requestBuild(git_repository, commit_sha1, das, stages) + build = self.requestBuild( + git_repository, commit_sha1, das, stages, git_refs + ) # Create reports for each individual job in this build so that # they show up as pending in the web UI. The job names # generated here should match those generated by @@ -778,7 +816,7 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin): # getCommits performs a web request! commits = getUtility(IGitHostingClient).getCommits( git_repository.getInternalPath(), - commit_sha1s, + list(commit_sha1s), # XXX cjwatson 2022-01-19: We should also fetch # debian/.launchpad.yaml (or perhaps make the path a property of # the repository) once lpci and launchpad-buildd support using @@ -814,6 +852,7 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin): das, stages[das.architecturetag], logger, + git_refs=commit_sha1s.get(commit["sha1"]), ) def getByID(self, build_id): diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py index c49024a..c9c7235 100644 --- a/lib/lp/code/model/tests/test_cibuild.py +++ b/lib/lp/code/model/tests/test_cibuild.py @@ -94,7 +94,7 @@ class TestGetAllCommitsForPaths(TestCaseWithFactory): rv = get_all_commits_for_paths(repository, ref_paths) - self.assertEqual([], rv) + self.assertEqual({}, rv) def test_one_ref_one_path(self): repository = self.factory.makeGitRepository() @@ -104,7 +104,7 @@ class TestGetAllCommitsForPaths(TestCaseWithFactory): rv = get_all_commits_for_paths(repository, ref_paths) self.assertEqual(1, len(rv)) - self.assertEqual(ref.commit_sha1, rv[0]) + self.assertTrue(ref.commit_sha1 in rv) def test_multiple_refs_and_paths(self): repository = self.factory.makeGitRepository() @@ -781,6 +781,56 @@ class TestCIBuildSet(TestCaseWithFactory): self.assertIsNone(build_queue.builder_constraints) self.assertEqual(BuildQueueStatus.WAITING, build_queue.status) + def test_requestCIBuild_with_git_refs(self): + # requestBuild creates a new CIBuild with the given git_refs + repository = self.factory.makeGitRepository() + commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest() + das = self.factory.makeBuildableDistroArchSeries() + stages = [[("build", 0)]] + + git_refs = ["ref/test", "ref/foo"] + build = getUtility(ICIBuildSet).requestBuild( + repository, commit_sha1, das, stages, git_refs + ) + + self.assertTrue(ICIBuild.providedBy(build)) + self.assertThat( + build, + MatchesStructure.byEquality( + git_repository=repository, + commit_sha1=commit_sha1, + distro_arch_series=das, + stages=stages, + status=BuildStatus.NEEDSBUILD, + ), + ) + store = Store.of(build) + store.flush() + build_queue = store.find( + BuildQueue, + BuildQueue._build_farm_job_id + == removeSecurityProxy(build).build_farm_job_id, + ).one() + self.assertProvides(build_queue, IBuildQueue) + self.assertTrue(build_queue.virtualized) + self.assertIsNone(build_queue.builder_constraints) + self.assertEqual(BuildQueueStatus.WAITING, build_queue.status) + self.assertEqual(git_refs, build.git_refs) + + # Re-scheduleing a build for the same comit_sha1 raises an error, but + # git_refs of the build are updated + self.assertRaises( + CIBuildAlreadyRequested, + getUtility(ICIBuildSet).requestBuild, + repository, + commit_sha1, + das, + stages, + ["ref/bar"], + ) + git_refs.append("ref/bar") + self.assertEqual(git_refs, build.git_refs) + def test_requestBuild_score(self): # CI builds have an initial queue score of 2600. repository = self.factory.makeGitRepository() @@ -1022,6 +1072,7 @@ class TestCIBuildSet(TestCaseWithFactory): ) ), ) + self.assertEqual(build.git_refs, ref_paths) def test_requestBuildsForRefs_multiple_architectures(self): ubuntu = getUtility(ILaunchpadCelebrities).ubuntu diff --git a/lib/lp/services/webhooks/browser.py b/lib/lp/services/webhooks/browser.py index bb53917..686d200 100644 --- a/lib/lp/services/webhooks/browser.py +++ b/lib/lp/services/webhooks/browser.py @@ -134,6 +134,7 @@ class WebhookAddView(LaunchpadFormView): event_types=data["event_types"], active=data["active"], secret=data["secret"], + ref_pattern=data.get("ref_pattern"), ) self.next_url = canonical_url(webhook) diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py index b00d45f..f1b48b7 100644 --- a/lib/lp/services/webhooks/interfaces.py +++ b/lib/lp/services/webhooks/interfaces.py @@ -25,6 +25,7 @@ from lazr.restful.declarations import ( call_with, export_destructor_operation, export_factory_operation, + export_read_operation, export_write_operation, exported, exported_as_webservice_entry, @@ -34,7 +35,7 @@ from lazr.restful.declarations import ( from lazr.restful.fields import CollectionField, Reference from lazr.restful.interface import copy_field from zope.interface import Attribute, Interface -from zope.schema import Bool, Choice, Datetime, Dict, Int, List, TextLine +from zope.schema import Bool, Choice, Datetime, Dict, Int, List, Text, TextLine from zope.schema.vocabulary import SimpleVocabulary from lp import _ @@ -176,6 +177,18 @@ class IWebhook(Interface): ) ) + ref_pattern = exported( + TextLine( + title=_("Git ref pattern"), + required=False, + description=_( + "Pattern to match against git-ref/branch name of an event, " + "to filter webhook triggers" + ), + max_length=200, + ) + ) + def getDelivery(id): """Retrieve a delivery by ID, or None if it doesn't exist.""" @@ -195,9 +208,27 @@ class IWebhook(Interface): def setSecret(secret): """Set the secret used to sign deliveries.""" + @export_read_operation() + @operation_parameters( + git_ref=Text(title=_("Git reference to compare pattern against")) + ) + @operation_for_version("devel") + def checkGitRefPattern(git_ref: str): + """Check if a given git ref matches against the webhook's `ref_pattern` + if it contains a `ref_pattern` (only Git Repository webhooks can + have a `ref_pattern` value)""" + class IWebhookSet(Interface): - def new(target, registrant, delivery_url, event_types, active, secret): + def new( + target, + registrant, + delivery_url, + event_types, + active, + secret, + ref_pattern=None, + ): """Create a new webhook.""" def delete(hooks): @@ -250,7 +281,12 @@ class IWebhookTarget(Interface): ) @operation_for_version("devel") def newWebhook( - registrant, delivery_url, event_types, active=True, secret=None + registrant, + delivery_url, + event_types, + active=True, + secret=None, + ref_pattern=None, ): """Create a new webhook.""" diff --git a/lib/lp/services/webhooks/model.py b/lib/lp/services/webhooks/model.py index bafd604..de83a62 100644 --- a/lib/lp/services/webhooks/model.py +++ b/lib/lp/services/webhooks/model.py @@ -12,6 +12,7 @@ import ipaddress import re import socket from datetime import datetime, timedelta, timezone +from fnmatch import fnmatch from urllib.parse import urlsplit import iso8601 @@ -114,6 +115,8 @@ class Webhook(StormBase): json_data = JSON(name="json_data") + ref_pattern = Unicode(allow_none=True) + @property def target(self): if self.git_repository is not None: @@ -168,6 +171,11 @@ class Webhook(StormBase): def destroySelf(self): getUtility(IWebhookSet).delete([self]) + def checkGitRefPattern(self, git_ref: str): + if not self.ref_pattern: + return True + return fnmatch(git_ref, self.ref_pattern) + @property def event_types(self): return (self.json_data or {}).get("event_types", []) @@ -192,7 +200,14 @@ class WebhookSet: """See `IWebhookSet`.""" def new( - self, target, registrant, delivery_url, event_types, active, secret + self, + target, + registrant, + delivery_url, + event_types, + active, + secret, + ref_pattern, ): from lp.charms.interfaces.charmrecipe import ICharmRecipe from lp.code.interfaces.branch import IBranch @@ -233,6 +248,7 @@ class WebhookSet: hook.delivery_url = delivery_url hook.active = active hook.secret = secret + hook.ref_pattern = ref_pattern hook.event_types = event_types IStore(Webhook).add(hook) IStore(Webhook).flush() @@ -346,10 +362,22 @@ class WebhookTargetMixin: return self.valid_webhook_event_types def newWebhook( - self, registrant, delivery_url, event_types, active=True, secret=None + self, + registrant, + delivery_url, + event_types, + active=True, + secret=None, + ref_pattern=None, ): return getUtility(IWebhookSet).new( - self, registrant, delivery_url, event_types, active, secret + self, + registrant, + delivery_url, + event_types, + active, + secret, + ref_pattern, ) diff --git a/lib/lp/services/webhooks/tests/test_model.py b/lib/lp/services/webhooks/tests/test_model.py index ede094c..77e54ca 100644 --- a/lib/lp/services/webhooks/tests/test_model.py +++ b/lib/lp/services/webhooks/tests/test_model.py @@ -71,6 +71,30 @@ class TestWebhook(TestCaseWithFactory): webhook.event_types = ["foo", [1]] self.assertContentEqual([], webhook.event_types) + def test_checkGitRefPattern(self): + # See lib/lp/code/templates/gitrepository-permissions.pt for an + # explanation of the wildcards logic + git_ref = "origin/head/foo-test" + expected_results = { + "": True, + "*": True, + "*foo*": True, + "foo": False, + "foo*": False, + "*foo": False, + "*foo?test": True, + "*foo[-_.]test": True, + "*foo![-]test": False, + "*bar*": False, + } + results = dict() + for pattern in expected_results.keys(): + webhook = self.factory.makeWebhook(ref_pattern=pattern) + with admin_logged_in(): + results[pattern] = webhook.checkGitRefPattern(git_ref) + + self.assertEqual(expected_results, results) + class TestWebhookPermissions(TestCaseWithFactory): layer = DatabaseFunctionalLayer @@ -98,6 +122,7 @@ class TestWebhookPermissions(TestCaseWithFactory): expected_get_permissions = { "launchpad.View": { "active", + "checkGitRefPattern", "date_created", "date_last_modified", "deliveries", @@ -112,6 +137,7 @@ class TestWebhookPermissions(TestCaseWithFactory): "secret", "setSecret", "target", + "ref_pattern", }, } webhook = self.factory.makeWebhook() @@ -128,6 +154,7 @@ class TestWebhookPermissions(TestCaseWithFactory): "event_types", "registrant_id", "secret", + "ref_pattern", }, } webhook = self.factory.makeWebhook() diff --git a/lib/lp/services/webhooks/tests/test_webservice.py b/lib/lp/services/webhooks/tests/test_webservice.py index 810a83d..aa5e99c 100644 --- a/lib/lp/services/webhooks/tests/test_webservice.py +++ b/lib/lp/services/webhooks/tests/test_webservice.py @@ -78,6 +78,7 @@ class TestWebhook(TestCaseWithFactory): "self_link", "target_link", "web_link", + "ref_pattern", ), ) diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py index ea6937e..dc27199 100644 --- a/lib/lp/testing/factory.py +++ b/lib/lp/testing/factory.py @@ -6163,6 +6163,7 @@ class LaunchpadObjectFactory(ObjectFactory): secret=None, active=True, event_types=None, + ref_pattern=None, ): if target is None: target = self.makeGitRepository() @@ -6175,6 +6176,7 @@ class LaunchpadObjectFactory(ObjectFactory): event_types or [], active, secret, + ref_pattern, ) def makeSnap(
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp