From 6ae58a4b3b17bc9f08f077cfbba54d0c5b2d2a67 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 25 Sep 2025 10:01:18 -0500 Subject: [PATCH 01/14] tuf repo list endpoint --- nexus/db-queries/src/db/datastore/update.rs | 40 ++++++++++ nexus/external-api/output/nexus_tags.txt | 1 + nexus/external-api/src/lib.rs | 14 ++++ nexus/src/app/update.rs | 12 +++ nexus/src/external_api/http_entrypoints.rs | 59 +++++++++++++++ openapi/nexus.json | 81 +++++++++++++++++++++ 6 files changed, 207 insertions(+) diff --git a/nexus/db-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index dabe70f5100..0489f536901 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -6,11 +6,14 @@ use std::collections::HashMap; +use chrono::{DateTime, Utc}; + use super::DataStore; use crate::authz; use crate::context::OpContext; use crate::db::model::SemverVersion; use crate::db::pagination::paginated; +use crate::db::pagination::paginated_multicolumn; use async_bb8_diesel::AsyncRunQueryDsl; use diesel::prelude::*; use diesel::result::Error as DieselError; @@ -206,6 +209,43 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + /// List all TUF repositories ordered by creation time (descending). + pub async fn tuf_repo_list( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, (DateTime, Uuid)>, + ) -> ListResultVec { + opctx.authorize(authz::Action::Read, &authz::FLEET).await?; + + use nexus_db_schema::schema::tuf_repo::dsl; + + let conn = self.pool_connection_authorized(opctx).await?; + + // Order by time_created descending (newest first), then by id for stable pagination + let repos = paginated_multicolumn( + dsl::tuf_repo, + (dsl::time_created, dsl::id), + pagparams, + ) + .order_by((dsl::time_created.desc(), dsl::id)) + .select(TufRepo::as_select()) + .load_async(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + // For each repo, fetch its artifacts + let mut results = Vec::new(); + for repo in repos { + let artifacts = + artifacts_for_repo(repo.id.into(), &conn).await.map_err( + |e| public_error_from_diesel(e, ErrorHandler::Server), + )?; + results.push(TufRepoDescription { repo, artifacts }); + } + + Ok(results) + } + /// List the trusted TUF root roles in the trust store. pub async fn tuf_trust_root_list( &self, diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 4d3daee3807..b4f170c757c 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -298,6 +298,7 @@ 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_trust_root_create POST /v1/system/update/trust-roots system_update_trust_root_delete DELETE /v1/system/update/trust-roots/{trust_root_id} system_update_trust_root_list GET /v1/system/update/trust-roots diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 60506ab0b5e..742283151e7 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -2988,6 +2988,20 @@ pub trait NexusExternalApi { path_params: Path, ) -> Result, HttpError>; + /// List all TUF repositories + /// + /// Returns a paginated list of all TUF repositories ordered by creation time + /// (descending), with the most recently created repositories appearing first. + #[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 /// /// A root role is a JSON document describing the cryptographic keys that diff --git a/nexus/src/app/update.rs b/nexus/src/app/update.rs index c4fb9428a47..4af6e0e5ee4 100644 --- a/nexus/src/app/update.rs +++ b/nexus/src/app/update.rs @@ -5,6 +5,7 @@ //! Software Updates use bytes::Bytes; +use chrono::{DateTime, Utc}; use dropshot::HttpError; use futures::Stream; use nexus_auth::authz; @@ -101,6 +102,17 @@ impl super::Nexus { .map_err(HttpError::from) } + pub(crate) async fn updates_list_repositories( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, (DateTime, Uuid)>, + ) -> Result, HttpError> { + self.db_datastore + .tuf_repo_list(opctx, pagparams) + .await + .map_err(HttpError::from) + } + pub(crate) async fn updates_add_trust_root( &self, opctx: &OpContext, diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index a569203d8c4..825131eb6a6 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -17,6 +17,7 @@ use crate::app::external_endpoints::authority_for_request; use crate::app::support_bundles::SupportBundleQueryType; use crate::context::ApiContext; use crate::external_api::shared; +use chrono::{DateTime, Utc}; use dropshot::Body; use dropshot::EmptyScanParams; use dropshot::Header; @@ -113,6 +114,7 @@ use propolis_client::support::tungstenite::protocol::{ }; use range_requests::PotentialRange; use ref_cast::RefCast; +use uuid::Uuid; type NexusApiDescription = ApiDescription; @@ -6687,6 +6689,63 @@ impl NexusExternalApi for NexusExternalApiImpl { .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?; + + // Create a helper struct to maintain the association between response and database info + struct TufRepoWithMeta { + response: TufRepoGetResponse, + time_created: DateTime, + repo_id: Uuid, + } + + let items: Vec = repos + .into_iter() + .map(|description| { + let time_created = description.repo.time_created; + let repo_id = description.repo.id.into_untyped_uuid(); + let response = TufRepoGetResponse { + description: description.into_external(), + }; + TufRepoWithMeta { response, time_created, repo_id } + }) + .collect(); + + let responses: Vec = + items.iter().map(|item| item.response.clone()).collect(); + + Ok(HttpResponseOk(ScanByTimeAndId::results_page( + &query, + responses, + &|_scan_params, item: &TufRepoGetResponse| { + // Find the corresponding metadata for this response + let meta = items + .iter() + .find(|meta| std::ptr::eq(&meta.response, item)) + .expect("Response should have corresponding metadata"); + (meta.time_created, meta.repo_id) + }, + )?)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + async fn system_update_trust_root_list( rqctx: RequestContext, query_params: Query, diff --git a/openapi/nexus.json b/openapi/nexus.json index d88de5977b9..300c98fe798 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -10903,6 +10903,66 @@ } } }, + "/v1/system/update/repositories": { + "get": { + "tags": [ + "system/update" + ], + "summary": "List all TUF repositories", + "description": "Returns a paginated list of all TUF repositories ordered by creation time (descending), with the most recently created repositories appearing first.", + "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/TimeAndIdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TufRepoGetResponseResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, "/v1/system/update/repository": { "put": { "tags": [ @@ -25969,6 +26029,27 @@ "description" ] }, + "TufRepoGetResponseResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/TufRepoGetResponse" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "TufRepoInsertResponse": { "description": "Data about a successful TUF repo import into Nexus.", "type": "object", From 3fc3169a864f87208313df7d0b734605c6390025 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 25 Sep 2025 12:16:29 -0500 Subject: [PATCH 02/14] add integration test for tuf repo list --- nexus/db-queries/src/db/datastore/update.rs | 1 - nexus/tests/integration_tests/updates.rs | 186 +++++++++++++++----- 2 files changed, 142 insertions(+), 45 deletions(-) diff --git a/nexus/db-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index 0489f536901..7e6f4ab6f56 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -227,7 +227,6 @@ impl DataStore { (dsl::time_created, dsl::id), pagparams, ) - .order_by((dsl::time_created.desc(), dsl::id)) .select(TufRepo::as_select()) .load_async(&*conn) .await diff --git a/nexus/tests/integration_tests/updates.rs b/nexus/tests/integration_tests/updates.rs index 0f55a3e7f86..ef3904aa0d3 100644 --- a/nexus/tests/integration_tests/updates.rs +++ b/nexus/tests/integration_tests/updates.rs @@ -12,6 +12,9 @@ use nexus_db_queries::context::OpContext; 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, object_get_error, objects_list_page_authz, +}; use nexus_test_utils::test_setup; use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::views::UpdatesTrustRoot; @@ -19,7 +22,6 @@ use omicron_common::api::external::{ TufRepoGetResponse, TufRepoInsertResponse, TufRepoInsertStatus, }; use pretty_assertions::assert_eq; -use semver::Version; use serde::Deserialize; use std::collections::HashSet; use std::fmt::Debug; @@ -161,16 +163,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/repository/1.0.0", + StatusCode::NOT_FOUND, + ) + .await; cptestctx.teardown().await; Ok(()) @@ -306,18 +304,11 @@ async fn test_repo_upload() -> Result<()> { // Now get the repository that was just uploaded. let mut get_description = { - let response = make_get_request( + let response = object_get::( client, - "1.0.0".parse().unwrap(), // this is the system version of the fake manifest - StatusCode::OK, + "/v1/system/update/repository/1.0.0", ) - .execute() - .await - .context("error fetching repository")?; - - let response = - serde_json::from_slice::(&response.body) - .context("error deserializing response body")?; + .await; response.description }; @@ -494,12 +485,11 @@ async fn test_repo_upload() -> Result<()> { // 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 response = object_get::( + client, + "/v1/system/update/repository/2.0.0", + ) + .await; let mut get_description = response.description; get_description.sort_artifacts(); @@ -528,23 +518,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, @@ -625,3 +598,128 @@ async fn test_trust_root_operations(cptestctx: &ControlPlaneTestContext) { .expect("failed to parse list after delete response"); assert!(response.items.is_empty()); } + +#[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, TufRepoInsertStatus::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, TufRepoInsertStatus::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, TufRepoInsertStatus::Inserted); + + // List repositories - should return all 3, ordered by creation time (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 creation time descending (newest first) + // Since repo3 was created last, it should be first in the list + let system_versions: Vec = list + .items + .iter() + .map(|item| item.description.repo.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 artifacts + 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.description.repo.system_version.to_string(), + expected_version + ); + + // Verify artifacts are present (should have Zone artifacts) + let zone_artifacts: Vec<_> = item + .description + .artifacts + .iter() + .filter(|artifact| { + artifact.id.kind == KnownArtifactKind::Zone.into() + }) + .collect(); + assert_eq!(zone_artifacts.len(), 2, "should have 2 zone artifacts"); + + // Should not have ControlPlane artifacts (they get decomposed into Zones) + assert!(!item.description.artifacts.iter().any(|artifact| { + artifact.id.kind == KnownArtifactKind::ControlPlane.into() + })); + } + + // 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.description.repo.system_version.to_string()) + .collect(); + assert_eq!(paginated_versions, vec!["3.0.0", "2.0.0"]); + + cptestctx.teardown().await; + Ok(()) +} From 19fd93eb9d8c9671dd056f065fc897ed415d02a1 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 25 Sep 2025 16:17:58 -0500 Subject: [PATCH 03/14] switch to paginating by version --- common/src/api/external/http_pagination.rs | 56 +++++++++++++++++++++ nexus/db-queries/src/db/datastore/update.rs | 35 +++++++------ nexus/external-api/src/lib.rs | 8 +-- nexus/src/app/update.rs | 3 +- nexus/src/external_api/http_entrypoints.rs | 36 +++---------- nexus/tests/integration_tests/updates.rs | 35 +++++++++++-- openapi/nexus.json | 23 ++++++++- 7 files changed, 142 insertions(+), 54 deletions(-) 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/nexus/db-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index 7e6f4ab6f56..127c6f927bd 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -6,14 +6,11 @@ use std::collections::HashMap; -use chrono::{DateTime, Utc}; - use super::DataStore; use crate::authz; use crate::context::OpContext; use crate::db::model::SemverVersion; use crate::db::pagination::paginated; -use crate::db::pagination::paginated_multicolumn; use async_bb8_diesel::AsyncRunQueryDsl; use diesel::prelude::*; use diesel::result::Error as DieselError; @@ -31,6 +28,7 @@ use omicron_common::api::external::{ use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::TufRepoKind; use omicron_uuid_kinds::TypedUuid; +use semver::Version; use swrite::{SWrite, swrite}; use tufaceous_artifact::ArtifactVersion; use uuid::Uuid; @@ -209,11 +207,11 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } - /// List all TUF repositories ordered by creation time (descending). + /// List all TUF repositories ordered by system version (newest first by default). pub async fn tuf_repo_list( &self, opctx: &OpContext, - pagparams: &DataPageParams<'_, (DateTime, Uuid)>, + pagparams: &DataPageParams<'_, Version>, ) -> ListResultVec { opctx.authorize(authz::Action::Read, &authz::FLEET).await?; @@ -221,16 +219,23 @@ impl DataStore { let conn = self.pool_connection_authorized(opctx).await?; - // Order by time_created descending (newest first), then by id for stable pagination - let repos = paginated_multicolumn( - dsl::tuf_repo, - (dsl::time_created, dsl::id), - pagparams, - ) - .select(TufRepo::as_select()) - .load_async(&*conn) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + 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, + }; + + let repos = + paginated(dsl::tuf_repo, dsl::system_version, &db_pagparams) + .select(TufRepo::as_select()) + .load_async(&*conn) + .await + .map_err(|e| { + public_error_from_diesel(e, ErrorHandler::Server) + })?; // For each repo, fetch its artifacts let mut results = Vec::new(); diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 742283151e7..eec7bf5a3e4 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -23,7 +23,7 @@ use nexus_types::{ use omicron_common::api::external::{ http_pagination::{ PaginatedById, PaginatedByName, PaginatedByNameOrId, - PaginatedByTimeAndId, + PaginatedByTimeAndId, PaginatedByVersion, }, *, }; @@ -2990,8 +2990,8 @@ pub trait NexusExternalApi { /// List all TUF repositories /// - /// Returns a paginated list of all TUF repositories ordered by creation time - /// (descending), with the most recently created repositories appearing first. + /// Returns a paginated list of all TUF repositories ordered by system + /// version (newest first by default). #[endpoint { method = GET, path = "/v1/system/update/repositories", @@ -2999,7 +2999,7 @@ pub trait NexusExternalApi { }] async fn system_update_repository_list( rqctx: RequestContext, - query_params: Query, + 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 4af6e0e5ee4..fc45cf37380 100644 --- a/nexus/src/app/update.rs +++ b/nexus/src/app/update.rs @@ -5,7 +5,6 @@ //! Software Updates use bytes::Bytes; -use chrono::{DateTime, Utc}; use dropshot::HttpError; use futures::Stream; use nexus_auth::authz; @@ -105,7 +104,7 @@ impl super::Nexus { pub(crate) async fn updates_list_repositories( &self, opctx: &OpContext, - pagparams: &DataPageParams<'_, (DateTime, Uuid)>, + pagparams: &DataPageParams<'_, Version>, ) -> Result, HttpError> { self.db_datastore .tuf_repo_list(opctx, pagparams) diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 825131eb6a6..5887d93e29d 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -17,7 +17,6 @@ use crate::app::external_endpoints::authority_for_request; use crate::app::support_bundles::SupportBundleQueryType; use crate::context::ApiContext; use crate::external_api::shared; -use chrono::{DateTime, Utc}; use dropshot::Body; use dropshot::EmptyScanParams; use dropshot::Header; @@ -95,10 +94,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; @@ -114,7 +115,6 @@ use propolis_client::support::tungstenite::protocol::{ }; use range_requests::PotentialRange; use ref_cast::RefCast; -use uuid::Uuid; type NexusApiDescription = ApiDescription; @@ -6691,7 +6691,7 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn system_update_repository_list( rqctx: RequestContext, - query_params: Query, + query_params: Query, ) -> Result>, HttpError> { let apictx = rqctx.context(); @@ -6704,38 +6704,18 @@ impl NexusExternalApi for NexusExternalApiImpl { let repos = nexus.updates_list_repositories(&opctx, &pagparams).await?; - // Create a helper struct to maintain the association between response and database info - struct TufRepoWithMeta { - response: TufRepoGetResponse, - time_created: DateTime, - repo_id: Uuid, - } - - let items: Vec = repos + let responses: Vec = repos .into_iter() - .map(|description| { - let time_created = description.repo.time_created; - let repo_id = description.repo.id.into_untyped_uuid(); - let response = TufRepoGetResponse { - description: description.into_external(), - }; - TufRepoWithMeta { response, time_created, repo_id } + .map(|description| TufRepoGetResponse { + description: description.into_external(), }) .collect(); - let responses: Vec = - items.iter().map(|item| item.response.clone()).collect(); - - Ok(HttpResponseOk(ScanByTimeAndId::results_page( + Ok(HttpResponseOk(ScanByVersion::results_page( &query, responses, &|_scan_params, item: &TufRepoGetResponse| { - // Find the corresponding metadata for this response - let meta = items - .iter() - .find(|meta| std::ptr::eq(&meta.response, item)) - .expect("Response should have corresponding metadata"); - (meta.time_created, meta.repo_id) + item.description.repo.system_version.clone() }, )?)) }; diff --git a/nexus/tests/integration_tests/updates.rs b/nexus/tests/integration_tests/updates.rs index ef3904aa0d3..0c8bc7cd3c6 100644 --- a/nexus/tests/integration_tests/updates.rs +++ b/nexus/tests/integration_tests/updates.rs @@ -657,14 +657,13 @@ async fn test_repo_list() -> Result<()> { .context("error deserializing third response body")?; assert_eq!(response3.status, TufRepoInsertStatus::Inserted); - // List repositories - should return all 3, ordered by creation time (newest first) + // 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 creation time descending (newest first) - // Since repo3 was created last, it should be first in the list + // Repositories should be ordered by system version descending (newest first) let system_versions: Vec = list .items .iter() @@ -702,6 +701,23 @@ async fn test_repo_list() -> Result<()> { })); } + // Request ascending order and expect the versions oldest-first + let ascending_list: ResultsPage = + objects_list_page_authz( + client, + "/v1/system/update/repositories?sort_by=ascending", + ) + .await; + + assert_eq!(ascending_list.items.len(), 3); + + let ascending_versions: Vec = ascending_list + .items + .iter() + .map(|item| item.description.repo.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, @@ -720,6 +736,19 @@ async fn test_repo_list() -> Result<()> { .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].description.repo.system_version.to_string(), + "1.0.0" + ); + cptestctx.teardown().await; Ok(()) } diff --git a/openapi/nexus.json b/openapi/nexus.json index 300c98fe798..54e2516c6da 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -10909,7 +10909,7 @@ "system/update" ], "summary": "List all TUF repositories", - "description": "Returns a paginated list of all TUF repositories ordered by creation time (descending), with the most recently created repositories appearing first.", + "description": "Returns a paginated list of all TUF repositories ordered by system version (newest first by default).", "operationId": "system_update_repository_list", "parameters": [ { @@ -10936,7 +10936,7 @@ "in": "query", "name": "sort_by", "schema": { - "$ref": "#/components/schemas/TimeAndIdSortMode" + "$ref": "#/components/schemas/VersionSortMode" } } ], @@ -28061,6 +28061,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": [ From 39d45abb882cf8cd5356e471c4b27ff6ece0de68 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 25 Sep 2025 18:16:41 -0500 Subject: [PATCH 04/14] do artifacts fetch in a single query (still bad) --- nexus/db-queries/src/db/datastore/update.rs | 51 ++++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/nexus/db-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index 127c6f927bd..077e6db8877 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -215,7 +215,9 @@ impl DataStore { ) -> ListResultVec { opctx.authorize(authz::Action::Read, &authz::FLEET).await?; - use nexus_db_schema::schema::tuf_repo::dsl; + use nexus_db_schema::schema::tuf_artifact; + use nexus_db_schema::schema::tuf_repo; + use nexus_db_schema::schema::tuf_repo_artifact; let conn = self.pool_connection_authorized(opctx).await?; @@ -228,8 +230,9 @@ impl DataStore { limit: pagparams.limit, }; + // First get the paginated repos let repos = - paginated(dsl::tuf_repo, dsl::system_version, &db_pagparams) + paginated(tuf_repo::table, tuf_repo::system_version, &db_pagparams) .select(TufRepo::as_select()) .load_async(&*conn) .await @@ -237,13 +240,47 @@ impl DataStore { public_error_from_diesel(e, ErrorHandler::Server) })?; - // For each repo, fetch its artifacts + if repos.is_empty() { + return Ok(Vec::new()); + } + + // Get all repo IDs for the artifacts query + let repo_ids: Vec<_> = repos.iter().map(|repo| repo.id).collect(); + + // Fetch all artifacts for these repos in a single query + let repo_artifacts: Vec<(TufRepo, TufArtifact)> = + tuf_repo::table + .filter(tuf_repo::id.eq_any(repo_ids)) + .inner_join( + tuf_repo_artifact::table + .on(tuf_repo::id.eq(tuf_repo_artifact::tuf_repo_id)), + ) + .inner_join(tuf_artifact::table.on( + tuf_repo_artifact::tuf_artifact_id.eq(tuf_artifact::id), + )) + .select((TufRepo::as_select(), TufArtifact::as_select())) + .load_async(&*conn) + .await + .map_err(|e| { + public_error_from_diesel(e, ErrorHandler::Server) + })?; + + // Group artifacts by repo ID + let mut artifacts_by_repo: HashMap< + TypedUuid, + Vec, + > = HashMap::new(); + for (repo, artifact) in repo_artifacts { + artifacts_by_repo.entry(repo.id.into()).or_default().push(artifact); + } + + // Build the final results, maintaining the original pagination order let mut results = Vec::new(); for repo in repos { - let artifacts = - artifacts_for_repo(repo.id.into(), &conn).await.map_err( - |e| public_error_from_diesel(e, ErrorHandler::Server), - )?; + let artifacts = artifacts_by_repo + .get(&repo.id.into()) + .cloned() + .unwrap_or_default(); results.push(TufRepoDescription { repo, artifacts }); } From 7b5615dac40b6b4b9ad48afda4c7cf43cf8ed599 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Sep 2025 11:32:30 -0500 Subject: [PATCH 05/14] try doing views::TufRepo --- Cargo.lock | 1 + common/src/api/external/mod.rs | 7 -- nexus/db-queries/src/db/datastore/update.rs | 81 +++------------- nexus/external-api/Cargo.toml | 1 + nexus/external-api/src/lib.rs | 4 +- nexus/src/app/update.rs | 8 +- nexus/src/external_api/http_entrypoints.rs | 22 ++--- nexus/tests/integration_tests/updates.rs | 82 +++++++--------- nexus/types/src/external_api/views.rs | 46 ++++++++- openapi/nexus.json | 101 ++++++++++++-------- 10 files changed, 164 insertions(+), 189 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6ec05a8814..7447f6ed718 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6603,6 +6603,7 @@ dependencies = [ "openapiv3", "oximeter-types 0.1.0", "oxql-types", + "tufaceous-artifact", ] [[package]] diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 64b5d310b84..4cd7059e948 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -3521,13 +3521,6 @@ pub enum TufRepoInsertStatus { 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-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index 077e6db8877..d4680a5e63c 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -143,19 +143,19 @@ 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())) .select(TufRepo::as_select()) .first_async::(&*conn) @@ -168,12 +168,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 }) + }) } /// Returns the list of all TUF repo artifacts known to the system. @@ -207,17 +202,15 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } - /// List all TUF repositories ordered by system version (newest first by default). - pub async fn tuf_repo_list( + /// List all TUF repositories (without artifacts) ordered by system version (newest first by default). + pub async fn tuf_repo_list_no_artifacts( &self, opctx: &OpContext, pagparams: &DataPageParams<'_, Version>, - ) -> ListResultVec { + ) -> ListResultVec { opctx.authorize(authz::Action::Read, &authz::FLEET).await?; - use nexus_db_schema::schema::tuf_artifact; use nexus_db_schema::schema::tuf_repo; - use nexus_db_schema::schema::tuf_repo_artifact; let conn = self.pool_connection_authorized(opctx).await?; @@ -230,61 +223,11 @@ impl DataStore { limit: pagparams.limit, }; - // First get the paginated repos - let repos = - paginated(tuf_repo::table, tuf_repo::system_version, &db_pagparams) - .select(TufRepo::as_select()) - .load_async(&*conn) - .await - .map_err(|e| { - public_error_from_diesel(e, ErrorHandler::Server) - })?; - - if repos.is_empty() { - return Ok(Vec::new()); - } - - // Get all repo IDs for the artifacts query - let repo_ids: Vec<_> = repos.iter().map(|repo| repo.id).collect(); - - // Fetch all artifacts for these repos in a single query - let repo_artifacts: Vec<(TufRepo, TufArtifact)> = - tuf_repo::table - .filter(tuf_repo::id.eq_any(repo_ids)) - .inner_join( - tuf_repo_artifact::table - .on(tuf_repo::id.eq(tuf_repo_artifact::tuf_repo_id)), - ) - .inner_join(tuf_artifact::table.on( - tuf_repo_artifact::tuf_artifact_id.eq(tuf_artifact::id), - )) - .select((TufRepo::as_select(), TufArtifact::as_select())) - .load_async(&*conn) - .await - .map_err(|e| { - public_error_from_diesel(e, ErrorHandler::Server) - })?; - - // Group artifacts by repo ID - let mut artifacts_by_repo: HashMap< - TypedUuid, - Vec, - > = HashMap::new(); - for (repo, artifact) in repo_artifacts { - artifacts_by_repo.entry(repo.id.into()).or_default().push(artifact); - } - - // Build the final results, maintaining the original pagination order - let mut results = Vec::new(); - for repo in repos { - let artifacts = artifacts_by_repo - .get(&repo.id.into()) - .cloned() - .unwrap_or_default(); - results.push(TufRepoDescription { repo, artifacts }); - } - - Ok(results) + paginated(tuf_repo::table, tuf_repo::system_version, &db_pagparams) + .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. diff --git a/nexus/external-api/Cargo.toml b/nexus/external-api/Cargo.toml index 4cfaf76445f..28a2baebd4c 100644 --- a/nexus/external-api/Cargo.toml +++ b/nexus/external-api/Cargo.toml @@ -22,3 +22,4 @@ openapi-manager-types.workspace = true oximeter-types.workspace = true oxql-types.workspace = true omicron-uuid-kinds.workspace = true +tufaceous-artifact.workspace = true diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index eec7bf5a3e4..c0f3b6d83b1 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -2986,7 +2986,7 @@ pub trait NexusExternalApi { async fn system_update_get_repository( rqctx: RequestContext, path_params: Path, - ) -> Result, HttpError>; + ) -> Result, HttpError>; /// List all TUF repositories /// @@ -3000,7 +3000,7 @@ pub trait NexusExternalApi { async fn system_update_repository_list( rqctx: RequestContext, query_params: Query, - ) -> Result>, HttpError>; + ) -> 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 fc45cf37380..37550b51527 100644 --- a/nexus/src/app/update.rs +++ b/nexus/src/app/update.rs @@ -9,7 +9,7 @@ use dropshot::HttpError; use futures::Stream; use nexus_auth::authz; use nexus_db_lookup::LookupPath; -use nexus_db_model::{TufRepoDescription, TufTrustRoot}; +use nexus_db_model::TufTrustRoot; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::{datastore::SQL_BATCH_SIZE, pagination::Paginator}; use nexus_types::external_api::shared::TufSignedRootRole; @@ -94,7 +94,7 @@ impl super::Nexus { &self, opctx: &OpContext, system_version: Version, - ) -> Result { + ) -> Result { self.db_datastore .tuf_repo_get_by_version(opctx, system_version.into()) .await @@ -105,9 +105,9 @@ impl super::Nexus { &self, opctx: &OpContext, pagparams: &DataPageParams<'_, Version>, - ) -> Result, HttpError> { + ) -> Result, HttpError> { self.db_datastore - .tuf_repo_list(opctx, pagparams) + .tuf_repo_list_no_artifacts(opctx, pagparams) .await .map_err(HttpError::from) } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 5887d93e29d..55d7461fc63 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -85,7 +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; @@ -6668,19 +6667,17 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn system_update_get_repository( 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_external().into())) }; apictx .context @@ -6692,7 +6689,7 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn system_update_repository_list( rqctx: RequestContext, query_params: Query, - ) -> Result>, HttpError> + ) -> Result>, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.context.nexus; @@ -6704,18 +6701,16 @@ impl NexusExternalApi for NexusExternalApiImpl { let repos = nexus.updates_list_repositories(&opctx, &pagparams).await?; - let responses: Vec = repos + let responses: Vec = repos .into_iter() - .map(|description| TufRepoGetResponse { - description: description.into_external(), - }) + .map(|repo| repo.into_external().into()) .collect(); Ok(HttpResponseOk(ScanByVersion::results_page( &query, responses, - &|_scan_params, item: &TufRepoGetResponse| { - item.description.repo.system_version.clone() + &|_scan_params, item: &views::TufRepo| { + item.system_version.clone() }, )?)) }; @@ -6909,7 +6904,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/updates.rs b/nexus/tests/integration_tests/updates.rs index 0c8bc7cd3c6..febe6666967 100644 --- a/nexus/tests/integration_tests/updates.rs +++ b/nexus/tests/integration_tests/updates.rs @@ -17,9 +17,9 @@ use nexus_test_utils::resource_helpers::{ }; use nexus_test_utils::test_setup; use nexus_test_utils_macros::nexus_test; -use nexus_types::external_api::views::UpdatesTrustRoot; +use nexus_types::external_api::views::{TufRepo, UpdatesTrustRoot}; use omicron_common::api::external::{ - TufRepoGetResponse, TufRepoInsertResponse, TufRepoInsertStatus, + TufRepoInsertResponse, TufRepoInsertStatus, }; use pretty_assertions::assert_eq; use serde::Deserialize; @@ -303,20 +303,23 @@ async fn test_repo_upload() -> Result<()> { ); // Now get the repository that was just uploaded. - let mut get_description = { - let response = object_get::( - client, - "/v1/system/update/repository/1.0.0", - ) - .await; - response.description - }; - - get_description.sort_artifacts(); + let get_repo = + object_get::(client, "/v1/system/update/repository/1.0.0") + .await; + // Compare just the repo metadata (not artifacts) + assert_eq!( + initial_description.repo.hash, + get_repo.hash.into(), + "repo hash matches" + ); + assert_eq!( + initial_description.repo.system_version, get_repo.system_version, + "system version matches" + ); assert_eq!( - initial_description, get_description, - "initial description matches fetched description" + initial_description.repo.valid_until, get_repo.valid_until, + "valid_until matches" ); // Upload a new repository with the same system version but a different @@ -483,20 +486,15 @@ 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 = object_get::( + // Now get the repository that was just uploaded. + let get_repo = object_get::( client, "/v1/system/update/repository/2.0.0", ) .await; - let mut get_description = response.description; - get_description.sort_artifacts(); - 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"); } // The installinator document changed, so the generation number is bumped to // 3. @@ -610,7 +608,7 @@ async fn test_repo_list() -> Result<()> { let logctx = &cptestctx.logctx; // Initially, list should be empty - let initial_list: ResultsPage = + 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()); @@ -658,7 +656,7 @@ async fn test_repo_list() -> Result<()> { assert_eq!(response3.status, TufRepoInsertStatus::Inserted); // List repositories - should return all 3, ordered by system version (newest first) - let list: ResultsPage = + let list: ResultsPage = objects_list_page_authz(client, "/v1/system/update/repositories").await; assert_eq!(list.items.len(), 3); @@ -667,11 +665,11 @@ async fn test_repo_list() -> Result<()> { let system_versions: Vec = list .items .iter() - .map(|item| item.description.repo.system_version.to_string()) + .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 artifacts + // 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", @@ -680,29 +678,13 @@ async fn test_repo_list() -> Result<()> { _ => panic!("unexpected index"), }; assert_eq!( - item.description.repo.system_version.to_string(), + item.system_version.to_string(), expected_version ); - - // Verify artifacts are present (should have Zone artifacts) - let zone_artifacts: Vec<_> = item - .description - .artifacts - .iter() - .filter(|artifact| { - artifact.id.kind == KnownArtifactKind::Zone.into() - }) - .collect(); - assert_eq!(zone_artifacts.len(), 2, "should have 2 zone artifacts"); - - // Should not have ControlPlane artifacts (they get decomposed into Zones) - assert!(!item.description.artifacts.iter().any(|artifact| { - artifact.id.kind == KnownArtifactKind::ControlPlane.into() - })); } // Request ascending order and expect the versions oldest-first - let ascending_list: ResultsPage = + let ascending_list: ResultsPage = objects_list_page_authz( client, "/v1/system/update/repositories?sort_by=ascending", @@ -714,12 +696,12 @@ async fn test_repo_list() -> Result<()> { let ascending_versions: Vec = ascending_list .items .iter() - .map(|item| item.description.repo.system_version.to_string()) + .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::( + let paginated_list = objects_list_page_authz::( client, "/v1/system/update/repositories?limit=2", ) @@ -732,7 +714,7 @@ async fn test_repo_list() -> Result<()> { let paginated_versions: Vec = paginated_list .items .iter() - .map(|item| item.description.repo.system_version.to_string()) + .map(|item| item.system_version.to_string()) .collect(); assert_eq!(paginated_versions, vec!["3.0.0", "2.0.0"]); @@ -741,11 +723,11 @@ async fn test_repo_list() -> Result<()> { "/v1/system/update/repositories?limit=2&page_token={}", paginated_list.next_page.clone().expect("expected next page token"), ); - let next_page: ResultsPage = + 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].description.repo.system_version.to_string(), + next_page.items[0].system_version.to_string(), "1.0.0" ); diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 321b29bd12d..d5ac081eb25 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -15,9 +15,9 @@ use chrono::Utc; use daft::Diffable; pub use omicron_common::api::external::IpVersion; use omicron_common::api::external::{ - AffinityPolicy, AllowedSourceIps as ExternalAllowedSourceIps, ByteCount, - Digest, Error, FailureDomain, IdentityMetadata, InstanceState, Name, - ObjectIdentity, SimpleIdentity, SimpleIdentityOrName, + self, AffinityPolicy, AllowedSourceIps as ExternalAllowedSourceIps, + ByteCount, Digest, Error, FailureDomain, IdentityMetadata, InstanceState, + Name, ObjectIdentity, SimpleIdentity, SimpleIdentityOrName, }; use omicron_uuid_kinds::*; use oxnet::{Ipv4Net, Ipv6Net}; @@ -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; @@ -1571,6 +1572,45 @@ pub struct UpdatesTrustRoot { pub root_role: TufSignedRootRole, } +/// 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 version of the targets role + pub targets_role_version: u64, + + /// The time until which the repo is valid + pub valid_until: DateTime, + + /// The system version in artifacts.json + pub system_version: Version, + + /// The file name of the repository + /// + /// This 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). + pub file_name: String, +} + +impl From for TufRepo { + fn from(meta: external::TufRepoMeta) -> Self { + Self { + hash: meta.hash, + targets_role_version: meta.targets_role_version, + valid_until: meta.valid_until, + system_version: meta.system_version, + file_name: meta.file_name, + } + } +} + 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 54e2516c6da..c1e68c3fba4 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -10946,7 +10946,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TufRepoGetResponseResultsPage" + "$ref": "#/components/schemas/TufRepoResultsPage" } } } @@ -11038,7 +11038,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TufRepoGetResponse" + "$ref": "#/components/schemas/TufRepo" } } } @@ -25987,6 +25987,44 @@ "size" ] }, + "TufRepo": { + "description": "Metadata about a TUF repository", + "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" + ] + }, "TufRepoDescription": { "description": "A description of an uploaded TUF repository.", "type": "object", @@ -26012,44 +26050,6 @@ "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" - } - ] - } - }, - "required": [ - "description" - ] - }, - "TufRepoGetResponseResultsPage": { - "description": "A single page of results", - "type": "object", - "properties": { - "items": { - "description": "list of items on this page of results", - "type": "array", - "items": { - "$ref": "#/components/schemas/TufRepoGetResponse" - } - }, - "next_page": { - "nullable": true, - "description": "token used to fetch the next page of results (if any)", - "type": "string" - } - }, - "required": [ - "items" - ] - }, "TufRepoInsertResponse": { "description": "Data about a successful TUF repo import into Nexus.", "type": "object", @@ -26133,6 +26133,27 @@ "valid_until" ] }, + "TufRepoResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/TufRepo" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "TxEqConfig": { "description": "Per-port tx-eq overrides. This can be used to fine-tune the transceiver equalization settings to improve signal integrity.", "type": "object", From 69b78d6fb4b62865627dfbe1698682cabff122e8 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Sep 2025 11:32:30 -0500 Subject: [PATCH 06/14] replace TufRepoInsertResponse with views::TufRepoUpload (tests fail) --- common/src/api/external/mod.rs | 18 +- nexus/db-queries/src/db/datastore/mod.rs | 2 +- nexus/db-queries/src/db/datastore/update.rs | 9 +- nexus/external-api/src/lib.rs | 2 +- nexus/src/app/update.rs | 5 +- nexus/src/external_api/http_entrypoints.rs | 9 +- .../tests/integration_tests/target_release.rs | 13 +- nexus/tests/integration_tests/updates.rs | 106 +++++----- nexus/types/src/external_api/views.rs | 29 +++ openapi/nexus.json | 182 ++---------------- 10 files changed, 117 insertions(+), 258 deletions(-) diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 4cd7059e948..be06022996a 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -3421,6 +3421,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 { @@ -3495,20 +3499,7 @@ 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, )] @@ -3521,7 +3512,6 @@ pub enum TufRepoInsertStatus { Inserted, } - #[derive( Clone, Debug, Deserialize, JsonSchema, Serialize, PartialEq, ObjectIdentity, )] diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index 6180ee8fb0b..9209817ca25 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 d4680a5e63c..aefd5967836 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -21,6 +21,7 @@ use nexus_db_model::{ ArtifactHash, TufArtifact, TufRepo, TufRepoDescription, TufTrustRoot, to_db_typed_uuid, }; +use nexus_types::external_api::views; use omicron_common::api::external::{ self, CreateResult, DataPageParams, DeleteResult, Generation, ListResultVec, LookupResult, LookupType, ResourceType, TufRepoInsertStatus, @@ -43,10 +44,10 @@ pub struct TufRepoInsertResponse { } impl TufRepoInsertResponse { - pub fn into_external(self) -> external::TufRepoInsertResponse { - external::TufRepoInsertResponse { - recorded: self.recorded.into_external(), - status: self.status, + pub fn into_external(self) -> views::TufRepoUpload { + views::TufRepoUpload { + repo: self.recorded.repo.into_external().into(), + status: self.status.into(), } } } diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index c0f3b6d83b1..9d8b38bfb5e 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -2975,7 +2975,7 @@ pub trait NexusExternalApi { rqctx: RequestContext, query: Query, body: StreamingBody, - ) -> Result, HttpError>; + ) -> Result, HttpError>; /// Fetch system release repository description by version #[endpoint { diff --git a/nexus/src/app/update.rs b/nexus/src/app/update.rs index 37550b51527..b3d44e82f79 100644 --- a/nexus/src/app/update.rs +++ b/nexus/src/app/update.rs @@ -11,10 +11,11 @@ use nexus_auth::authz; use nexus_db_lookup::LookupPath; use nexus_db_model::TufTrustRoot; use nexus_db_queries::context::OpContext; +use nexus_db_queries::db::datastore::update::TufRepoInsertResponse; use nexus_db_queries::db::{datastore::SQL_BATCH_SIZE, pagination::Paginator}; use nexus_types::external_api::shared::TufSignedRootRole; use omicron_common::api::external::{ - DataPageParams, Error, TufRepoInsertResponse, TufRepoInsertStatus, + DataPageParams, Error, TufRepoInsertStatus, }; use omicron_uuid_kinds::{GenericUuid, TufTrustRootUuid}; use semver::Version; @@ -87,7 +88,7 @@ impl super::Nexus { self.background_tasks.task_tuf_artifact_replication.activate(); } - Ok(response.into_external()) + Ok(response) } pub(crate) async fn updates_get_repository( diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 55d7461fc63..9e28e4c22e7 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -85,7 +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::TufRepoInsertResponse; use omicron_common::api::external::VpcFirewallRuleUpdateParams; use omicron_common::api::external::VpcFirewallRules; use omicron_common::api::external::http_pagination::PaginatedBy; @@ -6644,7 +6643,7 @@ impl NexusExternalApi for NexusExternalApiImpl { rqctx: RequestContext, query: Query, body: StreamingBody, - ) -> Result, HttpError> { + ) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.context.nexus; let handler = async { @@ -6655,7 +6654,8 @@ impl NexusExternalApi for NexusExternalApiImpl { let update = nexus .updates_put_repository(&opctx, body, query.file_name) .await?; - Ok(HttpResponseOk(update)) + let repo_upload = update.into_external(); + Ok(HttpResponseOk(repo_upload)) }; apictx .context @@ -6689,8 +6689,7 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn system_update_repository_list( rqctx: RequestContext, query_params: Query, - ) -> Result>, HttpError> - { + ) -> Result>, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.context.nexus; let handler = async { diff --git a/nexus/tests/integration_tests/target_release.rs b/nexus/tests/integration_tests/target_release.rs index 68e6008c928..000d964177a 100644 --- a/nexus/tests/integration_tests/target_release.rs +++ b/nexus/tests/integration_tests/target_release.rs @@ -13,8 +13,9 @@ use nexus_test_utils::http_testing::AuthnMode; use nexus_test_utils::http_testing::{NexusRequest, RequestBuilder}; use nexus_test_utils::test_setup; use nexus_types::external_api::params::SetTargetReleaseParams; -use nexus_types::external_api::views::{TargetRelease, TargetReleaseSource}; -use omicron_common::api::external::TufRepoInsertResponse; +use nexus_types::external_api::views::{ + TargetRelease, TargetReleaseSource, TufRepoUpload, +}; use semver::Version; use tufaceous_artifact::{ArtifactVersion, KnownArtifactKind}; use tufaceous_lib::assemble::ManifestTweak; @@ -61,14 +62,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: 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); let target_release = set_target_release(client, system_version.clone()).await?; @@ -93,14 +94,14 @@ async fn get_set_target_release() -> Result<()> { version: ArtifactVersion::new("non-semver-2").unwrap(), }, ]; - let response: TufRepoInsertResponse = trust_root + let response: 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); let target_release = set_target_release(client, system_version.clone()).await?; diff --git a/nexus/tests/integration_tests/updates.rs b/nexus/tests/integration_tests/updates.rs index febe6666967..675488b2f0d 100644 --- a/nexus/tests/integration_tests/updates.rs +++ b/nexus/tests/integration_tests/updates.rs @@ -17,9 +17,8 @@ use nexus_test_utils::resource_helpers::{ }; use nexus_test_utils::test_setup; use nexus_test_utils_macros::nexus_test; -use nexus_types::external_api::views::{TufRepo, UpdatesTrustRoot}; -use omicron_common::api::external::{ - TufRepoInsertResponse, TufRepoInsertStatus, +use nexus_types::external_api::views::{ + TufRepo, TufRepoUpload, TufRepoUploadStatus, UpdatesTrustRoot, }; use pretty_assertions::assert_eq; use serde::Deserialize; @@ -199,20 +198,19 @@ 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 mut 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 + let unique_sha256_count = initial_repo .artifacts .iter() .map(|artifact| artifact.hash) @@ -221,7 +219,7 @@ async fn test_repo_upload() -> Result<()> { // The repository description should have `Zone` artifacts instead of the // composite `ControlPlane` artifact. assert_eq!( - initial_description + initial_repo .artifacts .iter() .filter_map(|artifact| { @@ -234,7 +232,7 @@ async fn test_repo_upload() -> Result<()> { .collect::>(), ["zone-1", "zone-2"] ); - assert!(!initial_description.artifacts.iter().any(|artifact| { + assert!(!initial_repo.artifacts.iter().any(|artifact| { artifact.id.kind == KnownArtifactKind::ControlPlane.into() })); // The generation number should now be 2. @@ -281,18 +279,17 @@ async fn test_repo_upload() -> Result<()> { .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(); + initial_repo.sort_artifacts(); reupload_description.sort_artifacts(); assert_eq!( - initial_description, reupload_description, + initial_repo, reupload_description, "initial description matches reupload" ); @@ -303,22 +300,18 @@ async fn test_repo_upload() -> Result<()> { ); // Now get the repository that was just uploaded. - let get_repo = + let repo = object_get::(client, "/v1/system/update/repository/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.repo.hash, - get_repo.hash.into(), - "repo hash matches" - ); - assert_eq!( - initial_description.repo.system_version, get_repo.system_version, + initial_repo.system_version, repo.system_version, "system version matches" ); assert_eq!( - initial_description.repo.valid_until, get_repo.valid_until, + initial_repo.valid_until, repo.valid_until, "valid_until matches" ); @@ -433,18 +426,17 @@ 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; + let response = serde_json::from_slice::(&response.body) + .context("error deserializing response body")?; + assert_eq!(response.status, TufRepoUploadStatus::Inserted); + let mut description = response.repo; description.sort_artifacts(); // 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 + let filtered_artifacts_1 = initial_repo .artifacts .iter() .filter(|artifact| { @@ -487,11 +479,9 @@ async fn test_repo_upload() -> Result<()> { ); // Now get the repository that was just uploaded. - let get_repo = object_get::( - client, - "/v1/system/update/repository/2.0.0", - ) - .await; + let get_repo = + object_get::(client, "/v1/system/update/repository/2.0.0") + .await; // Validate the repo metadata assert_eq!(get_repo.system_version.to_string(), "2.0.0"); @@ -625,9 +615,9 @@ async fn test_repo_list() -> Result<()> { .await .context("error uploading first repository")?; let response1 = - serde_json::from_slice::(&upload_response1.body) + serde_json::from_slice::(&upload_response1.body) .context("error deserializing first response body")?; - assert_eq!(response1.status, TufRepoInsertStatus::Inserted); + assert_eq!(response1.status, TufRepoUploadStatus::Inserted); // Upload second repository (system version 2.0.0) let tweaks = &[ManifestTweak::SystemVersion("2.0.0".parse().unwrap())]; @@ -638,9 +628,9 @@ async fn test_repo_list() -> Result<()> { .await .context("error uploading second repository")?; let response2 = - serde_json::from_slice::(&upload_response2.body) + serde_json::from_slice::(&upload_response2.body) .context("error deserializing second response body")?; - assert_eq!(response2.status, TufRepoInsertStatus::Inserted); + assert_eq!(response2.status, TufRepoUploadStatus::Inserted); // Upload third repository (system version 3.0.0) let tweaks = &[ManifestTweak::SystemVersion("3.0.0".parse().unwrap())]; @@ -651,9 +641,9 @@ async fn test_repo_list() -> Result<()> { .await .context("error uploading third repository")?; let response3 = - serde_json::from_slice::(&upload_response3.body) + serde_json::from_slice::(&upload_response3.body) .context("error deserializing third response body")?; - assert_eq!(response3.status, TufRepoInsertStatus::Inserted); + assert_eq!(response3.status, TufRepoUploadStatus::Inserted); // List repositories - should return all 3, ordered by system version (newest first) let list: ResultsPage = @@ -662,11 +652,8 @@ async fn test_repo_list() -> Result<()> { 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(); + 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 @@ -677,19 +664,15 @@ async fn test_repo_list() -> Result<()> { 2 => "1.0.0", _ => panic!("unexpected index"), }; - assert_eq!( - item.system_version.to_string(), - expected_version - ); + 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=ascending", - ) - .await; + 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); @@ -726,10 +709,7 @@ async fn test_repo_list() -> Result<()> { 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" - ); + assert_eq!(next_page.items[0].system_version.to_string(), "1.0.0"); cptestctx.teardown().await; Ok(()) diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index d5ac081eb25..23c26941bce 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -1611,6 +1611,35 @@ impl From for TufRepo { } } +#[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, +} + +impl From for TufRepoUploadStatus { + fn from(status: external::TufRepoInsertStatus) -> Self { + match status { + external::TufRepoInsertStatus::AlreadyExists => Self::AlreadyExists, + external::TufRepoInsertStatus::Inserted => Self::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 c1e68c3fba4..1858aac97f4 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -10999,7 +10999,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TufRepoInsertResponse" + "$ref": "#/components/schemas/TufRepoUpload" } } } @@ -14653,29 +14653,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", @@ -25942,51 +25919,6 @@ "items" ] }, - "TufArtifactMeta": { - "description": "Metadata about an individual TUF artifact.\n\nFound within a `TufRepoDescription`.", - "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`).", - "type": "string" - }, - "hash": { - "description": "The hash of the artifact.", - "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 - } - }, - "size": { - "description": "The size of the artifact in bytes.", - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "hash", - "id", - "size" - ] - }, "TufRepo": { "description": "Metadata about a TUF repository", "type": "object", @@ -26025,69 +25957,54 @@ "valid_until" ] }, - "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" - } - ] + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" } }, "required": [ - "artifacts", - "repo" + "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" @@ -26095,65 +26012,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" - ] - }, - "TufRepoResultsPage": { - "description": "A single page of results", - "type": "object", - "properties": { - "items": { - "description": "list of items on this page of results", - "type": "array", - "items": { - "$ref": "#/components/schemas/TufRepo" - } - }, - "next_page": { - "nullable": true, - "description": "token used to fetch the next page of results (if any)", - "type": "string" - } - }, - "required": [ - "items" - ] - }, "TxEqConfig": { "description": "Per-port tx-eq overrides. This can be used to fine-tune the transceiver equalization settings to improve signal integrity.", "type": "object", From b901354a106ead392a86b3310ca6d4b77bd3c120 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Sep 2025 14:46:04 -0500 Subject: [PATCH 07/14] add lockstep endpoint for listing artifacts for a repo --- nexus/db-queries/src/db/datastore/update.rs | 24 +++ nexus/lockstep-api/src/lib.rs | 18 +++ nexus/src/lockstep_api/http_entrypoints.rs | 48 ++++++ openapi/nexus-lockstep.json | 153 ++++++++++++++++++++ 4 files changed, 243 insertions(+) diff --git a/nexus/db-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index aefd5967836..4adf21bc394 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -231,6 +231,30 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + /// List artifacts for a specific TUF repository by system version. + pub async fn tuf_repo_artifacts_list_by_version( + &self, + opctx: &OpContext, + system_version: SemverVersion, + _pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + opctx.authorize(authz::Action::Read, &authz::FLEET).await?; + + let conn = self.pool_connection_authorized(opctx).await?; + + // First get the repo by version + let repo = self.tuf_repo_get_by_version(opctx, system_version).await?; + + // Get all artifacts for this repo and apply simple pagination + let all_artifacts = artifacts_for_repo(repo.id.into(), &conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + // For now, return all artifacts since each repo should only have a few (under 20) + // The existing artifacts_for_repo comment mentions this limitation + Ok(all_artifacts) + } + /// List the trusted TUF root roles in the trust store. pub async fn tuf_trust_root_list( &self, diff --git a/nexus/lockstep-api/src/lib.rs b/nexus/lockstep-api/src/lib.rs index 43b54ac9260..4638c0e43ff 100644 --- a/nexus/lockstep-api/src/lib.rs +++ b/nexus/lockstep-api/src/lib.rs @@ -44,6 +44,7 @@ use nexus_types::internal_api::views::QuiesceStatus; use nexus_types::internal_api::views::Saga; use nexus_types::internal_api::views::UpdateStatus; use omicron_common::api::external::Instance; +use omicron_common::api::external::TufArtifactMeta; use omicron_common::api::external::http_pagination::PaginatedById; use omicron_common::api::external::http_pagination::PaginatedByTimeAndId; use omicron_uuid_kinds::*; @@ -304,6 +305,17 @@ pub trait NexusLockstepApi { rqctx: RequestContext, ) -> Result, HttpError>; + /// List artifacts for a TUF repository by version + #[endpoint { + method = GET, + path = "/deployment/repositories/{version}/artifacts" + }] + async fn tuf_repo_artifacts_list( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError>; + /// List uninitialized sleds #[endpoint { method = GET, @@ -570,3 +582,9 @@ pub struct SledId { pub struct VersionPathParam { pub version: u32, } + +/// Path parameters for TUF repository version requests +#[derive(Deserialize, JsonSchema)] +pub struct TufRepoVersionPathParam { + pub version: String, +} diff --git a/nexus/src/lockstep_api/http_entrypoints.rs b/nexus/src/lockstep_api/http_entrypoints.rs index 463b0883deb..327d12fb3df 100644 --- a/nexus/src/lockstep_api/http_entrypoints.rs +++ b/nexus/src/lockstep_api/http_entrypoints.rs @@ -49,6 +49,7 @@ use nexus_types::internal_api::views::Saga; use nexus_types::internal_api::views::UpdateStatus; use nexus_types::internal_api::views::to_list; use omicron_common::api::external::Instance; +use omicron_common::api::external::TufArtifactMeta; use omicron_common::api::external::http_pagination::PaginatedById; use omicron_common::api::external::http_pagination::PaginatedByTimeAndId; use omicron_common::api::external::http_pagination::ScanById; @@ -57,6 +58,8 @@ use omicron_common::api::external::http_pagination::ScanParams; use omicron_common::api::external::http_pagination::data_page_params_for; use omicron_uuid_kinds::*; use range_requests::PotentialRange; +use semver::Version; +use nexus_db_model::SemverVersion; use crate::app::support_bundles::SupportBundleQueryType; use crate::context::ApiContext; @@ -501,6 +504,51 @@ impl NexusLockstepApi for NexusLockstepApiImpl { .await } + async fn tuf_repo_artifacts_list( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let datastore = nexus.datastore(); + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let pagparams = data_page_params_for(&rqctx, &query)?; + + // Parse the version string as semver + let system_version = Version::parse(&path.version) + .map_err(|e| HttpError::for_bad_request( + None, + format!("Invalid version '{}': {}", path.version, e), + ))?; + let semver_version = SemverVersion::from(system_version); + + let artifacts = datastore + .tuf_repo_artifacts_list_by_version(&opctx, semver_version, &pagparams) + .await?; + + // Convert TufArtifact to TufArtifactMeta + let artifact_metas: Vec = artifacts + .into_iter() + .map(|artifact| artifact.into_external()) + .collect(); + + // Since TufArtifactMeta.id is not a UUID but a composite ArtifactId, + // and we don't have a direct UUID for artifacts, we'll return all + // artifacts without pagination for now (which is fine since each + // repo should only have a few artifacts under 20) + Ok(HttpResponseOk(ResultsPage { items: artifact_metas, next_page: None })) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + async fn sled_list_uninitialized( rqctx: RequestContext, ) -> Result>, HttpError> { diff --git a/openapi/nexus-lockstep.json b/openapi/nexus-lockstep.json index 456dc63d1d3..26e3d4615c1 100644 --- a/openapi/nexus-lockstep.json +++ b/openapi/nexus-lockstep.json @@ -551,6 +551,70 @@ } } }, + "/deployment/repositories/{version}/artifacts": { + "get": { + "summary": "List artifacts for a TUF repository by version", + "operationId": "tuf_repo_artifacts_list", + "parameters": [ + { + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + }, + { + "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/IdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TufArtifactMetaResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, "/deployment/update-status": { "get": { "summary": "Show deployed versions of artifacts", @@ -1370,6 +1434,29 @@ "dependency" ] }, + "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" + ] + }, "ArtifactVersion": { "description": "An artifact version.\n\nThis is a freeform identifier with some basic validation. It may be the serialized form of a semver version, or a custom identifier that uses the same character set as a semver, plus `_`.\n\nThe exact pattern accepted is `^[a-zA-Z0-9._+-]{1,63}$`.\n\n# Ord implementation\n\n`ArtifactVersion`s are not intended to be sorted, just compared for equality. `ArtifactVersion` implements `Ord` only for storage within sorted collections.", "type": "string", @@ -6700,6 +6787,72 @@ } } }, + "TufArtifactMeta": { + "description": "Metadata about an individual TUF artifact.\n\nFound within a `TufRepoDescription`.", + "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`).", + "type": "string" + }, + "hash": { + "description": "The hash of the artifact.", + "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 + } + }, + "size": { + "description": "The size of the artifact in bytes.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "hash", + "id", + "size" + ] + }, + "TufArtifactMetaResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/TufArtifactMeta" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "TufRepoVersion": { "oneOf": [ { From f6c5c1973ac6afcc60f6cd5b048d3df74d055e28 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Sep 2025 15:28:35 -0500 Subject: [PATCH 08/14] make repo upload test pass using artifacts endpoint --- nexus/tests/integration_tests/updates.rs | 92 ++++++++++++++++-------- 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/nexus/tests/integration_tests/updates.rs b/nexus/tests/integration_tests/updates.rs index 675488b2f0d..5660f665e09 100644 --- a/nexus/tests/integration_tests/updates.rs +++ b/nexus/tests/integration_tests/updates.rs @@ -20,6 +20,7 @@ use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::views::{ TufRepo, TufRepoUpload, TufRepoUploadStatus, UpdatesTrustRoot, }; +use omicron_common::api::external::TufArtifactMeta; use pretty_assertions::assert_eq; use serde::Deserialize; use std::collections::HashSet; @@ -36,6 +37,17 @@ const TRUST_ROOTS_URL: &str = "/v1/system/update/trust-roots"; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; +/// Fetch artifacts for a repository using the lockstep API +async fn fetch_repo_artifacts( + lockstep_client: &dropshot::test_util::ClientTestContext, + version: &str, +) -> Vec { + let url = format!("/deployment/repositories/{}/artifacts", version); + objects_list_page_authz::(lockstep_client, &url) + .await + .items +} + pub struct TestTrustRoot { pub key: Key, pub expiry: DateTime, @@ -198,7 +210,7 @@ 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_repo = { + let initial_repo = { let response = repo .to_upload_request(client, StatusCode::OK) .execute() @@ -210,29 +222,31 @@ async fn test_repo_upload() -> Result<()> { assert_eq!(response.status, TufRepoUploadStatus::Inserted); response.repo }; - let unique_sha256_count = initial_repo - .artifacts + + // Fetch artifacts using the new lockstep endpoint + let initial_artifacts = + fetch_repo_artifacts(&cptestctx.lockstep_client, "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_repo - .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_repo.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. @@ -272,7 +286,7 @@ 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() @@ -285,12 +299,32 @@ async fn test_repo_upload() -> Result<()> { response.repo }; - initial_repo.sort_artifacts(); - reupload_description.sort_artifacts(); + // Fetch artifacts again and compare them + let mut reupload_artifacts = + fetch_repo_artifacts(&cptestctx.lockstep_client, "1.0.0").await; + let mut initial_artifacts_sorted = initial_artifacts.clone(); + + // Sort artifacts by their ID for comparison (same order as ArtifactId::cmp) + initial_artifacts_sorted.sort_by(|a, b| a.id.cmp(&b.id)); + reupload_artifacts.sort_by(|a, b| a.id.cmp(&b.id)); + + assert_eq!( + initial_artifacts_sorted, 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" + ); assert_eq!( - initial_repo, reupload_description, - "initial description matches reupload" + initial_repo.valid_until, reupload_description.valid_until, + "valid_until matches" ); // We didn't insert a new repo, so the generation number should still be 2. @@ -429,15 +463,16 @@ async fn test_repo_upload() -> Result<()> { let response = serde_json::from_slice::(&response.body) .context("error deserializing response body")?; assert_eq!(response.status, TufRepoUploadStatus::Inserted); - let mut description = response.repo; - description.sort_artifacts(); + + // Fetch artifacts for the 2.0.0 repository + let artifacts_2_0_0 = + fetch_repo_artifacts(&cptestctx.lockstep_client, "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_repo - .artifacts + let filtered_artifacts_1 = initial_artifacts .iter() .filter(|artifact| { if artifact.id.kind @@ -451,8 +486,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 From 438624f68a56ad237e7001ec4880068ec063b90d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Sep 2025 16:33:55 -0500 Subject: [PATCH 09/14] fetch artifacts directly from datastore, no endpoint required --- nexus/tests/integration_tests/updates.rs | 56 ++++++++++++++---------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/nexus/tests/integration_tests/updates.rs b/nexus/tests/integration_tests/updates.rs index 5660f665e09..8ac89d19a7c 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_test_utils::background::run_tuf_artifact_replication_step; use nexus_test_utils::background::wait_tuf_artifact_replication_step; @@ -20,8 +21,9 @@ use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::views::{ TufRepo, TufRepoUpload, TufRepoUploadStatus, UpdatesTrustRoot, }; -use omicron_common::api::external::TufArtifactMeta; +use omicron_common::api::external::{DataPageParams, TufArtifactMeta}; use pretty_assertions::assert_eq; +use semver::Version; use serde::Deserialize; use std::collections::HashSet; use std::fmt::Debug; @@ -31,21 +33,39 @@ use tufaceous_artifact::KnownArtifactKind; use tufaceous_lib::Key; use tufaceous_lib::assemble::{ArtifactManifest, OmicronRepoAssembler}; use tufaceous_lib::assemble::{DeserializedManifest, ManifestTweak}; +use uuid::Uuid; const TRUST_ROOTS_URL: &str = "/v1/system/update/trust-roots"; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; -/// Fetch artifacts for a repository using the lockstep API -async fn fetch_repo_artifacts( - lockstep_client: &dropshot::test_util::ClientTestContext, +/// Get artifacts for a repository using the datastore directly, sorted by ID +async fn get_repo_artifacts( + cptestctx: &ControlPlaneTestContext, version: &str, ) -> Vec { - let url = format!("/deployment/repositories/{}/artifacts", version); - objects_list_page_authz::(lockstep_client, &url) + 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 pagparams = DataPageParams::::max_page(); + + let artifacts = datastore + .tuf_repo_artifacts_list_by_version(&opctx, system_version, &pagparams) .await - .items + .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 { @@ -223,9 +243,8 @@ async fn test_repo_upload() -> Result<()> { response.repo }; - // Fetch artifacts using the new lockstep endpoint - let initial_artifacts = - fetch_repo_artifacts(&cptestctx.lockstep_client, "1.0.0").await; + // 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) @@ -299,17 +318,11 @@ async fn test_repo_upload() -> Result<()> { response.repo }; - // Fetch artifacts again and compare them - let mut reupload_artifacts = - fetch_repo_artifacts(&cptestctx.lockstep_client, "1.0.0").await; - let mut initial_artifacts_sorted = initial_artifacts.clone(); - - // Sort artifacts by their ID for comparison (same order as ArtifactId::cmp) - initial_artifacts_sorted.sort_by(|a, b| a.id.cmp(&b.id)); - reupload_artifacts.sort_by(|a, b| a.id.cmp(&b.id)); + // Get artifacts again and compare them + let reupload_artifacts = get_repo_artifacts(&cptestctx, "1.0.0").await; assert_eq!( - initial_artifacts_sorted, reupload_artifacts, + initial_artifacts, reupload_artifacts, "initial artifacts match reupload artifacts" ); @@ -464,9 +477,8 @@ async fn test_repo_upload() -> Result<()> { .context("error deserializing response body")?; assert_eq!(response.status, TufRepoUploadStatus::Inserted); - // Fetch artifacts for the 2.0.0 repository - let artifacts_2_0_0 = - fetch_repo_artifacts(&cptestctx.lockstep_client, "2.0.0").await; + // 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 From 5222d49897a609fb4926420c408b56d326a7ba1f Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Sep 2025 16:41:20 -0500 Subject: [PATCH 10/14] never mind about that lockstep endpoint --- nexus/lockstep-api/src/lib.rs | 18 --- nexus/src/lockstep_api/http_entrypoints.rs | 48 ------- openapi/nexus-lockstep.json | 153 --------------------- 3 files changed, 219 deletions(-) diff --git a/nexus/lockstep-api/src/lib.rs b/nexus/lockstep-api/src/lib.rs index 4638c0e43ff..43b54ac9260 100644 --- a/nexus/lockstep-api/src/lib.rs +++ b/nexus/lockstep-api/src/lib.rs @@ -44,7 +44,6 @@ use nexus_types::internal_api::views::QuiesceStatus; use nexus_types::internal_api::views::Saga; use nexus_types::internal_api::views::UpdateStatus; use omicron_common::api::external::Instance; -use omicron_common::api::external::TufArtifactMeta; use omicron_common::api::external::http_pagination::PaginatedById; use omicron_common::api::external::http_pagination::PaginatedByTimeAndId; use omicron_uuid_kinds::*; @@ -305,17 +304,6 @@ pub trait NexusLockstepApi { rqctx: RequestContext, ) -> Result, HttpError>; - /// List artifacts for a TUF repository by version - #[endpoint { - method = GET, - path = "/deployment/repositories/{version}/artifacts" - }] - async fn tuf_repo_artifacts_list( - rqctx: RequestContext, - path_params: Path, - query_params: Query, - ) -> Result>, HttpError>; - /// List uninitialized sleds #[endpoint { method = GET, @@ -582,9 +570,3 @@ pub struct SledId { pub struct VersionPathParam { pub version: u32, } - -/// Path parameters for TUF repository version requests -#[derive(Deserialize, JsonSchema)] -pub struct TufRepoVersionPathParam { - pub version: String, -} diff --git a/nexus/src/lockstep_api/http_entrypoints.rs b/nexus/src/lockstep_api/http_entrypoints.rs index 327d12fb3df..463b0883deb 100644 --- a/nexus/src/lockstep_api/http_entrypoints.rs +++ b/nexus/src/lockstep_api/http_entrypoints.rs @@ -49,7 +49,6 @@ use nexus_types::internal_api::views::Saga; use nexus_types::internal_api::views::UpdateStatus; use nexus_types::internal_api::views::to_list; use omicron_common::api::external::Instance; -use omicron_common::api::external::TufArtifactMeta; use omicron_common::api::external::http_pagination::PaginatedById; use omicron_common::api::external::http_pagination::PaginatedByTimeAndId; use omicron_common::api::external::http_pagination::ScanById; @@ -58,8 +57,6 @@ use omicron_common::api::external::http_pagination::ScanParams; use omicron_common::api::external::http_pagination::data_page_params_for; use omicron_uuid_kinds::*; use range_requests::PotentialRange; -use semver::Version; -use nexus_db_model::SemverVersion; use crate::app::support_bundles::SupportBundleQueryType; use crate::context::ApiContext; @@ -504,51 +501,6 @@ impl NexusLockstepApi for NexusLockstepApiImpl { .await } - async fn tuf_repo_artifacts_list( - rqctx: RequestContext, - path_params: Path, - query_params: Query, - ) -> Result>, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = - crate::context::op_context_for_internal_api(&rqctx).await; - let nexus = &apictx.nexus; - let datastore = nexus.datastore(); - let path = path_params.into_inner(); - let query = query_params.into_inner(); - let pagparams = data_page_params_for(&rqctx, &query)?; - - // Parse the version string as semver - let system_version = Version::parse(&path.version) - .map_err(|e| HttpError::for_bad_request( - None, - format!("Invalid version '{}': {}", path.version, e), - ))?; - let semver_version = SemverVersion::from(system_version); - - let artifacts = datastore - .tuf_repo_artifacts_list_by_version(&opctx, semver_version, &pagparams) - .await?; - - // Convert TufArtifact to TufArtifactMeta - let artifact_metas: Vec = artifacts - .into_iter() - .map(|artifact| artifact.into_external()) - .collect(); - - // Since TufArtifactMeta.id is not a UUID but a composite ArtifactId, - // and we don't have a direct UUID for artifacts, we'll return all - // artifacts without pagination for now (which is fine since each - // repo should only have a few artifacts under 20) - Ok(HttpResponseOk(ResultsPage { items: artifact_metas, next_page: None })) - }; - apictx - .internal_latencies - .instrument_dropshot_handler(&rqctx, handler) - .await - } - async fn sled_list_uninitialized( rqctx: RequestContext, ) -> Result>, HttpError> { diff --git a/openapi/nexus-lockstep.json b/openapi/nexus-lockstep.json index 26e3d4615c1..456dc63d1d3 100644 --- a/openapi/nexus-lockstep.json +++ b/openapi/nexus-lockstep.json @@ -551,70 +551,6 @@ } } }, - "/deployment/repositories/{version}/artifacts": { - "get": { - "summary": "List artifacts for a TUF repository by version", - "operationId": "tuf_repo_artifacts_list", - "parameters": [ - { - "in": "path", - "name": "version", - "required": true, - "schema": { - "type": "string" - } - }, - { - "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/IdSortMode" - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TufArtifactMetaResultsPage" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - }, - "x-dropshot-pagination": { - "required": [] - } - } - }, "/deployment/update-status": { "get": { "summary": "Show deployed versions of artifacts", @@ -1434,29 +1370,6 @@ "dependency" ] }, - "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" - ] - }, "ArtifactVersion": { "description": "An artifact version.\n\nThis is a freeform identifier with some basic validation. It may be the serialized form of a semver version, or a custom identifier that uses the same character set as a semver, plus `_`.\n\nThe exact pattern accepted is `^[a-zA-Z0-9._+-]{1,63}$`.\n\n# Ord implementation\n\n`ArtifactVersion`s are not intended to be sorted, just compared for equality. `ArtifactVersion` implements `Ord` only for storage within sorted collections.", "type": "string", @@ -6787,72 +6700,6 @@ } } }, - "TufArtifactMeta": { - "description": "Metadata about an individual TUF artifact.\n\nFound within a `TufRepoDescription`.", - "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`).", - "type": "string" - }, - "hash": { - "description": "The hash of the artifact.", - "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 - } - }, - "size": { - "description": "The size of the artifact in bytes.", - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "hash", - "id", - "size" - ] - }, - "TufArtifactMetaResultsPage": { - "description": "A single page of results", - "type": "object", - "properties": { - "items": { - "description": "list of items on this page of results", - "type": "array", - "items": { - "$ref": "#/components/schemas/TufArtifactMeta" - } - }, - "next_page": { - "nullable": true, - "description": "token used to fetch the next page of results (if any)", - "type": "string" - } - }, - "required": [ - "items" - ] - }, "TufRepoVersion": { "oneOf": [ { From 4181ee19595bed4a36a0d518cebb7eace2c33543 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Sep 2025 16:50:07 -0500 Subject: [PATCH 11/14] make repo get and put endpoints more conventional --- nexus/external-api/output/nexus_tags.txt | 4 ++-- nexus/external-api/src/lib.rs | 8 ++++---- nexus/src/external_api/http_entrypoints.rs | 4 ++-- nexus/tests/integration_tests/endpoints.rs | 17 +++++++++++------ nexus/tests/integration_tests/updates.rs | 14 ++++++++------ nexus/types/src/external_api/params.rs | 4 ++-- openapi/nexus.json | 10 ++++------ 7 files changed, 33 insertions(+), 28 deletions(-) diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index b4f170c757c..0b5df05fc0a 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -296,9 +296,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_trust_root_create POST /v1/system/update/trust-roots system_update_trust_root_delete DELETE /v1/system/update/trust-roots/{trust_root_id} system_update_trust_root_list GET /v1/system/update/trust-roots diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 9d8b38bfb5e..7447c59e254 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -2967,11 +2967,11 @@ 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, @@ -2980,10 +2980,10 @@ pub trait NexusExternalApi { /// 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>; diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 9e28e4c22e7..81633cbbeaf 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6639,7 +6639,7 @@ impl NexusExternalApi for NexusExternalApiImpl { // Updates - async fn system_update_put_repository( + async fn system_update_repository_upload( rqctx: RequestContext, query: Query, body: StreamingBody, @@ -6664,7 +6664,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn system_update_get_repository( + async fn system_update_repository_view( rqctx: RequestContext, path_params: Path, ) -> Result, HttpError> { diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index d8c62a95d94..0584fa4874b 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/updates.rs b/nexus/tests/integration_tests/updates.rs index 8ac89d19a7c..32a5c71ef03 100644 --- a/nexus/tests/integration_tests/updates.rs +++ b/nexus/tests/integration_tests/updates.rs @@ -140,7 +140,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) @@ -196,7 +196,7 @@ async fn test_repo_upload_unconfigured() -> Result<()> { // with a 404 error. object_get_error( client, - "/v1/system/update/repository/1.0.0", + "/v1/system/update/repositories/1.0.0", StatusCode::NOT_FOUND, ) .await; @@ -348,7 +348,7 @@ async fn test_repo_upload() -> Result<()> { // Now get the repository that was just uploaded. let repo = - object_get::(client, "/v1/system/update/repository/1.0.0") + object_get::(client, "/v1/system/update/repositories/1.0.0") .await; // Compare just the repo metadata (not artifacts) @@ -525,9 +525,11 @@ async fn test_repo_upload() -> Result<()> { ); // Now get the repository that was just uploaded. - let get_repo = - object_get::(client, "/v1/system/update/repository/2.0.0") - .await; + let get_repo = object_get::( + client, + "/v1/system/update/repositories/2.0.0", + ) + .await; // Validate the repo metadata assert_eq!(get_repo.system_version.to_string(), "2.0.0"); diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 8a1cd6f6fa7..a3f4fa0e7a6 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/openapi/nexus.json b/openapi/nexus.json index 1858aac97f4..e32c75df642 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -10961,16 +10961,14 @@ "x-dropshot-pagination": { "required": [] } - } - }, - "/v1/system/update/repository": { + }, "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", @@ -11013,13 +11011,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", From f1b1a36acf1f9a6e5bfd7c633db1de7024f61a3b Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Sep 2025 17:27:28 -0500 Subject: [PATCH 12/14] self-review fixes --- nexus/db-queries/src/db/datastore/update.rs | 4 ++-- nexus/src/app/update.rs | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/nexus/db-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index 4adf21bc394..5e6adee8549 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -36,7 +36,7 @@ use uuid::Uuid; /// The return value of [`DataStore::tuf_repo_insert`]. /// -/// This is similar to [`external::TufRepoInsertResponse`], but uses +/// This is similar to [`views::TufRepoUpload`], but uses /// nexus-db-model's types instead of external types. pub struct TufRepoInsertResponse { pub recorded: TufRepoDescription, @@ -204,7 +204,7 @@ impl DataStore { } /// List all TUF repositories (without artifacts) ordered by system version (newest first by default). - pub async fn tuf_repo_list_no_artifacts( + pub async fn tuf_repo_list( &self, opctx: &OpContext, pagparams: &DataPageParams<'_, Version>, diff --git a/nexus/src/app/update.rs b/nexus/src/app/update.rs index b3d44e82f79..9214794590c 100644 --- a/nexus/src/app/update.rs +++ b/nexus/src/app/update.rs @@ -95,22 +95,18 @@ impl super::Nexus { &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, HttpError> { - self.db_datastore - .tuf_repo_list_no_artifacts(opctx, pagparams) - .await - .map_err(HttpError::from) + ) -> Result, Error> { + self.db_datastore.tuf_repo_list(opctx, pagparams).await } pub(crate) async fn updates_add_trust_root( From 1c3412267fa75ebe418cda9eda7d1b55b2b998d3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sat, 27 Sep 2025 17:21:04 -0500 Subject: [PATCH 13/14] no reference to "external" types in external API call tree --- common/src/api/external/mod.rs | 13 ----- nexus/db-model/src/tuf_repo.rs | 59 ++++++++++++++++++--- nexus/db-queries/src/db/datastore/update.rs | 38 +++---------- nexus/src/app/update.rs | 11 ++-- nexus/src/external_api/http_entrypoints.rs | 11 ++-- nexus/types/src/external_api/views.rs | 27 ++-------- 6 files changed, 73 insertions(+), 86 deletions(-) diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index be06022996a..d68ca22fc05 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -3499,19 +3499,6 @@ pub struct TufArtifactMeta { pub sign: Option>, } -/// Status of a TUF repo import. -#[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, -} - #[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 3b4a7c5bae4..81a27ca9830 100644 --- a/nexus/db-model/src/tuf_repo.rs +++ b/nexus/db-model/src/tuf_repo.rs @@ -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, )] @@ -132,7 +127,6 @@ impl TufRepo { ) } - /// Converts self into [`external::TufRepoMeta`]. pub fn into_external(self) -> external::TufRepoMeta { external::TufRepoMeta { hash: self.sha256.into(), @@ -154,6 +148,18 @@ impl TufRepo { } } +impl From for views::TufRepo { + fn from(repo: TufRepo) -> views::TufRepo { + views::TufRepo { + hash: repo.sha256.into(), + targets_role_version: repo.targets_role_version as u64, + valid_until: repo.valid_until, + system_version: repo.system_version.into(), + file_name: repo.file_name, + } + } +} + #[derive(Queryable, Insertable, Clone, Debug, Selectable, AsChangeset)] #[diesel(table_name = tuf_artifact)] pub struct TufArtifact { @@ -411,3 +417,44 @@ impl FromSql for DbTufSignedRootRole { .map_err(|e| e.into()) } } + +// The following aren't real models in the sense that they represent DB data, +// but they are the return types of datastore functions + +/// Status of a TUF repo import +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TufRepoUploadStatus { + /// The repository already existed in the database + AlreadyExists, + + /// The repository did not exist, and was inserted into the database + Inserted, +} + +impl From for views::TufRepoUploadStatus { + fn from(status: TufRepoUploadStatus) -> Self { + match status { + TufRepoUploadStatus::AlreadyExists => { + views::TufRepoUploadStatus::AlreadyExists + } + TufRepoUploadStatus::Inserted => { + views::TufRepoUploadStatus::Inserted + } + } + } +} + +/// 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.into(), + } + } +} diff --git a/nexus/db-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index 5e6adee8549..9295d0f57b7 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -18,13 +18,12 @@ use nexus_db_errors::OptionalError; use nexus_db_errors::{ErrorHandler, public_error_from_diesel}; use nexus_db_lookup::DbConnection; use nexus_db_model::{ - ArtifactHash, TufArtifact, TufRepo, TufRepoDescription, TufTrustRoot, - to_db_typed_uuid, + ArtifactHash, TufArtifact, TufRepo, TufRepoDescription, TufRepoUpload, + TufRepoUploadStatus, TufTrustRoot, to_db_typed_uuid, }; -use nexus_types::external_api::views; use omicron_common::api::external::{ self, CreateResult, DataPageParams, DeleteResult, Generation, - ListResultVec, LookupResult, LookupType, ResourceType, TufRepoInsertStatus, + ListResultVec, LookupResult, LookupType, ResourceType, }; use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::TufRepoKind; @@ -34,24 +33,6 @@ use swrite::{SWrite, swrite}; use tufaceous_artifact::ArtifactVersion; use uuid::Uuid; -/// The return value of [`DataStore::tuf_repo_insert`]. -/// -/// This is similar to [`views::TufRepoUpload`], 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) -> views::TufRepoUpload { - views::TufRepoUpload { - repo: self.recorded.repo.into_external().into(), - status: self.status.into(), - } - } -} - async fn artifacts_for_repo( repo_id: TypedUuid, conn: &async_bb8_diesel::Connection, @@ -85,7 +66,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!( @@ -327,7 +308,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 @@ -364,9 +345,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, }); } @@ -570,10 +551,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/src/app/update.rs b/nexus/src/app/update.rs index 9214794590c..3595b74d2ce 100644 --- a/nexus/src/app/update.rs +++ b/nexus/src/app/update.rs @@ -9,14 +9,13 @@ use dropshot::HttpError; use futures::Stream; use nexus_auth::authz; use nexus_db_lookup::LookupPath; +use nexus_db_model::TufRepoUpload; +use nexus_db_model::TufRepoUploadStatus; use nexus_db_model::TufTrustRoot; use nexus_db_queries::context::OpContext; -use nexus_db_queries::db::datastore::update::TufRepoInsertResponse; use nexus_db_queries::db::{datastore::SQL_BATCH_SIZE, pagination::Paginator}; use nexus_types::external_api::shared::TufSignedRootRole; -use omicron_common::api::external::{ - DataPageParams, Error, TufRepoInsertStatus, -}; +use omicron_common::api::external::{DataPageParams, Error}; use omicron_uuid_kinds::{GenericUuid, TufTrustRootUuid}; use semver::Version; use update_common::artifacts::{ @@ -30,7 +29,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, @@ -68,7 +67,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 diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 81633cbbeaf..db7059d84b5 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6654,8 +6654,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let update = nexus .updates_put_repository(&opctx, body, query.file_name) .await?; - let repo_upload = update.into_external(); - Ok(HttpResponseOk(repo_upload)) + Ok(HttpResponseOk(update.into())) }; apictx .context @@ -6677,7 +6676,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let repo = nexus .updates_get_repository(&opctx, params.system_version) .await?; - Ok(HttpResponseOk(repo.into_external().into())) + Ok(HttpResponseOk(repo.into())) }; apictx .context @@ -6700,10 +6699,8 @@ impl NexusExternalApi for NexusExternalApiImpl { let repos = nexus.updates_list_repositories(&opctx, &pagparams).await?; - let responses: Vec = repos - .into_iter() - .map(|repo| repo.into_external().into()) - .collect(); + let responses: Vec = + repos.into_iter().map(Into::into).collect(); Ok(HttpResponseOk(ScanByVersion::results_page( &query, diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 23c26941bce..898ab0b60db 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -15,9 +15,9 @@ use chrono::Utc; use daft::Diffable; pub use omicron_common::api::external::IpVersion; use omicron_common::api::external::{ - self, AffinityPolicy, AllowedSourceIps as ExternalAllowedSourceIps, - ByteCount, Digest, Error, FailureDomain, IdentityMetadata, InstanceState, - Name, ObjectIdentity, SimpleIdentity, SimpleIdentityOrName, + AffinityPolicy, AllowedSourceIps as ExternalAllowedSourceIps, ByteCount, + Digest, Error, FailureDomain, IdentityMetadata, InstanceState, Name, + ObjectIdentity, SimpleIdentity, SimpleIdentityOrName, }; use omicron_uuid_kinds::*; use oxnet::{Ipv4Net, Ipv6Net}; @@ -1599,18 +1599,6 @@ pub struct TufRepo { pub file_name: String, } -impl From for TufRepo { - fn from(meta: external::TufRepoMeta) -> Self { - Self { - hash: meta.hash, - targets_role_version: meta.targets_role_version, - valid_until: meta.valid_until, - system_version: meta.system_version, - file_name: meta.file_name, - } - } -} - #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] pub struct TufRepoUpload { pub repo: TufRepo, @@ -1631,15 +1619,6 @@ pub enum TufRepoUploadStatus { Inserted, } -impl From for TufRepoUploadStatus { - fn from(status: external::TufRepoInsertStatus) -> Self { - match status { - external::TufRepoInsertStatus::AlreadyExists => Self::AlreadyExists, - external::TufRepoInsertStatus::Inserted => Self::Inserted, - } - } -} - fn expected_one_of() -> String { use std::fmt::Write; let mut msg = "expected one of:".to_string(); From 625d187f8fab7caedb67c62dddbcdc39b6e96e69 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 2 Oct 2025 16:37:42 -0500 Subject: [PATCH 14/14] remove targets_role_version and valid_until from TufRepo view, add time_created --- nexus/db-model/src/tuf_repo.rs | 3 +-- nexus/tests/integration_tests/updates.rs | 8 -------- nexus/types/src/external_api/views.rs | 9 +++------ openapi/nexus.json | 13 +++---------- 4 files changed, 7 insertions(+), 26 deletions(-) diff --git a/nexus/db-model/src/tuf_repo.rs b/nexus/db-model/src/tuf_repo.rs index f9afe231340..9de9106ba27 100644 --- a/nexus/db-model/src/tuf_repo.rs +++ b/nexus/db-model/src/tuf_repo.rs @@ -154,10 +154,9 @@ impl From for views::TufRepo { fn from(repo: TufRepo) -> views::TufRepo { views::TufRepo { hash: repo.sha256.into(), - targets_role_version: repo.targets_role_version as u64, - valid_until: repo.valid_until, system_version: repo.system_version.into(), file_name: repo.file_name, + time_created: repo.time_created, } } } diff --git a/nexus/tests/integration_tests/updates.rs b/nexus/tests/integration_tests/updates.rs index 8beddb8b635..102a5546663 100644 --- a/nexus/tests/integration_tests/updates.rs +++ b/nexus/tests/integration_tests/updates.rs @@ -337,10 +337,6 @@ async fn test_repo_upload() -> Result<()> { initial_repo.system_version, reupload_description.system_version, "system version matches" ); - assert_eq!( - initial_repo.valid_until, reupload_description.valid_until, - "valid_until matches" - ); // We didn't insert a new repo, so the generation number should still be 2. assert_eq!( @@ -359,10 +355,6 @@ async fn test_repo_upload() -> Result<()> { initial_repo.system_version, repo.system_version, "system version matches" ); - assert_eq!( - initial_repo.valid_until, repo.valid_until, - "valid_until matches" - ); // Upload a new repository with the same system version but a different // version for one of the components. This will produce a different hash, diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 898ab0b60db..dd216680abf 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -1582,12 +1582,6 @@ pub struct TufRepo { /// convenience. pub hash: ArtifactHash, - /// The version of the targets role - pub targets_role_version: u64, - - /// The time until which the repo is valid - pub valid_until: DateTime, - /// The system version in artifacts.json pub system_version: Version, @@ -1597,6 +1591,9 @@ pub struct TufRepo { /// 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)] diff --git a/openapi/nexus.json b/openapi/nexus.json index e32c75df642..04504d73057 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -25935,14 +25935,8 @@ "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", + "time_created": { + "description": "Time the repository was uploaded", "type": "string", "format": "date-time" } @@ -25951,8 +25945,7 @@ "file_name", "hash", "system_version", - "targets_role_version", - "valid_until" + "time_created" ] }, "TufRepoResultsPage": {