Skip to content

Commit a33edf2

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

9 files changed

Lines changed: 275 additions & 0 deletions

File tree

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
/src/commands/snapshots @getsentry/emerge-tools @getsentry/owners-sentry-cli
2020
/src/utils/odiff @getsentry/emerge-tools @getsentry/owners-sentry-cli
2121
/tests/integration/build @getsentry/emerge-tools @getsentry/owners-sentry-cli
22+
/tests/integration/snapshots.rs @getsentry/emerge-tools @getsentry/owners-sentry-cli
23+
/tests/integration/_cases/snapshots @getsentry/emerge-tools @getsentry/owners-sentry-cli
2224

2325
# Files without codeowner protection
2426
/CHANGELOG.md

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
### Fixes
1011

Cargo.lock

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ sha1_smol = { version = "1.0.0", features = ["serde", "std"] }
6969
sha2 = "0.10.9"
7070
sourcemap = { version = "9.3.0", features = ["ram_bundle"] }
7171
symbolic = { version = "12.13.3", features = ["debuginfo-serde", "il2cpp"] }
72+
tar = "0.4"
7273
thiserror = "1.0.38"
7374
tokio = { version = "1.47", features = ["rt"] }
7475
url = "2.3.1"

src/api/mod.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,49 @@ impl AuthenticatedApi<'_> {
10361036
);
10371037
self.get(&path)?.convert()
10381038
}
1039+
1040+
pub fn get_latest_base_snapshot(
1041+
&self,
1042+
org: &str,
1043+
app_id: &str,
1044+
branch: Option<&str>,
1045+
project: Option<&str>,
1046+
) -> ApiResult<Option<LatestBaseSnapshotResponse>> {
1047+
let mut path = format!(
1048+
"/organizations/{}/preprodartifacts/snapshots/latest-base/?app_id={}",
1049+
PathArg(org),
1050+
QueryArg(app_id),
1051+
);
1052+
if let Some(branch) = branch {
1053+
path.push_str(&format!("&branch={}", QueryArg(branch)));
1054+
}
1055+
if let Some(project) = project {
1056+
path.push_str(&format!("&project={}", QueryArg(project)));
1057+
}
1058+
let resp = self.get(&path)?;
1059+
if resp.status() == 404 {
1060+
Ok(None)
1061+
} else {
1062+
resp.convert()
1063+
}
1064+
}
1065+
1066+
pub fn download_snapshot_zip(
1067+
&self,
1068+
org: &str,
1069+
snapshot_id: &str,
1070+
dst: &mut std::fs::File,
1071+
) -> ApiResult<ApiResponse> {
1072+
let path = format!(
1073+
"/organizations/{}/preprodartifacts/snapshots/{}/download/",
1074+
PathArg(org),
1075+
PathArg(snapshot_id),
1076+
);
1077+
self.request(Method::Get, &path)?
1078+
.follow_location(true)?
1079+
.progress_bar_mode(ProgressBarMode::Response)
1080+
.send_into(dst)
1081+
}
10391082
}
10401083

10411084
/// Available datasets for fetching organization events
@@ -2052,6 +2095,12 @@ pub struct LogEntry {
20522095
pub message: Option<String>,
20532096
}
20542097

2098+
#[derive(Deserialize)]
2099+
pub struct LatestBaseSnapshotResponse {
2100+
pub head_artifact_id: String,
2101+
pub image_count: u64,
2102+
}
2103+
20552104
/// Upload options returned by the snapshots upload-options endpoint.
20562105
#[derive(Debug, Deserialize)]
20572106
#[serde(rename_all = "camelCase")]

src/commands/snapshots/download.rs

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

src/commands/snapshots/mod.rs

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

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

78
macro_rules! each_subcommand {
89
($mac:ident) => {
910
$mac!(diff);
11+
$mac!(download);
1012
$mac!(upload);
1113
};
1214
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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+
Use --snapshot-id to download a specific snapshot, or --app-id to resolve the latest baseline (org
7+
auth tokens require --project with a numeric project ID for --app-id).
8+
9+
[EXPERIMENTAL] The "snapshots download" command is experimental. The command is subject to breaking
10+
changes, including removal, in any Sentry CLI release.
11+
12+
Usage: sentry-cli[EXE] snapshots download [OPTIONS]
13+
14+
Options:
15+
-o, --org <ORG>
16+
The organization ID or slug.
17+
18+
--header <KEY:VALUE>
19+
Custom headers that should be attached to all requests
20+
in key:value format.
21+
22+
-p, --project <PROJECT>
23+
The project ID or slug.
24+
25+
--app-id <APP_ID>
26+
App identifier (e.g. sentry-frontend). Mutually exclusive with --snapshot-id.
27+
28+
--auth-token <AUTH_TOKEN>
29+
Use the given Sentry auth token.
30+
31+
--log-level <LOG_LEVEL>
32+
Set the log output verbosity. [possible values: trace, debug, info, warn, error]
33+
34+
--snapshot-id <ID>
35+
Direct snapshot artifact ID. Mutually exclusive with --app-id.
36+
37+
--branch <NAME>
38+
Git branch filter (only with --app-id).
39+
40+
--quiet
41+
Do not print any output while preserving correct exit code. This flag is currently
42+
implemented only for selected subcommands.
43+
44+
[aliases: --silent]
45+
46+
--output <DIR>
47+
Directory for extracted images.
48+
49+
[default: ./snapshots-base/]
50+
51+
-h, --help
52+
Print help (see a summary with '-h')
53+
54+
```

tests/integration/snapshots.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ fn command_snapshots_diff_missing_dir() {
1010
TestManager::new().register_trycmd_test("snapshots/snapshots-diff-missing-dir.trycmd");
1111
}
1212

13+
#[test]
14+
fn command_snapshots_download_help() {
15+
TestManager::new().register_trycmd_test("snapshots/snapshots-download-help.trycmd");
16+
}
17+
1318
#[test]
1419
fn command_snapshots_upload_help() {
1520
TestManager::new().register_trycmd_test("snapshots/snapshots-upload-help.trycmd");

0 commit comments

Comments
 (0)