diff --git a/dev-tools/omdb/src/bin/omdb/nexus.rs b/dev-tools/omdb/src/bin/omdb/nexus.rs index 4500580ad50..b26c48c2460 100644 --- a/dev-tools/omdb/src/bin/omdb/nexus.rs +++ b/dev-tools/omdb/src/bin/omdb/nexus.rs @@ -17,6 +17,7 @@ use crate::helpers::ConfirmationPrompt; use crate::helpers::const_max_len; use crate::helpers::display_option_blank; use crate::helpers::should_colorize; +use crate::support_bundle_collect::TimeWindowArgs; use anyhow::Context as _; use anyhow::bail; use camino::Utf8PathBuf; @@ -95,6 +96,7 @@ use nexus_types::internal_api::background::TufArtifactReplicationRequest; use nexus_types::internal_api::background::TufArtifactReplicationStatus; use nexus_types::internal_api::background::TufRepoPrunerStatus; use nexus_types::internal_api::background::fm_rendezvous; +use nexus_types::support_bundle::BundleDataCategory; use omicron_uuid_kinds::BlueprintUuid; use omicron_uuid_kinds::CollectionUuid; use omicron_uuid_kinds::DemoSagaUuid; @@ -573,7 +575,7 @@ enum SupportBundleCommands { /// List all support bundles List, /// Create a new support bundle - Create, + Create(SupportBundleCreateArgs), /// Delete a support bundle Delete(SupportBundleDeleteArgs), /// Download an entire support bundle @@ -664,6 +666,17 @@ impl FromStr for TrustQuorumEpochOrLatest { } } +#[derive(Debug, Args)] +struct SupportBundleCreateArgs { + /// Categories of data to collect. May be supplied multiple times. + /// Defaults to all categories. + #[clap(long, value_enum)] + include: Vec, + + #[command(flatten)] + window: TimeWindowArgs, +} + #[derive(Debug, Args)] struct SupportBundleDeleteArgs { id: SupportBundleUuid, @@ -929,10 +942,10 @@ impl NexusArgs { command: SupportBundleCommands::List, }) => cmd_nexus_support_bundles_list(&client).await, NexusCommands::SupportBundles(SupportBundleArgs { - command: SupportBundleCommands::Create, + command: SupportBundleCommands::Create(args), }) => { let token = omdb.check_allow_destructive()?; - cmd_nexus_support_bundles_create(&client, token).await + cmd_nexus_support_bundles_create(&client, args, token).await } NexusCommands::SupportBundles(SupportBundleArgs { command: SupportBundleCommands::Delete(args), @@ -5649,14 +5662,62 @@ async fn lookup_sled_by_id( /// Runs `omdb nexus support-bundles create` async fn cmd_nexus_support_bundles_create( client: &nexus_lockstep_client::Client, + args: &SupportBundleCreateArgs, _destruction_token: DestructiveOperationToken, ) -> Result<(), anyhow::Error> { + use nexus_lockstep_client::types; + + // No --include collects everything, matching `support-bundle collect`. + // The API's other reading of an empty selection, "collect nothing", + // cannot be specified by clap, which rejects --include without a value. + let data = if args.include.is_empty() { + types::SupportBundleData::All + } else { + let mut reconfigurator = false; + let mut sled_cubby_info = false; + let mut sp_dumps = false; + let mut host_info = None; + let mut ereports = None; + + for category in &args.include { + match category { + BundleDataCategory::Reconfigurator => reconfigurator = true, + BundleDataCategory::SledCubbyInfo => sled_cubby_info = true, + BundleDataCategory::SpDumps => sp_dumps = true, + BundleDataCategory::HostInfo => { + host_info = Some(types::SupportBundleHostInfo { + sleds: types::SupportBundleSledSelection::All, + }) + } + BundleDataCategory::Ereports => { + ereports = Some(types::SupportBundleEreports { + only_serials: Vec::new(), + only_classes: Vec::new(), + }) + } + } + } + + types::SupportBundleData::Explicit { + reconfigurator, + sled_cubby_info, + sp_dumps, + host_info, + ereports, + } + }; + + let window = args.window.bounds()?; + let support_bundle_id = client - .support_bundle_create( - &nexus_lockstep_client::types::SupportBundleCreate { - user_comment: None, - }, - ) + .support_bundle_create(&types::SupportBundleCreate { + user_comment: None, + data_selection: Some(types::SupportBundleDataSelection { + data, + start_time: window.start, + end_time: window.end, + }), + }) .await .context("creating support bundle")? .into_inner() diff --git a/dev-tools/omdb/src/bin/omdb/support_bundle_collect.rs b/dev-tools/omdb/src/bin/omdb/support_bundle_collect.rs index 8297199ffc3..1356c660e0b 100644 --- a/dev-tools/omdb/src/bin/omdb/support_bundle_collect.rs +++ b/dev-tools/omdb/src/bin/omdb/support_bundle_collect.rs @@ -21,6 +21,8 @@ use crate::db::DbUrlOptions; use anyhow::Context; use camino::Utf8PathBuf; use camino_tempfile::tempdir_in; +use chrono::DateTime; +use chrono::Utc; use clap::Args; use clap::Subcommand; use clap::ValueEnum; @@ -79,17 +81,8 @@ struct CollectArgs { #[clap(long, value_enum)] include: Vec, - /// Only collect time-bounded data (zone logs, ereports) newer than - /// this age, e.g. "2days" or "12h 30m". Defaults to a 7-day window - /// ending at --until (or now). - #[clap(long, value_parser = humantime::parse_duration)] - since: Option, - - /// Only collect time-bounded data (zone logs, ereports) older than - /// this age, e.g. "1h". Must be a smaller age than --since. Log files - /// that span this bound are included in full. - #[clap(long, value_parser = humantime::parse_duration)] - until: Option, + #[command(flatten)] + window: TimeWindowArgs, } impl CollectArgs { @@ -113,10 +106,43 @@ impl CollectArgs { }; } - // Both flags are ages relative to now: --since is the oldest data - // to include (the window's start), --until the newest (its end). - // Without --since, `collect` fills in the default lookback, - // anchored to the end bound when one is given. + let window = self.window.bounds()?; + Ok(sel.with_time_range(BundleTimeRange::new(window.start, window.end)?)) + } +} + +/// The absolute window that [`TimeWindowArgs`] resolves to. +pub struct TimeWindowBounds { + pub start: Option>, + pub end: Option>, +} + +/// The `--since`/`--until` flags naming the window that bounds +/// time-bounded bundle data. +#[derive(Debug, Args)] +pub struct TimeWindowArgs { + /// Only collect time-bounded data (zone logs, ereports) newer than + /// this age, e.g. "2days" or "12h 30m". Defaults to a 7-day window + /// ending at --until (or now). + #[clap(long, value_parser = humantime::parse_duration)] + since: Option, + + /// Only collect time-bounded data (zone logs, ereports) older than + /// this age, e.g. "1h". Must be a smaller age than --since. Log files + /// that span this bound are included in full. + #[clap(long, value_parser = humantime::parse_duration)] + until: Option, +} + +impl TimeWindowArgs { + /// Resolves both flags into absolute timestamps. + /// + /// Both flags are ages relative to now: --since is the oldest data + /// to include (the window's start), --until the newest (its end). + /// Without --since, the start bound is filled in with the default + /// lookback where the selection enters the system, anchored to the + /// end bound when one is given. + pub fn bounds(&self) -> anyhow::Result { let now = omicron_common::now_db_precision(); let age_to_timestamp = |flag: &str, age: std::time::Duration| { chrono::Duration::from_std(age) @@ -135,7 +161,7 @@ impl CollectArgs { --until ({end})", ); } - Ok(sel.with_time_range(BundleTimeRange::new(start, end)?)) + Ok(TimeWindowBounds { start, end }) } } diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index df004b51382..cc402c90f6d 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -87,6 +87,7 @@ api_versions!([ // | date-based version should be at the top of the list. // v // (next_yyyy_mm_dd_nn, IDENT), + (2026_08_27_00, SUPPORT_BUNDLE_DATA_SELECTION), (2026_08_19_01, BGP_PEER_SRC_ADDR), (2026_08_17_00, SUPPORT_BUNDLES_STABLE), (2026_08_14_00, ALERT_LIST), @@ -8574,16 +8575,36 @@ pub trait NexusExternalApi { method = GET, path = "/v1/system/support-bundles/{bundle_id}", tags = ["system/support-bundles"], - versions = VERSION_SUPPORT_BUNDLES_STABLE.., + versions = VERSION_SUPPORT_BUNDLE_DATA_SELECTION.., }] async fn support_bundle_view( rqctx: RequestContext, path_params: Path, ) -> Result< - HttpResponseOk, + HttpResponseOk, HttpError, >; + /// View support bundle + #[endpoint { + operation_id = "support_bundle_view", + method = GET, + path = "/v1/system/support-bundles/{bundle_id}", + tags = ["system/support-bundles"], + versions = VERSION_SUPPORT_BUNDLES_STABLE..VERSION_SUPPORT_BUNDLE_DATA_SELECTION, + }] + async fn support_bundle_view_v2026_08_17_00( + rqctx: RequestContext, + path_params: Path, + ) -> Result< + HttpResponseOk, + HttpError, + > { + Ok(Self::support_bundle_view(rqctx, path_params) + .await? + .map(v2025_11_20_00::support_bundle::SupportBundleInfo::from)) + } + /// View support bundle #[endpoint { operation_id = "support_bundle_view", @@ -8599,7 +8620,7 @@ pub trait NexusExternalApi { HttpResponseOk, HttpError, > { - Self::support_bundle_view(rqctx, path_params).await + Self::support_bundle_view_v2026_08_17_00(rqctx, path_params).await } /// Download support bundle index @@ -8756,7 +8777,7 @@ pub trait NexusExternalApi { method = POST, path = "/v1/system/support-bundles", tags = ["system/support-bundles"], - versions = VERSION_SUPPORT_BUNDLES_STABLE.., + versions = VERSION_SUPPORT_BUNDLE_DATA_SELECTION.., }] async fn support_bundle_create( rqctx: RequestContext, @@ -8766,6 +8787,24 @@ pub trait NexusExternalApi { HttpError, >; + /// Create support bundle + #[endpoint { + operation_id = "support_bundle_create", + method = POST, + path = "/v1/system/support-bundles", + tags = ["system/support-bundles"], + versions = VERSION_SUPPORT_BUNDLES_STABLE..VERSION_SUPPORT_BUNDLE_DATA_SELECTION, + }] + async fn support_bundle_create_v2026_08_17_00( + rqctx: RequestContext, + body: TypedBody, + ) -> Result< + HttpResponseCreated, + HttpError, + > { + Self::support_bundle_create(rqctx, body.map(Into::into)).await + } + /// Create support bundle #[endpoint { operation_id = "support_bundle_create", @@ -8781,7 +8820,7 @@ pub trait NexusExternalApi { HttpResponseCreated, HttpError, > { - Self::support_bundle_create(rqctx, body).await + Self::support_bundle_create_v2026_08_17_00(rqctx, body).await } /// Delete support bundle diff --git a/nexus/src/app/support_bundles.rs b/nexus/src/app/support_bundles.rs index 1916bbc1d5c..b70c0eaf398 100644 --- a/nexus/src/app/support_bundles.rs +++ b/nexus/src/app/support_bundles.rs @@ -13,7 +13,9 @@ use nexus_db_model::SupportBundleState; use nexus_db_queries::authz; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::datastore::SupportBundleCreateParams; +use nexus_types::external_api::support_bundle::SupportBundleDataSelection; use nexus_types::support_bundle::BundleDataSelection; +use nexus_types::support_bundle::SledSelection; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::DeleteResult; @@ -59,12 +61,45 @@ impl super::Nexus { Ok(db_bundle) } + /// Looks up a support bundle along with the data selection it was + /// created with. + pub async fn support_bundle_view_with_data_selection( + &self, + opctx: &OpContext, + id: SupportBundleUuid, + ) -> LookupResult<(SupportBundle, BundleDataSelection)> { + let (authz_bundle, db_bundle) = + LookupPath::new(opctx, &self.db_datastore) + .support_bundle(id) + .fetch() + .await?; + + let data_selection = self + .db_datastore + .support_bundle_data_selection_get(opctx, &authz_bundle) + .await?; + + Ok((db_bundle, data_selection)) + } + pub async fn support_bundle_create( &self, opctx: &OpContext, reason: &'static str, user_comment: Option, + data_selection: Option, ) -> CreateResult { + // Authorize before validating the selection. Validation looks sleds + // up, and an unauthorized caller must be turned away by this check + // rather than by a lookup failure further in. + opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; + + let data_selection = match data_selection { + Some(data_selection) => data_selection.try_into()?, + None => BundleDataSelection::all(), + }; + self.support_bundle_validate_sleds(opctx, &data_selection).await?; + self.db_datastore .support_bundle_create( &opctx, @@ -72,13 +107,41 @@ impl super::Nexus { reason, nexus_id: self.id, user_comment, - // TODO: eventually allow user-selectable data selection from the API. - data_selection: BundleDataSelection::all(), + data_selection, }, ) .await } + /// Rejects a selection naming a sled that does not exist. + /// + /// Without this, a mistyped UUID produces a bundle that quietly collects + /// nothing from that sled. + async fn support_bundle_validate_sleds( + &self, + opctx: &OpContext, + data_selection: &BundleDataSelection, + ) -> Result<(), Error> { + let Some(SledSelection::Specific(sled_ids)) = + data_selection.sled_selection() + else { + return Ok(()); + }; + + for sled_id in sled_ids { + self.sled_lookup(opctx, sled_id)? + .lookup_for(authz::Action::Read) + .await + .map_err(|e| match e { + Error::ObjectNotFound { .. } => Error::invalid_request( + format!("sled {sled_id} does not exist"), + ), + e => e, + })?; + } + Ok(()) + } + pub async fn support_bundle_download( &self, opctx: &OpContext, diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 1046148aca1..cf74b10133a 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -8066,7 +8066,7 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn support_bundle_view( rqctx: RequestContext, path_params: Path, - ) -> Result, HttpError> + ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { @@ -8076,14 +8076,17 @@ impl NexusExternalApi for NexusExternalApiImpl { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let bundle = nexus - .support_bundle_view( + let (bundle, data_selection) = nexus + .support_bundle_view_with_data_selection( &opctx, SupportBundleUuid::from_untyped_uuid(path.bundle_id), ) .await?; - Ok(HttpResponseOk(bundle.into())) + Ok(HttpResponseOk(support_bundle::SupportBundleView { + bundle: bundle.into(), + data_selection: (&data_selection).into(), + })) }; apictx .context @@ -8281,6 +8284,7 @@ impl NexusExternalApi for NexusExternalApiImpl { &opctx, "Created by external API", create_params.user_comment, + create_params.data_selection, ) .await?; Ok(HttpResponseCreated(bundle.into())) diff --git a/nexus/src/lockstep_api/http_entrypoints.rs b/nexus/src/lockstep_api/http_entrypoints.rs index c793d199646..56412a8ff28 100644 --- a/nexus/src/lockstep_api/http_entrypoints.rs +++ b/nexus/src/lockstep_api/http_entrypoints.rs @@ -881,6 +881,7 @@ impl NexusLockstepApi for NexusLockstepApiImpl { &opctx, "Created by internal API", create_params.user_comment, + create_params.data_selection, ) .await?; Ok(HttpResponseCreated(bundle.into())) diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 20f6e856b4e..f30b5a52eae 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -3080,6 +3080,7 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( serde_json::to_value( &support_bundle::SupportBundleCreate { user_comment: None, + data_selection: None, }, ) .unwrap(), diff --git a/nexus/tests/integration_tests/support_bundles.rs b/nexus/tests/integration_tests/support_bundles.rs index fc946d33992..2d15d2ca112 100644 --- a/nexus/tests/integration_tests/support_bundles.rs +++ b/nexus/tests/integration_tests/support_bundles.rs @@ -19,8 +19,15 @@ use nexus_test_utils::http_testing::AuthnMode; use nexus_test_utils::http_testing::NexusRequest; use nexus_test_utils::http_testing::RequestBuilder; use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::support_bundle::SupportBundleCreate; +use nexus_types::external_api::support_bundle::SupportBundleData; +use nexus_types::external_api::support_bundle::SupportBundleDataSelection; +use nexus_types::external_api::support_bundle::SupportBundleEreports; +use nexus_types::external_api::support_bundle::SupportBundleHostInfo; use nexus_types::external_api::support_bundle::SupportBundleInfo; +use nexus_types::external_api::support_bundle::SupportBundleSledSelection; use nexus_types::external_api::support_bundle::SupportBundleState; +use nexus_types::external_api::support_bundle::SupportBundleView; use nexus_types::internal_api::background::SupportBundleActivationReport; use nexus_types::internal_api::background::SupportBundleCleanupReport; use nexus_types::internal_api::background::SupportBundleCollectionStep; @@ -30,6 +37,7 @@ use nexus_types::support_bundle::BundleDataSelection; use nexus_types::support_bundle::BundleTimeRange; use omicron_common::api::external::LookupType; use omicron_sled_agent::sim::SimLogEntry; +use omicron_uuid_kinds::SledUuid; use omicron_uuid_kinds::SupportBundleUuid; use serde::Deserialize; use std::io::Cursor; @@ -99,6 +107,13 @@ async fn bundle_get( client: &ClientTestContext, id: SupportBundleUuid, ) -> Result { + Ok(bundle_view(client, id).await?.bundle) +} + +async fn bundle_view( + client: &ClientTestContext, + id: SupportBundleUuid, +) -> Result { let url = format!("{BUNDLES_URL}/{id}"); NexusRequest::object_get(client, &url) .authn_as(AuthnMode::PrivilegedUser) @@ -161,9 +176,8 @@ async fn bundle_create_with_comment( client: &ClientTestContext, user_comment: Option, ) -> Result { - use nexus_types::external_api::support_bundle::SupportBundleCreate; - - let create_params = SupportBundleCreate { user_comment }; + let create_params = + SupportBundleCreate { user_comment, data_selection: None }; NexusRequest::new( RequestBuilder::new(client, Method::POST, BUNDLES_URL) @@ -183,9 +197,8 @@ async fn bundle_create_expect_fail( expected_status: StatusCode, expected_message: &str, ) -> Result<()> { - use nexus_types::external_api::support_bundle::SupportBundleCreate; - - let create_params = SupportBundleCreate { user_comment: None }; + let create_params = + SupportBundleCreate { user_comment: None, data_selection: None }; let error = NexusRequest::new( RequestBuilder::new(client, Method::POST, BUNDLES_URL) .body(Some(&create_params)) @@ -208,6 +221,53 @@ async fn bundle_create_expect_fail( Ok(()) } +async fn bundle_create_with_selection( + client: &ClientTestContext, + data_selection: SupportBundleDataSelection, +) -> Result { + let create_params = SupportBundleCreate { + user_comment: None, + data_selection: Some(data_selection), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, BUNDLES_URL) + .body(Some(&create_params)) + .expect_status(Some(StatusCode::CREATED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .context("failed to request bundle creation")? + .parsed_body() +} + +/// Requests a bundle expected to be rejected, returning the error message. +async fn bundle_create_with_selection_expect_fail( + client: &ClientTestContext, + data_selection: SupportBundleDataSelection, + expected_status: StatusCode, +) -> Result { + let create_params = SupportBundleCreate { + user_comment: None, + data_selection: Some(data_selection), + }; + + let error = NexusRequest::new( + RequestBuilder::new(client, Method::POST, BUNDLES_URL) + .body(Some(&create_params)) + .expect_status(Some(expected_status)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .context("should have failed to create bundle")? + .parsed_body::() + .context("failed to parse error from bundle creation")?; + + Ok(error.message) +} + async fn bundle_download( client: &ClientTestContext, id: SupportBundleUuid, @@ -1099,3 +1159,344 @@ async fn test_support_bundle_delete_failed_bundle( "Deleted bundle should not appear in bundle list" ); } + +/// Returns the categories a viewed selection reports, as a set of names, so +/// tests can compare against what they asked for. +fn viewed_categories( + selection: &SupportBundleDataSelection, +) -> Vec<&'static str> { + let SupportBundleData::Explicit { + reconfigurator, + sled_cubby_info, + sp_dumps, + host_info, + ereports, + } = &selection.data + else { + panic!("a stored selection is always reported explicitly"); + }; + + let mut categories = Vec::new(); + if *reconfigurator { + categories.push("reconfigurator"); + } + if *sled_cubby_info { + categories.push("sled_cubby_info"); + } + if *sp_dumps { + categories.push("sp_dumps"); + } + if host_info.is_some() { + categories.push("host_info"); + } + if ereports.is_some() { + categories.push("ereports"); + } + categories +} + +// Test that a bundle created without a data selection collects everything, +// and that the view reports the default lookback Nexus stamped for it. +#[nexus_test] +async fn test_support_bundle_default_data_selection( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let _disk_test = + DiskTestBuilder::new(&cptestctx).with_zpool_count(2).build().await; + + // CockroachDB stores timestamps at microsecond precision, so bracket + // creation with the same truncated clock Nexus stamps with. + let before = omicron_common::now_db_precision(); + let bundle = bundle_create(&client).await.unwrap(); + let after = omicron_common::now_db_precision(); + + let selection = + bundle_view(&client, bundle.id).await.unwrap().data_selection; + assert_eq!( + viewed_categories(&selection), + [ + "reconfigurator", + "sled_cubby_info", + "sp_dumps", + "host_info", + "ereports" + ], + ); + assert_eq!( + selection.data, + SupportBundleData::Explicit { + reconfigurator: true, + sled_cubby_info: true, + sp_dumps: true, + host_info: Some(SupportBundleHostInfo { + sleds: SupportBundleSledSelection::All + }), + ereports: Some(SupportBundleEreports { + only_serials: vec![], + only_classes: vec![], + }), + }, + ); + + // Nexus stamps a start bound seven days back, so an omitted window does + // not collect unbounded log history. + let lookback = chrono::Duration::days(7); + let start = selection.start_time.expect("creation stamps a start bound"); + assert!( + start >= before - lookback && start <= after - lookback, + "stamped start {start} is not seven days before creation \ + ({before} to {after})", + ); + assert_eq!(selection.end_time, None); + + // Asking for everything explicitly is the same thing. + let explicit = bundle_create_with_selection( + &client, + SupportBundleDataSelection { + data: SupportBundleData::All, + start_time: None, + end_time: None, + }, + ) + .await + .unwrap(); + let explicit = + bundle_view(&client, explicit.id).await.unwrap().data_selection; + assert_eq!(explicit.data, selection.data); +} + +// Test that an explicit data selection round-trips through creation and +// back out of the view endpoint. +#[nexus_test] +async fn test_support_bundle_explicit_data_selection( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let _disk_test = + DiskTestBuilder::new(&cptestctx).with_zpool_count(3).build().await; + + let sled_id = cptestctx.all_sled_agents().next().unwrap().sled_agent.id; + + // A subset of categories, each with its own settings. + let requested = SupportBundleData::Explicit { + reconfigurator: true, + sled_cubby_info: false, + sp_dumps: false, + host_info: Some(SupportBundleHostInfo { + sleds: SupportBundleSledSelection::Specific { + sleds: vec![sled_id], + }, + }), + ereports: Some(SupportBundleEreports { + only_serials: vec!["BRM-FAKE-0".to_string()], + only_classes: vec!["fake.class".to_string()], + }), + }; + // Microsecond precision: CockroachDB truncates anything finer, so a + // timestamp with nanoseconds would not come back as it was sent. + let now = omicron_common::now_db_precision(); + let start = now - chrono::Duration::days(2); + let end = now - chrono::Duration::hours(1); + + let bundle = bundle_create_with_selection( + &client, + SupportBundleDataSelection { + data: requested.clone(), + start_time: Some(start), + end_time: Some(end), + }, + ) + .await + .unwrap(); + + let selection = + bundle_view(&client, bundle.id).await.unwrap().data_selection; + assert_eq!( + viewed_categories(&selection), + ["reconfigurator", "host_info", "ereports"] + ); + assert_eq!(selection.data, requested); + assert_eq!(selection.start_time, Some(start)); + assert_eq!(selection.end_time, Some(end)); + + // An explicit selection naming nothing collects nothing. The bundle is + // still created; that is the caller's business. + let empty = SupportBundleData::Explicit { + reconfigurator: false, + sled_cubby_info: false, + sp_dumps: false, + host_info: None, + ereports: None, + }; + let bundle = bundle_create_with_selection( + &client, + SupportBundleDataSelection { + data: empty.clone(), + start_time: None, + end_time: None, + }, + ) + .await + .unwrap(); + + let selection = + bundle_view(&client, bundle.id).await.unwrap().data_selection; + assert_eq!(viewed_categories(&selection), Vec::<&str>::new()); + assert_eq!(selection.data, empty); +} + +// Test the data selections that creation rejects. +#[nexus_test] +async fn test_support_bundle_data_selection_bad_request( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let _disk_test = + DiskTestBuilder::new(&cptestctx).with_zpool_count(1).build().await; + + // A window whose start is after its end. + let now = omicron_common::now_db_precision(); + let message = bundle_create_with_selection_expect_fail( + &client, + SupportBundleDataSelection { + data: SupportBundleData::All, + start_time: Some(now), + end_time: Some(now - chrono::Duration::hours(1)), + }, + StatusCode::BAD_REQUEST, + ) + .await + .unwrap(); + assert!( + message.contains("must not be later than"), + "unexpected message: {message}" + ); + + // A sled that does not exist. Without this check, a mistyped UUID would + // produce a bundle that quietly collects nothing from that sled. + let missing_sled = SledUuid::new_v4(); + let message = bundle_create_with_selection_expect_fail( + &client, + SupportBundleDataSelection { + data: SupportBundleData::Explicit { + reconfigurator: false, + sled_cubby_info: false, + sp_dumps: false, + host_info: Some(SupportBundleHostInfo { + sleds: SupportBundleSledSelection::Specific { + sleds: vec![missing_sled], + }, + }), + ereports: None, + }, + start_time: None, + end_time: None, + }, + StatusCode::BAD_REQUEST, + ) + .await + .unwrap(); + assert!( + message.contains(&format!("sled {missing_sled} does not exist")), + "unexpected message: {message}" + ); +} + +// Test that viewing a bundle with no time range row succeeds, reporting no +// bounds. Bundles collected before time ranges were recorded have no such +// row: the schema migration only creates rows for bundles still awaiting +// collection. +#[nexus_test] +async fn test_support_bundle_view_without_time_range_row( + cptestctx: &ControlPlaneTestContext, +) { + use async_bb8_diesel::AsyncRunQueryDsl; + use diesel::ExpressionMethods; + use diesel::QueryDsl; + use omicron_uuid_kinds::GenericUuid; + + let client = &cptestctx.external_client; + let _disk_test = + DiskTestBuilder::new(&cptestctx).with_zpool_count(1).build().await; + + let bundle = bundle_create(&client).await.unwrap(); + + // Simulate such a bundle by deleting the row creation stamped. + let datastore = cptestctx.server.server_context().nexus.datastore(); + let conn = datastore.pool_connection_for_tests().await.unwrap(); + { + use nexus_db_schema::schema::support_bundle_data_selection_time_range::dsl; + diesel::delete( + dsl::support_bundle_data_selection_time_range + .filter(dsl::bundle_id.eq(bundle.id.into_untyped_uuid())), + ) + .execute_async(&*conn) + .await + .expect("Should be able to delete time range row"); + } + + let selection = + bundle_view(&client, bundle.id).await.unwrap().data_selection; + assert_eq!(selection.start_time, None); + assert_eq!(selection.end_time, None); + assert_eq!( + viewed_categories(&selection), + [ + "reconfigurator", + "sled_cubby_info", + "sp_dumps", + "host_info", + "ereports" + ], + ); +} + +// Test that an unprivileged caller is turned away by the authorization +// check, whatever the selection looks like. Validating the selection looks +// sleds up, so an unauthorized request must be rejected before that runs, +// rather than surfacing a lookup failure as a 400. +#[nexus_test] +async fn test_support_bundle_create_unauthorized_with_selection( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let _disk_test = + DiskTestBuilder::new(&cptestctx).with_zpool_count(1).build().await; + let sled_id = cptestctx.all_sled_agents().next().unwrap().sled_agent.id; + + for (label, sleds) in [ + ("no selection", None), + ("an existing sled", Some(vec![sled_id])), + ("a sled that does not exist", Some(vec![SledUuid::new_v4()])), + ] { + let create_params = SupportBundleCreate { + user_comment: None, + data_selection: sleds.map(|sleds| SupportBundleDataSelection { + data: SupportBundleData::Explicit { + reconfigurator: false, + sled_cubby_info: false, + sp_dumps: false, + host_info: Some(SupportBundleHostInfo { + sleds: SupportBundleSledSelection::Specific { sleds }, + }), + ereports: None, + }, + start_time: None, + end_time: None, + }), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, BUNDLES_URL) + .body(Some(&create_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::UnprivilegedUser) + .execute() + .await + .unwrap_or_else(|e| { + panic!("creating a bundle naming {label} should be forbidden: {e}") + }); + } +} diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 06223786b00..d088b5a16d4 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -541,6 +541,7 @@ static SETUP_REQUESTS: LazyLock> = LazyLock::new(|| { body: serde_json::to_value( &nexus_types::external_api::support_bundle::SupportBundleCreate { user_comment: None, + data_selection: None, }, ) .unwrap(), diff --git a/nexus/types/src/external_api/support_bundle.rs b/nexus/types/src/external_api/support_bundle.rs index 1ec9d2de73a..18e0e12768e 100644 --- a/nexus/types/src/external_api/support_bundle.rs +++ b/nexus/types/src/external_api/support_bundle.rs @@ -4,4 +4,192 @@ //! Support bundle types. +use crate::fm::ereport::EreportFilters; +use crate::support_bundle::BundleDataSelection; +use crate::support_bundle::BundleTimeRange; +use crate::support_bundle::SledSelection; +use omicron_common::api::external::Error; + pub use nexus_types_versions::latest::support_bundle::*; + +impl TryFrom for BundleDataSelection { + type Error = Error; + + fn try_from(api: SupportBundleDataSelection) -> Result { + let SupportBundleDataSelection { data, start_time, end_time } = api; + + let selection = match data { + SupportBundleData::All => BundleDataSelection::all(), + SupportBundleData::Explicit { + reconfigurator, + sled_cubby_info, + sp_dumps, + host_info, + ereports, + } => { + let mut selection = BundleDataSelection::new(); + if reconfigurator { + selection = selection.with_reconfigurator(); + } + if sled_cubby_info { + selection = selection.with_sled_cubby_info(); + } + if sp_dumps { + selection = selection.with_sp_dumps(); + } + if let Some(host_info) = host_info { + selection = match host_info.sleds { + SupportBundleSledSelection::All => { + selection.with_all_sleds() + } + SupportBundleSledSelection::Specific { sleds } => { + selection.with_specific_sleds(sleds) + } + }; + } + if let Some(ereports) = ereports { + selection = selection.with_ereports( + EreportFilters::new() + .with_serials(ereports.only_serials) + .with_classes(ereports.only_classes), + ); + } + selection + } + }; + + let time_range = BundleTimeRange::new(start_time, end_time) + .map_err(|e| Error::invalid_request(e.to_string()))?; + Ok(selection.with_time_range(time_range)) + } +} + +impl From<&BundleDataSelection> for SupportBundleDataSelection { + fn from(selection: &BundleDataSelection) -> Self { + // The stored selection is always a concrete set of categories, so + // this is always the explicit form. + let data = SupportBundleData::Explicit { + reconfigurator: selection.contains_reconfigurator(), + sled_cubby_info: selection.contains_sled_cubby_info(), + sp_dumps: selection.contains_sp_dumps(), + host_info: selection.sled_selection().map(|sleds| { + SupportBundleHostInfo { + sleds: match sleds { + SledSelection::All => SupportBundleSledSelection::All, + SledSelection::Specific(sleds) => { + SupportBundleSledSelection::Specific { + sleds: sleds.iter().copied().collect(), + } + } + }, + } + }), + ereports: selection.ereport_filters().map(|filters| { + SupportBundleEreports { + only_serials: filters.only_serials().to_vec(), + only_classes: filters.only_classes().to_vec(), + } + }), + }; + + let range = selection.time_range(); + Self { data, start_time: range.start(), end_time: range.end() } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::DateTime; + use chrono::Utc; + use omicron_uuid_kinds::SledUuid; + use proptest::prelude::*; + use test_strategy::proptest; + + /// Every stored selection survives a trip through the API type. + #[proptest] + fn api_type_round_trip(selection: BundleDataSelection) { + let api = SupportBundleDataSelection::from(&selection); + let back = BundleDataSelection::try_from(api) + .expect("a stored selection has an ordered time range"); + prop_assert_eq!(selection, back); + } + + #[test] + fn all_selects_every_category() { + let api = SupportBundleDataSelection { + data: SupportBundleData::All, + start_time: None, + end_time: None, + }; + let selection = BundleDataSelection::try_from(api).unwrap(); + assert_eq!(selection, BundleDataSelection::all()); + } + + #[test] + fn an_empty_explicit_selection_selects_nothing() { + let api = SupportBundleDataSelection { + data: SupportBundleData::Explicit { + reconfigurator: false, + sled_cubby_info: false, + sp_dumps: false, + host_info: None, + ereports: None, + }, + start_time: None, + end_time: None, + }; + let selection = BundleDataSelection::try_from(api).unwrap(); + assert_eq!(selection, BundleDataSelection::new()); + } + + #[test] + fn explicit_selection_carries_per_category_settings() { + let sled = SledUuid::new_v4(); + let api = SupportBundleDataSelection { + data: SupportBundleData::Explicit { + reconfigurator: true, + sled_cubby_info: false, + sp_dumps: false, + host_info: Some(SupportBundleHostInfo { + sleds: SupportBundleSledSelection::Specific { + sleds: vec![sled], + }, + }), + ereports: Some(SupportBundleEreports { + only_serials: vec!["BRM-FAKE-0".to_string()], + only_classes: vec!["fake.class".to_string()], + }), + }, + start_time: None, + end_time: None, + }; + let selection = BundleDataSelection::try_from(api).unwrap(); + + assert!(selection.contains_reconfigurator()); + assert!(!selection.contains_sled_cubby_info()); + assert!(!selection.contains_sp_dumps()); + assert_eq!( + selection.sled_selection(), + Some(&SledSelection::Specific([sled].into_iter().collect())) + ); + let filters = selection.ereport_filters().unwrap(); + assert_eq!(filters.only_serials(), ["BRM-FAKE-0"]); + assert_eq!(filters.only_classes(), ["fake.class"]); + } + + #[test] + fn an_inverted_time_range_is_a_bad_request() { + let ts = |secs| DateTime::::from_timestamp(secs, 0).unwrap(); + let api = SupportBundleDataSelection { + data: SupportBundleData::All, + start_time: Some(ts(200)), + end_time: Some(ts(100)), + }; + let err = BundleDataSelection::try_from(api).unwrap_err(); + assert!( + matches!(err, Error::InvalidRequest { .. }), + "unexpected error: {err:?}" + ); + } +} diff --git a/nexus/types/versions/src/latest.rs b/nexus/types/versions/src/latest.rs index c77692ed2db..019c79b5a6d 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -414,12 +414,19 @@ pub mod snapshot { } pub mod support_bundle { - pub use crate::v2025_11_20_00::support_bundle::SupportBundleCreate; pub use crate::v2025_11_20_00::support_bundle::SupportBundleFilePath; pub use crate::v2025_11_20_00::support_bundle::SupportBundleInfo; pub use crate::v2025_11_20_00::support_bundle::SupportBundlePath; pub use crate::v2025_11_20_00::support_bundle::SupportBundleState; pub use crate::v2025_11_20_00::support_bundle::SupportBundleUpdate; + + pub use crate::v2026_08_27_00::support_bundle::SupportBundleCreate; + pub use crate::v2026_08_27_00::support_bundle::SupportBundleData; + pub use crate::v2026_08_27_00::support_bundle::SupportBundleDataSelection; + pub use crate::v2026_08_27_00::support_bundle::SupportBundleEreports; + pub use crate::v2026_08_27_00::support_bundle::SupportBundleHostInfo; + pub use crate::v2026_08_27_00::support_bundle::SupportBundleSledSelection; + pub use crate::v2026_08_27_00::support_bundle::SupportBundleView; } pub mod switch { diff --git a/nexus/types/versions/src/lib.rs b/nexus/types/versions/src/lib.rs index 1f9611e71d6..2f643e70321 100644 --- a/nexus/types/versions/src/lib.rs +++ b/nexus/types/versions/src/lib.rs @@ -103,3 +103,5 @@ pub mod v2026_08_12_00; pub mod v2026_08_14_00; #[path = "bgp_peer_src_addr/mod.rs"] pub mod v2026_08_14_01; +#[path = "support_bundle_data_selection/mod.rs"] +pub mod v2026_08_27_00; diff --git a/nexus/types/versions/src/support_bundle_data_selection/mod.rs b/nexus/types/versions/src/support_bundle_data_selection/mod.rs new file mode 100644 index 00000000000..f996b9c95c7 --- /dev/null +++ b/nexus/types/versions/src/support_bundle_data_selection/mod.rs @@ -0,0 +1,10 @@ +// 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/. + +//! Version `SUPPORT_BUNDLE_DATA_SELECTION` of the Nexus external API. +//! +//! This version lets callers choose what a support bundle collects, and +//! reports the selection back when viewing a bundle. + +pub mod support_bundle; diff --git a/nexus/types/versions/src/support_bundle_data_selection/support_bundle.rs b/nexus/types/versions/src/support_bundle_data_selection/support_bundle.rs new file mode 100644 index 00000000000..4b5909a7b49 --- /dev/null +++ b/nexus/types/versions/src/support_bundle_data_selection/support_bundle.rs @@ -0,0 +1,154 @@ +// 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/. + +//! Support bundle types for the Nexus external API. + +use crate::v2025_11_20_00; +use crate::v2025_11_20_00::support_bundle::SupportBundleInfo; +use chrono::DateTime; +use chrono::Utc; +use omicron_uuid_kinds::SledUuid; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +/// The sleds to collect host info from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SupportBundleSledSelection { + /// Collect from every sled. + All, + /// Collect only from the listed sleds. An empty list collects from none + /// of them. + Specific { + #[schemars(with = "Vec")] + sleds: Vec, + }, +} + +impl Default for SupportBundleSledSelection { + fn default() -> Self { + Self::All {} + } +} + +/// Host info collection: diagnostic commands and zone logs from sleds. +#[derive( + Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, +)] +pub struct SupportBundleHostInfo { + /// The sleds to collect from. Every sled if omitted. + #[serde(default)] + pub sleds: SupportBundleSledSelection, +} + +/// Ereport collection. +#[derive( + Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, +)] +pub struct SupportBundleEreports { + /// Collect only ereports reported by systems with these serial numbers. + /// Unfiltered when empty. + #[serde(default)] + pub only_serials: Vec, + /// Collect only ereports with these class strings. Unfiltered when empty. + #[serde(default)] + pub only_classes: Vec, +} + +/// The data a support bundle collects. +/// +/// Each category's settings live within that category, so settings for a +/// category that is not being collected cannot be expressed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SupportBundleData { + /// Collect every category of data, from every sled, unfiltered. + All, + /// Collect exactly what is specified here. A category that is omitted is + /// not collected. + Explicit { + /// Collect reconfigurator state: recent blueprints and information + /// about the target blueprint. + #[serde(default)] + reconfigurator: bool, + /// Collect sled serial numbers, cubby numbers, and UUIDs. + #[serde(default)] + sled_cubby_info: bool, + /// Collect task dumps from service processors. + #[serde(default)] + sp_dumps: bool, + /// Collect diagnostic commands and zone logs from sleds. + host_info: Option, + /// Collect ereports. + ereports: Option, + }, +} + +impl Default for SupportBundleData { + fn default() -> Self { + Self::All {} + } +} + +/// What a support bundle collects. +/// +/// When creating a bundle, an omitted field takes its default: everything, +/// from every sled, unfiltered. When viewing a bundle, every field describes +/// what was actually recorded for it. +#[derive( + Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, +)] +pub struct SupportBundleDataSelection { + /// The data to collect. Everything if omitted. + #[serde(default)] + pub data: SupportBundleData, + /// The inclusive start of the window bounding time-bounded data: zone + /// logs and ereports. Ereports are filtered on their timestamp; a log + /// file is included when the interval it spans overlaps the window. + /// + /// When creating a bundle, an omitted start defaults to seven days + /// before the end of the window (or seven days ago, when the window has + /// no end). When viewing a bundle, an omitted start means the bundle was + /// created before time windows were recorded, and was collected without + /// a lower bound. + pub start_time: Option>, + /// The inclusive end of that window. Unbounded if omitted. + pub end_time: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SupportBundleCreate { + /// User comment for the support bundle + pub user_comment: Option, + + /// What the bundle should collect. Everything, over the last seven days, + /// if omitted. + pub data_selection: Option, +} + +impl From + for SupportBundleCreate +{ + fn from(old: v2025_11_20_00::support_bundle::SupportBundleCreate) -> Self { + Self { user_comment: old.user_comment, data_selection: None } + } +} + +/// A support bundle, along with what it collects. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SupportBundleView { + #[serde(flatten)] + pub bundle: SupportBundleInfo, + + /// What this bundle collects. + pub data_selection: SupportBundleDataSelection, +} + +impl From for SupportBundleInfo { + fn from(new: SupportBundleView) -> Self { + new.bundle + } +} diff --git a/openapi/nexus-lockstep.json b/openapi/nexus-lockstep.json index 2236c84721f..a85ddf75746 100644 --- a/openapi/nexus-lockstep.json +++ b/openapi/nexus-lockstep.json @@ -9782,6 +9782,15 @@ "SupportBundleCreate": { "type": "object", "properties": { + "data_selection": { + "nullable": true, + "description": "What the bundle should collect. Everything, over the last seven days, if omitted.", + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleDataSelection" + } + ] + }, "user_comment": { "nullable": true, "description": "User comment for the support bundle", @@ -9789,6 +9798,142 @@ } } }, + "SupportBundleData": { + "description": "The data a support bundle collects.\n\nEach category's settings live within that category, so settings for a category that is not being collected cannot be expressed.", + "oneOf": [ + { + "description": "Collect every category of data, from every sled, unfiltered.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "all" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Collect exactly what is specified here. A category that is omitted is not collected.", + "type": "object", + "properties": { + "ereports": { + "nullable": true, + "description": "Collect ereports.", + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleEreports" + } + ] + }, + "host_info": { + "nullable": true, + "description": "Collect diagnostic commands and zone logs from sleds.", + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleHostInfo" + } + ] + }, + "reconfigurator": { + "description": "Collect reconfigurator state: recent blueprints and information about the target blueprint.", + "default": false, + "type": "boolean" + }, + "sled_cubby_info": { + "description": "Collect sled serial numbers, cubby numbers, and UUIDs.", + "default": false, + "type": "boolean" + }, + "sp_dumps": { + "description": "Collect task dumps from service processors.", + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "explicit" + ] + } + }, + "required": [ + "type" + ] + } + ] + }, + "SupportBundleDataSelection": { + "description": "What a support bundle collects.\n\nWhen creating a bundle, an omitted field takes its default: everything, from every sled, unfiltered. When viewing a bundle, every field describes what was actually recorded for it.", + "type": "object", + "properties": { + "data": { + "description": "The data to collect. Everything if omitted.", + "default": { + "type": "all" + }, + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleData" + } + ] + }, + "end_time": { + "nullable": true, + "description": "The inclusive end of that window. Unbounded if omitted.", + "type": "string", + "format": "date-time" + }, + "start_time": { + "nullable": true, + "description": "The inclusive start of the window bounding time-bounded data: zone logs and ereports. Ereports are filtered on their timestamp; a log file is included when the interval it spans overlaps the window.\n\nWhen creating a bundle, an omitted start defaults to seven days before the end of the window (or seven days ago, when the window has no end). When viewing a bundle, an omitted start means the bundle was created before time windows were recorded, and was collected without a lower bound.", + "type": "string", + "format": "date-time" + } + } + }, + "SupportBundleEreports": { + "description": "Ereport collection.", + "type": "object", + "properties": { + "only_classes": { + "description": "Collect only ereports with these class strings. Unfiltered when empty.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "only_serials": { + "description": "Collect only ereports reported by systems with these serial numbers. Unfiltered when empty.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "SupportBundleHostInfo": { + "description": "Host info collection: diagnostic commands and zone logs from sleds.", + "type": "object", + "properties": { + "sleds": { + "description": "The sleds to collect from. Every sled if omitted.", + "default": { + "type": "all" + }, + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleSledSelection" + } + ] + } + } + }, "SupportBundleInfo": { "description": "Support bundle info with internal-only fields.\n\nThis wraps the external API's [`ExternalSupportBundleInfo`] and extends it with fields that are only exposed through the lockstep API (e.g., for OMDB).", "type": "object", @@ -9853,6 +9998,49 @@ "items" ] }, + "SupportBundleSledSelection": { + "description": "The sleds to collect host info from.", + "oneOf": [ + { + "description": "Collect from every sled.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "all" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Collect only from the listed sleds. An empty list collects from none of them.", + "type": "object", + "properties": { + "sleds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "type": { + "type": "string", + "enum": [ + "specific" + ] + } + }, + "required": [ + "sleds", + "type" + ] + } + ] + }, "SupportBundleState": { "oneOf": [ { diff --git a/openapi/nexus/nexus-2026081901.0.0-e5e4f5.json.gitstub b/openapi/nexus/nexus-2026081901.0.0-e5e4f5.json.gitstub new file mode 100644 index 00000000000..65ba4574110 --- /dev/null +++ b/openapi/nexus/nexus-2026081901.0.0-e5e4f5.json.gitstub @@ -0,0 +1 @@ +6b1ca22b4066024f8ac2c04bd678d6e09bf50a6f:openapi/nexus/nexus-2026081901.0.0-e5e4f5.json diff --git a/openapi/nexus/nexus-2026081901.0.0-e5e4f5.json b/openapi/nexus/nexus-2026082700.0.0-7e65d7.json similarity index 99% rename from openapi/nexus/nexus-2026081901.0.0-e5e4f5.json rename to openapi/nexus/nexus-2026082700.0.0-7e65d7.json index 0e2c3b606df..bcc91023135 100644 --- a/openapi/nexus/nexus-2026081901.0.0-e5e4f5.json +++ b/openapi/nexus/nexus-2026082700.0.0-7e65d7.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "2026081901.0.0" + "version": "2026082700.0.0" }, "paths": { "/device/auth": { @@ -12669,7 +12669,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SupportBundleInfo" + "$ref": "#/components/schemas/SupportBundleView" } } } @@ -29026,6 +29026,15 @@ "SupportBundleCreate": { "type": "object", "properties": { + "data_selection": { + "nullable": true, + "description": "What the bundle should collect. Everything, over the last seven days, if omitted.", + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleDataSelection" + } + ] + }, "user_comment": { "nullable": true, "description": "User comment for the support bundle", @@ -29033,6 +29042,142 @@ } } }, + "SupportBundleData": { + "description": "The data a support bundle collects.\n\nEach category's settings live within that category, so settings for a category that is not being collected cannot be expressed.", + "oneOf": [ + { + "description": "Collect every category of data, from every sled, unfiltered.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "all" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Collect exactly what is specified here. A category that is omitted is not collected.", + "type": "object", + "properties": { + "ereports": { + "nullable": true, + "description": "Collect ereports.", + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleEreports" + } + ] + }, + "host_info": { + "nullable": true, + "description": "Collect diagnostic commands and zone logs from sleds.", + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleHostInfo" + } + ] + }, + "reconfigurator": { + "description": "Collect reconfigurator state: recent blueprints and information about the target blueprint.", + "default": false, + "type": "boolean" + }, + "sled_cubby_info": { + "description": "Collect sled serial numbers, cubby numbers, and UUIDs.", + "default": false, + "type": "boolean" + }, + "sp_dumps": { + "description": "Collect task dumps from service processors.", + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "explicit" + ] + } + }, + "required": [ + "type" + ] + } + ] + }, + "SupportBundleDataSelection": { + "description": "What a support bundle collects.\n\nWhen creating a bundle, an omitted field takes its default: everything, from every sled, unfiltered. When viewing a bundle, every field describes what was actually recorded for it.", + "type": "object", + "properties": { + "data": { + "description": "The data to collect. Everything if omitted.", + "default": { + "type": "all" + }, + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleData" + } + ] + }, + "end_time": { + "nullable": true, + "description": "The inclusive end of that window. Unbounded if omitted.", + "type": "string", + "format": "date-time" + }, + "start_time": { + "nullable": true, + "description": "The inclusive start of the window bounding time-bounded data: zone logs and ereports. Ereports are filtered on their timestamp; a log file is included when the interval it spans overlaps the window.\n\nWhen creating a bundle, an omitted start defaults to seven days before the end of the window (or seven days ago, when the window has no end). When viewing a bundle, an omitted start means the bundle was created before time windows were recorded, and was collected without a lower bound.", + "type": "string", + "format": "date-time" + } + } + }, + "SupportBundleEreports": { + "description": "Ereport collection.", + "type": "object", + "properties": { + "only_classes": { + "description": "Collect only ereports with these class strings. Unfiltered when empty.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "only_serials": { + "description": "Collect only ereports reported by systems with these serial numbers. Unfiltered when empty.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "SupportBundleHostInfo": { + "description": "Host info collection: diagnostic commands and zone logs from sleds.", + "type": "object", + "properties": { + "sleds": { + "description": "The sleds to collect from. Every sled if omitted.", + "default": { + "type": "all" + }, + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleSledSelection" + } + ] + } + } + }, "SupportBundleInfo": { "type": "object", "properties": { @@ -29087,6 +29232,49 @@ "items" ] }, + "SupportBundleSledSelection": { + "description": "The sleds to collect host info from.", + "oneOf": [ + { + "description": "Collect from every sled.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "all" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Collect only from the listed sleds. An empty list collects from none of them.", + "type": "object", + "properties": { + "sleds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "type": { + "type": "string", + "enum": [ + "specific" + ] + } + }, + "required": [ + "sleds", + "type" + ] + } + ] + }, "SupportBundleState": { "oneOf": [ { @@ -29129,6 +29317,49 @@ } } }, + "SupportBundleView": { + "description": "A support bundle, along with what it collects.", + "type": "object", + "properties": { + "data_selection": { + "description": "What this bundle collects.", + "allOf": [ + { + "$ref": "#/components/schemas/SupportBundleDataSelection" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "reason_for_creation": { + "type": "string" + }, + "reason_for_failure": { + "nullable": true, + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/SupportBundleState" + }, + "time_created": { + "type": "string", + "format": "date-time" + }, + "user_comment": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "data_selection", + "id", + "reason_for_creation", + "state", + "time_created" + ] + }, "Switch": { "description": "An operator's view of a Switch.", "type": "object", diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index 1ed456d7c97..0889dc651eb 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026081901.0.0-e5e4f5.json \ No newline at end of file +nexus-2026082700.0.0-7e65d7.json \ No newline at end of file