diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..48ca4bb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.x86_64-unknown-linux-musl] +linker = "x86_64-linux-musl-gcc" + +[target.aarch64-unknown-linux-musl] +linker = "aarch64-linux-musl-gcc" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 775ae5f..1b3c0f0 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,11 @@ jobs: uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust }} - targets: x86_64-unknown-linux-musl - - name: Install cross-compilation tools (Ubuntu) - if: matrix.os == 'ubuntu-latest' + targets: x86_64-unknown-linux-musl,aarch64-unknown-linux-musl + - name: Install cross-compilation tools for both architectures run: | sudo apt-get update - sudo apt-get install -y musl-tools + sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu - name: Cache cargo registry uses: actions/cache@v4 with: @@ -45,6 +44,16 @@ jobs: with: path: target key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + - name: Setup cargo config for cross-compilation + run: | + mkdir -p .cargo + cat > .cargo/config.toml << 'EOF' + [target.x86_64-unknown-linux-musl] + linker = "x86_64-linux-musl-gcc" + + [target.aarch64-unknown-linux-musl] + linker = "aarch64-linux-gnu-gcc" + EOF - name: Verify cross-compilation setup (Unix) run: | echo "Installed targets:" @@ -57,9 +66,10 @@ jobs: which x86_64-unknown-linux-musl-gcc || echo "x86_64-unknown-linux-musl-gcc not found" which x86_64-linux-musl-gcc || echo "x86_64-linux-musl-gcc not found" which musl-gcc || echo "musl-gcc not found" + which aarch64-linux-gnu-gcc || echo "aarch64-linux-gnu-gcc not found" which rust-lld || echo "rust-lld not found" echo "Cargo config:" - cat ~/.cargo/config.toml || echo "No cargo config found" + cat .cargo/config.toml || echo "No cargo config found" - name: Build run: cargo build --verbose - name: Run unit tests @@ -105,11 +115,21 @@ 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: Setup cargo config for cross-compilation + run: | + mkdir -p .cargo + cat > .cargo/config.toml << 'EOF' + [target.x86_64-unknown-linux-musl] + linker = "x86_64-linux-musl-gcc" + + [target.aarch64-unknown-linux-musl] + linker = "aarch64-linux-gnu-gcc" + EOF - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build krust @@ -136,6 +156,15 @@ jobs: run: | cd example/hello-krust krust build --no-push --image local.test/hello:latest ./ + - name: Test multi-arch build and run + run: | + cd example/hello-krust + # Build for both platforms and push + export KRUST_REPO=ttl.sh/${{ github.run_id }}-multiarch + IMAGE_REF=$(krust build --platform linux/amd64,linux/arm64 ./) + echo "Built multi-arch image: $IMAGE_REF" + # Run the image - should automatically select the right architecture + docker run --rm $IMAGE_REF # Takes too long to run on CI, so it's commented out for now. # coverage: diff --git a/Cargo.toml b/Cargo.toml index e6acb4f..bdfcd46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } dirs = "5.0" toml = "0.8" which = "6.0" +tempfile = "3.9" [dev-dependencies] tempfile = "3.9" diff --git a/README.md b/README.md index 4549a5e..70e8ce9 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,17 @@ 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 + +# Default behavior builds for both amd64 and arm64 +krust build ``` ### Build with custom cargo arguments @@ -116,6 +125,31 @@ krust build -- --features=prod - `linux/arm64` (aarch64-unknown-linux-musl) - `linux/arm/v7` (armv7-unknown-linux-musleabihf) +### Multi-Architecture Images + +krust always pushes OCI image indexes (manifest lists) for consistency: +1. Builds each platform separately with its own binary +2. Pushes platform-specific images with unique tags +3. Creates and pushes a manifest list that references all platforms +4. Returns the manifest list digest for use with Docker/Kubernetes + +This means even single-platform builds result in a manifest list, ensuring a uniform interface regardless of the number of platforms built. + +## Build Process + +krust builds your Rust application in an isolated environment: + +1. **Temporary build directory** - Each build uses a unique temporary directory via `--target-dir` +2. **Static compilation** - Builds with `RUSTFLAGS="-C target-feature=+crt-static"` for musl targets +3. **Cross-compilation** - Automatically configures the appropriate linker for the target platform +4. **Binary extraction** - Copies the built binary from the temp directory for packaging +5. **Container creation** - Packages the binary into a minimal OCI image + +This approach ensures: +- No conflicts between concurrent builds +- Clean builds without interference from previous compilations +- Safe parallel execution of multiple krust instances + ## Static Binaries krust builds fully static binaries by default using: @@ -185,9 +219,12 @@ 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 +- **Isolated builds** - Each build uses a temporary directory to avoid conflicts +- **Concurrent builds** - Multiple builds can run safely in parallel ## Example @@ -260,6 +297,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/builder/mod.rs b/src/builder/mod.rs index 28e3961..538e4c6 100644 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use std::process::Command; +use tempfile::TempDir; use tracing::{debug, error, info}; #[cfg(test)] @@ -12,6 +13,11 @@ pub struct RustBuilder { cargo_args: Vec, } +pub struct BuildResult { + pub binary_path: PathBuf, + _temp_dir: TempDir, // Keep temp dir alive until BuildResult is dropped +} + impl RustBuilder { pub fn new(project_path: impl AsRef, target: &str) -> Self { Self { @@ -26,14 +32,21 @@ impl RustBuilder { self } - pub fn build(&self) -> Result { + pub fn build(&self) -> Result { info!("Building Rust project at {:?}", self.project_path); + // Use a unique target directory to avoid conflicts between concurrent builds + let temp_target_dir = + tempfile::tempdir().context("Failed to create temporary directory")?; + let target_dir = temp_target_dir.path(); + let mut cmd = Command::new("cargo"); cmd.arg("build") .arg("--release") .arg("--target") .arg(&self.target) + .arg("--target-dir") + .arg(target_dir) .current_dir(&self.project_path); // Set RUSTFLAGS for static linking @@ -116,6 +129,7 @@ impl RustBuilder { debug!("Running command: {:?}", cmd); debug!("RUSTFLAGS: {}", rustflags); + info!("Running cargo build for target: {}", self.target); let output = cmd.output().context("Failed to execute cargo build")?; if !output.status.success() { @@ -128,19 +142,30 @@ impl RustBuilder { } let binary_name = self.get_binary_name()?; - let binary_path = self - .project_path - .join("target") + let binary_path = target_dir .join(&self.target) .join("release") .join(&binary_name); + // Sometimes cargo build completes but the binary isn't immediately visible + // due to filesystem sync issues. Give it a moment. + let mut retries = 0; + while !binary_path.exists() && retries < 3 { + std::thread::sleep(std::time::Duration::from_millis(100)); + retries += 1; + } + if !binary_path.exists() { anyhow::bail!("Built binary not found at {:?}", binary_path); } info!("Successfully built binary at {:?}", binary_path); - Ok(binary_path) + + // Return the build result with the temp directory to keep it alive + Ok(BuildResult { + binary_path, + _temp_dir: temp_target_dir, + }) } fn get_binary_name(&self) -> Result { 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..9d00a85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use krust::{ cli::{Cli, Commands}, config::Config, image::ImageBuilder, + manifest::{ManifestDescriptor, Platform}, registry::RegistryClient, }; use std::path::{Path, PathBuf}; @@ -57,33 +58,109 @@ 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); + // Determine platforms to build for + let platforms = if let Some(platforms) = platform { + platforms + } else { + // Default to common platforms + vec!["linux/amd64".to_string(), "linux/arm64".to_string()] + }; - let binary_path = builder.build()?; + // Build for each platform + let mut manifest_descriptors = Vec::new(); + let auth = oci_distribution::secrets::RegistryAuth::Anonymous; + let mut registry_client = RegistryClient::new(auth)?; - // Build container image - let image_builder = ImageBuilder::new(binary_path, base_image, platform.clone()); + for platform_str in &platforms { + info!("Building for platform: {}", platform_str); - let (config_data, layer_data, manifest) = image_builder.build()?; + // 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()); - // Push by default unless --no-push is specified + 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(), + ); + + 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 manifest lists to work properly, we need to push to a consistent location + // We'll use the base image ref with a unique tag for each platform + let (base_ref, _) = if let Some(pos) = image_ref.rfind(':') { + ( + image_ref[..pos].to_string(), + image_ref[pos + 1..].to_string(), + ) + } else { + (image_ref.to_string(), "latest".to_string()) + }; + + // Create a unique tag for this platform to avoid conflicts + let platform_tag = format!("platform-{}", platform_str.replace('/', "-")); + let platform_ref = format!("{}:{}", base_ref, platform_tag); + + let (digest_ref, manifest_size) = registry_client + .push_image(&platform_ref, config_data, layers) + .await?; + + // Parse platform string + let parts: Vec<&str> = platform_str.split('/').collect(); + 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); + + // Add to manifest list + info!( + "Adding manifest to list - platform: {}/{}, digest: {}, size: {}", + os, arch, digest, manifest_size + ); + 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, + }, + }); + } + } + + // Always push manifest list if not --no-push (even for single platform) if !no_push { - info!("Pushing image to registry..."); - let auth = oci_distribution::secrets::RegistryAuth::Anonymous; - let mut registry_client = RegistryClient::new(auth)?; + info!("Creating and pushing manifest list..."); - let layers = vec![(layer_data, manifest.layers[0].media_type.clone())]; - - let digest_ref = registry_client - .push_image(&image_ref, config_data, layers) + let manifest_list_ref = registry_client + .push_manifest_list(&image_ref, manifest_descriptors) .await?; - // Print only the digest reference to stdout - println!("{}", digest_ref); + // Output the manifest list reference + println!("{}", manifest_list_ref); } else { - info!("Successfully built image: {}", image_ref); + 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/src/registry/mod.rs b/src/registry/mod.rs index 061c2b8..b71773f 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use oci_distribution::manifest::{OciDescriptor, OciImageManifest, OciManifest}; use oci_distribution::secrets::RegistryAuth; use oci_distribution::{Client, Reference}; +use std::str::FromStr; use tracing::{debug, info}; #[cfg(test)] @@ -24,7 +25,7 @@ impl RegistryClient { image_ref: &str, config_data: Vec, layers: Vec<(Vec, String)>, - ) -> Result { + ) -> Result<(String, usize)> { let reference: Reference = image_ref .parse() .context("Failed to parse image reference")?; @@ -100,7 +101,82 @@ impl RegistryClient { let repository = reference.repository(); let digest_ref = format!("{}/{}@{}", registry, repository, digest); - Ok(digest_ref) + // Return both the digest ref and the actual manifest size + let manifest_size = serde_json::to_vec(&manifest)?.len(); + Ok((digest_ref, manifest_size)) + } + + pub async fn push_manifest_list( + &mut self, + image_ref: &str, + manifest_descriptors: Vec, + ) -> Result { + let reference = Reference::from_str(image_ref) + .context(format!("Failed to parse image reference: {}", image_ref))?; + + // Create the image index + let index = crate::manifest::ImageIndex::new(manifest_descriptors); + + // Convert to OCI index + let oci_manifests: Vec = index + .manifests + .iter() + .map(|m| oci_distribution::manifest::ImageIndexEntry { + media_type: m.media_type.clone(), + digest: m.digest.clone(), + size: m.size, + platform: Some(oci_distribution::manifest::Platform { + architecture: m.platform.architecture.clone(), + os: m.platform.os.clone(), + os_version: None, + os_features: None, + variant: m.platform.variant.clone(), + features: None, + }), + annotations: None, + }) + .collect(); + + let oci_index = oci_distribution::manifest::OciImageIndex { + schema_version: 2, + media_type: Some("application/vnd.oci.image.index.v1+json".to_string()), + manifests: oci_manifests, + annotations: None, + }; + + // Wrap in OciManifest enum + let manifest = oci_distribution::manifest::OciManifest::ImageIndex(oci_index); + + debug!( + "Pushing manifest list with {} manifests", + index.manifests.len() + ); + for m in &index.manifests { + debug!( + " - Platform: {}/{}, digest: {}", + m.platform.os, m.platform.architecture, m.digest + ); + } + let manifest_url = self + .client + .push_manifest(&reference, &manifest) + .await + .context("Failed to push manifest list")?; + + info!("Successfully pushed manifest list to {}", manifest_url); + + // Extract digest from the manifest URL + let digest = manifest_url + .split('/') + .next_back() + .context("Failed to extract digest from manifest URL")?; + + // Build the full image reference with digest + let registry = reference.registry(); + let repository = reference.repository(); + let image_ref = format!("{}/{}@{}", registry, repository, digest); + + Ok(image_ref) } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index ea30910..fbf7d6e 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -4,6 +4,23 @@ use predicates::prelude::*; use std::env; use std::process::Command as StdCommand; +// Helper to get the appropriate test platform based on runtime architecture +fn get_test_platform() -> &'static str { + // In CI, test the native platform + if env::var("CI").is_ok() { + if cfg!(target_arch = "x86_64") { + "linux/amd64" + } else if cfg!(target_arch = "aarch64") { + "linux/arm64" + } else { + "linux/amd64" + } + } else { + // For local development, always use amd64 as it's most commonly available + "linux/amd64" + } +} + #[test] fn test_version_command() -> Result<()> { let mut cmd = Command::cargo_bin("krust")?; @@ -72,6 +89,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(get_test_platform()) .arg(".") // Explicitly pass current directory .env("KRUST_REPO", "test.local") .current_dir(&example_dir); @@ -80,7 +99,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 +114,8 @@ fn test_command_substitution_syntax() -> Result<()> { let output = cmd .arg("build") .arg("--no-push") + .arg("--platform") + .arg(get_test_platform()) .arg("--image") .arg("test.local/hello:latest") .arg(".") // Explicitly pass current directory @@ -123,6 +144,8 @@ fn test_verbose_logging() -> Result<()> { cmd.arg("--verbose") .arg("build") .arg("--no-push") + .arg("--platform") + .arg(get_test_platform()) .arg("--image") .arg("test.local/hello:latest") .arg(".") // Explicitly pass current directory @@ -152,15 +175,27 @@ fn test_full_build_and_run_workflow() -> Result<()> { let example_dir = env::current_dir()?.join("example").join("hello-krust"); // Build and push to ttl.sh + // For the full workflow test, build for the actual native platform so Docker can run it + let native_platform = if cfg!(target_arch = "aarch64") { + "linux/arm64" + } else { + "linux/amd64" + }; + let mut cmd = Command::cargo_bin("krust")?; let output = cmd .arg("build") + .arg("--platform") + .arg(native_platform) .arg(".") // Explicitly pass current directory .env("KRUST_REPO", "ttl.sh/krust-test") .current_dir(&example_dir) .output()?; - assert!(output.status.success(), "Build failed"); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!("Build failed: {}", stderr); + } // Get the image reference from stdout let image_ref = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -171,8 +206,136 @@ fn test_full_build_and_run_workflow() -> Result<()> { .args(&["run", "--rm", &image_ref]) .output()?; - assert!(docker_output.status.success(), "Docker run failed"); + if !docker_output.status.success() { + let stderr = String::from_utf8_lossy(&docker_output.stderr); + panic!("Docker run failed with image {}: {}", image_ref, stderr); + } + let docker_stdout = String::from_utf8_lossy(&docker_output.stdout); assert!(docker_stdout.contains("Hello from krust example!")); Ok(()) } + +#[test] +fn test_single_platform_build() -> Result<()> { + let example_dir = env::current_dir()?.join("example").join("hello-krust"); + let platform = get_test_platform(); + + let mut cmd = Command::cargo_bin("krust")?; + cmd.arg("build") + .arg("--no-push") + .arg("--platform") + .arg(platform) + .arg("--image") + .arg("test.local/single-platform:latest") + .arg(".") + .current_dir(&example_dir); + + cmd.assert() + .success() + .stderr(predicate::str::contains(format!( + "Building for platform: {}", + platform + ))) + .stderr(predicate::str::contains( + "Successfully built image for 1 platform(s)", + )); + Ok(()) +} + +#[test] +fn test_multi_platform_build() -> Result<()> { + let example_dir = env::current_dir()?.join("example").join("hello-krust"); + let mut cmd = Command::cargo_bin("krust")?; + + // Test building for multiple platforms + // This will use whatever targets are 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); + + // The command might fail if targets aren't installed, but we should test that it tries + let output = cmd.output()?; + + if output.status.success() { + // If it succeeds, verify we built for multiple platforms + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Building for platform: linux/amd64")); + assert!(stderr.contains("Building for platform: linux/arm64")); + assert!(stderr.contains("Successfully built image for 2 platform(s)")); + } else { + // If it fails, it should be because of missing targets + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("target may not be installed") + || stderr.contains("linker") + || stderr.contains("cross-compilation") + || stderr.contains("not found"), + "Build failed for unexpected reason: {}", + stderr + ); + } + Ok(()) +} + +#[test] +fn test_multi_arch_build_and_run() -> Result<()> { + // This test requires Docker + let docker_check = StdCommand::new("docker").arg("version").output(); + match docker_check { + Ok(output) if output.status.success() => { + // Docker is available, proceed with test + } + _ => { + eprintln!("Docker is required for this test but is not available"); + return Ok(()); + } + } + + let example_dir = env::current_dir()?.join("example").join("hello-krust"); + + // Build multi-arch image and push to ttl.sh + let mut cmd = Command::cargo_bin("krust")?; + let output = cmd + .arg("build") + .arg("--platform") + .arg("linux/amd64,linux/arm64") + .arg(".") + .env("KRUST_REPO", "ttl.sh/krust-multiarch-test") + .current_dir(&example_dir) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // If it fails due to missing targets, that's expected in some environments + if stderr.contains("target may not be installed") + || stderr.contains("linker") + || stderr.contains("cross-compilation") + || stderr.contains("not found") + { + eprintln!("Skipping multi-arch run test - build failed due to missing toolchain"); + return Ok(()); + } + panic!("Build failed unexpectedly: {}", stderr); + } + + // Get the image reference from stdout + let image_ref = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert!(image_ref.starts_with("ttl.sh/krust-multiarch-test/hello-krust")); + + // Try to run the image - it should work on the current architecture + let docker_output = StdCommand::new("docker") + .args(&["run", "--rm", &image_ref]) + .output()?; + + assert!(docker_output.status.success(), "Docker run failed"); + let docker_stdout = String::from_utf8_lossy(&docker_output.stdout); + assert!(docker_stdout.contains("Hello from krust example!")); + + Ok(()) +}