1
+ use std:: borrow:: Cow ;
1
2
use std:: io:: Write ;
2
3
use std:: path:: Path ;
3
4
4
- use anyhow:: { anyhow, Context as _, Result } ;
5
+ use anyhow:: { anyhow, bail , Context as _, Result } ;
5
6
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 ;
7
11
use symbolic:: common:: ByteView ;
8
12
use zip:: write:: SimpleFileOptions ;
9
- use zip:: ZipWriter ;
13
+ use zip:: { DateTime , ZipWriter } ;
10
14
15
+ use crate :: api:: { Api , AuthenticatedApi } ;
16
+ use crate :: config:: Config ;
11
17
use crate :: utils:: args:: ArgExt ;
18
+ use crate :: utils:: chunks:: { upload_chunks, Chunk , ASSEMBLE_POLL_INTERVAL } ;
19
+ use crate :: utils:: fs:: get_sha1_checksums;
12
20
use crate :: utils:: fs:: TempFile ;
13
21
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;
14
24
15
25
pub fn make_command ( command : Command ) -> Command {
16
26
command
@@ -24,13 +34,31 @@ pub fn make_command(command: Command) -> Command {
24
34
. num_args ( 1 ..)
25
35
. action ( ArgAction :: Append ) ,
26
36
)
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
+ )
27
47
}
28
48
29
49
pub fn execute ( matches : & ArgMatches ) -> Result < ( ) > {
30
50
let path_strings = matches
31
51
. get_many :: < String > ( "paths" )
32
52
. expect ( "paths argument is required" ) ;
33
53
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
+
34
62
debug ! (
35
63
"Starting mobile app upload for {} paths" ,
36
64
path_strings. len( )
@@ -77,15 +105,54 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
77
105
"Successfully normalized to: {}" ,
78
106
normalized_zip. path( ) . display( )
79
107
) ;
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
+ }
81
140
}
82
141
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
+ }
86
151
}
87
152
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
+ }
89
156
Ok ( ( ) )
90
157
}
91
158
@@ -133,7 +200,15 @@ fn normalize_file(path: &Path, bytes: &[u8]) -> Result<TempFile> {
133
200
. with_context ( || format ! ( "Failed to get relative path for {}" , path. display( ) ) ) ?;
134
201
135
202
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) ?;
137
212
zip. write_all ( bytes) ?;
138
213
139
214
zip. finish ( ) ?;
@@ -149,24 +224,38 @@ fn normalize_directory(path: &Path) -> Result<TempFile> {
149
224
let mut zip = ZipWriter :: new ( temp_file. open ( ) ?) ;
150
225
151
226
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)
153
232
. follow_links ( true )
154
233
. into_iter ( )
155
234
. 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 ;
170
259
}
171
260
172
261
zip. finish ( ) ?;
@@ -176,3 +265,117 @@ fn normalize_directory(path: &Path) -> Result<TempFile> {
176
265
) ;
177
266
Ok ( temp_file)
178
267
}
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