From ff605c373f745c6b9f1ef55f1891094144be22ba Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Fri, 11 Sep 2026 09:56:09 -0700 Subject: [PATCH 1/5] feat(snapshots): Upload images under the snapshots objectstore usecase --- CHANGELOG.md | 4 ++++ src/api/mod.rs | 8 +++++++- src/commands/snapshots/upload.rs | 2 +- tests/integration/snapshots.rs | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e178c7e537..6cc6d254d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - (build) Add dSYM support to IPA uploads ([#3393](https://github.com/getsentry/sentry-cli/pull/3393)) +### Improvements + +- (snapshots) Upload snapshot images under the dedicated `snapshots` objectstore usecase when the server supports it + ### Fixes - (snapshots) Create snapshot builds for selective uploads with no affected images when a complete filename manifest is provided ([#3395](https://github.com/getsentry/sentry-cli/pull/3395)) diff --git a/src/api/mod.rs b/src/api/mod.rs index 4d42c3c662..1235d89841 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1015,7 +1015,7 @@ impl AuthenticatedApi<'_> { project: &str, ) -> ApiResult { let path = format!( - "/projects/{}/{}/preprodartifacts/snapshots/upload-options/", + "/projects/{}/{}/preprodartifacts/snapshots/upload-options/?usecase=snapshots", PathArg(org), PathArg(project) ); @@ -2132,11 +2132,17 @@ pub struct SnapshotsUploadOptions { #[serde(rename_all = "camelCase")] pub struct ObjectstoreUploadOptions { pub url: String, + #[serde(default = "legacy_objectstore_usecase")] + pub usecase: String, pub scopes: Vec<(String, String)>, pub auth_token: Option, pub expiration_policy: String, } +fn legacy_objectstore_usecase() -> String { + "preprod".into() +} + #[cfg(test)] mod tests { use std::error::Error as _; diff --git a/src/commands/snapshots/upload.rs b/src/commands/snapshots/upload.rs index d9d9461909..c759b801a4 100644 --- a/src/commands/snapshots/upload.rs +++ b/src/commands/snapshots/upload.rs @@ -444,7 +444,7 @@ fn upload_images( let org_id = find_scope("org").context("Missing org in UploadOptions scope")?; let project_id = find_scope("project").context("Missing project in UploadOptions scope")?; - let mut scope = Usecase::new("preprod").scope(); + let mut scope = Usecase::new(&options.objectstore.usecase).scope(); for (key, value) in scopes { scope = scope.push(&key, value); } diff --git a/tests/integration/snapshots.rs b/tests/integration/snapshots.rs index ed3300730c..d5a1ca8732 100644 --- a/tests/integration/snapshots.rs +++ b/tests/integration/snapshots.rs @@ -120,7 +120,7 @@ fn command_snapshots_upload_renamed_project() { .mock_endpoint( MockEndpointBuilder::new( "GET", - "/api/0/projects/wat-org/wat-project/preprodartifacts/snapshots/upload-options/", + "/api/0/projects/wat-org/wat-project/preprodartifacts/snapshots/upload-options/?usecase=snapshots", ) .with_status(302) .with_response_body( From 7cf372804bec1a85484efd3aa0b888ddb65bcfc4 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Fri, 11 Sep 2026 10:44:30 -0700 Subject: [PATCH 2/5] test(snapshots): Cover usecase default and log resolved usecase --- src/api/mod.rs | 11 +++++++++++ src/commands/snapshots/upload.rs | 1 + 2 files changed, 12 insertions(+) diff --git a/src/api/mod.rs b/src/api/mod.rs index 1235d89841..a57a996410 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2160,6 +2160,17 @@ mod tests { } } + #[test] + fn objectstore_upload_options_usecase_defaults_to_preprod() { + let json = r#"{"url":"https://o.example","scopes":[],"expirationPolicy":"tti:30d"}"#; + let options: ObjectstoreUploadOptions = serde_json::from_str(json).unwrap(); + assert_eq!(options.usecase, "preprod"); + + let json = r#"{"url":"https://o.example","usecase":"snapshots","scopes":[],"expirationPolicy":"tti:30d"}"#; + let options: ObjectstoreUploadOptions = serde_json::from_str(json).unwrap(); + assert_eq!(options.usecase, "snapshots"); + } + #[test] fn convert_rnf_reports_project_rename() { let err = project_renamed_response() diff --git a/src/commands/snapshots/upload.rs b/src/commands/snapshots/upload.rs index c759b801a4..fa1d3ad404 100644 --- a/src/commands/snapshots/upload.rs +++ b/src/commands/snapshots/upload.rs @@ -444,6 +444,7 @@ fn upload_images( let org_id = find_scope("org").context("Missing org in UploadOptions scope")?; let project_id = find_scope("project").context("Missing project in UploadOptions scope")?; + debug!("Using objectstore usecase {}", options.objectstore.usecase); let mut scope = Usecase::new(&options.objectstore.usecase).scope(); for (key, value) in scopes { scope = scope.push(&key, value); From c5a3395e712c2a6ddf25b85b935d62294b52fbe3 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Fri, 11 Sep 2026 11:33:23 -0700 Subject: [PATCH 3/5] docs(changelog): Link snapshots usecase entry to its PR --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc6d254d1..57e8af8a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Improvements -- (snapshots) Upload snapshot images under the dedicated `snapshots` objectstore usecase when the server supports it +- (snapshots) Upload snapshot images under the dedicated `snapshots` objectstore usecase when the server supports it ([#3408](https://github.com/getsentry/sentry-cli/pull/3408)) ### Fixes From fd0097ce992dc18339ecff3ece7096c56b6d19e2 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Fri, 11 Sep 2026 13:52:23 -0700 Subject: [PATCH 4/5] ref(snapshots): Let the server choose the objectstore usecase Request usecase=auto so the server picks the usecase from its own config, leaving future usecase changes entirely server-side. --- CHANGELOG.md | 2 +- src/api/mod.rs | 2 +- tests/integration/snapshots.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e8af8a6d..8378e5c791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Improvements -- (snapshots) Upload snapshot images under the dedicated `snapshots` objectstore usecase when the server supports it ([#3408](https://github.com/getsentry/sentry-cli/pull/3408)) +- (snapshots) Upload snapshot images to the objectstore usecase chosen by the server instead of a hardcoded one ([#3408](https://github.com/getsentry/sentry-cli/pull/3408)) ### Fixes diff --git a/src/api/mod.rs b/src/api/mod.rs index a57a996410..6e8d4137c5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1015,7 +1015,7 @@ impl AuthenticatedApi<'_> { project: &str, ) -> ApiResult { let path = format!( - "/projects/{}/{}/preprodartifacts/snapshots/upload-options/?usecase=snapshots", + "/projects/{}/{}/preprodartifacts/snapshots/upload-options/?usecase=auto", PathArg(org), PathArg(project) ); diff --git a/tests/integration/snapshots.rs b/tests/integration/snapshots.rs index d5a1ca8732..c1dd100698 100644 --- a/tests/integration/snapshots.rs +++ b/tests/integration/snapshots.rs @@ -120,7 +120,7 @@ fn command_snapshots_upload_renamed_project() { .mock_endpoint( MockEndpointBuilder::new( "GET", - "/api/0/projects/wat-org/wat-project/preprodartifacts/snapshots/upload-options/?usecase=snapshots", + "/api/0/projects/wat-org/wat-project/preprodartifacts/snapshots/upload-options/?usecase=auto", ) .with_status(302) .with_response_body( From 2053b7db7d3d024352b8fb2718578753bfc37341 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Mon, 14 Sep 2026 11:12:23 -0700 Subject: [PATCH 5/5] test(snapshots): Cover server-selected objectstore upload routing Exercise authenticated batch checks and uploads for preprod_snapshots and preprod, including the legacy response without a usecase. Keep the changelog entry unreleased. --- CHANGELOG.md | 10 ++-- src/api/mod.rs | 11 ----- tests/integration/snapshots.rs | 84 ++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8378e5c791..7298bd58b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,17 @@ # Changelog +## Unreleased + +### Improvements + +- (snapshots) Upload snapshot images to the objectstore usecase chosen by the server instead of a hardcoded one ([#3408](https://github.com/getsentry/sentry-cli/pull/3408)) + ## 3.7.0 ### Features - (build) Add dSYM support to IPA uploads ([#3393](https://github.com/getsentry/sentry-cli/pull/3393)) -### Improvements - -- (snapshots) Upload snapshot images to the objectstore usecase chosen by the server instead of a hardcoded one ([#3408](https://github.com/getsentry/sentry-cli/pull/3408)) - ### Fixes - (snapshots) Create snapshot builds for selective uploads with no affected images when a complete filename manifest is provided ([#3395](https://github.com/getsentry/sentry-cli/pull/3395)) diff --git a/src/api/mod.rs b/src/api/mod.rs index 6e8d4137c5..4fda4ae5b3 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2160,17 +2160,6 @@ mod tests { } } - #[test] - fn objectstore_upload_options_usecase_defaults_to_preprod() { - let json = r#"{"url":"https://o.example","scopes":[],"expirationPolicy":"tti:30d"}"#; - let options: ObjectstoreUploadOptions = serde_json::from_str(json).unwrap(); - assert_eq!(options.usecase, "preprod"); - - let json = r#"{"url":"https://o.example","usecase":"snapshots","scopes":[],"expirationPolicy":"tti:30d"}"#; - let options: ObjectstoreUploadOptions = serde_json::from_str(json).unwrap(); - assert_eq!(options.usecase, "snapshots"); - } - #[test] fn convert_rnf_reports_project_rename() { let err = project_renamed_response() diff --git a/tests/integration/snapshots.rs b/tests/integration/snapshots.rs index c1dd100698..e00d5825ab 100644 --- a/tests/integration/snapshots.rs +++ b/tests/integration/snapshots.rs @@ -3,6 +3,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use serde_json::json; +use sha2::{Digest as _, Sha256}; use crate::integration::{AssertCommand, MockEndpointBuilder, TestManager}; @@ -131,6 +132,89 @@ fn command_snapshots_upload_renamed_project() { .with_default_token(); } +#[rstest::rstest] +#[case::preprod_snapshots(Some("preprod_snapshots"), "preprod_snapshots")] +#[case::preprod(Some("preprod"), "preprod")] +#[case::legacy(None, "preprod")] +fn command_snapshots_upload_uses_server_usecase( + #[case] returned_usecase: Option<&str>, + #[case] expected_usecase: &str, +) { + let mut objectstore = mockito::Server::new(); + let image = std::fs::read("tests/integration/_fixtures/snapshots/snapshot.png").unwrap(); + let hash = format!("{:x}", Sha256::digest(image)); + let batch_path = format!("/proxy/v1/objects:batch/{expected_usecase}/org=1;project=2/"); + let objectstore_mocks: Vec<_> = [("head", 404), ("insert", 200)] + .into_iter() + .map(|(operation, status)| { + objectstore + .mock("POST", batch_path.as_str()) + .match_header("x-os-auth", "Bearer objectstore-token") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::Regex(format!( + "x-sn-batch-operation-kind: {operation}\\r\\n" + )), + mockito::Matcher::Regex(format!( + "x-sn-batch-operation-key: 1%2F2%2F{hash}\\r\\n" + )), + ])) + .with_header("content-type", "multipart/form-data; boundary=response") + .with_body(format!( + "--response\r\n\ + Content-Disposition: form-data; name=\"part\"\r\n\ + x-sn-batch-operation-index: 0\r\n\ + x-sn-batch-operation-status: {status}\r\n\ + \r\n\r\n--response--\r\n" + )) + .expect(1) + .create() + }) + .collect(); + let mut upload_options = json!({ + "objectstore": { + "url": format!("{}/proxy", objectstore.url()), + "scopes": [["org", "1"], ["project", "2"]], + "authToken": "objectstore-token", + "expirationPolicy": "tti:30d" + } + }); + if let Some(usecase) = returned_usecase { + upload_options["objectstore"]["usecase"] = json!(usecase); + } + + TestManager::new() + .mock_endpoint( + MockEndpointBuilder::new( + "GET", + "/api/0/projects/wat-org/wat-project/preprodartifacts/snapshots/upload-options/?usecase=auto", + ) + .expect(1) + .with_response_body(upload_options.to_string()), + ) + .mock_endpoint( + MockEndpointBuilder::new( + "POST", + "/api/0/projects/wat-org/wat-project/preprodartifacts/snapshots/", + ) + .expect(1) + .with_response_body(r#"{"artifactId":"snapshot-id","imageCount":1,"snapshotUrl":null}"#), + ) + .assert_cmd(vec![ + "snapshots", + "upload", + "tests/integration/_fixtures/snapshots", + "--app-id", + "test-app", + "--no-git-metadata", + ]) + .with_default_token() + .run_and_assert(AssertCommand::Success); + + for mock in objectstore_mocks { + mock.assert(); + } +} + #[test] fn command_snapshots_upload_empty_selective_with_inline_names() { let snapshots = tempfile::tempdir().unwrap();