diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe44079d9..e68542894c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### New Features ✨ + +- Add `sentry-cli build download` command to download installable builds (IPA/APK) by build ID ([#3221](https://github.com/getsentry/sentry-cli/pull/3221)). + ## 3.3.3 ### Internal Changes 🔧 diff --git a/src/api/data_types/chunking/build.rs b/src/api/data_types/chunking/build.rs index b76a9fcb38..b46e23bb01 100644 --- a/src/api/data_types/chunking/build.rs +++ b/src/api/data_types/chunking/build.rs @@ -28,6 +28,13 @@ pub struct AssembleBuildResponse { pub artifact_url: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildInstallDetails { + pub is_installable: bool, + pub install_url: Option, +} + /// VCS information for build app uploads #[derive(Debug, Serialize)] pub struct VcsInfo<'a> { diff --git a/src/api/data_types/chunking/mod.rs b/src/api/data_types/chunking/mod.rs index 8b5657ee1c..f5d5c86d62 100644 --- a/src/api/data_types/chunking/mod.rs +++ b/src/api/data_types/chunking/mod.rs @@ -10,7 +10,7 @@ mod hash_algorithm; mod upload; pub use self::artifact::{AssembleArtifactsResponse, ChunkedArtifactRequest}; -pub use self::build::{AssembleBuildResponse, ChunkedBuildRequest, VcsInfo}; +pub use self::build::{AssembleBuildResponse, BuildInstallDetails, ChunkedBuildRequest, VcsInfo}; pub use self::compression::ChunkCompression; pub use self::dif::{AssembleDifsRequest, AssembleDifsResponse, ChunkedDifRequest}; pub use self::file_state::ChunkedFileState; diff --git a/src/api/mod.rs b/src/api/mod.rs index df57300b16..9d49b9b1f9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -16,7 +16,6 @@ use std::borrow::Cow; use std::cell::RefCell; use std::collections::HashMap; use std::error::Error as _; -#[cfg(any(target_os = "macos", not(feature = "managed")))] use std::fs::File; use std::io::{self, Read as _, Write}; use std::rc::Rc; @@ -724,6 +723,26 @@ impl AuthenticatedApi<'_> { .convert_rnf(ApiErrorKind::ProjectNotFound) } + pub fn get_build_install_details( + &self, + org: &str, + build_id: &str, + ) -> ApiResult { + let url = format!( + "/organizations/{}/preprodartifacts/{}/install-details/", + PathArg(org), + PathArg(build_id) + ); + + self.get(&url)?.convert() + } + + pub fn download_installable_build(&self, url: &str, dst: &mut File) -> ApiResult { + self.request(Method::Get, url)? + .progress_bar_mode(ProgressBarMode::Response) + .send_into(dst) + } + /// List all organizations associated with the authenticated token /// in the given `Region`. If no `Region` is provided, we assume /// we're issuing a request to a monolith deployment. diff --git a/src/commands/build/download.rs b/src/commands/build/download.rs new file mode 100644 index 0000000000..a4c6b22f18 --- /dev/null +++ b/src/commands/build/download.rs @@ -0,0 +1,100 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use clap::{Arg, ArgMatches, Command}; +use log::info; + +use crate::api::Api; +use crate::config::Config; +use crate::utils::args::ArgExt as _; +use crate::utils::fs::TempFile; + +pub fn make_command(command: Command) -> Command { + command + .about("Download a build from a project.") + .long_about("Download a build from a project.\n\nThis feature only works with Sentry SaaS.") + .org_arg() + .project_arg(false) + .arg( + Arg::new("build_id") + .long("build-id") + .short('b') + .required(true) + .help("The ID of the build to download."), + ) + .arg(Arg::new("output").long("output").help( + "The output file path. Defaults to \ + 'preprod_artifact_.' in the current directory, \ + where ext is ipa or apk depending on the platform.", + )) +} + +/// For iOS builds, the install URL points to a plist manifest. +/// Replace the response_format to download the actual IPA binary instead. +fn ensure_binary_format(url: &str) -> String { + url.replace("response_format=plist", "response_format=ipa") +} + +/// Extract the file extension from the response_format query parameter. +fn extension_from_url(url: &str) -> Result<&str> { + if url.contains("response_format=ipa") { + Ok("ipa") + } else if url.contains("response_format=apk") { + Ok("apk") + } else { + bail!("Unsupported build format in download URL.") + } +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + let config = Config::current(); + let org = config.get_org(matches)?; + let build_id = matches + .get_one::("build_id") + .expect("build_id is required"); + + let api = Api::current(); + let authenticated_api = api.authenticated()?; + + info!("Fetching install details for build {build_id}"); + let details = authenticated_api.get_build_install_details(&org, build_id)?; + + if !details.is_installable { + bail!("Build {build_id} is not installable."); + } + + let install_url = details + .install_url + .ok_or_else(|| anyhow::anyhow!("Build {build_id} has no install URL."))?; + + let download_url = ensure_binary_format(&install_url); + + let output_path = match matches.get_one::("output") { + Some(path) => PathBuf::from(path), + None => { + let ext = extension_from_url(&download_url)?; + PathBuf::from(format!("preprod_artifact_{build_id}.{ext}")) + } + }; + + info!("Downloading build {build_id} to {}", output_path.display()); + + let tmp = TempFile::create()?; + let mut file = tmp.open()?; + let response = authenticated_api.download_installable_build(&download_url, &mut file)?; + + if response.failed() { + bail!( + "Failed to download build (server returned status {}).", + response.status() + ); + } + + drop(file); + fs::copy(tmp.path(), &output_path)?; + + println!("Successfully downloaded build to {}", output_path.display()); + + Ok(()) +} diff --git a/src/commands/build/mod.rs b/src/commands/build/mod.rs index d302aa0791..55b28d1dfb 100644 --- a/src/commands/build/mod.rs +++ b/src/commands/build/mod.rs @@ -3,11 +3,13 @@ use clap::{ArgMatches, Command}; use crate::utils::args::ArgExt as _; +pub mod download; pub mod snapshots; pub mod upload; macro_rules! each_subcommand { ($mac:ident) => { + $mac!(download); $mac!(snapshots); $mac!(upload); }; diff --git a/tests/integration/_cases/build/build-download-help.trycmd b/tests/integration/_cases/build/build-download-help.trycmd new file mode 100644 index 0000000000..2bfa711fa6 --- /dev/null +++ b/tests/integration/_cases/build/build-download-help.trycmd @@ -0,0 +1,43 @@ +``` +$ sentry-cli build download --help +? success +Download a build from a project. + +This feature only works with Sentry SaaS. + +Usage: sentry-cli[EXE] build download [OPTIONS] --build-id + +Options: + -o, --org + The organization ID or slug. + + --header + Custom headers that should be attached to all requests + in key:value format. + + -p, --project + The project ID or slug. + + --auth-token + Use the given Sentry auth token. + + -b, --build-id + The ID of the build to download. + + --log-level + Set the log output verbosity. [possible values: trace, debug, info, warn, error] + + --output + The output file path. Defaults to 'preprod_artifact_.' in the current + directory, where ext is ipa or apk depending on the platform. + + --quiet + Do not print any output while preserving correct exit code. This flag is currently + implemented only for selected subcommands. + + [aliases: --silent] + + -h, --help + Print help (see a summary with '-h') + +``` diff --git a/tests/integration/_cases/build/build-help.trycmd b/tests/integration/_cases/build/build-help.trycmd index a04443131c..535f77c803 100644 --- a/tests/integration/_cases/build/build-help.trycmd +++ b/tests/integration/_cases/build/build-help.trycmd @@ -6,6 +6,7 @@ Manage builds. Usage: sentry-cli[EXE] build [OPTIONS] Commands: + download Download a build from a project. snapshots [EXPERIMENTAL] Upload build snapshots to a project. upload Upload builds to a project. help Print this message or the help of the given subcommand(s) diff --git a/tests/integration/build/download.rs b/tests/integration/build/download.rs new file mode 100644 index 0000000000..1110325bfd --- /dev/null +++ b/tests/integration/build/download.rs @@ -0,0 +1,129 @@ +use crate::integration::{AssertCommand, MockEndpointBuilder, TestManager}; + +#[test] +fn command_build_download_help() { + TestManager::new().register_trycmd_test("build/build-download-help.trycmd"); +} + +#[test] +fn command_build_download_not_installable() { + TestManager::new() + .mock_endpoint( + MockEndpointBuilder::new( + "GET", + "/api/0/organizations/wat-org/preprodartifacts/123/install-details/", + ) + .with_response_body(r#"{"isInstallable": false, "installUrl": null}"#), + ) + .assert_cmd(vec!["build", "download", "--build-id", "123"]) + .with_default_token() + .run_and_assert(AssertCommand::Failure); +} + +#[test] +fn command_build_download_apk() { + let manager = TestManager::new(); + let server_url = manager.server_url(); + let download_path = format!("{server_url}/download/build.apk?response_format=apk"); + let install_details_response = serde_json::json!({ + "isInstallable": true, + "installUrl": download_path, + }) + .to_string(); + + let output = tempfile::NamedTempFile::new().expect("Failed to create temp file"); + let output_path = output.path().to_str().unwrap().to_owned(); + + manager + .mock_endpoint( + MockEndpointBuilder::new( + "GET", + "/api/0/organizations/wat-org/preprodartifacts/456/install-details/", + ) + .with_response_body(install_details_response), + ) + .mock_endpoint( + MockEndpointBuilder::new("GET", "/download/build.apk?response_format=apk") + .with_response_body("fake apk content"), + ) + .assert_cmd(vec![ + "build", + "download", + "--build-id", + "456", + "--output", + &output_path, + ]) + .with_default_token() + .run_and_assert(AssertCommand::Success); + + let content = std::fs::read_to_string(&output_path).expect("Failed to read downloaded file"); + assert_eq!(content, "fake apk content"); +} + +#[test] +fn command_build_download_ipa_converts_plist_format() { + let manager = TestManager::new(); + let server_url = manager.server_url(); + // The install URL uses plist format, which should be converted to ipa + let install_url = format!("{server_url}/download/build.ipa?response_format=plist"); + let install_details_response = serde_json::json!({ + "isInstallable": true, + "installUrl": install_url, + }) + .to_string(); + + let output = tempfile::NamedTempFile::new().expect("Failed to create temp file"); + let output_path = output.path().to_str().unwrap().to_owned(); + + manager + .mock_endpoint( + MockEndpointBuilder::new( + "GET", + "/api/0/organizations/wat-org/preprodartifacts/789/install-details/", + ) + .with_response_body(install_details_response), + ) + // The mock should receive the converted URL with response_format=ipa + .mock_endpoint( + MockEndpointBuilder::new("GET", "/download/build.ipa?response_format=ipa") + .with_response_body("fake ipa content"), + ) + .assert_cmd(vec![ + "build", + "download", + "--build-id", + "789", + "--output", + &output_path, + ]) + .with_default_token() + .run_and_assert(AssertCommand::Success); + + let content = std::fs::read_to_string(&output_path).expect("Failed to read downloaded file"); + assert_eq!(content, "fake ipa content"); +} + +#[test] +fn command_build_download_unsupported_format() { + let manager = TestManager::new(); + let server_url = manager.server_url(); + let download_path = format!("{server_url}/download/build.zip?response_format=zip"); + let install_details_response = serde_json::json!({ + "isInstallable": true, + "installUrl": download_path, + }) + .to_string(); + + manager + .mock_endpoint( + MockEndpointBuilder::new( + "GET", + "/api/0/organizations/wat-org/preprodartifacts/999/install-details/", + ) + .with_response_body(install_details_response), + ) + .assert_cmd(vec!["build", "download", "--build-id", "999"]) + .with_default_token() + .run_and_assert(AssertCommand::Failure); +} diff --git a/tests/integration/build/mod.rs b/tests/integration/build/mod.rs index 239def73c5..2932868d41 100644 --- a/tests/integration/build/mod.rs +++ b/tests/integration/build/mod.rs @@ -1,5 +1,6 @@ use crate::integration::TestManager; +mod download; mod upload; #[test]