diff --git a/Cargo.lock b/Cargo.lock index 2f9536b3028..62db2f2c413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7062,6 +7062,7 @@ dependencies = [ "propolis-client 0.1.0 (git+https://github.com/oxidecomputer/propolis?rev=f30ff7a830da26874a00307a3c6d6e1035eec818)", "qorb", "rand", + "range-requests", "rcgen", "ref-cast", "regex", diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 4c8f032fcb4..9d35b5f9358 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -979,6 +979,7 @@ pub enum ResourceType { Instance, LoopbackAddress, SwitchPortSettings, + SupportBundle, IpPool, IpPoolResource, InstanceNetworkInterface, diff --git a/dev-tools/omdb/tests/successes.out b/dev-tools/omdb/tests/successes.out index 742ed1e840e..6c3b69f2657 100644 --- a/dev-tools/omdb/tests/successes.out +++ b/dev-tools/omdb/tests/successes.out @@ -698,11 +698,11 @@ task: "service_zone_nat_tracker" last completion reported error: inventory collection is None task: "support_bundle_collector" - configured period: every s + configured period: every days h m s currently executing: no last completed activation: , triggered by a periodic timer firing started at (s ago) and ran for ms - last completion reported error: task disabled +warning: unknown background task: "support_bundle_collector" (don't know how to interpret details: Object {"cleanup_err": Null, "cleanup_report": Object {"db_destroying_bundles_removed": Number(0), "db_failing_bundles_updated": Number(0), "sled_bundles_delete_failed": Number(0), "sled_bundles_deleted_not_found": Number(0), "sled_bundles_deleted_ok": Number(0)}, "collection_err": Null, "collection_report": Null}) task: "switch_port_config_manager" configured period: every s @@ -1150,11 +1150,11 @@ task: "service_zone_nat_tracker" last completion reported error: inventory collection is None task: "support_bundle_collector" - configured period: every s + configured period: every days h m s currently executing: no last completed activation: , triggered by a periodic timer firing started at (s ago) and ran for ms - last completion reported error: task disabled +warning: unknown background task: "support_bundle_collector" (don't know how to interpret details: Object {"cleanup_err": Null, "cleanup_report": Object {"db_destroying_bundles_removed": Number(0), "db_failing_bundles_updated": Number(0), "sled_bundles_delete_failed": Number(0), "sled_bundles_deleted_not_found": Number(0), "sled_bundles_deleted_ok": Number(0)}, "collection_err": Null, "collection_report": Null}) task: "switch_port_config_manager" configured period: every s diff --git a/docs/adding-an-endpoint.adoc b/docs/adding-an-endpoint.adoc index d9e5c559b40..e2f19c1fe70 100644 --- a/docs/adding-an-endpoint.adoc +++ b/docs/adding-an-endpoint.adoc @@ -53,7 +53,7 @@ this document should act as a jumping-off point. === **Testing** * Authorization -** There exists a https://github.com/oxidecomputer/omicron/blob/main/nexus/src/authz/policy_test[policy test] which compares all Oso objects against an expected policy. New resources are usually added to https://github.com/oxidecomputer/omicron/blob/main/nexus/src/authz/policy_test/resources.rs[resources.rs] to get coverage. +** There exists a https://github.com/oxidecomputer/omicron/blob/main/nexus/db-queries/src/policy_test[policy test] which compares all Oso objects against an expected policy. New resources are usually added to https://github.com/oxidecomputer/omicron/blob/main/nexus/db-queries/src/policy_test/resources.rs[resources.rs] to get coverage. * OpenAPI ** Once you've added or changed endpoint definitions in `nexus-external-api` or `nexus-internal-api`, you'll need to update the corresponding OpenAPI documents (the JSON files in `openapi/`). ** To update all OpenAPI documents, run `cargo xtask openapi generate`. diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 89a4f7ce704..cc9c056cc1c 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -72,6 +72,7 @@ progenitor-client.workspace = true propolis-client.workspace = true qorb.workspace = true rand.workspace = true +range-requests.workspace = true ref-cast.workspace = true reqwest = { workspace = true, features = ["json"] } ring.workspace = true diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index ee12c453627..745a699cf2b 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -994,6 +994,14 @@ authz_resource! { polar_snippet = FleetChild, } +authz_resource! { + name = "SupportBundle", + parent = "Fleet", + primary_key = { uuid_kind = SupportBundleKind }, + roles_allowed = false, + polar_snippet = FleetChild, +} + authz_resource! { name = "PhysicalDisk", parent = "Fleet", diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index acd74b2167f..321bb98b1c6 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -154,6 +154,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { Silo::init(), SiloUser::init(), SiloGroup::init(), + SupportBundle::init(), IdentityProvider::init(), SamlIdentityProvider::init(), Sled::init(), diff --git a/nexus/db-model/src/support_bundle.rs b/nexus/db-model/src/support_bundle.rs index a4b14d363b2..cb125362e11 100644 --- a/nexus/db-model/src/support_bundle.rs +++ b/nexus/db-model/src/support_bundle.rs @@ -118,6 +118,10 @@ impl SupportBundle { assigned_nexus: Some(nexus_id.into()), } } + + pub fn id(&self) -> SupportBundleUuid { + self.id.into() + } } impl From for SupportBundleView { diff --git a/nexus/db-queries/src/db/datastore/support_bundle.rs b/nexus/db-queries/src/db/datastore/support_bundle.rs index 1ba9a527727..6a8ca1c5fbc 100644 --- a/nexus/db-queries/src/db/datastore/support_bundle.rs +++ b/nexus/db-queries/src/db/datastore/support_bundle.rs @@ -10,6 +10,7 @@ use crate::context::OpContext; use crate::db; use crate::db::error::public_error_from_diesel; use crate::db::error::ErrorHandler; +use crate::db::lookup::LookupPath; use crate::db::model::Dataset; use crate::db::model::DatasetKind; use crate::db::model::SupportBundle; @@ -163,16 +164,10 @@ impl DataStore { opctx: &OpContext, id: SupportBundleUuid, ) -> LookupResult { - opctx.authorize(authz::Action::Read, &authz::FLEET).await?; - use db::schema::support_bundle::dsl; + let (.., db_bundle) = + LookupPath::new(opctx, self).support_bundle(id).fetch().await?; - let conn = self.pool_connection_authorized(opctx).await?; - dsl::support_bundle - .filter(dsl::id.eq(id.into_untyped_uuid())) - .select(SupportBundle::as_select()) - .first_async::(&*conn) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + Ok(db_bundle) } /// Lists one page of support bundles @@ -419,18 +414,20 @@ impl DataStore { pub async fn support_bundle_update( &self, opctx: &OpContext, - id: SupportBundleUuid, + authz_bundle: &authz::SupportBundle, state: SupportBundleState, ) -> Result<(), Error> { - opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; + opctx.authorize(authz::Action::Modify, authz_bundle).await?; use db::schema::support_bundle::dsl; + + let id = authz_bundle.id().into_untyped_uuid(); let conn = self.pool_connection_authorized(opctx).await?; let result = diesel::update(dsl::support_bundle) - .filter(dsl::id.eq(id.into_untyped_uuid())) + .filter(dsl::id.eq(id)) .filter(dsl::state.eq_any(state.valid_old_states())) .set(dsl::state.eq(state)) - .check_if_exists::(id.into_untyped_uuid()) + .check_if_exists::(id) .execute_and_check(&conn) .await .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; @@ -453,12 +450,13 @@ impl DataStore { pub async fn support_bundle_delete( &self, opctx: &OpContext, - id: SupportBundleUuid, + authz_bundle: &authz::SupportBundle, ) -> Result<(), Error> { - opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; + opctx.authorize(authz::Action::Delete, authz_bundle).await?; use db::schema::support_bundle::dsl; + let id = authz_bundle.id().into_untyped_uuid(); let conn = self.pool_connection_authorized(opctx).await?; diesel::delete(dsl::support_bundle) .filter( @@ -466,7 +464,7 @@ impl DataStore { .eq(SupportBundleState::Destroying) .or(dsl::state.eq(SupportBundleState::Failed)), ) - .filter(dsl::id.eq(id.into_untyped_uuid())) + .filter(dsl::id.eq(id)) .execute_async(&*conn) .await .map(|_rows_modified| ()) @@ -494,6 +492,7 @@ mod test { use nexus_types::deployment::BlueprintZoneDisposition; use nexus_types::deployment::BlueprintZoneFilter; use nexus_types::deployment::BlueprintZoneType; + use omicron_common::api::external::LookupType; use omicron_common::api::internal::shared::DatasetKind::Debug as DebugDatasetKind; use omicron_test_utils::dev; use omicron_uuid_kinds::BlueprintUuid; @@ -502,6 +501,16 @@ mod test { use omicron_uuid_kinds::SledUuid; use rand::Rng; + fn authz_support_bundle_from_id( + id: SupportBundleUuid, + ) -> authz::SupportBundle { + authz::SupportBundle::new( + authz::FLEET, + id, + LookupType::ById(id.into_untyped_uuid()), + ) + } + // Pool/Dataset pairs, for debug datasets only. struct TestPool { pool: ZpoolUuid, @@ -715,10 +724,11 @@ mod test { // When we update the state of the bundles, the list results // should also be filtered. + let authz_bundle = authz_support_bundle_from_id(bundle_a1.id.into()); datastore .support_bundle_update( &opctx, - bundle_a1.id.into(), + &authz_bundle, SupportBundleState::Active, ) .await @@ -816,11 +826,11 @@ mod test { // database. // // We should still expect to hit capacity limits. - + let authz_bundle = authz_support_bundle_from_id(bundles[0].id.into()); datastore .support_bundle_update( &opctx, - bundles[0].id.into(), + &authz_bundle, SupportBundleState::Destroying, ) .await @@ -835,8 +845,9 @@ mod test { // If we delete a bundle, it should be gone. This means we can // re-allocate from that dataset which was just freed up. + let authz_bundle = authz_support_bundle_from_id(bundles[0].id.into()); datastore - .support_bundle_delete(&opctx, bundles[0].id.into()) + .support_bundle_delete(&opctx, &authz_bundle) .await .expect("Should be able to destroy this bundle"); datastore @@ -888,11 +899,11 @@ mod test { assert_eq!(bundle, observed_bundles[0]); // Destroy the bundle, observe the new state - + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); datastore .support_bundle_update( &opctx, - bundle.id.into(), + &authz_bundle, SupportBundleState::Destroying, ) .await @@ -905,8 +916,9 @@ mod test { // Delete the bundle, observe that it's gone + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); datastore - .support_bundle_delete(&opctx, bundle.id.into()) + .support_bundle_delete(&opctx, &authz_bundle) .await .expect("Should be able to destroy our bundle"); let observed_bundles = datastore @@ -1146,10 +1158,11 @@ mod test { ); // Start the deletion of this bundle + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); datastore .support_bundle_update( &opctx, - bundle.id.into(), + &authz_bundle, SupportBundleState::Destroying, ) .await @@ -1314,8 +1327,9 @@ mod test { .unwrap() .contains(FAILURE_REASON_NO_DATASET)); + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); datastore - .support_bundle_delete(&opctx, bundle.id.into()) + .support_bundle_delete(&opctx, &authz_bundle) .await .expect("Should have been able to delete support bundle"); @@ -1377,10 +1391,11 @@ mod test { // // This is what we would do when we finish collecting, and // provisioned storage on a sled. + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); datastore .support_bundle_update( &opctx, - bundle.id.into(), + &authz_bundle, SupportBundleState::Active, ) .await diff --git a/nexus/db-queries/src/db/lookup.rs b/nexus/db-queries/src/db/lookup.rs index 0507da6839d..c629dbfc425 100644 --- a/nexus/db-queries/src/db/lookup.rs +++ b/nexus/db-queries/src/db/lookup.rs @@ -22,6 +22,7 @@ use omicron_common::api::external::Error; use omicron_common::api::external::InternalContext; use omicron_common::api::external::{LookupResult, LookupType, ResourceType}; use omicron_uuid_kinds::PhysicalDiskUuid; +use omicron_uuid_kinds::SupportBundleUuid; use omicron_uuid_kinds::TufArtifactKind; use omicron_uuid_kinds::TufRepoKind; use omicron_uuid_kinds::TypedUuid; @@ -391,6 +392,11 @@ impl<'a> LookupPath<'a> { PhysicalDisk::PrimaryKey(Root { lookup_root: self }, id) } + /// Select a resource of type SupportBundle, identified by its id + pub fn support_bundle(self, id: SupportBundleUuid) -> SupportBundle<'a> { + SupportBundle::PrimaryKey(Root { lookup_root: self }, id) + } + pub fn silo_image_id(self, id: Uuid) -> SiloImage<'a> { SiloImage::PrimaryKey(Root { lookup_root: self }, id) } @@ -872,6 +878,15 @@ lookup_resource! { primary_key_columns = [ { column_name = "id", uuid_kind = PhysicalDiskKind } ] } +lookup_resource! { + name = "SupportBundle", + ancestors = [], + children = [], + lookup_by_name = false, + soft_deletes = false, + primary_key_columns = [ { column_name = "id", uuid_kind = SupportBundleKind } ] +} + lookup_resource! { name = "TufRepo", ancestors = [], diff --git a/nexus/db-queries/src/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index 304b9703778..b6d7d97553e 100644 --- a/nexus/db-queries/src/policy_test/resource_builder.rs +++ b/nexus/db-queries/src/policy_test/resource_builder.rs @@ -271,6 +271,7 @@ impl_dyn_authorized_resource_for_resource!(authz::SiloUser); impl_dyn_authorized_resource_for_resource!(authz::Sled); impl_dyn_authorized_resource_for_resource!(authz::Snapshot); impl_dyn_authorized_resource_for_resource!(authz::SshKey); +impl_dyn_authorized_resource_for_resource!(authz::SupportBundle); impl_dyn_authorized_resource_for_resource!(authz::TufArtifact); impl_dyn_authorized_resource_for_resource!(authz::TufRepo); impl_dyn_authorized_resource_for_resource!(authz::Vpc); diff --git a/nexus/db-queries/src/policy_test/resources.rs b/nexus/db-queries/src/policy_test/resources.rs index dcd8704086b..6ee92e167cf 100644 --- a/nexus/db-queries/src/policy_test/resources.rs +++ b/nexus/db-queries/src/policy_test/resources.rs @@ -10,6 +10,7 @@ use nexus_auth::authz; use omicron_common::api::external::LookupType; use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::PhysicalDiskUuid; +use omicron_uuid_kinds::SupportBundleUuid; use oso::PolarClass; use std::collections::BTreeSet; use uuid::Uuid; @@ -109,6 +110,14 @@ pub async fn make_resources( LookupType::ById(physical_disk_id.into_untyped_uuid()), )); + let support_bundle_id: SupportBundleUuid = + "d9f923f6-caf3-4c83-96f9-8ffe8c627dd2".parse().unwrap(); + builder.new_resource(authz::SupportBundle::new( + authz::FLEET, + support_bundle_id, + LookupType::ById(support_bundle_id.into_untyped_uuid()), + )); + let device_user_code = String::from("a-device-user-code"); builder.new_resource(authz::DeviceAuthRequest::new( authz::FLEET, diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index eebece9d524..4b24e649ccb 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -1034,6 +1034,20 @@ resource: PhysicalDisk id "c9f923f6-caf3-4c83-96f9-8ffe8c627dd2" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: SupportBundle id "d9f923f6-caf3-4c83-96f9-8ffe8c627dd2" + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + fleet-collaborator ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: DeviceAuthRequest "a-device-user-code" USER Q R LC RP M MP CC D diff --git a/nexus/src/app/background/mod.rs b/nexus/src/app/background/mod.rs index 5b24907b0f4..3241e7c7cae 100644 --- a/nexus/src/app/background/mod.rs +++ b/nexus/src/app/background/mod.rs @@ -140,6 +140,13 @@ pub use init::BackgroundTasksData; pub use init::BackgroundTasksInitializer; pub use tasks::saga_recovery::SagaRecoveryHelpers; +// Expose background task outputs to they can be deserialized and +// observed. +pub mod task_output { + pub use super::tasks::support_bundle_collector::CleanupReport; + pub use super::tasks::support_bundle_collector::CollectionReport; +} + use futures::future::BoxFuture; use nexus_auth::context::OpContext; diff --git a/nexus/src/app/background/tasks/support_bundle_collector.rs b/nexus/src/app/background/tasks/support_bundle_collector.rs index 89becc5320c..65b680cdcb2 100644 --- a/nexus/src/app/background/tasks/support_bundle_collector.rs +++ b/nexus/src/app/background/tasks/support_bundle_collector.rs @@ -14,12 +14,14 @@ use futures::future::BoxFuture; use futures::FutureExt; use nexus_db_model::SupportBundle; use nexus_db_model::SupportBundleState; +use nexus_db_queries::authz; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::DataStore; use nexus_types::deployment::SledFilter; use nexus_types::identity::Asset; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::Error; +use omicron_common::api::external::LookupType; use omicron_common::api::external::ResourceType; use omicron_common::update::ArtifactHash; use omicron_uuid_kinds::DatasetUuid; @@ -28,6 +30,7 @@ use omicron_uuid_kinds::OmicronZoneUuid; use omicron_uuid_kinds::SledUuid; use omicron_uuid_kinds::SupportBundleUuid; use omicron_uuid_kinds::ZpoolUuid; +use serde::Deserialize; use serde::Serialize; use serde_json::json; use sha2::{Digest, Sha256}; @@ -43,6 +46,14 @@ use zip::ZipWriter; // rather than "/tmp", which would keep this collected data in-memory. const TEMPDIR: &str = "/var/tmp"; +fn authz_support_bundle_from_id(id: SupportBundleUuid) -> authz::SupportBundle { + authz::SupportBundle::new( + authz::FLEET, + id, + LookupType::ById(id.into_untyped_uuid()), + ) +} + // Specifies the data to be collected within the Support Bundle. #[derive(Default)] struct BundleRequest { @@ -51,16 +62,16 @@ struct BundleRequest { } // Describes what happened while attempting to clean up Support Bundles. -#[derive(Debug, Default, Serialize, Eq, PartialEq)] -struct CleanupReport { +#[derive(Debug, Default, Deserialize, Serialize, Eq, PartialEq)] +pub struct CleanupReport { // Responses from Sled Agents - sled_bundles_deleted_ok: usize, - sled_bundles_deleted_not_found: usize, - sled_bundles_delete_failed: usize, + pub sled_bundles_deleted_ok: usize, + pub sled_bundles_deleted_not_found: usize, + pub sled_bundles_delete_failed: usize, // Results from updating our database records - db_destroying_bundles_removed: usize, - db_failing_bundles_updated: usize, + pub db_destroying_bundles_removed: usize, + pub db_failing_bundles_updated: usize, } // Result of asking a sled agent to clean up a bundle @@ -154,13 +165,14 @@ impl SupportBundleCollector { opctx: &OpContext, bundle: &SupportBundle, ) -> anyhow::Result { + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); match bundle.state { SupportBundleState::Destroying => { // Destroying is a terminal state; no one should be able to // change this state from underneath us. self.datastore.support_bundle_delete( opctx, - bundle.id.into(), + &authz_bundle, ).await.map_err(|err| { warn!( &opctx.log, @@ -179,7 +191,7 @@ impl SupportBundleCollector { .datastore .support_bundle_update( &opctx, - bundle.id.into(), + &authz_bundle, SupportBundleState::Failed, ) .await @@ -377,12 +389,13 @@ impl SupportBundleCollector { bundle: &bundle, }; + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); let mut report = collection.collect_bundle_and_store_on_sled().await?; if let Err(err) = self .datastore .support_bundle_update( &opctx, - bundle.id.into(), + &authz_bundle, SupportBundleState::Active, ) .await @@ -730,15 +743,15 @@ async fn sha2_hash(file: &mut tokio::fs::File) -> anyhow::Result { // Identifies what we could or could not store within this support bundle. // // This struct will get emitted as part of the background task infrastructure. -#[derive(Debug, Serialize, PartialEq, Eq)] -struct CollectionReport { - bundle: SupportBundleUuid, +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct CollectionReport { + pub bundle: SupportBundleUuid, // True iff we could list in-service sleds - listed_in_service_sleds: bool, + pub listed_in_service_sleds: bool, // True iff the bundle was successfully made 'active' in the database. - activated_in_db_ok: bool, + pub activated_in_db_ok: bool, } impl CollectionReport { @@ -1144,10 +1157,11 @@ mod test { assert_eq!(bundle.state, SupportBundleState::Collecting); // Cancel the bundle immediately + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); datastore .support_bundle_update( &opctx, - bundle.id.into(), + &authz_bundle, SupportBundleState::Destroying, ) .await @@ -1209,10 +1223,11 @@ mod test { assert!(report.activated_in_db_ok); // Cancel the bundle after collection has completed + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); datastore .support_bundle_update( &opctx, - bundle.id.into(), + &authz_bundle, SupportBundleState::Destroying, ) .await @@ -1264,10 +1279,11 @@ mod test { // Mark the bundle as "failing" - this should be triggered // automatically by the blueprint executor if the corresponding // storage has been expunged. + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); datastore .support_bundle_update( &opctx, - bundle.id.into(), + &authz_bundle, SupportBundleState::Failing, ) .await @@ -1332,10 +1348,11 @@ mod test { // Mark the bundle as "failing" - this should be triggered // automatically by the blueprint executor if the corresponding // storage has been expunged. + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); datastore .support_bundle_update( &opctx, - bundle.id.into(), + &authz_bundle, SupportBundleState::Failing, ) .await @@ -1405,10 +1422,11 @@ mod test { // Mark the bundle as "failing" - this should be triggered // automatically by the blueprint executor if the corresponding // storage has been expunged. + let authz_bundle = authz_support_bundle_from_id(bundle.id.into()); datastore .support_bundle_update( &opctx, - bundle.id.into(), + &authz_bundle, SupportBundleState::Failing, ) .await diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 435ca2a56d1..74e1ed4fdc0 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -77,6 +77,7 @@ mod sled; mod sled_instance; mod snapshot; mod ssh_key; +pub(crate) mod support_bundles; mod switch; mod switch_interface; mod switch_port; diff --git a/nexus/src/app/sled.rs b/nexus/src/app/sled.rs index 61a032f8bd8..a8383b97762 100644 --- a/nexus/src/app/sled.rs +++ b/nexus/src/app/sled.rs @@ -12,6 +12,7 @@ use nexus_db_queries::authz; use nexus_db_queries::context::OpContext; use nexus_db_queries::db; use nexus_db_queries::db::lookup; +use nexus_db_queries::db::lookup::LookupPath; use nexus_sled_agent_shared::inventory::SledRole; use nexus_types::deployment::DiskFilter; use nexus_types::deployment::SledFilter; @@ -236,20 +237,45 @@ impl super::Nexus { .await } - /// Upserts a physical disk into the database, updating it if it already exists. - pub(crate) async fn upsert_physical_disk( + /// Inserts a physical disk into the database unless it already exists. + /// + /// NOTE: I'd like to re-work this to avoid the upsert-like behavior - can + /// we restructure our tests to ensure they ask for this physical disk + /// exactly once? + pub(crate) async fn insert_test_physical_disk_if_not_exists( &self, opctx: &OpContext, request: PhysicalDiskPutRequest, ) -> Result<(), Error> { info!( - self.log, "upserting physical disk"; + self.log, "inserting test physical disk"; "physical_disk_id" => %request.id, "sled_id" => %request.sled_id, "vendor" => %request.vendor, "serial" => %request.serial, "model" => %request.model, ); + + match LookupPath::new(&opctx, &self.db_datastore) + .physical_disk(request.id) + .fetch() + .await + { + Ok((_authz_disk, existing_disk)) => { + if existing_disk.vendor != request.vendor + || existing_disk.serial != request.serial + || existing_disk.model != request.model + { + return Err(Error::internal_error( + "Invalid Physical Disk update (was: {existing_disk:?}, asking for {request:?})" + )); + } + return Ok(()); + } + Err(Error::ObjectNotFound { .. }) => {} + Err(err) => return Err(err), + } + let disk = db::model::PhysicalDisk::new( request.id, request.vendor, @@ -309,23 +335,22 @@ impl super::Nexus { // Datasets (contained within zpools) - /// Upserts a crucible dataset into the database, updating it if it already exists. - pub(crate) async fn upsert_crucible_dataset( + /// Upserts a dataset into the database, updating it if it already exists. + pub(crate) async fn upsert_dataset( &self, id: DatasetUuid, zpool_id: Uuid, - address: SocketAddrV6, + kind: DatasetKind, + address: Option, ) -> Result<(), Error> { info!( self.log, "upserting dataset"; "zpool_id" => zpool_id.to_string(), "dataset_id" => id.to_string(), - "address" => address.to_string() + "kind" => kind.to_string(), ); - let kind = DatasetKind::Crucible; - let dataset = - db::model::Dataset::new(id, zpool_id, Some(address), kind); + let dataset = db::model::Dataset::new(id, zpool_id, address, kind); self.db_datastore.dataset_upsert(dataset).await?; Ok(()) } diff --git a/nexus/src/app/support_bundles.rs b/nexus/src/app/support_bundles.rs new file mode 100644 index 00000000000..2d7f3882ef1 --- /dev/null +++ b/nexus/src/app/support_bundles.rs @@ -0,0 +1,199 @@ +// 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/. + +//! Access to Support Bundles + +use dropshot::Body; +use futures::TryStreamExt; +use http::Response; +use nexus_db_model::SupportBundle; +use nexus_db_model::SupportBundleState; +use nexus_db_queries::authz; +use nexus_db_queries::context::OpContext; +use nexus_db_queries::db::lookup::LookupPath; +use omicron_common::api::external::CreateResult; +use omicron_common::api::external::DataPageParams; +use omicron_common::api::external::DeleteResult; +use omicron_common::api::external::Error; +use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::LookupResult; +use omicron_uuid_kinds::DatasetUuid; +use omicron_uuid_kinds::SupportBundleUuid; +use omicron_uuid_kinds::ZpoolUuid; +use range_requests::PotentialRange; +use uuid::Uuid; + +/// Describes the type of access to the support bundle +#[derive(Clone, Debug)] +pub enum SupportBundleQueryType { + /// Access the whole support bundle + Whole, + /// Access the names of all files within the support bundle + Index, + /// Access a specific file within the support bundle + Path { file_path: String }, +} + +impl super::Nexus { + pub async fn support_bundle_list( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + self.db_datastore.support_bundle_list(&opctx, pagparams).await + } + + pub async fn support_bundle_view( + &self, + opctx: &OpContext, + id: SupportBundleUuid, + ) -> LookupResult { + let (.., db_bundle) = LookupPath::new(opctx, &self.db_datastore) + .support_bundle(id) + .fetch() + .await?; + + Ok(db_bundle) + } + + pub async fn support_bundle_create( + &self, + opctx: &OpContext, + reason: &'static str, + ) -> CreateResult { + self.db_datastore.support_bundle_create(&opctx, reason, self.id).await + } + + pub async fn support_bundle_download( + &self, + opctx: &OpContext, + id: SupportBundleUuid, + query: SupportBundleQueryType, + head: bool, + _range: Option, + ) -> Result, Error> { + // Lookup the bundle, confirm it's accessible + let (.., bundle) = LookupPath::new(opctx, &self.db_datastore) + .support_bundle(id) + .fetch() + .await?; + + if !matches!(bundle.state, SupportBundleState::Active) { + return Err(Error::invalid_request( + "Cannot download bundle in non-active state", + )); + } + + // Lookup the sled holding the bundle, forward the request there + let sled_id = self + .db_datastore + .zpool_get_sled_if_in_service(&opctx, bundle.zpool_id.into()) + .await?; + let client = self.sled_client(&sled_id).await?; + + // TODO: Use "range"? + + let response = match (query, head) { + (SupportBundleQueryType::Whole, true) => { + client + .support_bundle_head( + &ZpoolUuid::from(bundle.zpool_id), + &DatasetUuid::from(bundle.dataset_id), + &SupportBundleUuid::from(bundle.id), + ) + .await + } + (SupportBundleQueryType::Whole, false) => { + client + .support_bundle_download( + &ZpoolUuid::from(bundle.zpool_id), + &DatasetUuid::from(bundle.dataset_id), + &SupportBundleUuid::from(bundle.id), + ) + .await + } + (SupportBundleQueryType::Index, true) => { + client + .support_bundle_head_index( + &ZpoolUuid::from(bundle.zpool_id), + &DatasetUuid::from(bundle.dataset_id), + &SupportBundleUuid::from(bundle.id), + ) + .await + } + (SupportBundleQueryType::Index, false) => { + client + .support_bundle_index( + &ZpoolUuid::from(bundle.zpool_id), + &DatasetUuid::from(bundle.dataset_id), + &SupportBundleUuid::from(bundle.id), + ) + .await + } + (SupportBundleQueryType::Path { file_path }, true) => { + client + .support_bundle_head_file( + &ZpoolUuid::from(bundle.zpool_id), + &DatasetUuid::from(bundle.dataset_id), + &SupportBundleUuid::from(bundle.id), + &file_path, + ) + .await + } + (SupportBundleQueryType::Path { file_path }, false) => { + client + .support_bundle_download_file( + &ZpoolUuid::from(bundle.zpool_id), + &DatasetUuid::from(bundle.dataset_id), + &SupportBundleUuid::from(bundle.id), + &file_path, + ) + .await + } + }; + + let response = + response.map_err(|err| Error::internal_error(&err.to_string()))?; + + // The result from the sled agent a "ResponseValue", but we + // need to coerce that type into a "Response" while preserving the + // status, headers, and body. + let mut builder = Response::builder().status(response.status()); + let headers = builder.headers_mut().unwrap(); + headers.extend( + response.headers().iter().map(|(k, v)| (k.clone(), v.clone())), + ); + let body = http_body_util::StreamBody::new( + response + .into_inner_stream() + .map_ok(|b| hyper::body::Frame::data(b)), + ); + Ok(builder.body(Body::wrap(body)).unwrap()) + } + + pub async fn support_bundle_delete( + &self, + opctx: &OpContext, + id: SupportBundleUuid, + ) -> DeleteResult { + let (authz_bundle, ..) = LookupPath::new(opctx, &self.db_datastore) + .support_bundle(id) + .lookup_for(authz::Action::Delete) + .await?; + + // NOTE: We can't necessarily delete the support bundle + // immediately - it might have state that needs cleanup + // by a background task - so, instead, we mark it deleting. + // + // This is a terminal state + self.db_datastore + .support_bundle_update( + &opctx, + &authz_bundle, + SupportBundleState::Destroying, + ) + .await?; + Ok(()) + } +} diff --git a/nexus/src/app/test_interfaces.rs b/nexus/src/app/test_interfaces.rs index 9852225e8cc..38d9b17a8de 100644 --- a/nexus/src/app/test_interfaces.rs +++ b/nexus/src/app/test_interfaces.rs @@ -18,6 +18,8 @@ pub use super::update::SpUpdater; pub use super::update::UpdateProgress; pub use gateway_client::types::SpType; +pub use crate::app::background::task_output; + /// The information needed to talk to a sled agent about an instance that is /// active on that sled. pub struct InstanceSledAgentInfo { diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index cfc9f99851a..7424d688afd 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -13,7 +13,10 @@ use super::{ Utilization, Vpc, VpcRouter, VpcSubnet, }, }; -use crate::{context::ApiContext, external_api::shared}; +use crate::{ + app::support_bundles::SupportBundleQueryType, context::ApiContext, + external_api::shared, +}; use dropshot::Body; use dropshot::EmptyScanParams; use dropshot::HttpError; @@ -90,11 +93,13 @@ use omicron_common::api::external::VpcFirewallRuleUpdateParams; use omicron_common::api::external::VpcFirewallRules; use omicron_common::bail_unless; use omicron_uuid_kinds::GenericUuid; +use omicron_uuid_kinds::SupportBundleUuid; use propolis_client::support::tungstenite::protocol::frame::coding::CloseCode; use propolis_client::support::tungstenite::protocol::{ CloseFrame, Role as WebSocketRole, }; use propolis_client::support::WebSocketStream; +use range_requests::RequestContextEx; use ref_cast::RefCast; type NexusApiDescription = ApiDescription; @@ -6028,20 +6033,33 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn support_bundle_list( rqctx: RequestContext, - _query_params: Query, + query_params: Query, ) -> Result>, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; + let query = query_params.into_inner(); + let pagparams = data_page_params_for(&rqctx, &query)?; + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - Err(nexus - .unimplemented_todo(&opctx, crate::app::Unimpl::Public) - .await - .into()) + let bundles = nexus + .support_bundle_list(&opctx, &pagparams) + .await? + .into_iter() + .map(|p| p.into()) + .collect(); + + Ok(HttpResponseOk(ScanById::results_page( + &query, + bundles, + &|_, bundle: &shared::SupportBundleInfo| { + bundle.id.into_untyped_uuid() + }, + )?)) }; apictx .context @@ -6052,19 +6070,24 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn support_bundle_view( rqctx: RequestContext, - _path_params: Path, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - Err(nexus - .unimplemented_todo(&opctx, crate::app::Unimpl::Public) - .await - .into()) + let bundle = nexus + .support_bundle_view( + &opctx, + SupportBundleUuid::from_untyped_uuid(path.support_bundle), + ) + .await?; + + Ok(HttpResponseOk(bundle.into())) }; apictx .context @@ -6075,19 +6098,28 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn support_bundle_index( rqctx: RequestContext, - _path_params: Path, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; - + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - Err(nexus - .unimplemented_todo(&opctx, crate::app::Unimpl::Public) - .await - .into()) + let head = false; + let range = rqctx.range(); + + let body = nexus + .support_bundle_download( + &opctx, + SupportBundleUuid::from_untyped_uuid(path.support_bundle), + SupportBundleQueryType::Index, + head, + range, + ) + .await?; + Ok(body) }; apictx .context @@ -6098,19 +6130,28 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn support_bundle_download( rqctx: RequestContext, - _path_params: Path, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; - + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - Err(nexus - .unimplemented_todo(&opctx, crate::app::Unimpl::Public) - .await - .into()) + let head = false; + let range = rqctx.range(); + + let body = nexus + .support_bundle_download( + &opctx, + SupportBundleUuid::from_untyped_uuid(path.support_bundle), + SupportBundleQueryType::Whole, + head, + range, + ) + .await?; + Ok(body) }; apictx .context @@ -6121,19 +6162,29 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn support_bundle_download_file( rqctx: RequestContext, - _path_params: Path, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; - + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let head = false; + let range = rqctx.range(); - Err(nexus - .unimplemented_todo(&opctx, crate::app::Unimpl::Public) - .await - .into()) + let body = nexus + .support_bundle_download( + &opctx, + SupportBundleUuid::from_untyped_uuid( + path.bundle.support_bundle, + ), + SupportBundleQueryType::Path { file_path: path.file }, + head, + range, + ) + .await?; + Ok(body) }; apictx .context @@ -6144,19 +6195,27 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn support_bundle_head( rqctx: RequestContext, - _path_params: Path, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; - + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let head = true; + let range = rqctx.range(); - Err(nexus - .unimplemented_todo(&opctx, crate::app::Unimpl::Public) - .await - .into()) + let body = nexus + .support_bundle_download( + &opctx, + SupportBundleUuid::from_untyped_uuid(path.support_bundle), + SupportBundleQueryType::Whole, + head, + range, + ) + .await?; + Ok(body) }; apictx .context @@ -6167,19 +6226,29 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn support_bundle_head_file( rqctx: RequestContext, - _path_params: Path, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; - + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let head = true; + let range = rqctx.range(); - Err(nexus - .unimplemented_todo(&opctx, crate::app::Unimpl::Public) - .await - .into()) + let body = nexus + .support_bundle_download( + &opctx, + SupportBundleUuid::from_untyped_uuid( + path.bundle.support_bundle, + ), + SupportBundleQueryType::Path { file_path: path.file }, + head, + range, + ) + .await?; + Ok(body) }; apictx .context @@ -6198,10 +6267,10 @@ impl NexusExternalApi for NexusExternalApiImpl { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - Err(nexus - .unimplemented_todo(&opctx, crate::app::Unimpl::Public) - .await - .into()) + let bundle = nexus + .support_bundle_create(&opctx, "Created by external API") + .await?; + Ok(HttpResponseCreated(bundle.into())) }; apictx .context @@ -6212,19 +6281,24 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn support_bundle_delete( rqctx: RequestContext, - _path_params: Path, + path_params: Path, ) -> Result { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - Err(nexus - .unimplemented_todo(&opctx, crate::app::Unimpl::Public) - .await - .into()) + nexus + .support_bundle_delete( + &opctx, + SupportBundleUuid::from_untyped_uuid(path.support_bundle), + ) + .await?; + + Ok(HttpResponseDeleted()) }; apictx .context diff --git a/nexus/src/lib.rs b/nexus/src/lib.rs index 1a25b2e9aae..715b0c3377a 100644 --- a/nexus/src/lib.rs +++ b/nexus/src/lib.rs @@ -41,6 +41,7 @@ use omicron_common::api::internal::nexus::{ProducerEndpoint, ProducerKind}; use omicron_common::api::internal::shared::{ AllowedSourceIps, ExternalPortDiscovery, RackNetworkConfig, SwitchLocation, }; +use omicron_common::disk::DatasetKind; use omicron_common::FileKv; use omicron_uuid_kinds::DatasetUuid; use oximeter::types::ProducerRegistry; @@ -365,18 +366,19 @@ impl nexus_test_interface::NexusServer for Server { self.apictx.context.nexus.get_internal_server_address().await.unwrap() } - async fn upsert_crucible_dataset( + async fn upsert_test_dataset( &self, physical_disk: PhysicalDiskPutRequest, zpool: ZpoolPutRequest, dataset_id: DatasetUuid, - address: SocketAddrV6, + kind: DatasetKind, + address: Option, ) { let opctx = self.apictx.context.nexus.opctx_for_internal_api(); self.apictx .context .nexus - .upsert_physical_disk(&opctx, physical_disk) + .insert_test_physical_disk_if_not_exists(&opctx, physical_disk) .await .unwrap(); @@ -387,7 +389,7 @@ impl nexus_test_interface::NexusServer for Server { self.apictx .context .nexus - .upsert_crucible_dataset(dataset_id, zpool_id, address) + .upsert_dataset(dataset_id, zpool_id, kind, address) .await .unwrap(); } diff --git a/nexus/test-interface/src/lib.rs b/nexus/test-interface/src/lib.rs index 68f462551cc..07e36068992 100644 --- a/nexus/test-interface/src/lib.rs +++ b/nexus/test-interface/src/lib.rs @@ -39,6 +39,7 @@ use nexus_types::internal_api::params::{ }; use nexus_types::inventory::Collection; use omicron_common::api::external::Error; +use omicron_common::disk::DatasetKind; use omicron_uuid_kinds::DatasetUuid; use slog::Logger; use std::net::{SocketAddr, SocketAddrV6}; @@ -108,12 +109,13 @@ pub trait NexusServer: Send + Sync + 'static { // use the "RackInitializationRequest" handoff, but this would require // creating all our Zpools and Datasets before performing handoff to Nexus. // However, doing so would let us remove this test-only API. - async fn upsert_crucible_dataset( + async fn upsert_test_dataset( &self, physical_disk: PhysicalDiskPutRequest, zpool: ZpoolPutRequest, dataset_id: DatasetUuid, - address: SocketAddrV6, + kind: DatasetKind, + address: Option, ); async fn inventory_collect_and_get_latest_collection( diff --git a/nexus/test-utils/src/http_testing.rs b/nexus/test-utils/src/http_testing.rs index afa0a8af034..aff17b541f9 100644 --- a/nexus/test-utils/src/http_testing.rs +++ b/nexus/test-utils/src/http_testing.rs @@ -246,6 +246,20 @@ impl<'a> RequestBuilder<'a> { self } + /// Tells the requst to expect headers related to range requests + pub fn expect_range_requestable(mut self) -> Self { + self.allowed_headers.as_mut().unwrap().extend([ + http::header::CONTENT_LENGTH, + http::header::CONTENT_TYPE, + http::header::ACCEPT_RANGES, + ]); + self.expect_response_header( + http::header::CONTENT_TYPE, + "application/zip", + ) + .expect_response_header(http::header::ACCEPT_RANGES, "bytes") + } + /// Tells the request to initiate and expect a WebSocket upgrade handshake. /// This also sets the request method to GET. pub fn expect_websocket_handshake(mut self) -> Self { @@ -306,7 +320,7 @@ impl<'a> RequestBuilder<'a> { } let mut builder = - http::Request::builder().method(self.method).uri(self.uri); + http::Request::builder().method(self.method.clone()).uri(self.uri); for (header_name, header_value) in &self.headers { builder = builder.header(header_name, header_value); } @@ -452,6 +466,7 @@ impl<'a> RequestBuilder<'a> { }; if (status.is_client_error() || status.is_server_error()) && !self.allow_non_dropshot_errors + && self.method != http::Method::HEAD { let error_body = test_response .parsed_body::() diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 89f453c873c..4b535164bc8 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -36,6 +36,7 @@ use nexus_types::internal_api::params as internal_params; use omicron_common::api::external::ByteCount; use omicron_common::api::external::Disk; use omicron_common::api::external::Error; +use omicron_common::api::external::Generation; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::Instance; use omicron_common::api::external::InstanceAutoRestartPolicy; @@ -46,7 +47,13 @@ use omicron_common::api::external::RouteDestination; use omicron_common::api::external::RouteTarget; use omicron_common::api::external::RouterRoute; use omicron_common::api::external::UserId; +use omicron_common::disk::DatasetConfig; +use omicron_common::disk::DatasetKind; +use omicron_common::disk::DatasetName; +use omicron_common::disk::DatasetsConfig; use omicron_common::disk::DiskIdentity; +use omicron_common::disk::SharedDatasetConfig; +use omicron_common::zpool_name::ZpoolName; use omicron_sled_agent::sim::SledAgent; use omicron_test_utils::dev::poll::wait_for_condition; use omicron_test_utils::dev::poll::CondCheckError; @@ -1012,8 +1019,10 @@ pub async fn projects_list( .collect() } +#[derive(Debug)] pub struct TestDataset { pub id: DatasetUuid, + pub kind: DatasetKind, } pub struct TestZpool { @@ -1177,7 +1186,10 @@ impl<'a, N: NexusServer> DiskTest<'a, N> { *sled_id, disk.id, disk.pool_id, - DatasetUuid::new_v4(), + vec![TestDataset { + id: DatasetUuid::new_v4(), + kind: DatasetKind::Crucible, + }], Self::DEFAULT_ZPOOL_SIZE_GIB, ) .await; @@ -1190,12 +1202,76 @@ impl<'a, N: NexusServer> DiskTest<'a, N> { sled_id, PhysicalDiskUuid::new_v4(), ZpoolUuid::new_v4(), - DatasetUuid::new_v4(), + vec![TestDataset { + id: DatasetUuid::new_v4(), + kind: DatasetKind::Crucible, + }], Self::DEFAULT_ZPOOL_SIZE_GIB, ) .await } + // Propagate the dataset configuration to all Sled Agents. + // + // # Panics + // + // This function will panic if any of the Sled Agents have already + // applied dataset configuration. + // + // TODO: Ideally, we should do the following: + // 1. Also call a similar method to invoke the "omicron_physical_disks_ensure" API. Right now, + // we aren't calling this at all for the simulated sled agent, which only works because + // the simulated sled agent simply treats this as a stored config, rather than processing it + // to actually provide a different view of storage. + // 2. Re-work the DiskTestBuilder API to automatically deploy the "disks + datasets" config + // to sled agents exactly once. Adding new zpools / datasets after the DiskTest has been + // started will need to also make a decision about re-deploying this configuration. + pub async fn propagate_datasets_to_sleds(&mut self) { + let cptestctx = self.cptestctx; + + for (sled_id, PerSledDiskState { zpools }) in &self.sleds { + // Grab the "SledAgent" object -- we'll be contacting it shortly. + let sleds = cptestctx.all_sled_agents(); + let sled_agent = sleds + .into_iter() + .find_map(|server| { + if server.sled_agent.id == *sled_id { + Some(server.sled_agent.clone()) + } else { + None + } + }) + .expect("Cannot find sled"); + + // Configure the Sled to use all datasets we created + let datasets = zpools + .iter() + .flat_map(|zpool| zpool.datasets.iter().map(|d| (zpool.id, d))) + .map(|(zpool_id, TestDataset { id, kind })| { + ( + *id, + DatasetConfig { + id: *id, + name: DatasetName::new( + ZpoolName::new_external(zpool_id), + kind.clone(), + ), + inner: SharedDatasetConfig::default(), + }, + ) + }) + .collect(); + + let generation = Generation::new(); + let dataset_config = DatasetsConfig { generation, datasets }; + let res = sled_agent.datasets_ensure(dataset_config).expect( + "Should have been able to ensure datasets, but could not. + Did someone else already attempt to ensure datasets?", + ); + assert!(!res.has_error()); + } + } + fn get_sled(&self, sled_id: SledUuid) -> Arc { let sleds = self.cptestctx.all_sled_agents(); sleds @@ -1223,7 +1299,7 @@ impl<'a, N: NexusServer> DiskTest<'a, N> { sled_id: SledUuid, physical_disk_id: PhysicalDiskUuid, zpool_id: ZpoolUuid, - dataset_id: DatasetUuid, + datasets: Vec, gibibytes: u32, ) { let cptestctx = self.cptestctx; @@ -1233,7 +1309,7 @@ impl<'a, N: NexusServer> DiskTest<'a, N> { let zpool = TestZpool { id: zpool_id, size: ByteCount::from_gibibytes_u32(gibibytes), - datasets: vec![TestDataset { id: dataset_id }], + datasets, }; let disk_identity = DiskIdentity { @@ -1293,28 +1369,34 @@ impl<'a, N: NexusServer> DiskTest<'a, N> { ); for dataset in &zpool.datasets { - // Sled Agent side: Create the Dataset, make sure regions can be - // created immediately if Nexus requests anything. - let address = - sled_agent.create_crucible_dataset(zpool.id, dataset.id); - let crucible = - sled_agent.get_crucible_dataset(zpool.id, dataset.id); - crucible.set_create_callback(Box::new(|_| RegionState::Created)); - - // Nexus side: Notify Nexus of the physical disk/zpool/dataset - // combination that exists. - - let address = match address { - std::net::SocketAddr::V6(addr) => addr, - _ => panic!("Unsupported address type: {address} "), + let address = if matches!(dataset.kind, DatasetKind::Crucible) { + // Sled Agent side: Create the Dataset, make sure regions can be + // created immediately if Nexus requests anything. + let address = + sled_agent.create_crucible_dataset(zpool.id, dataset.id); + let crucible = + sled_agent.get_crucible_dataset(zpool.id, dataset.id); + crucible + .set_create_callback(Box::new(|_| RegionState::Created)); + + // Nexus side: Notify Nexus of the physical disk/zpool/dataset + // combination that exists. + + match address { + std::net::SocketAddr::V6(addr) => Some(addr), + _ => panic!("Unsupported address type: {address} "), + } + } else { + None }; cptestctx .server - .upsert_crucible_dataset( + .upsert_test_dataset( physical_disk_request.clone(), zpool_request.clone(), dataset.id, + dataset.kind.clone(), address, ) .await; diff --git a/nexus/tests/config.test.toml b/nexus/tests/config.test.toml index 1ef2f908e47..c7e25081cf6 100644 --- a/nexus/tests/config.test.toml +++ b/nexus/tests/config.test.toml @@ -104,9 +104,9 @@ phantom_disks.period_secs = 30 physical_disk_adoption.period_secs = 30 # Disable automatic disk adoption to avoid interfering with tests. physical_disk_adoption.disable = true -support_bundle_collector.period_secs = 30 -# Disable automatic support bundle collection to avoid interfering with tests. -support_bundle_collector.disable = true +# Set this to a value that's so high as to not fire during tests unless +# explicitly requested. +support_bundle_collector.period_secs = 999999 decommissioned_disk_cleaner.period_secs = 60 # Disable disk decommissioning cleanup to avoid interfering with tests. decommissioned_disk_cleaner.disable = true diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 466cae17a81..656b4ba8266 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -76,6 +76,11 @@ pub static DEMO_UNINITIALIZED_SLED: Lazy = part: "demo-part".to_string(), }); +pub const SUPPORT_BUNDLES_URL: &'static str = + "/experimental/v1/system/support-bundles"; +pub static SUPPORT_BUNDLE_URL: Lazy = + Lazy::new(|| format!("{SUPPORT_BUNDLES_URL}/{{id}}")); + // Global policy pub const SYSTEM_POLICY_URL: &'static str = "/v1/system/policy"; @@ -2168,6 +2173,28 @@ pub static VERIFY_ENDPOINTS: Lazy> = Lazy::new(|| { allowed_methods: vec![AllowedMethod::Get], }, + /* Support Bundles */ + + VerifyEndpoint { + url: &SUPPORT_BUNDLES_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Post(serde_json::to_value(()).unwrap()) + ], + }, + + VerifyEndpoint { + url: &SUPPORT_BUNDLE_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Delete, + ], + }, + /* Updates */ VerifyEndpoint { diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index dfcea796076..dc404736cd1 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -43,6 +43,7 @@ mod snapshots; mod sp_updater; mod ssh_keys; mod subnet_allocation; +mod support_bundles; mod switch_port; mod unauthorized; mod unauthorized_coverage; diff --git a/nexus/tests/integration_tests/support_bundles.rs b/nexus/tests/integration_tests/support_bundles.rs new file mode 100644 index 00000000000..5c1c036a86b --- /dev/null +++ b/nexus/tests/integration_tests/support_bundles.rs @@ -0,0 +1,489 @@ +// 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/. + +//! Tests Support Bundles + +use anyhow::Context; +use anyhow::Result; +use dropshot::test_util::ClientTestContext; +use dropshot::HttpErrorResponseBody; +use http::method::Method; +use http::StatusCode; +use nexus_client::types::LastResult; +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::resource_helpers::TestDataset; +use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::shared::SupportBundleInfo; +use nexus_types::external_api::shared::SupportBundleState; +use omicron_common::api::internal::shared::DatasetKind; +use omicron_nexus::app::test_interfaces::task_output; +use omicron_uuid_kinds::{DatasetUuid, SupportBundleUuid, ZpoolUuid}; +use serde::Deserialize; +use std::io::Cursor; +use zip::read::ZipArchive; + +type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; +type DiskTest<'a> = + nexus_test_utils::resource_helpers::DiskTest<'a, omicron_nexus::Server>; + +// -- HTTP methods -- +// +// The following are a set of helper functions to access Support Bundle APIs +// through the public interface. + +const BUNDLES_URL: &str = "/experimental/v1/system/support-bundles"; + +async fn expect_not_found( + client: &ClientTestContext, + bundle_id: SupportBundleUuid, + bundle_url: &str, + method: Method, +) -> Result<()> { + let response = NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + method.clone(), + &bundle_url, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .context("Failed to execute request and get response")?; + + // HEAD requests should not return bodies + if method == Method::HEAD { + return Ok(()); + } + + let error: HttpErrorResponseBody = + response.parsed_body().context("Failed to parse response")?; + + let expected = + format!("not found: support-bundle with id \"{}\"", bundle_id); + if error.message != expected { + anyhow::bail!( + "Unexpected error: {} (wanted {})", + error.message, + expected + ); + } + Ok(()) +} + +async fn bundles_list( + client: &ClientTestContext, +) -> Result> { + Ok(NexusRequest::iter_collection_authn(client, BUNDLES_URL, "", None) + .await + .context("failed to list bundles")? + .all_items) +} + +async fn bundle_get( + client: &ClientTestContext, + id: SupportBundleUuid, +) -> Result { + let url = format!("{BUNDLES_URL}/{id}"); + NexusRequest::object_get(client, &url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .with_context(|| format!("failed to make \"GET\" request to {url}"))? + .parsed_body() +} + +async fn bundle_get_expect_fail( + client: &ClientTestContext, + id: SupportBundleUuid, + expected_status: StatusCode, + expected_message: &str, +) -> Result<()> { + let url = format!("{BUNDLES_URL}/{id}"); + let error = NexusRequest::new( + RequestBuilder::new(client, Method::GET, &url) + .expect_status(Some(expected_status)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .context("should have failed to GET bundle")? + .parsed_body::() + .context("failed to response error from bundle GET")?; + + if error.message != expected_message { + anyhow::bail!( + "Unexpected error: {} (wanted {})", + error.message, + expected_message + ); + } + Ok(()) +} + +async fn bundle_delete( + client: &ClientTestContext, + id: SupportBundleUuid, +) -> Result<()> { + let url = format!("{BUNDLES_URL}/{id}"); + NexusRequest::object_delete(client, &url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .with_context(|| { + format!("failed to make \"DELETE\" request to {url}") + })?; + Ok(()) +} + +async fn bundle_create( + client: &ClientTestContext, +) -> Result { + NexusRequest::new( + RequestBuilder::new(client, Method::POST, BUNDLES_URL) + .expect_status(Some(StatusCode::CREATED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .context("failed to request bundle creation")? + .parsed_body() + .context("failed to parse 'create bundle' response") +} + +async fn bundle_create_expect_fail( + client: &ClientTestContext, + expected_status: StatusCode, + expected_message: &str, +) -> Result<()> { + let error = NexusRequest::new( + RequestBuilder::new(client, Method::POST, BUNDLES_URL) + .expect_status(Some(expected_status)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .context("should have failed to create bundle")? + .parsed_body::() + .context("failed to response error from bundle creation")?; + + if error.message != expected_message { + anyhow::bail!( + "Unexpected error: {} (wanted {})", + error.message, + expected_message + ); + } + Ok(()) +} + +async fn bundle_download( + client: &ClientTestContext, + id: SupportBundleUuid, +) -> Result { + let url = format!("{BUNDLES_URL}/{id}/download"); + let body = NexusRequest::new( + RequestBuilder::new(client, Method::GET, &url) + .expect_status(Some(StatusCode::OK)) + .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, + expected_status: StatusCode, + expected_message: &str, +) -> Result<()> { + let url = format!("{BUNDLES_URL}/{id}/download"); + let error = NexusRequest::new( + RequestBuilder::new(client, Method::GET, &url) + .expect_status(Some(expected_status)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .context("failed to request bundle download")? + .parsed_body::() + .context("failed to response error from bundle download")?; + + if error.message != expected_message { + anyhow::bail!( + "Unexpected error: {} (wanted {})", + error.message, + expected_message + ); + } + Ok(()) +} + +// -- Background Task -- +// +// The following logic helps us trigger and observe the output of the support +// bundle background task. + +#[derive(Deserialize)] +struct TaskOutput { + cleanup_err: Option, + collection_err: Option, + cleanup_report: Option, + collection_report: Option, +} + +async fn activate_bundle_collection_background_task( + cptestctx: &ControlPlaneTestContext, +) -> TaskOutput { + use nexus_test_utils::background::activate_background_task; + + let task = activate_background_task( + &cptestctx.internal_client, + "support_bundle_collector", + ) + .await; + + let LastResult::Completed(result) = task.last else { + panic!("Task did not complete"); + }; + serde_json::from_value(result.details).expect( + "Should have been able to deserialize TaskOutput from background task", + ) +} + +// Test accessing support bundle interfaces when the bundle does not exist, +// and when no U.2s exist on which to store support bundles. +#[nexus_test] +async fn test_support_bundle_not_found(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + let id = SupportBundleUuid::new_v4(); + + expect_not_found( + &client, + id, + &format!("{BUNDLES_URL}/{id}"), + Method::DELETE, + ) + .await + .unwrap(); + + expect_not_found(&client, id, &format!("{BUNDLES_URL}/{id}"), Method::GET) + .await + .unwrap(); + + expect_not_found( + &client, + id, + &format!("{BUNDLES_URL}/{id}/download"), + Method::GET, + ) + .await + .unwrap(); + + expect_not_found( + &client, + id, + &format!("{BUNDLES_URL}/{id}/download/single-file"), + Method::GET, + ) + .await + .unwrap(); + + expect_not_found( + &client, + id, + &format!("{BUNDLES_URL}/{id}/download"), + Method::HEAD, + ) + .await + .unwrap(); + + expect_not_found( + &client, + id, + &format!("{BUNDLES_URL}/{id}/download/single-file"), + Method::HEAD, + ) + .await + .unwrap(); + + expect_not_found( + &client, + id, + &format!("{BUNDLES_URL}/{id}/download/index"), + Method::HEAD, + ) + .await + .unwrap(); + + assert!(bundles_list(&client).await.unwrap().is_empty()); + + bundle_create_expect_fail( + &client, + StatusCode::INSUFFICIENT_STORAGE, + "Insufficient capacity: Current policy limits support bundle creation to 'one per external disk', and no disks are available. You must delete old support bundles before new ones can be created", + ).await.unwrap(); +} + +// Test the create, read, and deletion operations on a bundle. +#[nexus_test] +async fn test_support_bundle_lifecycle(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + let mut disk_test = DiskTest::new(&cptestctx).await; + + // We really care about just "getting a debug dataset" here. + let sled_id = cptestctx.first_sled(); + disk_test + .add_zpool_with_dataset_ext( + sled_id, + nexus_test_utils::PHYSICAL_DISK_UUID.parse().unwrap(), + ZpoolUuid::new_v4(), + vec![ + TestDataset { + id: DatasetUuid::new_v4(), + kind: DatasetKind::Crucible, + }, + TestDataset { + id: DatasetUuid::new_v4(), + kind: DatasetKind::Debug, + }, + ], + DiskTest::DEFAULT_ZPOOL_SIZE_GIB, + ) + .await; + disk_test.propagate_datasets_to_sleds().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() { + for dataset in &zpool.datasets { + if matches!(dataset.kind, DatasetKind::Debug) { + debug_dataset_count += 1; + } + } + } + assert_eq!(debug_dataset_count, 1); + + // We should see no bundles before we start creation. + assert!(bundles_list(&client).await.unwrap().is_empty()); + let bundle = bundle_create(&client).await.unwrap(); + + assert_eq!(bundle.reason_for_creation, "Created by external API"); + assert_eq!(bundle.reason_for_failure, None); + assert_eq!(bundle.state, SupportBundleState::Collecting); + + let bundles = bundles_list(&client).await.unwrap(); + assert_eq!(bundles.len(), 1); + assert_eq!(bundles[0].id, bundle.id); + assert_eq!(bundle_get(&client, bundle.id).await.unwrap().id, bundle.id); + + // We can't collect a second bundle because the debug dataset already fully + // occupied. + // + // We'll retry this at the end of the test, and see that we can create + // another bundle when the first is cleared. + bundle_create_expect_fail( + &client, + StatusCode::INSUFFICIENT_STORAGE, + "Insufficient capacity: Current policy limits support bundle creation to 'one per external disk', and no disks are available. You must delete old support bundles before new ones can be created", + ).await.unwrap(); + + // The bundle is "Collecting", not "Active", so we can't download it yet. + bundle_download_expect_fail( + &client, + bundle.id, + StatusCode::BAD_REQUEST, + "Cannot download bundle in non-active state", + ) + .await + .unwrap(); + + // If we prompt the background task to run, the bundle should transition to + // "Active". + let output = activate_bundle_collection_background_task(&cptestctx).await; + assert_eq!(output.cleanup_err, None); + assert_eq!(output.collection_err, None); + assert_eq!( + output.cleanup_report, + Some(task_output::CleanupReport { ..Default::default() }) + ); + assert_eq!( + output.collection_report, + Some(task_output::CollectionReport { + 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); + + // Now we should be able to download the bundle + let contents = bundle_download(&client, bundle.id).await.unwrap(); + let archive = ZipArchive::new(Cursor::new(&contents)).unwrap(); + let mut names = archive.file_names(); + assert_eq!(names.next(), Some("bundle_id.txt")); + assert_eq!(names.next(), Some("rack/")); + // There's much more data in the bundle, but validating it isn't the point + // of this test, which cares more about bundle lifecycle. + + // We are also able to delete the bundle + bundle_delete(&client, bundle.id).await.unwrap(); + let observed = bundle_get(&client, bundle.id).await.unwrap(); + assert_eq!(observed.state, SupportBundleState::Destroying); + + // We cannot download anything after bundle deletion starts + bundle_download_expect_fail( + &client, + bundle.id, + StatusCode::BAD_REQUEST, + "Cannot download bundle in non-active state", + ) + .await + .unwrap(); + + // If we prompt the background task to run, the bundle will be cleaned up. + let output = activate_bundle_collection_background_task(&cptestctx).await; + assert_eq!(output.cleanup_err, None); + assert_eq!(output.collection_err, None); + assert_eq!( + output.cleanup_report, + Some(task_output::CleanupReport { + sled_bundles_deleted_ok: 1, + db_destroying_bundles_removed: 1, + ..Default::default() + }) + ); + assert_eq!(output.collection_report, None); + + // The bundle is now fully deleted, so it should no longer appear. + bundle_get_expect_fail( + &client, + bundle.id, + StatusCode::NOT_FOUND, + &format!("not found: support-bundle with id \"{}\"", bundle.id), + ) + .await + .unwrap(); + + // We can now create a second bundle, as the first has been freed. + let second_bundle = bundle_create(&client).await.unwrap(); + + assert_ne!( + second_bundle.id, bundle.id, + "We should have made a distinct bundle" + ); + assert_eq!(second_bundle.reason_for_creation, "Created by external API"); + assert_eq!(second_bundle.state, SupportBundleState::Collecting); +} diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 6581dcb9e93..785277f230c 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -18,7 +18,9 @@ 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::http_testing::TestResponse; +use nexus_test_utils::resource_helpers::TestDataset; use nexus_test_utils_macros::nexus_test; +use omicron_common::disk::DatasetKind; use omicron_uuid_kinds::DatasetUuid; use omicron_uuid_kinds::ZpoolUuid; use once_cell::sync::Lazy; @@ -64,10 +66,20 @@ async fn test_unauthorized(cptestctx: &ControlPlaneTestContext) { sled_id, nexus_test_utils::PHYSICAL_DISK_UUID.parse().unwrap(), ZpoolUuid::new_v4(), - DatasetUuid::new_v4(), + vec![ + TestDataset { + id: DatasetUuid::new_v4(), + kind: DatasetKind::Crucible, + }, + TestDataset { + id: DatasetUuid::new_v4(), + kind: DatasetKind::Debug, + }, + ], DiskTest::DEFAULT_ZPOOL_SIZE_GIB, ) .await; + disk_test.propagate_datasets_to_sleds().await; let client = &cptestctx.external_client; let log = &cptestctx.logctx.log; @@ -322,6 +334,12 @@ static SETUP_REQUESTS: Lazy> = Lazy::new(|| { body: serde_json::to_value(&*DEMO_CERTIFICATE_CREATE).unwrap(), id_routes: vec![], }, + // Create a Support Bundle + SetupReq::Post { + url: &SUPPORT_BUNDLES_URL, + body: serde_json::to_value(()).unwrap(), + id_routes: vec!["/experimental/v1/system/support-bundles/{id}"], + }, ] }); diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index 0a9e62707f0..a53e94414a6 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,10 +1,7 @@ API endpoints with no coverage in authz tests: probe_delete (delete "/experimental/v1/probes/{probe}") -support_bundle_delete (delete "/experimental/v1/system/support-bundles/{support_bundle}") probe_list (get "/experimental/v1/probes") probe_view (get "/experimental/v1/probes/{probe}") -support_bundle_list (get "/experimental/v1/system/support-bundles") -support_bundle_view (get "/experimental/v1/system/support-bundles/{support_bundle}") support_bundle_download (get "/experimental/v1/system/support-bundles/{support_bundle}/download") support_bundle_download_file (get "/experimental/v1/system/support-bundles/{support_bundle}/download/{file}") support_bundle_index (get "/experimental/v1/system/support-bundles/{support_bundle}/index") @@ -16,7 +13,6 @@ device_auth_request (post "/device/auth") device_auth_confirm (post "/device/confirm") device_access_token (post "/device/token") probe_create (post "/experimental/v1/probes") -support_bundle_create (post "/experimental/v1/system/support-bundles") login_saml (post "/login/{silo_name}/saml/{provider_name}") login_local (post "/v1/login/{silo_name}/local") logout (post "/v1/logout") diff --git a/nexus/types/src/external_api/shared.rs b/nexus/types/src/external_api/shared.rs index 9e9bc26ffe8..e61b74e527c 100644 --- a/nexus/types/src/external_api/shared.rs +++ b/nexus/types/src/external_api/shared.rs @@ -417,7 +417,9 @@ mod test { } } -#[derive(Debug, Clone, Copy, JsonSchema, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, JsonSchema, Serialize, Deserialize, Eq, PartialEq, +)] #[serde(rename_all = "snake_case")] pub enum SupportBundleState { /// Support Bundle still actively being collected. diff --git a/sled-agent/src/sim/storage.rs b/sled-agent/src/sim/storage.rs index e907aaffe19..e93de3730e1 100644 --- a/sled-agent/src/sim/storage.rs +++ b/sled-agent/src/sim/storage.rs @@ -1312,6 +1312,11 @@ impl StorageInner { .map(|config| config.name.clone()) .collect(); for dataset in &dataset_names { + // Datasets delegated to zones manage their own storage. + if dataset.kind().zoned() { + continue; + } + let root = self.root().to_path_buf(); self.nested_datasets.entry(dataset.clone()).or_insert_with(|| { NestedDatasetStorage::new( diff --git a/test-utils/src/dev/test_cmds.rs b/test-utils/src/dev/test_cmds.rs index 220efa46288..d2662abb1ce 100644 --- a/test-utils/src/dev/test_cmds.rs +++ b/test-utils/src/dev/test_cmds.rs @@ -257,6 +257,18 @@ fn redact_basic(input: &str) -> String { .replace_all(&s, "m") .to_string(); + // Replace interval (h). + let s = regex::Regex::new(r"\d+h") + .unwrap() + .replace_all(&s, "h") + .to_string(); + + // Replace interval (days). + let s = regex::Regex::new(r"\d+days") + .unwrap() + .replace_all(&s, "days") + .to_string(); + let s = regex::Regex::new( r"note: database schema version matches expected \(\d+\.\d+\.\d+\)", )