Merge ~ines-almeida/launchpad:webhook-patterns/update-models into launchpad:master

Proposed by Ines Almeida
Status: Merged
Approved by: Ines Almeida
Approved revision: 7b067caf1d31c625c447c9cd8932308b3388413d
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~ines-almeida/launchpad:webhook-patterns/update-models
Merge into: launchpad:master
Diff against target: 543 lines (+224/-21)
9 files modified
lib/lp/code/interfaces/cibuild.py (+13/-1)
lib/lp/code/model/cibuild.py (+51/-12)
lib/lp/code/model/tests/test_cibuild.py (+53/-2)
lib/lp/services/webhooks/browser.py (+1/-0)
lib/lp/services/webhooks/interfaces.py (+27/-2)
lib/lp/services/webhooks/model.py (+35/-3)
lib/lp/services/webhooks/tests/test_model.py (+41/-1)
lib/lp/services/webhooks/tests/test_webservice.py (+1/-0)
lib/lp/testing/factory.py (+2/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+446948@code.launchpad.net

Commit message

Update Webhook and CIBuild models with the new fields for webhook pattern filtering

`git_ref_pattern` field and `check_webhook_git_ref_pattern` function 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

Description of the change

This is the preparation work needed to filter git repo webhooks: https://warthogs.atlassian.net/browse/LP-1151

This MP adds 3 main things:
 - `git_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.

To post a comment you must log in.
Revision history for this message
Ines Almeida (ines-almeida) wrote :

Will update `ref_pattern` to `git_ref_pattern` as I also just changed it in the DB MP. I will wait for the approval of that MP before making that change.

This MP can still be reviewed with that in mind.

https://code.launchpad.net/~ines-almeida/launchpad/+git/launchpad/+merge/446950

Revision history for this message
Ines Almeida (ines-almeida) wrote :

> Will update `ref_pattern` to `git_ref_pattern` as I also just changed it in
> the DB MP. I will wait for the approval of that MP before making that change.

Done

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Ines Almeida (ines-almeida) :
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Ines Almeida (ines-almeida) wrote :

Addressed all comments and cleaned up commit tree. Will merge after checks pass

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
2index bac6181..472b67e 100644
3--- a/lib/lp/code/interfaces/cibuild.py
4+++ b/lib/lp/code/interfaces/cibuild.py
5@@ -105,6 +105,15 @@ class ICIBuildView(IPackageBuildView, IPrivacy):
6 )
7 )
8
9+ git_refs = exported(
10+ List(
11+ TextLine(),
12+ title=_("The git references that originated this CI Build."),
13+ required=False,
14+ readonly=True,
15+ )
16+ )
17+
18 distro_arch_series = exported(
19 Reference(
20 IDistroArchSeries,
21@@ -274,6 +283,7 @@ class ICIBuildSet(ISpecificBuildFarmJobSource):
22 distro_arch_series,
23 stages,
24 date_created=DEFAULT,
25+ git_refs=None,
26 ):
27 """Create an `ICIBuild`."""
28
29@@ -285,7 +295,9 @@ class ICIBuildSet(ISpecificBuildFarmJobSource):
30 these Git commit IDs.
31 """
32
33- def requestBuild(git_repository, commit_sha1, distro_arch_series, stages):
34+ def requestBuild(
35+ git_repository, commit_sha1, distro_arch_series, stages, git_refs=None
36+ ):
37 """Request a CI build.
38
39 This checks that the architecture is allowed and that there isn't
40diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
41index 1cf5a19..0634d50 100644
42--- a/lib/lp/code/model/cibuild.py
43+++ b/lib/lp/code/model/cibuild.py
44@@ -14,7 +14,16 @@ from operator import itemgetter
45
46 from lazr.lifecycle.event import ObjectCreatedEvent
47 from storm.databases.postgres import JSON
48-from storm.locals import Bool, DateTime, Desc, Int, Reference, Store, Unicode
49+from storm.locals import (
50+ Bool,
51+ DateTime,
52+ Desc,
53+ Int,
54+ List,
55+ Reference,
56+ Store,
57+ Unicode,
58+)
59 from storm.store import EmptyResultSet
60 from zope.component import getUtility
61 from zope.event import notify
62@@ -171,12 +180,14 @@ def determine_DASes_to_build(configuration, logger=None):
63
64
65 def get_all_commits_for_paths(git_repository, paths):
66- return [
67- ref.commit_sha1
68- for ref in GitRef.findByReposAndPaths(
69- [(git_repository, ref_path) for ref_path in paths]
70- ).values()
71- ]
72+ commits = {}
73+ for ref in GitRef.findByReposAndPaths(
74+ [(git_repository, ref_path) for ref_path in paths]
75+ ).values():
76+ if ref.commit_sha1 not in commits:
77+ commits[ref.commit_sha1] = []
78+ commits[ref.commit_sha1].append(ref.path)
79+ return commits
80
81
82 def parse_configuration(git_repository, blob):
83@@ -204,6 +215,7 @@ class CIBuild(PackageBuildMixin, StormBase):
84 git_repository = Reference(git_repository_id, "GitRepository.id")
85
86 commit_sha1 = Unicode(name="commit_sha1", allow_none=False)
87+ git_refs = List(name="git_refs", allow_none=True)
88
89 distro_arch_series_id = Int(name="distro_arch_series", allow_none=False)
90 distro_arch_series = Reference(
91@@ -261,12 +273,14 @@ class CIBuild(PackageBuildMixin, StormBase):
92 builder_constraints,
93 stages,
94 date_created=DEFAULT,
95+ git_refs=None,
96 ):
97 """Construct a `CIBuild`."""
98 super().__init__()
99 self.build_farm_job = build_farm_job
100 self.git_repository = git_repository
101 self.commit_sha1 = commit_sha1
102+ self.git_refs = git_refs
103 self.distro_arch_series = distro_arch_series
104 self.processor = processor
105 self.virtualized = virtualized
106@@ -655,6 +669,7 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
107 commit_sha1,
108 distro_arch_series,
109 stages,
110+ git_refs=None,
111 date_created=DEFAULT,
112 ):
113 """See `ICIBuildSet`."""
114@@ -674,6 +689,7 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
115 ),
116 stages=stages,
117 date_created=date_created,
118+ git_refs=git_refs,
119 )
120 store.add(cibuild)
121 store.flush()
122@@ -712,7 +728,12 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
123 ) is not None and self._isBuildableArchitectureAllowed(das)
124
125 def requestBuild(
126- self, git_repository, commit_sha1, distro_arch_series, stages
127+ self,
128+ git_repository,
129+ commit_sha1,
130+ distro_arch_series,
131+ stages,
132+ git_refs=None,
133 ):
134 """See `ICIBuildSet`."""
135 pocket = PackagePublishingPocket.UPDATES
136@@ -726,17 +747,32 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
137 CIBuild.distro_arch_series == distro_arch_series,
138 )
139 if not result.is_empty():
140+ # We append the new git_refs to existing builds here to keep the
141+ # git_refs list up-to-date, and potentially filter git repository
142+ # webhooks by their git refs if the status of the build changes
143+ if git_refs:
144+ for cibuild in result:
145+ if cibuild.git_refs is None:
146+ cibuild.git_refs = []
147+ cibuild.git_refs.extend(git_refs)
148 raise CIBuildAlreadyRequested
149
150 build = self.new(
151- git_repository, commit_sha1, distro_arch_series, stages
152+ git_repository, commit_sha1, distro_arch_series, stages, git_refs
153 )
154 build.queueBuild()
155 notify(ObjectCreatedEvent(build))
156 return build
157
158 def _tryToRequestBuild(
159- self, git_repository, commit_sha1, configuration, das, stages, logger
160+ self,
161+ git_repository,
162+ commit_sha1,
163+ configuration,
164+ das,
165+ stages,
166+ logger,
167+ git_refs=None,
168 ):
169 try:
170 if logger is not None:
171@@ -746,7 +782,9 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
172 das.distroseries.name,
173 das.architecturetag,
174 )
175- build = self.requestBuild(git_repository, commit_sha1, das, stages)
176+ build = self.requestBuild(
177+ git_repository, commit_sha1, das, stages, git_refs
178+ )
179 # Create reports for each individual job in this build so that
180 # they show up as pending in the web UI. The job names
181 # generated here should match those generated by
182@@ -778,7 +816,7 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
183 # getCommits performs a web request!
184 commits = getUtility(IGitHostingClient).getCommits(
185 git_repository.getInternalPath(),
186- commit_sha1s,
187+ list(commit_sha1s),
188 # XXX cjwatson 2022-01-19: We should also fetch
189 # debian/.launchpad.yaml (or perhaps make the path a property of
190 # the repository) once lpci and launchpad-buildd support using
191@@ -814,6 +852,7 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
192 das,
193 stages[das.architecturetag],
194 logger,
195+ git_refs=commit_sha1s.get(commit["sha1"]),
196 )
197
198 def getByID(self, build_id):
199diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
200index c49024a..bc3fd83 100644
201--- a/lib/lp/code/model/tests/test_cibuild.py
202+++ b/lib/lp/code/model/tests/test_cibuild.py
203@@ -94,7 +94,7 @@ class TestGetAllCommitsForPaths(TestCaseWithFactory):
204
205 rv = get_all_commits_for_paths(repository, ref_paths)
206
207- self.assertEqual([], rv)
208+ self.assertEqual({}, rv)
209
210 def test_one_ref_one_path(self):
211 repository = self.factory.makeGitRepository()
212@@ -104,7 +104,7 @@ class TestGetAllCommitsForPaths(TestCaseWithFactory):
213 rv = get_all_commits_for_paths(repository, ref_paths)
214
215 self.assertEqual(1, len(rv))
216- self.assertEqual(ref.commit_sha1, rv[0])
217+ self.assertIn(ref.commit_sha1, rv)
218
219 def test_multiple_refs_and_paths(self):
220 repository = self.factory.makeGitRepository()
221@@ -781,6 +781,56 @@ class TestCIBuildSet(TestCaseWithFactory):
222 self.assertIsNone(build_queue.builder_constraints)
223 self.assertEqual(BuildQueueStatus.WAITING, build_queue.status)
224
225+ def test_requestCIBuild_with_git_refs(self):
226+ # requestBuild creates a new CIBuild with the given git_refs
227+ repository = self.factory.makeGitRepository()
228+ commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
229+ das = self.factory.makeBuildableDistroArchSeries()
230+ stages = [[("build", 0)]]
231+
232+ git_refs = ["ref/test", "ref/foo"]
233+ build = getUtility(ICIBuildSet).requestBuild(
234+ repository, commit_sha1, das, stages, git_refs
235+ )
236+
237+ self.assertTrue(ICIBuild.providedBy(build))
238+ self.assertThat(
239+ build,
240+ MatchesStructure.byEquality(
241+ git_repository=repository,
242+ commit_sha1=commit_sha1,
243+ distro_arch_series=das,
244+ stages=stages,
245+ status=BuildStatus.NEEDSBUILD,
246+ ),
247+ )
248+ store = Store.of(build)
249+ store.flush()
250+ build_queue = store.find(
251+ BuildQueue,
252+ BuildQueue._build_farm_job_id
253+ == removeSecurityProxy(build).build_farm_job_id,
254+ ).one()
255+ self.assertProvides(build_queue, IBuildQueue)
256+ self.assertTrue(build_queue.virtualized)
257+ self.assertIsNone(build_queue.builder_constraints)
258+ self.assertEqual(BuildQueueStatus.WAITING, build_queue.status)
259+ self.assertEqual(git_refs, build.git_refs)
260+
261+ # Rescheduling a build for the same commit_sha1 raises an error, but
262+ # git_refs of the build are updated
263+ self.assertRaises(
264+ CIBuildAlreadyRequested,
265+ getUtility(ICIBuildSet).requestBuild,
266+ repository,
267+ commit_sha1,
268+ das,
269+ stages,
270+ ["ref/bar"],
271+ )
272+ git_refs.append("ref/bar")
273+ self.assertEqual(git_refs, build.git_refs)
274+
275 def test_requestBuild_score(self):
276 # CI builds have an initial queue score of 2600.
277 repository = self.factory.makeGitRepository()
278@@ -1022,6 +1072,7 @@ class TestCIBuildSet(TestCaseWithFactory):
279 )
280 ),
281 )
282+ self.assertEqual(build.git_refs, ref_paths)
283
284 def test_requestBuildsForRefs_multiple_architectures(self):
285 ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
286diff --git a/lib/lp/services/webhooks/browser.py b/lib/lp/services/webhooks/browser.py
287index bb53917..991d7ef 100644
288--- a/lib/lp/services/webhooks/browser.py
289+++ b/lib/lp/services/webhooks/browser.py
290@@ -134,6 +134,7 @@ class WebhookAddView(LaunchpadFormView):
291 event_types=data["event_types"],
292 active=data["active"],
293 secret=data["secret"],
294+ git_ref_pattern=data.get("git_ref_pattern"),
295 )
296 self.next_url = canonical_url(webhook)
297
298diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
299index b00d45f..9b32ba1 100644
300--- a/lib/lp/services/webhooks/interfaces.py
301+++ b/lib/lp/services/webhooks/interfaces.py
302@@ -176,6 +176,18 @@ class IWebhook(Interface):
303 )
304 )
305
306+ git_ref_pattern = exported(
307+ TextLine(
308+ title=_("Git ref pattern"),
309+ required=False,
310+ description=_(
311+ "Pattern to match against git-ref/branch name of an event, "
312+ "to filter webhook triggers"
313+ ),
314+ max_length=200,
315+ )
316+ )
317+
318 def getDelivery(id):
319 """Retrieve a delivery by ID, or None if it doesn't exist."""
320
321@@ -197,7 +209,15 @@ class IWebhook(Interface):
322
323
324 class IWebhookSet(Interface):
325- def new(target, registrant, delivery_url, event_types, active, secret):
326+ def new(
327+ target,
328+ registrant,
329+ delivery_url,
330+ event_types,
331+ active,
332+ secret,
333+ git_ref_pattern=None,
334+ ):
335 """Create a new webhook."""
336
337 def delete(hooks):
338@@ -250,7 +270,12 @@ class IWebhookTarget(Interface):
339 )
340 @operation_for_version("devel")
341 def newWebhook(
342- registrant, delivery_url, event_types, active=True, secret=None
343+ registrant,
344+ delivery_url,
345+ event_types,
346+ active=True,
347+ secret=None,
348+ git_ref_pattern=None,
349 ):
350 """Create a new webhook."""
351
352diff --git a/lib/lp/services/webhooks/model.py b/lib/lp/services/webhooks/model.py
353index bafd604..d5e1267 100644
354--- a/lib/lp/services/webhooks/model.py
355+++ b/lib/lp/services/webhooks/model.py
356@@ -12,6 +12,7 @@ import ipaddress
357 import re
358 import socket
359 from datetime import datetime, timedelta, timezone
360+from fnmatch import fnmatch
361 from urllib.parse import urlsplit
362
363 import iso8601
364@@ -66,6 +67,15 @@ def webhook_modified(webhook, event):
365 removeSecurityProxy(webhook).date_last_modified = UTC_NOW
366
367
368+def check_webhook_git_ref_pattern(webhook: IWebhook, git_ref: str):
369+ """Check if a given git ref matches against the webhook's
370+ `git_ref_pattern` if it has one (only Git Repository webhooks can have
371+ a `git_ref_pattern` value)"""
372+ if not webhook.git_ref_pattern:
373+ return True
374+ return fnmatch(git_ref, webhook.git_ref_pattern)
375+
376+
377 @implementer(IWebhook)
378 class Webhook(StormBase):
379 """See `IWebhook`."""
380@@ -114,6 +124,8 @@ class Webhook(StormBase):
381
382 json_data = JSON(name="json_data")
383
384+ git_ref_pattern = Unicode(allow_none=True)
385+
386 @property
387 def target(self):
388 if self.git_repository is not None:
389@@ -192,7 +204,14 @@ class WebhookSet:
390 """See `IWebhookSet`."""
391
392 def new(
393- self, target, registrant, delivery_url, event_types, active, secret
394+ self,
395+ target,
396+ registrant,
397+ delivery_url,
398+ event_types,
399+ active,
400+ secret,
401+ git_ref_pattern=None,
402 ):
403 from lp.charms.interfaces.charmrecipe import ICharmRecipe
404 from lp.code.interfaces.branch import IBranch
405@@ -233,6 +252,7 @@ class WebhookSet:
406 hook.delivery_url = delivery_url
407 hook.active = active
408 hook.secret = secret
409+ hook.git_ref_pattern = git_ref_pattern
410 hook.event_types = event_types
411 IStore(Webhook).add(hook)
412 IStore(Webhook).flush()
413@@ -346,10 +366,22 @@ class WebhookTargetMixin:
414 return self.valid_webhook_event_types
415
416 def newWebhook(
417- self, registrant, delivery_url, event_types, active=True, secret=None
418+ self,
419+ registrant,
420+ delivery_url,
421+ event_types,
422+ active=True,
423+ secret=None,
424+ git_ref_pattern=None,
425 ):
426 return getUtility(IWebhookSet).new(
427- self, registrant, delivery_url, event_types, active, secret
428+ self,
429+ registrant,
430+ delivery_url,
431+ event_types,
432+ active,
433+ secret,
434+ git_ref_pattern,
435 )
436
437
438diff --git a/lib/lp/services/webhooks/tests/test_model.py b/lib/lp/services/webhooks/tests/test_model.py
439index ede094c..7053899 100644
440--- a/lib/lp/services/webhooks/tests/test_model.py
441+++ b/lib/lp/services/webhooks/tests/test_model.py
442@@ -20,7 +20,11 @@ from lp.services.features.testing import FeatureFixture
443 from lp.services.webapp.authorization import check_permission
444 from lp.services.webapp.snapshot import notify_modified
445 from lp.services.webhooks.interfaces import IWebhookSet
446-from lp.services.webhooks.model import WebhookJob, WebhookSet
447+from lp.services.webhooks.model import (
448+ WebhookJob,
449+ WebhookSet,
450+ check_webhook_git_ref_pattern,
451+)
452 from lp.soyuz.interfaces.livefs import (
453 LIVEFS_FEATURE_FLAG,
454 LIVEFS_WEBHOOKS_FEATURE_FLAG,
455@@ -71,6 +75,40 @@ class TestWebhook(TestCaseWithFactory):
456 webhook.event_types = ["foo", [1]]
457 self.assertContentEqual([], webhook.event_types)
458
459+ def test_check_webhook_git_ref_pattern(self):
460+ # See lib/lp/code/templates/gitrepository-permissions.pt for an
461+ # explanation of the wildcards logic
462+ git_ref = "refs/heads/foo-test"
463+ expected_results = {
464+ None: True,
465+ "": True,
466+ "*": True,
467+ "*foo*": True,
468+ "foo": False,
469+ "foo-test": False, # it needs to match the full git ref
470+ "foo*": False,
471+ "*foo": False,
472+ "*foo?test": True,
473+ "*foo[-_.]test": True,
474+ "*foo![-]test": False,
475+ "*bar*": False,
476+ "refs/heads/*": True, # this should match all branches (not tags)
477+ "refs/heads/foo*": True,
478+ "refs/heads/bar*": False,
479+ "refs/heads/*-test": True,
480+ }
481+
482+ results = dict()
483+ webhook = self.factory.makeWebhook()
484+ with admin_logged_in():
485+ for pattern in expected_results:
486+ webhook.git_ref_pattern = pattern
487+ results[pattern] = check_webhook_git_ref_pattern(
488+ webhook, git_ref
489+ )
490+
491+ self.assertEqual(expected_results, results)
492+
493
494 class TestWebhookPermissions(TestCaseWithFactory):
495 layer = DatabaseFunctionalLayer
496@@ -112,6 +150,7 @@ class TestWebhookPermissions(TestCaseWithFactory):
497 "secret",
498 "setSecret",
499 "target",
500+ "git_ref_pattern",
501 },
502 }
503 webhook = self.factory.makeWebhook()
504@@ -128,6 +167,7 @@ class TestWebhookPermissions(TestCaseWithFactory):
505 "event_types",
506 "registrant_id",
507 "secret",
508+ "git_ref_pattern",
509 },
510 }
511 webhook = self.factory.makeWebhook()
512diff --git a/lib/lp/services/webhooks/tests/test_webservice.py b/lib/lp/services/webhooks/tests/test_webservice.py
513index 810a83d..f7d9d0b 100644
514--- a/lib/lp/services/webhooks/tests/test_webservice.py
515+++ b/lib/lp/services/webhooks/tests/test_webservice.py
516@@ -78,6 +78,7 @@ class TestWebhook(TestCaseWithFactory):
517 "self_link",
518 "target_link",
519 "web_link",
520+ "git_ref_pattern",
521 ),
522 )
523
524diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
525index 11d337b..2ac51bc 100644
526--- a/lib/lp/testing/factory.py
527+++ b/lib/lp/testing/factory.py
528@@ -6163,6 +6163,7 @@ class LaunchpadObjectFactory(ObjectFactory):
529 secret=None,
530 active=True,
531 event_types=None,
532+ git_ref_pattern=None,
533 ):
534 if target is None:
535 target = self.makeGitRepository()
536@@ -6175,6 +6176,7 @@ class LaunchpadObjectFactory(ObjectFactory):
537 event_types or [],
538 active,
539 secret,
540+ git_ref_pattern,
541 )
542
543 def makeSnap(

Subscribers

People subscribed via source and target branches

to status/vote changes: