Browse Source

feat(proguard): associate release with proguard mapping files (#48511)

Ref: https://github.com/getsentry/sentry-android-gradle-plugin/issues/40

This allows to create a weak association between a release and a
proguard mapping file

---------

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Giancarlo Buenaflor 1 year ago
parent
commit
2324f4ebe6

+ 1 - 1
migrations_lockfile.txt

@@ -6,5 +6,5 @@ To resolve this, rebase against latest master and regenerate your migration. Thi
 will then be regenerated, and you should be able to merge without conflicts.
 
 nodestore: 0002_nodestore_no_dictfield
-sentry: 0511_pickle_to_json_sentry_rawevent
+sentry: 0512_add_proguard_release_association
 social_auth: 0001_initial

+ 81 - 1
src/sentry/api/endpoints/debug_files.py

@@ -1,12 +1,14 @@
 import logging
 import posixpath
 import re
+import uuid
 from typing import Sequence
 
 import jsonschema
-from django.db import router
+from django.db import IntegrityError, router
 from django.db.models import Q
 from django.http import Http404, HttpResponse, StreamingHttpResponse
+from rest_framework import status
 from rest_framework.request import Request
 from rest_framework.response import Response
 from symbolic.debuginfo import normalize_debug_id
@@ -31,6 +33,7 @@ from sentry.models import (
     ReleaseFile,
     create_files_from_dif_zip,
 )
+from sentry.models.debugfile import ProguardArtifactRelease
 from sentry.models.release import get_artifact_counts
 from sentry.tasks.assemble import (
     AssembleTask,
@@ -83,6 +86,83 @@ def has_download_permission(request, project):
     return roles.get(current_role).priority >= roles.get(required_role).priority
 
 
+@region_silo_endpoint
+class ProguardArtifactReleasesEndpoint(ProjectEndpoint):
+    permission_classes = (ProjectReleasePermission,)
+
+    def post(self, request: Request, project) -> Response:
+        release_name = request.data.get("release_name")
+        proguard_uuid = request.data.get("proguard_uuid")
+
+        missing_fields = []
+        if not release_name:
+            missing_fields.append("release_name")
+        if not proguard_uuid:
+            missing_fields.append("proguard_uuid")
+
+        if missing_fields:
+            error_message = f"Missing required fields: {', '.join(missing_fields)}"
+            return Response(data={"error": error_message}, status=status.HTTP_400_BAD_REQUEST)
+
+        try:
+            uuid.UUID(proguard_uuid)
+        except ValueError:
+            return Response(
+                data={"error": "Invalid proguard_uuid"}, status=status.HTTP_400_BAD_REQUEST
+            )
+
+        proguard_uuid = str(proguard_uuid)
+
+        difs = ProjectDebugFile.objects.find_by_debug_ids(project, [proguard_uuid])
+        if not difs:
+            return Response(
+                data={"error": "No matching proguard mapping file with this uuid found"},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        try:
+            ProguardArtifactRelease.objects.create(
+                organization_id=project.organization_id,
+                project_id=project.id,
+                release_name=release_name,
+                project_debug_file=difs[proguard_uuid],
+                proguard_uuid=proguard_uuid,
+            )
+            return Response(status=status.HTTP_201_CREATED)
+        except IntegrityError:
+            return Response(
+                data={
+                    "error": "Proguard artifact release with this name in this project already exists."
+                },
+                status=status.HTTP_409_CONFLICT,
+            )
+
+    def get(self, request: Request, project) -> Response:
+        """
+        List a Project's Proguard Associated Releases
+        ````````````````````````````````````````
+
+        Retrieve a list of associated releases for a given Proguard File.
+
+        :pparam string organization_slug: the slug of the organization the
+                                          file belongs to.
+        :pparam string project_slug: the slug of the project to list the
+                                     DIFs of.
+        :qparam string proguard_uuid: the uuid of the Proguard file.
+        :auth: required
+        """
+
+        proguard_uuid = request.GET.get("proguard_uuid")
+        releases = None
+        if proguard_uuid:
+            releases = ProguardArtifactRelease.objects.filter(
+                organization_id=project.organization_id,
+                project_id=project.id,
+                proguard_uuid=proguard_uuid,
+            ).values_list("release_name", flat=True)
+        return Response({"releases": releases})
+
+
 @region_silo_endpoint
 class DebugFilesEndpoint(ProjectEndpoint):
     permission_classes = (ProjectReleasePermission,)

+ 6 - 0
src/sentry/api/urls.py

@@ -144,6 +144,7 @@ from .endpoints.debug_files import (
     AssociateDSymFilesEndpoint,
     DebugFilesEndpoint,
     DifAssembleEndpoint,
+    ProguardArtifactReleasesEndpoint,
     SourceMapsEndpoint,
     UnknownDebugFilesEndpoint,
 )
@@ -1954,6 +1955,11 @@ PROJECT_URLS = [
         ArtifactBundlesEndpoint.as_view(),
         name="sentry-api-0-artifact-bundles",
     ),
+    re_path(
+        r"^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/files/proguard-artifact-releases",
+        ProguardArtifactReleasesEndpoint.as_view(),
+        name="sentry-api-0-proguard-artifact-releases",
+    ),
     re_path(
         r"^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/files/difs/assemble/$",
         DifAssembleEndpoint.as_view(),

+ 1 - 0
src/sentry/deletions/defaults/project.py

@@ -45,6 +45,7 @@ class ProjectDeletionTask(ModelDeletionTask):
             models.UserReport,
             models.ProjectTransactionThreshold,
             models.ProjectArtifactBundle,
+            models.ProguardArtifactRelease,
             DiscoverSavedQueryProject,
             IncidentProject,
             QuerySubscription,

+ 56 - 0
src/sentry/migrations/0512_add_proguard_release_association.py

@@ -0,0 +1,56 @@
+# Generated by Django 2.2.28 on 2023-07-13 11:11
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations, models
+
+import sentry.db.models.fields.bounded
+import sentry.db.models.fields.foreignkey
+from sentry.new_migrations.migrations import CheckedMigration
+
+
+class Migration(CheckedMigration):
+    # This flag is used to mark that a migration shouldn't be automatically run in production. For
+    # the most part, this should only be used for operations where it's safe to run the migration
+    # after your code has deployed. So this should not be used for most operations that alter the
+    # schema of a table.
+    # Here are some things that make sense to mark as dangerous:
+    # - Large data migrations. Typically we want these to be run manually by ops so that they can
+    #   be monitored and not block the deploy for a long period of time while they run.
+    # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
+    #   have ops run this and not block the deploy. Note that while adding an index is a schema
+    #   change, it's completely safe to run the operation after the code has deployed.
+    is_dangerous = False
+
+    dependencies = [
+        ("sentry", "0511_pickle_to_json_sentry_rawevent"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="ProguardArtifactRelease",
+            fields=[
+                (
+                    "id",
+                    sentry.db.models.fields.bounded.BoundedBigAutoField(
+                        primary_key=True, serialize=False
+                    ),
+                ),
+                ("organization_id", sentry.db.models.fields.bounded.BoundedBigIntegerField()),
+                ("project_id", sentry.db.models.fields.bounded.BoundedBigIntegerField()),
+                ("release_name", models.CharField(max_length=250)),
+                ("proguard_uuid", models.UUIDField(db_index=True)),
+                ("date_added", models.DateTimeField(default=django.utils.timezone.now)),
+                (
+                    "project_debug_file",
+                    sentry.db.models.fields.foreignkey.FlexibleForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to="sentry.ProjectDebugFile"
+                    ),
+                ),
+            ],
+            options={
+                "db_table": "sentry_proguardartifactrelease",
+                "unique_together": {("project_id", "release_name", "proguard_uuid")},
+            },
+        ),
+    ]

+ 18 - 0
src/sentry/models/debugfile.py

@@ -363,6 +363,24 @@ def _analyze_progard_filename(filename: str) -> Optional[str]:
         return None
 
 
+@region_silo_only_model
+class ProguardArtifactRelease(Model):
+    __include_in_export__ = False
+
+    organization_id = BoundedBigIntegerField()
+    project_id = BoundedBigIntegerField()
+    release_name = models.CharField(max_length=250)
+    proguard_uuid = models.UUIDField(db_index=True)
+    project_debug_file = FlexibleForeignKey("sentry.ProjectDebugFile")
+    date_added = models.DateTimeField(default=timezone.now)
+
+    class Meta:
+        app_label = "sentry"
+        db_table = "sentry_proguardartifactrelease"
+
+        unique_together = (("project_id", "release_name", "proguard_uuid"),)
+
+
 class DifMeta:
     def __init__(
         self,

+ 235 - 0
tests/sentry/api/endpoints/test_project_proguard_artifact_releases.py

@@ -0,0 +1,235 @@
+from typing import Dict
+
+from django.urls import reverse
+
+from sentry.models.debugfile import ProguardArtifactRelease, ProjectDebugFile
+from sentry.models.files.file import File
+from sentry.testutils import APITestCase
+from sentry.testutils.silo import region_silo_test
+
+
+@region_silo_test(stable=True)
+class ProguardArtifactReleasesEndpointTest(APITestCase):
+    def test_create_proguard_artifact_release_successfully(self):
+        project = self.create_project(name="foo")
+
+        proguard_uuid = "660f839b-8bfd-580d-9a7c-ea339a6c9867"
+
+        url = reverse(
+            "sentry-api-0-proguard-artifact-releases",
+            kwargs={
+                "organization_slug": project.organization.slug,
+                "project_slug": project.slug,
+            },
+        )
+
+        data = {
+            "release_name": "test@1.0.0",
+            "proguard_uuid": proguard_uuid,
+        }
+
+        file = File.objects.create(
+            name="proguard.txt", type="default", headers={"Content-Type": "text/plain"}
+        )
+
+        ProjectDebugFile.objects.create(
+            file=file,
+            object_name="proguard.txt",
+            cpu_name="x86",
+            project_id=project.id,
+            debug_id=proguard_uuid,
+        )
+
+        self.login_as(user=self.user)
+
+        response = self.client.post(url, data=data, format="json")
+        assert response.status_code == 201, response.content
+        assert ProguardArtifactRelease.objects.count() == 1
+
+        proguard_artifact_release = ProguardArtifactRelease.objects.first()
+        assert proguard_artifact_release.organization_id == project.organization.id
+        assert proguard_artifact_release.project_id == project.id
+
+    def test_create_proguard_artifact_release_with_missing_fields(self):
+        project = self.create_project(name="foo")
+
+        url = reverse(
+            "sentry-api-0-proguard-artifact-releases",
+            kwargs={
+                "organization_slug": project.organization.slug,
+                "project_slug": project.slug,
+            },
+        )
+
+        data_missing_uuid = {
+            "release_name": "test@1.0.0",
+        }
+        data_missing_release_name = {
+            "proguard_uuid": "660f839b-8bfd-580d-9a7c-ea339a6c9867",
+        }
+        data_missing_all: Dict[str, str] = {}
+
+        self.login_as(user=self.user)
+
+        response = self.client.post(url, data=data_missing_uuid, format="json")
+        assert response.status_code == 400, response.content
+        assert response.data == {"error": "Missing required fields: proguard_uuid"}
+
+        response = self.client.post(url, data=data_missing_release_name, format="json")
+        assert response.status_code == 400, response.content
+        assert response.data == {"error": "Missing required fields: release_name"}
+
+        response = self.client.post(url, data=data_missing_all, format="json")
+        assert response.status_code == 400, response.content
+        assert response.data == {"error": "Missing required fields: release_name, proguard_uuid"}
+
+    def test_create_proguard_artifact_release_with_conflicting_release_name(self):
+        project = self.create_project(name="foo")
+
+        url = reverse(
+            "sentry-api-0-proguard-artifact-releases",
+            kwargs={
+                "organization_slug": project.organization.slug,
+                "project_slug": project.slug,
+            },
+        )
+
+        data = {
+            "release_name": "test@1.0.0",
+            "proguard_uuid": "660f839b-8bfd-580d-9a7c-ea339a6c9867",
+        }
+
+        file = File.objects.create(
+            name="proguard.txt", type="default", headers={"Content-Type": "text/plain"}
+        )
+
+        project_debug_file = ProjectDebugFile.objects.create(
+            file=file,
+            object_name="proguard.txt",
+            cpu_name="x86",
+            project_id=project.id,
+            debug_id="660f839b-8bfd-580d-9a7c-ea339a6c9867",
+        )
+
+        ProguardArtifactRelease.objects.create(
+            organization_id=project.organization_id,
+            project_id=project.id,
+            release_name=data["release_name"],
+            proguard_uuid=data["proguard_uuid"],
+            project_debug_file=project_debug_file,
+        )
+
+        self.login_as(user=self.user)
+        response = self.client.post(url, data=data)
+
+        assert response.status_code == 409, response.content
+        assert response.data == {
+            "error": "Proguard artifact release with this name in this project already exists."
+        }
+
+    def test_list_proguard_artifact_releases_with_uuid_successfully(self):
+        project = self.create_project(name="foo")
+        proguard_uuid = "660f839b-8bfd-580d-9a7c-ea339a6c9867"
+
+        file = File.objects.create(
+            name="proguard.txt", type="default", headers={"Content-Type": "text/plain"}
+        )
+
+        project_debug_file = ProjectDebugFile.objects.create(
+            file=file,
+            object_name="proguard.txt",
+            cpu_name="x86",
+            project_id=project.id,
+            debug_id=proguard_uuid,
+        )
+
+        ProguardArtifactRelease.objects.create(
+            organization_id=project.organization_id,
+            project_id=project.id,
+            release_name="test@1.0.0",
+            proguard_uuid=proguard_uuid,
+            project_debug_file=project_debug_file,
+        )
+
+        url = reverse(
+            "sentry-api-0-proguard-artifact-releases",
+            kwargs={
+                "organization_slug": project.organization.slug,
+                "project_slug": project.slug,
+            },
+        )
+
+        self.login_as(user=self.user)
+        response = self.client.get(url, {"proguard_uuid": proguard_uuid})
+
+        assert response.status_code == 200, response.content
+        assert len(response.data) == 1
+        assert list(response.data["releases"]) == ["test@1.0.0"]
+
+    def test_create_proguard_artifact_release_with_non_existent_uuid(self):
+        project = self.create_project(name="foo")
+
+        url = reverse(
+            "sentry-api-0-proguard-artifact-releases",
+            kwargs={
+                "organization_slug": project.organization.slug,
+                "project_slug": project.slug,
+            },
+        )
+
+        data = {
+            "release_name": "test@1.0.0",
+            "proguard_uuid": "660f839b-8bfd-580d-9a7c-ea339a6ccccc",
+        }
+
+        file = File.objects.create(
+            name="proguard.txt", type="default", headers={"Content-Type": "text/plain"}
+        )
+
+        ProjectDebugFile.objects.create(
+            file=file,
+            object_name="proguard.txt",
+            cpu_name="x86",
+            project_id=project.id,
+            debug_id="660f839b-8bfd-580d-9a7c-ea339a6cbbbb",
+        )
+
+        self.login_as(user=self.user)
+        response = self.client.post(url, data=data)
+
+        assert response.status_code == 400, response.content
+        assert response.data == {"error": "No matching proguard mapping file with this uuid found"}
+
+    def test_create_proguard_artifact_release_with_invalid_uuid(self):
+        project = self.create_project(name="foo")
+
+        url = reverse(
+            "sentry-api-0-proguard-artifact-releases",
+            kwargs={
+                "organization_slug": project.organization.slug,
+                "project_slug": project.slug,
+            },
+        )
+
+        data = {
+            "release_name": "test@1.0.0",
+            "proguard_uuid": "invalid-uuid",
+        }
+
+        file = File.objects.create(
+            name="proguard.txt", type="default", headers={"Content-Type": "text/plain"}
+        )
+
+        ProjectDebugFile.objects.create(
+            file=file,
+            object_name="proguard.txt",
+            cpu_name="x86",
+            project_id=project.id,
+            debug_id="660f839b-8bfd-580d-9a7c-ea339a6cbbbb",
+        )
+
+        self.login_as(user=self.user)
+        response = self.client.post(url, data=data)
+
+        assert response.status_code == 400, response.content
+        assert response.data == {"error": "Invalid proguard_uuid"}