use std::{ borrow::Borrow, path::PathBuf, process::{ExitStatus, Output}, }; use blue_build_utils::{constants::COSIGN_PUB_PATH, retry, string_vec}; use log::{debug, info, trace}; use miette::{bail, miette, Context, IntoDiagnostic, Result}; use oci_distribution::Reference; use semver::{Version, VersionReq}; use crate::drivers::{functions::get_private_key, types::CiDriverType, Driver}; #[cfg(feature = "sigstore")] use super::sigstore_driver::SigstoreDriver; use super::{ buildah_driver::BuildahDriver, cosign_driver::CosignDriver, docker_driver::DockerDriver, github_driver::GithubDriver, gitlab_driver::GitlabDriver, local_driver::LocalDriver, opts::{ BuildOpts, BuildTagPushOpts, CheckKeyPairOpts, GenerateImageNameOpts, GenerateKeyPairOpts, GenerateTagsOpts, GetMetadataOpts, PushOpts, RunOpts, SignOpts, SignVerifyOpts, TagOpts, VerifyOpts, VerifyType, }, podman_driver::PodmanDriver, skopeo_driver::SkopeoDriver, types::ImageMetadata, }; #[cfg(feature = "rechunk")] use super::{ opts::RechunkOpts, types::{ContainerId, MountId}, }; trait PrivateDriver {} macro_rules! impl_private_driver { ($($driver:ty),* $(,)?) => { $( impl PrivateDriver for $driver {} )* }; } impl_private_driver!( Driver, DockerDriver, PodmanDriver, BuildahDriver, GithubDriver, GitlabDriver, LocalDriver, CosignDriver, SkopeoDriver, CiDriverType, ); #[cfg(feature = "sigstore")] impl_private_driver!(SigstoreDriver); /// Trait for retrieving version of a driver. #[allow(private_bounds)] pub trait DriverVersion: PrivateDriver { /// The version req string slice that follows /// the semver standard . const VERSION_REQ: &'static str; /// Returns the version of the driver. /// /// # Errors /// Will error if it can't retrieve the version. fn version() -> Result; #[must_use] fn is_supported_version() -> bool { Self::version().is_ok_and(|version| { VersionReq::parse(Self::VERSION_REQ).is_ok_and(|req| req.matches(&version)) }) } } /// Allows agnostic building, tagging /// pushing, and login. #[allow(private_bounds)] pub trait BuildDriver: PrivateDriver { /// Runs the build logic for the driver. /// /// # Errors /// Will error if the build fails. fn build(opts: &BuildOpts) -> Result<()>; /// Runs the tag logic for the driver. /// /// # Errors /// Will error if the tagging fails. fn tag(opts: &TagOpts) -> Result<()>; /// Runs the push logic for the driver /// /// # Errors /// Will error if the push fails. fn push(opts: &PushOpts) -> Result<()>; /// Runs the login logic for the driver. /// /// # Errors /// Will error if login fails. fn login() -> Result<()>; /// Runs prune commands for the driver. /// /// # Errors /// Will error if the driver fails to prune. #[cfg(feature = "prune")] fn prune(opts: &super::opts::PruneOpts) -> Result<()>; /// Runs the logic for building, tagging, and pushing an image. /// /// # Errors /// Will error if building, tagging, or pusing fails. fn build_tag_push(opts: &BuildTagPushOpts) -> Result> { trace!("BuildDriver::build_tag_push({opts:#?})"); let full_image = match (opts.archive_path.as_ref(), opts.image.as_ref()) { (Some(archive_path), None) => { format!("oci-archive:{archive_path}") } (None, Some(image)) => opts .tags .first() .map_or_else(|| image.to_string(), |tag| format!("{image}:{tag}")), (Some(_), Some(_)) => bail!("Cannot use both image and archive path"), (None, None) => bail!("Need either the image or archive path set"), }; let build_opts = BuildOpts::builder() .image(&full_image) .containerfile(opts.containerfile.as_ref()) .platform(opts.platform) .squash(opts.squash) .build(); info!("Building image {full_image}"); Self::build(&build_opts)?; let image_list: Vec = if !opts.tags.is_empty() && opts.archive_path.is_none() { let image = opts .image .as_ref() .ok_or_else(|| miette!("Image is required in order to tag"))?; debug!("Tagging all images"); let mut image_list = Vec::with_capacity(opts.tags.len()); for tag in &opts.tags { debug!("Tagging {} with {tag}", &full_image); let tagged_image = format!("{image}:{tag}"); let tag_opts = TagOpts::builder() .src_image(&full_image) .dest_image(&tagged_image) .build(); Self::tag(&tag_opts)?; image_list.push(tagged_image); if opts.push { let retry_count = if opts.retry_push { opts.retry_count } else { 0 }; debug!("Pushing all images"); // Push images with retries (1s delay between retries) blue_build_utils::retry(retry_count, 5, || { let tag_image = format!("{image}:{tag}"); debug!("Pushing image {tag_image}"); let push_opts = PushOpts::builder() .image(&tag_image) .compression_type(opts.compression) .build(); Self::push(&push_opts) })?; } } image_list } else { string_vec![&full_image] }; Ok(image_list) } } /// Allows agnostic inspection of images. #[allow(private_bounds)] pub trait InspectDriver: PrivateDriver { /// Gets the metadata on an image tag. /// /// # Errors /// Will error if it is unable to get the labels. fn get_metadata(opts: &GetMetadataOpts) -> Result; } /// Allows agnostic running of containers. #[allow(private_bounds)] pub trait RunDriver: PrivateDriver { /// Run a container to perform an action. /// /// # Errors /// Will error if there is an issue running the container. fn run(opts: &RunOpts) -> Result; /// Run a container to perform an action and capturing output. /// /// # Errors /// Will error if there is an issue running the container. fn run_output(opts: &RunOpts) -> Result; } #[allow(private_bounds)] #[cfg(feature = "rechunk")] pub(super) trait ContainerMountDriver: PrivateDriver { /// Creates container /// /// # Errors /// Will error if the container create command fails. fn create_container(image: &Reference) -> Result; /// Removes a container /// /// # Errors /// Will error if the container remove command fails. fn remove_container(container_id: &ContainerId) -> Result<()>; /// Removes an image /// /// # Errors /// Will error if the image remove command fails. fn remove_image(image: &Reference) -> Result<()>; /// Mounts the container /// /// # Errors /// Will error if the container mount command fails. fn mount_container(container_id: &ContainerId) -> Result; /// Unmount the container /// /// # Errors /// Will error if the container unmount command fails. fn unmount_container(container_id: &ContainerId) -> Result<()>; /// Remove a volume /// /// # Errors /// Will error if the volume remove command fails. fn remove_volume(volume_id: &str) -> Result<()>; } #[cfg(feature = "rechunk")] pub(super) trait OciCopy { fn copy_oci_dir( oci_dir: &super::types::OciDir, registry: &oci_distribution::Reference, ) -> Result<()>; } #[allow(private_bounds)] #[cfg(feature = "rechunk")] pub trait RechunkDriver: RunDriver + BuildDriver + ContainerMountDriver { const RECHUNK_IMAGE: &str = "ghcr.io/hhd-dev/rechunk:v1.0.1"; /// Perform a rechunk build of a recipe. /// /// # Errors /// Will error if the rechunk process fails. fn rechunk(opts: &RechunkOpts) -> Result> { let ostree_cache_id = &uuid::Uuid::new_v4().to_string(); let raw_image = &Reference::try_from(format!("localhost/{ostree_cache_id}/raw-rechunk")).unwrap(); let current_dir = &std::env::current_dir().into_diagnostic()?; let current_dir = &*current_dir.to_string_lossy(); let full_image = Reference::try_from(opts.tags.first().map_or_else( || opts.image.to_string(), |tag| format!("{}:{tag}", opts.image), )) .into_diagnostic()?; Self::build( &BuildOpts::builder() .image(raw_image.to_string()) .containerfile(&*opts.containerfile) .platform(opts.platform) .squash(true) .host_network(true) .build(), )?; let container = &Self::create_container(raw_image)?; let mount = &Self::mount_container(container)?; Self::prune_image(mount, container, raw_image, opts)?; Self::create_ostree_commit(mount, ostree_cache_id, container, raw_image, opts)?; let temp_dir = tempfile::TempDir::new().into_diagnostic()?; let temp_dir_str = &*temp_dir.path().to_string_lossy(); Self::rechunk_image(ostree_cache_id, temp_dir_str, current_dir, opts)?; let mut image_list = Vec::with_capacity(opts.tags.len()); if opts.push { let oci_dir = &super::types::OciDir::try_from(temp_dir.path().join(ostree_cache_id))?; for tag in &opts.tags { let tagged_image = Reference::with_tag( full_image.registry().to_string(), full_image.repository().to_string(), tag.to_string(), ); blue_build_utils::retry(opts.retry_count, 5, || { debug!("Pushing image {tagged_image}"); Driver::copy_oci_dir(oci_dir, &tagged_image) })?; image_list.push(tagged_image.into()); } } Ok(image_list) } /// Step 1 of the rechunk process that prunes excess files. /// /// # Errors /// Will error if the prune process fails. fn prune_image( mount: &MountId, container: &ContainerId, raw_image: &Reference, opts: &RechunkOpts<'_>, ) -> Result<(), miette::Error> { let status = Self::run( &RunOpts::builder() .image(Self::RECHUNK_IMAGE) .remove(true) .user("0:0") .privileged(true) .volumes(crate::run_volumes! { mount => "/var/tree", }) .env_vars(crate::run_envs! { "TREE" => "/var/tree", }) .args(bon::vec!["/sources/rechunk/1_prune.sh"]) .build(), )?; if !status.success() { Self::unmount_container(container)?; Self::remove_container(container)?; Self::remove_image(raw_image)?; bail!("Failed to run prune step for {}", &opts.image); } Ok(()) } /// Step 2 of the rechunk process that creates the ostree commit. /// /// # Errors /// Will error if the ostree commit process fails. fn create_ostree_commit( mount: &MountId, ostree_cache_id: &str, container: &ContainerId, raw_image: &Reference, opts: &RechunkOpts<'_>, ) -> Result<()> { let status = Self::run( &RunOpts::builder() .image(Self::RECHUNK_IMAGE) .remove(true) .user("0:0") .privileged(true) .volumes(crate::run_volumes! { mount => "/var/tree", ostree_cache_id => "/var/ostree", }) .env_vars(crate::run_envs! { "TREE" => "/var/tree", "REPO" => "/var/ostree/repo", "RESET_TIMESTAMP" => "1", }) .args(bon::vec!["/sources/rechunk/2_create.sh"]) .build(), )?; Self::unmount_container(container)?; Self::remove_container(container)?; Self::remove_image(raw_image)?; if !status.success() { bail!("Failed to run Ostree create step for {}", &opts.image); } Ok(()) } /// Step 3 of the rechunk process that generates the final chunked image. /// /// # Errors /// Will error if the chunk process fails. fn rechunk_image( ostree_cache_id: &str, temp_dir_str: &str, current_dir: &str, opts: &RechunkOpts<'_>, ) -> Result<()> { let status = Self::run( &RunOpts::builder() .image(Self::RECHUNK_IMAGE) .remove(true) .user("0:0") .privileged(true) .volumes(crate::run_volumes! { ostree_cache_id => "/var/ostree", temp_dir_str => "/workspace", current_dir => "/var/git" }) .env_vars(crate::run_envs! { "REPO" => "/var/ostree/repo", "PREV_REF" => &*opts.image, "OUT_NAME" => ostree_cache_id, // "PREV_REF_FAIL" => "true", "VERSION" => format!("{}", opts.version), "OUT_REF" => format!("oci:{ostree_cache_id}"), "GIT_DIR" => "/var/git", "LABELS" => format!( "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", format_args!("{}={}", blue_build_utils::constants::BUILD_ID_LABEL, Driver::get_build_id()), format_args!("org.opencontainers.image.title={}", &opts.name), format_args!("org.opencontainers.image.description={}", &opts.description), format_args!("org.opencontainers.image.source={}", &opts.repo), format_args!("org.opencontainers.image.base.digest={}", &opts.base_digest), format_args!("org.opencontainers.image.base.name={}", &opts.base_image), "org.opencontainers.image.created=", "io.artifacthub.package.readme-url=https://raw.githubusercontent.com/blue-build/cli/main/README.md", ) }) .args(bon::vec!["/sources/rechunk/3_chunk.sh"]) .build(), )?; Self::remove_volume(ostree_cache_id)?; if !status.success() { bail!("Failed to run rechunking for {}", &opts.image); } Ok(()) } } /// Allows agnostic management of signature keys. #[allow(private_bounds)] pub trait SigningDriver: PrivateDriver { /// Generate a new private/public key pair. /// /// # Errors /// Will error if a key-pair couldn't be generated. fn generate_key_pair(opts: &GenerateKeyPairOpts) -> Result<()>; /// Checks the signing key files to ensure /// they match. /// /// # Errors /// Will error if the files cannot be verified. fn check_signing_files(opts: &CheckKeyPairOpts) -> Result<()>; /// Signs the image digest. /// /// # Errors /// Will error if signing fails. fn sign(opts: &SignOpts) -> Result<()>; /// Verifies the image. /// /// The image can be verified either with `VerifyType::File` containing /// the public key contents, or with `VerifyType::Keyless` containing /// information about the `issuer` and `identity`. /// /// # Errors /// Will error if the image fails to be verified. fn verify(opts: &VerifyOpts) -> Result<()>; /// Sign an image given the image name and tag. /// /// # Errors /// Will error if the image fails to be signed. fn sign_and_verify(opts: &SignVerifyOpts) -> Result<()> { trace!("sign_and_verify({opts:?})"); let path = opts .dir .as_ref() .map_or_else(|| PathBuf::from("."), |d| d.to_path_buf()); let image_name: &str = opts.image.as_ref(); let inspect_opts = GetMetadataOpts::builder() .image(image_name) .platform(opts.platform); let inspect_opts = if let Some(ref tag) = opts.tag { inspect_opts.tag(&**tag).build() } else { inspect_opts.build() }; let image_digest = Driver::get_metadata(&inspect_opts)?.digest; let image_name_tag = opts .tag .as_ref() .map_or_else(|| image_name.to_owned(), |t| format!("{image_name}:{t}")); let image_digest = format!("{image_name}@{image_digest}"); let (sign_opts, verify_opts) = match (Driver::get_ci_driver(), get_private_key(&path)) { // Cosign public/private key pair (_, Ok(priv_key)) => ( SignOpts::builder() .image(&image_digest) .dir(&path) .key(priv_key.to_string()) .build(), VerifyOpts::builder() .image(&image_name_tag) .verify_type(VerifyType::File(path.join(COSIGN_PUB_PATH).into())) .build(), ), // Gitlab keyless (CiDriverType::Github | CiDriverType::Gitlab, _) => ( SignOpts::builder().dir(&path).image(&image_digest).build(), VerifyOpts::builder() .image(&image_name_tag) .verify_type(VerifyType::Keyless { issuer: Driver::oidc_provider()?.into(), identity: Driver::keyless_cert_identity()?.into(), }) .build(), ), _ => bail!("Failed to get information for signing the image"), }; let retry_count = if opts.retry_push { opts.retry_count } else { 0 }; retry(retry_count, 5, || { Self::sign(&sign_opts)?; Self::verify(&verify_opts) })?; Ok(()) } /// Runs the login logic for the signing driver. /// /// # Errors /// Will error if login fails. fn signing_login() -> Result<()>; } /// Allows agnostic retrieval of CI-based information. #[allow(private_bounds)] pub trait CiDriver: PrivateDriver { /// Determines if we're on the main branch of /// a repository. fn on_default_branch() -> bool; /// Retrieve the certificate identity for /// keyless signing. /// /// # Errors /// Will error if the environment variables aren't set. fn keyless_cert_identity() -> Result; /// Retrieve the OIDC Provider for keyless signing. /// /// # Errors /// Will error if the environment variables aren't set. fn oidc_provider() -> Result; /// Generate a list of tags based on the OS version. /// /// ## CI /// The tags are generated based on the CI system that /// is detected. The general format for the default branch is: /// - `${os_version}` /// - `${timestamp}-${os_version}` /// /// On a branch: /// - `br-${branch_name}-${os_version}` /// /// In a PR(GitHub)/MR(GitLab) /// - `pr-${pr_event_number}-${os_version}`/`mr-${mr_iid}-${os_version}` /// /// In all above cases the short git sha is also added: /// - `${commit_sha}-${os_version}` /// /// When `alt_tags` are not present, the following tags are added: /// - `latest` /// - `${timestamp}` /// /// ## Locally /// When ran locally, only a local tag is created: /// - `local-${os_version}` /// /// # Errors /// Will error if the environment variables aren't set. fn generate_tags(opts: &GenerateTagsOpts) -> Result>; /// Generates the image name based on CI. /// /// # Errors /// Will error if the environment variables aren't set. fn generate_image_name<'a, O>(opts: O) -> Result where O: Borrow>, { fn inner(opts: &GenerateImageNameOpts, driver_registry: &str) -> Result { let image = match (&opts.registry, &opts.registry_namespace) { (Some(registry), Some(registry_namespace)) => { format!( "{}/{}/{}", registry.trim().to_lowercase(), registry_namespace.trim().to_lowercase(), opts.name.trim().to_lowercase() ) } (Some(registry), None) => { format!( "{}/{}", registry.trim().to_lowercase(), opts.name.trim().to_lowercase() ) } _ => { format!( "{}/{}", driver_registry.trim().to_lowercase(), opts.name.trim().to_lowercase() ) } }; image .parse() .into_diagnostic() .with_context(|| format!("Unable to parse image {image}")) } inner(opts.borrow(), &Self::get_registry()?) } /// Get the URL for the repository. /// /// # Errors /// Will error if the environment variables aren't set. fn get_repo_url() -> Result; /// Get the registry ref for the image. /// /// # Errors /// Will error if the environment variables aren't set. fn get_registry() -> Result; fn default_ci_file_path() -> PathBuf; }