Skip to content

Commit 83d3884

Browse files
rbro112Abdkhan14Abdullah Khan
authored
feat(preprod): Add preprod images download endpoint (#102117)
Adds a new preprod `/files/images/<image_id>` endpoint which allows our frontend to download images from an ID with objectstore. First to be used with app icons coming soon. Tested fully E2E locally and all works well. To work, this is reliant on landing getsentry/launchpad#430 once we get a published version of the objectstore client, but since nobody will be consuming this endpoint until we land the stacked PR (#102118) this is safe to go ahead and merge. --------- Co-authored-by: Abdullah Khan <[email protected]> Co-authored-by: Abdullah Khan <[email protected]>
1 parent 59c6341 commit 83d3884

File tree

7 files changed

+210
-15
lines changed

7 files changed

+210
-15
lines changed

src/sentry/objectstore/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ def distribution(
3838

3939

4040
_ATTACHMENTS_CLIENT: Client | None = None
41+
42+
_OBJECTSTORE_CLIENT: Client | None = None
4143
_ATTACHMENTS_USECASE = Usecase("attachments", expiration_policy=TimeToLive(timedelta(days=30)))
44+
_PREPROD_USECASE = Usecase("preprod", expiration_policy=TimeToLive(timedelta(days=30)))
4245

4346

4447
def create_client() -> Client:
@@ -63,6 +66,14 @@ def get_attachments_session(org: int, project: int) -> Session:
6366
return _ATTACHMENTS_CLIENT.session(_ATTACHMENTS_USECASE, org=org, project=project)
6467

6568

69+
def get_preprod_session(org: int, project: int) -> Session:
70+
global _OBJECTSTORE_CLIENT
71+
if not _OBJECTSTORE_CLIENT:
72+
_OBJECTSTORE_CLIENT = create_client()
73+
74+
return _OBJECTSTORE_CLIENT.session(_PREPROD_USECASE, org=org, project=project)
75+
76+
6677
_IS_SYMBOLICATOR_CONTAINER: bool | None = None
6778

6879

src/sentry/preprod/analytics.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,27 +22,27 @@ class PreprodArtifactApiAssembleGenericEvent(analytics.Event):
2222
project_id: int
2323

2424

25-
@analytics.eventclass("preprod_artifact.api.size_analysis_download")
26-
class PreprodArtifactApiSizeAnalysisDownloadEvent(analytics.Event):
25+
@analytics.eventclass("preprod_artifact.api.get_build_details")
26+
class PreprodArtifactApiGetBuildDetailsEvent(analytics.Event):
2727
organization_id: int
2828
project_id: int
2929
user_id: int | None = None
3030
artifact_id: str
3131

3232

33-
@analytics.eventclass("preprod_artifact.api.get_build_details")
34-
class PreprodArtifactApiGetBuildDetailsEvent(analytics.Event):
33+
@analytics.eventclass("preprod_artifact.api.list_builds")
34+
class PreprodArtifactApiListBuildsEvent(analytics.Event):
3535
organization_id: int
3636
project_id: int
3737
user_id: int | None = None
38-
artifact_id: str
3938

4039

41-
@analytics.eventclass("preprod_artifact.api.list_builds")
42-
class PreprodArtifactApiListBuildsEvent(analytics.Event):
40+
@analytics.eventclass("preprod_artifact.api.install_details")
41+
class PreprodArtifactApiInstallDetailsEvent(analytics.Event):
4342
organization_id: int
4443
project_id: int
4544
user_id: int | None = None
45+
artifact_id: str
4646

4747

4848
@analytics.eventclass("preprod_artifact.api.admin_rerun_analysis")
@@ -77,6 +77,15 @@ class PreprodArtifactApiDeleteEvent(analytics.Event):
7777
artifact_id: str
7878

7979

80+
# Size analysis
81+
@analytics.eventclass("preprod_artifact.api.size_analysis_download")
82+
class PreprodArtifactApiSizeAnalysisDownloadEvent(analytics.Event):
83+
organization_id: int
84+
project_id: int
85+
user_id: int | None = None
86+
artifact_id: str
87+
88+
8089
@analytics.eventclass("preprod_artifact.api.size_analysis_compare.get")
8190
class PreprodArtifactApiSizeAnalysisCompareGetEvent(analytics.Event):
8291
organization_id: int
@@ -95,14 +104,6 @@ class PreprodArtifactApiSizeAnalysisComparePostEvent(analytics.Event):
95104
base_artifact_id: str
96105

97106

98-
@analytics.eventclass("preprod_artifact.api.install_details")
99-
class PreprodArtifactApiInstallDetailsEvent(analytics.Event):
100-
organization_id: int
101-
project_id: int
102-
user_id: int | None = None
103-
artifact_id: str
104-
105-
106107
@analytics.eventclass("preprod_artifact.api.size_analysis_compare_download")
107108
class PreprodArtifactApiSizeAnalysisCompareDownloadEvent(analytics.Event):
108109
organization_id: int
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from django.http import HttpResponse
6+
from rest_framework.request import Request
7+
8+
from sentry.api.api_owners import ApiOwner
9+
from sentry.api.api_publish_status import ApiPublishStatus
10+
from sentry.api.base import region_silo_endpoint
11+
from sentry.api.bases.project import ProjectEndpoint
12+
from sentry.models.project import Project
13+
from sentry.objectstore import get_preprod_session
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
@region_silo_endpoint
19+
class ProjectPreprodArtifactImageEndpoint(ProjectEndpoint):
20+
owner = ApiOwner.EMERGE_TOOLS
21+
publish_status = {
22+
"GET": ApiPublishStatus.EXPERIMENTAL,
23+
}
24+
25+
def get(
26+
self,
27+
_: Request,
28+
project: Project,
29+
image_id: str,
30+
) -> HttpResponse:
31+
32+
organization_id = project.organization_id
33+
project_id = project.id
34+
35+
object_key = f"{organization_id}/{project_id}/{image_id}"
36+
session = get_preprod_session(organization_id, project_id)
37+
38+
try:
39+
result = session.get(object_key)
40+
# Read the entire stream at once (necessary for content_type)
41+
image_data = result.payload.read()
42+
43+
# Detect content type from the image data
44+
return HttpResponse(image_data, content_type=result.metadata.content_type)
45+
46+
except Exception:
47+
logger.exception(
48+
"Unexpected error retrieving image",
49+
extra={
50+
"organization_id": organization_id,
51+
"project_id": project_id,
52+
"image_id": image_id,
53+
},
54+
)
55+
return HttpResponse({"error": "Internal server error"}, status=500)

src/sentry/preprod/api/endpoints/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from django.urls import re_path
44

5+
from sentry.preprod.api.endpoints.project_preprod_artifact_image import (
6+
ProjectPreprodArtifactImageEndpoint,
7+
)
58
from sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare import (
69
ProjectPreprodArtifactSizeAnalysisCompareEndpoint,
710
)
@@ -81,6 +84,11 @@
8184
ProjectInstallablePreprodArtifactDownloadEndpoint.as_view(),
8285
name="sentry-api-0-installable-preprod-artifact-download",
8386
),
87+
re_path(
88+
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/files/images/(?P<image_id>[^/]+)/$",
89+
ProjectPreprodArtifactImageEndpoint.as_view(),
90+
name="sentry-api-0-project-preprod-artifact-image",
91+
),
8492
# Size analysis
8593
re_path(
8694
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/preprodartifacts/size-analysis/compare/(?P<head_artifact_id>[^/]+)/(?P<base_artifact_id>[^/]+)/$",

src/sentry/preprod/api/models/project_preprod_build_details_models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class BuildDetailsAppInfo(BaseModel):
3838
platform: Platform | None = None
3939
is_installable: bool
4040
build_configuration: str | None = None
41+
app_icon_id: str | None = None
4142
apple_app_info: AppleAppInfo | None = None
4243
android_app_info: AndroidAppInfo | None = None
4344

@@ -270,6 +271,7 @@ def transform_preprod_artifact_to_build_details(
270271
build_configuration=(
271272
artifact.build_configuration.name if artifact.build_configuration else None
272273
),
274+
app_icon_id=artifact.app_icon_id,
273275
apple_app_info=apple_app_info,
274276
android_app_info=android_app_info,
275277
)

static/app/utils/api/knownSentryApiUrls.generated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,7 @@ export type KnownSentryApiUrls =
617617
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/dsyms/'
618618
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/dsyms/associate/'
619619
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/dsyms/unknown/'
620+
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/images/$imageId/'
620621
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/installablepreprodartifact/$urlPath/'
621622
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/size-analysis/'
622623
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/assemble/'
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
from django.urls import reverse
4+
5+
from sentry.testutils.cases import APITestCase
6+
7+
8+
class ProjectPreprodArtifactImageTest(APITestCase):
9+
def setUp(self):
10+
super().setUp()
11+
self.login_as(user=self.user)
12+
self.org = self.create_organization(owner=self.user)
13+
self.project = self.create_project(organization=self.org)
14+
self.api_token = self.create_user_auth_token(
15+
user=self.user, scope_list=["org:admin", "project:admin"]
16+
)
17+
self.image_id = "test-image-123"
18+
self.base_path = f"/api/0/{self.org.slug}/{self.project.slug}/files/images/{self.image_id}/"
19+
20+
def _get_url(self, image_id=None):
21+
image_id = image_id or self.image_id
22+
return reverse(
23+
"sentry-api-0-project-preprod-artifact-image",
24+
args=[self.org.slug, self.project.slug, image_id],
25+
)
26+
27+
def _create_mock_session(self, image_data, content_type):
28+
"""Create a mock object store session that returns the given data and content type."""
29+
mock_result = MagicMock()
30+
mock_result.payload.read.return_value = image_data
31+
mock_result.metadata.content_type = content_type
32+
33+
mock_session = MagicMock()
34+
mock_session.get.return_value = mock_result
35+
36+
return mock_session
37+
38+
@patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session")
39+
def test_successful_image_retrieval_png(self, mock_get_session):
40+
png_data = b"\x89PNG\r\n\x1a\n" + b"fake png content" * 100
41+
mock_session = self._create_mock_session(png_data, "image/png")
42+
mock_get_session.return_value = mock_session
43+
44+
url = self._get_url()
45+
response = self.client.get(
46+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
47+
)
48+
49+
assert response.status_code == 200
50+
assert response.content == png_data
51+
assert response["Content-Type"] == "image/png"
52+
mock_get_session.assert_called_once_with(self.org.id, self.project.id)
53+
mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}")
54+
55+
@patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session")
56+
def test_successful_image_retrieval_jpeg(self, mock_get_session):
57+
jpeg_data = b"\xff\xd8\xff" + b"fake jpeg content" * 100
58+
mock_session = self._create_mock_session(jpeg_data, "image/jpeg")
59+
mock_get_session.return_value = mock_session
60+
61+
url = self._get_url()
62+
response = self.client.get(
63+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
64+
)
65+
66+
assert response.status_code == 200
67+
assert response.content == jpeg_data
68+
assert response["Content-Type"] == "image/jpeg"
69+
mock_get_session.assert_called_once_with(self.org.id, self.project.id)
70+
mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}")
71+
72+
@patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session")
73+
def test_successful_image_retrieval_webp(self, mock_get_session):
74+
webp_data = b"RIFF" + b"1234" + b"WEBP" + b"fake webp content" * 100
75+
mock_session = self._create_mock_session(webp_data, "image/webp")
76+
mock_get_session.return_value = mock_session
77+
78+
url = self._get_url()
79+
response = self.client.get(
80+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
81+
)
82+
83+
assert response.status_code == 200
84+
assert response.content == webp_data
85+
assert response["Content-Type"] == "image/webp"
86+
mock_get_session.assert_called_once_with(self.org.id, self.project.id)
87+
mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}")
88+
89+
@patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session")
90+
def test_unknown_image_format(self, mock_get_session):
91+
unknown_data = b"unknown binary data" * 50
92+
mock_session = self._create_mock_session(unknown_data, "application/octet-stream")
93+
mock_get_session.return_value = mock_session
94+
95+
url = self._get_url()
96+
response = self.client.get(
97+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
98+
)
99+
100+
assert response.status_code == 200
101+
assert response.content == unknown_data
102+
assert response["Content-Type"] == "application/octet-stream"
103+
mock_get_session.assert_called_once_with(self.org.id, self.project.id)
104+
mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}")
105+
106+
def test_endpoint_requires_project_access(self):
107+
other_user = self.create_user()
108+
self.login_as(user=other_user)
109+
self.api_token = self.create_user_auth_token(
110+
user=other_user, scope_list=["org:read", "project:read"]
111+
)
112+
113+
url = self._get_url()
114+
response = self.client.get(
115+
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
116+
)
117+
assert response.status_code == 403

0 commit comments

Comments
 (0)