feat(init): Add the new/init subcommands (#85)

This commit is contained in:
Gerald Pinder 2024-11-14 20:15:12 -05:00 committed by GitHub
parent e3b246ef91
commit 918da22952
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 765 additions and 152 deletions

64
Cargo.lock generated
View file

@ -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]]

View file

@ -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"]

View file

@ -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", "--"]

View file

@ -13,7 +13,8 @@ RUN dnf -y install dnf-plugins-core \
podman \
skopeo \
gpg \
dumb-init
dumb-init \
git
ENTRYPOINT ["/usr/bin/dumb-init", "--"]

View file

@ -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

View file

@ -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

View file

@ -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())
}
}

View file

@ -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<ImageMetadata> {
impl RunDriver for DockerDriver {
fn run(opts: &RunOpts) -> std::io::Result<ExitStatus> {
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<std::process::Output> {
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",

View file

@ -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)]

View file

@ -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)]

View file

@ -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<String> {
trace!("LocalDriver::keyless_cert_identity()");
bail!("Keyless not supported");
unimplemented!()
}
fn oidc_provider() -> miette::Result<String> {
trace!("LocalDriver::oidc_provider()");
bail!("Keyless not supported");
unimplemented!()
}
fn generate_tags(opts: &GenerateTagsOpts) -> miette::Result<Vec<String>> {
@ -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<String> {

View file

@ -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 {

View file

@ -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 <https://semver.org/>.
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<ImageMetadata>;
}
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<Output>;
}
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<String>;
fn default_ci_file_path() -> PathBuf;
}

View file

@ -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(),

View file

@ -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")]

View file

@ -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<PathBuf>,
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<String> {
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<Self, Self::Error> {
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, Self::Error> {
Self::try_from(value.as_str())
}
}
impl FromStr for CiProvider {
type Err = Report;
fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> {
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<String>,
/// The name of the org where your repo will be located.
/// This could end up being your username.
#[arg(long)]
org_name: Option<String>,
/// Optional description for the GitHub repository.
#[arg(long)]
description: Option<String>,
/// The registry to store the image.
#[arg(long)]
registry: Option<String>,
/// 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<CiProvider>,
/// 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<PathBuf>,
#[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<Answers> {
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(),
)
}
}

4
template/rinja.toml Normal file
View file

@ -0,0 +1,4 @@
[[syntax]]
name = "github-actions"
expr_start = "{{{"
expr_end = "}}}"

View file

@ -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()

View file

@ -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