diff --git a/src/sentry/objectstore/__init__.py b/src/sentry/objectstore/__init__.py index ca4be80a34ee..be77e4bb8c49 100644 --- a/src/sentry/objectstore/__init__.py +++ b/src/sentry/objectstore/__init__.py @@ -89,6 +89,7 @@ class UsecaseId(Enum): DEBUG_FILES = "debug_files" PROFILE_ATTACHMENTS = "profile_attachments" PREPROD = "preprod" + PREPROD_SNAPSHOTS = "preprod_snapshots" def create(self) -> ObjectstoreClientUsecase: match self: @@ -108,7 +109,7 @@ def create(self) -> ObjectstoreClientUsecase: self.value, expiration_policy=TimeToLive(timedelta(days=default_attachment_retention())), ) - case UsecaseId.PREPROD: + case UsecaseId.PREPROD | UsecaseId.PREPROD_SNAPSHOTS: return ObjectstoreClientUsecase( self.value, expiration_policy=TimeToIdle(timedelta(days=30)), diff --git a/src/sentry/objectstore/types.py b/src/sentry/objectstore/types.py index 478c6d8f3a12..e5080706538a 100644 --- a/src/sentry/objectstore/types.py +++ b/src/sentry/objectstore/types.py @@ -5,6 +5,7 @@ class ObjectstoreUploadOptions(TypedDict): url: str + usecase: str scopes: list[tuple[str, str]] authToken: str | None expirationPolicy: str diff --git a/src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py b/src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py index d670911a9cb4..477d8b41eef4 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py @@ -14,7 +14,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint from sentry.models.project import Project -from sentry.objectstore import UsecaseId, get_session +from sentry.preprod.snapshots.storage import get_snapshot_storage from sentry.ratelimits.config import RateLimitConfig from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -69,7 +69,7 @@ def get( project_id = project.id object_key = f"{organization_id}/{project_id}/{image_id}" - session = get_session(UsecaseId.PREPROD, project) + session = get_snapshot_storage(project) try: result = session.get(object_key) diff --git a/src/sentry/preprod/api/endpoints/project_preprod_upload_options.py b/src/sentry/preprod/api/endpoints/project_preprod_upload_options.py index a49b69518e93..2999f75a246d 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_upload_options.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_upload_options.py @@ -28,8 +28,12 @@ class ProjectPreprodUploadOptionsEndpoint(ProjectEndpoint): permission_classes = (ProjectReleasePermission,) def get(self, request: Request, project: Project) -> Response: + if request.GET.get("usecase") not in (None, "auto"): + return Response({"detail": "Invalid usecase"}, status=400) + usecase = UsecaseId.PREPROD + organization = project.organization - session = get_session(UsecaseId.PREPROD, project) + session = get_session(usecase, project) path = reverse( "sentry-api-0-organization-objectstore", @@ -44,6 +48,7 @@ def get(self, request: Request, project: Project) -> Response: options = ObjectstoreUploadOptions( url=url, + usecase=usecase.value, scopes=[ ("org", str(organization.id)), ("project", str(project.id)), diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py index 95a6e897171c..9f51e71853be 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py @@ -34,7 +34,6 @@ from sentry.models.commitcomparison import CommitComparison from sentry.models.organization import Organization from sentry.models.project import Project -from sentry.objectstore import UsecaseId, get_session from sentry.preprod.analytics import ( PreprodArtifactApiDeleteEvent, PreprodArtifactApiGetSnapshotDetailsEvent, @@ -79,6 +78,7 @@ load_precomputed_head_images, refresh_manifest_expiration, ) +from sentry.preprod.snapshots.storage import get_snapshot_storage from sentry.preprod.snapshots.tasks import compare_snapshots from sentry.preprod.snapshots.utils import ( find_base_snapshot_artifact, @@ -355,7 +355,7 @@ def get( return Response({"detail": "Snapshot metrics not found"}, status=404) extras = snapshot_metrics.extras or {} - session = get_session(UsecaseId.PREPROD, artifact.project) + session = get_snapshot_storage(artifact.project) image_list: list[SnapshotImageResponseDict] precomputed = load_precomputed_head_images(session, extras.get("head_images_key")) @@ -844,7 +844,7 @@ def post( # Write manifest inside the transaction so that a failed objectstore # write rolls back the DB records, ensuring both succeed or neither does. - session = get_session(UsecaseId.PREPROD, project) + session = get_snapshot_storage(project) manifest_bytes = manifest.json(exclude_none=True).encode() manifest_size_bytes = len(manifest_bytes) session.put(manifest_bytes, key=manifest_key) diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_archive.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_archive.py index 46c11f86fbfe..92ff203e7cf3 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_archive.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_archive.py @@ -18,10 +18,10 @@ from sentry.auth.staff import is_active_staff from sentry.issues.action_log import resolve_action_source from sentry.models.organization import Organization -from sentry.objectstore import UsecaseId, get_session from sentry.preprod.analytics import PreprodArtifactApiSnapshotArchiveDownloadEvent from sentry.preprod.models import PreprodArtifact from sentry.preprod.snapshots.models import PreprodSnapshotMetrics +from sentry.preprod.snapshots.storage import get_snapshot_storage from sentry.preprod.snapshots.zip_builder import archive_exists, archive_object_key from sentry.preprod.snapshots.zip_tasks import build_snapshot_images_zip from sentry.ratelimits.config import RateLimitConfig @@ -87,7 +87,7 @@ def _resolve( return artifact, metrics def _download(self, artifact: PreprodArtifact) -> HttpResponseBase: - session = get_session(UsecaseId.PREPROD, artifact.project) + session = get_snapshot_storage(artifact.project) result = session.get(archive_object_key(artifact.id)) if result is None: return Response({"detail": "Download not ready"}, status=409) @@ -104,7 +104,7 @@ def _download(self, artifact: PreprodArtifact) -> HttpResponseBase: return response def _archive_exists(self, artifact: PreprodArtifact) -> bool: - session = get_session(UsecaseId.PREPROD, artifact.project) + session = get_snapshot_storage(artifact.project) try: return archive_exists(session, archive_object_key(artifact.id)) except RequestError: diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py index 690576772787..bd52924af4e5 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py @@ -24,7 +24,6 @@ from sentry.auth.staff import is_active_staff from sentry.issues.action_log import resolve_action_source from sentry.models.organization import Organization -from sentry.objectstore import UsecaseId, get_session from sentry.preprod.analytics import PreprodArtifactApiGetSnapshotImageEvent from sentry.preprod.api.models.public.snapshots import SnapshotImageDetailResponseDict from sentry.preprod.api.models.snapshots.project_preprod_snapshot_models import ( @@ -40,6 +39,7 @@ image_metadata_extras, ) from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics +from sentry.preprod.snapshots.storage import get_snapshot_storage logger = logging.getLogger(__name__) @@ -216,7 +216,7 @@ def get( return Response({"detail": "Manifest key not found"}, status=404) try: - session = get_session(UsecaseId.PREPROD, artifact.project) + session = get_snapshot_storage(artifact.project) response = session.get(manifest_key) if response is None: raise FileNotFoundError("Manifest does not exist in objectstore") diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py index f73449414f6d..a3e8c723267c 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py @@ -27,7 +27,6 @@ from sentry.constants import ObjectStatus from sentry.issues.action_log import resolve_action_source from sentry.models.organization import Organization -from sentry.objectstore import UsecaseId, get_session from sentry.preprod.analytics import PreprodArtifactApiGetLatestBaseSnapshotEvent from sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot import ( _strip_to_compact, @@ -38,6 +37,7 @@ ) from sentry.preprod.models import PreprodArtifact from sentry.preprod.snapshots.image_serialization import build_head_image_dict +from sentry.preprod.snapshots.storage import get_snapshot_storage logger = logging.getLogger(__name__) @@ -201,7 +201,7 @@ def get( return Response({"detail": "Manifest key not found"}, status=404) try: - session = get_session(UsecaseId.PREPROD, artifact.project) + session = get_snapshot_storage(artifact.project) response = session.get(manifest_key) if response is None: raise FileNotFoundError("Manifest does not exist in objectstore") diff --git a/src/sentry/preprod/helpers/deletion.py b/src/sentry/preprod/helpers/deletion.py index 00369ea3c200..b8a352b91f1b 100644 --- a/src/sentry/preprod/helpers/deletion.py +++ b/src/sentry/preprod/helpers/deletion.py @@ -17,11 +17,11 @@ from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter, TraceItemFilter from sentry.models.files.file import File -from sentry.objectstore import UsecaseId, get_session from sentry.preprod.eap.constants import get_preprod_trace_id from sentry.preprod.models import PreprodArtifact, PreprodArtifactSizeMetrics from sentry.preprod.snapshots.manifest import ComparisonManifest from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics +from sentry.preprod.snapshots.storage import get_snapshot_storage from sentry.utils import snuba_rpc from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor @@ -150,7 +150,7 @@ def _collect_snapshot_objectstore_keys( keys.append((org_id, project_id, comparison_key)) try: - session = get_session(UsecaseId.PREPROD, project_id, org=org_id) + session = get_snapshot_storage(project_id, org=org_id) response = session.get(comparison_key) if response is None: raise FileNotFoundError("Comparison manifest does not exist in objectstore") @@ -170,7 +170,7 @@ def _collect_snapshot_objectstore_keys( def _delete_objectstore_key(args: tuple[int, int, str]) -> bool: org_id, project_id, key = args try: - get_session(UsecaseId.PREPROD, project_id, org=org_id).delete(key) + get_snapshot_storage(project_id, org=org_id).delete(key) return True except Exception: logger.exception("preprod.cleanup.objectstore_delete_failed", extra={"key": key}) diff --git a/src/sentry/preprod/snapshots/precompute.py b/src/sentry/preprod/snapshots/precompute.py index f1119e155240..fef24e191bd0 100644 --- a/src/sentry/preprod/snapshots/precompute.py +++ b/src/sentry/preprod/snapshots/precompute.py @@ -5,10 +5,10 @@ from typing import Any, TypedDict import orjson -from objectstore_client import Session from sentry.preprod.api.models.public.snapshots import SnapshotImageResponseDict from sentry.preprod.snapshots.image_serialization import build_head_image_list +from sentry.preprod.snapshots.storage import SnapshotStorage from sentry.utils.tracing import set_span_data, start_span logger = logging.getLogger(__name__) @@ -36,7 +36,7 @@ def build_head_images_payload( } -def refresh_manifest_expiration(session: Session, manifest_key: str | None) -> None: +def refresh_manifest_expiration(session: SnapshotStorage, manifest_key: str | None) -> None: if not manifest_key: return try: @@ -46,7 +46,7 @@ def refresh_manifest_expiration(session: Session, manifest_key: str | None) -> N def load_precomputed_head_images( - session: Session, key: str | None + session: SnapshotStorage, key: str | None ) -> tuple[list[SnapshotImageResponseDict], float | None] | None: if not key: return None diff --git a/src/sentry/preprod/snapshots/reconstruction.py b/src/sentry/preprod/snapshots/reconstruction.py index 6b6ec0e43df9..9e6bcae1ed14 100644 --- a/src/sentry/preprod/snapshots/reconstruction.py +++ b/src/sentry/preprod/snapshots/reconstruction.py @@ -5,12 +5,13 @@ from typing import NamedTuple import orjson -from objectstore_client import RequestError, Session +from objectstore_client import RequestError from pydantic import ValidationError from sentry.preprod.models import PreprodArtifact from sentry.preprod.snapshots.categorize import categorize_image_sets from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest +from sentry.preprod.snapshots.storage import SnapshotStorage from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor MAX_CHAIN_DEPTH = 50 @@ -63,7 +64,7 @@ def _manifest_key(artifact: PreprodArtifact) -> str | None: return (metrics.extras or {}).get("manifest_key") -def _read_manifest_by_key(session: Session, key: str) -> _ManifestRead: +def _read_manifest_by_key(session: SnapshotStorage, key: str) -> _ManifestRead: """Fetch and parse a manifest. Pure objectstore I/O — no ORM access, so this is safe to run from a worker thread (see _fetch_manifests).""" try: @@ -134,7 +135,7 @@ def _collect_chain(base_artifact: PreprodArtifact) -> tuple[list[PreprodArtifact def _fetch_manifests( - session: Session, artifacts: list[PreprodArtifact] + session: SnapshotStorage, artifacts: list[PreprodArtifact] ) -> dict[int, _ManifestRead]: """Read each artifact's manifest, fanning the objectstore gets across a thread pool. @@ -167,7 +168,7 @@ def _fetch_manifests( def reconstruct_base_manifest( - base_artifact: PreprodArtifact, session: Session + base_artifact: PreprodArtifact, session: SnapshotStorage ) -> ReconstructionResult: """Reconstruct the complete manifest for base_artifact by folding the ancestry chain. diff --git a/src/sentry/preprod/snapshots/storage.py b/src/sentry/preprod/snapshots/storage.py new file mode 100644 index 000000000000..1a2473bb4cbf --- /dev/null +++ b/src/sentry/preprod/snapshots/storage.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import IO, Literal + +from objectstore_client import Compression, GetResponse, Metadata, RequestError, Session +from objectstore_client.multipart import MultipartUpload +from urllib3.exceptions import HTTPError + +from sentry.models.project import Project +from sentry.objectstore import UsecaseId, get_session +from sentry.utils import metrics + + +# TODO: On January 1, 2027, remove the preprod fallback and use preprod_snapshots exclusively. +class SnapshotStorage: + def __init__(self, primary: Session, fallback: Session) -> None: + self._primary = primary + self._fallback = fallback + + def get(self, key: str) -> GetResponse | None: + response = self._primary.get(key) + if response is None: + response = self._fallback.get(key) + self._record_fallback("get", response is not None) + return response + + def head(self, key: str) -> Metadata | None: + metadata = self._primary.head(key) + if metadata is None: + metadata = self._fallback.head(key) + self._record_fallback("head", metadata is not None) + return metadata + + def _record_fallback(self, op: str, found: bool) -> None: + metrics.incr( + "preprod.snapshot_storage.legacy_fallback", + tags={"op": op, "found": str(found).lower()}, + ) + + def put(self, contents: bytes | IO[bytes], *, key: str, content_type: str | None = None) -> str: + return self._primary.put(contents, key=key, content_type=content_type) + + def initiate_multipart_upload( + self, *, key: str, compression: Compression | Literal["none"], content_type: str + ) -> MultipartUpload: + return self._primary.initiate_multipart_upload( + key=key, compression=compression, content_type=content_type + ) + + def delete(self, key: str) -> None: + error: RequestError | HTTPError | None = None + for session in (self._primary, self._fallback): + try: + session.delete(key) + except (RequestError, HTTPError) as caught_error: + if error is None and ( + not isinstance(caught_error, RequestError) or caught_error.status != 404 + ): + error = caught_error + if error is not None: + raise error + + +def get_snapshot_storage(project: Project | int, *, org: int | None = None) -> SnapshotStorage: + return SnapshotStorage( + primary=get_session(UsecaseId.PREPROD, project, org=org), + fallback=get_session(UsecaseId.PREPROD_SNAPSHOTS, project, org=org), + ) diff --git a/src/sentry/preprod/snapshots/tasks.py b/src/sentry/preprod/snapshots/tasks.py index 18bfbd42d924..f2f6b3cf738d 100644 --- a/src/sentry/preprod/snapshots/tasks.py +++ b/src/sentry/preprod/snapshots/tasks.py @@ -13,12 +13,11 @@ from django.db import IntegrityError, models from django.db.models import F, Func, Value from django.utils import timezone -from objectstore_client import RequestError, Session +from objectstore_client import RequestError from pydantic import BaseModel, ValidationError from taskbroker_client.retry import Retry from sentry import analytics, options -from sentry.objectstore import UsecaseId, get_session from sentry.preprod.analytics import PreprodStatusCheckApprovalCreatedEvent from sentry.preprod.models import PreprodArtifact, PreprodComparisonApproval from sentry.preprod.snapshots.categorize import categorize_image_sets @@ -50,6 +49,7 @@ PreprodSnapshotMetrics, ) from sentry.preprod.snapshots.reconstruction import reconstruct_base_manifest +from sentry.preprod.snapshots.storage import SnapshotStorage, get_snapshot_storage from sentry.preprod.vcs.tasks import update_preprod_snapshot_vcs from sentry.silo.base import SiloMode from sentry.tasks.base import instrumented_task @@ -112,24 +112,24 @@ def _retry_objectstore[T](operation: Callable[[], T]) -> T: raise AssertionError("unreachable") -def _read_objectstore(session: Session, key: str) -> bytes: +def _read_objectstore(session: SnapshotStorage, key: str) -> bytes: response = session.get(key) if response is None: raise FileNotFoundError("Object does not exist in objectstore") return response.payload.read() -def _get_json[T: BaseModel](session: Session, key: str, model_cls: type[T]) -> T: +def _get_json[T: BaseModel](session: SnapshotStorage, key: str, model_cls: type[T]) -> T: return model_cls(**orjson.loads(_retry_objectstore(lambda: _read_objectstore(session, key)))) -def _put_json(session: Session, key: str, model: BaseModel) -> None: +def _put_json(session: SnapshotStorage, key: str, model: BaseModel) -> None: _retry_objectstore( lambda: session.put(orjson.dumps(model.dict()), key=key, content_type="application/json") ) -def _put_diff_mask(session: Session, key: str, data: bytes) -> None: +def _put_diff_mask(session: SnapshotStorage, key: str, data: bytes) -> None: _retry_objectstore(lambda: session.put(data, key=key, content_type="image/png")) @@ -270,7 +270,7 @@ def _image_name_to_path_stem(name: str) -> str: def _fetch_batch_images( - session: Session, + session: SnapshotStorage, key_prefix: str, hashes: set[str], ) -> tuple[dict[str, bytes], set[str]]: @@ -393,7 +393,7 @@ class SiblingComparison(NamedTuple): def _find_approved_sibling( - head_artifact: PreprodArtifact, session: Session + head_artifact: PreprodArtifact, session: SnapshotStorage ) -> SiblingComparison | None: cc = head_artifact.commit_comparison if not cc or not cc.pr_number or not cc.head_repo_name: @@ -476,7 +476,7 @@ def _try_auto_approve_snapshot( comparison_manifest: ComparisonManifest, plan: ComparisonPlan, sibling_images: dict[str, ComparisonImageResult], - session: Session, + session: SnapshotStorage, ) -> None: if plan.sibling_artifact_id is None or not plan.sibling_comparison_key: return @@ -706,7 +706,7 @@ def _build_comparison_plan( def _process_chunk( - session: Session, + session: SnapshotStorage, assignment: ChunkAssignment, org_id: int, project_id: int, @@ -901,7 +901,7 @@ def process_snapshot_comparison_chunk( base_artifact_id: int, **kwargs: Any, ) -> None: - session = get_session(UsecaseId.PREPROD, project_id, org=org_id) + session = get_snapshot_storage(project_id, org=org_id) plan_key = _plan_key(org_id, project_id, head_artifact_id, base_artifact_id) try: @@ -1100,7 +1100,7 @@ def _fail_comparison(error_code: PreprodSnapshotComparison.ErrorCode, message: s ) try: - session = get_session(UsecaseId.PREPROD, project_id, org=org_id) + session = get_snapshot_storage(project_id, org=org_id) head_manifest_key = (head_metrics.extras or {}).get("manifest_key") base_manifest_key = (base_metrics.extras or {}).get("manifest_key") @@ -1362,7 +1362,7 @@ def finalize_snapshot_comparison( ).update(date_updated=timezone.now()) comparison.refresh_from_db(fields=["chunks_done_indices"]) - session = get_session(UsecaseId.PREPROD, project_id, org=org_id) + session = get_snapshot_storage(project_id, org=org_id) plan_key = _plan_key(org_id, project_id, head_artifact_id, base_artifact_id) try: plan = _get_json(session, plan_key, ComparisonPlan) diff --git a/src/sentry/preprod/snapshots/zip_builder.py b/src/sentry/preprod/snapshots/zip_builder.py index 27a815d1c7a9..fb9d53b1abcc 100644 --- a/src/sentry/preprod/snapshots/zip_builder.py +++ b/src/sentry/preprod/snapshots/zip_builder.py @@ -6,10 +6,9 @@ from concurrent.futures import as_completed from typing import IO -from objectstore_client import Session - from sentry.preprod.snapshots.constants import SNAPSHOT_ARCHIVE_MANIFEST_FILENAME from sentry.preprod.snapshots.manifest import SnapshotManifest +from sentry.preprod.snapshots.storage import SnapshotStorage from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.zip import is_unsafe_path @@ -22,7 +21,7 @@ def archive_object_key(artifact_id: int) -> str: return f"snapshot_archives/{artifact_id}.zip" -def archive_exists(session: Session, key: str) -> bool: +def archive_exists(session: SnapshotStorage, key: str) -> bool: archive = session.get(key) if archive is None: return False @@ -36,7 +35,7 @@ class SnapshotZipBuildError(Exception): def build_snapshot_zip( manifest: SnapshotManifest, - session: Session, + session: SnapshotStorage, key_prefix: str, out: IO[bytes], artifact_id: int, diff --git a/src/sentry/preprod/snapshots/zip_tasks.py b/src/sentry/preprod/snapshots/zip_tasks.py index a79de3bee01e..f167e42a40e1 100644 --- a/src/sentry/preprod/snapshots/zip_tasks.py +++ b/src/sentry/preprod/snapshots/zip_tasks.py @@ -7,14 +7,14 @@ from typing import IO, Any import orjson -from objectstore_client import RequestError, Session +from objectstore_client import RequestError from objectstore_client.multipart import CompletePart, MultipartUpload from urllib3.exceptions import HTTPError from sentry.models.organization import Organization -from sentry.objectstore import UsecaseId, get_session from sentry.preprod.snapshots.manifest import SnapshotManifest from sentry.preprod.snapshots.models import PreprodSnapshotMetrics +from sentry.preprod.snapshots.storage import SnapshotStorage, get_snapshot_storage from sentry.preprod.snapshots.zip_builder import ( SnapshotZipBuildError, archive_exists, @@ -79,13 +79,13 @@ def _put_part_with_retry(upload: MultipartUpload, chunk: bytes, part_number: int def _archive_available(org_id: int, project_id: int, artifact_id: int) -> bool: try: - session = get_session(UsecaseId.PREPROD, project_id, org=org_id) + session = get_snapshot_storage(project_id, org=org_id) return archive_exists(session, archive_object_key(artifact_id)) except Exception: return False -def _upload_archive_multipart(session: Session, key: str, tmp: IO[bytes]) -> None: +def _upload_archive_multipart(session: SnapshotStorage, key: str, tmp: IO[bytes]) -> None: upload = session.initiate_multipart_upload( key=key, compression="none", content_type="application/zip" ) @@ -155,7 +155,7 @@ def build_snapshot_images_zip( if not manifest_key: raise SnapshotZipBuildError(f"missing manifest_key for artifact {artifact_id}") - session = get_session(UsecaseId.PREPROD, project_id, org=org_id) + session = get_snapshot_storage(project_id, org=org_id) key = archive_object_key(artifact_id) # Snapshot images for a given artifact are immutable, so a stored archive @@ -232,7 +232,7 @@ def build_snapshot_images_zip( _send_archive_email(organization, user_id, artifact_id, ready=True) -def _load_manifest(session: Session, manifest_key: str) -> tuple[SnapshotManifest, bytes]: +def _load_manifest(session: SnapshotStorage, manifest_key: str) -> tuple[SnapshotManifest, bytes]: """Return the validated manifest and its original objectstore payload bytes.""" response = session.get(manifest_key) if response is None: diff --git a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_archive.py b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_archive.py index c4787a16a28b..72b7f8477dce 100644 --- a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_archive.py +++ b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_archive.py @@ -21,7 +21,7 @@ "preprod_artifact_snapshot_archive.build_snapshot_images_zip" ) SESSION_TARGET = ( - "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_archive.get_session" + "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_archive.get_snapshot_storage" ) diff --git a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py index 97644cd55dc3..59e5290643c6 100644 --- a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py +++ b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py @@ -9,9 +9,7 @@ from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.analytics import assert_last_analytics_event -MOCK_TARGET = ( - "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_image_detail.get_session" -) +MOCK_TARGET = "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_image_detail.get_snapshot_storage" class OrganizationPreprodSnapshotImageDetailTest(APITestCase): diff --git a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py index c1fb7eae107e..dedc614df3bd 100644 --- a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py +++ b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py @@ -9,9 +9,7 @@ from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.analytics import assert_last_analytics_event -MOCK_TARGET = ( - "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_session" -) +MOCK_TARGET = "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_snapshot_storage" class OrganizationPreprodLatestBaseSnapshotTest(APITestCase): diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py index d597acb40bea..e4f645d5d529 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py @@ -2,7 +2,6 @@ from django.urls import reverse -from sentry.objectstore import UsecaseId from sentry.testutils.cases import APITestCase @@ -36,7 +35,7 @@ def _create_mock_session(self, image_data, content_type): return mock_session - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_successful_image_retrieval_png(self, mock_get_session): png_data = b"\x89PNG\r\n\x1a\n" + b"fake png content" * 100 mock_session = self._create_mock_session(png_data, "image/png") @@ -50,10 +49,10 @@ def test_successful_image_retrieval_png(self, mock_get_session): assert response.status_code == 200 assert response.content == png_data assert response["Content-Type"] == "image/png" - mock_get_session.assert_called_once_with(UsecaseId.PREPROD, self.project) + mock_get_session.assert_called_once_with(self.project) mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_successful_image_retrieval_jpeg(self, mock_get_session): jpeg_data = b"\xff\xd8\xff" + b"fake jpeg content" * 100 mock_session = self._create_mock_session(jpeg_data, "image/jpeg") @@ -67,10 +66,10 @@ def test_successful_image_retrieval_jpeg(self, mock_get_session): assert response.status_code == 200 assert response.content == jpeg_data assert response["Content-Type"] == "image/jpeg" - mock_get_session.assert_called_once_with(UsecaseId.PREPROD, self.project) + mock_get_session.assert_called_once_with(self.project) mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_successful_image_retrieval_webp(self, mock_get_session): webp_data = b"RIFF" + b"1234" + b"WEBP" + b"fake webp content" * 100 mock_session = self._create_mock_session(webp_data, "image/webp") @@ -84,10 +83,10 @@ def test_successful_image_retrieval_webp(self, mock_get_session): assert response.status_code == 200 assert response.content == webp_data assert response["Content-Type"] == "image/webp" - mock_get_session.assert_called_once_with(UsecaseId.PREPROD, self.project) + mock_get_session.assert_called_once_with(self.project) mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_unknown_image_format(self, mock_get_session): unknown_data = b"unknown binary data" * 50 mock_session = self._create_mock_session(unknown_data, "application/octet-stream") @@ -101,10 +100,10 @@ def test_unknown_image_format(self, mock_get_session): assert response.status_code == 200 assert response.content == unknown_data assert response["Content-Type"] == "application/octet-stream" - mock_get_session.assert_called_once_with(UsecaseId.PREPROD, self.project) + mock_get_session.assert_called_once_with(self.project) mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_content_disposition_with_filename(self, mock_get_session): mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -120,7 +119,7 @@ def test_content_disposition_with_filename(self, mock_get_session): assert response.status_code == 200 assert response["Content-Disposition"] == 'inline; filename="alert-dark-danger-no-icon.png"' - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_no_content_disposition_without_filename_param(self, mock_get_session): mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -133,7 +132,7 @@ def test_no_content_disposition_without_filename_param(self, mock_get_session): assert response.status_code == 200 assert not response.has_header("Content-Disposition") - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_content_disposition_strips_path_traversal(self, mock_get_session): mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -149,7 +148,7 @@ def test_content_disposition_strips_path_traversal(self, mock_get_session): assert response.status_code == 200 assert response["Content-Disposition"] == 'inline; filename="alert-dark-danger-no-icon.png"' - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_content_disposition_strips_parent_dir_traversal(self, mock_get_session): mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -165,7 +164,7 @@ def test_content_disposition_strips_parent_dir_traversal(self, mock_get_session) assert response.status_code == 200 assert response["Content-Disposition"] == 'inline; filename="passwd"' - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_content_disposition_strips_header_injection(self, mock_get_session): mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -184,7 +183,7 @@ def test_content_disposition_strips_header_injection(self, mock_get_session): assert "\n" not in cd assert not response.has_header("Set-Cookie") - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_content_disposition_strips_quotes(self, mock_get_session): mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -200,7 +199,7 @@ def test_content_disposition_strips_quotes(self, mock_get_session): assert response.status_code == 200 assert response["Content-Disposition"] == 'inline; filename="foo.png"' - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_no_content_disposition_when_filename_empties_out(self, mock_get_session): mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -216,7 +215,7 @@ def test_no_content_disposition_when_filename_empties_out(self, mock_get_session assert response.status_code == 200 assert not response.has_header("Content-Disposition") - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_content_disposition_non_ascii_filename(self, mock_get_session): mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -247,7 +246,7 @@ def test_endpoint_requires_project_access(self) -> None: ) assert response.status_code == 403 - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_objectstore_404_returns_404(self, mock_get_session): mock_session = MagicMock() mock_session.get.return_value = None @@ -261,7 +260,7 @@ def test_objectstore_404_returns_404(self, mock_get_session): assert response.status_code == 404 assert response.json() == {"detail": "Image not found"} - @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") + @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") def test_error_handling_returns_json(self, mock_get_session): mock_session = MagicMock() mock_session.get.side_effect = Exception("Storage error") diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py index 2ce64946e716..d9c1c5631086 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py @@ -6,7 +6,6 @@ from django.urls import reverse from sentry.models.commitcomparison import CommitComparison -from sentry.objectstore import UsecaseId from sentry.preprod.analytics import PreprodArtifactApiGetSnapshotDetailsEvent from sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base import ( LATEST_BASE_SNAPSHOT_GET_QUERY_PARAMS, @@ -449,7 +448,7 @@ def test_selective_with_all_image_file_names_accepted(self): response = self._post_selective() assert response.status_code == 200 - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.compare_snapshots") def test_base_upload_triggers_comparison_for_waiting_head( self, mock_compare_snapshots, mock_get_session @@ -535,7 +534,7 @@ def test_base_upload_triggers_comparison_for_waiting_head( } ) - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.compare_snapshots") def test_selective_base_is_matched_for_comparison( self, mock_compare_snapshots, mock_get_session @@ -668,7 +667,7 @@ def _create_mock_session(self, manifest_json): mock_session.get.return_value = mock_result return mock_session - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_details(self, mock_get_session): artifact, _, manifest_key, manifest_json, images = self._create_artifact_with_manifest() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -686,7 +685,7 @@ def test_get_snapshot_details(self, mock_get_session): assert response.data["images"][0]["image_file_name"] == "img1" assert response.data["images"][1]["key"] == "img2" - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_details_uses_precomputed_head_images(self, mock_get_session): images = { "img1": { @@ -726,7 +725,7 @@ def _get(key): assert [img["key"] for img in response.data["images"]] == ["img1", "img2"] mock_session.head.assert_called_once_with(manifest_key) - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_details_returns_canvas_theme(self, mock_get_session): images = { "img1": { @@ -754,7 +753,7 @@ def test_get_snapshot_details_returns_canvas_theme(self, mock_get_session): assert themes == {"img1": "dark", "img2": "light"} @patch("sentry.analytics.record") - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_details_records_web_client(self, mock_get_session, mock_record): artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -774,7 +773,7 @@ def test_get_snapshot_details_records_web_client(self, mock_get_session, mock_re ) @patch("sentry.analytics.record") - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_details_records_mcp_client(self, mock_get_session, mock_record): artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -797,7 +796,7 @@ def test_get_snapshot_details_records_mcp_client(self, mock_get_session, mock_re ), ) - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_details_with_vcs_info(self, mock_get_session): commit_comparison = CommitComparison.objects.create( organization_id=self.org.id, @@ -825,7 +824,7 @@ def test_get_snapshot_details_with_vcs_info(self, mock_get_session): assert vcs_info["head_ref"] == "chore/cleanup" assert vcs_info["pr_number"] == 123 - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_details_returns_all_images(self, mock_get_session): images = { f"img{i:03d}": { @@ -848,7 +847,7 @@ def test_get_snapshot_details_returns_all_images(self, mock_get_session): assert response.data["images"][0]["key"] == "img000" assert response.data["images"][9]["key"] == "img009" - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_diff_omits_images(self, mock_get_session): from sentry.preprod.snapshots.manifest import ( ComparisonImageResult, @@ -1000,7 +999,7 @@ def test_get_snapshot_wrong_organization(self) -> None: assert response.status_code == 404 - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_objectstore_error(self, mock_get_session): artifact, _, _, _, _ = self._create_artifact_with_manifest() mock_session = MagicMock() @@ -1041,7 +1040,7 @@ def test_get_snapshot_returns_404_for_member_without_project_access(self) -> Non assert response.status_code == 404 - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_flat_fields_solo_no_approval(self, mock_get_session): artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -1056,7 +1055,7 @@ def test_get_snapshot_flat_fields_solo_no_approval(self, mock_get_session): assert response.data["approvers"] == [] assert response.data["comparison_type"] == "solo" - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_flat_fields_pending_comparison(self, mock_get_session): artifact, snapshot_metrics, _, manifest_json, _ = self._create_artifact_with_manifest( commit_comparison=CommitComparison.objects.create( @@ -1091,7 +1090,7 @@ def test_get_snapshot_flat_fields_pending_comparison(self, mock_get_session): assert response.status_code == 200 assert response.data["comparison_state"] == "pending" - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_flat_fields_with_approval(self, mock_get_session): artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() self.create_preprod_comparison_approval( @@ -1109,7 +1108,7 @@ def test_get_snapshot_flat_fields_with_approval(self, mock_get_session): assert len(response.data["approvers"]) == 1 assert response.data["approvers"][0]["source"] == "sentry" - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_flat_fields_auto_approved(self, mock_get_session): artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() self.create_preprod_comparison_approval( @@ -1125,7 +1124,7 @@ def test_get_snapshot_flat_fields_auto_approved(self, mock_get_session): assert response.status_code == 200 assert response.data["approval_status"] == "auto_approved" - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_get_snapshot_flat_fields_waiting_for_base(self, mock_get_session): artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest( commit_comparison=CommitComparison.objects.create( @@ -1203,7 +1202,7 @@ def test_query_params_document_project_id_or_slug(self): } @patch( - "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_session" + "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_snapshot_storage" ) def test_get_latest_base_snapshot_scoped_by_project_slug(self, mock_get_session): artifact, manifest_key, manifest_json = self._create_base_snapshot() @@ -1219,10 +1218,10 @@ def test_get_latest_base_snapshot_scoped_by_project_slug(self, mock_get_session) assert response.data["project_slug"] == "sausage" assert response.data["image_count"] == 1 assert response.data["images"][0]["image_file_name"] == "components/button.png" - mock_get_session.assert_called_once_with(UsecaseId.PREPROD, self.project) + mock_get_session.assert_called_once_with(self.project) @patch( - "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_session" + "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_snapshot_storage" ) def test_get_latest_base_snapshot_scoped_by_project_param_slug(self, mock_get_session): artifact, _, manifest_json = self._create_base_snapshot() @@ -1236,10 +1235,10 @@ def test_get_latest_base_snapshot_scoped_by_project_param_slug(self, mock_get_se assert response.status_code == 200 assert response.data["head_artifact_id"] == str(artifact.id) assert response.data["project_slug"] == "sausage" - mock_get_session.assert_called_once_with(UsecaseId.PREPROD, self.project) + mock_get_session.assert_called_once_with(self.project) @patch( - "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_session" + "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_snapshot_storage" ) def test_get_latest_base_snapshot_scoped_by_project_param_id(self, mock_get_session): artifact, _, manifest_json = self._create_base_snapshot() @@ -1253,10 +1252,10 @@ def test_get_latest_base_snapshot_scoped_by_project_param_id(self, mock_get_sess assert response.status_code == 200 assert response.data["head_artifact_id"] == str(artifact.id) assert response.data["project_slug"] == "sausage" - mock_get_session.assert_called_once_with(UsecaseId.PREPROD, self.project) + mock_get_session.assert_called_once_with(self.project) @patch( - "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_session" + "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_snapshot_storage" ) def test_get_latest_base_snapshot_project_slug_takes_precedence_over_project( self, mock_get_session @@ -1278,7 +1277,7 @@ def test_get_latest_base_snapshot_project_slug_takes_precedence_over_project( assert response.status_code == 200 assert response.data["head_artifact_id"] == str(artifact.id) assert response.data["project_slug"] == "other-project" - mock_get_session.assert_called_once_with(UsecaseId.PREPROD, other_project) + mock_get_session.assert_called_once_with(other_project) def test_get_latest_base_snapshot_rejects_all_project_id_sentinel(self): response = self.client.get( @@ -1577,7 +1576,7 @@ def _assert_golden(self, name, response, dynamic_ids): ) @patch("sentry.analytics.record") - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_golden_solo(self, mock_get_session, mock_record): head_images = self._head_images() artifact, _ = self._create_artifact(image_count=len(head_images)) @@ -1601,7 +1600,7 @@ def _head_images_blob_bytes(self, head_images): ) @patch("sentry.analytics.record") - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_golden_solo_precomputed_head_images(self, mock_get_session, mock_record): head_images = self._head_images() artifact, metrics = self._create_artifact(image_count=len(head_images)) @@ -1622,7 +1621,7 @@ def test_golden_solo_precomputed_head_images(self, mock_get_session, mock_record ) @patch("sentry.analytics.record") - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_golden_diff_precomputed_head_images(self, mock_get_session, mock_record): head_artifact, _ = self._setup_diff(mock_get_session, use_precomputed_head=True) @@ -1675,7 +1674,7 @@ def _setup_diff(self, mock_get_session, use_precomputed_head=False): return head_artifact, base_artifact @patch("sentry.analytics.record") - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_golden_diff(self, mock_get_session, mock_record): head_artifact, _ = self._setup_diff(mock_get_session) @@ -1692,7 +1691,7 @@ def test_golden_diff(self, mock_get_session, mock_record): ) @patch("sentry.analytics.record") - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_golden_diff_compact(self, mock_get_session, mock_record): head_artifact, _ = self._setup_diff(mock_get_session) @@ -1709,7 +1708,7 @@ def test_golden_diff_compact(self, mock_get_session, mock_record): ) @patch("sentry.analytics.record") - @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_session") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_comparison_manifest_missing_base_artifact_id_degrades( self, mock_get_session, mock_record ): diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py index 2afde59d338b..f09e31e46495 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py @@ -39,8 +39,27 @@ def test_returns_upload_options(self, mock_get_session) -> None: assert data["expirationPolicy"] == "tti:30d" + assert data["usecase"] == "preprod" + + mock_get_session.assert_called_once_with(UsecaseId.PREPROD, self.project) + + @patch("sentry.preprod.api.endpoints.project_preprod_upload_options.get_session") + def test_auto_returns_preprod(self, mock_get_session) -> None: + mock_session = MagicMock() + mock_session.mint_token.return_value = "fake-token" + mock_get_session.return_value = mock_session + + response = self.client.get(self.url, {"usecase": "auto"}) + + assert response.status_code == 200 + assert response.data["objectstore"]["usecase"] == "preprod" mock_get_session.assert_called_once_with(UsecaseId.PREPROD, self.project) + def test_rejects_explicit_usecase(self) -> None: + response = self.client.get(self.url, {"usecase": "preprod_snapshots"}) + + assert response.status_code == 400 + @patch("sentry.preprod.api.endpoints.project_preprod_upload_options.get_session") def test_objectstore_url_uses_region_endpoint(self, mock_get_session) -> None: mock_session = MagicMock() diff --git a/tests/sentry/preprod/snapshots/test_compare_snapshots.py b/tests/sentry/preprod/snapshots/test_compare_snapshots.py index 3d67b821c179..116668cf16e7 100644 --- a/tests/sentry/preprod/snapshots/test_compare_snapshots.py +++ b/tests/sentry/preprod/snapshots/test_compare_snapshots.py @@ -191,7 +191,7 @@ def test_chunk_processes_slice_and_records_done_index(self): ) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks._fetch_batch_images", return_value=({"h": b"img", "b": b"img"}, set()), @@ -280,7 +280,7 @@ def test_chunk_writes_sibling_results_separately(self): ) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.OdiffServer"), patch( "sentry.preprod.snapshots.tasks._fetch_batch_images", @@ -373,7 +373,7 @@ def test_chunk_triggers_finalize_when_last(self): ) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks._fetch_batch_images", return_value=({"h": b"img", "b": b"img"}, set()), @@ -413,7 +413,7 @@ def test_chunk_does_not_trigger_finalize_when_not_last(self): ) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks._fetch_batch_images", return_value=({"h": b"img", "b": b"img"}, set()), @@ -465,7 +465,7 @@ def test_chunk_with_unchanged_diff_hash_writes_result(self): ) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks._fetch_batch_images", return_value=({"h": b"img", "b": b"img"}, set()), @@ -504,7 +504,7 @@ def test_chunk_hard_failure_still_recorded_and_triggers_finalize(self): ) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks._process_chunk", side_effect=Exception("boom"), @@ -540,7 +540,7 @@ def test_chunk_failure_logs_exception_details(self): ) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks._process_chunk", side_effect=ValueError("odiff exploded"), @@ -637,7 +637,7 @@ def _finalize_with_extras(self, extras): } session = _dict_backed_session(stored) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks._try_auto_approve_snapshot"), patch("sentry.preprod.snapshots.tasks.metrics") as mock_metrics, ): @@ -665,7 +665,7 @@ def test_terminal_state_skips_finalize(self): from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(1, state=PreprodSnapshotComparison.State.SUCCESS) - with patch("sentry.preprod.snapshots.tasks.get_session") as session: + with patch("sentry.preprod.snapshots.tasks.get_snapshot_storage") as session: finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) assert not session.called @@ -680,7 +680,7 @@ def test_all_done_finalizes(self): } session = _dict_backed_session(stored) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks._try_auto_approve_snapshot") as mock_auto_approve, ): finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) @@ -746,7 +746,7 @@ def test_finalize_passes_sibling_results_to_auto_approve(self): } session = _dict_backed_session(stored) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks._try_auto_approve_snapshot") as mock_auto_approve, ): finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) @@ -766,7 +766,7 @@ def test_finalize_writes_images_errored_column(self): prefix = f"{self.organization.id}/{self.project.id}/{h.id}/{b.id}" stored = {f"{prefix}/plan.json": orjson.dumps(self._single_chunk_plan(h, b).dict())} session = _dict_backed_session(stored) - with patch("sentry.preprod.snapshots.tasks.get_session", return_value=session): + with patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session): finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) comparison.refresh_from_db() assert comparison.state == PreprodSnapshotComparison.State.SUCCESS @@ -783,7 +783,7 @@ def test_finalize_is_exactly_once(self): } session = _dict_backed_session(stored) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks._try_auto_approve_snapshot") as mock_auto_approve, ): finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) @@ -796,7 +796,7 @@ def test_chunks_total_none_skips_finalize(self): from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(None) - with patch("sentry.preprod.snapshots.tasks.get_session") as session: + with patch("sentry.preprod.snapshots.tasks.get_snapshot_storage") as session: finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) assert not session.called comparison.refresh_from_db() @@ -806,7 +806,7 @@ def test_not_all_done_skips_finalize(self): from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(3, done_indices=[0]) - with patch("sentry.preprod.snapshots.tasks.get_session") as session: + with patch("sentry.preprod.snapshots.tasks.get_snapshot_storage") as session: finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) assert not session.called comparison.refresh_from_db() @@ -839,7 +839,7 @@ def _capture(*args, **kwargs): return session with ( - patch("sentry.preprod.snapshots.tasks.get_session", side_effect=_capture), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", side_effect=_capture), patch("sentry.preprod.snapshots.tasks._try_auto_approve_snapshot"), ): finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) @@ -854,7 +854,7 @@ def test_finalize_marks_missing_chunk_images_errored(self): prefix = f"{self.organization.id}/{self.project.id}/{h.id}/{b.id}" stored = {f"{prefix}/plan.json": orjson.dumps(self._single_chunk_plan(h, b).dict())} session = _dict_backed_session(stored) - with patch("sentry.preprod.snapshots.tasks.get_session", return_value=session): + with patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session): finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) comparison.refresh_from_db() assert comparison.state == PreprodSnapshotComparison.State.SUCCESS @@ -869,7 +869,7 @@ def test_finalize_degrades_when_done_chunk_result_unreadable(self): prefix = f"{self.organization.id}/{self.project.id}/{h.id}/{b.id}" stored = {f"{prefix}/plan.json": orjson.dumps(self._single_chunk_plan(h, b).dict())} session = _dict_backed_session(stored) - with patch("sentry.preprod.snapshots.tasks.get_session", return_value=session): + with patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session): finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) comparison.refresh_from_db() assert comparison.state == PreprodSnapshotComparison.State.SUCCESS @@ -883,7 +883,7 @@ def test_finalize_fails_when_plan_unreadable(self): comparison, h, b = self._comparison(1, done_indices=[0]) session = _dict_backed_session({}) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs") as vcs, ): finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) @@ -930,7 +930,7 @@ def test_orchestrator_dispatches_and_sets_total(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks.process_snapshot_comparison_chunk.apply_async" ) as dispatch, @@ -1013,7 +1013,7 @@ def test_orchestrator_records_sibling_in_plan(self): with ( self.options({"preprod.snapshots.auto-approve-sibling-diffs.enabled": True}), - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks._find_approved_sibling", return_value=sibling ) as mock_find_sibling, @@ -1104,7 +1104,7 @@ def test_orchestrator_records_sibling_without_candidates_when_disabled(self): with ( self.options({"preprod.snapshots.auto-approve-sibling-diffs.enabled": False}), - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks._find_approved_sibling", return_value=sibling ) as mock_find_sibling, @@ -1155,7 +1155,7 @@ def test_orchestrator_finalizes_when_no_diff_chunks(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks.process_snapshot_comparison_chunk.apply_async" ) as dispatch, @@ -1215,7 +1215,7 @@ def test_orchestrator_resumes_stuck_processing_row(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch( "sentry.preprod.snapshots.tasks.process_snapshot_comparison_chunk.apply_async" ) as dispatch, @@ -1273,7 +1273,7 @@ def test_orchestrator_finalizes_when_chunks_already_done(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.process_snapshot_comparison_chunk.apply_async"), patch( "sentry.preprod.snapshots.tasks.finalize_snapshot_comparison.apply_async" @@ -1330,7 +1330,7 @@ def test_resume_of_failed_row_preserves_completed_chunks(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.process_snapshot_comparison_chunk.apply_async"), patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs"), ): @@ -1372,7 +1372,7 @@ def _get(key): session.get.side_effect = _get with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs") as vcs, ): compare_snapshots( @@ -1409,7 +1409,7 @@ def test_manifest_failure_on_processing_row_flips_to_failed(self): session.get.return_value = None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs") as vcs, ): compare_snapshots( @@ -1446,7 +1446,7 @@ def test_orchestrator_skips_processing_row_with_chunks_total(self): comparison.save() with ( - patch("sentry.preprod.snapshots.tasks.get_session") as session_factory, + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage") as session_factory, patch( "sentry.preprod.snapshots.tasks.process_snapshot_comparison_chunk.apply_async" ) as dispatch, @@ -1533,7 +1533,7 @@ def test_compare_against_selective_base_reconstructs_and_processes(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.process_snapshot_comparison_chunk.apply_async"), patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs"), ): @@ -1590,7 +1590,7 @@ def test_reconstruction_gated_on_manifest_not_db_flag(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.process_snapshot_comparison_chunk.apply_async"), patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs"), ): @@ -1650,7 +1650,7 @@ def test_resume_from_pending_skips_compare_start_vcs(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.process_snapshot_comparison_chunk.apply_async"), patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs") as vcs, ): @@ -1676,7 +1676,7 @@ def test_fresh_start_posts_compare_start_vcs(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.process_snapshot_comparison_chunk.apply_async"), patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs") as vcs, ): @@ -1715,7 +1715,7 @@ def test_incomplete_chain_within_window_defers_to_pending(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.compare_snapshots.apply_async") as reschedule, patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs"), ): @@ -1772,7 +1772,7 @@ def test_old_head_with_fresh_comparison_within_window_defers(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.compare_snapshots.apply_async") as reschedule, patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs"), ): @@ -1827,7 +1827,7 @@ def test_incomplete_chain_past_window_fails(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.compare_snapshots.apply_async") as reschedule, patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs") as vcs, ): @@ -1876,7 +1876,7 @@ def test_corrupt_ancestor_fails_immediately_not_deferred(self): session.put.side_effect = lambda *a, **k: None with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.compare_snapshots.apply_async") as reschedule, patch("sentry.preprod.snapshots.tasks.update_preprod_snapshot_vcs"), ): @@ -1979,7 +1979,7 @@ def test_full_flow_reaches_success(self): ) with ( - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks.MAX_PIXELS_PER_BATCH", 1), patch( "sentry.preprod.snapshots.tasks._fetch_batch_images", side_effect=self._fake_fetch @@ -2142,7 +2142,7 @@ def test_full_flow_auto_approves_via_sibling_results(self): with ( self.options({"preprod.snapshots.auto-approve-sibling-diffs.enabled": True}), - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks._find_approved_sibling", return_value=sibling), patch( "sentry.preprod.snapshots.tasks._fetch_batch_images", side_effect=self._fake_fetch @@ -2283,7 +2283,7 @@ def test_full_flow_auto_approves_exact_match_when_disabled(self): with ( self.options({"preprod.snapshots.auto-approve-sibling-diffs.enabled": False}), - patch("sentry.preprod.snapshots.tasks.get_session", return_value=session), + patch("sentry.preprod.snapshots.tasks.get_snapshot_storage", return_value=session), patch("sentry.preprod.snapshots.tasks._find_approved_sibling", return_value=sibling), patch( "sentry.preprod.snapshots.tasks._fetch_batch_images", side_effect=self._fake_fetch diff --git a/tests/sentry/preprod/snapshots/test_storage.py b/tests/sentry/preprod/snapshots/test_storage.py new file mode 100644 index 000000000000..e730e64b9670 --- /dev/null +++ b/tests/sentry/preprod/snapshots/test_storage.py @@ -0,0 +1,101 @@ +from unittest.mock import MagicMock, call, patch + +import pytest +from objectstore_client import RequestError +from urllib3.exceptions import HTTPError + +from sentry.objectstore import UsecaseId +from sentry.preprod.snapshots.storage import SnapshotStorage, get_snapshot_storage + + +@pytest.fixture +def sessions() -> tuple[MagicMock, MagicMock]: + return MagicMock(name="primary"), MagicMock(name="fallback") + + +def test_get_prefers_primary(sessions) -> None: + primary, fallback = sessions + storage = SnapshotStorage(primary, fallback) + assert storage.get("k") is primary.get.return_value + fallback.get.assert_not_called() + + +def test_get_uses_fallback_when_primary_missing(sessions) -> None: + primary, fallback = sessions + primary.get.return_value = None + storage = SnapshotStorage(primary, fallback) + assert storage.get("k") is fallback.get.return_value + fallback.get.assert_called_once_with("k") + + +def test_get_error_propagates_without_fallback(sessions) -> None: + primary, fallback = sessions + primary.get.side_effect = RequestError("boom", 500, "") + storage = SnapshotStorage(primary, fallback) + with pytest.raises(RequestError): + storage.get("k") + fallback.get.assert_not_called() + + +def test_head_uses_fallback_when_primary_missing(sessions) -> None: + primary, fallback = sessions + primary.head.return_value = None + storage = SnapshotStorage(primary, fallback) + assert storage.head("k") is fallback.head.return_value + + +def test_put_only_writes_primary(sessions) -> None: + primary, fallback = sessions + storage = SnapshotStorage(primary, fallback) + storage.put(b"data", key="k", content_type="image/png") + primary.put.assert_called_once_with(b"data", key="k", content_type="image/png") + fallback.put.assert_not_called() + + +def test_delete_removes_from_both(sessions) -> None: + primary, fallback = sessions + storage = SnapshotStorage(primary, fallback) + storage.delete("k") + primary.delete.assert_called_once_with("k") + fallback.delete.assert_called_once_with("k") + + +def test_delete_ignores_missing(sessions) -> None: + primary, fallback = sessions + primary.delete.side_effect = RequestError("missing", 404, "") + storage = SnapshotStorage(primary, fallback) + storage.delete("k") + fallback.delete.assert_called_once_with("k") + + +@pytest.mark.parametrize("primary_error", [RequestError("primary", 500, ""), HTTPError("primary")]) +def test_delete_raises_first_non_404_after_trying_both( + sessions, primary_error: RequestError | HTTPError +) -> None: + primary, fallback = sessions + primary.delete.side_effect = primary_error + fallback.delete.side_effect = RequestError("fallback", 503, "") + storage = SnapshotStorage(primary, fallback) + with pytest.raises(type(primary_error), match="primary"): + storage.delete("k") + fallback.delete.assert_called_once_with("k") + + +@patch("sentry.preprod.snapshots.storage.metrics") +def test_fallback_records_metric(mock_metrics, sessions) -> None: + primary, fallback = sessions + primary.get.return_value = None + fallback.get.return_value = None + SnapshotStorage(primary, fallback).get("k") + mock_metrics.incr.assert_called_once_with( + "preprod.snapshot_storage.legacy_fallback", tags={"op": "get", "found": "false"} + ) + + +@patch("sentry.preprod.snapshots.storage.get_session") +def test_factory_builds_all_sessions(mock_get_session) -> None: + get_snapshot_storage(42, org=7) + assert mock_get_session.call_args_list == [ + call(UsecaseId.PREPROD, 42, org=7), + call(UsecaseId.PREPROD_SNAPSHOTS, 42, org=7), + ] diff --git a/tests/sentry/preprod/snapshots/test_zip_tasks.py b/tests/sentry/preprod/snapshots/test_zip_tasks.py index 938c22d88c8e..29d8ea27f1e0 100644 --- a/tests/sentry/preprod/snapshots/test_zip_tasks.py +++ b/tests/sentry/preprod/snapshots/test_zip_tasks.py @@ -19,7 +19,7 @@ ) from sentry.testutils.cases import TestCase -SESSION_TARGET = "sentry.preprod.snapshots.zip_tasks.get_session" +SESSION_TARGET = "sentry.preprod.snapshots.zip_tasks.get_snapshot_storage" def _manifest_bytes() -> bytes: