diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 35ff366f11c..9802a75a4b3 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -3571,7 +3571,7 @@ pub enum ImportExportPolicy { /// will fail to parse if the key is not present. The JSON Schema in the /// OpenAPI definition will also reflect that the field is required. See /// . -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub struct Nullable(pub Option); impl From> for Nullable { diff --git a/nexus/db-model/src/target_release.rs b/nexus/db-model/src/target_release.rs index cbc681912f6..9dd767aaaa2 100644 --- a/nexus/db-model/src/target_release.rs +++ b/nexus/db-model/src/target_release.rs @@ -6,7 +6,6 @@ use super::{Generation, impl_enum_type}; use crate::typed_uuid::DbTypedUuid; use chrono::{DateTime, Utc}; use nexus_db_schema::schema::target_release; -use nexus_types::external_api::views; use omicron_uuid_kinds::TufRepoKind; impl_enum_type!( @@ -60,15 +59,4 @@ impl TargetRelease { tuf_repo_id: Some(tuf_repo_id), } } - - pub fn into_external( - &self, - release_source: views::TargetReleaseSource, - ) -> views::TargetRelease { - views::TargetRelease { - generation: (&self.generation.0).into(), - time_requested: self.time_requested, - release_source, - } - } } diff --git a/nexus/db-queries/src/db/datastore/target_release.rs b/nexus/db-queries/src/db/datastore/target_release.rs index 99b7a5356a4..91f171b9bfe 100644 --- a/nexus/db-queries/src/db/datastore/target_release.rs +++ b/nexus/db-queries/src/db/datastore/target_release.rs @@ -7,9 +7,7 @@ use super::DataStore; use crate::authz; use crate::context::OpContext; -use crate::db::model::{ - Generation, SemverVersion, TargetRelease, TargetReleaseSource, -}; +use crate::db::model::{Generation, TargetRelease}; use async_bb8_diesel::AsyncRunQueryDsl as _; use diesel::insert_into; use diesel::prelude::*; @@ -17,7 +15,6 @@ use diesel::sql_types; use nexus_db_errors::{ErrorHandler, public_error_from_diesel}; use nexus_db_schema::enums::TargetReleaseSourceEnum; use nexus_db_schema::schema::target_release::dsl; -use nexus_types::external_api::views; use omicron_common::api::external::{CreateResult, Error, LookupResult}; use omicron_uuid_kinds::TufRepoUuid; use std::collections::BTreeSet; @@ -181,49 +178,6 @@ impl DataStore { } } - /// Convert a model-level target release to an external view. - /// This method lives here because we have to look up the version - /// corresponding to the TUF repo. - pub async fn target_release_view( - &self, - opctx: &OpContext, - target_release: &TargetRelease, - ) -> LookupResult { - opctx - .authorize(authz::Action::Read, &authz::TARGET_RELEASE_CONFIG) - .await?; - let conn = self.pool_connection_authorized(opctx).await?; - let release_source = match target_release.release_source { - TargetReleaseSource::Unspecified => { - views::TargetReleaseSource::Unspecified - } - TargetReleaseSource::SystemVersion => { - use nexus_db_schema::schema::tuf_repo; - if let Some(tuf_repo_id) = target_release.tuf_repo_id { - views::TargetReleaseSource::SystemVersion { - version: tuf_repo::table - .select(tuf_repo::system_version) - .filter(tuf_repo::id.eq(tuf_repo_id)) - .first_async::(&*conn) - .await - .map_err(|e| { - public_error_from_diesel( - e, - ErrorHandler::Server, - ) - })? - .into(), - } - } else { - return Err(Error::internal_error( - "missing TUF repo ID for specified system version", - )); - } - } - }; - Ok(target_release.into_external(release_source)) - } - /// Lists the most recent N distinct target releases pub async fn target_release_fetch_recent_distinct( &self, diff --git a/nexus/db-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index 01afd6d9dbd..618b99452d4 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -21,8 +21,8 @@ use nexus_db_errors::OptionalError; use nexus_db_errors::{ErrorHandler, public_error_from_diesel}; use nexus_db_lookup::DbConnection; use nexus_db_model::{ - ArtifactHash, TargetRelease, TufArtifact, TufRepo, TufRepoDescription, - TufTrustRoot, to_db_typed_uuid, + ArtifactHash, DbTypedUuid, TargetRelease, TufArtifact, TufRepo, + TufRepoDescription, TufTrustRoot, to_db_typed_uuid, }; use omicron_common::api::external::{ self, CreateResult, DataPageParams, DeleteResult, Generation, @@ -180,6 +180,33 @@ impl DataStore { Ok(TufRepoDescription { repo, artifacts }) } + /// Given a TUF repo ID, get its version. We could use `tuf_repo_get_by_id`, + /// but that makes an additional query for the artifacts that we don't need + /// in the code that uses this method. + pub async fn tuf_repo_get_version( + &self, + opctx: &OpContext, + tuf_repo_id: &DbTypedUuid, + ) -> LookupResult { + opctx + .authorize(authz::Action::Read, &authz::TARGET_RELEASE_CONFIG) + .await?; + let conn = self.pool_connection_authorized(opctx).await?; + use nexus_db_schema::schema::tuf_repo; + tuf_repo::table + .select(tuf_repo::system_version) + .filter(tuf_repo::id.eq(tuf_repo_id.into_untyped_uuid())) + .first_async::(&*conn) + .await + .map(|v| v.0) + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + // looking up a non-existent ID will 500, but it doesn't + // automatically include the bad ID + .with_internal_context(|| { + format!("tuf_repo_get_version {tuf_repo_id}") + }) + } + /// Returns the list of all TUF repo artifacts known to the system. pub async fn tuf_list_repos( &self, diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 4d3daee3807..496067dae17 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -298,12 +298,12 @@ API operations found with tag "system/update" OPERATION ID METHOD URL PATH system_update_get_repository GET /v1/system/update/repository/{system_version} system_update_put_repository PUT /v1/system/update/repository +system_update_status GET /v1/system/update/status system_update_trust_root_create POST /v1/system/update/trust-roots system_update_trust_root_delete DELETE /v1/system/update/trust-roots/{trust_root_id} system_update_trust_root_list GET /v1/system/update/trust-roots system_update_trust_root_view GET /v1/system/update/trust-roots/{trust_root_id} target_release_update PUT /v1/system/update/target-release -target_release_view GET /v1/system/update/target-release API operations found with tag "tokens" OPERATION ID METHOD URL PATH diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index b98bca4a449..be9a2b28027 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -3041,36 +3041,35 @@ pub trait NexusExternalApi { path_params: Path, ) -> Result; - /// Get the current target release of the rack's system software + /// Set target release /// - /// This may not correspond to the actual software running on the rack - /// at the time of request; it is instead the release that the rack - /// reconfigurator should be moving towards as a goal state. After some - /// number of planning and execution phases, the software running on the - /// rack should eventually correspond to the release described here. + /// Set the current target release of the rack's system software. The rack + /// reconfigurator will treat the software specified here as a goal state + /// for the rack's software, and attempt to asynchronously update to that + /// release. Use the update status endpoint to view the current target + /// release. #[endpoint { - method = GET, + method = PUT, path = "/v1/system/update/target-release", tags = ["system/update"], }] - async fn target_release_view( + async fn target_release_update( rqctx: RequestContext, - ) -> Result, HttpError>; + params: TypedBody, + ) -> Result; - /// Set the current target release of the rack's system software + /// Fetch system update status /// - /// The rack reconfigurator will treat the software specified here as - /// a goal state for the rack's software, and attempt to asynchronously - /// update to that release. + /// Returns information about the current target release and the + /// progress of system software updates. #[endpoint { - method = PUT, - path = "/v1/system/update/target-release", + method = GET, + path = "/v1/system/update/status", tags = ["system/update"], }] - async fn target_release_update( + async fn system_update_status( rqctx: RequestContext, - params: TypedBody, - ) -> Result, HttpError>; + ) -> Result, HttpError>; // Silo users diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 0f33f470873..9b202ee2dd6 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -9,6 +9,7 @@ use self::saga::SagaExecutor; use crate::DropshotServer; use crate::app::background::BackgroundTasksData; use crate::app::background::SagaRecoveryHelpers; +use crate::app::update::UpdateStatusHandle; use crate::populate::PopulateArgs; use crate::populate::PopulateStatus; use crate::populate::populate_start; @@ -285,6 +286,9 @@ pub struct Nexus { #[allow(dead_code)] repo_depot_resolver: Box, + /// handle to pull update status data + update_status: UpdateStatusHandle, + /// state of overall Nexus quiesce activity quiesce: NexusQuiesceHandle, } @@ -351,7 +355,7 @@ impl Nexus { let quiesce = NexusQuiesceHandle::new( db_datastore.clone(), config.deployment.id, - blueprint_load_rx, + blueprint_load_rx.clone(), quiesce_opctx, ); @@ -534,6 +538,7 @@ impl Nexus { mgs_update_status_rx, mgs_resolver, repo_depot_resolver, + update_status: UpdateStatusHandle::new(blueprint_load_rx), quiesce, }; diff --git a/nexus/src/app/update.rs b/nexus/src/app/update.rs index c4fb9428a47..9b4e4ff3a9b 100644 --- a/nexus/src/app/update.rs +++ b/nexus/src/app/update.rs @@ -9,20 +9,49 @@ use dropshot::HttpError; use futures::Stream; use nexus_auth::authz; use nexus_db_lookup::LookupPath; -use nexus_db_model::{TufRepoDescription, TufTrustRoot}; +use nexus_db_model::{Generation, TufRepoDescription, TufTrustRoot}; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::{datastore::SQL_BATCH_SIZE, pagination::Paginator}; +use nexus_types::deployment::{ + Blueprint, BlueprintTarget, TargetReleaseDescription, +}; use nexus_types::external_api::shared::TufSignedRootRole; +use nexus_types::external_api::views; +use nexus_types::internal_api::views as internal_views; +use nexus_types::inventory::RotSlot; +use omicron_common::api::external::InternalContext; +use omicron_common::api::external::Nullable; use omicron_common::api::external::{ DataPageParams, Error, TufRepoInsertResponse, TufRepoInsertStatus, }; +use omicron_common::disk::M2Slot; use omicron_uuid_kinds::{GenericUuid, TufTrustRootUuid}; use semver::Version; +use std::collections::BTreeMap; +use std::sync::Arc; +use tokio::sync::watch; use update_common::artifacts::{ ArtifactsWithPlan, ControlPlaneZonesMode, VerificationMode, }; use uuid::Uuid; +/// Used to pull data out of the channels +#[derive(Clone)] +pub struct UpdateStatusHandle { + latest_blueprint: + watch::Receiver>>, +} + +impl UpdateStatusHandle { + pub fn new( + latest_blueprint: watch::Receiver< + Option>, + >, + ) -> Self { + Self { latest_blueprint } + } +} + impl super::Nexus { pub(crate) async fn updates_put_repository( &self, @@ -149,4 +178,184 @@ impl super::Nexus { .await .map_err(HttpError::from) } + + /// Get external update status with aggregated component counts + pub async fn update_status_external( + &self, + opctx: &OpContext, + ) -> Result { + let db_target_release = + self.datastore().target_release_get_current(opctx).await?; + + let current_tuf_repo = match db_target_release.tuf_repo_id { + Some(tuf_repo_id) => Some( + self.datastore() + .tuf_repo_get_by_id(opctx, tuf_repo_id.into()) + .await?, + ), + None => None, + }; + + let target_release = + current_tuf_repo.as_ref().map(|repo| views::TargetRelease { + time_requested: db_target_release.time_requested, + version: repo.repo.system_version.0.clone(), + }); + + let components_by_release_version = self + .component_version_counts( + opctx, + &db_target_release, + current_tuf_repo, + ) + .await?; + + let bp_arc = self + .update_status + .latest_blueprint + .borrow() + .clone() // drop read lock held by outstanding borrow + .ok_or_else(|| { + Error::internal_error("Tried to get update status before target blueprint is loaded") + })?; + + let (blueprint_target, blueprint) = &*bp_arc; + + let time_last_step_planned = blueprint_target.time_made_target; + + // Update activity is suspended if the current target release generation + // is less than the blueprint's minimum generation + let suspended = *db_target_release.generation + < blueprint.target_release_minimum_generation; + + Ok(views::UpdateStatus { + target_release: Nullable(target_release), + components_by_release_version, + time_last_step_planned, + suspended, + }) + } + + /// Build a map of version strings to the number of components on that version + async fn component_version_counts( + &self, + opctx: &OpContext, + target_release: &nexus_db_model::TargetRelease, + current_tuf_repo: Option, + ) -> Result, Error> { + let Some(inventory) = + self.datastore().inventory_get_latest_collection(opctx).await? + else { + return Err(Error::internal_error("No inventory collection found")); + }; + + // Build current TargetReleaseDescription, defaulting to Initial if + // there is no tuf repo ID which, based on DB constraints, happens if + // and only if target_release_source is 'unspecified', which should only + // happen in the initial state before any target release has been set + let curr_target_desc = match current_tuf_repo { + Some(repo) => { + TargetReleaseDescription::TufRepo(repo.into_external()) + } + None => TargetReleaseDescription::Initial, + }; + + // Get previous target release (if it exists). Build the "prev" + // TargetReleaseDescription from the previous generation if available, + // otherwise fall back to Initial. + let prev_repo_id = + if let Some(prev_gen) = target_release.generation.prev() { + self.datastore() + .target_release_get_generation(opctx, Generation(prev_gen)) + .await + .internal_context("fetching previous target release")? + .and_then(|r| r.tuf_repo_id) + } else { + None + }; + + // It should never happen that a target release other than the initial + // one with target_release_source unspecified should be missing a + // tuf_repo_id. So if we have a tuf_repo_id for the previous target + // release, we should always have one for the current target. + if prev_repo_id.is_some() && target_release.tuf_repo_id.is_none() { + return Err(Error::internal_error( + "Target release has no tuf repo but previous release has one", + )); + } + + let prev_target_desc = match prev_repo_id { + Some(id) => TargetReleaseDescription::TufRepo( + self.datastore() + .tuf_repo_get_by_id(opctx, id.into()) + .await? + .into_external(), + ), + None => TargetReleaseDescription::Initial, + }; + + // It's weird to use the internal view this way. It would feel more + // correct to extract shared logic and call it in both places. On the + // other hand, that sharing would be boilerplatey and not add much yet. + // So for now, use the internal view, but plan to extract shared logic + // or do our own thing here once things settle. + let status = internal_views::UpdateStatus::new( + &prev_target_desc, + &curr_target_desc, + &inventory, + ); + + let sled_versions = status.sleds.into_iter().flat_map(|sled| { + let zone_versions = sled.zones.into_iter().map(|zone| zone.version); + + // boot_disk tells you which slot is relevant + let host_version = + sled.host_phase_2.boot_disk.ok().map(|slot| match slot { + M2Slot::A => sled.host_phase_2.slot_a_version.clone(), + M2Slot::B => sled.host_phase_2.slot_b_version.clone(), + }); + + zone_versions.chain(host_version) + }); + + let mgs_driven_versions = + status.mgs_driven.into_iter().flat_map(|status| { + // for the SP, slot0_version is the active one + let sp_version = status.sp.slot0_version.clone(); + + // for the bootloader, stage0_version is the active one. + let bootloader_version = + status.rot_bootloader.stage0_version.clone(); + + let rot_version = + status.rot.active_slot.map(|slot| match slot { + RotSlot::A => status.rot.slot_a_version.clone(), + RotSlot::B => status.rot.slot_b_version.clone(), + }); + + let host_version = match &status.host_os_phase_1 { + internal_views::HostPhase1Status::Sled { + slot_a_version, + slot_b_version, + active_slot, + .. + } => active_slot.map(|slot| match slot { + M2Slot::A => slot_a_version.clone(), + M2Slot::B => slot_b_version.clone(), + }), + internal_views::HostPhase1Status::NotASled => None, + }; + + std::iter::once(sp_version) + .chain(rot_version) + .chain(std::iter::once(bootloader_version)) + .chain(host_version) + }); + + let mut counts = BTreeMap::new(); + for version in sled_versions.chain(mgs_driven_versions) { + *counts.entry(version.to_string()).or_insert(0) += 1; + } + Ok(counts) + } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 4ae16603151..a715fdead6e 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6796,34 +6796,10 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn target_release_view( - rqctx: RequestContext, - ) -> Result, HttpError> { - let apictx = rqctx.context(); - let handler = async { - let nexus = &apictx.context.nexus; - let opctx = - crate::context::op_context_for_external_api(&rqctx).await?; - let target_release = - nexus.datastore().target_release_get_current(&opctx).await?; - Ok(HttpResponseOk( - nexus - .datastore() - .target_release_view(&opctx, &target_release) - .await?, - )) - }; - apictx - .context - .external_latencies - .instrument_dropshot_handler(&rqctx, handler) - .await - } - async fn target_release_update( rqctx: RequestContext, body: TypedBody, - ) -> Result, HttpError> { + ) -> Result { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; @@ -6844,24 +6820,20 @@ impl NexusExternalApi for NexusExternalApiImpl { nexus.datastore().target_release_get_current(&opctx).await?; // Disallow downgrades. - if let views::TargetReleaseSource::SystemVersion { version } = nexus - .datastore() - .target_release_view(&opctx, ¤t_target_release) - .await? - .release_source - { + if let Some(tuf_repo_id) = current_target_release.tuf_repo_id { + let version = nexus + .datastore() + .tuf_repo_get_version(&opctx, &tuf_repo_id) + .await?; if !is_new_target_release_version_allowed( &version, &system_version, ) { - return Err(HttpError::for_bad_request( - None, - format!( - "The requested target system release ({system_version}) \ - is older than the current target system release ({version}). \ - This is not supported." - ), - )); + return Err(Error::invalid_request(format!( + "Requested target release ({system_version}) must not \ + be older than current target release ({version})." + )) + .into()); } } @@ -6877,17 +6849,29 @@ impl NexusExternalApi for NexusExternalApiImpl { ¤t_target_release, tuf_repo_id, ); - let target_release = nexus + nexus .datastore() .target_release_insert(&opctx, next_target_release) .await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } - Ok(HttpResponseCreated( - nexus - .datastore() - .target_release_view(&opctx, &target_release) - .await?, - )) + async fn system_update_status( + rqctx: RequestContext, + ) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + let status = nexus.update_status_external(&opctx).await?; + Ok(HttpResponseOk(status)) }; apictx .context diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index d8c62a95d94..649207d3f98 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -2554,12 +2554,19 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( visibility: Visibility::Public, unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ - AllowedMethod::Get, AllowedMethod::Put( serde_json::to_value(&*DEMO_TARGET_RELEASE).unwrap(), ), ], }, + VerifyEndpoint { + url: "/v1/system/update/status", + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + ], + }, /* Metrics */ VerifyEndpoint { url: &DEMO_SYSTEM_METRICS_URL, diff --git a/nexus/tests/integration_tests/target_release.rs b/nexus/tests/integration_tests/target_release.rs index 68e6008c928..813d779cd2a 100644 --- a/nexus/tests/integration_tests/target_release.rs +++ b/nexus/tests/integration_tests/target_release.rs @@ -11,9 +11,10 @@ use http::StatusCode; use http::method::Method; use nexus_test_utils::http_testing::AuthnMode; use nexus_test_utils::http_testing::{NexusRequest, RequestBuilder}; +use nexus_test_utils::resource_helpers::object_get; use nexus_test_utils::test_setup; use nexus_types::external_api::params::SetTargetReleaseParams; -use nexus_types::external_api::views::{TargetRelease, TargetReleaseSource}; +use nexus_types::external_api::views; use omicron_common::api::external::TufRepoInsertResponse; use semver::Version; use tufaceous_artifact::{ArtifactVersion, KnownArtifactKind}; @@ -28,18 +29,10 @@ async fn get_set_target_release() -> Result<()> { let client = &ctx.external_client; let logctx = &ctx.logctx; - // There should always be a target release. - let target_release: TargetRelease = - NexusRequest::object_get(client, "/v1/system/update/target-release") - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - assert_eq!(target_release.generation, 1); - assert!(target_release.time_requested < Utc::now()); - assert_eq!(target_release.release_source, TargetReleaseSource::Unspecified); + // There is no target release before one has ever been specified + let status: views::UpdateStatus = + object_get(client, "/v1/system/update/status").await; + assert_eq!(status.target_release.0, None); // Attempting to set an invalid system version should fail. let system_version = Version::new(0, 0, 0); @@ -70,16 +63,16 @@ async fn get_set_target_release() -> Result<()> { .parsed_body()?; assert_eq!(system_version, response.recorded.repo.system_version); - let target_release = - set_target_release(client, system_version.clone()).await?; + set_target_release(client, &system_version).await?; + + let status: views::UpdateStatus = + object_get(client, "/v1/system/update/status").await; + + let target_release = status.target_release.0.unwrap(); let after = Utc::now(); - assert_eq!(target_release.generation, 2); assert!(target_release.time_requested >= before); assert!(target_release.time_requested <= after); - assert_eq!( - target_release.release_source, - TargetReleaseSource::SystemVersion { version: system_version }, - ); + assert_eq!(target_release.version, system_version); } // Adding a repo with non-semver artifact versions should be ok, too. @@ -102,21 +95,21 @@ async fn get_set_target_release() -> Result<()> { .parsed_body()?; assert_eq!(system_version, response.recorded.repo.system_version); - let target_release = - set_target_release(client, system_version.clone()).await?; + set_target_release(client, &system_version).await?; + + let status: views::UpdateStatus = + object_get(client, "/v1/system/update/status").await; + + let target_release = status.target_release.0.unwrap(); let after = Utc::now(); - assert_eq!(target_release.generation, 3); assert!(target_release.time_requested >= before); assert!(target_release.time_requested <= after); - assert_eq!( - target_release.release_source, - TargetReleaseSource::SystemVersion { version: system_version }, - ); + assert_eq!(target_release.version, system_version); } // Attempting to downgrade to an earlier system version (2.0.0 → 1.0.0) // should not be allowed. - set_target_release(client, Version::new(1, 0, 0)) + set_target_release(client, &Version::new(1, 0, 0)) .await .expect_err("shouldn't be able to downgrade system"); @@ -124,21 +117,23 @@ async fn get_set_target_release() -> Result<()> { Ok(()) } -async fn set_target_release( +pub async fn set_target_release( client: &ClientTestContext, - system_version: Version, -) -> Result { + system_version: &Version, +) -> Result<(), anyhow::Error> { NexusRequest::new( RequestBuilder::new( client, Method::PUT, "/v1/system/update/target-release", ) - .body(Some(&SetTargetReleaseParams { system_version })) - .expect_status(Some(StatusCode::CREATED)), + .body(Some(&SetTargetReleaseParams { + system_version: system_version.clone(), + })) + .expect_status(Some(StatusCode::NO_CONTENT)), ) .authn_as(AuthnMode::PrivilegedUser) .execute() .await - .map(|response| response.parsed_body().unwrap()) + .map(|_| ()) } diff --git a/nexus/tests/integration_tests/updates.rs b/nexus/tests/integration_tests/updates.rs index 6dd24a79891..86d5fe3d9c2 100644 --- a/nexus/tests/integration_tests/updates.rs +++ b/nexus/tests/integration_tests/updates.rs @@ -14,8 +14,10 @@ use nexus_test_utils::background::activate_background_task; use nexus_test_utils::background::run_tuf_artifact_replication_step; use nexus_test_utils::background::wait_tuf_artifact_replication_step; use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; +use nexus_test_utils::resource_helpers::object_get; use nexus_test_utils::test_setup; use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::views; use nexus_types::external_api::views::UpdatesTrustRoot; use omicron_common::api::external::{ TufRepoGetResponse, TufRepoInsertResponse, TufRepoInsertStatus, @@ -27,11 +29,14 @@ use std::collections::HashSet; use std::fmt::Debug; use tough::editor::signed::SignedRole; use tough::schema::Root; +use tufaceous_artifact::ArtifactVersion; use tufaceous_artifact::KnownArtifactKind; use tufaceous_lib::Key; use tufaceous_lib::assemble::{ArtifactManifest, OmicronRepoAssembler}; use tufaceous_lib::assemble::{DeserializedManifest, ManifestTweak}; +use crate::integration_tests::target_release::set_target_release; + const TRUST_ROOTS_URL: &str = "/v1/system/update/trust-roots"; type ControlPlaneTestContext = @@ -699,6 +704,89 @@ async fn test_trust_root_operations(cptestctx: &ControlPlaneTestContext) { assert!(response.items.is_empty()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_update_status() -> Result<()> { + let cptestctx = + test_setup::("test_update_uninitialized", 0) + .await; + let client = &cptestctx.external_client; + let logctx = &cptestctx.logctx; + + // initial status + let status: views::UpdateStatus = + object_get(client, "/v1/system/update/status").await; + assert_eq!(status.target_release.0, None); + // does not start suspended because the DB migration initialized the + // target_release table with a row with gen 1, and the initial target + // blueprint also has gen 1 + assert!(!status.suspended); + + let counts = status.components_by_release_version; + assert_eq!(counts.get("install dataset").unwrap(), &7); + assert_eq!(counts.get("unknown").unwrap(), &15); + + // hold onto this to compare it to later values + let time_last_step_planned = status.time_last_step_planned; + + // Upload a fake TUF repo and set it as the target release + let trust_root = TestTrustRoot::generate().await?; + trust_root.to_upload_request(client, StatusCode::CREATED).execute().await?; + trust_root + .assemble_repo(&logctx.log, &[]) + .await? + .to_upload_request(client, StatusCode::OK) + .execute() + .await?; + let v1 = Version::new(1, 0, 0); + set_target_release(client, &v1).await?; + + let status: views::UpdateStatus = + object_get(client, "/v1/system/update/status").await; + assert_eq!(status.target_release.0.unwrap().version, v1); + assert!(!status.suspended, "should not be suspended after setting v1"); + + // blueprint time doesn't change + assert_eq!(time_last_step_planned, status.time_last_step_planned); + + let counts = status.components_by_release_version; + assert_eq!(counts.get("install dataset").unwrap(), &7); + assert_eq!(counts.get("unknown").unwrap(), &15); + + // do it again so there are two, so both versions are associated with tuf repos + let v2 = Version::new(2, 0, 0); + let tweaks = &[ + ManifestTweak::SystemVersion(v2.clone()), + ManifestTweak::ArtifactVersion { + kind: KnownArtifactKind::SwitchRotBootloader, + version: ArtifactVersion::new("non-semver-2").unwrap(), + }, + ]; + let trust_root = TestTrustRoot::generate().await?; + trust_root.to_upload_request(client, StatusCode::CREATED).execute().await?; + trust_root + .assemble_repo(&logctx.log, tweaks) + .await? + .to_upload_request(client, StatusCode::OK) + .execute() + .await?; + set_target_release(client, &v2).await?; + + let status: views::UpdateStatus = + object_get(client, "/v1/system/update/status").await; + + assert_eq!(status.target_release.0.unwrap().version, v2); + assert!(!status.suspended, "should not be suspended after setting v2"); + + // blueprint time doesn't change + assert_eq!(time_last_step_planned, status.time_last_step_planned); + + let counts = status.components_by_release_version; + assert_eq!(counts.get("install dataset").unwrap(), &7); + assert_eq!(counts.get("unknown").unwrap(), &15); + + cptestctx.teardown().await; + Ok(()) +} #[nexus_test] async fn test_repo_prune(cptestctx: &ControlPlaneTestContext) { let logctx = &cptestctx.logctx; diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 321b29bd12d..82ff8471f7c 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -17,7 +17,7 @@ pub use omicron_common::api::external::IpVersion; use omicron_common::api::external::{ AffinityPolicy, AllowedSourceIps as ExternalAllowedSourceIps, ByteCount, Digest, Error, FailureDomain, IdentityMetadata, InstanceState, Name, - ObjectIdentity, SimpleIdentity, SimpleIdentityOrName, + Nullable, ObjectIdentity, SimpleIdentity, SimpleIdentityOrName, }; use omicron_uuid_kinds::*; use oxnet::{Ipv4Net, Ipv6Net}; @@ -1535,28 +1535,14 @@ pub struct AlertProbeResult { // UPDATE -/// Source of a system software target release. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum TargetReleaseSource { - /// Unspecified or unknown source (probably MUPdate). - Unspecified, - - /// The specified release of the rack's system software. - SystemVersion { version: Version }, -} - -/// View of a system software target release. +/// View of a system software target release #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, JsonSchema)] pub struct TargetRelease { - /// The target-release generation number. - pub generation: i64, - - /// The time it was set as the target release. + /// Time this was set as the target release pub time_requested: DateTime, - /// The source of the target release. - pub release_source: TargetReleaseSource, + /// The specified release of the rack's system software + pub version: Version, } /// Trusted root role used by the update system to verify update repositories. @@ -1571,6 +1557,45 @@ pub struct UpdatesTrustRoot { pub root_role: TufSignedRootRole, } +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct UpdateStatus { + /// Current target release of the system software + /// + /// This may not correspond to the actual system software running + /// at the time of request; it is instead the release that the system + /// should be moving towards as a goal state. The system asynchronously + /// updates software to match this target release. + /// + /// Will only be null if a target release has never been set. In that case, + /// the system is not automatically attempting to manage software versions. + pub target_release: Nullable, + + /// Count of components running each release version + /// + /// Keys will be either: + /// + /// * Semver-like release version strings + /// * "install dataset", representing the initial rack software before + /// any updates + /// * "unknown", which means there is no TUF repo uploaded that matches + /// the software running on the component) + pub components_by_release_version: BTreeMap, + + /// Time of most recent update planning activity + /// + /// This is intended as a rough indicator of the last time something + /// happened in the update planner. + pub time_last_step_planned: DateTime, + + /// Whether automatic update is suspended due to manual update activity + /// + /// After a manual support procedure that changes the system software, + /// automatic update activity is suspended to avoid undoing the change. To + /// resume automatic update, first upload the TUF repository matching the + /// manually applied update, then set that as the target release. + pub suspended: bool, +} + fn expected_one_of() -> String { use std::fmt::Write; let mut msg = "expected one of:".to_string(); diff --git a/openapi/nexus.json b/openapi/nexus.json index d88de5977b9..be1d26e0b26 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -10992,21 +10992,21 @@ } } }, - "/v1/system/update/target-release": { + "/v1/system/update/status": { "get": { "tags": [ "system/update" ], - "summary": "Get the current target release of the rack's system software", - "description": "This may not correspond to the actual software running on the rack at the time of request; it is instead the release that the rack reconfigurator should be moving towards as a goal state. After some number of planning and execution phases, the software running on the rack should eventually correspond to the release described here.", - "operationId": "target_release_view", + "summary": "Fetch system update status", + "description": "Returns information about the current target release and the progress of system software updates.", + "operationId": "system_update_status", "responses": { "200": { "description": "successful operation", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TargetRelease" + "$ref": "#/components/schemas/UpdateStatus" } } } @@ -11018,13 +11018,15 @@ "$ref": "#/components/responses/Error" } } - }, + } + }, + "/v1/system/update/target-release": { "put": { "tags": [ "system/update" ], - "summary": "Set the current target release of the rack's system software", - "description": "The rack reconfigurator will treat the software specified here as a goal state for the rack's software, and attempt to asynchronously update to that release.", + "summary": "Set target release", + "description": "Set the current target release of the rack's system software. The rack reconfigurator will treat the software specified here as a goal state for the rack's software, and attempt to asynchronously update to that release. Use the update status endpoint to view the current target release.", "operationId": "target_release_update", "requestBody": { "content": { @@ -11037,15 +11039,8 @@ "required": true }, "responses": { - "201": { - "description": "successful creation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TargetRelease" - } - } - } + "204": { + "description": "resource updated" }, "4XX": { "$ref": "#/components/responses/Error" @@ -25687,72 +25682,23 @@ ] }, "TargetRelease": { - "description": "View of a system software target release.", + "description": "View of a system software target release", "type": "object", "properties": { - "generation": { - "description": "The target-release generation number.", - "type": "integer", - "format": "int64" - }, - "release_source": { - "description": "The source of the target release.", - "allOf": [ - { - "$ref": "#/components/schemas/TargetReleaseSource" - } - ] - }, "time_requested": { - "description": "The time it was set as the target release.", + "description": "Time this was set as the target release", "type": "string", "format": "date-time" + }, + "version": { + "description": "The specified release of the rack's system software", + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" } }, "required": [ - "generation", - "release_source", - "time_requested" - ] - }, - "TargetReleaseSource": { - "description": "Source of a system software target release.", - "oneOf": [ - { - "description": "Unspecified or unknown source (probably MUPdate).", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "unspecified" - ] - } - }, - "required": [ - "type" - ] - }, - { - "description": "The specified release of the rack's system software.", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "system_version" - ] - }, - "version": { - "type": "string", - "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" - } - }, - "required": [ - "type", - "version" - ] - } + "time_requested", + "version" ] }, "Timeseries": { @@ -26216,6 +26162,44 @@ } ] }, + "UpdateStatus": { + "type": "object", + "properties": { + "components_by_release_version": { + "description": "Count of components running each release version\n\nKeys will be either:\n\n* Semver-like release version strings * \"install dataset\", representing the initial rack software before any updates * \"unknown\", which means there is no TUF repo uploaded that matches the software running on the component)", + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "suspended": { + "description": "Whether automatic update is suspended due to manual update activity\n\nAfter a manual support procedure that changes the system software, automatic update activity is suspended to avoid undoing the change. To resume automatic update, first upload the TUF repository matching the manually applied update, then set that as the target release.", + "type": "boolean" + }, + "target_release": { + "nullable": true, + "description": "Current target release of the system software\n\nThis may not correspond to the actual system software running at the time of request; it is instead the release that the system should be moving towards as a goal state. The system asynchronously updates software to match this target release.\n\nWill only be null if a target release has never been set. In that case, the system is not automatically attempting to manage software versions.", + "allOf": [ + { + "$ref": "#/components/schemas/TargetRelease" + } + ] + }, + "time_last_step_planned": { + "description": "Time of most recent update planning activity\n\nThis is intended as a rough indicator of the last time something happened in the update planner.", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "components_by_release_version", + "suspended", + "target_release", + "time_last_step_planned" + ] + }, "UpdatesTrustRoot": { "description": "Trusted root role used by the update system to verify update repositories.", "type": "object",