Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from src.jbi.bugzilla import BugzillaWebhookRequest
from src.jbi.models import Actions
from src.jbi.runner import IgnoreInvalidRequestError, execute_action
from src.jbi.services import get_jira
from src.jbi.services import jira_visible_projects

SRC_DIR = Path(__file__).parents[1]

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


Expand Down
16 changes: 11 additions & 5 deletions src/app/monitor.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
"""
Router dedicated to Dockerflow APIs
"""
from fastapi import APIRouter, Response
from fastapi import APIRouter, Depends, Response

from src.app import environment
from src.app import configuration, environment
from src.jbi.models import Actions
from src.jbi.services import jbi_service_health_map

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


@api_router.get("/__heartbeat__")
@api_router.head("/__heartbeat__")
def heartbeat(response: Response):
def heartbeat(
response: Response, actions: Actions = Depends(configuration.get_actions)
):
"""Return status of backing services, as required by Dockerflow."""
health_map = jbi_service_health_map()
if not all(health["up"] for health in health_map.values()):
health_map = jbi_service_health_map(actions)
health_checks = []
for health in health_map.values():
health_checks.extend(health.values())
if not all(health_checks):
response.status_code = 503
return health_map

Expand Down
14 changes: 13 additions & 1 deletion src/jbi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import warnings
from inspect import signature
from types import ModuleType
from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Union
from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Set, Union

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

def __iter__(self):
return iter(self.__root__)

def __len__(self):
return len(self.__root__)

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

@functools.cached_property
def configured_jira_projects_keys(self) -> Set[str]:
"""Return the list of Jira project keys from all configured actions"""
return {
action.parameters["jira_project_key"]
for action in self.__root__
if "jira_project_key" in action.parameters
}

@validator("__root__")
def validate_actions( # pylint: disable=no-self-argument
cls, actions: List[Action]
Expand Down
42 changes: 34 additions & 8 deletions src/jbi/services.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"""Services and functions that can be used to create custom actions"""
from typing import TypedDict
import logging
from typing import Dict, List

import bugzilla as rh_bugzilla
from atlassian import Jira

from src.app import environment
from src.jbi.models import Actions

settings = environment.get_settings()

logger = logging.getLogger(__name__)

ServiceHealth = TypedDict("ServiceHealth", {"up": bool})

ServiceHealth = Dict[str, bool]


def get_jira():
Expand All @@ -22,31 +26,53 @@ def get_jira():
)


def jira_visible_projects(jira=None) -> List[Dict]:
"""Return list of projects that are visible with the configured Jira credentials"""
jira = jira or get_jira()
projects: List[Dict] = jira.projects(included_archived=None)
return projects


def get_bugzilla():
"""Get bugzilla service"""
return rh_bugzilla.Bugzilla(
settings.bugzilla_base_url, api_key=str(settings.bugzilla_api_key)
)


def bugzilla_check_health() -> ServiceHealth:
def _bugzilla_check_health() -> ServiceHealth:
"""Check health for Bugzilla Service"""
bugzilla = get_bugzilla()
health: ServiceHealth = {"up": bugzilla.logged_in}
return health


def jira_check_health() -> ServiceHealth:
def _jira_check_health(actions: Actions) -> ServiceHealth:
"""Check health for Jira Service"""
jira = get_jira()
server_info = jira.get_server_info(True)
health: ServiceHealth = {"up": server_info is not None}
is_up = server_info is not None
health: ServiceHealth = {
"up": is_up,
"all_projects_are_visible": is_up and _all_jira_projects_visible(jira, actions),
}
return health


def jbi_service_health_map():
def _all_jira_projects_visible(jira, actions: Actions) -> bool:
visible_projects = {project["key"] for project in jira_visible_projects(jira)}
missing_projects = actions.configured_jira_projects_keys - visible_projects
if missing_projects:
logger.error(
"Jira projects %s are not visible with configured credentials",
missing_projects,
)
return not missing_projects


def jbi_service_health_map(actions: Actions):
"""Returns dictionary of health check's for Bugzilla and Jira Services"""
return {
"bugzilla": bugzilla_check_health(),
"jira": jira_check_health(),
"bugzilla": _bugzilla_check_health(),
"jira": _jira_check_health(actions),
}
26 changes: 26 additions & 0 deletions tests/unit/app/test_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def test_read_heartbeat_all_services_fail(anon_client, mocked_jira, mocked_bugzi
assert resp.json() == {
"jira": {
"up": False,
"all_projects_are_visible": False,
},
"bugzilla": {
"up": False,
Expand All @@ -50,6 +51,7 @@ def test_read_heartbeat_jira_services_fails(anon_client, mocked_jira, mocked_bug
assert resp.json() == {
"jira": {
"up": False,
"all_projects_are_visible": False,
},
"bugzilla": {
"up": True,
Expand All @@ -63,13 +65,15 @@ def test_read_heartbeat_bugzilla_services_fails(
"""/__heartbeat__ returns 503 when one service is unavailable."""
mocked_bugzilla().logged_in = False
mocked_jira().get_server_info.return_value = {}
mocked_jira().projects.return_value = [{"key": "MR2"}, {"key": "JST"}]

resp = anon_client.get("/__heartbeat__")

assert resp.status_code == 503
assert resp.json() == {
"jira": {
"up": True,
"all_projects_are_visible": True,
},
"bugzilla": {
"up": False,
Expand All @@ -81,13 +85,34 @@ def test_read_heartbeat_success(anon_client, mocked_jira, mocked_bugzilla):
"""/__heartbeat__ returns 200 when checks succeed."""
mocked_bugzilla().logged_in = True
mocked_jira().get_server_info.return_value = {}
mocked_jira().projects.return_value = [{"key": "MR2"}, {"key": "JST"}]

resp = anon_client.get("/__heartbeat__")

assert resp.status_code == 200
assert resp.json() == {
"jira": {
"up": True,
"all_projects_are_visible": True,
},
"bugzilla": {
"up": True,
},
}


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

resp = anon_client.get("/__heartbeat__")

assert resp.status_code == 503
assert resp.json() == {
"jira": {
"up": True,
"all_projects_are_visible": False,
},
"bugzilla": {
"up": True,
Expand All @@ -99,6 +124,7 @@ def test_head_heartbeat(anon_client, mocked_jira, mocked_bugzilla):
"""/__heartbeat__ support head requests"""
mocked_bugzilla().logged_in = True
mocked_jira().get_server_info.return_value = {}
mocked_jira().projects.return_value = [{"key": "MR2"}, {"key": "JST"}]

resp = anon_client.head("/__heartbeat__")

Expand Down