Skip to content

Commit 209e42f

Browse files
authored
feat(releases): Add an endpoint for artifact bundles (#13448)
Creates an assemble-like endpoint for uploading artifact bundles
1 parent be223f5 commit 209e42f

File tree

15 files changed

+813
-94
lines changed

15 files changed

+813
-94
lines changed

src/sentry/api/endpoints/chunk.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
MAX_CONCURRENCY = settings.DEBUG and 1 or 8
2626
HASH_ALGORITHM = 'sha1'
2727

28+
CHUNK_UPLOAD_ACCEPT = (
29+
'debug_files', # DIF assemble
30+
'release_files', # Artifacts assemble
31+
)
32+
2833

2934
class GzipChunk(BytesIO):
3035
def __init__(self, file):
@@ -61,6 +66,7 @@ def get(self, request, organization):
6166
'concurrency': MAX_CONCURRENCY,
6267
'hashAlgorithm': HASH_ALGORITHM,
6368
'compression': ['gzip'],
69+
'accept': CHUNK_UPLOAD_ACCEPT,
6470
}
6571
)
6672

src/sentry/api/endpoints/debug_files.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
from sentry.api.paginator import OffsetPaginator
1919
from sentry.api.serializers import serialize
2020
from sentry.constants import KNOWN_DIF_FORMATS
21-
from sentry.models import ChunkFileState, FileBlobOwner, ProjectDebugFile, \
22-
create_files_from_dif_zip, get_assemble_status, set_assemble_status
21+
from sentry.models import FileBlobOwner, ProjectDebugFile, create_files_from_dif_zip
22+
from sentry.tasks.assemble import get_assemble_status, set_assemble_status, \
23+
AssembleTask, ChunkFileState
2324
from sentry.utils import json
2425

2526
try:
@@ -252,7 +253,10 @@ def post(self, request, project):
252253
"name": {"type": "string"},
253254
"chunks": {
254255
"type": "array",
255-
"items": {"type": "string"}
256+
"items": {
257+
"type": "string",
258+
"pattern": "^[0-9a-f]{40}$",
259+
}
256260
}
257261
},
258262
"additionalProperties": False
@@ -273,15 +277,14 @@ def post(self, request, project):
273277

274278
file_response = {}
275279

276-
from sentry.tasks.assemble import assemble_dif
277280
for checksum, file_to_assemble in six.iteritems(files):
278281
name = file_to_assemble.get('name', None)
279282
chunks = file_to_assemble.get('chunks', [])
280283

281284
# First, check the cached assemble status. During assembling, a
282285
# ProjectDebugFile will be created and we need to prevent a race
283286
# condition.
284-
state, detail = get_assemble_status(project, checksum)
287+
state, detail = get_assemble_status(AssembleTask.DIF, project.id, checksum)
285288
if state == ChunkFileState.OK:
286289
file_response[checksum] = {
287290
'state': state,
@@ -337,7 +340,10 @@ def post(self, request, project):
337340

338341
# We don't have a state yet, this means we can now start
339342
# an assemble job in the background.
340-
set_assemble_status(project, checksum, ChunkFileState.CREATED)
343+
set_assemble_status(AssembleTask.DIF, project.id, checksum,
344+
ChunkFileState.CREATED)
345+
346+
from sentry.tasks.assemble import assemble_dif
341347
assemble_dif.apply_async(
342348
kwargs={
343349
'project_id': project.id,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from __future__ import absolute_import
2+
3+
import jsonschema
4+
from rest_framework.response import Response
5+
6+
from sentry.api.bases.organization import OrganizationReleasesBaseEndpoint
7+
from sentry.api.exceptions import ResourceDoesNotExist
8+
from sentry.models import Release
9+
from sentry.tasks.assemble import get_assemble_status, set_assemble_status, \
10+
AssembleTask, ChunkFileState
11+
from sentry.utils import json
12+
13+
14+
class OrganizationReleaseAssembleEndpoint(OrganizationReleasesBaseEndpoint):
15+
def post(self, request, organization, version):
16+
"""
17+
Handle an artifact bundle and merge it into the release
18+
```````````````````````````````````````````````````````
19+
20+
:auth: required
21+
"""
22+
23+
try:
24+
release = Release.objects.get(
25+
organization_id=organization.id,
26+
version=version,
27+
)
28+
except Release.DoesNotExist:
29+
raise ResourceDoesNotExist
30+
31+
if not self.has_release_permission(request, organization, release):
32+
raise ResourceDoesNotExist
33+
34+
schema = {
35+
"type": "object",
36+
"properties": {
37+
"checksum": {
38+
"type": "string",
39+
"pattern": "^[0-9a-f]{40}$",
40+
},
41+
"chunks": {
42+
"type": "array",
43+
"items": {
44+
"type": "string",
45+
"pattern": "^[0-9a-f]{40}$",
46+
}
47+
}
48+
},
49+
"required": ["checksum", "chunks"],
50+
"additionalProperties": False,
51+
}
52+
53+
try:
54+
data = json.loads(request.body)
55+
jsonschema.validate(data, schema)
56+
except jsonschema.ValidationError as e:
57+
return Response({'error': str(e).splitlines()[0]},
58+
status=400)
59+
except BaseException as e:
60+
return Response({'error': 'Invalid json body'},
61+
status=400)
62+
63+
checksum = data.get('checksum', None)
64+
chunks = data.get('chunks', [])
65+
66+
state, detail = get_assemble_status(AssembleTask.ARTIFACTS, organization.id, checksum)
67+
if state == ChunkFileState.OK:
68+
return Response({
69+
'state': state,
70+
'detail': None,
71+
'missingChunks': [],
72+
}, status=200)
73+
elif state is not None:
74+
return Response({
75+
'state': state,
76+
'detail': detail,
77+
'missingChunks': [],
78+
})
79+
80+
# There is neither a known file nor a cached state, so we will
81+
# have to create a new file. Assure that there are checksums.
82+
# If not, we assume this is a poll and report NOT_FOUND
83+
if not chunks:
84+
return Response({
85+
'state': ChunkFileState.NOT_FOUND,
86+
'missingChunks': [],
87+
}, status=200)
88+
89+
set_assemble_status(AssembleTask.ARTIFACTS, organization.id, checksum,
90+
ChunkFileState.CREATED)
91+
92+
from sentry.tasks.assemble import assemble_artifacts
93+
assemble_artifacts.apply_async(
94+
kwargs={
95+
'org_id': organization.id,
96+
'version': version,
97+
'checksum': checksum,
98+
'chunks': chunks,
99+
}
100+
)
101+
102+
return Response({
103+
'state': ChunkFileState.CREATED,
104+
'missingChunks': [],
105+
}, status=200)

src/sentry/api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
from .endpoints.organization_recent_searches import OrganizationRecentSearchesEndpoint
106106
from .endpoints.organization_releases import OrganizationReleasesEndpoint
107107
from .endpoints.organization_release_details import OrganizationReleaseDetailsEndpoint
108+
from .endpoints.organization_release_assemble import OrganizationReleaseAssembleEndpoint
108109
from .endpoints.organization_release_files import OrganizationReleaseFilesEndpoint
109110
from .endpoints.organization_release_file_details import OrganizationReleaseFileDetailsEndpoint
110111
from .endpoints.organization_release_commits import OrganizationReleaseCommitsEndpoint
@@ -748,6 +749,11 @@
748749
OrganizationReleaseDetailsEndpoint.as_view(),
749750
name='sentry-api-0-organization-release-details'
750751
),
752+
url(
753+
r'^organizations/(?P<organization_slug>[^\/]+)/releases/(?P<version>[^/]+)/assemble/$',
754+
OrganizationReleaseAssembleEndpoint.as_view(),
755+
name='sentry-api-0-organization-release-assemble'
756+
),
751757
url(
752758
r'^organizations/(?P<organization_slug>[^\/]+)/releases/(?P<version>[^/]+)/files/$',
753759
OrganizationReleaseFilesEndpoint.as_view(),

src/sentry/models/debugfile.py

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from symbolic import Archive, SymbolicError, ObjectErrorUnsupportedObject
2525

2626
from sentry import options
27-
from sentry.cache import default_cache
2827
from sentry.constants import KNOWN_DIF_FORMATS
2928
from sentry.db.models import FlexibleForeignKey, Model, sane_repr, BaseManager, JSONField
3029
from sentry.models.file import File
@@ -47,37 +46,6 @@
4746
_proguard_file_re = re.compile(r'/proguard/(?:mapping-)?(.*?)\.txt$')
4847

4948

50-
def _get_idempotency_id(project, checksum):
51-
"""For some operations an idempotency ID is needed."""
52-
return hashlib.sha1(b'%s|%s|project.dsym' % (
53-
str(project.id).encode('ascii'),
54-
checksum.encode('ascii'),
55-
)).hexdigest()
56-
57-
58-
def get_assemble_status(project, checksum):
59-
"""For a given file it checks what the current status of the assembling is.
60-
Returns a tuple in the form ``(status, details)`` where details is either
61-
`None` or a string identifying an error condition or notice.
62-
"""
63-
cache_key = 'assemble-status:%s' % _get_idempotency_id(
64-
project, checksum)
65-
rv = default_cache.get(cache_key)
66-
if rv is None:
67-
return None, None
68-
return tuple(rv)
69-
70-
71-
def set_assemble_status(project, checksum, state, detail=None):
72-
cache_key = 'assemble-status:%s' % _get_idempotency_id(
73-
project, checksum)
74-
75-
# NB: Also cache successfully created debug files to avoid races between
76-
# multiple DIFs with the same identifier. On the downside, this blocks
77-
# re-uploads for 10 minutes.
78-
default_cache.set(cache_key, (state, detail), 600)
79-
80-
8149
class BadDif(Exception):
8250
pass
8351

src/sentry/models/file.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,6 @@ class nooplogger(object):
5757
exception = staticmethod(lambda *a, **kw: None)
5858

5959

60-
def enum(**named_values):
61-
return type('Enum', (), named_values)
62-
63-
64-
ChunkFileState = enum(
65-
OK='ok', # File in database
66-
NOT_FOUND='not_found', # File not found in database
67-
CREATED='created', # File was created in the request and send to the worker for assembling
68-
ASSEMBLING='assembling', # File still being processed by worker
69-
ERROR='error' # Error happened during assembling
70-
)
71-
72-
7360
def _get_size_and_checksum(fileobj, logger=nooplogger):
7461
logger.info('_get_size_and_checksum.start')
7562
size = 0

0 commit comments

Comments
 (0)