diff --git a/Cargo.lock b/Cargo.lock index f6883ed551..ec530af7b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9628,7 +9628,6 @@ dependencies = [ "anyhow", "async-trait", "bootstrap-agent-client", - "buf-list", "bytes", "camino", "camino-tempfile", @@ -9676,6 +9675,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "tokio-util", "toml 0.7.6", "tough", "trust-dns-resolver", diff --git a/common/src/update.rs b/common/src/update.rs index 38cda88b6e..81256eb526 100644 --- a/common/src/update.rs +++ b/common/src/update.rs @@ -61,6 +61,11 @@ impl Artifact { kind: self.kind.clone(), } } + + /// Returns the artifact ID for this artifact without clones. + pub fn into_id(self) -> ArtifactId { + ArtifactId { name: self.name, version: self.version, kind: self.kind } + } } /// An identifier for an artifact. @@ -165,6 +170,40 @@ impl ArtifactKind { /// These artifact kinds are not stored anywhere, but are derived from stored /// kinds and used as internal identifiers. impl ArtifactKind { + /// Gimlet root of trust A slot image identifier. + /// + /// Derived from [`KnownArtifactKind::GimletRot`]. + pub const GIMLET_ROT_IMAGE_A: Self = + Self::from_static("gimlet_rot_image_a"); + + /// Gimlet root of trust B slot image identifier. + /// + /// Derived from [`KnownArtifactKind::GimletRot`]. + pub const GIMLET_ROT_IMAGE_B: Self = + Self::from_static("gimlet_rot_image_b"); + + /// PSC root of trust A slot image identifier. + /// + /// Derived from [`KnownArtifactKind::PscRot`]. + pub const PSC_ROT_IMAGE_A: Self = Self::from_static("psc_rot_image_a"); + + /// PSC root of trust B slot image identifier. + /// + /// Derived from [`KnownArtifactKind::PscRot`]. + pub const PSC_ROT_IMAGE_B: Self = Self::from_static("psc_rot_image_b"); + + /// Switch root of trust A slot image identifier. + /// + /// Derived from [`KnownArtifactKind::SwitchRot`]. + pub const SWITCH_ROT_IMAGE_A: Self = + Self::from_static("switch_rot_image_a"); + + /// Switch root of trust B slot image identifier. + /// + /// Derived from [`KnownArtifactKind::SwitchRot`]. + pub const SWITCH_ROT_IMAGE_B: Self = + Self::from_static("switch_rot_image_b"); + /// Host phase 1 identifier. /// /// Derived from [`KnownArtifactKind::Host`]. diff --git a/gateway/src/lib.rs b/gateway/src/lib.rs index 5bf4a193eb..871b05719a 100644 --- a/gateway/src/lib.rs +++ b/gateway/src/lib.rs @@ -100,10 +100,12 @@ fn start_dropshot_server( let http_server_starter = dropshot::HttpServerStarter::new( &dropshot, http_entrypoints::api(), - Arc::clone(&apictx), + Arc::clone(apictx), &log.new(o!("component" => "dropshot")), ) - .map_err(|error| format!("initializing http server: {}", error))?; + .map_err(|error| { + format!("initializing http server listening at {addr}: {}", error) + })?; match http_servers.entry(addr) { Entry::Vacant(slot) => { diff --git a/installinator-artifactd/src/http_entrypoints.rs b/installinator-artifactd/src/http_entrypoints.rs index b99cf96fd6..8360fc9e35 100644 --- a/installinator-artifactd/src/http_entrypoints.rs +++ b/installinator-artifactd/src/http_entrypoints.rs @@ -11,7 +11,7 @@ use dropshot::{ }; use hyper::{header, Body, StatusCode}; use installinator_common::EventReport; -use omicron_common::update::{ArtifactHashId, ArtifactId}; +use omicron_common::update::ArtifactHashId; use schemars::JsonSchema; use serde::Deserialize; use uuid::Uuid; @@ -25,7 +25,6 @@ pub fn api() -> ArtifactServerApiDesc { fn register_endpoints( api: &mut ArtifactServerApiDesc, ) -> Result<(), String> { - api.register(get_artifact_by_id)?; api.register(get_artifact_by_hash)?; api.register(report_progress)?; Ok(()) @@ -38,27 +37,6 @@ pub fn api() -> ArtifactServerApiDesc { api } -/// Fetch an artifact from this server. -#[endpoint { - method = GET, - path = "/artifacts/by-id/{kind}/{name}/{version}" -}] -async fn get_artifact_by_id( - rqctx: RequestContext, - // NOTE: this is an `ArtifactId` and not an `UpdateArtifactId`, because this - // code might be dealing with an unknown artifact kind. This can happen - // if a new artifact kind is introduced across version changes. - path: Path, -) -> Result>, HttpError> { - match rqctx.context().artifact_store.get_artifact(&path.into_inner()).await - { - Some((size, body)) => Ok(body_to_artifact_response(size, body)), - None => { - Err(HttpError::for_not_found(None, "Artifact not found".into())) - } - } -} - /// Fetch an artifact by hash. #[endpoint { method = GET, diff --git a/installinator-artifactd/src/store.rs b/installinator-artifactd/src/store.rs index 3eff7f6375..12e2880893 100644 --- a/installinator-artifactd/src/store.rs +++ b/installinator-artifactd/src/store.rs @@ -10,16 +10,13 @@ use async_trait::async_trait; use dropshot::HttpError; use hyper::Body; use installinator_common::EventReport; -use omicron_common::update::{ArtifactHashId, ArtifactId}; +use omicron_common::update::ArtifactHashId; use slog::Logger; use uuid::Uuid; /// Represents a way to fetch artifacts. #[async_trait] pub trait ArtifactGetter: fmt::Debug + Send + Sync + 'static { - /// Gets an artifact, returning it as a [`Body`] along with its length. - async fn get(&self, id: &ArtifactId) -> Option<(u64, Body)>; - /// Gets an artifact by hash, returning it as a [`Body`]. async fn get_by_hash(&self, id: &ArtifactHashId) -> Option<(u64, Body)>; @@ -63,14 +60,6 @@ impl ArtifactStore { Self { log, getter: Box::new(getter) } } - pub(crate) async fn get_artifact( - &self, - id: &ArtifactId, - ) -> Option<(u64, Body)> { - slog::debug!(self.log, "Artifact requested: {:?}", id); - self.getter.get(id).await - } - pub(crate) async fn get_artifact_by_hash( &self, id: &ArtifactHashId, diff --git a/installinator/src/write.rs b/installinator/src/write.rs index 551883ecce..6c0c1f63c7 100644 --- a/installinator/src/write.rs +++ b/installinator/src/write.rs @@ -953,7 +953,9 @@ mod tests { Event, InstallinatorCompletionMetadata, InstallinatorComponent, InstallinatorStepId, StepEventKind, StepOutcome, }; - use omicron_common::api::internal::nexus::KnownArtifactKind; + use omicron_common::{ + api::internal::nexus::KnownArtifactKind, update::ArtifactKind, + }; use omicron_test_utils::dev::test_setup_log; use partial_io::{ proptest_types::{ @@ -1072,7 +1074,7 @@ mod tests { data2.into_iter().map(Bytes::from).collect(); let host_id = ArtifactHashId { - kind: KnownArtifactKind::Host.into(), + kind: ArtifactKind::HOST_PHASE_2, hash: { // The `validate_written_host_phase_2_hash()` will fail unless // we give the actual hash of the host phase 2 data, so compute diff --git a/openapi/installinator-artifactd.json b/openapi/installinator-artifactd.json index c8b63c7616..3132af6ff6 100644 --- a/openapi/installinator-artifactd.json +++ b/openapi/installinator-artifactd.json @@ -53,57 +53,6 @@ } } }, - "/artifacts/by-id/{kind}/{name}/{version}": { - "get": { - "summary": "Fetch an artifact from this server.", - "operationId": "get_artifact_by_id", - "parameters": [ - { - "in": "path", - "name": "kind", - "description": "The kind of artifact this is.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "name", - "description": "The artifact's name.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "version", - "description": "The artifact's version.", - "required": true, - "schema": { - "$ref": "#/components/schemas/SemverVersion" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "*/*": { - "schema": {} - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, "/report-progress/{update_id}": { "post": { "summary": "Report progress and completion to the server.", @@ -2373,10 +2322,6 @@ "slots_attempted", "slots_written" ] - }, - "SemverVersion": { - "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-]+)*))?$" } } } diff --git a/openapi/wicketd.json b/openapi/wicketd.json index 7826182b83..40d798da00 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -724,6 +724,25 @@ "message" ] }, + "ArtifactHashId": { + "description": "A hash-based identifier for an artifact.\n\nSome places, e.g. the installinator, request artifacts by hash rather than by name and version. This type indicates that.", + "type": "object", + "properties": { + "hash": { + "description": "The hash of the artifact.", + "type": "string", + "format": "hex string (32 bytes)" + }, + "kind": { + "description": "The kind of artifact this is.", + "type": "string" + } + }, + "required": [ + "hash", + "kind" + ] + }, "ArtifactId": { "description": "An identifier for an artifact.\n\nThe kind is [`ArtifactKind`], indicating that it might represent an artifact whose kind is unknown.", "type": "object", @@ -1154,9 +1173,10 @@ "type": "object", "properties": { "artifacts": { + "description": "Map of artifacts we ingested from the most-recently-uploaded TUF repository to a list of artifacts we're serving over the bootstrap network. In some cases the list of artifacts being served will have length 1 (when we're serving the artifact directly); in other cases the artifact in the TUF repo contains multiple nested artifacts inside it (e.g., RoT artifacts contain both A and B images), and we serve the list of extracted artifacts but not the original combination.\n\nConceptually, this is a `BTreeMap>`, but JSON requires string keys for maps, so we give back a vec of pairs instead.", "type": "array", "items": { - "$ref": "#/components/schemas/ArtifactId" + "$ref": "#/components/schemas/InstallableArtifacts" } }, "event_reports": { @@ -1301,6 +1321,24 @@ } } }, + "InstallableArtifacts": { + "type": "object", + "properties": { + "artifact_id": { + "$ref": "#/components/schemas/ArtifactId" + }, + "installable": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ArtifactHashId" + } + } + }, + "required": [ + "artifact_id", + "installable" + ] + }, "IpRange": { "oneOf": [ { diff --git a/tufaceous-lib/src/artifact.rs b/tufaceous-lib/src/artifact.rs index 9158be5a37..56f3e34ecb 100644 --- a/tufaceous-lib/src/artifact.rs +++ b/tufaceous-lib/src/artifact.rs @@ -3,7 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use std::{ - io::{self, BufReader, Read, Write}, + io::{self, BufReader, Write}, path::Path, }; @@ -128,13 +128,28 @@ pub struct HostPhaseImages { impl HostPhaseImages { pub fn extract(reader: R) -> Result { + let mut phase_1 = Vec::new(); + let mut phase_2 = Vec::new(); + Self::extract_into( + reader, + io::Cursor::<&mut Vec>::new(&mut phase_1), + io::Cursor::<&mut Vec>::new(&mut phase_2), + )?; + Ok(Self { phase_1: phase_1.into(), phase_2: phase_2.into() }) + } + + pub fn extract_into( + reader: R, + phase_1: W, + phase_2: W, + ) -> Result<()> { let uncompressed = flate2::bufread::GzDecoder::new(BufReader::new(reader)); let mut archive = tar::Archive::new(uncompressed); let mut oxide_json_found = false; - let mut phase_1 = None; - let mut phase_2 = None; + let mut phase_1_writer = Some(phase_1); + let mut phase_2_writer = Some(phase_2); for entry in archive .entries() .context("error building list of entries from archive")? @@ -160,12 +175,19 @@ impl HostPhaseImages { } oxide_json_found = true; } else if path == Path::new(HOST_PHASE_1_FILE_NAME) { - phase_1 = Some(read_entry(entry, HOST_PHASE_1_FILE_NAME)?); + if let Some(phase_1) = phase_1_writer.take() { + read_entry_into(entry, HOST_PHASE_1_FILE_NAME, phase_1)?; + } } else if path == Path::new(HOST_PHASE_2_FILE_NAME) { - phase_2 = Some(read_entry(entry, HOST_PHASE_2_FILE_NAME)?); + if let Some(phase_2) = phase_2_writer.take() { + read_entry_into(entry, HOST_PHASE_2_FILE_NAME, phase_2)?; + } } - if oxide_json_found && phase_1.is_some() && phase_2.is_some() { + if oxide_json_found + && phase_1_writer.is_none() + && phase_2_writer.is_none() + { break; } } @@ -174,34 +196,45 @@ impl HostPhaseImages { if !oxide_json_found { not_found.push(OXIDE_JSON_FILE_NAME); } - if phase_1.is_none() { + + // If we didn't `.take()` the writer out of the options, we never saw + // the expected phase1/phase2 filenames. + if phase_1_writer.is_some() { not_found.push(HOST_PHASE_1_FILE_NAME); } - if phase_2.is_none() { + if phase_2_writer.is_some() { not_found.push(HOST_PHASE_2_FILE_NAME); } + if !not_found.is_empty() { bail!("required files not found: {}", not_found.join(", ")) } - Ok(Self { phase_1: phase_1.unwrap(), phase_2: phase_2.unwrap() }) + Ok(()) } } fn read_entry( - mut entry: tar::Entry, + entry: tar::Entry, file_name: &str, ) -> Result { + let mut buf = Vec::new(); + read_entry_into(entry, file_name, io::Cursor::new(&mut buf))?; + Ok(buf.into()) +} + +fn read_entry_into( + mut entry: tar::Entry, + file_name: &str, + mut out: W, +) -> Result<()> { let entry_type = entry.header().entry_type(); if entry_type != tar::EntryType::Regular { bail!("for {file_name}, expected regular file, found {entry_type:?}"); } - let size = entry.size(); - let mut buf = Vec::with_capacity(size as usize); - entry - .read_to_end(&mut buf) + io::copy(&mut entry, &mut out) .with_context(|| format!("error reading {file_name} from archive"))?; - Ok(buf.into()) + Ok(()) } /// Represents RoT A/B hubris archives. @@ -216,13 +249,28 @@ pub struct RotArchives { impl RotArchives { pub fn extract(reader: R) -> Result { + let mut archive_a = Vec::new(); + let mut archive_b = Vec::new(); + Self::extract_into( + reader, + io::Cursor::<&mut Vec>::new(&mut archive_a), + io::Cursor::<&mut Vec>::new(&mut archive_b), + )?; + Ok(Self { archive_a: archive_a.into(), archive_b: archive_b.into() }) + } + + pub fn extract_into( + reader: R, + archive_a: W, + archive_b: W, + ) -> Result<()> { let uncompressed = flate2::bufread::GzDecoder::new(BufReader::new(reader)); let mut archive = tar::Archive::new(uncompressed); let mut oxide_json_found = false; - let mut archive_a = None; - let mut archive_b = None; + let mut archive_a_writer = Some(archive_a); + let mut archive_b_writer = Some(archive_b); for entry in archive .entries() .context("error building list of entries from archive")? @@ -248,12 +296,19 @@ impl RotArchives { } oxide_json_found = true; } else if path == Path::new(ROT_ARCHIVE_A_FILE_NAME) { - archive_a = Some(read_entry(entry, ROT_ARCHIVE_A_FILE_NAME)?); + if let Some(archive_a) = archive_a_writer.take() { + read_entry_into(entry, ROT_ARCHIVE_A_FILE_NAME, archive_a)?; + } } else if path == Path::new(ROT_ARCHIVE_B_FILE_NAME) { - archive_b = Some(read_entry(entry, ROT_ARCHIVE_B_FILE_NAME)?); + if let Some(archive_b) = archive_b_writer.take() { + read_entry_into(entry, ROT_ARCHIVE_B_FILE_NAME, archive_b)?; + } } - if oxide_json_found && archive_a.is_some() && archive_b.is_some() { + if oxide_json_found + && archive_a_writer.is_none() + && archive_b_writer.is_none() + { break; } } @@ -262,20 +317,21 @@ impl RotArchives { if !oxide_json_found { not_found.push(OXIDE_JSON_FILE_NAME); } - if archive_a.is_none() { + + // If we didn't `.take()` the writer out of the options, we never saw + // the expected A/B filenames. + if archive_a_writer.is_some() { not_found.push(ROT_ARCHIVE_A_FILE_NAME); } - if archive_b.is_none() { + if archive_b_writer.is_some() { not_found.push(ROT_ARCHIVE_B_FILE_NAME); } + if !not_found.is_empty() { bail!("required files not found: {}", not_found.join(", ")) } - Ok(Self { - archive_a: archive_a.unwrap(), - archive_b: archive_b.unwrap(), - }) + Ok(()) } } diff --git a/tufaceous-lib/src/assemble/manifest.rs b/tufaceous-lib/src/assemble/manifest.rs index f417a1cf04..409c85808c 100644 --- a/tufaceous-lib/src/assemble/manifest.rs +++ b/tufaceous-lib/src/assemble/manifest.rs @@ -83,11 +83,12 @@ impl ArtifactManifest { ArtifactSource::File(base_dir.join(path)) } DeserializedArtifactSource::Fake { size } => { - let fake_data = make_fake_data( - &kind, + let fake_data = FakeDataAttributes::new( + &data.name, + kind, &data.version, - size.0 as usize, - ); + ) + .make_data(size.0 as usize); ArtifactSource::Memory(fake_data.into()) } DeserializedArtifactSource::CompositeHost { @@ -104,15 +105,30 @@ impl ArtifactManifest { artifact kind {kind:?}" ); - let data = Vec::new(); let mut builder = - CompositeHostArchiveBuilder::new(data)?; - phase_1.with_data(|data| { - builder.append_phase_1(data.len(), data.as_slice()) - })?; - phase_2.with_data(|data| { - builder.append_phase_2(data.len(), data.as_slice()) - })?; + CompositeHostArchiveBuilder::new(Vec::new())?; + phase_1.with_data( + FakeDataAttributes::new( + "fake-phase-1", + kind, + &data.version, + ), + |buf| { + builder + .append_phase_1(buf.len(), buf.as_slice()) + }, + )?; + phase_2.with_data( + FakeDataAttributes::new( + "fake-phase-2", + kind, + &data.version, + ), + |buf| { + builder + .append_phase_2(buf.len(), buf.as_slice()) + }, + )?; ArtifactSource::Memory(builder.finish()?.into()) } DeserializedArtifactSource::CompositeRot { @@ -130,17 +146,30 @@ impl ArtifactManifest { artifact kind {kind:?}" ); - let data = Vec::new(); let mut builder = - CompositeRotArchiveBuilder::new(data)?; - archive_a.with_data(|data| { - builder - .append_archive_a(data.len(), data.as_slice()) - })?; - archive_b.with_data(|data| { - builder - .append_archive_b(data.len(), data.as_slice()) - })?; + CompositeRotArchiveBuilder::new(Vec::new())?; + archive_a.with_data( + FakeDataAttributes::new( + "fake-rot-archive-a", + kind, + &data.version, + ), + |buf| { + builder + .append_archive_a(buf.len(), buf.as_slice()) + }, + )?; + archive_b.with_data( + FakeDataAttributes::new( + "fake-rot-archive-b", + kind, + &data.version, + ), + |buf| { + builder + .append_archive_b(buf.len(), buf.as_slice()) + }, + )?; ArtifactSource::Memory(builder.finish()?.into()) } DeserializedArtifactSource::CompositeControlPlane { @@ -207,38 +236,51 @@ impl ArtifactManifest { } } -fn make_fake_data( - kind: &KnownArtifactKind, - version: &SemverVersion, - size: usize, -) -> Vec { - use hubtools::{CabooseBuilder, HubrisArchiveBuilder}; +#[derive(Debug)] +struct FakeDataAttributes<'a> { + name: &'a str, + kind: KnownArtifactKind, + version: &'a SemverVersion, +} - let board = match kind { - // non-Hubris artifacts: just make fake data - KnownArtifactKind::Host - | KnownArtifactKind::Trampoline - | KnownArtifactKind::ControlPlane => return make_filler_text(size), +impl<'a> FakeDataAttributes<'a> { + fn new( + name: &'a str, + kind: KnownArtifactKind, + version: &'a SemverVersion, + ) -> Self { + Self { name, kind, version } + } - // hubris artifacts: build a fake archive - KnownArtifactKind::GimletSp => "fake-gimlet-sp", - KnownArtifactKind::GimletRot => "fake-gimlet-rot", - KnownArtifactKind::PscSp => "fake-psc-sp", - KnownArtifactKind::PscRot => "fake-psc-rot", - KnownArtifactKind::SwitchSp => "fake-sidecar-sp", - KnownArtifactKind::SwitchRot => "fake-sidecar-rot", - }; + fn make_data(&self, size: usize) -> Vec { + use hubtools::{CabooseBuilder, HubrisArchiveBuilder}; - let caboose = CabooseBuilder::default() - .git_commit("this-is-fake-data") - .board(board) - .version(version.to_string()) - .name(board) - .build(); + let board = match self.kind { + // non-Hubris artifacts: just make fake data + KnownArtifactKind::Host + | KnownArtifactKind::Trampoline + | KnownArtifactKind::ControlPlane => return make_filler_text(size), - let mut builder = HubrisArchiveBuilder::with_fake_image(); - builder.write_caboose(caboose.as_slice()).unwrap(); - builder.build_to_vec().unwrap() + // hubris artifacts: build a fake archive + KnownArtifactKind::GimletSp => "fake-gimlet-sp", + KnownArtifactKind::GimletRot => "fake-gimlet-rot", + KnownArtifactKind::PscSp => "fake-psc-sp", + KnownArtifactKind::PscRot => "fake-psc-rot", + KnownArtifactKind::SwitchSp => "fake-sidecar-sp", + KnownArtifactKind::SwitchRot => "fake-sidecar-rot", + }; + + let caboose = CabooseBuilder::default() + .git_commit("this-is-fake-data") + .board(board) + .version(self.version.to_string()) + .name(self.name) + .build(); + + let mut builder = HubrisArchiveBuilder::with_fake_image(); + builder.write_caboose(caboose.as_slice()).unwrap(); + builder.build_to_vec().unwrap() + } } /// Information about an individual artifact. @@ -301,7 +343,7 @@ enum DeserializedFileArtifactSource { } impl DeserializedFileArtifactSource { - fn with_data(&self, f: F) -> Result + fn with_data(&self, fake_attr: FakeDataAttributes, f: F) -> Result where F: FnOnce(Vec) -> Result, { @@ -311,7 +353,7 @@ impl DeserializedFileArtifactSource { .with_context(|| format!("failed to read {path}"))? } DeserializedFileArtifactSource::Fake { size } => { - make_filler_text(size.0 as usize) + fake_attr.make_data(size.0 as usize) } }; f(data) diff --git a/wicket/src/state/inventory.rs b/wicket/src/state/inventory.rs index 159879df80..7de9c795da 100644 --- a/wicket/src/state/inventory.rs +++ b/wicket/src/state/inventory.rs @@ -5,7 +5,6 @@ //! Information about all top-level Oxide components (sleds, switches, PSCs) use anyhow::anyhow; -use omicron_common::api::internal::nexus::KnownArtifactKind; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -19,8 +18,8 @@ use wicketd_client::types::{ pub static ALL_COMPONENT_IDS: Lazy> = Lazy::new(|| { (0..=31u8) - .map(|i| ComponentId::Sled(i)) - .chain((0..=1u8).map(|i| ComponentId::Switch(i))) + .map(ComponentId::Sled) + .chain((0..=1u8).map(ComponentId::Switch)) // Currently shipping racks don't have PSC 1. .chain(std::iter::once(ComponentId::Psc(0))) .collect() @@ -209,22 +208,6 @@ impl ComponentId { pub fn name(&self) -> String { self.to_string() } - - pub fn sp_known_artifact_kind(&self) -> KnownArtifactKind { - match self { - ComponentId::Sled(_) => KnownArtifactKind::GimletSp, - ComponentId::Switch(_) => KnownArtifactKind::SwitchSp, - ComponentId::Psc(_) => KnownArtifactKind::PscSp, - } - } - - pub fn rot_known_artifact_kind(&self) -> KnownArtifactKind { - match self { - ComponentId::Sled(_) => KnownArtifactKind::GimletRot, - ComponentId::Switch(_) => KnownArtifactKind::SwitchRot, - ComponentId::Psc(_) => KnownArtifactKind::PscRot, - } - } } impl Display for ComponentId { diff --git a/wicket/src/wicketd.rs b/wicket/src/wicketd.rs index 0554e072cb..160bcb1c6a 100644 --- a/wicket/src/wicketd.rs +++ b/wicket/src/wicketd.rs @@ -446,7 +446,11 @@ impl WicketdManager { Ok(val) => { // TODO: Only send on changes let rsp = val.into_inner(); - let artifacts = rsp.artifacts; + let artifacts = rsp + .artifacts + .into_iter() + .map(|artifact| artifact.artifact_id) + .collect(); let system_version = rsp.system_version; let event_reports: EventReportMap = rsp.event_reports; let _ = tx.send(Event::ArtifactsAndEventReports { diff --git a/wicketd/Cargo.toml b/wicketd/Cargo.toml index 1abdb472ea..1ba2e3256c 100644 --- a/wicketd/Cargo.toml +++ b/wicketd/Cargo.toml @@ -7,7 +7,6 @@ license = "MPL-2.0" [dependencies] anyhow.workspace = true async-trait.workspace = true -buf-list.workspace = true bytes.workspace = true camino.workspace = true camino-tempfile.workspace = true @@ -34,6 +33,7 @@ slog-dtrace.workspace = true thiserror.workspace = true tokio = { workspace = true, features = [ "full" ] } tokio-stream.workspace = true +tokio-util.workspace = true tough.workspace = true trust-dns-resolver.workspace = true snafu.workspace = true diff --git a/wicketd/src/artifacts.rs b/wicketd/src/artifacts.rs index dfe4e14eaf..7b55d73dcb 100644 --- a/wicketd/src/artifacts.rs +++ b/wicketd/src/artifacts.rs @@ -2,509 +2,30 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use std::{ - borrow::Borrow, - collections::{btree_map, hash_map::Entry, BTreeMap, HashMap}, - convert::Infallible, - io::{self, Read}, - sync::{Arc, Mutex}, -}; - -use async_trait::async_trait; -use buf_list::BufList; -use bytes::{BufMut, Bytes, BytesMut}; -use debug_ignore::DebugIgnore; -use display_error_chain::DisplayErrorChain; -use dropshot::HttpError; -use futures::stream; -use hubtools::RawHubrisArchive; -use hyper::Body; -use installinator_artifactd::{ArtifactGetter, EventReportStatus}; -use omicron_common::api::{ - external::SemverVersion, internal::nexus::KnownArtifactKind, -}; -use omicron_common::update::{ - ArtifactHash, ArtifactHashId, ArtifactId, ArtifactKind, -}; -use sha2::{Digest, Sha256}; -use slog::{info, Logger}; -use thiserror::Error; -use tough::TargetName; -use tufaceous_lib::{ - ArchiveExtractor, HostPhaseImages, OmicronRepo, RotArchives, -}; -use uuid::Uuid; - -use crate::installinator_progress::IprArtifactServer; - -// A collection of artifacts along with an update plan using those artifacts. -#[derive(Debug, Default)] -struct ArtifactsWithPlan { - // TODO: replace with BufList once it supports Read via a cursor (required - // for host tarball extraction) - by_id: DebugIgnore>, - by_hash: DebugIgnore>, - plan: Option, -} - -/// The artifact server interface for wicketd. -#[derive(Debug)] -pub(crate) struct WicketdArtifactServer { - #[allow(dead_code)] - log: Logger, - store: WicketdArtifactStore, - ipr_artifact: IprArtifactServer, -} - -impl WicketdArtifactServer { - pub(crate) fn new( - log: &Logger, - store: WicketdArtifactStore, - ipr_artifact: IprArtifactServer, - ) -> Self { - let log = log.new(slog::o!("component" => "wicketd artifact server")); - Self { log, store, ipr_artifact } - } -} - -#[async_trait] -impl ArtifactGetter for WicketdArtifactServer { - async fn get(&self, id: &ArtifactId) -> Option<(u64, Body)> { - // This is a test artifact name used by the installinator. - if id.name == "__installinator-test" { - // For testing, the major version is the size of the artifact. - let size: u64 = id.version.0.major; - let mut bytes = BytesMut::with_capacity(size as usize); - bytes.put_bytes(0, size as usize); - return Some((size, Body::from(bytes.freeze()))); - } - - let buf_list = self.store.get(id)?; - let size = buf_list.num_bytes() as u64; - // Return the list as a stream of bytes. - Some(( - size, - Body::wrap_stream(stream::iter( - buf_list.into_iter().map(|bytes| Ok::<_, Infallible>(bytes)), - )), - )) - } - - async fn get_by_hash(&self, id: &ArtifactHashId) -> Option<(u64, Body)> { - let buf_list = self.store.get_by_hash(id)?; - let size = buf_list.num_bytes() as u64; - Some(( - size, - Body::wrap_stream(stream::iter( - buf_list.into_iter().map(|bytes| Ok::<_, Infallible>(bytes)), - )), - )) - } - - async fn report_progress( - &self, - update_id: Uuid, - report: installinator_common::EventReport, - ) -> Result { - Ok(self.ipr_artifact.report_progress(update_id, report)) - } -} - -/// The artifact store for wicketd. +use omicron_common::update::ArtifactId; +use std::borrow::Borrow; + +mod artifacts_with_plan; +mod error; +mod extracted_artifacts; +mod server; +mod store; +mod update_plan; + +pub(crate) use self::extracted_artifacts::ExtractedArtifactDataHandle; +pub(crate) use self::server::WicketdArtifactServer; +pub(crate) use self::store::WicketdArtifactStore; +pub use self::update_plan::UpdatePlan; + +/// A pair containing both the ID of an artifact and a handle to its data. /// -/// This can be cheaply cloned, and is intended to be shared across the parts of artifactd that -/// upload artifacts and the parts that fetch them. -#[derive(Clone, Debug)] -pub struct WicketdArtifactStore { - log: Logger, - // NOTE: this is a `std::sync::Mutex` rather than a `tokio::sync::Mutex` because the critical - // sections are extremely small. - artifacts_with_plan: Arc>, -} - -impl WicketdArtifactStore { - pub(crate) fn new(log: &Logger) -> Self { - let log = log.new(slog::o!("component" => "wicketd artifact store")); - Self { log, artifacts_with_plan: Default::default() } - } - - pub(crate) fn put_repository( - &self, - bytes: BufList, - ) -> Result<(), HttpError> { - slog::debug!(self.log, "adding repository"; "size" => bytes.num_bytes()); - - let new_artifacts = ArtifactsWithPlan::from_zip(bytes, &self.log) - .map_err(|error| error.to_http_error())?; - self.replace(new_artifacts); - Ok(()) - } - - pub(crate) fn system_version_and_artifact_ids( - &self, - ) -> (Option, Vec) { - let artifacts = self.artifacts_with_plan.lock().unwrap(); - let system_version = - artifacts.plan.as_ref().map(|p| p.system_version.clone()); - let artifact_ids = artifacts.by_id.keys().cloned().collect(); - (system_version, artifact_ids) - } - - /// Obtain the current plan. - /// - /// Exposed for testing. - pub fn current_plan(&self) -> Option { - // We expect this hashmap to be relatively small (order ~10), and - // cloning both ArtifactIds and BufLists are cheap. - self.artifacts_with_plan.lock().unwrap().plan.clone() - } - - // --- - // Helper methods - // --- - - fn get(&self, id: &ArtifactId) -> Option { - // NOTE: cloning a `BufList` is cheap since it's just a bunch of reference count bumps. - // Cloning it here also means we can release the lock quickly. - self.artifacts_with_plan.lock().unwrap().get(id).map(BufList::from) - } - - pub fn get_by_hash(&self, id: &ArtifactHashId) -> Option { - // NOTE: cloning a `BufList` is cheap since it's just a bunch of reference count bumps. - // Cloning it here also means we can release the lock quickly. - self.artifacts_with_plan - .lock() - .unwrap() - .get_by_hash(id) - .map(BufList::from) - } - - /// Replaces the artifact hash map, returning the previous map. - fn replace(&self, new_artifacts: ArtifactsWithPlan) -> ArtifactsWithPlan { - let mut artifacts = self.artifacts_with_plan.lock().unwrap(); - std::mem::replace(&mut *artifacts, new_artifacts) - } -} - -impl ArtifactsWithPlan { - fn from_zip( - zip_bytes: BufList, - log: &Logger, - ) -> Result { - let mut extractor = ArchiveExtractor::from_owned_buf_list(zip_bytes) - .map_err(RepositoryError::OpenArchive)?; - - // Create a temporary directory to hold artifacts in (we'll read them - // into memory as we extract them). - let dir = camino_tempfile::tempdir() - .map_err(RepositoryError::TempDirCreate)?; - - info!(log, "extracting uploaded archive to {}", dir.path()); - - // XXX: might be worth differentiating between server-side issues (503) - // and issues with the uploaded archive (400). - extractor.extract(dir.path()).map_err(RepositoryError::Extract)?; - - // Time is unavailable during initial setup, so ignore expiration. Even - // if time were available, we might want to be able to load older - // versions of artifacts over the technician port in an emergency. - // - // XXX we aren't checking against a root of trust at this point -- - // anyone can sign the repositories and this code will accept that. - let repository = - OmicronRepo::load_untrusted_ignore_expiration(log, dir.path()) - .map_err(RepositoryError::LoadRepository)?; - - let artifacts = repository - .read_artifacts() - .map_err(RepositoryError::ReadArtifactsDocument)?; - - // Read the artifact into memory. - // - // XXX Could also read the artifact from disk directly, keeping the - // files around. That introduces some new failure domains (e.g. what if - // the directory gets removed from underneath us?) but is worth - // revisiting. - // - // Notes: - // - // 1. With files on disk it is possible to keep the file descriptor - // open, preventing deletes from affecting us. However, tough doesn't - // quite provide an interface to do that. - // 2. Ideally we'd write the zip to disk, implement a zip transport, and - // just keep the zip's file descriptor open. Unfortunately, a zip - // transport can't currently be written in safe Rust due to - // https://github.com/awslabs/tough/pull/563. If that lands and/or we - // write our own TUF implementation, we should switch to that approach. - let mut by_id = HashMap::new(); - let mut by_hash = HashMap::new(); - for artifact in artifacts.artifacts { - // The artifact kind might be unknown here: possibly attempting to do an - // update where the current version of wicketd isn't aware of a new - // artifact kind. - let artifact_id = artifact.id(); - - let target_name = TargetName::try_from(artifact.target.as_str()) - .map_err(|error| RepositoryError::LocateTarget { - target: artifact.target.clone(), - error: Box::new(error), - })?; - - let target_hash = repository - .repo() - .targets() - .signed - .find_target(&target_name) - .map_err(|error| RepositoryError::TargetHashRead { - target: artifact.target.clone(), - error, - })? - .hashes - .sha256 - .clone() - .into_vec(); - - // The target hash is SHA-256, which is 32 bytes long. - let artifact_hash = ArtifactHash( - target_hash - .try_into() - .map_err(RepositoryError::TargetHashLength)?, - ); - let artifact_hash_id = ArtifactHashId { - kind: artifact_id.kind.clone(), - hash: artifact_hash, - }; - - let mut reader = repository - .repo() - .read_target(&target_name) - .map_err(|error| RepositoryError::LocateTarget { - target: artifact.target.clone(), - error: Box::new(error), - })? - .ok_or_else(|| { - RepositoryError::MissingTarget(artifact.target.clone()) - })?; - let mut buf = Vec::new(); - reader.read_to_end(&mut buf).map_err(|error| { - RepositoryError::ReadTarget { - target: artifact.target.clone(), - error, - } - })?; - - let bytes = Bytes::from(buf); - let num_bytes = bytes.len(); - - match by_id.entry(artifact_id.clone()) { - Entry::Occupied(_) => { - // We got two entries for an artifact? - return Err(RepositoryError::DuplicateEntry(artifact_id)); - } - Entry::Vacant(entry) => { - entry.insert((artifact_hash, bytes.clone())); - } - } - - match by_hash.entry(artifact_hash_id.clone()) { - Entry::Occupied(_) => { - // We got two entries for an artifact? - return Err(RepositoryError::DuplicateHashEntry( - artifact_hash_id, - )); - } - Entry::Vacant(entry) => { - entry.insert(bytes); - } - } - - info!( - log, "added artifact"; - "kind" => %artifact_id.kind, - "id" => format!("{}:{}", artifact_id.name, artifact_id.version), - "hash" => %artifact_hash, - "length" => num_bytes, - ); - } - - // Ensure we know how to apply updates from this set of artifacts; we'll - // remember the plan we create. - let plan = UpdatePlan::new( - artifacts.system_version, - &by_id, - &mut by_hash, - log, - read_hubris_board_from_archive, - )?; - - Ok(Self { - by_id: by_id.into(), - by_hash: by_hash.into(), - plan: Some(plan), - }) - } - - fn get(&self, id: &ArtifactId) -> Option { - self.by_id - .get(id) - .cloned() - .map(|(_hash, bytes)| BufList::from_iter([bytes])) - } - - fn get_by_hash(&self, id: &ArtifactHashId) -> Option { - self.by_hash.get(id).cloned().map(|bytes| BufList::from_iter([bytes])) - } -} - -#[derive(Debug, Error)] -enum RepositoryError { - #[error("error opening archive")] - OpenArchive(#[source] anyhow::Error), - - #[error("error creating temporary directory")] - TempDirCreate(#[source] std::io::Error), - - #[error("error extracting repository")] - Extract(#[source] anyhow::Error), - - #[error("error loading repository")] - LoadRepository(#[source] anyhow::Error), - - #[error("error reading artifacts.json")] - ReadArtifactsDocument(#[source] anyhow::Error), - - #[error("error reading target hash for `{target}` in repository")] - TargetHashRead { - target: String, - #[source] - error: tough::schema::Error, - }, - - #[error("target hash `{}` expected to be 32 bytes long, was {}", hex::encode(.0), .0.len())] - TargetHashLength(Vec), - - #[error("error locating target `{target}` in repository")] - LocateTarget { - target: String, - #[source] - error: Box, - }, - - #[error( - "artifacts.json defines target `{0}` which is missing from the repo" - )] - MissingTarget(String), - - #[error("error reading target `{target}` from repository")] - ReadTarget { - target: String, - #[source] - error: std::io::Error, - }, - - #[error("error extracting tarball for {kind} from repository")] - TarballExtract { - kind: KnownArtifactKind, - #[source] - error: anyhow::Error, - }, - - #[error( - "duplicate entries found in artifacts.json for kind `{}`, `{}:{}`", .0.kind, .0.name, .0.version - )] - DuplicateEntry(ArtifactId), - - #[error("multiple artifacts found for kind `{0:?}`")] - DuplicateArtifactKind(KnownArtifactKind), - - #[error("duplicate board found for kind `{kind:?}`: `{board}`")] - DuplicateBoardEntry { board: String, kind: KnownArtifactKind }, - - #[error("error parsing artifact {id:?} as hubris archive")] - ParsingHubrisArchive { - id: ArtifactId, - #[source] - error: Box, - }, - - #[error("error reading hubris caboose from {id:?}")] - ReadHubrisCaboose { - id: ArtifactId, - #[source] - error: Box, - }, - - #[error("error reading board from hubris caboose of {id:?}")] - ReadHubrisCabooseBoard { - id: ArtifactId, - #[source] - error: hubtools::CabooseError, - }, - - #[error( - "error reading board from hubris caboose of {0:?}: non-utf8 value" - )] - ReadHubrisCabooseBoardUtf8(ArtifactId), - - #[error("missing artifact of kind `{0:?}`")] - MissingArtifactKind(KnownArtifactKind), - - #[error( - "duplicate hash entries found in artifacts.json for kind `{}`, hash `{}`", .0.kind, .0.hash - )] - DuplicateHashEntry(ArtifactHashId), -} - -impl RepositoryError { - fn to_http_error(&self) -> HttpError { - let message = DisplayErrorChain::new(self).to_string(); - - match self { - // Errors we had that are unrelated to the contents of a repository - // uploaded by a client. - RepositoryError::TempDirCreate(_) => { - HttpError::for_unavail(None, message) - } - - // Errors that are definitely caused by bad repository contents. - RepositoryError::DuplicateEntry(_) - | RepositoryError::DuplicateArtifactKind(_) - | RepositoryError::LocateTarget { .. } - | RepositoryError::TargetHashLength(_) - | RepositoryError::MissingArtifactKind(_) - | RepositoryError::MissingTarget(_) - | RepositoryError::DuplicateHashEntry(_) - | RepositoryError::DuplicateBoardEntry { .. } - | RepositoryError::ParsingHubrisArchive { .. } - | RepositoryError::ReadHubrisCaboose { .. } - | RepositoryError::ReadHubrisCabooseBoard { .. } - | RepositoryError::ReadHubrisCabooseBoardUtf8(_) => { - HttpError::for_bad_request(None, message) - } - - // Gray area - these are _probably_ caused by bad repository - // contents, but there might be some cases (or cases-with-cases) - // where good contents still produce one of these errors. We'll opt - // for sending a 4xx bad request in hopes that it was our client's - // fault. - RepositoryError::OpenArchive(_) - | RepositoryError::Extract(_) - | RepositoryError::TarballExtract { .. } - | RepositoryError::LoadRepository(_) - | RepositoryError::ReadArtifactsDocument(_) - | RepositoryError::TargetHashRead { .. } - | RepositoryError::ReadTarget { .. } => { - HttpError::for_bad_request(None, message) - } - } - } -} - +/// Note that cloning an `ArtifactIdData` will clone the handle, which has +/// implications on temporary directory cleanup. See +/// [`ExtractedArtifactDataHandle`] for details. #[derive(Debug, Clone)] pub(crate) struct ArtifactIdData { pub(crate) id: ArtifactId, - pub(crate) data: DebugIgnore, - pub(crate) hash: ArtifactHash, + pub(crate) data: ExtractedArtifactDataHandle, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -515,702 +36,3 @@ impl Borrow for Board { &self.0 } } - -/// The update plan currently in effect. -/// -/// Exposed for testing. -#[derive(Debug, Clone)] -pub struct UpdatePlan { - pub(crate) system_version: SemverVersion, - pub(crate) gimlet_sp: BTreeMap, - pub(crate) gimlet_rot_a: ArtifactIdData, - pub(crate) gimlet_rot_b: ArtifactIdData, - pub(crate) psc_sp: BTreeMap, - pub(crate) psc_rot_a: ArtifactIdData, - pub(crate) psc_rot_b: ArtifactIdData, - pub(crate) sidecar_sp: BTreeMap, - pub(crate) sidecar_rot_a: ArtifactIdData, - pub(crate) sidecar_rot_b: ArtifactIdData, - - // Note: The Trampoline image is broken into phase1/phase2 as part of our - // update plan (because they go to different destinations), but the two - // phases will have the _same_ `ArtifactId` (i.e., the ID of the Host - // artifact from the TUF repository. - // - // The same would apply to the host phase1/phase2, but we don't actually - // need the `host_phase_2` data as part of this plan (we serve it from the - // artifact server instead). - pub(crate) host_phase_1: ArtifactIdData, - pub(crate) trampoline_phase_1: ArtifactIdData, - pub(crate) trampoline_phase_2: ArtifactIdData, - - // We need to send installinator the hash of the host_phase_2 data it should - // fetch from us; we compute it while generating the plan. - // - // This is exposed for testing. - pub host_phase_2_hash: ArtifactHash, - - // We also need to send installinator the hash of the control_plane image it - // should fetch from us. This is already present in the TUF repository, but - // we record it here for use by the update process. - // - // This is exposed for testing. - pub control_plane_hash: ArtifactHash, -} - -impl UpdatePlan { - // `read_hubris_board` should always be `read_hubris_board_from_archive`; we - // take it as an argument to allow unit tests to give us invalid/fake - // "hubris archives" `read_hubris_board_from_archive` wouldn't be able to - // handle. - fn new( - system_version: SemverVersion, - by_id: &HashMap, - by_hash: &mut HashMap, - log: &Logger, - read_hubris_board: F, - ) -> Result - where - F: Fn( - ArtifactId, - Vec, - ) -> Result<(ArtifactId, Board), RepositoryError>, - { - // We expect at least one of each of these kinds to be present, but we - // allow multiple (keyed by the Hubris archive caboose `board` value, - // which identifies hardware revision). We could do the same for the RoT - // images, but to date the RoT images are applicable to all revs; only - // the SP images change from rev to rev. - let mut gimlet_sp = BTreeMap::new(); - let mut psc_sp = BTreeMap::new(); - let mut sidecar_sp = BTreeMap::new(); - - // We expect exactly one of each of these kinds to be present in the - // snapshot. Scan the snapshot and record the first of each we find, - // failing if we find a second. - let mut gimlet_rot_a = None; - let mut gimlet_rot_b = None; - let mut psc_rot_a = None; - let mut psc_rot_b = None; - let mut sidecar_rot_a = None; - let mut sidecar_rot_b = None; - let mut host_phase_1 = None; - let mut host_phase_2 = None; - let mut trampoline_phase_1 = None; - let mut trampoline_phase_2 = None; - - // We get the `ArtifactHash`s of top-level artifacts for free from the - // TUF repo, but for artifacts we expand, we recompute hashes of the - // inner parts ourselves. - let compute_hash = |data: &Bytes| { - let mut hasher = Sha256::new(); - hasher.update(data); - ArtifactHash(hasher.finalize().into()) - }; - - // Helper called for each SP image found in the loop below. - let sp_image_found = |out: &mut BTreeMap, - id, - hash: &ArtifactHash, - data: &Bytes| { - let (id, board) = read_hubris_board(id, data.clone().into())?; - - match out.entry(board) { - btree_map::Entry::Vacant(slot) => { - info!( - log, "found SP archive"; - "kind" => ?id.kind, - "board" => &slot.key().0, - ); - slot.insert(ArtifactIdData { - id, - data: DebugIgnore(data.clone()), - hash: *hash, - }); - Ok(()) - } - btree_map::Entry::Occupied(slot) => { - Err(RepositoryError::DuplicateBoardEntry { - board: slot.key().0.clone(), - // This closure is only called with well-known kinds. - kind: slot.get().id.kind.to_known().unwrap(), - }) - } - } - }; - - // Helper called for each non-SP artifact found in the loop below. - let artifact_found = |out: &mut Option, - id, - hash: Option<&ArtifactHash>, - data: &Bytes| { - let hash = match hash { - Some(hash) => *hash, - None => compute_hash(data), - }; - let data = DebugIgnore(data.clone()); - match out.replace(ArtifactIdData { id, hash, data }) { - None => Ok(()), - Some(prev) => { - // This closure is only called with well-known kinds. - let kind = prev.id.kind.to_known().unwrap(); - Err(RepositoryError::DuplicateArtifactKind(kind)) - } - } - }; - - for (artifact_id, (hash, data)) in by_id.iter() { - // In generating an update plan, skip any artifact kinds that are - // unknown to us (we wouldn't know how to incorporate them into our - // plan). - let Some(artifact_kind) = artifact_id.kind.to_known() else { - continue; - }; - let artifact_id = artifact_id.clone(); - - match artifact_kind { - KnownArtifactKind::GimletSp => { - sp_image_found(&mut gimlet_sp, artifact_id, hash, data)?; - } - KnownArtifactKind::GimletRot => { - slog::debug!(log, "extracting gimlet rot tarball"); - let archives = unpack_rot_artifact(artifact_kind, data)?; - artifact_found( - &mut gimlet_rot_a, - artifact_id.clone(), - None, - &archives.archive_a, - )?; - artifact_found( - &mut gimlet_rot_b, - artifact_id.clone(), - None, - &archives.archive_b, - )?; - } - KnownArtifactKind::PscSp => { - sp_image_found(&mut psc_sp, artifact_id, hash, data)?; - } - KnownArtifactKind::PscRot => { - slog::debug!(log, "extracting psc rot tarball"); - let archives = unpack_rot_artifact(artifact_kind, data)?; - artifact_found( - &mut psc_rot_a, - artifact_id.clone(), - None, - &archives.archive_a, - )?; - artifact_found( - &mut psc_rot_b, - artifact_id.clone(), - None, - &archives.archive_b, - )?; - } - KnownArtifactKind::SwitchSp => { - sp_image_found(&mut sidecar_sp, artifact_id, hash, data)?; - } - KnownArtifactKind::SwitchRot => { - slog::debug!(log, "extracting switch rot tarball"); - let archives = unpack_rot_artifact(artifact_kind, data)?; - artifact_found( - &mut sidecar_rot_a, - artifact_id.clone(), - None, - &archives.archive_a, - )?; - artifact_found( - &mut sidecar_rot_b, - artifact_id.clone(), - None, - &archives.archive_b, - )?; - } - KnownArtifactKind::Host => { - slog::debug!(log, "extracting host tarball"); - let images = unpack_host_artifact(artifact_kind, data)?; - artifact_found( - &mut host_phase_1, - artifact_id.clone(), - None, - &images.phase_1, - )?; - artifact_found( - &mut host_phase_2, - artifact_id, - None, - &images.phase_2, - )?; - } - KnownArtifactKind::Trampoline => { - slog::debug!(log, "extracting trampoline tarball"); - let images = unpack_host_artifact(artifact_kind, data)?; - artifact_found( - &mut trampoline_phase_1, - artifact_id.clone(), - None, - &images.phase_1, - )?; - artifact_found( - &mut trampoline_phase_2, - artifact_id, - None, - &images.phase_2, - )?; - } - KnownArtifactKind::ControlPlane => { - // Only the installinator needs this artifact. - } - } - } - - // Find the TUF hash of the control plane artifact. This is a little - // hokey: scan through `by_hash` looking for the right artifact kind. - let control_plane_hash = by_hash - .keys() - .find_map(|hash_id| { - if hash_id.kind.to_known() - == Some(KnownArtifactKind::ControlPlane) - { - Some(hash_id.hash) - } else { - None - } - }) - .ok_or(RepositoryError::MissingArtifactKind( - KnownArtifactKind::ControlPlane, - ))?; - - // Ensure we found a Host artifact. - let host_phase_2 = host_phase_2.ok_or( - RepositoryError::MissingArtifactKind(KnownArtifactKind::Host), - )?; - - // Add the host phase 2 image to the set of artifacts we're willing to - // serve by hash; that's how installinator will be requesting it. - let host_phase_2_hash_id = ArtifactHashId { - kind: ArtifactKind::HOST_PHASE_2, - hash: host_phase_2.hash, - }; - match by_hash.entry(host_phase_2_hash_id.clone()) { - Entry::Occupied(_) => { - // We got two entries for an artifact? - return Err(RepositoryError::DuplicateHashEntry( - host_phase_2_hash_id, - )); - } - Entry::Vacant(entry) => { - info!(log, "added host phase 2 artifact"; - "kind" => %host_phase_2_hash_id.kind, - "hash" => %host_phase_2_hash_id.hash, - "length" => host_phase_2.data.len(), - ); - entry.insert(host_phase_2.data.0.clone()); - } - } - - // Ensure our multi-board-supporting kinds have at least one board - // present. - if gimlet_sp.is_empty() { - return Err(RepositoryError::MissingArtifactKind( - KnownArtifactKind::GimletSp, - )); - } - if psc_sp.is_empty() { - return Err(RepositoryError::MissingArtifactKind( - KnownArtifactKind::PscSp, - )); - } - if sidecar_sp.is_empty() { - return Err(RepositoryError::MissingArtifactKind( - KnownArtifactKind::SwitchSp, - )); - } - - Ok(Self { - system_version, - gimlet_sp, - gimlet_rot_a: gimlet_rot_a.ok_or( - RepositoryError::MissingArtifactKind( - KnownArtifactKind::GimletRot, - ), - )?, - gimlet_rot_b: gimlet_rot_b.ok_or( - RepositoryError::MissingArtifactKind( - KnownArtifactKind::GimletRot, - ), - )?, - psc_sp, - psc_rot_a: psc_rot_a.ok_or( - RepositoryError::MissingArtifactKind(KnownArtifactKind::PscRot), - )?, - psc_rot_b: psc_rot_b.ok_or( - RepositoryError::MissingArtifactKind(KnownArtifactKind::PscRot), - )?, - sidecar_sp, - sidecar_rot_a: sidecar_rot_a.ok_or( - RepositoryError::MissingArtifactKind( - KnownArtifactKind::SwitchRot, - ), - )?, - sidecar_rot_b: sidecar_rot_b.ok_or( - RepositoryError::MissingArtifactKind( - KnownArtifactKind::SwitchRot, - ), - )?, - host_phase_1: host_phase_1.ok_or( - RepositoryError::MissingArtifactKind(KnownArtifactKind::Host), - )?, - trampoline_phase_1: trampoline_phase_1.ok_or( - RepositoryError::MissingArtifactKind( - KnownArtifactKind::Trampoline, - ), - )?, - trampoline_phase_2: trampoline_phase_2.ok_or( - RepositoryError::MissingArtifactKind( - KnownArtifactKind::Trampoline, - ), - )?, - host_phase_2_hash: host_phase_2.hash, - control_plane_hash, - }) - } -} - -fn unpack_host_artifact( - kind: KnownArtifactKind, - data: &Bytes, -) -> Result { - HostPhaseImages::extract(io::Cursor::new(data)) - .map_err(|error| RepositoryError::TarballExtract { kind, error }) -} - -fn unpack_rot_artifact( - kind: KnownArtifactKind, - data: &Bytes, -) -> Result { - RotArchives::extract(io::Cursor::new(data)) - .map_err(|error| RepositoryError::TarballExtract { kind, error }) -} - -// 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( - id: ArtifactId, - data: Vec, -) -> Result<(ArtifactId, Board), 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 board = match caboose.board() { - Ok(board) => board, - Err(error) => { - return Err(RepositoryError::ReadHubrisCabooseBoard { id, error }); - } - }; - let board = match std::str::from_utf8(board) { - Ok(s) => s, - Err(_) => { - return Err(RepositoryError::ReadHubrisCabooseBoardUtf8(id)); - } - }; - Ok((id, Board(board.to_string()))) -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::collections::BTreeSet; - - use anyhow::{Context, Result}; - use camino_tempfile::Utf8TempDir; - use clap::Parser; - use omicron_test_utils::dev::test_setup_log; - use rand::{distributions::Standard, thread_rng, Rng}; - - /// Test that `ArtifactsWithPlan` can extract the fake repository generated - /// by tufaceous. - #[test] - fn test_extract_fake() -> Result<()> { - let logctx = test_setup_log("test_extract_fake"); - let temp_dir = Utf8TempDir::new()?; - let archive_path = temp_dir.path().join("archive.zip"); - - // Create the archive. - let args = tufaceous::Args::try_parse_from([ - "tufaceous", - "assemble", - "../tufaceous/manifests/fake.toml", - archive_path.as_str(), - ]) - .context("error parsing args")?; - - args.exec(&logctx.log).context("error executing assemble command")?; - - // Now check that it can be read by the archive extractor. - let zip_bytes = fs_err::read(&archive_path)?.into(); - let plan = ArtifactsWithPlan::from_zip(zip_bytes, &logctx.log) - .context("error reading archive.zip")?; - // Check that all known artifact kinds are present in the map. - let by_id_kinds: BTreeSet<_> = - plan.by_id.keys().map(|id| id.kind.clone()).collect(); - let by_hash_kinds: BTreeSet<_> = - plan.by_hash.keys().map(|id| id.kind.clone()).collect(); - - let mut expected_kinds: BTreeSet<_> = - KnownArtifactKind::iter().map(ArtifactKind::from).collect(); - assert_eq!( - expected_kinds, by_id_kinds, - "expected kinds match by_id kinds" - ); - - // The by_hash map has the host phase 2 kind. - // XXX should by_id also contain this kind? - expected_kinds.insert(ArtifactKind::HOST_PHASE_2); - assert_eq!( - expected_kinds, by_hash_kinds, - "expected kinds match by_hash kinds" - ); - - logctx.cleanup_successful(); - - Ok(()) - } - - fn make_random_bytes() -> Vec { - thread_rng().sample_iter(Standard).take(128).collect() - } - - struct RandomHostOsImage { - phase1: Bytes, - phase2: Bytes, - tarball: Bytes, - } - - fn make_random_host_os_image() -> RandomHostOsImage { - use tufaceous_lib::CompositeHostArchiveBuilder; - - let phase1 = make_random_bytes(); - let phase2 = make_random_bytes(); - - let mut builder = CompositeHostArchiveBuilder::new(Vec::new()).unwrap(); - builder.append_phase_1(phase1.len(), phase1.as_slice()).unwrap(); - builder.append_phase_2(phase2.len(), phase2.as_slice()).unwrap(); - - let tarball = builder.finish().unwrap(); - - RandomHostOsImage { - phase1: Bytes::from(phase1), - phase2: Bytes::from(phase2), - tarball: Bytes::from(tarball), - } - } - - struct RandomRotImage { - archive_a: Bytes, - archive_b: Bytes, - tarball: Bytes, - } - - fn make_random_rot_image() -> RandomRotImage { - use tufaceous_lib::CompositeRotArchiveBuilder; - - let archive_a = make_random_bytes(); - let archive_b = make_random_bytes(); - - let mut builder = CompositeRotArchiveBuilder::new(Vec::new()).unwrap(); - builder - .append_archive_a(archive_a.len(), archive_a.as_slice()) - .unwrap(); - builder - .append_archive_b(archive_b.len(), archive_b.as_slice()) - .unwrap(); - - let tarball = builder.finish().unwrap(); - - RandomRotImage { - archive_a: Bytes::from(archive_a), - archive_b: Bytes::from(archive_b), - tarball: Bytes::from(tarball), - } - } - - #[test] - fn test_update_plan_from_artifacts() { - const VERSION_0: SemverVersion = SemverVersion::new(0, 0, 0); - - let mut by_id = HashMap::new(); - let mut by_hash = HashMap::new(); - - // The control plane artifact can be arbitrary bytes; just populate it - // with random data. - { - let kind = KnownArtifactKind::ControlPlane; - let data = Bytes::from(make_random_bytes()); - let hash = ArtifactHash(Sha256::digest(&data).into()); - let id = ArtifactId { - name: format!("{kind:?}"), - version: VERSION_0, - kind: kind.into(), - }; - let hash_id = ArtifactHashId { kind: kind.into(), hash }; - by_id.insert(id, (hash, data.clone())); - by_hash.insert(hash_id, data); - } - - // 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 = Bytes::from(make_random_bytes()); - let hash = ArtifactHash(Sha256::digest(&data).into()); - let id = ArtifactId { - name: board.to_string(), - version: VERSION_0, - kind: kind.into(), - }; - println!("{kind:?}={board:?} => {id:?}"); - let hash_id = ArtifactHashId { kind: kind.into(), hash }; - by_id.insert(id, (hash, data.clone())); - by_hash.insert(hash_id, data); - } - } - - // 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 hash = ArtifactHash(Sha256::digest(&image.tarball).into()); - let id = ArtifactId { - name: format!("{kind:?}"), - version: VERSION_0, - kind: kind.into(), - }; - let hash_id = ArtifactHashId { kind: kind.into(), hash }; - by_id.insert(id, (hash, image.tarball.clone())); - by_hash.insert(hash_id, image.tarball.clone()); - } - - let gimlet_rot = make_random_rot_image(); - let psc_rot = make_random_rot_image(); - let sidecar_rot = make_random_rot_image(); - - for (kind, artifact) in [ - (KnownArtifactKind::GimletRot, &gimlet_rot), - (KnownArtifactKind::PscRot, &psc_rot), - (KnownArtifactKind::SwitchRot, &sidecar_rot), - ] { - let hash = ArtifactHash(Sha256::digest(&artifact.tarball).into()); - let id = ArtifactId { - name: format!("{kind:?}"), - version: VERSION_0, - kind: kind.into(), - }; - let hash_id = ArtifactHashId { kind: kind.into(), hash }; - by_id.insert(id, (hash, artifact.tarball.clone())); - by_hash.insert(hash_id, artifact.tarball.clone()); - } - - let logctx = test_setup_log("test_update_plan_from_artifacts"); - - let plan = UpdatePlan::new( - VERSION_0, - &by_id, - &mut by_hash, - &logctx.log, - |id, _data| { - let board = id.name.clone(); - Ok((id, Board(board))) - }, - ) - .unwrap(); - - assert_eq!(plan.gimlet_sp.len(), 2); - assert_eq!(plan.psc_sp.len(), 2); - assert_eq!(plan.sidecar_sp.len(), 2); - - for (id, (hash, data)) in &by_id { - match id.kind.to_known().unwrap() { - KnownArtifactKind::GimletSp => { - assert!( - id.name.starts_with("test-gimlet-"), - "unexpected id.name {:?}", - id.name - ); - assert_eq!( - *plan.gimlet_sp.get(&id.name).unwrap().data, - data - ); - } - KnownArtifactKind::ControlPlane => { - assert_eq!(plan.control_plane_hash, *hash); - } - KnownArtifactKind::PscSp => { - assert!( - id.name.starts_with("test-psc-"), - "unexpected id.name {:?}", - id.name - ); - assert_eq!(*plan.psc_sp.get(&id.name).unwrap().data, data); - } - KnownArtifactKind::SwitchSp => { - assert!( - id.name.starts_with("test-switch-"), - "unexpected id.name {:?}", - id.name - ); - assert_eq!( - *plan.sidecar_sp.get(&id.name).unwrap().data, - data - ); - } - KnownArtifactKind::Host - | KnownArtifactKind::Trampoline - | KnownArtifactKind::GimletRot - | KnownArtifactKind::PscRot - | KnownArtifactKind::SwitchRot => { - // special; we check these below - } - } - } - - // Check extracted host and trampoline data - assert_eq!(*plan.host_phase_1.data, host.phase1); - assert_eq!(*plan.trampoline_phase_1.data, trampoline.phase1); - assert_eq!(*plan.trampoline_phase_2.data, trampoline.phase2); - - let hash = Sha256::digest(&host.phase2); - assert_eq!(plan.host_phase_2_hash.0, *hash); - - // Check extracted RoT data - assert_eq!(*plan.gimlet_rot_a.data, gimlet_rot.archive_a); - assert_eq!(*plan.gimlet_rot_b.data, gimlet_rot.archive_b); - assert_eq!(*plan.psc_rot_a.data, psc_rot.archive_a); - assert_eq!(*plan.psc_rot_b.data, psc_rot.archive_b); - assert_eq!(*plan.sidecar_rot_a.data, sidecar_rot.archive_a); - assert_eq!(*plan.sidecar_rot_b.data, sidecar_rot.archive_b); - - logctx.cleanup_successful(); - } -} diff --git a/wicketd/src/artifacts/artifacts_with_plan.rs b/wicketd/src/artifacts/artifacts_with_plan.rs new file mode 100644 index 0000000000..331aecfc70 --- /dev/null +++ b/wicketd/src/artifacts/artifacts_with_plan.rs @@ -0,0 +1,303 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use super::error::RepositoryError; +use super::update_plan::UpdatePlanBuilder; +use super::ExtractedArtifactDataHandle; +use super::UpdatePlan; +use camino_tempfile::Utf8TempDir; +use debug_ignore::DebugIgnore; +use omicron_common::update::ArtifactHash; +use omicron_common::update::ArtifactHashId; +use omicron_common::update::ArtifactId; +use slog::info; +use slog::Logger; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::io; +use tough::TargetName; +use tufaceous_lib::ArchiveExtractor; +use tufaceous_lib::OmicronRepo; + +/// A collection of artifacts along with an update plan using those artifacts. +#[derive(Debug)] +pub(super) struct ArtifactsWithPlan { + // Map of top-level artifact IDs (present in the TUF repo) to the actual + // artifacts we're serving (e.g., a top-level RoT artifact will map to two + // artifact hashes: one for each of the A and B images). + // + // The sum of the lengths of the values of this map will match the number of + // entries in `by_hash`. + by_id: BTreeMap>, + + // Map of the artifact hashes (and their associated kind) that we extracted + // from a TUF repository to a handle to the data of that artifact. + // + // An example of the difference between `by_id` and `by_hash`: An uploaded + // TUF repository will contain an artifact for the host OS (i.e., + // `KnownArtifactKind::Host`). On ingest, we will unpack that artifact into + // the parts it contains: a phase 1 image (`ArtifactKind::HOST_PHASE_1`) and + // a phase 2 image (`ArtifactKind::HOST_PHASE_2`). We will hash each of + // those images and store them in a temporary directory. `by_id` will + // contain a single entry mapping the original TUF repository artifact ID + // to the two `ArtifactHashId`s extracted from that artifact, and `by_hash` + // will contain two entries mapping each of the images to their data. + by_hash: DebugIgnore>, + + // The plan to use to update a component within the rack. + plan: UpdatePlan, +} + +impl ArtifactsWithPlan { + pub(super) fn from_zip( + zip_data: T, + log: &Logger, + ) -> Result + where + T: io::Read + io::Seek, + { + // Create a temporary directory to hold the extracted TUF repository. + let dir = unzip_into_tempdir(zip_data, log)?; + + // Time is unavailable during initial setup, so ignore expiration. Even + // if time were available, we might want to be able to load older + // versions of artifacts over the technician port in an emergency. + // + // XXX we aren't checking against a root of trust at this point -- + // anyone can sign the repositories and this code will accept that. + let repository = + OmicronRepo::load_untrusted_ignore_expiration(log, dir.path()) + .map_err(RepositoryError::LoadRepository)?; + + let artifacts = repository + .read_artifacts() + .map_err(RepositoryError::ReadArtifactsDocument)?; + + // Create another temporary directory where we'll "permanently" (as long + // as this plan is in use) hold extracted artifacts we need; most of + // these are just direct copies of artifacts we just unpacked into + // `dir`, but we'll also unpack nested artifacts like the RoT dual A/B + // archives. + let mut plan_builder = + UpdatePlanBuilder::new(artifacts.system_version, log)?; + + // Make a pass through each artifact in the repo. For each artifact, we + // do one of the following: + // + // 1. Ignore it (if it's of an artifact kind we don't understand) + // 2. Add it directly to tempdir managed by `extracted_artifacts`; we'll + // keep a handle to any such file and use it later. (SP images fall + // into this category.) + // 3. Unpack its contents and copy inner artifacts into the tempdir + // managed by `extracted_artifacts`. (RoT artifacts and OS images + // fall into this category: RoT artifacts themselves contain A and B + // hubris archives, and OS images artifacts contain separate phase1 + // and phase2 blobs.) + // + // For artifacts in case 2, we should be able to move the file instead + // of copying it, but the source paths aren't exposed by `repository`. + // The only artifacts that fall into this category are SP images, which + // are not very large, so fixing this is not a particularly high + // priority - copying small SP artifacts is neglible compared to the + // work we do to unpack host OS images. + + let mut by_id = BTreeMap::new(); + let mut by_hash = HashMap::new(); + for artifact in artifacts.artifacts { + let target_name = TargetName::try_from(artifact.target.as_str()) + .map_err(|error| RepositoryError::LocateTarget { + target: artifact.target.clone(), + error: Box::new(error), + })?; + + let target_hash = repository + .repo() + .targets() + .signed + .find_target(&target_name) + .map_err(|error| RepositoryError::TargetHashRead { + target: artifact.target.clone(), + error, + })? + .hashes + .sha256 + .clone() + .into_vec(); + + // The target hash is SHA-256, which is 32 bytes long. + let artifact_hash = ArtifactHash( + target_hash + .try_into() + .map_err(RepositoryError::TargetHashLength)?, + ); + + let reader = repository + .repo() + .read_target(&target_name) + .map_err(|error| RepositoryError::LocateTarget { + target: artifact.target.clone(), + error: Box::new(error), + })? + .ok_or_else(|| { + RepositoryError::MissingTarget(artifact.target.clone()) + })?; + + plan_builder.add_artifact( + artifact.into_id(), + artifact_hash, + io::BufReader::new(reader), + &mut by_id, + &mut by_hash, + )?; + } + + // Ensure we know how to apply updates from this set of artifacts; we'll + // remember the plan we create. + let plan = plan_builder.build()?; + + Ok(Self { by_id, by_hash: by_hash.into(), plan }) + } + + pub(super) fn by_id(&self) -> &BTreeMap> { + &self.by_id + } + + #[cfg(test)] + pub(super) fn by_hash( + &self, + ) -> &HashMap { + &self.by_hash + } + + pub(super) fn plan(&self) -> &UpdatePlan { + &self.plan + } + + pub(super) fn get_by_hash( + &self, + id: &ArtifactHashId, + ) -> Option { + self.by_hash.get(id).cloned() + } +} + +fn unzip_into_tempdir( + zip_data: T, + log: &Logger, +) -> Result +where + T: io::Read + io::Seek, +{ + let mut extractor = ArchiveExtractor::new(zip_data) + .map_err(RepositoryError::OpenArchive)?; + + let dir = + camino_tempfile::tempdir().map_err(RepositoryError::TempDirCreate)?; + + info!(log, "extracting uploaded archive to {}", dir.path()); + + // XXX: might be worth differentiating between server-side issues (503) + // and issues with the uploaded archive (400). + extractor.extract(dir.path()).map_err(RepositoryError::Extract)?; + + Ok(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::{Context, Result}; + use camino_tempfile::Utf8TempDir; + use clap::Parser; + use omicron_common::{ + api::internal::nexus::KnownArtifactKind, update::ArtifactKind, + }; + use omicron_test_utils::dev::test_setup_log; + use std::collections::BTreeSet; + + /// Test that `ArtifactsWithPlan` can extract the fake repository generated + /// by tufaceous. + #[test] + fn test_extract_fake() -> Result<()> { + let logctx = test_setup_log("test_extract_fake"); + let temp_dir = Utf8TempDir::new()?; + let archive_path = temp_dir.path().join("archive.zip"); + + // Create the archive. + let args = tufaceous::Args::try_parse_from([ + "tufaceous", + "assemble", + "../tufaceous/manifests/fake.toml", + archive_path.as_str(), + ]) + .context("error parsing args")?; + + args.exec(&logctx.log).context("error executing assemble command")?; + + // Now check that it can be read by the archive extractor. + let zip_bytes = std::fs::File::open(&archive_path) + .context("error opening archive.zip")?; + let plan = ArtifactsWithPlan::from_zip(zip_bytes, &logctx.log) + .context("error reading archive.zip")?; + // Check that all known artifact kinds are present in the map. + let by_id_kinds: BTreeSet<_> = + plan.by_id().keys().map(|id| id.kind.clone()).collect(); + let by_hash_kinds: BTreeSet<_> = + plan.by_hash().keys().map(|id| id.kind.clone()).collect(); + + // `by_id` should contain one entry for every `KnownArtifactKind`... + let mut expected_kinds: BTreeSet<_> = + KnownArtifactKind::iter().map(ArtifactKind::from).collect(); + assert_eq!( + expected_kinds, by_id_kinds, + "expected kinds match by_id kinds" + ); + + // ... but `by_hash` should replace the artifacts that contain nested + // artifacts to be expanded into their inner parts (phase1/phase2 for OS + // images and A/B images for the RoT) during import. + for remove in [ + KnownArtifactKind::Host, + KnownArtifactKind::Trampoline, + KnownArtifactKind::GimletRot, + KnownArtifactKind::PscRot, + KnownArtifactKind::SwitchRot, + ] { + assert!(expected_kinds.remove(&remove.into())); + } + for add in [ + ArtifactKind::HOST_PHASE_1, + ArtifactKind::HOST_PHASE_2, + ArtifactKind::TRAMPOLINE_PHASE_1, + ArtifactKind::TRAMPOLINE_PHASE_2, + ArtifactKind::GIMLET_ROT_IMAGE_A, + ArtifactKind::GIMLET_ROT_IMAGE_B, + ArtifactKind::PSC_ROT_IMAGE_A, + ArtifactKind::PSC_ROT_IMAGE_B, + ArtifactKind::SWITCH_ROT_IMAGE_A, + ArtifactKind::SWITCH_ROT_IMAGE_B, + ] { + assert!(expected_kinds.insert(add)); + } + assert_eq!( + expected_kinds, by_hash_kinds, + "expected kinds match by_hash kinds" + ); + + // Every value present in `by_id` should also be a key in `by_hash`. + for (id, hash_ids) in plan.by_id() { + for hash_id in hash_ids { + assert!( + plan.by_hash().contains_key(hash_id), + "plan.by_hash is missing an entry for \ + {hash_id:?} (derived from {id:?})" + ); + } + } + + logctx.cleanup_successful(); + + Ok(()) + } +} diff --git a/wicketd/src/artifacts/error.rs b/wicketd/src/artifacts/error.rs new file mode 100644 index 0000000000..626426ac48 --- /dev/null +++ b/wicketd/src/artifacts/error.rs @@ -0,0 +1,157 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use camino::Utf8PathBuf; +use display_error_chain::DisplayErrorChain; +use dropshot::HttpError; +use omicron_common::api::internal::nexus::KnownArtifactKind; +use omicron_common::update::{ArtifactHashId, ArtifactId, ArtifactKind}; +use slog::error; +use thiserror::Error; + +#[derive(Debug, Error)] +pub(super) enum RepositoryError { + #[error("error opening archive")] + OpenArchive(#[source] anyhow::Error), + + #[error("error creating temporary directory")] + TempDirCreate(#[source] std::io::Error), + + #[error("error creating temporary file in {path}")] + TempFileCreate { + path: Utf8PathBuf, + #[source] + error: std::io::Error, + }, + + #[error("error extracting repository")] + Extract(#[source] anyhow::Error), + + #[error("error loading repository")] + LoadRepository(#[source] anyhow::Error), + + #[error("error reading artifacts.json")] + ReadArtifactsDocument(#[source] anyhow::Error), + + #[error("error reading target hash for `{target}` in repository")] + TargetHashRead { + target: String, + #[source] + error: tough::schema::Error, + }, + + #[error("target hash `{}` expected to be 32 bytes long, was {}", hex::encode(.0), .0.len())] + TargetHashLength(Vec), + + #[error("error locating target `{target}` in repository")] + LocateTarget { + target: String, + #[source] + error: Box, + }, + + #[error( + "artifacts.json defines target `{0}` which is missing from the repo" + )] + MissingTarget(String), + + #[error("error copying artifact of kind `{kind}` from repository")] + CopyExtractedArtifact { + kind: ArtifactKind, + #[source] + error: anyhow::Error, + }, + + #[error("error extracting tarball for {kind} from repository")] + TarballExtract { + kind: KnownArtifactKind, + #[source] + error: anyhow::Error, + }, + + #[error("multiple artifacts found for kind `{0:?}`")] + DuplicateArtifactKind(KnownArtifactKind), + + #[error("duplicate board found for kind `{kind:?}`: `{board}`")] + DuplicateBoardEntry { board: String, kind: KnownArtifactKind }, + + #[error("error parsing artifact {id:?} as hubris archive")] + ParsingHubrisArchive { + id: ArtifactId, + #[source] + error: Box, + }, + + #[error("error reading hubris caboose from {id:?}")] + ReadHubrisCaboose { + id: ArtifactId, + #[source] + error: Box, + }, + + #[error("error reading board from hubris caboose of {id:?}")] + ReadHubrisCabooseBoard { + id: ArtifactId, + #[source] + error: hubtools::CabooseError, + }, + + #[error( + "error reading board from hubris caboose of {0:?}: non-utf8 value" + )] + ReadHubrisCabooseBoardUtf8(ArtifactId), + + #[error("missing artifact of kind `{0:?}`")] + MissingArtifactKind(KnownArtifactKind), + + #[error( + "duplicate hash entries found in artifacts.json for kind `{}`, hash `{}`", .0.kind, .0.hash + )] + DuplicateHashEntry(ArtifactHashId), +} + +impl RepositoryError { + pub(super) fn to_http_error(&self) -> HttpError { + let message = DisplayErrorChain::new(self).to_string(); + + match self { + // Errors we had that are unrelated to the contents of a repository + // uploaded by a client. + RepositoryError::TempDirCreate(_) + | RepositoryError::TempFileCreate { .. } => { + HttpError::for_unavail(None, message) + } + + // Errors that are definitely caused by bad repository contents. + RepositoryError::DuplicateArtifactKind(_) + | RepositoryError::LocateTarget { .. } + | RepositoryError::TargetHashLength(_) + | RepositoryError::MissingArtifactKind(_) + | RepositoryError::MissingTarget(_) + | RepositoryError::DuplicateHashEntry(_) + | RepositoryError::DuplicateBoardEntry { .. } + | RepositoryError::ParsingHubrisArchive { .. } + | RepositoryError::ReadHubrisCaboose { .. } + | RepositoryError::ReadHubrisCabooseBoard { .. } + | RepositoryError::ReadHubrisCabooseBoardUtf8(_) => { + HttpError::for_bad_request(None, message) + } + + // Gray area - these are _probably_ caused by bad repository + // contents, but there might be some cases (or cases-with-cases) + // where good contents still produce one of these errors. We'll opt + // for sending a 4xx bad request in hopes that it was our client's + // fault. + RepositoryError::OpenArchive(_) + | RepositoryError::Extract(_) + | RepositoryError::TarballExtract { .. } + | RepositoryError::LoadRepository(_) + | RepositoryError::ReadArtifactsDocument(_) + | RepositoryError::TargetHashRead { .. } + | RepositoryError::CopyExtractedArtifact { .. } => { + HttpError::for_bad_request(None, message) + } + } + } +} diff --git a/wicketd/src/artifacts/extracted_artifacts.rs b/wicketd/src/artifacts/extracted_artifacts.rs new file mode 100644 index 0000000000..f9ad59404b --- /dev/null +++ b/wicketd/src/artifacts/extracted_artifacts.rs @@ -0,0 +1,254 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use super::error::RepositoryError; +use anyhow::Context; +use camino::Utf8PathBuf; +use camino_tempfile::NamedUtf8TempFile; +use camino_tempfile::Utf8TempDir; +use omicron_common::update::ArtifactHash; +use omicron_common::update::ArtifactHashId; +use omicron_common::update::ArtifactKind; +use sha2::Digest; +use sha2::Sha256; +use slog::info; +use slog::Logger; +use std::fs::File; +use std::io; +use std::io::BufWriter; +use std::io::Read; +use std::io::Write; +use std::sync::Arc; +use tokio::io::AsyncRead; +use tokio_util::io::ReaderStream; + +/// Handle to the data of an extracted artifact. +/// +/// This does not contain the actual data; use `reader_stream()` to get a new +/// handle to the underlying file to read it on demand. +/// +/// Note that although this type implements `Clone` and that cloning is +/// relatively cheap, it has additional implications on filesystem cleanup. +/// `ExtractedArtifactDataHandle`s point to a file in a temporary directory +/// created when a TUF repo is uploaded. That directory contains _all_ +/// extracted artifacts from the TUF repo, and the directory will not be +/// cleaned up until all `ExtractedArtifactDataHandle`s that refer to files +/// inside it have been dropped. Therefore, one must be careful not to squirrel +/// away unneeded clones of `ExtractedArtifactDataHandle`s: only clone this in +/// contexts where you need the data and need the temporary directory containing +/// it to stick around. +#[derive(Debug, Clone)] +pub(crate) struct ExtractedArtifactDataHandle { + tempdir: Arc, + file_size: usize, + hash_id: ArtifactHashId, +} + +// We implement this by hand to use `Arc::ptr_eq`, because `Utf8TempDir` +// (sensibly!) does not implement `PartialEq`. We only use it it in tests. +#[cfg(test)] +impl PartialEq for ExtractedArtifactDataHandle { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.tempdir, &other.tempdir) + && self.file_size == other.file_size + && self.hash_id == other.hash_id + } +} + +#[cfg(test)] +impl Eq for ExtractedArtifactDataHandle {} + +impl ExtractedArtifactDataHandle { + /// File size of this artifact in bytes. + pub(super) fn file_size(&self) -> usize { + self.file_size + } + + pub(crate) fn hash(&self) -> ArtifactHash { + self.hash_id.hash + } + + /// Async stream to read the contents of this artifact on demand. + /// + /// This can fail due to I/O errors outside our control (e.g., something + /// removed the contents of our temporary directory). + pub(crate) async fn reader_stream( + &self, + ) -> anyhow::Result> { + let path = path_for_artifact(&self.tempdir, &self.hash_id); + + let file = tokio::fs::File::open(&path) + .await + .with_context(|| format!("failed to open {path}"))?; + + Ok(ReaderStream::new(file)) + } +} + +/// `ExtractedArtifacts` is a temporary wrapper around a `Utf8TempDir` for use +/// when ingesting a new TUF repository. +/// +/// It provides methods to copy artifacts into the tempdir (`store` and the +/// combo of `new_tempfile` + `store_tempfile`) that return +/// `ExtractedArtifactDataHandle`. The handles keep shared references to the +/// `Utf8TempDir`, so it will not be removed until all handles are dropped +/// (e.g., when a new TUF repository is uploaded). The handles can be used to +/// on-demand read files that were copied into the temp dir during ingest. +#[derive(Debug)] +pub(crate) struct ExtractedArtifacts { + // Directory in which we store extracted artifacts. This is currently a + // single flat directory with files named by artifact hash; we don't expect + // more than a few dozen files total, so no need to nest directories. + tempdir: Arc, +} + +impl ExtractedArtifacts { + pub(super) fn new(log: &Logger) -> Result { + let tempdir = camino_tempfile::Builder::new() + .prefix("wicketd-update-artifacts.") + .tempdir() + .map_err(RepositoryError::TempDirCreate)?; + info!( + log, "created directory to store extracted artifacts"; + "path" => %tempdir.path(), + ); + Ok(Self { tempdir: Arc::new(tempdir) }) + } + + fn path_for_artifact( + &self, + artifact_hash_id: &ArtifactHashId, + ) -> Utf8PathBuf { + self.tempdir.path().join(format!("{}", artifact_hash_id.hash)) + } + + /// Copy from `reader` into our temp directory, returning a handle to the + /// extracted artifact on success. + pub(super) fn store( + &mut self, + artifact_hash_id: ArtifactHashId, + mut reader: impl Read, + ) -> Result { + let output_path = self.path_for_artifact(&artifact_hash_id); + + let mut writer = BufWriter::new( + File::create(&output_path) + .with_context(|| { + format!("failed to create temp file {output_path}") + }) + .map_err(|error| RepositoryError::CopyExtractedArtifact { + kind: artifact_hash_id.kind.clone(), + error, + })?, + ); + + let file_size = io::copy(&mut reader, &mut writer) + .with_context(|| format!("failed writing to {output_path}")) + .map_err(|error| RepositoryError::CopyExtractedArtifact { + kind: artifact_hash_id.kind.clone(), + error, + })? as usize; + + writer + .flush() + .with_context(|| format!("failed flushing {output_path}")) + .map_err(|error| RepositoryError::CopyExtractedArtifact { + kind: artifact_hash_id.kind.clone(), + error, + })?; + + Ok(ExtractedArtifactDataHandle { + tempdir: Arc::clone(&self.tempdir), + file_size, + hash_id: artifact_hash_id, + }) + } + + /// Create a new temporary file inside this temporary directory. + /// + /// As the returned file is written to, the data will be hashed; once + /// writing is complete, call [`ExtractedArtifacts::store_tempfile()`] to + /// persist the temporary file into an [`ExtractedArtifactDataHandle()`]. + pub(super) fn new_tempfile( + &self, + ) -> Result { + let file = NamedUtf8TempFile::new_in(self.tempdir.path()).map_err( + |error| RepositoryError::TempFileCreate { + path: self.tempdir.path().to_owned(), + error, + }, + )?; + Ok(HashingNamedUtf8TempFile { + file: io::BufWriter::new(file), + hasher: Sha256::new(), + bytes_written: 0, + }) + } + + /// Persist a temporary file that was returned by + /// [`ExtractedArtifacts::new_tempfile()`] as an extracted artifact. + pub(super) fn store_tempfile( + &self, + kind: ArtifactKind, + file: HashingNamedUtf8TempFile, + ) -> Result { + let HashingNamedUtf8TempFile { file, hasher, bytes_written } = file; + + // We don't need to `.flush()` explicitly: `into_inner()` does that for + // us. + let file = file + .into_inner() + .context("failed to flush temp file") + .map_err(|error| RepositoryError::CopyExtractedArtifact { + kind: kind.clone(), + error, + })?; + + let hash = ArtifactHash(hasher.finalize().into()); + let artifact_hash_id = ArtifactHashId { kind, hash }; + let output_path = self.path_for_artifact(&artifact_hash_id); + file.persist(&output_path) + .map_err(|error| error.error) + .with_context(|| { + format!("failed to persist temp file to {output_path}") + }) + .map_err(|error| RepositoryError::CopyExtractedArtifact { + kind: artifact_hash_id.kind.clone(), + error, + })?; + + Ok(ExtractedArtifactDataHandle { + tempdir: Arc::clone(&self.tempdir), + file_size: bytes_written, + hash_id: artifact_hash_id, + }) + } +} + +fn path_for_artifact( + tempdir: &Utf8TempDir, + artifact_hash_id: &ArtifactHashId, +) -> Utf8PathBuf { + tempdir.path().join(format!("{}", artifact_hash_id.hash)) +} + +// Wrapper around a `NamedUtf8TempFile` that hashes contents as they're written. +pub(super) struct HashingNamedUtf8TempFile { + file: io::BufWriter, + hasher: Sha256, + bytes_written: usize, +} + +impl Write for HashingNamedUtf8TempFile { + fn write(&mut self, buf: &[u8]) -> io::Result { + let n = self.file.write(buf)?; + self.hasher.update(&buf[..n]); + self.bytes_written += n; + Ok(n) + } + + fn flush(&mut self) -> io::Result<()> { + self.file.flush() + } +} diff --git a/wicketd/src/artifacts/server.rs b/wicketd/src/artifacts/server.rs new file mode 100644 index 0000000000..3808f01753 --- /dev/null +++ b/wicketd/src/artifacts/server.rs @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use super::store::WicketdArtifactStore; +use crate::installinator_progress::IprArtifactServer; +use async_trait::async_trait; +use dropshot::HttpError; +use hyper::Body; +use installinator_artifactd::ArtifactGetter; +use installinator_artifactd::EventReportStatus; +use omicron_common::update::ArtifactHashId; +use slog::error; +use slog::Logger; +use uuid::Uuid; + +/// The artifact server interface for wicketd. +#[derive(Debug)] +pub(crate) struct WicketdArtifactServer { + #[allow(dead_code)] + log: Logger, + store: WicketdArtifactStore, + ipr_artifact: IprArtifactServer, +} + +impl WicketdArtifactServer { + pub(crate) fn new( + log: &Logger, + store: WicketdArtifactStore, + ipr_artifact: IprArtifactServer, + ) -> Self { + let log = log.new(slog::o!("component" => "wicketd artifact server")); + Self { log, store, ipr_artifact } + } +} + +#[async_trait] +impl ArtifactGetter for WicketdArtifactServer { + async fn get_by_hash(&self, id: &ArtifactHashId) -> Option<(u64, Body)> { + let data_handle = self.store.get_by_hash(id)?; + let size = data_handle.file_size() as u64; + let data_stream = match data_handle.reader_stream().await { + Ok(stream) => stream, + Err(err) => { + error!( + self.log, "failed to open extracted archive on demand"; + "error" => #%err, + ); + return None; + } + }; + + Some((size, Body::wrap_stream(data_stream))) + } + + async fn report_progress( + &self, + update_id: Uuid, + report: installinator_common::EventReport, + ) -> Result { + Ok(self.ipr_artifact.report_progress(update_id, report)) + } +} diff --git a/wicketd/src/artifacts/store.rs b/wicketd/src/artifacts/store.rs new file mode 100644 index 0000000000..29e1ecef0a --- /dev/null +++ b/wicketd/src/artifacts/store.rs @@ -0,0 +1,110 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use super::artifacts_with_plan::ArtifactsWithPlan; +use super::ExtractedArtifactDataHandle; +use super::UpdatePlan; +use crate::http_entrypoints::InstallableArtifacts; +use dropshot::HttpError; +use omicron_common::api::external::SemverVersion; +use omicron_common::update::ArtifactHashId; +use slog::Logger; +use std::io; +use std::sync::Arc; +use std::sync::Mutex; + +/// The artifact store for wicketd. +/// +/// This can be cheaply cloned, and is intended to be shared across the parts of artifactd that +/// upload artifacts and the parts that fetch them. +#[derive(Clone, Debug)] +pub struct WicketdArtifactStore { + log: Logger, + // NOTE: this is a `std::sync::Mutex` rather than a `tokio::sync::Mutex` + // because the critical sections are extremely small. + artifacts_with_plan: Arc>>, +} + +impl WicketdArtifactStore { + pub(crate) fn new(log: &Logger) -> Self { + let log = log.new(slog::o!("component" => "wicketd artifact store")); + Self { log, artifacts_with_plan: Default::default() } + } + + pub(crate) async fn put_repository( + &self, + data: T, + ) -> Result<(), HttpError> + where + T: io::Read + io::Seek + Send + 'static, + { + slog::debug!(self.log, "adding repository"); + + let log = self.log.clone(); + let new_artifacts = tokio::task::spawn_blocking(move || { + ArtifactsWithPlan::from_zip(data, &log) + .map_err(|error| error.to_http_error()) + }) + .await + .unwrap()?; + self.replace(new_artifacts); + + Ok(()) + } + + pub(crate) fn system_version_and_artifact_ids( + &self, + ) -> Option<(SemverVersion, Vec)> { + 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(), + }) + .collect(); + Some((system_version, artifact_ids)) + } + + /// Obtain the current plan. + /// + /// Exposed for testing. + pub fn current_plan(&self) -> Option { + // We expect this hashmap to be relatively small (order ~10), and + // cloning both ArtifactIds and ExtractedArtifactDataHandles are cheap. + self.artifacts_with_plan + .lock() + .unwrap() + .as_ref() + .map(|artifacts| artifacts.plan().clone()) + } + + // --- + // Helper methods + // --- + + pub(super) fn get_by_hash( + &self, + id: &ArtifactHashId, + ) -> Option { + self.artifacts_with_plan.lock().unwrap().as_ref()?.get_by_hash(id) + } + + // `pub` to allow use in integration tests. + pub fn contains_by_hash(&self, id: &ArtifactHashId) -> bool { + self.get_by_hash(id).is_some() + } + + /// Replaces the artifact hash map, returning the previous map. + fn replace( + &self, + new_artifacts: ArtifactsWithPlan, + ) -> Option { + let mut artifacts = self.artifacts_with_plan.lock().unwrap(); + std::mem::replace(&mut *artifacts, Some(new_artifacts)) + } +} diff --git a/wicketd/src/artifacts/update_plan.rs b/wicketd/src/artifacts/update_plan.rs new file mode 100644 index 0000000000..2668aaac51 --- /dev/null +++ b/wicketd/src/artifacts/update_plan.rs @@ -0,0 +1,1063 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Constructor for the `UpdatePlan` wicketd uses to drive sled mupdates. +//! +//! This is a "plan" in name only: it is a strict list of which artifacts to +//! apply to which components; the ordering and application of the plan lives +//! elsewhere. + +use super::error::RepositoryError; +use super::extracted_artifacts::ExtractedArtifacts; +use super::extracted_artifacts::HashingNamedUtf8TempFile; +use super::ArtifactIdData; +use super::Board; +use super::ExtractedArtifactDataHandle; +use anyhow::anyhow; +use hubtools::RawHubrisArchive; +use omicron_common::api::external::SemverVersion; +use omicron_common::api::internal::nexus::KnownArtifactKind; +use omicron_common::update::ArtifactHash; +use omicron_common::update::ArtifactHashId; +use omicron_common::update::ArtifactId; +use omicron_common::update::ArtifactKind; +use slog::info; +use slog::Logger; +use std::collections::btree_map; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::io; +use std::io::Read; +use tufaceous_lib::HostPhaseImages; +use tufaceous_lib::RotArchives; + +/// The update plan currently in effect. +/// +/// Exposed for testing. +#[derive(Debug, Clone)] +pub struct UpdatePlan { + pub(crate) system_version: SemverVersion, + pub(crate) gimlet_sp: BTreeMap, + pub(crate) gimlet_rot_a: ArtifactIdData, + pub(crate) gimlet_rot_b: ArtifactIdData, + pub(crate) psc_sp: BTreeMap, + pub(crate) psc_rot_a: ArtifactIdData, + pub(crate) psc_rot_b: ArtifactIdData, + pub(crate) sidecar_sp: BTreeMap, + pub(crate) sidecar_rot_a: ArtifactIdData, + pub(crate) sidecar_rot_b: ArtifactIdData, + + // Note: The Trampoline image is broken into phase1/phase2 as part of our + // update plan (because they go to different destinations), but the two + // phases will have the _same_ `ArtifactId` (i.e., the ID of the Host + // artifact from the TUF repository. + // + // The same would apply to the host phase1/phase2, but we don't actually + // need the `host_phase_2` data as part of this plan (we serve it from the + // artifact server instead). + pub(crate) host_phase_1: ArtifactIdData, + pub(crate) trampoline_phase_1: ArtifactIdData, + pub(crate) trampoline_phase_2: ArtifactIdData, + + // We need to send installinator the hash of the host_phase_2 data it should + // fetch from us; we compute it while generating the plan. + // + // This is exposed for testing. + pub host_phase_2_hash: ArtifactHash, + + // We also need to send installinator the hash of the control_plane image it + // should fetch from us. This is already present in the TUF repository, but + // we record it here for use by the update process. + // + // This is exposed for testing. + pub control_plane_hash: ArtifactHash, +} + +/// `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 +/// the final plan. +#[derive(Debug)] +pub(super) struct UpdatePlanBuilder<'a> { + // fields that mirror `UpdatePlan` + system_version: SemverVersion, + gimlet_sp: BTreeMap, + gimlet_rot_a: Option, + gimlet_rot_b: Option, + psc_sp: BTreeMap, + psc_rot_a: Option, + psc_rot_b: Option, + sidecar_sp: BTreeMap, + sidecar_rot_a: Option, + sidecar_rot_b: Option, + + // We always send phase 1 images (regardless of host or trampoline) to the + // SP via MGS, so we retain their data. + host_phase_1: Option, + trampoline_phase_1: Option, + + // Trampoline phase 2 images must be sent to MGS so that the SP is able to + // fetch it on demand while the trampoline OS is booting, so we need the + // data to send to MGS when we start an update. + trampoline_phase_2: Option, + + // In contrast to the trampoline phase 2 image, the host phase 2 image and + // the control plane are fetched by installinator from us over the bootstrap + // network. The only information we have to send to the SP via MGS is the + // hash of these two artifacts; we still hold the data in our `by_hash` map + // we build below, but we don't need the data when driving an update. + host_phase_2_hash: Option, + control_plane_hash: Option, + + // extra fields we use to build the plan + extracted_artifacts: ExtractedArtifacts, + log: &'a Logger, +} + +impl<'a> UpdatePlanBuilder<'a> { + pub(super) fn new( + system_version: SemverVersion, + log: &'a Logger, + ) -> Result { + let extracted_artifacts = ExtractedArtifacts::new(log)?; + Ok(Self { + system_version, + gimlet_sp: BTreeMap::new(), + gimlet_rot_a: None, + gimlet_rot_b: None, + psc_sp: BTreeMap::new(), + psc_rot_a: None, + psc_rot_b: None, + sidecar_sp: BTreeMap::new(), + sidecar_rot_a: None, + sidecar_rot_b: None, + host_phase_1: None, + trampoline_phase_1: None, + trampoline_phase_2: None, + host_phase_2_hash: None, + control_plane_hash: None, + + extracted_artifacts, + log, + }) + } + + pub(super) fn add_artifact( + &mut self, + artifact_id: ArtifactId, + artifact_hash: ArtifactHash, + reader: io::BufReader, + by_id: &mut BTreeMap>, + by_hash: &mut HashMap, + ) -> Result<(), RepositoryError> { + // If we don't know this artifact kind, we'll still serve it up by hash, + // but we don't do any further processing on it. + let Some(artifact_kind) = artifact_id.kind.to_known() else { + return self.add_unknown_artifact( + artifact_id, + artifact_hash, + reader, + by_id, + by_hash, + ); + }; + + // If we do know the artifact kind, we may have additional work to do, + // so we break each out into its own method. The particulars of that + // work varies based on the kind of artifact; for example, we have to + // unpack RoT artifacts into the A and B images they contain. + match artifact_kind { + KnownArtifactKind::GimletSp + | KnownArtifactKind::PscSp + | KnownArtifactKind::SwitchSp => self.add_sp_artifact( + artifact_id, + artifact_kind, + artifact_hash, + reader, + by_id, + by_hash, + ), + KnownArtifactKind::GimletRot + | KnownArtifactKind::PscRot + | KnownArtifactKind::SwitchRot => self.add_rot_artifact( + artifact_id, + artifact_kind, + reader, + by_id, + by_hash, + ), + KnownArtifactKind::Host => { + self.add_host_artifact(artifact_id, reader, by_id, by_hash) + } + KnownArtifactKind::Trampoline => self.add_trampoline_artifact( + artifact_id, + reader, + by_id, + by_hash, + ), + KnownArtifactKind::ControlPlane => self.add_control_plane_artifact( + artifact_id, + artifact_hash, + reader, + by_id, + by_hash, + ), + } + } + + fn add_sp_artifact( + &mut self, + artifact_id: ArtifactId, + artifact_kind: KnownArtifactKind, + artifact_hash: ArtifactHash, + mut reader: io::BufReader, + by_id: &mut BTreeMap>, + by_hash: &mut HashMap, + ) -> Result<(), RepositoryError> { + let sp_map = match artifact_kind { + KnownArtifactKind::GimletSp => &mut self.gimlet_sp, + KnownArtifactKind::PscSp => &mut self.psc_sp, + KnownArtifactKind::SwitchSp => &mut self.sidecar_sp, + // We're only called with an SP artifact kind. + KnownArtifactKind::GimletRot + | KnownArtifactKind::Host + | KnownArtifactKind::Trampoline + | KnownArtifactKind::ControlPlane + | KnownArtifactKind::PscRot + | KnownArtifactKind::SwitchRot => unreachable!(), + }; + + // SP images are small, and hubtools wants a `&[u8]` to parse, so we'll + // read the whole thing into memory. + let mut data = Vec::new(); + reader.read_to_end(&mut data).map_err(|error| { + RepositoryError::CopyExtractedArtifact { + kind: artifact_kind.into(), + error: anyhow!(error), + } + })?; + + let (artifact_id, board) = + read_hubris_board_from_archive(artifact_id, data.clone())?; + + let slot = match sp_map.entry(board) { + btree_map::Entry::Vacant(slot) => slot, + btree_map::Entry::Occupied(slot) => { + return Err(RepositoryError::DuplicateBoardEntry { + board: slot.key().0.clone(), + kind: artifact_kind, + }); + } + }; + + let artifact_hash_id = + ArtifactHashId { kind: artifact_kind.into(), hash: artifact_hash }; + let data = self + .extracted_artifacts + .store(artifact_hash_id, io::Cursor::new(&data))?; + slot.insert(ArtifactIdData { + id: artifact_id.clone(), + data: data.clone(), + }); + + record_extracted_artifact( + artifact_id, + by_id, + by_hash, + data, + artifact_kind.into(), + self.log, + )?; + + Ok(()) + } + + fn add_rot_artifact( + &mut self, + artifact_id: ArtifactId, + artifact_kind: KnownArtifactKind, + reader: io::BufReader, + by_id: &mut BTreeMap>, + by_hash: &mut HashMap, + ) -> Result<(), RepositoryError> { + let (rot_a, rot_a_kind, rot_b, rot_b_kind) = match artifact_kind { + KnownArtifactKind::GimletRot => ( + &mut self.gimlet_rot_a, + ArtifactKind::GIMLET_ROT_IMAGE_A, + &mut self.gimlet_rot_b, + ArtifactKind::GIMLET_ROT_IMAGE_B, + ), + KnownArtifactKind::PscRot => ( + &mut self.psc_rot_a, + ArtifactKind::PSC_ROT_IMAGE_A, + &mut self.psc_rot_b, + ArtifactKind::PSC_ROT_IMAGE_B, + ), + KnownArtifactKind::SwitchRot => ( + &mut self.sidecar_rot_a, + ArtifactKind::SWITCH_ROT_IMAGE_A, + &mut self.sidecar_rot_b, + ArtifactKind::SWITCH_ROT_IMAGE_B, + ), + // We're only called with an RoT artifact kind. + KnownArtifactKind::GimletSp + | KnownArtifactKind::Host + | KnownArtifactKind::Trampoline + | KnownArtifactKind::ControlPlane + | KnownArtifactKind::PscSp + | KnownArtifactKind::SwitchSp => unreachable!(), + }; + + if rot_a.is_some() || rot_b.is_some() { + return Err(RepositoryError::DuplicateArtifactKind(artifact_kind)); + } + + let (rot_a_data, rot_b_data) = Self::extract_nested_artifact_pair( + &mut self.extracted_artifacts, + artifact_kind, + |out_a, out_b| RotArchives::extract_into(reader, out_a, out_b), + )?; + + // 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 + // record them in `by_id` and `by_hash`: their addition will be + // consistently logged the way other artifacts are, and they'll show up + // in our dropshot endpoint that reports the artifacts we have. + let rot_a_id = ArtifactId { + name: artifact_id.name.clone(), + version: artifact_id.version.clone(), + kind: rot_a_kind.clone(), + }; + let rot_b_id = ArtifactId { + name: artifact_id.name.clone(), + version: artifact_id.version.clone(), + kind: rot_b_kind.clone(), + }; + + *rot_a = + Some(ArtifactIdData { id: rot_a_id, data: rot_a_data.clone() }); + *rot_b = + Some(ArtifactIdData { id: rot_b_id, data: rot_b_data.clone() }); + + record_extracted_artifact( + artifact_id.clone(), + by_id, + by_hash, + rot_a_data, + rot_a_kind, + self.log, + )?; + record_extracted_artifact( + artifact_id, + by_id, + by_hash, + rot_b_data, + rot_b_kind, + self.log, + )?; + + Ok(()) + } + + fn add_host_artifact( + &mut self, + artifact_id: ArtifactId, + reader: io::BufReader, + by_id: &mut BTreeMap>, + by_hash: &mut HashMap, + ) -> Result<(), RepositoryError> { + if self.host_phase_1.is_some() || self.host_phase_2_hash.is_some() { + return Err(RepositoryError::DuplicateArtifactKind( + KnownArtifactKind::Host, + )); + } + + let (phase_1_data, phase_2_data) = Self::extract_nested_artifact_pair( + &mut self.extracted_artifacts, + KnownArtifactKind::Host, + |out_1, out_2| HostPhaseImages::extract_into(reader, out_1, out_2), + )?; + + // Similarly to the RoT, we need to create new, non-conflicting artifact + // IDs for each image. + let phase_1_id = ArtifactId { + name: artifact_id.name.clone(), + version: artifact_id.version.clone(), + kind: ArtifactKind::HOST_PHASE_1, + }; + + self.host_phase_1 = + Some(ArtifactIdData { id: phase_1_id, data: phase_1_data.clone() }); + self.host_phase_2_hash = Some(phase_2_data.hash()); + + record_extracted_artifact( + artifact_id.clone(), + by_id, + by_hash, + phase_1_data, + ArtifactKind::HOST_PHASE_1, + self.log, + )?; + record_extracted_artifact( + artifact_id, + by_id, + by_hash, + phase_2_data, + ArtifactKind::HOST_PHASE_2, + self.log, + )?; + + Ok(()) + } + + fn add_trampoline_artifact( + &mut self, + artifact_id: ArtifactId, + reader: io::BufReader, + by_id: &mut BTreeMap>, + by_hash: &mut HashMap, + ) -> Result<(), RepositoryError> { + if self.trampoline_phase_1.is_some() + || self.trampoline_phase_2.is_some() + { + return Err(RepositoryError::DuplicateArtifactKind( + KnownArtifactKind::Trampoline, + )); + } + + let (phase_1_data, phase_2_data) = Self::extract_nested_artifact_pair( + &mut self.extracted_artifacts, + KnownArtifactKind::Trampoline, + |out_1, out_2| HostPhaseImages::extract_into(reader, out_1, out_2), + )?; + + // Similarly to the RoT, we need to create new, non-conflicting artifact + // IDs for each image. We'll append a suffix to the name; keep the + // version and kind the same. + let phase_1_id = ArtifactId { + name: artifact_id.name.clone(), + version: artifact_id.version.clone(), + kind: ArtifactKind::TRAMPOLINE_PHASE_1, + }; + let phase_2_id = ArtifactId { + name: artifact_id.name.clone(), + version: artifact_id.version.clone(), + kind: ArtifactKind::TRAMPOLINE_PHASE_2, + }; + + self.trampoline_phase_1 = + Some(ArtifactIdData { id: phase_1_id, data: phase_1_data.clone() }); + self.trampoline_phase_2 = + Some(ArtifactIdData { id: phase_2_id, data: phase_2_data.clone() }); + + record_extracted_artifact( + artifact_id.clone(), + by_id, + by_hash, + phase_1_data, + ArtifactKind::TRAMPOLINE_PHASE_1, + self.log, + )?; + record_extracted_artifact( + artifact_id, + by_id, + by_hash, + phase_2_data, + ArtifactKind::TRAMPOLINE_PHASE_2, + self.log, + )?; + + Ok(()) + } + + fn add_control_plane_artifact( + &mut self, + artifact_id: ArtifactId, + artifact_hash: ArtifactHash, + reader: io::BufReader, + by_id: &mut BTreeMap>, + by_hash: &mut HashMap, + ) -> Result<(), RepositoryError> { + if self.control_plane_hash.is_some() { + return Err(RepositoryError::DuplicateArtifactKind( + KnownArtifactKind::ControlPlane, + )); + } + + // The control plane artifact is the easiest one: we just need to copy + // it into our tempdir and record it. Nothing to inspect or extract. + let artifact_hash_id = ArtifactHashId { + kind: artifact_id.kind.clone(), + hash: artifact_hash, + }; + + let data = self.extracted_artifacts.store(artifact_hash_id, reader)?; + + self.control_plane_hash = Some(data.hash()); + + record_extracted_artifact( + artifact_id, + by_id, + by_hash, + data, + KnownArtifactKind::ControlPlane.into(), + self.log, + )?; + + Ok(()) + } + + fn add_unknown_artifact( + &mut self, + artifact_id: ArtifactId, + artifact_hash: ArtifactHash, + reader: io::BufReader, + by_id: &mut BTreeMap>, + by_hash: &mut HashMap, + ) -> Result<(), RepositoryError> { + let artifact_kind = artifact_id.kind.clone(); + let artifact_hash_id = + ArtifactHashId { kind: artifact_kind.clone(), hash: artifact_hash }; + + let data = self.extracted_artifacts.store(artifact_hash_id, reader)?; + + record_extracted_artifact( + artifact_id, + by_id, + by_hash, + data, + artifact_kind, + self.log, + )?; + + Ok(()) + } + + // RoT, host OS, and trampoline OS artifacts all contain a pair of artifacts + // we actually care about (RoT: A/B images; host/trampoline: phase1/phase2 + // images). This method is a helper that converts a single artifact `reader` + // into a pair of extracted artifacts. + fn extract_nested_artifact_pair( + extracted_artifacts: &mut ExtractedArtifacts, + kind: KnownArtifactKind, + extract: F, + ) -> Result< + (ExtractedArtifactDataHandle, ExtractedArtifactDataHandle), + RepositoryError, + > + where + F: FnOnce( + &mut HashingNamedUtf8TempFile, + &mut HashingNamedUtf8TempFile, + ) -> anyhow::Result<()>, + { + // Create two temp files for the pair of images we want to + // extract from `reader`. + let mut image1_out = extracted_artifacts.new_tempfile()?; + let mut image2_out = extracted_artifacts.new_tempfile()?; + + // Extract the two images from `reader`. + extract(&mut image1_out, &mut image2_out) + .map_err(|error| RepositoryError::TarballExtract { kind, error })?; + + // Persist the two images we just extracted. + let image1 = + extracted_artifacts.store_tempfile(kind.into(), image1_out)?; + let image2 = + extracted_artifacts.store_tempfile(kind.into(), image2_out)?; + + Ok((image1, image2)) + } + + pub(super) fn build(self) -> Result { + // Ensure our multi-board-supporting kinds have at least one board + // present. + if self.gimlet_sp.is_empty() { + return Err(RepositoryError::MissingArtifactKind( + KnownArtifactKind::GimletSp, + )); + } + if self.psc_sp.is_empty() { + return Err(RepositoryError::MissingArtifactKind( + KnownArtifactKind::PscSp, + )); + } + if self.sidecar_sp.is_empty() { + return Err(RepositoryError::MissingArtifactKind( + KnownArtifactKind::SwitchSp, + )); + } + + Ok(UpdatePlan { + system_version: self.system_version, + gimlet_sp: self.gimlet_sp, // checked above + gimlet_rot_a: self.gimlet_rot_a.ok_or( + RepositoryError::MissingArtifactKind( + KnownArtifactKind::GimletRot, + ), + )?, + gimlet_rot_b: self.gimlet_rot_b.ok_or( + RepositoryError::MissingArtifactKind( + KnownArtifactKind::GimletRot, + ), + )?, + psc_sp: self.psc_sp, // checked above + psc_rot_a: self.psc_rot_a.ok_or( + RepositoryError::MissingArtifactKind(KnownArtifactKind::PscRot), + )?, + psc_rot_b: self.psc_rot_b.ok_or( + RepositoryError::MissingArtifactKind(KnownArtifactKind::PscRot), + )?, + sidecar_sp: self.sidecar_sp, // checked above + sidecar_rot_a: self.sidecar_rot_a.ok_or( + RepositoryError::MissingArtifactKind( + KnownArtifactKind::SwitchRot, + ), + )?, + sidecar_rot_b: self.sidecar_rot_b.ok_or( + RepositoryError::MissingArtifactKind( + KnownArtifactKind::SwitchRot, + ), + )?, + host_phase_1: self.host_phase_1.ok_or( + RepositoryError::MissingArtifactKind(KnownArtifactKind::Host), + )?, + trampoline_phase_1: self.trampoline_phase_1.ok_or( + RepositoryError::MissingArtifactKind( + KnownArtifactKind::Trampoline, + ), + )?, + trampoline_phase_2: self.trampoline_phase_2.ok_or( + RepositoryError::MissingArtifactKind( + KnownArtifactKind::Trampoline, + ), + )?, + host_phase_2_hash: self.host_phase_2_hash.ok_or( + RepositoryError::MissingArtifactKind(KnownArtifactKind::Host), + )?, + control_plane_hash: self.control_plane_hash.ok_or( + RepositoryError::MissingArtifactKind( + KnownArtifactKind::ControlPlane, + ), + )?, + }) + } +} + +// 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( + id: ArtifactId, + data: Vec, +) -> Result<(ArtifactId, Board), 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 board = match caboose.board() { + Ok(board) => board, + Err(error) => { + return Err(RepositoryError::ReadHubrisCabooseBoard { id, error }); + } + }; + let board = match std::str::from_utf8(board) { + Ok(s) => s, + Err(_) => { + return Err(RepositoryError::ReadHubrisCabooseBoardUtf8(id)); + } + }; + Ok((id, Board(board.to_string()))) +} + +// Record an artifact in `by_id` and `by_hash`, or fail if either already has an +// entry for this id/hash. +fn record_extracted_artifact( + tuf_repo_artifact_id: ArtifactId, + by_id: &mut BTreeMap>, + by_hash: &mut HashMap, + data: ExtractedArtifactDataHandle, + data_kind: ArtifactKind, + log: &Logger, +) -> Result<(), RepositoryError> { + use std::collections::hash_map::Entry; + + let artifact_hash_id = + ArtifactHashId { kind: data_kind, hash: data.hash() }; + + let by_hash_slot = match by_hash.entry(artifact_hash_id) { + Entry::Occupied(slot) => { + return Err(RepositoryError::DuplicateHashEntry( + slot.key().clone(), + )); + } + Entry::Vacant(slot) => slot, + }; + + info!( + log, "added artifact"; + "name" => %tuf_repo_artifact_id.name, + "kind" => %by_hash_slot.key().kind, + "version" => %tuf_repo_artifact_id.version, + "hash" => %by_hash_slot.key().hash, + "length" => data.file_size(), + ); + + by_id + .entry(tuf_repo_artifact_id) + .or_default() + .push(by_hash_slot.key().clone()); + by_hash_slot.insert(data); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + use bytes::Bytes; + use futures::StreamExt; + use omicron_test_utils::dev::test_setup_log; + use rand::{distributions::Standard, thread_rng, Rng}; + use sha2::{Digest, Sha256}; + + fn make_random_bytes() -> Vec { + thread_rng().sample_iter(Standard).take(128).collect() + } + + struct RandomHostOsImage { + phase1: Bytes, + phase2: Bytes, + tarball: Bytes, + } + + fn make_random_host_os_image() -> RandomHostOsImage { + use tufaceous_lib::CompositeHostArchiveBuilder; + + let phase1 = make_random_bytes(); + let phase2 = make_random_bytes(); + + let mut builder = CompositeHostArchiveBuilder::new(Vec::new()).unwrap(); + builder.append_phase_1(phase1.len(), phase1.as_slice()).unwrap(); + builder.append_phase_2(phase2.len(), phase2.as_slice()).unwrap(); + + let tarball = builder.finish().unwrap(); + + RandomHostOsImage { + phase1: Bytes::from(phase1), + phase2: Bytes::from(phase2), + tarball: Bytes::from(tarball), + } + } + + struct RandomRotImage { + archive_a: Bytes, + archive_b: Bytes, + tarball: Bytes, + } + + fn make_random_rot_image() -> RandomRotImage { + use tufaceous_lib::CompositeRotArchiveBuilder; + + let archive_a = make_random_bytes(); + let archive_b = make_random_bytes(); + + let mut builder = CompositeRotArchiveBuilder::new(Vec::new()).unwrap(); + builder + .append_archive_a(archive_a.len(), archive_a.as_slice()) + .unwrap(); + builder + .append_archive_b(archive_b.len(), archive_b.as_slice()) + .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_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] + async fn test_update_plan_from_artifacts() { + const VERSION_0: SemverVersion = SemverVersion::new(0, 0, 0); + + let logctx = test_setup_log("test_update_plan_from_artifacts"); + + let mut by_id = BTreeMap::new(); + let mut by_hash = HashMap::new(); + let mut plan_builder = + UpdatePlanBuilder::new("0.0.0".parse().unwrap(), &logctx.log) + .unwrap(); + + // Add a couple artifacts with kinds wicketd doesn't understand; it + // should still ingest and serve them. + let mut expected_unknown_artifacts = BTreeSet::new(); + + for kind in ["test-kind-1", "test-kind-2"] { + let data = make_random_bytes(); + let hash = ArtifactHash(Sha256::digest(&data).into()); + let id = ArtifactId { + name: kind.to_string(), + version: VERSION_0, + kind: kind.parse().unwrap(), + }; + expected_unknown_artifacts.insert(id.clone()); + plan_builder + .add_artifact( + id, + hash, + io::BufReader::new(io::Cursor::new(&data)), + &mut by_id, + &mut by_hash, + ) + .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, + io::BufReader::new(io::Cursor::new(&data)), + &mut by_id, + &mut by_hash, + ) + .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, + io::BufReader::new(io::Cursor::new(&data)), + &mut by_id, + &mut by_hash, + ) + .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, + io::BufReader::new(io::Cursor::new(data)), + &mut by_id, + &mut by_hash, + ) + .unwrap(); + } + + let gimlet_rot = make_random_rot_image(); + let psc_rot = make_random_rot_image(); + let sidecar_rot = make_random_rot_image(); + + 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, + io::BufReader::new(io::Cursor::new(data)), + &mut by_id, + &mut by_hash, + ) + .unwrap(); + } + + let plan = plan_builder.build().unwrap(); + + assert_eq!(plan.gimlet_sp.len(), 2); + assert_eq!(plan.psc_sp.len(), 2); + assert_eq!(plan.sidecar_sp.len(), 2); + + for (id, hash_ids) in &by_id { + let kind = match id.kind.to_known() { + Some(kind) => kind, + None => { + assert!( + expected_unknown_artifacts.remove(id), + "unexpected unknown artifact ID {id:?}" + ); + continue; + } + }; + match kind { + KnownArtifactKind::GimletSp => { + assert!( + id.name.starts_with("test-gimlet-"), + "unexpected id.name {:?}", + id.name + ); + assert_eq!(hash_ids.len(), 1); + assert_eq!( + plan.gimlet_sp.get(&id.name).unwrap().data.hash(), + hash_ids[0].hash + ); + } + KnownArtifactKind::ControlPlane => { + assert_eq!(hash_ids.len(), 1); + assert_eq!(plan.control_plane_hash, hash_ids[0].hash); + } + KnownArtifactKind::PscSp => { + assert!( + id.name.starts_with("test-psc-"), + "unexpected id.name {:?}", + id.name + ); + assert_eq!(hash_ids.len(), 1); + assert_eq!( + plan.psc_sp.get(&id.name).unwrap().data.hash(), + hash_ids[0].hash + ); + } + KnownArtifactKind::SwitchSp => { + assert!( + id.name.starts_with("test-switch-"), + "unexpected id.name {:?}", + id.name + ); + assert_eq!(hash_ids.len(), 1); + assert_eq!( + plan.sidecar_sp.get(&id.name).unwrap().data.hash(), + hash_ids[0].hash + ); + } + // These are special (we import their inner parts) and we'll + // check them below. + KnownArtifactKind::Host + | KnownArtifactKind::Trampoline + | KnownArtifactKind::GimletRot + | KnownArtifactKind::PscRot + | KnownArtifactKind::SwitchRot => {} + } + } + + // Check extracted host and trampoline data + assert_eq!(read_to_vec(&plan.host_phase_1.data).await, host.phase1); + assert_eq!( + read_to_vec(&plan.trampoline_phase_1.data).await, + trampoline.phase1 + ); + assert_eq!( + read_to_vec(&plan.trampoline_phase_2.data).await, + trampoline.phase2 + ); + + let hash = Sha256::digest(&host.phase2); + assert_eq!(plan.host_phase_2_hash.0, *hash); + + // Check extracted RoT data + assert_eq!( + read_to_vec(&plan.gimlet_rot_a.data).await, + gimlet_rot.archive_a + ); + assert_eq!( + read_to_vec(&plan.gimlet_rot_b.data).await, + gimlet_rot.archive_b + ); + assert_eq!(read_to_vec(&plan.psc_rot_a.data).await, psc_rot.archive_a); + assert_eq!(read_to_vec(&plan.psc_rot_b.data).await, psc_rot.archive_b); + assert_eq!( + read_to_vec(&plan.sidecar_rot_a.data).await, + sidecar_rot.archive_a + ); + assert_eq!( + read_to_vec(&plan.sidecar_rot_b.data).await, + sidecar_rot.archive_b + ); + + 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(); + while let Some(data) = stream.next().await { + let data = data.unwrap(); + buf.extend_from_slice(&data); + } + buf + } +} diff --git a/wicketd/src/http_entrypoints.rs b/wicketd/src/http_entrypoints.rs index 14eeb15819..22f0558c43 100644 --- a/wicketd/src/http_entrypoints.rs +++ b/wicketd/src/http_entrypoints.rs @@ -31,6 +31,7 @@ use omicron_common::address; use omicron_common::api::external::SemverVersion; use omicron_common::api::internal::shared::RackNetworkConfig; use omicron_common::api::internal::shared::SwitchLocation; +use omicron_common::update::ArtifactHashId; use omicron_common::update::ArtifactId; use schemars::JsonSchema; use serde::Deserialize; @@ -38,9 +39,11 @@ use serde::Serialize; use sled_hardware::Baseboard; use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::io; use std::net::IpAddr; use std::net::Ipv6Addr; use std::time::Duration; +use tokio::io::AsyncWriteExt; use uuid::Uuid; use wicket_common::rack_setup::PutRssUserConfigInsensitive; use wicket_common::update_events::EventReport; @@ -561,21 +564,75 @@ async fn put_repository( ) -> Result { let rqctx = rqctx.context(); - // TODO: do we need to return more information with the response? + // Create a temporary file to store the incoming archive. + let tempfile = tokio::task::spawn_blocking(|| { + camino_tempfile::tempfile().map_err(|err| { + HttpError::for_unavail( + None, + format!("failed to create temp file: {err}"), + ) + }) + }) + .await + .unwrap()?; + let mut tempfile = + tokio::io::BufWriter::new(tokio::fs::File::from_std(tempfile)); + + let mut body = std::pin::pin!(body.into_stream()); + + // Stream the uploaded body into our tempfile. + while let Some(bytes) = body.try_next().await? { + tempfile.write_all(&bytes).await.map_err(|err| { + HttpError::for_unavail( + None, + format!("failed to write to temp file: {err}"), + ) + })?; + } - let bytes = body.into_stream().try_collect().await?; - rqctx.update_tracker.put_repository(bytes).await?; + // Flush writes. We don't need to seek back to the beginning of the file + // because extracting the repository will do its own seeking as a part of + // unzipping this repo. + tempfile.flush().await.map_err(|err| { + HttpError::for_unavail( + None, + format!("failed to flush temp file: {err}"), + ) + })?; + + let tempfile = tempfile.into_inner().into_std().await; + rqctx.update_tracker.put_repository(io::BufReader::new(tempfile)).await?; Ok(HttpResponseUpdatedNoContent()) } +#[derive(Clone, Debug, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct InstallableArtifacts { + pub artifact_id: ArtifactId, + pub installable: Vec, +} + /// The response to a `get_artifacts` call: the system version, and the list of /// all artifacts currently held by wicketd. #[derive(Clone, Debug, JsonSchema, Serialize)] #[serde(rename_all = "snake_case")] pub struct GetArtifactsAndEventReportsResponse { pub system_version: Option, - pub artifacts: Vec, + + /// Map of artifacts we ingested from the most-recently-uploaded TUF + /// repository to a list of artifacts we're serving over the bootstrap + /// network. In some cases the list of artifacts being served will have + /// length 1 (when we're serving the artifact directly); in other cases the + /// artifact in the TUF repo contains multiple nested artifacts inside it + /// (e.g., RoT artifacts contain both A and B images), and we serve the list + /// of extracted artifacts but not the original combination. + /// + /// Conceptually, this is a `BTreeMap>`, but + /// JSON requires string keys for maps, so we give back a vec of pairs + /// instead. + pub artifacts: Vec, + pub event_reports: BTreeMap>, } diff --git a/wicketd/src/update_tracker.rs b/wicketd/src/update_tracker.rs index bfa65f3b5f..a95a98bd72 100644 --- a/wicketd/src/update_tracker.rs +++ b/wicketd/src/update_tracker.rs @@ -17,12 +17,9 @@ use anyhow::anyhow; use anyhow::bail; use anyhow::ensure; use anyhow::Context; -use buf_list::BufList; -use bytes::Bytes; use display_error_chain::DisplayErrorChain; use dropshot::HttpError; use futures::Future; -use futures::TryStream; use gateway_client::types::HostPhase2Progress; use gateway_client::types::HostPhase2RecoveryImageId; use gateway_client::types::HostStartupOptions; @@ -48,6 +45,7 @@ use slog::Logger; use std::collections::btree_map::Entry; use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::io; use std::net::SocketAddrV6; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -175,7 +173,7 @@ impl UpdateTracker { // might still be trying to upload) and start a new one // with our current image. if prev.status.borrow().hash - != plan.trampoline_phase_2.hash + != plan.trampoline_phase_2.data.hash() { // It does _not_ match - we have a new plan with a // different trampoline image. If the old task is @@ -360,7 +358,7 @@ impl UpdateTracker { let artifact = plan.trampoline_phase_2.clone(); let (status_tx, status_rx) = watch::channel(UploadTrampolinePhase2ToMgsStatus { - hash: artifact.hash, + hash: artifact.data.hash(), uploaded_image_id: None, }); let task = tokio::spawn(upload_trampoline_phase_2_to_mgs( @@ -373,12 +371,15 @@ impl UpdateTracker { } /// Updates the repository stored inside the update tracker. - pub(crate) async fn put_repository( + pub(crate) async fn put_repository( &self, - bytes: BufList, - ) -> Result<(), HttpError> { + data: T, + ) -> Result<(), HttpError> + where + T: io::Read + io::Seek + Send + 'static, + { let mut update_data = self.sp_update_data.lock().await; - update_data.put_repository(bytes) + update_data.put_repository(data).await } /// Gets a list of artifacts stored in the update repository. @@ -387,8 +388,15 @@ impl UpdateTracker { ) -> GetArtifactsAndEventReportsResponse { let update_data = self.sp_update_data.lock().await; - let (system_version, artifacts) = - update_data.artifact_store.system_version_and_artifact_ids(); + let (system_version, artifacts) = match update_data + .artifact_store + .system_version_and_artifact_ids() + { + Some((system_version, artifacts)) => { + (Some(system_version), artifacts) + } + None => (None, Vec::new()), + }; let mut event_reports = BTreeMap::new(); for (sp, update_data) in &update_data.sp_update_data { @@ -476,7 +484,10 @@ impl UpdateTrackerData { } } - fn put_repository(&mut self, bytes: BufList) -> Result<(), HttpError> { + async fn put_repository(&mut self, data: T) -> Result<(), HttpError> + where + T: io::Read + io::Seek + Send + 'static, + { // Are there any updates currently running? If so, then reject the new // repository. let running_sps = self @@ -494,7 +505,7 @@ impl UpdateTrackerData { } // Put the repository into the artifact store. - self.artifact_store.put_repository(bytes)?; + self.artifact_store.put_repository(data).await?; // Reset all running data: a new repository means starting afresh. self.sp_update_data.clear(); @@ -1770,12 +1781,6 @@ enum ComponentUpdateStage { InProgress, } -fn buf_list_to_try_stream( - data: BufList, -) -> impl TryStream { - futures::stream::iter(data.into_iter().map(Ok)) -} - async fn upload_trampoline_phase_2_to_mgs( mgs_client: gateway_client::Client, artifact: ArtifactIdData, @@ -1783,14 +1788,25 @@ async fn upload_trampoline_phase_2_to_mgs( log: Logger, ) { let data = artifact.data; + let hash = data.hash(); let upload_task = move || { let mgs_client = mgs_client.clone(); - let image = - buf_list_to_try_stream(BufList::from_iter([data.0.clone()])); + let data = data.clone(); async move { + let image_stream = data.reader_stream().await.map_err(|e| { + // TODO-correctness If we get an I/O error opening the file + // associated with `data`, is it actually a transient error? If + // we change this to `permanent` we'll have to do some different + // error handling below and at our call site to retry. We + // _shouldn't_ get errors from `reader_stream()` in general, so + // it's probably okay either way? + backoff::BackoffError::transient(format!("{e:#}")) + })?; mgs_client - .recovery_host_phase2_upload(reqwest::Body::wrap_stream(image)) + .recovery_host_phase2_upload(reqwest::Body::wrap_stream( + image_stream, + )) .await .map_err(|e| backoff::BackoffError::transient(e.to_string())) } @@ -1818,7 +1834,7 @@ async fn upload_trampoline_phase_2_to_mgs( // Notify all receivers that we've uploaded the image. _ = status.send(UploadTrampolinePhase2ToMgsStatus { - hash: artifact.hash, + hash, uploaded_image_id: Some(uploaded_image_id), }); @@ -1862,6 +1878,18 @@ impl<'a> SpComponentUpdateContext<'a> { SpComponentUpdateStepId::Sending, format!("Sending data to MGS (slot {firmware_slot})"), move |_cx| async move { + let data_stream = artifact + .data + .reader_stream() + .await + .map_err(|error| { + SpComponentUpdateTerminalError::SpComponentUpdateFailed { + stage: SpComponentUpdateStage::Sending, + artifact: artifact.id.clone(), + error, + } + })?; + // TODO: we should be able to report some sort of progress // here for the file upload. update_cx @@ -1872,9 +1900,7 @@ impl<'a> SpComponentUpdateContext<'a> { component_name, firmware_slot, &update_id, - reqwest::Body::wrap_stream(buf_list_to_try_stream( - BufList::from_iter([artifact.data.0.clone()]), - )), + reqwest::Body::wrap_stream(data_stream), ) .await .map_err(|error| { diff --git a/wicketd/tests/integration_tests/updates.rs b/wicketd/tests/integration_tests/updates.rs index da80d2b97f..a4b330930a 100644 --- a/wicketd/tests/integration_tests/updates.rs +++ b/wicketd/tests/integration_tests/updates.rs @@ -61,18 +61,52 @@ async fn test_updates() { .expect("get_artifacts_and_event_reports succeeded") .into_inner(); + // We should have an artifact for every known artifact kind... + let expected_kinds: BTreeSet<_> = + KnownArtifactKind::iter().map(ArtifactKind::from).collect(); + + // ... and installable artifacts that replace the top level host, + // trampoline, and RoT with their inner parts (phase1/phase2 for OS images + // and A/B images for the RoT) during import. + let mut expected_installable_kinds = expected_kinds.clone(); + for remove in [ + KnownArtifactKind::Host, + KnownArtifactKind::Trampoline, + KnownArtifactKind::GimletRot, + KnownArtifactKind::PscRot, + KnownArtifactKind::SwitchRot, + ] { + assert!(expected_installable_kinds.remove(&remove.into())); + } + for add in [ + ArtifactKind::HOST_PHASE_1, + ArtifactKind::HOST_PHASE_2, + ArtifactKind::TRAMPOLINE_PHASE_1, + ArtifactKind::TRAMPOLINE_PHASE_2, + ArtifactKind::GIMLET_ROT_IMAGE_A, + ArtifactKind::GIMLET_ROT_IMAGE_B, + ArtifactKind::PSC_ROT_IMAGE_A, + ArtifactKind::PSC_ROT_IMAGE_B, + ArtifactKind::SWITCH_ROT_IMAGE_A, + ArtifactKind::SWITCH_ROT_IMAGE_B, + ] { + assert!(expected_installable_kinds.insert(add)); + } + // Ensure that this is a sensible result. - let kinds = response - .artifacts - .iter() - .map(|artifact| { - artifact.kind.parse::().unwrap_or_else(|error| { - panic!("unrecognized artifact kind {}: {error}", artifact.kind) - }) - }) - .collect(); - let expected_kinds: BTreeSet<_> = KnownArtifactKind::iter().collect(); + let mut kinds = BTreeSet::new(); + let mut installable_kinds = BTreeSet::new(); + for artifact in response.artifacts { + kinds.insert(artifact.artifact_id.kind.parse().unwrap()); + for installable in artifact.installable { + installable_kinds.insert(installable.kind.parse().unwrap()); + } + } assert_eq!(expected_kinds, kinds, "all expected kinds present"); + assert_eq!( + expected_installable_kinds, installable_kinds, + "all expected installable kinds present" + ); let target_sp = SpIdentifier { type_: SpType::Sled, slot: 0 }; @@ -193,8 +227,7 @@ async fn test_installinator_fetch() { wicketd_testctx .server .artifact_store - .get_by_hash(&host_phase_2_id) - .is_some(), + .contains_by_hash(&host_phase_2_id), "host phase 2 ID found by hash" ); @@ -206,8 +239,7 @@ async fn test_installinator_fetch() { wicketd_testctx .server .artifact_store - .get_by_hash(&control_plane_id) - .is_some(), + .contains_by_hash(&control_plane_id), "control plane ID found by hash" );