diff --git a/Cargo.lock b/Cargo.lock index 411d83a..762556b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,9 +74,9 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.18" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" [[package]] name = "android-tzdata" @@ -378,6 +378,7 @@ dependencies = [ "reqwest 0.12.9", "rstest", "rusty-hook", + "semver", "serde", "serde_json", "serde_yml", @@ -592,9 +593,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.36" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baee610e9452a8f6f0a1b6194ec09ff9e2d85dea54432acdae41aa0761c95d70" +checksum = "1aeb932158bd710538c73702db6945cb68a8fb08c519e6e12706b94263b36db8" dependencies = [ "shlex", ] @@ -806,9 +807,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +checksum = "0ca741a962e1b0bff6d724a1a0958b686406e853bb14061f218562e1896f95e6" dependencies = [ "libc", ] @@ -1229,9 +1230,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" [[package]] name = "ff" @@ -1984,17 +1985,17 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.17.8" +version = "0.17.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" +checksum = "cbf675b85ed934d3c67b5c5469701eec7db22689d0a2139d856e0925fa28b281" dependencies = [ "console", - "instant", "number_prefix", "portable-atomic", "rayon", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width 0.2.0", + "web-time", ] [[package]] @@ -2017,15 +2018,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "ipnet" version = "2.10.1" @@ -2308,9 +2300,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.161" +version = "0.2.162" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" [[package]] name = "libm" @@ -3498,9 +3490,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -3789,9 +3781,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.39" +version = "0.38.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375116bee2be9ed569afe2154ea6a99dfdffd257f533f187498c2a8f5feaf4ee" +checksum = "99e4ea3e1cdc4b559b8e5650f9c8e5998e3e5c1343b4eaf034565f32318d63c0" dependencies = [ "bitflags 2.6.0", "errno", @@ -3972,9 +3964,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.214" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" dependencies = [ "serde_derive", ] @@ -3991,9 +3983,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.214" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", @@ -4574,18 +4566,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.68" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.68" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7c61ec9a6f64d2793d8a45faba21efbe3ced62a886d44c36a009b2b519b4c7e" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", @@ -5231,7 +5223,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9413a6d..fad407f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,11 +22,11 @@ oci-distribution = { version = "0.11", default-features = false } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } miette = "7" rstest = "0.18" +semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = { version = "0.0.12", package = "serde_yml" } syntect = { version = "5", default-features = false, features = ["default-fancy"] } -# tempdir = "0.3" tempfile = "3" tokio = { version = "1", features = ["rt", "rt-multi-thread"] } users = "0.11" @@ -88,6 +88,7 @@ log.workspace = true miette = { workspace = true, features = ["fancy", "syntect-highlighter"] } oci-distribution.workspace = true reqwest.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true @@ -98,7 +99,9 @@ bon.workspace = true users.workspace = true [features] +# Top level features default = [] +init = [] stages = ["blue-build-recipe/stages"] copy = ["blue-build-recipe/copy"] multi-recipe = ["dep:rayon", "indicatif/rayon"] diff --git a/Dockerfile.alpine b/Dockerfile.alpine index 7e00a23..63882d6 100644 --- a/Dockerfile.alpine +++ b/Dockerfile.alpine @@ -1,7 +1,7 @@ ARG BASE_IMAGE="alpine" FROM $BASE_IMAGE -RUN apk update && apk add buildah podman skopeo fuse-overlayfs gpg tini dumb-init +RUN apk update && apk add buildah podman skopeo fuse-overlayfs gpg tini dumb-init git ENTRYPOINT ["/usr/bin/dumb-init", "--"] diff --git a/Dockerfile.fedora b/Dockerfile.fedora index 6812f22..643ecc4 100644 --- a/Dockerfile.fedora +++ b/Dockerfile.fedora @@ -13,7 +13,8 @@ RUN dnf -y install dnf-plugins-core \ podman \ skopeo \ gpg \ - dumb-init + dumb-init \ + git ENTRYPOINT ["/usr/bin/dumb-init", "--"] diff --git a/integration-tests/Earthfile b/integration-tests/Earthfile index 39ca60a..a2a2af5 100644 --- a/integration-tests/Earthfile +++ b/integration-tests/Earthfile @@ -91,6 +91,39 @@ validate: RUN --no-cache bluebuild -v validate recipes/recipe-invalid-stage.yml && exit 1 || exit 0 RUN --no-cache bluebuild -v validate recipes/recipe-invalid-from-file.yml && exit 1 || exit 0 +init: + FROM +test-base + + WORKDIR /tmp + RUN --no-cache bluebuild new test-github \ + --image-name test-github \ + --org-name test \ + --description 'This is a description' \ + --registry 'ghcr.io' \ + --ci-provider github + RUN --no-cache bluebuild new test-gitlab \ + --image-name test-gitlab \ + --org-name test \ + --description 'This is a description' \ + --registry 'registry.gitlab.com' \ + --ci-provider gitlab + RUN --no-cache bluebuild new test-none \ + --image-name test-none \ + --org-name test \ + --description 'This is a description' \ + --registry 'docker.io' \ + --ci-provider none \ + --no-git + + WORKDIR /tmp/test-init + RUN --no-cache bluebuild init \ + --image-name test-init \ + --org-name test \ + --description 'This is a description' \ + --registry 'docker.io' \ + --ci-provider none \ + --no-git + legacy-base: FROM ../+blue-build-cli-alpine --RELEASE=false RUN apk update --no-cache && apk add bash grep jq sudo coreutils @@ -108,7 +141,10 @@ legacy-base: test-base: FROM ../+blue-build-cli-alpine --RELEASE=false - RUN apk update --no-cache && apk add bash grep jq sudo coreutils + RUN apk update --no-cache && apk add bash grep jq sudo coreutils git && \ + git config --global user.email "you@example.com" && \ + git config --global user.name "Your Name" + ENV BB_TEST_LOCAL_IMAGE=/etc/bluebuild/cli_test.tar.gz ENV CLICOLOR_FORCE=1 diff --git a/process/Cargo.toml b/process/Cargo.toml index 81df7fb..0a8580a 100644 --- a/process/Cargo.toml +++ b/process/Cargo.toml @@ -21,7 +21,6 @@ nix = { version = "0.29", features = ["signal"] } once_cell = "1" os_pipe = { version = "1", features = ["io_safety"] } rand = "0.8" -semver = { version = "1", features = ["serde"] } signal-hook = { version = "0.3", features = ["extended-siginfo"] } sigstore = { version = "0.10", features = ["full-rustls-tls", "cached-client", "sigstore-trust-root", "sign"], default-features = false, optional = true } zeroize = { version = "1", features = ["aarch64", "derive", "serde"] } @@ -36,6 +35,7 @@ log.workspace = true miette.workspace = true oci-distribution.workspace = true reqwest.workspace = true +semver = { workspace = true, features = ["serde"] } serde.workspace = true serde_json.workspace = true tempfile.workspace = true diff --git a/process/drivers.rs b/process/drivers.rs index 25253e3..8d12cae 100644 --- a/process/drivers.rs +++ b/process/drivers.rs @@ -23,34 +23,25 @@ use log::{info, trace, warn}; use miette::{miette, IntoDiagnostic, Result}; use oci_distribution::Reference; use once_cell::sync::Lazy; -use opts::{GenerateImageNameOpts, GenerateTagsOpts}; -#[cfg(feature = "sigstore")] -use sigstore_driver::SigstoreDriver; -use types::Platform; +use opts::{ + BuildOpts, BuildTagPushOpts, CheckKeyPairOpts, GenerateImageNameOpts, GenerateKeyPairOpts, + GenerateTagsOpts, GetMetadataOpts, PushOpts, RunOpts, SignOpts, TagOpts, VerifyOpts, +}; +use types::{ + BuildDriverType, CiDriverType, DetermineDriver, ImageMetadata, InspectDriverType, Platform, + RunDriverType, SigningDriverType, +}; use uuid::Uuid; use crate::logging::Logger; -use self::{ - buildah_driver::BuildahDriver, - cosign_driver::CosignDriver, - docker_driver::DockerDriver, - github_driver::GithubDriver, - gitlab_driver::GitlabDriver, - local_driver::LocalDriver, - opts::{ - BuildOpts, BuildTagPushOpts, CheckKeyPairOpts, GenerateKeyPairOpts, GetMetadataOpts, - PushOpts, RunOpts, SignOpts, TagOpts, VerifyOpts, - }, - podman_driver::PodmanDriver, - skopeo_driver::SkopeoDriver, - types::{ - BuildDriverType, CiDriverType, DetermineDriver, ImageMetadata, InspectDriverType, - RunDriverType, SigningDriverType, - }, +pub use self::{ + buildah_driver::BuildahDriver, cosign_driver::CosignDriver, docker_driver::DockerDriver, + github_driver::GithubDriver, gitlab_driver::GitlabDriver, local_driver::LocalDriver, + podman_driver::PodmanDriver, skopeo_driver::SkopeoDriver, traits::*, }; - -pub use traits::*; +#[cfg(feature = "sigstore")] +pub use sigstore_driver::SigstoreDriver; mod buildah_driver; mod cosign_driver; @@ -449,4 +440,8 @@ impl CiDriver for Driver { { impl_ci_driver!(generate_image_name(opts)) } + + fn default_ci_file_path() -> std::path::PathBuf { + impl_ci_driver!(default_ci_file_path()) + } } diff --git a/process/drivers/docker_driver.rs b/process/drivers/docker_driver.rs index 0bbba13..4a15c5a 100644 --- a/process/drivers/docker_driver.rs +++ b/process/drivers/docker_driver.rs @@ -15,7 +15,7 @@ use blue_build_utils::{ }; use cached::proc_macro::cached; use log::{debug, info, trace, warn}; -use miette::{bail, IntoDiagnostic, Result}; +use miette::{bail, miette, IntoDiagnostic, Result}; use once_cell::sync::Lazy; use semver::Version; use serde::Deserialize; @@ -23,19 +23,18 @@ use tempfile::TempDir; use crate::{ drivers::{ - opts::{RunOptsEnv, RunOptsVolume}, + opts::{ + BuildOpts, BuildTagPushOpts, GetMetadataOpts, PushOpts, RunOpts, RunOptsEnv, + RunOptsVolume, TagOpts, + }, + traits::{BuildDriver, DriverVersion, InspectDriver, RunDriver}, + types::ImageMetadata, types::Platform, }, logging::CommandLogging, signal_handler::{add_cid, remove_cid, ContainerId, ContainerRuntime}, }; -use super::{ - opts::{BuildOpts, BuildTagPushOpts, GetMetadataOpts, PushOpts, RunOpts, TagOpts}, - types::ImageMetadata, - BuildDriver, DriverVersion, InspectDriver, RunDriver, -}; - #[derive(Deserialize, Debug, Clone)] struct DockerImageMetadata { manifest: DockerImageMetadataManifest, @@ -238,7 +237,14 @@ impl BuildDriver for DockerDriver { trace!("{command:?}"); let mut child = command.spawn().into_diagnostic()?; - write!(child.stdin.as_mut().unwrap(), "{password}").into_diagnostic()?; + write!( + child + .stdin + .as_mut() + .ok_or_else(|| miette!("Unable to open pipe to stdin"))?, + "{password}" + ) + .into_diagnostic()?; let output = child.wait_with_output().into_diagnostic()?; @@ -399,6 +405,8 @@ fn get_metadata_cache(opts: &GetMetadataOpts) -> Result { impl RunDriver for DockerDriver { fn run(opts: &RunOpts) -> std::io::Result { + trace!("DockerDriver::run({opts:#?})"); + let cid_path = TempDir::new()?; let cid_file = cid_path.path().join("cid"); let cid = ContainerId::new(&cid_file, ContainerRuntime::Docker, false); @@ -414,6 +422,8 @@ impl RunDriver for DockerDriver { } fn run_output(opts: &RunOpts) -> std::io::Result { + trace!("DockerDriver::run({opts:#?})"); + let cid_path = TempDir::new()?; let cid_file = cid_path.path().join("cid"); let cid = ContainerId::new(&cid_file, ContainerRuntime::Docker, false); @@ -432,7 +442,8 @@ fn docker_run(opts: &RunOpts, cid_file: &Path) -> Command { let command = cmd!( "docker", "run", - format!("--cidfile={}", cid_file.display()), + "--cidfile", + cid_file, if opts.privileged => "--privileged", if opts.remove => "--rm", if opts.pull => "--pull=always", diff --git a/process/drivers/github_driver.rs b/process/drivers/github_driver.rs index 563317d..284f7e1 100644 --- a/process/drivers/github_driver.rs +++ b/process/drivers/github_driver.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use blue_build_utils::{ constants::{ GITHUB_EVENT_NAME, GITHUB_EVENT_PATH, GITHUB_REF_NAME, GITHUB_SHA, GITHUB_TOKEN_ISSUER_URL, @@ -130,6 +132,10 @@ impl CiDriver for GithubDriver { .trim() .to_lowercase()) } + + fn default_ci_file_path() -> PathBuf { + PathBuf::from(".github/workflows/build.yml") + } } #[cfg(test)] diff --git a/process/drivers/gitlab_driver.rs b/process/drivers/gitlab_driver.rs index bbac949..26a8160 100644 --- a/process/drivers/gitlab_driver.rs +++ b/process/drivers/gitlab_driver.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use blue_build_utils::{ constants::{ CI_COMMIT_REF_NAME, CI_COMMIT_SHORT_SHA, CI_DEFAULT_BRANCH, CI_MERGE_REQUEST_IID, @@ -140,6 +142,10 @@ impl CiDriver for GitlabDriver { ) .to_lowercase()) } + + fn default_ci_file_path() -> PathBuf { + PathBuf::from(".gitlab-ci.yml") + } } #[cfg(test)] diff --git a/process/drivers/local_driver.rs b/process/drivers/local_driver.rs index 955a6e1..1dbf593 100644 --- a/process/drivers/local_driver.rs +++ b/process/drivers/local_driver.rs @@ -1,6 +1,7 @@ +use std::path::PathBuf; + use blue_build_utils::{cmd, string_vec}; use log::trace; -use miette::bail; use super::{opts::GenerateTagsOpts, CiDriver, Driver}; @@ -13,13 +14,11 @@ impl CiDriver for LocalDriver { } fn keyless_cert_identity() -> miette::Result { - trace!("LocalDriver::keyless_cert_identity()"); - bail!("Keyless not supported"); + unimplemented!() } fn oidc_provider() -> miette::Result { - trace!("LocalDriver::oidc_provider()"); - bail!("Keyless not supported"); + unimplemented!() } fn generate_tags(opts: &GenerateTagsOpts) -> miette::Result> { @@ -75,6 +74,10 @@ impl CiDriver for LocalDriver { trace!("LocalDriver::get_registry()"); Ok(String::from("localhost")) } + + fn default_ci_file_path() -> PathBuf { + unimplemented!() + } } fn commit_sha() -> Option { diff --git a/process/drivers/podman_driver.rs b/process/drivers/podman_driver.rs index c8d2259..39e7c72 100644 --- a/process/drivers/podman_driver.rs +++ b/process/drivers/podman_driver.rs @@ -19,19 +19,14 @@ use tempfile::TempDir; use crate::{ drivers::{ - opts::{RunOptsEnv, RunOptsVolume}, - types::ImageMetadata, - types::Platform, + opts::{BuildOpts, GetMetadataOpts, PushOpts, RunOpts, RunOptsEnv, RunOptsVolume, TagOpts}, + types::{ImageMetadata, Platform}, + BuildDriver, DriverVersion, InspectDriver, RunDriver, }, logging::{CommandLogging, Logger}, signal_handler::{add_cid, remove_cid, ContainerId, ContainerRuntime}, }; -use super::{ - opts::{BuildOpts, GetMetadataOpts, PushOpts, RunOpts, TagOpts}, - BuildDriver, DriverVersion, InspectDriver, RunDriver, -}; - #[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "PascalCase")] struct PodmanImageMetadata { diff --git a/process/drivers/traits.rs b/process/drivers/traits.rs index 305e6e3..4680a81 100644 --- a/process/drivers/traits.rs +++ b/process/drivers/traits.rs @@ -12,17 +12,54 @@ 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, }; +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. -pub trait DriverVersion { +#[allow(private_bounds)] +pub trait DriverVersion: PrivateDriver { /// The version req string slice that follows /// the semver standard . const VERSION_REQ: &'static str; @@ -43,7 +80,8 @@ pub trait DriverVersion { /// Allows agnostic building, tagging /// pushing, and login. -pub trait BuildDriver { +#[allow(private_bounds)] +pub trait BuildDriver: PrivateDriver { /// Runs the build logic for the driver. /// /// # Errors @@ -148,7 +186,8 @@ pub trait BuildDriver { } /// Allows agnostic inspection of images. -pub trait InspectDriver { +#[allow(private_bounds)] +pub trait InspectDriver: PrivateDriver { /// Gets the metadata on an image tag. /// /// # Errors @@ -156,7 +195,9 @@ pub trait InspectDriver { fn get_metadata(opts: &GetMetadataOpts) -> Result; } -pub trait RunDriver: Sync + Send { +/// Allows agnostic running of containers. +#[allow(private_bounds)] +pub trait RunDriver: PrivateDriver { /// Run a container to perform an action. /// /// # Errors @@ -170,7 +211,9 @@ pub trait RunDriver: Sync + Send { fn run_output(opts: &RunOpts) -> std::io::Result; } -pub trait SigningDriver { +/// Allows agnostic management of signature keys. +#[allow(private_bounds)] +pub trait SigningDriver: PrivateDriver { /// Generate a new private/public key pair. /// /// # Errors @@ -275,7 +318,8 @@ pub trait SigningDriver { } /// Allows agnostic retrieval of CI-based information. -pub trait CiDriver { +#[allow(private_bounds)] +pub trait CiDriver: PrivateDriver { /// Determines if we're on the main branch of /// a repository. fn on_default_branch() -> bool; @@ -374,4 +418,6 @@ pub trait CiDriver { /// # Errors /// Will error if the environment variables aren't set. fn get_registry() -> Result; + + fn default_ci_file_path() -> PathBuf; } diff --git a/src/bin/bluebuild.rs b/src/bin/bluebuild.rs index 879af08..01f9c76 100644 --- a/src/bin/bluebuild.rs +++ b/src/bin/bluebuild.rs @@ -40,6 +40,12 @@ fn main() { #[cfg(feature = "login")] CommandArgs::Login(mut command) => command.run(), + #[cfg(feature = "init")] + CommandArgs::New(mut command) => command.run(), + + #[cfg(feature = "init")] + CommandArgs::Init(mut command) => command.run(), + #[cfg(feature = "iso")] CommandArgs::GenerateIso(mut command) => command.run(), diff --git a/src/commands.rs b/src/commands.rs index eb03854..ddb197b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -13,16 +13,16 @@ pub mod completions; pub mod generate; #[cfg(feature = "iso")] pub mod generate_iso; -#[cfg(feature = "login")] -pub mod login; -#[cfg(feature = "validate")] -pub mod validate; -// #[cfg(feature = "init")] -// pub mod init; +#[cfg(feature = "init")] +pub mod init; #[cfg(not(feature = "switch"))] pub mod local; +#[cfg(feature = "login")] +pub mod login; #[cfg(feature = "switch")] pub mod switch; +#[cfg(feature = "validate")] +pub mod validate; pub trait BlueBuildCommand { /// Runs the command and returns a result @@ -117,6 +117,14 @@ pub enum CommandArgs { #[cfg(feature = "login")] Login(login::LoginCommand), + /// Create a new bluebuild project. + #[cfg(feature = "init")] + New(init::NewCommand), + + /// Create a new bluebuild project. + #[cfg(feature = "init")] + Init(init::InitCommand), + /// Validate your recipe file and display /// errors to help fix problems. #[cfg(feature = "validate")] diff --git a/src/commands/init.rs b/src/commands/init.rs index 5952020..5f67a98 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,68 +1,142 @@ use std::{ - path::{Path, PathBuf}, - process, + env, + fmt::{Display, Write as FmtWrite}, + fs::{self, OpenOptions}, + io::{BufWriter, Write as IoWrite}, + path::PathBuf, + str::FromStr, }; -use anyhow::Result; -use clap::Args; -use log::error; -use typed_builder::TypedBuilder; +use blue_build_process_management::drivers::{ + opts::GenerateKeyPairOpts, CiDriver, Driver, DriverArgs, GitlabDriver, SigningDriver, +}; +use blue_build_template::{GitlabCiTemplate, InitReadmeTemplate, Template}; +use blue_build_utils::{ + cmd, + constants::{COSIGN_PUB_PATH, RECIPE_FILE, RECIPE_PATH, TEMPLATE_REPO_URL}, +}; +use bon::Builder; +use clap::{crate_version, Args, ValueEnum}; +use log::{debug, info, trace}; +use miette::{bail, miette, Context, IntoDiagnostic, Report, Result}; +use requestty::{questions, Answer, Answers, OnEsc}; +use semver::Version; -use super::BlueBuildCommand; +use crate::commands::BlueBuildCommand; -const GITLAB_CI_FILE: &'static str = include_str!("../../templates/init/gitlab-ci.yml.tera"); -const RECIPE_FILE: &'static str = include_str!("../../templates/init/recipe.yml.tera"); -const LICENSE_FILE: &'static str = include_str!("../../LICENSE"); - -#[derive(Debug, Clone, Default, Args, TypedBuilder)] -pub struct NewInitCommon { - #[builder(default)] - no_git: bool, +#[derive(Debug, Default, Clone, Copy, ValueEnum)] +pub enum CiProvider { + #[default] + Github, + Gitlab, + None, } -#[derive(Debug, Clone, Args, TypedBuilder)] -pub struct InitCommand { - /// The directory to extract the files into. Defaults to the current directory - #[arg()] - #[builder(setter(strip_option, into), default)] - dir: Option, +impl CiProvider { + fn default_ci_file_path(self) -> std::path::PathBuf { + match self { + Self::Gitlab => GitlabDriver::default_ci_file_path(), + Self::None | Self::Github => unimplemented!(), + } + } + + fn render_file(self) -> Result { + match self { + Self::Gitlab => GitlabCiTemplate::builder() + .version({ + let version = crate_version!(); + let version: Version = version.parse().into_diagnostic()?; + + format!("v{}.{}", version.major, version.minor) + }) + .build() + .render() + .into_diagnostic(), + Self::None | Self::Github => unimplemented!(), + } + } +} + +impl TryFrom<&str> for CiProvider { + type Error = Report; + + fn try_from(value: &str) -> std::result::Result { + Ok(match value { + "Gitlab" => Self::Gitlab, + "Github" => Self::Github, + "None" => Self::None, + _ => bail!("Unable to parse for CiProvider"), + }) + } +} + +impl TryFrom<&String> for CiProvider { + type Error = Report; + + fn try_from(value: &String) -> std::result::Result { + Self::try_from(value.as_str()) + } +} + +impl FromStr for CiProvider { + type Err = Report; + + fn from_str(s: &str) -> std::prelude::v1::Result { + Self::try_from(s) + } +} + +impl Display for CiProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match *self { + Self::Github => "Github", + Self::Gitlab => "Gitlab", + Self::None => "None", + } + ) + } +} + +#[derive(Debug, Clone, Default, Args, Builder)] +#[builder(on(String, into))] +pub struct NewInitCommon { + /// The name of the image for the recipe. + #[arg(long)] + image_name: Option, + + /// The name of the org where your repo will be located. + /// This could end up being your username. + #[arg(long)] + org_name: Option, + + /// Optional description for the GitHub repository. + #[arg(long)] + description: Option, + + /// The registry to store the image. + #[arg(long)] + registry: Option, + + /// The CI provider that will be building the image. + /// + /// GitHub Actions and Gitlab CI are currently the + /// officially supported CI providers. + #[arg(long, short)] + ci_provider: Option, + + /// Disable setting up git. + #[arg(long)] + no_git: bool, #[clap(flatten)] #[builder(default)] - common: NewInitCommon, + drivers: DriverArgs, } -impl BlueBuildCommand for InitCommand { - fn try_run(&mut self) -> Result<()> { - let base_dir = match self.dir.as_ref() { - Some(dir) => dir, - None => std::path::Path::new("./"), - }; - - self.initialize_directory(base_dir); - Ok(()) - } -} - -impl InitCommand { - fn initialize_directory(&self, base_dir: &Path) { - let recipe_path = base_dir.join("recipe.yml"); - - let gitlab_ci_path = base_dir.join(".gitlab-ci.yml"); - - let readme_path = base_dir.join("README.md"); - - let license_path = base_dir.join("LICENSE"); - - let scripts_dir = base_dir.join("scripts/"); - - let pre_scripts_dir = scripts_dir.join("pre/"); - - let post_scripts_dir = scripts_dir.join("post/"); - } -} - -#[derive(Debug, Clone, Args, TypedBuilder)] +#[derive(Debug, Clone, Args, Builder)] pub struct NewCommand { #[arg()] dir: PathBuf, @@ -80,3 +154,383 @@ impl BlueBuildCommand for NewCommand { .try_run() } } + +#[derive(Debug, Clone, Args, Builder)] +pub struct InitCommand { + #[clap(skip)] + #[builder(into)] + dir: Option, + + #[clap(flatten)] + common: NewInitCommon, +} + +impl BlueBuildCommand for InitCommand { + fn try_run(&mut self) -> Result<()> { + Driver::init(self.common.drivers); + + let base_dir = self + .dir + .get_or_insert(env::current_dir().into_diagnostic()?); + + if base_dir.exists() && fs::read_dir(base_dir).is_ok_and(|dir| dir.count() != 0) { + bail!("Must be in an empty directory!"); + } + + self.start(&self.questions()?) + } +} + +macro_rules! when { + ($check:expr) => { + |_answers: &::requestty::Answers| $check + }; +} + +impl InitCommand { + const CI_PROVIDER: &str = "ci_provider"; + const REGISTRY: &str = "registry"; + const IMAGE_NAME: &str = "image_name"; + const ORG_NAME: &str = "org_name"; + const DESCRIPTION: &str = "description"; + + fn questions(&self) -> Result { + let questions = questions![ + Input { + name: Self::IMAGE_NAME, + message: "What would you like to name your image?", + when: when!(self.common.image_name.is_none()), + on_esc: OnEsc::Terminate, + }, + Input { + name: Self::REGISTRY, + message: + "What is the registry for the image? (e.g. ghcr.io or registry.gitlab.com)", + when: when!(self.common.registry.is_none()), + on_esc: OnEsc::Terminate, + }, + Input { + name: Self::ORG_NAME, + message: "What is the name of your org/username?", + when: when!(self.common.org_name.is_none()), + on_esc: OnEsc::Terminate, + }, + Input { + name: Self::DESCRIPTION, + message: "Write a short description of your image:", + when: when!(self.common.description.is_none()), + on_esc: OnEsc::Terminate, + }, + Select { + name: Self::CI_PROVIDER, + message: "Are you building on Github or Gitlab?", + when: when!(!self.common.no_git && self.common.ci_provider.is_none()), + on_esc: OnEsc::Terminate, + choices: vec!["Github", "Gitlab", "None"], + } + ]; + + requestty::prompt(questions).into_diagnostic() + } + + fn start(&self, answers: &Answers) -> Result<()> { + self.clone_repository()?; + self.remove_git_directory()?; + self.template_readme(answers)?; + self.template_ci_file(answers)?; + self.update_recipe_file(answers)?; + self.generate_signing_files()?; + + if !self.common.no_git { + self.initialize_git()?; + self.add_files()?; + self.initial_commit()?; + } + + info!( + "Created new BlueBuild project in {}", + self.dir.as_ref().unwrap().display() + ); + + Ok(()) + } + + fn clone_repository(&self) -> Result<()> { + let dir = self.dir.as_ref().unwrap(); + trace!("clone_repository()"); + + let mut command = cmd!("git", "clone", "-q", TEMPLATE_REPO_URL, dir); + trace!("{command:?}"); + + let status = command + .status() + .into_diagnostic() + .context("Failed to execute git clone")?; + + if !status.success() { + bail!("Failed to clone template repo"); + } + + Ok(()) + } + + fn remove_git_directory(&self) -> Result<()> { + trace!("remove_git_directory()"); + + let dir = self.dir.as_ref().unwrap(); + let git_path = dir.join(".git"); + + if git_path.exists() { + fs::remove_dir_all(&git_path) + .into_diagnostic() + .context("Failed to remove .git directory")?; + debug!(".git directory removed."); + } + Ok(()) + } + + fn initialize_git(&self) -> Result<()> { + trace!("initialize_git()"); + + let dir = self.dir.as_ref().unwrap(); + + let mut command = cmd!("git", "init", "-q", "-b", "main", dir); + trace!("{command:?}"); + + let status = command + .status() + .into_diagnostic() + .context("Failed to execute git init")?; + + if !status.success() { + bail!("Error initializing git"); + } + + debug!("Initialized git in {}", dir.display()); + + Ok(()) + } + + fn initial_commit(&self) -> Result<()> { + trace!("initial_commit()"); + + let dir = self.dir.as_ref().unwrap(); + + let mut command = cmd!( + "git", + "commit", + "-a", + "-m", + "chore: Initial Commit", + current_dir = dir, + ); + trace!("{command:?}"); + + let status = command + .status() + .into_diagnostic() + .context("Failed to run git commit")?; + + if !status.success() { + bail!("Failed to commit initial changes"); + } + + debug!("Created initial commit"); + + Ok(()) + } + + fn add_files(&self) -> Result<()> { + trace!("add_files()"); + + let dir = self.dir.as_ref().unwrap(); + + let mut command = cmd!("git", "add", ".", current_dir = dir,); + trace!("{command:?}"); + + let status = command + .status() + .into_diagnostic() + .context("Failed to run git add")?; + + if !status.success() { + bail!("Failed to add files to initial commit"); + } + + debug!("Added files for initial commit"); + + Ok(()) + } + + fn template_readme(&self, answers: &Answers) -> Result<()> { + trace!("template_readme()"); + + let readme_path = self.dir.as_ref().unwrap().join("README.md"); + + let readme = InitReadmeTemplate::builder() + .repo_name( + self.common + .org_name + .as_deref() + .or_else(|| answers.get(Self::ORG_NAME).and_then(Answer::as_string)) + .ok_or_else(|| miette!("Failed to get organization name"))?, + ) + .image_name( + self.common + .image_name + .as_deref() + .or_else(|| answers.get(Self::IMAGE_NAME).and_then(Answer::as_string)) + .ok_or_else(|| miette!("Failed to get image name"))?, + ) + .registry( + self.common + .registry + .as_deref() + .or_else(|| answers.get(Self::REGISTRY).and_then(Answer::as_string)) + .ok_or_else(|| miette!("Failed to get registry"))?, + ) + .build(); + + debug!("Templating README"); + let readme = readme.render().into_diagnostic()?; + + debug!("Writing README to {}", readme_path.display()); + fs::write(readme_path, readme).into_diagnostic() + } + + fn template_ci_file(&self, answers: &Answers) -> Result<()> { + trace!("template_ci_file()"); + + let ci_provider = self + .common + .ci_provider + .ok_or("CLI Arg not set") + .or_else(|e| { + answers + .get(Self::CI_PROVIDER) + .and_then(Answer::as_list_item) + .map(|li| &li.text) + .ok_or_else(|| miette!("Failed to get CI Provider answer:\n{e}")) + .and_then(CiProvider::try_from) + })?; + + if matches!(ci_provider, CiProvider::Github) { + fs::remove_file(self.dir.as_ref().unwrap().join(".github/CODEOWNERS")) + .into_diagnostic()?; + return Ok(()); + } + + fs::remove_dir_all(self.dir.as_ref().unwrap().join(".github")).into_diagnostic()?; + + // Never run for None + if matches!(ci_provider, CiProvider::None) { + return Ok(()); + } + + let ci_file_path = self + .dir + .as_ref() + .unwrap() + .join(ci_provider.default_ci_file_path()); + let parent_path = ci_file_path + .parent() + .ok_or_else(|| miette!("Couldn't get parent directory from {ci_file_path:?}"))?; + fs::create_dir_all(parent_path) + .into_diagnostic() + .with_context(|| format!("Couldn't create directory path {parent_path:?}"))?; + + let file = &mut BufWriter::new( + OpenOptions::new() + .truncate(true) + .create(true) + .write(true) + .open(&ci_file_path) + .into_diagnostic() + .with_context(|| format!("Failed to open file at {ci_file_path:?}"))?, + ); + + let template = ci_provider.render_file()?; + + writeln!(file, "{template}") + .into_diagnostic() + .with_context(|| format!("Failed to write CI file {ci_file_path:?}")) + } + + fn update_recipe_file(&self, answers: &Answers) -> Result<()> { + trace!("update_recipe_file()"); + + let recipe_path = self + .dir + .as_ref() + .unwrap() + .join(RECIPE_PATH) + .join(RECIPE_FILE); + + debug!("Reading {recipe_path:?}"); + let file = fs::read_to_string(&recipe_path) + .into_diagnostic() + .with_context(|| format!("Failed to read {recipe_path:?}"))?; + + let description = self + .common + .description + .as_deref() + .ok_or("Description arg not set") + .or_else(|e| { + answers + .get(Self::DESCRIPTION) + .and_then(Answer::as_string) + .ok_or_else(|| miette!("Failed to get description:\n{e}")) + })?; + let name = self + .common + .image_name + .as_deref() + .ok_or("Description arg not set") + .or_else(|e| { + answers + .get(Self::IMAGE_NAME) + .and_then(Answer::as_string) + .ok_or_else(|| miette!("Failed to get description:\n{e}")) + })?; + + let mut new_file_str = String::with_capacity(file.capacity()); + + for line in file.lines() { + if line.starts_with("description:") { + writeln!(&mut new_file_str, "description: {description}").into_diagnostic()?; + } else if line.starts_with("name: ") { + writeln!(&mut new_file_str, "name: {name}").into_diagnostic()?; + } else { + writeln!(&mut new_file_str, "{line}").into_diagnostic()?; + } + } + + let file = &mut BufWriter::new( + OpenOptions::new() + .truncate(true) + .write(true) + .open(&recipe_path) + .into_diagnostic() + .with_context(|| format!("Failed to open {recipe_path:?}"))?, + ); + write!(file, "{new_file_str}") + .into_diagnostic() + .with_context(|| format!("Failed to write to file {recipe_path:?}")) + } + + fn generate_signing_files(&self) -> Result<()> { + trace!("generate_signing_files()"); + + debug!("Removing old cosign files {COSIGN_PUB_PATH}"); + fs::remove_file(self.dir.as_ref().unwrap().join(COSIGN_PUB_PATH)) + .into_diagnostic() + .with_context(|| format!("Failed to delete old public file {COSIGN_PUB_PATH}"))?; + + Driver::generate_key_pair( + &GenerateKeyPairOpts::builder() + .maybe_dir(self.dir.as_ref()) + .build(), + ) + } +} diff --git a/template/rinja.toml b/template/rinja.toml new file mode 100644 index 0000000..abb3103 --- /dev/null +++ b/template/rinja.toml @@ -0,0 +1,4 @@ +[[syntax]] +name = "github-actions" +expr_start = "{{{" +expr_end = "}}}" diff --git a/template/src/lib.rs b/template/src/lib.rs index 56dab69..e44e38f 100644 --- a/template/src/lib.rs +++ b/template/src/lib.rs @@ -60,6 +60,13 @@ pub struct InitReadmeTemplate<'a> { image_name: Cow<'a, str>, } +#[derive(Debug, Clone, Template, Builder)] +#[template(path = "init/gitlab-ci.yml.j2", escape = "none")] +#[builder(on(Cow<'_, str>, into))] +pub struct GitlabCiTemplate<'a> { + version: Cow<'a, str>, +} + fn has_cosign_file() -> bool { trace!("has_cosign_file()"); std::env::current_dir() diff --git a/template/templates/init/gitlab-ci.yml.j2 b/template/templates/init/gitlab-ci.yml.j2 new file mode 100644 index 0000000..3e97e61 --- /dev/null +++ b/template/templates/init/gitlab-ci.yml.j2 @@ -0,0 +1,40 @@ +workflow: + rules: + - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push" + when: never + - if: "$CI_COMMIT_TAG" + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: "$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS" + when: never + - if: "$CI_COMMIT_BRANCH" + +stages: + - build + +build-image: + stage: build + image: + name: ghcr.io/blue-build/cli:{{ version }} + entrypoint: [""] + services: + - docker:dind + parallel: + matrix: + - RECIPE: + # Add your recipe files here + - recipe.yml + variables: + # Setup a secure connection with docker-in-docker service + # https://docs.gitlab.com/ee/ci/docker/using_docker_build.html + DOCKER_HOST: tcp://docker:2376 + DOCKER_TLS_CERTDIR: /certs + DOCKER_TLS_VERIFY: 1 + DOCKER_CERT_PATH: $DOCKER_TLS_CERTDIR/client + before_script: + # Pulls secure files into the build + - curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash + - export COSIGN_PRIVATE_KEY=$(cat .secure_files/cosign.key) + script: + - sleep 5 # Wait a bit for the docker-in-docker service to start + - bluebuild build --push ./recipes/$RECIPE +