Skip to content

Commit 8783c35

Browse files
feat(snapshots): Add download command for baseline snapshots
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 41af7fd commit 8783c35

7 files changed

Lines changed: 233 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features
66

77
- (snapshots) Add `snapshots diff` command for locally comparing directories of PNG snapshot images using odiff ([#3306](https://github.com/getsentry/sentry-cli/pull/3306))
8+
- (snapshots) Add `snapshots download` command for downloading baseline snapshot images from Sentry ([#3310](https://github.com/getsentry/sentry-cli/pull/3310))
89

910
### Performance
1011

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ sha2 = "0.10.9"
7070
sourcemap = { version = "9.3.0", features = ["ram_bundle"] }
7171
symbolic = { version = "12.13.3", features = ["debuginfo-serde", "il2cpp"] }
7272
tar = "0.4"
73+
tempfile = "3.8.1"
7374
thiserror = "1.0.38"
7475
tokio = { version = "1.47", features = ["rt"] }
7576
url = "2.3.1"

src/api/mod.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,45 @@ impl AuthenticatedApi<'_> {
10281028
);
10291029
self.get(&path)?.convert()
10301030
}
1031+
1032+
pub fn get_latest_base_snapshot(
1033+
&self,
1034+
org: &str,
1035+
app_id: &str,
1036+
branch: Option<&str>,
1037+
) -> ApiResult<Option<LatestBaseSnapshotResponse>> {
1038+
let mut path = format!(
1039+
"/organizations/{}/preprodartifacts/snapshots/latest-base/?app_id={}",
1040+
PathArg(org),
1041+
QueryArg(app_id),
1042+
);
1043+
if let Some(branch) = branch {
1044+
path.push_str(&format!("&branch={}", QueryArg(branch)));
1045+
}
1046+
let resp = self.get(&path)?;
1047+
if resp.status() == 404 {
1048+
Ok(None)
1049+
} else {
1050+
resp.convert()
1051+
}
1052+
}
1053+
1054+
pub fn download_snapshot_zip(
1055+
&self,
1056+
org: &str,
1057+
snapshot_id: &str,
1058+
dst: &mut std::fs::File,
1059+
) -> ApiResult<ApiResponse> {
1060+
let path = format!(
1061+
"/organizations/{}/preprodartifacts/snapshots/{}/download/",
1062+
PathArg(org),
1063+
PathArg(snapshot_id),
1064+
);
1065+
self.request(Method::Get, &path)?
1066+
.follow_location(true)?
1067+
.progress_bar_mode(ProgressBarMode::Response)
1068+
.send_into(dst)
1069+
}
10311070
}
10321071

10331072
/// Available datasets for fetching organization events
@@ -2044,6 +2083,12 @@ pub struct LogEntry {
20442083
pub message: Option<String>,
20452084
}
20462085

2086+
#[derive(Deserialize)]
2087+
pub struct LatestBaseSnapshotResponse {
2088+
pub head_artifact_id: String,
2089+
pub image_count: u64,
2090+
}
2091+
20472092
/// Upload options returned by the snapshots upload-options endpoint.
20482093
#[derive(Debug, Deserialize)]
20492094
#[serde(rename_all = "camelCase")]

src/commands/snapshots/download.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
use std::fs;
2+
use std::io::{self, Seek as _};
3+
use std::path::PathBuf;
4+
5+
use anyhow::{bail, Result};
6+
use clap::{Arg, ArgMatches, Command};
7+
8+
use crate::api::Api;
9+
use crate::config::Config;
10+
use crate::utils::fs::path_as_url;
11+
12+
const EXPERIMENTAL_WARNING: &str =
13+
"[EXPERIMENTAL] The \"snapshots download\" command is experimental. \
14+
The command is subject to breaking changes, including removal, in any Sentry CLI release.";
15+
16+
pub fn make_command(command: Command) -> Command {
17+
command
18+
.about("[EXPERIMENTAL] Download baseline snapshot images from Sentry.")
19+
.long_about(format!(
20+
"Download baseline snapshot images from Sentry's preprod system to a local directory.\n\n\
21+
{EXPERIMENTAL_WARNING}"
22+
))
23+
.arg(
24+
Arg::new("app_id")
25+
.long("app-id")
26+
.value_name("APP_ID")
27+
.help("App identifier (e.g. sentry-frontend). Mutually exclusive with --snapshot-id.")
28+
.conflicts_with("snapshot_id"),
29+
)
30+
.arg(
31+
Arg::new("snapshot_id")
32+
.long("snapshot-id")
33+
.value_name("ID")
34+
.help("Direct snapshot artifact ID. Mutually exclusive with --app-id.")
35+
.conflicts_with("app_id"),
36+
)
37+
.arg(
38+
Arg::new("branch")
39+
.long("branch")
40+
.value_name("NAME")
41+
.help("Git branch filter (only with --app-id).")
42+
.requires("app_id"),
43+
)
44+
.arg(
45+
Arg::new("output")
46+
.long("output")
47+
.short('o')
48+
.value_name("DIR")
49+
.help("Directory for extracted images.")
50+
.default_value("./snapshots-base/"),
51+
)
52+
}
53+
54+
pub fn execute(matches: &ArgMatches) -> Result<()> {
55+
eprintln!("{EXPERIMENTAL_WARNING}");
56+
57+
let config = Config::current();
58+
let org = config.get_org(matches)?;
59+
let api_ref = Api::current();
60+
let api = api_ref.authenticated()?;
61+
62+
let app_id = matches.get_one::<String>("app_id");
63+
let snapshot_id_arg = matches.get_one::<String>("snapshot_id");
64+
let branch = matches.get_one::<String>("branch").map(|s| s.as_str());
65+
let output_dir = PathBuf::from(
66+
matches
67+
.get_one::<String>("output")
68+
.expect("output has a default value"),
69+
);
70+
71+
let snapshot_id = match (app_id, snapshot_id_arg) {
72+
(Some(app_id), None) => {
73+
eprintln!("Resolving latest baseline snapshot for app '{app_id}'...");
74+
match api.get_latest_base_snapshot(&org, app_id, branch)? {
75+
Some(resp) => {
76+
eprintln!(
77+
"Found snapshot {} ({} images)",
78+
resp.head_artifact_id, resp.image_count
79+
);
80+
resp.head_artifact_id
81+
}
82+
None => {
83+
let branch_msg = branch
84+
.map(|b| format!(" on branch '{b}'"))
85+
.unwrap_or_default();
86+
bail!("No baseline snapshot found for app '{app_id}'{branch_msg}");
87+
}
88+
}
89+
}
90+
(None, Some(id)) => id.clone(),
91+
_ => bail!("Exactly one of --app-id or --snapshot-id must be provided"),
92+
};
93+
94+
eprintln!("Downloading snapshot {snapshot_id}...");
95+
let mut tmp = tempfile::tempfile()?;
96+
let response = api.download_snapshot_zip(&org, &snapshot_id, &mut tmp)?;
97+
98+
if response.failed() {
99+
bail!(
100+
"Failed to download snapshot (server returned status {}).",
101+
response.status()
102+
);
103+
}
104+
105+
tmp.seek(io::SeekFrom::Start(0))?;
106+
let mut archive = zip::ZipArchive::new(&mut tmp)?;
107+
108+
fs::create_dir_all(&output_dir)?;
109+
110+
let mut extracted = 0usize;
111+
for i in 0..archive.len() {
112+
let mut entry = archive.by_index(i)?;
113+
if entry.is_dir() {
114+
continue;
115+
}
116+
let Some(enclosed_name) = entry.enclosed_name() else {
117+
continue;
118+
};
119+
let out_path = output_dir.join(&enclosed_name);
120+
if let Some(parent) = out_path.parent() {
121+
fs::create_dir_all(parent)?;
122+
}
123+
let mut out_file = fs::File::create(&out_path)?;
124+
io::copy(&mut entry, &mut out_file)?;
125+
extracted += 1;
126+
}
127+
128+
eprintln!(
129+
"\nDownloaded {extracted} images from snapshot {snapshot_id} to {}",
130+
path_as_url(&output_dir)
131+
);
132+
133+
Ok(())
134+
}

src/commands/snapshots/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ use anyhow::Result;
22
use clap::{ArgMatches, Command};
33

44
pub mod diff;
5+
pub mod download;
56

67
macro_rules! each_subcommand {
78
($mac:ident) => {
89
$mac!(diff);
10+
$mac!(download);
911
};
1012
}
1113

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
```
2+
$ sentry-cli snapshots download --help
3+
? success
4+
Download baseline snapshot images from Sentry's preprod system to a local directory.
5+
6+
[EXPERIMENTAL] The "snapshots download" command is experimental. The command is subject to breaking
7+
changes, including removal, in any Sentry CLI release.
8+
9+
Usage: sentry-cli[EXE] snapshots download [OPTIONS]
10+
11+
Options:
12+
--app-id <APP_ID>
13+
App identifier (e.g. sentry-frontend). Mutually exclusive with --snapshot-id.
14+
15+
--header <KEY:VALUE>
16+
Custom headers that should be attached to all requests
17+
in key:value format.
18+
19+
--snapshot-id <ID>
20+
Direct snapshot artifact ID. Mutually exclusive with --app-id.
21+
22+
--auth-token <AUTH_TOKEN>
23+
Use the given Sentry auth token.
24+
25+
--branch <NAME>
26+
Git branch filter (only with --app-id).
27+
28+
--log-level <LOG_LEVEL>
29+
Set the log output verbosity. [possible values: trace, debug, info, warn, error]
30+
31+
-o, --output <DIR>
32+
Directory for extracted images.
33+
34+
[default: ./snapshots-base/]
35+
36+
--quiet
37+
Do not print any output while preserving correct exit code. This flag is currently
38+
implemented only for selected subcommands.
39+
40+
[aliases: --silent]
41+
42+
-h, --help
43+
Print help (see a summary with '-h')
44+
45+
```

tests/integration/snapshots.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ fn command_snapshots_diff_help() {
99
fn command_snapshots_diff_missing_dir() {
1010
TestManager::new().register_trycmd_test("snapshots/snapshots-diff-missing-dir.trycmd");
1111
}
12+
13+
#[test]
14+
fn command_snapshots_download_help() {
15+
TestManager::new().register_trycmd_test("snapshots/snapshots-download-help.trycmd");
16+
}

0 commit comments

Comments
 (0)