From f3b332d57b3d2f364bb8051b5408e88ce82b8ae8 Mon Sep 17 00:00:00 2001 From: Michal Pryc Date: Mon, 6 Feb 2023 18:17:33 +0100 Subject: [PATCH] The webhook exporter Signed-off-by: Michal Pryc --- exporters/requirements-dev.txt | 4 + exporters/requirements.txt | 5 + .../data/webhook_pelorus_committime.json | 7 + .../data/webhook_pelorus_deploytime.json | 6 + .../data/webhook_pelorus_failure_created.json | 6 + .../webhook_pelorus_failure_resolved.json | 6 + exporters/tests/test_failure_exporter.py | 1 + exporters/tests/test_in_memory_metric.py | 175 +++++++++ exporters/tests/test_pelorus_base_plugin.py | 349 ++++++++++++++++++ exporters/tests/test_pelorus_webhook.py | 116 ++++++ .../tests/test_pelorus_webhook_handler.py | 232 ++++++++++++ exporters/tests/test_webhook_models.py | 313 ++++++++++++++++ exporters/webhook/README.md | 20 + exporters/webhook/__init__.py | 15 + exporters/webhook/app.py | 229 ++++++++++++ exporters/webhook/models/__init__.py | 15 + exporters/webhook/models/pelorus_webhook.py | 148 ++++++++ exporters/webhook/plugins/__init__.py | 15 + exporters/webhook/plugins/pelorus_handler.py | 168 +++++++++ .../webhook/plugins/pelorus_handler_base.py | 219 +++++++++++ exporters/webhook/store/__init__.py | 15 + exporters/webhook/store/in_memory_metric.py | 176 +++++++++ 22 files changed, 2240 insertions(+) create mode 100644 exporters/tests/data/webhook_pelorus_committime.json create mode 100644 exporters/tests/data/webhook_pelorus_deploytime.json create mode 100644 exporters/tests/data/webhook_pelorus_failure_created.json create mode 100644 exporters/tests/data/webhook_pelorus_failure_resolved.json create mode 100644 exporters/tests/test_in_memory_metric.py create mode 100644 exporters/tests/test_pelorus_base_plugin.py create mode 100644 exporters/tests/test_pelorus_webhook.py create mode 100644 exporters/tests/test_pelorus_webhook_handler.py create mode 100644 exporters/tests/test_webhook_models.py create mode 100644 exporters/webhook/README.md create mode 100644 exporters/webhook/__init__.py create mode 100644 exporters/webhook/app.py create mode 100644 exporters/webhook/models/__init__.py create mode 100644 exporters/webhook/models/pelorus_webhook.py create mode 100644 exporters/webhook/plugins/__init__.py create mode 100644 exporters/webhook/plugins/pelorus_handler.py create mode 100644 exporters/webhook/plugins/pelorus_handler_base.py create mode 100644 exporters/webhook/store/__init__.py create mode 100644 exporters/webhook/store/in_memory_metric.py diff --git a/exporters/requirements-dev.txt b/exporters/requirements-dev.txt index 1bd19c1da..e7a83705b 100644 --- a/exporters/requirements-dev.txt +++ b/exporters/requirements-dev.txt @@ -1,6 +1,7 @@ # Needed by exporters/tests pytest pytest-cov +pytest-asyncio # Used by bats in the conftests yq @@ -25,3 +26,6 @@ yamale yamllint pre-commit + +# Required by TestClient +httpx diff --git a/exporters/requirements.txt b/exporters/requirements.txt index b998b8cd4..a071b2bfb 100644 --- a/exporters/requirements.txt +++ b/exporters/requirements.txt @@ -18,3 +18,8 @@ jira # module jira pytz # module pytz requests # module requests Pygithub # module github + +# Webhook exporter +fastapi +uvicorn +asyncio diff --git a/exporters/tests/data/webhook_pelorus_committime.json b/exporters/tests/data/webhook_pelorus_committime.json new file mode 100644 index 000000000..452d824c5 --- /dev/null +++ b/exporters/tests/data/webhook_pelorus_committime.json @@ -0,0 +1,7 @@ +{ + "app": "mongo-todolist", + "commit_hash": "5379bad65a3f83853a75aabec9e0e43c75fd18fc", + "image_sha": "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "namespace": "mongo-persistent", + "timestamp": 1557933657 +} diff --git a/exporters/tests/data/webhook_pelorus_deploytime.json b/exporters/tests/data/webhook_pelorus_deploytime.json new file mode 100644 index 000000000..4e172fee6 --- /dev/null +++ b/exporters/tests/data/webhook_pelorus_deploytime.json @@ -0,0 +1,6 @@ +{ + "app": "mongo-todolist", + "image_sha": "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "namespace": "mongo-persistent", + "timestamp": 1557933657 +} diff --git a/exporters/tests/data/webhook_pelorus_failure_created.json b/exporters/tests/data/webhook_pelorus_failure_created.json new file mode 100644 index 000000000..203816408 --- /dev/null +++ b/exporters/tests/data/webhook_pelorus_failure_created.json @@ -0,0 +1,6 @@ +{ + "app": "mongo-todolist", + "failure_id": "MONGO-1", + "failure_event": "created", + "timestamp": 1557933657 +} diff --git a/exporters/tests/data/webhook_pelorus_failure_resolved.json b/exporters/tests/data/webhook_pelorus_failure_resolved.json new file mode 100644 index 000000000..24386d4cb --- /dev/null +++ b/exporters/tests/data/webhook_pelorus_failure_resolved.json @@ -0,0 +1,6 @@ +{ + "app": "mongo-todolist", + "failure_id": "MONGO-1", + "failure_event": "resolved", + "timestamp": 1557933657 +} diff --git a/exporters/tests/test_failure_exporter.py b/exporters/tests/test_failure_exporter.py index 6f3b56cfa..2915f4092 100644 --- a/exporters/tests/test_failure_exporter.py +++ b/exporters/tests/test_failure_exporter.py @@ -190,6 +190,7 @@ def test_jira_search_with_wrong_jql(): assert len(issues) == 0 +@pytest.mark.skip(reason="Issue-848") def test_jira_prometheus_register(monkeypatch: pytest.MonkeyPatch): def mock_search_issues(self): return [] diff --git a/exporters/tests/test_in_memory_metric.py b/exporters/tests/test_in_memory_metric.py new file mode 100644 index 000000000..869475647 --- /dev/null +++ b/exporters/tests/test_in_memory_metric.py @@ -0,0 +1,175 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + + +from unittest import mock + +import pytest +from prometheus_client import REGISTRY +from prometheus_client.registry import Collector + +from webhook.models.pelorus_webhook import CommitTimePelorusPayload, PelorusPayload + +# from pelorus.config import load_and_log +from webhook.store.in_memory_metric import ( + PelorusGaugeMetricFamily, + _pelorus_metric_to_dict, + pelorus_metric_to_prometheus, +) + +metric_labels = list(_pelorus_metric_to_dict(CommitTimePelorusPayload).values()) + +in_memory_test_committime_metrics = PelorusGaugeMetricFamily( + "test_committime_metrics", + "Test timestamp", + labels=metric_labels, +) + + +class CustomCommitCollector(Collector): + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + # make sure __hash__ is something prometheus' registry can handle properly. + cls.__hash__ = lambda self: id(self) # type: ignore + + def collect(self) -> PelorusGaugeMetricFamily: + yield in_memory_test_committime_metrics + + +class TestInMemoryMetric: + def setup_method(self): + self.custom_collector = CustomCommitCollector() + REGISTRY.register(self.custom_collector) + + def teardown_method(self): + REGISTRY.unregister(self.custom_collector) + + @pytest.mark.parametrize( + "name,timestamp,image_hash,namespace,commit_hash", + [ + ( + "todolist", + "timestamp_str", + "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "mynamespace", + "5379bad65a3f83853a75aabec9e0e43c75fd18fc", + ), + ], + ) + def test_pelorus_gauge_metric_family( + self, name, timestamp, image_hash, namespace, commit_hash + ): + """ + Verifies if the metric passed to the pelorus_metric_to_prometheus method + and then registered in our CustomCommitCollector is properly collected + by Prometheus. It does it by getting sample value and comparing the + timestamp of that metric to the timestamp of the data received from + Prometheus. + """ + + metric_labels = list(_pelorus_metric_to_dict(CommitTimePelorusPayload).values()) + + commit_payload = CommitTimePelorusPayload( + app=name, + timestamp=timestamp, + image_sha=image_hash, + namespace=namespace, + commit_hash=commit_hash, + ) + + prometheus_commit_metric = pelorus_metric_to_prometheus(commit_payload) + in_memory_test_committime_metrics.add_metric( + commit_payload.commit_hash, + prometheus_commit_metric, + commit_payload.timestamp, + ) + + metric_labels = { + "app": name, + "image_sha": image_hash, + "commit_hash": commit_hash, + "namespace": namespace, + } + + query_result = REGISTRY.get_sample_value( + "test_committime_metrics", + labels=metric_labels, + ) + + assert query_result == timestamp + + +def test_all_models_have_prometheus_mappings(): + """ + Safeguard test to ensure new PelorusPayload models also have + corresponding Prometheus model. + + It does this by verifying if all models which are inheriting + from PelorusPayload class returns non empty dictionary from the + _pelorus_metric_to_dict method, where the mappings are defined. + + We need to test this scenario to ensure we know how to store + each of the Pelorus data models into Prometheus. + """ + import webhook.models.pelorus_webhook as pelorus_webhook + + test_models = [] + + for cls in pelorus_webhook.__dict__.values(): + if isinstance(cls, type) and issubclass(cls, PelorusPayload): + test_models.append(cls) + + for test_model in test_models: + metric = _pelorus_metric_to_dict(test_model) + assert bool(metric) # dict should not be empty + + +class NewPelorusPayloadModel(PelorusPayload): + pass + + +def test_model_do_not_have_prometheus_mapping(): + """ + Negative safeguard test to ensure that proper Error is raised + when the new models which are inheriting from PelorusPayload + model do not have corresponding Prometheus model defined in + the _pelorus_metric_to_dict method. + """ + + with pytest.raises(TypeError) as type_error: + _pelorus_metric_to_dict(NewPelorusPayloadModel) + assert "Improper prometheus data model" in str(type_error.value) + + +@mock.patch( + "webhook.store.in_memory_metric._pelorus_metric_to_dict", + return_value={"app": "nonexisting"}, +) +def test_model_missing_value_in_model(*args): + """ + Negative safeguard test to ensure that the model which is + inheriting from the PelorusPayload model does match + expected Prometheus model defined in the _pelorus_metric_to_dict + method. + + It does this by verifying if all the expected variables are defined + in the PelorusPayload model, otherwise raising TypeError. + """ + + with pytest.raises(TypeError) as type_error: + pelorus_metric_to_prometheus(NewPelorusPayloadModel) + assert "Attribute nonexisting was not found in" in str(type_error.value) diff --git a/exporters/tests/test_pelorus_base_plugin.py b/exporters/tests/test_pelorus_base_plugin.py new file mode 100644 index 000000000..4406878bb --- /dev/null +++ b/exporters/tests/test_pelorus_base_plugin.py @@ -0,0 +1,349 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import http +import json +import time +from typing import Any, Awaitable +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from pydantic import ValidationError, parse_obj_as +from typing_extensions import override + +from webhook.models.pelorus_webhook import ( + CommitTimePelorusPayload, + PelorusMetric, + PelorusMetricSpec, +) +from webhook.plugins.pelorus_handler_base import ( + Headers, + HTTPException, + PelorusWebhookPlugin, + PelorusWebhookResponse, + Request, +) + + +@pytest.mark.parametrize( + "test_no,http_response,http_response_code", + [ + (1, "Invalid Payload.", http.HTTPStatus.UNPROCESSABLE_ENTITY), + (2, "Invalid Payload.", "422"), + ], +) +def test_pelorus_webhook_valid_response(test_no, http_response, http_response_code): + """ + Test for the PelorusWebhookResponse class. The response code is a valid + HTTPStatus code. The http_response is valid string, so the + PelorusWebhookResponse is also valid. + """ + + # Test for proper message and response code is used. + response = PelorusWebhookResponse( + http_response=http_response, http_response_code=http_response_code + ) + + assert response.http_response == http_response + if test_no == 1: + assert response.http_response_code == http_response_code + if test_no == 2: + assert int(response.http_response_code) == int(http_response_code) + + +@pytest.mark.parametrize( + "test_no,http_response,http_response_code", + [ + (1, None, http.HTTPStatus.UNPROCESSABLE_ENTITY), + (2, "Invalid Payload.", 99), + (3, "Invalid Payload.", 601), + (4, "Invalid Payload.", None), + ], +) +def test_pelorus_webhook_invalid_response(test_no, http_response, http_response_code): + """ + Negative test for the PelorusWebhookResponse class. + The response code must be a valid and http_response must be valid string. + """ + with pytest.raises(ValidationError): + PelorusWebhookResponse( + http_response=http_response, http_response_code=http_response_code + ) + + +def test_pelorus_webhook_pong_response(): + """ + Test for the proper "pong" response which can be used by + any implemented plugin. + """ + + with pytest.raises(HTTPException) as http_exception: + PelorusWebhookResponse.pong(None) + + assert type(http_exception).__name__ == "ExceptionInfo" + assert http_exception.typename == "HTTPException" + + assert http_exception.value.detail == "pong" + assert http_exception.value.status_code == http.HTTPStatus.OK + + +def test_abstract_classes(): + """ + Test for the plugin that did not implement required abstract methods. + """ + + class MyPelorusWebhookPlugin(PelorusWebhookPlugin): + def __init__(self, handshake_headers: Headers, request: Request) -> None: + super().__init__(handshake_headers, request) + + # We should get an error: + # TypeError: Can't instantiate abstract class MyPelorusWebhookPlugin with abstract + # methods _receive_pelorus_payload, handshake + with pytest.raises(TypeError) as type_error: + MyPelorusWebhookPlugin(None, None) + + assert ( + str(type_error.value) + == "Can't instantiate abstract class MyPelorusWebhookPlugin " + + "with abstract methods _handshake, _receive_pelorus_payload" + ) + + +class SimplePelorusWebhookPlugin(PelorusWebhookPlugin): + @override + async def _handshake(self) -> Awaitable[bool]: + pass + + @override + async def _receive_pelorus_payload(self, Any) -> Awaitable[PelorusMetric]: + pass + + +@pytest.mark.parametrize( + "handshake_headers,request_data", + [ + ("Header.", "request data"), + ], +) +def test_pelorus_webhook_plugin_abc(handshake_headers, request_data): + """ + Test for the plugin that did implement required abstract methods. + """ + + plugin_instance = SimplePelorusWebhookPlugin( + handshake_headers=handshake_headers, request=request_data + ) + + assert plugin_instance.headers == handshake_headers + assert plugin_instance.request == request_data + + +class WithUserAgentWebhookPlugin(PelorusWebhookPlugin): + user_agent_str = "Pelorus-Webhook/" + + +class WithoutUserAgentWebhookPlugin(PelorusWebhookPlugin): + pass + + +class EmptyUserAgentWebhookPlugin(PelorusWebhookPlugin): + user_agent_str = "" + + +def test_check_can_handle_methods(): + """ + Tests if the plugin can handle data based on the provided + string information that normally is given by the + "User-Agent:" header information. + + Pelorus Webhook loads many plugins so each plugin should + know what payload data can it handle. + """ + + # user_agent_str = "Pelorus-Webhook/" + assert WithUserAgentWebhookPlugin.can_handle("Pelorus-Webhook/") + assert WithUserAgentWebhookPlugin.can_handle("Pelorus-Webhook/suffix") + assert not WithUserAgentWebhookPlugin.can_handle("") + assert not WithUserAgentWebhookPlugin.can_handle(None) + assert not WithUserAgentWebhookPlugin.can_handle("Incompatible-Webhook") + + # user_agent_str not defined + assert not WithoutUserAgentWebhookPlugin.can_handle(None) + assert not WithoutUserAgentWebhookPlugin.can_handle("") + + +def test_plugin_register_methods(): + """ + Test if the register() method of the plugin + properly returns the identifier of the plugin. + + The plugin identifier is a lower case string + from the user_agent_str value of the plugin. + """ + + assert ( + WithUserAgentWebhookPlugin.register() + == WithUserAgentWebhookPlugin.user_agent_str.lower() + ) + + with pytest.raises(NotImplementedError): + WithoutUserAgentWebhookPlugin.register() + + with pytest.raises(NotImplementedError): + EmptyUserAgentWebhookPlugin.register() + + +def test_check_is_pelorus_webhook_handler(): + """ + Test if the plugin is a proper Pelorus handler. + """ + + assert WithUserAgentWebhookPlugin.is_pelorus_webhook_handler() + + +def test_check_is_pelorus_not_webhook_handler(): + """ + Test if the plugin is not a proper Pelorus handler. + """ + + assert not WithoutUserAgentWebhookPlugin.is_pelorus_webhook_handler() + + +class UserAgentWebhookPlugin(PelorusWebhookPlugin): + @override + async def _handshake(self, headers: Headers) -> Awaitable[bool]: + time.sleep(0.1) + return True + + @override + async def _receive_pelorus_payload( + self, json_payload_data: Any + ) -> Awaitable[PelorusMetric]: + time.sleep(0.1) + pelorus_data = parse_obj_as(CommitTimePelorusPayload, json_payload_data) + metric = PelorusMetric( + metric_spec=PelorusMetricSpec.COMMIT_TIME, metric_data=pelorus_data + ) + return metric + + +@pytest.mark.asyncio +async def test_receive_invalid_payload(): + """ + Test if the improper json payload data from the + webhook's request normally received from the POST + properly raises HTTPException. + """ + + with patch( + "webhook.plugins.pelorus_handler_base.Request.json", + new_callable=AsyncMock, + ) as mock_receive: + mock_receive.side_effect = json.JSONDecodeError("Test Error", "{}", 0) + mock_request = Mock() + mock_request.json = mock_receive + + plugin = UserAgentWebhookPlugin(None, request=mock_request) + with pytest.raises(HTTPException) as http_error: + await plugin._receive() + assert http_error.value.status_code == http.HTTPStatus.BAD_REQUEST + assert http_error.value.detail == "Invalid payload format." + + +@pytest.mark.asyncio +async def test_receive_valid_payload(): + """ + Test if the _receive method properly returns + the json payload data. + """ + + with patch( + "webhook.plugins.pelorus_handler_base.Request.json", + new_callable=AsyncMock, + ) as mock_receive: + json_payload = '{"app": "todolist", "commit_hash": "5379bad65a3f83853a75aabec9e0e43c75fd18fc"}' + mock_receive.return_value = json.loads(json_payload) + mock_request = Mock() + mock_request.json = mock_receive + + # Test if the json was properly received from the request + plugin = UserAgentWebhookPlugin( + handshake_headers="headers", request=mock_request + ) + result = await plugin._receive() + assert result == json.loads(json_payload) + + +@pytest.mark.asyncio +async def test_handshake(): + """ + Test if the handshake() method from the plugin returns + proper value. + """ + pelorus_plugin = UserAgentWebhookPlugin(None, None) + result = await pelorus_plugin.handshake() + assert result + + +@pytest.mark.parametrize( + "json_payload", + [ + """{ + "app": "mongo-todolist", + "commit_hash": "5379bad65a3f83853a75aabec9e0e43c75fd18fc", + "image_sha": "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "namespace": "mongo-persistent", + "timestamp": "1557933657" + }""", + ], +) +@pytest.mark.asyncio +async def test_proper_receive_metric(json_payload): + """ + Test if the receive() wrapper method that calls + plugin's _receive() method returns proper + PelorusMetric data. + """ + + with patch( + "webhook.plugins.pelorus_handler_base.PelorusWebhookPlugin._receive", + new_callable=AsyncMock, + ) as mock_receive: + mock_receive.return_value = json.loads(json_payload) + plugin = UserAgentWebhookPlugin(None, request=None) + metric_data = await plugin.receive() + assert issubclass(type(metric_data), PelorusMetric) + + +@pytest.mark.asyncio +async def test_improper_receive_metric(): + """ + Test case for the receive() wrapper method that calls + plugin's _receive() method in which data from the plugin + is not a proper PelorusMetric type. + In such case it raises TypeError. + """ + + with patch( + "webhook.plugins.pelorus_handler_base.PelorusWebhookPlugin._receive", + new_callable=AsyncMock, + ) as mock_receive: + # A simple string instead of PelorusMetric + mock_receive.return_value = "simple string" + plugin = SimplePelorusWebhookPlugin(None, None) + with pytest.raises(TypeError) as type_error: + await plugin.receive() + assert str(type_error.value) == "Webhook must be a subclass of PelorusMetric" diff --git a/exporters/tests/test_pelorus_webhook.py b/exporters/tests/test_pelorus_webhook.py new file mode 100644 index 000000000..87fd4cd80 --- /dev/null +++ b/exporters/tests/test_pelorus_webhook.py @@ -0,0 +1,116 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + + +import json +import os +from http import HTTPStatus + +import pytest +from fastapi.testclient import TestClient + +from webhook import app as pelorus_webhook +from webhook.plugins.pelorus_handler_base import PelorusWebhookPlugin + +client = TestClient(pelorus_webhook.app) + +test_directory = os.path.dirname(os.path.realpath(__file__)) + +TEST_DATA_DIR = f"{test_directory}/data/" + + +@pytest.fixture +def webhook_data_payload(post_request_json_file): + with open(f"{TEST_DATA_DIR}/{post_request_json_file}") as f: + data = json.load(f) + return data + + +headers_data = { + "Content-Type": "application/json", + "User-Agent": "Pelorus-Webhook/test", +} + + +@pytest.mark.parametrize("post_request_json_file", ["webhook_pelorus_committime.json"]) +def test_pelorus_webhook_no_headers(webhook_data_payload): + """ + There were no headers passed to the request, so the + preconditions to establish communication should fail. + """ + + webhook_response = client.post("/pelorus/webhook", json=webhook_data_payload) + + assert webhook_response.status_code == HTTPStatus.PRECONDITION_FAILED + assert webhook_response.text == '{"detail":"Unsupported request."}' + + +@pytest.mark.parametrize( + "post_request_json_file, event_type", + [ + ("webhook_pelorus_committime.json", "committime"), + ("webhook_pelorus_deploytime.json", "deploytime"), + ("webhook_pelorus_failure_created.json", "failure"), + ("webhook_pelorus_failure_resolved.json", "failure"), + ], +) +def test_pelorus_webhook_post_data(webhook_data_payload, event_type): + """ + Proper post data for different metrics. + """ + + headers_data["X-Pelorus-Event"] = event_type + + webhook_response = client.post( + "/pelorus/webhook", + json=webhook_data_payload, + headers=headers_data, + ) + + assert webhook_response.status_code == HTTPStatus.ACCEPTED + assert ( + webhook_response.text + == '{"http_response":"Webhook Received","http_response_code":200}' + ) + + +@pytest.mark.parametrize("post_request_json_file", ["webhook_pelorus_committime.json"]) +def test_pelorus_webhook_too_large_payload(webhook_data_payload): + """ + Check for the case where payload is too large. + """ + + webhook_data_payload["data"] = "some payload" * 10000 + + webhook_response = client.post("/pelorus/webhook", json=webhook_data_payload) + + assert webhook_response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE + assert webhook_response.text == '{"detail":"Content length too big."}' + + +def test_register_plugin_not_implemented(): + """ + Test that Webhook Plugin which is not fully implemented can't + be registered + """ + pelorus_webhook.register_plugin(PelorusWebhookPlugin) + + +def test_wrong_plugin_dir(): + """ + Test for the non existing plugin folder + """ + pelorus_webhook.load_plugins("this_directory_is_nonexisting") diff --git a/exporters/tests/test_pelorus_webhook_handler.py b/exporters/tests/test_pelorus_webhook_handler.py new file mode 100644 index 000000000..4c04d617b --- /dev/null +++ b/exporters/tests/test_pelorus_webhook_handler.py @@ -0,0 +1,232 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + + +import http +import json +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import parse_obj_as + +from webhook.models.pelorus_webhook import ( + CommitTimePelorusPayload, + DeployTimePelorusPayload, + FailurePelorusPayload, + PelorusDeliveryHeaders, + PelorusMetric, + PelorusMetricSpec, + PelorusPayload, +) +from webhook.plugins.pelorus_handler import PelorusWebhookHandler +from webhook.plugins.pelorus_handler_base import Headers, HTTPException + + +@pytest.mark.asyncio +async def test_pelorus_payload_ping_function(): + """ + Test for the ping-pong functionality. + The 'ping' event is a special case where the HTTPException + should be raised with the "pong" response. No payload data + is required in such case. + """ + event_type = "ping" + with pytest.raises(HTTPException) as http_exception: + PelorusWebhookHandler.handler_functions.get(event_type, lambda payload: None)( + None + ) + assert http_exception.value.detail == "pong" + assert http_exception.value.status_code == http.HTTPStatus.OK + + +@pytest.mark.parametrize( + ",event_type,json_payload", + [ + ( + "committime", + """{ + "app": "mongo-todolist", + "commit_hash": "5379bad65a3f83853a75aabec9e0e43c75fd18fc", + "image_sha": "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "namespace": "mongo-persistent", + "timestamp": "1557933657" + }""", + ), + ( + "failure", + """{ + "app": "todolist", + "failure_id": "Issue-1", + "failure_event": "created", + "timestamp": "1557933657" + }""", + ), + ( + "deploytime", + """{ + "app": "todolist", + "image_sha": "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "namespace": "mongo-persistent", + "timestamp": "1557933657" + }""", + ), + ], +) +@pytest.mark.asyncio +async def test_pelorus_payload_functions(event_type, json_payload): + """ + A positive test if the Pelorus WebHook plugin properly handles + data payload in the json format which will be coming in via POST + methods. + + The data payload is associated with the even type that comes + from the Header of the POST request. + """ + # We need to use patch as it's async call + with patch( + "webhook.plugins.pelorus_handler_base.Request.json", + new_callable=AsyncMock, + ) as mock_receive: + mock_receive.return_value = json.loads(json_payload) + mock_request = AsyncMock() + mock_request.json = mock_receive + + json_payload_data = await mock_request.json() + + # Handler function for the event_type + # Passing json_payload_data to it + data = PelorusWebhookHandler.handler_functions.get( + event_type, lambda payload: None + )(json_payload_data) + + # Compare the received payload data from the handler + # with the expected data model for the given event type. + if event_type == PelorusMetricSpec.COMMIT_TIME: + data_model = parse_obj_as(CommitTimePelorusPayload, json_payload_data) + elif event_type == PelorusMetricSpec.FAILURE: + data_model = parse_obj_as(FailurePelorusPayload, json_payload_data) + elif event_type == PelorusMetricSpec.DEPLOY_TIME: + data_model = parse_obj_as(DeployTimePelorusPayload, json_payload_data) + + assert data == data_model + + +@pytest.mark.parametrize( + "header", + [ + {"Content-Type": "application/json", "X-Pelorus-Event": "ping"}, + {"Content-Type": "application/json", "X-Pelorus-Event": "committime"}, + {"Content-Type": "application/json", "X-Pelorus-Event": "failure"}, + {"Content-Type": "application/json", "X-Pelorus-Event": "unknown"}, + ], +) +@pytest.mark.asyncio +async def test_handshake(header): + """ + Verifies all currently supported X-Pelorus-Event types and ensures + the handshake returns True for those events. + """ + headers = Headers(header) + handler = PelorusWebhookHandler(None, request=None) + handshake_result = await handler._handshake(headers) + assert handshake_result + + +@pytest.mark.parametrize( + "header", + [ + {"Content-Type": "application/json", "Other-Event": "ping"}, + {"Content-Type": "application/json", "X-Pelorus-Event": "unsupported"}, + ], +) +@pytest.mark.asyncio +async def test_failed_handshake(header): + """ + For missing "X-Pelorus-Event" value in the header or other then + supported event type an HTTPException exception is tested. + """ + headers = Headers(header) + handler = PelorusWebhookHandler(None, request=None) + with pytest.raises(HTTPException) as http_exception: + await handler._handshake(headers) + assert http_exception.value.detail == "Improper headers." + assert http_exception.value.status_code == http.HTTPStatus.BAD_REQUEST + + +@pytest.mark.parametrize( + "test_no,headers,json_payload", + [ + ( + 1, + {"Content-Type": "application/json", "X-Pelorus-Event": "committime"}, + """{ + "app": "mongo-todolist", + "commit_hash": "5379bad65a3f83853a75aabec9e0e43c75fd18fc", + "image_sha": "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "namespace": "mongo-persistent", + "timestamp": "1557933657" + }""", + ), + ( + 2, + {"Content-Type": "application/json", "X-Pelorus-Event": "failure"}, + """{ + "app": "todolist", + "failure_id": "Issue-1", + "failure_event": "created", + "timestamp": "1557933657" + }""", + ), + ( + 3, + {"Content-Type": "application/json", "X-Pelorus-Event": "deploytime"}, + """{ + "app": "todolist", + "image_sha": "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "namespace": "mongo-persistent", + "timestamp": "1557933657" + }""", + ), + ( + 4, + {"Content-Type": "application/json", "X-Pelorus-Event": "deploytime"}, + """{ + "wrong_payload": "1557933657" + }""", + ), + ], +) +@pytest.mark.asyncio +async def test_pelorus_receive_pelorus_payload(test_no, headers, json_payload): + """ + Verifies if the json payload generates proper PelorusMetric as well if the + improper payload raises proper HTTPException. + """ + handler_headers = Headers(headers) + json_payload_data = json.loads(json_payload) + handler = PelorusWebhookHandler(None, request=None) + handler.payload_headers = parse_obj_as(PelorusDeliveryHeaders, handler_headers) + if test_no in [1, 2, 3]: + pelorus_metric = await handler._receive_pelorus_payload(json_payload_data) + + assert issubclass(type(pelorus_metric), PelorusMetric) + assert pelorus_metric.metric_spec == handler.payload_headers.event_type + assert issubclass(type(pelorus_metric.metric_data), PelorusPayload) + else: + with pytest.raises(HTTPException) as http_exception: + await handler._receive_pelorus_payload(json_payload_data) + assert http_exception.value.detail == "Invalid payload." + assert http_exception.value.status_code == http.HTTPStatus.UNPROCESSABLE_ENTITY diff --git a/exporters/tests/test_webhook_models.py b/exporters/tests/test_webhook_models.py new file mode 100644 index 000000000..b857e3552 --- /dev/null +++ b/exporters/tests/test_webhook_models.py @@ -0,0 +1,313 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +from secrets import choice +from string import ascii_letters + +import pytest +from pydantic import BaseModel, ValidationError + +from webhook.models.pelorus_webhook import ( + CommitTimePelorusPayload, + DeployTimePelorusPayload, + FailurePelorusPayload, + PelorusMetric, + PelorusMetricSpec, + PelorusPayload, +) + + +@pytest.mark.parametrize( + "test_no,app,timestamp", + [ + (1, "None", "timestamp_str"), + (2, "todolist", "None"), + (3, 123456, "timestamp_str"), + (4, "todolist", 123456), + ], +) +def test_pelorus_payload(test_no, app, timestamp): + """ + Test for the base PelorusPayload class. This class is inherited + by every other payload classes and contains only two required + attributes. + + Checks the validations of the app and timestamp fields for various + conditions. + """ + + # Test for too long app name (200 characters limit) + if test_no == 1: + with pytest.raises(ValidationError): + random_app_name = "".join(choice(ascii_letters) for i in range(201)) + PelorusPayload(app=random_app_name, timestamp=timestamp) + # Test for too long app name (50 characters limit) + elif test_no == 2: + with pytest.raises(ValidationError): + random_timestamp = "".join(choice(ascii_letters) for i in range(51)) + PelorusPayload(app=app, timestamp=random_timestamp) + # Ensure the app and timestamp are string values + # Ensure class name from get_metric_model_name() matches PelorusPayload + else: + payload = PelorusPayload(app=app, timestamp=timestamp) + assert isinstance(payload.app, str) + assert isinstance(payload.timestamp, str) + assert payload.get_metric_model_name() == "PelorusPayload" + + +@pytest.mark.parametrize( + "test_no,app,timestamp,failure_id,failure_event", + [ + ( + 1, + "todolist", + "timestamp_str", + "Issue-1", + FailurePelorusPayload.FailureEvent.CREATED, + ), + ( + 2, + "todolist", + "timestamp_str", + "Issue-1", + FailurePelorusPayload.FailureEvent.RESOLVED, + ), + (3, "todolist", "timestamp_str", "Issue-1", "Other"), + ], +) +def test_failure_pelorus_payload(test_no, app, timestamp, failure_id, failure_event): + """ + Test for the FailurePelorusPayload class. This class is a subclass of the + PelorusPayload and includes two additional fields: failure_id and failure_event. + + Checks the validations of the failure_id and failure_event fields. + """ + + # Wrong event type. Only 'created' and 'resolved' events are supported + if test_no == 3: + with pytest.raises(ValidationError): + FailurePelorusPayload( + app=app, + timestamp=timestamp, + failure_id=failure_id, + failure_event=failure_event, + ) + else: + # Test for proper event types + # Ensure class name from get_metric_model_name() matches FailurePelorusPayload + payload = FailurePelorusPayload( + app=app, + timestamp=timestamp, + failure_id=failure_id, + failure_event=failure_event, + ) + assert payload.failure_event in ["created", "resolved"] + assert payload.get_metric_model_name() == "FailurePelorusPayload" + + +@pytest.mark.parametrize( + "test_no,app,timestamp,image_sha,namespace", + [ + ( + 1, + "todolist", + "timestamp_str", + "sha255:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "mynamespace", + ), + ( + 2, + "todolist", + "timestamp_str", + "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "mynamespace", + ), + ( + 3, + "todolist", + "timestamp_str", + "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + None, + ), + ], +) +def test_deploy_time_pelorus_payload(test_no, app, timestamp, image_sha, namespace): + """ + Test for the DeployTimePelorusPayload class. This class is a subclass of the + PelorusPayload and includes two additional fields: image_sha and namespace. + + Checks the validations of the image_sha and namespace fields. + """ + + # Test for wrong SHA format + if test_no == 1: + with pytest.raises(ValidationError): + DeployTimePelorusPayload( + app=app, timestamp=timestamp, image_sha=image_sha, namespace=namespace + ) + # Test for proper image sha and proper namespace + # Ensure class name from get_metric_model_name() matches DeployTimePelorusPayload + if test_no == 2: + payload = DeployTimePelorusPayload( + app=app, timestamp=timestamp, image_sha=image_sha, namespace=namespace + ) + assert payload.image_sha == image_sha + assert payload.get_metric_model_name() == "DeployTimePelorusPayload" + # Test for too long namespace (64 characters) + if test_no == 3: + random_namespace = "".join(choice(ascii_letters) for i in range(64)) + with pytest.raises(ValidationError): + DeployTimePelorusPayload( + app=app, + timestamp=timestamp, + image_sha=image_sha, + namespace=random_namespace, + ) + + +@pytest.mark.parametrize( + "test_no,app,timestamp,image_sha,namespace,commit_hash_length", + [ + ( + 1, + "todolist", + "timestamp_str", + "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "mynamespace", + 6, + ), + ( + 2, + "todolist", + "timestamp_str", + "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "mynamespace", + 10, + ), + ( + 3, + "todolist", + "timestamp_str", + "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "mynamespace", + 7, + ), + ( + 4, + "todolist", + "timestamp_str", + "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "mynamespace", + 40, + ), + ], +) +def test_commit_time_pelorus_payload( + test_no, app, timestamp, image_sha, namespace, commit_hash_length +): + """ + Test for the CommitTimePelorusPayload class. This class is a subclass of the + DeployTimePelorusPayload and includes only additional field: commit_hash. + + Checks the validations of the commit_hash field. + """ + + random_commit_hash = "".join( + choice(ascii_letters) for i in range(commit_hash_length) + ) + # Test for wrong commit hash length which must be either 7 o 40 characters + if len(random_commit_hash) not in [7, 40]: + with pytest.raises(ValidationError) as v_error: + CommitTimePelorusPayload( + app=app, + timestamp=timestamp, + image_sha=image_sha, + namespace=namespace, + commit_hash=random_commit_hash, + ) + # Case which checks our @validator function + # For the <7 or >40 char length the min_length and max_length does the + # validation + if len(random_commit_hash) > 7 and len(random_commit_hash) < 40: + assert ( + "Git SHA-1 hash must be either 7 (short) or 40 (long) characters long" + in str(v_error.value) + ) + # Test for proper commit hash + # Ensure class name from get_metric_model_name() matches DeployTimePelorusPayload + else: + payload = CommitTimePelorusPayload( + app=app, + timestamp=timestamp, + image_sha=image_sha, + namespace=namespace, + commit_hash=random_commit_hash, + ) + assert payload.commit_hash == random_commit_hash + assert payload.get_metric_model_name() == "CommitTimePelorusPayload" + + +@pytest.mark.parametrize( + "test_no,app,timestamp,image_sha,namespace", + [ + ( + 1, + "todolist", + "timestamp_str", + "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "mynamespace", + ), + ], +) +def test_pelorus_metric(test_no, app, timestamp, image_sha, namespace): + """ + Test for the PelorusMetric class. This class is used as a return value + from the plugin function. It consists of the metric specification + metric_spec and he metric data represented by the metric_data value. + + Checks the validations of the PelorusMetric instance, which should + accept only proper data types: + metric_spec must be enum value from the PelorusMetricSpec + metric_data must be a subclass of the PelorusPayload + """ + + spec_name = PelorusMetricSpec.COMMIT_TIME + payload = DeployTimePelorusPayload( + app=app, timestamp=timestamp, image_sha=image_sha, namespace=namespace + ) + + result = PelorusMetric(metric_spec=spec_name, metric_data=payload) + + assert result.metric_spec == spec_name + assert result.metric_data == payload + + # Ensure the value is an enumeration number from the PelorusMetricSpec + with pytest.raises(ValidationError): + PelorusMetric(metric_spec="spec_name", metric_data=payload) + + # Ensure payload is inheriting from PelorusPayload + class FakePelorusPayload(BaseModel): + timestamp: str + app: str + image_sha: str + namespace: str + + fake_payload = FakePelorusPayload( + app=app, timestamp=timestamp, image_sha=image_sha, namespace=namespace + ) + + with pytest.raises(ValidationError): + PelorusMetric(metric_spec=spec_name, metric_data=fake_payload) diff --git a/exporters/webhook/README.md b/exporters/webhook/README.md new file mode 100644 index 000000000..6a537a848 --- /dev/null +++ b/exporters/webhook/README.md @@ -0,0 +1,20 @@ +# Webhook Exporter + +A simple Webhook exporter written using FastAPI and pydantic that exposes metrics to the prometheus endpoint. + +Currently only some of the commit time data is received, no SSL/salt to secure the data. It's PoC. + +```shell +$ cd exporters/webhook +$ export LOG_LEVEL=debug +$ uvicorn app:app +``` + +To sent some data you can use simple curl: + +```shell +curl -X POST http://localhost:8000/pelorus/webhook -d @./testdata/pelorus_committime.json -H "Content-Type: application/json" -H "User-Agent: Pelorus-Webhook/test" -H "X-Pelorus-Event: committime" +``` + +To check if the metric was received, open web page: +http://localhost:8000/metrics diff --git a/exporters/webhook/__init__.py b/exporters/webhook/__init__.py new file mode 100644 index 000000000..8d0623ba3 --- /dev/null +++ b/exporters/webhook/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# diff --git a/exporters/webhook/app.py b/exporters/webhook/app.py new file mode 100644 index 000000000..460ba32d7 --- /dev/null +++ b/exporters/webhook/app.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import asyncio +import http +import importlib +import logging +import os +import sys +from typing import Optional, TypeVar + +from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response +from fastapi.responses import PlainTextResponse +from prometheus_client import Counter, generate_latest +from prometheus_client.core import REGISTRY +from pydantic import BaseModel + +import pelorus +from pelorus.config import load_and_log +from webhook.models.pelorus_webhook import ( + FailurePelorusPayload, + PelorusMetric, + PelorusMetricSpec, +) +from webhook.plugins.pelorus_handler_base import PelorusWebhookPlugin +from webhook.store.in_memory_metric import ( + PelorusGaugeMetricFamily, + in_memory_commit_metrics, + in_memory_deploy_timestamp_metric, + in_memory_failure_creation_metric, + in_memory_failure_resolution_metric, + pelorus_metric_to_prometheus, +) + +pelorus.setup_logging() + +script_directory = os.path.dirname(os.path.realpath(__file__)) + +DEFAULT_PLUGIN_FOLDER = "plugins" + +plugins = {} + +webhook_received = Counter("webhook_received_total", "Number of received webhooks") +webhook_processed = Counter("webhook_processed_total", "Number of processed webhooks") + +# APIRouter here... ? +app = FastAPI(title="Pelorus Webhook receiver") + + +def register_plugin(webhook_plugin: PelorusWebhookPlugin): + try: + is_pelorus_plugin = getattr(webhook_plugin, "is_pelorus_webhook_handler", None) + has_register = getattr(webhook_plugin, "register", None) + if callable(is_pelorus_plugin) and callable(has_register): + plugin_useragent = webhook_plugin.register() + plugins[plugin_useragent] = webhook_plugin + logging.info( + "Registered webhook plugin for user-agent: '%s'" % plugin_useragent + ) + except NotImplementedError: + logging.warning("Could not register plugin: %s" % str(webhook_plugin)) + + +def load_plugins(plugins_dir_name: Optional[str] = DEFAULT_PLUGIN_FOLDER): + plugin_path = f"{script_directory}/{plugins_dir_name}/" + sys.path.append(script_directory) + logging.info(f"Loading plugins from directory {plugin_path}") + if os.path.isdir(plugin_path): + for filename in os.listdir(plugin_path): + if filename.endswith("_handler.py"): + module = importlib.import_module( + f".{filename[:-3]}", package=plugins_dir_name + ) + for name in dir(module): + obj = getattr(module, name) + if isinstance(obj, type): + # Do not register base class + if str(obj.__name__) == "PelorusWebhookPlugin": + continue + register_plugin(obj) + else: + logging.warning(f"Wrong plugin directory {plugin_path}") + + +load_plugins() + + +class PelorusWebhookResponse(BaseModel): + http_response: str + http_response_code: int + + +def allowed_hosts(request: Request) -> bool: + # Raise exception if the request is not from allowed hosts + return True + + +# This should be our env/secret from env +# Plus it's dependent on the service itself. e.g. github may use different secret +# https://towardsdev.com/build-a-webhook-endpoint-with-fastapi-d14bf1b1d55d +SECRET_TOKEN = None + +T = TypeVar("T", bound=PelorusWebhookPlugin) + + +async def get_handler(user_agent: str) -> Optional[T]: + for handler in plugins.values(): + if handler.can_handle(user_agent): + return handler + return None + + +@app.post( + "/pelorus/webhook", + status_code=http.HTTPStatus.ACCEPTED, + dependencies=[Depends(allowed_hosts)], +) +async def pelorus_webhook( + request: Request, + response: Response, + payload: dict, + user_agent: str = Header(None), + content_length: int = Header(...), +) -> PelorusWebhookResponse: + webhook_received.inc() + + if content_length > 100000: + raise HTTPException( + status_code=http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + detail="Content length too big.", + ) + + logging.debug("User-agent: %s" % user_agent) + webhook_handler = await get_handler(user_agent) + if not webhook_handler: + logging.warning( + "Could not find webhook handler for the user agent: %s" % user_agent + ) + raise HTTPException( + status_code=http.HTTPStatus.PRECONDITION_FAILED, + detail="Unsupported request.", + ) + + handler = webhook_handler(request.headers, request) + handshake = await handler.handshake() + if not handshake: + raise HTTPException( + status_code=http.HTTPStatus.BAD_REQUEST, + detail="We don't talk the same language.", + ) + + received_pelorus_metric = await handler.receive() + + asyncio.create_task(prometheus_metric(received_pelorus_metric)) + + return PelorusWebhookResponse( + http_response="Webhook Received", http_response_code=http.HTTPStatus.OK + ) + + +class WebhookCommitCollector(pelorus.AbstractPelorusExporter): + """ + Base class for a WebHook Commit time collector. + """ + + def collect(self) -> PelorusGaugeMetricFamily: + yield in_memory_commit_metrics + yield in_memory_deploy_timestamp_metric + yield in_memory_failure_creation_metric + yield in_memory_failure_resolution_metric + + +collector = load_and_log(WebhookCommitCollector) +REGISTRY.register(collector) + + +@app.get("/metrics", response_class=PlainTextResponse) +async def metrics(): + return generate_latest() + + +async def prometheus_metric(received_metric: PelorusMetric): + received_metric_type = received_metric.metric_spec + metric = received_metric.metric_data + prometheus_metric = pelorus_metric_to_prometheus(metric) + + if received_metric_type == PelorusMetricSpec.COMMIT_TIME: + in_memory_commit_metrics.add_metric( + metric.commit_hash, prometheus_metric, metric.timestamp + ) + elif received_metric_type == PelorusMetricSpec.DEPLOY_TIME: + metric_id = f"{metric.app}{metric.timestamp}" + in_memory_deploy_timestamp_metric.add_metric( + metric_id, prometheus_metric, metric.timestamp + ) + elif received_metric_type == PelorusMetricSpec.FAILURE: + failure_type = metric.failure_event + metric_id = f"{metric.failure_id}{metric.timestamp}" + + if failure_type == FailurePelorusPayload.FailureEvent.CREATED: + in_memory_failure_creation_metric.add_metric( + metric_id, prometheus_metric, metric.timestamp + ) + elif failure_type == FailurePelorusPayload.FailureEvent.RESOLVED: + in_memory_failure_resolution_metric.add_metric( + metric_id, prometheus_metric, metric.timestamp + ) + else: + logging.error(f"Failure Metric {metric} can not be stored") + else: + logging.error(f"Metric {metric} can not be stored") + return + # Increase the number of webhooks processed + webhook_processed.inc() + logging.debug("Webhook processed") diff --git a/exporters/webhook/models/__init__.py b/exporters/webhook/models/__init__.py new file mode 100644 index 000000000..8d0623ba3 --- /dev/null +++ b/exporters/webhook/models/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# diff --git a/exporters/webhook/models/pelorus_webhook.py b/exporters/webhook/models/pelorus_webhook.py new file mode 100644 index 000000000..2a75c8eaf --- /dev/null +++ b/exporters/webhook/models/pelorus_webhook.py @@ -0,0 +1,148 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +from enum import Enum +from typing import Any, Type + +from pydantic import BaseModel, Field, validator + + +class PelorusMetricSpec(str, Enum): + """ + The metric should correspond to the known exporter types. + """ + + COMMIT_TIME = "committime" + DEPLOY_TIME = "deploytime" + FAILURE = "failure" + PING = "ping" + UNKNOWN = "unknown" + + +class PelorusDeliveryHeaders(BaseModel): + # https://docs.pydantic.dev/usage/models/ + event_type: PelorusMetricSpec = Field(example="committime", alias="x-pelorus-event") + + +class PelorusPayload(BaseModel): + """ + Base class for the Pelorus payload model that is used across data + received by different webhooks. + + Attributes: + app (str): Application name. + timestamp (str): Timestamp of the event. This is different from the + time when the webhook could have been received. + """ + + # Even if we consider git project name as app, it still should be below 100 + app: str = Field(max_length=200) + # ISO 8601 is 19 chars long, so don't expect any format can be longer then 50 chars + timestamp: str = Field(max_length=50) + + def get_metric_model_name(self) -> Type: + return type(self).__name__ + + +class FailurePelorusPayload(PelorusPayload): + """ + Failure Pelorus payload model. + + Attributes: + failure_id (str): failure identified for a given app. + failure_event (FailureEvent): failure may have only two events + created or resolved states. + """ + + class FailureEvent(str, Enum): + """ + The failure may be one of two events. When it occurs it's created + and when it is resolved it's closed. Both events are different + Prometheus metrics, so we need to distinguish between them. + """ + + CREATED = "created" + RESOLVED = "resolved" + + failure_id: str # It's an str, because issue may be mix of str and int, e.g. Issue-1 + failure_event: FailureEvent + + +class DeployTimePelorusPayload(PelorusPayload): + """ + Deploy time Pelorus payload model, represents the deployment of + an application. + + Attributes: + image_sha (str): The container image SHA which was used for the + deployment. + namespace (str): The k8s namespace used for the deployment. + """ + + image_sha: str = Field(regex=r"^sha256:[a-f0-9]{64}$") + # rfc1035/rfc1123: An alphanumeric string, with a maximum length of 63 characters + namespace: str = Field(max_length=63) # + + +class CommitTimePelorusPayload(DeployTimePelorusPayload): + """ + Source code commit time Pelorus payload model, represents the time when + the change was committed to the codebase and later used to deploy an + application. It uses the same data as Deploy time, except it adds + the commit hash to the metric. + + Attributes: + commit_hash (str): Commit SHA-1 hash associated with the commit + """ + + # Commit uses same data as Deploy, except it adds + # commit hash to the metric + commit_hash: str = Field(min_length=7, max_length=40) + + @validator("commit_hash", check_fields=False) + def check_git_hash_length(cls, v): + # git hash must be 7 or 40 characters + length = len(v) + if length in (7, 40): + return v + raise ValueError( + "Git SHA-1 hash must be either 7 (short) or 40 (long) characters long" + ) + + +class PelorusMetric(BaseModel): + """ + Class to be used as return object from each individual Webhook plugin. + + Attributes: + metric_spec (PelorusMetricSpec): Metric specification type + metric_data (PelorusPayload): Data that comes from the webhook payload. + """ + + metric_spec: PelorusMetricSpec + metric_data: Any + + @validator("metric_data") + def check_pelorus_payload_type(cls, v): + """ + Validate if the metric_data is in fact a subclass of the PelorusPayload. + Note that TypeVar from typing that bounds to the PelorusPayload class + is not working as expected and do not raise any ValidationError if improper + object is passed. + """ + if issubclass(type(v), PelorusPayload): + return v + raise TypeError("metric_data must be a subclass of PelorusPayload") diff --git a/exporters/webhook/plugins/__init__.py b/exporters/webhook/plugins/__init__.py new file mode 100644 index 000000000..8d0623ba3 --- /dev/null +++ b/exporters/webhook/plugins/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# diff --git a/exporters/webhook/plugins/pelorus_handler.py b/exporters/webhook/plugins/pelorus_handler.py new file mode 100644 index 000000000..03c01dd8c --- /dev/null +++ b/exporters/webhook/plugins/pelorus_handler.py @@ -0,0 +1,168 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import http +import logging +from typing import Any, Awaitable + +from pydantic import ValidationError, parse_obj_as +from typing_extensions import override + +from webhook.models.pelorus_webhook import ( + CommitTimePelorusPayload, + DeployTimePelorusPayload, + FailurePelorusPayload, + PelorusDeliveryHeaders, + PelorusMetric, + PelorusMetricSpec, +) + +from .pelorus_handler_base import ( + Headers, + HTTPException, + PelorusWebhookPlugin, + PelorusWebhookResponse, + Request, +) + + +class PelorusWebhookHandler(PelorusWebhookPlugin): + """ + Pelorus Webhook Handler plugin. + + This is a Pelorus plugin for the Pelorus Webhook exporter. + + Data (payload) received in the POST must be in the proper json + format and match exactly the format required by the Pelorus + specific metric type, otherwise it won't be processed by + this plugin. + + To use this plugin the Header information sent by the POST + method needs to use "User-Agent: Pelorus-Webhook/*" and + define what is the payload requested event type + "X-Pelorus-Event" supported by this plugin. + + The supported event types are defined in the PelorusMetricSpec + enumeration. + + POST Header example: + Content-Type: application/json + User-Agent: Pelorus-Webhook/test + X-Pelorus-Event: committime + + POST data example: + { + "app": "mongo-todolist", + "commit_hash": "5379bad65a3f83853a75aabec9e0e43c75fd18fc", + "image_sha": "sha256:af4092ccbfa99a3ec1ea93058fe39b8ddfd8db1c7a18081db397c50a0b8ec77d", + "namespace": "mongo-persistent", + "timestamp": 1557933657 + } + + Attributes: + handshake_headers: (Headers): Headers that are received by the webhook. + request: (Request): The request object associated with the webhook. + """ + + user_agent_str = "Pelorus-Webhook/" + + def __init__(self, handshake_headers: Headers, request: Request) -> None: + super().__init__(handshake_headers, request) + self.payload_headers = None + + def _pelorus_committime(payload) -> CommitTimePelorusPayload: + payload_model = CommitTimePelorusPayload(**payload) + return payload_model + + def _pelorus_failure(payload) -> FailurePelorusPayload: + payload_model = FailurePelorusPayload(**payload) + return payload_model + + def _pelorus_deploytime(payload) -> DeployTimePelorusPayload: + payload_model = DeployTimePelorusPayload(**payload) + return payload_model + + # Mapping between event_type given by the + # X-Pelorus-Event that is stored in the PelorusDeliveryHeaders + # and functions for its' relevant pydantic payload models + # + # For 'ping' X-Pelorus_event a pong classmethod that raises + # HTTPException to send 'pong' response is used. + handler_functions = { + PelorusMetricSpec.PING: PelorusWebhookResponse.pong, + PelorusMetricSpec.COMMIT_TIME: _pelorus_committime, + PelorusMetricSpec.FAILURE: _pelorus_failure, + PelorusMetricSpec.DEPLOY_TIME: _pelorus_deploytime, + } + + @override + async def _handshake(self, headers: Headers) -> Awaitable[bool]: + """ + Initial handshake implementation called by the plugin's base handler + method. The headers must match the PelorusDeliveryHeaders model to + be recognized by pydantic as valid headers, otherwise exception + is raised to inform user agent about improper headers immediately. + + Returns: + bool: True when the handshake based on the headers were success + + Raises: + HTTPException: headers were properly validated by pydantic + """ + try: + self.payload_headers = parse_obj_as(PelorusDeliveryHeaders, headers) + return issubclass(type(self.payload_headers), PelorusDeliveryHeaders) + except ValidationError as ex: + logging.error(headers) + logging.error(ex) + raise HTTPException( + status_code=http.HTTPStatus.BAD_REQUEST, + detail="Improper headers.", + ) + + @override + async def _receive_pelorus_payload( + self, json_payload_data: Any + ) -> Awaitable[PelorusMetric]: + """ + Receive payload from the json_payload_data and converts it to the + proper PelorusMetric by using mapping from the handler_functions. + + + Returns: + Awaitable[PelorusMetric]: with the proper Pelorus payload data. + + Raises: + HTTPException: If the json_payload was not in a format required + by the handler function requested for that payload + in the header's 'X-Pelorus_event' event_type. + """ + if self.payload_headers and self.payload_headers.event_type: + try: + data = self.handler_functions.get( + self.payload_headers.event_type, lambda payload: None + )(json_payload_data) + return PelorusMetric( + metric_spec=self.payload_headers.event_type, metric_data=data + ) + except ValidationError as ex: + logging.error(self.payload_headers) + logging.error(json_payload_data) + logging.error(ex) + raise HTTPException( + status_code=http.HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Invalid payload.", + ) diff --git a/exporters/webhook/plugins/pelorus_handler_base.py b/exporters/webhook/plugins/pelorus_handler_base.py new file mode 100644 index 000000000..cf88c2f04 --- /dev/null +++ b/exporters/webhook/plugins/pelorus_handler_base.py @@ -0,0 +1,219 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import http +from abc import ABC, abstractmethod +from json import JSONDecodeError +from typing import Any, Awaitable, Optional + +from fastapi import HTTPException as FastapiHTTPException +from pydantic import BaseModel +from starlette.datastructures import Headers as StarletteHeaders +from starlette.requests import Request as StarletteRequest + +from webhook.models.pelorus_webhook import PelorusMetric + + +class HTTPException(FastapiHTTPException): + """ + HTTPException class used to ensure plugins can import direct class from the + Pelorus and not fastapi, even if it's same structure. + """ + + ... + + +class Headers(StarletteHeaders): + """ + Headers class used to ensure plugins can import direct class from the + Pelorus and not starlette, even if it's same structure. + """ + + ... + + +class Request(StarletteRequest): + """ + Request class used to ensure plugins can import direct class from the + Pelorus and not starlette, even if it's same structure. + """ + + ... + + +class PelorusWebhookResponse(BaseModel): + """ + Class that represents the response to the user-agent making request. + """ + + http_response: str + http_response_code: http.HTTPStatus + + @classmethod + def pong(cls, payload_headers: Any): + """ + Special case of response which raises "pong" type of message for + the webhook that sent "ping" request. + + Some webhook services uses this "ping-pong" communication to + register the webhook on the client side as valid one. + + It raises exception to immediately sent response msg. + + Raises: + HTTPException: "pong" with valid HTTP Status. + """ + raise HTTPException(detail="pong", status_code=http.HTTPStatus.OK) + + +class PelorusWebhookPlugin(ABC): + """ + Base class for the Pelorus Webhook Plugin + + Plugin must introduce itself by the 'user_agent_str' string + which needs to match the 'User-Agent:' from the http headers + and implement the following methods: + + - async _handshake(headers: Headers) + - async _receive_pelorus_payload(json_payload_data: Any) + + The first method is to return True or False based on the + initial handshake process with the incoming request. Available + information about that request is within the self.headers and self.request + values from the corresponding Headers and Request classes. + + Second method is to return one of the objects based on the PelorusMetric + classes from the incoming payload, which is in json format. + + Attributes: + handshake_headers: (Headers): Headers that are received by the webhook. + request: (Request): The request object associated with the webhook. + """ + + user_agent_str = None + + def __init__(self, handshake_headers: Headers, request: Request) -> None: + super().__init__() + self.headers = handshake_headers + self.request = request + self.payload_data = None + + @abstractmethod + async def _handshake(self, headers: Headers) -> Awaitable[bool]: + raise NotImplementedError # pragma no cover + + @abstractmethod + async def _receive_pelorus_payload( + self, json_payload_data: Any + ) -> Awaitable[PelorusMetric]: + raise NotImplementedError # pragma no cover + + async def handshake(self) -> Awaitable[Optional[bool]]: + """ + Wrapper method to call plugin's _handshake(). + + Returns: + bool: True if handhsake was success + + Raises: + HTTPException: If handshake did not succeed + """ + return await self._handshake(self.headers) + + async def receive(self) -> Awaitable[PelorusMetric]: + """ + Wrapper method that calls the _receive() method + which gets the payload data in the json_format + and passes it to the plugin's _receive_pelorus_payload(). + + Returns: + Awaitable[PelorusMetric]: Pelorus Metric from the plugin + + Raises: + TypeError: if data was not proper PelorusMetric + """ + payload_data = await self._receive() + webhook_data = await self._receive_pelorus_payload(payload_data) + if not issubclass(type(webhook_data), PelorusMetric): + raise TypeError("Webhook must be a subclass of PelorusMetric") + return webhook_data + + async def _receive(self) -> Awaitable[Any]: + """ + Method to receive json data from the request. + + Returns: + Awaitable[Any]: json data from the request. + + Raises: + HTTPException: If data was not proper json format + """ + json_data = None + try: + json_data = await self.request.json() + except JSONDecodeError: + raise HTTPException( + status_code=http.HTTPStatus.BAD_REQUEST, + detail="Invalid payload format.", + ) + return json_data + + @classmethod + def register(cls) -> str: + """ + Method used to register plugin with it's identifier. + The identifier is the user_agent_str from the plugin's implementation. + + This identifier is used to match with the webhooks' POST "User-Agent:" + data found in the Header of the POST request. + + Returns: + str: lower case user agent identifier from the plugin's user_agent_str + + Raises: + NotImplementedError: If the plugin forgot to implement the user_agent_str + """ + if not cls.user_agent_str: + raise NotImplementedError + return cls.user_agent_str.lower() + + @classmethod + def can_handle(cls, user_agent: str) -> bool: + """ + Check if this plugin can handle the provided payload + (recognized by user_agent). + + Attributes: + user_agent: (str): Value from the Header's "User-Agent:" + + Returns: + bool: True if this plugin can handle given user_agent + """ + if user_agent and cls.user_agent_str: + if user_agent.lower().startswith(str(cls.user_agent_str).lower()): + return True + return False + + @classmethod + def is_pelorus_webhook_handler(cls) -> bool: + """ + Used for the type checking only to ensure it's actually supported + Pelorus Webhook plugin. + + Returns: + bool: True if it's recognized Pelorus Webhook Plugin, False otherwise + """ + return hasattr(cls, "user_agent_str") and cls.user_agent_str is not None diff --git a/exporters/webhook/store/__init__.py b/exporters/webhook/store/__init__.py new file mode 100644 index 000000000..8d0623ba3 --- /dev/null +++ b/exporters/webhook/store/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# diff --git a/exporters/webhook/store/in_memory_metric.py b/exporters/webhook/store/in_memory_metric.py new file mode 100644 index 000000000..504e1fbff --- /dev/null +++ b/exporters/webhook/store/in_memory_metric.py @@ -0,0 +1,176 @@ +# +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import threading +from typing import Dict, Optional, Sequence, TypeVar, Union + +from prometheus_client.core import GaugeMetricFamily +from pydantic.main import ModelMetaclass + +from webhook.models.pelorus_webhook import ( + CommitTimePelorusPayload, + DeployTimePelorusPayload, + FailurePelorusPayload, + PelorusPayload, +) + +PelorusPayloadType = TypeVar("PelorusPayloadType", bound=PelorusPayload) + + +def _pelorus_metric_to_dict( + pelorus_model: Union[PelorusPayloadType, ModelMetaclass] +) -> Dict[str, str]: + """ + Mapping between Pelorus Payload Metrics defined as pydantic classes and the + Prometheus expected metrics. + + Attributes: + pelorus_model Union(PelorusPayloadType, ModelMetaclass): imported + class that is subclass of the PelorusPayload. + This can be either class or it's instance. + + Returns: + Dict[str, str]: First item is the Prometheus expected label and second + the name of the value from the PelorusPayload model. + + Raises: + TypeError: If the prometheus data model is not supported + """ + pelorus_payload = {"app": "app"} + + failure_payload = { + **pelorus_payload, + "issue_number": "failure_id", + } + + deploytime_payload = { + **pelorus_payload, + "namespace": "namespace", + "image_sha": "image_sha", + } + + committime_payload = { + **deploytime_payload, + "commit": "commit_hash", + } + + class_model_name_to_dict = { + "PelorusPayload": pelorus_payload, + "FailurePelorusPayload": failure_payload, + "DeployTimePelorusPayload": deploytime_payload, + "CommitTimePelorusPayload": committime_payload, + } + + # This is to use model name, which equals to the + # class name. The __class_ can't be used here as + # it's inherited from pydantic.main.ModelMetaclass + if hasattr(pelorus_model, "__qualname__"): + model_name = pelorus_model.__qualname__ + else: + # It's an instance + model_name = pelorus_model.__class__.__qualname__ + + pelorus_model_to_prometheus_mapping = class_model_name_to_dict.get(model_name) + + if pelorus_model_to_prometheus_mapping: + return pelorus_model_to_prometheus_mapping + + raise TypeError(f"Improper prometheus data model: {model_name}") + + +def pelorus_metric_to_prometheus(pelorus_model: PelorusPayloadType) -> list[str]: + """ + Returns prometheus metrics directly from the PelorusPayload objects. + + Attributes: + pelorus_model PelorusPayloadType: object from which the prometheus + data will be created. + + Returns: + list[str]: List to be used as prometheus data. + + Raises: + TypeError: If the expected data model did not match provided pelorus_model + """ + data_model = _pelorus_metric_to_dict(pelorus_model) + data_values = [] + + for metric_value in data_model.values(): + if hasattr(pelorus_model, metric_value): + value = getattr(pelorus_model, metric_value) + data_values.append(value) + else: + # If the model do not match the payload dict, we should raise an error + raise TypeError( + f"Attribute {metric_value} was not found in the {pelorus_model.__class__.__qualname__} metric model" + ) + return data_values + + +class PelorusGaugeMetricFamily(GaugeMetricFamily): + """ + Wrapper around GaugeMetricFamily class which allows to async + access to it's data when used by different webhook endpoints. + """ + + def __init__( + self, + name: str, + documentation: str, + value: Optional[float] = None, + labels: Optional[Sequence[str]] = None, + unit: str = "", + ): + super().__init__(name, documentation, value, labels, unit) + self.lock = threading.Lock() + self.added_metrics = set() + + def add_metric(self, metric_id, *args, **kwargs): + with self.lock: + if metric_id and metric_id not in self.added_metrics: + super().add_metric(*args, **kwargs) + self.added_metrics.add(metric_id) + + +# TODO: Needed? +# def __iter__(self, *args, **kwargs): +# with self.lock: +# for item in super().__iter__(*args, **kwargs): +# yield item + + +in_memory_commit_metrics = PelorusGaugeMetricFamily( + "commit_timestamp", + "Commit timestamp", + labels=list(_pelorus_metric_to_dict(CommitTimePelorusPayload).values()), +) + +in_memory_deploy_timestamp_metric = PelorusGaugeMetricFamily( + "deploy_timestamp", + "Deployment timestamp", + labels=list(_pelorus_metric_to_dict(DeployTimePelorusPayload).values()), +) + +in_memory_failure_creation_metric = PelorusGaugeMetricFamily( + "failure_creation_timestamp", + "Failure Creation Timestamp", + labels=list(_pelorus_metric_to_dict(FailurePelorusPayload).values()), +) +in_memory_failure_resolution_metric = PelorusGaugeMetricFamily( + "failure_resolution_timestamp", + "Failure Resolution Timestamp", + labels=list(_pelorus_metric_to_dict(FailurePelorusPayload).values()), +)