Skip to content

Commit 5d3af76

Browse files
authored
Use range requests to download bundles, add progress bar (#1113)
- Uses range requests to ask for chunks of the support bundle more incrementally - Adds a progress bar to show progress, which is particularly useful for larger downloads - Tested manually by using: ``` # In omicron: $ cargo xtask omicron-dev run-all # From this repo $ cargo run -- bundle create $ cargo run -- bundle download --id a2ae42e6-e863-415e-b211-94d5757defef output.zip ``` This downloads the bundle quite quickly - it's only KiB in size - but tuning the chunk size up or down can slow progress enough to show the progress bar.
1 parent 11fe44b commit 5d3af76

3 files changed

Lines changed: 265 additions & 5 deletions

File tree

cli/docs/cli.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,11 @@
593593
"name": "download",
594594
"about": "Downloads a support bundle",
595595
"args": [
596+
{
597+
"long": "chunk-size",
598+
"short": "c",
599+
"help": "The size, in bytes, of each range request to use when downloading bundles"
600+
},
596601
{
597602
"long": "id",
598603
"help": "ID of the bundle"

cli/src/cmd_support_bundle.rs

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@ use camino::Utf8PathBuf;
1515
use clap::Parser;
1616
use futures::Stream;
1717
use futures::StreamExt;
18+
use futures::TryStreamExt;
19+
use indicatif::ProgressBar;
20+
use indicatif::ProgressStyle;
1821
use oxide::Client;
1922
use oxide::ClientHiddenExt;
2023
use std::io;
24+
use std::num::NonZeroU64;
2125
use std::pin::Pin;
2226
use std::task::Context;
2327
use std::task::Poll;
@@ -28,6 +32,7 @@ use support_bundle_viewer::SupportBundleIndex;
2832
use tokio::io::AsyncRead;
2933
use tokio::io::AsyncWriteExt;
3034
use tokio::io::ReadBuf;
35+
use tokio::sync::watch;
3136
use uuid::Uuid;
3237

3338
/// Downloads a support bundle
@@ -42,6 +47,71 @@ pub struct CmdDownload {
4247
/// Path where the bundle should be downloaded
4348
#[clap(short, long)]
4449
output: Utf8PathBuf,
50+
51+
/// The size, in bytes, of each range request to use when downloading bundles
52+
#[clap(short, long, default_value_t = NonZeroU64::new(100 * (1 << 20)).unwrap())]
53+
chunk_size: NonZeroU64,
54+
}
55+
56+
// Downloads a portion of a support bundle using range requests.
57+
//
58+
// "range" is in bytes, and is inclusive on both sides.
59+
//
60+
// Returns: The observed content length of the stream, and the stream.
61+
async fn support_bundle_download_range(
62+
client: &Client,
63+
id: Uuid,
64+
range: (u64, u64),
65+
) -> anyhow::Result<(
66+
u64,
67+
impl futures::Stream<Item = anyhow::Result<bytes::Bytes>>,
68+
)> {
69+
let range_str = format!("bytes={}-{}", range.0, range.1);
70+
let response = client
71+
.support_bundle_download()
72+
.bundle_id(id)
73+
.range(&range_str)
74+
.send()
75+
.await
76+
.with_context(|| format!("downloading support bundle {}", id))?;
77+
78+
let Some(len) = response.content_length() else {
79+
bail!("No content length observed when downloading bundle");
80+
};
81+
82+
Ok((
83+
len,
84+
response
85+
.into_inner_stream()
86+
.map(|r| r.map_err(|err| anyhow::anyhow!(err))),
87+
))
88+
}
89+
90+
// Downloads all ranges of a support bundle, and combines them into a single
91+
// stream.
92+
//
93+
// Starts the download at "start" bytes (inclusive) and continues up to "end"
94+
// bytes (exclusive).
95+
fn support_bundle_download_ranges(
96+
client: &Client,
97+
id: Uuid,
98+
chunk_size: NonZeroU64,
99+
start: u64,
100+
end: u64,
101+
) -> impl futures::Stream<Item = anyhow::Result<bytes::Bytes>> + use<'_> {
102+
futures::stream::try_unfold(
103+
(start, start + chunk_size.get() - 1),
104+
move |range| async move {
105+
if end <= range.0 {
106+
return Ok(None);
107+
}
108+
109+
let (actual_len, stream) = support_bundle_download_range(client, id, range).await?;
110+
let next_range = (range.0 + actual_len, range.1 + actual_len);
111+
Ok::<_, anyhow::Error>(Some((stream, next_range)))
112+
},
113+
)
114+
.try_flatten()
45115
}
46116

47117
#[async_trait]
@@ -51,24 +121,67 @@ impl crate::AuthenticatedCmd for CmdDownload {
51121
.await
52122
.with_context(|| format!("Cannot create output file: {}", self.output))?;
53123

54-
// NOTE: It might be worth adding a progress bar here?
55-
let mut stream = client
56-
.support_bundle_download()
124+
let (progress_tx, progress_rx) = watch::channel(0);
125+
126+
let total_length = client
127+
.support_bundle_head()
57128
.bundle_id(self.id)
58129
.send()
59130
.await?
60-
.into_inner_stream();
131+
.content_length()
132+
.ok_or_else(|| anyhow::anyhow!("No content length"))?;
133+
134+
start_progress_bar(progress_rx, total_length, self.id)?;
61135

136+
let mut stream =
137+
support_bundle_download_ranges(client, self.id, self.chunk_size, 0, total_length);
138+
let mut stream = std::pin::pin!(stream);
62139
while let Some(data) = stream.next().await {
63140
match data {
64141
Err(err) => bail!(err),
65-
Ok(data) => output.write_all(&data).await?,
142+
Ok(data) => {
143+
output.write_all(&data).await?;
144+
progress_tx.send_modify(|l| *l += data.len() as u64);
145+
}
66146
}
67147
}
68148
Ok(())
69149
}
70150
}
71151

152+
fn start_progress_bar(
153+
mut progress_rx: watch::Receiver<u64>,
154+
file_size: u64,
155+
bundle_id: Uuid,
156+
) -> Result<ProgressBar> {
157+
let pb = ProgressBar::new(file_size);
158+
pb.set_style(ProgressStyle::default_bar().template(
159+
"[{elapsed_precise}] [{wide_bar:.green}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta}",
160+
)?);
161+
pb.set_position(0);
162+
pb.println(format!("Downloading bundle \"{bundle_id}\""));
163+
let pb_updater = pb.clone();
164+
165+
tokio::spawn(async move {
166+
loop {
167+
match progress_rx.changed().await {
168+
Ok(_) => {
169+
let p = *progress_rx.borrow();
170+
pb_updater.set_position(p);
171+
172+
if p >= file_size {
173+
pb_updater.finish();
174+
return;
175+
}
176+
}
177+
Err(_) => return, // Sender has dropped.
178+
}
179+
}
180+
});
181+
182+
Ok(pb)
183+
}
184+
72185
/// Inspects a support bundle
73186
///
74187
/// Support bundles may be inspected before they are downloaded (via

cli/tests/test_bundles.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
// Copyright 2025 Oxide Computer Company
6+
7+
use assert_cmd::Command;
8+
use httpmock::MockServer;
9+
use oxide_httpmock::MockServerExt;
10+
11+
#[test]
12+
fn test_bundle_download_ranges() {
13+
let server = MockServer::start();
14+
15+
let id = uuid::Uuid::try_from("0a27f7c9-a18c-4c6b-9ba9-7e461e60f556").unwrap();
16+
let output_path = tempfile::NamedTempFile::new().expect("Failed to make tempfile");
17+
let chunk_size: u64 = 1024;
18+
19+
let mock_head = server.support_bundle_head(|when, then| {
20+
when.bundle_id(&id).into_inner().any_request();
21+
then.into_inner()
22+
.header("accept-ranges", "bytes")
23+
.header("content-length", "2048")
24+
.header("content-type", "application/octet-stream")
25+
.status(200);
26+
});
27+
28+
let mock_download1 = server.support_bundle_download(|when, then| {
29+
when.bundle_id(&id)
30+
.into_inner()
31+
.header("range", "bytes=0-1023")
32+
.any_request();
33+
34+
then.into_inner()
35+
.header("accept-ranges", "bytes")
36+
.header("content-length", "1024")
37+
.header("content-range", "bytes 0-1023/2048")
38+
.header("content-type", "application/octet-stream")
39+
.body([0; 1024]);
40+
});
41+
let mock_download2 = server.support_bundle_download(|when, then| {
42+
when.bundle_id(&id)
43+
.into_inner()
44+
.header("range", "bytes=1024-2047")
45+
.any_request();
46+
47+
then.into_inner()
48+
.header("accept-ranges", "bytes")
49+
.header("content-length", "1024")
50+
.header("content-range", "bytes 1024-2047/2048")
51+
.header("content-type", "application/octet-stream")
52+
.body([1; 1024]);
53+
});
54+
55+
Command::cargo_bin("oxide")
56+
.unwrap()
57+
.env("RUST_BACKTRACE", "1")
58+
.env("OXIDE_HOST", server.url(""))
59+
.env("OXIDE_TOKEN", "fake-token")
60+
.arg("bundle")
61+
.arg("download")
62+
.arg("--id")
63+
.arg(id.to_string())
64+
.arg("--output")
65+
.arg(output_path.path())
66+
.arg("--chunk-size")
67+
.arg(chunk_size.to_string())
68+
.assert()
69+
.success();
70+
71+
assert_eq!(
72+
std::fs::read(output_path.path()).expect("Failed to read output"),
73+
[[0; 1024], [1; 1024]].concat()
74+
);
75+
76+
mock_head.assert();
77+
mock_download1.assert();
78+
mock_download2.assert();
79+
}
80+
81+
// This server claims to support range requests, but it returns the entire file
82+
// anyway.
83+
//
84+
// Our CLI should cope by streaming the file as it is returned.
85+
#[test]
86+
fn test_bundle_download_range_too_large_response() {
87+
let server = MockServer::start();
88+
89+
let id = uuid::Uuid::try_from("0a27f7c9-a18c-4c6b-9ba9-7e461e60f556").unwrap();
90+
let output_path = tempfile::NamedTempFile::new().expect("Failed to make tempfile");
91+
let chunk_size: u64 = 1024;
92+
93+
let mock_head = server.support_bundle_head(|when, then| {
94+
when.bundle_id(&id).into_inner().any_request();
95+
then.into_inner()
96+
.header("accept-ranges", "bytes")
97+
.header("content-length", "4096")
98+
.header("content-type", "application/octet-stream")
99+
.status(200);
100+
});
101+
102+
// Even though the server advertised that range requests were supported,
103+
// it'll return the whole file at once. Confirm that we download it
104+
// in a single call anyway.
105+
let mock_download = server.support_bundle_download(|when, then| {
106+
when.bundle_id(&id)
107+
.into_inner()
108+
.header("range", "bytes=0-1023")
109+
.any_request();
110+
111+
then.into_inner()
112+
.header("accept-ranges", "bytes")
113+
.header("content-length", "4096")
114+
.header("content-range", "bytes 0-4095/4096")
115+
.header("content-type", "application/octet-stream")
116+
.body([0; 4096]);
117+
});
118+
119+
Command::cargo_bin("oxide")
120+
.unwrap()
121+
.env("RUST_BACKTRACE", "1")
122+
.env("OXIDE_HOST", server.url(""))
123+
.env("OXIDE_TOKEN", "fake-token")
124+
.arg("bundle")
125+
.arg("download")
126+
.arg("--id")
127+
.arg(id.to_string())
128+
.arg("--output")
129+
.arg(output_path.path())
130+
.arg("--chunk-size")
131+
.arg(chunk_size.to_string())
132+
.assert()
133+
.success();
134+
135+
assert_eq!(
136+
std::fs::read(output_path.path()).expect("Failed to read output"),
137+
[0; 4096]
138+
);
139+
140+
mock_head.assert();
141+
mock_download.assert();
142+
}

0 commit comments

Comments
 (0)