From c39418980abe6d7c5e225b8186c0026e8bab841c Mon Sep 17 00:00:00 2001 From: Laura Abbott Date: Wed, 25 Sep 2024 15:44:17 -0400 Subject: [PATCH] Allow multiple RoT versions in the same repository (#6586) --- Cargo.lock | 1 + gateway-types/src/caboose.rs | 2 + gateway/src/http_entrypoints.rs | 41 +- nexus/inventory/src/builder.rs | 4 + nexus/inventory/src/examples.rs | 2 + openapi/gateway.json | 8 + openapi/wicketd.json | 19 +- sp-sim/src/gimlet.rs | 4 + sp-sim/src/sidecar.rs | 4 + tufaceous-lib/src/assemble/manifest.rs | 3 + .../src/artifacts/artifacts_with_plan.rs | 25 +- update-common/src/artifacts/update_plan.rs | 681 ++++++++++++++++-- update-common/src/errors.rs | 19 +- wicket/Cargo.toml | 1 + wicket/src/events.rs | 11 +- wicket/src/state/inventory.rs | 20 + wicket/src/state/mod.rs | 2 +- wicket/src/state/update.rs | 34 +- wicket/src/ui/panes/overview.rs | 38 +- wicket/src/ui/panes/update.rs | 88 ++- wicket/src/wicketd.rs | 7 +- wicketd-api/src/lib.rs | 1 + wicketd/src/artifacts/store.rs | 2 + wicketd/src/update_tracker.rs | 479 ++++++------ 24 files changed, 1125 insertions(+), 371 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da60ee64c3..ef7044b212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12542,6 +12542,7 @@ dependencies = [ "crossterm 0.28.1", "expectorate", "futures", + "hex", "humantime", "indexmap 2.4.0", "indicatif", diff --git a/gateway-types/src/caboose.rs b/gateway-types/src/caboose.rs index 97c9cfd7dc..ef11b9903a 100644 --- a/gateway-types/src/caboose.rs +++ b/gateway-types/src/caboose.rs @@ -12,4 +12,6 @@ pub struct SpComponentCaboose { pub board: String, pub name: String, pub version: String, + pub sign: Option, + pub epoch: Option, } diff --git a/gateway/src/http_entrypoints.rs b/gateway/src/http_entrypoints.rs index c10e71ad61..5746fbbf2e 100644 --- a/gateway/src/http_entrypoints.rs +++ b/gateway/src/http_entrypoints.rs @@ -217,6 +217,8 @@ impl GatewayApi for GatewayImpl { const CABOOSE_KEY_BOARD: [u8; 4] = *b"BORD"; const CABOOSE_KEY_NAME: [u8; 4] = *b"NAME"; const CABOOSE_KEY_VERSION: [u8; 4] = *b"VERS"; + const CABOOSE_KEY_SIGN: [u8; 4] = *b"SIGN"; + const CABOOSE_KEY_EPOC: [u8; 4] = *b"EPOC"; let apictx = rqctx.context(); let PathSpComponent { sp, component } = path.into_inner(); @@ -287,8 +289,43 @@ impl GatewayApi for GatewayImpl { let name = from_utf8(&CABOOSE_KEY_NAME, name)?; let version = from_utf8(&CABOOSE_KEY_VERSION, version)?; - let caboose = - SpComponentCaboose { git_commit, board, name, version }; + // Not all images include the SIGN or EPOC in the caboose, if it's not present + // don't treat it as an error + + let sign = match sp + .read_component_caboose( + component, + firmware_slot, + CABOOSE_KEY_SIGN, + ) + .await + .ok() + { + None => None, + Some(v) => Some(from_utf8(&CABOOSE_KEY_SIGN, v)?), + }; + + let epoch = match sp + .read_component_caboose( + component, + firmware_slot, + CABOOSE_KEY_EPOC, + ) + .await + .ok() + { + None => None, + Some(v) => Some(from_utf8(&CABOOSE_KEY_EPOC, v)?), + }; + + let caboose = SpComponentCaboose { + git_commit, + board, + name, + version, + sign, + epoch, + }; Ok(HttpResponseOk(caboose)) }; diff --git a/nexus/inventory/src/builder.rs b/nexus/inventory/src/builder.rs index 064d0025e2..b233180a88 100644 --- a/nexus/inventory/src/builder.rs +++ b/nexus/inventory/src/builder.rs @@ -1087,6 +1087,8 @@ mod test { git_commit: String::from("git_commit1"), name: String::from("name1"), version: String::from("version1"), + sign: None, + epoch: None, }; assert!(!builder .found_caboose_already(&bogus_baseboard, CabooseWhich::SpSlot0)); @@ -1153,6 +1155,8 @@ mod test { git_commit: String::from("git_commit2"), name: String::from("name2"), version: String::from("version2"), + sign: None, + epoch: None, }, ) .unwrap_err(); diff --git a/nexus/inventory/src/examples.rs b/nexus/inventory/src/examples.rs index d0a2886f87..9ec2978b39 100644 --- a/nexus/inventory/src/examples.rs +++ b/nexus/inventory/src/examples.rs @@ -512,6 +512,8 @@ pub fn caboose(unique: &str) -> SpComponentCaboose { git_commit: format!("git_commit_{}", unique), name: format!("name_{}", unique), version: format!("version_{}", unique), + sign: None, + epoch: None, } } diff --git a/openapi/gateway.json b/openapi/gateway.json index 763a1b8ae9..b1a7adc96e 100644 --- a/openapi/gateway.json +++ b/openapi/gateway.json @@ -2603,12 +2603,20 @@ "board": { "type": "string" }, + "epoch": { + "nullable": true, + "type": "string" + }, "git_commit": { "type": "string" }, "name": { "type": "string" }, + "sign": { + "nullable": true, + "type": "string" + }, "version": { "type": "string" } diff --git a/openapi/wicketd.json b/openapi/wicketd.json index 6d17d9c071..c7f6ea68d8 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -1661,6 +1661,15 @@ "items": { "$ref": "#/components/schemas/ArtifactHashId" } + }, + "sign": { + "nullable": true, + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } } }, "required": [ @@ -3413,18 +3422,26 @@ ] }, "SpComponentCaboose": { - "description": "SpComponentCaboose\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"board\", \"git_commit\", \"name\", \"version\" ], \"properties\": { \"board\": { \"type\": \"string\" }, \"git_commit\": { \"type\": \"string\" }, \"name\": { \"type\": \"string\" }, \"version\": { \"type\": \"string\" } } } ```
", + "description": "SpComponentCaboose\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"board\", \"git_commit\", \"name\", \"version\" ], \"properties\": { \"board\": { \"type\": \"string\" }, \"epoch\": { \"type\": [ \"string\", \"null\" ] }, \"git_commit\": { \"type\": \"string\" }, \"name\": { \"type\": \"string\" }, \"sign\": { \"type\": [ \"string\", \"null\" ] }, \"version\": { \"type\": \"string\" } } } ```
", "type": "object", "properties": { "board": { "type": "string" }, + "epoch": { + "nullable": true, + "type": "string" + }, "git_commit": { "type": "string" }, "name": { "type": "string" }, + "sign": { + "nullable": true, + "type": "string" + }, "version": { "type": "string" } diff --git a/sp-sim/src/gimlet.rs b/sp-sim/src/gimlet.rs index 49fe2a7819..de6c91b4d7 100644 --- a/sp-sim/src/gimlet.rs +++ b/sp-sim/src/gimlet.rs @@ -1351,12 +1351,16 @@ impl SpHandler for Handler { (SpComponent::ROT, b"NAME", _, _) => ROT_NAME, (SpComponent::ROT, b"VERS", 0, _) => ROT_VERS0, (SpComponent::ROT, b"VERS", 1, _) => ROT_VERS1, + // gimlet staging/devel hash + (SpComponent::ROT, b"SIGN", _, _) => &"11594bb5548a757e918e6fe056e2ad9e084297c9555417a025d8788eacf55daf".as_bytes(), (SpComponent::STAGE0, b"GITC", 0, false) => STAGE0_GITC0, (SpComponent::STAGE0, b"GITC", 1, false) => STAGE0_GITC1, (SpComponent::STAGE0, b"BORD", _, false) => STAGE0_BORD, (SpComponent::STAGE0, b"NAME", _, false) => STAGE0_NAME, (SpComponent::STAGE0, b"VERS", 0, false) => STAGE0_VERS0, (SpComponent::STAGE0, b"VERS", 1, false) => STAGE0_VERS1, + // gimlet staging/devel hash + (SpComponent::STAGE0, b"SIGN", _, false) => &"11594bb5548a757e918e6fe056e2ad9e084297c9555417a025d8788eacf55daf".as_bytes(), _ => return Err(SpError::NoSuchCabooseKey(key)), }; diff --git a/sp-sim/src/sidecar.rs b/sp-sim/src/sidecar.rs index cd2bdfead7..286dd50638 100644 --- a/sp-sim/src/sidecar.rs +++ b/sp-sim/src/sidecar.rs @@ -1061,12 +1061,16 @@ impl SpHandler for Handler { (SpComponent::ROT, b"NAME", _, _) => ROT_NAME, (SpComponent::ROT, b"VERS", 0, _) => ROT_VERS0, (SpComponent::ROT, b"VERS", 1, _) => ROT_VERS1, + // sidecar staging/devel hash + (SpComponent::ROT, b"SIGN", _, _) => &"1432cc4cfe5688c51b55546fe37837c753cfbc89e8c3c6aabcf977fdf0c41e27".as_bytes(), (SpComponent::STAGE0, b"GITC", 0, false) => STAGE0_GITC0, (SpComponent::STAGE0, b"GITC", 1, false) => STAGE0_GITC1, (SpComponent::STAGE0, b"BORD", _, false) => STAGE0_BORD, (SpComponent::STAGE0, b"NAME", _, false) => STAGE0_NAME, (SpComponent::STAGE0, b"VERS", 0, false) => STAGE0_VERS0, (SpComponent::STAGE0, b"VERS", 1, false) => STAGE0_VERS1, + // sidecar staging/devel hash + (SpComponent::STAGE0, b"SIGN", _, false) => &"1432cc4cfe5688c51b55546fe37837c753cfbc89e8c3c6aabcf977fdf0c41e27".as_bytes(), _ => return Err(SpError::NoSuchCabooseKey(key)), }; diff --git a/tufaceous-lib/src/assemble/manifest.rs b/tufaceous-lib/src/assemble/manifest.rs index e9187ff0af..c2ba35d256 100644 --- a/tufaceous-lib/src/assemble/manifest.rs +++ b/tufaceous-lib/src/assemble/manifest.rs @@ -297,11 +297,14 @@ impl<'a> FakeDataAttributes<'a> { KnownArtifactKind::SwitchRot => "SimRot", }; + // For our purposes sign = board represents what we want for the RoT + // and we don't care about the SP at this point let caboose = CabooseBuilder::default() .git_commit("this-is-fake-data") .board(board) .version(self.version.to_string()) .name(self.name) + .sign(board) .build(); let mut builder = HubrisArchiveBuilder::with_fake_image(); diff --git a/update-common/src/artifacts/artifacts_with_plan.rs b/update-common/src/artifacts/artifacts_with_plan.rs index 950e2c5ab7..650efccdfb 100644 --- a/update-common/src/artifacts/artifacts_with_plan.rs +++ b/update-common/src/artifacts/artifacts_with_plan.rs @@ -58,6 +58,10 @@ pub struct ArtifactsWithPlan { // will contain two entries mapping each of the images to their data. by_hash: DebugIgnore>, + // Map from Rot artifact IDs to hash of signing information. This is + // used to select between different artifact versions in the same + // repository + rot_by_sign: DebugIgnore>>, // The plan to use to update a component within the rack. plan: UpdatePlan, } @@ -240,8 +244,13 @@ impl ArtifactsWithPlan { // Ensure we know how to apply updates from this set of artifacts; we'll // remember the plan we create. - let UpdatePlanBuildOutput { plan, by_id, by_hash, artifacts_meta } = - builder.build()?; + let UpdatePlanBuildOutput { + plan, + by_id, + by_hash, + rot_by_sign, + artifacts_meta, + } = builder.build()?; let tuf_repository = repository.repo(); @@ -266,7 +275,13 @@ impl ArtifactsWithPlan { let description = TufRepoDescription { repo: repo_meta, artifacts: artifacts_meta }; - Ok(Self { description, by_id, by_hash: by_hash.into(), plan }) + Ok(Self { + description, + by_id, + by_hash: by_hash.into(), + rot_by_sign: rot_by_sign.into(), + plan, + }) } /// Returns the `ArtifactsDocument` corresponding to this TUF repo. @@ -289,6 +304,10 @@ impl ArtifactsWithPlan { &self.plan } + pub fn rot_by_sign(&self) -> &HashMap> { + &self.rot_by_sign + } + pub fn get_by_hash( &self, id: &ArtifactHashId, diff --git a/update-common/src/artifacts/update_plan.rs b/update-common/src/artifacts/update_plan.rs index ae5a582be3..6d755892f2 100644 --- a/update-common/src/artifacts/update_plan.rs +++ b/update-common/src/artifacts/update_plan.rs @@ -33,6 +33,7 @@ use std::collections::btree_map; use std::collections::BTreeMap; use std::collections::HashMap; use std::io; +use tokio::io::AsyncReadExt; use tufaceous_lib::HostPhaseImages; use tufaceous_lib::RotArchives; @@ -76,6 +77,16 @@ pub struct UpdatePlan { pub control_plane_hash: ArtifactHash, } +// Used to represent the information extracted from signed RoT images. This +// is used when going from `UpdatePlanBuilder` -> `UpdatePlan` to check +// the versions on the RoT images and also to generate the map of +// ArtifactId -> Sign hashes for checking artifacts +#[derive(Debug, Eq, Hash, PartialEq)] +struct RotSignData { + kind: KnownArtifactKind, + sign: Vec, +} + /// `UpdatePlanBuilder` mirrors all the fields of `UpdatePlan`, but they're all /// optional: it can be filled in as we read a TUF repository. /// [`UpdatePlanBuilder::build()`] will (fallibly) convert from the builder to @@ -120,6 +131,10 @@ pub struct UpdatePlanBuilder<'a> { by_hash: HashMap, artifacts_meta: Vec, + // map for RoT signing information, used in `ArtifactsWithPlan` + // Note this covers the RoT bootloader which are also signed + rot_by_sign: HashMap>, + // extra fields we use to build the plan extracted_artifacts: ExtractedArtifacts, log: &'a Logger, @@ -153,6 +168,7 @@ impl<'a> UpdatePlanBuilder<'a> { by_id: BTreeMap::new(), by_hash: HashMap::new(), + rot_by_sign: HashMap::new(), artifacts_meta: Vec::new(), extracted_artifacts, @@ -341,6 +357,14 @@ impl<'a> UpdatePlanBuilder<'a> { data.extend_from_slice(&chunk); } + let (artifact_id, bootloader_sign) = + read_hubris_sign_from_archive(artifact_id, data.clone())?; + + self.rot_by_sign + .entry(RotSignData { kind: artifact_kind, sign: bootloader_sign }) + .or_default() + .push(artifact_id.clone()); + let artifact_hash_id = ArtifactHashId { kind: artifact_kind.into(), hash: artifact_hash }; let data = self @@ -411,6 +435,56 @@ impl<'a> UpdatePlanBuilder<'a> { }, )?; + // We need to get all the signing information now to properly check + // version at builder time (builder time is not async) + let image_a_stream = rot_a_data + .reader_stream() + .await + .map_err(RepositoryError::CreateReaderStream)?; + let mut image_a = Vec::with_capacity(rot_a_data.file_size()); + tokio_util::io::StreamReader::new(image_a_stream) + .read_to_end(&mut image_a) + .await + .map_err(|error| RepositoryError::ReadExtractedArchive { + artifact: ArtifactHashId { + kind: artifact_id.kind.clone(), + hash: rot_a_data.hash(), + }, + error, + })?; + + let (artifact_id, image_a_sign) = + read_hubris_sign_from_archive(artifact_id, image_a)?; + + self.rot_by_sign + .entry(RotSignData { kind: artifact_kind, sign: image_a_sign }) + .or_default() + .push(artifact_id.clone()); + + let image_b_stream = rot_b_data + .reader_stream() + .await + .map_err(RepositoryError::CreateReaderStream)?; + let mut image_b = Vec::with_capacity(rot_b_data.file_size()); + tokio_util::io::StreamReader::new(image_b_stream) + .read_to_end(&mut image_b) + .await + .map_err(|error| RepositoryError::ReadExtractedArchive { + artifact: ArtifactHashId { + kind: artifact_id.kind.clone(), + hash: rot_b_data.hash(), + }, + error, + })?; + + let (artifact_id, image_b_sign) = + read_hubris_sign_from_archive(artifact_id, image_b)?; + + self.rot_by_sign + .entry(RotSignData { kind: artifact_kind, sign: image_b_sign }) + .or_default() + .push(artifact_id.clone()); + // Technically we've done all we _need_ to do with the RoT images. We // send them directly to MGS ourself, so don't expect anyone to ask for // them via `by_id` or `by_hash`. However, it's more convenient to @@ -806,66 +880,31 @@ impl<'a> UpdatePlanBuilder<'a> { } } - // Ensure that all A/B RoT images for each board kind have the same - // version number. - for (kind, mut single_board_rot_artifacts) in [ - ( - KnownArtifactKind::GimletRot, - self.gimlet_rot_a.iter().chain(&self.gimlet_rot_b), - ), - ( - KnownArtifactKind::PscRot, - self.psc_rot_a.iter().chain(&self.psc_rot_b), - ), - ( - KnownArtifactKind::SwitchRot, - self.sidecar_rot_a.iter().chain(&self.sidecar_rot_b), - ), - ] { - // We know each of these iterators has at least 2 elements (one from - // the A artifacts and one from the B artifacts, checked above) so - // we can safely unwrap the first. - let version = - &single_board_rot_artifacts.next().unwrap().id.version; - for artifact in single_board_rot_artifacts { - if artifact.id.version != *version { + // Ensure that all A/B RoT images for each board kind and same + // signing key have the same version. (i.e. allow gimlet_rot signed + // with a staging key to be a different version from gimlet_rot signed + // with a production key) + for (entry, versions) in &self.rot_by_sign { + let kind = entry.kind; + // This unwrap is safe because we check above that each of the types + // has at least one entry + let version = &versions.first().unwrap().version; + match versions.iter().find(|x| x.version != *version) { + None => continue, + Some(v) => { return Err(RepositoryError::MultipleVersionsPresent { kind, v1: version.clone(), - v2: artifact.id.version.clone(), - }); + v2: v.version.clone(), + }) } } } - // Same check for the RoT bootloader. We are explicitly treating the - // bootloader as distinct from the main A/B images here. - for (kind, mut single_board_rot_artifacts) in [ - ( - KnownArtifactKind::GimletRotBootloader, - self.gimlet_rot_bootloader.iter(), - ), - ( - KnownArtifactKind::PscRotBootloader, - self.psc_rot_bootloader.iter(), - ), - ( - KnownArtifactKind::SwitchRotBootloader, - self.sidecar_rot_bootloader.iter(), - ), - ] { - // We know each of these iterators has at least 1 element (checked - // above) so we can safely unwrap the first. - let version = - &single_board_rot_artifacts.next().unwrap().id.version; - for artifact in single_board_rot_artifacts { - if artifact.id.version != *version { - return Err(RepositoryError::MultipleVersionsPresent { - kind, - v1: version.clone(), - v2: artifact.id.version.clone(), - }); - } + let mut rot_by_sign = HashMap::new(); + for (k, v) in self.rot_by_sign { + for id in v { + rot_by_sign.insert(id, k.sign.clone()); } } @@ -930,6 +969,7 @@ impl<'a> UpdatePlanBuilder<'a> { plan, by_id: self.by_id, by_hash: self.by_hash, + rot_by_sign, artifacts_meta: self.artifacts_meta, }) } @@ -940,9 +980,36 @@ pub struct UpdatePlanBuildOutput { pub plan: UpdatePlan, pub by_id: BTreeMap>, pub by_hash: HashMap, + pub rot_by_sign: HashMap>, pub artifacts_meta: Vec, } +// We take id solely to be able to output error messages +fn read_hubris_sign_from_archive( + id: ArtifactId, + data: Vec, +) -> Result<(ArtifactId, Vec), RepositoryError> { + let archive = match RawHubrisArchive::from_vec(data).map_err(Box::new) { + Ok(archive) => archive, + Err(error) => { + return Err(RepositoryError::ParsingHubrisArchive { id, error }); + } + }; + let caboose = match archive.read_caboose().map_err(Box::new) { + Ok(caboose) => caboose, + Err(error) => { + return Err(RepositoryError::ReadHubrisCaboose { id, error }); + } + }; + let sign = match caboose.sign() { + Ok(sign) => sign, + Err(error) => { + return Err(RepositoryError::ReadHubrisCabooseSign { id, error }); + } + }; + Ok((id, sign.to_vec())) +} + // This function takes and returns `id` to avoid an unnecessary clone; `id` will // be present in either the Ok tuple or the error. fn read_hubris_board_from_archive( @@ -1035,11 +1102,11 @@ mod tests { tarball: Bytes, } - fn make_random_rot_image() -> RandomRotImage { + fn make_bad_rot_image(board: &str) -> RandomRotImage { use tufaceous_lib::CompositeRotArchiveBuilder; - let archive_a = make_random_bytes(); - let archive_b = make_random_bytes(); + let archive_a = make_fake_bad_rot_image(board); + let archive_b = make_fake_bad_rot_image(board); let mut builder = CompositeRotArchiveBuilder::new(Vec::new(), MtimeSource::Zero) @@ -1066,14 +1133,46 @@ mod tests { } } - fn make_fake_sp_image(board: &str) -> Vec { + fn make_random_rot_image(sign: &str, board: &str) -> RandomRotImage { + use tufaceous_lib::CompositeRotArchiveBuilder; + + let archive_a = make_fake_rot_image(sign, board); + let archive_b = make_fake_rot_image(sign, board); + + let mut builder = + CompositeRotArchiveBuilder::new(Vec::new(), MtimeSource::Zero) + .unwrap(); + builder + .append_archive_a(CompositeEntry { + data: &archive_a, + mtime_source: MtimeSource::Zero, + }) + .unwrap(); + builder + .append_archive_b(CompositeEntry { + data: &archive_b, + mtime_source: MtimeSource::Zero, + }) + .unwrap(); + + let tarball = builder.finish().unwrap(); + + RandomRotImage { + archive_a: Bytes::from(archive_a), + archive_b: Bytes::from(archive_b), + tarball: Bytes::from(tarball), + } + } + + fn make_fake_rot_bootloader_image(sign: &str, board: &str) -> Vec { use hubtools::{CabooseBuilder, HubrisArchiveBuilder}; let caboose = CabooseBuilder::default() .git_commit("this-is-fake-data") .board(board) .version("0.0.0") - .name(board) + .name("rot-bord") + .sign(sign) .build(); let mut builder = HubrisArchiveBuilder::with_fake_image(); @@ -1081,14 +1180,30 @@ mod tests { builder.build_to_vec().unwrap() } - fn make_fake_rot_bootloader_image(board: &str, sign: &str) -> Vec { + fn make_fake_bad_rot_image(board: &str) -> Vec { use hubtools::{CabooseBuilder, HubrisArchiveBuilder}; + // Intentionally leave out `sign` let caboose = CabooseBuilder::default() .git_commit("this-is-fake-data") .board(board) .version("0.0.0") - .name(board) + .name("rot-bord") + .build(); + + let mut builder = HubrisArchiveBuilder::with_fake_image(); + builder.write_caboose(caboose.as_slice()).unwrap(); + builder.build_to_vec().unwrap() + } + + fn make_fake_rot_image(sign: &str, board: &str) -> Vec { + use hubtools::{CabooseBuilder, HubrisArchiveBuilder}; + + let caboose = CabooseBuilder::default() + .git_commit("this-is-fake-data") + .board(board) + .version("0.0.0") + .name("rot-bord") .sign(sign) .build(); @@ -1097,6 +1212,371 @@ mod tests { builder.build_to_vec().unwrap() } + fn make_fake_sp_image(board: &str) -> Vec { + use hubtools::{CabooseBuilder, HubrisArchiveBuilder}; + + let caboose = CabooseBuilder::default() + .git_commit("this-is-fake-data") + .board(board) + .version("0.0.0") + .name(board) + .build(); + + let mut builder = HubrisArchiveBuilder::with_fake_image(); + builder.write_caboose(caboose.as_slice()).unwrap(); + builder.build_to_vec().unwrap() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_bad_rot_versions() { + const VERSION_0: SemverVersion = SemverVersion::new(0, 0, 0); + const VERSION_1: SemverVersion = SemverVersion::new(0, 0, 1); + + let logctx = test_setup_log("test_bad_rot_version"); + + let mut plan_builder = + UpdatePlanBuilder::new(VERSION_0, &logctx.log).unwrap(); + + // The control plane artifact can be arbitrary bytes; just populate it + // with random data. + { + let kind = KnownArtifactKind::ControlPlane; + let data = make_random_bytes(); + let hash = ArtifactHash(Sha256::digest(&data).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_0, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(Bytes::from(data))]), + ) + .await + .unwrap(); + } + + // For each SP image, we'll insert two artifacts: these should end up in + // the update plan's SP image maps keyed by their "board". Normally the + // board is read from the archive itself via hubtools; we'll inject a + // test function that returns the artifact ID name as the board instead. + for (kind, boards) in [ + (KnownArtifactKind::GimletSp, ["test-gimlet-a", "test-gimlet-b"]), + (KnownArtifactKind::PscSp, ["test-psc-a", "test-psc-b"]), + (KnownArtifactKind::SwitchSp, ["test-switch-a", "test-switch-b"]), + ] { + for board in boards { + let data = make_fake_sp_image(board); + let hash = ArtifactHash(Sha256::digest(&data).into()); + let id = ArtifactId { + name: board.to_string(), + version: VERSION_0, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(Bytes::from(data))]), + ) + .await + .unwrap(); + } + } + + // The Host, Trampoline, and RoT artifacts must be structed the way we + // expect (i.e., .tar.gz's containing multiple inner artifacts). + let host = make_random_host_os_image(); + let trampoline = make_random_host_os_image(); + + for (kind, image) in [ + (KnownArtifactKind::Host, &host), + (KnownArtifactKind::Trampoline, &trampoline), + ] { + let data = &image.tarball; + let hash = ArtifactHash(Sha256::digest(data).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_0, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(data.clone())]), + ) + .await + .unwrap(); + } + + let gimlet_rot = make_random_rot_image("gimlet", "gimlet"); + let psc_rot = make_random_rot_image("psc", "psc"); + let sidecar_rot = make_random_rot_image("sidecar", "sidecar"); + + let gimlet_rot_2 = make_random_rot_image("gimlet", "gimlet-the second"); + + for (kind, artifact) in [ + (KnownArtifactKind::GimletRot, &gimlet_rot), + (KnownArtifactKind::PscRot, &psc_rot), + (KnownArtifactKind::SwitchRot, &sidecar_rot), + ] { + let data = &artifact.tarball; + let hash = ArtifactHash(Sha256::digest(data).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_0, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(data.clone())]), + ) + .await + .unwrap(); + } + + let bad_kind = KnownArtifactKind::GimletRot; + let data = &gimlet_rot_2.tarball; + let hash = ArtifactHash(Sha256::digest(data).into()); + let id = ArtifactId { + name: format!("{bad_kind:?}"), + version: VERSION_1, + kind: bad_kind.into(), + }; + plan_builder + .add_artifact(id, hash, futures::stream::iter([Ok(data.clone())])) + .await + .unwrap(); + + let gimlet_rot_bootloader = + make_fake_rot_bootloader_image("test-gimlet-a", "test-gimlet-a"); + let psc_rot_bootloader = + make_fake_rot_bootloader_image("test-psc-a", "test-psc-a"); + let switch_rot_bootloader = + make_fake_rot_bootloader_image("test-sidecar-a", "test-sidecar-a"); + + for (kind, artifact) in [ + ( + KnownArtifactKind::GimletRotBootloader, + gimlet_rot_bootloader.clone(), + ), + (KnownArtifactKind::PscRotBootloader, psc_rot_bootloader.clone()), + ( + KnownArtifactKind::SwitchRotBootloader, + switch_rot_bootloader.clone(), + ), + ] { + let hash = ArtifactHash(Sha256::digest(&artifact).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_0, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(Bytes::from(artifact))]), + ) + .await + .unwrap(); + } + + match plan_builder.build() { + Err(_) => (), + Ok(_) => panic!("Added two artifacts with the same version"), + } + logctx.cleanup_successful(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_multi_rot_version() { + const VERSION_0: SemverVersion = SemverVersion::new(0, 0, 0); + const VERSION_1: SemverVersion = SemverVersion::new(0, 0, 1); + + let logctx = test_setup_log("test_multi_rot_version"); + + let mut plan_builder = + UpdatePlanBuilder::new("0.0.0".parse().unwrap(), &logctx.log) + .unwrap(); + + // The control plane artifact can be arbitrary bytes; just populate it + // with random data. + { + let kind = KnownArtifactKind::ControlPlane; + let data = make_random_bytes(); + let hash = ArtifactHash(Sha256::digest(&data).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_0, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(Bytes::from(data))]), + ) + .await + .unwrap(); + } + + // For each SP image, we'll insert two artifacts: these should end up in + // the update plan's SP image maps keyed by their "board". Normally the + // board is read from the archive itself via hubtools; we'll inject a + // test function that returns the artifact ID name as the board instead. + for (kind, boards) in [ + (KnownArtifactKind::GimletSp, ["test-gimlet-a", "test-gimlet-b"]), + (KnownArtifactKind::PscSp, ["test-psc-a", "test-psc-b"]), + (KnownArtifactKind::SwitchSp, ["test-switch-a", "test-switch-b"]), + ] { + for board in boards { + let data = make_fake_sp_image(board); + let hash = ArtifactHash(Sha256::digest(&data).into()); + let id = ArtifactId { + name: board.to_string(), + version: VERSION_0, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(Bytes::from(data))]), + ) + .await + .unwrap(); + } + } + + // The Host, Trampoline, and RoT artifacts must be structed the way we + // expect (i.e., .tar.gz's containing multiple inner artifacts). + let host = make_random_host_os_image(); + let trampoline = make_random_host_os_image(); + + for (kind, image) in [ + (KnownArtifactKind::Host, &host), + (KnownArtifactKind::Trampoline, &trampoline), + ] { + let data = &image.tarball; + let hash = ArtifactHash(Sha256::digest(data).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_0, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(data.clone())]), + ) + .await + .unwrap(); + } + + let gimlet_rot = make_random_rot_image("gimlet", "gimlet"); + let psc_rot = make_random_rot_image("psc", "psc"); + let sidecar_rot = make_random_rot_image("sidecar", "sidecar"); + + let gimlet_rot_2 = make_random_rot_image("gimlet2", "gimlet"); + let psc_rot_2 = make_random_rot_image("psc2", "psc"); + let sidecar_rot_2 = make_random_rot_image("sidecar2", "sidecar"); + + for (kind, artifact) in [ + (KnownArtifactKind::GimletRot, &gimlet_rot), + (KnownArtifactKind::PscRot, &psc_rot), + (KnownArtifactKind::SwitchRot, &sidecar_rot), + ] { + let data = &artifact.tarball; + let hash = ArtifactHash(Sha256::digest(data).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_0, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(data.clone())]), + ) + .await + .unwrap(); + } + + for (kind, artifact) in [ + (KnownArtifactKind::GimletRot, &gimlet_rot_2), + (KnownArtifactKind::PscRot, &psc_rot_2), + (KnownArtifactKind::SwitchRot, &sidecar_rot_2), + ] { + let data = &artifact.tarball; + let hash = ArtifactHash(Sha256::digest(data).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_1, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(data.clone())]), + ) + .await + .unwrap(); + } + + let gimlet_rot_bootloader = + make_fake_rot_bootloader_image("test-gimlet-a", "test-gimlet-a"); + let psc_rot_bootloader = + make_fake_rot_bootloader_image("test-psc-a", "test-psc-a"); + let switch_rot_bootloader = + make_fake_rot_bootloader_image("test-sidecar-a", "test-sidecar-a"); + + for (kind, artifact) in [ + ( + KnownArtifactKind::GimletRotBootloader, + gimlet_rot_bootloader.clone(), + ), + (KnownArtifactKind::PscRotBootloader, psc_rot_bootloader.clone()), + ( + KnownArtifactKind::SwitchRotBootloader, + switch_rot_bootloader.clone(), + ), + ] { + let hash = ArtifactHash(Sha256::digest(&artifact).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_0, + kind: kind.into(), + }; + plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(Bytes::from(artifact))]), + ) + .await + .unwrap(); + } + + let UpdatePlanBuildOutput { plan, .. } = plan_builder.build().unwrap(); + + assert_eq!(plan.gimlet_rot_a.len(), 2); + assert_eq!(plan.gimlet_rot_b.len(), 2); + assert_eq!(plan.psc_rot_a.len(), 2); + assert_eq!(plan.psc_rot_b.len(), 2); + assert_eq!(plan.sidecar_rot_a.len(), 2); + assert_eq!(plan.sidecar_rot_b.len(), 2); + logctx.cleanup_successful(); + } + // See documentation for extract_nested_artifact_pair for why multi_thread // is required. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1207,9 +1687,9 @@ mod tests { .unwrap(); } - let gimlet_rot = make_random_rot_image(); - let psc_rot = make_random_rot_image(); - let sidecar_rot = make_random_rot_image(); + let gimlet_rot = make_random_rot_image("gimlet", "gimlet"); + let psc_rot = make_random_rot_image("psc", "psc"); + let sidecar_rot = make_random_rot_image("sidecar", "sidecar"); for (kind, artifact) in [ (KnownArtifactKind::GimletRot, &gimlet_rot), @@ -1395,6 +1875,81 @@ mod tests { logctx.cleanup_successful(); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_bad_hubris_cabooses() { + const VERSION_0: SemverVersion = SemverVersion::new(0, 0, 0); + + let logctx = test_setup_log("test_bad_hubris_cabooses"); + + let mut plan_builder = + UpdatePlanBuilder::new("0.0.0".parse().unwrap(), &logctx.log) + .unwrap(); + + let gimlet_rot = make_bad_rot_image("gimlet"); + let psc_rot = make_bad_rot_image("psc"); + let sidecar_rot = make_bad_rot_image("sidecar"); + + for (kind, artifact) in [ + (KnownArtifactKind::GimletRot, &gimlet_rot), + (KnownArtifactKind::PscRot, &psc_rot), + (KnownArtifactKind::SwitchRot, &sidecar_rot), + ] { + let data = &artifact.tarball; + let hash = ArtifactHash(Sha256::digest(data).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_0, + kind: kind.into(), + }; + match plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(data.clone())]), + ) + .await + { + Ok(_) => panic!("expected to fail"), + Err(_) => (), + } + } + + let gimlet_rot_bootloader = make_fake_bad_rot_image("test-gimlet-a"); + let psc_rot_bootloader = make_fake_bad_rot_image("test-psc-a"); + let switch_rot_bootloader = make_fake_bad_rot_image("test-sidecar-a"); + for (kind, artifact) in [ + ( + KnownArtifactKind::GimletRotBootloader, + gimlet_rot_bootloader.clone(), + ), + (KnownArtifactKind::PscRotBootloader, psc_rot_bootloader.clone()), + ( + KnownArtifactKind::SwitchRotBootloader, + switch_rot_bootloader.clone(), + ), + ] { + let hash = ArtifactHash(Sha256::digest(&artifact).into()); + let id = ArtifactId { + name: format!("{kind:?}"), + version: VERSION_0, + kind: kind.into(), + }; + match plan_builder + .add_artifact( + id, + hash, + futures::stream::iter([Ok(Bytes::from(artifact))]), + ) + .await + { + Ok(_) => panic!("unexpected success"), + Err(_) => (), + } + } + + logctx.cleanup_successful(); + } + async fn read_to_vec(data: &ExtractedArtifactDataHandle) -> Vec { let mut buf = Vec::with_capacity(data.file_size()); let mut stream = data.reader_stream().await.unwrap(); diff --git a/update-common/src/errors.rs b/update-common/src/errors.rs index 0d65312c56..f9bfa48f7a 100644 --- a/update-common/src/errors.rs +++ b/update-common/src/errors.rs @@ -118,6 +118,12 @@ pub enum RepositoryError { #[source] error: hubtools::CabooseError, }, + #[error("error reading sign from hubris caboose of {id:?}")] + ReadHubrisCabooseSign { + id: ArtifactId, + #[source] + error: hubtools::CabooseError, + }, #[error( "error reading board from hubris caboose of {0:?}: non-utf8 value" @@ -140,6 +146,14 @@ pub enum RepositoryError { "duplicate hash entries found in artifacts.json for kind `{}`, hash `{}`", .0.kind, .0.hash )] DuplicateHashEntry(ArtifactHashId), + #[error("error creating reader stream")] + CreateReaderStream(#[source] anyhow::Error), + #[error("error reading extracted archive kind {}, hash {}", .artifact.kind, .artifact.hash)] + ReadExtractedArchive { + artifact: ArtifactHashId, + #[source] + error: std::io::Error, + }, } impl RepositoryError { @@ -153,7 +167,9 @@ impl RepositoryError { | RepositoryError::TempFileCreate(_) | RepositoryError::TempFileWrite(_) | RepositoryError::TempFileFlush(_) - | RepositoryError::NamedTempFileCreate { .. } => { + | RepositoryError::NamedTempFileCreate { .. } + | RepositoryError::ReadExtractedArchive { .. } + | RepositoryError::CreateReaderStream { .. } => { HttpError::for_unavail(None, message) } @@ -176,6 +192,7 @@ impl RepositoryError { | RepositoryError::ParsingHubrisArchive { .. } | RepositoryError::ReadHubrisCaboose { .. } | RepositoryError::ReadHubrisCabooseBoard { .. } + | RepositoryError::ReadHubrisCabooseSign { .. } | RepositoryError::ReadHubrisCabooseBoardUtf8(_) | RepositoryError::MultipleVersionsPresent { .. } => { HttpError::for_bad_request(None, message) diff --git a/wicket/Cargo.toml b/wicket/Cargo.toml index a443232aeb..117a012845 100644 --- a/wicket/Cargo.toml +++ b/wicket/Cargo.toml @@ -17,6 +17,7 @@ ciborium.workspace = true clap.workspace = true crossterm.workspace = true futures.workspace = true +hex.workspace = true humantime.workspace = true indexmap.workspace = true indicatif.workspace = true diff --git a/wicket/src/events.rs b/wicket/src/events.rs index 36480d261f..0e39d131b4 100644 --- a/wicket/src/events.rs +++ b/wicket/src/events.rs @@ -18,6 +18,15 @@ use wicketd_client::types::{ /// Event report type returned by the get_artifacts_and_event_reports API call. pub type EventReportMap = HashMap>; +/// Represents an artifact from a TUF repo +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtifactData { + /// Artifact ID + pub id: ArtifactId, + /// Optional sign information (currently only used for RoT images) + pub sign: Option>, +} + /// An event that will update state /// /// This can be a keypress, mouse event, or response from a downstream service. @@ -32,7 +41,7 @@ pub enum Event { /// TUF repo artifacts unpacked by wicketd, and event reports ArtifactsAndEventReports { system_version: Option, - artifacts: Vec, + artifacts: Vec, event_reports: EventReportMap, }, diff --git a/wicket/src/state/inventory.rs b/wicket/src/state/inventory.rs index 8155efb606..cec8e46fd9 100644 --- a/wicket/src/state/inventory.rs +++ b/wicket/src/state/inventory.rs @@ -138,6 +138,10 @@ fn version_or_unknown(caboose: Option<&SpComponentCaboose>) -> String { caboose.map(|c| c.version.as_str()).unwrap_or("UNKNOWN").to_string() } +fn caboose_sign(caboose: &SpComponentCaboose) -> Option> { + caboose.sign.as_ref().map(|s| s.as_bytes().to_vec()) +} + impl Component { pub fn sp(&self) -> &Sp { match self { @@ -190,6 +194,22 @@ impl Component { rot.caboose_stage0next.as_ref().map_or(None, |x| x.as_ref()) })) } + + // Technically the slots could have different SIGN values in the + // caboose. An active slot implies the RoT is up and valid so + // we should rely on that value for selection. + // We also use this for the bootloader selection as the SIGN + // of the bootloader is going to be identical to the RoT. + pub fn rot_sign(&self) -> Option> { + match self.rot_active_slot()? { + RotSlot::A => self.sp().rot.as_ref().map_or(None, |rot| { + rot.caboose_a.as_ref().and_then(caboose_sign) + }), + RotSlot::B => self.sp().rot.as_ref().map_or(None, |rot| { + rot.caboose_b.as_ref().map_or(None, |x| caboose_sign(x)) + }), + } + } } /// The component type and its slot. diff --git a/wicket/src/state/mod.rs b/wicket/src/state/mod.rs index d287a153a9..9e6da97d4f 100644 --- a/wicket/src/state/mod.rs +++ b/wicket/src/state/mod.rs @@ -17,7 +17,7 @@ pub use inventory::{ pub use rack::{KnightRiderMode, RackState}; pub use status::ServiceStatus; pub use update::{ - parse_event_report_map, update_component_title, + parse_event_report_map, update_component_title, ArtifactVersions, CreateClearUpdateStateOptions, CreateStartUpdateOptions, RackUpdateState, UpdateItemState, }; diff --git a/wicket/src/state/update.rs b/wicket/src/state/update.rs index 97c0b49d2d..f143ac3645 100644 --- a/wicket/src/state/update.rs +++ b/wicket/src/state/update.rs @@ -11,7 +11,10 @@ use wicket_common::update_events::{ }; use crate::helpers::{get_update_simulated_result, get_update_test_error}; -use crate::{events::EventReportMap, ui::defaults::style}; +use crate::{ + events::{ArtifactData, EventReportMap}, + ui::defaults::style, +}; use super::{ComponentId, ParsableComponentId, ALL_COMPONENT_IDS}; use omicron_common::api::internal::nexus::KnownArtifactKind; @@ -19,14 +22,24 @@ use serde::{Deserialize, Serialize}; use slog::Logger; use std::collections::BTreeMap; use std::fmt::Display; -use wicketd_client::types::{ArtifactId, SemverVersion}; +use wicketd_client::types::SemverVersion; + +// Represents a version and the signature (optional) associated +// with a particular artifact. This allows for multiple versions +// with different versions to be present in the repo. Note +// sign is currently only used for RoT artifacts +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtifactVersions { + pub version: SemverVersion, + pub sign: Option>, +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RackUpdateState { pub items: BTreeMap, pub system_version: Option, - pub artifacts: Vec, - pub artifact_versions: BTreeMap, + pub artifacts: Vec, + pub artifact_versions: BTreeMap>, // The update item currently selected is recorded in // state.rack_state.selected. pub status_view_displayed: bool, @@ -102,15 +115,20 @@ impl RackUpdateState { &mut self, logger: &Logger, system_version: Option, - artifacts: Vec, + artifacts: Vec, reports: EventReportMap, ) { self.system_version = system_version; self.artifacts = artifacts; self.artifact_versions.clear(); - for id in &mut self.artifacts { - if let Ok(known) = id.kind.parse() { - self.artifact_versions.insert(known, id.version.clone()); + for a in &mut self.artifacts { + if let Ok(known) = a.id.kind.parse() { + self.artifact_versions.entry(known).or_default().push( + ArtifactVersions { + version: a.id.version.clone(), + sign: a.sign.clone(), + }, + ); } } diff --git a/wicket/src/ui/panes/overview.rs b/wicket/src/ui/panes/overview.rs index 00da6396c2..b807a13a97 100644 --- a/wicket/src/ui/panes/overview.rs +++ b/wicket/src/ui/panes/overview.rs @@ -1125,13 +1125,8 @@ fn append_caboose( prefix: Span<'static>, caboose: &SpComponentCaboose, ) { - let SpComponentCaboose { - board, - git_commit, - // Currently `name` is always the same as `board`, so we'll skip it. - name: _, - version, - } = caboose; + let SpComponentCaboose { board, git_commit, name, sign, version, epoch } = + caboose; let label_style = style::text_label(); let ok_style = style::text_success(); @@ -1151,6 +1146,35 @@ fn append_caboose( ] .into(), ); + spans.push( + vec![ + prefix.clone(), + Span::styled("Name: ", label_style), + Span::styled(name.clone(), ok_style), + ] + .into(), + ); + if let Some(s) = sign { + spans.push( + vec![ + prefix.clone(), + Span::styled("Sign Hash: ", label_style), + Span::styled(s.clone(), ok_style), + ] + .into(), + ); + } + if let Some(s) = epoch { + spans.push( + vec![ + prefix.clone(), + Span::styled("Epoch: ", label_style), + Span::styled(s.clone(), ok_style), + ] + .into(), + ); + } + let mut version_spans = vec![prefix.clone(), Span::styled("Version: ", label_style)]; version_spans.push(Span::styled(version, ok_style)); diff --git a/wicket/src/ui/panes/update.rs b/wicket/src/ui/panes/update.rs index de34391fcc..1b2a1ed295 100644 --- a/wicket/src/ui/panes/update.rs +++ b/wicket/src/ui/panes/update.rs @@ -8,8 +8,8 @@ use std::collections::BTreeMap; use super::{align_by, help_text, push_text_lines, Control, PendingScroll}; use crate::keymap::ShowPopupCmd; use crate::state::{ - update_component_title, ComponentId, Inventory, UpdateItemState, - ALL_COMPONENT_IDS, + update_component_title, ArtifactVersions, ComponentId, Inventory, + UpdateItemState, ALL_COMPONENT_IDS, }; use crate::ui::defaults::style; use crate::ui::widgets::{ @@ -39,7 +39,6 @@ use wicket_common::update_events::{ EventBuffer, EventReport, ProgressEvent, StepOutcome, StepStatus, UpdateComponent, }; -use wicketd_client::types::SemverVersion; const MAX_COLUMN_WIDTH: u16 = 25; @@ -844,8 +843,9 @@ impl UpdatePane { let children: Vec<_> = states .iter() .flat_map(|(component, s)| { - let target_version = - artifact_version(id, component, &versions); + let target_version = artifact_version( + id, component, &versions, inventory, + ); let installed_versions = all_installed_versions(id, component, inventory); installed_versions.into_iter().map(move |v| { @@ -1459,6 +1459,7 @@ impl UpdatePane { &state.rack_state.selected, component, versions, + inventory, ); let installed_versions = all_installed_versions( &state.rack_state.selected, @@ -1740,7 +1741,7 @@ impl From<&'_ State> for ForceUpdateSelectionState { } let artifact_version = - artifact_version(&component_id, component, versions); + artifact_version(&component_id, component, versions, inventory); let installed_version = active_installed_version(&component_id, component, inventory); match component { @@ -2395,38 +2396,41 @@ fn all_installed_versions( fn artifact_version( id: &ComponentId, - component: UpdateComponent, - versions: &BTreeMap, + update_component: UpdateComponent, + versions: &BTreeMap>, + inventory: &Inventory, ) -> String { - let artifact = match (id, component) { + let (artifact, multiple) = match (id, update_component) { (ComponentId::Sled(_), UpdateComponent::RotBootloader) => { - KnownArtifactKind::GimletRotBootloader + (KnownArtifactKind::GimletRotBootloader, true) } (ComponentId::Sled(_), UpdateComponent::Rot) => { - KnownArtifactKind::GimletRot + (KnownArtifactKind::GimletRot, true) } (ComponentId::Sled(_), UpdateComponent::Sp) => { - KnownArtifactKind::GimletSp + (KnownArtifactKind::GimletSp, false) } (ComponentId::Sled(_), UpdateComponent::Host) => { - KnownArtifactKind::Host + (KnownArtifactKind::Host, false) } (ComponentId::Switch(_), UpdateComponent::RotBootloader) => { - KnownArtifactKind::SwitchRotBootloader + (KnownArtifactKind::SwitchRotBootloader, true) } (ComponentId::Switch(_), UpdateComponent::Rot) => { - KnownArtifactKind::SwitchRot + (KnownArtifactKind::SwitchRot, true) } (ComponentId::Switch(_), UpdateComponent::Sp) => { - KnownArtifactKind::SwitchSp + (KnownArtifactKind::SwitchSp, false) } (ComponentId::Psc(_), UpdateComponent::RotBootloader) => { - KnownArtifactKind::PscRotBootloader + (KnownArtifactKind::PscRotBootloader, true) } (ComponentId::Psc(_), UpdateComponent::Rot) => { - KnownArtifactKind::PscRot + (KnownArtifactKind::PscRot, true) + } + (ComponentId::Psc(_), UpdateComponent::Sp) => { + (KnownArtifactKind::PscSp, false) } - (ComponentId::Psc(_), UpdateComponent::Sp) => KnownArtifactKind::PscSp, // Switches and PSCs do not have a host. (ComponentId::Switch(_), UpdateComponent::Host) @@ -2434,10 +2438,48 @@ fn artifact_version( return "N/A".to_string() } }; - versions - .get(&artifact) - .cloned() - .map_or_else(|| "UNKNOWN".to_string(), |v| v.to_string()) + match versions.get(&artifact) { + None => "UNKNOWN".to_string(), + Some(artifact_versions) => { + let component = match inventory.get_inventory(id) { + Some(c) => c, + None => return "UNKNOWN".to_string(), + }; + let cnt = artifact_versions.len(); + // We loop through all possible artifact versions for a + // given artifact type. Right now only RoT artifacts + // will have a sign value. + for a in artifact_versions { + match (&a.sign, component.rot_sign()) { + // No sign anywhere, this is for SP components + (None, None) => return a.version.to_string(), + // if we have a version that's tagged with sign data but + // we can't read from the caboose check if we can fall + // back to just returning the version. This matches + // very old repositories + (Some(_), None) => { + if multiple && cnt > 1 { + return "UNKNOWN (MISSING SIGN)".to_string(); + } else { + return a.version.to_string(); + } + } + // If something isn't tagged with a sign just + // pass on the version. This should only match + // very old repositories/testing configurations + (None, Some(_)) => return a.version.to_string(), + // The interesting case to make sure the sign + // matches both the component and the caboose + (Some(s), Some(c)) => { + if *s == c { + return a.version.to_string(); + } + } + } + } + "NO MATCH".to_string() + } + } } impl Control for UpdatePane { diff --git a/wicket/src/wicketd.rs b/wicket/src/wicketd.rs index dce1c7d286..635878e080 100644 --- a/wicket/src/wicketd.rs +++ b/wicket/src/wicketd.rs @@ -19,7 +19,7 @@ use wicketd_client::types::{ GetLocationResponse, IgnitionCommand, StartUpdateParams, }; -use crate::events::EventReportMap; +use crate::events::{ArtifactData, EventReportMap}; use crate::keymap::ShowPopupCmd; use crate::state::ComponentId; use crate::{Cmd, Event}; @@ -451,7 +451,10 @@ impl WicketdManager { let artifacts = rsp .artifacts .into_iter() - .map(|artifact| artifact.artifact_id) + .map(|artifact| ArtifactData { + id: artifact.artifact_id, + sign: artifact.sign, + }) .collect(); let system_version = rsp.system_version; let event_reports: EventReportMap = rsp.event_reports; diff --git a/wicketd-api/src/lib.rs b/wicketd-api/src/lib.rs index 12a1653226..c4c170481b 100644 --- a/wicketd-api/src/lib.rs +++ b/wicketd-api/src/lib.rs @@ -460,6 +460,7 @@ pub enum GetInventoryResponse { pub struct InstallableArtifacts { pub artifact_id: ArtifactId, pub installable: Vec, + pub sign: Option>, } /// The response to a `get_artifacts` call: the system version, and the list of diff --git a/wicketd/src/artifacts/store.rs b/wicketd/src/artifacts/store.rs index 98a6abcaad..8ab83335a8 100644 --- a/wicketd/src/artifacts/store.rs +++ b/wicketd/src/artifacts/store.rs @@ -44,12 +44,14 @@ impl WicketdArtifactStore { let artifacts = self.artifacts_with_plan.lock().unwrap(); let artifacts = artifacts.as_ref()?; let system_version = artifacts.plan().system_version.clone(); + let artifact_ids = artifacts .by_id() .iter() .map(|(k, v)| InstallableArtifacts { artifact_id: k.clone(), installable: v.clone(), + sign: artifacts.rot_by_sign().get(&k).cloned(), }) .collect(); Some((system_version, artifact_ids)) diff --git a/wicketd/src/update_tracker.rs b/wicketd/src/update_tracker.rs index ee8cacd1dd..0fad3ba37f 100644 --- a/wicketd/src/update_tracker.rs +++ b/wicketd/src/update_tracker.rs @@ -1020,24 +1020,17 @@ impl UpdateDriver { (), format!( "RoT bootloader already at version {}", - rot_bootloader_interrogation.available_artifacts_version, + rot_bootloader_interrogation.artifact_to_apply.id.version, ), ) .into(); } - let artifact_to_apply = rot_bootloader_interrogation - .choose_artifact_to_apply( - &update_cx.mgs_client, - &update_cx.log, - ) - .await?; - cx.with_nested_engine(|engine| { inner_cx.register_steps( engine, rot_bootloader_interrogation.slot_to_update, - artifact_to_apply, + &rot_bootloader_interrogation.artifact_to_apply, ); Ok(()) }) @@ -1051,7 +1044,7 @@ impl UpdateDriver { (), format!( "RoT bootloader updated despite already having version {}", - rot_bootloader_interrogation.available_artifacts_version + rot_bootloader_interrogation.artifact_to_apply.id.version, ), ) .into() @@ -1088,24 +1081,17 @@ impl UpdateDriver { (), format!( "RoT active slot already at version {}", - rot_interrogation.available_artifacts_version, + rot_interrogation.artifact_to_apply.id.version ), ) .into(); } - let artifact_to_apply = rot_interrogation - .choose_artifact_to_apply( - &update_cx.mgs_client, - &update_cx.log, - ) - .await?; - cx.with_nested_engine(|engine| { inner_cx.register_steps( engine, rot_interrogation.slot_to_update, - artifact_to_apply, + &rot_interrogation.artifact_to_apply, ); Ok(()) }) @@ -1119,7 +1105,7 @@ impl UpdateDriver { (), format!( "RoT updated despite already having version {}", - rot_interrogation.available_artifacts_version + rot_interrogation.artifact_to_apply.id.version ), ) .into() @@ -1252,8 +1238,8 @@ impl UpdateDriver { UpdateTerminalError::DownloadingInstallinatorFailed { error } })?; - StepSuccess::new(report_receiver).into() - }, + StepSuccess::new(report_receiver).into() + }, ) .register(); @@ -1364,11 +1350,11 @@ impl UpdateDriver { upload_trampoline_phase_2_to_mgs.changed().await.map_err( |_recv_err| { UpdateTerminalError::TrampolinePhase2UploadCancelled - } + } )?; } }, - ).register(); + ).register(); registrar .new_step( @@ -1491,11 +1477,11 @@ impl UpdateDriver { move |_cx| async move { if let Err(err) = update_cx .mgs_client - .sp_installinator_image_id_delete( - update_cx.sp.type_, - update_cx.sp.slot, - ) - .await + .sp_installinator_image_id_delete( + update_cx.sp.type_, + update_cx.sp.slot, + ) + .await { warn!( update_cx.log, @@ -1642,64 +1628,57 @@ fn define_test_steps(engine: &UpdateEngine, secs: u64) { |cx| async move { for sec in 0..secs { cx.send_progress( - StepProgress::with_current_and_total( - sec, - secs, - "seconds", - serde_json::Value::Null, - ), - ) - .await; + StepProgress::with_current_and_total( + sec, + secs, + "seconds", + serde_json::Value::Null, + ), + ) + .await; tokio::time::sleep( Duration::from_secs(1), ) - .await; - } + .await; + } StepSuccess::new(()) .with_message(format!( - "Step completed after {secs} seconds" - )) + "Step completed after {secs} seconds" + )) .into() }, - ) - .register(); + ) + .register(); engine - .new_step( - TestStepComponent::Test, - TestStepId::Delay, - "Nested stub step", - |_cx| async move { StepSuccess::new(()).into() }, - ) - .register(); + .new_step( + TestStepComponent::Test, + TestStepId::Delay, + "Nested stub step", + |_cx| async move { StepSuccess::new(()).into() }, + ) + .register(); Ok(()) }, ) - .await?; + .await?; StepSuccess::new(()).into() }, - ) - .register(); + ) + .register(); } #[derive(Debug)] struct RotInterrogation { // Which RoT slot we need to update. slot_to_update: u16, - // This is a `Vec<_>` because we may have the same version of the RoT - // artifact supplied with different keys. Once we decide whether we're going - // to apply this update at all, we'll ask the RoT for its CMPA and CFPA - // pages to filter this list down to the one matching artifact. - available_artifacts: Vec, - // We require all the artifacts in `available_artifacts` to have the same - // version, and we record that here for use in our methods below. - available_artifacts_version: SemverVersion, // Identifier of the target RoT's SP. sp: SpIdentifier, // Version reported by the target RoT. + artifact_to_apply: ArtifactIdData, active_version: Option, } @@ -1742,176 +1721,7 @@ impl RotInterrogation { } fn active_version_matches_artifact_to_apply(&self) -> bool { - Some(&self.available_artifacts_version) == self.active_version.as_ref() - } - - /// Via `client`, ask the target RoT for its CMPA/CFPA pages, then loop - /// through our `available_artifacts` to find one that verifies. - /// - /// For backwards compatibility with RoTs that do not know how to return - /// their CMPA/CFPA pages, if we fail to fetch them _and_ - /// `available_artifacts` has exactly one item, we will return that one - /// item. - /// - /// This is also applicable to the RoT bootloader which follows the - /// same vaildation method - async fn choose_artifact_to_apply( - &self, - client: &gateway_client::Client, - log: &Logger, - ) -> Result<&ArtifactIdData, UpdateTerminalError> { - let cmpa = match client - .sp_rot_cmpa_get( - self.sp.type_, - self.sp.slot, - SpComponent::ROT.const_as_str(), - ) - .await - { - Ok(response) => { - let data = response.into_inner().base64_data; - self.decode_rot_page(&data).map_err(|error| { - UpdateTerminalError::GetRotCmpaFailed { error } - })? - } - // TODO is there a better way to check the _specific_ error response - // we get here? We only have a couple of strings; we could check the - // error string contents for something like "WrongVersion", but - // that's pretty fragile. Instead we'll treat any error response - // here as a "fallback to previous behavior". - Err(err @ gateway_client::Error::ErrorResponse(_)) => { - if self.available_artifacts.len() == 1 { - info!( - log, - "Failed to get RoT CMPA page; \ - using only available RoT artifact"; - "err" => %err, - ); - return Ok(&self.available_artifacts[0]); - } else { - error!( - log, - "Failed to get RoT CMPA; unable to choose from \ - multiple available RoT artifacts"; - "err" => %err, - "num_rot_artifacts" => self.available_artifacts.len(), - ); - return Err(UpdateTerminalError::GetRotCmpaFailed { - error: err.into(), - }); - } - } - // For any other error (e.g., comms failures), just fail as normal. - Err(err) => { - return Err(UpdateTerminalError::GetRotCmpaFailed { - error: err.into(), - }); - } - }; - - // We have a CMPA; we also need the CFPA, but we don't bother checking - // for an `ErrorResponse` as above because succeeding in getting the - // CMPA means the RoT is new enough to support returning both. - let cfpa = client - .sp_rot_cfpa_get( - self.sp.type_, - self.sp.slot, - SpComponent::ROT.const_as_str(), - &gateway_client::types::GetCfpaParams { - slot: RotCfpaSlot::Active, - }, - ) - .await - .map_err(|err| UpdateTerminalError::GetRotCfpaFailed { - error: err.into(), - }) - .and_then(|response| { - let data = response.into_inner().base64_data; - self.decode_rot_page(&data).map_err(|error| { - UpdateTerminalError::GetRotCfpaFailed { error } - }) - })?; - - // Loop through our possible artifacts and find the first (we only - // expect one!) that verifies against the RoT's CMPA/CFPA. - for artifact in &self.available_artifacts { - let image = artifact - .data - .reader_stream() - .and_then(|stream| async { - let mut buf = Vec::with_capacity(artifact.data.file_size()); - StreamReader::new(stream) - .read_to_end(&mut buf) - .await - .context("I/O error reading extracted archive")?; - Ok(buf) - }) - .await - .map_err(|error| { - UpdateTerminalError::FailedFindingSignedRotImage { error } - })?; - let archive = RawHubrisArchive::from_vec(image).map_err(|err| { - UpdateTerminalError::FailedFindingSignedRotImage { - error: anyhow::Error::new(err).context(format!( - "failed to read hubris archive for {:?}", - artifact.id - )), - } - })?; - match archive.verify(&cmpa, &cfpa) { - Ok(()) => { - info!( - log, "RoT archive verification success"; - "name" => artifact.id.name.as_str(), - "version" => %artifact.id.version, - "kind" => ?artifact.id.kind, - ); - return Ok(artifact); - } - Err(err) => { - // We log this but don't fail - we want to continue - // looking for a verifiable artifact. - info!( - log, "RoT archive verification failed"; - "artifact" => ?artifact.id, - "err" => %DisplayErrorChain::new(&err), - ); - } - } - } - - // If the loop above didn't find a verifiable image, we cannot proceed. - Err(UpdateTerminalError::FailedFindingSignedRotImage { - error: anyhow!("no RoT image found with valid CMPA/CFPA"), - }) - } - - /// Decode a base64-encoded RoT page we received from MGS. - fn decode_rot_page( - &self, - data: &str, - ) -> anyhow::Result<[u8; ROT_PAGE_SIZE]> { - // Even though we know `data` should decode to exactly - // `ROT_PAGE_SIZE` bytes, the base64 crate requires an output buffer - // of at least `decoded_len_estimate`. Allocate such a buffer here, - // then we'll copy to the fixed-size array we need after confirming - // the number of decoded bytes; - let mut output_buf = vec![0; base64::decoded_len_estimate(data.len())]; - - let n = base64::engine::general_purpose::STANDARD - .decode_slice(&data, &mut output_buf) - .with_context(|| { - format!("failed to decode base64 string: {data:?}") - })?; - if n != ROT_PAGE_SIZE { - bail!( - "incorrect len ({n}, expected {ROT_PAGE_SIZE}) \ - after decoding base64 string: {data:?}", - ); - } - let mut page = [0; ROT_PAGE_SIZE]; - page.copy_from_slice(&output_buf[..n]); - Ok(page) + Some(&self.artifact_to_apply.id.version) == self.active_version.as_ref() } } @@ -2026,18 +1836,6 @@ impl UpdateContext { "fa73f26fb73b27b5db8f425320e206df5ebf3e137475d40be76b540ea8bd2af9", ]; - // We already validated at repo-upload time there is at least one RoT - // artifact available and that all available RoT artifacts are the same - // version, so we can unwrap the first artifact here and assume its - // version matches any subsequent artifacts. - // TODO this needs to be fixed for multi version to work! - let available_artifacts_version = rot_bootloader - .get(0) - .expect("no RoT artifacts available") - .id - .version - .clone(); - let stage0_fwid = match self .mgs_client .sp_rot_boot_info( @@ -2075,6 +1873,10 @@ impl UpdateContext { .into(), }; + let available_artifacts = rot_bootloader.to_vec(); + let artifact_to_apply = + self.choose_rot_artifact_to_apply(&available_artifacts).await?; + // Read the caboose of the currently running version (always 0) // When updating from older stage0 we may not have a caboose so an error here // need not be fatal @@ -2091,13 +1893,11 @@ impl UpdateContext { .map(|v| v.into_inner()) .ok(); - let available_artifacts = rot_bootloader.to_vec(); let make_result = |active_version| { Some(RotInterrogation { // We always update slot 1 slot_to_update: 1, - available_artifacts, - available_artifacts_version, + artifact_to_apply: artifact_to_apply.clone(), sp: self.sp, active_version, }) @@ -2172,16 +1972,9 @@ impl UpdateContext { } }; - // We already validated at repo-upload time there is at least one RoT - // artifact available and that all available RoT artifacts are the same - // version, so we can unwrap the first artifact here and assume its - // version matches any subsequent artifacts. - let available_artifacts_version = available_artifacts - .get(0) - .expect("no RoT artifacts available") - .id - .version - .clone(); + let available_artifacts = available_artifacts.to_vec(); + let artifact_to_apply = + self.choose_rot_artifact_to_apply(&available_artifacts).await?; // Read the caboose of the currently-active slot. let caboose = self @@ -2203,11 +1996,9 @@ impl UpdateContext { caboose.version, caboose.git_commit ); - let available_artifacts = available_artifacts.to_vec(); let make_result = |active_version| RotInterrogation { slot_to_update, - available_artifacts, - available_artifacts_version, + artifact_to_apply: artifact_to_apply.clone(), sp: self.sp, active_version, }; @@ -2224,6 +2015,176 @@ impl UpdateContext { } } + /// Via `client`, ask the target RoT for its CMPA/CFPA pages, then loop + /// through our `available_artifacts` to find one that verifies. + /// + /// For backwards compatibility with RoTs that do not know how to return + /// their CMPA/CFPA pages, if we fail to fetch them _and_ + /// `available_artifacts` has exactly one item, we will return that one + /// item. + /// + /// This is also applicable to the RoT bootloader which follows the + /// same vaildation method + async fn choose_rot_artifact_to_apply<'a>( + &'a self, + available_artifacts: &'a Vec, + ) -> Result<&ArtifactIdData, UpdateTerminalError> { + let cmpa = match self + .mgs_client + .sp_rot_cmpa_get( + self.sp.type_, + self.sp.slot, + SpComponent::ROT.const_as_str(), + ) + .await + { + Ok(response) => { + let data = response.into_inner().base64_data; + self.decode_rot_page(&data).map_err(|error| { + UpdateTerminalError::GetRotCmpaFailed { error } + })? + } + // TODO is there a better way to check the _specific_ error response + // we get here? We only have a couple of strings; we could check the + // error string contents for something like "WrongVersion", but + // that's pretty fragile. Instead we'll treat any error response + // here as a "fallback to previous behavior". + Err(err @ gateway_client::Error::ErrorResponse(_)) => { + if available_artifacts.len() == 1 { + info!( + self.log, + "Failed to get RoT CMPA page; \ + using only available RoT artifact"; + "err" => %err, + ); + return Ok(&available_artifacts[0]); + } else { + error!( + self.log, + "Failed to get RoT CMPA; unable to choose from \ + multiple available RoT artifacts"; + "err" => %err, + "num_rot_artifacts" => available_artifacts.len(), + ); + return Err(UpdateTerminalError::GetRotCmpaFailed { + error: err.into(), + }); + } + } + // For any other error (e.g., comms failures), just fail as normal. + Err(err) => { + return Err(UpdateTerminalError::GetRotCmpaFailed { + error: err.into(), + }); + } + }; + + // We have a CMPA; we also need the CFPA, but we don't bother checking + // for an `ErrorResponse` as above because succeeding in getting the + // CMPA means the RoT is new enough to support returning both. + let cfpa = self + .mgs_client + .sp_rot_cfpa_get( + self.sp.type_, + self.sp.slot, + SpComponent::ROT.const_as_str(), + &gateway_client::types::GetCfpaParams { + slot: RotCfpaSlot::Active, + }, + ) + .await + .map_err(|err| UpdateTerminalError::GetRotCfpaFailed { + error: err.into(), + }) + .and_then(|response| { + let data = response.into_inner().base64_data; + self.decode_rot_page(&data).map_err(|error| { + UpdateTerminalError::GetRotCfpaFailed { error } + }) + })?; + + // Loop through our possible artifacts and find the first (we only + // expect one!) that verifies against the RoT's CMPA/CFPA. + for artifact in available_artifacts { + let image = artifact + .data + .reader_stream() + .and_then(|stream| async { + let mut buf = Vec::with_capacity(artifact.data.file_size()); + StreamReader::new(stream) + .read_to_end(&mut buf) + .await + .context("I/O error reading extracted archive")?; + Ok(buf) + }) + .await + .map_err(|error| { + UpdateTerminalError::FailedFindingSignedRotImage { error } + })?; + let archive = RawHubrisArchive::from_vec(image).map_err(|err| { + UpdateTerminalError::FailedFindingSignedRotImage { + error: anyhow::Error::new(err).context(format!( + "failed to read hubris archive for {:?}", + artifact.id + )), + } + })?; + match archive.verify(&cmpa, &cfpa) { + Ok(()) => { + info!( + self.log, "RoT archive verification success"; + "name" => artifact.id.name.as_str(), + "version" => %artifact.id.version, + "kind" => ?artifact.id.kind, + ); + return Ok(artifact); + } + Err(err) => { + // We log this but don't fail - we want to continue + // looking for a verifiable artifact. + info!( + self.log, "RoT archive verification failed"; + "artifact" => ?artifact.id, + "err" => %DisplayErrorChain::new(&err), + ); + } + } + } + + // If the loop above didn't find a verifiable image, we cannot proceed. + Err(UpdateTerminalError::FailedFindingSignedRotImage { + error: anyhow!("no RoT image found with valid CMPA/CFPA"), + }) + } + + /// Decode a base64-encoded RoT page we received from MGS. + fn decode_rot_page( + &self, + data: &str, + ) -> anyhow::Result<[u8; ROT_PAGE_SIZE]> { + // Even though we know `data` should decode to exactly + // `ROT_PAGE_SIZE` bytes, the base64 crate requires an output buffer + // of at least `decoded_len_estimate`. Allocate such a buffer here, + // then we'll copy to the fixed-size array we need after confirming + // the number of decoded bytes; + let mut output_buf = vec![0; base64::decoded_len_estimate(data.len())]; + + let n = base64::engine::general_purpose::STANDARD + .decode_slice(&data, &mut output_buf) + .with_context(|| { + format!("failed to decode base64 string: {data:?}") + })?; + if n != ROT_PAGE_SIZE { + bail!( + "incorrect len ({n}, expected {ROT_PAGE_SIZE}) \ + after decoding base64 string: {data:?}", + ); + } + let mut page = [0; ROT_PAGE_SIZE]; + page.copy_from_slice(&output_buf[..n]); + Ok(page) + } + /// Poll the RoT asking for its boot information. This is used to check /// state after RoT bootloader updates async fn wait_for_rot_boot_info(