diff --git a/Cargo.lock b/Cargo.lock index 9cbb3b518e1..04781fd0640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6675,6 +6675,7 @@ dependencies = [ "oximeter-types 0.1.0", "oxql-types", "scim2-rs", + "tufaceous-artifact", ] [[package]] diff --git a/common/src/api/external/http_pagination.rs b/common/src/api/external/http_pagination.rs index 710de77f483..2c8d91eb0fd 100644 --- a/common/src/api/external/http_pagination.rs +++ b/common/src/api/external/http_pagination.rs @@ -53,6 +53,7 @@ use dropshot::RequestContext; use dropshot::ResultsPage; use dropshot::WhichPage; use schemars::JsonSchema; +use semver::Version; use serde::Deserialize; use serde::Serialize; use serde::de::DeserializeOwned; @@ -163,6 +164,54 @@ pub fn marker_for_name_or_id( } } +// Pagination by semantic version in ascending or descending order + +/// Scan parameters for resources that support scanning by semantic version +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +pub struct ScanByVersion { + #[serde(default = "default_version_sort_mode")] + sort_by: VersionSortMode, + #[serde(flatten)] + pub selector: Selector, +} + +/// Supported sort modes when scanning by semantic version +#[derive(Copy, Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum VersionSortMode { + /// Sort in increasing semantic version order (oldest first) + VersionAscending, + /// Sort in decreasing semantic version order (newest first) + VersionDescending, +} + +fn default_version_sort_mode() -> VersionSortMode { + VersionSortMode::VersionDescending +} + +impl ScanParams for ScanByVersion +where + T: Clone + Debug + DeserializeOwned + JsonSchema + PartialEq + Serialize, +{ + type MarkerValue = Version; + + fn direction(&self) -> PaginationOrder { + match self.sort_by { + VersionSortMode::VersionAscending => PaginationOrder::Ascending, + VersionSortMode::VersionDescending => PaginationOrder::Descending, + } + } + + fn from_query( + p: &PaginationParams>, + ) -> Result<&Self, HttpError> { + Ok(match p.page { + WhichPage::First(ref scan_params) => scan_params, + WhichPage::Next(PageSelector { ref scan, .. }) => scan, + }) + } +} + /// See `dropshot::ResultsPage::new` fn page_selector_for( item: &T, @@ -313,6 +362,13 @@ pub type PaginatedByNameOrId = PaginationParams< pub type PageSelectorByNameOrId = PageSelector, NameOrId>; +/// Query parameters for pagination by semantic version +pub type PaginatedByVersion = + PaginationParams, PageSelectorByVersion>; +/// Page selector for pagination by semantic version +pub type PageSelectorByVersion = + PageSelector, Version>; + pub fn id_pagination<'a, Selector>( pag_params: &'a DataPageParams, scan_params: &'a ScanById, diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 436619ac5f8..59933c5fe2c 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -3427,6 +3427,10 @@ pub struct ServiceIcmpConfig { pub enabled: bool, } +// TODO: move these TUF repo structs out of this file. They're not external +// anymore after refactors that use views::TufRepo in the external API. They are +// still used extensively in internal services. + /// A description of an uploaded TUF repository. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] pub struct TufRepoDescription { @@ -3501,40 +3505,6 @@ pub struct TufArtifactMeta { pub sign: Option>, } -/// Data about a successful TUF repo import into Nexus. -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub struct TufRepoInsertResponse { - /// The repository as present in the database. - pub recorded: TufRepoDescription, - - /// Whether this repository already existed or is new. - pub status: TufRepoInsertStatus, -} - -/// Status of a TUF repo import. -/// -/// Part of `TufRepoInsertResponse`. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, -)] -#[serde(rename_all = "snake_case")] -pub enum TufRepoInsertStatus { - /// The repository already existed in the database. - AlreadyExists, - - /// The repository did not exist, and was inserted into the database. - Inserted, -} - -/// Data about a successful TUF repo get from Nexus. -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub struct TufRepoGetResponse { - /// The description of the repository. - pub description: TufRepoDescription, -} - #[derive( Clone, Debug, Deserialize, JsonSchema, Serialize, PartialEq, ObjectIdentity, )] diff --git a/nexus/db-model/src/tuf_repo.rs b/nexus/db-model/src/tuf_repo.rs index fd54f95ccca..d898e4ca006 100644 --- a/nexus/db-model/src/tuf_repo.rs +++ b/nexus/db-model/src/tuf_repo.rs @@ -12,7 +12,7 @@ use nexus_db_schema::schema::{ tuf_artifact, tuf_repo, tuf_repo_artifact, tuf_trust_root, }; use nexus_types::external_api::shared::TufSignedRootRole; -use nexus_types::external_api::views; +use nexus_types::external_api::views::{self, TufRepoUploadStatus}; use omicron_common::{api::external, update::ArtifactId}; use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::TufArtifactKind; @@ -30,8 +30,6 @@ use uuid::Uuid; /// A description of a TUF update: a repo, along with the artifacts it /// contains. -/// -/// This is the internal variant of [`external::TufRepoDescription`]. #[derive(Debug, Clone)] pub struct TufRepoDescription { /// The repository. @@ -64,7 +62,6 @@ impl TufRepoDescription { } } - /// Converts self into [`external::TufRepoDescription`]. pub fn into_external(self) -> external::TufRepoDescription { external::TufRepoDescription { repo: self.repo.into_external(), @@ -78,8 +75,6 @@ impl TufRepoDescription { } /// A record representing an uploaded TUF repository. -/// -/// This is the internal variant of [`external::TufRepoMeta`]. #[derive( Queryable, Identifiable, Insertable, Clone, Debug, Selectable, AsChangeset, )] @@ -134,7 +129,6 @@ impl TufRepo { ) } - /// Converts self into [`external::TufRepoMeta`]. pub fn into_external(self) -> external::TufRepoMeta { external::TufRepoMeta { hash: self.sha256.into(), @@ -156,6 +150,17 @@ impl TufRepo { } } +impl From for views::TufRepo { + fn from(repo: TufRepo) -> views::TufRepo { + views::TufRepo { + hash: repo.sha256.into(), + system_version: repo.system_version.into(), + file_name: repo.file_name, + time_created: repo.time_created, + } + } +} + #[derive(Queryable, Insertable, Clone, Debug, Selectable, AsChangeset)] #[diesel(table_name = tuf_artifact)] pub struct TufArtifact { @@ -413,3 +418,24 @@ impl FromSql for DbTufSignedRootRole { .map_err(|e| e.into()) } } + +// The following isn't a real model in the sense that it represents DB data, +// but it is the return type of a datastore function. The main reason we can't +// just use the view for this like we do with TufRepoUploadStatus is that +// TufRepoDescription has a bit more info in it that we rely on in code outside +// of the external API, like tests and internal APIs + +/// The return value of the tuf repo insert function +pub struct TufRepoUpload { + pub recorded: TufRepoDescription, + pub status: TufRepoUploadStatus, +} + +impl From for views::TufRepoUpload { + fn from(upload: TufRepoUpload) -> Self { + views::TufRepoUpload { + repo: upload.recorded.repo.into(), + status: upload.status, + } + } +} diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index 0bf533a5e72..ce0576cc65f 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -111,7 +111,7 @@ mod switch_port; mod target_release; #[cfg(test)] pub(crate) mod test_utils; -mod update; +pub mod update; mod user_data_export; mod utilization; mod v2p_mapping; diff --git a/nexus/db-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index 618b99452d4..348babba343 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -22,39 +22,22 @@ use nexus_db_errors::{ErrorHandler, public_error_from_diesel}; use nexus_db_lookup::DbConnection; use nexus_db_model::{ ArtifactHash, DbTypedUuid, TargetRelease, TufArtifact, TufRepo, - TufRepoDescription, TufTrustRoot, to_db_typed_uuid, + TufRepoDescription, TufRepoUpload, TufTrustRoot, to_db_typed_uuid, }; +use nexus_types::external_api::views::TufRepoUploadStatus; use omicron_common::api::external::{ self, CreateResult, DataPageParams, DeleteResult, Generation, - ListResultVec, LookupResult, LookupType, ResourceType, TufRepoInsertStatus, - UpdateResult, + ListResultVec, LookupResult, LookupType, ResourceType, UpdateResult, }; use omicron_common::api::external::{Error, InternalContext}; use omicron_uuid_kinds::TufRepoKind; use omicron_uuid_kinds::TypedUuid; use omicron_uuid_kinds::{GenericUuid, TufRepoUuid}; +use semver::Version; use swrite::{SWrite, swrite}; use tufaceous_artifact::ArtifactVersion; use uuid::Uuid; -/// The return value of [`DataStore::tuf_repo_insert`]. -/// -/// This is similar to [`external::TufRepoInsertResponse`], but uses -/// nexus-db-model's types instead of external types. -pub struct TufRepoInsertResponse { - pub recorded: TufRepoDescription, - pub status: TufRepoInsertStatus, -} - -impl TufRepoInsertResponse { - pub fn into_external(self) -> external::TufRepoInsertResponse { - external::TufRepoInsertResponse { - recorded: self.recorded.into_external(), - status: self.status, - } - } -} - async fn artifacts_for_repo( repo_id: TypedUuid, conn: &async_bb8_diesel::Connection, @@ -88,7 +71,7 @@ impl DataStore { &self, opctx: &OpContext, description: &external::TufRepoDescription, - ) -> CreateResult { + ) -> CreateResult { opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; let log = opctx.log.new( slog::o!( @@ -147,20 +130,21 @@ impl DataStore { Ok(TufRepoDescription { repo, artifacts }) } - /// Returns the TUF repo description corresponding to this system version. + /// Returns the TUF repo corresponding to this system version. pub async fn tuf_repo_get_by_version( &self, opctx: &OpContext, system_version: SemverVersion, - ) -> LookupResult { + ) -> LookupResult { opctx.authorize(authz::Action::Read, &authz::FLEET).await?; use nexus_db_schema::schema::tuf_repo::dsl; let conn = self.pool_connection_authorized(opctx).await?; - let repo = dsl::tuf_repo + dsl::tuf_repo .filter(dsl::system_version.eq(system_version.clone())) + .filter(dsl::time_pruned.is_null()) .select(TufRepo::as_select()) .first_async::(&*conn) .await @@ -172,12 +156,7 @@ impl DataStore { LookupType::ByCompositeId(system_version.to_string()), ), ) - })?; - - let artifacts = artifacts_for_repo(repo.id.into(), &conn) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; - Ok(TufRepoDescription { repo, artifacts }) + }) } /// Given a TUF repo ID, get its version. We could use `tuf_repo_get_by_id`, @@ -207,26 +186,6 @@ impl DataStore { }) } - /// Returns the list of all TUF repo artifacts known to the system. - pub async fn tuf_list_repos( - &self, - opctx: &OpContext, - generation: Generation, - pagparams: &DataPageParams<'_, Uuid>, - ) -> ListResultVec { - opctx.authorize(authz::Action::Read, &authz::FLEET).await?; - - use nexus_db_schema::schema::tuf_artifact::dsl; - - let generation = nexus_db_model::Generation(generation); - paginated(dsl::tuf_artifact, dsl::id, pagparams) - .filter(dsl::generation_added.le(generation)) - .select(TufArtifact::as_select()) - .load_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) - } - /// Pages through the list of all not-yet-pruned TUF repos in the system pub async fn tuf_list_repos_unpruned( &self, @@ -465,6 +424,36 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + /// List all TUF repositories (without artifacts) ordered by system version + /// (newest first by default). + pub async fn tuf_repo_list( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, Version>, + ) -> ListResultVec { + opctx.authorize(authz::Action::Read, &authz::FLEET).await?; + + use nexus_db_schema::schema::tuf_repo; + + let conn = self.pool_connection_authorized(opctx).await?; + + let marker_owner = pagparams + .marker + .map(|version| SemverVersion::from(version.clone())); + let db_pagparams = DataPageParams { + marker: marker_owner.as_ref(), + direction: pagparams.direction, + limit: pagparams.limit, + }; + + paginated(tuf_repo::table, tuf_repo::system_version, &db_pagparams) + .filter(tuf_repo::time_pruned.is_null()) + .select(TufRepo::as_select()) + .load_async(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + /// List the trusted TUF root roles in the trust store. pub async fn tuf_trust_root_list( &self, @@ -537,7 +526,7 @@ async fn insert_impl( conn: async_bb8_diesel::Connection, desc: &external::TufRepoDescription, err: OptionalError, -) -> Result { +) -> Result { // Load the current generation from the database and increment it, then // use that when creating the `TufRepoDescription`. If we determine there // are any artifacts to be inserted, we update the generation to this value @@ -574,9 +563,9 @@ async fn insert_impl( let recorded = TufRepoDescription { repo: existing_repo, artifacts }; - return Ok(TufRepoInsertResponse { + return Ok(TufRepoUpload { recorded, - status: TufRepoInsertStatus::AlreadyExists, + status: TufRepoUploadStatus::AlreadyExists, }); } @@ -780,10 +769,7 @@ async fn insert_impl( } let recorded = TufRepoDescription { repo, artifacts: all_artifacts }; - Ok(TufRepoInsertResponse { - recorded, - status: TufRepoInsertStatus::Inserted, - }) + Ok(TufRepoUpload { recorded, status: TufRepoUploadStatus::Inserted }) } async fn get_generation( diff --git a/nexus/external-api/Cargo.toml b/nexus/external-api/Cargo.toml index 6df96087369..77147af4698 100644 --- a/nexus/external-api/Cargo.toml +++ b/nexus/external-api/Cargo.toml @@ -23,3 +23,4 @@ oximeter-types.workspace = true oxql-types.workspace = true omicron-uuid-kinds.workspace = true scim2-rs.workspace = true +tufaceous-artifact.workspace = true diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index a51839ff963..9f84bca8d18 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -301,8 +301,9 @@ ping GET /v1/ping 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_repository_list GET /v1/system/update/repositories +system_update_repository_upload PUT /v1/system/update/repositories +system_update_repository_view GET /v1/system/update/repositories/{system_version} 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} diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 87b6b8054fb..78510a4f78a 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -24,7 +24,7 @@ use nexus_types::{ use omicron_common::api::external::{ http_pagination::{ PaginatedById, PaginatedByName, PaginatedByNameOrId, - PaginatedByTimeAndId, + PaginatedByTimeAndId, PaginatedByVersion, }, *, }; @@ -3183,26 +3183,40 @@ pub trait NexusExternalApi { /// System release repositories are verified by the updates trust store. #[endpoint { method = PUT, - path = "/v1/system/update/repository", + path = "/v1/system/update/repositories", tags = ["system/update"], request_body_max_bytes = PUT_UPDATE_REPOSITORY_MAX_BYTES, }] - async fn system_update_put_repository( + async fn system_update_repository_upload( rqctx: RequestContext, query: Query, body: StreamingBody, - ) -> Result, HttpError>; + ) -> Result, HttpError>; /// Fetch system release repository description by version #[endpoint { method = GET, - path = "/v1/system/update/repository/{system_version}", + path = "/v1/system/update/repositories/{system_version}", tags = ["system/update"], }] - async fn system_update_get_repository( + async fn system_update_repository_view( rqctx: RequestContext, path_params: Path, - ) -> Result, HttpError>; + ) -> Result, HttpError>; + + /// List all TUF repositories + /// + /// Returns a paginated list of all TUF repositories ordered by system + /// version (newest first by default). + #[endpoint { + method = GET, + path = "/v1/system/update/repositories", + tags = ["system/update"], + }] + async fn system_update_repository_list( + rqctx: RequestContext, + query_params: Query, + ) -> Result>, HttpError>; /// List root roles in the updates trust store /// diff --git a/nexus/src/app/update.rs b/nexus/src/app/update.rs index 9b4e4ff3a9b..05753d50387 100644 --- a/nexus/src/app/update.rs +++ b/nexus/src/app/update.rs @@ -9,7 +9,9 @@ use dropshot::HttpError; use futures::Stream; use nexus_auth::authz; use nexus_db_lookup::LookupPath; -use nexus_db_model::{Generation, TufRepoDescription, TufTrustRoot}; +use nexus_db_model::Generation; +use nexus_db_model::TufRepoUpload; +use nexus_db_model::TufTrustRoot; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::{datastore::SQL_BATCH_SIZE, pagination::Paginator}; use nexus_types::deployment::{ @@ -17,13 +19,12 @@ use nexus_types::deployment::{ }; use nexus_types::external_api::shared::TufSignedRootRole; use nexus_types::external_api::views; +use nexus_types::external_api::views::TufRepoUploadStatus; 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::api::external::{DataPageParams, Error}; use omicron_common::disk::M2Slot; use omicron_uuid_kinds::{GenericUuid, TufTrustRootUuid}; use semver::Version; @@ -58,7 +59,7 @@ impl super::Nexus { opctx: &OpContext, body: impl Stream> + Send + Sync + 'static, file_name: String, - ) -> Result { + ) -> Result { let mut trusted_roots = Vec::new(); let mut paginator = Paginator::new( SQL_BATCH_SIZE, @@ -96,7 +97,7 @@ impl super::Nexus { // carries with it the `Utf8TempDir`s storing the artifacts) into the // artifact replication background task, then immediately activate the // task. - if response.status == TufRepoInsertStatus::Inserted { + if response.status == TufRepoUploadStatus::Inserted { self.tuf_artifact_replication_tx .send(artifacts_with_plan) .await @@ -116,18 +117,25 @@ impl super::Nexus { self.background_tasks.task_tuf_artifact_replication.activate(); } - Ok(response.into_external()) + Ok(response) } pub(crate) async fn updates_get_repository( &self, opctx: &OpContext, system_version: Version, - ) -> Result { + ) -> Result { self.db_datastore .tuf_repo_get_by_version(opctx, system_version.into()) .await - .map_err(HttpError::from) + } + + pub(crate) async fn updates_list_repositories( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, Version>, + ) -> Result, Error> { + self.db_datastore.tuf_repo_list(opctx, pagparams).await } pub(crate) async fn updates_add_trust_root( diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index f9ff0e8683e..0cc6fbceb84 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -85,8 +85,6 @@ use omicron_common::api::external::ServiceIcmpConfig; use omicron_common::api::external::SwitchPort; use omicron_common::api::external::SwitchPortSettings; use omicron_common::api::external::SwitchPortSettingsIdentity; -use omicron_common::api::external::TufRepoGetResponse; -use omicron_common::api::external::TufRepoInsertResponse; use omicron_common::api::external::VpcFirewallRuleUpdateParams; use omicron_common::api::external::VpcFirewallRules; use omicron_common::api::external::http_pagination::PaginatedBy; @@ -94,10 +92,12 @@ use omicron_common::api::external::http_pagination::PaginatedById; use omicron_common::api::external::http_pagination::PaginatedByName; use omicron_common::api::external::http_pagination::PaginatedByNameOrId; use omicron_common::api::external::http_pagination::PaginatedByTimeAndId; +use omicron_common::api::external::http_pagination::PaginatedByVersion; use omicron_common::api::external::http_pagination::ScanById; use omicron_common::api::external::http_pagination::ScanByName; use omicron_common::api::external::http_pagination::ScanByNameOrId; use omicron_common::api::external::http_pagination::ScanByTimeAndId; +use omicron_common::api::external::http_pagination::ScanByVersion; use omicron_common::api::external::http_pagination::ScanParams; use omicron_common::api::external::http_pagination::data_page_params_for; use omicron_common::api::external::http_pagination::marker_for_id; @@ -7221,11 +7221,11 @@ impl NexusExternalApi for NexusExternalApiImpl { // Updates - async fn system_update_put_repository( + async fn system_update_repository_upload( rqctx: RequestContext, query: Query, body: StreamingBody, - ) -> Result, HttpError> { + ) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.context.nexus; let handler = async { @@ -7236,7 +7236,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let update = nexus .updates_put_repository(&opctx, body, query.file_name) .await?; - Ok(HttpResponseOk(update)) + Ok(HttpResponseOk(update.into())) }; apictx .context @@ -7245,22 +7245,52 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn system_update_get_repository( + async fn system_update_repository_view( rqctx: RequestContext, path_params: Path, - ) -> Result, HttpError> { + ) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.context.nexus; let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let params = path_params.into_inner(); - let description = nexus + let repo = nexus .updates_get_repository(&opctx, params.system_version) .await?; - Ok(HttpResponseOk(TufRepoGetResponse { - description: description.into_external(), - })) + Ok(HttpResponseOk(repo.into())) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn system_update_repository_list( + rqctx: RequestContext, + query_params: Query, + ) -> Result>, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.context.nexus; + let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + let query = query_params.into_inner(); + let pagparams = data_page_params_for(&rqctx, &query)?; + let repos = + nexus.updates_list_repositories(&opctx, &pagparams).await?; + + let responses: Vec = + repos.into_iter().map(Into::into).collect(); + + Ok(HttpResponseOk(ScanByVersion::results_page( + &query, + responses, + &|_scan_params, item: &views::TufRepo| { + item.system_version.clone() + }, + )?)) }; apictx .context @@ -7424,7 +7454,6 @@ impl NexusExternalApi for NexusExternalApiImpl { .datastore() .tuf_repo_get_by_version(&opctx, system_version.into()) .await? - .repo .id; let next_target_release = nexus_db_model::TargetRelease::new_system_version( diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 649207d3f98..b88b41a143e 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -2535,16 +2535,21 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( ], }, VerifyEndpoint { - url: "/v1/system/update/repository?file_name=demo-repo.zip", + url: "/v1/system/update/repositories?file_name=demo-repo.zip", visibility: Visibility::Public, unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Put( - // In reality this is the contents of a zip file. - serde_json::Value::Null, - )], + allowed_methods: vec![ + // the query param is only relevant to the put + AllowedMethod::Put( + // In reality this is the contents of a zip file. + serde_json::Value::Null, + ), + // get doesn't use the query param but it doesn't break if it's there + AllowedMethod::Get + ], }, VerifyEndpoint { - url: "/v1/system/update/repository/1.0.0", + url: "/v1/system/update/repositories/1.0.0", visibility: Visibility::Public, unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], diff --git a/nexus/tests/integration_tests/target_release.rs b/nexus/tests/integration_tests/target_release.rs index 813d779cd2a..897b197a859 100644 --- a/nexus/tests/integration_tests/target_release.rs +++ b/nexus/tests/integration_tests/target_release.rs @@ -15,7 +15,6 @@ 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; -use omicron_common::api::external::TufRepoInsertResponse; use semver::Version; use tufaceous_artifact::{ArtifactVersion, KnownArtifactKind}; use tufaceous_lib::assemble::ManifestTweak; @@ -54,14 +53,14 @@ async fn get_set_target_release() -> Result<()> { { let before = Utc::now(); let system_version = Version::new(1, 0, 0); - let response: TufRepoInsertResponse = trust_root + let response: views::TufRepoUpload = trust_root .assemble_repo(&logctx.log, &[]) .await? .into_upload_request(client, StatusCode::OK) .execute() .await? .parsed_body()?; - assert_eq!(system_version, response.recorded.repo.system_version); + assert_eq!(system_version, response.repo.system_version); set_target_release(client, &system_version).await?; @@ -86,14 +85,14 @@ async fn get_set_target_release() -> Result<()> { version: ArtifactVersion::new("non-semver-2").unwrap(), }, ]; - let response: TufRepoInsertResponse = trust_root + let response: views::TufRepoUpload = trust_root .assemble_repo(&logctx.log, tweaks) .await? .into_upload_request(client, StatusCode::OK) .execute() .await? .parsed_body()?; - assert_eq!(system_version, response.recorded.repo.system_version); + assert_eq!(system_version, response.repo.system_version); set_target_release(client, &system_version).await?; diff --git a/nexus/tests/integration_tests/updates.rs b/nexus/tests/integration_tests/updates.rs index 86d5fe3d9c2..b1e319045ed 100644 --- a/nexus/tests/integration_tests/updates.rs +++ b/nexus/tests/integration_tests/updates.rs @@ -8,6 +8,7 @@ use camino_tempfile::{Builder, Utf8TempPath}; use chrono::{DateTime, Duration, Timelike, Utc}; use dropshot::ResultsPage; use http::{Method, StatusCode}; +use nexus_db_model::SemverVersion; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::pub_test_utils::helpers::insert_test_tuf_repo; use nexus_test_utils::background::activate_background_task; @@ -15,13 +16,13 @@ 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::resource_helpers::object_get_error; +use nexus_test_utils::resource_helpers::objects_list_page_authz; 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, -}; +use nexus_types::external_api::views::{TufRepoUpload, TufRepoUploadStatus}; +use omicron_common::api::external::TufArtifactMeta; use pretty_assertions::assert_eq; use semver::Version; use serde::Deserialize; @@ -42,6 +43,38 @@ const TRUST_ROOTS_URL: &str = "/v1/system/update/trust-roots"; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; +/// Get artifacts for a repository using the datastore directly, sorted by ID +async fn get_repo_artifacts( + cptestctx: &ControlPlaneTestContext, + version: &str, +) -> Vec { + let datastore = cptestctx.server.server_context().nexus.datastore(); + let opctx = + OpContext::for_tests(cptestctx.logctx.log.new(o!()), datastore.clone()); + let system_version = SemverVersion::from( + version.parse::().expect("version should parse"), + ); + + let repo = datastore + .tuf_repo_get_by_version(&opctx, system_version) + .await + .expect("should get repo by version"); + + let artifacts = datastore + .tuf_list_repo_artifacts(&opctx, repo.id.into()) + .await + .expect("should get artifacts"); + + let mut result: Vec = artifacts + .into_iter() + .map(|artifact| artifact.into_external()) + .collect(); + + // Sort artifacts by their ID for consistent comparison + result.sort_by(|a, b| a.id.cmp(&b.id)); + result +} + pub struct TestTrustRoot { pub key: Key, pub expiry: DateTime, @@ -114,7 +147,7 @@ impl TestRepo { expected_status: StatusCode, ) -> NexusRequest<'a> { let url = format!( - "/v1/system/update/repository?file_name={}", + "/v1/system/update/repositories?file_name={}", self.0.file_name().expect("archive path must have a file name") ); let request = RequestBuilder::new(client, Method::PUT, &url) @@ -168,16 +201,12 @@ async fn test_repo_upload_unconfigured() -> Result<()> { // Attempt to fetch a repository description from Nexus. This should fail // with a 404 error. - { - make_get_request( - client, - "1.0.0".parse().unwrap(), - StatusCode::NOT_FOUND, - ) - .execute() - .await - .context("repository fetch should have failed with 404 error")?; - } + object_get_error( + client, + "/v1/system/update/repositories/1.0.0", + StatusCode::NOT_FOUND, + ) + .await; cptestctx.teardown().await; Ok(()) @@ -208,42 +237,42 @@ async fn test_repo_upload() -> Result<()> { let repo = trust_root.assemble_repo(&logctx.log, &[]).await?; // Generate a repository and upload it to Nexus. - let mut initial_description = { + let initial_repo = { let response = repo .to_upload_request(client, StatusCode::OK) .execute() .await .context("error uploading repository")?; - let response = - serde_json::from_slice::(&response.body) - .context("error deserializing response body")?; - assert_eq!(response.status, TufRepoInsertStatus::Inserted); - response.recorded + let response = serde_json::from_slice::(&response.body) + .context("error deserializing response body")?; + assert_eq!(response.status, TufRepoUploadStatus::Inserted); + response.repo }; - let unique_sha256_count = initial_description - .artifacts + + // Get artifacts using the datastore directly + let initial_artifacts = get_repo_artifacts(&cptestctx, "1.0.0").await; + let unique_sha256_count = initial_artifacts .iter() .map(|artifact| artifact.hash) .collect::>() .len(); // The repository description should have `Zone` artifacts instead of the // composite `ControlPlane` artifact. - assert_eq!( - initial_description - .artifacts - .iter() - .filter_map(|artifact| { - if artifact.id.kind == KnownArtifactKind::Zone.into() { - Some(&artifact.id.name) - } else { - None - } - }) - .collect::>(), - ["zone-1", "zone-2"] - ); - assert!(!initial_description.artifacts.iter().any(|artifact| { + let zone_names: HashSet<&str> = initial_artifacts + .iter() + .filter_map(|artifact| { + if artifact.id.kind == KnownArtifactKind::Zone.into() { + Some(artifact.id.name.as_str()) + } else { + None + } + }) + .collect(); + let expected_zones: HashSet<&str> = + ["zone-1", "zone-2"].into_iter().collect(); + assert_eq!(zone_names, expected_zones); + assert!(!initial_artifacts.iter().any(|artifact| { artifact.id.kind == KnownArtifactKind::ControlPlane.into() })); // The generation number should now be 2. @@ -283,26 +312,35 @@ async fn test_repo_upload() -> Result<()> { // Upload the repository to Nexus again. This should return a 200 with an // `AlreadyExists` status. - let mut reupload_description = { + let reupload_description = { let response = repo .into_upload_request(client, StatusCode::OK) .execute() .await .context("error uploading repository a second time")?; - let response = - serde_json::from_slice::(&response.body) - .context("error deserializing response body")?; - assert_eq!(response.status, TufRepoInsertStatus::AlreadyExists); - response.recorded + let response = serde_json::from_slice::(&response.body) + .context("error deserializing response body")?; + assert_eq!(response.status, TufRepoUploadStatus::AlreadyExists); + response.repo }; - initial_description.sort_artifacts(); - reupload_description.sort_artifacts(); + // Get artifacts again and compare them + let reupload_artifacts = get_repo_artifacts(&cptestctx, "1.0.0").await; assert_eq!( - initial_description, reupload_description, - "initial description matches reupload" + initial_artifacts, reupload_artifacts, + "initial artifacts match reupload artifacts" + ); + + // Also verify that the repo metadata (without artifacts) matches + assert_eq!( + initial_repo.hash, reupload_description.hash, + "repo hash matches" + ); + assert_eq!( + initial_repo.system_version, reupload_description.system_version, + "system version matches" ); // We didn't insert a new repo, so the generation number should still be 2. @@ -312,27 +350,17 @@ async fn test_repo_upload() -> Result<()> { ); // Now get the repository that was just uploaded. - let mut get_description = { - let response = make_get_request( - client, - "1.0.0".parse().unwrap(), // this is the system version of the fake manifest - StatusCode::OK, - ) - .execute() - .await - .context("error fetching repository")?; - - let response = - serde_json::from_slice::(&response.body) - .context("error deserializing response body")?; - response.description - }; - - get_description.sort_artifacts(); + let repo = object_get::( + client, + "/v1/system/update/repositories/1.0.0", + ) + .await; + // Compare just the repo metadata (not artifacts) + assert_eq!(initial_repo.hash, repo.hash, "repo hash matches"); assert_eq!( - initial_description, get_description, - "initial description matches fetched description" + initial_repo.system_version, repo.system_version, + "system version matches" ); // Upload a new repository with the same system version but a different @@ -433,7 +461,7 @@ async fn test_repo_upload() -> Result<()> { // Upload a new repository with a different system version but no other // changes. This should be accepted. - let initial_installinator_doc = { + let initial_installinator_doc_hash = { let tweaks = &[ManifestTweak::SystemVersion("2.0.0".parse().unwrap())]; let response = trust_root .assemble_repo(&logctx.log, tweaks) @@ -446,19 +474,18 @@ async fn test_repo_upload() -> Result<()> { (should succeed)", )?; - let response = - serde_json::from_slice::(&response.body) - .context("error deserializing response body")?; - assert_eq!(response.status, TufRepoInsertStatus::Inserted); - let mut description = response.recorded; - description.sort_artifacts(); + let response = serde_json::from_slice::(&response.body) + .context("error deserializing response body")?; + assert_eq!(response.status, TufRepoUploadStatus::Inserted); + + // Get artifacts for the 2.0.0 repository + let artifacts_2_0_0 = get_repo_artifacts(&cptestctx, "2.0.0").await; // The artifacts should be exactly the same as the 1.0.0 repo we // uploaded, other than the installinator document (which will have // system version 2.0.0). let mut installinator_doc_1 = None; - let filtered_artifacts_1 = initial_description - .artifacts + let filtered_artifacts_1 = initial_artifacts .iter() .filter(|artifact| { if artifact.id.kind @@ -472,8 +499,7 @@ async fn test_repo_upload() -> Result<()> { }) .collect::>(); let mut installinator_doc_2 = None; - let filtered_artifacts_2 = description - .artifacts + let filtered_artifacts_2 = artifacts_2_0_0 .iter() .filter(|artifact| { if artifact.id.kind @@ -499,23 +525,17 @@ async fn test_repo_upload() -> Result<()> { "artifacts for 1.0.0 and 2.0.0 should match" ); - // Now get the repository that was just uploaded and make sure the - // artifact list is the same. - let response: TufRepoGetResponse = - make_get_request(client, "2.0.0".parse().unwrap(), StatusCode::OK) - .execute() - .await - .context("error fetching repository")? - .parsed_body()?; - let mut get_description = response.description; - get_description.sort_artifacts(); + // Now get the repository that was just uploaded. + let get_repo = object_get::( + client, + "/v1/system/update/repositories/2.0.0", + ) + .await; - assert_eq!( - description, get_description, - "initial description matches fetched description" - ); + // Validate the repo metadata + assert_eq!(get_repo.system_version.to_string(), "2.0.0"); - installinator_doc_1 + installinator_doc_1.hash.to_string() }; // The installinator document changed, so the generation number is bumped to // 3. @@ -543,10 +563,9 @@ async fn test_repo_upload() -> Result<()> { assert_eq!(status.local_repos, 0); // Verify the initial installinator document is present on all sled-agents. - let installinator_doc_hash = initial_installinator_doc.hash.to_string(); for sled_agent in &cptestctx.sled_agents { for dir in sled_agent.sled_agent().artifact_store().storage_paths() { - let path = dir.join(&installinator_doc_hash); + let path = dir.join(&initial_installinator_doc_hash); assert!(path.exists(), "{path} does not exist"); } } @@ -572,7 +591,7 @@ async fn test_repo_upload() -> Result<()> { &opctx, status.generation, &recent_releases, - initial_repo.repo.id(), + initial_repo.id(), ) .await .unwrap(); @@ -597,7 +616,7 @@ async fn test_repo_upload() -> Result<()> { // Verify the installinator document from the initial repo is deleted. for sled_agent in &cptestctx.sled_agents { for dir in sled_agent.sled_agent().artifact_store().storage_paths() { - let path = dir.join(&installinator_doc_hash); + let path = dir.join(&initial_installinator_doc_hash); assert!(!path.exists(), "{path} was not deleted"); } } @@ -606,23 +625,6 @@ async fn test_repo_upload() -> Result<()> { Ok(()) } -fn make_get_request( - client: &dropshot::test_util::ClientTestContext, - system_version: Version, - expected_status: StatusCode, -) -> NexusRequest<'_> { - let request = NexusRequest::new( - RequestBuilder::new( - client, - Method::GET, - &format!("/v1/system/update/repository/{system_version}"), - ) - .expect_status(Some(expected_status)), - ) - .authn_as(AuthnMode::PrivilegedUser); - request -} - #[derive(Debug, Deserialize)] struct ErrorBody { message: String, @@ -651,7 +653,7 @@ async fn test_trust_root_operations(cptestctx: &ControlPlaneTestContext) { TestTrustRoot::generate().await.expect("trust root generation failed"); // POST /v1/system/update/trust-roots - let trust_root_view: UpdatesTrustRoot = trust_root + let trust_root_view: views::UpdatesTrustRoot = trust_root .to_upload_request(client, StatusCode::CREATED) .execute() .await @@ -662,20 +664,21 @@ async fn test_trust_root_operations(cptestctx: &ControlPlaneTestContext) { // GET /v1/system/update/trust-roots let request = RequestBuilder::new(client, Method::GET, TRUST_ROOTS_URL) .expect_status(Some(StatusCode::OK)); - let response: ResultsPage = NexusRequest::new(request) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("trust root list failed") - .parsed_body() - .expect("failed to parse list response"); + let response: ResultsPage = + NexusRequest::new(request) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("trust root list failed") + .parsed_body() + .expect("failed to parse list response"); assert_eq!(response.items, std::slice::from_ref(&trust_root_view.clone())); // GET /v1/system/update/trust-roots/{id} let id_url = format!("{TRUST_ROOTS_URL}/{}", trust_root_view.id); let request = RequestBuilder::new(client, Method::GET, &id_url) .expect_status(Some(StatusCode::OK)); - let response: UpdatesTrustRoot = NexusRequest::new(request) + let response: views::UpdatesTrustRoot = NexusRequest::new(request) .authn_as(AuthnMode::PrivilegedUser) .execute() .await @@ -694,13 +697,14 @@ async fn test_trust_root_operations(cptestctx: &ControlPlaneTestContext) { .expect("trust root delete failed"); let request = RequestBuilder::new(client, Method::GET, TRUST_ROOTS_URL) .expect_status(Some(StatusCode::OK)); - let response: ResultsPage = NexusRequest::new(request) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("trust root list after delete failed") - .parsed_body() - .expect("failed to parse list after delete response"); + let response: ResultsPage = + NexusRequest::new(request) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("trust root list after delete failed") + .parsed_body() + .expect("failed to parse list after delete response"); assert!(response.items.is_empty()); } @@ -787,6 +791,7 @@ async fn test_update_status() -> Result<()> { cptestctx.teardown().await; Ok(()) } + #[nexus_test] async fn test_repo_prune(cptestctx: &ControlPlaneTestContext) { let logctx = &cptestctx.logctx; @@ -827,3 +832,180 @@ async fn test_repo_prune(cptestctx: &ControlPlaneTestContext) { assert!(repos.iter().any(|r| r.id() == repo3id)); assert!(repos.iter().any(|r| r.id() == repo4id)); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_repo_list() -> Result<()> { + let cptestctx = test_setup::( + "test_update_repo_list", + 3, // 4 total sled agents + ) + .await; + let client = &cptestctx.external_client; + let logctx = &cptestctx.logctx; + + // Initially, list should be empty + let initial_list: ResultsPage = + objects_list_page_authz(client, "/v1/system/update/repositories").await; + + assert_eq!(initial_list.items.len(), 0); + assert!(initial_list.next_page.is_none()); + + // Add a trust root + let trust_root = TestTrustRoot::generate().await?; + trust_root.to_upload_request(client, StatusCode::CREATED).execute().await?; + + // Upload first repository (system version 1.0.0) + let repo1 = trust_root.assemble_repo(&logctx.log, &[]).await?; + let upload_response1 = repo1 + .into_upload_request(client, StatusCode::OK) + .execute() + .await + .context("error uploading first repository")?; + let response1 = + serde_json::from_slice::(&upload_response1.body) + .context("error deserializing first response body")?; + assert_eq!(response1.status, TufRepoUploadStatus::Inserted); + + // Upload second repository (system version 2.0.0) + let tweaks = &[ManifestTweak::SystemVersion("2.0.0".parse().unwrap())]; + let repo2 = trust_root.assemble_repo(&logctx.log, tweaks).await?; + let upload_response2 = repo2 + .into_upload_request(client, StatusCode::OK) + .execute() + .await + .context("error uploading second repository")?; + let response2 = + serde_json::from_slice::(&upload_response2.body) + .context("error deserializing second response body")?; + assert_eq!(response2.status, TufRepoUploadStatus::Inserted); + + // Upload third repository (system version 3.0.0) + let tweaks = &[ManifestTweak::SystemVersion("3.0.0".parse().unwrap())]; + let repo3 = trust_root.assemble_repo(&logctx.log, tweaks).await?; + let upload_response3 = repo3 + .into_upload_request(client, StatusCode::OK) + .execute() + .await + .context("error uploading third repository")?; + let response3 = + serde_json::from_slice::(&upload_response3.body) + .context("error deserializing third response body")?; + assert_eq!(response3.status, TufRepoUploadStatus::Inserted); + + // List repositories - should return all 3, ordered by system version (newest first) + let list: ResultsPage = + objects_list_page_authz(client, "/v1/system/update/repositories").await; + + assert_eq!(list.items.len(), 3); + + // Repositories should be ordered by system version descending (newest first) + let system_versions: Vec = + list.items.iter().map(|item| item.system_version.to_string()).collect(); + assert_eq!(system_versions, vec!["3.0.0", "2.0.0", "1.0.0"]); + + // Verify that each response contains the correct system version + for (i, item) in list.items.iter().enumerate() { + let expected_version = match i { + 0 => "3.0.0", + 1 => "2.0.0", + 2 => "1.0.0", + _ => panic!("unexpected index"), + }; + assert_eq!(item.system_version.to_string(), expected_version); + } + + // Request ascending order and expect the versions oldest-first + let ascending_list: ResultsPage = objects_list_page_authz( + client, + "/v1/system/update/repositories?sort_by=version_ascending", + ) + .await; + + assert_eq!(ascending_list.items.len(), 3); + + let ascending_versions: Vec = ascending_list + .items + .iter() + .map(|item| item.system_version.to_string()) + .collect(); + assert_eq!(ascending_versions, vec!["1.0.0", "2.0.0", "3.0.0"]); + + // Test pagination by setting a small limit + let paginated_list = objects_list_page_authz::( + client, + "/v1/system/update/repositories?limit=2", + ) + .await; + + assert_eq!(paginated_list.items.len(), 2); + assert!(paginated_list.next_page.is_some()); + + // First two items should be 3.0.0 and 2.0.0 (newest first) + let paginated_versions: Vec = paginated_list + .items + .iter() + .map(|item| item.system_version.to_string()) + .collect(); + assert_eq!(paginated_versions, vec!["3.0.0", "2.0.0"]); + + // Fetch the next page via the returned page token and expect the remaining repo + let next_page_url = format!( + "/v1/system/update/repositories?limit=2&page_token={}", + paginated_list.next_page.clone().expect("expected next page token"), + ); + let next_page: ResultsPage = + objects_list_page_authz(client, &next_page_url).await; + assert_eq!(next_page.items.len(), 1); + assert_eq!(next_page.items[0].system_version.to_string(), "1.0.0"); + + // Test filtering out pruned repos + + // Confirm that GET works for 1.0.0 before pruning + let _repo_before_prune: views::TufRepo = + object_get(client, "/v1/system/update/repositories/1.0.0").await; + + // Mark the 1.0.0 repo as pruned (use datastore methods since there's no API + // for it) + let datastore = cptestctx.server.server_context().nexus.datastore(); + let opctx = OpContext::for_tests(logctx.log.new(o!()), datastore.clone()); + + let v1 = "1.0.0".parse::().unwrap(); + let repo_to_prune = + datastore.tuf_repo_get_by_version(&opctx, v1.into()).await?; + + let generation = datastore.tuf_get_generation(&opctx).await?; + let recent_releases = + datastore.target_release_fetch_recent_distinct(&opctx, 3).await?; + + datastore + .tuf_repo_mark_pruned( + &opctx, + generation, + &recent_releases, + repo_to_prune.id(), + ) + .await?; + + // pruned repo now 404s + object_get_error( + client, + "/v1/system/update/repositories/1.0.0", + StatusCode::NOT_FOUND, + ) + .await; + + // List repositories again - the pruned repo should not appear + let list_after_prune: ResultsPage = + objects_list_page_authz(client, "/v1/system/update/repositories").await; + + assert_eq!(list_after_prune.items.len(), 2); + let versions_after_prune: Vec = list_after_prune + .items + .iter() + .map(|item| item.system_version.to_string()) + .collect(); + assert_eq!(versions_after_prune, vec!["3.0.0", "2.0.0"]); + + cptestctx.teardown().await; + Ok(()) +} diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 03e93dab5b2..773222aca38 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -2398,14 +2398,14 @@ pub struct ResourceMetrics { // SYSTEM UPDATE -/// Parameters for PUT requests for `/v1/system/update/repository`. +/// Parameters for PUT requests for `/v1/system/update/repositories`. #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct UpdatesPutRepositoryParams { /// The name of the uploaded file. pub file_name: String, } -/// Parameters for GET requests for `/v1/system/update/repository`. +/// Parameters for GET requests for `/v1/system/update/repositories`. #[derive(Clone, Debug, Deserialize, JsonSchema)] pub struct UpdatesGetRepositoryParams { /// The version to get. diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index f0abc6ec97d..3180aa20659 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -30,6 +30,7 @@ use std::fmt; use std::net::IpAddr; use std::sync::LazyLock; use strum::{EnumIter, IntoEnumIterator}; +use tufaceous_artifact::ArtifactHash; use url::Url; use uuid::Uuid; @@ -1596,6 +1597,55 @@ pub struct UpdateStatus { pub suspended: bool, } +/// Metadata about a TUF repository +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +pub struct TufRepo { + /// The hash of the repository + // This is a slight abuse of `ArtifactHash`, since that's the hash of + // individual artifacts within the repository. However, we use it here for + // convenience. + pub hash: ArtifactHash, + + /// The system version for this repository + /// + /// The system version is a top-level version number applied to all the + /// software in the repository. + pub system_version: Version, + + /// The file name of the repository, as reported by the client that uploaded + /// it + /// + /// This is intended for debugging. The file name may not match any + /// particular pattern, and even if it does, it may not be accurate since + /// it's just what the client reported. + // (e.g., with wicket, we read the file contents from stdin so we don't know + // the correct file name). + pub file_name: String, + + /// Time the repository was uploaded + pub time_created: DateTime, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +pub struct TufRepoUpload { + pub repo: TufRepo, + pub status: TufRepoUploadStatus, +} + +/// Whether the uploaded TUF repo already existed or was new and had to be +/// inserted. Part of `TufRepoUpload`. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum TufRepoUploadStatus { + /// The repository already existed in the database + AlreadyExists, + + /// The repository did not exist, and was inserted into the database + Inserted, +} + 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 c075755c920..b2c21cfa1a4 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -11100,14 +11100,72 @@ } } }, - "/v1/system/update/repository": { + "/v1/system/update/repositories": { + "get": { + "tags": [ + "system/update" + ], + "summary": "List all TUF repositories", + "description": "Returns a paginated list of all TUF repositories ordered by system version (newest first by default).", + "operationId": "system_update_repository_list", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/VersionSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TufRepoResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + }, "put": { "tags": [ "system/update" ], "summary": "Upload system release repository", "description": "System release repositories are verified by the updates trust store.", - "operationId": "system_update_put_repository", + "operationId": "system_update_repository_upload", "parameters": [ { "in": "query", @@ -11136,7 +11194,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TufRepoInsertResponse" + "$ref": "#/components/schemas/TufRepoUpload" } } } @@ -11150,13 +11208,13 @@ } } }, - "/v1/system/update/repository/{system_version}": { + "/v1/system/update/repositories/{system_version}": { "get": { "tags": [ "system/update" ], "summary": "Fetch system release repository description by version", - "operationId": "system_update_get_repository", + "operationId": "system_update_repository_view", "parameters": [ { "in": "path", @@ -11175,7 +11233,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TufRepoGetResponse" + "$ref": "#/components/schemas/TufRepo" } } } @@ -14785,29 +14843,6 @@ } } }, - "ArtifactId": { - "description": "An identifier for an artifact.", - "type": "object", - "properties": { - "kind": { - "description": "The kind of artifact this is.", - "type": "string" - }, - "name": { - "description": "The artifact's name.", - "type": "string" - }, - "version": { - "description": "The artifact's version.", - "type": "string" - } - }, - "required": [ - "kind", - "name", - "version" - ] - }, "AuditLogEntry": { "description": "Audit log entry", "type": "object", @@ -26081,131 +26116,85 @@ "items" ] }, - "TufArtifactMeta": { - "description": "Metadata about an individual TUF artifact.\n\nFound within a `TufRepoDescription`.", + "TufRepo": { + "description": "Metadata about a TUF repository", "type": "object", "properties": { - "board": { - "nullable": true, - "description": "Contents of the `BORD` field of a Hubris archive caboose. Only applicable to artifacts that are Hubris archives.\n\nThis field should always be `Some(_)` if `sign` is `Some(_)`, but the opposite is not true (SP images will have a `board` but not a `sign`).", + "file_name": { + "description": "The file name of the repository, as reported by the client that uploaded it\n\nThis is intended for debugging. The file name may not match any particular pattern, and even if it does, it may not be accurate since it's just what the client reported.", "type": "string" }, "hash": { - "description": "The hash of the artifact.", + "description": "The hash of the repository", "type": "string", "format": "hex string (32 bytes)" }, - "id": { - "description": "The artifact ID.", - "allOf": [ - { - "$ref": "#/components/schemas/ArtifactId" - } - ] - }, - "sign": { - "nullable": true, - "description": "Contents of the `SIGN` field of a Hubris archive caboose, i.e., an identifier for the set of valid signing keys. Currently only applicable to RoT image and bootloader artifacts, where it will be an LPC55 Root Key Table Hash (RKTH).", - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0 - } + "system_version": { + "description": "The system version for this repository\n\nThe system version is a top-level version number applied to all the software in the repository.", + "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-]+)*))?$" }, - "size": { - "description": "The size of the artifact in bytes.", - "type": "integer", - "format": "uint64", - "minimum": 0 + "time_created": { + "description": "Time the repository was uploaded", + "type": "string", + "format": "date-time" } }, "required": [ + "file_name", "hash", - "id", - "size" + "system_version", + "time_created" ] }, - "TufRepoDescription": { - "description": "A description of an uploaded TUF repository.", + "TufRepoResultsPage": { + "description": "A single page of results", "type": "object", "properties": { - "artifacts": { - "description": "Information about the artifacts present in the repository.", + "items": { + "description": "list of items on this page of results", "type": "array", "items": { - "$ref": "#/components/schemas/TufArtifactMeta" + "$ref": "#/components/schemas/TufRepo" } }, - "repo": { - "description": "Information about the repository.", - "allOf": [ - { - "$ref": "#/components/schemas/TufRepoMeta" - } - ] - } - }, - "required": [ - "artifacts", - "repo" - ] - }, - "TufRepoGetResponse": { - "description": "Data about a successful TUF repo get from Nexus.", - "type": "object", - "properties": { - "description": { - "description": "The description of the repository.", - "allOf": [ - { - "$ref": "#/components/schemas/TufRepoDescription" - } - ] + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" } }, "required": [ - "description" + "items" ] }, - "TufRepoInsertResponse": { - "description": "Data about a successful TUF repo import into Nexus.", + "TufRepoUpload": { "type": "object", "properties": { - "recorded": { - "description": "The repository as present in the database.", - "allOf": [ - { - "$ref": "#/components/schemas/TufRepoDescription" - } - ] + "repo": { + "$ref": "#/components/schemas/TufRepo" }, "status": { - "description": "Whether this repository already existed or is new.", - "allOf": [ - { - "$ref": "#/components/schemas/TufRepoInsertStatus" - } - ] + "$ref": "#/components/schemas/TufRepoUploadStatus" } }, "required": [ - "recorded", + "repo", "status" ] }, - "TufRepoInsertStatus": { - "description": "Status of a TUF repo import.\n\nPart of `TufRepoInsertResponse`.", + "TufRepoUploadStatus": { + "description": "Whether the uploaded TUF repo already existed or was new and had to be inserted. Part of `TufRepoUpload`.", "oneOf": [ { - "description": "The repository already existed in the database.", + "description": "The repository already existed in the database", "type": "string", "enum": [ "already_exists" ] }, { - "description": "The repository did not exist, and was inserted into the database.", + "description": "The repository did not exist, and was inserted into the database", "type": "string", "enum": [ "inserted" @@ -26213,44 +26202,6 @@ } ] }, - "TufRepoMeta": { - "description": "Metadata about a TUF repository.\n\nFound within a `TufRepoDescription`.", - "type": "object", - "properties": { - "file_name": { - "description": "The file name of the repository.\n\nThis is purely used for debugging and may not always be correct (e.g. with wicket, we read the file contents from stdin so we don't know the correct file name).", - "type": "string" - }, - "hash": { - "description": "The hash of the repository.\n\nThis is a slight abuse of `ArtifactHash`, since that's the hash of individual artifacts within the repository. However, we use it here for convenience.", - "type": "string", - "format": "hex string (32 bytes)" - }, - "system_version": { - "description": "The system version in artifacts.json.", - "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-]+)*))?$" - }, - "targets_role_version": { - "description": "The version of the targets role.", - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "valid_until": { - "description": "The time until which the repo is valid.", - "type": "string", - "format": "date-time" - } - }, - "required": [ - "file_name", - "hash", - "system_version", - "targets_role_version", - "valid_until" - ] - }, "TxEqConfig": { "description": "Per-port tx-eq overrides. This can be used to fine-tune the transceiver equalization settings to improve signal integrity.", "type": "object", @@ -28217,6 +28168,25 @@ "descending" ] }, + "VersionSortMode": { + "description": "Supported sort modes when scanning by semantic version", + "oneOf": [ + { + "description": "Sort in increasing semantic version order (oldest first)", + "type": "string", + "enum": [ + "version_ascending" + ] + }, + { + "description": "Sort in decreasing semantic version order (newest first)", + "type": "string", + "enum": [ + "version_descending" + ] + } + ] + }, "NameSortMode": { "description": "Supported set of sort modes for scanning by name only\n\nCurrently, we only support scanning in ascending order.", "oneOf": [