From ad04192a7252751b8d1cf92843c5ef5e06e26d28 Mon Sep 17 00:00:00 2001 From: Dan Rosen Date: Wed, 18 Mar 2026 13:32:35 -0400 Subject: [PATCH 1/3] nexus-types: add SupportBundleRequest type, Display impls, and Case field Add the SupportBundleRequest type to fm::case and the support_bundles_requested field to Case. Add Display impls for BundleData, BundleDataSelection, SledSelection, and EreportFilters so that case formatting can show the full data selection for each requested support bundle. Add Serialize/Deserialize derives to the support bundle selection types (BundleDataSelection, BundleData, BundleDataCategory, SledSelection, EreportFilters) since SupportBundleRequest contains Option. Add proptest Arbitrary impls for BundleDataSelection and EreportFilters (in test_utils modules) and a proptest-based serde round-trip test for BundleDataSelection. Add test_case_display coverage for support bundle requests, exercising both data_selection: None and Some with parameterized categories. --- nexus/db-model/src/fm/case.rs | 1 + nexus/db-queries/src/db/datastore/fm.rs | 4 + nexus/fm/src/builder/case.rs | 1 + .../src/app/background/tasks/fm_rendezvous.rs | 2 + nexus/types/src/fm/case.rs | 104 +++++++++++++++++- nexus/types/src/fm/ereport.rs | 88 ++++++++++++++- nexus/types/src/support_bundle.rs | 75 ++++++++++++- 7 files changed, 269 insertions(+), 6 deletions(-) diff --git a/nexus/db-model/src/fm/case.rs b/nexus/db-model/src/fm/case.rs index efe5d4e5487..1922df26a30 100644 --- a/nexus/db-model/src/fm/case.rs +++ b/nexus/db-model/src/fm/case.rs @@ -58,6 +58,7 @@ impl CaseMetadata { de, comment, alerts_requested: _, + support_bundles_requested: _, ereports: _, } = case; Self { diff --git a/nexus/db-queries/src/db/datastore/fm.rs b/nexus/db-queries/src/db/datastore/fm.rs index 81072530b5c..f3334621deb 100644 --- a/nexus/db-queries/src/db/datastore/fm.rs +++ b/nexus/db-queries/src/db/datastore/fm.rs @@ -385,6 +385,7 @@ impl DataStore { comment, ereports, alerts_requested, + support_bundles_requested: iddqd::IdOrdMap::new(), } })); } @@ -1552,6 +1553,7 @@ mod tests { de, ereports, alerts_requested, + support_bundles_requested: _, } = case; let case_id = id; let Some(expected) = this.cases.get(&case_id) else { @@ -1770,6 +1772,7 @@ mod tests { de: fm::DiagnosisEngineKind::PowerShelf, ereports, alerts_requested, + support_bundles_requested: iddqd::IdOrdMap::new(), comment: "my cool case".to_string(), } }; @@ -1802,6 +1805,7 @@ mod tests { de: fm::DiagnosisEngineKind::PowerShelf, ereports, alerts_requested, + support_bundles_requested: iddqd::IdOrdMap::new(), comment: "break in case of emergency".to_string(), } }; diff --git a/nexus/fm/src/builder/case.rs b/nexus/fm/src/builder/case.rs index 29d3cc3aa4d..f5b6a2d1419 100644 --- a/nexus/fm/src/builder/case.rs +++ b/nexus/fm/src/builder/case.rs @@ -67,6 +67,7 @@ impl AllCases { comment: String::new(), ereports: Default::default(), alerts_requested: Default::default(), + support_bundles_requested: Default::default(), }; entry.insert(CaseBuilder::new( &self.log, sitrep_id, case, case_rng, diff --git a/nexus/src/app/background/tasks/fm_rendezvous.rs b/nexus/src/app/background/tasks/fm_rendezvous.rs index 4d15465b84c..9e893d83e42 100644 --- a/nexus/src/app/background/tasks/fm_rendezvous.rs +++ b/nexus/src/app/background/tasks/fm_rendezvous.rs @@ -230,6 +230,7 @@ mod tests { de: fm::DiagnosisEngineKind::PowerShelf, alerts_requested: iddqd::IdOrdMap::new(), ereports: iddqd::IdOrdMap::new(), + support_bundles_requested: iddqd::IdOrdMap::new(), comment: "my great case".to_string(), }; case1 @@ -305,6 +306,7 @@ mod tests { de: fm::DiagnosisEngineKind::PowerShelf, alerts_requested: iddqd::IdOrdMap::new(), ereports: iddqd::IdOrdMap::new(), + support_bundles_requested: iddqd::IdOrdMap::new(), comment: "my other great case".to_string(), }; case2 diff --git a/nexus/types/src/fm/case.rs b/nexus/types/src/fm/case.rs index afcefd3dd93..1b59ea76188 100644 --- a/nexus/types/src/fm/case.rs +++ b/nexus/types/src/fm/case.rs @@ -5,8 +5,11 @@ use crate::alert::AlertClass; use crate::fm::DiagnosisEngineKind; use crate::fm::Ereport; +use crate::support_bundle::BundleDataSelection; use iddqd::{IdOrdItem, IdOrdMap}; -use omicron_uuid_kinds::{AlertUuid, CaseEreportUuid, CaseUuid, SitrepUuid}; +use omicron_uuid_kinds::{ + AlertUuid, CaseEreportUuid, CaseUuid, SitrepUuid, SupportBundleUuid, +}; use serde::{Deserialize, Serialize}; use std::fmt; use std::sync::Arc; @@ -21,6 +24,7 @@ pub struct Case { pub ereports: IdOrdMap, pub alerts_requested: IdOrdMap, + pub support_bundles_requested: IdOrdMap, pub comment: String, } @@ -88,6 +92,22 @@ impl iddqd::IdOrdItem for AlertRequest { iddqd::id_upcast!(); } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct SupportBundleRequest { + pub id: SupportBundleUuid, + pub requested_sitrep_id: SitrepUuid, + pub data_selection: Option, +} + +impl iddqd::IdOrdItem for SupportBundleRequest { + type Key<'a> = &'a SupportBundleUuid; + fn key(&self) -> Self::Key<'_> { + &self.id + } + + iddqd::id_upcast!(); +} + struct DisplayCase<'a> { case: &'a Case, indent: usize, @@ -120,6 +140,7 @@ impl fmt::Display for DisplayCase<'_> { ereports, comment, alerts_requested, + support_bundles_requested, }, indent, sitrep_id, @@ -234,6 +255,47 @@ impl fmt::Display for DisplayCase<'_> { } } + if !support_bundles_requested.is_empty() { + writeln!(f, "\n{:>indent$}support bundles requested:", "")?; + writeln!(f, "{:>indent$}-------------------------", "")?; + + let indent = indent + 2; + for SupportBundleRequest { + id, + requested_sitrep_id, + data_selection, + } in support_bundles_requested.iter() + { + const REQUESTED_IN: &str = "requested in:"; + const DATA: &str = "data:"; + const WIDTH: usize = const_max_len(&[REQUESTED_IN, DATA]); + + writeln!(f, "{BULLET:>indent$}bundle {id}",)?; + writeln!( + f, + "{:>indent$}{REQUESTED_IN: { + writeln!( + f, + "{:>indent$}{DATA: { + writeln!( + f, + "{:>indent$}{DATA:, } +impl fmt::Display for EreportFilters { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use itertools::Itertools; + + // Writes a semicolon-separated part to the formatter, tracking whether + // we've written anything yet. + let mut empty = true; + let mut fmt_part = + |f: &mut fmt::Formatter, args: fmt::Arguments| -> fmt::Result { + if !empty { + write!(f, "; ")?; + } + empty = false; + f.write_fmt(args) + }; + + if let Some(start) = &self.start_time { + fmt_part(f, format_args!("start: {start}"))?; + } + if let Some(end) = &self.end_time { + fmt_part(f, format_args!("end: {end}"))?; + } + if !self.only_serials.is_empty() { + fmt_part( + f, + format_args!( + "serials: {}", + self.only_serials.iter().format(", ") + ), + )?; + } + if !self.only_classes.is_empty() { + fmt_part( + f, + format_args!( + "classes: {}", + self.only_classes.iter().format(", ") + ), + )?; + } + + // If no filters are set, display "none" rather than empty output. + if empty { + write!(f, "none")?; + } + Ok(()) + } +} + impl EreportFilters { pub fn check_time_range(&self) -> Result<(), Error> { if let (Some(start), Some(end)) = (self.start_time, self.end_time) { @@ -253,3 +302,40 @@ impl EreportFilters { Ok(()) } } + +#[cfg(test)] +pub(crate) mod test_utils { + use super::*; + use proptest::prelude::*; + + fn arb_datetime() -> impl Strategy> { + // Generate timestamps in a reasonable range (2020-2030). + (1577836800i64..1893456000i64) + .prop_map(|secs| DateTime::from_timestamp(secs, 0).unwrap()) + } + + impl Arbitrary for EreportFilters { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + ( + prop::option::of(arb_datetime()), + prop::option::of(arb_datetime()), + prop::collection::vec(".*", 0..=3), + prop::collection::vec(".*", 0..=3), + ) + .prop_map( + |(start_time, end_time, only_serials, only_classes)| { + EreportFilters { + start_time, + end_time, + only_serials, + only_classes, + } + }, + ) + .boxed() + } + } +} diff --git a/nexus/types/src/support_bundle.rs b/nexus/types/src/support_bundle.rs index ed53e426bb9..4d7075dc2fe 100644 --- a/nexus/types/src/support_bundle.rs +++ b/nexus/types/src/support_bundle.rs @@ -8,12 +8,16 @@ //! They are shared between the support bundle collector and FM case types. use crate::fm::ereport::EreportFilters; +use itertools::Itertools; use omicron_uuid_kinds::SledUuid; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::collections::HashSet; +use std::fmt; /// Describes the category of support bundle data. -#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum BundleDataCategory { /// Collects reconfigurator state (some of the latest blueprints, /// information about the target blueprint). @@ -35,7 +39,8 @@ pub enum BundleDataCategory { /// For categories without additional parameters, the variant is a unit variant. /// For categories that can be filtered or configured, the variant contains /// that configuration data. -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum BundleData { Reconfigurator, HostInfo(HashSet), @@ -56,12 +61,26 @@ impl BundleData { } } +impl fmt::Display for BundleData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Reconfigurator => write!(f, "reconfigurator"), + Self::HostInfo(sleds) => { + write!(f, "host_info({})", sleds.iter().format(", ")) + } + Self::SledCubbyInfo => write!(f, "sled_cubby_info"), + Self::SpDumps => write!(f, "sp_dumps"), + Self::Ereports(filters) => write!(f, "ereports({filters})"), + } + } +} + /// A collection of bundle data specifications. /// /// This wrapper ensures that categories and data always match - you can't /// insert (BundleDataCategory::Reconfigurator, BundleData::SpDumps) /// because each BundleData determines its own category. -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct BundleDataSelection { data: HashMap, } @@ -94,6 +113,12 @@ impl BundleDataSelection { } } +impl fmt::Display for BundleDataSelection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.data.values().format(", ")) + } +} + impl FromIterator for BundleDataSelection { fn from_iter>(iter: T) -> Self { let mut selection = Self::new(); @@ -128,8 +153,50 @@ impl Default for BundleDataSelection { /// /// Multiple values of this enum are joined together into a HashSet. /// Therefore "SledSelection::All" overrides specific sleds. -#[derive(Debug, Clone, Hash, Eq, PartialEq)] +#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum SledSelection { All, Specific(SledUuid), } + +impl fmt::Display for SledSelection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::All => write!(f, "all"), + Self::Specific(id) => write!(f, "{id}"), + } + } +} + +#[cfg(test)] +pub(crate) mod test_utils { + use super::*; + use proptest::prelude::*; + + impl Arbitrary for BundleDataSelection { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + prop::collection::vec(any::(), 0..=5) + .prop_map(|data| data.into_iter().collect()) + .boxed() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use test_strategy::proptest; + + #[proptest] + fn bundle_data_selection_serde_round_trip(selection: BundleDataSelection) { + let json = serde_json::to_string(&selection).unwrap(); + let deserialized: BundleDataSelection = + serde_json::from_str(&json).unwrap(); + prop_assert_eq!(selection, deserialized); + } +} From c25bd4730482dd201d6f90be3ed151187886ee4e Mon Sep 17 00:00:00 2001 From: Dan Rosen Date: Wed, 18 Mar 2026 13:32:35 -0400 Subject: [PATCH 2/3] schema: add fm_support_bundle_request table, per-variant data selection tables, and fm_case_id on support_bundle Add migration SQL for fm_support_bundle_request and five per-variant data selection tables (reconfigurator, sled_cubby_info, sp_dumps, host_info, ereports). Add fm_case_id column to support_bundle. Update dbinit.sql, Diesel schema, and schema version. --- nexus/db-model/src/schema_versions.rs | 3 +- nexus/db-schema/src/schema.rs | 75 +++++++++++++++++++ schema/crdb/dbinit.sql | 71 +++++++++++++++++- schema/crdb/fm-support-bundle-request/up1.sql | 13 ++++ schema/crdb/fm-support-bundle-request/up2.sql | 3 + schema/crdb/fm-support-bundle-request/up3.sql | 6 ++ schema/crdb/fm-support-bundle-request/up4.sql | 6 ++ schema/crdb/fm-support-bundle-request/up5.sql | 6 ++ schema/crdb/fm-support-bundle-request/up6.sql | 8 ++ schema/crdb/fm-support-bundle-request/up7.sql | 10 +++ schema/crdb/fm-support-bundle-request/up8.sql | 2 + 11 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 schema/crdb/fm-support-bundle-request/up1.sql create mode 100644 schema/crdb/fm-support-bundle-request/up2.sql create mode 100644 schema/crdb/fm-support-bundle-request/up3.sql create mode 100644 schema/crdb/fm-support-bundle-request/up4.sql create mode 100644 schema/crdb/fm-support-bundle-request/up5.sql create mode 100644 schema/crdb/fm-support-bundle-request/up6.sql create mode 100644 schema/crdb/fm-support-bundle-request/up7.sql create mode 100644 schema/crdb/fm-support-bundle-request/up8.sql diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index 5e0fdd10b09..d80d50e35bd 100644 --- a/nexus/db-model/src/schema_versions.rs +++ b/nexus/db-model/src/schema_versions.rs @@ -16,7 +16,7 @@ use std::{collections::BTreeMap, sync::LazyLock}; /// /// This must be updated when you change the database schema. Refer to /// schema/crdb/README.adoc in the root of this repository for details. -pub const SCHEMA_VERSION: Version = Version::new(240, 0, 0); +pub const SCHEMA_VERSION: Version = Version::new(241, 0, 0); /// List of all past database schema versions, in *reverse* order /// @@ -28,6 +28,7 @@ static KNOWN_VERSIONS: LazyLock> = LazyLock::new(|| { // | leaving the first copy as an example for the next person. // v // KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"), + KnownVersion::new(241, "fm-support-bundle-request"), KnownVersion::new(240, "multicast-drop-mvlan"), KnownVersion::new(239, "fm-alert-request"), KnownVersion::new(238, "fewer-nullable-columns"), diff --git a/nexus/db-schema/src/schema.rs b/nexus/db-schema/src/schema.rs index d108372694b..fb162694fdf 100644 --- a/nexus/db-schema/src/schema.rs +++ b/nexus/db-schema/src/schema.rs @@ -1590,6 +1590,7 @@ table! { assigned_nexus -> Nullable, user_comment -> Nullable, + fm_case_id -> Nullable, } } @@ -3183,6 +3184,80 @@ table! { } } +// FM support bundle requests, stored per-sitrep like alert requests. +table! { + fm_support_bundle_request (sitrep_id, id) { + id -> Uuid, + sitrep_id -> Uuid, + requested_sitrep_id -> Uuid, + case_id -> Uuid, + } +} + +// Per-variant data selection tables for fm_support_bundle_request. +// Each table corresponds to a BundleData variant. Row existence means +// "include this category in the bundle." No rows across any variant +// table means "collect everything." + +// BundleData::Reconfigurator (unit variant, no filter columns) +table! { + fm_sb_req_reconfigurator (sitrep_id, request_id) { + sitrep_id -> Uuid, + request_id -> Uuid, + } +} + +// BundleData::SledCubbyInfo (unit variant, no filter columns) +table! { + fm_sb_req_sled_cubby_info (sitrep_id, request_id) { + sitrep_id -> Uuid, + request_id -> Uuid, + } +} + +// BundleData::SpDumps (unit variant, no filter columns) +table! { + fm_sb_req_sp_dumps (sitrep_id, request_id) { + sitrep_id -> Uuid, + request_id -> Uuid, + } +} + +// BundleData::HostInfo(HashSet) +table! { + fm_sb_req_host_info (sitrep_id, request_id) { + sitrep_id -> Uuid, + request_id -> Uuid, + all_sleds -> Bool, + sled_ids -> Array, + } +} + +// BundleData::Ereports(EreportFilters) +table! { + fm_sb_req_ereports (sitrep_id, request_id) { + sitrep_id -> Uuid, + request_id -> Uuid, + start_time -> Nullable, + end_time -> Nullable, + only_serials -> Array, + only_classes -> Array, + } +} + +// The per-variant tables use composite keys (sitrep_id, request_id) +// matching fm_support_bundle_request's (sitrep_id, id), so joinable! +// cannot be used (it requires single-column FKs). Queries must use +// explicit .on() clauses instead. +allow_tables_to_appear_in_same_query!( + fm_support_bundle_request, + fm_sb_req_reconfigurator, + fm_sb_req_sled_cubby_info, + fm_sb_req_sp_dumps, + fm_sb_req_host_info, + fm_sb_req_ereports, +); + table! { trust_quorum_configuration (rack_id, epoch) { rack_id -> Uuid, diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index ae61055b071..566bbf82702 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -3156,7 +3156,10 @@ CREATE TABLE IF NOT EXISTS omicron.public.support_bundle ( -- and later managing its storage. assigned_nexus UUID, - user_comment TEXT + user_comment TEXT, + + -- If this bundle was requested by an FM case, the case UUID. + fm_case_id UUID ); @@ -7440,6 +7443,70 @@ CREATE INDEX IF NOT EXISTS lookup_fm_alert_requests_for_case ON omicron.public.fm_alert_request (sitrep_id, case_id); +CREATE TABLE IF NOT EXISTS omicron.public.fm_support_bundle_request ( + -- Requested support bundle UUID. + id UUID NOT NULL, + -- UUID of the current sitrep that this request record is part of. + -- + -- Note that this is *not* the sitrep in which the bundle was requested. + sitrep_id UUID NOT NULL, + -- UUID of the original sitrep in which the bundle was first requested. + requested_sitrep_id UUID NOT NULL, + -- UUID of the case to which this request belongs. + case_id UUID NOT NULL, + + PRIMARY KEY (sitrep_id, id) +); + +CREATE INDEX IF NOT EXISTS + lookup_fm_support_bundle_requests_for_case +ON omicron.public.fm_support_bundle_request (sitrep_id, case_id); + +-- Per-variant data selection tables for fm_support_bundle_request. +-- Row existence = "include this category in the bundle." +-- No rows in any variant table = "collect everything." + +CREATE TABLE IF NOT EXISTS omicron.public.fm_sb_req_reconfigurator ( + sitrep_id UUID NOT NULL, + request_id UUID NOT NULL, + + PRIMARY KEY (sitrep_id, request_id) +); + +CREATE TABLE IF NOT EXISTS omicron.public.fm_sb_req_sled_cubby_info ( + sitrep_id UUID NOT NULL, + request_id UUID NOT NULL, + + PRIMARY KEY (sitrep_id, request_id) +); + +CREATE TABLE IF NOT EXISTS omicron.public.fm_sb_req_sp_dumps ( + sitrep_id UUID NOT NULL, + request_id UUID NOT NULL, + + PRIMARY KEY (sitrep_id, request_id) +); + +CREATE TABLE IF NOT EXISTS omicron.public.fm_sb_req_host_info ( + sitrep_id UUID NOT NULL, + request_id UUID NOT NULL, + all_sleds BOOL NOT NULL, + sled_ids UUID[] NOT NULL DEFAULT ARRAY[], + + PRIMARY KEY (sitrep_id, request_id) +); + +CREATE TABLE IF NOT EXISTS omicron.public.fm_sb_req_ereports ( + sitrep_id UUID NOT NULL, + request_id UUID NOT NULL, + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + only_serials TEXT[] NOT NULL DEFAULT ARRAY[], + only_classes TEXT[] NOT NULL DEFAULT ARRAY[], + + PRIMARY KEY (sitrep_id, request_id) +); + /* * List of datasets available to be sliced up and passed to VMMs for encrypted * instance local storage. @@ -8270,7 +8337,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '240.0.0', NULL) + (TRUE, NOW(), NOW(), '241.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; diff --git a/schema/crdb/fm-support-bundle-request/up1.sql b/schema/crdb/fm-support-bundle-request/up1.sql new file mode 100644 index 00000000000..4ab9fdda7f9 --- /dev/null +++ b/schema/crdb/fm-support-bundle-request/up1.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS omicron.public.fm_support_bundle_request ( + -- Requested support bundle UUID. + id UUID NOT NULL, + -- UUID of the current sitrep that this request record is part of. + -- + -- Note that this is *not* the sitrep in which the bundle was requested. + sitrep_id UUID NOT NULL, + -- UUID of the original sitrep in which the bundle was first requested. + requested_sitrep_id UUID NOT NULL, + -- UUID of the case to which this request belongs. + case_id UUID NOT NULL, + PRIMARY KEY (sitrep_id, id) +); diff --git a/schema/crdb/fm-support-bundle-request/up2.sql b/schema/crdb/fm-support-bundle-request/up2.sql new file mode 100644 index 00000000000..2742178f61a --- /dev/null +++ b/schema/crdb/fm-support-bundle-request/up2.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS + lookup_fm_support_bundle_requests_for_case +ON omicron.public.fm_support_bundle_request (sitrep_id, case_id); diff --git a/schema/crdb/fm-support-bundle-request/up3.sql b/schema/crdb/fm-support-bundle-request/up3.sql new file mode 100644 index 00000000000..0699362b195 --- /dev/null +++ b/schema/crdb/fm-support-bundle-request/up3.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS omicron.public.fm_sb_req_reconfigurator ( + sitrep_id UUID NOT NULL, + request_id UUID NOT NULL, + + PRIMARY KEY (sitrep_id, request_id) +); diff --git a/schema/crdb/fm-support-bundle-request/up4.sql b/schema/crdb/fm-support-bundle-request/up4.sql new file mode 100644 index 00000000000..565f4943625 --- /dev/null +++ b/schema/crdb/fm-support-bundle-request/up4.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS omicron.public.fm_sb_req_sled_cubby_info ( + sitrep_id UUID NOT NULL, + request_id UUID NOT NULL, + + PRIMARY KEY (sitrep_id, request_id) +); diff --git a/schema/crdb/fm-support-bundle-request/up5.sql b/schema/crdb/fm-support-bundle-request/up5.sql new file mode 100644 index 00000000000..f30f40caaf6 --- /dev/null +++ b/schema/crdb/fm-support-bundle-request/up5.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS omicron.public.fm_sb_req_sp_dumps ( + sitrep_id UUID NOT NULL, + request_id UUID NOT NULL, + + PRIMARY KEY (sitrep_id, request_id) +); diff --git a/schema/crdb/fm-support-bundle-request/up6.sql b/schema/crdb/fm-support-bundle-request/up6.sql new file mode 100644 index 00000000000..6cce61c17c2 --- /dev/null +++ b/schema/crdb/fm-support-bundle-request/up6.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS omicron.public.fm_sb_req_host_info ( + sitrep_id UUID NOT NULL, + request_id UUID NOT NULL, + all_sleds BOOL NOT NULL, + sled_ids UUID[] NOT NULL DEFAULT ARRAY[], + + PRIMARY KEY (sitrep_id, request_id) +); diff --git a/schema/crdb/fm-support-bundle-request/up7.sql b/schema/crdb/fm-support-bundle-request/up7.sql new file mode 100644 index 00000000000..d64e8010792 --- /dev/null +++ b/schema/crdb/fm-support-bundle-request/up7.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS omicron.public.fm_sb_req_ereports ( + sitrep_id UUID NOT NULL, + request_id UUID NOT NULL, + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + only_serials TEXT[] NOT NULL DEFAULT ARRAY[], + only_classes TEXT[] NOT NULL DEFAULT ARRAY[], + + PRIMARY KEY (sitrep_id, request_id) +); diff --git a/schema/crdb/fm-support-bundle-request/up8.sql b/schema/crdb/fm-support-bundle-request/up8.sql new file mode 100644 index 00000000000..199098c0ccb --- /dev/null +++ b/schema/crdb/fm-support-bundle-request/up8.sql @@ -0,0 +1,2 @@ +ALTER TABLE omicron.public.support_bundle + ADD COLUMN IF NOT EXISTS fm_case_id UUID; From 20770714990106f54d1b9369975657315733b656 Mon Sep 17 00:00:00 2001 From: Dan Rosen Date: Wed, 18 Mar 2026 13:32:35 -0400 Subject: [PATCH 3/3] nexus-db-model: add SupportBundleRequest, per-variant models, and SupportBundle.fm_case_id Add DB models for fm_support_bundle_request and the five per-variant data selection tables. Add fm_case_id to SupportBundle with new_for_fm constructor. Update CaseMetadata destructure for new field. --- nexus/db-model/src/fm.rs | 12 ++++ nexus/db-model/src/fm/case.rs | 2 + nexus/db-model/src/fm/sb_req_ereports.rs | 49 +++++++++++++++ nexus/db-model/src/fm/sb_req_host_info.rs | 59 +++++++++++++++++++ .../db-model/src/fm/sb_req_reconfigurator.rs | 16 +++++ .../db-model/src/fm/sb_req_sled_cubby_info.rs | 16 +++++ nexus/db-model/src/fm/sb_req_sp_dumps.rs | 16 +++++ .../db-model/src/fm/support_bundle_request.rs | 51 ++++++++++++++++ nexus/db-model/src/support_bundle.rs | 27 +++++++++ 9 files changed, 248 insertions(+) create mode 100644 nexus/db-model/src/fm/sb_req_ereports.rs create mode 100644 nexus/db-model/src/fm/sb_req_host_info.rs create mode 100644 nexus/db-model/src/fm/sb_req_reconfigurator.rs create mode 100644 nexus/db-model/src/fm/sb_req_sled_cubby_info.rs create mode 100644 nexus/db-model/src/fm/sb_req_sp_dumps.rs create mode 100644 nexus/db-model/src/fm/support_bundle_request.rs diff --git a/nexus/db-model/src/fm.rs b/nexus/db-model/src/fm.rs index 353a2174aac..f19ba7e4514 100644 --- a/nexus/db-model/src/fm.rs +++ b/nexus/db-model/src/fm.rs @@ -25,6 +25,18 @@ mod case; pub use case::*; mod diagnosis_engine; pub use diagnosis_engine::*; +mod sb_req_ereports; +pub use sb_req_ereports::*; +mod sb_req_host_info; +pub use sb_req_host_info::*; +mod sb_req_reconfigurator; +pub use sb_req_reconfigurator::*; +mod sb_req_sled_cubby_info; +pub use sb_req_sled_cubby_info::*; +mod sb_req_sp_dumps; +pub use sb_req_sp_dumps::*; +mod support_bundle_request; +pub use support_bundle_request::*; #[derive(Queryable, Insertable, Clone, Debug, Selectable)] #[diesel(table_name = fm_sitrep)] diff --git a/nexus/db-model/src/fm/case.rs b/nexus/db-model/src/fm/case.rs index 1922df26a30..c99da343b89 100644 --- a/nexus/db-model/src/fm/case.rs +++ b/nexus/db-model/src/fm/case.rs @@ -6,6 +6,7 @@ use super::AlertRequest; use super::DiagnosisEngine; +use super::SupportBundleRequest; use crate::DbTypedUuid; use crate::ereport; use nexus_db_schema::schema::{fm_case, fm_ereport_in_case}; @@ -139,4 +140,5 @@ pub struct Case { pub metadata: CaseMetadata, pub ereports: Vec, pub alerts_requested: Vec, + pub support_bundles_requested: Vec, } diff --git a/nexus/db-model/src/fm/sb_req_ereports.rs b/nexus/db-model/src/fm/sb_req_ereports.rs new file mode 100644 index 00000000000..74277219e5d --- /dev/null +++ b/nexus/db-model/src/fm/sb_req_ereports.rs @@ -0,0 +1,49 @@ +// 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/. + +//! DB model for the `fm_sb_req_ereports` per-variant table. + +use crate::DbTypedUuid; +use chrono::{DateTime, Utc}; +use nexus_db_schema::schema::fm_sb_req_ereports; +use nexus_types::fm::ereport::EreportFilters; +use omicron_uuid_kinds::{SitrepKind, SupportBundleKind}; + +#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[diesel(table_name = fm_sb_req_ereports)] +pub struct SbReqEreports { + pub sitrep_id: DbTypedUuid, + pub request_id: DbTypedUuid, + pub start_time: Option>, + pub end_time: Option>, + pub only_serials: Vec, + pub only_classes: Vec, +} + +impl SbReqEreports { + pub fn new( + sitrep_id: impl Into>, + request_id: impl Into>, + filters: &EreportFilters, + ) -> Self { + SbReqEreports { + sitrep_id: sitrep_id.into(), + request_id: request_id.into(), + start_time: filters.start_time, + end_time: filters.end_time, + only_serials: filters.only_serials.clone(), + only_classes: filters.only_classes.clone(), + } + } + + /// Reconstruct the `EreportFilters` from the DB row. + pub fn into_ereport_filters(self) -> EreportFilters { + EreportFilters { + start_time: self.start_time, + end_time: self.end_time, + only_serials: self.only_serials, + only_classes: self.only_classes, + } + } +} diff --git a/nexus/db-model/src/fm/sb_req_host_info.rs b/nexus/db-model/src/fm/sb_req_host_info.rs new file mode 100644 index 00000000000..5452530cbbb --- /dev/null +++ b/nexus/db-model/src/fm/sb_req_host_info.rs @@ -0,0 +1,59 @@ +// 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/. + +//! DB model for the `fm_sb_req_host_info` per-variant table. + +use crate::DbTypedUuid; +use nexus_db_schema::schema::fm_sb_req_host_info; +use nexus_types::support_bundle::SledSelection; +use omicron_uuid_kinds::{ + GenericUuid, SitrepKind, SledUuid, SupportBundleKind, +}; +use std::collections::HashSet; + +#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[diesel(table_name = fm_sb_req_host_info)] +pub struct SbReqHostInfo { + pub sitrep_id: DbTypedUuid, + pub request_id: DbTypedUuid, + pub all_sleds: bool, + pub sled_ids: Vec, +} + +impl SbReqHostInfo { + pub fn new( + sitrep_id: impl Into>, + request_id: impl Into>, + sleds: &HashSet, + ) -> Self { + let all_sleds = sleds.contains(&SledSelection::All); + let sled_ids: Vec = sleds + .iter() + .filter_map(|s| match s { + SledSelection::Specific(id) => Some(id.into_untyped_uuid()), + SledSelection::All => None, + }) + .collect(); + SbReqHostInfo { + sitrep_id: sitrep_id.into(), + request_id: request_id.into(), + all_sleds, + sled_ids, + } + } + + /// Reconstruct the `HashSet` from the DB row. + pub fn into_sled_selections(self) -> HashSet { + let mut set = HashSet::new(); + if self.all_sleds { + set.insert(SledSelection::All); + } + for id in self.sled_ids { + set.insert(SledSelection::Specific(SledUuid::from_untyped_uuid( + id, + ))); + } + set + } +} diff --git a/nexus/db-model/src/fm/sb_req_reconfigurator.rs b/nexus/db-model/src/fm/sb_req_reconfigurator.rs new file mode 100644 index 00000000000..dc223a388dc --- /dev/null +++ b/nexus/db-model/src/fm/sb_req_reconfigurator.rs @@ -0,0 +1,16 @@ +// 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/. + +//! DB model for the `fm_sb_req_reconfigurator` per-variant table. + +use crate::DbTypedUuid; +use nexus_db_schema::schema::fm_sb_req_reconfigurator; +use omicron_uuid_kinds::{SitrepKind, SupportBundleKind}; + +#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[diesel(table_name = fm_sb_req_reconfigurator)] +pub struct SbReqReconfigurator { + pub sitrep_id: DbTypedUuid, + pub request_id: DbTypedUuid, +} diff --git a/nexus/db-model/src/fm/sb_req_sled_cubby_info.rs b/nexus/db-model/src/fm/sb_req_sled_cubby_info.rs new file mode 100644 index 00000000000..00910ce2ee0 --- /dev/null +++ b/nexus/db-model/src/fm/sb_req_sled_cubby_info.rs @@ -0,0 +1,16 @@ +// 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/. + +//! DB model for the `fm_sb_req_sled_cubby_info` per-variant table. + +use crate::DbTypedUuid; +use nexus_db_schema::schema::fm_sb_req_sled_cubby_info; +use omicron_uuid_kinds::{SitrepKind, SupportBundleKind}; + +#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[diesel(table_name = fm_sb_req_sled_cubby_info)] +pub struct SbReqSledCubbyInfo { + pub sitrep_id: DbTypedUuid, + pub request_id: DbTypedUuid, +} diff --git a/nexus/db-model/src/fm/sb_req_sp_dumps.rs b/nexus/db-model/src/fm/sb_req_sp_dumps.rs new file mode 100644 index 00000000000..a937293d999 --- /dev/null +++ b/nexus/db-model/src/fm/sb_req_sp_dumps.rs @@ -0,0 +1,16 @@ +// 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/. + +//! DB model for the `fm_sb_req_sp_dumps` per-variant table. + +use crate::DbTypedUuid; +use nexus_db_schema::schema::fm_sb_req_sp_dumps; +use omicron_uuid_kinds::{SitrepKind, SupportBundleKind}; + +#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[diesel(table_name = fm_sb_req_sp_dumps)] +pub struct SbReqSpDumps { + pub sitrep_id: DbTypedUuid, + pub request_id: DbTypedUuid, +} diff --git a/nexus/db-model/src/fm/support_bundle_request.rs b/nexus/db-model/src/fm/support_bundle_request.rs new file mode 100644 index 00000000000..c3aaa6a7e59 --- /dev/null +++ b/nexus/db-model/src/fm/support_bundle_request.rs @@ -0,0 +1,51 @@ +// 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/. + +//! Fault management support bundle requests. + +use crate::DbTypedUuid; +use nexus_db_schema::schema::fm_support_bundle_request; +use nexus_types::fm; +use omicron_uuid_kinds::{CaseKind, SitrepKind, SupportBundleKind}; + +#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[diesel(table_name = fm_support_bundle_request)] +pub struct SupportBundleRequest { + pub id: DbTypedUuid, + pub sitrep_id: DbTypedUuid, + pub requested_sitrep_id: DbTypedUuid, + pub case_id: DbTypedUuid, +} + +impl SupportBundleRequest { + pub fn from_sitrep( + sitrep_id: impl Into>, + case_id: impl Into>, + req: fm::case::SupportBundleRequest, + ) -> Self { + let fm::case::SupportBundleRequest { + id, + requested_sitrep_id, + data_selection: _, + } = req; + SupportBundleRequest { + id: id.into(), + sitrep_id: sitrep_id.into(), + requested_sitrep_id: requested_sitrep_id.into(), + case_id: case_id.into(), + } + } +} + +impl From for fm::case::SupportBundleRequest { + fn from(req: SupportBundleRequest) -> Self { + fm::case::SupportBundleRequest { + id: req.id.into(), + requested_sitrep_id: req.requested_sitrep_id.into(), + // data_selection is reconstructed from per-variant tables + // by the sitrep read path, not stored on this row. + data_selection: None, + } + } +} diff --git a/nexus/db-model/src/support_bundle.rs b/nexus/db-model/src/support_bundle.rs index 81e0d7e2fc7..564d10f94e4 100644 --- a/nexus/db-model/src/support_bundle.rs +++ b/nexus/db-model/src/support_bundle.rs @@ -8,6 +8,8 @@ use nexus_db_schema::schema::support_bundle; use chrono::{DateTime, Utc}; use nexus_types::external_api::support_bundle as support_bundle_types; +use omicron_uuid_kinds::CaseKind; +use omicron_uuid_kinds::CaseUuid; use omicron_uuid_kinds::DatasetKind; use omicron_uuid_kinds::DatasetUuid; use omicron_uuid_kinds::OmicronZoneKind; @@ -95,6 +97,7 @@ pub struct SupportBundle { pub dataset_id: DbTypedUuid, pub assigned_nexus: Option>, pub user_comment: Option, + pub fm_case_id: Option>, } impl SupportBundle { @@ -115,6 +118,30 @@ impl SupportBundle { dataset_id: dataset_id.into(), assigned_nexus: Some(nexus_id.into()), user_comment, + fm_case_id: None, + } + } + + /// Create a new support bundle requested by the FM subsystem. + pub fn new_for_fm( + id: SupportBundleUuid, + reason_for_creation: String, + zpool_id: ZpoolUuid, + dataset_id: DatasetUuid, + nexus_id: OmicronZoneUuid, + fm_case_id: CaseUuid, + ) -> Self { + Self { + id: id.into(), + time_created: Utc::now(), + reason_for_creation, + reason_for_failure: None, + state: SupportBundleState::Collecting, + zpool_id: zpool_id.into(), + dataset_id: dataset_id.into(), + assigned_nexus: Some(nexus_id.into()), + user_comment: None, + fm_case_id: Some(fm_case_id.into()), } }