From 04055b92b2922f3b5618c43517134f6dc4e89e4f Mon Sep 17 00:00:00 2001 From: Jarrett Tierney Date: Tue, 18 Jun 2024 13:40:43 -0700 Subject: [PATCH] twoliter: add support for crane --- Cargo.lock | 35 +++++ Cargo.toml | 1 + tools/oci-cli-wrapper/Cargo.toml | 17 +++ tools/oci-cli-wrapper/src/cli.rs | 51 ++++++++ tools/oci-cli-wrapper/src/crane.rs | 46 +++++++ tools/oci-cli-wrapper/src/docker.rs | 71 ++++++++++ tools/oci-cli-wrapper/src/lib.rs | 126 ++++++++++++++++++ twoliter/Cargo.toml | 3 + twoliter/src/lock.rs | 196 +++++++++++----------------- 9 files changed, 425 insertions(+), 121 deletions(-) create mode 100644 tools/oci-cli-wrapper/Cargo.toml create mode 100644 tools/oci-cli-wrapper/src/cli.rs create mode 100644 tools/oci-cli-wrapper/src/crane.rs create mode 100644 tools/oci-cli-wrapper/src/docker.rs create mode 100644 tools/oci-cli-wrapper/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b25558350..7855a53bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2147,6 +2147,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "oci-cli-wrapper" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "snafu 0.8.2", + "tar", + "tempfile", + "tokio", + "which", +] + [[package]] name = "olpc-cjson" version = "0.1.3" @@ -3792,6 +3806,7 @@ version = "0.4.1" dependencies = [ "anyhow", "async-recursion", + "async-trait", "async-walkdir", "base64 0.22.0", "buildsys", @@ -3805,6 +3820,7 @@ dependencies = [ "hex", "log", "non-empty-string", + "oci-cli-wrapper", "olpc-cjson", "pubsys", "pubsys-setup", @@ -3819,6 +3835,7 @@ dependencies = [ "toml", "tuftool", "uuid", + "which", ] [[package]] @@ -4070,6 +4087,18 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "which" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8211e4f58a2b2805adfbefbc07bab82958fc91e3836339b1ab7ae32465dce0d7" +dependencies = [ + "either", + "home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4261,6 +4290,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "xattr" version = "1.3.1" diff --git a/Cargo.toml b/Cargo.toml index e8d2a18bd..2cc14e0c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "tools/bottlerocket-variant", "tools/buildsys", "tools/buildsys-config", + "tools/oci-cli-wrapper", "tools/parse-datetime", "tools/pubsys", "tools/pubsys-config", diff --git a/tools/oci-cli-wrapper/Cargo.toml b/tools/oci-cli-wrapper/Cargo.toml new file mode 100644 index 000000000..d06ea3fef --- /dev/null +++ b/tools/oci-cli-wrapper/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "oci-cli-wrapper" +version = "0.1.0" +authors = ["Jarrett Tierney "] +license = "Apache-2.0 OR MIT" +edition = "2021" +publish = false + +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"]} +serde_json = "1" +snafu = "0.8" +tar = "0.4" +tempfile = "3" +tokio = { version = "1.32", features = ["process"] } +which = "6" diff --git a/tools/oci-cli-wrapper/src/cli.rs b/tools/oci-cli-wrapper/src/cli.rs new file mode 100644 index 000000000..b75f0cad6 --- /dev/null +++ b/tools/oci-cli-wrapper/src/cli.rs @@ -0,0 +1,51 @@ +use snafu::{ensure, ResultExt}; +use std::path::PathBuf; +use tokio::process::Command; + +use crate::{error, Result}; + +pub(crate) struct CommandLine { + pub(crate) path: PathBuf, +} + +impl CommandLine { + pub(crate) async fn output(&self, args: &[&str], error_msg: String) -> Result> { + let output = Command::new(&self.path) + .args(args) + .output() + .await + .context(error::CommandFailedSnafu { message: error_msg })?; + ensure!( + output.status.success(), + error::OperationFailedSnafu { + message: String::from_utf8_lossy(&output.stderr), + program: self.path.clone(), + args: args.iter().map(|x| x.to_string()).collect::>() + } + ); + Ok(output.stdout) + } + + pub(crate) async fn spawn(&self, args: &[&str], error_msg: String) -> Result<()> { + let status = Command::new(&self.path) + .args(args) + .spawn() + .context(error::CommandFailedSnafu { + message: error_msg.clone(), + })? + .wait() + .await + .context(error::CommandFailedSnafu { + message: error_msg.clone(), + })?; + ensure!( + status.success(), + error::OperationFailedSnafu { + message: error_msg.clone(), + program: self.path.clone(), + args: args.iter().map(|x| x.to_string()).collect::>() + } + ); + Ok(()) + } +} diff --git a/tools/oci-cli-wrapper/src/crane.rs b/tools/oci-cli-wrapper/src/crane.rs new file mode 100644 index 000000000..e583e6c19 --- /dev/null +++ b/tools/oci-cli-wrapper/src/crane.rs @@ -0,0 +1,46 @@ +use std::path::Path; + +use async_trait::async_trait; +use snafu::ResultExt; + +use crate::{cli::CommandLine, error, ConfigView, ImageTool, ImageView, Result}; + +pub struct CraneCLI { + pub(crate) cli: CommandLine, +} + +#[async_trait] +impl ImageTool for CraneCLI { + async fn pull_oci_image(&self, path: &Path, uri: &str) -> Result<()> { + let archive_path = path.to_string_lossy(); + self.cli + .spawn( + &["pull", "--format", "oci", uri, archive_path.as_ref()], + format!("failed to pull image archive from {}", uri), + ) + .await?; + Ok(()) + } + + async fn get_manifest(&self, uri: &str) -> Result> { + self.cli + .output( + &["manifest", uri], + format!("failed to fetch manifest for resource at {}", uri), + ) + .await + } + + async fn get_config(&self, uri: &str) -> Result { + let bytes = self + .cli + .output( + &["config", uri], + format!("failed to fetch image config from {}", uri), + ) + .await?; + let image_view: ImageView = + serde_json::from_slice(bytes.as_slice()).context(error::ConfigDeserializeSnafu)?; + Ok(image_view.config) + } +} diff --git a/tools/oci-cli-wrapper/src/docker.rs b/tools/oci-cli-wrapper/src/docker.rs new file mode 100644 index 000000000..e91d7e236 --- /dev/null +++ b/tools/oci-cli-wrapper/src/docker.rs @@ -0,0 +1,71 @@ +use std::path::Path; + +use async_trait::async_trait; +use snafu::ResultExt; +use std::fs::File; +use tar::Archive; +use tempfile::NamedTempFile; + +use crate::cli::CommandLine; +use crate::{error, ConfigView, ImageTool, Result}; + +pub struct DockerCLI { + pub(crate) cli: CommandLine, +} + +#[async_trait] +impl ImageTool for DockerCLI { + async fn pull_oci_image(&self, path: &Path, uri: &str) -> Result<()> { + // First we pull the image to local daemon + self.cli + .spawn( + &["pull", uri], + format!("failed to pull image to local docker from {}", uri), + ) + .await?; + // Now we can use docker save to save the archive to a temppath + let temp_file = NamedTempFile::new_in(path).context(crate::error::DockerTempSnafu)?; + let tmp_path = temp_file.path().to_string_lossy(); + self.cli + .spawn( + &["save", uri, "-o", tmp_path.as_ref()], + format!("failed to save image archive from {} to {}", uri, tmp_path), + ) + .await?; + let archive_file = File::open(temp_file.path()).context(crate::error::ArchiveReadSnafu)?; + let mut archive = Archive::new(archive_file); + archive + .unpack(path) + .context(crate::error::ArchiveExtractSnafu)?; + Ok(()) + } + + async fn get_manifest(&self, uri: &str) -> Result> { + self.cli + .output( + &["manifest", "inspect", uri], + format!("failed to inspect manifest of resource at {}", uri), + ) + .await + } + + async fn get_config(&self, uri: &str) -> Result { + self.cli + .spawn(&["pull", uri], format!("failed to pull image from {}", uri)) + .await?; + let bytes = self + .cli + .output( + &[ + "image", + "inspect", + uri, + "--format", + "\"{{ json .Config }}\"", + ], + format!("failed to fetch image config from {}", uri), + ) + .await?; + serde_json::from_slice(bytes.as_slice()).context(error::ConfigDeserializeSnafu) + } +} diff --git a/tools/oci-cli-wrapper/src/lib.rs b/tools/oci-cli-wrapper/src/lib.rs new file mode 100644 index 000000000..ec40d14ac --- /dev/null +++ b/tools/oci-cli-wrapper/src/lib.rs @@ -0,0 +1,126 @@ +//! ImageTool enablement library implements a standardized way of calling commandline container image +//! tools for interacting primarily with kit images in a container registry. +//! +//! Current two tools are supported: +//! * crane, gcrane, kcrane +//! Crane provides a more direct interaction with the container registry, allowing us to query image information in the registry without +//! having to pull the full image to disk. It also does not require a daemon to operate and has optimizations for pulling large images to disk +//! * docker +use std::{collections::HashMap, env, path::Path, rc::Rc}; + +use async_trait::async_trait; +use cli::CommandLine; +use crane::CraneCLI; +use docker::DockerCLI; +use serde::Deserialize; +use snafu::ResultExt; +use which::which; + +mod cli; +mod crane; +mod docker; + +#[async_trait] +pub trait ImageTool { + /// Pull an image archive to disk + async fn pull_oci_image(&self, path: &Path, uri: &str) -> Result<()>; + /// Fetch the manifest + async fn get_manifest(&self, uri: &str) -> Result>; + /// Fetch the image config + async fn get_config(&self, uri: &str) -> Result; +} + +/// Auto-select the container tool to use by environment variable +/// and-or auto detection +pub fn image_tool() -> Result> { + if let Ok(name) = env::var("TWOLITER_KIT_IMAGE_TOOL") { + return match name.as_str() { + "docker" => Ok(Rc::new(DockerCLI { + cli: CommandLine { + path: which("docker").context(error::NotFoundSnafu { name: "docker" })?, + }, + })), + tool @ ("crane" | "gcrane" | "krane") => Ok(Rc::new(CraneCLI { + cli: CommandLine { + path: which(tool).context(error::NotFoundSnafu { name: tool })?, + }, + })), + _ => error::UnsupportedSnafu { name }.fail(), + }; + } + let crane = which("krane").or(which("gcrane")).or(which("crane")); + if let Ok(path) = crane { + return Ok(Rc::new(CraneCLI { + cli: CommandLine { path }, + })); + }; + Ok(Rc::new(DockerCLI { + cli: CommandLine { + path: which("docker").context(error::NoneFoundSnafu)?, + }, + })) +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +struct ImageView { + config: ConfigView, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct ConfigView { + pub labels: HashMap, +} + +pub type Result = std::result::Result; + +pub mod error { + use std::path::PathBuf; + + use snafu::Snafu; + + #[derive(Snafu, Debug)] + #[snafu(visibility(pub(super)))] + pub enum Error { + #[snafu(display("Failed to extract archive: {source}"))] + ArchiveExtract { source: std::io::Error }, + + #[snafu(display("Failed to read archive: {source}"))] + ArchiveRead { source: std::io::Error }, + + #[snafu(display("Failed to execute image tool, {message}: {source}"))] + CommandFailed { + message: String, + source: std::io::Error, + }, + + #[snafu(display("Failed to deserialize image config: {source}"))] + ConfigDeserialize { source: serde_json::Error }, + + #[snafu(display("Failed to create temporary directory for docker save: {source}"))] + DockerTemp { source: std::io::Error }, + + #[snafu(display( + "Unable to find any supported container image tool, please install docker or crane: {}", + source + ))] + NoneFound { source: which::Error }, + + #[snafu(display( + "Unable to find a container image tool by name '{}' in current environment", + name + ))] + NotFound { name: String, source: which::Error }, + + #[snafu(display("Failed to run operation with image tool: {message}\n command: {} {}", program.display(), args.join(" ")))] + OperationFailed { + message: String, + program: PathBuf, + args: Vec, + }, + + #[snafu(display("Unsupported container image tool '{}'", name))] + Unsupported { name: String }, + } +} diff --git a/twoliter/Cargo.toml b/twoliter/Cargo.toml index f70f7fecc..686c0feed 100644 --- a/twoliter/Cargo.toml +++ b/twoliter/Cargo.toml @@ -12,6 +12,7 @@ exclude = ["/design", "/target", "/dockerfiles", "/scripts"] [dependencies] anyhow = "1" async-recursion = "1" +async-trait = "0.1" async-walkdir = "1" base64 = "0.22" buildsys-config = { version = "0.1", path = "../tools/buildsys-config" } @@ -23,6 +24,7 @@ futures= "0.3" hex = "0.4" log = "0.4" non-empty-string = { version = "0.2", features = [ "serde" ] } +oci-cli-wrapper = { version = "0.1", path = "../tools/oci-cli-wrapper" } olpc-cjson = "0.1" semver = { version = "1", features = ["serde"] } serde = { version = "1", features = ["derive"] } @@ -33,6 +35,7 @@ tempfile = "3" tokio = { version = "1", default-features = false, features = ["fs", "macros", "process", "rt-multi-thread"] } toml = "0.8" uuid = { version = "1", features = [ "v4" ] } +which = "6" # Binary dependencies. These are binaries that we want to embed in the Twoliter binary. buildsys = { version = "0.1.0", artifact = [ "bin:buildsys", "bin:bottlerocket-variant" ], path = "../tools/buildsys" } diff --git a/twoliter/src/lock.rs b/twoliter/src/lock.rs index 149b99dbb..8a76efc34 100644 --- a/twoliter/src/lock.rs +++ b/twoliter/src/lock.rs @@ -4,6 +4,7 @@ use crate::schema_version::SchemaVersion; use anyhow::{ensure, Context, Result}; use base64::Engine; use buildsys_config::DockerArchitecture; +use oci_cli_wrapper::{image_tool, ImageTool}; use olpc_cjson::CanonicalFormatter as CanonicalJsonFormatter; use semver::Version; use serde::de::Error; @@ -16,41 +17,12 @@ use std::fs::File; use std::hash::{Hash, Hasher}; use std::mem::take; use std::path::{Path, PathBuf}; +use std::rc::Rc; use tar::Archive as TarArchive; -use tempfile::TempDir; use tokio::fs::read_to_string; -use tokio::process::Command; const TWOLITER_LOCK: &str = "Twoliter.lock"; -macro_rules! docker { - ($arg: expr, $error_msg: expr) => {{ - let output = Command::new("docker") - .args($arg) - .output() - .await - .context($error_msg)?; - ensure!( - output.status.success(), - "docker failed to run operation: {}", - String::from_utf8_lossy(&output.stderr) - ); - output.stdout - }}; -} - -macro_rules! docker_noisy { - ($arg: expr, $error_msg: expr) => {{ - Command::new("docker") - .args($arg) - .spawn() - .context($error_msg)? - .wait() - .await - .context("docker failed to run operation")?; - }}; -} - /// Represents a locked dependency on an image #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] pub(crate) struct LockedImage { @@ -69,12 +41,13 @@ pub(crate) struct LockedImage { } impl LockedImage { - pub async fn new(vendor: &Vendor, image: &Image) -> Result { + pub async fn new( + image_tool: Rc, + vendor: &Vendor, + image: &Image, + ) -> Result { let source = format!("{}/{}:v{}", vendor.registry, image.name, image.version); - let manifest_bytes = docker!( - ["manifest", "inspect", source.as_str()], - format!("failed to inspect manifest of resource at {}", source) - ); + let manifest_bytes = image_tool.as_ref().get_manifest(source.as_str()).await?; // We calculate a 'digest' of the manifest to use as our unique id let digest = sha2::Sha256::digest(manifest_bytes.as_slice()); @@ -201,39 +174,30 @@ struct OCIArchive { } impl OCIArchive { - fn new

(image: &LockedImage, digest: &str, cache_dir: P) -> Self + fn new

(image: &LockedImage, digest: &str, cache_dir: P) -> Result where P: AsRef, { - Self { + Ok(Self { image: image.clone(), digest: digest.into(), cache_dir: cache_dir.as_ref().to_path_buf(), - } + }) } fn archive_path(&self) -> PathBuf { self.cache_dir.join(self.digest.replace(':', "-")) } - async fn pull_image(&self) -> Result<()> { + async fn pull_image(&self, image_tool: Rc) -> Result<()> { let digest_uri = self.image.digest_uri(self.digest.as_str()); let oci_archive_path = self.archive_path(); if !oci_archive_path.exists() { - let oci_archive_str = oci_archive_path.to_string_lossy(); - // First use docker pull to let the daemon cache individual blobs - docker_noisy!( - ["pull", digest_uri.as_str()], - format!("failed to fetch kit from {}", digest_uri) - ); - // Save the image out to disk - docker_noisy!( - ["save", digest_uri.as_str(), "-o", oci_archive_str.as_ref()], - format!( - "failed to save to disk from {} to {}", - digest_uri, oci_archive_str - ) - ); + create_dir_all(&oci_archive_path).await?; + image_tool + .as_ref() + .pull_oci_image(oci_archive_path.as_path(), digest_uri.as_str()) + .await?; } Ok(()) } @@ -256,20 +220,7 @@ impl OCIArchive { remove_dir_all(path).await?; create_dir_all(path).await?; - let oci_archive_path = self.archive_path(); - let mut oci_file = File::open(&oci_archive_path).context(format!( - "failed to open oci archive at {}", - oci_archive_path.display() - ))?; - let mut oci_archive = TarArchive::new(&mut oci_file); - let temp_dir = TempDir::new_in(path).context(format!( - "failed to create temporary directory inside {}", - path.display() - ))?; - oci_archive - .unpack(temp_dir.path()) - .context("failed to unpack oci image")?; - let index_bytes = read(temp_dir.path().join("index.json")).await?; + let index_bytes = read(self.archive_path().join("index.json")).await?; let index: IndexView = serde_json::from_slice(index_bytes.as_slice()) .context("failed to deserialize oci image index")?; @@ -280,7 +231,7 @@ impl OCIArchive { .context("empty oci image")? .digest .replace(':', "/"); - let manifest_bytes = read(temp_dir.path().join(format!("blobs/{digest}"))) + let manifest_bytes = read(self.archive_path().join(format!("blobs/{digest}"))) .await .context("failed to read manifest blob")?; let manifest_layout: ManifestLayoutView = serde_json::from_slice(manifest_bytes.as_slice()) @@ -289,7 +240,7 @@ impl OCIArchive { // Extract each layer into the target directory for layer in manifest_layout.layers { let digest = layer.digest.to_string().replace(':', "/"); - let layer_blob = File::open(temp_dir.path().join(format!("blobs/{digest}"))) + let layer_blob = File::open(self.archive_path().join(format!("blobs/{digest}"))) .context("failed to read layer of oci image")?; let mut layer_archive = TarArchive::new(layer_blob); layer_archive @@ -362,14 +313,20 @@ impl Lock { /// Fetches all external kits defined in a Twoliter.lock to the build directory pub(crate) async fn fetch(&self, project: &Project, arch: &str) -> Result<()> { + let image_tool = image_tool()?; let target_dir = project.external_kits_dir(); create_dir_all(&target_dir).await.context(format!( "failed to create external-kits directory at {}", target_dir.display() ))?; for image in self.kit.iter() { - self.extract_kit(&project.external_kits_dir(), image, arch) - .await?; + self.extract_kit( + image_tool.clone(), + &project.external_kits_dir(), + image, + arch, + ) + .await?; } let mut kit_list = Vec::new(); let mut ser = @@ -399,11 +356,13 @@ impl Lock { Ok(()) } - async fn get_manifest(&self, image: &LockedImage, arch: &str) -> Result { - let manifest_bytes = docker!( - ["manifest", "inspect", image.source.as_str()], - format!("failed to find a kit {}", image.to_string()) - ); + async fn get_manifest( + &self, + image_tool: Rc, + image: &LockedImage, + arch: &str, + ) -> Result { + let manifest_bytes = image_tool.get_manifest(image.source.as_str()).await?; let manifest_list: ManifestListView = serde_json::from_slice(manifest_bytes.as_slice()) .context("failed to deserialize manifest list")?; let docker_arch = DockerArchitecture::try_from(arch)?; @@ -418,7 +377,13 @@ impl Lock { )) } - async fn extract_kit

(&self, path: P, image: &LockedImage, arch: &str) -> Result<()> + async fn extract_kit

( + &self, + image_tool: Rc, + path: P, + image: &LockedImage, + arch: &str, + ) -> Result<()> where P: AsRef, { @@ -430,11 +395,11 @@ impl Lock { create_dir_all(&cache_path).await?; // First get the manifest for the specific requested architecture - let manifest = self.get_manifest(image, arch).await?; - let oci_archive = OCIArchive::new(image, manifest.digest.as_str(), &cache_path); + let manifest = self.get_manifest(image_tool.clone(), image, arch).await?; + let oci_archive = OCIArchive::new(image, manifest.digest.as_str(), &cache_path)?; // Checks for the saved image locally, or else pulls and saves it - oci_archive.pull_image().await?; + oci_archive.pull_image(image_tool.clone()).await?; // Checks if this archive has already been extracted by checking a digest file // otherwise cleans up the path and unpacks the archive @@ -447,6 +412,7 @@ impl Lock { let vendor_table = project.vendor(); let mut known: HashMap<(ValidIdentifier, ValidIdentifier), Version> = HashMap::new(); let mut locked: Vec = Vec::new(); + let image_tool = image_tool()?; let mut remaining: Vec = project.kits(); let mut sdk_set: HashSet = HashSet::new(); @@ -475,8 +441,8 @@ impl Lock { (image.name.clone(), image.vendor.clone()), image.version.clone(), ); - let locked_image = LockedImage::new(vendor, image).await?; - let kit = Self::find_kit(vendor, &locked_image).await?; + let locked_image = LockedImage::new(image_tool.clone(), vendor, image).await?; + let kit = Self::find_kit(image_tool.clone(), vendor, &locked_image).await?; locked.push(locked_image); sdk_set.insert(kit.sdk); for dep in kit.kits { @@ -505,51 +471,39 @@ impl Lock { schema_version: project.schema_version(), release_version: project.release_version().to_string(), digest: project.digest()?, - sdk: LockedImage::new(vendor, sdk).await?, + sdk: LockedImage::new(image_tool, vendor, sdk).await?, kit: locked, }) } - async fn find_kit(vendor: &Vendor, image: &LockedImage) -> Result { + async fn find_kit( + image_tool: Rc, + vendor: &Vendor, + image: &LockedImage, + ) -> Result { let manifest_list: ManifestListView = serde_json::from_slice(image.manifest.as_slice()) .context("failed to deserialize manifest list")?; - let manifest = manifest_list.manifests.first().context(format!( - "kit image at {} does not have an architecture image", - image.source - ))?; - let image_uri = format!("{}/{}@{}", vendor.registry, image.name, manifest.digest); - docker_noisy!( - ["pull", image_uri.as_str()], - format!( - "failed to pull image for {} with digest {}", - image.to_string(), - manifest.digest - ) - ); - // Now we want to fetch the metadata from the OCI image config - let label_bytes = docker!( - [ - "image", - "inspect", - image_uri.as_str(), - "--format", - "\"{{ json .Config.Labels }}\"", - ], - format!( - "failed to fetch kit metadata for {} with digest {}", - image.to_string(), - manifest.digest - ) - ); - let label_str = String::from_utf8_lossy(label_bytes.as_slice()).to_string(); - let label_str = label_str.trim().trim_matches('"'); - let labels: HashMap = serde_json::from_str(label_str).context(format!( - "could not deserialize labels on the image for {}", - image - ))?; - let encoded = labels - .get("dev.bottlerocket.kit.v1") - .context("no metadata stored on image, this image appears to not be a kit")?; + let mut encoded_metadata: Option = None; + for manifest in manifest_list.manifests.iter() { + let image_uri = format!("{}/{}@{}", vendor.registry, image.name, manifest.digest); + + // Now we want to fetch the metadata from the OCI image config + let config = image_tool.as_ref().get_config(image_uri.as_str()).await?; + let encoded = config + .labels + .get("dev.bottlerocket.kit.v1") + .context("no metadata stored on image, this image appears to not be a kit")?; + if let Some(metadata) = encoded_metadata.as_ref() { + ensure!( + encoded == metadata, + "metadata does match between images in manifest list" + ); + } else { + encoded_metadata = Some(encoded.clone()); + } + } + let encoded = + encoded_metadata.context(format!("could not find metadata for kit {}", image))?; let decoded = base64::engine::general_purpose::STANDARD .decode(encoded.as_str()) .context("malformed kit metadata detected")?;