Skip to content

Commit 1ee0f68

Browse files
authored
Check visible projects in heartbeat (fixes #126) (#127)
* Check visible projects in heartbeat (fixes #126) * Heartbeat can now have arbitrary keys * Add convencience methods in Actions model * Make services functions private * Refactor healthcheck from Graham's feedback
1 parent 5538c7f commit 1ee0f68

File tree

5 files changed

+86
-17
lines changed

5 files changed

+86
-17
lines changed

src/app/api.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from src.jbi.bugzilla import BugzillaWebhookRequest
2424
from src.jbi.models import Actions
2525
from src.jbi.runner import IgnoreInvalidRequestError, execute_action
26-
from src.jbi.services import get_jira
26+
from src.jbi.services import jira_visible_projects
2727

2828
SRC_DIR = Path(__file__).parents[1]
2929

@@ -117,8 +117,7 @@ def get_whiteboard_tag(
117117
@app.get("/jira_projects/")
118118
def get_jira_projects():
119119
"""API for viewing projects that are currently accessible by API"""
120-
jira = get_jira()
121-
visible_projects: List[Dict] = jira.projects(included_archived=None)
120+
visible_projects: List[Dict] = jira_visible_projects()
122121
return [project["key"] for project in visible_projects]
123122

124123

src/app/monitor.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
"""
22
Router dedicated to Dockerflow APIs
33
"""
4-
from fastapi import APIRouter, Response
4+
from fastapi import APIRouter, Depends, Response
55

6-
from src.app import environment
6+
from src.app import configuration, environment
7+
from src.jbi.models import Actions
78
from src.jbi.services import jbi_service_health_map
89

910
api_router = APIRouter(tags=["Monitor"])
1011

1112

1213
@api_router.get("/__heartbeat__")
1314
@api_router.head("/__heartbeat__")
14-
def heartbeat(response: Response):
15+
def heartbeat(
16+
response: Response, actions: Actions = Depends(configuration.get_actions)
17+
):
1518
"""Return status of backing services, as required by Dockerflow."""
16-
health_map = jbi_service_health_map()
17-
if not all(health["up"] for health in health_map.values()):
19+
health_map = jbi_service_health_map(actions)
20+
health_checks = []
21+
for health in health_map.values():
22+
health_checks.extend(health.values())
23+
if not all(health_checks):
1824
response.status_code = 503
1925
return health_map
2026

src/jbi/models.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import warnings
77
from inspect import signature
88
from types import ModuleType
9-
from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Union
9+
from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Set, Union
1010

1111
from pydantic import EmailStr, Extra, Field, root_validator, validator
1212
from pydantic_yaml import YamlModel
@@ -70,6 +70,9 @@ def by_tag(self) -> Mapping[str, Action]:
7070
"""Build mapping of actions by lookup tag."""
7171
return {action.whiteboard_tag: action for action in self.__root__}
7272

73+
def __iter__(self):
74+
return iter(self.__root__)
75+
7376
def __len__(self):
7477
return len(self.__root__)
7578

@@ -80,6 +83,15 @@ def get(self, tag: Optional[str]) -> Optional[Action]:
8083
"""Lookup actions by whiteboard tag"""
8184
return self.by_tag.get(tag.lower()) if tag else None
8285

86+
@functools.cached_property
87+
def configured_jira_projects_keys(self) -> Set[str]:
88+
"""Return the list of Jira project keys from all configured actions"""
89+
return {
90+
action.parameters["jira_project_key"]
91+
for action in self.__root__
92+
if "jira_project_key" in action.parameters
93+
}
94+
8395
@validator("__root__")
8496
def validate_actions( # pylint: disable=no-self-argument
8597
cls, actions: List[Action]

src/jbi/services.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
"""Services and functions that can be used to create custom actions"""
2-
from typing import TypedDict
2+
import logging
3+
from typing import Dict, List
34

45
import bugzilla as rh_bugzilla
56
from atlassian import Jira
67

78
from src.app import environment
9+
from src.jbi.models import Actions
810

911
settings = environment.get_settings()
1012

13+
logger = logging.getLogger(__name__)
1114

12-
ServiceHealth = TypedDict("ServiceHealth", {"up": bool})
15+
16+
ServiceHealth = Dict[str, bool]
1317

1418

1519
def get_jira():
@@ -22,31 +26,53 @@ def get_jira():
2226
)
2327

2428

29+
def jira_visible_projects(jira=None) -> List[Dict]:
30+
"""Return list of projects that are visible with the configured Jira credentials"""
31+
jira = jira or get_jira()
32+
projects: List[Dict] = jira.projects(included_archived=None)
33+
return projects
34+
35+
2536
def get_bugzilla():
2637
"""Get bugzilla service"""
2738
return rh_bugzilla.Bugzilla(
2839
settings.bugzilla_base_url, api_key=str(settings.bugzilla_api_key)
2940
)
3041

3142

32-
def bugzilla_check_health() -> ServiceHealth:
43+
def _bugzilla_check_health() -> ServiceHealth:
3344
"""Check health for Bugzilla Service"""
3445
bugzilla = get_bugzilla()
3546
health: ServiceHealth = {"up": bugzilla.logged_in}
3647
return health
3748

3849

39-
def jira_check_health() -> ServiceHealth:
50+
def _jira_check_health(actions: Actions) -> ServiceHealth:
4051
"""Check health for Jira Service"""
4152
jira = get_jira()
4253
server_info = jira.get_server_info(True)
43-
health: ServiceHealth = {"up": server_info is not None}
54+
is_up = server_info is not None
55+
health: ServiceHealth = {
56+
"up": is_up,
57+
"all_projects_are_visible": is_up and _all_jira_projects_visible(jira, actions),
58+
}
4459
return health
4560

4661

47-
def jbi_service_health_map():
62+
def _all_jira_projects_visible(jira, actions: Actions) -> bool:
63+
visible_projects = {project["key"] for project in jira_visible_projects(jira)}
64+
missing_projects = actions.configured_jira_projects_keys - visible_projects
65+
if missing_projects:
66+
logger.error(
67+
"Jira projects %s are not visible with configured credentials",
68+
missing_projects,
69+
)
70+
return not missing_projects
71+
72+
73+
def jbi_service_health_map(actions: Actions):
4874
"""Returns dictionary of health check's for Bugzilla and Jira Services"""
4975
return {
50-
"bugzilla": bugzilla_check_health(),
51-
"jira": jira_check_health(),
76+
"bugzilla": _bugzilla_check_health(),
77+
"jira": _jira_check_health(actions),
5278
}

tests/unit/app/test_monitor.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def test_read_heartbeat_all_services_fail(anon_client, mocked_jira, mocked_bugzi
3232
assert resp.json() == {
3333
"jira": {
3434
"up": False,
35+
"all_projects_are_visible": False,
3536
},
3637
"bugzilla": {
3738
"up": False,
@@ -50,6 +51,7 @@ def test_read_heartbeat_jira_services_fails(anon_client, mocked_jira, mocked_bug
5051
assert resp.json() == {
5152
"jira": {
5253
"up": False,
54+
"all_projects_are_visible": False,
5355
},
5456
"bugzilla": {
5557
"up": True,
@@ -63,13 +65,15 @@ def test_read_heartbeat_bugzilla_services_fails(
6365
"""/__heartbeat__ returns 503 when one service is unavailable."""
6466
mocked_bugzilla().logged_in = False
6567
mocked_jira().get_server_info.return_value = {}
68+
mocked_jira().projects.return_value = [{"key": "MR2"}, {"key": "JST"}]
6669

6770
resp = anon_client.get("/__heartbeat__")
6871

6972
assert resp.status_code == 503
7073
assert resp.json() == {
7174
"jira": {
7275
"up": True,
76+
"all_projects_are_visible": True,
7377
},
7478
"bugzilla": {
7579
"up": False,
@@ -81,13 +85,34 @@ def test_read_heartbeat_success(anon_client, mocked_jira, mocked_bugzilla):
8185
"""/__heartbeat__ returns 200 when checks succeed."""
8286
mocked_bugzilla().logged_in = True
8387
mocked_jira().get_server_info.return_value = {}
88+
mocked_jira().projects.return_value = [{"key": "MR2"}, {"key": "JST"}]
8489

8590
resp = anon_client.get("/__heartbeat__")
8691

8792
assert resp.status_code == 200
8893
assert resp.json() == {
8994
"jira": {
9095
"up": True,
96+
"all_projects_are_visible": True,
97+
},
98+
"bugzilla": {
99+
"up": True,
100+
},
101+
}
102+
103+
104+
def test_jira_heartbeat_visible_projects(anon_client, mocked_jira, mocked_bugzilla):
105+
"""/__heartbeat__ fails if configured projects don't match."""
106+
mocked_bugzilla().logged_in = True
107+
mocked_jira().get_server_info.return_value = {}
108+
109+
resp = anon_client.get("/__heartbeat__")
110+
111+
assert resp.status_code == 503
112+
assert resp.json() == {
113+
"jira": {
114+
"up": True,
115+
"all_projects_are_visible": False,
91116
},
92117
"bugzilla": {
93118
"up": True,
@@ -99,6 +124,7 @@ def test_head_heartbeat(anon_client, mocked_jira, mocked_bugzilla):
99124
"""/__heartbeat__ support head requests"""
100125
mocked_bugzilla().logged_in = True
101126
mocked_jira().get_server_info.return_value = {}
127+
mocked_jira().projects.return_value = [{"key": "MR2"}, {"key": "JST"}]
102128

103129
resp = anon_client.head("/__heartbeat__")
104130

0 commit comments

Comments
 (0)