From 65e390098eeb56db183986d17d6ac8527e3b283e Mon Sep 17 00:00:00 2001 From: Daniel Hast Date: Wed, 27 May 2026 23:55:38 -0400 Subject: [PATCH] feat: support rechunking using Chunkah Add a `--chunkah` option to `bluebuild build` that rechunks the image using [Chunkah](https://github.com/coreos/chunkah), a more modern, content-agnostic rechunker that's actively maintained and should offer various improvements over build-chunked-oci and the legacy rechunker. This is implemented using a fairly generic "post-build hook" mechanism that allows a post-processing step (such as rechunking) to be applied after building the image but before tagging and pushing. --- .github/workflows/test.yml | 111 +++++++ Cargo.lock | 21 -- Cargo.toml | 3 - .../test-repo/recipes/recipe-chunkah.yml | 10 + .../recipes/recipe-multiplatform-chunkah.yml | 13 + justfile | 27 +- process/Cargo.toml | 3 - process/drivers.rs | 26 +- process/drivers/buildah_driver.rs | 28 +- process/drivers/docker_driver.rs | 30 +- process/drivers/opts.rs | 4 + process/drivers/opts/image_storage.rs | 22 ++ process/drivers/opts/oci_copy.rs | 3 + process/drivers/opts/post_build.rs | 41 +++ process/drivers/opts/run.rs | 9 - process/drivers/podman_driver.rs | 288 +++++++++++++++++- process/drivers/post_build.rs | 3 + process/drivers/post_build/chunkah.rs | 172 +++++++++++ process/drivers/rpm_ostree_runner.rs | 16 +- process/drivers/skopeo_driver.rs | 62 ++-- process/drivers/traits.rs | 103 +++++-- process/logging.rs | 169 +++++----- recipe/Cargo.toml | 2 - src/commands/build.rs | 101 ++++-- src/commands/switch.rs | 6 +- template/Cargo.toml | 1 - utils/Cargo.toml | 2 - utils/src/constants.rs | 3 + 28 files changed, 1025 insertions(+), 254 deletions(-) create mode 100644 integration-tests/test-repo/recipes/recipe-chunkah.yml create mode 100644 integration-tests/test-repo/recipes/recipe-multiplatform-chunkah.yml create mode 100644 process/drivers/opts/image_storage.rs create mode 100644 process/drivers/opts/post_build.rs create mode 100644 process/drivers/post_build.rs create mode 100644 process/drivers/post_build/chunkah.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5430cdb5..42afce0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -247,6 +247,44 @@ jobs: COSIGN_PRIVATE_KEY: ${{ secrets.TEST_SIGNING_SECRET }} run: just test-bluefin-build + chunkah-build: + timeout-minutes: 40 + runs-on: ubuntu-24.04 + permissions: + contents: read # read repo contents + packages: write # write test package to ghcr + id-token: write # docker auth + + steps: + - name: Maximize build space + uses: ublue-os/remove-unwanted-software@cc0becac701cf642c8f0a6613bbdaf5dc36b259e # v9 + + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + install-dir: /usr/bin + use-sudo: true + + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ inputs.ref }} + repository: ${{ inputs.repo }} + + + - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 + + - name: Run Build + env: + GH_TOKEN: ${{ github.token }} + GH_PR_EVENT_NUMBER: ${{ inputs.pr_event_number }} + COSIGN_PRIVATE_KEY: ${{ secrets.TEST_SIGNING_SECRET }} + run: | + export CARGO_HOME=$HOME/.cargo + just test-chunkah-build + build-chunked-oci-build: timeout-minutes: 40 runs-on: ubuntu-24.04 @@ -598,6 +636,42 @@ jobs: COSIGN_PRIVATE_KEY: ${{ secrets.TEST_SIGNING_SECRET }} run: just test-multiplatform-buildah + multi-platform-chunkah: + timeout-minutes: 120 + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Maximize build space + uses: ublue-os/remove-unwanted-software@cc0becac701cf642c8f0a6613bbdaf5dc36b259e # v9 + + - name: Set up QEMU + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ inputs.ref }} + repository: ${{ inputs.repo }} + + + - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 + + - name: Run Build + env: + GH_TOKEN: ${{ github.token }} + GH_PR_EVENT_NUMBER: ${{ inputs.pr_event_number }} + COSIGN_PRIVATE_KEY: ${{ secrets.TEST_SIGNING_SECRET }} + run: just test-multiplatform-chunkah + multi-platform-build-chunked-oci: timeout-minutes: 120 runs-on: ubuntu-latest @@ -821,6 +895,43 @@ jobs: COSIGN_PRIVATE_KEY: ${{ secrets.TEST_SIGNING_SECRET }} run: just test-container-podman-build + container-podman-chunkah: + timeout-minutes: 60 + runs-on: ubuntu-latest + permissions: + contents: read # read repo contents + packages: write # write test package to ghcr + id-token: write # docker auth + + steps: + - name: Maximize build space + uses: ublue-os/remove-unwanted-software@cc0becac701cf642c8f0a6613bbdaf5dc36b259e # v9 + + - uses: earthly/actions-setup@43211c7a0eae5344d6d79fb4aaf209c8f8866203 # v1.0.13 + with: + use-cache: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + with: + install: true + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ inputs.ref }} + repository: ${{ inputs.repo }} + + - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 + + - name: Run Build + env: + GH_TOKEN: ${{ github.token }} + GH_PR_EVENT_NUMBER: ${{ inputs.pr_event_number }} + COSIGN_PRIVATE_KEY: ${{ secrets.TEST_SIGNING_SECRET }} + run: just test-container-podman-chunkah + container-podman-build-chunked-oci: timeout-minutes: 60 runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 6679f1a0..8599935e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -460,7 +460,6 @@ dependencies = [ "fuzzy-matcher", "git2", "indexmap 2.13.0", - "indicatif", "jsonschema", "log", "miette", @@ -479,8 +478,6 @@ dependencies = [ "serde_json", "serde_yaml_ng", "shadow-rs", - "syntect", - "tempfile", "thiserror 2.0.18", "tokio", "urlencoding", @@ -499,7 +496,6 @@ dependencies = [ "clap", "colored", "comlexr", - "indexmap 2.13.0", "indicatif", "indicatif-log-bridge", "lazy-regex", @@ -509,10 +505,8 @@ dependencies = [ "nix 0.31.2", "nu-ansi-term", "oci-client", - "os_pipe", "pretty_assertions", "rayon", - "reqwest", "rstest", "semver", "serde", @@ -538,9 +532,7 @@ dependencies = [ "log", "miette", "oci-client", - "reqwest", "serde", - "serde_json", "serde_yaml_ng", ] @@ -554,7 +546,6 @@ dependencies = [ "bon", "colored", "log", - "oci-client", "uuid", ] @@ -579,11 +570,9 @@ dependencies = [ "oci-client", "process_control", "rand 0.10.1", - "reqwest", "rstest", "semver", "serde", - "serde_json", "serde_yaml_ng", "shellexpand", "syntect", @@ -3376,16 +3365,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "outref" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index b5915cfc..404042b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,7 +94,6 @@ clap = { workspace = true, features = ["derive", "cargo", "unicode", "env"] } colored.workspace = true comlexr.workspace = true indexmap.workspace = true -indicatif.workspace = true log.workspace = true miette = { workspace = true, features = ["fancy"] } oci-client.workspace = true @@ -105,8 +104,6 @@ semver.workspace = true serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true -syntect.workspace = true -tempfile.workspace = true tokio.workspace = true bon.workspace = true diff --git a/integration-tests/test-repo/recipes/recipe-chunkah.yml b/integration-tests/test-repo/recipes/recipe-chunkah.yml new file mode 100644 index 00000000..9ba40d81 --- /dev/null +++ b/integration-tests/test-repo/recipes/recipe-chunkah.yml @@ -0,0 +1,10 @@ +--- +# yaml-language-server: $schema=https://schema.blue-build.org/recipe-v1.json +name: cli/test-chunkah +description: This is my personal OS image. +base-image: quay.io/fedora/fedora-bootc +image-version: latest +stages: + - from-file: stages.yml +modules: + - from-file: common.yml diff --git a/integration-tests/test-repo/recipes/recipe-multiplatform-chunkah.yml b/integration-tests/test-repo/recipes/recipe-multiplatform-chunkah.yml new file mode 100644 index 00000000..ce64bd93 --- /dev/null +++ b/integration-tests/test-repo/recipes/recipe-multiplatform-chunkah.yml @@ -0,0 +1,13 @@ +--- +# yaml-language-server: $schema=https://schema.blue-build.org/recipe-v1.json +name: cli/test-multiplatform-chunkah +description: This is my personal OS image. +base-image: quay.io/fedora/fedora-bootc +image-version: latest +platforms: + - linux/amd64 + - linux/arm64 +stages: + - from-file: stages.yml +modules: + - from-file: common.yml diff --git a/justfile b/justfile index b5bcc961..5d6c4f1c 100644 --- a/justfile +++ b/justfile @@ -188,6 +188,14 @@ test-bluefin-build: generate-test-secret install-debug-all-features -vv \ recipes/recipe-bluefin.yml +test-chunkah-build: generate-test-secret install-debug-all-features + cd integration-tests/test-repo \ + && bluebuild build \ + {{ should_push }} \ + -vv \ + --chunkah \ + recipes/recipe-chunkah.yml + test-build-chunked-oci-build: generate-test-secret install-debug-all-features cd integration-tests/test-repo \ && bluebuild build \ @@ -256,7 +264,7 @@ test-buildah-build: generate-test-secret install-debug-all-features recipes/recipe-buildah.yml # Run the multi-platform builds -test-multiplatform: test-multiplatform-docker test-multiplatform-podman test-multiplatform-buildah test-multiplatform-build-chunked-oci test-multiplatform-rechunk +test-multiplatform: test-multiplatform-docker test-multiplatform-podman test-multiplatform-buildah test-multiplatform-chunkah test-multiplatform-build-chunked-oci test-multiplatform-rechunk test-multiplatform-docker: generate-test-secret install-debug-all-features cd integration-tests/test-repo \ @@ -288,6 +296,17 @@ test-multiplatform-buildah: generate-test-secret install-debug-all-features -vv \ recipes/recipe-multiplatform-buildah.yml +test-multiplatform-chunkah: generate-test-secret install-debug-all-features + cd integration-tests/test-repo \ + && bluebuild build \ + --retry-push \ + --chunkah \ + --remove-base-image \ + -S sigstore \ + {{ should_push }} \ + -vv \ + recipes/recipe-multiplatform-chunkah.yml + test-multiplatform-build-chunked-oci: generate-test-secret install-debug-all-features cd integration-tests/test-repo \ && bluebuild build \ @@ -351,6 +370,12 @@ test-container-podman-build: \ generate-test-secret \ (exec-cli-container "bluebuild" "build" "-B" "podman" "--squash" "-vv") +# Run a cli container using the podman build driver with chunkah +test-container-podman-chunkah: \ + generate-test-secret \ + (exec-cli-container "bluebuild" "build" "-B" \ + "podman" "-vv" "--chunkah" "recipes/recipe-chunkah.yml") + # Run a cli container using the podman build driver with build-chunked-oci test-container-podman-build-chunked-oci: \ generate-test-secret \ diff --git a/process/Cargo.toml b/process/Cargo.toml index 87bf96ca..595f46fd 100644 --- a/process/Cargo.toml +++ b/process/Cargo.toml @@ -16,7 +16,6 @@ blue-build-utils = { version = "=0.9.35", path = "../utils" } indicatif-log-bridge = "0.2.3" log4rs = { version = "1.4.0", features = ["background_rotation"] } nu-ansi-term = { version = "0.50.3", features = ["gnu_legacy"] } -os_pipe = { version = "1.2.3", features = ["io_safety"] } signal-hook = { version = "0.4.4", features = ["extended-siginfo"] } sigstore = { version = "0.13.0", features = ["rustls-tls", "cached-client", "sigstore-trust-root", "sign", "cosign"], default-features = false } @@ -26,7 +25,6 @@ chrono.workspace = true clap = { workspace = true, features = ["derive", "env"] } colored.workspace = true comlexr.workspace = true -indexmap.workspace = true indicatif.workspace = true lazy-regex.workspace = true log.workspace = true @@ -34,7 +32,6 @@ miette.workspace = true nix = { workspace = true, features = ["signal"] } rayon.workspace = true oci-client.workspace = true -reqwest.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/process/drivers.rs b/process/drivers.rs index 57cf2bc9..2c0ddbb9 100644 --- a/process/drivers.rs +++ b/process/drivers.rs @@ -36,9 +36,9 @@ use crate::{logging::Logger, signal_handler::DetachedContainer}; use opts::{ BuildChunkedOciOpts, BuildOpts, BuildRechunkTagPushOpts, BuildTagPushOpts, CheckKeyPairOpts, ContainerOpts, CopyOciOpts, CreateContainerOpts, GenerateImageNameOpts, GenerateKeyPairOpts, - GenerateTagsOpts, GetMetadataOpts, ManifestCreateOpts, ManifestPushOpts, PruneOpts, PullOpts, - PushOpts, RechunkOpts, RemoveContainerOpts, RemoveImageOpts, RunOpts, SignOpts, SwitchOpts, - TagOpts, UntagOpts, VerifyOpts, VolumeOpts, + GenerateTagsOpts, GetMetadataOpts, InspectImageOpts, ManifestCreateOpts, ManifestPushOpts, + PostBuildDriverOpts, PruneOpts, PullOpts, PushOpts, RechunkOpts, RemoveContainerOpts, + RemoveImageOpts, RunOpts, SignOpts, SwitchOpts, TagOpts, UntagOpts, VerifyOpts, VolumeOpts, }; use types::{ BootDriverType, BuildDriverType, CiDriverType, ImageMetadata, InspectDriverType, RunDriverType, @@ -67,6 +67,7 @@ mod local_driver; mod oci_client_driver; pub mod opts; mod podman_driver; +pub mod post_build; mod rpm_ostree_driver; mod rpm_ostree_runner; mod sigstore_driver; @@ -325,7 +326,11 @@ fn get_version_run_image(oci_ref: &Reference) -> Result { } if should_remove { - Driver::remove_image(RemoveImageOpts::builder().image(oci_ref).build())?; + Driver::remove_image( + RemoveImageOpts::builder() + .image(&oci_ref.to_string()) + .build(), + )?; } progress.finish_and_clear(); @@ -345,6 +350,10 @@ macro_rules! impl_build_driver { } impl ImageStorageDriver for Driver { + fn inspect_image(opts: InspectImageOpts) -> Result>> { + impl_build_driver!(inspect_image(opts)) + } + fn remove_image(opts: RemoveImageOpts) -> Result<()> { impl_build_driver!(remove_image(opts)) } @@ -396,6 +405,15 @@ impl BuildDriver for Driver { } } +impl PostBuildDriver for Driver { + fn build_tag_push_with_post_build( + opts: BuildTagPushOpts, + pb_opts: PostBuildDriverOpts, + ) -> Result> { + PodmanDriver::build_tag_push_with_post_build(opts, pb_opts) + } +} + macro_rules! impl_signing_driver { ($func:ident($($args:expr),*)) => { match Self::get_signing_driver() { diff --git a/process/drivers/buildah_driver.rs b/process/drivers/buildah_driver.rs index dff497ad..41f8ff04 100644 --- a/process/drivers/buildah_driver.rs +++ b/process/drivers/buildah_driver.rs @@ -1,3 +1,5 @@ +use std::{fs::File, process::Stdio}; + use blue_build_utils::{ container::ContainerId, credentials::Credentials, secret::SecretArgs, semver::Version, sudo_cmd, tempdir, @@ -14,8 +16,8 @@ use crate::logging::CommandLogging; use super::{ BuildDriver, DriverVersion, ImageStorageDriver, opts::{ - BuildOpts, ManifestCreateOpts, ManifestPushOpts, PruneOpts, PullOpts, PushOpts, TagOpts, - UntagOpts, + BuildOpts, InspectImageOpts, ManifestCreateOpts, ManifestPushOpts, PruneOpts, PullOpts, + PushOpts, TagOpts, UntagOpts, }, }; @@ -343,6 +345,25 @@ impl BuildDriver for BuildahDriver { } impl ImageStorageDriver for BuildahDriver { + fn inspect_image(opts: InspectImageOpts) -> Result>> { + let stdout = if let Some(output_path) = opts.output_path { + Stdio::from(File::create(output_path).into_diagnostic()?) + } else { + Stdio::piped() + }; + let output = cmd!("buildah", "inspect", "--type", "image", "--", opts.image) + .stdout(stdout) + .output() + .into_diagnostic()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Failed to inspect image {}:\n{}", opts.image, stderr); + } + + Ok(opts.output_path.map(|_| output.stdout)) + } + fn remove_image(opts: super::opts::RemoveImageOpts) -> Result<()> { trace!("BuildahDriver::remove_image({opts:?})"); @@ -352,7 +373,8 @@ impl ImageStorageDriver for BuildahDriver { sudo_check = opts.privileged, "buildah", "rmi", - opts.image.to_string(), + "--", + opts.image, ); trace!("{c:?}"); c diff --git a/process/drivers/docker_driver.rs b/process/drivers/docker_driver.rs index 08ee67e9..543f3ef3 100644 --- a/process/drivers/docker_driver.rs +++ b/process/drivers/docker_driver.rs @@ -1,7 +1,8 @@ use std::{ + fs::File, ops::Not, path::Path, - process::{Command, ExitStatus}, + process::{Command, ExitStatus, Stdio}, }; use blue_build_utils::{ @@ -29,9 +30,9 @@ use crate::{ use super::{ opts::{ - BuildOpts, BuildTagPushOpts, CreateContainerOpts, ManifestCreateOpts, ManifestPushOpts, - PruneOpts, PullOpts, PushOpts, RemoveContainerOpts, RemoveImageOpts, RunOpts, RunOptsEnv, - RunOptsVolume, TagOpts, UntagOpts, + BuildOpts, BuildTagPushOpts, CreateContainerOpts, InspectImageOpts, ManifestCreateOpts, + ManifestPushOpts, PruneOpts, PullOpts, PushOpts, RemoveContainerOpts, RemoveImageOpts, + RunOpts, RunOptsEnv, RunOptsVolume, TagOpts, UntagOpts, }, traits::{BuildDriver, DriverVersion, ImageStorageDriver, RunDriver}, }; @@ -700,11 +701,30 @@ impl RunDriver for DockerDriver { } impl ImageStorageDriver for DockerDriver { + fn inspect_image(opts: InspectImageOpts) -> Result>> { + let stdout = if let Some(output_path) = opts.output_path { + Stdio::from(File::create(output_path).into_diagnostic()?) + } else { + Stdio::piped() + }; + let output = cmd!("docker", "image", "inspect", "--", opts.image) + .stdout(stdout) + .output() + .into_diagnostic()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Failed to inspect image {}:\n{}", opts.image, stderr); + } + + Ok(opts.output_path.map(|_| output.stdout)) + } + fn remove_image(opts: RemoveImageOpts) -> Result<()> { trace!("DockerDriver::remove_image({opts:?})"); let output = { - let c = cmd!("docker", "rmi", opts.image.to_string()); + let c = cmd!("docker", "rmi", "--", opts.image); trace!("{c:?}"); c } diff --git a/process/drivers/opts.rs b/process/drivers/opts.rs index de9ff681..a17c5ade 100644 --- a/process/drivers/opts.rs +++ b/process/drivers/opts.rs @@ -4,8 +4,10 @@ pub use boot::*; pub use build::*; pub use build_chunked_oci::*; pub use ci::*; +pub use image_storage::*; pub use inspect::*; pub use oci_copy::*; +pub use post_build::*; pub use rechunk::*; pub use run::*; pub use signing::*; @@ -14,8 +16,10 @@ mod boot; mod build; mod build_chunked_oci; mod ci; +mod image_storage; mod inspect; mod oci_copy; +mod post_build; mod rechunk; mod run; mod signing; diff --git a/process/drivers/opts/image_storage.rs b/process/drivers/opts/image_storage.rs new file mode 100644 index 00000000..2cb9deaf --- /dev/null +++ b/process/drivers/opts/image_storage.rs @@ -0,0 +1,22 @@ +use std::path::Path; + +use bon::Builder; + +#[derive(Debug, Clone, Copy, Builder)] +#[builder(derive(Debug, Clone))] +pub struct InspectImageOpts<'scope> { + /// Image in local storage to be inspected + pub image: &'scope str, + + /// Path to write output, or `None` if output should be returned to the caller. + pub output_path: Option<&'scope Path>, +} + +#[derive(Debug, Clone, Copy, Builder)] +#[builder(derive(Debug, Clone))] +pub struct RemoveImageOpts<'scope> { + pub image: &'scope str, + + #[builder(default)] + pub privileged: bool, +} diff --git a/process/drivers/opts/oci_copy.rs b/process/drivers/opts/oci_copy.rs index 22d1ea10..702012d9 100644 --- a/process/drivers/opts/oci_copy.rs +++ b/process/drivers/opts/oci_copy.rs @@ -12,4 +12,7 @@ pub struct CopyOciOpts<'scope> { #[builder(default)] pub retry_count: u8, + + #[builder(default)] + pub podman_unshare: bool, } diff --git a/process/drivers/opts/post_build.rs b/process/drivers/opts/post_build.rs new file mode 100644 index 00000000..c3a23f97 --- /dev/null +++ b/process/drivers/opts/post_build.rs @@ -0,0 +1,41 @@ +use blue_build_utils::{container::ImageRef, platform::Platform}; +use bon::Builder; +use oci_client::Reference; + +use crate::drivers::PostBuild; + +/// Options passed to the post-build hook for each image to be processed +#[derive(Debug, Clone, Copy, Builder)] +#[builder(derive(Debug, Clone))] +pub struct PostBuildOpts<'scope> { + /// The image reference to be postprocessed. + pub input_image: &'scope ImageRef<'scope>, + + /// The image reference where the postprocessed image should be placed. + pub output_image: &'scope ImageRef<'scope>, + + /// The image reference of a previous build that may be taken into account. + pub previous_image: Option<&'scope ImageRef<'scope>>, + + /// The platform of the image. + pub platform: Platform, + + /// Runs post-processing with elevated privileges. + #[builder(default)] + pub privileged: bool, +} + +/// Options for the post-build driver +#[derive(Debug, Clone, Copy, Builder)] +#[builder(derive(Debug, Clone))] +pub struct PostBuildDriverOpts<'scope> { + /// Post-build hook (e.g. for rechunking) + pub post_build: &'scope dyn PostBuild, + + /// Base image to remove after building. + pub remove_base_image: Option<&'scope Reference>, + + /// Whether to take a previous image into account. + #[builder(default)] + pub use_previous_image: bool, +} diff --git a/process/drivers/opts/run.rs b/process/drivers/opts/run.rs index 6d3cfb6e..0d2f6fe0 100644 --- a/process/drivers/opts/run.rs +++ b/process/drivers/opts/run.rs @@ -92,12 +92,3 @@ pub struct RemoveContainerOpts<'scope> { #[builder(default)] pub privileged: bool, } - -#[derive(Debug, Clone, Copy, Builder)] -#[builder(derive(Debug, Clone))] -pub struct RemoveImageOpts<'scope> { - pub image: &'scope Reference, - - #[builder(default)] - pub privileged: bool, -} diff --git a/process/drivers/podman_driver.rs b/process/drivers/podman_driver.rs index d5ae283a..9c4afa54 100644 --- a/process/drivers/podman_driver.rs +++ b/process/drivers/podman_driver.rs @@ -1,31 +1,35 @@ use std::{ + fs::File, ops::Not, path::Path, - process::{Command, ExitStatus}, + process::{Command, ExitStatus, Stdio}, }; use blue_build_utils::{ constants::USER, - container::{ContainerId, MountId}, + container::{ContainerId, ImageRef, MountId}, credentials::Credentials, get_env_var, + platform::Platform, secret::SecretArgs, semver::Version, - sudo_cmd, tempdir, + string_vec, sudo_cmd, tempdir, }; use colored::Colorize; use comlexr::{cmd, pipe}; use log::{debug, error, info, trace, warn}; use miette::{Context, IntoDiagnostic, Result, bail}; use oci_client::Reference; +use rayon::prelude::*; use serde::Deserialize; use super::{ BuildChunkedOciDriver, BuildDriver, ContainerMountDriver, DriverVersion, ImageStorageDriver, - RechunkDriver, RunDriver, + PostBuildDriver, RechunkDriver, RunDriver, opts::{ - BuildOpts, ContainerOpts, CreateContainerOpts, ManifestCreateOpts, ManifestPushOpts, - PruneOpts, PullOpts, PushOpts, RemoveContainerOpts, RemoveImageOpts, RunOpts, RunOptsEnv, + BuildOpts, BuildTagPushOpts, ContainerOpts, CreateContainerOpts, InspectImageOpts, + ManifestCreateOpts, ManifestPushOpts, PostBuildDriverOpts, PostBuildOpts, PruneOpts, + PullOpts, PushOpts, RemoveContainerOpts, RemoveImageOpts, RunOpts, RunOptsEnv, RunOptsVolume, TagOpts, UntagOpts, VolumeOpts, }, rpm_ostree_runner::RpmOstreeRunner, @@ -86,6 +90,76 @@ impl PodmanDriver { Ok(()) } + + pub(crate) fn get_podman_info(fmt: &str) -> Result { + let output = cmd!("podman", "info", format!("--format={fmt}")) + .output() + .into_diagnostic()?; + if !output.status.success() { + bail!("Failed to find podman info {fmt}"); + } + let mut stdout = output.stdout; + while stdout.pop_if(|byte| byte.is_ascii_whitespace()).is_some() {} + String::from_utf8(stdout).into_diagnostic() + } + + /// Push a manifest list to a remote registry, applying workarounds for the following bug: + /// https://github.com/containers/podman/issues/27796 + /// This ensures that layer annotations are preserved by pushing. + /// + /// Currently the following workaround steps are applied: + /// + /// * To ensure the pushing is done by a newer podman version, a container allowing + /// podman-in-podman operations to be run can be provided. + /// * The manifest list is pushed twice. + /// + /// This is a hack, and at the time of writing, it's not clear why this works (if we knew, we + /// could probably fix the bug in podman). + /// + /// # Errors + /// Returns an error if any of the container operations fails. + pub(crate) fn manifest_push_with_layer_annotation_bug_workaround( + opts: ManifestPushOpts, + podman_in_podman_container: Option<&DetachedContainer>, + podman_storage_dir: &str, + ) -> Result<()> { + let Some(container) = podman_in_podman_container else { + return Self::manifest_push(opts).and_then(|()| Self::manifest_push(opts)); + }; + + for i in 0..2 { + let status = { + let c = cmd!( + "podman", + "exec", + container.id(), + "podman", + format!("--root={podman_storage_dir}"), + "manifest", + "push", + "--authfile=/run/containers/auth.json", + if let Some(compression_fmt) = opts.compression_type => format!( + "--compression-format={compression_fmt}" + ), + if i != 0 => "--quiet", + opts.final_image.to_string(), + format!("docker://{}", opts.final_image), + ); + trace!("{c:?}"); + c + } + .build_status( + opts.final_image.to_string(), + format!("Pushing manifest {}...", opts.final_image), + ) + .into_diagnostic()?; + if !status.success() { + bail!("Failed to push manifest for {}", opts.final_image); + } + } + + Ok(()) + } } impl DriverVersion for PodmanDriver { @@ -267,7 +341,6 @@ impl BuildDriver for PodmanDriver { "podman", "pull", "--quiet", - if let Some(retries) = opts.retry_count => format!("--retry={retries}"), if let Some(platform) = opts.platform => format!("--platform={platform}"), &image_str, ); @@ -275,11 +348,18 @@ impl BuildDriver for PodmanDriver { info!("Pulling image {image_str}..."); trace!("{command:?}"); - let output = command.output().into_diagnostic()?; - - if !output.status.success() { - bail!("Failed to pull image {}", image_str.bold().red()); - } + let output = blue_build_utils::retry(opts.retry_count.unwrap_or(1), 5, || { + let output = command.output().into_diagnostic()?; + if !output.status.success() { + let err_out = String::from_utf8_lossy(&output.stderr); + bail!( + "Failed to pull image {}:\n{}", + image_str.bold().red(), + err_out + ); + } + Ok(output) + })?; info!("Successfully pulled image {}", image_str.bold().green()); let container_id = { let mut stdout = output.stdout; @@ -414,6 +494,168 @@ impl BuildDriver for PodmanDriver { } } +impl PostBuildDriver for PodmanDriver { + #[expect(clippy::too_many_lines)] + fn build_tag_push_with_post_build( + opts: BuildTagPushOpts, + pb_opts: PostBuildDriverOpts, + ) -> Result> { + trace!("PostBuildDriver::build_tag_push_with_post_build({opts:#?}, {pb_opts:#?})"); + + assert!( + opts.platform.is_empty().not(), + "Must have at least 1 platform" + ); + + pb_opts.post_build.check_driver_requirements()?; + + let build_opts_base = BuildOpts::builder() + .containerfile(opts.containerfile.as_ref()) + .squash(true) + .secrets(opts.secrets) + .privileged(opts.privileged); + + let images_to_process: Vec<(ImageRef, ImageRef, Platform)> = opts + .platform + .par_iter() + .map(|&platform| { + let final_image = opts.image.with_platform(platform); + let raw_image = + final_image.append_tag(&"raw".parse().expect("Should be a valid tag")); + info!("Building image {final_image}"); + Self::build( + build_opts_base + .clone() + .image(&raw_image) + .platform(platform) + .build(), + )?; + Ok((raw_image, final_image, platform)) + }) + .collect::>>()?; + + if let Some(base_image) = pb_opts.remove_base_image { + Self::remove_image( + RemoveImageOpts::builder() + .image(&base_image.to_string()) + .privileged(opts.privileged) + .build(), + )?; + Self::prune(PruneOpts::builder().volumes(true).build())?; + } + + let post_build_runner = pb_opts.post_build.init()?; + let previous_image = pb_opts.use_previous_image.then_some(opts.image); + for (input_image, output_image, platform) in images_to_process { + post_build_runner.post_build( + PostBuildOpts::builder() + .input_image(&input_image) + .output_image(&output_image) + .maybe_previous_image(previous_image) + .platform(platform) + .build(), + )?; + if let ImageRef::Remote(input_image) = input_image { + Self::remove_image( + RemoveImageOpts::builder() + .image(&input_image.to_string()) + .build(), + )?; + } + } + + let podman_storage_dir = Self::get_podman_info("{{.Store.GraphRoot}}")?; + let runtime_container_dir = Self::get_podman_info("{{.Store.RunRoot}}")?; + // This is to ensure a recent podman version is used when applying workarounds for the + // following podman bug that prevents layer annotations from being pushed: + // https://github.com/containers/podman/issues/27796 + let podman_in_podman_container = if *Self::version()? < semver::Version::new(5, 0, 0) { + let podman_storage_mount = RunOptsVolume::builder() + .path_or_vol_name(&podman_storage_dir) + .container_path(&podman_storage_dir) + .build(); + let runtime_container_mount = RunOptsVolume::builder() + .path_or_vol_name(&runtime_container_dir) + .container_path("/run/containers") + .build(); + + let container = Self::run_detached( + RunOpts::builder() + .image("quay.io/podman/stable:latest") + .pull(true) + .privileged(true) + .rootless(true) + .remove(true) + .volumes(&[podman_storage_mount, runtime_container_mount]) + .args(&[ + "/bin/sh".to_owned(), + "-c".to_owned(), + "while true; do sleep 86400; done".to_owned(), + ]) + .build(), + )?; + Some(container) + } else { + None + }; + + let image_list: Vec = match &opts.image { + ImageRef::Remote(image) if !opts.tags.is_empty() => { + debug!("Tagging all images"); + + let mut image_list = Vec::with_capacity(opts.tags.len()); + let platform_images = opts + .platform + .iter() + .map(|&platform| platform.tagged_image(image)) + .collect::>(); + + for tag in opts.tags { + debug!("Tagging {} with {tag}", &image); + let tagged_image = Reference::with_tag( + image.registry().into(), + image.repository().into(), + tag.to_string(), + ); + + Self::manifest_create( + ManifestCreateOpts::builder() + .final_image(&tagged_image) + .image_list(&platform_images) + .build(), + )?; + image_list.push(tagged_image.to_string()); + + if opts.push { + let retry_count = if opts.retry_push { opts.retry_count } else { 0 }; + + // Push images with retries (1s delay between retries) + blue_build_utils::retry(retry_count, 5, || { + debug!("Pushing image {tagged_image}"); + + Self::manifest_push_with_layer_annotation_bug_workaround( + ManifestPushOpts::builder() + .final_image(&tagged_image) + .compression_type(opts.compression) + .build(), + podman_in_podman_container.as_ref(), + &podman_storage_dir, + ) + })?; + } + } + + image_list + } + _ => { + string_vec![opts.image] + } + }; + + Ok(image_list) + } +} + impl BuildChunkedOciDriver for PodmanDriver { fn manifest_create_with_runner( runner: &RpmOstreeRunner, @@ -726,6 +968,25 @@ impl RunDriver for PodmanDriver { } impl ImageStorageDriver for PodmanDriver { + fn inspect_image(opts: InspectImageOpts) -> Result>> { + let stdout = if let Some(output_path) = opts.output_path { + Stdio::from(File::create(output_path).into_diagnostic()?) + } else { + Stdio::piped() + }; + let output = cmd!("podman", "image", "inspect", "--", opts.image) + .stdout(stdout) + .output() + .into_diagnostic()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Failed to inspect image {}:\n{}", opts.image, stderr); + } + + Ok(opts.output_path.map(|_| output.stdout)) + } + fn remove_image(opts: RemoveImageOpts) -> Result<()> { trace!("PodmanDriver::remove_image({opts:?})"); @@ -735,7 +996,8 @@ impl ImageStorageDriver for PodmanDriver { sudo_check = opts.privileged, "podman", "rmi", - opts.image.to_string() + "--", + opts.image, ); trace!("{c:?}"); c diff --git a/process/drivers/post_build.rs b/process/drivers/post_build.rs new file mode 100644 index 00000000..195d3d51 --- /dev/null +++ b/process/drivers/post_build.rs @@ -0,0 +1,3 @@ +mod chunkah; + +pub use chunkah::Chunkah; diff --git a/process/drivers/post_build/chunkah.rs b/process/drivers/post_build/chunkah.rs new file mode 100644 index 00000000..6fba7b87 --- /dev/null +++ b/process/drivers/post_build/chunkah.rs @@ -0,0 +1,172 @@ +use std::num::NonZeroU32; + +use blue_build_utils::{ + constants::DEFAULT_MAX_LAYERS, + container::{ContainerId, ImageRef, OciRef}, + tempdir, +}; +use bon::Builder; +use comlexr::cmd; +use log::trace; +use miette::{IntoDiagnostic, Result, bail}; +use oci_client::Reference; + +use crate::{ + drivers::{ + BuildDriver, BuildDriverType, Driver, ImageStorageDriver, OciCopy, PodmanDriver, PostBuild, + PostBuildRunner, + opts::{CopyOciOpts, InspectImageOpts, PostBuildOpts, PullOpts}, + }, + logging::CommandLogging, + signal_handler::{ContainerRuntime, ContainerSignalId, add_cid, remove_cid}, +}; + +#[derive(Debug, Copy, Clone, Builder)] +#[builder(derive(Debug, Clone))] +pub struct Chunkah<'a> { + /// Maximum number of layers to use. + #[builder(default = DEFAULT_MAX_LAYERS)] + pub max_layers: NonZeroU32, + + /// Chunkah tag to use. + #[builder(default = "latest")] + pub tag: &'a str, + + /// Digest of Chunkah image to use. + pub digest: Option<&'a str>, +} + +impl Chunkah<'_> { + pub const REGISTRY: &'static str = "quay.io"; + pub const REPOSITORY: &'static str = "coreos/chunkah"; +} + +impl PostBuild for Chunkah<'_> { + fn check_driver_requirements(&self) -> Result<()> { + trace!("Chunkah::check_driver_requirements({self:#?})"); + if !matches!(Driver::get_build_driver(), BuildDriverType::Podman) { + bail!("Chunkah requires podman to be used as the build driver"); + } + Ok(()) + } + + fn init(&self) -> Result> { + trace!("Chunkah::init({self:#?})"); + let registry = Self::REGISTRY.to_owned(); + let repository = Self::REPOSITORY.to_owned(); + let chunkah_image_ref = if let Some(digest) = self.digest { + let digest = digest.to_owned(); + Reference::with_digest(registry, repository, digest) + } else { + let tag = self.tag.to_owned(); + Reference::with_tag(registry, repository, tag) + }; + let chunkah_image_id = PodmanDriver::pull( + PullOpts::builder() + .image(&chunkah_image_ref) + .retry_count(5) + .build(), + )?; + Ok(Box::new(ChunkahRunner { + max_layers: self.max_layers, + chunkah_image_id, + })) + } +} + +#[derive(Debug, Clone)] +pub struct ChunkahRunner { + /// Maximum number of layers to use. + pub max_layers: NonZeroU32, + + /// Image ID of Chunkah image + pub chunkah_image_id: ContainerId, +} + +impl PostBuildRunner for ChunkahRunner { + fn post_build(&self, opts: PostBuildOpts) -> Result<()> { + trace!("ChunkahRunner::post_build({self:#?}, {opts:#?})"); + + let chunkah_temp_dir = tempdir()?; + let config_path = chunkah_temp_dir.path().join("chunkah_config.json"); + let oci_dir = chunkah_temp_dir.path().join("oci-out"); + + PodmanDriver::inspect_image( + InspectImageOpts::builder() + .image(&opts.input_image.to_string()) + .output_path(&config_path) + .build(), + )?; + + let output_image_str = opts.output_image.to_string(); + + let cid_dir = tempdir()?; + let cid_path = cid_dir.path().join("cid"); + let cid = ContainerSignalId::new(&cid_path, ContainerRuntime::Podman, false); + add_cid(&cid); + + let chunkah_status = { + let c = cmd!( + "podman", + "run", + "--cidfile", + cid_path, + "--pull=never", + "--rm", + format!( + "--mount=type=image,src={},destination=/chunkah", + opts.input_image + ), + "--volume", + format!("{path}:{path}:Z", path = chunkah_temp_dir.path().display()), + "--", + &self.chunkah_image_id, + "build", + "--compressed", + "--config", + config_path, + "--prune", + "/sysroot/", + "--max-layers", + self.max_layers.to_string(), + "--label", + "ostree.commit-", + "--label", + "ostree.final-diffid-", + "--tag", + &output_image_str, + "--output", + format!("oci:{}", oci_dir.display()), + ); + trace!("{c:?}"); + c + } + .build_status( + &output_image_str, + format!("Running Chunkah on image {output_image_str}"), + ) + .into_diagnostic()?; + + if !chunkah_status.success() { + bail!("Chunkah child process failed with exit code {chunkah_status}"); + } + + remove_cid(&cid); + + let dest_ref = match opts.output_image.clone() { + ImageRef::Remote(image_ref) => OciRef::LocalStorage(image_ref.into_owned()), + ImageRef::LocalTar(path) => OciRef::OciArchive(path.into_owned()), + ImageRef::Other(other) => bail!("Unknown image ref type: {other}"), + }; + + Driver.copy_oci( + CopyOciOpts::builder() + .src_ref(&OciRef::OciDir(oci_dir)) + .dest_ref(&dest_ref) + .podman_unshare(true) + .build(), + )?; + + Ok(()) + } +} diff --git a/process/drivers/rpm_ostree_runner.rs b/process/drivers/rpm_ostree_runner.rs index 47c449b8..4a62eab3 100644 --- a/process/drivers/rpm_ostree_runner.rs +++ b/process/drivers/rpm_ostree_runner.rs @@ -82,12 +82,12 @@ impl RpmOstreeContainer { const IMAGE_REF: &str = "ghcr.io/blue-build/rpm-ostree-container:latest"; fn start() -> Result { - let podman_storage_dir = get_podman_info("{{.Store.GraphRoot}}")?; + let podman_storage_dir = PodmanDriver::get_podman_info("{{.Store.GraphRoot}}")?; let podman_storage_mount = RunOptsVolume::builder() .path_or_vol_name(&podman_storage_dir) .container_path(&podman_storage_dir) .build(); - let runtime_container_dir = get_podman_info("{{.Store.RunRoot}}")?; + let runtime_container_dir = PodmanDriver::get_podman_info("{{.Store.RunRoot}}")?; let runtime_container_mount = RunOptsVolume::builder() .path_or_vol_name(&runtime_container_dir) .container_path("/run/containers") @@ -176,15 +176,3 @@ impl OciCopy for RpmOstreeContainer { Ok(()) } } - -fn get_podman_info(fmt: &str) -> Result { - let output = cmd!("podman", "info", format!("--format={fmt}")) - .output() - .into_diagnostic()?; - if !output.status.success() { - bail!("Failed to find podman info {fmt}"); - } - let mut stdout = output.stdout; - while stdout.pop_if(|byte| byte.is_ascii_whitespace()).is_some() {} - String::from_utf8(stdout).into_diagnostic() -} diff --git a/process/drivers/skopeo_driver.rs b/process/drivers/skopeo_driver.rs index 7a9f2f41..da59628b 100644 --- a/process/drivers/skopeo_driver.rs +++ b/process/drivers/skopeo_driver.rs @@ -1,4 +1,5 @@ -use comlexr::cmd; +use std::{ffi::OsString, process::Command}; + use log::trace; use miette::{IntoDiagnostic, Result, bail}; @@ -12,37 +13,46 @@ impl super::OciCopy for SkopeoDriver { fn copy_oci(&self, opts: CopyOciOpts) -> Result<()> { trace!("SkopeoDriver::copy_oci({opts:?})"); let use_sudo = opts.privileged && !blue_build_utils::running_as_root(); - let status = { - let c = cmd!( - if use_sudo { - "sudo" - } else { - "skopeo" - }, - if use_sudo && blue_build_utils::has_env_var(blue_build_utils::constants::SUDO_ASKPASS) => [ - "-A", - "-p", + let mut initial_args = Vec::::new(); + if use_sudo { + initial_args.push("sudo".into()); + if blue_build_utils::has_env_var(blue_build_utils::constants::SUDO_ASKPASS) { + initial_args.push("-A".into()); + initial_args.push("-p".into()); + initial_args.push( format!( "Password is required to copy {source} to {dest}", source = opts.src_ref, dest = opts.dest_ref, ) - ], - if use_sudo => "skopeo", - "copy", - "--all", - if opts.retry_count != 0 => format!("--retry-times={}", opts.retry_count), - opts.src_ref.to_os_string(), - opts.dest_ref.to_os_string(), - ); - trace!("{c:?}"); - c + .into(), + ); + } + } + if opts.podman_unshare { + initial_args.push("podman".into()); + initial_args.push("unshare".into()); } - .build_status( - opts.dest_ref.to_string(), - format!("Copying {} to", opts.src_ref), - ) - .into_diagnostic()?; + initial_args.push("skopeo".into()); + let mut initial_args = initial_args.into_iter(); + + let mut skopeo_cmd = Command::new(initial_args.next().unwrap()); + skopeo_cmd.args(initial_args); + skopeo_cmd.arg("copy"); + skopeo_cmd.arg("--all"); + if opts.retry_count != 0 { + skopeo_cmd.arg(format!("--retry-times={}", opts.retry_count)); + } + skopeo_cmd.arg(opts.src_ref.to_os_string()); + skopeo_cmd.arg(opts.dest_ref.to_os_string()); + trace!("{skopeo_cmd:?}"); + + let status = skopeo_cmd + .build_status( + opts.dest_ref.to_string(), + format!("Copying {} to", opts.src_ref), + ) + .into_diagnostic()?; if !status.success() { bail!("Failed to copy {} to {}", opts.src_ref, opts.dest_ref); diff --git a/process/drivers/traits.rs b/process/drivers/traits.rs index 80c2cd2e..ca85fc1f 100644 --- a/process/drivers/traits.rs +++ b/process/drivers/traits.rs @@ -1,5 +1,6 @@ use std::{ borrow::Borrow, + fmt::Debug, ops::Not, path::PathBuf, process::{ExitStatus, Output}, @@ -25,9 +26,10 @@ use super::{ opts::{ BuildChunkedOciOpts, BuildOpts, BuildRechunkTagPushOpts, BuildTagPushOpts, CheckKeyPairOpts, ContainerOpts, CopyOciOpts, CreateContainerOpts, GenerateImageNameOpts, - GenerateKeyPairOpts, GenerateTagsOpts, GetMetadataOpts, PruneOpts, PullOpts, PushOpts, - RechunkOpts, RemoveContainerOpts, RemoveImageOpts, RunOpts, SignOpts, SignVerifyOpts, - SwitchOpts, TagOpts, UntagOpts, VerifyOpts, VerifyType, VolumeOpts, + GenerateKeyPairOpts, GenerateTagsOpts, GetMetadataOpts, InspectImageOpts, + PostBuildDriverOpts, PostBuildOpts, PruneOpts, PullOpts, PushOpts, RechunkOpts, + RemoveContainerOpts, RemoveImageOpts, RunOpts, SignOpts, SignVerifyOpts, SwitchOpts, + TagOpts, UntagOpts, VerifyOpts, VerifyType, VolumeOpts, }, opts::{ManifestCreateOpts, ManifestPushOpts}, rpm_ostree_runner::RpmOstreeRunner, @@ -108,8 +110,7 @@ pub trait DriverVersion: PrivateDriver { } /// Allows agnostic building, tagging, pushing, and login. -#[expect(private_bounds)] -pub trait BuildDriver: PrivateDriver { +pub trait BuildDriver: ImageStorageDriver { /// Runs the build logic for the driver. /// /// # Errors @@ -175,30 +176,26 @@ pub trait BuildDriver: PrivateDriver { opts.platform.is_empty().not(), "Must have at least 1 platform" ); - let platform_images: Vec<(ImageRef<'_>, Platform)> = opts - .platform - .iter() - .map(|&platform| (opts.image.with_platform(platform), platform)) - .collect(); - let build_opts = BuildOpts::builder() + let build_opts_base = BuildOpts::builder() .containerfile(opts.containerfile.as_ref()) .squash(opts.squash) .maybe_cache_from(opts.cache_from) .maybe_cache_to(opts.cache_to) - .secrets(opts.secrets); - let build_opts = platform_images - .iter() - .map(|(image, platform)| build_opts.clone().image(image).platform(*platform).build()) - .collect::>(); - - build_opts - .par_iter() - .try_for_each(|&build_opts| -> Result<()> { - info!("Building image {}", build_opts.image); - - Self::build(build_opts) - })?; + .secrets(opts.secrets) + .privileged(opts.privileged); + + opts.platform.par_iter().try_for_each(|&platform| { + let image = opts.image.with_platform(platform); + info!("Building image {image}"); + Self::build( + build_opts_base + .clone() + .image(&image) + .platform(platform) + .build(), + ) + })?; let image_list: Vec = match &opts.image { ImageRef::Remote(image) if !opts.tags.is_empty() => { @@ -255,6 +252,46 @@ pub trait BuildDriver: PrivateDriver { } } +/// Trait for a postprocessing step (such as rechunking) that can be applied to +/// images after they're built. +pub trait PostBuild: Debug { + /// Check driver requirements for post-build steps. + /// + /// # Errors + /// Will error if driver requirements are not met. + fn check_driver_requirements(&self) -> Result<()>; + + /// Run any initialization for the post-build steps. + /// + /// # Errors + /// Will error if an initialization step fails. + fn init(&self) -> Result>; +} + +/// Auxiliary trait that actually runs the postprocessing step. +pub trait PostBuildRunner { + /// Run postprocessing on image. Implementors should write the output image + /// to the image reference given by `opts.output_image`. + /// + /// # Errors + /// Will error if postprocessing fails. + fn post_build(&self, opts: PostBuildOpts) -> Result<()>; +} + +/// A build driver that can run post-build steps. +pub trait PostBuildDriver: BuildDriver { + /// Runs the logic for building, tagging, and pushing an image, with post- + /// build processing applied after building but before tagging and pushing. + /// + /// # Errors + /// Will error if building, post-build processing, tagging, or pushing + /// fails. + fn build_tag_push_with_post_build( + opts: BuildTagPushOpts, + pb_opts: PostBuildDriverOpts, + ) -> Result>; +} + /// Allows agnostic inspection of images. #[expect(private_bounds)] pub trait InspectDriver: PrivateDriver { @@ -303,6 +340,12 @@ pub trait RunDriver: ImageStorageDriver { /// Allows agnostic management of container image storage. #[expect(private_bounds)] pub trait ImageStorageDriver: PrivateDriver { + /// Gets low-level image metadata and writes it to a file or returns it. + /// + /// # Errors + /// Will error if the image inspection command fails. + fn inspect_image(opts: InspectImageOpts) -> Result>>; + /// Removes an image /// /// # Errors @@ -465,7 +508,7 @@ pub trait BuildChunkedOciDriver: BuildDriver + ImageStorageDriver { if let Some(base_image) = remove_base_image { Self::remove_image( RemoveImageOpts::builder() - .image(base_image) + .image(&base_image.to_string()) .privileged(btp_opts.privileged) .build(), )?; @@ -491,7 +534,11 @@ pub trait BuildChunkedOciDriver: BuildDriver + ImageStorageDriver { ); // Clean up the unchunked image whether or not rechunking succeeded. if let ImageRef::Remote(unchunked_image) = unchunked_image { - Self::remove_image(RemoveImageOpts::builder().image(&unchunked_image).build())?; + Self::remove_image( + RemoveImageOpts::builder() + .image(&unchunked_image.to_string()) + .build(), + )?; } result?; @@ -778,7 +825,7 @@ pub trait RechunkDriver: RunDriver + BuildDriver + ContainerMountDriver { )?; Self::remove_image( RemoveImageOpts::builder() - .image(image) + .image(&image.to_string()) .privileged(true) .build(), )?; @@ -831,7 +878,7 @@ pub trait RechunkDriver: RunDriver + BuildDriver + ContainerMountDriver { )?; Self::remove_image( RemoveImageOpts::builder() - .image(image) + .image(&image.to_string()) .privileged(true) .build(), )?; diff --git a/process/logging.rs b/process/logging.rs index e46364e6..7b770de5 100644 --- a/process/logging.rs +++ b/process/logging.rs @@ -1,9 +1,8 @@ use std::{ - borrow::Cow, fs::OpenOptions, io::{BufRead, BufReader, Result, Write as IoWrite}, path::{Path, PathBuf}, - process::{Command, ExitStatus, Stdio}, + process::{Child, Command, ExitStatus, Stdio}, sync::Mutex, thread, time::Duration, @@ -208,7 +207,7 @@ pub trait CommandLogging: Private { fn message_status(self, header: S, message: D) -> Result where S: AsRef, - D: Into>; + D: Into; } impl CommandLogging for Command { @@ -221,64 +220,26 @@ impl CommandLogging for Command { let ansi_color = gen_random_ansi_color(); let name = color_str(image_ref, ansi_color); let short_name = color_str(shorten_name(image_ref), ansi_color); - let (reader, writer) = os_pipe::pipe()?; + let (reader, writer) = std::io::pipe()?; + let reader = Box::new(BufReader::new(reader)); command .stdout(writer.try_clone()?) .stderr(writer) .stdin(Stdio::piped()); - let progress = Logger::multi_progress() - .add(ProgressBar::new_spinner().with_message(format!("{message} {name}"))); - progress.enable_steady_tick(Duration::from_millis(100)); - - let mut child = command.spawn()?; - - let child_pid = child.id(); - add_pid(child_pid); - + let child = command.spawn()?; // We drop the `Command` to prevent blocking on writer - // https://docs.rs/os_pipe/latest/os_pipe/#examples drop(command); - let reader = BufReader::new(reader); - let log_file_path = { - let lock = LOG_DIR.lock().expect("Should lock LOG_DIR"); - lock.join(format!("{}.log", image_ref.replace(['/', ':', '.'], "_"))) - }; - let log_file = OpenOptions::new() - .create(true) - .append(true) - .open(log_file_path.as_path())?; - - thread::spawn(move || { - let mp = Logger::multi_progress(); - reader.lines().for_each(|line| { - if let Ok(l) = line { - let text = - format!("{log_prefix} {l}", log_prefix = log_header(&short_name)); - if mp.is_hidden() { - eprintln!("{text}"); - } else { - mp.println(text).unwrap(); - } - if let Err(e) = writeln!(&log_file, "{l}") { - warn!( - "Failed to write to log for build {}: {e:?}", - log_file_path.display() - ); - } - } - }); - }); - - let status = child.wait()?; - remove_pid(child_pid); - - progress.finish(); - Logger::multi_progress().remove(&progress); - - Ok(status) + let log_filename = format!("{}.log", image_ref.replace(['/', ':', '.'], "_")); + log_child_output_from_reader( + reader, + child, + short_name, + format!("{message} {name}"), + Some(&log_filename), + ) } inner(self, image_ref.as_ref(), message.as_ref()) } @@ -286,58 +247,24 @@ impl CommandLogging for Command { fn message_status(self, header: S, message: D) -> Result where S: AsRef, - D: Into>, + D: Into, { - fn inner( - mut command: Command, - header: &str, - message: Cow<'static, str>, - ) -> Result { + fn inner(mut command: Command, header: &str, message: String) -> Result { let ansi_color = gen_random_ansi_color(); let header = color_str(header, ansi_color); - let (reader, writer) = os_pipe::pipe()?; + let (reader, writer) = std::io::pipe()?; + let reader = Box::new(BufReader::new(reader)); command .stdout(writer.try_clone()?) .stderr(writer) .stdin(Stdio::piped()); - let progress = - Logger::multi_progress().add(ProgressBar::new_spinner().with_message(message)); - progress.enable_steady_tick(Duration::from_millis(100)); - - let mut child = command.spawn()?; - - let child_pid = child.id(); - add_pid(child_pid); - + let child = command.spawn()?; // We drop the `Command` to prevent blocking on writer - // https://docs.rs/os_pipe/latest/os_pipe/#examples drop(command); - let reader = BufReader::new(reader); - - thread::spawn(move || { - let mp = Logger::multi_progress(); - reader.lines().for_each(|line| { - if let Ok(l) = line { - let text = format!("{log_prefix} {l}", log_prefix = log_header(&header)); - if mp.is_hidden() { - eprintln!("{text}"); - } else { - mp.println(text).unwrap(); - } - } - }); - }); - - let status = child.wait()?; - remove_pid(child_pid); - - progress.finish(); - Logger::multi_progress().remove(&progress); - - Ok(status) + log_child_output_from_reader(reader, child, header, message, None) } inner(self, header.as_ref(), message.into()) } @@ -400,6 +327,62 @@ impl Encode for CustomPatternEncoder { } } +pub(crate) fn log_child_output_from_reader( + reader: Box, + mut child: Child, + header: String, + message: String, + log_filename: Option<&str>, +) -> Result { + let progress = Logger::multi_progress().add(ProgressBar::new_spinner().with_message(message)); + progress.enable_steady_tick(Duration::from_millis(100)); + + let child_pid = child.id(); + add_pid(child_pid); + + let log_file_path = log_filename.map(|filename| { + let lock = LOG_DIR.lock().expect("Should lock LOG_DIR"); + lock.join(filename) + }); + let mut log_file = log_file_path + .as_ref() + .map(|log_file_path| { + OpenOptions::new() + .create(true) + .append(true) + .open(log_file_path) + }) + .transpose()?; + + thread::spawn(move || { + let mp = Logger::multi_progress(); + for line in reader.lines().map_while(Result::ok) { + let text = format!("{log_prefix} {line}", log_prefix = log_header(&header)); + if mp.is_hidden() { + eprintln!("{text}"); + } else { + mp.println(text).unwrap(); + } + if let Some(log_file) = log_file.as_mut() + && let Err(e) = writeln!(log_file, "{line}") + { + warn!( + "Failed to write to log for build {}: {e:?}", + log_file_path.as_ref().unwrap().display() + ); + } + } + }); + + let status = child.wait()?; + remove_pid(child_pid); + + progress.finish(); + Logger::multi_progress().remove(&progress); + + Ok(status) +} + /// Used to keep the style of logs consistent between /// normal log use and command output. fn log_header(text: T) -> String @@ -431,7 +414,7 @@ where /// `ghcr.io/blue-build/cli:latest` -> `g.i/b/cli:latest` /// `registry.gitlab.com/some/namespace/image:latest` -> `r.g.c/s/n/image:latest` #[must_use] -fn shorten_name(text: T) -> String +pub(crate) fn shorten_name(text: T) -> String where T: AsRef, { diff --git a/recipe/Cargo.toml b/recipe/Cargo.toml index 927ef845..ec099d53 100644 --- a/recipe/Cargo.toml +++ b/recipe/Cargo.toml @@ -17,10 +17,8 @@ log.workspace = true miette.workspace = true oci-client.workspace = true indexmap.workspace = true -reqwest.workspace = true serde.workspace = true serde_yaml.workspace = true -serde_json.workspace = true bon.workspace = true [lints] diff --git a/src/commands/build.rs b/src/commands/build.rs index f97c9748..b4c59fd9 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -7,12 +7,13 @@ use std::{ use blue_build_process_management::{ drivers::{ BuildChunkedOciDriver, BuildDriver, CiDriver, Driver, DriverArgs, InspectDriver, - RechunkDriver, SigningDriver, + PostBuildDriver, RechunkDriver, SigningDriver, opts::{ BuildChunkedOciOpts, BuildRechunkTagPushOpts, BuildTagPushOpts, CheckKeyPairOpts, - CompressionType, GenerateImageNameOpts, GenerateTagsOpts, GetMetadataOpts, RechunkOpts, - SignVerifyOpts, + CompressionType, GenerateImageNameOpts, GenerateTagsOpts, GetMetadataOpts, + PostBuildDriverOpts, RechunkOpts, SignVerifyOpts, }, + post_build::Chunkah, types::{BuildDriverType, RunDriverType}, }, logging::color_str, @@ -21,11 +22,12 @@ use blue_build_recipe::Recipe; use blue_build_utils::{ colors::gen_random_ansi_color, constants::{ - ARCHIVE_SUFFIX, BB_BUILD_ARCHIVE, BB_BUILD_CHUNKED_OCI, BB_BUILD_CHUNKED_OCI_MAX_LAYERS, + ARCHIVE_SUFFIX, BB_BUILD_ARCHIVE, BB_BUILD_CHUNKAH, BB_BUILD_CHUNKAH_VERSION, + BB_BUILD_CHUNKED_OCI, BB_BUILD_CHUNKED_OCI_MAX_LAYERS, BB_BUILD_MAX_LAYERS, BB_BUILD_NO_SIGN, BB_BUILD_PLATFORM, BB_BUILD_PUSH, BB_BUILD_RECHUNK, BB_BUILD_RECHUNK_CLEAR_PLAN, BB_BUILD_REMOVE_BASE_IMAGE, BB_BUILD_RETRY_COUNT, BB_BUILD_RETRY_PUSH, BB_BUILD_SQUASH, BB_CACHE_LAYERS, BB_REGISTRY_NAMESPACE, - BB_SKIP_VALIDATION, BB_TEMPDIR, CONFIG_PATH, DEFAULT_MAX_LAYERS, RECIPE_FILE, RECIPE_PATH, + BB_SKIP_VALIDATION, BB_TEMPDIR, CONFIG_PATH, RECIPE_FILE, RECIPE_PATH, }, container::{ImageRef, Tag}, credentials::{Credentials, CredentialsArgs}, @@ -35,7 +37,7 @@ use blue_build_utils::{ use bon::Builder; use clap::Args; use log::{debug, info, trace, warn}; -use miette::{Result, bail}; +use miette::{IntoDiagnostic, Result}; use oci_client::Reference; use rayon::prelude::*; @@ -117,29 +119,39 @@ pub struct BuildCommand { #[builder(default)] squash: bool, + /// Uses Chunkah (https://github.com/coreos/chunkah) to rechunk the image, + /// allowing for smaller images and smaller updates. + #[arg(long, group = "any_rechunker", env = BB_BUILD_CHUNKAH)] + #[builder(default)] + chunkah: bool, + + /// Chunkah tag or digest that should be used to rechunk the image. + /// Defaults to "latest" tag. + #[arg(long, requires = "chunkah", env = BB_BUILD_CHUNKAH_VERSION)] + chunkah_version: Option, + /// Uses `rpm-ostree compose build-chunked-oci` to rechunk the image, /// allowing for smaller images and smaller updates. /// /// WARN: This will increase the build-time /// and take up more space during build-time. - #[arg(long, env = BB_BUILD_CHUNKED_OCI)] + #[arg(long, group = "any_rechunker", env = BB_BUILD_CHUNKED_OCI)] #[builder(default)] build_chunked_oci: bool, - /// Maximum number of layers to use when rechunking. Requires `--build-chunked-oci`. + /// Maximum number of layers to use when rechunking with Chunkah or build-chunked-oci. #[arg( long, - default_value_t = DEFAULT_MAX_LAYERS, - env = BB_BUILD_CHUNKED_OCI_MAX_LAYERS, - requires = "build_chunked_oci" + env = BB_BUILD_MAX_LAYERS, + requires = "any_rechunker", + conflicts_with = "rechunk" )] - #[builder(default = DEFAULT_MAX_LAYERS)] - max_layers: NonZeroU32, + max_layers: Option, /// Removes the base image from local storage and prunes unused podman containers - /// and volumes after the image is built, but before running build-chunked-oci. + /// and volumes after the image is built, but before running a rechunker. /// This frees up additional disk space. - #[arg(long, env = BB_BUILD_REMOVE_BASE_IMAGE, requires = "build_chunked_oci")] + #[arg(long, env = BB_BUILD_REMOVE_BASE_IMAGE, requires = "any_rechunker")] #[builder(default)] remove_base_image: bool, @@ -152,7 +164,7 @@ pub struct BuildCommand { /// and take up more space during build-time. /// /// NOTE: This must be run as root! - #[arg(long, group = "archive_rechunk", env = BB_BUILD_RECHUNK)] + #[arg(long, group = "any_rechunker", group = "archive_rechunk", env = BB_BUILD_RECHUNK)] #[builder(default)] rechunk: bool, @@ -194,7 +206,7 @@ impl BlueBuildCommand for BuildCommand { fn try_run(&mut self) -> Result<()> { trace!("BuildCommand::try_run()"); - Driver::init(if self.build_chunked_oci || self.rechunk { + Driver::init(if self.chunkah || self.build_chunked_oci || self.rechunk { DriverArgs::builder() .build_driver(BuildDriverType::Podman) .run_driver(RunDriverType::Podman) @@ -207,12 +219,12 @@ impl BlueBuildCommand for BuildCommand { Credentials::init(self.credentials.clone()); - if self.push && self.archive.is_some() { - bail!("You cannot use '--archive' and '--push' at the same time"); - } - - if self.rechunk && self.build_chunked_oci { - bail!("You cannot use '--rechunk' and '--build-chunked-oci' at the same time"); + // Read old environment variable name for backwards compatibility + if self.build_chunked_oci + && self.max_layers.is_none() + && let Ok(max_layers) = std::env::var(BB_BUILD_CHUNKED_OCI_MAX_LAYERS) + { + self.max_layers = Some(max_layers.parse().into_diagnostic()?); } if self.push && !self.no_sign { @@ -387,7 +399,46 @@ impl BuildCommand { build_tag_opts.build() }; - let images = if self.build_chunked_oci { + let images = if self.chunkah { + let base_image = recipe.base_image_ref()?; + let base_digest = + Driver::get_metadata(GetMetadataOpts::builder().image(&base_image).build())? + .digest() + .to_owned(); + let remove_base_image = self + .remove_base_image + .then_some(base_image.clone_with_digest(base_digest)); + + let (chunkah_tag, chunkah_digest) = + self.chunkah_version + .as_deref() + .map_or((None, None), |chunkah_version| { + const SHA256_LEN: usize = 32; + // Assume the version string is a digest if it looks like a SHA-256 hash, + // otherwise assume it's a tag. + if chunkah_version.len() == SHA256_LEN + && chunkah_version.chars().all(|c| c.is_ascii_hexdigit()) + { + (None, Some(chunkah_version)) + } else { + (Some(chunkah_version), None) + } + }); + + let post_build = Chunkah::builder() + .maybe_max_layers(self.max_layers) + .maybe_tag(chunkah_tag) + .maybe_digest(chunkah_digest) + .build(); + Driver::build_tag_push_with_post_build( + opts, + PostBuildDriverOpts::builder() + .post_build(&post_build) + .maybe_remove_base_image(remove_base_image.as_ref()) + .use_previous_image(!self.rechunk_clear_plan) + .build(), + )? + } else if self.build_chunked_oci { let base_image = recipe.base_image_ref()?; let base_digest = Driver::get_metadata(GetMetadataOpts::builder().image(&base_image).build())? @@ -398,7 +449,7 @@ impl BuildCommand { .then_some(base_image.clone_with_digest(base_digest)); let rechunk_opts = BuildChunkedOciOpts::builder() - .max_layers(self.max_layers) + .maybe_max_layers(self.max_layers) .clear_plan(self.rechunk_clear_plan) .build(); Driver::build_rechunk_tag_push( diff --git a/src/commands/switch.rs b/src/commands/switch.rs index 5000d9e7..fb108b47 100644 --- a/src/commands/switch.rs +++ b/src/commands/switch.rs @@ -90,7 +90,11 @@ impl BlueBuildCommand for SwitchCommand { .build(), )?; PodmanDriver::copy_image_to_root_store(&image_name)?; - PodmanDriver::remove_image(RemoveImageOpts::builder().image(&image_name).build())?; + PodmanDriver::remove_image( + RemoveImageOpts::builder() + .image(&image_name.to_string()) + .build(), + )?; if status .booted_image() diff --git a/template/Cargo.toml b/template/Cargo.toml index 78cd7434..4c40fa00 100644 --- a/template/Cargo.toml +++ b/template/Cargo.toml @@ -16,7 +16,6 @@ blue-build-utils = { version = "=0.9.35", path = "../utils" } bon.workspace = true colored.workspace = true log.workspace = true -oci-client.workspace = true uuid.workspace = true [lints] diff --git a/utils/Cargo.toml b/utils/Cargo.toml index 56f7e22a..2a84b944 100644 --- a/utils/Cargo.toml +++ b/utils/Cargo.toml @@ -28,10 +28,8 @@ miette.workspace = true nix = { workspace = true, features = ["user"] } oci-client.workspace = true rand.workspace = true -reqwest.workspace = true semver = { workspace = true, features = ["serde"] } serde.workspace = true -serde_json.workspace = true serde_yaml.workspace = true syntect.workspace = true tempfile.workspace = true diff --git a/utils/src/constants.rs b/utils/src/constants.rs index 76b0ea65..3ee49c42 100644 --- a/utils/src/constants.rs +++ b/utils/src/constants.rs @@ -24,9 +24,12 @@ pub const IMAGE_VERSION_LABEL: &str = "org.opencontainers.image.version"; pub const BB_CACHE_LAYERS: &str = "BB_CACHE_LAYERS"; pub const BB_BOOT_DRIVER: &str = "BB_BOOT_DRIVER"; pub const BB_BUILD_ARCHIVE: &str = "BB_BUILD_ARCHIVE"; +pub const BB_BUILD_CHUNKAH: &str = "BB_BUILD_CHUNKAH"; +pub const BB_BUILD_CHUNKAH_VERSION: &str = "BB_BUILD_CHUNKAH_VERSION"; pub const BB_BUILD_CHUNKED_OCI: &str = "BB_BUILD_CHUNKED_OCI"; pub const BB_BUILD_CHUNKED_OCI_MAX_LAYERS: &str = "BB_BUILD_CHUNKED_OCI_MAX_LAYERS"; pub const BB_BUILD_DRIVER: &str = "BB_BUILD_DRIVER"; +pub const BB_BUILD_MAX_LAYERS: &str = "BB_BUILD_MAX_LAYERS"; pub const BB_BUILD_NO_SIGN: &str = "BB_BUILD_NO_SIGN"; pub const BB_BUILD_PUSH: &str = "BB_BUILD_PUSH"; pub const BB_BUILD_PLATFORM: &str = "BB_BUILD_PLATFORM";