diff --git a/src/sentry/objectstore/__init__.py b/src/sentry/objectstore/__init__.py index 83144e48fb69c7..2fc7cc7cfb058c 100644 --- a/src/sentry/objectstore/__init__.py +++ b/src/sentry/objectstore/__init__.py @@ -36,6 +36,9 @@ def distribution( _ATTACHMENTS_CLIENT: Client | None = None _ATTACHMENTS_USECASE = Usecase("attachments", expiration_policy=TimeToLive(timedelta(days=30))) +_PREPROD_CLIENT: Client | None = None +_PREPROD_USECASE = Usecase("preprod", expiration_policy=TimeToLive(timedelta(days=30))) + def get_attachments_session(org: int, project: int) -> Session: global _ATTACHMENTS_CLIENT @@ -49,3 +52,17 @@ def get_attachments_session(org: int, project: int) -> Session: ) return _ATTACHMENTS_CLIENT.session(_ATTACHMENTS_USECASE, org=org, project=project) + + +def get_preprod_session(org: int, project: int) -> Session: + global _PREPROD_CLIENT + if not _PREPROD_CLIENT: + from sentry import options as options_store + + options = options_store.get("objectstore.config") + _PREPROD_CLIENT = Client( + options["base_url"], + metrics_backend=SentryMetricsBackend(), + ) + + return _PREPROD_CLIENT.session(_PREPROD_USECASE, org=org, project=project) diff --git a/src/sentry/preprod/analytics.py b/src/sentry/preprod/analytics.py index 759558a5272aac..4abb47d549be01 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,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 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..6a80e34a0ee486 --- /dev/null +++ b/src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py @@ -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() + + # 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) 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 ee105943dab4b9..51d2d122f8107c 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 @@ -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 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, ) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index ed54894059d59d..12d92e01103d57 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -231,6 +231,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/detector-workflow/$detectorWorkflowId/' | '/organizations/$organizationIdOrSlug/detectors/' | '/organizations/$organizationIdOrSlug/detectors/$detectorId/' + | '/organizations/$organizationIdOrSlug/detectors/$detectorId/anomaly-data/' | '/organizations/$organizationIdOrSlug/detectors/count/' | '/organizations/$organizationIdOrSlug/discover/homepage/' | '/organizations/$organizationIdOrSlug/discover/saved/' @@ -346,6 +347,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/groups/$issueId/tags/$key/values/' | '/organizations/$organizationIdOrSlug/groups/$issueId/user-feedback/' | '/organizations/$organizationIdOrSlug/groups/$issueId/user-reports/' + | '/organizations/$organizationIdOrSlug/incident-groupopenperiod/' | '/organizations/$organizationIdOrSlug/incidents/' | '/organizations/$organizationIdOrSlug/incidents/$incidentIdentifier/' | '/organizations/$organizationIdOrSlug/insights/starred-segments/' @@ -452,6 +454,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/notifications/actions/' | '/organizations/$organizationIdOrSlug/notifications/actions/$actionId/' | '/organizations/$organizationIdOrSlug/notifications/available-actions/' + | '/organizations/$organizationIdOrSlug/objectstore/' | '/organizations/$organizationIdOrSlug/onboarding-continuation-email/' | '/organizations/$organizationIdOrSlug/onboarding-tasks/' | '/organizations/$organizationIdOrSlug/ondemand-rules-stats/' @@ -609,6 +612,7 @@ export type KnownSentryApiUrls = | '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/dsyms/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/dsyms/associate/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/dsyms/unknown/' + | '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/images/$imageId/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/installablepreprodartifact/$urlPath/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/size-analysis/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/assemble/' @@ -711,10 +715,10 @@ export type KnownSentryApiUrls = | '/projects/$organizationIdOrSlug/$projectIdOrSlug/user-reports/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/user-stats/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/users/' + | '/projects/$organizationIdOrSlug/$projectIdOrSlug/web-vitals-detector/' | '/projects/$organizationIdOrSlug/pr-comments/$repoName/$prNumber/' | '/projects/$organizationIdOrSlug/pull-requests/size-analysis/$artifactId/' | '/projects/$organizationIdOrSlug/pullrequest-details/$repoName/$prNumber/' - | '/prompts-activity/' | '/publickeys/relocations/' | '/relays/' | '/relays/$relayId/' 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..8555eb2ff6e278 --- /dev/null +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py @@ -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