Skip to content

Commit 761c9a5

Browse files
runningcodeclaudegetsantry[bot]
authored
feat(preprod): Add distribution error endpoint for launchpad (#109497)
Add a dedicated endpoint (`PUT .../files/preprodartifacts/{id}/distribution/`) for launchpad to report distribution processing errors back to the monolith. This mirrors the existing `ProjectPreprodSizeEndpoint` pattern. When launchpad encounters a distribution failure (unsupported artifact type, invalid code signature, simulator build), it needs a way to set `installable_app_error_code` and `installable_app_error_message` on the artifact so the frontend can display the reason. Previously, the only option was the general `update` endpoint which marks the entire artifact as failed — but distribution errors shouldn't affect the artifact's overall state. Follow-up to #109062. The launchpad side that calls this endpoint is in getsentry/launchpad#567. Refs EME-842, EME-422 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 659896f commit 761c9a5

5 files changed

Lines changed: 149 additions & 2 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Any, cast
5+
6+
from pydantic import BaseModel, Field
7+
from rest_framework.request import Request
8+
from rest_framework.response import Response
9+
10+
from sentry.api.api_owners import ApiOwner
11+
from sentry.api.api_publish_status import ApiPublishStatus
12+
from sentry.api.base import internal_region_silo_endpoint
13+
from sentry.models.project import Project
14+
from sentry.preprod.api.bases.preprod_artifact_endpoint import PreprodArtifactEndpoint
15+
from sentry.preprod.api.endpoints.project_preprod_size import parse_request_with_pydantic
16+
from sentry.preprod.authentication import (
17+
LaunchpadRpcPermission,
18+
LaunchpadRpcSignatureAuthentication,
19+
)
20+
from sentry.preprod.models import PreprodArtifact
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
class PutDistribution(BaseModel):
26+
error_code: int = Field(ge=0, le=3)
27+
error_message: str
28+
29+
30+
@internal_region_silo_endpoint
31+
class ProjectPreprodDistributionEndpoint(PreprodArtifactEndpoint):
32+
owner = ApiOwner.EMERGE_TOOLS
33+
publish_status = {
34+
"PUT": ApiPublishStatus.PRIVATE,
35+
}
36+
authentication_classes = (LaunchpadRpcSignatureAuthentication,)
37+
permission_classes = (LaunchpadRpcPermission,)
38+
39+
def put(
40+
self,
41+
request: Request,
42+
project: Project,
43+
head_artifact_id: int,
44+
head_artifact: PreprodArtifact,
45+
) -> Response:
46+
put: PutDistribution = parse_request_with_pydantic(request, cast(Any, PutDistribution))
47+
48+
head_artifact.installable_app_error_code = put.error_code
49+
head_artifact.installable_app_error_message = put.error_message
50+
head_artifact.save(
51+
update_fields=[
52+
"installable_app_error_code",
53+
"installable_app_error_message",
54+
"date_updated",
55+
]
56+
)
57+
58+
return Response({"artifactId": str(head_artifact.id)})

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ def parse_request_with_pydantic(request: Request, model: type[T]) -> T:
3939
# can be used instead of parse_obj_as
4040
return parse_obj_as(model, j)
4141
except pydantic.ValidationError:
42-
logger.exception("Could not parse PutSize")
43-
raise serializers.ValidationError("Could not parse PutSize")
42+
name = getattr(model, "__name__", str(model))
43+
logger.exception("Could not parse %s", name)
44+
raise serializers.ValidationError(f"Could not parse {name}")
4445

4546

4647
@internal_region_silo_endpoint

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from .project_preprod_artifact_update import ProjectPreprodArtifactUpdateEndpoint
4343
from .project_preprod_build_details import ProjectPreprodBuildDetailsEndpoint
4444
from .project_preprod_check_for_updates import ProjectPreprodArtifactCheckForUpdatesEndpoint
45+
from .project_preprod_distribution import ProjectPreprodDistributionEndpoint
4546
from .project_preprod_size import (
4647
ProjectPreprodSizeEndpoint,
4748
ProjectPreprodSizeWithIdentifierEndpoint,
@@ -242,6 +243,11 @@
242243
PreprodSnapshotRecompareEndpoint.as_view(),
243244
name="sentry-api-0-organization-preprod-snapshots-recompare",
244245
),
246+
re_path(
247+
r"^(?P<organization_id_or_slug>[^/]+)/preprodartifacts/(?P<head_artifact_id>[^/]+)/distribution/$",
248+
ProjectPreprodDistributionEndpoint.as_view(),
249+
name="sentry-api-0-organization-preprod-artifact-distribution",
250+
),
245251
]
246252

247253
preprod_internal_urlpatterns = [

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ export type KnownSentryApiUrls =
478478
| '/organizations/$organizationIdOrSlug/preprodartifacts/$artifactId/size-analysis/'
479479
| '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/build-details/'
480480
| '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/delete/'
481+
| '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/distribution/'
481482
| '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/private-install-details/'
482483
| '/organizations/$organizationIdOrSlug/preprodartifacts/list-builds/'
483484
| '/organizations/$organizationIdOrSlug/preprodartifacts/size-analysis/compare/$headArtifactId/$baseArtifactId/'
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import orjson
2+
from django.test import override_settings
3+
4+
from sentry.preprod.models import PreprodArtifact
5+
from sentry.testutils.auth import generate_service_request_signature
6+
from sentry.testutils.cases import TestCase
7+
8+
SHARED_SECRET_FOR_TESTS = "test-secret-key"
9+
10+
11+
class ProjectPreprodDistributionEndpointTest(TestCase):
12+
def setUp(self) -> None:
13+
super().setUp()
14+
self.file = self.create_file(name="test_artifact.apk", type="application/octet-stream")
15+
self.artifact = self.create_preprod_artifact(
16+
project=self.project,
17+
file_id=self.file.id,
18+
state=PreprodArtifact.ArtifactState.PROCESSED,
19+
)
20+
21+
def _put(self, data, secret=SHARED_SECRET_FOR_TESTS):
22+
url = f"/api/0/organizations/{self.organization.slug}/preprodartifacts/{self.artifact.id}/distribution/"
23+
signature = generate_service_request_signature(url, data, [secret], "Launchpad")
24+
return self.client.put(
25+
url,
26+
data=data,
27+
content_type="application/json",
28+
HTTP_AUTHORIZATION=f"rpcsignature {signature}",
29+
)
30+
31+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
32+
def test_bad_auth(self) -> None:
33+
response = self._put(b"{}", secret="wrong secret")
34+
assert response.status_code == 401
35+
36+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
37+
def test_missing_fields(self) -> None:
38+
response = self._put(b"{}")
39+
assert response.status_code == 400
40+
41+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
42+
def test_bad_json(self) -> None:
43+
response = self._put(b"{")
44+
assert response.status_code == 400
45+
46+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
47+
def test_set_error(self) -> None:
48+
response = self._put(
49+
orjson.dumps({"error_code": 3, "error_message": "Unsupported artifact type"})
50+
)
51+
52+
assert response.status_code == 200
53+
self.artifact.refresh_from_db()
54+
assert (
55+
self.artifact.installable_app_error_code
56+
== PreprodArtifact.InstallableAppErrorCode.PROCESSING_ERROR
57+
)
58+
assert self.artifact.installable_app_error_message == "Unsupported artifact type"
59+
60+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
61+
def test_invalid_error_code(self) -> None:
62+
response = self._put(orjson.dumps({"error_code": 99, "error_message": "bad"}))
63+
assert response.status_code == 400
64+
65+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
66+
def test_non_dict_json_body(self) -> None:
67+
response = self._put(orjson.dumps([1, 2, 3]))
68+
assert response.status_code == 400
69+
70+
@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS])
71+
def test_requires_launchpad_rpc_authentication(self) -> None:
72+
self.login_as(self.user)
73+
74+
url = f"/api/0/organizations/{self.organization.slug}/preprodartifacts/{self.artifact.id}/distribution/"
75+
response = self.client.put(
76+
url,
77+
data=orjson.dumps({"error_code": 3, "error_message": "some error"}),
78+
content_type="application/json",
79+
)
80+
81+
assert response.status_code == 401

0 commit comments

Comments
 (0)