-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat(preprod): Add preprod images download endpoint #102117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
4f1cdd7
959819a
f0d545a
ad89232
9b7255f
d1bceb6
473549f
24d9e1d
25db450
f7a9713
e2a8a0c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,27 +22,27 @@ class PreprodArtifactApiAssembleGenericEvent(analytics.Event): | |
| project_id: int | ||
|
|
||
|
|
||
| @analytics.eventclass("preprod_artifact.api.size_analysis_download") | ||
| class PreprodArtifactApiSizeAnalysisDownloadEvent(analytics.Event): | ||
| @analytics.eventclass("preprod_artifact.api.get_build_details") | ||
| class PreprodArtifactApiGetBuildDetailsEvent(analytics.Event): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't change these, I just reordered a few of these to match the |
||
| organization_id: int | ||
| project_id: int | ||
| user_id: int | None = None | ||
| artifact_id: str | ||
|
|
||
|
|
||
| @analytics.eventclass("preprod_artifact.api.get_build_details") | ||
| class PreprodArtifactApiGetBuildDetailsEvent(analytics.Event): | ||
| @analytics.eventclass("preprod_artifact.api.list_builds") | ||
| class PreprodArtifactApiListBuildsEvent(analytics.Event): | ||
| organization_id: int | ||
| project_id: int | ||
| user_id: int | None = None | ||
| artifact_id: str | ||
|
|
||
|
|
||
| @analytics.eventclass("preprod_artifact.api.list_builds") | ||
| class PreprodArtifactApiListBuildsEvent(analytics.Event): | ||
| @analytics.eventclass("preprod_artifact.api.install_details") | ||
| class PreprodArtifactApiInstallDetailsEvent(analytics.Event): | ||
| organization_id: int | ||
| project_id: int | ||
| user_id: int | None = None | ||
| artifact_id: str | ||
|
|
||
|
|
||
| @analytics.eventclass("preprod_artifact.api.admin_rerun_analysis") | ||
|
|
@@ -77,6 +77,15 @@ class PreprodArtifactApiDeleteEvent(analytics.Event): | |
| artifact_id: str | ||
|
|
||
|
|
||
| # Size analysis | ||
| @analytics.eventclass("preprod_artifact.api.size_analysis_download") | ||
| class PreprodArtifactApiSizeAnalysisDownloadEvent(analytics.Event): | ||
| organization_id: int | ||
| project_id: int | ||
| user_id: int | None = None | ||
| artifact_id: str | ||
|
|
||
|
|
||
| @analytics.eventclass("preprod_artifact.api.size_analysis_compare.get") | ||
| class PreprodArtifactApiSizeAnalysisCompareGetEvent(analytics.Event): | ||
| organization_id: int | ||
|
|
@@ -95,14 +104,6 @@ class PreprodArtifactApiSizeAnalysisComparePostEvent(analytics.Event): | |
| base_artifact_id: str | ||
|
|
||
|
|
||
| @analytics.eventclass("preprod_artifact.api.install_details") | ||
| class PreprodArtifactApiInstallDetailsEvent(analytics.Event): | ||
| organization_id: int | ||
| project_id: int | ||
| user_id: int | None = None | ||
| artifact_id: str | ||
|
|
||
|
|
||
| @analytics.eventclass("preprod_artifact.api.size_analysis_compare_download") | ||
| class PreprodArtifactApiSizeAnalysisCompareDownloadEvent(analytics.Event): | ||
| organization_id: int | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
|
|
||
| from django.http import HttpResponse | ||
| from rest_framework.request import Request | ||
|
|
||
| from sentry.api.api_owners import ApiOwner | ||
| from sentry.api.api_publish_status import ApiPublishStatus | ||
| from sentry.api.base import region_silo_endpoint | ||
| from sentry.api.bases.project import ProjectEndpoint | ||
| from sentry.models.project import Project | ||
| from sentry.objectstore import get_preprod_session | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @region_silo_endpoint | ||
| class ProjectPreprodArtifactImageEndpoint(ProjectEndpoint): | ||
| owner = ApiOwner.EMERGE_TOOLS | ||
| publish_status = { | ||
| "GET": ApiPublishStatus.EXPERIMENTAL, | ||
| } | ||
|
|
||
| def get( | ||
| self, | ||
| _: Request, | ||
| project: Project, | ||
| image_id: str, | ||
| ) -> HttpResponse: | ||
|
|
||
| organization_id = project.organization_id | ||
| project_id = project.id | ||
|
|
||
| object_key = f"{organization_id}/{project_id}/{image_id}" | ||
| session = get_preprod_session(organization_id, project_id) | ||
|
|
||
| try: | ||
| result = session.get(object_key) | ||
| # Read the entire stream at once (necessary for content_type) | ||
| image_data = result.payload.read() | ||
rbro112 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| # Detect content type from the image data | ||
| return HttpResponse(image_data, content_type=result.metadata.content_type) | ||
|
|
||
| except Exception: | ||
| logger.exception( | ||
| "Unexpected error retrieving image", | ||
| extra={ | ||
| "organization_id": organization_id, | ||
| "project_id": project_id, | ||
| "image_id": image_id, | ||
| }, | ||
| ) | ||
| return HttpResponse({"error": "Internal server error"}, status=500) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Invalid dictionary passed to HttpResponse without JSON serializationThe error response passes a dictionary directly to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Invalid dictionary passed to HttpResponse without JSON serializationThe error response passes a dictionary directly to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The 🔍 Detailed AnalysisThe 💡 Suggested FixSerialize the dictionary to JSON: 🤖 Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ class BuildDetailsAppInfo(BaseModel): | |
| platform: Platform | None = None | ||
| is_installable: bool | ||
| build_configuration: str | None = None | ||
| app_icon_id: str | None = None | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is related to app icon, not the generic download endpoint, but should be fine to merge as the frontend does not yet use this until #102118 lands |
||
| apple_app_info: AppleAppInfo | None = None | ||
| android_app_info: AndroidAppInfo | None = None | ||
|
|
||
|
|
@@ -213,6 +214,7 @@ def transform_preprod_artifact_to_build_details( | |
| build_configuration=( | ||
| artifact.build_configuration.name if artifact.build_configuration else None | ||
| ), | ||
| app_icon_id=artifact.app_icon_id, | ||
| apple_app_info=apple_app_info, | ||
| android_app_info=android_app_info, | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| from django.urls import reverse | ||
|
|
||
| from sentry.testutils.cases import APITestCase | ||
|
|
||
|
|
||
| class ProjectPreprodArtifactImageTest(APITestCase): | ||
| def setUp(self): | ||
| super().setUp() | ||
| self.login_as(user=self.user) | ||
| self.org = self.create_organization(owner=self.user) | ||
| self.project = self.create_project(organization=self.org) | ||
| self.api_token = self.create_user_auth_token( | ||
| user=self.user, scope_list=["org:admin", "project:admin"] | ||
| ) | ||
| self.image_id = "test-image-123" | ||
| self.base_path = f"/api/0/{self.org.slug}/{self.project.slug}/files/images/{self.image_id}/" | ||
|
|
||
| def _get_url(self, image_id=None): | ||
| image_id = image_id or self.image_id | ||
| return reverse( | ||
| "sentry-api-0-project-preprod-artifact-image", | ||
| args=[self.org.slug, self.project.slug, image_id], | ||
| ) | ||
|
|
||
| def _create_mock_session(self, image_data, content_type): | ||
| """Create a mock object store session that returns the given data and content type.""" | ||
| mock_result = MagicMock() | ||
| mock_result.payload.read.return_value = image_data | ||
| mock_result.metadata.content_type = content_type | ||
|
|
||
| mock_session = MagicMock() | ||
| mock_session.get.return_value = mock_result | ||
|
|
||
| return mock_session | ||
|
|
||
| @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session") | ||
| def test_successful_image_retrieval_png(self, mock_get_session): | ||
| png_data = b"\x89PNG\r\n\x1a\n" + b"fake png content" * 100 | ||
| mock_session = self._create_mock_session(png_data, "image/png") | ||
| mock_get_session.return_value = mock_session | ||
|
|
||
| url = self._get_url() | ||
| response = self.client.get( | ||
| url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}" | ||
| ) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.content == png_data | ||
| assert response["Content-Type"] == "image/png" | ||
| mock_get_session.assert_called_once_with(self.org.id, self.project.id) | ||
| mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") | ||
|
|
||
| @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session") | ||
| def test_successful_image_retrieval_jpeg(self, mock_get_session): | ||
| jpeg_data = b"\xff\xd8\xff" + b"fake jpeg content" * 100 | ||
| mock_session = self._create_mock_session(jpeg_data, "image/jpeg") | ||
| mock_get_session.return_value = mock_session | ||
|
|
||
| url = self._get_url() | ||
| response = self.client.get( | ||
| url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}" | ||
| ) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.content == jpeg_data | ||
| assert response["Content-Type"] == "image/jpeg" | ||
| mock_get_session.assert_called_once_with(self.org.id, self.project.id) | ||
| mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") | ||
|
|
||
| @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session") | ||
| def test_successful_image_retrieval_webp(self, mock_get_session): | ||
| webp_data = b"RIFF" + b"1234" + b"WEBP" + b"fake webp content" * 100 | ||
| mock_session = self._create_mock_session(webp_data, "image/webp") | ||
| mock_get_session.return_value = mock_session | ||
|
|
||
| url = self._get_url() | ||
| response = self.client.get( | ||
| url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}" | ||
| ) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.content == webp_data | ||
| assert response["Content-Type"] == "image/webp" | ||
| mock_get_session.assert_called_once_with(self.org.id, self.project.id) | ||
| mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") | ||
|
|
||
| @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session") | ||
| def test_unknown_image_format(self, mock_get_session): | ||
| unknown_data = b"unknown binary data" * 50 | ||
| mock_session = self._create_mock_session(unknown_data, "application/octet-stream") | ||
| mock_get_session.return_value = mock_session | ||
|
|
||
| url = self._get_url() | ||
| response = self.client.get( | ||
| url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}" | ||
| ) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.content == unknown_data | ||
| assert response["Content-Type"] == "application/octet-stream" | ||
| mock_get_session.assert_called_once_with(self.org.id, self.project.id) | ||
| mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") | ||
|
|
||
| def test_endpoint_requires_project_access(self): | ||
| other_user = self.create_user() | ||
| self.login_as(user=other_user) | ||
| self.api_token = self.create_user_auth_token( | ||
| user=other_user, scope_list=["org:read", "project:read"] | ||
| ) | ||
|
|
||
| url = self._get_url() | ||
| response = self.client.get( | ||
| url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}" | ||
| ) | ||
| assert response.status_code == 403 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jan-auer saw this practice already used for attachments, is this how you all would recommend using a global session?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah. Although you might as well create a dict mapping
Usecase -> Client | Noneto store the usecase and the client, as I can see us adding more of these over time.