From a218cbaf5293a9a85a8a1d3cf074bb9adcb9cfbf Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Thu, 21 May 2026 14:00:23 -0700 Subject: [PATCH] feat(snapshots): Add download command for baseline snapshots Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + Cargo.toml | 1 + src/api/mod.rs | 45 ++++++ src/commands/snapshots/download.rs | 135 ++++++++++++++++++ src/commands/snapshots/mod.rs | 2 + .../snapshots/snapshots-download-help.trycmd | 48 +++++++ tests/integration/snapshots.rs | 5 + 7 files changed, 237 insertions(+) create mode 100644 src/commands/snapshots/download.rs create mode 100644 tests/integration/_cases/snapshots/snapshots-download-help.trycmd diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6dda1128..ba52c40ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - (snapshots) Add `snapshots diff` command for locally comparing directories of PNG snapshot images using odiff ([#3306](https://github.com/getsentry/sentry-cli/pull/3306)) +- (snapshots) Add `snapshots download` command for downloading baseline snapshot images from Sentry ([#3310](https://github.com/getsentry/sentry-cli/pull/3310)) ### Performance diff --git a/Cargo.toml b/Cargo.toml index 902e95317a..706883df2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ sha2 = "0.10.9" sourcemap = { version = "9.3.0", features = ["ram_bundle"] } symbolic = { version = "12.13.3", features = ["debuginfo-serde", "il2cpp"] } tar = "0.4" +tempfile = "3.8.1" thiserror = "1.0.38" tokio = { version = "1.47", features = ["rt"] } url = "2.3.1" diff --git a/src/api/mod.rs b/src/api/mod.rs index b53e034953..fdaa6ad9c6 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1028,6 +1028,45 @@ impl AuthenticatedApi<'_> { ); self.get(&path)?.convert() } + + pub fn get_latest_base_snapshot( + &self, + org: &str, + app_id: &str, + branch: Option<&str>, + ) -> ApiResult> { + let mut path = format!( + "/organizations/{}/preprodartifacts/snapshots/latest-base/?app_id={}", + PathArg(org), + QueryArg(app_id), + ); + if let Some(branch) = branch { + path.push_str(&format!("&branch={}", QueryArg(branch))); + } + let resp = self.get(&path)?; + if resp.status() == 404 { + Ok(None) + } else { + resp.convert() + } + } + + pub fn download_snapshot_zip( + &self, + org: &str, + snapshot_id: &str, + dst: &mut std::fs::File, + ) -> ApiResult { + let path = format!( + "/organizations/{}/preprodartifacts/snapshots/{}/download/", + PathArg(org), + PathArg(snapshot_id), + ); + self.request(Method::Get, &path)? + .follow_location(true)? + .progress_bar_mode(ProgressBarMode::Response) + .send_into(dst) + } } /// Available datasets for fetching organization events @@ -2044,6 +2083,12 @@ pub struct LogEntry { pub message: Option, } +#[derive(Deserialize)] +pub struct LatestBaseSnapshotResponse { + pub head_artifact_id: String, + pub image_count: u64, +} + /// Upload options returned by the snapshots upload-options endpoint. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src/commands/snapshots/download.rs b/src/commands/snapshots/download.rs new file mode 100644 index 0000000000..d7ff6a20aa --- /dev/null +++ b/src/commands/snapshots/download.rs @@ -0,0 +1,135 @@ +use std::fs; +use std::io::{self, Seek as _}; +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use clap::{Arg, ArgMatches, Command}; + +use crate::api::Api; +use crate::config::Config; +use crate::utils::args::ArgExt as _; +use crate::utils::fs::path_as_url; + +const EXPERIMENTAL_WARNING: &str = + "[EXPERIMENTAL] The \"snapshots download\" command is experimental. \ + The command is subject to breaking changes, including removal, in any Sentry CLI release."; + +pub fn make_command(command: Command) -> Command { + command + .about("[EXPERIMENTAL] Download baseline snapshot images from Sentry.") + .long_about(format!( + "Download baseline snapshot images from Sentry's preprod system to a local directory.\n\n\ + {EXPERIMENTAL_WARNING}" + )) + .org_arg() + .arg( + Arg::new("app_id") + .long("app-id") + .value_name("APP_ID") + .help("App identifier (e.g. sentry-frontend). Mutually exclusive with --snapshot-id.") + .conflicts_with("snapshot_id"), + ) + .arg( + Arg::new("snapshot_id") + .long("snapshot-id") + .value_name("ID") + .help("Direct snapshot artifact ID. Mutually exclusive with --app-id.") + .conflicts_with("app_id"), + ) + .arg( + Arg::new("branch") + .long("branch") + .value_name("NAME") + .help("Git branch filter (only with --app-id).") + .requires("app_id"), + ) + .arg( + Arg::new("output") + .long("output") + .value_name("DIR") + .help("Directory for extracted images.") + .default_value("./snapshots-base/"), + ) +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + eprintln!("{EXPERIMENTAL_WARNING}"); + + let config = Config::current(); + let org = config.get_org(matches)?; + let api_ref = Api::current(); + let api = api_ref.authenticated()?; + + let app_id = matches.get_one::("app_id"); + let snapshot_id_arg = matches.get_one::("snapshot_id"); + let branch = matches.get_one::("branch").map(|s| s.as_str()); + let output_dir = PathBuf::from( + matches + .get_one::("output") + .expect("output has a default value"), + ); + + let snapshot_id = match (app_id, snapshot_id_arg) { + (Some(app_id), None) => { + eprintln!("Resolving latest baseline snapshot for app '{app_id}'..."); + match api.get_latest_base_snapshot(&org, app_id, branch)? { + Some(resp) => { + eprintln!( + "Found snapshot {} ({} images)", + resp.head_artifact_id, resp.image_count + ); + resp.head_artifact_id + } + None => { + let branch_msg = branch + .map(|b| format!(" on branch '{b}'")) + .unwrap_or_default(); + bail!("No baseline snapshot found for app '{app_id}'{branch_msg}"); + } + } + } + (None, Some(id)) => id.clone(), + _ => bail!("Exactly one of --app-id or --snapshot-id must be provided"), + }; + + eprintln!("Downloading snapshot {snapshot_id}..."); + let mut tmp = tempfile::tempfile()?; + let response = api.download_snapshot_zip(&org, &snapshot_id, &mut tmp)?; + + if response.failed() { + bail!( + "Failed to download snapshot (server returned status {}).", + response.status() + ); + } + + tmp.seek(io::SeekFrom::Start(0))?; + let mut archive = zip::ZipArchive::new(&mut tmp)?; + + fs::create_dir_all(&output_dir)?; + + let mut extracted = 0usize; + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + if entry.is_dir() { + continue; + } + let Some(enclosed_name) = entry.enclosed_name() else { + continue; + }; + let out_path = output_dir.join(&enclosed_name); + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent)?; + } + let mut out_file = fs::File::create(&out_path)?; + io::copy(&mut entry, &mut out_file)?; + extracted += 1; + } + + eprintln!( + "\nDownloaded {extracted} images from snapshot {snapshot_id} to {}", + path_as_url(&output_dir) + ); + + Ok(()) +} diff --git a/src/commands/snapshots/mod.rs b/src/commands/snapshots/mod.rs index 79815be3e9..6287d7a14e 100644 --- a/src/commands/snapshots/mod.rs +++ b/src/commands/snapshots/mod.rs @@ -2,10 +2,12 @@ use anyhow::Result; use clap::{ArgMatches, Command}; pub mod diff; +pub mod download; macro_rules! each_subcommand { ($mac:ident) => { $mac!(diff); + $mac!(download); }; } diff --git a/tests/integration/_cases/snapshots/snapshots-download-help.trycmd b/tests/integration/_cases/snapshots/snapshots-download-help.trycmd new file mode 100644 index 0000000000..df61309479 --- /dev/null +++ b/tests/integration/_cases/snapshots/snapshots-download-help.trycmd @@ -0,0 +1,48 @@ +``` +$ sentry-cli snapshots download --help +? success +Download baseline snapshot images from Sentry's preprod system to a local directory. + +[EXPERIMENTAL] The "snapshots download" command is experimental. The command is subject to breaking +changes, including removal, in any Sentry CLI release. + +Usage: sentry-cli[EXE] snapshots download [OPTIONS] + +Options: + -o, --org + The organization ID or slug. + + --app-id + App identifier (e.g. sentry-frontend). Mutually exclusive with --snapshot-id. + + --header + Custom headers that should be attached to all requests + in key:value format. + + --auth-token + Use the given Sentry auth token. + + --snapshot-id + Direct snapshot artifact ID. Mutually exclusive with --app-id. + + --branch + Git branch filter (only with --app-id). + + --log-level + Set the log output verbosity. [possible values: trace, debug, info, warn, error] + + --output + Directory for extracted images. + + [default: ./snapshots-base/] + + --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/snapshots.rs b/tests/integration/snapshots.rs index 909890cf17..6e001d010a 100644 --- a/tests/integration/snapshots.rs +++ b/tests/integration/snapshots.rs @@ -9,3 +9,8 @@ fn command_snapshots_diff_help() { fn command_snapshots_diff_missing_dir() { TestManager::new().register_trycmd_test("snapshots/snapshots-diff-missing-dir.trycmd"); } + +#[test] +fn command_snapshots_download_help() { + TestManager::new().register_trycmd_test("snapshots/snapshots-download-help.trycmd"); +}