diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..1ed1ffe --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.x86_64-unknown-linux-musl] +linker = "x86_64-unknown-linux-musl-gcc" + +[target.aarch64-unknown-linux-musl] +linker = "aarch64-unknown-linux-musl-gcc" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 775ae5f..d310030 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest] + os: [ubuntu-latest, ubuntu-24.04-arm] rust: [stable, beta] steps: - uses: actions/checkout@v4 @@ -24,12 +24,17 @@ jobs: uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust }} - targets: x86_64-unknown-linux-musl - - name: Install cross-compilation tools (Ubuntu) + targets: x86_64-unknown-linux-musl,aarch64-unknown-linux-musl + - name: Install cross-compilation tools (Ubuntu x64) if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update - sudo apt-get install -y musl-tools + sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu + - name: Install cross-compilation tools (Ubuntu ARM) + if: matrix.os == 'ubuntu-24.04-arm' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools gcc-x86-64-linux-gnu - name: Cache cargo registry uses: actions/cache@v4 with: @@ -105,11 +110,11 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable with: - targets: x86_64-unknown-linux-musl + targets: x86_64-unknown-linux-musl,aarch64-unknown-linux-musl - name: Install musl tools run: | sudo apt-get update - sudo apt-get install -y musl-tools + sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build krust @@ -136,6 +141,10 @@ jobs: run: | cd example/hello-krust krust build --no-push --image local.test/hello:latest ./ + - name: Test multi-arch build + run: | + cd example/hello-krust + krust build --no-push --platform linux/amd64,linux/arm64 --image local.test/multiarch:latest ./ # Takes too long to run on CI, so it's commented out for now. # coverage: diff --git a/README.md b/README.md index 4549a5e..86a3863 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,14 @@ krust build example/hello-krust --no-push # Use a specific image name (overrides KRUST_REPO) krust build --image myregistry.io/myapp:v1.0 -# Build for a different platform +# Build for a specific platform krust build --platform linux/arm64 + +# Build for multiple platforms (multi-arch) +krust build --platform linux/amd64,linux/arm64 + +# Or specify platforms separately +krust build --platform linux/amd64 --platform linux/arm64 ``` ### Build with custom cargo arguments @@ -185,6 +191,7 @@ When determining the base image, krust uses this precedence order: - **Docker-free** - Builds OCI container images without requiring Docker daemon - **Static binaries** - Produces truly static binaries using musl libc - **Composable** - Outputs image digest to stdout, enabling `docker run $(krust build)` +- **Multi-arch support** - Build for multiple platforms in a single command - **Cross-platform** - Supports multiple architectures (amd64, arm64, arm/v7) - **Minimal images** - Uses distroless base images for security and size - **OCI compliant** - Works with any OCI-compliant container registry @@ -260,6 +267,15 @@ This is normal when building linux/amd64 images on Apple Silicon. The images wil git clone https://github.com/imjasonh/krust.git cd krust +# Install cross-compilation toolchain (required for tests) +# On macOS: +brew install messense/macos-cross-toolchains/x86_64-unknown-linux-musl +brew install messense/macos-cross-toolchains/aarch64-unknown-linux-musl + +# Install Rust targets +rustup target add x86_64-unknown-linux-musl +rustup target add aarch64-unknown-linux-musl # Optional, for ARM64 support + # Install pre-commit hooks pip install pre-commit or diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0f8793e..1d63a3a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -25,9 +25,10 @@ pub enum Commands { #[arg(short, long, env = "KRUST_IMAGE")] image: Option, - /// Target platform (e.g., linux/amd64, linux/arm64) - #[arg(long, default_value = "linux/amd64")] - platform: String, + /// Target platforms (e.g., linux/amd64, linux/arm64) + /// Can be specified multiple times or as a comma-separated list + #[arg(long, value_delimiter = ',')] + platform: Option>, /// Skip pushing the image to the registry after building #[arg(long)] diff --git a/src/lib.rs b/src/lib.rs index 75eb949..bfd2632 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod builder; pub mod cli; pub mod config; pub mod image; +pub mod manifest; pub mod registry; pub use anyhow::Result; diff --git a/src/main.rs b/src/main.rs index 850bb19..d67d64c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use krust::{ cli::{Cli, Commands}, config::Config, image::ImageBuilder, + manifest::{ImageIndex, ManifestDescriptor, Platform}, registry::RegistryClient, }; use std::path::{Path, PathBuf}; @@ -57,33 +58,100 @@ async fn main() -> Result<()> { format!("{}/{}:latest", repo, project_name) }; - // Build the Rust binary - let target = get_rust_target_triple(&platform)?; - let builder = RustBuilder::new(&project_path, &target).with_cargo_args(cargo_args); - - let binary_path = builder.build()?; - - // Build container image - let image_builder = ImageBuilder::new(binary_path, base_image, platform.clone()); - - let (config_data, layer_data, manifest) = image_builder.build()?; - - // Push by default unless --no-push is specified - if !no_push { - info!("Pushing image to registry..."); - let auth = oci_distribution::secrets::RegistryAuth::Anonymous; - let mut registry_client = RegistryClient::new(auth)?; - - let layers = vec![(layer_data, manifest.layers[0].media_type.clone())]; - - let digest_ref = registry_client - .push_image(&image_ref, config_data, layers) - .await?; - - // Print only the digest reference to stdout - println!("{}", digest_ref); + // Determine platforms to build for + let platforms = if let Some(platforms) = platform { + platforms } else { - info!("Successfully built image: {}", image_ref); + // Default to common platforms + vec!["linux/amd64".to_string(), "linux/arm64".to_string()] + }; + + // 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)?; + + for platform_str in &platforms { + 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.clone()); + + let binary_path = builder.build()?; + + // Build container image for this platform + let image_builder = + ImageBuilder::new(binary_path, base_image.clone(), platform_str.clone()); + + let (config_data, layer_data, manifest) = image_builder.build()?; + + // Push platform-specific image if not --no-push + if !no_push { + info!("Pushing image for platform: {}", platform_str); + + 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() + } else { + // For multi-platform, use platform-specific tag + format!("{}-{}", image_ref, platform_str.replace('/', "-")) + }; + + let digest_ref = registry_client + .push_image(&push_ref, config_data, layers) + .await?; + + // Store digest for single platform builds + if platforms.len() == 1 { + single_platform_digest = Some(digest_ref.clone()); + } + + // 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)); + }; + + // Add to manifest list + 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(), + platform: Platform { + architecture: arch, + os, + variant: None, + }, + }); + } + } + + // Create and push manifest list if not --no-push + if !no_push && platforms.len() > 1 { + 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); + } + } else { + info!( + "Successfully built image for {} platform(s)", + platforms.len() + ); info!("Skipping push (--no-push specified)"); } } diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..0c4e718 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +/// OCI Image Index (manifest list) for multi-arch support +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageIndex { + #[serde(rename = "schemaVersion")] + pub schema_version: i32, + #[serde(rename = "mediaType")] + pub media_type: String, + pub manifests: Vec, +} + +/// Descriptor for a platform-specific manifest in the index +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestDescriptor { + #[serde(rename = "mediaType")] + pub media_type: String, + pub size: i64, + pub digest: String, + pub platform: Platform, +} + +/// Platform information for a manifest +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Platform { + pub architecture: String, + pub os: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub variant: Option, +} + +impl ImageIndex { + pub fn new(manifests: Vec) -> Self { + Self { + schema_version: 2, + media_type: "application/vnd.oci.image.index.v1+json".to_string(), + manifests, + } + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index ea30910..7ade5d4 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -72,6 +72,8 @@ fn test_build_with_krust_repo_env() -> Result<()> { let mut cmd = Command::cargo_bin("krust")?; cmd.arg("build") .arg("--no-push") + .arg("--platform") + .arg("linux/amd64") .arg(".") // Explicitly pass current directory .env("KRUST_REPO", "test.local") .current_dir(&example_dir); @@ -80,7 +82,7 @@ fn test_build_with_krust_repo_env() -> Result<()> { .success() .stderr(predicate::str::contains("Building Rust project")) .stderr(predicate::str::contains( - "Successfully built image: test.local/hello-krust:latest", + "Successfully built image for 1 platform(s)", )); Ok(()) } @@ -95,6 +97,8 @@ fn test_command_substitution_syntax() -> Result<()> { let output = cmd .arg("build") .arg("--no-push") + .arg("--platform") + .arg("linux/amd64") .arg("--image") .arg("test.local/hello:latest") .arg(".") // Explicitly pass current directory @@ -123,6 +127,8 @@ fn test_verbose_logging() -> Result<()> { cmd.arg("--verbose") .arg("build") .arg("--no-push") + .arg("--platform") + .arg("linux/amd64") .arg("--image") .arg("test.local/hello:latest") .arg(".") // Explicitly pass current directory @@ -155,6 +161,8 @@ fn test_full_build_and_run_workflow() -> Result<()> { let mut cmd = Command::cargo_bin("krust")?; let output = cmd .arg("build") + .arg("--platform") + .arg("linux/amd64") .arg(".") // Explicitly pass current directory .env("KRUST_REPO", "ttl.sh/krust-test") .current_dir(&example_dir) @@ -176,3 +184,78 @@ fn test_full_build_and_run_workflow() -> Result<()> { assert!(docker_stdout.contains("Hello from krust example!")); Ok(()) } + +#[test] +fn test_single_platform_build() -> Result<()> { + let example_dir = env::current_dir()?.join("example").join("hello-krust"); + + let mut cmd = Command::cargo_bin("krust")?; + cmd.arg("build") + .arg("--no-push") + .arg("--platform") + .arg("linux/amd64") + .arg("--image") + .arg("test.local/single-platform:latest") + .arg(".") + .current_dir(&example_dir); + + cmd.assert().success().stderr(predicate::str::contains( + "Building for platform: linux/amd64", + )); + Ok(()) +} + +#[test] +fn test_multi_platform_build() -> Result<()> { + // Check if we have the ARM64 target installed + let has_arm64_target = StdCommand::new("rustup") + .args(&["target", "list", "--installed"]) + .output() + .map(|output| { + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.contains("aarch64-unknown-linux-musl") + }) + .unwrap_or(false); + + let example_dir = env::current_dir()?.join("example").join("hello-krust"); + + let mut cmd = Command::cargo_bin("krust")?; + + if has_arm64_target { + // Test with both platforms if ARM64 is available + cmd.arg("build") + .arg("--no-push") + .arg("--platform") + .arg("linux/amd64,linux/arm64") + .arg("--image") + .arg("test.local/multi-platform:latest") + .arg(".") + .current_dir(&example_dir); + + cmd.assert() + .success() + .stderr(predicate::str::contains( + "Building for platform: linux/amd64", + )) + .stderr(predicate::str::contains( + "Building for platform: linux/arm64", + )); + } else { + // Test with multiple x64 platforms (same platform twice to test the multi-platform flow) + cmd.arg("build") + .arg("--no-push") + .arg("--platform") + .arg("linux/amd64") + .arg("--platform") + .arg("linux/amd64") + .arg("--image") + .arg("test.local/multi-platform:latest") + .arg(".") + .current_dir(&example_dir); + + cmd.assert().success().stderr(predicate::str::contains( + "Building for platform: linux/amd64", + )); + } + Ok(()) +}