From 4c3f7a99965a40dc4ceaa5b1852062cee0e9bc14 Mon Sep 17 00:00:00 2001 From: Matt Hammerly Date: Tue, 27 May 2025 16:50:43 -0700 Subject: [PATCH 1/2] feat(codecov): initial client for requests to codecov api --- src/sentry/codecov/__init__.py | 0 src/sentry/codecov/client.py | 127 ++++++++++++++++++++++++++++ src/sentry/conf/server.py | 4 + src/sentry/options/defaults.py | 2 + tests/sentry/codecov/test_client.py | 84 ++++++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 src/sentry/codecov/__init__.py create mode 100644 src/sentry/codecov/client.py create mode 100644 tests/sentry/codecov/test_client.py diff --git a/src/sentry/codecov/__init__.py b/src/sentry/codecov/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/codecov/client.py b/src/sentry/codecov/client.py new file mode 100644 index 00000000000000..a9f2440c9891ba --- /dev/null +++ b/src/sentry/codecov/client.py @@ -0,0 +1,127 @@ +import datetime +import logging +from enum import StrEnum +from typing import TypeAlias + +import requests +from django.conf import settings +from rest_framework import status + +from sentry import options +from sentry.api.exceptions import SentryAPIException +from sentry.utils import jwt + +GitProviderId: TypeAlias = str + + +class GitProvider(StrEnum): + """ + Enum representing the Git provider that hosts the user/org that a + `CodecovApiClient` instance is acting on behalf of. + + Codecov doesn't require this to be GitHub, but that's all that's implemented + for now. + """ + GitHub = "github" + + +logger = logging.getLogger(__name__) + +TIMEOUT_SECONDS = 10 +JWT_VALIDITY_WINDOW_SECONDS = 300 # 5 minutes + + +class ConfigurationError(SentryAPIException): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + code = "configuration-error" + + +class CodecovApiClient: + """ + Thin client for making JWT-authenticated requests to the Codecov API. + + For each request, Sentry creates and signs (HS256) a JWT with a key shared + with Codecov. This JWT contains information that Codecov needs to service + the request. + """ + + def _create_jwt(self): + now = int(datetime.datetime.now(datetime.UTC).timestamp()) + exp = now + JWT_VALIDITY_WINDOW_SECONDS + claims = { + "iss": "https://sentry.io", + "iat": now, + "exp": exp, + } + claims.update(self.custom_claims) + + return jwt.encode(claims, self.signing_secret, algorithm="HS256") + + def __init__( + self, + git_provider_user: GitProviderId, + git_provider: GitProvider = GitProvider.GitHub, + ): + """ + Creates a `CodecovApiClient`. + + :param git_provider_user: The ID of the current Sentry user's linked git + provider account, according to the git provider. + :param git_provider: The git provider that the above user's account is + hosted on. + """ + + if not (signing_secret := options.get("codecov.api-bridge-signing-secret")): + raise ConfigurationError() + + self.base_url = settings.CODECOV_API_BASE_URL + self.signing_secret = signing_secret + self.custom_claims = { + "g_u": git_provider_user, + "g_p": git_provider, + } + + def get(self, endpoint: str, params=None, headers=None) -> requests.Response | None: + """ + Makes a GET request to the specified endpoint of the configured Codecov + API host with the provided params and headers. + + :param endpoint: The endpoint to request, without the host portion. For + example: `/api/v2/gh/getsentry/users` or `/graphql` + :param params: Dictionary of query params. + :param headers: Dictionary of request headers. + """ + headers = headers or {} + token = self._create_jwt() + headers.update(jwt.authorization_header(token)) + + url = f"{self.base_url}{endpoint}" + try: + response = requests.get(url, params=params, headers=headers, timeout=TIMEOUT_SECONDS) + except Exception: + logger.exception("Error when making GET request") + raise + + return response + + def post(self, endpoint: str, data=None, headers=None) -> requests.Response | None: + """ + Makes a POST request to the specified endpoint of the configured Codecov + API host with the provided data and headers. + + :param endpoint: The endpoint to request, without the host portion. For + example: `/api/v2/gh/getsentry/users` or `/graphql` + :param data: Dictionary of form data. + :param headers: Dictionary of request headers. + """ + headers = headers or {} + token = self._create_jwt() + headers.update(jwt.authorization_header(token)) + url = f"{self.base_url}{endpoint}" + try: + response = requests.post(url, data=data, headers=headers, timeout=TIMEOUT_SECONDS) + except Exception: + logger.exception("Error when making POST request") + raise + + return response diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 158bcfa371cb0f..777ec04a52eb8c 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -3679,6 +3679,10 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: MARKETO_CLIENT_SECRET = os.getenv("MARKETO_CLIENT_SECRET") MARKETO_FORM_ID = os.getenv("MARKETO_FORM_ID") +# Base URL for Codecov API. Override if developing against a local instance +# of Codecov. +CODECOV_API_BASE_URL = "https://api.codecov.io" + # Devserver configuration overrides. ngrok_host = os.environ.get("SENTRY_DEVSERVER_NGROK") if ngrok_host: diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 21954cd56ec2b4..1a303c4e8d609f 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -602,6 +602,8 @@ # Codecov Integration register("codecov.client-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK) +register("codecov.base-url", default="https://api.codecov.io") +register("codecov.api-bridge-signing-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK) # GitHub Integration register("github-app.id", default=0, flags=FLAG_AUTOMATOR_MODIFIABLE) diff --git a/tests/sentry/codecov/test_client.py b/tests/sentry/codecov/test_client.py new file mode 100644 index 00000000000000..6a39dc3c84ac6d --- /dev/null +++ b/tests/sentry/codecov/test_client.py @@ -0,0 +1,84 @@ +import datetime + +import pytest +from django.test import override_settings +from unittest.mock import patch + +from sentry.codecov.client import CodecovApiClient, ConfigurationError, GitProvider +from sentry.testutils.cases import TestCase +from sentry.utils import jwt + + +@override_settings(CODECOV_API_BASE_URL="http://example.com") +class TestCodecovApiClient(TestCase): + def setUp(self): + self.test_git_provider_user = "12345" + self.test_secret = "test-secret-" + "a" * 20 + + self.test_timestamp = datetime.datetime.now(datetime.UTC) + self._mock_now = patch("datetime.datetime.now", return_value=self.test_timestamp) + + with self.options({ + "codecov.api-bridge-signing-secret": self.test_secret, + }): + self.codecov_client = CodecovApiClient(self.test_git_provider_user) + + def test_raises_configuration_error_without_signing_secret(self): + with self.options({ + "codecov.api-bridge-signing-secret": None, + }): + with pytest.raises(ConfigurationError): + CodecovApiClient(self.test_git_provider_user) + + def test_creates_valid_jwt(self): + encoded_jwt = self.codecov_client._create_jwt() + + header = jwt.peek_header(encoded_jwt) + assert header == { + "typ": "JWT", + "alg": "HS256", + } + + # Ensure the claims are what we expect, separate from verifying the + # signature and standard claims + claims = jwt.peek_claims(encoded_jwt) + expected_iat = int(self.test_timestamp.timestamp()) + expected_exp = expected_iat + 300 + assert claims == { + "g_u": self.test_git_provider_user, + "g_p": GitProvider.GitHub.value, + "iss": "https://sentry.io", + "iat": expected_iat, + "exp": expected_exp, + } + + # Ensure we can verify the signature and whatall + jwt.decode(encoded_jwt, self.test_secret) + + @patch("requests.get") + def test_sends_get_request_with_jwt_auth_header(self, mock_get): + with patch.object(self.codecov_client, "_create_jwt", return_value="test"): + self.codecov_client.get("/example/endpoint", {"example-param": "foo"}, {"X_TEST_HEADER": "bar"}) + mock_get.assert_called_once_with( + "http://example.com/example/endpoint", + params={"example-param": "foo"}, + headers={ + "Authorization": "Bearer test", + "X_TEST_HEADER": "bar", + }, + timeout=10, + ) + + @patch("requests.post") + def test_sends_post_request_with_jwt_auth_header(self, mock_post): + with patch.object(self.codecov_client, "_create_jwt", return_value="test"): + self.codecov_client.post("/example/endpoint", {"example-param": "foo"}, {"X_TEST_HEADER": "bar"}) + mock_post.assert_called_once_with( + "http://example.com/example/endpoint", + data={"example-param": "foo"}, + headers={ + "Authorization": "Bearer test", + "X_TEST_HEADER": "bar", + }, + timeout=10, + ) From 578c1b8616040297f6a6291a8b845483dd00a9f1 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 23:51:54 +0000 Subject: [PATCH 2/2] :hammer_and_wrench: apply pre-commit fixes --- src/sentry/codecov/client.py | 1 + tests/sentry/codecov/test_client.py | 26 +++++++++++++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/sentry/codecov/client.py b/src/sentry/codecov/client.py index a9f2440c9891ba..0cfc8f14b046f7 100644 --- a/src/sentry/codecov/client.py +++ b/src/sentry/codecov/client.py @@ -22,6 +22,7 @@ class GitProvider(StrEnum): Codecov doesn't require this to be GitHub, but that's all that's implemented for now. """ + GitHub = "github" diff --git a/tests/sentry/codecov/test_client.py b/tests/sentry/codecov/test_client.py index 6a39dc3c84ac6d..7adade0e709d19 100644 --- a/tests/sentry/codecov/test_client.py +++ b/tests/sentry/codecov/test_client.py @@ -1,8 +1,8 @@ import datetime +from unittest.mock import patch import pytest from django.test import override_settings -from unittest.mock import patch from sentry.codecov.client import CodecovApiClient, ConfigurationError, GitProvider from sentry.testutils.cases import TestCase @@ -18,15 +18,19 @@ def setUp(self): self.test_timestamp = datetime.datetime.now(datetime.UTC) self._mock_now = patch("datetime.datetime.now", return_value=self.test_timestamp) - with self.options({ - "codecov.api-bridge-signing-secret": self.test_secret, - }): + with self.options( + { + "codecov.api-bridge-signing-secret": self.test_secret, + } + ): self.codecov_client = CodecovApiClient(self.test_git_provider_user) def test_raises_configuration_error_without_signing_secret(self): - with self.options({ - "codecov.api-bridge-signing-secret": None, - }): + with self.options( + { + "codecov.api-bridge-signing-secret": None, + } + ): with pytest.raises(ConfigurationError): CodecovApiClient(self.test_git_provider_user) @@ -58,7 +62,9 @@ def test_creates_valid_jwt(self): @patch("requests.get") def test_sends_get_request_with_jwt_auth_header(self, mock_get): with patch.object(self.codecov_client, "_create_jwt", return_value="test"): - self.codecov_client.get("/example/endpoint", {"example-param": "foo"}, {"X_TEST_HEADER": "bar"}) + self.codecov_client.get( + "/example/endpoint", {"example-param": "foo"}, {"X_TEST_HEADER": "bar"} + ) mock_get.assert_called_once_with( "http://example.com/example/endpoint", params={"example-param": "foo"}, @@ -72,7 +78,9 @@ def test_sends_get_request_with_jwt_auth_header(self, mock_get): @patch("requests.post") def test_sends_post_request_with_jwt_auth_header(self, mock_post): with patch.object(self.codecov_client, "_create_jwt", return_value="test"): - self.codecov_client.post("/example/endpoint", {"example-param": "foo"}, {"X_TEST_HEADER": "bar"}) + self.codecov_client.post( + "/example/endpoint", {"example-param": "foo"}, {"X_TEST_HEADER": "bar"} + ) mock_post.assert_called_once_with( "http://example.com/example/endpoint", data={"example-param": "foo"},