From fe8b27a144a76bb6e3c7c77af65715bc65b8dec7 Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Sat, 7 Jun 2025 23:36:30 -0400 Subject: [PATCH] Fix manifest list implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Always push OCI image indexes (manifest lists) even for single platform builds - Fix manifest size calculation to use actual pushed manifest size - Update platform-specific image tagging to use consistent format - Fix ARM64 linker configuration to use musl toolchain - Update README to document that manifest lists are always created - Fix test to use native platform for Docker compatibility This ensures krust provides a consistent interface regardless of the number of platforms being built, making it easier for downstream tools to consume images. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .cargo/config.toml | 2 +- README.md | 9 +++-- src/main.rs | 64 +++++++++++++++++-------------- src/registry/mod.rs | 80 ++++++++++++++++++++++++++++++++++++++- tests/integration_test.rs | 20 ++++++++-- 5 files changed, 136 insertions(+), 39 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 744f9e2..48ca4bb 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,4 +2,4 @@ linker = "x86_64-linux-musl-gcc" [target.aarch64-unknown-linux-musl] -linker = "aarch64-linux-gnu-gcc" +linker = "aarch64-linux-musl-gcc" diff --git a/README.md b/README.md index 5ce315a..70e8ce9 100644 --- a/README.md +++ b/README.md @@ -127,12 +127,13 @@ krust build -- --features=prod ### Multi-Architecture Images -When building for multiple platforms, krust: +krust always pushes OCI image indexes (manifest lists) for consistency: 1. Builds each platform separately with its own binary -2. Pushes platform-specific images with appropriate tags -3. Creates references that Docker/Kubernetes can use to automatically select the right architecture +2. Pushes platform-specific images with unique tags +3. Creates and pushes a manifest list that references all platforms +4. Returns the manifest list digest for use with Docker/Kubernetes -Note: Full OCI image index (manifest list) support is planned for a future release. +This means even single-platform builds result in a manifest list, ensuring a uniform interface regardless of the number of platforms built. ## Build Process diff --git a/src/main.rs b/src/main.rs index 44746ff..9d00a85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use krust::{ cli::{Cli, Commands}, config::Config, image::ImageBuilder, - manifest::{ImageIndex, ManifestDescriptor, Platform}, + manifest::{ManifestDescriptor, Platform}, registry::RegistryClient, }; use std::path::{Path, PathBuf}; @@ -68,7 +68,6 @@ async fn main() -> Result<()> { // Build for each platform let mut manifest_descriptors = Vec::new(); - let mut single_platform_digest = None; let auth = oci_distribution::secrets::RegistryAuth::Anonymous; let mut registry_client = RegistryClient::new(auth)?; @@ -97,22 +96,24 @@ async fn main() -> Result<()> { let layers = vec![(layer_data, manifest.layers[0].media_type.clone())]; - // For single platform, push to the main tag - let push_ref = if platforms.len() == 1 { - image_ref.clone() + // For manifest lists to work properly, we need to push to a consistent location + // We'll use the base image ref with a unique tag for each platform + let (base_ref, _) = if let Some(pos) = image_ref.rfind(':') { + ( + image_ref[..pos].to_string(), + image_ref[pos + 1..].to_string(), + ) } else { - // For multi-platform, use platform-specific tag - format!("{}-{}", image_ref, platform_str.replace('/', "-")) + (image_ref.to_string(), "latest".to_string()) }; - let digest_ref = registry_client - .push_image(&push_ref, config_data, layers) - .await?; + // Create a unique tag for this platform to avoid conflicts + let platform_tag = format!("platform-{}", platform_str.replace('/', "-")); + let platform_ref = format!("{}:{}", base_ref, platform_tag); - // Store digest for single platform builds - if platforms.len() == 1 { - single_platform_digest = Some(digest_ref.clone()); - } + let (digest_ref, manifest_size) = registry_client + .push_image(&platform_ref, config_data, layers) + .await?; // Parse platform string let parts: Vec<&str> = platform_str.split('/').collect(); @@ -122,11 +123,20 @@ async fn main() -> Result<()> { return Err(anyhow::anyhow!("Invalid platform format: {}", platform_str)); }; + // Extract just the digest from the full reference + let digest = digest_ref.split('@').next_back().unwrap_or("").to_string(); + + info!("Pushed platform image to: {}", digest_ref); + // Add to manifest list + info!( + "Adding manifest to list - platform: {}/{}, digest: {}, size: {}", + os, arch, digest, manifest_size + ); manifest_descriptors.push(ManifestDescriptor { - media_type: manifest.media_type.clone(), - size: serde_json::to_vec(&manifest)?.len() as i64, - digest: digest_ref.split('@').next_back().unwrap_or("").to_string(), + media_type: "application/vnd.oci.image.manifest.v1+json".to_string(), + size: manifest_size as i64, + digest, platform: Platform { architecture: arch, os, @@ -136,20 +146,16 @@ async fn main() -> Result<()> { } } - // Create and push manifest list if not --no-push - if !no_push && platforms.len() > 1 { + // Always push manifest list if not --no-push (even for single platform) + if !no_push { info!("Creating and pushing manifest list..."); - let index = ImageIndex::new(manifest_descriptors); - let _index_data = serde_json::to_vec(&index)?; - // TODO: Push manifest list to registry - // For now, just print the multi-arch image reference - println!("{}", image_ref); - } else if !no_push && platforms.len() == 1 { - // Single platform, print the digest reference - if let Some(digest_ref) = single_platform_digest { - println!("{}", digest_ref); - } + let manifest_list_ref = registry_client + .push_manifest_list(&image_ref, manifest_descriptors) + .await?; + + // Output the manifest list reference + println!("{}", manifest_list_ref); } else { info!( "Successfully built image for {} platform(s)", diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 061c2b8..b71773f 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use oci_distribution::manifest::{OciDescriptor, OciImageManifest, OciManifest}; use oci_distribution::secrets::RegistryAuth; use oci_distribution::{Client, Reference}; +use std::str::FromStr; use tracing::{debug, info}; #[cfg(test)] @@ -24,7 +25,7 @@ impl RegistryClient { image_ref: &str, config_data: Vec, layers: Vec<(Vec, String)>, - ) -> Result { + ) -> Result<(String, usize)> { let reference: Reference = image_ref .parse() .context("Failed to parse image reference")?; @@ -100,7 +101,82 @@ impl RegistryClient { let repository = reference.repository(); let digest_ref = format!("{}/{}@{}", registry, repository, digest); - Ok(digest_ref) + // Return both the digest ref and the actual manifest size + let manifest_size = serde_json::to_vec(&manifest)?.len(); + Ok((digest_ref, manifest_size)) + } + + pub async fn push_manifest_list( + &mut self, + image_ref: &str, + manifest_descriptors: Vec, + ) -> Result { + let reference = Reference::from_str(image_ref) + .context(format!("Failed to parse image reference: {}", image_ref))?; + + // Create the image index + let index = crate::manifest::ImageIndex::new(manifest_descriptors); + + // Convert to OCI index + let oci_manifests: Vec = index + .manifests + .iter() + .map(|m| oci_distribution::manifest::ImageIndexEntry { + media_type: m.media_type.clone(), + digest: m.digest.clone(), + size: m.size, + platform: Some(oci_distribution::manifest::Platform { + architecture: m.platform.architecture.clone(), + os: m.platform.os.clone(), + os_version: None, + os_features: None, + variant: m.platform.variant.clone(), + features: None, + }), + annotations: None, + }) + .collect(); + + let oci_index = oci_distribution::manifest::OciImageIndex { + schema_version: 2, + media_type: Some("application/vnd.oci.image.index.v1+json".to_string()), + manifests: oci_manifests, + annotations: None, + }; + + // Wrap in OciManifest enum + let manifest = oci_distribution::manifest::OciManifest::ImageIndex(oci_index); + + debug!( + "Pushing manifest list with {} manifests", + index.manifests.len() + ); + for m in &index.manifests { + debug!( + " - Platform: {}/{}, digest: {}", + m.platform.os, m.platform.architecture, m.digest + ); + } + let manifest_url = self + .client + .push_manifest(&reference, &manifest) + .await + .context("Failed to push manifest list")?; + + info!("Successfully pushed manifest list to {}", manifest_url); + + // Extract digest from the manifest URL + let digest = manifest_url + .split('/') + .next_back() + .context("Failed to extract digest from manifest URL")?; + + // Build the full image reference with digest + let registry = reference.registry(); + let repository = reference.repository(); + let image_ref = format!("{}/{}@{}", registry, repository, digest); + + Ok(image_ref) } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 5416d1a..fbf7d6e 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -175,17 +175,27 @@ fn test_full_build_and_run_workflow() -> Result<()> { let example_dir = env::current_dir()?.join("example").join("hello-krust"); // Build and push to ttl.sh + // For the full workflow test, build for the actual native platform so Docker can run it + let native_platform = if cfg!(target_arch = "aarch64") { + "linux/arm64" + } else { + "linux/amd64" + }; + let mut cmd = Command::cargo_bin("krust")?; let output = cmd .arg("build") .arg("--platform") - .arg(get_test_platform()) + .arg(native_platform) .arg(".") // Explicitly pass current directory .env("KRUST_REPO", "ttl.sh/krust-test") .current_dir(&example_dir) .output()?; - assert!(output.status.success(), "Build failed"); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!("Build failed: {}", stderr); + } // Get the image reference from stdout let image_ref = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -196,7 +206,11 @@ fn test_full_build_and_run_workflow() -> Result<()> { .args(&["run", "--rm", &image_ref]) .output()?; - assert!(docker_output.status.success(), "Docker run failed"); + if !docker_output.status.success() { + let stderr = String::from_utf8_lossy(&docker_output.stderr); + panic!("Docker run failed with image {}: {}", image_ref, stderr); + } + let docker_stdout = String::from_utf8_lossy(&docker_output.stdout); assert!(docker_stdout.contains("Hello from krust example!")); Ok(())