Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 45 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<LatestBaseSnapshotResponse>> {
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<ApiResponse> {
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)
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

/// Available datasets for fetching organization events
Expand Down Expand Up @@ -2044,6 +2083,12 @@ pub struct LogEntry {
pub message: Option<String>,
}

#[derive(Deserialize)]
pub struct LatestBaseSnapshotResponse {
pub head_artifact_id: String,
pub image_count: u64,
}
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
NicoHinderling marked this conversation as resolved.

/// Upload options returned by the snapshots upload-options endpoint.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down
135 changes: 135 additions & 0 deletions src/commands/snapshots/download.rs
Original file line number Diff line number Diff line change
@@ -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::<String>("app_id");
let snapshot_id_arg = matches.get_one::<String>("snapshot_id");
let branch = matches.get_one::<String>("branch").map(|s| s.as_str());
let output_dir = PathBuf::from(
matches
.get_one::<String>("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(())
}
2 changes: 2 additions & 0 deletions src/commands/snapshots/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <ORG>
The organization ID or slug.

--app-id <APP_ID>
App identifier (e.g. sentry-frontend). Mutually exclusive with --snapshot-id.

--header <KEY:VALUE>
Custom headers that should be attached to all requests
in key:value format.

--auth-token <AUTH_TOKEN>
Use the given Sentry auth token.

--snapshot-id <ID>
Direct snapshot artifact ID. Mutually exclusive with --app-id.

--branch <NAME>
Git branch filter (only with --app-id).

--log-level <LOG_LEVEL>
Set the log output verbosity. [possible values: trace, debug, info, warn, error]

--output <DIR>
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')

```
5 changes: 5 additions & 0 deletions tests/integration/snapshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Loading