From 5e89023925d9853803ff31db9d6396be6e9e69f4 Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Sat, 7 Jun 2025 20:46:08 -0400 Subject: [PATCH] Initial commit: krust - container image build tool for Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit krust builds container images for Rust applications without Docker: - Builds static binaries using musl libc - Creates minimal OCI container images - Pushes to any OCI-compliant registry - Outputs digest to stdout for composability Inspired by ko.build for Go applications. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 82 +++++++++ .gitignore | 23 +++ CLAUDE.md | 154 ++++++++++++++++ Cargo.toml | 31 ++++ LICENSE | 202 +++++++++++++++++++++ Makefile | 39 ++++ README.md | 236 +++++++++++++++++++++++++ example/hello-krust/.cargo/config.toml | 8 + example/hello-krust/Cargo.toml | 6 + example/hello-krust/src/main.rs | 4 + src/builder/mod.rs | 104 +++++++++++ src/builder/tests.rs | 21 +++ src/cli/mod.rs | 53 ++++++ src/config/mod.rs | 74 ++++++++ src/config/tests.rs | 20 +++ src/image/mod.rs | 197 +++++++++++++++++++++ src/image/tests.rs | 29 +++ src/lib.rs | 7 + src/main.rs | 111 ++++++++++++ src/registry/mod.rs | 115 ++++++++++++ src/registry/tests.rs | 27 +++ tests/e2e/basic_test.rs | 25 +++ tests/integration_test.rs | 43 +++++ 23 files changed, 1611 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 example/hello-krust/.cargo/config.toml create mode 100644 example/hello-krust/Cargo.toml create mode 100644 example/hello-krust/src/main.rs create mode 100644 src/builder/mod.rs create mode 100644 src/builder/tests.rs create mode 100644 src/cli/mod.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/tests.rs create mode 100644 src/image/mod.rs create mode 100644 src/image/tests.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/registry/mod.rs create mode 100644 src/registry/tests.rs create mode 100644 tests/e2e/basic_test.rs create mode 100644 tests/integration_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a48d98c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + rust: [stable, beta] + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + - name: Build + run: cargo build --verbose + - name: Run unit tests + run: cargo test --verbose + - name: Run e2e tests + run: cargo test --test '*' --verbose + + fmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Check formatting + run: cargo fmt -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - name: Run clippy + run: cargo clippy -- -D warnings + + coverage: + name: Code coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + - name: Generate code coverage + run: cargo tarpaulin --verbose --workspace \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35e94e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Rust +target/ +**/*.rs.bk +*.pdb + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Cargo +Cargo.lock + +# Coverage +tarpaulin-report.html +cobertura.xml +lcov.info diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1712b72 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,154 @@ +# krust Development Notes + +This document captures key learnings and decisions made during the development of krust with Claude. + +## Project Overview + +krust is a container image build tool for Rust applications, inspired by ko.build for Go. It builds static binaries and packages them into minimal OCI container images without requiring Docker. + +## Key Design Decisions + +### 1. Static Binaries with musl + +We chose musl libc over glibc for static linking because: +- **True static linking**: glibc uses dynamic loading internally (NSS) which breaks in static binaries +- **Smaller binaries**: musl static binaries are 5-10x smaller than glibc +- **No runtime surprises**: glibc static binaries often fail with DNS resolution, user lookups, or locale issues +- **Container-optimized**: Perfect for minimal container images + +### 2. Default Push Behavior + +krust pushes images by default (use `--no-push` to skip) because: +- Aligns with the common workflow of building and immediately using images +- Enables the `docker run $(krust build)` pattern +- Reduces friction for the most common use case + +### 3. Output Design + +- **stdout**: Only the pushed image reference by digest (e.g., `ttl.sh/user/app@sha256:...`) +- **stderr**: All logging and progress information +- This enables composability with other tools + +### 4. Image Naming Strategy + +- Uses `KRUST_REPO` environment variable for repository prefix +- Automatically appends project name from Cargo.toml +- Can be overridden with `--image` flag +- Default tag is `latest` + +## Technical Learnings + +### OCI Image Building + +1. **Layer Digest vs Diff ID**: + - Layer digest: SHA256 of the compressed (gzip) layer + - Diff ID: SHA256 of the uncompressed tar (goes in image config) + - Docker validates these match during pull + +2. **Image Structure**: + ``` + Manifest -> Config + Layers + Config contains: architecture, OS, environment, command, diff_ids + Layers contain: compressed tar.gz files + ``` + +3. **Registry API**: + - Push blobs (config and layers) first + - Then push manifest referencing those blobs + - Manifest URL contains the final digest + +### Cross-Compilation on macOS + +For Linux targets from macOS, you need: +1. Target toolchain: `rustup target add x86_64-unknown-linux-musl` +2. Cross-linker: `brew install filosottile/musl-cross/musl-cross` +3. Cargo config to specify the linker: + ```toml + [target.x86_64-unknown-linux-musl] + linker = "x86_64-linux-musl-gcc" + ``` + +### Rust Static Linking + +- Use `RUSTFLAGS="-C target-feature=+crt-static"` for static linking +- musl targets default to static, but explicit is better +- The resulting binary has no runtime dependencies + +## Architecture Decisions + +### Module Structure + +``` +src/ +├── main.rs # CLI entry point and orchestration +├── lib.rs # Public API exports +├── cli/ # Command-line interface definitions +├── builder/ # Rust compilation logic +├── image/ # OCI image construction +├── registry/ # Registry push operations +└── config/ # Configuration management +``` + +### Error Handling + +- Used `anyhow` for error propagation with context +- Errors include contextual information for debugging +- All errors go to stderr, preserving stdout for output + +### Dependencies + +Key crates chosen: +- `clap` - CLI parsing with derive macros +- `tokio` - Async runtime for registry operations +- `oci-distribution` - OCI registry client +- `tar` + `flate2` - Layer creation +- `sha256` - Digest calculation +- `tracing` - Structured logging + +## Testing Strategy + +1. **Unit tests** for each module +2. **Integration tests** for CLI commands +3. **E2E tests** that actually run the built binary +4. Used `assert_cmd` for testing CLI behavior + +## Development Workflow + +The iterative development process: +1. Start with basic CLI structure +2. Implement core functionality (build, image, push) +3. Test with real registries (ttl.sh for anonymous push) +4. Fix issues discovered during real usage +5. Refine UX based on actual workflows + +## Future Improvements + +Potential enhancements identified: +1. Registry authentication support +2. Multi-platform image manifests +3. Build caching +4. Image layer optimization +5. Support for custom Dockerfile-like configs +6. SBOM (Software Bill of Materials) generation + +## Useful Commands + +```bash +# Test the full workflow +export KRUST_REPO=ttl.sh/test +docker run $(krust build example/hello-krust) + +# Debug output +krust build -v 2>&1 | less + +# Check static linking +file target/x86_64-unknown-linux-musl/release/binary +ldd target/x86_64-unknown-linux-musl/release/binary # should say "not a dynamic executable" +``` + +## Resources + +- [OCI Image Spec](https://github.com/opencontainers/image-spec) +- [OCI Distribution Spec](https://github.com/opencontainers/distribution-spec) +- [ko.build](https://ko.build) - Inspiration for this project +- [ttl.sh](https://ttl.sh) - Anonymous ephemeral registry for testing \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4805f2e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "krust" +version = "0.1.0" +edition = "2021" +authors = ["Jason Hall "] +description = "A container image build tool for Rust applications" +license = "MIT OR Apache-2.0" +repository = "https://github.com/imjasonh/krust" +keywords = ["container", "docker", "oci", "build", "rust"] +categories = ["command-line-utilities", "development-tools"] + +[dependencies] +clap = { version = "4.5", features = ["derive", "env"] } +tokio = { version = "1.35", features = ["full"] } +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha256 = "1.5" +chrono = "0.4" +tar = "0.4" +flate2 = "1.0" +oci-distribution = "0.11" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dirs = "5.0" +toml = "0.8" + +[dev-dependencies] +tempfile = "3.9" +assert_cmd = "2.0" +predicates = "3.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8fb299f --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: test test-unit test-e2e build run clean fmt lint + +# Build the project +build: + cargo build + +# Run the project +run: + cargo run + +# Run all tests +test: test-unit test-e2e + +# Run unit tests only +test-unit: + cargo test --lib --bins + +# Run e2e tests only +test-e2e: + cargo test --test '*' + +# Clean build artifacts +clean: + cargo clean + +# Format code +fmt: + cargo fmt + +# Run linter +lint: + cargo clippy -- -D warnings + +# Check formatting +check-fmt: + cargo fmt -- --check + +# Run all checks (format, lint, test) +check: check-fmt lint test \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..253d46a --- /dev/null +++ b/README.md @@ -0,0 +1,236 @@ +# krust + +A container image build tool for Rust applications, inspired by [`ko`](https://ko.build) for Go. + +## Overview + +krust builds container images for Rust applications without requiring Docker. It: +- Executes `cargo build` to compile your Rust application as a static binary using musl libc +- Packages the resulting binary into a minimal container image layer +- Pushes images to OCI-compliant registries by default (use `--no-push` to skip) +- Creates truly static binaries by default for maximum portability and security + +## Quick Start + +```bash +# Install krust +cargo install --path . + +# Set up your repository +export KRUST_REPO=ttl.sh/$USER + +# Build and run your Rust app as a container +docker run $(krust build) +``` + +## Installation + +```bash +cargo install --path . +``` + +### Prerequisites + +Install the Linux musl targets for static binary cross-compilation: + +```bash +# For linux/amd64 (most common) +rustup target add x86_64-unknown-linux-musl + +# For linux/arm64 +rustup target add aarch64-unknown-linux-musl + +# For linux/arm/v7 +rustup target add armv7-unknown-linux-musleabihf +``` + +#### macOS Cross-compilation Setup + +On macOS, you'll need a cross-compilation toolchain: + +```bash +# Install musl cross-compilation tools +brew install filosottile/musl-cross/musl-cross + +# Create a .cargo/config.toml in your project with: +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-musl-gcc" +EOF +``` + +Note: krust builds fully static binaries by default using musl libc, ensuring maximum portability across different Linux distributions and container environments. + +## Usage + +krust outputs the pushed image reference by digest to stdout, with all other output going to stderr. This enables composability with other tools. + +### Build a project in the current directory + +```bash +# Set your repository prefix +export KRUST_REPO=ttl.sh/jason + +# Build and push (default behavior) +krust build + +# Build without pushing +krust build --no-push + +# Build, push, and run immediately +docker run $(krust build) +``` + +### Build a specific directory + +```bash +# Build and push a specific project +krust build path/to/rust/project + +# Build without pushing +krust build example/hello-krust --no-push +``` + +### Override the image name + +```bash +# Use a specific image name (overrides KRUST_REPO) +krust build --image myregistry.io/myapp:v1.0 + +# Build for a different platform +krust build --platform linux/arm64 +``` + +### Build with custom cargo arguments + +```bash +krust build -- --features=prod +``` + +## Supported Platforms + +- `linux/amd64` (x86_64-unknown-linux-musl) +- `linux/arm64` (aarch64-unknown-linux-musl) +- `linux/arm/v7` (armv7-unknown-linux-musleabihf) + +## Static Binaries + +krust builds fully static binaries by default using: +- musl libc for Linux targets +- `RUSTFLAGS="-C target-feature=+crt-static"` for static linking +- Distroless static base image (`gcr.io/distroless/static:nonroot`) + +This ensures your applications work across all Linux distributions without dependency issues. + +### Why musl instead of glibc? + +krust uses musl libc instead of glibc for several important reasons: + +1. **True static linking** - musl is designed for static linking, while glibc uses dynamic loading internally (NSS) that breaks in static binaries +2. **Smaller binaries** - musl static binaries are typically 5-10x smaller than glibc equivalents +3. **No runtime surprises** - glibc static binaries often fail at runtime with DNS resolution, user lookups, or locale issues +4. **Container-optimized** - musl's simplicity makes it ideal for containers where you want minimal dependencies +5. **Security** - Smaller attack surface with fewer moving parts + +The tradeoff is that musl has slightly different behavior than glibc in some edge cases, but for most applications this is not an issue. If your application requires glibc-specific behavior, you can override the default by building locally with cargo and creating your own container image. + +## Environment Variables + +- `KRUST_REPO` - Default repository prefix for built images (e.g., `ttl.sh/username`) +- `KRUST_IMAGE` - Override the full image reference for a build + +## Configuration + +krust looks for configuration at `~/.config/krust/config.toml`: + +```toml +base_image = "gcr.io/distroless/static:nonroot" +default_registry = "ghcr.io" + +[build] +cargo_args = ["--features", "production"] + +[registries."ghcr.io"] +username = "myuser" +password = "mytoken" +``` + +Note: Registry authentication is not yet implemented. Currently, krust uses anonymous authentication. + +## Key Features + +- **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)` +- **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 + +## Example + +Build and run the example application: + +```bash +# Set your repository (ttl.sh provides temporary anonymous storage) +export KRUST_REPO=ttl.sh/jason + +# Build and push the example (default behavior) +krust build example/hello-krust + +# Build without pushing +krust build example/hello-krust --no-push + +# Build, push, and run the example +docker run $(krust build example/hello-krust) + +# Or specify a custom image name with TTL (time-to-live) +# Images on ttl.sh expire based on the tag: 1h, 2d, 1w, etc. +krust build example/hello-krust --image ttl.sh/jason/hello:1h +``` + +Note: [ttl.sh](https://ttl.sh) is a free, temporary container registry perfect for testing. Images are automatically deleted after their TTL expires. + +## CLI Reference + +``` +krust build [OPTIONS] [DIRECTORY] [-- ...] + +Arguments: + [DIRECTORY] Path to the Rust project directory (defaults to current directory) + [CARGO_ARGS]... Additional cargo build arguments + +Options: + -i, --image Target image reference (overrides KRUST_REPO) + --platform Target platform [default: linux/amd64] + --no-push Skip pushing the image to registry + --repo Repository prefix (uses KRUST_REPO env var) + -v, --verbose Enable verbose logging + -h, --help Print help +``` + +## Troubleshooting + +### macOS: "linking with `cc` failed" + +This error occurs when the cross-compilation toolchain is not properly configured. Make sure you: + +1. Install musl-cross: `brew install filosottile/musl-cross/musl-cross` +2. Create `.cargo/config.toml` in your project with the appropriate linker configuration + +### "target may not be installed" + +Install the required target with rustup: +```bash +rustup target add x86_64-unknown-linux-musl +``` + +### Platform mismatch warning when running images + +This is normal when building linux/amd64 images on Apple Silicon. The images will still run correctly under emulation. + +## License + +MIT OR Apache-2.0 diff --git a/example/hello-krust/.cargo/config.toml b/example/hello-krust/.cargo/config.toml new file mode 100644 index 0000000..3eba26b --- /dev/null +++ b/example/hello-krust/.cargo/config.toml @@ -0,0 +1,8 @@ +[target.x86_64-unknown-linux-musl] +linker = "x86_64-linux-musl-gcc" + +[target.aarch64-unknown-linux-musl] +linker = "aarch64-linux-musl-gcc" + +[target.armv7-unknown-linux-musleabihf] +linker = "armv7-linux-musleabihf-gcc" \ No newline at end of file diff --git a/example/hello-krust/Cargo.toml b/example/hello-krust/Cargo.toml new file mode 100644 index 0000000..4f950bb --- /dev/null +++ b/example/hello-krust/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "hello-krust" +version = "0.1.0" +edition = "2021" + +[dependencies] \ No newline at end of file diff --git a/example/hello-krust/src/main.rs b/example/hello-krust/src/main.rs new file mode 100644 index 0000000..ce8248c --- /dev/null +++ b/example/hello-krust/src/main.rs @@ -0,0 +1,4 @@ +fn main() { + println!("Hello from krust example!"); + println!("This is running inside a container built with krust."); +} \ No newline at end of file diff --git a/src/builder/mod.rs b/src/builder/mod.rs new file mode 100644 index 0000000..5c8bef2 --- /dev/null +++ b/src/builder/mod.rs @@ -0,0 +1,104 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tracing::{debug, info}; + +#[cfg(test)] +mod tests; + +pub struct RustBuilder { + project_path: PathBuf, + target: String, + cargo_args: Vec, +} + +impl RustBuilder { + pub fn new(project_path: impl AsRef, target: &str) -> Self { + Self { + project_path: project_path.as_ref().to_path_buf(), + target: target.to_string(), + cargo_args: Vec::new(), + } + } + + pub fn with_cargo_args(mut self, args: Vec) -> Self { + self.cargo_args = args; + self + } + + pub fn build(&self) -> Result { + info!("Building Rust project at {:?}", self.project_path); + + let mut cmd = Command::new("cargo"); + cmd.arg("build") + .arg("--release") + .arg("--target") + .arg(&self.target) + .current_dir(&self.project_path); + + // Set RUSTFLAGS for static linking + let rustflags = if self.target.contains("musl") { + // For musl targets, ensure fully static linking + "-C target-feature=+crt-static" + } else { + // For GNU targets, link statically where possible + "-C target-feature=+crt-static -C link-arg=-static-libgcc" + }; + cmd.env("RUSTFLAGS", rustflags); + + for arg in &self.cargo_args { + cmd.arg(arg); + } + + debug!("Running command: {:?}", cmd); + debug!("RUSTFLAGS: {}", rustflags); + + let output = cmd.output().context("Failed to execute cargo build")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Cargo build failed: {}", stderr); + } + + let binary_name = self.get_binary_name()?; + let binary_path = self + .project_path + .join("target") + .join(&self.target) + .join("release") + .join(&binary_name); + + if !binary_path.exists() { + anyhow::bail!("Built binary not found at {:?}", binary_path); + } + + info!("Successfully built binary at {:?}", binary_path); + Ok(binary_path) + } + + fn get_binary_name(&self) -> Result { + let cargo_toml_path = self.project_path.join("Cargo.toml"); + let content = + std::fs::read_to_string(&cargo_toml_path).context("Failed to read Cargo.toml")?; + + let manifest: toml::Value = + toml::from_str(&content).context("Failed to parse Cargo.toml")?; + + let name = manifest + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .context("Failed to get package name from Cargo.toml")?; + + Ok(name.to_string()) + } +} + +pub fn get_rust_target_triple(platform: &str) -> Result { + match platform { + "linux/amd64" => Ok("x86_64-unknown-linux-musl".to_string()), + "linux/arm64" => Ok("aarch64-unknown-linux-musl".to_string()), + "linux/arm/v7" => Ok("armv7-unknown-linux-musleabihf".to_string()), + _ => anyhow::bail!("Unsupported platform: {}", platform), + } +} diff --git a/src/builder/tests.rs b/src/builder/tests.rs new file mode 100644 index 0000000..e9bc397 --- /dev/null +++ b/src/builder/tests.rs @@ -0,0 +1,21 @@ +#[cfg(test)] +mod tests { + use super::super::*; + + #[test] + fn test_get_rust_target_triple() { + assert_eq!( + get_rust_target_triple("linux/amd64").unwrap(), + "x86_64-unknown-linux-musl" + ); + assert_eq!( + get_rust_target_triple("linux/arm64").unwrap(), + "aarch64-unknown-linux-musl" + ); + assert_eq!( + get_rust_target_triple("linux/arm/v7").unwrap(), + "armv7-unknown-linux-musleabihf" + ); + assert!(get_rust_target_triple("windows/amd64").is_err()); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..74eba1a --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,53 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "krust")] +#[command(author, version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + + /// Enable verbose logging + #[arg(short, long, global = true)] + pub verbose: bool, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Build a container image from a Rust application + Build { + /// Path to the Rust project directory + #[arg(value_name = "DIRECTORY")] + path: Option, + + /// Target image reference (overrides KRUST_REPO) + #[arg(short, long, env = "KRUST_IMAGE")] + image: Option, + + /// Target platform (e.g., linux/amd64, linux/arm64) + #[arg(long, default_value = "linux/amd64")] + platform: String, + + /// Skip pushing the image to the registry after building + #[arg(long)] + no_push: bool, + + /// Repository prefix (e.g., ghcr.io/username) + #[arg(long, env = "KRUST_REPO")] + repo: Option, + + /// Additional cargo build arguments + #[arg(last = true)] + cargo_args: Vec, + }, + + /// Push a built image to a container registry + Push { + /// Image reference to push + image: String, + }, + + /// Show version information + Version, +} \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..c5c1cf5 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Default base image for containers + #[serde(default = "default_base_image")] + pub base_image: String, + + /// Default registry to push images to + pub default_registry: Option, + + /// Build configuration + #[serde(default)] + pub build: BuildConfig, + + /// Registry authentication configuration + #[serde(default)] + pub registries: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BuildConfig { + /// Additional environment variables for cargo build + #[serde(default)] + pub env: HashMap, + + /// Default cargo build arguments + #[serde(default)] + pub cargo_args: Vec, + + /// Target directory for build artifacts + pub target_dir: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryAuth { + pub username: Option, + pub password: Option, + pub auth: Option, +} + +fn default_base_image() -> String { + "gcr.io/distroless/static:nonroot".to_string() +} + +impl Default for Config { + fn default() -> Self { + Self { + base_image: default_base_image(), + default_registry: None, + build: BuildConfig::default(), + registries: HashMap::new(), + } + } +} + +impl Config { + pub fn load() -> anyhow::Result { + if let Some(config_dir) = dirs::config_dir() { + let config_path = config_dir.join("krust").join("config.toml"); + if config_path.exists() { + let content = std::fs::read_to_string(config_path)?; + let config: Config = toml::from_str(&content)?; + return Ok(config); + } + } + Ok(Config::default()) + } +} diff --git a/src/config/tests.rs b/src/config/tests.rs new file mode 100644 index 0000000..7c570ea --- /dev/null +++ b/src/config/tests.rs @@ -0,0 +1,20 @@ +#[cfg(test)] +mod tests { + use super::super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.base_image, "gcr.io/distroless/static:nonroot"); + assert!(config.default_registry.is_none()); + assert!(config.registries.is_empty()); + } + + #[test] + fn test_build_config_default() { + let build_config = BuildConfig::default(); + assert!(build_config.env.is_empty()); + assert!(build_config.cargo_args.is_empty()); + assert!(build_config.target_dir.is_none()); + } +} diff --git a/src/image/mod.rs b/src/image/mod.rs new file mode 100644 index 0000000..ce3221c --- /dev/null +++ b/src/image/mod.rs @@ -0,0 +1,197 @@ +use anyhow::{Context, Result}; +use flate2::write::GzEncoder; +use flate2::Compression; +use serde::{Deserialize, Serialize}; +use sha256::digest; +use std::fs::File; +use std::io::Write; +use tar::Builder; +use tracing::{debug, info}; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageConfig { + pub architecture: String, + pub os: String, + pub config: Config, + pub rootfs: RootFs, + pub history: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(rename = "Env")] + pub env: Vec, + #[serde(rename = "Cmd")] + pub cmd: Option>, + #[serde(rename = "WorkingDir")] + pub working_dir: String, + #[serde(rename = "User")] + pub user: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RootFs { + #[serde(rename = "type")] + pub fs_type: String, + pub diff_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct History { + pub created: String, + pub created_by: String, + pub comment: String, + pub empty_layer: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + #[serde(rename = "schemaVersion")] + pub schema_version: i32, + #[serde(rename = "mediaType")] + pub media_type: String, + pub config: Descriptor, + pub layers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Descriptor { + #[serde(rename = "mediaType")] + pub media_type: String, + pub size: i64, + pub digest: String, +} + +pub struct ImageBuilder { + binary_path: PathBuf, + #[allow(dead_code)] + base_image: String, + platform: String, +} + +use std::path::PathBuf; + +impl ImageBuilder { + pub fn new(binary_path: PathBuf, base_image: String, platform: String) -> Self { + Self { + binary_path, + base_image, + platform, + } + } + + pub fn build(&self) -> Result<(Vec, Vec, Manifest)> { + info!("Building container image"); + + let (os, arch) = self.parse_platform()?; + + // Create application layer + let (layer_data, diff_id) = self.create_layer()?; + let layer_digest = format!("sha256:{}", digest(&layer_data)); + let layer_size = layer_data.len() as i64; + + // Create image config + let config = self.create_config(&os, &arch, &diff_id)?; + let config_data = serde_json::to_vec_pretty(&config)?; + let config_digest = format!("sha256:{}", digest(&config_data)); + let config_size = config_data.len() as i64; + + // Create manifest + let manifest = Manifest { + schema_version: 2, + media_type: "application/vnd.docker.distribution.manifest.v2+json".to_string(), + config: Descriptor { + media_type: "application/vnd.docker.container.image.v1+json".to_string(), + size: config_size, + digest: config_digest, + }, + layers: vec![Descriptor { + media_type: "application/vnd.docker.image.rootfs.diff.tar.gzip".to_string(), + size: layer_size, + digest: layer_digest, + }], + }; + + Ok((config_data, layer_data, manifest)) + } + + fn parse_platform(&self) -> Result<(String, String)> { + let parts: Vec<&str> = self.platform.split('/').collect(); + if parts.len() != 2 { + anyhow::bail!("Invalid platform format: {}", self.platform); + } + Ok((parts[0].to_string(), parts[1].to_string())) + } + + fn create_layer(&self) -> Result<(Vec, String)> { + debug!("Creating layer from binary: {:?}", self.binary_path); + + let mut tar_data = Vec::new(); + { + let mut tar = Builder::new(&mut tar_data); + + // Add the binary to /app/ + let mut file = File::open(&self.binary_path)?; + let binary_name = self + .binary_path + .file_name() + .context("Invalid binary path")? + .to_str() + .context("Invalid UTF-8 in binary name")?; + + let mut header = tar::Header::new_gnu(); + header.set_path(format!("app/{}", binary_name))?; + header.set_size(std::fs::metadata(&self.binary_path)?.len()); + header.set_mode(0o755); + header.set_cksum(); + + tar.append(&header, &mut file)?; + tar.finish()?; + } + + // Calculate diff_id (digest of uncompressed tar) + let diff_id = format!("sha256:{}", digest(&tar_data)); + + // Compress the tar + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&tar_data)?; + let compressed = encoder.finish()?; + + Ok((compressed, diff_id)) + } + + fn create_config(&self, os: &str, arch: &str, layer_digest: &str) -> Result { + let binary_name = self + .binary_path + .file_name() + .context("Invalid binary path")? + .to_str() + .context("Invalid UTF-8 in binary name")?; + + Ok(ImageConfig { + architecture: arch.to_string(), + os: os.to_string(), + config: Config { + env: vec![ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(), + ], + cmd: Some(vec![format!("/app/{}", binary_name)]), + working_dir: "/".to_string(), + user: "65532:65532".to_string(), // nonroot user + }, + rootfs: RootFs { + fs_type: "layers".to_string(), + diff_ids: vec![layer_digest.to_string()], + }, + history: vec![History { + created: chrono::Utc::now().to_rfc3339(), + created_by: "krust".to_string(), + comment: "Built with krust".to_string(), + empty_layer: false, + }], + }) + } +} diff --git a/src/image/tests.rs b/src/image/tests.rs new file mode 100644 index 0000000..b029471 --- /dev/null +++ b/src/image/tests.rs @@ -0,0 +1,29 @@ +#[cfg(test)] +mod tests { + use super::super::*; + use std::path::PathBuf; + + #[test] + fn test_parse_platform() { + let builder = ImageBuilder::new( + PathBuf::from("/tmp/test"), + "test-base".to_string(), + "linux/amd64".to_string(), + ); + + let (os, arch) = builder.parse_platform().unwrap(); + assert_eq!(os, "linux"); + assert_eq!(arch, "amd64"); + } + + #[test] + fn test_parse_platform_invalid() { + let builder = ImageBuilder::new( + PathBuf::from("/tmp/test"), + "test-base".to_string(), + "invalid-platform".to_string(), + ); + + assert!(builder.parse_platform().is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..75eb949 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod builder; +pub mod cli; +pub mod config; +pub mod image; +pub mod registry; + +pub use anyhow::Result; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d15be6a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,111 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use krust::{ + builder::{get_rust_target_triple, RustBuilder}, + cli::{Cli, Commands}, + config::Config, + image::ImageBuilder, + registry::RegistryClient, +}; +use std::path::PathBuf; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + // Initialize logging to stderr + let filter = if cli.verbose { + EnvFilter::new("debug") + } else { + EnvFilter::new("info") + }; + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .init(); + + match cli.command { + Commands::Build { + path, + image, + platform, + no_push, + repo, + cargo_args, + } => { + let config = Config::load()?; + let project_path = path.unwrap_or_else(|| PathBuf::from(".")); + + // Determine the image name + let image_ref = if let Some(image) = image { + // Use explicit image if provided + image + } else { + // Build image name from repo and project name + let repo = repo.context("Either --image or KRUST_REPO must be set")?; + let project_name = get_project_name(&project_path)?; + 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, config.base_image.clone(), 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); + } else { + info!("Successfully built image: {}", image_ref); + info!("Skipping push (--no-push specified)"); + } + } + Commands::Push { image } => { + let _ = image; + error!("Push command not yet implemented"); + std::process::exit(1); + } + Commands::Version => { + println!("krust {}", env!("CARGO_PKG_VERSION")); + } + } + + Ok(()) +} + +fn get_project_name(project_path: &PathBuf) -> Result { + let cargo_toml_path = project_path.join("Cargo.toml"); + let content = std::fs::read_to_string(&cargo_toml_path) + .context("Failed to read Cargo.toml")?; + + let manifest: toml::Value = toml::from_str(&content) + .context("Failed to parse Cargo.toml")?; + + let name = manifest + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .context("Failed to get package name from Cargo.toml")?; + + Ok(name.to_string()) +} \ No newline at end of file diff --git a/src/registry/mod.rs b/src/registry/mod.rs new file mode 100644 index 0000000..dbceb22 --- /dev/null +++ b/src/registry/mod.rs @@ -0,0 +1,115 @@ +use anyhow::{Context, Result}; +use oci_distribution::manifest::{OciDescriptor, OciImageManifest, OciManifest}; +use oci_distribution::secrets::RegistryAuth; +use oci_distribution::{Client, Reference}; +use tracing::{debug, info}; + +#[cfg(test)] +mod tests; + +pub struct RegistryClient { + client: Client, + #[allow(dead_code)] + auth: RegistryAuth, +} + +impl RegistryClient { + pub fn new(auth: RegistryAuth) -> Result { + let client = Client::new(oci_distribution::client::ClientConfig::default()); + Ok(Self { client, auth }) + } + + pub async fn push_image( + &mut self, + image_ref: &str, + config_data: Vec, + layers: Vec<(Vec, String)>, + ) -> Result { + let reference: Reference = image_ref + .parse() + .context("Failed to parse image reference")?; + + info!("Pushing image to {}", reference); + + // Push config blob + let config_digest = format!("sha256:{}", sha256::digest(&config_data)); + debug!("Pushing config blob: {}", config_digest); + + self.client + .push_blob(&reference, &config_data, &config_digest) + .await + .context("Failed to push config blob")?; + + // Push layers + let mut manifest_layers = Vec::new(); + for (layer_data, media_type) in layers { + let digest = format!("sha256:{}", sha256::digest(&layer_data)); + debug!("Pushing layer: {}", digest); + + self.client + .push_blob(&reference, &layer_data, &digest) + .await + .context("Failed to push layer")?; + + manifest_layers.push(OciDescriptor { + media_type: media_type.clone(), + digest: digest.clone(), + size: layer_data.len() as i64, + urls: None, + annotations: None, + }); + } + + // Create and push manifest + let image_manifest = OciImageManifest { + schema_version: 2, + media_type: Some("application/vnd.oci.image.manifest.v1+json".to_string()), + artifact_type: None, + config: OciDescriptor { + media_type: "application/vnd.oci.image.config.v1+json".to_string(), + digest: config_digest, + size: config_data.len() as i64, + urls: None, + annotations: None, + }, + layers: manifest_layers, + annotations: None, + }; + + // Wrap the image manifest in the OciManifest enum + let manifest = OciManifest::Image(image_manifest); + + debug!("Pushing manifest"); + let manifest_url = self + .client + .push_manifest(&reference, &manifest) + .await + .context("Failed to push manifest")?; + + info!("Successfully pushed image to {}", manifest_url); + + // Extract digest from the manifest URL + // URL format: https://registry/v2/repo/manifests/sha256:digest + let digest = manifest_url + .split('/') + .last() + .context("Failed to extract digest from manifest URL")?; + + // Build the full image reference with digest + let registry = reference.registry(); + let repository = reference.repository(); + let digest_ref = format!("{}/{}@{}", registry, repository, digest); + + Ok(digest_ref) + } +} + +pub fn parse_image_reference(image: &str) -> Result<(String, String, String)> { + let reference: Reference = image.parse().context("Failed to parse image reference")?; + + let registry = reference.registry().to_string(); + let repository = reference.repository().to_string(); + let tag = reference.tag().unwrap_or("latest").to_string(); + + Ok((registry, repository, tag)) +} diff --git a/src/registry/tests.rs b/src/registry/tests.rs new file mode 100644 index 0000000..2a93475 --- /dev/null +++ b/src/registry/tests.rs @@ -0,0 +1,27 @@ +#[cfg(test)] +mod tests { + use super::super::*; + + #[test] + fn test_parse_image_reference() { + let (registry, repo, tag) = + parse_image_reference("docker.io/library/hello-world:latest").unwrap(); + assert_eq!(registry, "docker.io"); + assert_eq!(repo, "library/hello-world"); + assert_eq!(tag, "latest"); + } + + #[test] + fn test_parse_image_reference_no_tag() { + let (_, _, tag) = parse_image_reference("docker.io/library/hello-world").unwrap(); + assert_eq!(tag, "latest"); + } + + #[test] + fn test_parse_image_reference_with_port() { + let (registry, repo, tag) = parse_image_reference("localhost:5000/myapp:v1.0").unwrap(); + assert_eq!(registry, "localhost:5000"); + assert_eq!(repo, "myapp"); + assert_eq!(tag, "v1.0"); + } +} diff --git a/tests/e2e/basic_test.rs b/tests/e2e/basic_test.rs new file mode 100644 index 0000000..b67587c --- /dev/null +++ b/tests/e2e/basic_test.rs @@ -0,0 +1,25 @@ +use std::process::Command; + +#[test] +fn test_help_flag() { + let output = Command::new("cargo") + .args(&["run", "--", "--version"]) + .output() + .expect("Failed to execute command"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("krust")); + assert!(output.status.success()); +} + +#[test] +fn test_basic_run() { + let output = Command::new("cargo") + .arg("run") + .output() + .expect("Failed to execute command"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Hello from krust!")); + assert!(output.status.success()); +} \ No newline at end of file diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..bc5e659 --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,43 @@ +use anyhow::Result; +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn test_version_command() -> Result<()> { + let mut cmd = Command::cargo_bin("krust")?; + cmd.arg("--version"); + cmd.assert() + .success() + .stdout(predicate::str::contains("krust 0.1.0")); + Ok(()) +} + +#[test] +fn test_version_subcommand() -> Result<()> { + let mut cmd = Command::cargo_bin("krust")?; + cmd.arg("version"); + cmd.assert() + .success() + .stdout(predicate::str::contains("krust 0.1.0")); + Ok(()) +} + +#[test] +fn test_help_command() -> Result<()> { + let mut cmd = Command::cargo_bin("krust")?; + cmd.arg("--help"); + cmd.assert().success().stdout(predicate::str::contains( + "A container image build tool for Rust applications", + )); + Ok(()) +} + +#[test] +fn test_build_help() -> Result<()> { + let mut cmd = Command::cargo_bin("krust")?; + cmd.arg("build").arg("--help"); + cmd.assert().success().stdout(predicate::str::contains( + "Build a container image from a Rust application", + )); + Ok(()) +}