aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site
diff options
context:
space:
mode:
Diffstat (limited to 'pydis_site')
-rw-r--r--pydis_site/apps/api/github_utils.py183
-rw-r--r--pydis_site/apps/api/tests/test_github_utils.py287
-rw-r--r--pydis_site/apps/api/urls.py9
-rw-r--r--pydis_site/apps/api/views.py53
-rw-r--r--pydis_site/settings.py10
5 files changed, 539 insertions, 3 deletions
diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py
new file mode 100644
index 00000000..70dccdff
--- /dev/null
+++ b/pydis_site/apps/api/github_utils.py
@@ -0,0 +1,183 @@
+"""Utilities for working with the GitHub API."""
+
+import asyncio
+import datetime
+import math
+
+import httpx
+import jwt
+from asgiref.sync import async_to_sync
+
+from pydis_site import settings
+
+MAX_POLLS = 20
+"""The maximum number of attempts at fetching a workflow run."""
+
+
+class ArtifactProcessingError(Exception):
+ """Base exception for other errors related to processing a GitHub artifact."""
+
+ status: int
+
+
+class UnauthorizedError(ArtifactProcessingError):
+ """The application does not have permission to access the requested repo."""
+
+ status = 401
+
+
+class NotFoundError(ArtifactProcessingError):
+ """The requested resource could not be found."""
+
+ status = 404
+
+
+class ActionFailedError(ArtifactProcessingError):
+ """The requested workflow did not conclude successfully."""
+
+ status = 400
+
+
+class RunTimeoutError(ArtifactProcessingError):
+ """The requested workflow run was not ready in time."""
+
+ status = 408
+
+
+def generate_token() -> str:
+ """
+ Generate a JWT token to access the GitHub API.
+
+ The token is valid for roughly 10 minutes after generation, before the API starts
+ returning 401s.
+
+ Refer to:
+ https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app
+ """
+ now = datetime.datetime.now()
+ return jwt.encode(
+ {
+ "iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at
+ "exp": math.floor((now + datetime.timedelta(minutes=9)).timestamp()), # Expires at
+ "iss": settings.GITHUB_OAUTH_APP_ID,
+ },
+ settings.GITHUB_OAUTH_KEY,
+ algorithm="RS256"
+ )
+
+
+async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
+ """
+ Get an access token for the requested repository.
+
+ The process is roughly:
+ - GET app/installations to get a list of all app installations
+ - POST <app_access_token> to get a token to access the given app
+ - GET installation/repositories and check if the requested one is part of those
+ """
+ client = httpx.AsyncClient(
+ base_url=settings.GITHUB_API,
+ headers={"Authorization": f"bearer {generate_token()}"},
+ timeout=settings.TIMEOUT_PERIOD,
+ )
+
+ try:
+ # Get a list of app installations we have access to
+ apps = await client.get("app/installations")
+ apps.raise_for_status()
+
+ for app in apps.json():
+ # Look for an installation with the right owner
+ if app["account"]["login"] != owner:
+ continue
+
+ # Get the repositories of the specified owner
+ app_token = await client.post(app["access_tokens_url"])
+ app_token.raise_for_status()
+ client.headers["Authorization"] = f"bearer {app_token.json()['token']}"
+
+ repos = await client.get("installation/repositories")
+ repos.raise_for_status()
+
+ # Search for the request repository
+ for accessible_repo in repos.json()["repositories"]:
+ if accessible_repo["name"] == repo:
+ # We've found the correct repository, and it's accessible with the current auth
+ return client
+
+ raise NotFoundError(
+ "Could not find the requested repository. Make sure the application can access it."
+ )
+
+ except BaseException as e:
+ # Close the client if we encountered an unexpected exception
+ await client.aclose()
+ raise e
+
+
+async def wait_for_run(client: httpx.AsyncClient, run: dict) -> str:
+ """Wait for the provided `run` to finish, and return the URL to its artifacts."""
+ polls = 0
+ while polls <= MAX_POLLS:
+ if run["status"] != "completed":
+ # The action is still processing, wait a bit longer
+ polls += 1
+ await asyncio.sleep(10)
+
+ elif run["conclusion"] != "success":
+ # The action failed, or did not run
+ raise ActionFailedError(f"The requested workflow ended with: {run['conclusion']}")
+
+ else:
+ # The desired action was found, and it ended successfully
+ return run["artifacts_url"]
+
+ run = await client.get(run["url"])
+ run.raise_for_status()
+ run = run.json()
+
+ raise RunTimeoutError("The requested workflow was not ready in time.")
+
+
+@async_to_sync
+async def get_artifact(
+ owner: str, repo: str, sha: str, action_name: str, artifact_name: str
+) -> str:
+ """Get a download URL for a build artifact."""
+ client = await authorize(owner, repo)
+
+ try:
+ # Get the workflow runs for this repository
+ runs = await client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100})
+ runs.raise_for_status()
+ runs = runs.json()
+
+ # Filter the runs for the one associated with the given SHA
+ for run in runs["workflow_runs"]:
+ if run["name"] == action_name and sha == run["head_sha"]:
+ break
+ else:
+ raise NotFoundError(
+ "Could not find a run matching the provided settings in the previous hundred runs."
+ )
+
+ # Wait for the workflow to finish
+ url = await wait_for_run(client, run)
+
+ # Filter the artifacts, and return the download URL
+ artifacts = await client.get(url)
+ artifacts.raise_for_status()
+
+ for artifact in artifacts.json()["artifacts"]:
+ if artifact["name"] == artifact_name:
+ data = await client.get(artifact["archive_download_url"])
+ if data.status_code == 302:
+ return str(data.next_request.url)
+
+ # The following line is left untested since it should in theory be impossible
+ data.raise_for_status() # pragma: no cover
+
+ raise NotFoundError("Could not find an artifact matching the provided name.")
+
+ finally:
+ await client.aclose()
diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py
new file mode 100644
index 00000000..dc17d609
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_github_utils.py
@@ -0,0 +1,287 @@
+import asyncio
+import datetime
+import random
+import unittest
+from unittest import mock
+
+import django.test
+import httpx
+import jwt
+import rest_framework.response
+import rest_framework.test
+from django.urls import reverse
+
+from .. import github_utils
+
+
+def patched_raise_for_status(response: httpx.Response):
+ """Fake implementation of raise_for_status which does not need a request to be set."""
+ if response.status_code // 100 != 2: # pragma: no cover
+ raise httpx.HTTPStatusError(
+ f"Non 2xx response code: {response.status_code}",
+ request=getattr(response, "_request", httpx.Request("GET", "")),
+ response=response
+ )
+
+
+class GeneralUtilityTests(unittest.TestCase):
+ """Test the utility methods which do not fit in another class."""
+
+ def test_token_generation(self):
+ """Test that the a valid JWT token is generated."""
+ def encode(payload: dict, _: str, algorithm: str, *args, **kwargs) -> str:
+ """
+ Intercept the encode method.
+
+ It is performed with an algorithm which does not require a PEM key, as it may
+ not be available in testing environments.
+ """
+ self.assertEqual("RS256", algorithm, "The GitHub App JWT must be signed using RS256.")
+ return original_encode(
+ payload, "secret-encoding-key", algorithm="HS256", *args, **kwargs
+ )
+
+ original_encode = jwt.encode
+ with mock.patch("jwt.encode", new=encode):
+ token = github_utils.generate_token()
+ decoded = jwt.decode(token, "secret-encoding-key", algorithms=["HS256"])
+
+ delta = datetime.timedelta(minutes=10)
+ self.assertAlmostEqual(decoded["exp"] - decoded["iat"], delta.total_seconds())
+ self.assertLess(decoded["exp"], (datetime.datetime.now() + delta).timestamp())
+
+
[email protected]("httpx.AsyncClient", autospec=True)
[email protected]("asyncio.sleep", new=mock.AsyncMock(return_value=asyncio.Future))
[email protected]("httpx.Response.raise_for_status", new=patched_raise_for_status)
+class WaitForTests(unittest.IsolatedAsyncioTestCase):
+ """Tests the wait_for utility."""
+
+ async def test_wait_for_successful_run(self, client_mock: mock.Mock):
+ """Test that the wait_for method handles successfully runs."""
+ final_url = "some_url" + str(random.randint(0, 10))
+
+ client_mock.get.side_effect = responses = [
+ httpx.Response(200, json={"status": "queued", "url": ""}),
+ httpx.Response(200, json={"status": "pending", "url": ""}),
+ httpx.Response(200, json={
+ "status": "completed",
+ "conclusion": "success",
+ "url": "",
+ "artifacts_url": final_url
+ })
+ ]
+
+ result = await github_utils.wait_for_run(client_mock, responses[0].json())
+ self.assertEqual(final_url, result)
+
+ async def test_wait_for_failed_run(self, client_mock: mock.Mock):
+ """Test that the wait_for method handles failed runs."""
+ client_mock.get.return_value = httpx.Response(200, json={
+ "status": "completed",
+ "conclusion": "failed",
+ })
+
+ with self.assertRaises(github_utils.ActionFailedError):
+ await github_utils.wait_for_run(client_mock, {"status": "pending", "url": ""})
+
+ async def test_wait_for_timeout(self, client_mock: mock.Mock):
+ """Test that the wait_for method quits after a few attempts."""
+ client_mock.get.side_effect = responses = [
+ httpx.Response(200, json={"status": "pending", "url": ""})
+ ] * (github_utils.MAX_POLLS + 5)
+
+ with self.assertRaises(github_utils.RunTimeoutError):
+ await github_utils.wait_for_run(client_mock, responses[0].json())
+
+
+async def get_response_authorize(
+ _: httpx.AsyncClient, request: httpx.Request, **__
+) -> httpx.Response:
+ """
+ Helper method for the authorize tests.
+
+ Requests are intercepted before being sent out, and the appropriate responses are returned.
+ """
+ path = request.url.path
+ auth = request.headers.get("Authorization")
+
+ if request.method == "GET":
+ if path == "/app/installations":
+ if auth == "bearer JWT initial token":
+ return httpx.Response(200, request=request, json=[{
+ "account": {"login": "VALID_OWNER"},
+ "access_tokens_url": "https://example.com/ACCESS_TOKEN_URL"
+ }])
+ else:
+ return httpx.Response(
+ 401, json={"error": "auth app/installations"}, request=request
+ )
+
+ elif path == "/installation/repositories":
+ if auth == "bearer app access token":
+ return httpx.Response(200, request=request, json={
+ "repositories": [{
+ "name": "VALID_REPO"
+ }]
+ })
+ else: # pragma: no cover
+ return httpx.Response(
+ 401, json={"error": "auth installation/repositories"}, request=request
+ )
+
+ elif request.method == "POST":
+ if path == "/ACCESS_TOKEN_URL":
+ if auth == "bearer JWT initial token":
+ return httpx.Response(200, request=request, json={"token": "app access token"})
+ else: # pragma: no cover
+ return httpx.Response(401, json={"error": "auth access_token"}, request=request)
+
+ # Reaching this point means something has gone wrong
+ return httpx.Response(500, request=request) # pragma: no cover
+
+
[email protected]("httpx.AsyncClient.send", new=get_response_authorize)
[email protected](github_utils, "generate_token", new=mock.Mock(return_value="JWT initial token"))
+class AuthorizeTests(unittest.IsolatedAsyncioTestCase):
+ """Test the authorize utility."""
+
+ async def test_invalid_apps_auth(self):
+ """Test that an exception is raised if authorization was attempted with an invalid token."""
+ with mock.patch.object(github_utils, "generate_token", return_value="Invalid token"):
+ with self.assertRaises(httpx.HTTPStatusError) as error:
+ await github_utils.authorize("VALID_OWNER", "VALID_REPO")
+
+ exception: httpx.HTTPStatusError = error.exception
+ self.assertEqual(401, exception.response.status_code)
+ self.assertEqual("auth app/installations", exception.response.json()["error"])
+
+ async def test_missing_repo(self):
+ """Test that an exception is raised when the selected owner or repo are not available."""
+ with self.assertRaises(github_utils.NotFoundError):
+ await github_utils.authorize("INVALID_OWNER", "VALID_REPO")
+ with self.assertRaises(github_utils.NotFoundError):
+ await github_utils.authorize("VALID_OWNER", "INVALID_REPO")
+
+ async def test_valid_authorization(self):
+ """Test that an accessible repository can be accessed."""
+ client = await github_utils.authorize("VALID_OWNER", "VALID_REPO")
+ self.assertEqual("bearer app access token", client.headers.get("Authorization"))
+
+
+async def get_response_get_artifact(request: httpx.Request, **_) -> httpx.Response:
+ """
+ Helper method for the get_artifact tests.
+
+ Requests are intercepted before being sent out, and the appropriate responses are returned.
+ """
+ path = request.url.path
+
+ if "force_error" in path:
+ return httpx.Response(404, request=request)
+
+ if request.method == "GET":
+ if path == "/repos/owner/repo/actions/runs":
+ return httpx.Response(200, request=request, json={"workflow_runs": [{
+ "name": "action_name",
+ "head_sha": "action_sha"
+ }]})
+ elif path == "/artifact_url":
+ return httpx.Response(200, request=request, json={"artifacts": [{
+ "name": "artifact_name",
+ "archive_download_url": "artifact_download_url"
+ }]})
+ elif path == "/artifact_download_url":
+ response = httpx.Response(302, request=request)
+ response.next_request = httpx.Request("GET", httpx.URL("https://final_download.url"))
+ return response
+
+ # Reaching this point means something has gone wrong
+ return httpx.Response(500, request=request) # pragma: no cover
+
+
+class ArtifactFetcherTests(unittest.IsolatedAsyncioTestCase):
+ """Test the get_artifact utility."""
+
+ def setUp(self) -> None:
+ self.call_args = ["owner", "repo", "action_sha", "action_name", "artifact_name"]
+ self.client = httpx.AsyncClient(base_url="https://example.com")
+
+ self.patchers = [
+ mock.patch.object(self.client, "send", new=get_response_get_artifact),
+ mock.patch.object(github_utils, "authorize", return_value=self.client),
+ mock.patch.object(github_utils, "wait_for_run", return_value="artifact_url"),
+ ]
+
+ for patcher in self.patchers:
+ patcher.start()
+
+ def tearDown(self) -> None:
+ for patcher in self.patchers:
+ patcher.stop()
+
+ def test_client_closed_on_errors(self):
+ """Test that the client is terminated even if an error occurs at some point."""
+ self.call_args[0] = "force_error"
+ with self.assertRaises(httpx.HTTPStatusError):
+ github_utils.get_artifact(*self.call_args)
+ self.assertTrue(self.client.is_closed)
+
+ def test_missing(self):
+ """Test that an exception is raised if the requested artifact was not found."""
+ cases = (
+ "invalid sha",
+ "invalid action name",
+ "invalid artifact name",
+ )
+ for i, name in enumerate(cases, 2):
+ with self.subTest(f"Test {name} raises an error"):
+ new_args = self.call_args.copy()
+ new_args[i] = name
+
+ with self.assertRaises(github_utils.NotFoundError):
+ github_utils.get_artifact(*new_args)
+
+ def test_valid(self):
+ """Test that the correct download URL is returned for valid requests."""
+ url = github_utils.get_artifact(*self.call_args)
+ self.assertEqual("https://final_download.url", url)
+ self.assertTrue(self.client.is_closed)
+
+
[email protected](github_utils, "get_artifact")
+class GitHubArtifactViewTests(django.test.TestCase):
+ """Test the GitHub artifact fetch API view."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.kwargs = {
+ "owner": "test_owner",
+ "repo": "test_repo",
+ "sha": "test_sha",
+ "action_name": "test_action",
+ "artifact_name": "test_artifact",
+ }
+ cls.url = reverse("api:github-artifacts", kwargs=cls.kwargs)
+
+ async def test_successful(self, artifact_mock: mock.Mock):
+ """Test a proper response is returned with proper input."""
+ artifact_mock.return_value = "final download url"
+ result = self.client.get(self.url)
+
+ self.assertIsInstance(result, rest_framework.response.Response)
+ self.assertEqual({"url": artifact_mock.return_value}, result.data)
+
+ async def test_failed_fetch(self, artifact_mock: mock.Mock):
+ """Test that a proper error is returned when the request fails."""
+ artifact_mock.side_effect = github_utils.NotFoundError("Test error message")
+ result = self.client.get(self.url)
+
+ self.assertIsInstance(result, rest_framework.response.Response)
+ self.assertEqual({
+ "error_type": github_utils.NotFoundError.__name__,
+ "error": "Test error message",
+ "requested_resource": "/".join(self.kwargs.values())
+ }, result.data)
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index 1e564b29..2757f176 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -1,7 +1,7 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
-from .views import HealthcheckView, RulesView
+from .views import GitHubArtifactsView, HealthcheckView, RulesView
from .viewsets import (
AocAccountLinkViewSet,
AocCompletionistBlockViewSet,
@@ -86,5 +86,10 @@ urlpatterns = (
# from django_hosts.resolvers import reverse
path('bot/', include((bot_router.urls, 'api'), namespace='bot')),
path('healthcheck', HealthcheckView.as_view(), name='healthcheck'),
- path('rules', RulesView.as_view(), name='rules')
+ path('rules', RulesView.as_view(), name='rules'),
+ path(
+ 'github/artifact/<str:owner>/<str:repo>/<str:sha>/<str:action_name>/<str:artifact_name>',
+ GitHubArtifactsView.as_view(),
+ name="github-artifacts"
+ ),
)
diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py
index 816463f6..ad2d948e 100644
--- a/pydis_site/apps/api/views.py
+++ b/pydis_site/apps/api/views.py
@@ -1,7 +1,10 @@
from rest_framework.exceptions import ParseError
+from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
+from . import github_utils
+
class HealthcheckView(APIView):
"""
@@ -152,3 +155,53 @@ class RulesView(APIView):
"Do not offer or ask for paid work of any kind."
),
])
+
+
+class GitHubArtifactsView(APIView):
+ """
+ Provides utilities for interacting with the GitHub API and obtaining action artifacts.
+
+ ## Routes
+ ### GET /github/artifacts
+ Returns a download URL for the artifact requested.
+
+ {
+ 'url': 'https://pipelines.actions.githubusercontent.com/...'
+ }
+
+ ### Exceptions
+ In case of an error, the following body will be returned:
+
+ {
+ "error_type": "<error class name>",
+ "error": "<error description>",
+ "requested_resource": "<owner>/<repo>/<sha>/<artifact_name>"
+ }
+
+ ## Authentication
+ Does not require any authentication nor permissions.
+ """
+
+ authentication_classes = ()
+ permission_classes = ()
+
+ def get(
+ self,
+ request: Request,
+ *,
+ owner: str,
+ repo: str,
+ sha: str,
+ action_name: str,
+ artifact_name: str
+ ) -> Response:
+ """Return a download URL for the requested artifact."""
+ try:
+ url = github_utils.get_artifact(owner, repo, sha, action_name, artifact_name)
+ return Response({"url": url})
+ except github_utils.ArtifactProcessingError as e:
+ return Response({
+ "error_type": e.__class__.__name__,
+ "error": str(e),
+ "requested_resource": f"{owner}/{repo}/{sha}/{action_name}/{artifact_name}"
+ }, status=e.status)
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index 03c16f4b..f382b052 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -21,7 +21,6 @@ import environ
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
-
env = environ.Env(
DEBUG=(bool, False),
SITE_DSN=(str, ""),
@@ -30,10 +29,19 @@ env = environ.Env(
GIT_SHA=(str, 'development'),
TIMEOUT_PERIOD=(int, 5),
GITHUB_TOKEN=(str, None),
+ GITHUB_OAUTH_APP_ID=(str, None),
+ GITHUB_OAUTH_KEY=(str, None),
)
GIT_SHA = env("GIT_SHA")
+GITHUB_API = "https://api.github.com"
GITHUB_TOKEN = env("GITHUB_TOKEN")
+GITHUB_OAUTH_APP_ID = env("GITHUB_OAUTH_APP_ID")
+GITHUB_OAUTH_KEY = env("GITHUB_OAUTH_KEY")
+
+if GITHUB_OAUTH_KEY and (oauth_file := Path(GITHUB_OAUTH_KEY)).is_file():
+ # Allow the OAuth key to be loaded from a file
+ GITHUB_OAUTH_KEY = oauth_file.read_text(encoding="utf-8")
sentry_sdk.init(
dsn=env('SITE_DSN'),