diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d16048f..76318e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: os: [ubuntu-latest, ubuntu-24.04-arm] rust: [stable, beta] steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master with: toolchain: ${{ matrix.rust }} @@ -30,17 +30,17 @@ jobs: sudo apt-get update sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu - name: Cache cargo registry - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 with: path: ~/.cargo/registry key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo index - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 with: path: ~/.cargo/git key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo build - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 with: path: target key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} diff --git a/Cargo.toml b/Cargo.toml index 1195e32..b265c3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,9 +19,9 @@ sha256 = "1.5" chrono = "0.4" tar = "0.4" flate2 = "1.0" -reqwest = { version = "0.12", features = ["json", "stream"] } +reqwest = { version = "0.13", features = ["json", "stream"] } bytes = "1.0" -yaml-rust2 = "0.10" +yaml-rust2 = "0.11" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } dirs = "6.0" diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 6a646d0..34c20c5 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -205,25 +205,25 @@ mod unit_tests { // Test anonymous let auth = AuthConfig::anonymous(); - matches!(auth.to_registry_auth(), RegistryAuth::Anonymous); + assert!(matches!(auth.to_registry_auth(), RegistryAuth::Anonymous)); // Test basic auth let auth = AuthConfig::new("user".to_string(), "pass".to_string()); - matches!( + assert!(matches!( auth.to_registry_auth(), - RegistryAuth::Basic { username, password } + RegistryAuth::Basic { ref username, ref password } if username == "user" && password == "pass" - ); + )); // Test bearer token let auth = AuthConfig { registry_token: Some("token123".to_string()), ..Default::default() }; - matches!( + assert!(matches!( auth.to_registry_auth(), - RegistryAuth::Bearer { token } + RegistryAuth::Bearer { ref token } if token == "token123" - ); + )); } } diff --git a/src/builder/tests.rs b/src/builder/tests.rs index cd6c3a4..ae13c03 100644 --- a/src/builder/tests.rs +++ b/src/builder/tests.rs @@ -108,4 +108,75 @@ version = "0.1.0" assert_eq!(builder.cargo_args, vec!["--features", "foo"]); } + + #[test] + fn test_get_binary_name_with_bin_arg() { + let dir = tempdir().unwrap(); + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--bin".to_string(), "my-binary".to_string()]); + let name = builder.get_binary_name().unwrap(); + assert_eq!(name, "my-binary"); + } + + #[test] + fn test_get_binary_name_with_example_arg() { + let dir = tempdir().unwrap(); + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--example".to_string(), "my-example".to_string()]); + let name = builder.get_binary_name().unwrap(); + assert_eq!(name, "my-example"); + } + + #[test] + fn test_get_binary_name_bin_arg_at_end_without_value() { + let dir = tempdir().unwrap(); + // --bin at end with no following value should fall through to Cargo.toml + let cargo_toml = dir.path().join("Cargo.toml"); + fs::write( + &cargo_toml, + r#" +[package] +name = "fallback-name" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--bin".to_string()]); + let name = builder.get_binary_name().unwrap(); + assert_eq!(name, "fallback-name"); + } + + #[test] + fn test_get_binary_subdir_with_example() { + let dir = tempdir().unwrap(); + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--example".to_string(), "my-example".to_string()]); + assert_eq!(builder.get_binary_subdir(), Some("examples")); + } + + #[test] + fn test_get_binary_subdir_without_example() { + let dir = tempdir().unwrap(); + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl"); + assert_eq!(builder.get_binary_subdir(), None); + } + + #[test] + fn test_get_binary_subdir_with_bin() { + let dir = tempdir().unwrap(); + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--bin".to_string(), "my-bin".to_string()]); + assert_eq!(builder.get_binary_subdir(), None); + } + + #[test] + fn test_get_binary_subdir_example_at_end_without_value() { + let dir = tempdir().unwrap(); + // --example at end with no value should not match (needs i+1 < len) + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--example".to_string()]); + assert_eq!(builder.get_binary_subdir(), None); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e621015..7063f3e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -44,12 +44,6 @@ pub enum Commands { cargo_args: Vec, }, - /// Push a built image to a container registry - Push { - /// Image reference to push - image: String, - }, - /// Resolve krust:// references in YAML files Resolve { /// Path to YAML file or directory containing YAML files diff --git a/src/config/mod.rs b/src/config/mod.rs index 9b1c948..cf65c36 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -20,7 +20,7 @@ pub struct Config { /// Registry authentication configuration #[serde(default)] - pub registries: HashMap, + pub registries: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -38,7 +38,7 @@ pub struct BuildConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegistryAuth { +pub struct RegistryCredential { pub username: Option, pub password: Option, pub auth: Option, diff --git a/src/image/mod.rs b/src/image/mod.rs index 2462be0..3ee436c 100644 --- a/src/image/mod.rs +++ b/src/image/mod.rs @@ -79,6 +79,20 @@ pub struct Descriptor { pub digest: String, } +/// Parse a platform string like "linux/amd64" or "linux/arm/v7" into (os, arch, variant). +pub fn parse_platform_string(platform: &str) -> Result<(String, String, Option)> { + let parts: Vec<&str> = platform.split('/').collect(); + match parts.len() { + 2 => Ok((parts[0].to_string(), parts[1].to_string(), None)), + 3 => Ok(( + parts[0].to_string(), + parts[1].to_string(), + Some(parts[2].to_string()), + )), + _ => anyhow::bail!("Invalid platform format: {}", platform), + } +} + pub struct ImageBuilder { binary_path: PathBuf, #[allow(dead_code)] @@ -104,7 +118,7 @@ impl ImageBuilder { ) -> Result<(Vec, Vec, Manifest)> { info!("Building container image"); - let (_os, _arch) = self.parse_platform()?; + let (_os, _arch, _variant) = self.parse_platform()?; // Fetch base image data info!( @@ -133,7 +147,7 @@ impl ImageBuilder { // Add the application layer all_layers.push(Descriptor { - media_type: "application/vnd.docker.image.rootfs.diff.tar.gzip".to_string(), + media_type: "application/vnd.oci.image.layer.v1.tar+gzip".to_string(), size: app_layer_size, digest: app_layer_digest, }); @@ -147,9 +161,9 @@ impl ImageBuilder { // Create manifest let manifest = Manifest { schema_version: 2, - media_type: "application/vnd.docker.distribution.manifest.v2+json".to_string(), + media_type: "application/vnd.oci.image.manifest.v1+json".to_string(), config: Descriptor { - media_type: "application/vnd.docker.container.image.v1+json".to_string(), + media_type: "application/vnd.oci.image.config.v1+json".to_string(), size: config_size, digest: config_digest, }, @@ -159,12 +173,8 @@ impl ImageBuilder { Ok((config_data, app_layer_data, manifest)) } - fn parse_platform(&self) -> Result<(String, String)> { - let parts: Vec<&str> = self.platform.split('/').collect(); - if parts.len() != 2 { - anyhow::bail!("Invalid platform format: {}", self.platform); - } - Ok((parts[0].to_string(), parts[1].to_string())) + fn parse_platform(&self) -> Result<(String, String, Option)> { + parse_platform_string(&self.platform) } fn create_layer(&self) -> Result<(Vec, String)> { @@ -264,13 +274,12 @@ mod tests { use std::path::PathBuf; use tempfile::NamedTempFile; - fn create_test_binary() -> PathBuf { + fn create_test_binary() -> (PathBuf, tempfile::TempPath) { let mut temp_file = NamedTempFile::new().unwrap(); temp_file.write_all(b"fake binary content").unwrap(); - let path = temp_file.path().to_path_buf(); - // Keep the file alive by converting to a regular file - std::fs::copy(&path, &path).unwrap(); - path + let temp_path = temp_file.into_temp_path(); + let path = temp_path.to_path_buf(); + (path, temp_path) } fn create_base_image_config() -> ImageConfig { @@ -318,14 +327,29 @@ mod tests { "linux/amd64".to_string(), ); - let (os, arch) = builder.parse_platform().unwrap(); + let (os, arch, variant) = builder.parse_platform().unwrap(); assert_eq!(os, "linux"); assert_eq!(arch, "amd64"); + assert_eq!(variant, None); + } + + #[test] + fn test_parse_platform_with_variant() { + let builder = ImageBuilder::new( + PathBuf::from("/tmp/test"), + "test-base".to_string(), + "linux/arm/v7".to_string(), + ); + + let (os, arch, variant) = builder.parse_platform().unwrap(); + assert_eq!(os, "linux"); + assert_eq!(arch, "arm"); + assert_eq!(variant, Some("v7".to_string())); } #[test] fn test_create_layered_config_preserves_base_environment() { - let binary_path = create_test_binary(); + let (binary_path, _guard) = create_test_binary(); let builder = ImageBuilder::new( binary_path, "test-base".to_string(), @@ -359,7 +383,7 @@ mod tests { #[test] fn test_create_layered_config_combines_diff_ids() { - let binary_path = create_test_binary(); + let (binary_path, _guard) = create_test_binary(); let builder = ImageBuilder::new( binary_path, "test-base".to_string(), @@ -382,7 +406,7 @@ mod tests { #[test] fn test_create_layered_config_combines_history() { - let binary_path = create_test_binary(); + let (binary_path, _guard) = create_test_binary(); let builder = ImageBuilder::new( binary_path, "test-base".to_string(), @@ -524,7 +548,7 @@ mod tests { #[test] fn test_create_layered_config_adds_path_if_missing() { - let binary_path = create_test_binary(); + let (binary_path, _guard) = create_test_binary(); let builder = ImageBuilder::new( binary_path, "test-base".to_string(), @@ -546,7 +570,7 @@ mod tests { #[test] fn test_create_layered_config_sets_cmd() { - let binary_path = create_test_binary(); + let (binary_path, _guard) = create_test_binary(); let builder = ImageBuilder::new( binary_path.clone(), "test-base".to_string(), diff --git a/src/main.rs b/src/main.rs index 8f9a9dc..832e53d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,14 +5,14 @@ use krust::{ builder::{get_rust_target_triple, RustBuilder}, cli::{Cli, Commands}, config::Config, - image::ImageBuilder, + image::{parse_platform_string, ImageBuilder}, manifest::{ManifestDescriptor, Platform}, registry::RegistryClient, resolve::{find_krust_references, read_yaml_files, replace_krust_references}, }; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use tracing::{error, info}; +use tracing::info; use tracing_subscriber::EnvFilter; #[tokio::main] @@ -102,98 +102,17 @@ async fn main() -> Result<()> { let no_push_flag = no_push; let task = tokio::spawn(async move { - info!("Building for platform: {}", platform_str); + let descriptor = build_and_push_platform( + &project_path, + &base_image, + &target_repo, + &platform_str, + cargo_args, + !no_push_flag, + ) + .await?; - // Build the Rust binary for this platform - let target = get_rust_target_triple(&platform_str)?; - let builder = - RustBuilder::new(&project_path, &target).with_cargo_args(cargo_args); - - let build_result = builder.build()?; - - // Build container image for this platform - let image_builder = ImageBuilder::new( - build_result.binary_path, - base_image.clone(), - platform_str.clone(), - ); - - // Create a registry client for this task - let mut registry_client = RegistryClient::new()?; - - // Always use layered approach - registry layer will handle cross-registry blob copying - let base_auth = resolve_auth(&base_image)?; - let (config_data, layer_data, manifest) = image_builder - .build(&mut registry_client, &base_auth) - .await?; - - // Push platform-specific image if not --no-push - let manifest_descriptor = if !no_push_flag { - info!("Pushing image for platform: {}", platform_str); - - // Get auth for the target registry - let push_auth = resolve_auth(&target_repo)?; - - // Get the media type of the application layer (last layer in manifest) - let app_layer_media_type = manifest - .layers - .last() - .map(|l| l.media_type.clone()) - .unwrap_or_else(|| { - "application/vnd.docker.image.rootfs.diff.tar.gzip".to_string() - }); - - // Push layered image by digest only (no tag) - // This will be referenced by digest in the final manifest list - let (digest_ref, manifest_size) = registry_client - .push_layered_image( - &target_repo, - config_data, - layer_data, - app_layer_media_type, - &manifest, - &push_auth, - &base_image, - &base_auth, - ) - .await?; - - // Parse platform string - let parts: Vec<&str> = platform_str.split('/').collect(); - let (os, arch) = if parts.len() >= 2 { - (parts[0].to_string(), parts[1].to_string()) - } else { - 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); - - // Return manifest descriptor - info!( - "Adding manifest to list - platform: {}/{}, digest: {}, size: {}", - os, arch, digest, manifest_size - ); - Some(ManifestDescriptor { - media_type: "application/vnd.oci.image.manifest.v1+json".to_string(), - size: manifest_size as i64, - digest, - platform: Platform { - architecture: arch, - os, - variant: None, - }, - }) - } else { - None - }; - - Ok::<_, anyhow::Error>(manifest_descriptor) + Ok::<_, anyhow::Error>(descriptor) }); tasks.push(task); @@ -210,32 +129,16 @@ async fn main() -> Result<()> { // Always push manifest list if not --no-push (even for single platform) if !no_push { - info!("Creating and pushing manifest list..."); - - // Determine the target for the manifest list - let has_tag = tag.is_some(); - let manifest_target = if let Some(tag_name) = &tag { - // If --tag is specified, push to that tag - format!("{}:{}", target_repo, tag_name) - } else { - // If no tag specified, push digest-only (no tag) - target_repo.clone() - }; - - // Get auth for the final image push - let final_auth = resolve_auth(&manifest_target)?; - - let manifest_list_ref = registry_client - .push_manifest_list( - &manifest_target, - manifest_descriptors, - &final_auth, - has_tag, - ) - .await?; + let image_ref = push_tagged_manifest_list( + &mut registry_client, + &target_repo, + manifest_descriptors, + &tag, + ) + .await?; // Output the manifest list reference (always by digest) - println!("{}", manifest_list_ref); + println!("{}", image_ref); } else { info!( "Successfully built image for {} platform(s)", @@ -244,11 +147,6 @@ async fn main() -> Result<()> { info!("Skipping push (--no-push specified)"); } } - Commands::Push { image } => { - let _ = image; - error!("Push command not yet implemented"); - std::process::exit(1); - } Commands::Resolve { filenames, platform, @@ -306,6 +204,104 @@ async fn main() -> Result<()> { Ok(()) } +/// Build a binary and push an image for a single platform. +/// Returns a ManifestDescriptor if push is true, None otherwise. +async fn build_and_push_platform( + project_path: &Path, + base_image: &str, + target_repo: &str, + platform_str: &str, + cargo_args: Vec, + push: bool, +) -> Result> { + info!("Building for platform: {}", platform_str); + + // Build the Rust binary for this platform + let target = get_rust_target_triple(platform_str)?; + let builder = RustBuilder::new(project_path, &target).with_cargo_args(cargo_args); + let build_result = builder.build()?; + + // Build container image for this platform + let image_builder = ImageBuilder::new( + build_result.binary_path, + base_image.to_string(), + platform_str.to_string(), + ); + + // Create a registry client for this task + let mut registry_client = RegistryClient::new()?; + + let base_auth = resolve_auth(base_image)?; + let (config_data, layer_data, manifest) = image_builder + .build(&mut registry_client, &base_auth) + .await?; + + if !push { + return Ok(None); + } + + info!("Pushing image for platform: {}", platform_str); + + let push_auth = resolve_auth(target_repo)?; + let app_layer_media_type = manifest + .layers + .last() + .map(|l| l.media_type.clone()) + .unwrap_or_else(|| "application/vnd.oci.image.layer.v1.tar+gzip".to_string()); + + let (digest_ref, manifest_size) = registry_client + .push_layered_image( + target_repo, + config_data, + layer_data, + app_layer_media_type, + &manifest, + &push_auth, + base_image, + &base_auth, + ) + .await?; + + let (os, arch, variant) = parse_platform_string(platform_str)?; + let digest = digest_ref.split('@').next_back().unwrap_or("").to_string(); + + info!("Pushed platform image: {} ({})", digest_ref, platform_str); + + Ok(Some(ManifestDescriptor { + media_type: "application/vnd.oci.image.manifest.v1+json".to_string(), + size: manifest_size as i64, + digest, + platform: Platform { + architecture: arch, + os, + variant, + }, + })) +} + +/// Push a manifest list, optionally tagged. +async fn push_tagged_manifest_list( + registry_client: &mut RegistryClient, + target_repo: &str, + manifest_descriptors: Vec, + tag: &Option, +) -> Result { + info!("Creating and pushing manifest list..."); + + let has_tag = tag.is_some(); + let manifest_target = if let Some(tag_name) = tag { + format!("{}:{}", target_repo, tag_name) + } else { + target_repo.to_string() + }; + + let final_auth = resolve_auth(&manifest_target)?; + + registry_client + .push_manifest_list(&manifest_target, manifest_descriptors, &final_auth, has_tag) + .await +} + /// Resolve krust:// references in YAML files async fn resolve_yaml_files( filenames: Vec, @@ -341,107 +337,50 @@ async fn resolve_yaml_files( for krust_path in all_references { info!("Building image for: krust://{}", krust_path); - // Resolve the path (could be relative like ./example/hello-krust) let project_path = PathBuf::from(&krust_path); - if !project_path.exists() { anyhow::bail!("Path does not exist: {}", krust_path); } - // Get project name let project_name = get_project_name(&project_path)?; let target_repo = format!("{}/{}", repo, project_name); - // Load project config let project_config = Config::load_project_config(&project_path)?; let base_image = project_config .base_image .unwrap_or(config.base_image.clone()); - // Determine platforms let platforms = if let Some(ref platforms) = platform { platforms.clone() } else { - // Default to linux/amd64 for resolve vec!["linux/amd64".to_string()] }; // Build for each platform let mut manifest_descriptors = Vec::new(); - for platform_str in &platforms { - info!("Building {} for platform: {}", krust_path, platform_str); - - let target = get_rust_target_triple(platform_str)?; - let builder = RustBuilder::new(&project_path, &target); - let build_result = builder.build()?; - - let image_builder = ImageBuilder::new( - build_result.binary_path, - base_image.clone(), - platform_str.clone(), - ); - - let base_auth = resolve_auth(&base_image)?; - let (config_data, layer_data, manifest) = image_builder - .build(&mut registry_client, &base_auth) - .await?; - - // Push the image - let push_auth = resolve_auth(&target_repo)?; - let app_layer_media_type = manifest - .layers - .last() - .map(|l| l.media_type.clone()) - .unwrap_or_else(|| "application/vnd.docker.image.rootfs.diff.tar.gzip".to_string()); - - let (digest_ref, manifest_size) = registry_client - .push_layered_image( - &target_repo, - config_data, - layer_data, - app_layer_media_type, - &manifest, - &push_auth, - &base_image, - &base_auth, - ) - .await?; - - // Parse platform - let parts: Vec<&str> = platform_str.split('/').collect(); - let (os, arch) = if parts.len() >= 2 { - (parts[0].to_string(), parts[1].to_string()) - } else { - return Err(anyhow::anyhow!("Invalid platform format: {}", platform_str)); - }; - - let digest = digest_ref.split('@').next_back().unwrap_or("").to_string(); - - manifest_descriptors.push(ManifestDescriptor { - media_type: "application/vnd.oci.image.manifest.v1+json".to_string(), - size: manifest_size as i64, - digest, - platform: Platform { - architecture: arch, - os, - variant: None, - }, - }); + if let Some(descriptor) = build_and_push_platform( + &project_path, + &base_image, + &target_repo, + platform_str, + Vec::new(), + true, + ) + .await? + { + manifest_descriptors.push(descriptor); + } } // Push manifest list - let has_tag = tag.is_some(); - let manifest_target = if let Some(tag_name) = &tag { - format!("{}:{}", target_repo, tag_name) - } else { - target_repo.clone() - }; - - let final_auth = resolve_auth(&manifest_target)?; - let image_ref = registry_client - .push_manifest_list(&manifest_target, manifest_descriptors, &final_auth, has_tag) - .await?; + let image_ref = push_tagged_manifest_list( + &mut registry_client, + &target_repo, + manifest_descriptors, + &tag, + ) + .await?; info!("Resolved krust://{} -> {}", krust_path, image_ref); replacements.insert(krust_path, image_ref); diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 8176833..1ade6fd 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -32,13 +32,7 @@ pub struct OciImageManifest { pub annotations: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Platform { - pub architecture: String, - pub os: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub variant: Option, -} +use crate::manifest::Platform; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageIndexEntry { @@ -185,20 +179,16 @@ impl ImageReference { pub struct RegistryClient { client: reqwest::Client, - #[allow(dead_code)] - auth_cache: HashMap, // registry -> token } impl RegistryClient { pub fn new() -> Result { - // Create two clients - one with redirects, one without let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(300)) .build()?; - Ok(Self { - client, - auth_cache: HashMap::new(), - }) + Ok(Self { client }) } /// Check if a blob exists in the registry using HEAD request @@ -385,10 +375,13 @@ impl RegistryClient { challenge.scope.clone() }; - let token_url = format!( - "{}?service={}&scope={}", - challenge.realm, challenge.service, scope - ); + let mut token_url = + reqwest::Url::parse(&challenge.realm).context("Invalid token realm URL")?; + token_url + .query_pairs_mut() + .append_pair("service", &challenge.service) + .append_pair("scope", &scope); + let token_url = token_url.to_string(); let response = self.client.get(&token_url).send().await?; @@ -418,10 +411,13 @@ impl RegistryClient { challenge.scope.clone() }; - let token_url = format!( - "{}?service={}&scope={}", - challenge.realm, challenge.service, scope - ); + let mut token_url = + reqwest::Url::parse(&challenge.realm).context("Invalid token realm URL")?; + token_url + .query_pairs_mut() + .append_pair("service", &challenge.service) + .append_pair("scope", &scope); + let token_url = token_url.to_string(); let auth_header = format!("{}:{}", username, password); let encoded_auth = base64::engine::general_purpose::STANDARD.encode(auth_header.as_bytes()); @@ -445,11 +441,23 @@ impl RegistryClient { } } - // Pull a manifest from the registry + // Pull a manifest from the registry, optionally filtering by platform + // when the manifest is an image index. pub async fn pull_manifest( &mut self, image_ref: &str, auth: &RegistryAuth, + ) -> Result<(OciImageManifest, String)> { + self.pull_manifest_for_platform(image_ref, auth, None).await + } + + // Pull a manifest from the registry, selecting the given platform from + // an image index if present. + async fn pull_manifest_for_platform( + &mut self, + image_ref: &str, + auth: &RegistryAuth, + platform: Option<&str>, ) -> Result<(OciImageManifest, String)> { debug!("Parsing image reference: {}", image_ref); let reference = ImageReference::parse(image_ref)?; @@ -476,7 +484,7 @@ impl RegistryClient { let mut req = self.client .get(&url) - .header("Accept", "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"); + .header("Accept", "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json"); if let Some(token) = token { req = req.header("Authorization", format!("Bearer {}", token)); @@ -488,69 +496,135 @@ impl RegistryClient { anyhow::bail!("Failed to pull manifest: {}", response.status()); } - let digest = response + let header_digest = response .headers() .get("docker-content-digest") .and_then(|h| h.to_str().ok()) - .unwrap_or("") - .to_string(); + .map(|s| s.to_string()); let body = response.bytes().await?; + + // Use header digest if available, otherwise compute from body + let index_digest = + header_digest.unwrap_or_else(|| format!("sha256:{}", sha256::digest(body.as_ref()))); debug!("Manifest response body: {}", String::from_utf8_lossy(&body)); // Try to parse as either image manifest or image index - let manifest: OciImageManifest = if let Ok(image_manifest) = - serde_json::from_slice::(&body) - { - image_manifest - } else if let Ok(image_index) = serde_json::from_slice::(&body) { - // If it's an image index, we need to find the specific platform manifest - // For now, just take the first one (this should be enhanced to match platform) - if let Some(first_manifest) = image_index.manifests.first() { - // Pull the platform-specific manifest directly - let platform_digest = &first_manifest.digest; - let url = format!( - "https://{}/v2/{}/manifests/{}", - reference.registry, reference.repository, platform_digest - ); - - debug!("Pulling platform-specific manifest from URL: {}", url); - - let mut req = self.client - .get(&url) - .header("Accept", "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"); - - // Re-authenticate for the platform-specific request - let platform_token = self - .authenticate(&reference.registry, &reference.repository, auth) - .await?; - if let Some(token) = platform_token { - req = req.header("Authorization", format!("Bearer {}", token)); + let (manifest, digest) = + if let Ok(image_manifest) = serde_json::from_slice::(&body) { + // Check if this is actually an image index by looking at the media type + if image_manifest.media_type.contains("index") + || image_manifest.media_type.contains("manifest.list") + { + // Re-parse as index + let image_index: OciImageIndex = serde_json::from_slice(&body)?; + self.select_platform_manifest(&reference, &image_index, auth, platform) + .await? + } else { + (image_manifest, index_digest) } - - let response = req.send().await?; - - if !response.status().is_success() { - anyhow::bail!("Failed to pull platform manifest: {}", response.status()); - } - - let platform_body = response.bytes().await?; - debug!( - "Platform manifest response body: {}", - String::from_utf8_lossy(&platform_body) - ); - - serde_json::from_slice::(&platform_body)? + } else if let Ok(image_index) = serde_json::from_slice::(&body) { + self.select_platform_manifest(&reference, &image_index, auth, platform) + .await? } else { - anyhow::bail!("Image index has no manifests"); - } - } else { - anyhow::bail!("Response is neither a valid image manifest nor image index"); - }; + anyhow::bail!("Response is neither a valid image manifest nor image index"); + }; Ok((manifest, digest)) } + /// Select and pull a platform-specific manifest from an image index. + /// Returns the manifest and its digest (from the platform-specific response). + async fn select_platform_manifest( + &mut self, + reference: &ImageReference, + image_index: &OciImageIndex, + auth: &RegistryAuth, + platform: Option<&str>, + ) -> Result<(OciImageManifest, String)> { + let selected = if let Some(platform_str) = platform { + // Parse the requested platform using the shared parser + let (req_os, req_arch, req_variant) = + crate::image::parse_platform_string(platform_str)?; + + // Find a matching manifest entry + image_index + .manifests + .iter() + .find(|entry| { + if let Some(p) = &entry.platform { + let os_match = p.os == req_os; + let arch_match = p.architecture == req_arch; + let variant_match = match &req_variant { + Some(v) => p.variant.as_deref() == Some(v.as_str()), + None => true, + }; + os_match && arch_match && variant_match + } else { + false + } + }) + .ok_or_else(|| { + anyhow::anyhow!( + "No manifest found for platform {} in image index", + platform_str + ) + })? + } else { + // No platform specified, take the first entry + image_index + .manifests + .first() + .ok_or_else(|| anyhow::anyhow!("Image index has no manifests"))? + }; + + let platform_digest = &selected.digest; + let url = format!( + "https://{}/v2/{}/manifests/{}", + reference.registry, reference.repository, platform_digest + ); + + debug!("Pulling platform-specific manifest from URL: {}", url); + + let mut req = self.client + .get(&url) + .header("Accept", "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"); + + let platform_token = self + .authenticate(&reference.registry, &reference.repository, auth) + .await?; + if let Some(token) = platform_token { + req = req.header("Authorization", format!("Bearer {}", token)); + } + + let response = req.send().await?; + + if !response.status().is_success() { + anyhow::bail!("Failed to pull platform manifest: {}", response.status()); + } + + let header_digest = response + .headers() + .get("docker-content-digest") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + let platform_body = response.bytes().await?; + + // Use header digest if available, otherwise compute from body + let platform_digest = header_digest + .unwrap_or_else(|| format!("sha256:{}", sha256::digest(platform_body.as_ref()))); + debug!( + "Platform manifest response body: {}", + String::from_utf8_lossy(&platform_body) + ); + + Ok(( + serde_json::from_slice::(&platform_body)?, + platform_digest, + )) + } + // Pull a blob from the registry pub async fn pull_blob( &mut self, @@ -805,13 +879,13 @@ impl RegistryClient { anyhow::bail!("Failed to upload blob: {} - {}", monolithic_status, body) } - // Push a manifest to the registry + // Push a manifest to the registry, returns the digest string pub async fn push_manifest( &mut self, image_ref: &str, manifest: &OciImageManifest, auth: &RegistryAuth, - ) -> Result<(String, String)> { + ) -> Result { let reference = ImageReference::parse(image_ref)?; let manifest_json = serde_json::to_vec_pretty(manifest)?; let manifest_digest = format!("sha256:{}", sha256::digest(&manifest_json)); @@ -827,11 +901,7 @@ impl RegistryClient { .await? { debug!("Manifest {} already exists, skipping push", manifest_digest); - let digest_ref = format!( - "{}/{}@{}", - reference.registry, reference.repository, manifest_digest - ); - return Ok((digest_ref, manifest_digest)); + return Ok(manifest_digest); } info!("Pushing manifest with digest: {}", manifest_digest); @@ -871,16 +941,10 @@ impl RegistryClient { let digest = headers .get("docker-content-digest") .and_then(|h| h.to_str().ok()) - .unwrap_or("") + .unwrap_or(&manifest_digest) .to_string(); - let location = headers - .get("location") - .and_then(|h| h.to_str().ok()) - .unwrap_or(&url) - .to_string(); - - Ok((location, digest)) + Ok(digest) } // Legacy methods for compatibility with existing code @@ -929,7 +993,7 @@ impl RegistryClient { annotations: None, }; - let (_, digest) = self.push_manifest(repository, &manifest, auth).await?; + let digest = self.push_manifest(repository, &manifest, auth).await?; let reference = ImageReference::parse(repository)?; let digest_ref = format!("{}/{}@{}", reference.registry, reference.repository, digest); let manifest_size = serde_json::to_vec(&manifest)?.len(); @@ -940,10 +1004,12 @@ impl RegistryClient { pub async fn fetch_image_data( &mut self, image_ref: &str, - _platform: &str, + platform: &str, auth: &RegistryAuth, ) -> Result<(OciImageManifest, crate::image::ImageConfig)> { - let (manifest, _digest) = self.pull_manifest(image_ref, auth).await?; + let (manifest, _digest) = self + .pull_manifest_for_platform(image_ref, auth, Some(platform)) + .await?; if let Some(config_descriptor) = &manifest.config { let config_data = self.pull_blob(image_ref, config_descriptor, auth).await?; @@ -956,12 +1022,75 @@ impl RegistryClient { pub async fn get_image_platforms( &mut self, - _image_ref: &str, - _auth: &RegistryAuth, + image_ref: &str, + auth: &RegistryAuth, ) -> Result> { - // For now, return default platforms - this would need to be enhanced - // to actually fetch and parse image indexes - Ok(vec!["linux/amd64".to_string(), "linux/arm64".to_string()]) + let reference = ImageReference::parse(image_ref)?; + let token = self + .authenticate(&reference.registry, &reference.repository, auth) + .await?; + + let manifest_ref = if let Some(digest) = &reference.digest { + digest.clone() + } else { + reference.tag.as_deref().unwrap_or("latest").to_string() + }; + + let url = format!( + "https://{}/v2/{}/manifests/{}", + reference.registry, reference.repository, manifest_ref + ); + + let mut req = self.client + .get(&url) + .header("Accept", "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"); + + if let Some(token) = token { + req = req.header("Authorization", format!("Bearer {}", token)); + } + + let response = req.send().await?; + + if !response.status().is_success() { + anyhow::bail!( + "Failed to fetch manifest for platform detection: {}", + response.status() + ); + } + + let body = response.bytes().await?; + + // Try to parse as an image index + if let Ok(image_index) = serde_json::from_slice::(&body) { + let platforms: Vec = image_index + .manifests + .iter() + .filter_map(|entry| { + entry.platform.as_ref().map(|p| { + if let Some(variant) = &p.variant { + format!("{}/{}/{}", p.os, p.architecture, variant) + } else { + format!("{}/{}", p.os, p.architecture) + } + }) + }) + .collect(); + Ok(platforms) + } else if let Ok(manifest) = serde_json::from_slice::(&body) { + // Single-platform image — read the config to determine its platform + let config_descriptor = manifest.config.as_ref().ok_or_else(|| { + anyhow::anyhow!("Single-platform manifest has no config descriptor") + })?; + let config_data = self.pull_blob(image_ref, config_descriptor, auth).await?; + let config = serde_json::from_slice::(&config_data) + .context("Failed to parse image config for platform detection")?; + Ok(vec![format!("{}/{}", config.os, config.architecture)]) + } else { + anyhow::bail!( + "Response is neither a valid image index nor image manifest; \ + cannot detect platforms" + ) + } } /// Push a layered image where only the top layer is new @@ -1055,7 +1184,7 @@ impl RegistryClient { annotations: None, }; - let (_, digest) = self.push_manifest(repository, &oci_manifest, auth).await?; + let digest = self.push_manifest(repository, &oci_manifest, auth).await?; let digest_ref = format!( "{}/{}@{}", target_reference.registry, target_reference.repository, digest @@ -1092,11 +1221,7 @@ impl RegistryClient { media_type: m.media_type.clone(), digest: m.digest.clone(), size: m.size, - platform: Some(Platform { - architecture: m.platform.architecture.clone(), - os: m.platform.os.clone(), - variant: m.platform.variant.clone(), - }), + platform: Some(m.platform.clone()), annotations: None, }) .collect();