From b98c34b6f270005aa515123f97e98ef6e31feae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ba=C5=A1ti?= Date: Tue, 12 Mar 2019 17:08:05 +0100 Subject: [PATCH] Add health check endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoint '/v1/health/ping' provides info about service status * OSBS-6692 Signed-off-by: Martin Bašti --- README.md | 65 +++++++++++++++++++++++ omps/api/v1/__init__.py | 2 +- omps/api/v1/health.py | 79 ++++++++++++++++++++++++++++ omps/koji_util.py | 13 +++++ omps/quay.py | 13 +++++ tests/api/v1/test_api_health.py | 92 +++++++++++++++++++++++++++++++++ tests/conftest.py | 24 +++++++++ tests/test_koji_util.py | 21 ++++++++ tests/test_quay.py | 12 ++++- 9 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 omps/api/v1/health.py create mode 100644 tests/api/v1/test_api_health.py diff --git a/README.md b/README.md index 45a774b..6359f98 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,71 @@ curl \ -X DELETE https://example.com/v1/myorg/myrepo/1.1.5 ``` +### Health status + +Check status of OMPS service and accessibility of: +* quay service +* koji service + +If HTTP status is not 200, consider service as not working. + +#### Endpoints + +* [GET] `/v1/health/ping` + + +#### Replies + +**OK** + +HTTP code: 200 + +```json +{ + "ok": true, + "services": { + "koji": { + "details": "It works!", + "ok": true + }, + "quay": { + "details": "It works!", + "ok": true + } + }, + "status": 200 +} +``` + +**Failure** + +HTTP code: other than 200 + +Format of message cannot be guaranteed. However service will try to answer with +the same format as is listed above, for example: + +```json +{ + "ok": false, + "services": { + "koji": { + "details": "Cannot connect!", + "ok": false + }, + "quay": { + "details": "It works!", + "ok": true + } + }, + "status": 503 +} +``` + + +#### Examples +```bash +curl https://example.com/v1/health/ping +``` ## Development diff --git a/omps/api/v1/__init__.py b/omps/api/v1/__init__.py index 660a832..b4d5aa3 100644 --- a/omps/api/v1/__init__.py +++ b/omps/api/v1/__init__.py @@ -7,4 +7,4 @@ API = Blueprint('v1', __name__) -from . import packages, push # noqa, register routes +from . import packages, push, health # noqa, register routes diff --git a/omps/api/v1/health.py b/omps/api/v1/health.py new file mode 100644 index 0000000..8c47cdd --- /dev/null +++ b/omps/api/v1/health.py @@ -0,0 +1,79 @@ +# +# Copyright (C) 2019 Red Hat, Inc +# see the LICENSE file for license +# + +import logging + +from flask import jsonify +import requests +from requests.exceptions import RequestException + +from . import API +from omps.errors import KojiError +from omps.quay import get_cnr_api_version +from omps.koji_util import KOJI + +logger = logging.getLogger(__name__) + + +@API.route("/health/ping", methods=('GET', )) +def ping(): + """Provides status report + * 200 if everything is ok + * 503 if service is not working as expected + :return: HTTP Response + """ + def _ok(): + return { + "ok": True, + "details": "It works!", + } + + def _err(exc): + return { + "ok": False, + "details": str(exc) + } + + everything_ok = True + + # quay.io status + try: + # try to retrieve API version to check if quay.io is alive + get_cnr_api_version() + except RequestException as e: + logger.error('Koji version check: %s', e) + quay_result = _err(e) + everything_ok = False + else: + quay_result = _ok() + + # koji status + try: + # try to retrieve API version to check if koji is alive + KOJI.get_api_version() + except KojiError as e: + logger.error('Quay version check: %s', e) + koji_result = _err(e) + everything_ok = False + else: + koji_result = _ok() + + status_code = ( + requests.codes.ok if everything_ok + else requests.codes.unavailable + ) + + data = { + "ok": everything_ok, + "status": status_code, + "services": { + "koji": koji_result, + "quay": quay_result + } + } + + resp = jsonify(data) + resp.status_code = status_code + return resp diff --git a/omps/koji_util.py b/omps/koji_util.py index e54d057..8e8c886 100644 --- a/omps/koji_util.py +++ b/omps/koji_util.py @@ -83,5 +83,18 @@ def download_manifest_archive(self, nvr, target_fd): url = self._kojiroot_url + path self._file_download(url, target_fd) + def get_api_version(self): + """Returns API version of koji + + :rtype: int + :return: Koji API version + """ + try: + ver = self.session.getAPIVersion() + except Exception as e: + raise KojiError(str(e)) + + return ver + KOJI = KojiUtil() diff --git a/omps/quay.py b/omps/quay.py index 63e16bc..7619a9f 100644 --- a/omps/quay.py +++ b/omps/quay.py @@ -395,4 +395,17 @@ def publish_repo(self, repo): raise QuayPackageError(msg) +def get_cnr_api_version(): + """Returns quay's CNR api version + + :raises: HTTPError if version cannot be fetched + :rtype: str + :returns: API version + """ + url = "https://quay.io/cnr/version" + r = requests.get(url) + r.raise_for_status() + return r.json()['cnr-api'] + + ORG_MANAGER = OrgManager() diff --git a/tests/api/v1/test_api_health.py b/tests/api/v1/test_api_health.py new file mode 100644 index 0000000..6c740fb --- /dev/null +++ b/tests/api/v1/test_api_health.py @@ -0,0 +1,92 @@ +# +# Copyright (C) 2019 Red Hat, Inc +# see the LICENSE file for license +# + +import requests +import requests_mock + +from omps.errors import KojiError + + +PING_ENDPOINT = '/v1/health/ping' + + +def test_health_ping(client, mocked_koji_get_api_version, mocked_quay_version): + """Test reporting of ok status""" + rv = client.get(PING_ENDPOINT) + + expected = { + "ok": True, + "status": requests.codes.ok, + "services": { + "koji": { + "ok": True, + "details": "It works!" + }, + "quay": { + "ok": True, + "details": "It works!" + }, + } + } + + assert rv.status_code == requests.codes.ok + assert rv.get_json() == expected + + +def test_health_ping_broken_quay(client, mocked_koji_get_api_version): + """Test if broken quay is reported properly""" + with requests_mock.Mocker() as m: + m.get( + 'https://quay.io/cnr/version', + status_code=requests.codes.server_error + ) + rv = client.get(PING_ENDPOINT) + + expected = { + "ok": False, + "status": requests.codes.unavailable, + "services": { + "koji": { + "ok": True, + "details": "It works!" + }, + "quay": { + "ok": False, + "details": ( + "500 Server Error: None for url: " + "https://quay.io/cnr/version" + ) + }, + } + } + assert rv.status_code == requests.codes.unavailable + assert rv.get_json() == expected + + +def test_health_ping_broken_koji( + client, mocked_quay_version, mocked_koji_get_api_version +): + """Test if broken koji is reported properly""" + e_msg = "something happened" + mocked_koji_get_api_version.side_effect = KojiError(e_msg) + + rv = client.get(PING_ENDPOINT) + + expected = { + "ok": False, + "status": requests.codes.unavailable, + "services": { + "koji": { + "ok": False, + "details": e_msg + }, + "quay": { + "ok": True, + "details": "It works!" + }, + } + } + assert rv.status_code == requests.codes.unavailable + assert rv.get_json() == expected diff --git a/tests/conftest.py b/tests/conftest.py index e5d7a00..d1c92b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import re import os import shutil +from unittest.mock import Mock import zipfile from flexmock import flexmock @@ -121,6 +122,21 @@ def fake_download(nvr, target_fd): KOJI.download_manifest_archive = orig +@pytest.fixture +def mocked_koji_get_api_version(): + """Mock global KOJI.get_api_version to return valid version""" + def fake_version(): + return 1 + + orig = KOJI.get_api_version + try: + m = Mock(return_value=1) + KOJI.get_api_version = m + yield m + finally: + KOJI.get_api_version = orig + + @pytest.fixture def mocked_koji(): """Return mocked KojiUtil with session""" @@ -129,3 +145,11 @@ def mocked_koji(): ku.initialize(conf) ku._session = flexmock() return ku + + +@pytest.fixture +def mocked_quay_version(): + """Return mocked quay api version""" + with requests_mock.Mocker() as m: + m.get("/cnr/version", json={"cnr-api": "0.0.1-test"}) + yield m diff --git a/tests/test_koji_util.py b/tests/test_koji_util.py index 15aff62..5751d45 100644 --- a/tests/test_koji_util.py +++ b/tests/test_koji_util.py @@ -106,3 +106,24 @@ def test_download_manifest_archive(self, mocked_koji): mocked_koji.download_manifest_archive('test', target_f) target_f.seek(0) assert target_f.read() == data + + def test_get_api_version(self, mocked_koji): + """Test get_api_version method""" + + version = 1 + mocked_koji.session.should_receive('getAPIVersion').and_return(version) + + assert mocked_koji.get_api_version() == version + + def test_get_api_version_error(self, mocked_koji): + """Test if get_api_version method raises proper exception""" + msg = "something wrong happened" + (mocked_koji.session + .should_receive('getAPIVersion') + .and_raise(Exception(msg)) + ) + + with pytest.raises(KojiError) as ke: + mocked_koji.get_api_version() + + assert msg in str(ke) diff --git a/tests/test_quay.py b/tests/test_quay.py index dd63b18..49e61e6 100644 --- a/tests/test_quay.py +++ b/tests/test_quay.py @@ -14,7 +14,12 @@ QuayPackageError, QuayPackageNotFound, ) -from omps.quay import QuayOrganization, ReleaseVersion, OrgManager +from omps.quay import ( + get_cnr_api_version, + QuayOrganization, + OrgManager, + ReleaseVersion, +) from omps.settings import Config @@ -360,3 +365,8 @@ class ConfClass: assert isinstance(unconfigured_org, QuayOrganization) assert not unconfigured_org.public assert not unconfigured_org.oauth_access + + +def test_get_cnr_api_version(mocked_quay_version): + """Tests of quay.get_cnr_api_version function""" + assert get_cnr_api_version() == "0.0.1-test"