Browse Source

feat(difs): download dSYMs from app store connect task (#26683)

This adds a new task which can download dSYMs from Apple App Store
Connect and make them available in the project's debug information
files.

The task is invoked when the credentials are created or updated
(refreshed).  It is currently one big task that will run for a long time.
It also does not yet have concurrency control (the iTunes session
should only be used by one task at a time).  All of these things
will probably need to improve before letting customers use this.

Because we need the config stored in the database before the task
is invoked the create and update endpoints now also store the
config.  This however means that the UI will still store it as well,
resulting in the same config being saved twice.  This is relatively
harmless but does result in duplicate audit entries.
Floris Bruynooghe 3 years ago
parent
commit
5707b829f1

+ 2 - 0
mypy.ini

@@ -22,9 +22,11 @@ files = src/sentry/api/bases/external_actor.py,
         src/sentry/integrations/slack/requests/*.py,
         src/sentry/integrations/slack/util/*.py,
         src/sentry/killswitches.py,
+        src/sentry/lang/native/appconnect.py,
         src/sentry/notifications/**/*.py,
         src/sentry/snuba/outcomes.py,
         src/sentry/snuba/query_subscription_consumer.py,
+        src/sentry/tasks/app_store_connect.py,
         src/sentry/utils/appleconnect/,
         src/sentry/utils/avatar.py,
         src/sentry/utils/codecs.py,

+ 61 - 71
src/sentry/api/endpoints/project_app_store_connect_credentials.py

@@ -50,11 +50,9 @@ To create and manage these credentials, several API endpoints exist:
    initiated by steps 2-4.  See :class:`AppStoreConnectCredentialsValidateEndpoint`.
 """
 import datetime
-from typing import Optional
+import logging
 from uuid import uuid4
 
-import dateutil.parser
-import jsonschema
 import requests
 from rest_framework import serializers
 from rest_framework.request import Request
@@ -67,23 +65,14 @@ from sentry.api.exceptions import (
     ItunesAuthenticationError,
     ItunesTwoFactorAuthenticationRequired,
 )
-from sentry.lang.native.symbolicator import APP_STORE_CONNECT_SCHEMA
-from sentry.models import Project
-from sentry.utils import json
+from sentry.lang.native import appconnect
+from sentry.models import AuditLogEntryEvent, Project
+from sentry.tasks.app_store_connect import dsym_download
 from sentry.utils.appleconnect import appstore_connect, itunes_connect
 from sentry.utils.appleconnect.itunes_connect import ITunesHeaders
 from sentry.utils.safe import get_path
 
-# The property name of the project option which contains the encryption key.
-#
-# This key is what is used to encrypt the secrets before storing them in the project
-# options.  Specifically the ``sessionContext``, ``itunesPassword`` and
-# ``appconnectPrivateKey`` are currently encrypted using this key.
-CREDENTIALS_KEY_NAME = "sentry:appleconnect_key"
-
-
-# The key in the project options under which all symbol sources are stored.
-SYMBOL_SOURCES_PROP_NAME = "sentry:symbol_sources"
+logger = logging.getLogger(__name__)
 
 
 # The name of the feature flag which enables the App Store Connect symbol source.
@@ -93,27 +82,6 @@ APP_STORE_CONNECT_FEATURE_NAME = "organizations:app-store-connect"
 ITUNES_TOKEN_VALIDITY = datetime.timedelta(weeks=1)
 
 
-def get_app_store_config(
-    project: Project, credentials_id: Optional[str]
-) -> Optional[json.JSONData]:
-    """Returns the appStoreConnect symbol source config for a project."""
-    sources_config = project.get_option(SYMBOL_SOURCES_PROP_NAME)
-
-    if credentials_id is None:
-        return None
-    try:
-        sources = json.loads(sources_config)
-        for source in sources:
-            if (
-                source.get("type") == "appStoreConnect"
-                and source.get("id") == credentials_id.lower()
-            ):
-                return source
-        return None
-    except BaseException as e:
-        raise ValueError("bad sources") from e
-
-
 class AppStoreConnectCredentialsSerializer(serializers.Serializer):  # type: ignore
     """Input validation for :class:`AppStoreConnectAppsEndpoint."""
 
@@ -271,11 +239,22 @@ class AppStoreConnectCreateCredentialsEndpoint(ProjectEndpoint):  # type: ignore
         config["itunesSession"] = session_context.get("itunes_session")
         config["itunesPersonId"] = session_context.get("itunes_person_id")
 
-        # We need to have a serialised datetime, but django-rest-framework de-serialised it
-        # into the class.  So, serialize it back.
-        clone = config.copy()
-        clone["itunesCreated"] = config["itunesCreated"].isoformat()
-        jsonschema.validate(clone, APP_STORE_CONNECT_SCHEMA)
+        validated_config = appconnect.AppStoreConnectConfig.from_json(config)
+        new_sources = validated_config.update_project_symbol_source(project)
+        self.create_audit_entry(
+            request=request,
+            organization=project.organization,
+            target_object=project.id,
+            event=AuditLogEntryEvent.PROJECT_EDIT,
+            data={appconnect.SYMBOL_SOURCES_PROP_NAME: new_sources},
+        )
+
+        dsym_download.apply_async(
+            kwargs={
+                "project_id": project.id,
+                "config_id": validated_config.id,
+            }
+        )
 
         return Response(config, status=200)
 
@@ -335,8 +314,11 @@ class AppStoreConnectUpdateCredentialsEndpoint(ProjectEndpoint):  # type: ignore
             return Response(serializer.errors, status=400)
 
         # get the existing credentials
-        symbol_source_config = get_app_store_config(project, credentials_id)
-        if symbol_source_config is None:
+        try:
+            symbol_source_config = appconnect.AppStoreConnectConfig.from_project_config(
+                project, credentials_id
+            )
+        except KeyError:
             return Response(status=404)
 
         # get the new credentials
@@ -348,18 +330,26 @@ class AppStoreConnectUpdateCredentialsEndpoint(ProjectEndpoint):  # type: ignore
             data["itunesSession"] = session_context.get("itunes_session")
             data["itunesPersonId"] = session_context.get("itunes_person_id")
 
-        symbol_source_config.update(data)
+        new_data = symbol_source_config.to_json()
+        new_data.update(data)
+        symbol_source_config = appconnect.AppStoreConnectConfig.from_json(new_data)
+        new_sources = symbol_source_config.update_project_symbol_source(project)
+        self.create_audit_entry(
+            request=request,
+            organization=project.organization,
+            target_object=project.id,
+            event=AuditLogEntryEvent.PROJECT_EDIT,
+            data={appconnect.SYMBOL_SOURCES_PROP_NAME: new_sources},
+        )
 
-        # We need to have a serialised datetime, but django-rest-framework de-serialised it
-        # into the class.  So, serialize it back.
-        if isinstance(symbol_source_config["itunesCreated"], datetime.datetime):
-            clone = symbol_source_config.copy()
-            clone["itunesCreated"] = symbol_source_config["itunesCreated"].isoformat()
-        else:
-            clone = data
-        jsonschema.validate(clone, APP_STORE_CONNECT_SCHEMA)
+        dsym_download.apply_async(
+            kwargs={
+                "project_id": project.id,
+                "config_id": symbol_source_config.id,
+            }
+        )
 
-        return Response(symbol_source_config, status=200)
+        return Response(symbol_source_config.to_json(), status=200)
 
 
 class AppStoreConnectCredentialsValidateEndpoint(ProjectEndpoint):  # type: ignore
@@ -390,28 +380,25 @@ class AppStoreConnectCredentialsValidateEndpoint(ProjectEndpoint):  # type: igno
         ):
             return Response(status=404)
 
-        symbol_source_cfg = get_app_store_config(project, credentials_id)
-        if symbol_source_cfg is None:
+        try:
+            symbol_source_cfg = appconnect.AppStoreConnectConfig.from_project_config(
+                project, credentials_id
+            )
+        except KeyError:
             return Response(status=404)
 
-        if symbol_source_cfg.get("itunesCreated") is not None:
-            expiration_date: Optional[datetime.datetime] = (
-                dateutil.parser.isoparse(symbol_source_cfg.get("itunesCreated"))
-                + ITUNES_TOKEN_VALIDITY
-            )
-        else:
-            expiration_date = None
+        expiration_date = symbol_source_cfg.itunesCreated + ITUNES_TOKEN_VALIDITY
 
         credentials = appstore_connect.AppConnectCredentials(
-            key_id=symbol_source_cfg.get("appconnectKey"),
-            key=symbol_source_cfg.get("appconnectPrivateKey"),
-            issuer_id=symbol_source_cfg.get("appconnectIssuer"),
+            key_id=symbol_source_cfg.appconnectKey,
+            key=symbol_source_cfg.appconnectPrivateKey,
+            issuer_id=symbol_source_cfg.appconnectIssuer,
         )
 
         session = requests.Session()
         apps = appstore_connect.get_apps(session, credentials)
 
-        itunes_connect.load_session_cookie(session, symbol_source_cfg.get("itunesSession"))
+        itunes_connect.load_session_cookie(session, symbol_source_cfg.itunesSession)
         itunes_session_info = itunes_connect.get_session_info(session)
 
         return Response(
@@ -486,12 +473,15 @@ class AppStoreConnectStartAuthEndpoint(ProjectEndpoint):  # type: ignore
         credentials_id = serializer.validated_data.get("id")
 
         if credentials_id is not None:
-            symbol_source_config = get_app_store_config(project, credentials_id)
-            if symbol_source_config is None:
+            try:
+                symbol_source_config = appconnect.AppStoreConnectConfig.from_project_config(
+                    project, credentials_id
+                )
+            except KeyError:
                 return Response("No credentials found.", status=400)
 
-            user_name = symbol_source_config.get("itunesUser")
-            password = symbol_source_config.get("itunesPassword")
+            user_name = symbol_source_config.itunesUser
+            password = symbol_source_config.itunesPassword
 
         if user_name is None:
             return Response("No user name provided.", status=400)

+ 12 - 9
src/sentry/conf/server.py

@@ -534,10 +534,11 @@ CELERY_IMPORTS = (
     "sentry.discover.tasks",
     "sentry.incidents.tasks",
     "sentry.snuba.tasks",
+    "sentry.tasks.app_store_connect",
     "sentry.tasks.assemble",
     "sentry.tasks.auth",
-    "sentry.tasks.auto_resolve_issues",
     "sentry.tasks.auto_remove_inbox",
+    "sentry.tasks.auto_resolve_issues",
     "sentry.tasks.beacon",
     "sentry.tasks.check_auth",
     "sentry.tasks.check_monitors",
@@ -557,6 +558,8 @@ CELERY_IMPORTS = (
     "sentry.tasks.ping",
     "sentry.tasks.post_process",
     "sentry.tasks.process_buffer",
+    "sentry.tasks.relay",
+    "sentry.tasks.release_registry",
     "sentry.tasks.reports",
     "sentry.tasks.reprocessing",
     "sentry.tasks.scheduler",
@@ -566,35 +569,34 @@ CELERY_IMPORTS = (
     "sentry.tasks.store",
     "sentry.tasks.unmerge",
     "sentry.tasks.update_user_reports",
-    "sentry.tasks.relay",
-    "sentry.tasks.release_registry",
 )
 CELERY_QUEUES = [
     Queue("activity.notify", routing_key="activity.notify"),
     Queue("alerts", routing_key="alerts"),
     Queue("app_platform", routing_key="app_platform"),
-    Queue("auth", routing_key="auth"),
+    Queue("appstoreconnect", routing_key="sentry.tasks.app_store_connect.#"),
     Queue("assemble", routing_key="assemble"),
+    Queue("auth", routing_key="auth"),
     Queue("buffers.process_pending", routing_key="buffers.process_pending"),
-    Queue("commits", routing_key="commits"),
     Queue("cleanup", routing_key="cleanup"),
+    Queue("commits", routing_key="commits"),
     Queue("data_export", routing_key="data_export"),
     Queue("default", routing_key="default"),
     Queue("digests.delivery", routing_key="digests.delivery"),
     Queue("digests.scheduling", routing_key="digests.scheduling"),
     Queue("email", routing_key="email"),
     Queue("events.preprocess_event", routing_key="events.preprocess_event"),
+    Queue("events.process_event", routing_key="events.process_event"),
+    Queue("events.reprocess_events", routing_key="events.reprocess_events"),
     Queue(
         "events.reprocessing.preprocess_event", routing_key="events.reprocessing.preprocess_event"
     ),
-    Queue("events.symbolicate_event", routing_key="events.symbolicate_event"),
+    Queue("events.reprocessing.process_event", routing_key="events.reprocessing.process_event"),
     Queue(
         "events.reprocessing.symbolicate_event", routing_key="events.reprocessing.symbolicate_event"
     ),
-    Queue("events.process_event", routing_key="events.process_event"),
-    Queue("events.reprocessing.process_event", routing_key="events.reprocessing.process_event"),
-    Queue("events.reprocess_events", routing_key="events.reprocess_events"),
     Queue("events.save_event", routing_key="events.save_event"),
+    Queue("events.symbolicate_event", routing_key="events.symbolicate_event"),
     Queue("files.delete", routing_key="files.delete"),
     Queue(
         "group_owners.process_suspect_commits", routing_key="group_owners.process_suspect_commits"
@@ -605,6 +607,7 @@ CELERY_QUEUES = [
     ),
     Queue("incidents", routing_key="incidents"),
     Queue("incident_snapshots", routing_key="incident_snapshots"),
+    Queue("incidents", routing_key="incidents"),
     Queue("integrations", routing_key="integrations"),
     Queue("merge", routing_key="merge"),
     Queue("options", routing_key="options"),

+ 386 - 0
src/sentry/lang/native/appconnect.py

@@ -0,0 +1,386 @@
+"""Integration of native symbolication with Apple App Store Connect.
+
+Sentry can download dSYMs directly from App Store Connect, this is the support code for
+this.
+"""
+
+import dataclasses
+import enum
+import io
+import logging
+import pathlib
+from datetime import datetime
+from typing import Any, Dict, List
+
+import dateutil
+import jsonschema
+import requests
+from django.db import transaction
+
+from sentry.lang.native.symbolicator import APP_STORE_CONNECT_SCHEMA
+from sentry.models import Project
+from sentry.utils import json
+from sentry.utils.appleconnect import appstore_connect, itunes_connect
+
+logger = logging.getLogger(__name__)
+
+
+# The key in the project options under which all symbol sources are stored.
+SYMBOL_SOURCES_PROP_NAME = "sentry:symbol_sources"
+
+
+# The symbol source type for an App Store Connect symbol source.
+SYMBOL_SOURCE_TYPE_NAME = "appStoreConnect"
+
+
+class InvalidCredentialsError(Exception):
+    """Invalid credentials for the App Store Connect API."""
+
+    pass
+
+
+class InvalidConfigError(Exception):
+    """Invalid configuration for the appStoreConnect symbol source."""
+
+    pass
+
+
+class NoDsymsError(Exception):
+    """No dSYMs were found."""
+
+    pass
+
+
+@dataclasses.dataclass(frozen=True)
+class AppStoreConnectConfig:
+    """The symbol source configuration for an App Store Connect source.
+
+    This is stored as a symbol source inside symbolSources project option.
+    """
+
+    # The type of symbol source, can only be `appStoreConnect`.
+    type: str
+
+    # The ID which identifies this symbol source for this project.
+    #
+    # Currently we only allow one appStoreConnect source per project, but we already
+    # identify them using an ID anyway for future safety.
+    id: str
+
+    # The name of the symbol source.
+    #
+    # Currently users can not chose this name, but it has a name anyway.
+    name: str
+
+    # Issuer ID for the API credentials.
+    appconnectIssuer: str
+
+    # Key ID for the API credentials.
+    appconnectKey: str
+
+    # Private key for the API credentials.
+    appconnectPrivateKey: str
+
+    # Username for the iTunes credentials.
+    itunesUser: str
+
+    # Password for the iTunes credentials.
+    itunesPassword: str
+
+    # Person ID of the iTunes user.
+    #
+    # This is an internal field that some iTunes calls need, but it is also relatively
+    # easily to retrieve via an HTTP request to iTunes.
+    itunesPersonId: str
+
+    # The iTuness session cookie.
+    #
+    # Loading this cookie into ``requests.Session`` (see
+    # ``sentry.utils.appleconnect.itunes_connect.load_session_cookie``) will allow this
+    # session to make API iTunes requests as the user.
+    itunesSession: str
+
+    # The time the ``itunesSession`` cookie was created.
+    #
+    # The cookie only has a valid session for a limited time and needs user-interaction to
+    # create it.  So we keep track of when it was created.
+    itunesCreated: datetime
+
+    # The name of the application, as supplied by the App Store Connect API.
+    appName: str
+
+    # The ID of the application in the App Store Connect API.
+    #
+    # We presume this is stable until proven otherwise.
+    appId: str
+
+    # The bundleID, e.g. io.sentry.sample.iOS-Swift.
+    #
+    # This is guaranteed to be unique and should map 1:1 to ``appId``.
+    bundleId: str
+
+    # The organisation ID according to iTunes.
+    #
+    # An iTunes session can have multiple organisations and needs this ID to be able to
+    # select the correct organisation to operate on.
+    orgId: int
+
+    # The name of an organisation, as supplied by iTunes.
+    orgName: str
+
+    def __post_init__(self) -> None:
+        # All fields are required.
+        for field in dataclasses.fields(self):
+            if not getattr(self, field.name, None):
+                raise ValueError(f"Missing field: {field.name}")
+
+    @classmethod
+    def from_json(cls, data: Dict[str, Any]) -> "AppStoreConnectConfig":
+        """Creates a new instance from **deserialised** JSON data.
+
+        This will include the JSON schema validation.  It accepts both a str or a datetime
+        for the ``itunesCreated``.  Thus you can safely use this to create and validate the
+        config as desrialised by both plain JSON deserialiser or by Django Rest Framework's
+        deserialiser.
+
+        :raises InvalidConfigError: if the data does not contain a valid App Store Connect
+           symbol source configuration.
+        """
+        if isinstance(data["itunesCreated"], datetime):
+            data["itunesCreated"] = data["itunesCreated"].isoformat()
+        try:
+            jsonschema.validate(data, APP_STORE_CONNECT_SCHEMA)
+        except jsonschema.exceptions.ValidationError as e:
+            raise InvalidConfigError from e
+        data["itunesCreated"] = dateutil.parser.isoparse(data["itunesCreated"])
+        return cls(**data)
+
+    @classmethod
+    def from_project_config(cls, project: Project, config_id: str) -> "AppStoreConnectConfig":
+        """Creates a new instance from the symbol source configured in the project.
+
+        :raises KeyError: if the config is not found.
+        :raises InvalidConfigError if the stored config is somehow invalid.
+        """
+        raw = project.get_option(SYMBOL_SOURCES_PROP_NAME, default="[]")
+        all_sources = json.loads(raw)
+        for source in all_sources:
+            if source.get("type") == SYMBOL_SOURCE_TYPE_NAME and source.get("id") == config_id:
+                return cls.from_json(source)
+        else:
+            raise KeyError(f"No {SYMBOL_SOURCE_TYPE_NAME} symbol source found with id {config_id}")
+
+    def to_json(self) -> Dict[str, Any]:
+        """Creates a dict which can be serialised to JSON.
+
+        The generated dict will validate according to the schema.
+
+        :raises InvalidConfigError: if somehow the data in the class is not valid, this
+           should only occur if the class was created in a weird way.
+        """
+        data = dict()
+        for field in dataclasses.fields(self):
+            value = getattr(self, field.name)
+            if field.name == "itunesCreated":
+                value = value.isoformat()
+            data[field.name] = value
+        try:
+            jsonschema.validate(data, APP_STORE_CONNECT_SCHEMA)
+        except jsonschema.exceptions.ValidationError as e:
+            raise InvalidConfigError from e
+        return data
+
+    def update_project_symbol_source(self, project: Project) -> json.JSONData:
+        """Updates this configuration in the Project's symbol sources.
+
+        If a symbol source of type ``appStoreConnect`` already exists the ID must match and it
+        will be updated.  If not ``appStoreConnect`` source exists yet it is added.
+
+        :returns: The new value of the sources.  Use this in a call to
+           `ProjectEndpoint.create_audit_entry()` to create an audit log.
+
+        :raises ValueError: if an ``appStoreConnect`` source already exists but the ID does not
+           match.
+        """
+        with transaction.atomic():
+            all_sources_raw = project.get_option(SYMBOL_SOURCES_PROP_NAME, default="[]")
+            all_sources = json.loads(all_sources_raw)
+            for i, source in enumerate(all_sources):
+                if source.get("type") == SYMBOL_SOURCE_TYPE_NAME:
+                    if source.get("id") != self.id:
+                        raise ValueError(
+                            "Existing appStoreConnect symbolSource config does not match id"
+                        )
+                    all_sources[i] = self.to_json()
+                    break
+            else:
+                # No existing appStoreConnect symbol source, simply append it.
+                all_sources.append(self.to_json())
+            project.update_option(SYMBOL_SOURCES_PROP_NAME, json.dumps(all_sources))
+        return all_sources
+
+
+@enum.unique
+class BuildKind(enum.Enum):
+    ALL = 1
+    PRE_RELEASE = 2
+    RELEASE = 3
+
+
+@dataclasses.dataclass(frozen=True)
+class BuildInfo:
+    """Information about an App Store Connect build.
+
+    A build is identified by the tuple of (app_id, platform, version, build_number), though
+    Apple mostly names these differently.
+    """
+
+    # The kind of build, either PRE_RELEASE or RELEASE
+    kind: BuildKind
+
+    # The app ID
+    app_id: str
+
+    # A platform identifying e.g. iOS, TvOS etc.
+    #
+    # These are not always human readable but some opaque string supplied by apple.
+    platform: str
+
+    # The human-readable version, e.g. "7.2.0".
+    #
+    # Each version can have multiple builds, Apple naming is a little confusing and calls
+    # this "bundle_short_version".
+    version: str
+
+    # The build number, typically just a monotonically increasing number.
+    #
+    # Apple naming calls this the "bundle_version".
+    build_number: str
+
+
+class ITunesClient:
+    """A client for the legacy iTunes API.
+
+    Create this by calling :class:`AppConnectClient.itunes_client()`.
+
+    On creation this will contact iTunes and will fail if it does not have a valid iTunes
+    session.
+    """
+
+    def __init__(self, itunes_cookie: str, itunes_org: int):
+        self._session = requests.Session()
+        itunes_connect.load_session_cookie(self._session, itunes_cookie)
+        # itunes_connect.set_provider(self._session, itunes_org)
+
+    def download_dsyms(self, build: BuildInfo, path: pathlib.Path) -> None:
+        url = itunes_connect.get_dsym_url(
+            self._session, build.app_id, build.version, build.build_number, build.platform
+        )
+        if not url:
+            raise NoDsymsError
+        logger.debug("Fetching dSYM from: %s", url)
+        with requests.get(url, stream=True) as req:
+            req.raise_for_status()
+            with open(path, "wb") as fp:
+                for chunk in req.iter_content(chunk_size=io.DEFAULT_BUFFER_SIZE):
+                    fp.write(chunk)
+
+
+class AppConnectClient:
+    """Client to interact with a single app from App Store Connect.
+
+    Note that on creating this instance it will already connect to iTunes to set the
+    provider for this session.  You also don't want to use the same iTunes cookie in
+    multiple connections, so only make one client for a project.
+    """
+
+    def __init__(
+        self,
+        api_credentials: appstore_connect.AppConnectCredentials,
+        itunes_cookie: str,
+        itunes_org: int,
+        app_id: str,
+    ) -> None:
+        """Internal init, use one of the classmethods instead."""
+        self._api_credentials = api_credentials
+        self._session = requests.Session()
+        self._itunes_cookie = itunes_cookie
+        self._itunes_org = itunes_org
+        self._app_id = app_id
+
+    @classmethod
+    def from_project(cls, project: Project, config_id: str) -> "AppConnectClient":
+        """Creates a new client for the project's appStoreConnect symbol source.
+
+        This will load the configuration from the symbol sources for the project if a symbol
+        source of the ``appStoreConnect`` type can be found which also has matching
+        ``credentials_id``.
+        """
+        config = AppStoreConnectConfig.from_project_config(project, config_id)
+        return cls.from_config(config)
+
+    @classmethod
+    def from_config(cls, config: AppStoreConnectConfig) -> "AppConnectClient":
+        """Creates a new client from an appStoreConnect symbol source config.
+
+        This config is normally stored as a symbol source of type ``appStoreConnect`` in a
+        project's ``sentry:symbol_sources`` property.
+        """
+        api_credentials = appstore_connect.AppConnectCredentials(
+            key_id=config.appconnectKey,
+            key=config.appconnectPrivateKey,
+            issuer_id=config.appconnectIssuer,
+        )
+        return cls(
+            api_credentials=api_credentials,
+            itunes_cookie=config.itunesSession,
+            itunes_org=config.orgId,
+            app_id=config.appId,
+        )
+
+    def itunes_client(self) -> ITunesClient:
+        """Returns an iTunes client capable of downloading dSYMs.
+
+        This will raise an exception if the session cookie is expired.
+        """
+        return ITunesClient(itunes_cookie=self._itunes_cookie, itunes_org=self._itunes_org)
+
+    def list_builds(self, kind: BuildKind = BuildKind.ALL) -> List[BuildInfo]:
+        """Returns the available builds, grouped by release.
+
+        :param kind: Whether to only query pre-releases or only releases or all.
+        :param bundle: The bundle ID, e.g. ``io.sentry.sample.iOS-Swift``.
+        """
+        if kind == BuildKind.PRE_RELEASE:
+            ret = appstore_connect.get_pre_release_version_info(
+                self._session, self._api_credentials, self._app_id
+            )
+            all_results = {"pre_releases": ret}
+        elif kind == BuildKind.RELEASE:
+            ret = appstore_connect.get_release_version_info(
+                self._session, self._api_credentials, self._app_id
+            )
+            all_results = {"releases": ret}
+        else:
+            all_results = appstore_connect.get_build_info(
+                self._session, self._api_credentials, self._app_id
+            )
+
+        builds = []
+        for kind_name, results in all_results.items():
+            if kind_name == "pre_releases":
+                kind = BuildKind.PRE_RELEASE
+            else:
+                kind = BuildKind.RELEASE
+
+            for release in results:
+                for build in release["versions"]:
+                    build = BuildInfo(
+                        kind=kind,
+                        app_id=self._app_id,
+                        platform=release["platform"],
+                        version=release["short_version"],
+                        build_number=build["version"],
+                    )
+                    builds.append(build)
+        return builds

+ 23 - 9
src/sentry/models/debugfile.py

@@ -368,10 +368,17 @@ def determine_dif_kind(path):
             return DifKind.Object
 
 
-def detect_dif_from_path(path, name=None, debug_id=None):
-    """This detects which kind of dif(Debug Information File) the path
-    provided is. It returns an array since an Archive can contain more than
-    one Object.
+def detect_dif_from_path(path, name=None, debug_id=None, accept_unknown=False):
+    """Detects which kind of Debug Information File (DIF) the file at `path` is.
+
+    :param accept_unknown: If this is ``False`` an exception will be logged with the error
+       when a file which is not a known DIF is found.  This is useful for when ingesting ZIP
+       files directly from Apple App Store Connect which you know will also contain files
+       which are not DIFs.
+
+    :returns: an array since an Archive can contain more than one Object.
+
+    :raises BadDif: If the file is not a valid DIF.
     """
     # proguard files (proguard/UUID.txt) or
     # (proguard/mapping-UUID.txt).
@@ -395,9 +402,10 @@ def detect_dif_from_path(path, name=None, debug_id=None):
         try:
             BcSymbolMap.open(path)
         except SymbolicError as e:
-            logger.warning("bcsymbolmap.bad-file", exc_info=True)
+            logger.debug("File failed to load as BCSymbolmap: %s", path)
             raise BadDif("Invalid BCSymbolMap: %s" % e)
         else:
+            logger.debug("File loaded as BCSymbolMap: %s", path)
             return [
                 DifMeta(
                     file_format="bcsymbolmap", arch="any", debug_id=debug_id, name=name, path=path
@@ -407,9 +415,10 @@ def detect_dif_from_path(path, name=None, debug_id=None):
         try:
             UuidMapping.from_plist(debug_id, path)
         except SymbolicError as e:
-            logger.warning("uuidmap.bad-file", exc_info=True)
+            logger.debug("File failed to load as UUIDMap: %s", path)
             raise BadDif("Invalid UuidMap: %s" % e)
         else:
+            logger.debug(f"File loaded as UUIDMap: {path}")
             return [
                 DifMeta(file_format="uuidmap", arch="any", debug_id=debug_id, name=name, path=path)
             ]
@@ -420,12 +429,17 @@ def detect_dif_from_path(path, name=None, debug_id=None):
         except ObjectErrorUnsupportedObject as e:
             raise BadDif("Unsupported debug information file: %s" % e)
         except SymbolicError as e:
-            logger.warning("dsymfile.bad-fat-object", exc_info=True)
+            if accept_unknown:
+                level = logging.DEBUG
+            else:
+                level = logging.WARNING
+            logger.log(level, "dsymfile.bad-fat-object", exc_info=True)
             raise BadDif("Invalid debug information file: %s" % e)
         else:
             objs = []
             for obj in archive.iter_objects():
                 objs.append(DifMeta.from_object(obj, path, name=name, debug_id=debug_id))
+            logger.debug("File is Archive with {len(objs)} objects: %s", path)
             return objs
 
 
@@ -442,7 +456,7 @@ def create_debug_file_from_dif(to_create, project):
     return rv
 
 
-def create_files_from_dif_zip(fileobj, project):
+def create_files_from_dif_zip(fileobj, project, accept_unknown=False):
     """Creates all missing debug files from the given zip file.  This
     returns a list of all files created.
     """
@@ -455,7 +469,7 @@ def create_files_from_dif_zip(fileobj, project):
             for fn in filenames:
                 fn = os.path.join(dirpath, fn)
                 try:
-                    difs = detect_dif_from_path(fn)
+                    difs = detect_dif_from_path(fn, accept_unknown=accept_unknown)
                 except BadDif:
                     difs = None
 

+ 78 - 0
src/sentry/tasks/app_store_connect.py

@@ -0,0 +1,78 @@
+"""Tasks for managing Debug Information Files from Apple App Store Connect.
+
+Users can instruct Sentry to download dSYM from App Store Connect and put them into Sentry's
+debug files.  These tasks enable this functionality.
+"""
+
+import logging
+import pathlib
+import tempfile
+
+from sentry.lang.native import appconnect
+from sentry.models import AppConnectBuild, Project, debugfile
+from sentry.tasks.base import instrumented_task
+from sentry.utils.sdk import configure_scope
+
+logger = logging.getLogger(__name__)
+
+
+# Sadly this decorator makes this entire function untyped for now as it does not itself have
+# typing annotations.  So we do all the work outside of the decorated task function to work
+# around this.
+# Since all these args must be pickled we keep them to built-in types as well.
+@instrumented_task(name="sentry.tasks.app_store_connect.dsym_download", queue="appstoreconnect")  # type: ignore
+def dsym_download(project_id: int, config_id: str) -> None:
+    inner_dsym_download(project_id=project_id, config_id=config_id)
+
+
+def inner_dsym_download(
+    project_id: int,
+    config_id: str,
+) -> None:
+    """Downloads the dSYMs from App Store Connect and stores them in the Project's debug files."""
+    # TODO(flub): we should only run one task ever for a project.  Is
+    # sentry.cache.default_cache the right thing to put a "mutex" into?  See how
+    # sentry.tasks.assemble uses this.
+    with configure_scope() as scope:
+        scope.set_tag("project", project_id)
+
+    project = Project.objects.get(pk=project_id)
+    config = appconnect.AppStoreConnectConfig.from_project_config(project, config_id)
+    client = appconnect.AppConnectClient.from_config(config)
+    itunes_client = client.itunes_client()
+
+    builds = client.list_builds()
+    for build in builds:
+        try:
+            build_state = AppConnectBuild.objects.get(
+                project=project,
+                app_id=build.app_id,
+                platform=build.platform,
+                bundle_short_version=build.version,
+                bundle_version=build.build_number,
+            )
+        except AppConnectBuild.DoesNotExist:
+            build_state = AppConnectBuild(
+                project=project,
+                app_id=build.app_id,
+                bundle_id=config.bundleId,
+                platform=build.platform,
+                bundle_short_version=build.version,
+                bundle_version=build.build_number,
+                fetched=False,
+            )
+
+        if not build_state.fetched:
+            with tempfile.NamedTemporaryFile() as dsyms_zip:
+                itunes_client.download_dsyms(build, pathlib.Path(dsyms_zip.name))
+                create_difs_from_dsyms_zip(dsyms_zip.name, project)
+            build_state.fetched = True
+            build_state.save()
+            logger.debug("Uploaded dSYMs for build %s", build)
+
+
+def create_difs_from_dsyms_zip(dsyms_zip: str, project: Project) -> None:
+    with open(dsyms_zip, "rb") as fp:
+        created = debugfile.create_files_from_dif_zip(fp, project, accept_unknown=True)
+        for proj_debug_file in created:
+            logger.debug("Created %r for project %s", proj_debug_file, project.id)

+ 14 - 15
src/sentry/utils/appleconnect/appstore_connect.py

@@ -41,15 +41,17 @@ def _get_authorization_header(
     return f"Bearer {token}"
 
 
-def _get_appstore_info(
+def _get_appstore_json(
     session: Session, credentials: AppConnectCredentials, url: str
-) -> Optional[Mapping[str, Any]]:
-    """
-    Get info from an appstore url
+) -> Mapping[str, Any]:
+    """Returns response data from an appstore URL.
+
+    It builds and makes the request and extracts the data from the response.
 
-    It builds the request, and extracts the data
+    :returns: a dictionary with the requested data or None if the call fails.
 
-    :return: a dictionary with the requested data or None if the call fails
+    :raises ValueError: if the request failed or the response body could not be parsed as
+       JSON.
     """
     headers = {"Authorization": _get_authorization_header(credentials)}
 
@@ -73,19 +75,18 @@ def _get_appstore_info(
 
 
 def _get_next_page(response_json: Mapping[str, Any]) -> Optional[str]:
-    """
-    Gets the next page url from a app store connect paged response
-    """
+    """Gets the URL for the next page from an App Store Connect paged response."""
     return safe.get_path(response_json, "links", "next")  # type: ignore
 
 
 def _get_appstore_info_paged_data(
     session: Session, credentials: AppConnectCredentials, url: str
 ) -> Generator[Any, None, None]:
-    """
-    Iterate through all the pages from a paged response and concatenate the `data` part of the response
+    """Iterates through all the pages from a paged response.
+
+    The `data` part of the responses are concatenated.
 
-    App store connect share the general format:
+    App Store Connect responses shares the general format:
 
     data:
       - list of elements
@@ -101,9 +102,7 @@ def _get_appstore_info_paged_data(
     """
     next_url: Optional[str] = url
     while next_url is not None:
-        response = _get_appstore_info(session, credentials, url)
-        if response is None:
-            return
+        response = _get_appstore_json(session, credentials, next_url)
         data = response["data"]
         yield from data
         next_url = _get_next_page(response)

+ 1 - 1
src/sentry/utils/appleconnect/itunes_connect.py

@@ -282,7 +282,7 @@ def set_provider(session: Session, content_provider_id: int) -> None:
     user_details_response = session.get(user_details_url)
     if user_details_response.status_code != HTTPStatus.OK:
         raise ValueError(
-            f"Failed to get user details: {user_details_response}: {user_details_response.json()}"
+            f"Failed to get user details: {user_details_response}: {user_details_response.text}"
         )
     user_id = safe.get_path(user_details_response.json(), "data", "sessionToken", "dsId")
 

+ 184 - 0
tests/sentry/lang/native/test_appconnect.py

@@ -0,0 +1,184 @@
+import uuid
+from datetime import datetime
+
+import pytest
+
+from sentry.lang.native import appconnect
+from sentry.utils import json
+
+
+class TestAppStoreConnectConfig:
+    @pytest.fixture
+    def now(self):
+        # Fixture so we can have one "now" for the entire test and its fixtures.
+        return datetime.utcnow()
+
+    @pytest.fixture
+    def data(self, now):
+        return {
+            "type": "appStoreConnect",
+            "id": "abc123",
+            "name": "Apple App Store Connect",
+            "appconnectIssuer": "abc123" * 6,
+            "appconnectKey": "abc123",
+            "appconnectPrivateKey": "---- BEGIN PRIVATE KEY ---- ABC123...",
+            "itunesUser": "someone@example.com",
+            "itunesPassword": "a secret",
+            "itunesPersonId": "123",
+            "itunesSession": "ABC123",
+            "itunesCreated": now.isoformat(),
+            "appName": "Sample Application",
+            "appId": "1234",
+            "bundleId": "com.example.app",
+            "orgId": 1234,
+            "orgName": "Example Organisation",
+        }
+
+    def test_from_json_basic(self, data, now):
+        config = appconnect.AppStoreConnectConfig.from_json(data)
+        assert config.type == "appStoreConnect"
+        assert config.id == data["id"]
+        assert config.name == data["name"]
+        assert config.appconnectIssuer == data["appconnectIssuer"]
+        assert config.appconnectPrivateKey == data["appconnectPrivateKey"]
+        assert config.itunesUser == data["itunesUser"]
+        assert config.itunesPassword == data["itunesPassword"]
+        assert config.itunesPersonId == data["itunesPersonId"]
+        assert config.itunesSession == data["itunesSession"]
+        assert config.itunesCreated == now
+        assert config.appName == data["appName"]
+        assert config.bundleId == data["bundleId"]
+        assert config.orgId == data["orgId"]
+        assert config.orgName == data["orgName"]
+
+    def test_from_json_isoformat(self, data, now):
+        data["itunesCreated"] = now.isoformat()
+        config = appconnect.AppStoreConnectConfig.from_json(data)
+        assert config.itunesCreated == now
+
+    def test_from_json_datetime(self, data, now):
+        data["itunesCreated"] = now
+        config = appconnect.AppStoreConnectConfig.from_json(data)
+        assert config.itunesCreated == now
+
+    def test_to_json(self, data, now):
+        config = appconnect.AppStoreConnectConfig.from_json(data)
+        new_data = config.to_json()
+
+        # Fixup our input to expected JSON format
+        data["itunesCreated"] = now.isoformat()
+
+        assert new_data == data
+
+    @pytest.mark.django_db
+    def test_from_project_config_empty_sources(self, default_project, data):
+        with pytest.raises(KeyError):
+            appconnect.AppStoreConnectConfig.from_project_config(default_project, "not-an-id")
+
+
+class TestAppStoreConnectConfigUpdateProjectSymbolSource:
+    @pytest.fixture
+    def config(self):
+        return appconnect.AppStoreConnectConfig(
+            type="appStoreConnect",
+            id=uuid.uuid4().hex,
+            name="Apple App Store Connect",
+            appconnectIssuer="abc123" * 6,
+            appconnectKey="abc123key",
+            appconnectPrivateKey="----BEGIN PRIVATE KEY---- blabla",
+            itunesUser="me@example.com",
+            itunesPassword="secret",
+            itunesPersonId="123",
+            itunesSession="THE-COOKIE",
+            itunesCreated=datetime.utcnow(),
+            appName="My App",
+            appId="123",
+            bundleId="com.example.app",
+            orgId=123,
+            orgName="Example Com",
+        )
+
+    @pytest.mark.django_db
+    def test_new_source(self, default_project, config):
+        sources = config.update_project_symbol_source(default_project)
+
+        cfg = appconnect.AppStoreConnectConfig.from_json(sources[0].copy())
+        assert cfg == config
+
+        raw = default_project.get_option(appconnect.SYMBOL_SOURCES_PROP_NAME, default="[]")
+        stored_sources = json.loads(raw)
+        assert stored_sources == sources
+
+    @pytest.mark.django_db
+    def test_new_sources_with_existing(self, default_project, config):
+        old_sources = json.dumps(
+            [{"type": "not-this-one", "id": "a"}, {"type": "not-this-one", "id": "b"}]
+        )
+        default_project.update_option(appconnect.SYMBOL_SOURCES_PROP_NAME, old_sources)
+
+        sources = config.update_project_symbol_source(default_project)
+
+        cfg = appconnect.AppStoreConnectConfig.from_project_config(default_project, config.id)
+        assert cfg == config
+
+        raw = default_project.get_option(appconnect.SYMBOL_SOURCES_PROP_NAME, default="[]")
+        stored_sources = json.loads(raw)
+        assert stored_sources == sources
+
+        new_sources = json.loads(old_sources)
+        new_sources.append(cfg.to_json())
+        assert stored_sources == new_sources
+
+    @pytest.mark.django_db
+    def test_update(self, default_project, config):
+        config.update_project_symbol_source(default_project)
+
+        updated = appconnect.AppStoreConnectConfig(
+            type=config.type,
+            id=config.id,
+            name=config.name,
+            appconnectIssuer=config.appconnectIssuer,
+            appconnectKey=config.appconnectKey,
+            appconnectPrivateKey=config.appconnectPrivateKey,
+            itunesUser=config.itunesUser,
+            itunesPassword=config.itunesPassword,
+            itunesPersonId=config.itunesPersonId,
+            itunesSession="A NEW COOKIE",
+            itunesCreated=datetime.utcnow(),
+            appName=config.appName,
+            appId=config.appId,
+            bundleId=config.bundleId,
+            orgId=config.orgId,
+            orgName=config.orgName,
+        )
+
+        updated.update_project_symbol_source(default_project)
+
+        current = appconnect.AppStoreConnectConfig.from_project_config(default_project, config.id)
+        assert current.itunesSession == "A NEW COOKIE"
+
+    @pytest.mark.django_db
+    def test_update_no_matching_id(self, default_project, config):
+        config.update_project_symbol_source(default_project)
+
+        updated = appconnect.AppStoreConnectConfig(
+            type=config.type,
+            id=uuid.uuid4().hex,
+            name=config.name,
+            appconnectIssuer=config.appconnectIssuer,
+            appconnectKey=config.appconnectKey,
+            appconnectPrivateKey=config.appconnectPrivateKey,
+            itunesUser=config.itunesUser,
+            itunesPassword=config.itunesPassword,
+            itunesPersonId=config.itunesPersonId,
+            itunesSession="A NEW COOKIE",
+            itunesCreated=datetime.utcnow(),
+            appName=config.appName,
+            appId=config.appId,
+            bundleId=config.bundleId,
+            orgId=config.orgId,
+            orgName=config.orgName,
+        )
+
+        with pytest.raises(ValueError):
+            updated.update_project_symbol_source(default_project)