diff --git a/Cargo.lock b/Cargo.lock index 9e560f69f8d..39eca0683f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2730,9 +2730,9 @@ dependencies = [ [[package]] name = "dropshot" -version = "0.16.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8fc533bd839ebdf1f3170e01d120b4302a60f317d16c384d5eaec3524f6c92" +checksum = "50e8fed669e35e757646ad10f97c4d26dd22cce3da689b307954f7000d2719d0" dependencies = [ "async-stream", "async-trait", @@ -2781,9 +2781,9 @@ dependencies = [ [[package]] name = "dropshot_endpoint" -version = "0.16.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3b67d65d510666d55a833ada8d5762ba21c37dd133632284342f164745baa8" +checksum = "acebb687581abdeaa2c89fa448818a5f803b0e68e5d7e7a1cf585a8f3c5c57ac" dependencies = [ "heck 0.5.0", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index f69cf13efec..0dc35ad19c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -417,7 +417,7 @@ dns-server = { path = "dns-server" } dns-server-api = { path = "dns-server-api" } dns-service-client = { path = "clients/dns-service-client" } dpd-client = { git = "https://github.com/oxidecomputer/dendrite" } -dropshot = { version = "0.16.1", features = [ "usdt-probes" ] } +dropshot = { version = "0.16.2", features = [ "usdt-probes" ] } dyn-clone = "1.0.19" either = "1.14.0" ereport-types = { path = "ereport/types" } diff --git a/dev-tools/omdb/src/bin/omdb/nexus.rs b/dev-tools/omdb/src/bin/omdb/nexus.rs index 63d60ddb5cb..f608a4b0214 100644 --- a/dev-tools/omdb/src/bin/omdb/nexus.rs +++ b/dev-tools/omdb/src/bin/omdb/nexus.rs @@ -76,6 +76,7 @@ use serde::Deserialize; use slog_error_chain::InlineErrorChain; use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::fs::OpenOptions; use std::str::FromStr; use std::sync::Arc; use support_bundle_viewer::LocalFileAccess; @@ -522,6 +523,13 @@ struct SupportBundleDownloadArgs { /// instead of stdout. #[arg(short, long)] output: Option, + + /// If "true" and using an output path, resumes downloading. + /// + /// This assumes the contents of "output" are already valid, and resumes + /// downloading at the end of the file. + #[arg(short, long, default_value_t = false)] + resume: bool, } #[derive(Debug, Args)] @@ -3858,10 +3866,10 @@ async fn cmd_nexus_support_bundles_delete( } async fn write_stream_to_sink( - mut stream: impl futures::Stream> - + std::marker::Unpin, + mut stream: impl futures::Stream>, mut sink: impl std::io::Write, ) -> Result<(), anyhow::Error> { + let mut stream = std::pin::pin!(stream); while let Some(data) = stream.next().await { match data { Err(err) => return Err(anyhow::anyhow!(err)), @@ -3871,19 +3879,85 @@ async fn write_stream_to_sink( Ok(()) } +// Downloads a portion of a support bundle using range requests. +// +// "range" is in bytes, and is inclusive on both sides. +async fn support_bundle_download_range( + client: &nexus_client::Client, + id: SupportBundleUuid, + range: (u64, u64), +) -> anyhow::Result>> { + let range = format!("bytes={}-{}", range.0, range.1); + Ok(client + .support_bundle_download(id.as_untyped_uuid(), Some(&range)) + .await + .with_context(|| format!("downloading support bundle {}", id))? + .into_inner_stream() + .map(|r| r.map_err(|err| anyhow::anyhow!(err)))) +} + +// Downloads all ranges of a support bundle, and combines them into a single +// stream. +// +// Starts the download at "start" bytes (inclusive) and continues up to "end" +// bytes (exclusive). +fn support_bundle_download_ranges( + client: &nexus_client::Client, + id: SupportBundleUuid, + start: u64, + end: u64, +) -> impl futures::Stream> + use<'_> { + // Arbitrary chunk size of 100 MiB. + // + // Note that we'll still stream data in packets which are smaller than this, + // but we won't keep a single connection to Nexus open for longer than a 100 + // MiB download. + const CHUNK_SIZE: u64 = 100 * (1 << 20); + futures::stream::try_unfold( + (start, start + CHUNK_SIZE - 1), + move |range| async move { + if end <= range.0 { + return Ok(None); + } + + let stream = + support_bundle_download_range(client, id, range).await?; + let next_range = (range.0 + CHUNK_SIZE, range.1 + CHUNK_SIZE); + Ok::<_, anyhow::Error>(Some((stream, next_range))) + }, + ) + .try_flatten() +} + /// Runs `omdb nexus support-bundles download` async fn cmd_nexus_support_bundles_download( client: &nexus_client::Client, args: &SupportBundleDownloadArgs, ) -> Result<(), anyhow::Error> { - let stream = client - .support_bundle_download(args.id.as_untyped_uuid()) - .await - .with_context(|| format!("downloading support bundle {}", args.id))? - .into_inner_stream(); + let total_length = client + .support_bundle_head(args.id.as_untyped_uuid()) + .await? + .content_length() + .ok_or_else(|| anyhow::anyhow!("No content length"))?; + + let start = match &args.output { + Some(output) if output.exists() && args.resume => { + output.metadata()?.len() + } + _ => 0, + }; + + let stream = + support_bundle_download_ranges(client, args.id, start, total_length); let sink: Box = match &args.output { - Some(path) => Box::new(std::fs::File::create(path)?), + Some(path) => Box::new( + OpenOptions::new() + .create(true) + .append(true) + .truncate(!args.resume) + .open(path)?, + ), None => Box::new(std::io::stdout()), }; @@ -3904,7 +3978,8 @@ async fn cmd_nexus_support_bundles_get_index( .with_context(|| { format!("downloading support bundle index {}", args.id) })? - .into_inner_stream(); + .into_inner_stream() + .map(|r| r.map_err(|err| anyhow::anyhow!(err))); write_stream_to_sink(stream, std::io::stdout()).await.with_context( || format!("streaming support bundle index {}", args.id), @@ -3929,7 +4004,8 @@ async fn cmd_nexus_support_bundles_get_file( args.id, args.path ) })? - .into_inner_stream(); + .into_inner_stream() + .map(|r| r.map_err(|err| anyhow::anyhow!(err))); let sink: Box = match &args.output { Some(path) => Box::new(std::fs::File::create(path)?), diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 49577a4316a..628fcdefe41 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -3,18 +3,18 @@ use std::collections::BTreeMap; use anyhow::anyhow; use dropshot::Body; use dropshot::{ - EmptyScanParams, EndpointTagPolicy, HttpError, HttpResponseAccepted, - HttpResponseCreated, HttpResponseDeleted, HttpResponseFound, - HttpResponseHeaders, HttpResponseOk, HttpResponseSeeOther, - HttpResponseUpdatedNoContent, PaginationParams, Path, Query, - RequestContext, ResultsPage, StreamingBody, TypedBody, + EmptyScanParams, EndpointTagPolicy, Header, HttpError, + HttpResponseAccepted, HttpResponseCreated, HttpResponseDeleted, + HttpResponseFound, HttpResponseHeaders, HttpResponseOk, + HttpResponseSeeOther, HttpResponseUpdatedNoContent, PaginationParams, Path, + Query, RequestContext, ResultsPage, StreamingBody, TypedBody, WebsocketChannelResult, WebsocketConnection, }; use http::Response; use ipnetwork::IpNetwork; use nexus_types::{ authn::cookies::Cookies, - external_api::{params, shared, views}, + external_api::{headers, params, shared, views}, }; use omicron_common::api::external::{ http_pagination::{ @@ -3168,6 +3168,7 @@ pub trait NexusExternalApi { }] async fn support_bundle_download( rqctx: RequestContext, + headers: Header, path_params: Path, ) -> Result, HttpError>; diff --git a/nexus/internal-api/src/lib.rs b/nexus/internal-api/src/lib.rs index b209626e77e..1c247a70cd4 100644 --- a/nexus/internal-api/src/lib.rs +++ b/nexus/internal-api/src/lib.rs @@ -5,9 +5,9 @@ use std::collections::{BTreeMap, BTreeSet}; use dropshot::{ - Body, HttpError, HttpResponseCreated, HttpResponseDeleted, HttpResponseOk, - HttpResponseUpdatedNoContent, Path, Query, RequestContext, ResultsPage, - TypedBody, + Body, Header, HttpError, HttpResponseCreated, HttpResponseDeleted, + HttpResponseOk, HttpResponseUpdatedNoContent, Path, Query, RequestContext, + ResultsPage, TypedBody, }; use http::Response; use nexus_types::{ @@ -16,6 +16,7 @@ use nexus_types::{ ClickhousePolicy, OximeterReadPolicy, }, external_api::{ + headers::RangeRequest, params::{self, PhysicalDiskPath, SledSelector, UninitializedSledId}, shared::{self, ProbeInfo, UninitializedSled}, views::Ping, @@ -579,6 +580,7 @@ pub trait NexusInternalApi { }] async fn support_bundle_download( rqctx: RequestContext, + headers: Header, path_params: Path, ) -> Result, HttpError>; diff --git a/nexus/src/app/support_bundles.rs b/nexus/src/app/support_bundles.rs index 5005a536f08..4673f6a55ac 100644 --- a/nexus/src/app/support_bundles.rs +++ b/nexus/src/app/support_bundles.rs @@ -71,7 +71,7 @@ impl super::Nexus { id: SupportBundleUuid, query: SupportBundleQueryType, head: bool, - _range: Option, + range: Option, ) -> Result, Error> { // Lookup the bundle, confirm it's accessible let (.., bundle) = LookupPath::new(opctx, &self.db_datastore) @@ -105,7 +105,16 @@ impl super::Nexus { .expect("Failed to build reqwest Client"); let client = self.sled_client_ext(&sled_id, client).await?; - // TODO: Use "range"? + let range = if let Some(potential_range) = &range { + Some(potential_range.try_into_str().map_err(|err| match err { + range_requests::Error::Parse(_) => Error::invalid_request( + "Failed to parse range request header", + ), + _ => Error::internal_error("Invalid range request"), + })?) + } else { + None + }; let response = match (query, head) { (SupportBundleQueryType::Whole, true) => { @@ -123,6 +132,7 @@ impl super::Nexus { &ZpoolUuid::from(bundle.zpool_id), &DatasetUuid::from(bundle.dataset_id), &SupportBundleUuid::from(bundle.id), + range, ) .await } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 8cd800aeea1..a8ab86a0810 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -19,6 +19,7 @@ use crate::context::ApiContext; use crate::external_api::shared; use dropshot::Body; use dropshot::EmptyScanParams; +use dropshot::Header; use dropshot::HttpError; use dropshot::HttpResponseDeleted; use dropshot::HttpResponseOk; @@ -49,6 +50,7 @@ use nexus_external_api::*; use nexus_types::{ authn::cookies::Cookies, external_api::{ + headers::RangeRequest, params::SystemMetricsPathParam, shared::{BfdStatus, ProbeInfo}, }, @@ -108,6 +110,7 @@ use propolis_client::support::tungstenite::protocol::frame::coding::CloseCode; use propolis_client::support::tungstenite::protocol::{ CloseFrame, Role as WebSocketRole, }; +use range_requests::PotentialRange; use range_requests::RequestContextEx; use ref_cast::RefCast; @@ -7107,6 +7110,7 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn support_bundle_download( rqctx: RequestContext, + headers: Header, path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); @@ -7117,7 +7121,10 @@ impl NexusExternalApi for NexusExternalApiImpl { crate::context::op_context_for_external_api(&rqctx).await?; let head = false; - let range = rqctx.range(); + let range = headers + .into_inner() + .range + .map(|r| PotentialRange::new(r.as_bytes())); let body = nexus .support_bundle_download( diff --git a/nexus/src/internal_api/http_entrypoints.rs b/nexus/src/internal_api/http_entrypoints.rs index 6d4b9d365b2..100d6c6e145 100644 --- a/nexus/src/internal_api/http_entrypoints.rs +++ b/nexus/src/internal_api/http_entrypoints.rs @@ -10,6 +10,7 @@ use crate::context::ApiContext; use crate::external_api::shared; use dropshot::ApiDescription; use dropshot::Body; +use dropshot::Header; use dropshot::HttpError; use dropshot::HttpResponseCreated; use dropshot::HttpResponseDeleted; @@ -28,6 +29,7 @@ use nexus_types::deployment::BlueprintTarget; use nexus_types::deployment::BlueprintTargetSet; use nexus_types::deployment::ClickhousePolicy; use nexus_types::deployment::OximeterReadPolicy; +use nexus_types::external_api::headers::RangeRequest; use nexus_types::external_api::params::PhysicalDiskPath; use nexus_types::external_api::params::SledSelector; use nexus_types::external_api::params::SupportBundleFilePath; @@ -63,6 +65,7 @@ use omicron_common::api::internal::nexus::SledVmmState; use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::InstanceUuid; use omicron_uuid_kinds::SupportBundleUuid; +use range_requests::PotentialRange; use range_requests::RequestContextEx; use std::collections::BTreeMap; @@ -1026,6 +1029,7 @@ impl NexusInternalApi for NexusInternalApiImpl { async fn support_bundle_download( rqctx: RequestContext, + headers: Header, path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); @@ -1036,7 +1040,10 @@ impl NexusInternalApi for NexusInternalApiImpl { crate::context::op_context_for_internal_api(&rqctx).await; let head = false; - let range = rqctx.range(); + let range = headers + .into_inner() + .range + .map(|r| PotentialRange::new(r.as_bytes())); let body = nexus .support_bundle_download( diff --git a/nexus/test-utils/src/http_testing.rs b/nexus/test-utils/src/http_testing.rs index f0602bc54d3..79d65d975e6 100644 --- a/nexus/test-utils/src/http_testing.rs +++ b/nexus/test-utils/src/http_testing.rs @@ -250,6 +250,7 @@ impl<'a> RequestBuilder<'a> { pub fn expect_range_requestable(mut self) -> Self { self.allowed_headers.as_mut().unwrap().extend([ http::header::CONTENT_LENGTH, + http::header::CONTENT_RANGE, http::header::CONTENT_TYPE, http::header::ACCEPT_RANGES, ]); diff --git a/nexus/tests/integration_tests/support_bundles.rs b/nexus/tests/integration_tests/support_bundles.rs index 27e519d0058..652567db55a 100644 --- a/nexus/tests/integration_tests/support_bundles.rs +++ b/nexus/tests/integration_tests/support_bundles.rs @@ -200,6 +200,57 @@ async fn bundle_download( Ok(body) } +async fn bundle_download_head( + client: &ClientTestContext, + id: SupportBundleUuid, +) -> Result { + let url = format!("{BUNDLES_URL}/{id}/download"); + let len = NexusRequest::new( + RequestBuilder::new(client, Method::HEAD, &url) + .expect_status(Some(StatusCode::OK)) + .expect_range_requestable(), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .context("failed to request bundle download")? + .headers + .get(http::header::CONTENT_LENGTH) + .context("Missing content length response header")? + .to_str() + .context("Failed to convert content length to string")? + .parse() + .context("Failed to parse content length")?; + + Ok(len) +} + +async fn bundle_download_range( + client: &ClientTestContext, + id: SupportBundleUuid, + value: &str, + expected_content_range: &str, +) -> Result { + let url = format!("{BUNDLES_URL}/{id}/download"); + let body = NexusRequest::new( + RequestBuilder::new(client, Method::GET, &url) + .header(http::header::RANGE, value) + .expect_status(Some(StatusCode::PARTIAL_CONTENT)) + .expect_response_header( + http::header::CONTENT_RANGE, + expected_content_range, + ) + .expect_range_requestable(), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .context("failed to request bundle download")? + .body; + + Ok(body) +} + async fn bundle_download_expect_fail( client: &ClientTestContext, id: SupportBundleUuid, @@ -464,3 +515,76 @@ async fn test_support_bundle_lifecycle(cptestctx: &ControlPlaneTestContext) { assert_eq!(second_bundle.reason_for_creation, "Created by external API"); assert_eq!(second_bundle.state, SupportBundleState::Collecting); } + +// Test range requests on a bundle +#[nexus_test] +async fn test_support_bundle_range_requests( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let disk_test = + DiskTestBuilder::new(&cptestctx).with_zpool_count(1).build().await; + + // Validate our test setup: We should see a single Debug dataset + // in our disk test. + let mut debug_dataset_count = 0; + for zpool in disk_test.zpools() { + let _dataset = zpool.debug_dataset(); + debug_dataset_count += 1; + } + assert_eq!(debug_dataset_count, 1); + + let bundle = bundle_create(&client).await.unwrap(); + assert_eq!(bundle.state, SupportBundleState::Collecting); + + // Finish collection, activate the bundle. + let output = activate_bundle_collection_background_task(&cptestctx).await; + assert_eq!(output.collection_err, None); + assert_eq!( + output.collection_report, + Some(SupportBundleCollectionReport { + bundle: bundle.id, + listed_in_service_sleds: true, + activated_in_db_ok: true, + }) + ); + let bundle = bundle_get(&client, bundle.id).await.unwrap(); + assert_eq!(bundle.state, SupportBundleState::Active); + + // Download the bundle without using range requests. + let full_contents = bundle_download(&client, bundle.id).await.unwrap(); + let len = full_contents.len(); + + // HEAD the bundle length + let head_len = bundle_download_head(&client, bundle.id).await.unwrap(); + assert_eq!( + len, head_len, + "Length from 'download bundle' vs 'HEAD bundle' did not match" + ); + + // Download portions of the bundle using range requests. + let (rr1_start, rr1_end) = (0, len / 2); + let (rr2_start, rr2_end) = (len / 2 + 1, len - 1); + let rr_header1 = format!("bytes={rr1_start}-{rr1_end}"); + let rr_header2 = format!("bytes={rr2_start}-{rr2_end}"); + let first_half = bundle_download_range( + &client, + bundle.id, + &rr_header1, + &format!("bytes {rr1_start}-{rr1_end}/{len}"), + ) + .await + .unwrap(); + assert_eq!(first_half, full_contents[..first_half.len()]); + + let second_half = bundle_download_range( + &client, + bundle.id, + &rr_header2, + &format!("bytes {rr2_start}-{rr2_end}/{len}"), + ) + .await + .unwrap(); + assert_eq!(second_half, full_contents[first_half.len()..]); +} diff --git a/nexus/types/src/external_api/headers.rs b/nexus/types/src/external_api/headers.rs new file mode 100644 index 00000000000..58cf8e854e5 --- /dev/null +++ b/nexus/types/src/external_api/headers.rs @@ -0,0 +1,20 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +/// Range request headers +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct RangeRequest { + /// A request to access a portion of the resource, such as: + /// + /// ```text + /// bytes=0-499 + /// ``` + /// + /// + pub range: Option, +} diff --git a/nexus/types/src/external_api/mod.rs b/nexus/types/src/external_api/mod.rs index e95beb865b1..363ddd3f41d 100644 --- a/nexus/types/src/external_api/mod.rs +++ b/nexus/types/src/external_api/mod.rs @@ -2,6 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +pub mod headers; pub mod params; pub mod shared; pub mod views; diff --git a/openapi/nexus-internal.json b/openapi/nexus-internal.json index b69473e1385..426e6be8192 100644 --- a/openapi/nexus-internal.json +++ b/openapi/nexus-internal.json @@ -891,6 +891,14 @@ "summary": "Download the contents of a support bundle", "operationId": "support_bundle_download", "parameters": [ + { + "in": "header", + "name": "range", + "description": "A request to access a portion of the resource, such as:\n\n```text bytes=0-499 ```\n\n", + "schema": { + "type": "string" + } + }, { "in": "path", "name": "bundle_id", diff --git a/openapi/nexus.json b/openapi/nexus.json index 8ba6882ed3b..1a87a74ea99 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -463,6 +463,14 @@ "summary": "Download the contents of a support bundle", "operationId": "support_bundle_download", "parameters": [ + { + "in": "header", + "name": "range", + "description": "A request to access a portion of the resource, such as:\n\n```text bytes=0-499 ```\n\n", + "schema": { + "type": "string" + } + }, { "in": "path", "name": "bundle_id", diff --git a/openapi/sled-agent.json b/openapi/sled-agent.json index 3aeac93c776..bcc691ca635 100644 --- a/openapi/sled-agent.json +++ b/openapi/sled-agent.json @@ -1123,6 +1123,14 @@ "summary": "Fetch a support bundle from a particular dataset", "operationId": "support_bundle_download", "parameters": [ + { + "in": "header", + "name": "range", + "description": "A request to access a portion of the resource, such as:\n\n```text bytes=0-499 ```\n\n", + "schema": { + "type": "string" + } + }, { "in": "path", "name": "dataset_id", diff --git a/range-requests/src/lib.rs b/range-requests/src/lib.rs index 44aa999fb31..3987aa00a65 100644 --- a/range-requests/src/lib.rs +++ b/range-requests/src/lib.rs @@ -137,6 +137,22 @@ impl PotentialRange { Self(Vec::from(bytes)) } + /// Parse the range request as a UTF-8 string. + /// + /// This makes no other attempts to validate the range -- use [Self::parse] + /// to accomplish that. + /// + /// This can be useful when attempting to proxy the range request + /// without interpreting the contents - e.g., when the total length of the + /// underlying object is not known. + /// + /// Will only return an [Error::Parse] error on failure. + pub fn try_into_str(&self) -> Result<&str, Error> { + str::from_utf8(&self.0).map_err(|_err| { + Error::Parse(http_range::HttpRangeParseError::InvalidRange) + }) + } + /// Parses a single range request out of the range request. /// /// `len` is the total length of the document, for the range request being made. @@ -315,6 +331,26 @@ mod test { } } + #[test] + fn range_into_str() { + let s = "not actually a range; we're just testing UTF-8 parsing"; + let ok_range = PotentialRange::new(s.as_bytes()); + assert_eq!( + ok_range + .try_into_str() + .expect("Should have been able to parse string"), + s + ); + + let bad_range = PotentialRange::new(&[0xff, 0xff, 0xff, 0xff]); + assert!(matches!( + bad_range + .try_into_str() + .expect_err("Should not parse invalid UTF-8"), + Error::Parse(http_range::HttpRangeParseError::InvalidRange) + )); + } + #[test] fn invalid_ranges() { assert!(matches!( diff --git a/sled-agent/api/src/lib.rs b/sled-agent/api/src/lib.rs index 7bdba01e1bc..a3510309a30 100644 --- a/sled-agent/api/src/lib.rs +++ b/sled-agent/api/src/lib.rs @@ -7,10 +7,10 @@ use std::time::Duration; use camino::Utf8PathBuf; use dropshot::{ - Body, FreeformBody, HttpError, HttpResponseAccepted, HttpResponseCreated, - HttpResponseDeleted, HttpResponseHeaders, HttpResponseOk, - HttpResponseUpdatedNoContent, Path, Query, RequestContext, StreamingBody, - TypedBody, + Body, FreeformBody, Header, HttpError, HttpResponseAccepted, + HttpResponseCreated, HttpResponseDeleted, HttpResponseHeaders, + HttpResponseOk, HttpResponseUpdatedNoContent, Path, Query, RequestContext, + StreamingBody, TypedBody, }; use nexus_sled_agent_shared::inventory::{ Inventory, OmicronSledConfig, OmicronSledConfigResult, SledRole, @@ -190,6 +190,7 @@ pub trait SledAgentApi { }] async fn support_bundle_download( rqctx: RequestContext, + headers: Header, path_params: Path, ) -> Result, HttpError>; @@ -811,6 +812,19 @@ pub struct SupportBundleMetadata { pub state: SupportBundleState, } +/// Range request headers +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct RangeRequestHeaders { + /// A request to access a portion of the resource, such as: + /// + /// ```text + /// bytes=0-499 + /// ``` + /// + /// + pub range: Option, +} + /// Path parameters for sled-diagnostics log requests used by support bundles /// (sled agent API) #[derive(Deserialize, JsonSchema)] diff --git a/sled-agent/src/http_entrypoints.rs b/sled-agent/src/http_entrypoints.rs index 0056056eb33..b2f66177e5c 100644 --- a/sled-agent/src/http_entrypoints.rs +++ b/sled-agent/src/http_entrypoints.rs @@ -12,7 +12,7 @@ use bootstore::schemes::v0::NetworkConfig; use camino::Utf8PathBuf; use display_error_chain::DisplayErrorChain; use dropshot::{ - ApiDescription, Body, ErrorStatusCode, FreeformBody, HttpError, + ApiDescription, Body, ErrorStatusCode, FreeformBody, Header, HttpError, HttpResponseAccepted, HttpResponseCreated, HttpResponseDeleted, HttpResponseHeaders, HttpResponseOk, HttpResponseUpdatedNoContent, Path, Query, RequestContext, StreamingBody, TypedBody, @@ -29,6 +29,7 @@ use omicron_common::api::internal::shared::{ use omicron_common::disk::{ DatasetsConfig, DiskVariant, M2Slot, OmicronPhysicalDisksConfig, }; +use range_requests::PotentialRange; use range_requests::RequestContextEx; use sled_agent_api::*; use sled_agent_types::boot_disk::{ @@ -255,13 +256,17 @@ impl SledAgentApi for SledAgentImpl { async fn support_bundle_download( rqctx: RequestContext, + headers: Header, path_params: Path, ) -> Result, HttpError> { let sa = rqctx.context(); let SupportBundlePathParam { zpool_id, dataset_id, support_bundle_id } = path_params.into_inner(); - let range = rqctx.range(); + let range = headers + .into_inner() + .range + .map(|r| PotentialRange::new(r.as_bytes())); Ok(sa .as_support_bundle_storage() .get( diff --git a/sled-agent/src/sim/http_entrypoints.rs b/sled-agent/src/sim/http_entrypoints.rs index 59eb99a4d6e..0ba716df0d4 100644 --- a/sled-agent/src/sim/http_entrypoints.rs +++ b/sled-agent/src/sim/http_entrypoints.rs @@ -10,6 +10,7 @@ use camino::Utf8PathBuf; use dropshot::ApiDescription; use dropshot::ErrorStatusCode; use dropshot::FreeformBody; +use dropshot::Header; use dropshot::HttpError; use dropshot::HttpResponseAccepted; use dropshot::HttpResponseCreated; @@ -37,6 +38,7 @@ use omicron_common::api::internal::shared::{ }; use omicron_common::disk::DatasetsConfig; use omicron_common::disk::OmicronPhysicalDisksConfig; +use range_requests::PotentialRange; use range_requests::RequestContextEx; use sled_agent_api::*; use sled_agent_types::boot_disk::BootDiskOsWriteStatus; @@ -425,13 +427,17 @@ impl SledAgentApi for SledAgentSimImpl { async fn support_bundle_download( rqctx: RequestContext, + headers: Header, path_params: Path, ) -> Result, HttpError> { let sa = rqctx.context(); let SupportBundlePathParam { zpool_id, dataset_id, support_bundle_id } = path_params.into_inner(); - let range = rqctx.range(); + let range = headers + .into_inner() + .range + .map(|r| PotentialRange::new(r.as_bytes())); sa.support_bundle_get( zpool_id, dataset_id,