From 951651a735bf4b31da445b01245324af8ecf20dd Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 12 Sep 2023 11:07:26 -0400 Subject: [PATCH] [wicketd] Store TUF artifacts on disk instead of in memory (#3953) Prior to this PR, `wicketd` could end up with quite a large heap because we were keeping several things in memory: 1. When a TUF repo was uploaded from `wicket`, we streamed it into memory 2. After extracting the TUF repo into a temporary directory, we neglected to free it until _after_ we'd ingested all its contents (pushing our high-water mark `$TUF_REPO_SIZE` higher than it needed to be) 3. We then read every extracted artifact from the TUF repo back into memory 4. We then further extract the contents of all RoT (tiny) and host OS (not at all tiny) artifacts to get at their inner artifacts (A/B images for the RoT; phase1 / phase2 blobs for OS images) We then kept all the in-memory artifacts from 3 and 4 around indefinitely. If a new TUF repo is uploaded, we would repeat the above 1-4, and only free the item 3 and 4 artifacts from the first repo _after_ successfully finishing all four steps on the second repo. This leads to some large heaps: 1. After uploading one TUF repo: ``` root@oxz_switch:/opt/oxide# pgrep wicketd | xargs pmap -S | grep heap 0000000002979000 3390440 3390440 rw--- [ heap ] 00000000D1873000 2392080 2392080 rw--- [ heap ] ``` 2. After uploading a second TUF repo: ``` root@oxz_switch:/opt/oxide# pgrep wicketd | xargs pmap -S | grep heap 0000000002979000 3390440 3390440 rw--- [ heap ] 00000000D1873000 3424296 3424296 rw--- [ heap ] 00000001A287D000 2392080 2392080 rw--- [ heap ] ``` Actually performing updates does not grow these sizes. --- This change makes some significant cuts on the above by shifting several things out of memory and into files on disk (currently all stored in tempdirs under `/tmp`, so still RAM, but we could move them to a physical disk if desired): 1. When receiving a TUF repo from wicket, we now stream it directly to a temporary file instead of into memory, and we extract from that temp file to a temp directory. 2. Instead of reading all the TUF contents into memory, we now copy them into a temporary directory (where "temporary" here is perhaps misleading: we keep this directory around until a new TUF repo is uploaded); we then read them (in a streaming fashion: not entirely into memory) from this directory on-demand while updates are performed Heap usage as of this PR after uploading one TUF repo: ``` BRM44220001 # pgrep wicketd | xargs pmap -S | grep heap 00000000029D7000 36216 36216 rw--- [ heap ] ``` After uploading a second TUF repo: ``` BRM44220001 # pgrep wicketd | xargs pmap -S | grep heap 00000000029D7000 48708 48708 rw--- [ heap ] ``` Neither uploading additional repos nor running updates grows this size. This change has some tradeoffs: 1. Ingesting an uploaded TUF repo is slower, but tolerably so (~10 seconds on main, ~13 seconds on this PR) 2. We now have more potential I/O errors, including possibly failing to open files that we expect to be in our temporary directory when they're needed during an update 3. We're using a nontrivial amount of space under `/tmp` (high water mark is during the ingest of a second TUF repo, where we would have all the extracted artifacts of both repos in separate tempdirs just before we remove one of them) but these are probably worth it in light of RFD 413 / #3943. Expanding on item 3 on `/tmp` usage slightly, in case it ends up being valuable: 1. When `wicketd` first starts, it is not using `/tmp`. 2. As a repo is uploaded, it is streamed into a file under `/tmp` (space used: roughly 1.2 GiB currently) 3. Once the upload is complete, the repo is unzipped into a temporary directory under `/tmp` (space used: the size of the repo .zip file plus the size of the unpacked repo; roughly 1.2 GiB + 1.2 GiB = 2.4 GiB currently) 4. The temp file from step 2 is deleted (space used: back down to the size of the unpacked repo; roughly 1.2 GiB) 5. wicketd creates a new temporary directory with a somewhat meaningful name (`wicketd-update-artifacts.ZZZZZZ`), then iterates through the artifacts in the repo, copying some and further extracting others; e.g., host and trampoline artifacts are expanded into their phase1 and phase2 components (space used: the size of the unpacked repo plus the size of the unpacked artifacts; roughly 1.2 GiB + 1.4 GiB = 2.6 GiB currently) 6. The unpacked repo temp directory from step 3 is deleted (space used: back down to the size of the unpacked artifacts: roughly 1.4 GiB currently) If a _second_ repo is then uploaded, we go through all of the above steps again, but we still have a `wicketd-update-artifacts.ZZZZZZ` from the first repo that is not removed until we get to the end of step 6, making our high water step 5 of a second (or later) repo upload, where we're using a total of (current values in parens): ``` size of extracted artifacts from first repo (1.4 GiB) + size of expanded second repo (1.2 GiB) + size of extracted artifacts from second repo (1.4 GiB) = maximal space used under /tmp (4.0 GiB) ``` --- Fixes #3943. --- Cargo.lock | 2 +- common/src/update.rs | 39 + gateway/src/lib.rs | 6 +- .../src/http_entrypoints.rs | 24 +- installinator-artifactd/src/store.rs | 13 +- installinator/src/write.rs | 6 +- openapi/installinator-artifactd.json | 55 - openapi/wicketd.json | 40 +- tufaceous-lib/src/artifact.rs | 108 +- tufaceous-lib/src/assemble/manifest.rs | 146 +- wicket/src/state/inventory.rs | 21 +- wicket/src/wicketd.rs | 6 +- wicketd/Cargo.toml | 2 +- wicketd/src/artifacts.rs | 1218 +---------------- wicketd/src/artifacts/artifacts_with_plan.rs | 303 ++++ wicketd/src/artifacts/error.rs | 157 +++ wicketd/src/artifacts/extracted_artifacts.rs | 254 ++++ wicketd/src/artifacts/server.rs | 63 + wicketd/src/artifacts/store.rs | 110 ++ wicketd/src/artifacts/update_plan.rs | 1063 ++++++++++++++ wicketd/src/http_entrypoints.rs | 65 +- wicketd/src/update_tracker.rs | 78 +- wicketd/tests/integration_tests/updates.rs | 60 +- 23 files changed, 2402 insertions(+), 1437 deletions(-) create mode 100644 wicketd/src/artifacts/artifacts_with_plan.rs create mode 100644 wicketd/src/artifacts/error.rs create mode 100644 wicketd/src/artifacts/extracted_artifacts.rs create mode 100644 wicketd/src/artifacts/server.rs create mode 100644 wicketd/src/artifacts/store.rs create mode 100644 wicketd/src/artifacts/update_plan.rs 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" );