diff --git a/src/sentry/preprod/api/endpoints/project_preprod_artifact_update.py b/src/sentry/preprod/api/endpoints/project_preprod_artifact_update.py index 1fda7dde0c79..f67c1c99a809 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_artifact_update.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_artifact_update.py @@ -439,11 +439,11 @@ def put( distro_error_code = PreprodArtifact.InstallableAppErrorCode.NO_QUOTA distro_error_message = "Distribution quota exceeded" elif distro_skip_reason == "disabled": - distro_error_code = PreprodArtifact.InstallableAppErrorCode.SKIPPED + distro_error_code = PreprodArtifact.InstallableAppErrorCode.DISTRIBUTION_DISABLED distro_error_message = "Distribution disabled for this project" else: - distro_error_code = PreprodArtifact.InstallableAppErrorCode.SKIPPED - distro_error_message = "Distribution filtered out by project settings" + distro_error_code = PreprodArtifact.InstallableAppErrorCode.DISTRIBUTION_FILTERED + distro_error_message = "Build filtered out by project settings" head_artifact.installable_app_error_code = distro_error_code head_artifact.installable_app_error_message = distro_error_message diff --git a/src/sentry/preprod/api/endpoints/project_preprod_distribution.py b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py index 3ae3c1882165..33f89694ae27 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_distribution.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py @@ -3,7 +3,7 @@ import logging from typing import Any, cast -from pydantic import BaseModel, Field +from pydantic import BaseModel from rest_framework.request import Request from rest_framework.response import Response @@ -24,7 +24,7 @@ class PutDistribution(BaseModel): - error_code: int = Field(ge=0, le=3) + error_code: PreprodArtifact.InstallableAppErrorCode error_message: str diff --git a/src/sentry/preprod/models.py b/src/sentry/preprod/models.py index 9f172db9d9dd..28ceea73dcc8 100644 --- a/src/sentry/preprod/models.py +++ b/src/sentry/preprod/models.py @@ -159,9 +159,19 @@ class InstallableAppErrorCode(IntEnum): NO_QUOTA = 1 """No quota available for distribution.""" SKIPPED = 2 - """Distribution was not requested on this build.""" + """Generic skip; a more specific code should be used when available.""" PROCESSING_ERROR = 3 - """Distribution failed due to a processing error.""" + """Generic distribution failure; a more specific code should be used when available.""" + DISTRIBUTION_DISABLED = 4 + """Build distribution is disabled in the project's settings.""" + DISTRIBUTION_FILTERED = 5 + """Build does not match the project's distribution filter rules.""" + INVALID_CODE_SIGNATURE = 6 + """The build's code signature could not be verified.""" + SIMULATOR_BUILD = 7 + """Simulator builds cannot be distributed.""" + UNSUPPORTED_ARTIFACT_TYPE = 8 + """The artifact type is not supported for distribution.""" @classmethod def as_choices(cls) -> tuple[tuple[int, str], ...]: @@ -170,6 +180,11 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: (cls.NO_QUOTA, "no_quota"), (cls.SKIPPED, "skipped"), (cls.PROCESSING_ERROR, "processing_error"), + (cls.DISTRIBUTION_DISABLED, "distribution_disabled"), + (cls.DISTRIBUTION_FILTERED, "distribution_filtered"), + (cls.INVALID_CODE_SIGNATURE, "invalid_code_signature"), + (cls.SIMULATOR_BUILD, "simulator_build"), + (cls.UNSUPPORTED_ARTIFACT_TYPE, "unsupported_artifact_type"), ) __relocation_scope__ = RelocationScope.Excluded diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py index f4db03426398..331f3c5c8e28 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py @@ -651,7 +651,7 @@ def test_update_sets_error_code_no_quota(self, mock_has_installable_quota) -> No assert self.preprod_artifact.installable_app_error_message == "Distribution quota exceeded" @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) - def test_update_sets_error_code_skipped_when_filtered(self) -> None: + def test_update_sets_error_code_distribution_filtered(self) -> None: self.preprod_artifact.app_id = "com.my.app" self.preprod_artifact.save() @@ -664,11 +664,31 @@ def test_update_sets_error_code_skipped_when_filtered(self) -> None: self.preprod_artifact.refresh_from_db() assert ( self.preprod_artifact.installable_app_error_code - == PreprodArtifact.InstallableAppErrorCode.SKIPPED + == PreprodArtifact.InstallableAppErrorCode.DISTRIBUTION_FILTERED ) assert ( self.preprod_artifact.installable_app_error_message - == "Distribution filtered out by project settings" + == "Build filtered out by project settings" + ) + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) + def test_update_sets_error_code_distribution_disabled(self) -> None: + from sentry.preprod.quotas import DISTRIBUTION_ENABLED_KEY + + self.project.update_option(DISTRIBUTION_ENABLED_KEY, False) + + data = {"artifact_type": 1} + response = self._make_request(data) + + assert response.status_code == 200 + self.preprod_artifact.refresh_from_db() + assert ( + self.preprod_artifact.installable_app_error_code + == PreprodArtifact.InstallableAppErrorCode.DISTRIBUTION_DISABLED + ) + assert ( + self.preprod_artifact.installable_app_error_message + == "Distribution disabled for this project" ) diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py index a5bd25de0184..c09ca9b98fbf 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py @@ -61,10 +61,8 @@ def test_bad_json(self, mock_send_webhook) -> None: @patch( "sentry.preprod.api.endpoints.project_preprod_distribution.send_build_distribution_webhook" ) - def test_set_error(self, mock_send_webhook) -> None: - response = self._put( - orjson.dumps({"error_code": 3, "error_message": "Unsupported artifact type"}) - ) + def test_accepts_generic_processing_error(self, mock_send_webhook) -> None: + response = self._put(orjson.dumps({"error_code": 3, "error_message": "some novel failure"})) assert response.status_code == 200 self.artifact.refresh_from_db() @@ -72,13 +70,55 @@ def test_set_error(self, mock_send_webhook) -> None: self.artifact.installable_app_error_code == PreprodArtifact.InstallableAppErrorCode.PROCESSING_ERROR ) - assert self.artifact.installable_app_error_message == "Unsupported artifact type" + assert self.artifact.installable_app_error_message == "some novel failure" - # Verify webhook was sent mock_send_webhook.assert_called_once() call_kwargs = mock_send_webhook.call_args assert call_kwargs.kwargs["organization_id"] == self.project.organization_id + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) + @patch( + "sentry.preprod.api.endpoints.project_preprod_distribution.send_build_distribution_webhook" + ) + def test_legacy_payload_stored_verbatim(self, mock_send_webhook) -> None: + # Launchpad deployments that still send the legacy shape + # (error_code=SKIPPED + error_message="invalid_signature", etc.) + # should continue to write unchanged until launchpad emits the + # new granular codes directly. + response = self._put(orjson.dumps({"error_code": 2, "error_message": "invalid_signature"})) + + assert response.status_code == 200 + self.artifact.refresh_from_db() + assert ( + self.artifact.installable_app_error_code + == PreprodArtifact.InstallableAppErrorCode.SKIPPED + ) + assert self.artifact.installable_app_error_message == "invalid_signature" + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) + @patch( + "sentry.preprod.api.endpoints.project_preprod_distribution.send_build_distribution_webhook" + ) + def test_accepts_new_granular_code(self, mock_send_webhook) -> None: + response = self._put( + orjson.dumps( + { + "error_code": int( + PreprodArtifact.InstallableAppErrorCode.INVALID_CODE_SIGNATURE + ), + "error_message": "", + } + ) + ) + + assert response.status_code == 200 + self.artifact.refresh_from_db() + assert ( + self.artifact.installable_app_error_code + == PreprodArtifact.InstallableAppErrorCode.INVALID_CODE_SIGNATURE + ) + assert self.artifact.installable_app_error_message == "" + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) def test_invalid_error_code(self) -> None: response = self._put(orjson.dumps({"error_code": 99, "error_message": "bad"}))