diff --git a/artifact/src/installinator.rs b/artifact/src/installinator.rs new file mode 100644 index 0000000..2f49d95 --- /dev/null +++ b/artifact/src/installinator.rs @@ -0,0 +1,65 @@ +// 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 semver::Version; +use serde::{Deserialize, Serialize}; + +use crate::ArtifactHash; + +/// Artifact-specific information used by installinator. +/// +/// This document contains information used by installinator to learn about +/// which artifacts to fetch. Unlike +/// [`ArtifactsDocument`](crate::ArtifactsDocument): +/// +/// * This document is treated as an opaque blob by Wicketd and Nexus, since +/// we'd like previous versions of those services to be able to process newer +/// versions of this document. +/// * There are no backwards compatibility constraints for this document. The +/// version of installinator that processes this document is the same as the +/// version of tufaceous that creates it. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InstallinatorDocument { + pub system_version: Version, + pub artifacts: Vec, +} + +impl InstallinatorDocument { + /// Creates an installinator document with the provided system version and + /// an empty list of artifacts. + pub fn empty(system_version: Version) -> Self { + Self { system_version, artifacts: Vec::new() } + } + + pub fn file_name(&self) -> String { + format!("installinator_document-{}.json", self.system_version) + } +} + +/// Describes an artifact available to installinator. +/// +/// The fields here match [`Artifact`](crate::Artifact). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InstallinatorArtifact { + pub name: String, + /// The kind of artifact. + /// + /// This is an [`InstallinatorArtifactKind`] rather than an + /// [`ArtifactKind`](crate::ArtifactKind) because there aren't any backwards + /// compatibility constraints with `InstallinatorArtifact`. + pub kind: InstallinatorArtifactKind, + pub hash: ArtifactHash, +} + +/// The artifact kind for an installinator artifact. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InstallinatorArtifactKind { + /// The host phase 2 artifact. + /// + /// This is extracted from the composite host artifact. + HostPhase2, + /// The composite control plane artifact. + ControlPlane, +} diff --git a/artifact/src/kind.rs b/artifact/src/kind.rs index 1f750e8..d6eb8d4 100644 --- a/artifact/src/kind.rs +++ b/artifact/src/kind.rs @@ -182,6 +182,13 @@ pub enum KnownArtifactKind { GimletRotBootloader, Host, Trampoline, + /// Installinator document identifier. + /// + /// While the installinator document is a metadata file similar to + /// [`ArtifactsDocument`](crate::ArtifactsDocument), Wicketd and Nexus treat + /// it as an opaque single-unit artifact to avoid backwards compatibility + /// issues. + InstallinatorDocument, /// Composite artifact of all control plane zones ControlPlane, /// Individual control plane zone @@ -220,6 +227,7 @@ impl KnownArtifactKind { | KnownArtifactKind::GimletRotBootloader | KnownArtifactKind::Host | KnownArtifactKind::Trampoline + | KnownArtifactKind::InstallinatorDocument | KnownArtifactKind::ControlPlane | KnownArtifactKind::Zone | KnownArtifactKind::PscSp diff --git a/artifact/src/lib.rs b/artifact/src/lib.rs index 7414fcc..78e7351 100644 --- a/artifact/src/lib.rs +++ b/artifact/src/lib.rs @@ -3,9 +3,11 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. mod artifact; +mod installinator; mod kind; mod version; pub use artifact::*; +pub use installinator::*; pub use kind::*; pub use version::*; diff --git a/bin/src/dispatch.rs b/bin/src/dispatch.rs index e4c5dd0..a0ba460 100644 --- a/bin/src/dispatch.rs +++ b/bin/src/dispatch.rs @@ -7,7 +7,9 @@ use camino::Utf8PathBuf; use chrono::{DateTime, Utc}; use clap::{CommandFactory, Parser}; use semver::Version; -use tufaceous_artifact::{ArtifactKind, ArtifactVersion, ArtifactsDocument}; +use tufaceous_artifact::{ + ArtifactKind, ArtifactVersion, ArtifactsDocument, KnownArtifactKind, +}; use tufaceous_lib::assemble::{ArtifactManifest, OmicronRepoAssembler}; use tufaceous_lib::{AddArtifact, ArchiveExtractor, Key, OmicronRepo}; @@ -42,6 +44,11 @@ impl Args { }; match self.command { + // TODO-cleanup: we no longer use the init and add commands in + // production. We should get rid of these options and direct users + // towards assemble. (If necessary, we should build tooling for + // making it easy to build up a manifest that can then be + // assembled.) Command::Init { system_version, no_generate_key } => { let keys = maybe_generate_keys(self.keys, no_generate_key)?; let root = @@ -55,6 +62,7 @@ impl Args { keys, root, self.expiry, + true, ) .await?; slog::info!( @@ -121,7 +129,7 @@ impl Args { editor .add_artifact(&new_artifact) .context("error adding artifact")?; - editor.sign_and_finish(self.keys, self.expiry).await?; + editor.sign_and_finish(self.keys, self.expiry, true).await?; println!( "added {} {}, version {}", new_artifact.kind(), @@ -144,7 +152,11 @@ impl Args { Ok(()) } - Command::Extract { archive_file, dest } => { + Command::Extract { + archive_file, + dest, + no_installinator_document, + } => { let mut extractor = ArchiveExtractor::from_path(&archive_file)?; extractor.extract(&dest)?; @@ -157,13 +169,43 @@ impl Args { (extracted files are still available)" ) })?; - repo.read_artifacts().await.with_context(|| { - format!( - "error loading {} from extracted archive \ - at `{dest}`", - ArtifactsDocument::FILE_NAME + let artifacts = + repo.read_artifacts().await.with_context(|| { + format!( + "error loading {} from extracted archive \ + at `{dest}`", + ArtifactsDocument::FILE_NAME + ) + })?; + if !no_installinator_document { + // There should be a reference to an installinator document + // within artifacts_document. + let installinator_doc_artifact = artifacts + .artifacts + .iter() + .find(|artifact| { + artifact.kind.to_known() + == Some( + KnownArtifactKind::InstallinatorDocument, + ) + }) + .context( + "could not find artifact with kind \ + `installinator_document` within artifacts.json", + )?; + + repo.read_installinator_document( + &installinator_doc_artifact.target, ) - })?; + .await + .with_context(|| { + format!( + "error loading {} from extracted archive \ + at `{dest}`", + installinator_doc_artifact.target, + ) + })?; + } Ok(()) } @@ -174,6 +216,7 @@ impl Args { no_generate_key, skip_all_present, allow_non_semver, + no_installinator_document, } => { // The filename must end with "zip". if output_path.extension() != Some("zip") { @@ -195,6 +238,7 @@ impl Args { manifest, keys, self.expiry, + !no_installinator_document, output_path, ); if let Some(dir) = build_dir { @@ -260,6 +304,10 @@ enum Command { /// The destination to extract the file to. dest: Utf8PathBuf, + + /// Indicate that the file does not contain an installinator document. + #[clap(long)] + no_installinator_document: bool, }, /// Assembles a repository from a provided manifest. Assemble { @@ -287,6 +335,12 @@ enum Command { /// allowed to be non-semver by default. #[clap(long)] allow_non_semver: bool, + + /// Do not include the installinator document. + /// + /// Transitional option for v15 -> v16, meant to be used for testing. + #[clap(long)] + no_installinator_document: bool, }, } diff --git a/bin/tests/integration-tests/command_tests.rs b/bin/tests/integration-tests/command_tests.rs index b7e4ca8..de63ecf 100644 --- a/bin/tests/integration-tests/command_tests.rs +++ b/bin/tests/integration-tests/command_tests.rs @@ -81,11 +81,25 @@ async fn test_init_and_add() -> Result<()> { let artifacts = repo.read_artifacts().await?; assert_eq!( artifacts.artifacts.len(), - 3, - "repo should contain exactly 3 artifacts: {artifacts:?}" + // 3 artifacts added above + installinator_document.json. + 4, + "repo should contain exactly 4 artifacts: {artifacts:?}" ); let mut artifacts_iter = artifacts.artifacts.into_iter(); + let artifact = artifacts_iter.next().unwrap(); + assert_eq!(artifact.name, "installinator_document", "artifact name"); + assert_eq!(artifact.version, "0.0.0".parse().unwrap(), "artifact version"); + assert_eq!( + artifact.kind, + ArtifactKind::from_known(KnownArtifactKind::InstallinatorDocument), + "artifact kind" + ); + assert_eq!( + artifact.target, "installinator_document-0.0.0.json", + "artifact target" + ); + let artifact = artifacts_iter.next().unwrap(); assert_eq!(artifact.name, "nexus", "artifact name"); assert_eq!(artifact.version, "42.0.0".parse().unwrap(), "artifact version"); diff --git a/lib/src/artifact.rs b/lib/src/artifact.rs index 5ec0b4d..25bbe50 100644 --- a/lib/src/artifact.rs +++ b/lib/src/artifact.rs @@ -8,9 +8,14 @@ use std::path::Path; use anyhow::{Context, Result, bail}; use buf_list::BufList; use bytes::Bytes; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use fs_err::File; -use tufaceous_artifact::{ArtifactKind, ArtifactVersion}; +use sha2::{Digest, Sha256}; +use tough::editor::RepositoryEditor; +use tufaceous_artifact::{ + ArtifactHash, ArtifactKind, ArtifactVersion, InstallinatorArtifact, + InstallinatorArtifactKind, KnownArtifactKind, +}; use tufaceous_brand_metadata::Metadata; mod composite; @@ -22,6 +27,7 @@ pub use composite::CompositeRotArchiveBuilder; pub use composite::MtimeSource; use crate::assemble::ArtifactDeploymentUnits; +use crate::target::{TargetFinishWrite, TargetWriter}; /// The location a artifact will be obtained from. #[derive(Clone, Debug)] @@ -73,9 +79,6 @@ impl AddArtifact { .to_owned(), }; - // TODO: In the future, it would be nice to extract the deployment units - // from the file. But that would require parsing the file, and the code - // for that lives in Omicron under update-common. Ok(Self { kind, name, @@ -110,8 +113,33 @@ impl AddArtifact { &self.deployment_units } - /// Writes this artifact to the specified writer. - pub(crate) fn write_to(&self, writer: &mut W) -> Result<()> { + pub(crate) fn target_name(&self) -> String { + format!("{}-{}-{}.tar.gz", self.kind, self.name, self.version) + } + + /// Writes this artifact as a temporary file, returning a + /// [`TempWrittenArtifact`]. + pub(crate) fn write_temp( + &self, + targets_dir: &Utf8Path, + ) -> Result { + let target_name = self.target_name(); + let mut file = TargetWriter::new(targets_dir, &target_name)?; + self.write_to(&mut file).with_context(|| { + format!("error writing artifact `{target_name}") + })?; + let finished_file = file.finish_write(); + Ok(TempWrittenArtifact { + kind: self.kind.clone(), + name: self.name.clone(), + version: self.version.clone(), + deployment_units: self.deployment_units.clone(), + finished_file, + }) + } + + /// Writes this artifact to the specifid writer. + fn write_to(&self, writer: &mut W) -> Result<()> { match &self.source { ArtifactSource::File(path) => { let mut reader = File::open(path)?; @@ -128,8 +156,101 @@ impl AddArtifact { } } +/// A newly-added artifact that's been written out to a temporary file. +#[must_use = "the artifact is still temporary and must be finalized"] +pub(crate) struct TempWrittenArtifact { + kind: ArtifactKind, + name: String, + version: ArtifactVersion, + deployment_units: ArtifactDeploymentUnits, + finished_file: TargetFinishWrite, +} + +impl TempWrittenArtifact { + pub(crate) fn name(&self) -> &str { + &self.name + } + + pub(crate) fn version(&self) -> &ArtifactVersion { + &self.version + } + + pub(crate) fn kind(&self) -> &ArtifactKind { + &self.kind + } + + pub(crate) fn digest(&self) -> ArtifactHash { + self.finished_file.digest() + } + + pub(crate) fn deployment_units(&self) -> &ArtifactDeploymentUnits { + &self.deployment_units + } + + /// Returns information about installinator artifacts for this newly-added + /// artifact. + pub(crate) fn installinator_artifacts( + &self, + ) -> impl Iterator + '_ { + let known = self.kind.to_known(); + + // Currently, a `TempWrittenArtifact` corresponds to zero or one + // installinator artifacts so we can just return an Option. If, in the + // future, a single `TempWrittenArtifact` corresponds to multiple + // installinator artifacts, we'd have to return a more complex iterator. + let artifact = match known { + Some(KnownArtifactKind::Host) => { + // The host phase 2 artifact is an installinator artifact. + let host_phase_2 = match &self.deployment_units { + ArtifactDeploymentUnits::SingleUnit + | ArtifactDeploymentUnits::Unknown => { + panic!( + "expected Host artifact to be Composite, found {:?}", + self.deployment_units + ); + } + ArtifactDeploymentUnits::Composite { deployment_units } => { + deployment_units + .values() + .find(|unit| { + unit.kind == ArtifactKind::HOST_PHASE_2 + }) + .unwrap_or_else(|| { + panic!( + "Host artifact must have a host phase 2 \ + deployment unit, found {:?}", + deployment_units, + ) + }) + } + }; + Some(InstallinatorArtifact { + name: host_phase_2.name.clone(), + kind: InstallinatorArtifactKind::HostPhase2, + hash: host_phase_2.hash, + }) + } + Some(KnownArtifactKind::ControlPlane) => { + Some(InstallinatorArtifact { + name: self.name.clone(), + kind: InstallinatorArtifactKind::ControlPlane, + hash: self.digest(), + }) + } + Some(_) | None => None, + }; + artifact.into_iter() + } + + pub(crate) fn finalize( + self, + editor: &mut RepositoryEditor, + ) -> Result { + self.finished_file.finalize(editor) + } +} + pub(crate) fn make_filler_text( - // This can be either the artifact kind, or the deployment unit kind for a // composite artifact. kind: &str, version: &ArtifactVersion, @@ -247,6 +368,16 @@ impl HostPhaseImages { Ok(()) } + + pub fn phase_1_hash(&self) -> ArtifactHash { + let hash = Sha256::digest(&self.phase_1); + ArtifactHash(hash.into()) + } + + pub fn phase_2_hash(&self) -> ArtifactHash { + let hash = Sha256::digest(&self.phase_2); + ArtifactHash(hash.into()) + } } fn read_entry( diff --git a/lib/src/assemble/build.rs b/lib/src/assemble/build.rs index 2f0b38b..6270c01 100644 --- a/lib/src/assemble/build.rs +++ b/lib/src/assemble/build.rs @@ -21,6 +21,7 @@ pub struct OmicronRepoAssembler { keys: Vec, root: Option>, expiry: DateTime, + include_installinator_doc: bool, output_path: Utf8PathBuf, } @@ -30,6 +31,7 @@ impl OmicronRepoAssembler { manifest: ArtifactManifest, keys: Vec, expiry: DateTime, + include_installinator_doc: bool, output_path: Utf8PathBuf, ) -> Self { Self { @@ -39,6 +41,7 @@ impl OmicronRepoAssembler { keys, root: None, expiry, + include_installinator_doc, output_path, } } @@ -114,6 +117,7 @@ impl OmicronRepoAssembler { self.keys.clone(), root, self.expiry, + self.include_installinator_doc, ) .await? .into_editor() @@ -146,7 +150,13 @@ impl OmicronRepoAssembler { } // Write out the repository. - repository.sign_and_finish(self.keys.clone(), self.expiry).await?; + repository + .sign_and_finish( + self.keys.clone(), + self.expiry, + self.include_installinator_doc, + ) + .await?; // Now reopen the repository to archive it into a zip file. let repo2 = OmicronRepo::load_untrusted(&self.log, build_dir) diff --git a/lib/src/assemble/manifest.rs b/lib/src/assemble/manifest.rs index eb21609..7b5471b 100644 --- a/lib/src/assemble/manifest.rs +++ b/lib/src/assemble/manifest.rs @@ -3,8 +3,9 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; +use std::io::BufReader; use std::str::FromStr; +use std::{fmt, fs}; use anyhow::{Context, Result, bail, ensure}; use camino::{Utf8Path, Utf8PathBuf}; @@ -18,8 +19,9 @@ use crate::assemble::{DeploymentUnitData, DeploymentUnitScope}; use crate::{ ArtifactSource, CompositeControlPlaneArchiveBuilder, CompositeEntry, CompositeHostArchiveBuilder, CompositeRotArchiveBuilder, - HOST_PHASE_1_FILE_NAME, HOST_PHASE_2_FILE_NAME, MtimeSource, - ROT_ARCHIVE_A_FILE_NAME, ROT_ARCHIVE_B_FILE_NAME, make_filler_text, + HOST_PHASE_1_FILE_NAME, HOST_PHASE_2_FILE_NAME, HostPhaseImages, + MtimeSource, ROT_ARCHIVE_A_FILE_NAME, ROT_ARCHIVE_B_FILE_NAME, + make_filler_text, }; use super::{ArtifactDeploymentUnits, DeploymentUnitMapBuilder}; @@ -95,10 +97,57 @@ impl ArtifactManifest { .into_iter() .map(|artifact_data| { let (source, deployment_units) = match artifact_data.source { - DeserializedArtifactSource::File { path } => ( - ArtifactSource::File(base_dir.join(path)), - ArtifactDeploymentUnits::SingleUnit, - ), + DeserializedArtifactSource::File { path } => { + let path = base_dir.join(&path); + + // Host images are actually composite artifacts, and we + // need to treat them that way for installinator. + let deployment_units = + if kind == KnownArtifactKind::Host { + let file = + fs::File::open(&path).with_context(|| { + format!( + "error opening host image at `{path}`" + ) + })?; + let reader = BufReader::new(file); + let images = HostPhaseImages::extract(reader)?; + + let mut data_builder = + DeploymentUnitMapBuilder::new( + DeploymentUnitScope::Artifact { + composite_kind: kind, + }, + ); + data_builder + .insert(DeploymentUnitData { + name: HOST_PHASE_1_FILE_NAME.to_owned(), + version: artifact_data.version.clone(), + kind: ArtifactKind::HOST_PHASE_1, + hash: images.phase_1_hash(), + }) + .expect("unique kind"); + data_builder + .insert(DeploymentUnitData { + name: HOST_PHASE_2_FILE_NAME.to_owned(), + version: artifact_data.version.clone(), + kind: ArtifactKind::HOST_PHASE_2, + hash: images.phase_2_hash(), + }) + .expect("unique kind"); + + data_builder.finish_units() + } else { + // It would be nice to extract other kinds of + // composite artifacts here, but (a) we don't + // have a need for that in this case and (b) the + // code for that currently lives in omicron's + // update-common. + ArtifactDeploymentUnits::SingleUnit + }; + + (ArtifactSource::File(path), deployment_units) + } DeserializedArtifactSource::Fake { size, data_version } => { // This test-only environment variable is used to // simulate two artifacts with different @@ -338,7 +387,15 @@ impl ArtifactManifest { /// details if any artifacts are missing. pub fn verify_all_present(&self) -> Result<()> { let all_artifacts: BTreeSet<_> = KnownArtifactKind::iter() - .filter(|k| !matches!(k, KnownArtifactKind::Zone)) + .filter(|k| { + // Installinator documents are generated by tufaceous and should + // not be part of the manifest. + !matches!( + k, + KnownArtifactKind::Zone + | KnownArtifactKind::InstallinatorDocument + ) + }) .collect(); let present_artifacts: BTreeSet<_> = self.artifacts.keys().copied().collect(); @@ -384,6 +441,11 @@ impl<'a> FakeDataAttributes<'a> { size, ); } + KnownArtifactKind::InstallinatorDocument => { + panic!( + "fake manifest should not have an installinator document" + ); + } // hubris artifacts: build a fake archive (SimGimletSp and // SimGimletRot are used by sp-sim) diff --git a/lib/src/repository.rs b/lib/src/repository.rs index 5c28fdb..14f66cc 100644 --- a/lib/src/repository.rs +++ b/lib/src/repository.rs @@ -19,6 +19,7 @@ use tough::schema::{Root, Target}; use tough::{ExpirationEnforcement, Repository, RepositoryLoader, TargetName}; use tufaceous_artifact::{ Artifact, ArtifactHash, ArtifactVersion, ArtifactsDocument, + InstallinatorDocument, KnownArtifactKind, }; use url::Url; @@ -48,6 +49,10 @@ impl OmicronRepo { keys: Vec, root: SignedRole, expiry: DateTime, + // TODO-cleanup: This is a transitional option for v15 -> v16, meant to be used + // for testing. After v16 we can assume that all valid TUF repositories have + // installinator documents. + include_installinator_doc: bool, ) -> Result { let editor = OmicronRepoEditor::initialize( repo_path.to_owned(), @@ -57,7 +62,7 @@ impl OmicronRepo { .await?; editor - .sign_and_finish(keys, expiry) + .sign_and_finish(keys, expiry, include_installinator_doc) .await .context("error signing new repository")?; @@ -199,20 +204,33 @@ impl OmicronRepo { /// Reads the artifacts document from the repo. pub async fn read_artifacts(&self) -> Result { + self.read_json(ArtifactsDocument::FILE_NAME).await + } + + /// Reads the installinator document from the repo. + pub async fn read_installinator_document( + &self, + file_name: &str, + ) -> Result { + self.read_json(file_name).await + } + + /// Reads a JSON document from the repo by target name. + async fn read_json(&self, file_name: &str) -> Result + where + T: serde::de::DeserializeOwned, + { let reader = self .repo - .read_target(&ArtifactsDocument::FILE_NAME.try_into()?) + .read_target(&file_name.try_into()?) .await? - .ok_or_else(|| { - anyhow!("{} should be present", ArtifactsDocument::FILE_NAME) - })?; - let buf_list = - reader.try_collect::().await.with_context(|| { - format!("error reading from {}", ArtifactsDocument::FILE_NAME) - })?; - serde_json::from_reader(buf_list::Cursor::new(&buf_list)).with_context( - || format!("error deserializing {}", ArtifactsDocument::FILE_NAME), - ) + .ok_or_else(|| anyhow!("{} should be present", file_name))?; + let buf_list = reader + .try_collect::() + .await + .with_context(|| format!("error reading from {}", file_name))?; + serde_json::from_reader(buf_list::Cursor::new(&buf_list)) + .with_context(|| format!("error deserializing {}", file_name)) } /// Archives the repository to the given path as a zip file. @@ -301,6 +319,7 @@ pub struct OmicronRepoEditor { editor: RepositoryEditor, repo_path: Utf8PathBuf, artifacts: ArtifactsDocument, + installinator_document: InstallinatorDocument, // Set of `TargetName::resolved()` names for every target that existed when // the repo was opened. We use this to ensure we don't overwrite an existing @@ -314,6 +333,31 @@ pub struct OmicronRepoEditor { impl OmicronRepoEditor { async fn new(repo: OmicronRepo) -> Result { let artifacts = repo.read_artifacts().await?; + + // There should be a reference to an installinator document within + // artifacts_document. + let installinator_document = + match artifacts.artifacts.iter().find(|artifact| { + artifact.kind.to_known() + == Some(KnownArtifactKind::InstallinatorDocument) + }) { + Some(artifact) => { + repo.read_installinator_document(&artifact.target).await? + } + None => { + // With empty repos and those without a preexisting + // installinator document, we generate an empty one. + // + // This isn't quite correct for incrementally updated TUF + // repos created via `tufaceous init` and `tufaceous add`, + // but our production users don't use that functionality and + // those should be removed in the future. + InstallinatorDocument::empty( + artifacts.system_version.clone(), + ) + } + }; + let artifacts_by_target_name = artifacts .artifacts .iter() @@ -393,6 +437,7 @@ impl OmicronRepoEditor { editor, repo_path: repo.repo_path, artifacts, + installinator_document, existing_target_names, existing_deployment_units: DeploymentUnitMapBuilder::new( DeploymentUnitScope::Repository, @@ -419,7 +464,10 @@ impl OmicronRepoEditor { Ok(Self { editor, repo_path, - artifacts: ArtifactsDocument::empty(system_version), + artifacts: ArtifactsDocument::empty(system_version.clone()), + installinator_document: InstallinatorDocument::empty( + system_version, + ), existing_target_names: BTreeSet::new(), existing_deployment_units: DeploymentUnitMapBuilder::new( DeploymentUnitScope::Repository, @@ -453,12 +501,7 @@ impl OmicronRepoEditor { // Start writing the target out to a temporary path, catching errors // that might happen. let targets_dir = self.repo_path.join("targets"); - - let mut file = TargetWriter::new(&targets_dir, target_name.clone())?; - new_artifact.write_to(&mut file).with_context(|| { - format!("error writing artifact `{target_name}") - })?; - let finished_file = file.finish_write(); + let new_artifact = new_artifact.write_temp(&targets_dir)?; // Make sure we're not adding a new deployment unit with the same // kind/hash as an existing one. @@ -473,7 +516,7 @@ impl OmicronRepoEditor { name: new_artifact.name().to_owned(), version: new_artifact.version().clone(), kind: new_artifact.kind().clone(), - hash: finished_file.digest(), + hash: new_artifact.digest(), }, ) } @@ -517,9 +560,13 @@ impl OmicronRepoEditor { kind: new_artifact.kind().clone(), target: target_name, }); + self.installinator_document + .artifacts + .extend(new_artifact.installinator_artifacts()); + // The host phase 2 image is part of the host image. new_units.expect("new_units is None => errors handled above").commit(); - finished_file.finalize(&mut self.editor) + new_artifact.finalize(&mut self.editor) } /// Consumes self, signing the repository and writing out this repository to disk. @@ -527,13 +574,54 @@ impl OmicronRepoEditor { mut self, keys: Vec, expiry: DateTime, + include_installinator_doc: bool, ) -> Result<()> { let targets_dir = self.repo_path.join("targets"); - let mut file = + if include_installinator_doc { + let mut installinator_doc_writer = TargetWriter::new( + &targets_dir, + self.installinator_document.file_name(), + )?; + serde_json::to_writer_pretty( + &mut installinator_doc_writer, + &self.installinator_document, + )?; + installinator_doc_writer + .finish_write() + .finalize(&mut self.editor)?; + + // Add the installinator document in the artifacts.json if it's missing. + // + // TODO: our production users don't add artifacts incrementally to a TUF + // repo -- rather, they generate a manifest and create the TUF repo in a + // one-shot fashion. We should clean up any code we have to handle + // incremental updates of TUF repos, and remove this scan as part of + // that. + let system_version = self.artifacts.system_version.clone(); + let artifact_version = + ArtifactVersion::new(system_version.to_string()) + .expect("system versions are usable as artifact versions"); + if !self.artifacts.artifacts.iter().any(|artifact| { + artifact.kind.to_known() + == Some(KnownArtifactKind::InstallinatorDocument) + }) { + self.artifacts.artifacts.push(Artifact { + name: KnownArtifactKind::InstallinatorDocument.to_string(), + version: artifact_version, + kind: KnownArtifactKind::InstallinatorDocument.into(), + target: self.installinator_document.file_name(), + }); + } + } + + let mut artifacts_doc_writer = TargetWriter::new(&targets_dir, ArtifactsDocument::FILE_NAME)?; - serde_json::to_writer_pretty(&mut file, &self.artifacts)?; - file.finish_write().finalize(&mut self.editor)?; + serde_json::to_writer_pretty( + &mut artifacts_doc_writer, + &self.artifacts, + )?; + artifacts_doc_writer.finish_write().finalize(&mut self.editor)?; update_versions(&mut self.editor, expiry)?; @@ -612,6 +700,7 @@ mod tests { ArtifactManifest::new_fake(), vec![trusted_key], expiry, + true, archive_path.clone(), ); assembler.set_root_role(trusted_root.clone()); @@ -691,6 +780,7 @@ mod tests { keys, root, expiry, + true, ) .await .unwrap()