1
0
Fork 0
mirror of https://github.com/imjasonh/krust synced 2026-07-22 16:01:49 +00:00

Merge branch 'main' into dependabot/github_actions/dtolnay/rust-toolchain-f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561

This commit is contained in:
Jason Hall 2026-03-17 15:46:51 -04:00 committed by GitHub
commit 31e5c5a5a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 495 additions and 342 deletions

View file

@ -19,7 +19,7 @@ jobs:
os: [ubuntu-latest, ubuntu-24.04-arm] os: [ubuntu-latest, ubuntu-24.04-arm]
rust: [stable, beta] rust: [stable, beta]
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master
with: with:
toolchain: ${{ matrix.rust }} toolchain: ${{ matrix.rust }}
@ -30,17 +30,17 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu
- name: Cache cargo registry - name: Cache cargo registry
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
with: with:
path: ~/.cargo/registry path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index - name: Cache cargo index
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
with: with:
path: ~/.cargo/git path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build - name: Cache cargo build
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
with: with:
path: target path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}

View file

@ -19,9 +19,9 @@ sha256 = "1.5"
chrono = "0.4" chrono = "0.4"
tar = "0.4" tar = "0.4"
flate2 = "1.0" flate2 = "1.0"
reqwest = { version = "0.12", features = ["json", "stream"] } reqwest = { version = "0.13", features = ["json", "stream"] }
bytes = "1.0" bytes = "1.0"
yaml-rust2 = "0.10" yaml-rust2 = "0.11"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dirs = "6.0" dirs = "6.0"

View file

@ -205,25 +205,25 @@ mod unit_tests {
// Test anonymous // Test anonymous
let auth = AuthConfig::anonymous(); let auth = AuthConfig::anonymous();
matches!(auth.to_registry_auth(), RegistryAuth::Anonymous); assert!(matches!(auth.to_registry_auth(), RegistryAuth::Anonymous));
// Test basic auth // Test basic auth
let auth = AuthConfig::new("user".to_string(), "pass".to_string()); let auth = AuthConfig::new("user".to_string(), "pass".to_string());
matches!( assert!(matches!(
auth.to_registry_auth(), auth.to_registry_auth(),
RegistryAuth::Basic { username, password } RegistryAuth::Basic { ref username, ref password }
if username == "user" && password == "pass" if username == "user" && password == "pass"
); ));
// Test bearer token // Test bearer token
let auth = AuthConfig { let auth = AuthConfig {
registry_token: Some("token123".to_string()), registry_token: Some("token123".to_string()),
..Default::default() ..Default::default()
}; };
matches!( assert!(matches!(
auth.to_registry_auth(), auth.to_registry_auth(),
RegistryAuth::Bearer { token } RegistryAuth::Bearer { ref token }
if token == "token123" if token == "token123"
); ));
} }
} }

View file

@ -108,4 +108,75 @@ version = "0.1.0"
assert_eq!(builder.cargo_args, vec!["--features", "foo"]); 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);
}
} }

View file

@ -44,12 +44,6 @@ pub enum Commands {
cargo_args: Vec<String>, cargo_args: Vec<String>,
}, },
/// Push a built image to a container registry
Push {
/// Image reference to push
image: String,
},
/// Resolve krust:// references in YAML files /// Resolve krust:// references in YAML files
Resolve { Resolve {
/// Path to YAML file or directory containing YAML files /// Path to YAML file or directory containing YAML files

View file

@ -20,7 +20,7 @@ pub struct Config {
/// Registry authentication configuration /// Registry authentication configuration
#[serde(default)] #[serde(default)]
pub registries: HashMap<String, RegistryAuth>, pub registries: HashMap<String, RegistryCredential>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@ -38,7 +38,7 @@ pub struct BuildConfig {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryAuth { pub struct RegistryCredential {
pub username: Option<String>, pub username: Option<String>,
pub password: Option<String>, pub password: Option<String>,
pub auth: Option<String>, pub auth: Option<String>,

View file

@ -79,6 +79,20 @@ pub struct Descriptor {
pub digest: String, 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<String>)> {
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 { pub struct ImageBuilder {
binary_path: PathBuf, binary_path: PathBuf,
#[allow(dead_code)] #[allow(dead_code)]
@ -104,7 +118,7 @@ impl ImageBuilder {
) -> Result<(Vec<u8>, Vec<u8>, Manifest)> { ) -> Result<(Vec<u8>, Vec<u8>, Manifest)> {
info!("Building container image"); info!("Building container image");
let (_os, _arch) = self.parse_platform()?; let (_os, _arch, _variant) = self.parse_platform()?;
// Fetch base image data // Fetch base image data
info!( info!(
@ -133,7 +147,7 @@ impl ImageBuilder {
// Add the application layer // Add the application layer
all_layers.push(Descriptor { 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, size: app_layer_size,
digest: app_layer_digest, digest: app_layer_digest,
}); });
@ -147,9 +161,9 @@ impl ImageBuilder {
// Create manifest // Create manifest
let manifest = Manifest { let manifest = Manifest {
schema_version: 2, 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 { 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, size: config_size,
digest: config_digest, digest: config_digest,
}, },
@ -159,12 +173,8 @@ impl ImageBuilder {
Ok((config_data, app_layer_data, manifest)) Ok((config_data, app_layer_data, manifest))
} }
fn parse_platform(&self) -> Result<(String, String)> { fn parse_platform(&self) -> Result<(String, String, Option<String>)> {
let parts: Vec<&str> = self.platform.split('/').collect(); parse_platform_string(&self.platform)
if parts.len() != 2 {
anyhow::bail!("Invalid platform format: {}", self.platform);
}
Ok((parts[0].to_string(), parts[1].to_string()))
} }
fn create_layer(&self) -> Result<(Vec<u8>, String)> { fn create_layer(&self) -> Result<(Vec<u8>, String)> {
@ -264,13 +274,12 @@ mod tests {
use std::path::PathBuf; use std::path::PathBuf;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
fn create_test_binary() -> PathBuf { fn create_test_binary() -> (PathBuf, tempfile::TempPath) {
let mut temp_file = NamedTempFile::new().unwrap(); let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(b"fake binary content").unwrap(); temp_file.write_all(b"fake binary content").unwrap();
let path = temp_file.path().to_path_buf(); let temp_path = temp_file.into_temp_path();
// Keep the file alive by converting to a regular file let path = temp_path.to_path_buf();
std::fs::copy(&path, &path).unwrap(); (path, temp_path)
path
} }
fn create_base_image_config() -> ImageConfig { fn create_base_image_config() -> ImageConfig {
@ -318,14 +327,29 @@ mod tests {
"linux/amd64".to_string(), "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!(os, "linux");
assert_eq!(arch, "amd64"); 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] #[test]
fn test_create_layered_config_preserves_base_environment() { 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( let builder = ImageBuilder::new(
binary_path, binary_path,
"test-base".to_string(), "test-base".to_string(),
@ -359,7 +383,7 @@ mod tests {
#[test] #[test]
fn test_create_layered_config_combines_diff_ids() { 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( let builder = ImageBuilder::new(
binary_path, binary_path,
"test-base".to_string(), "test-base".to_string(),
@ -382,7 +406,7 @@ mod tests {
#[test] #[test]
fn test_create_layered_config_combines_history() { fn test_create_layered_config_combines_history() {
let binary_path = create_test_binary(); let (binary_path, _guard) = create_test_binary();
let builder = ImageBuilder::new( let builder = ImageBuilder::new(
binary_path, binary_path,
"test-base".to_string(), "test-base".to_string(),
@ -524,7 +548,7 @@ mod tests {
#[test] #[test]
fn test_create_layered_config_adds_path_if_missing() { 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( let builder = ImageBuilder::new(
binary_path, binary_path,
"test-base".to_string(), "test-base".to_string(),
@ -546,7 +570,7 @@ mod tests {
#[test] #[test]
fn test_create_layered_config_sets_cmd() { fn test_create_layered_config_sets_cmd() {
let binary_path = create_test_binary(); let (binary_path, _guard) = create_test_binary();
let builder = ImageBuilder::new( let builder = ImageBuilder::new(
binary_path.clone(), binary_path.clone(),
"test-base".to_string(), "test-base".to_string(),

View file

@ -5,14 +5,14 @@ use krust::{
builder::{get_rust_target_triple, RustBuilder}, builder::{get_rust_target_triple, RustBuilder},
cli::{Cli, Commands}, cli::{Cli, Commands},
config::Config, config::Config,
image::ImageBuilder, image::{parse_platform_string, ImageBuilder},
manifest::{ManifestDescriptor, Platform}, manifest::{ManifestDescriptor, Platform},
registry::RegistryClient, registry::RegistryClient,
resolve::{find_krust_references, read_yaml_files, replace_krust_references}, resolve::{find_krust_references, read_yaml_files, replace_krust_references},
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::{error, info}; use tracing::info;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
#[tokio::main] #[tokio::main]
@ -102,98 +102,17 @@ async fn main() -> Result<()> {
let no_push_flag = no_push; let no_push_flag = no_push;
let task = tokio::spawn(async move { 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 Ok::<_, anyhow::Error>(descriptor)
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)
}); });
tasks.push(task); tasks.push(task);
@ -210,32 +129,16 @@ async fn main() -> Result<()> {
// Always push manifest list if not --no-push (even for single platform) // Always push manifest list if not --no-push (even for single platform)
if !no_push { if !no_push {
info!("Creating and pushing manifest list..."); let image_ref = push_tagged_manifest_list(
&mut registry_client,
// Determine the target for the manifest list &target_repo,
let has_tag = tag.is_some(); manifest_descriptors,
let manifest_target = if let Some(tag_name) = &tag { &tag,
// If --tag is specified, push to that tag )
format!("{}:{}", target_repo, tag_name) .await?;
} 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?;
// Output the manifest list reference (always by digest) // Output the manifest list reference (always by digest)
println!("{}", manifest_list_ref); println!("{}", image_ref);
} else { } else {
info!( info!(
"Successfully built image for {} platform(s)", "Successfully built image for {} platform(s)",
@ -244,11 +147,6 @@ async fn main() -> Result<()> {
info!("Skipping push (--no-push specified)"); info!("Skipping push (--no-push specified)");
} }
} }
Commands::Push { image } => {
let _ = image;
error!("Push command not yet implemented");
std::process::exit(1);
}
Commands::Resolve { Commands::Resolve {
filenames, filenames,
platform, platform,
@ -306,6 +204,104 @@ async fn main() -> Result<()> {
Ok(()) 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<String>,
push: bool,
) -> Result<Option<ManifestDescriptor>> {
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<ManifestDescriptor>,
tag: &Option<String>,
) -> Result<String> {
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 /// Resolve krust:// references in YAML files
async fn resolve_yaml_files( async fn resolve_yaml_files(
filenames: Vec<PathBuf>, filenames: Vec<PathBuf>,
@ -341,107 +337,50 @@ async fn resolve_yaml_files(
for krust_path in all_references { for krust_path in all_references {
info!("Building image for: krust://{}", krust_path); info!("Building image for: krust://{}", krust_path);
// Resolve the path (could be relative like ./example/hello-krust)
let project_path = PathBuf::from(&krust_path); let project_path = PathBuf::from(&krust_path);
if !project_path.exists() { if !project_path.exists() {
anyhow::bail!("Path does not exist: {}", krust_path); anyhow::bail!("Path does not exist: {}", krust_path);
} }
// Get project name
let project_name = get_project_name(&project_path)?; let project_name = get_project_name(&project_path)?;
let target_repo = format!("{}/{}", repo, project_name); let target_repo = format!("{}/{}", repo, project_name);
// Load project config
let project_config = Config::load_project_config(&project_path)?; let project_config = Config::load_project_config(&project_path)?;
let base_image = project_config let base_image = project_config
.base_image .base_image
.unwrap_or(config.base_image.clone()); .unwrap_or(config.base_image.clone());
// Determine platforms
let platforms = if let Some(ref platforms) = platform { let platforms = if let Some(ref platforms) = platform {
platforms.clone() platforms.clone()
} else { } else {
// Default to linux/amd64 for resolve
vec!["linux/amd64".to_string()] vec!["linux/amd64".to_string()]
}; };
// Build for each platform // Build for each platform
let mut manifest_descriptors = Vec::new(); let mut manifest_descriptors = Vec::new();
for platform_str in &platforms { for platform_str in &platforms {
info!("Building {} for platform: {}", krust_path, platform_str); if let Some(descriptor) = build_and_push_platform(
&project_path,
let target = get_rust_target_triple(platform_str)?; &base_image,
let builder = RustBuilder::new(&project_path, &target); &target_repo,
let build_result = builder.build()?; platform_str,
Vec::new(),
let image_builder = ImageBuilder::new( true,
build_result.binary_path, )
base_image.clone(), .await?
platform_str.clone(), {
); manifest_descriptors.push(descriptor);
}
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,
},
});
} }
// Push manifest list // Push manifest list
let has_tag = tag.is_some(); let image_ref = push_tagged_manifest_list(
let manifest_target = if let Some(tag_name) = &tag { &mut registry_client,
format!("{}:{}", target_repo, tag_name) &target_repo,
} else { manifest_descriptors,
target_repo.clone() &tag,
}; )
.await?;
let final_auth = resolve_auth(&manifest_target)?;
let image_ref = registry_client
.push_manifest_list(&manifest_target, manifest_descriptors, &final_auth, has_tag)
.await?;
info!("Resolved krust://{} -> {}", krust_path, image_ref); info!("Resolved krust://{} -> {}", krust_path, image_ref);
replacements.insert(krust_path, image_ref); replacements.insert(krust_path, image_ref);

View file

@ -32,13 +32,7 @@ pub struct OciImageManifest {
pub annotations: Option<HashMap<String, String>>, pub annotations: Option<HashMap<String, String>>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] use crate::manifest::Platform;
pub struct Platform {
pub architecture: String,
pub os: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageIndexEntry { pub struct ImageIndexEntry {
@ -185,20 +179,16 @@ impl ImageReference {
pub struct RegistryClient { pub struct RegistryClient {
client: reqwest::Client, client: reqwest::Client,
#[allow(dead_code)]
auth_cache: HashMap<String, String>, // registry -> token
} }
impl RegistryClient { impl RegistryClient {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
// Create two clients - one with redirects, one without
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.connect_timeout(std::time::Duration::from_secs(30))
.timeout(std::time::Duration::from_secs(300))
.build()?; .build()?;
Ok(Self { Ok(Self { client })
client,
auth_cache: HashMap::new(),
})
} }
/// Check if a blob exists in the registry using HEAD request /// Check if a blob exists in the registry using HEAD request
@ -385,10 +375,13 @@ impl RegistryClient {
challenge.scope.clone() challenge.scope.clone()
}; };
let token_url = format!( let mut token_url =
"{}?service={}&scope={}", reqwest::Url::parse(&challenge.realm).context("Invalid token realm URL")?;
challenge.realm, challenge.service, scope 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?; let response = self.client.get(&token_url).send().await?;
@ -418,10 +411,13 @@ impl RegistryClient {
challenge.scope.clone() challenge.scope.clone()
}; };
let token_url = format!( let mut token_url =
"{}?service={}&scope={}", reqwest::Url::parse(&challenge.realm).context("Invalid token realm URL")?;
challenge.realm, challenge.service, scope 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 auth_header = format!("{}:{}", username, password);
let encoded_auth = base64::engine::general_purpose::STANDARD.encode(auth_header.as_bytes()); 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( pub async fn pull_manifest(
&mut self, &mut self,
image_ref: &str, image_ref: &str,
auth: &RegistryAuth, 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)> { ) -> Result<(OciImageManifest, String)> {
debug!("Parsing image reference: {}", image_ref); debug!("Parsing image reference: {}", image_ref);
let reference = ImageReference::parse(image_ref)?; let reference = ImageReference::parse(image_ref)?;
@ -476,7 +484,7 @@ impl RegistryClient {
let mut req = self.client let mut req = self.client
.get(&url) .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 { if let Some(token) = token {
req = req.header("Authorization", format!("Bearer {}", token)); req = req.header("Authorization", format!("Bearer {}", token));
@ -488,69 +496,135 @@ impl RegistryClient {
anyhow::bail!("Failed to pull manifest: {}", response.status()); anyhow::bail!("Failed to pull manifest: {}", response.status());
} }
let digest = response let header_digest = response
.headers() .headers()
.get("docker-content-digest") .get("docker-content-digest")
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.unwrap_or("") .map(|s| s.to_string());
.to_string();
let body = response.bytes().await?; 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)); debug!("Manifest response body: {}", String::from_utf8_lossy(&body));
// Try to parse as either image manifest or image index // Try to parse as either image manifest or image index
let manifest: OciImageManifest = if let Ok(image_manifest) = let (manifest, digest) =
serde_json::from_slice::<OciImageManifest>(&body) if let Ok(image_manifest) = serde_json::from_slice::<OciImageManifest>(&body) {
{ // Check if this is actually an image index by looking at the media type
image_manifest if image_manifest.media_type.contains("index")
} else if let Ok(image_index) = serde_json::from_slice::<OciImageIndex>(&body) { || image_manifest.media_type.contains("manifest.list")
// 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) // Re-parse as index
if let Some(first_manifest) = image_index.manifests.first() { let image_index: OciImageIndex = serde_json::from_slice(&body)?;
// Pull the platform-specific manifest directly self.select_platform_manifest(&reference, &image_index, auth, platform)
let platform_digest = &first_manifest.digest; .await?
let url = format!( } else {
"https://{}/v2/{}/manifests/{}", (image_manifest, index_digest)
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));
} }
} else if let Ok(image_index) = serde_json::from_slice::<OciImageIndex>(&body) {
let response = req.send().await?; self.select_platform_manifest(&reference, &image_index, auth, platform)
.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::<OciImageManifest>(&platform_body)?
} else { } else {
anyhow::bail!("Image index has no manifests"); anyhow::bail!("Response is neither a valid image manifest nor image index");
} };
} else {
anyhow::bail!("Response is neither a valid image manifest nor image index");
};
Ok((manifest, digest)) 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::<OciImageManifest>(&platform_body)?,
platform_digest,
))
}
// Pull a blob from the registry // Pull a blob from the registry
pub async fn pull_blob( pub async fn pull_blob(
&mut self, &mut self,
@ -805,13 +879,13 @@ impl RegistryClient {
anyhow::bail!("Failed to upload blob: {} - {}", monolithic_status, body) 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( pub async fn push_manifest(
&mut self, &mut self,
image_ref: &str, image_ref: &str,
manifest: &OciImageManifest, manifest: &OciImageManifest,
auth: &RegistryAuth, auth: &RegistryAuth,
) -> Result<(String, String)> { ) -> Result<String> {
let reference = ImageReference::parse(image_ref)?; let reference = ImageReference::parse(image_ref)?;
let manifest_json = serde_json::to_vec_pretty(manifest)?; let manifest_json = serde_json::to_vec_pretty(manifest)?;
let manifest_digest = format!("sha256:{}", sha256::digest(&manifest_json)); let manifest_digest = format!("sha256:{}", sha256::digest(&manifest_json));
@ -827,11 +901,7 @@ impl RegistryClient {
.await? .await?
{ {
debug!("Manifest {} already exists, skipping push", manifest_digest); debug!("Manifest {} already exists, skipping push", manifest_digest);
let digest_ref = format!( return Ok(manifest_digest);
"{}/{}@{}",
reference.registry, reference.repository, manifest_digest
);
return Ok((digest_ref, manifest_digest));
} }
info!("Pushing manifest with digest: {}", manifest_digest); info!("Pushing manifest with digest: {}", manifest_digest);
@ -871,16 +941,10 @@ impl RegistryClient {
let digest = headers let digest = headers
.get("docker-content-digest") .get("docker-content-digest")
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.unwrap_or("") .unwrap_or(&manifest_digest)
.to_string(); .to_string();
let location = headers Ok(digest)
.get("location")
.and_then(|h| h.to_str().ok())
.unwrap_or(&url)
.to_string();
Ok((location, digest))
} }
// Legacy methods for compatibility with existing code // Legacy methods for compatibility with existing code
@ -929,7 +993,7 @@ impl RegistryClient {
annotations: None, 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 reference = ImageReference::parse(repository)?;
let digest_ref = format!("{}/{}@{}", reference.registry, reference.repository, digest); let digest_ref = format!("{}/{}@{}", reference.registry, reference.repository, digest);
let manifest_size = serde_json::to_vec(&manifest)?.len(); let manifest_size = serde_json::to_vec(&manifest)?.len();
@ -940,10 +1004,12 @@ impl RegistryClient {
pub async fn fetch_image_data( pub async fn fetch_image_data(
&mut self, &mut self,
image_ref: &str, image_ref: &str,
_platform: &str, platform: &str,
auth: &RegistryAuth, auth: &RegistryAuth,
) -> Result<(OciImageManifest, crate::image::ImageConfig)> { ) -> 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 { if let Some(config_descriptor) = &manifest.config {
let config_data = self.pull_blob(image_ref, config_descriptor, auth).await?; let config_data = self.pull_blob(image_ref, config_descriptor, auth).await?;
@ -956,12 +1022,75 @@ impl RegistryClient {
pub async fn get_image_platforms( pub async fn get_image_platforms(
&mut self, &mut self,
_image_ref: &str, image_ref: &str,
_auth: &RegistryAuth, auth: &RegistryAuth,
) -> Result<Vec<String>> { ) -> Result<Vec<String>> {
// For now, return default platforms - this would need to be enhanced let reference = ImageReference::parse(image_ref)?;
// to actually fetch and parse image indexes let token = self
Ok(vec!["linux/amd64".to_string(), "linux/arm64".to_string()]) .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::<OciImageIndex>(&body) {
let platforms: Vec<String> = 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::<OciImageManifest>(&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::<crate::image::ImageConfig>(&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 /// Push a layered image where only the top layer is new
@ -1055,7 +1184,7 @@ impl RegistryClient {
annotations: None, 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!( let digest_ref = format!(
"{}/{}@{}", "{}/{}@{}",
target_reference.registry, target_reference.repository, digest target_reference.registry, target_reference.repository, digest
@ -1092,11 +1221,7 @@ impl RegistryClient {
media_type: m.media_type.clone(), media_type: m.media_type.clone(),
digest: m.digest.clone(), digest: m.digest.clone(),
size: m.size, size: m.size,
platform: Some(Platform { platform: Some(m.platform.clone()),
architecture: m.platform.architecture.clone(),
os: m.platform.os.clone(),
variant: m.platform.variant.clone(),
}),
annotations: None, annotations: None,
}) })
.collect(); .collect();