diff --git a/src/sentry/objectstore/__init__.py b/src/sentry/objectstore/__init__.py index 21b96d503bebde..13f56fc2f9656f 100644 --- a/src/sentry/objectstore/__init__.py +++ b/src/sentry/objectstore/__init__.py @@ -1,5 +1,6 @@ from sentry.objectstore.service import Client, ClientBuilder -__all__ = ["attachments", "Client", "ClientBuilder"] +__all__ = ["attachments", "preprod", "Client", "ClientBuilder"] attachments = ClientBuilder("attachments") +preprod = ClientBuilder("preprod") diff --git a/src/sentry/preprod/analytics.py b/src/sentry/preprod/analytics.py index 759558a5272aac..333deab1da6725 100644 --- a/src/sentry/preprod/analytics.py +++ b/src/sentry/preprod/analytics.py @@ -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): 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,23 @@ class PreprodArtifactApiDeleteEvent(analytics.Event): artifact_id: str +@analytics.eventclass("preprod_artifact.api.image") +class PreprodArtifactApiImageEvent(analytics.Event): + organization_id: int + project_id: int + user_id: int | None = None + image_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 +112,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 @@ -146,6 +155,7 @@ class PreprodApiPrPageCommentsEvent(analytics.Event): analytics.register(PreprodArtifactApiAdminGetInfoEvent) analytics.register(PreprodArtifactApiAdminBatchDeleteEvent) analytics.register(PreprodArtifactApiDeleteEvent) +analytics.register(PreprodArtifactApiImageEvent) # Size analysis analytics.register(PreprodArtifactApiSizeAnalysisDownloadEvent) analytics.register(PreprodArtifactApiSizeAnalysisCompareGetEvent) diff --git a/src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py b/src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py new file mode 100644 index 00000000000000..77097079bc988d --- /dev/null +++ b/src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import logging + +from django.http import HttpResponse +from rest_framework.request import Request + +from sentry import analytics +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 preprod +from sentry.objectstore.service import ClientError +from sentry.preprod.analytics import PreprodArtifactApiImageEvent + +logger = logging.getLogger(__name__) + + +def detect_image_content_type(image_data: bytes) -> str: + if not image_data: + return "application/octet-stream" + + # Check magic bytes for common image formats + if image_data[:8] == b"\x89PNG\r\n\x1a\n": + return "image/png" + elif image_data[:3] == b"\xff\xd8\xff": + return "image/jpeg" + elif image_data[:4] == b"RIFF" and image_data[8:12] == b"WEBP": + return "image/webp" + elif image_data[:2] in (b"BM", b"BA", b"CI", b"CP", b"IC", b"PT"): + return "image/bmp" + elif image_data[:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" + elif image_data[:4] == b"\x00\x00\x01\x00": + return "image/x-icon" + elif len(image_data) >= 12 and image_data[4:12] in (b"ftypavif", b"ftypavis"): + return "image/avif" + elif len(image_data) >= 12 and image_data[4:12] in ( + b"ftypheic", + b"ftypheix", + b"ftyphevc", + b"ftyphevx", + ): + return "image/heic" + + # Default to generic binary if we can't detect the type + logger.warning( + "Could not detect image content type from magic bytes", + extra={"first_bytes": image_data[:16].hex() if len(image_data) >= 16 else image_data.hex()}, + ) + return "application/octet-stream" + + +@region_silo_endpoint +class ProjectPreprodArtifactImageEndpoint(ProjectEndpoint): + owner = ApiOwner.EMERGE_TOOLS + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + } + + def get( + self, + request: Request, + project: Project, + image_id: str, + ) -> HttpResponse: + + analytics.record( + PreprodArtifactApiImageEvent( + organization_id=project.organization_id, + project_id=project.id, + user_id=request.user.id, + image_id=image_id, + ) + ) + + organization_id = project.organization_id + project_id = project.id + + object_key = f"{organization_id}/{project_id}/{image_id}" + logger.info( + "Retrieving image from objectstore", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "image_id": image_id, + }, + ) + client = preprod.for_project(organization_id, project_id) + + try: + result = client.get(object_key) + # Read the entire stream at once + image_data = result.payload.read() + + # Detect content type from the image data + content_type = detect_image_content_type(image_data) + + logger.info( + "Retrieved image from objectstore", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "image_id": image_id, + "size_bytes": len(image_data), + "content_type": content_type, + }, + ) + return HttpResponse(image_data, content_type=content_type) + + except ClientError as e: + if e.status == 404: + logger.warning( + "App icon not found in objectstore", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "image_id": image_id, + }, + ) + + # Upload failed, return appropriate error + return HttpResponse({"error": "Not found"}, status=404) + + logger.warning( + "Failed to retrieve app icon from objectstore", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "image_id": image_id, + "error": str(e), + "status": e.status, + }, + ) + return HttpResponse({"error": "Failed to retrieve app icon"}, status=500) + + except Exception: + logger.exception( + "Unexpected error retrieving app icon", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "image_id": image_id, + }, + ) + return HttpResponse({"error": "Internal server error"}, status=500) diff --git a/src/sentry/preprod/api/endpoints/urls.py b/src/sentry/preprod/api/endpoints/urls.py index 720a4c7cd81df2..4a3c6aef5d8edc 100644 --- a/src/sentry/preprod/api/endpoints/urls.py +++ b/src/sentry/preprod/api/endpoints/urls.py @@ -2,6 +2,9 @@ from django.urls import re_path +from sentry.preprod.api.endpoints.project_preprod_artifact_image import ( + ProjectPreprodArtifactImageEndpoint, +) from sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare import ( ProjectPreprodArtifactSizeAnalysisCompareEndpoint, ) @@ -81,6 +84,11 @@ ProjectInstallablePreprodArtifactDownloadEndpoint.as_view(), name="sentry-api-0-installable-preprod-artifact-download", ), + re_path( + r"^(?P[^/]+)/(?P[^/]+)/files/images/(?P[^/]+)/$", + ProjectPreprodArtifactImageEndpoint.as_view(), + name="sentry-api-0-project-preprod-artifact-image", + ), # Size analysis re_path( r"^(?P[^/]+)/(?P[^/]+)/preprodartifacts/size-analysis/compare/(?P[^/]+)/(?P[^/]+)/$", diff --git a/src/sentry/preprod/api/models/project_preprod_build_details_models.py b/src/sentry/preprod/api/models/project_preprod_build_details_models.py index e319d3a57970d9..9f7d2ef6f61e7a 100644 --- a/src/sentry/preprod/api/models/project_preprod_build_details_models.py +++ b/src/sentry/preprod/api/models/project_preprod_build_details_models.py @@ -33,6 +33,7 @@ class BuildDetailsAppInfo(BaseModel): platform: Platform | None = None is_installable: bool build_configuration: str | None = None + app_icon_id: str | None = None apple_app_info: AppleAppInfo | None = None @@ -163,6 +164,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, ) diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py new file mode 100644 index 00000000000000..0b77e5e367e8c1 --- /dev/null +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py @@ -0,0 +1,129 @@ +from io import BytesIO + +from django.urls import reverse + +from sentry.objectstore import preprod +from sentry.testutils.cases import APITestCase +from sentry.testutils.skips import requires_objectstore + + +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], + ) + + @requires_objectstore + def test_successful_image_retrieval_png(self): + png_data = b"\x89PNG\r\n\x1a\n" + b"fake png content" * 100 + + client = preprod.for_project(self.org.id, self.project.id) + client.put(BytesIO(png_data), id=f"{self.org.id}/{self.project.id}/test-image-123") + + 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" + + @requires_objectstore + def test_successful_image_retrieval_jpeg(self): + jpeg_data = b"\xff\xd8\xff" + b"fake jpeg content" * 100 + + client = preprod.for_project(self.org.id, self.project.id) + client.put(BytesIO(jpeg_data), id=f"{self.org.id}/{self.project.id}/test-image-123") + + 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" + + @requires_objectstore + def test_successful_image_retrieval_webp(self): + webp_data = b"RIFF" + b"1234" + b"WEBP" + b"fake webp content" * 100 + + client = preprod.for_project(self.org.id, self.project.id) + client.put(BytesIO(webp_data), id=f"{self.org.id}/{self.project.id}/test-image-123") + + 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" + + def test_successful_image_retrieval_heic(self): + heic_data = b"RIFF" + b"ftypheic" + b"fake heic content" * 100 + + client = preprod.for_project(self.org.id, self.project.id) + client.put(BytesIO(heic_data), id=f"{self.org.id}/{self.project.id}/test-image-123") + + 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 == heic_data + assert response["Content-Type"] == "image/heic" + + @requires_objectstore + def test_image_not_found(self): + url = self._get_url() + response = self.client.get( + url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}" + ) + + assert response.status_code == 404 + assert response.content == b'{"error":"Not found"}' + + @requires_objectstore + def test_unknown_image_format(self): + unknown_data = b"unknown binary data" * 50 + + client = preprod.for_project(self.org.id, self.project.id) + client.put(BytesIO(unknown_data), id=f"{self.org.id}/{self.project.id}/test-image-123") + + 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" + + 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