Skip to content

Commit a218cba

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

7 files changed

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

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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
-o, --org <ORG>
13+
The organization ID or slug.
14+
15+
--app-id <APP_ID>
16+
App identifier (e.g. sentry-frontend). Mutually exclusive with --snapshot-id.
17+
18+
--header <KEY:VALUE>
19+
Custom headers that should be attached to all requests
20+
in key:value format.
21+
22+
--auth-token <AUTH_TOKEN>
23+
Use the given Sentry auth token.
24+
25+
--snapshot-id <ID>
26+
Direct snapshot artifact ID. Mutually exclusive with --app-id.
27+
28+
--branch <NAME>
29+
Git branch filter (only with --app-id).
30+
31+
--log-level <LOG_LEVEL>
32+
Set the log output verbosity. [possible values: trace, debug, info, warn, error]
33+
34+
--output <DIR>
35+
Directory for extracted images.
36+
37+
[default: ./snapshots-base/]
38+
39+
--quiet
40+
Do not print any output while preserving correct exit code. This flag is currently
41+
implemented only for selected subcommands.
42+
43+
[aliases: --silent]
44+
45+
-h, --help
46+
Print help (see a summary with '-h')
47+
48+
```

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)