Skip to content

Commit d9466a5

Browse files
authored
feat: Fill in check for updates API (#97392)
Fills in the check for updates API to find the currently running app (using binary identifier) and the most recent update if it differs from the current version
1 parent aa331ca commit d9466a5

File tree

5 files changed

+559
-70
lines changed

5 files changed

+559
-70
lines changed

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

Lines changed: 3 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import logging
2-
import secrets
3-
from datetime import timedelta
42
from typing import Any
53

64
from django.http import JsonResponse
75
from django.http.response import HttpResponseBase
8-
from django.utils import timezone
96
from rest_framework.request import Request
107
from rest_framework.response import Response
118

@@ -17,52 +14,12 @@
1714
from sentry.preprod.api.models.project_preprod_build_details_models import (
1815
platform_from_artifact_type,
1916
)
20-
from sentry.preprod.models import InstallablePreprodArtifact, PreprodArtifact
17+
from sentry.preprod.build_distribution_utils import get_download_url_for_artifact
18+
from sentry.preprod.models import PreprodArtifact
2119

2220
logger = logging.getLogger(__name__)
2321

2422

25-
def create_installable_preprod_artifact(
26-
preprod_artifact: PreprodArtifact, expiration_hours: int = 12
27-
) -> InstallablePreprodArtifact:
28-
"""
29-
Creates a new InstallablePreprodArtifact for a given PreprodArtifact.
30-
31-
Args:
32-
preprod_artifact: The PreprodArtifact to create an installable version for
33-
expiration_hours: Number of hours until the install link expires (default: 12)
34-
35-
Returns:
36-
The created InstallablePreprodArtifact instance
37-
"""
38-
39-
url_path = secrets.token_urlsafe(12)
40-
41-
# Set expiration date
42-
expiration_date = timezone.now() + timedelta(hours=expiration_hours)
43-
44-
# Create the installable artifact
45-
installable_artifact = InstallablePreprodArtifact.objects.create(
46-
preprod_artifact=preprod_artifact,
47-
url_path=url_path,
48-
expiration_date=expiration_date,
49-
download_count=0,
50-
)
51-
52-
logger.info(
53-
"Created installable preprod artifact",
54-
extra={
55-
"installable_artifact_id": installable_artifact.id,
56-
"preprod_artifact_id": preprod_artifact.id,
57-
"project_id": preprod_artifact.project.id,
58-
"organization_id": preprod_artifact.project.organization.id,
59-
"expiration_date": expiration_date.isoformat(),
60-
},
61-
)
62-
63-
return installable_artifact
64-
65-
6623
@region_silo_endpoint
6724
class ProjectPreprodInstallDetailsEndpoint(ProjectEndpoint):
6825
owner = ApiOwner.EMERGE_TOOLS
@@ -103,16 +60,7 @@ def get(self, request: Request, project, artifact_id) -> HttpResponseBase:
10360
if not preprod_artifact.installable_app_file_id:
10461
return Response({"error": "Installable file not available"}, status=404)
10562

106-
installable = create_installable_preprod_artifact(preprod_artifact)
107-
108-
# Only add response_format=plist for iOS apps (XCARCHIVE type)
109-
url_params = ""
110-
if preprod_artifact.artifact_type == PreprodArtifact.ArtifactType.XCARCHIVE:
111-
url_params = "?response_format=plist"
112-
113-
installable_url = request.build_absolute_uri(
114-
f"/api/0/projects/{project.organization.slug}/{project.slug}/files/installablepreprodartifact/{installable.url_path}/{url_params}"
115-
)
63+
installable_url = get_download_url_for_artifact(preprod_artifact, request)
11664

11765
# Build response based on artifact type
11866
response_data: dict[str, Any] = {

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

Lines changed: 122 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22

3+
from packaging.version import parse as parse_version
34
from pydantic import BaseModel
45
from rest_framework.request import Request
56
from rest_framework.response import Response
@@ -8,15 +9,23 @@
89
from sentry.api.api_publish_status import ApiPublishStatus
910
from sentry.api.base import region_silo_endpoint
1011
from sentry.api.bases.project import ProjectEndpoint, ProjectReleasePermission
11-
from sentry.preprod.models import PreprodArtifact
12+
from sentry.preprod.build_distribution_utils import (
13+
get_download_url_for_artifact,
14+
is_installable_artifact,
15+
)
16+
from sentry.preprod.models import PreprodArtifact, PreprodBuildConfiguration
1217
from sentry.types.ratelimit import RateLimit, RateLimitCategory
1318

1419
logger = logging.getLogger(__name__)
1520

1621

1722
class InstallableBuildDetails(BaseModel):
23+
id: str
1824
build_version: str
1925
build_number: int
26+
download_url: str
27+
app_name: str
28+
created_date: str
2029

2130

2231
class CheckForUpdatesApiResponse(BaseModel):
@@ -46,22 +55,123 @@ def get(self, request: Request, project) -> Response:
4655
Check for updates for a preprod artifact
4756
"""
4857
main_binary_identifier = request.GET.get("main_binary_identifier")
49-
if not main_binary_identifier:
50-
# Not implemented yet
51-
return Response(CheckForUpdatesApiResponse().dict())
58+
app_id = request.GET.get("app_id")
59+
60+
platform = request.GET.get("platform")
61+
provided_version = request.GET.get("version")
62+
provided_build_configuration_name = request.GET.get("build_configuration")
63+
64+
if not app_id or not platform or not provided_version or not main_binary_identifier:
65+
return Response({"error": "Missing required parameters"}, status=400)
66+
67+
provided_build_configuration = None
68+
if provided_build_configuration_name:
69+
try:
70+
provided_build_configuration = PreprodBuildConfiguration.objects.get(
71+
project=project,
72+
name=provided_build_configuration_name,
73+
)
74+
except PreprodBuildConfiguration.DoesNotExist:
75+
return Response({"error": "Invalid build configuration"}, status=400)
76+
77+
preprod_artifact = None
78+
current = None
79+
update = None
80+
81+
# Common filter logic
82+
def get_base_filters():
83+
filter_kwargs = {
84+
"project": project,
85+
"app_id": app_id,
86+
}
87+
88+
if platform == "android":
89+
filter_kwargs["artifact_type__in"] = [
90+
PreprodArtifact.ArtifactType.AAB,
91+
PreprodArtifact.ArtifactType.APK,
92+
]
93+
elif platform == "ios":
94+
filter_kwargs["artifact_type"] = PreprodArtifact.ArtifactType.XCARCHIVE
95+
96+
return filter_kwargs
5297

5398
try:
54-
preprod_artifact = PreprodArtifact.objects.filter(
55-
project=project, main_binary_identifier=main_binary_identifier
56-
).latest("date_added")
99+
current_filter_kwargs = get_base_filters()
100+
current_filter_kwargs.update(
101+
{
102+
"main_binary_identifier": main_binary_identifier,
103+
"build_version": provided_version,
104+
}
105+
)
106+
107+
if provided_build_configuration:
108+
current_filter_kwargs["build_configuration"] = provided_build_configuration
109+
110+
preprod_artifact = PreprodArtifact.objects.filter(**current_filter_kwargs).latest(
111+
"date_added"
112+
)
57113
except PreprodArtifact.DoesNotExist:
58-
return Response({"error": "Not found"}, status=404)
114+
logger.warning(
115+
"No artifact found for binary identifier with version %s", provided_version
116+
)
59117

60-
if preprod_artifact.build_version and preprod_artifact.build_number:
118+
if preprod_artifact and preprod_artifact.build_version and preprod_artifact.build_number:
61119
current = InstallableBuildDetails(
120+
id=str(preprod_artifact.id),
62121
build_version=preprod_artifact.build_version,
63122
build_number=preprod_artifact.build_number,
123+
app_name=preprod_artifact.app_name,
124+
download_url=get_download_url_for_artifact(preprod_artifact, request),
125+
created_date=preprod_artifact.date_added.isoformat(),
64126
)
65-
else:
66-
current = None
67-
return Response(CheckForUpdatesApiResponse(current=current).dict())
127+
128+
# Get the update object - find the highest version available
129+
# Get all build versions for this app and platform
130+
new_build_filter_kwargs = get_base_filters()
131+
if preprod_artifact:
132+
new_build_filter_kwargs["build_configuration"] = preprod_artifact.build_configuration
133+
elif provided_build_configuration:
134+
new_build_filter_kwargs["build_configuration"] = provided_build_configuration
135+
all_versions = (
136+
PreprodArtifact.objects.filter(**new_build_filter_kwargs)
137+
.values_list("build_version", flat=True)
138+
.distinct()
139+
)
140+
141+
# Find the highest semver version
142+
highest_version = None
143+
for version in all_versions:
144+
if version:
145+
try:
146+
parsed_version = parse_version(version)
147+
if highest_version is None or parsed_version > parse_version(highest_version):
148+
highest_version = version
149+
except Exception:
150+
# Skip invalid version strings
151+
continue
152+
153+
# Get all artifacts for the highest version
154+
if highest_version:
155+
new_build_filter_kwargs["build_version"] = highest_version
156+
potential_artifacts = PreprodArtifact.objects.filter(**new_build_filter_kwargs)
157+
158+
# Filter for installable artifacts and get the one with highest build_number
159+
installable_artifacts = [
160+
artifact for artifact in potential_artifacts if is_installable_artifact(artifact)
161+
]
162+
if len(installable_artifacts) > 0:
163+
best_artifact = max(
164+
installable_artifacts, key=lambda a: (a.build_number, a.date_added)
165+
)
166+
if not preprod_artifact or preprod_artifact.id != best_artifact.id:
167+
if best_artifact.build_version and best_artifact.build_number:
168+
update = InstallableBuildDetails(
169+
id=str(best_artifact.id),
170+
build_version=best_artifact.build_version,
171+
build_number=best_artifact.build_number,
172+
app_name=best_artifact.app_name,
173+
download_url=get_download_url_for_artifact(best_artifact, request),
174+
created_date=best_artifact.date_added.isoformat(),
175+
)
176+
177+
return Response(CheckForUpdatesApiResponse(current=current, update=update).dict())
Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,81 @@
1-
from sentry.preprod.models import PreprodArtifact
1+
import logging
2+
import secrets
3+
from datetime import timedelta
4+
5+
from django.utils import timezone
6+
7+
from sentry.preprod.models import InstallablePreprodArtifact, PreprodArtifact
8+
9+
logger = logging.getLogger(__name__)
210

311

412
def is_installable_artifact(artifact: PreprodArtifact) -> bool:
513
# TODO: Adjust this logic when we have a better way to determine if an artifact is installable
6-
return artifact.installable_app_file_id is not None
14+
return artifact.installable_app_file_id is not None and artifact.build_number is not None
15+
16+
17+
def get_download_url_for_artifact(artifact: PreprodArtifact, request) -> str:
18+
"""
19+
Generate a download URL for a PreprodArtifact.
20+
21+
Args:
22+
artifact: The PreprodArtifact to generate a download URL for
23+
request: The HTTP request object to build absolute URLs
24+
25+
Returns:
26+
A download URL string for the artifact
27+
"""
28+
# Create an installable artifact (this handles the URL path generation and expiration)
29+
installable = create_installable_preprod_artifact(artifact)
30+
31+
# Only add response_format=plist for iOS apps (XCARCHIVE type)
32+
url_params = ""
33+
if artifact.artifact_type == PreprodArtifact.ArtifactType.XCARCHIVE:
34+
url_params = "?response_format=plist"
35+
36+
download_url = request.build_absolute_uri(
37+
f"/api/0/projects/{artifact.project.organization.slug}/{artifact.project.slug}/files/installablepreprodartifact/{installable.url_path}/{url_params}"
38+
)
39+
40+
return download_url
41+
42+
43+
def create_installable_preprod_artifact(
44+
preprod_artifact: PreprodArtifact, expiration_hours: int = 12
45+
):
46+
"""
47+
Creates a new InstallablePreprodArtifact for a given PreprodArtifact.
48+
49+
Args:
50+
preprod_artifact: The PreprodArtifact to create an installable version for
51+
expiration_hours: Number of hours until the install link expires (default: 12)
52+
53+
Returns:
54+
The created InstallablePreprodArtifact instance
55+
"""
56+
57+
url_path = secrets.token_urlsafe(12)
58+
59+
# Set expiration date
60+
expiration_date = timezone.now() + timedelta(hours=expiration_hours)
61+
62+
# Create the installable artifact
63+
installable_artifact = InstallablePreprodArtifact.objects.create(
64+
preprod_artifact=preprod_artifact,
65+
url_path=url_path,
66+
expiration_date=expiration_date,
67+
download_count=0,
68+
)
69+
70+
logger.info(
71+
"Created installable preprod artifact",
72+
extra={
73+
"installable_artifact_id": installable_artifact.id,
74+
"preprod_artifact_id": preprod_artifact.id,
75+
"project_id": preprod_artifact.project.id,
76+
"organization_id": preprod_artifact.project.organization.id,
77+
"expiration_date": expiration_date.isoformat(),
78+
},
79+
)
80+
81+
return installable_artifact

tests/sentry/preprod/api/endpoints/test_project_preprod_build_details.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def setUp(self) -> None:
3939
app_name="TestApp",
4040
build_version="1.0.0",
4141
build_number=42,
42-
build_configuration_id=None,
42+
build_configuration=None,
4343
installable_app_file_id=1234,
4444
commit_comparison=commit_comparison,
4545
)

0 commit comments

Comments
 (0)