Skip to content

Commit 8f04882

Browse files
authored
Fix #181: check webhooks status in heartbeat (#395)
* Fix #181: check webhooks status in heartbeat * Show warning if a hook has errors * Remove comment about product==Any
1 parent 8765dc1 commit 8f04882

File tree

4 files changed

+158
-47
lines changed

4 files changed

+158
-47
lines changed

jbi/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,26 @@ class BugzillaApiResponse(BaseModel):
346346
bugs: Optional[list[BugzillaBug]]
347347

348348

349+
class BugzillaWebhook(BaseModel):
350+
"""Bugzilla Webhook"""
351+
352+
id: int
353+
creator: str
354+
name: str
355+
url: str
356+
event: str
357+
product: str
358+
component: str
359+
enabled: bool
360+
errors: int
361+
362+
363+
class BugzillaWebhooksResponse(BaseModel):
364+
"""Bugzilla Webhooks List Response Object"""
365+
366+
webhooks: Optional[list[BugzillaWebhook]]
367+
368+
349369
class Context(BaseModel):
350370
"""Generic log context throughout JBI"""
351371

jbi/services/bugzilla.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
from pydantic import parse_obj_as
1010

1111
from jbi import Operation, environment
12-
from jbi.models import ActionContext, BugzillaApiResponse, BugzillaBug, BugzillaComment
12+
from jbi.models import (
13+
ActionContext,
14+
BugzillaApiResponse,
15+
BugzillaBug,
16+
BugzillaComment,
17+
BugzillaWebhooksResponse,
18+
)
1319

1420
from .common import InstrumentedClient, ServiceHealth
1521

@@ -95,6 +101,17 @@ def update_bug(self, bugid, **fields) -> BugzillaBug:
95101
)
96102
return parsed.bugs[0]
97103

104+
def list_webhooks(self):
105+
"""List the currently configured webhooks, including their status."""
106+
url = f"{self.base_url}/rest/webhooks/list"
107+
webhooks_info = self._call("GET", url)
108+
parsed = BugzillaWebhooksResponse.parse_obj(webhooks_info)
109+
if parsed.webhooks is None:
110+
raise BugzillaClientError(
111+
f"Unexpected response content from 'GET {url}' (no 'webhooks' field)"
112+
)
113+
return parsed.webhooks
114+
98115

99116
@lru_cache(maxsize=1)
100117
def get_client():
@@ -109,6 +126,7 @@ def get_client():
109126
"get_bug",
110127
"get_comments",
111128
"update_bugs",
129+
"list_webhooks",
112130
),
113131
exceptions=(
114132
BugzillaClientError,
@@ -120,7 +138,30 @@ def get_client():
120138
def check_health() -> ServiceHealth:
121139
"""Check health for Bugzilla Service"""
122140
client = get_client()
123-
health: ServiceHealth = {"up": client.logged_in}
141+
logged_in = client.logged_in
142+
143+
# Check that all JBI webhooks are enabled in Bugzilla,
144+
# and report disabled ones.
145+
all_webhooks_enabled = False
146+
if logged_in:
147+
webhooks = client.list_webhooks()
148+
jbi_webhooks = [wh for wh in webhooks if "/bugzilla_webhook" in wh.url]
149+
all_webhooks_enabled = len(jbi_webhooks) > 0
150+
for webhook in jbi_webhooks:
151+
if webhook.errors > 0:
152+
logger.warning(
153+
"Webhook %s has %s error(s)", webhook.name, webhook.errors
154+
)
155+
if not webhook.enabled:
156+
all_webhooks_enabled = False
157+
logger.error(
158+
"Webhook %s is disabled (%s errors)", webhook.name, webhook.errors
159+
)
160+
161+
health: ServiceHealth = {
162+
"up": logged_in,
163+
"all_webhooks_enabled": all_webhooks_enabled,
164+
}
124165
return health
125166

126167

tests/unit/services/test_bugzilla.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,44 @@ def test_bugzilla_missing_private_comment(
179179
expanded = get_client().get_bug(webhook_private_comment_example.bug.id)
180180

181181
assert not expanded.comment
182+
183+
184+
@pytest.mark.no_mocked_bugzilla
185+
def test_bugzilla_list_webhooks(mocked_responses):
186+
url = f"{get_settings().bugzilla_base_url}/rest/webhooks/list"
187+
mocked_responses.add(
188+
responses.GET,
189+
url,
190+
json={
191+
"webhooks": [
192+
{
193+
"id": 0,
194+
"creator": "Bob",
195+
"name": "",
196+
"url": "http://server/bugzilla_webhook",
197+
"event": "create,change,comment",
198+
"product": "Any",
199+
"component": "Any",
200+
"enabled": True,
201+
"errors": 0,
202+
}
203+
]
204+
},
205+
)
206+
207+
webhooks = get_client().list_webhooks()
208+
209+
assert len(webhooks) == 1
210+
assert webhooks[0].creator == "Bob"
211+
assert "/bugzilla_webhook" in webhooks[0].url
212+
213+
214+
@pytest.mark.no_mocked_bugzilla
215+
def test_bugzilla_list_webhooks_raises_if_response_has_no_webhooks(mocked_responses):
216+
url = f"{get_settings().bugzilla_base_url}/rest/webhooks/list"
217+
mocked_responses.add(responses.GET, url, json={})
218+
219+
with pytest.raises(BugzillaClientError) as exc:
220+
get_client().list_webhooks()
221+
222+
assert "Unexpected response" in str(exc)

tests/unit/test_router.py

Lines changed: 54 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,19 @@
66

77
from jbi.app import app
88
from jbi.environment import get_settings
9-
from jbi.models import BugzillaWebhookRequest
9+
from jbi.models import BugzillaWebhook, BugzillaWebhookRequest
10+
11+
EXAMPLE_WEBHOOK = BugzillaWebhook(
12+
id=0,
13+
creator="",
14+
name="",
15+
url="http://server/bugzilla_webhook",
16+
event="create,change,comment",
17+
product="Any",
18+
component="Any",
19+
enabled=True,
20+
errors=0,
21+
)
1022

1123

1224
def test_read_root(anon_client):
@@ -158,28 +170,40 @@ def test_read_heartbeat_all_services_fail(anon_client, mocked_jira, mocked_bugzi
158170
},
159171
"bugzilla": {
160172
"up": False,
173+
"all_webhooks_enabled": False,
161174
},
162175
}
163176

164177

165178
def test_read_heartbeat_jira_services_fails(anon_client, mocked_jira, mocked_bugzilla):
166179
"""/__heartbeat__ returns 503 when one service is unavailable."""
167-
mocked_bugzilla.logged_in = True
168180
mocked_jira.get_server_info.return_value = None
169181

170182
resp = anon_client.get("/__heartbeat__")
171183

172184
assert resp.status_code == 503
173-
assert resp.json() == {
174-
"jira": {
175-
"up": False,
176-
"all_projects_are_visible": False,
177-
"all_projects_have_permissions": False,
178-
"all_projects_components_exist": False,
179-
},
180-
"bugzilla": {
181-
"up": True,
182-
},
185+
assert resp.json()["jira"] == {
186+
"up": False,
187+
"all_projects_are_visible": False,
188+
"all_projects_have_permissions": False,
189+
"all_projects_components_exist": False,
190+
}
191+
192+
193+
def test_read_heartbeat_bugzilla_webhooks_fails(
194+
anon_client, mocked_jira, mocked_bugzilla
195+
):
196+
mocked_bugzilla.logged_in = True
197+
mocked_bugzilla.list_webhooks.return_value = [
198+
EXAMPLE_WEBHOOK.copy(update={"enabled": False})
199+
]
200+
201+
resp = anon_client.get("/__heartbeat__")
202+
203+
assert resp.status_code == 503
204+
assert resp.json()["bugzilla"] == {
205+
"up": True,
206+
"all_webhooks_enabled": False,
183207
}
184208

185209

@@ -194,22 +218,16 @@ def test_read_heartbeat_bugzilla_services_fails(
194218
resp = anon_client.get("/__heartbeat__")
195219

196220
assert resp.status_code == 503
197-
assert resp.json() == {
198-
"jira": {
199-
"up": True,
200-
"all_projects_are_visible": True,
201-
"all_projects_have_permissions": False,
202-
"all_projects_components_exist": False,
203-
},
204-
"bugzilla": {
205-
"up": False,
206-
},
221+
assert resp.json()["bugzilla"] == {
222+
"up": False,
223+
"all_webhooks_enabled": False,
207224
}
208225

209226

210227
def test_read_heartbeat_success(anon_client, mocked_jira, mocked_bugzilla):
211228
"""/__heartbeat__ returns 200 when checks succeed."""
212229
mocked_bugzilla.logged_in = True
230+
mocked_bugzilla.list_webhooks.return_value = [EXAMPLE_WEBHOOK]
213231
mocked_jira.get_server_info.return_value = {}
214232
mocked_jira.projects.return_value = [{"key": "DevTest"}]
215233
mocked_jira.get_project_components.return_value = [{"name": "Main"}]
@@ -234,34 +252,28 @@ def test_read_heartbeat_success(anon_client, mocked_jira, mocked_bugzilla):
234252
},
235253
"bugzilla": {
236254
"up": True,
255+
"all_webhooks_enabled": True,
237256
},
238257
}
239258

240259

241260
def test_jira_heartbeat_visible_projects(anon_client, mocked_jira, mocked_bugzilla):
242261
"""/__heartbeat__ fails if configured projects don't match."""
243-
mocked_bugzilla.logged_in = True
244262
mocked_jira.get_server_info.return_value = {}
245263

246264
resp = anon_client.get("/__heartbeat__")
247265

248266
assert resp.status_code == 503
249-
assert resp.json() == {
250-
"jira": {
251-
"up": True,
252-
"all_projects_are_visible": False,
253-
"all_projects_have_permissions": False,
254-
"all_projects_components_exist": False,
255-
},
256-
"bugzilla": {
257-
"up": True,
258-
},
267+
assert resp.json()["jira"] == {
268+
"up": True,
269+
"all_projects_are_visible": False,
270+
"all_projects_have_permissions": False,
271+
"all_projects_components_exist": False,
259272
}
260273

261274

262275
def test_jira_heartbeat_missing_permissions(anon_client, mocked_jira, mocked_bugzilla):
263276
"""/__heartbeat__ fails if configured projects don't match."""
264-
mocked_bugzilla.logged_in = True
265277
mocked_jira.get_server_info.return_value = {}
266278
mocked_jira.get_project_components.return_value = [{"name": "Main"}]
267279
mocked_jira.get_project_permission_scheme.return_value = {
@@ -276,21 +288,17 @@ def test_jira_heartbeat_missing_permissions(anon_client, mocked_jira, mocked_bug
276288
resp = anon_client.get("/__heartbeat__")
277289

278290
assert resp.status_code == 503
279-
assert resp.json() == {
280-
"jira": {
281-
"up": True,
282-
"all_projects_are_visible": False,
283-
"all_projects_have_permissions": False,
284-
"all_projects_components_exist": True,
285-
},
286-
"bugzilla": {
287-
"up": True,
288-
},
291+
assert resp.json()["jira"] == {
292+
"up": True,
293+
"all_projects_are_visible": False,
294+
"all_projects_have_permissions": False,
295+
"all_projects_components_exist": True,
289296
}
290297

291298

292299
def test_jira_heartbeat_unknown_components(anon_client, mocked_jira, mocked_bugzilla):
293300
mocked_bugzilla.logged_in = True
301+
mocked_bugzilla.list_webhooks.return_value = [EXAMPLE_WEBHOOK]
294302
mocked_jira.get_server_info.return_value = {}
295303

296304
resp = anon_client.get("/__heartbeat__")
@@ -299,9 +307,10 @@ def test_jira_heartbeat_unknown_components(anon_client, mocked_jira, mocked_bugz
299307
assert not resp.json()["jira"]["all_projects_components_exist"]
300308

301309

302-
def test_head_heartbeat(anon_client, mocked_jira, mocked_bugzilla):
310+
def test_head_heartbeat_success(anon_client, mocked_jira, mocked_bugzilla):
303311
"""/__heartbeat__ support head requests"""
304312
mocked_bugzilla.logged_in = True
313+
mocked_bugzilla.list_webhooks.return_value = [EXAMPLE_WEBHOOK]
305314
mocked_jira.get_server_info.return_value = {}
306315
mocked_jira.projects.return_value = [{"key": "DevTest"}]
307316
mocked_jira.get_project_components.return_value = [{"name": "Main"}]

0 commit comments

Comments
 (0)