diff --git a/Cargo.lock b/Cargo.lock index cd6cc92295..0ea86b81e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5920,6 +5920,7 @@ dependencies = [ "term", "thiserror 1.0.69", "tokio", + "tufaceous-artifact", "url", "usdt", "uuid", diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 6b55a0e31d..5823118c29 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -3091,10 +3091,10 @@ pub enum BfdMode { /// A description of an uploaded TUF repository. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] pub struct TufRepoDescription { - // Information about the repository. + /// Information about the repository. pub repo: TufRepoMeta, - // Information about the artifacts present in the repository. + /// Information about the artifacts present in the repository. pub artifacts: Vec, } @@ -3107,7 +3107,7 @@ impl TufRepoDescription { /// Metadata about a TUF repository. /// -/// Found within a [`TufRepoDescription`]. +/// Found within a `TufRepoDescription`. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] pub struct TufRepoMeta { /// The hash of the repository. @@ -3136,7 +3136,7 @@ pub struct TufRepoMeta { /// Metadata about an individual TUF artifact. /// -/// Found within a [`TufRepoDescription`]. +/// Found within a `TufRepoDescription`. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] pub struct TufArtifactMeta { /// The artifact ID. @@ -3162,7 +3162,7 @@ pub struct TufRepoInsertResponse { /// Status of a TUF repo import. /// -/// Part of [`TufRepoInsertResponse`]. +/// Part of `TufRepoInsertResponse`. #[derive( Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, )] diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index b66990049b..e833d1efcd 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -668,6 +668,49 @@ impl AuthorizedResource for SiloUserList { } } +/// System software target version configuration +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TargetReleaseConfig; + +pub const TARGET_RELEASE_CONFIG: TargetReleaseConfig = TargetReleaseConfig; + +impl oso::PolarClass for TargetReleaseConfig { + fn get_polar_class_builder() -> oso::ClassBuilder { + oso::Class::builder() + .with_equality_check() + .add_attribute_getter("fleet", |_: &TargetReleaseConfig| FLEET) + } +} + +impl AuthorizedResource for TargetReleaseConfig { + fn load_roles<'fut>( + &'fut self, + opctx: &'fut OpContext, + authn: &'fut authn::Context, + roleset: &'fut mut RoleSet, + ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { + // There are no roles on the TargetReleaseConfig, only permissions. But we + // still need to load the Fleet-related roles to verify that the actor + // has the "admin" role on the Fleet (possibly conferred from a Silo + // role). + load_roles_for_resource_tree(&FLEET, opctx, authn, roleset).boxed() + } + + fn on_unauthorized( + &self, + _: &Authz, + error: Error, + _: AnyActor, + _: Action, + ) -> Error { + error + } + + fn polar_class(&self) -> oso::Class { + Self::get_polar_class() + } +} + // Main resource hierarchy: Projects and their resources authz_resource! { diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index f9382401fd..c1e463e9bf 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -383,6 +383,20 @@ resource BlueprintConfig { has_relation(fleet: Fleet, "parent_fleet", list: BlueprintConfig) if list.fleet = fleet; +# Describes the policy for accessing blueprints +resource TargetReleaseConfig { + permissions = [ + "read", # read the current target release + "modify", # change the current target release + ]; + + relations = { parent_fleet: Fleet }; + "read" if "viewer" on "parent_fleet"; + "modify" if "admin" on "parent_fleet"; +} +has_relation(fleet: Fleet, "parent_fleet", resource: TargetReleaseConfig) + if resource.fleet = fleet; + # Describes the policy for reading and modifying low-level inventory resource Inventory { permissions = [ "read", "modify" ]; diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index 805b846301..cef3364221 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -114,6 +114,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { SiloCertificateList::get_polar_class(), SiloIdentityProviderList::get_polar_class(), SiloUserList::get_polar_class(), + TargetReleaseConfig::get_polar_class(), ]; for c in classes { oso_builder = oso_builder.register_class(c)?; diff --git a/nexus/db-model/src/lib.rs b/nexus/db-model/src/lib.rs index e242e454e7..5323c17171 100644 --- a/nexus/db-model/src/lib.rs +++ b/nexus/db-model/src/lib.rs @@ -63,6 +63,7 @@ mod rendezvous_debug_dataset; mod semver_version; mod switch_interface; mod switch_port; +mod target_release; mod v2p_mapping; mod vmm_state; // These actually represent subqueries, not real table. @@ -211,6 +212,7 @@ pub use support_bundle::*; pub use switch::*; pub use switch_interface::*; pub use switch_port::*; +pub use target_release::*; pub use tuf_repo::*; pub use typed_uuid::to_db_typed_uuid; pub use upstairs_repair::*; diff --git a/nexus/db-model/src/schema.rs b/nexus/db-model/src/schema.rs index 55d7b1f9d5..dbbd27a8df 100644 --- a/nexus/db-model/src/schema.rs +++ b/nexus/db-model/src/schema.rs @@ -1411,6 +1411,15 @@ allow_tables_to_appear_in_same_query!( joinable!(tuf_repo_artifact -> tuf_repo (tuf_repo_id)); joinable!(tuf_repo_artifact -> tuf_artifact (tuf_artifact_id)); +table! { + target_release (generation) { + generation -> Int8, + time_requested -> Timestamptz, + release_source -> crate::TargetReleaseSourceEnum, + tuf_repo_id -> Nullable, + } +} + table! { support_bundle { id -> Uuid, diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index a4654d9021..99059ded94 100644 --- a/nexus/db-model/src/schema_versions.rs +++ b/nexus/db-model/src/schema_versions.rs @@ -16,7 +16,7 @@ use std::{collections::BTreeMap, sync::LazyLock}; /// /// This must be updated when you change the database schema. Refer to /// schema/crdb/README.adoc in the root of this repository for details. -pub const SCHEMA_VERSION: Version = Version::new(128, 0, 0); +pub const SCHEMA_VERSION: Version = Version::new(129, 0, 0); /// List of all past database schema versions, in *reverse* order /// @@ -28,6 +28,7 @@ static KNOWN_VERSIONS: LazyLock> = LazyLock::new(|| { // | leaving the first copy as an example for the next person. // v // KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"), + KnownVersion::new(129, "create-target-release"), KnownVersion::new(128, "sled-resource-for-vmm"), KnownVersion::new(127, "bp-disk-disposition-expunged-cleanup"), KnownVersion::new(126, "affinity"), diff --git a/nexus/db-model/src/target_release.rs b/nexus/db-model/src/target_release.rs new file mode 100644 index 0000000000..aaac40e850 --- /dev/null +++ b/nexus/db-model/src/target_release.rs @@ -0,0 +1,77 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use super::{Generation, impl_enum_type}; +use crate::schema::target_release; +use crate::typed_uuid::DbTypedUuid; +use chrono::{DateTime, Utc}; +use nexus_types::external_api::views; +use omicron_uuid_kinds::TufRepoKind; + +impl_enum_type!( + #[derive(SqlType, Debug, QueryId)] + #[diesel(postgres_type(name = "target_release_source", schema = "public"))] + pub struct TargetReleaseSourceEnum; + + /// The source of the software release that should be deployed to the rack. + #[derive(Copy, Clone, Debug, AsExpression, FromSqlRow, PartialEq, Eq, Hash)] + #[diesel(sql_type = TargetReleaseSourceEnum)] + pub enum TargetReleaseSource; + + Unspecified => b"unspecified" + SystemVersion => b"system_version" +); + +/// Specify the target system software release. +#[derive(Clone, Debug, Insertable, Queryable, Selectable)] +#[diesel(table_name = target_release)] +pub struct TargetRelease { + /// Each change to the target release is recorded as a row with + /// a monotonically increasing generation number. The row with + /// the largest generation is the current target release. + pub generation: Generation, + + /// The time at which this target release was requested. + pub time_requested: DateTime, + + /// The source of the target release. + pub release_source: TargetReleaseSource, + + /// The TUF repo containing the target release. + pub tuf_repo_id: Option>, +} + +impl TargetRelease { + pub fn new_unspecified(prev: &TargetRelease) -> Self { + Self { + generation: Generation(prev.generation.next()), + time_requested: Utc::now(), + release_source: TargetReleaseSource::Unspecified, + tuf_repo_id: None, + } + } + + pub fn new_system_version( + prev: &TargetRelease, + tuf_repo_id: DbTypedUuid, + ) -> Self { + Self { + generation: Generation(prev.generation.next()), + time_requested: Utc::now(), + release_source: TargetReleaseSource::SystemVersion, + 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/Cargo.toml b/nexus/db-queries/Cargo.toml index cdb199f502..0ddc32cf82 100644 --- a/nexus/db-queries/Cargo.toml +++ b/nexus/db-queries/Cargo.toml @@ -48,6 +48,7 @@ strum.workspace = true swrite.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["full"] } +tufaceous-artifact.workspace = true url.workspace = true usdt.workspace = true uuid.workspace = true diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index 12b510dc51..927526695f 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -99,6 +99,7 @@ mod support_bundle; mod switch; mod switch_interface; mod switch_port; +mod target_release; #[cfg(test)] pub(crate) mod test_utils; mod update; diff --git a/nexus/db-queries/src/db/datastore/target_release.rs b/nexus/db-queries/src/db/datastore/target_release.rs new file mode 100644 index 0000000000..3f156df3f3 --- /dev/null +++ b/nexus/db-queries/src/db/datastore/target_release.rs @@ -0,0 +1,240 @@ +// 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/. + +//! [`DataStore`] methods to get/set the current [`TargetRelease`]. + +use super::DataStore; +use crate::authz; +use crate::context::OpContext; +use crate::db::error::{ErrorHandler, public_error_from_diesel}; +use crate::db::model::{SemverVersion, TargetRelease, TargetReleaseSource}; +use crate::db::schema::target_release::dsl; +use async_bb8_diesel::AsyncRunQueryDsl as _; +use diesel::insert_into; +use diesel::prelude::*; +use nexus_types::external_api::views; +use omicron_common::api::external::{CreateResult, Error, LookupResult}; + +impl DataStore { + /// Fetch the current target release, i.e., the row with the largest + /// generation number. + pub async fn target_release_get_current( + &self, + opctx: &OpContext, + ) -> LookupResult { + opctx + .authorize(authz::Action::Read, &authz::TARGET_RELEASE_CONFIG) + .await?; + let conn = self.pool_connection_authorized(opctx).await?; + let current = dsl::target_release + .select(TargetRelease::as_select()) + .order_by(dsl::generation.desc()) + .first_async(&*conn) + .await + .optional() + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + // We expect there to always be a current target release, + // since the database migration `create-target-release/up3.sql` + // adds an initial row. + let current = current + .ok_or_else(|| Error::internal_error("no target release"))?; + + Ok(current) + } + + /// Insert a new target release row and return it. It will only become + /// the current target release if its generation is larger than any + /// existing row. + pub async fn target_release_insert( + &self, + opctx: &OpContext, + target_release: TargetRelease, + ) -> CreateResult { + opctx + .authorize(authz::Action::Modify, &authz::TARGET_RELEASE_CONFIG) + .await?; + let conn = self.pool_connection_authorized(opctx).await?; + insert_into(dsl::target_release) + .values(target_release) + .returning(TargetRelease::as_returning()) + .get_result_async(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + + /// 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 crate::db::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)) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::db::model::{ + ArtifactHash, Generation, SemverVersion, TargetReleaseSource, + TufArtifact, TufRepo, TufRepoDescription, + }; + use crate::db::pub_test_utils::TestDatabase; + use chrono::{TimeDelta, Utc}; + use omicron_common::update::ArtifactId; + use omicron_test_utils::dev; + use tufaceous_artifact::ArtifactKind; + + #[tokio::test] + async fn target_release_datastore() { + let logctx = dev::test_setup_log("target_release_datastore"); + let db = TestDatabase::new_with_datastore(&logctx.log).await; + let (opctx, datastore) = (db.opctx(), db.datastore()); + + // There should always be a target release. + // This is ensured by the schema migration. + let initial_target_release = datastore + .target_release_get_current(opctx) + .await + .expect("should be a target release"); + assert_eq!(initial_target_release.generation, Generation(1.into())); + assert!(initial_target_release.time_requested < Utc::now()); + assert_eq!( + initial_target_release.release_source, + TargetReleaseSource::Unspecified + ); + assert!(initial_target_release.tuf_repo_id.is_none()); + + // We should be able to set a new generation just like the first. + // We allow some slack in the timestamp comparison because the + // database only stores timestamps with μsec precision. + let target_release = + TargetRelease::new_unspecified(&initial_target_release); + let target_release = datastore + .target_release_insert(opctx, target_release) + .await + .unwrap(); + assert_eq!(target_release.generation, Generation(2.into())); + assert!( + (target_release.time_requested - target_release.time_requested) + .abs() + < TimeDelta::new(0, 1_000).expect("1 μsec") + ); + assert!(target_release.tuf_repo_id.is_none()); + + // Trying to reuse a generation should fail. + assert!( + datastore + .target_release_insert( + opctx, + TargetRelease::new_unspecified(&initial_target_release), + ) + .await + .is_err() + ); + + // But inserting a new unspecified target should be fine. + let target_release = datastore + .target_release_insert( + opctx, + TargetRelease::new_unspecified(&target_release), + ) + .await + .unwrap(); + assert_eq!(target_release.generation, Generation(3.into())); + + // Now add a new TUF repo and use it as the source. + let version = SemverVersion::new(0, 0, 1); + let hash = ArtifactHash( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .parse() + .expect("SHA256('')"), + ); + let repo = datastore + .update_tuf_repo_insert( + opctx, + TufRepoDescription { + repo: TufRepo::new( + hash, + 0, + Utc::now(), + version.clone(), + String::from(""), + ), + artifacts: vec![TufArtifact::new( + ArtifactId { + name: String::from(""), + version: version.clone().into(), + kind: ArtifactKind::from_static("empty"), + }, + hash, + 0, + )], + }, + ) + .await + .unwrap() + .recorded + .repo; + assert_eq!(repo.system_version, version); + let tuf_repo_id = repo.id; + + let before = Utc::now(); + let target_release = datastore + .target_release_insert( + opctx, + TargetRelease::new_system_version(&target_release, tuf_repo_id), + ) + .await + .unwrap(); + let after = Utc::now(); + assert_eq!(target_release.generation, Generation(4.into())); + assert!(target_release.time_requested >= before); + assert!(target_release.time_requested <= after); + assert_eq!( + target_release.release_source, + TargetReleaseSource::SystemVersion + ); + assert_eq!(target_release.tuf_repo_id, Some(tuf_repo_id)); + + // Clean up. + db.terminate().await; + logctx.cleanup_successful(); + } +} diff --git a/nexus/db-queries/src/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index 3c596937e3..3d5ea068ca 100644 --- a/nexus/db-queries/src/policy_test/resource_builder.rs +++ b/nexus/db-queries/src/policy_test/resource_builder.rs @@ -287,6 +287,7 @@ impl_dyn_authorized_resource_for_global!(authz::DeviceAuthRequestList); impl_dyn_authorized_resource_for_global!(authz::DnsConfig); impl_dyn_authorized_resource_for_global!(authz::IpPoolList); impl_dyn_authorized_resource_for_global!(authz::Inventory); +impl_dyn_authorized_resource_for_global!(authz::TargetReleaseConfig); impl DynAuthorizedResource for authz::SiloCertificateList { fn do_authorize<'a, 'b>( diff --git a/nexus/db-queries/src/policy_test/resources.rs b/nexus/db-queries/src/policy_test/resources.rs index 0465541053..b069d1df2a 100644 --- a/nexus/db-queries/src/policy_test/resources.rs +++ b/nexus/db-queries/src/policy_test/resources.rs @@ -73,6 +73,7 @@ pub async fn make_resources( builder.new_resource(authz::DEVICE_AUTH_REQUEST_LIST); builder.new_resource(authz::INVENTORY); builder.new_resource(authz::IP_POOL_LIST); + builder.new_resource(authz::TARGET_RELEASE_CONFIG); // Silo/organization/project hierarchy make_silo(&mut builder, "silo1", main_silo_id, true).await; diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index e0d43250d1..6ff2685369 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -110,6 +110,20 @@ resource: authz::IpPoolList silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: authz::TargetReleaseConfig + + 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: Silo "silo1" USER Q R LC RP M MP CC D diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 59ba14e5fb..834675fc48 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -60,6 +60,8 @@ support_bundle_head_file HEAD /experimental/v1/system/suppor support_bundle_index GET /experimental/v1/system/support-bundles/{support_bundle}/index support_bundle_list GET /experimental/v1/system/support-bundles support_bundle_view GET /experimental/v1/system/support-bundles/{support_bundle} +target_release_get GET /v1/system/update/target-release +target_release_set PUT /v1/system/update/target-release timeseries_query POST /v1/timeseries/query API operations found with tag "images" diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index e163cf0bc3..ede487574b 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -2887,6 +2887,37 @@ pub trait NexusExternalApi { path_params: Path, ) -> Result, HttpError>; + /// Get the current target release of the rack's system software + /// + /// 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. + #[endpoint { + method = GET, + path = "/v1/system/update/target-release", + tags = ["hidden"], // "system/update" + }] + async fn target_release_get( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// 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. + #[endpoint { + method = PUT, + path = "/v1/system/update/target-release", + tags = ["hidden"], // "system/update" + }] + async fn target_release_set( + rqctx: RequestContext, + params: TypedBody, + ) -> Result, HttpError>; + // Silo users /// List users diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 2084ee4782..e52242e1cb 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6407,6 +6407,72 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn target_release_get( + 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_set( + rqctx: RequestContext, + body: TypedBody, + ) -> 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 params = body.into_inner(); + let system_version = params.system_version; + let tuf_repo_id = nexus + .datastore() + .update_tuf_repo_get(&opctx, system_version.into()) + .await? + .repo + .id; + let current_target_release = + nexus.datastore().target_release_get_current(&opctx).await?; + let next_target_release = + nexus_db_model::TargetRelease::new_system_version( + ¤t_target_release, + tuf_repo_id, + ); + let target_release = nexus + .datastore() + .target_release_insert(&opctx, next_target_release) + .await?; + Ok(HttpResponseCreated( + nexus + .datastore() + .target_release_view(&opctx, &target_release) + .await?, + )) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + // Silo users async fn user_list( diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index ee685589cc..a606a0c94c 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -38,6 +38,7 @@ use omicron_common::api::external::RouteTarget; use omicron_common::api::external::UserId; use omicron_common::api::external::VpcFirewallRuleUpdateParams; use omicron_test_utils::certificates::CertificateChain; +use semver::Version; use std::net::IpAddr; use std::net::Ipv4Addr; use std::str::FromStr; @@ -1137,6 +1138,12 @@ pub static ALLOW_LIST_UPDATE: LazyLock = allowed_ips: AllowedSourceIps::Any, }); +// Updates +pub static DEMO_TARGET_RELEASE: LazyLock = + LazyLock::new(|| params::SetTargetReleaseParams { + system_version: Version::new(0, 0, 0), + }); + /// Describes an API endpoint to be verified by the "unauthorized" test /// /// These structs are also used to check whether we're covering all endpoints in @@ -2350,6 +2357,17 @@ pub static VERIFY_ENDPOINTS: LazyLock> = // privileged users. That is captured by GetUnimplemented. allowed_methods: vec![AllowedMethod::GetUnimplemented], }, + VerifyEndpoint { + url: "/v1/system/update/target-release", + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Put( + serde_json::to_value(&*DEMO_TARGET_RELEASE).unwrap(), + ), + ], + }, /* Metrics */ VerifyEndpoint { url: &DEMO_SYSTEM_METRICS_URL, diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 0274f884a7..a315adbe3b 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -51,6 +51,7 @@ mod ssh_keys; mod subnet_allocation; mod support_bundles; mod switch_port; +mod target_release; mod unauthorized; mod unauthorized_coverage; mod updates; diff --git a/nexus/tests/integration_tests/target_release.rs b/nexus/tests/integration_tests/target_release.rs new file mode 100644 index 0000000000..abcd63fb9b --- /dev/null +++ b/nexus/tests/integration_tests/target_release.rs @@ -0,0 +1,136 @@ +// 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/. + +//! Get/set the target release via the external API. + +use camino_tempfile::Utf8TempDir; +use chrono::Utc; +use clap::Parser as _; +use dropshot::test_util::LogContext; +use http::StatusCode; +use http::method::Method; +use nexus_config::UpdatesConfig; +use nexus_test_utils::http_testing::AuthnMode; +use nexus_test_utils::http_testing::{NexusRequest, RequestBuilder}; +use nexus_test_utils::load_test_config; +use nexus_test_utils::test_setup_with_config; +use nexus_types::external_api::params::SetTargetReleaseParams; +use nexus_types::external_api::views::{TargetRelease, TargetReleaseSource}; +use omicron_common::api::external::TufRepoInsertResponse; +use omicron_sled_agent::sim; +use semver::Version; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_set_target_release() -> anyhow::Result<()> { + let mut config = load_test_config(); + config.pkg.updates = Some(UpdatesConfig { + // XXX: This is currently not used by the update system, but + // trusted_root will become meaningful in the future. + trusted_root: "does-not-exist.json".into(), + }); + let ctx = test_setup_with_config::( + "test_update_uninitialized", + &mut config, + sim::SimMode::Explicit, + None, + 0, + ) + .await; + let client = &ctx.external_client; + + // 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); + + // Attempting to set an invalid system version should fail. + let system_version = Version::new(0, 0, 0); + NexusRequest::object_put( + client, + "/v1/system/update/target-release", + Some(&SetTargetReleaseParams { system_version }), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect_err("invalid TUF repo"); + + // Adding a fake (tufaceous) repo and then setting it as the + // target release should succeed. + let before = Utc::now(); + let system_version = Version::new(1, 0, 0); + let logctx = LogContext::new("get_set_target_release", &config.pkg.log); + let temp = Utf8TempDir::new().unwrap(); + let path = temp.path().join("repo.zip"); + tufaceous::Args::try_parse_from([ + "tufaceous", + "assemble", + "../update-common/manifests/fake.toml", + path.as_str(), + ]) + .expect("can't parse tufaceous args") + .exec(&logctx.log) + .await + .expect("can't assemble TUF repo"); + + assert_eq!( + system_version, + NexusRequest::new( + RequestBuilder::new( + client, + http::Method::PUT, + "/v1/system/update/repository?file_name=/tmp/foo.zip", + ) + .body_file(Some(&path)) + .expect_status(Some(http::StatusCode::OK)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body::() + .unwrap() + .recorded + .repo + .system_version + ); + + let target_release: TargetRelease = NexusRequest::new( + RequestBuilder::new( + client, + Method::PUT, + "/v1/system/update/target-release", + ) + .body(Some(&SetTargetReleaseParams { + system_version: system_version.clone(), + })) + .expect_status(Some(StatusCode::CREATED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .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 }, + ); + + ctx.teardown().await; + logctx.cleanup_successful(); + Ok(()) +} diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 9183f13104..90f5e171d5 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -2232,13 +2232,19 @@ pub struct UpdatesPutRepositoryParams { } /// Parameters for GET requests for `/v1/system/update/repository`. - #[derive(Clone, Debug, Deserialize, JsonSchema)] pub struct UpdatesGetRepositoryParams { /// The version to get. pub system_version: Version, } +/// Parameters for PUT requests to `/v1/system/update/target-release`. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct SetTargetReleaseParams { + /// Version of the system software to make the target release. + pub system_version: Version, +} + // Probes /// Create time parameters for probes. diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 02aaffd3cc..6d8e232f12 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -19,6 +19,7 @@ use omicron_common::api::external::{ }; use oxnet::{Ipv4Net, Ipv6Net}; use schemars::JsonSchema; +use semver::Version; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -1046,3 +1047,29 @@ pub struct OxqlQueryResult { /// Tables resulting from the query, each containing timeseries. pub tables: Vec, } + +// 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. +#[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. + pub time_requested: DateTime, + + /// The source of the target release. + pub release_source: TargetReleaseSource, +} diff --git a/openapi/nexus.json b/openapi/nexus.json index 5ee6278159..326765045a 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -10057,6 +10057,70 @@ } } }, + "/v1/system/update/target-release": { + "get": { + "tags": [ + "hidden" + ], + "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_get", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TargetRelease" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "tags": [ + "hidden" + ], + "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.", + "operationId": "target_release_set", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetTargetReleaseParams" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TargetRelease" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/system/users": { "get": { "tags": [ @@ -20989,6 +21053,20 @@ } ] }, + "SetTargetReleaseParams": { + "description": "Parameters for PUT requests to `/v1/system/update/target-release`.", + "type": "object", + "properties": { + "system_version": { + "description": "Version of the system software to make the target release.", + "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": [ + "system_version" + ] + }, "Silo": { "description": "View of a Silo\n\nA Silo is the highest level unit of isolation.", "type": "object", @@ -22895,6 +22973,75 @@ "timeseries" ] }, + "TargetRelease": { + "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.", + "type": "string", + "format": "date-time" + } + }, + "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" + ] + } + ] + }, "Timeseries": { "description": "A timeseries contains a timestamped set of values from one source.\n\nThis includes the typed key-value pairs that uniquely identify it, and the set of timestamps and data values from it.", "type": "object", diff --git a/schema/crdb/create-target-release/up1.sql b/schema/crdb/create-target-release/up1.sql new file mode 100644 index 0000000000..12a1c3f2b6 --- /dev/null +++ b/schema/crdb/create-target-release/up1.sql @@ -0,0 +1,5 @@ +-- The source of the software release that should be deployed to the rack. +CREATE TYPE IF NOT EXISTS omicron.public.target_release_source AS ENUM ( + 'unspecified', + 'system_version' +); diff --git a/schema/crdb/create-target-release/up2.sql b/schema/crdb/create-target-release/up2.sql new file mode 100644 index 0000000000..5e22aec5ef --- /dev/null +++ b/schema/crdb/create-target-release/up2.sql @@ -0,0 +1,12 @@ +-- Software releases that should be/have been deployed to the rack. The +-- current target release is the one with the largest generation number. +CREATE TABLE IF NOT EXISTS omicron.public.target_release ( + generation INT8 NOT NULL PRIMARY KEY, + time_requested TIMESTAMPTZ NOT NULL, + release_source omicron.public.target_release_source NOT NULL, + tuf_repo_id UUID, -- "foreign key" into the `tuf_repo` table + CONSTRAINT tuf_repo_for_system_version CHECK ( + (release_source != 'system_version' AND tuf_repo_id IS NULL) OR + (release_source = 'system_version' AND tuf_repo_id IS NOT NULL) + ) +); diff --git a/schema/crdb/create-target-release/up3.sql b/schema/crdb/create-target-release/up3.sql new file mode 100644 index 0000000000..98aefd8f88 --- /dev/null +++ b/schema/crdb/create-target-release/up3.sql @@ -0,0 +1,12 @@ +-- System software is by default from the `install` dataset. +INSERT INTO omicron.public.target_release ( + generation, + time_requested, + release_source, + tuf_repo_id +) VALUES ( + 1, + NOW(), + 'unspecified', + NULL +) ON CONFLICT DO NOTHING; diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index 1039b634b5..217f21b4f0 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -2410,6 +2410,40 @@ CREATE TABLE IF NOT EXISTS omicron.public.tuf_repo_artifact ( /*******************************************************************/ +-- The source of the software release that should be deployed to the rack. +CREATE TYPE IF NOT EXISTS omicron.public.target_release_source AS ENUM ( + 'unspecified', + 'system_version' +); + +-- Software releases that should be/have been deployed to the rack. The +-- current target release is the one with the largest generation number. +CREATE TABLE IF NOT EXISTS omicron.public.target_release ( + generation INT8 NOT NULL PRIMARY KEY, + time_requested TIMESTAMPTZ NOT NULL, + release_source omicron.public.target_release_source NOT NULL, + tuf_repo_id UUID, -- "foreign key" into the `tuf_repo` table + CONSTRAINT tuf_repo_for_system_version CHECK ( + (release_source != 'system_version' AND tuf_repo_id IS NULL) OR + (release_source = 'system_version' AND tuf_repo_id IS NOT NULL) + ) +); + +-- System software is by default from the `install` dataset. +INSERT INTO omicron.public.target_release ( + generation, + time_requested, + release_source, + tuf_repo_id +) VALUES ( + 1, + NOW(), + 'unspecified', + NULL +) ON CONFLICT DO NOTHING; + +/*******************************************************************/ + /* * Support Bundles */ @@ -4971,7 +5005,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '128.0.0', NULL) + (TRUE, NOW(), NOW(), '129.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT;