mirror of
https://github.com/imjasonh/krust
synced 2026-07-18 23:16:03 +00:00
Merge pull request #1 from imjasonh/feat/multi-arch-support
feat: Add multi-arch support
This commit is contained in:
commit
f494035f75
11 changed files with 504 additions and 40 deletions
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
|
|
@ -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"
|
||||||
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest]
|
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||||
rust: [stable, beta]
|
rust: [stable, beta]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -24,12 +24,11 @@ jobs:
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ matrix.rust }}
|
toolchain: ${{ matrix.rust }}
|
||||||
targets: x86_64-unknown-linux-musl
|
targets: x86_64-unknown-linux-musl,aarch64-unknown-linux-musl
|
||||||
- name: Install cross-compilation tools (Ubuntu)
|
- name: Install cross-compilation tools for both architectures
|
||||||
if: matrix.os == 'ubuntu-latest'
|
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
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
|
- name: Cache cargo registry
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -45,6 +44,16 @@ jobs:
|
||||||
with:
|
with:
|
||||||
path: target
|
path: target
|
||||||
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
|
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)
|
- name: Verify cross-compilation setup (Unix)
|
||||||
run: |
|
run: |
|
||||||
echo "Installed targets:"
|
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-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 x86_64-linux-musl-gcc || echo "x86_64-linux-musl-gcc not found"
|
||||||
which musl-gcc || echo "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"
|
which rust-lld || echo "rust-lld not found"
|
||||||
echo "Cargo config:"
|
echo "Cargo config:"
|
||||||
cat ~/.cargo/config.toml || echo "No cargo config found"
|
cat .cargo/config.toml || echo "No cargo config found"
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose
|
run: cargo build --verbose
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
|
|
@ -105,11 +115,21 @@ jobs:
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: x86_64-unknown-linux-musl
|
targets: x86_64-unknown-linux-musl,aarch64-unknown-linux-musl
|
||||||
- name: Install musl tools
|
- name: Install musl tools
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Build krust
|
- name: Build krust
|
||||||
|
|
@ -136,6 +156,15 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
cd example/hello-krust
|
cd example/hello-krust
|
||||||
krust build --no-push --image local.test/hello:latest ./
|
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.
|
# Takes too long to run on CI, so it's commented out for now.
|
||||||
# coverage:
|
# coverage:
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
which = "6.0"
|
which = "6.0"
|
||||||
|
tempfile = "3.9"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.9"
|
tempfile = "3.9"
|
||||||
|
|
|
||||||
48
README.md
48
README.md
|
|
@ -100,8 +100,17 @@ krust build example/hello-krust --no-push
|
||||||
# Use a specific image name (overrides KRUST_REPO)
|
# Use a specific image name (overrides KRUST_REPO)
|
||||||
krust build --image myregistry.io/myapp:v1.0
|
krust build --image myregistry.io/myapp:v1.0
|
||||||
|
|
||||||
# Build for a different platform
|
# Build for a specific platform
|
||||||
krust build --platform linux/arm64
|
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
|
### Build with custom cargo arguments
|
||||||
|
|
@ -116,6 +125,31 @@ krust build -- --features=prod
|
||||||
- `linux/arm64` (aarch64-unknown-linux-musl)
|
- `linux/arm64` (aarch64-unknown-linux-musl)
|
||||||
- `linux/arm/v7` (armv7-unknown-linux-musleabihf)
|
- `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
|
## Static Binaries
|
||||||
|
|
||||||
krust builds fully static binaries by default using:
|
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
|
- **Docker-free** - Builds OCI container images without requiring Docker daemon
|
||||||
- **Static binaries** - Produces truly static binaries using musl libc
|
- **Static binaries** - Produces truly static binaries using musl libc
|
||||||
- **Composable** - Outputs image digest to stdout, enabling `docker run $(krust build)`
|
- **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)
|
- **Cross-platform** - Supports multiple architectures (amd64, arm64, arm/v7)
|
||||||
- **Minimal images** - Uses distroless base images for security and size
|
- **Minimal images** - Uses distroless base images for security and size
|
||||||
- **OCI compliant** - Works with any OCI-compliant container registry
|
- **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
|
## 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
|
git clone https://github.com/imjasonh/krust.git
|
||||||
cd krust
|
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
|
# Install pre-commit hooks
|
||||||
pip install pre-commit
|
pip install pre-commit
|
||||||
or
|
or
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use tempfile::TempDir;
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -12,6 +13,11 @@ pub struct RustBuilder {
|
||||||
cargo_args: Vec<String>,
|
cargo_args: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct BuildResult {
|
||||||
|
pub binary_path: PathBuf,
|
||||||
|
_temp_dir: TempDir, // Keep temp dir alive until BuildResult is dropped
|
||||||
|
}
|
||||||
|
|
||||||
impl RustBuilder {
|
impl RustBuilder {
|
||||||
pub fn new(project_path: impl AsRef<Path>, target: &str) -> Self {
|
pub fn new(project_path: impl AsRef<Path>, target: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -26,14 +32,21 @@ impl RustBuilder {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(&self) -> Result<PathBuf> {
|
pub fn build(&self) -> Result<BuildResult> {
|
||||||
info!("Building Rust project at {:?}", self.project_path);
|
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");
|
let mut cmd = Command::new("cargo");
|
||||||
cmd.arg("build")
|
cmd.arg("build")
|
||||||
.arg("--release")
|
.arg("--release")
|
||||||
.arg("--target")
|
.arg("--target")
|
||||||
.arg(&self.target)
|
.arg(&self.target)
|
||||||
|
.arg("--target-dir")
|
||||||
|
.arg(target_dir)
|
||||||
.current_dir(&self.project_path);
|
.current_dir(&self.project_path);
|
||||||
|
|
||||||
// Set RUSTFLAGS for static linking
|
// Set RUSTFLAGS for static linking
|
||||||
|
|
@ -116,6 +129,7 @@ impl RustBuilder {
|
||||||
debug!("Running command: {:?}", cmd);
|
debug!("Running command: {:?}", cmd);
|
||||||
debug!("RUSTFLAGS: {}", rustflags);
|
debug!("RUSTFLAGS: {}", rustflags);
|
||||||
|
|
||||||
|
info!("Running cargo build for target: {}", self.target);
|
||||||
let output = cmd.output().context("Failed to execute cargo build")?;
|
let output = cmd.output().context("Failed to execute cargo build")?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
|
|
@ -128,19 +142,30 @@ impl RustBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
let binary_name = self.get_binary_name()?;
|
let binary_name = self.get_binary_name()?;
|
||||||
let binary_path = self
|
let binary_path = target_dir
|
||||||
.project_path
|
|
||||||
.join("target")
|
|
||||||
.join(&self.target)
|
.join(&self.target)
|
||||||
.join("release")
|
.join("release")
|
||||||
.join(&binary_name);
|
.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() {
|
if !binary_path.exists() {
|
||||||
anyhow::bail!("Built binary not found at {:?}", binary_path);
|
anyhow::bail!("Built binary not found at {:?}", binary_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Successfully built binary 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<String> {
|
fn get_binary_name(&self) -> Result<String> {
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,10 @@ pub enum Commands {
|
||||||
#[arg(short, long, env = "KRUST_IMAGE")]
|
#[arg(short, long, env = "KRUST_IMAGE")]
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
|
|
||||||
/// Target platform (e.g., linux/amd64, linux/arm64)
|
/// Target platforms (e.g., linux/amd64, linux/arm64)
|
||||||
#[arg(long, default_value = "linux/amd64")]
|
/// Can be specified multiple times or as a comma-separated list
|
||||||
platform: String,
|
#[arg(long, value_delimiter = ',')]
|
||||||
|
platform: Option<Vec<String>>,
|
||||||
|
|
||||||
/// Skip pushing the image to the registry after building
|
/// Skip pushing the image to the registry after building
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ pub mod builder;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod image;
|
pub mod image;
|
||||||
|
pub mod manifest;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
|
||||||
pub use anyhow::Result;
|
pub use anyhow::Result;
|
||||||
|
|
|
||||||
113
src/main.rs
113
src/main.rs
|
|
@ -5,6 +5,7 @@ use krust::{
|
||||||
cli::{Cli, Commands},
|
cli::{Cli, Commands},
|
||||||
config::Config,
|
config::Config,
|
||||||
image::ImageBuilder,
|
image::ImageBuilder,
|
||||||
|
manifest::{ManifestDescriptor, Platform},
|
||||||
registry::RegistryClient,
|
registry::RegistryClient,
|
||||||
};
|
};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -57,33 +58,109 @@ async fn main() -> Result<()> {
|
||||||
format!("{}/{}:latest", repo, project_name)
|
format!("{}/{}:latest", repo, project_name)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the Rust binary
|
// Determine platforms to build for
|
||||||
let target = get_rust_target_triple(&platform)?;
|
let platforms = if let Some(platforms) = platform {
|
||||||
let builder = RustBuilder::new(&project_path, &target).with_cargo_args(cargo_args);
|
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
|
for platform_str in &platforms {
|
||||||
let image_builder = ImageBuilder::new(binary_path, base_image, platform.clone());
|
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 {
|
if !no_push {
|
||||||
info!("Pushing image to registry...");
|
info!("Creating and pushing manifest list...");
|
||||||
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 manifest_list_ref = registry_client
|
||||||
|
.push_manifest_list(&image_ref, manifest_descriptors)
|
||||||
let digest_ref = registry_client
|
|
||||||
.push_image(&image_ref, config_data, layers)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Print only the digest reference to stdout
|
// Output the manifest list reference
|
||||||
println!("{}", digest_ref);
|
println!("{}", manifest_list_ref);
|
||||||
} else {
|
} else {
|
||||||
info!("Successfully built image: {}", image_ref);
|
info!(
|
||||||
|
"Successfully built image for {} platform(s)",
|
||||||
|
platforms.len()
|
||||||
|
);
|
||||||
info!("Skipping push (--no-push specified)");
|
info!("Skipping push (--no-push specified)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
src/manifest.rs
Normal file
40
src/manifest.rs
Normal file
|
|
@ -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<ManifestDescriptor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageIndex {
|
||||||
|
pub fn new(manifests: Vec<ManifestDescriptor>) -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: 2,
|
||||||
|
media_type: "application/vnd.oci.image.index.v1+json".to_string(),
|
||||||
|
manifests,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ use anyhow::{Context, Result};
|
||||||
use oci_distribution::manifest::{OciDescriptor, OciImageManifest, OciManifest};
|
use oci_distribution::manifest::{OciDescriptor, OciImageManifest, OciManifest};
|
||||||
use oci_distribution::secrets::RegistryAuth;
|
use oci_distribution::secrets::RegistryAuth;
|
||||||
use oci_distribution::{Client, Reference};
|
use oci_distribution::{Client, Reference};
|
||||||
|
use std::str::FromStr;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -24,7 +25,7 @@ impl RegistryClient {
|
||||||
image_ref: &str,
|
image_ref: &str,
|
||||||
config_data: Vec<u8>,
|
config_data: Vec<u8>,
|
||||||
layers: Vec<(Vec<u8>, String)>,
|
layers: Vec<(Vec<u8>, String)>,
|
||||||
) -> Result<String> {
|
) -> Result<(String, usize)> {
|
||||||
let reference: Reference = image_ref
|
let reference: Reference = image_ref
|
||||||
.parse()
|
.parse()
|
||||||
.context("Failed to parse image reference")?;
|
.context("Failed to parse image reference")?;
|
||||||
|
|
@ -100,7 +101,82 @@ impl RegistryClient {
|
||||||
let repository = reference.repository();
|
let repository = reference.repository();
|
||||||
let digest_ref = format!("{}/{}@{}", registry, repository, digest);
|
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<crate::manifest::ManifestDescriptor>,
|
||||||
|
) -> Result<String> {
|
||||||
|
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<oci_distribution::manifest::ImageIndexEntry> = 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,23 @@ use predicates::prelude::*;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::process::Command as StdCommand;
|
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]
|
#[test]
|
||||||
fn test_version_command() -> Result<()> {
|
fn test_version_command() -> Result<()> {
|
||||||
let mut cmd = Command::cargo_bin("krust")?;
|
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")?;
|
let mut cmd = Command::cargo_bin("krust")?;
|
||||||
cmd.arg("build")
|
cmd.arg("build")
|
||||||
.arg("--no-push")
|
.arg("--no-push")
|
||||||
|
.arg("--platform")
|
||||||
|
.arg(get_test_platform())
|
||||||
.arg(".") // Explicitly pass current directory
|
.arg(".") // Explicitly pass current directory
|
||||||
.env("KRUST_REPO", "test.local")
|
.env("KRUST_REPO", "test.local")
|
||||||
.current_dir(&example_dir);
|
.current_dir(&example_dir);
|
||||||
|
|
@ -80,7 +99,7 @@ fn test_build_with_krust_repo_env() -> Result<()> {
|
||||||
.success()
|
.success()
|
||||||
.stderr(predicate::str::contains("Building Rust project"))
|
.stderr(predicate::str::contains("Building Rust project"))
|
||||||
.stderr(predicate::str::contains(
|
.stderr(predicate::str::contains(
|
||||||
"Successfully built image: test.local/hello-krust:latest",
|
"Successfully built image for 1 platform(s)",
|
||||||
));
|
));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +114,8 @@ fn test_command_substitution_syntax() -> Result<()> {
|
||||||
let output = cmd
|
let output = cmd
|
||||||
.arg("build")
|
.arg("build")
|
||||||
.arg("--no-push")
|
.arg("--no-push")
|
||||||
|
.arg("--platform")
|
||||||
|
.arg(get_test_platform())
|
||||||
.arg("--image")
|
.arg("--image")
|
||||||
.arg("test.local/hello:latest")
|
.arg("test.local/hello:latest")
|
||||||
.arg(".") // Explicitly pass current directory
|
.arg(".") // Explicitly pass current directory
|
||||||
|
|
@ -123,6 +144,8 @@ fn test_verbose_logging() -> Result<()> {
|
||||||
cmd.arg("--verbose")
|
cmd.arg("--verbose")
|
||||||
.arg("build")
|
.arg("build")
|
||||||
.arg("--no-push")
|
.arg("--no-push")
|
||||||
|
.arg("--platform")
|
||||||
|
.arg(get_test_platform())
|
||||||
.arg("--image")
|
.arg("--image")
|
||||||
.arg("test.local/hello:latest")
|
.arg("test.local/hello:latest")
|
||||||
.arg(".") // Explicitly pass current directory
|
.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");
|
let example_dir = env::current_dir()?.join("example").join("hello-krust");
|
||||||
|
|
||||||
// Build and push to ttl.sh
|
// 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 mut cmd = Command::cargo_bin("krust")?;
|
||||||
let output = cmd
|
let output = cmd
|
||||||
.arg("build")
|
.arg("build")
|
||||||
|
.arg("--platform")
|
||||||
|
.arg(native_platform)
|
||||||
.arg(".") // Explicitly pass current directory
|
.arg(".") // Explicitly pass current directory
|
||||||
.env("KRUST_REPO", "ttl.sh/krust-test")
|
.env("KRUST_REPO", "ttl.sh/krust-test")
|
||||||
.current_dir(&example_dir)
|
.current_dir(&example_dir)
|
||||||
.output()?;
|
.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
|
// Get the image reference from stdout
|
||||||
let image_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
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])
|
.args(&["run", "--rm", &image_ref])
|
||||||
.output()?;
|
.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);
|
let docker_stdout = String::from_utf8_lossy(&docker_output.stdout);
|
||||||
assert!(docker_stdout.contains("Hello from krust example!"));
|
assert!(docker_stdout.contains("Hello from krust example!"));
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue