Skip to content

Commit ad9cef4

Browse files
NicoHinderlingandrewshie-sentry
authored andcommitted
feat(preprod): Add preprod artifact upload endpoint (#92528)
Upload endpoint described in the [emerge size integration proposal](https://www.notion.so/sentry/Emerge-Size-Integration-Technical-Proposal-1ec8b10e4b5d801d9bc4cf8c7cc5ad1b?d=1fa8b10e4b5d80df9ed5001c55e19b89#1fa8b10e4b5d80dba610cb7273434b0d) Most of the logic is pretty similar to the debug file and sourcemap upload endpoints in addition to the unit tests, I've manually tested uploading works via my [test sentry-cli branch](getsentry/sentry-cli#2533)
1 parent d3bcd94 commit ad9cef4

File tree

9 files changed

+1103
-1
lines changed

9 files changed

+1103
-1
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,7 @@ tests/sentry/api/endpoints/test_organization_dashboard_widget_details.py @ge
681681
/src/sentry/*/migrations/ @getsentry/owners-migrations
682682

683683
# Preprod build artifact analysis
684-
/src/sentry/preprod @getsentry/emerge-tool
684+
/src/sentry/preprod @getsentry/emerge-tools
685685
# End of preprod
686686

687687
## Frontend Platform (keep last as we want highest specificity)

src/sentry/api/api_owners.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ class ApiOwner(Enum):
2828
UNOWNED = "unowned"
2929
WEB_FRONTEND_SDKS = "team-web-sdk-frontend"
3030
GDX = "gdx"
31+
EMERGE_TOOLS = "emerge-tools"

src/sentry/api/endpoints/chunk.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"artifact_bundles", # Artifact Bundles for JavaScript Source Maps
3838
"artifact_bundles_v2", # The `assemble` endpoint will check for missing chunks
3939
"proguard", # Chunk-uploaded proguard mappings
40+
"preprod_artifacts", # Preprod artifacts (mobile builds, etc.)
4041
)
4142

4243

src/sentry/api/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,9 @@
286286
from sentry.notifications.api.endpoints.user_notification_settings_providers import (
287287
UserNotificationSettingsProvidersEndpoint,
288288
)
289+
from sentry.preprod.api.endpoints.organization_preprod_artifact_assemble import (
290+
ProjectPreprodArtifactAssembleEndpoint,
291+
)
289292
from sentry.relocation.api.endpoints.abort import RelocationAbortEndpoint
290293
from sentry.relocation.api.endpoints.artifacts.details import RelocationArtifactDetailsEndpoint
291294
from sentry.relocation.api.endpoints.artifacts.index import RelocationArtifactIndexEndpoint
@@ -2497,6 +2500,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
24972500
DifAssembleEndpoint.as_view(),
24982501
name="sentry-api-0-assemble-dif-files",
24992502
),
2503+
re_path(
2504+
r"^(?P<organization_id_or_slug>[^\/]+)/(?P<project_id_or_slug>[^\/]+)/files/preprodartifacts/assemble/$",
2505+
ProjectPreprodArtifactAssembleEndpoint.as_view(),
2506+
name="sentry-api-0-assemble-preprod-artifact-files",
2507+
),
25002508
re_path(
25012509
r"^(?P<organization_id_or_slug>[^\/]+)/(?P<project_id_or_slug>[^\/]+)/files/dsyms/unknown/$",
25022510
UnknownDebugFilesEndpoint.as_view(),
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import jsonschema
2+
import orjson
3+
import sentry_sdk
4+
from rest_framework.request import Request
5+
from rest_framework.response import Response
6+
7+
from sentry.api.api_owners import ApiOwner
8+
from sentry.api.api_publish_status import ApiPublishStatus
9+
from sentry.api.base import region_silo_endpoint
10+
from sentry.api.bases.project import ProjectEndpoint, ProjectReleasePermission
11+
from sentry.debug_files.upload import find_missing_chunks
12+
from sentry.models.orgauthtoken import is_org_auth_token_auth, update_org_auth_token_last_used
13+
from sentry.preprod.tasks import assemble_preprod_artifact
14+
from sentry.tasks.assemble import (
15+
AssembleTask,
16+
ChunkFileState,
17+
get_assemble_status,
18+
set_assemble_status,
19+
)
20+
21+
22+
def validate_preprod_artifact_schema(request_body: bytes) -> tuple[dict, str | None]:
23+
"""
24+
Validate the JSON schema for preprod artifact assembly requests.
25+
26+
Returns:
27+
tuple: (parsed_data, error_message) where error_message is None if validation succeeds
28+
"""
29+
schema = {
30+
"type": "object",
31+
"properties": {
32+
"checksum": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
33+
"chunks": {
34+
"type": "array",
35+
"items": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
36+
},
37+
# Optional metadata
38+
"git_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
39+
"build_configuration": {"type": "string"},
40+
},
41+
"required": ["checksum", "chunks"],
42+
"additionalProperties": False,
43+
}
44+
45+
error_messages = {
46+
"checksum": "The checksum field is required and must be a 40-character hexadecimal string.",
47+
"chunks": "The chunks field is required and must be provided as an array of 40-character hexadecimal strings.",
48+
"git_sha": "The git_sha field must be a 40-character hexadecimal string.",
49+
"build_configuration": "The build_configuration field must be a string.",
50+
}
51+
52+
try:
53+
data = orjson.loads(request_body)
54+
jsonschema.validate(data, schema)
55+
return data, None
56+
except jsonschema.ValidationError as e:
57+
error_message = e.message
58+
# Get the field from the path if available
59+
if e.path:
60+
if field := e.path[0]:
61+
error_message = error_messages.get(str(field), error_message)
62+
return {}, error_message
63+
except (orjson.JSONDecodeError, TypeError):
64+
return {}, "Invalid json body"
65+
66+
67+
@region_silo_endpoint
68+
class ProjectPreprodArtifactAssembleEndpoint(ProjectEndpoint):
69+
owner = ApiOwner.EMERGE_TOOLS
70+
publish_status = {
71+
"POST": ApiPublishStatus.EXPERIMENTAL,
72+
}
73+
permission_classes = (ProjectReleasePermission,)
74+
75+
def post(self, request: Request, project) -> Response:
76+
"""
77+
Assembles a preprod artifact (mobile build, etc.) and stores it in the database.
78+
"""
79+
with sentry_sdk.start_span(op="preprod_artifact.assemble"):
80+
data, error_message = validate_preprod_artifact_schema(request.body)
81+
if error_message:
82+
return Response({"error": error_message}, status=400)
83+
84+
checksum = data.get("checksum")
85+
chunks = data.get("chunks", [])
86+
87+
# Check if all requested chunks have been uploaded
88+
missing_chunks = find_missing_chunks(project.organization_id, set(chunks))
89+
if missing_chunks:
90+
return Response(
91+
{
92+
"state": ChunkFileState.NOT_FOUND,
93+
"missingChunks": missing_chunks,
94+
}
95+
)
96+
97+
# Check current assembly status
98+
state, detail = get_assemble_status(AssembleTask.PREPROD_ARTIFACT, project.id, checksum)
99+
if state is not None:
100+
return Response({"state": state, "detail": detail, "missingChunks": []})
101+
102+
# There is neither a known file nor a cached state, so we will
103+
# have to create a new file. Assure that there are checksums.
104+
# If not, we assume this is a poll and report NOT_FOUND
105+
if not chunks:
106+
return Response({"state": ChunkFileState.NOT_FOUND, "missingChunks": []})
107+
108+
set_assemble_status(
109+
AssembleTask.PREPROD_ARTIFACT, project.id, checksum, ChunkFileState.CREATED
110+
)
111+
112+
assemble_preprod_artifact.apply_async(
113+
kwargs={
114+
"org_id": project.organization_id,
115+
"project_id": project.id,
116+
"checksum": checksum,
117+
"chunks": chunks,
118+
"git_sha": data.get("git_sha"),
119+
"build_configuration": data.get("build_configuration"),
120+
}
121+
)
122+
123+
if is_org_auth_token_auth(request.auth):
124+
update_org_auth_token_last_used(request.auth, [project.id])
125+
126+
return Response({"state": ChunkFileState.CREATED, "missingChunks": []})

src/sentry/preprod/tasks.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from __future__ import annotations
2+
3+
import datetime
4+
import logging
5+
import uuid
6+
7+
from django.db import router, transaction
8+
9+
from sentry.models.organization import Organization
10+
from sentry.models.project import Project
11+
from sentry.silo.base import SiloMode
12+
from sentry.tasks.assemble import AssembleTask, ChunkFileState, assemble_file, set_assemble_status
13+
from sentry.tasks.base import instrumented_task
14+
from sentry.taskworker.config import TaskworkerConfig
15+
from sentry.taskworker.namespaces import attachments_tasks
16+
from sentry.utils.sdk import bind_organization_context
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
@instrumented_task(
22+
name="sentry.preprod.tasks.assemble_preprod_artifact",
23+
queue="assemble",
24+
silo_mode=SiloMode.REGION,
25+
taskworker_config=TaskworkerConfig(
26+
namespace=attachments_tasks,
27+
processing_deadline_duration=30,
28+
),
29+
)
30+
def assemble_preprod_artifact(
31+
org_id,
32+
project_id,
33+
checksum,
34+
chunks,
35+
git_sha=None,
36+
build_configuration=None,
37+
**kwargs,
38+
) -> None:
39+
"""
40+
Creates a preprod artifact from uploaded chunks.
41+
"""
42+
from sentry.preprod.models import PreprodArtifact, PreprodBuildConfiguration
43+
44+
logger.info(
45+
"Starting preprod artifact assembly",
46+
extra={
47+
"timestamp": datetime.datetime.now().isoformat(),
48+
"project_id": project_id,
49+
"organization_id": org_id,
50+
},
51+
)
52+
53+
try:
54+
organization = Organization.objects.get_from_cache(pk=org_id)
55+
project = Project.objects.get(id=project_id, organization=organization)
56+
bind_organization_context(organization)
57+
58+
set_assemble_status(
59+
AssembleTask.PREPROD_ARTIFACT, org_id, checksum, ChunkFileState.ASSEMBLING
60+
)
61+
62+
assemble_result = assemble_file(
63+
task=AssembleTask.PREPROD_ARTIFACT,
64+
org_or_project=organization,
65+
name=f"preprod-artifact-{uuid.uuid4().hex}",
66+
checksum=checksum,
67+
chunks=chunks,
68+
file_type="preprod.artifact",
69+
)
70+
71+
if assemble_result is None:
72+
return
73+
74+
with transaction.atomic(router.db_for_write(PreprodArtifact)):
75+
build_config = None
76+
if build_configuration:
77+
build_config, _ = PreprodBuildConfiguration.objects.get_or_create(
78+
project=project,
79+
name=build_configuration,
80+
)
81+
82+
# Create PreprodArtifact record
83+
preprod_artifact = PreprodArtifact.objects.create(
84+
project=project,
85+
file_id=assemble_result.bundle.id,
86+
build_configuration=build_config,
87+
state=PreprodArtifact.ArtifactState.UPLOADED,
88+
)
89+
90+
logger.info(
91+
"Created preprod artifact",
92+
extra={
93+
"preprod_artifact_id": preprod_artifact.id,
94+
"project_id": project_id,
95+
"organization_id": org_id,
96+
},
97+
)
98+
99+
logger.info(
100+
"Finished preprod artifact assembly",
101+
extra={
102+
"timestamp": datetime.datetime.now().isoformat(),
103+
"project_id": project_id,
104+
"organization_id": org_id,
105+
},
106+
)
107+
108+
# where next set of changes will happen
109+
# TODO: Trigger artifact processing (size analysis, etc.)
110+
# This is where you'd add logic to:
111+
# 1. create_or_update a new row in the Commit table as well (once base_sha is added as a column to it)
112+
# 2. Detect artifact type (iOS/Android/etc.)
113+
# 3. Queue processing tasks
114+
# 4. Update state to PROCESSED when done (also update the date_built value to reflect when the artifact was built, among other fields)
115+
116+
except Exception as e:
117+
logger.exception(
118+
"Failed to assemble preprod artifact",
119+
extra={
120+
"project_id": project_id,
121+
"organization_id": org_id,
122+
},
123+
)
124+
set_assemble_status(
125+
AssembleTask.PREPROD_ARTIFACT,
126+
org_id,
127+
checksum,
128+
ChunkFileState.ERROR,
129+
detail=str(e),
130+
)
131+
else:
132+
set_assemble_status(AssembleTask.PREPROD_ARTIFACT, org_id, checksum, ChunkFileState.OK)

src/sentry/tasks/assemble.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class AssembleTask:
6060
DIF = "project.dsym" # Debug file upload
6161
RELEASE_BUNDLE = "organization.artifacts" # Release file upload
6262
ARTIFACT_BUNDLE = "organization.artifact_bundle" # Artifact bundle upload
63+
PREPROD_ARTIFACT = "organization.preprod_artifact_bundle" # Preprod artifact upload
6364

6465

6566
class AssembleResult(NamedTuple):

0 commit comments

Comments
 (0)