diff --git a/CHANGELOG.md b/CHANGELOG.md index e178c7e537..7298bd58b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 diff --git a/src/api/mod.rs b/src/api/mod.rs index 4d42c3c662..4fda4ae5b3 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=auto", 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..fa1d3ad404 100644 --- a/src/commands/snapshots/upload.rs +++ b/src/commands/snapshots/upload.rs @@ -444,7 +444,8 @@ 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(); + 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); } diff --git a/tests/integration/snapshots.rs b/tests/integration/snapshots.rs index ed3300730c..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}; @@ -120,7 +121,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=auto", ) .with_status(302) .with_response_body( @@ -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();