Skip to content

Commit bab1afd

Browse files
authored
ref(mobile-app): Wireup mobile app upload (#2542)
Adds the upload functionality to the new `mobile-app upload` command. Tested E2E with new API preprod/assemble endpoint added in getsentry/sentry#92528. Also added some basic tests to ensure a minimal APK is successfully uploaded and that it skips uploading if the server already has the chunks.
1 parent 3821e33 commit bab1afd

File tree

13 files changed

+441
-37
lines changed

13 files changed

+441
-37
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
use serde::{Deserialize, Serialize};
2+
use sha1_smol::Digest;
3+
4+
use super::ChunkedFileState;
5+
6+
#[derive(Debug, Serialize)]
7+
pub struct ChunkedMobileAppRequest<'a> {
8+
pub checksum: Digest,
9+
pub chunks: &'a [Digest],
10+
#[serde(skip_serializing_if = "Option::is_none")]
11+
pub git_sha: Option<&'a str>,
12+
#[serde(skip_serializing_if = "Option::is_none")]
13+
pub build_configuration: Option<&'a str>,
14+
}
15+
16+
#[derive(Debug, Deserialize)]
17+
#[serde(rename_all = "camelCase")]
18+
pub struct AssembleMobileAppResponse {
19+
pub state: ChunkedFileState,
20+
pub missing_chunks: Vec<Digest>,
21+
pub detail: Option<String>,
22+
}

src/api/data_types/chunking/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ mod compression;
66
mod dif;
77
mod file_state;
88
mod hash_algorithm;
9+
mod mobile_app;
910
mod upload;
1011

1112
pub use self::artifact::{AssembleArtifactsResponse, ChunkedArtifactRequest};
1213
pub use self::compression::ChunkCompression;
1314
pub use self::dif::{AssembleDifsRequest, AssembleDifsResponse, ChunkedDifRequest};
1415
pub use self::file_state::ChunkedFileState;
1516
pub use self::hash_algorithm::ChunkHashAlgorithm;
17+
pub use self::mobile_app::{AssembleMobileAppResponse, ChunkedMobileAppRequest};
1618
pub use self::upload::{ChunkServerOptions, ChunkUploadCapability};

src/api/data_types/chunking/upload/capability.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ pub enum ChunkUploadCapability {
3030
/// Upload of il2cpp line mappings
3131
Il2Cpp,
3232

33+
/// Upload of preprod artifacts
34+
PreprodArtifacts,
35+
3336
/// Any other unsupported capability (ignored)
3437
Unknown,
3538
}
@@ -49,6 +52,7 @@ impl<'de> Deserialize<'de> for ChunkUploadCapability {
4952
"sources" => ChunkUploadCapability::Sources,
5053
"bcsymbolmaps" => ChunkUploadCapability::BcSymbolmap,
5154
"il2cpp" => ChunkUploadCapability::Il2Cpp,
55+
"preprod_artifacts" => ChunkUploadCapability::PreprodArtifacts,
5256
_ => ChunkUploadCapability::Unknown,
5357
})
5458
}

src/api/mod.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,32 @@ impl<'a> AuthenticatedApi<'a> {
10181018
.convert_rnf(ApiErrorKind::ReleaseNotFound)
10191019
}
10201020

1021+
pub fn assemble_mobile_app(
1022+
&self,
1023+
org: &str,
1024+
project: &str,
1025+
checksum: Digest,
1026+
chunks: &[Digest],
1027+
git_sha: Option<&str>,
1028+
build_configuration: Option<&str>,
1029+
) -> ApiResult<AssembleMobileAppResponse> {
1030+
let url = format!(
1031+
"/projects/{}/{}/files/preprodartifacts/assemble/",
1032+
PathArg(org),
1033+
PathArg(project)
1034+
);
1035+
1036+
self.request(Method::Post, &url)?
1037+
.with_json_body(&ChunkedMobileAppRequest {
1038+
checksum,
1039+
chunks,
1040+
git_sha,
1041+
build_configuration,
1042+
})?
1043+
.send()?
1044+
.convert_rnf(ApiErrorKind::ProjectNotFound)
1045+
}
1046+
10211047
pub fn associate_proguard_mappings(
10221048
&self,
10231049
org: &str,

src/commands/mobile_app/upload.rs

Lines changed: 227 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
1+
use std::borrow::Cow;
12
use std::io::Write;
23
use std::path::Path;
34

4-
use anyhow::{anyhow, Context as _, Result};
5+
use anyhow::{anyhow, bail, Context as _, Result};
56
use clap::{Arg, ArgAction, ArgMatches, Command};
6-
use log::debug;
7+
use indicatif::ProgressStyle;
8+
use itertools::Itertools;
9+
use log::{debug, info, warn};
10+
use sha1_smol::Digest;
711
use symbolic::common::ByteView;
812
use zip::write::SimpleFileOptions;
9-
use zip::ZipWriter;
13+
use zip::{DateTime, ZipWriter};
1014

15+
use crate::api::{Api, AuthenticatedApi};
16+
use crate::config::Config;
1117
use crate::utils::args::ArgExt;
18+
use crate::utils::chunks::{upload_chunks, Chunk, ASSEMBLE_POLL_INTERVAL};
19+
use crate::utils::fs::get_sha1_checksums;
1220
use crate::utils::fs::TempFile;
1321
use crate::utils::mobile_app::{is_aab_file, is_apk_file, is_xcarchive_directory, is_zip_file};
22+
use crate::utils::progress::ProgressBar;
23+
use crate::utils::vcs;
1424

1525
pub fn make_command(command: Command) -> Command {
1626
command
@@ -24,13 +34,31 @@ pub fn make_command(command: Command) -> Command {
2434
.num_args(1..)
2535
.action(ArgAction::Append),
2636
)
37+
.arg(
38+
Arg::new("sha")
39+
.long("sha")
40+
.help("The git commit sha to use for the upload. If not provided, the current commit sha will be used.")
41+
)
42+
.arg(
43+
Arg::new("build_configuration")
44+
.long("build-configuration")
45+
.help("The build configuration to use for the upload. If not provided, the current version will be used.")
46+
)
2747
}
2848

2949
pub fn execute(matches: &ArgMatches) -> Result<()> {
3050
let path_strings = matches
3151
.get_many::<String>("paths")
3252
.expect("paths argument is required");
3353

54+
let sha = matches
55+
.get_one("sha")
56+
.map(String::as_str)
57+
.map(Cow::Borrowed)
58+
.or_else(|| vcs::find_head().ok().map(Cow::Owned));
59+
60+
let build_configuration = matches.get_one("build_configuration").map(String::as_str);
61+
3462
debug!(
3563
"Starting mobile app upload for {} paths",
3664
path_strings.len()
@@ -77,15 +105,54 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
77105
"Successfully normalized to: {}",
78106
normalized_zip.path().display()
79107
);
80-
normalized_zips.push(normalized_zip);
108+
normalized_zips.push((path, normalized_zip));
109+
}
110+
111+
let config = Config::current();
112+
let (org, project) = config.get_org_and_project(matches)?;
113+
114+
let mut uploaded_paths = vec![];
115+
let mut errored_paths = vec![];
116+
for (path, zip) in normalized_zips {
117+
info!("Uploading file: {}", path.display());
118+
let bytes = ByteView::open(zip.path())?;
119+
match upload_file(&bytes, &org, &project, sha.as_deref(), build_configuration) {
120+
Ok(_) => {
121+
info!("Successfully uploaded file: {}", path.display());
122+
uploaded_paths.push(path.to_path_buf());
123+
}
124+
Err(e) => {
125+
debug!("Failed to upload file at path {}: {}", path.display(), e);
126+
errored_paths.push(path.to_path_buf());
127+
}
128+
}
129+
}
130+
131+
if !errored_paths.is_empty() {
132+
warn!(
133+
"Failed to upload {} file{}:",
134+
errored_paths.len(),
135+
if errored_paths.len() == 1 { "" } else { "s" }
136+
);
137+
for path in errored_paths {
138+
warn!(" - {}", path.display());
139+
}
81140
}
82141

83-
for zip in normalized_zips {
84-
println!("Created normalized zip at: {}", zip.path().display());
85-
// TODO: Upload the normalized zip to the chunked uploads API
142+
println!(
143+
"Successfully uploaded {} file{} to Sentry",
144+
uploaded_paths.len(),
145+
if uploaded_paths.len() == 1 { "" } else { "s" }
146+
);
147+
if uploaded_paths.len() < 3 {
148+
for path in &uploaded_paths {
149+
println!(" - {}", path.display());
150+
}
86151
}
87152

88-
eprintln!("Uploading mobile app files to a project is not yet implemented.");
153+
if uploaded_paths.is_empty() {
154+
bail!("Failed to upload any files");
155+
}
89156
Ok(())
90157
}
91158

@@ -133,7 +200,15 @@ fn normalize_file(path: &Path, bytes: &[u8]) -> Result<TempFile> {
133200
.with_context(|| format!("Failed to get relative path for {}", path.display()))?;
134201

135202
debug!("Adding file to zip: {}", file_name);
136-
zip.start_file(file_name, SimpleFileOptions::default())?;
203+
204+
// Need to set the last modified time to a fixed value to ensure consistent checksums
205+
// This is important as an optimization to avoid re-uploading the same chunks if they're already on the server
206+
// but the last modified time being different will cause checksums to be different.
207+
let options = SimpleFileOptions::default()
208+
.compression_method(zip::CompressionMethod::Stored)
209+
.last_modified_time(DateTime::default());
210+
211+
zip.start_file(file_name, options)?;
137212
zip.write_all(bytes)?;
138213

139214
zip.finish()?;
@@ -149,24 +224,38 @@ fn normalize_directory(path: &Path) -> Result<TempFile> {
149224
let mut zip = ZipWriter::new(temp_file.open()?);
150225

151226
let mut file_count = 0;
152-
for entry in walkdir::WalkDir::new(path)
227+
228+
// Collect and sort entries for deterministic ordering
229+
// This is important to ensure stable sha1 checksums for the zip file as
230+
// an optimization is used to avoid re-uploading the same chunks if they're already on the server.
231+
let entries = walkdir::WalkDir::new(path)
153232
.follow_links(true)
154233
.into_iter()
155234
.filter_map(Result::ok)
156-
{
157-
let entry_path = entry.path();
158-
if entry_path.is_file() {
159-
let relative_path = entry_path.strip_prefix(path)?;
160-
debug!("Adding file to zip: {}", relative_path.display());
161-
162-
zip.start_file(
163-
relative_path.to_string_lossy(),
164-
SimpleFileOptions::default(),
165-
)?;
166-
let file_byteview = ByteView::open(entry_path)?;
167-
zip.write_all(file_byteview.as_slice())?;
168-
file_count += 1;
169-
}
235+
.filter(|entry| entry.path().is_file())
236+
.map(|entry| {
237+
let entry_path = entry.into_path();
238+
let relative_path = entry_path.strip_prefix(path)?.to_owned();
239+
Ok((entry_path, relative_path))
240+
})
241+
.collect::<Result<Vec<_>>>()?
242+
.into_iter()
243+
.sorted_by(|(_, a), (_, b)| a.cmp(b));
244+
245+
// Need to set the last modified time to a fixed value to ensure consistent checksums
246+
// This is important as an optimization to avoid re-uploading the same chunks if they're already on the server
247+
// but the last modified time being different will cause checksums to be different.
248+
let options = SimpleFileOptions::default()
249+
.compression_method(zip::CompressionMethod::Stored)
250+
.last_modified_time(DateTime::default());
251+
252+
for (entry_path, relative_path) in entries {
253+
debug!("Adding file to zip: {}", relative_path.display());
254+
255+
zip.start_file(relative_path.to_string_lossy(), options)?;
256+
let file_byteview = ByteView::open(&entry_path)?;
257+
zip.write_all(file_byteview.as_slice())?;
258+
file_count += 1;
170259
}
171260

172261
zip.finish()?;
@@ -176,3 +265,117 @@ fn normalize_directory(path: &Path) -> Result<TempFile> {
176265
);
177266
Ok(temp_file)
178267
}
268+
269+
fn upload_file(
270+
bytes: &[u8],
271+
org: &str,
272+
project: &str,
273+
sha: Option<&str>,
274+
build_configuration: Option<&str>,
275+
) -> Result<()> {
276+
debug!(
277+
"Uploading file to organization: {}, project: {}, sha: {}, build_configuration: {}",
278+
org,
279+
project,
280+
sha.unwrap_or("unknown"),
281+
build_configuration.unwrap_or("unknown")
282+
);
283+
284+
let api = Api::current();
285+
let authenticated_api = api.authenticated()?;
286+
287+
let chunk_upload_options = authenticated_api
288+
.get_chunk_upload_options(org)?
289+
.expect("Chunked uploading is not supported");
290+
291+
let progress_style =
292+
ProgressStyle::default_spinner().template("{spinner} Optimizing bundle for upload...");
293+
let pb = ProgressBar::new_spinner();
294+
pb.enable_steady_tick(100);
295+
pb.set_style(progress_style);
296+
297+
let chunk_size = chunk_upload_options.chunk_size as usize;
298+
let (checksum, checksums) = get_sha1_checksums(bytes, chunk_size)?;
299+
let mut chunks = bytes
300+
.chunks(chunk_size)
301+
.zip(checksums.iter())
302+
.map(|(data, checksum)| Chunk((*checksum, data)))
303+
.collect::<Vec<_>>();
304+
305+
pb.finish_with_duration("Finishing upload");
306+
307+
let response = authenticated_api.assemble_mobile_app(
308+
org,
309+
project,
310+
checksum,
311+
&checksums,
312+
sha,
313+
build_configuration,
314+
)?;
315+
chunks.retain(|Chunk((digest, _))| response.missing_chunks.contains(digest));
316+
317+
if !chunks.is_empty() {
318+
let upload_progress_style = ProgressStyle::default_bar().template(
319+
"{prefix:.dim} Uploading files...\
320+
\n{wide_bar} {bytes}/{total_bytes} ({eta})",
321+
);
322+
upload_chunks(&chunks, &chunk_upload_options, upload_progress_style)?;
323+
} else {
324+
println!("Nothing to upload, all files are on the server");
325+
}
326+
327+
poll_assemble(
328+
&authenticated_api,
329+
checksum,
330+
&checksums,
331+
org,
332+
project,
333+
sha,
334+
build_configuration,
335+
)?;
336+
Ok(())
337+
}
338+
339+
fn poll_assemble(
340+
api: &AuthenticatedApi,
341+
checksum: Digest,
342+
chunks: &[Digest],
343+
org: &str,
344+
project: &str,
345+
sha: Option<&str>,
346+
build_configuration: Option<&str>,
347+
) -> Result<()> {
348+
debug!("Polling assemble for checksum: {}", checksum);
349+
350+
let progress_style = ProgressStyle::default_spinner().template("{spinner} Processing files...");
351+
let pb = ProgressBar::new_spinner();
352+
353+
pb.enable_steady_tick(100);
354+
pb.set_style(progress_style);
355+
356+
let response = loop {
357+
let response =
358+
api.assemble_mobile_app(org, project, checksum, chunks, sha, build_configuration)?;
359+
360+
if response.state.is_finished() {
361+
break response;
362+
}
363+
364+
std::thread::sleep(ASSEMBLE_POLL_INTERVAL);
365+
};
366+
367+
pb.finish_with_duration("Processing");
368+
369+
if response.state.is_err() {
370+
let message = response.detail.as_deref().unwrap_or("unknown error");
371+
bail!("Failed to process uploaded files: {}", message);
372+
}
373+
374+
if response.state.is_pending() {
375+
info!("File upload complete (processing pending on server)");
376+
} else {
377+
info!("File processing complete");
378+
}
379+
380+
Ok(())
381+
}

0 commit comments

Comments
 (0)