feat: Bugreport command (#28)

Add a bug report + completions command(not complete yet) so that new
users can easily submit bugs to us, and I wanted completions for bb
(super easy with clap)

---------

Co-authored-by: Gerald Pinder <gmpinder@gmail.com>
This commit is contained in:
Hikari 2024-01-31 08:51:13 -06:00 committed by GitHub
parent bdbbcea7cc
commit e069346e15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1595 additions and 280 deletions

5
.gitignore vendored
View file

@ -1,3 +1,6 @@
/target
.sccache/
.vscode/
.vscode/
# Local testing for bb recipe files
/config/

View file

@ -1,2 +1,6 @@
[language-server.rust-analyzer.config]
cargo.features = ["nightly"]
[language-server.rust-analyzer.config.check]
command = "clippy"
args = ["--no-deps"]

View file

@ -1,5 +1,5 @@
[hooks]
pre-commit = "cargo fmt && cargo test && cargo clippy -- -D warnings"
pre-commit = "cargo fmt --check && cargo test && cargo clippy -- -D warnings"
[logging]
verbose = true

756
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,27 +6,41 @@ description = "A CLI tool built for creating Containerfile templates based on th
repository = "https://github.com/blue-build/cli"
license = "Apache-2.0"
categories = ["command-line-utilities"]
build = "build.rs"
[dependencies]
anyhow = "1"
askama = { version = "0.12.1", features = ["serde-json"] }
cfg-if = "1.0.0"
askama = { version = "0.12", features = ["serde-json", "serde-yaml"] }
chrono = "0.4"
clap = { version = "4", features = ["derive"] }
clap-verbosity-flag = "2.1.1"
derive_builder = "0.12.0"
env_logger = "0.10.1"
futures-util = { version = "0.3.30", optional = true }
indexmap = { version = "2.1.0", features = ["serde"] }
clap = { version = "4", features = ["derive", "cargo", "unicode"] }
clap-verbosity-flag = "2"
clap_complete = "4"
clap_complete_nushell = "4"
colorized = "1"
derive_builder = "0.13"
directories = "5"
env_logger = "0.11"
futures-util = { version = "0.3", optional = true }
fuzzy-matcher = "0.3"
indexmap = { version = "2", features = ["serde"] }
log = "0.4"
open = "5"
# update os module config and tests when upgrading os_info
shadow-rs = { version = "0.26" }
os_info = "3.7"
podman-api = { version = "0.10.0", optional = true }
process_control = { version = "4.0.3", features = ["crossbeam-channel"] }
requestty = { version = "0.5", features = ["macros", "termion"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9.25"
serde_yaml = "0.9.30"
sigstore = { version = "0.8.0", optional = true }
tokio = { version = "1", features = ["full"], optional = true }
typed-builder = "0.18.0"
typed-builder = "0.18.1"
urlencoding = "2.1.3"
users = "0.11.0"
which = "6"
format_serde_error = "0.3.0"
[features]
default = []
@ -37,3 +51,13 @@ init = []
[dev-dependencies]
rusty-hook = "0.11.2"
[build-dependencies]
shadow-rs = { version = "0.26.1", default-features = false }
dunce = "1.0.4"
[profile.release]
lto = true
codegen-units = 1
strip = true
debug = false

View file

@ -65,6 +65,7 @@ common:
COPY --keep-ts Cargo.* /app
COPY --keep-ts *.md /app
COPY --keep-ts LICENSE /app
COPY --keep-ts build.rs /app
DO cargo+INIT

50
build.rs Normal file
View file

@ -0,0 +1,50 @@
use shadow_rs::SdResult;
use std::fs::File;
use std::io::Write;
use std::process::Command;
fn main() -> SdResult<()> {
shadow_rs::new_hook(hook)
}
fn hook(file: &File) -> SdResult<()> {
append_write_const(file)?;
Ok(())
}
fn append_write_const(mut file: &File) -> SdResult<()> {
let hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.map(|x| {
String::from_utf8(x.stdout)
.ok()
.map(|x| x.trim().to_string())
})
.unwrap_or(None);
let short_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.map(|x| {
String::from_utf8(x.stdout)
.ok()
.map(|x| x.trim().to_string())
})
.unwrap_or(None);
let hook_const: &str = &format!(
"{}\n{}",
&format!(
"pub const BB_COMMIT_HASH: &str = \"{}\";",
hash.unwrap_or_default()
),
&format!(
"pub const BB_COMMIT_HASH_SHORT: &str = \"{}\";",
short_hash.unwrap_or_default()
)
);
writeln!(file, "{hook_const}")?;
Ok(())
}

View file

@ -1,3 +1,8 @@
[toolchain]
channel = "stable"
targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl"]
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
]

View file

@ -1,66 +1,6 @@
#![warn(clippy::pedantic, clippy::nursery)]
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
use blue_build::commands::*;
use clap::Parser;
use env_logger::WriteStyle;
use log::trace;
use blue_build::{
self,
commands::{build, local, template, BlueBuildCommand},
};
#[cfg(feature = "init")]
use blue_build::commands::init;
#[derive(Parser, Debug)]
#[command(name = "BlueBuild", author, version, about, long_about = None)]
struct BlueBuildArgs {
#[command(subcommand)]
command: CommandArgs,
#[clap(flatten)]
verbosity: Verbosity<InfoLevel>,
}
#[derive(Debug, Subcommand)]
enum CommandArgs {
/// Build an image from a recipe
Build(build::BuildCommand),
/// Generate a Containerfile from a recipe
Template(template::TemplateCommand),
/// Upgrade your current OS with the
/// local image saved at `/etc/blue-build/`.
///
/// This requires having rebased already onto
/// a local archive already by using the `rebase`
/// subcommand.
///
/// NOTE: This can only be used if you have `rpm-ostree`
/// installed and if the `--push` and `--rebase` option isn't
/// used. This image will not be signed.
Upgrade(local::UpgradeCommand),
/// Rebase your current OS onto the image
/// being built.
///
/// This will create a tarball of your image at
/// `/etc/blue-build/` and invoke `rpm-ostree` to
/// rebase onto the image using `oci-archive`.
///
/// NOTE: This can only be used if you have `rpm-ostree`
/// installed.
Rebase(local::RebaseCommand),
/// Initialize a new Ublue Starting Point repo
#[cfg(feature = "init")]
Init(init::InitCommand),
#[cfg(feature = "init")]
New(init::NewCommand),
}
fn main() {
let args = BlueBuildArgs::parse();
@ -71,18 +11,18 @@ fn main() {
.write_style(WriteStyle::Always)
.init();
trace!("{args:#?}");
log::trace!("Parsed arguments: {args:#?}");
match args.command {
#[cfg(feature = "init")]
CommandArgs::Init(mut command) => command.run(),
#[cfg(feature = "init")]
CommandArgs::New(mut command) => command.run(),
CommandArgs::Template(mut command) => command.run(),
CommandArgs::Build(mut command) => command.run(),
CommandArgs::Rebase(mut command) => command.run(),
CommandArgs::Upgrade(mut command) => command.run(),
CommandArgs::Template(mut command) => command.run(),
CommandArgs::BugReport(mut command) => command.run(),
CommandArgs::Completions(mut command) => command.run(),
}
}

View file

@ -1,25 +0,0 @@
#[cfg(feature = "init")]
pub mod init;
pub mod build;
pub mod local;
pub mod template;
use log::error;
pub trait BlueBuildCommand {
/// Runs the command and returns a result
/// of the execution
///
/// # Errors
/// Can return an `anyhow` Error
fn try_run(&mut self) -> anyhow::Result<()>;
/// Runs the command and exits if there is an error.
fn run(&mut self) {
if let Err(e) = self.try_run() {
error!("{e}");
std::process::exit(1);
}
}
}

471
src/commands/bug_report.rs Normal file
View file

@ -0,0 +1,471 @@
use crate::module_recipe::{Module, ModuleExt, Recipe};
use crate::shadow;
use anyhow::Result;
use askama::Template;
use clap::Args;
use clap_complete::Shell;
use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};
use log::{debug, error, trace};
use requestty::question::{completions, Completions};
use std::borrow::Cow;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use typed_builder::TypedBuilder;
use super::utils::exec_cmd;
use super::BlueBuildCommand;
const UNKNOWN_SHELL: &str = "<unknown shell>";
const UNKNOWN_VERSION: &str = "<unknown version>";
const UNKNOWN_TERMINAL: &str = "<unknown terminal>";
const GITHUB_CHAR_LIMIT: usize = 8100; // Magic number accepted by Github
#[derive(Default, Debug, Clone, TypedBuilder, Args)]
pub struct BugReportRecipe {
recipe_dir: Option<String>,
recipe_path: Option<String>,
}
#[derive(Debug, Clone, Args, TypedBuilder)]
pub struct BugReportCommand {
/// Path to the recipe file
#[arg(short, long)]
#[builder(default)]
recipe_path: Option<String>,
}
impl BlueBuildCommand for BugReportCommand {
fn try_run(&mut self) -> anyhow::Result<()> {
debug!(
"Generating bug report for hash: {}\n",
shadow::BB_COMMIT_HASH
);
debug!("Shadow Versioning:\n{}", shadow::VERSION.trim());
self.create_bugreport()
}
}
impl BugReportCommand {
/// Create a pre-populated GitHub issue with information about your configuration
///
/// # Errors
///
/// This function will return an error if it fails to open the issue in your browser.
/// If this happens, you can copy the generated report and open an issue manually.
///
/// # Panics
///
/// This function will panic if it fails to get the current shell or terminal version.
pub fn create_bugreport(&self) -> anyhow::Result<()> {
use colorized::{Color, Colors};
let os_info = os_info::get();
let recipe = self.get_recipe();
let environment = Environment {
os_type: os_info.os_type(),
shell_info: get_shell_info(),
terminal_info: get_terminal_info(),
os_version: os_info.version().clone(),
};
let issue_body = match generate_github_issue(&environment, &recipe) {
Ok(body) => body,
Err(e) => {
println!(
"{}: {e}",
"Failed to generate bug report".color(Colors::BrightRedFg)
);
return Err(e);
}
};
println!(
"\n{}\n{}\n",
"Generated bug report:".color(Colors::BrightGreenFg),
issue_body
.color(Colors::BrightBlackBg)
.color(Colors::BrightWhiteFg)
);
const WARNING_MESSAGE: &str = "Please copy the above report and open an issue manually.";
let question = requestty::Question::confirm("anonymous")
.message(
"Forward the pre-filled report above to GitHub in your browser?"
.color(Colors::BrightYellowFg),
)
.default(true)
.build();
println!("{} To avoid any sensitive data from being exposed, please review the included information before proceeding.", "Warning:".color(Colors::BrightRedBg).color(Colors::BrightWhiteFg));
println!("Data forwarded to GitHub is subject to GitHub's privacy policy. For more information, see https://docs.github.com/en/github/site-policy/github-privacy-statement.\n");
match requestty::prompt_one(question) {
Ok(answer) => {
if answer.as_bool().unwrap() {
let link = make_github_issue_link(&issue_body);
if let Err(e) = open::that(&link) {
println!("Failed to open issue report in your browser: {e}");
println!("Please copy the above report and open an issue manually, or try opening the following link:\n{link}");
return Err(e.into());
}
} else {
println!("{WARNING_MESSAGE}");
}
}
Err(_) => {
println!("Will not open an issue in your browser! {WARNING_MESSAGE}");
}
}
println!(
"\n{}",
"Thanks for using the BlueBuild bug report tool!".color(Colors::BrightCyanFg)
);
Ok(())
}
fn get_recipe(&self) -> Option<Recipe> {
let recipe_path = if let Some(recipe_path) = self.recipe_path.clone() {
recipe_path
} else if let Ok(recipe) = get_config_file("recipe", "Enter path to recipe file") {
recipe
} else {
trace!("Failed to get recipe");
String::new()
};
Recipe::parse(&recipe_path).ok()
}
}
fn get_config_file(title: &str, message: &str) -> anyhow::Result<String> {
use std::path::Path;
let question = requestty::Question::input(title)
.message(message)
.auto_complete(|p, _| auto_complete(p))
.validate(|p, _| {
if (p.as_ref() as &Path).exists() {
Ok(())
} else if p.is_empty() {
Err("No file specified. Please enter a file path".to_string())
} else {
Err(format!("file `{p}` doesn't exist"))
}
})
.build();
match requestty::prompt_one(question) {
Ok(requestty::Answer::String(path)) => Ok(path),
Ok(_) => unreachable!(),
Err(e) => {
trace!("Failed to get file: {}", e);
Err(e.into())
}
}
}
fn auto_complete(p: String) -> Completions<String> {
use std::path::Path;
let current: &Path = p.as_ref();
let (mut dir, last) = if p.ends_with('/') {
(current, "")
} else {
let dir = current.parent().unwrap_or_else(|| "/".as_ref());
let last = current
.file_name()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("");
(dir, last)
};
if dir.to_str().unwrap().is_empty() {
dir = ".".as_ref();
}
let mut files: Completions<_> = match dir.read_dir() {
Ok(files) => files
.flatten()
.filter_map(|file| {
let path = file.path();
let is_dir = path.is_dir();
match path.into_os_string().into_string() {
Ok(s) if is_dir => Some(s + "/"),
Ok(s) => Some(s),
Err(_) => None,
}
})
.collect(),
Err(_) => {
return completions![p];
}
};
if files.is_empty() {
return completions![p];
}
let fuzzer = SkimMatcherV2::default();
files.sort_by_cached_key(|file| fuzzer.fuzzy_match(file, last).unwrap_or(i64::MAX));
files
}
// ============================================================================= //
struct Environment {
shell_info: ShellInfo,
os_type: os_info::Type,
terminal_info: TerminalInfo,
os_version: os_info::Version,
}
#[derive(Debug)]
struct TerminalInfo {
name: String,
version: String,
}
fn get_terminal_info() -> TerminalInfo {
let terminal = std::env::var("TERM_PROGRAM")
.or_else(|_| std::env::var("LC_TERMINAL"))
.unwrap_or_else(|_| UNKNOWN_TERMINAL.to_string());
let version = std::env::var("TERM_PROGRAM_VERSION")
.or_else(|_| std::env::var("LC_TERMINAL_VERSION"))
.unwrap_or_else(|_| UNKNOWN_VERSION.to_string());
TerminalInfo {
name: terminal,
version,
}
}
#[derive(Debug)]
struct ShellInfo {
name: String,
version: String,
}
fn get_shell_info() -> ShellInfo {
let failure_shell_info = ShellInfo {
name: UNKNOWN_SHELL.to_string(),
version: UNKNOWN_VERSION.to_string(),
};
let current_shell = match Shell::from_env() {
Some(shell) => shell.to_string(),
None => return failure_shell_info,
};
let version = get_shell_version(&current_shell);
ShellInfo {
version,
name: current_shell.to_string(),
}
}
fn get_shell_version(shell: &str) -> String {
let time_limit = Duration::from_millis(500);
match shell {
"powershecll" => {
error!("Powershell is not supported.");
None
}
_ => exec_cmd(shell, &["--version"], time_limit),
}
.map_or_else(
|| UNKNOWN_VERSION.to_string(),
|output| output.stdout.trim().to_string(),
)
}
// ============================================================================= //
// Git
// ============================================================================= //
#[derive(Debug, Clone, Template, TypedBuilder)]
#[template(path = "github_issue.j2", escape = "md")]
struct GithubIssueTemplate<'a> {
#[builder(setter(into))]
bb_version: Cow<'a, str>,
#[builder(setter(into))]
build_rust_channel: Cow<'a, str>,
#[builder(setter(into))]
build_time: Cow<'a, str>,
#[builder(setter(into))]
git_commit_hash: Cow<'a, str>,
#[builder(setter(into))]
os_name: Cow<'a, str>,
#[builder(setter(into))]
os_version: Cow<'a, str>,
#[builder(setter(into))]
pkg_branch_tag: Cow<'a, str>,
#[builder(setter(into))]
recipe: Cow<'a, str>,
#[builder(setter(into))]
rust_channel: Cow<'a, str>,
#[builder(setter(into))]
rust_version: Cow<'a, str>,
#[builder(setter(into))]
shell_name: Cow<'a, str>,
#[builder(setter(into))]
shell_version: Cow<'a, str>,
#[builder(setter(into))]
terminal_name: Cow<'a, str>,
#[builder(setter(into))]
terminal_version: Cow<'a, str>,
}
fn get_pkg_branch_tag() -> String {
format!("{} ({})", shadow::BRANCH, shadow::LAST_TAG)
}
fn generate_github_issue(
environment: &Environment,
recipe: &Option<Recipe>,
) -> anyhow::Result<String> {
let recipe = recipe
.as_ref()
.map_or_else(Default::default, |r| print_full_recipe(r));
let github_template = GithubIssueTemplate::builder()
.bb_version(shadow::PKG_VERSION)
.build_rust_channel(shadow::BUILD_RUST_CHANNEL)
.build_time(shadow::BUILD_TIME)
.git_commit_hash(shadow::BB_COMMIT_HASH)
.os_name(format!("{}", environment.os_type))
.os_version(format!("{}", environment.os_version))
.pkg_branch_tag(get_pkg_branch_tag())
.recipe(recipe)
.rust_channel(shadow::RUST_CHANNEL)
.rust_version(shadow::RUST_VERSION)
.shell_name(environment.shell_info.name.clone())
.shell_version(environment.shell_info.version.clone())
.terminal_name(environment.terminal_info.name.clone())
.terminal_version(environment.terminal_info.version.clone())
.build();
Ok(github_template.render()?)
}
fn make_github_issue_link(body: &str) -> String {
let escaped = urlencoding::encode(body).replace("%20", "+");
format!(
"https://github.com/blue-build/cli/issues/new?template={}&body={}",
urlencoding::encode("Bug_report.md"),
escaped
)
.chars()
.take(GITHUB_CHAR_LIMIT)
.collect()
}
fn get_module_from_file(file_name: &str) -> Result<ModuleExt> {
let file_path = PathBuf::from("config").join(file_name);
let file_path = if file_path.is_absolute() {
file_path
} else {
std::env::current_dir()?.join(file_path)
};
let file = fs::read_to_string(file_path.clone())?;
serde_yaml::from_str::<ModuleExt>(file.as_str()).map_or_else(
|_| -> Result<ModuleExt> {
let module = serde_yaml::from_str::<Module>(file.as_str())?;
Ok(ModuleExt::builder().modules(vec![module]).build())
},
Ok,
)
}
fn get_modules(modules: &[Module]) -> Vec<Module> {
modules
.iter()
.flat_map(|module| {
if let Some(file_name) = &module.from_file {
match get_module_from_file(file_name) {
Err(e) => {
error!("Failed to get module from {file_name}: {e}");
vec![]
}
Ok(module_ext) => get_modules(&module_ext.modules),
}
} else {
vec![module.clone()]
}
})
.collect()
}
fn print_full_recipe(recipe: &Recipe) -> String {
let module_list: Vec<Module> = get_modules(&recipe.modules_ext.modules);
let recipe = Recipe::builder()
.name(recipe.name.as_ref())
.description(recipe.description.as_ref())
.base_image(recipe.base_image.as_ref())
.image_version(recipe.image_version.as_ref())
.extra(recipe.extra.clone())
.modules_ext(ModuleExt::builder().modules(module_list).build())
.build();
serde_yaml::to_string(&recipe).unwrap_or_else(|e| {
error!("Failed to serialize recipe: {e}");
format!("Error rendering recipe!!\n{e}")
})
}
// ============================================================================= //
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_make_github_link() {
let environment = Environment {
os_type: os_info::Type::Linux,
os_version: os_info::Version::Semantic(1, 2, 3),
shell_info: ShellInfo {
version: "2.3.4".to_string(),
name: "test_shell".to_string(),
},
terminal_info: TerminalInfo {
name: "test_terminal".to_string(),
version: "5.6.7".to_string(),
},
};
let recipe = Recipe::default();
let body = generate_github_issue(&environment, &Some(recipe)).unwrap();
let link = make_github_issue_link(&body);
assert!(link.contains(clap::crate_version!()));
assert!(link.contains("Linux"));
assert!(link.contains("1.2.3"));
assert!(link.contains("test_shell"));
assert!(link.contains("2.3.4"));
}
}

View file

@ -1,21 +1,16 @@
#[cfg(feature = "podman-api")]
mod build_strategy;
use std::{
env, fs,
path::{Path, PathBuf},
process::{self, Command},
};
use std::{env, fs, path::PathBuf, process::Command};
use anyhow::{anyhow, bail, Result};
use clap::Args;
use log::{debug, error, info, trace, warn};
use log::{debug, info, trace, warn};
use typed_builder::TypedBuilder;
#[cfg(feature = "podman-api")]
use podman_api::{
api::Image,
opts::{ImageBuildOpts, ImageListOpts, ImagePushOpts, RegistryAuth},
opts::{ImageBuildOpts, ImagePushOpts, RegistryAuth},
Podman,
};
@ -183,8 +178,6 @@ impl BlueBuildCommand for BuildCommand {
impl BuildCommand {
#[cfg(feature = "podman-api")]
async fn build_image_podman_api(&self, client: Podman) -> Result<()> {
use podman_api::opts::ImageTagOpts;
trace!("BuildCommand::build_image({client:#?})");
let credentials = self.get_login_creds();

View file

@ -0,0 +1,27 @@
use clap::{Args, CommandFactory};
use clap_complete::{generate, Shell as CompletionShell};
use crate::commands::BlueBuildArgs;
use super::BlueBuildCommand;
#[derive(Debug, Clone, Args)]
pub struct CompletionsCommand {
#[arg(value_enum)]
shell: CompletionShell,
}
impl BlueBuildCommand for CompletionsCommand {
fn try_run(&mut self) -> anyhow::Result<()> {
log::debug!("Generating completions for {}", self.shell);
generate(
self.shell,
&mut BlueBuildArgs::command(),
"bb",
&mut std::io::stdout().lock(),
);
Ok(())
}
}

View file

@ -1,12 +1,12 @@
use std::{
fs,
path::{Path, PathBuf},
process::{self, Command},
process::Command,
};
use anyhow::{bail, Result};
use clap::{Args, Subcommand};
use log::{debug, error, info, trace};
use clap::Args;
use log::{debug, info, trace};
use typed_builder::TypedBuilder;
use users::{Users, UsersCache};

94
src/commands/mod.rs Normal file
View file

@ -0,0 +1,94 @@
use log::error;
use clap::{command, crate_authors, Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
pub mod bug_report;
pub mod build;
pub mod completions;
#[cfg(feature = "init")]
pub mod init;
pub mod local;
pub mod template;
pub mod utils;
pub trait BlueBuildCommand {
/// Runs the command and returns a result
/// of the execution
///
/// # Errors
/// Can return an `anyhow` Error
fn try_run(&mut self) -> anyhow::Result<()>;
/// Runs the command and exits if there is an error.
fn run(&mut self) {
if let Err(e) = self.try_run() {
error!("{e}");
std::process::exit(1);
}
}
}
shadow_rs::shadow!(shadow);
#[derive(Parser, Debug)]
#[clap(
name = "BlueBuild",
about,
long_about = None,
author=crate_authors!(),
version=shadow::PKG_VERSION,
long_version=shadow::CLAP_LONG_VERSION,
)]
pub struct BlueBuildArgs {
#[command(subcommand)]
pub command: CommandArgs,
#[clap(flatten)]
pub verbosity: Verbosity<InfoLevel>,
}
#[derive(Debug, Subcommand)]
pub enum CommandArgs {
/// Build an image from a recipe
Build(build::BuildCommand),
/// Generate a Containerfile from a recipe
Template(template::TemplateCommand),
/// Upgrade your current OS with the
/// local image saved at `/etc/blue-build/`.
///
/// This requires having rebased already onto
/// a local archive already by using the `rebase`
/// subcommand.
///
/// NOTE: This can only be used if you have `rpm-ostree`
/// installed and if the `--push` and `--rebase` option isn't
/// used. This image will not be signed.
Upgrade(local::UpgradeCommand),
/// Rebase your current OS onto the image
/// being built.
///
/// This will create a tarball of your image at
/// `/etc/blue-build/` and invoke `rpm-ostree` to
/// rebase onto the image using `oci-archive`.
///
/// NOTE: This can only be used if you have `rpm-ostree`
/// installed.
Rebase(local::RebaseCommand),
/// Initialize a new Ublue Starting Point repo
#[cfg(feature = "init")]
Init(init::InitCommand),
#[cfg(feature = "init")]
New(init::NewCommand),
/// Create a pre-populated GitHub issue with information about your configuration
BugReport(bug_report::BugReportCommand),
/// Generate shell completions for your shell to stdout
Completions(completions::CompletionsCommand),
}

View file

@ -1,18 +1,13 @@
use std::{
collections::HashMap,
env, fs,
path::{Path, PathBuf},
process,
};
use anyhow::{Error, Result};
use anyhow::Result;
use askama::Template;
use chrono::Local;
use clap::Args;
use indexmap::IndexMap;
use log::{debug, error, info, trace, warn};
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use log::{debug, error, info, trace};
use typed_builder::TypedBuilder;
use crate::module_recipe::{Module, ModuleExt, Recipe};
@ -22,7 +17,7 @@ use super::BlueBuildCommand;
#[derive(Debug, Clone, Template, TypedBuilder)]
#[template(path = "Containerfile")]
pub struct ContainerFileTemplate<'a> {
recipe: &'a Recipe,
recipe: &'a Recipe<'a>,
recipe_path: &'a Path,
module_template: ModuleTemplate<'a>,
@ -67,7 +62,7 @@ impl TemplateCommand {
trace!("TemplateCommand::template_file()");
debug!("Deserializing recipe");
let recipe_de = serde_yaml::from_str::<Recipe>(fs::read_to_string(&self.recipe)?.as_str())?;
let recipe_de = Recipe::parse(&self.recipe)?;
trace!("recipe_de: {recipe_de:#?}");
let template = ContainerFileTemplate::builder()
@ -119,11 +114,11 @@ fn print_script(script_contents: &ExportsTemplate) -> String {
fn running_gitlab_actions() -> bool {
trace!(" running_gitlab_actions()");
env::var("GITHUB_ACTIONS").is_ok_and(|e| e == "true")
}
fn get_containerfile_list(module: &Module) -> Option<Vec<String>> {
#[must_use]
pub fn get_containerfile_list(module: &Module) -> Option<Vec<String>> {
if module.module_type.as_ref()? == "containerfile" {
Some(
module
@ -139,8 +134,9 @@ fn get_containerfile_list(module: &Module) -> Option<Vec<String>> {
}
}
fn print_containerfile(containerfile: &str) -> String {
trace!("print_containerfile({containerfile})");
#[must_use]
pub fn print_containerfile(containerfile: &str) -> String {
debug!("print_containerfile({containerfile})");
debug!("Loading containerfile contents for {containerfile}");
let path = format!("config/containerfiles/{containerfile}/Containerfile");
@ -150,27 +146,20 @@ fn print_containerfile(containerfile: &str) -> String {
process::exit(1);
});
trace!("Containerfile contents {path}:\n{file}");
debug!("Containerfile contents {path}:\n{file}");
file
}
fn get_module_from_file(file_name: &str) -> String {
trace!("get_module_from_file({file_name})");
let io_err_fn = |e| {
error!("Failed to read module {file_name}: {e}");
process::exit(1);
};
#[must_use]
pub fn template_module_from_file(file_name: &str) -> String {
debug!("get_module_from_file({file_name})");
let file_path = PathBuf::from("config").join(file_name);
let file = fs::read_to_string(file_path).unwrap_or_else(io_err_fn);
let serde_err_fn = |e| {
error!("Failed to deserialize module {file_name}: {e}");
let file = fs::read_to_string(file_path).unwrap_or_else(|e| {
error!("Failed to read module {file_name}: {e}");
process::exit(1);
};
});
let template_err_fn = |e| {
error!("Failed to render module {file_name}: {e}");
@ -179,7 +168,10 @@ fn get_module_from_file(file_name: &str) -> String {
serde_yaml::from_str::<ModuleExt>(file.as_str()).map_or_else(
|_| {
let module = serde_yaml::from_str::<Module>(file.as_str()).unwrap_or_else(serde_err_fn);
let module = serde_yaml::from_str::<Module>(file.as_str()).unwrap_or_else(|e| {
error!("Failed to deserialize module {file_name}: {e}");
process::exit(1);
});
ModuleTemplate::builder()
.module_ext(&ModuleExt::builder().modules(vec![module]).build())

136
src/commands/utils.rs Normal file
View file

@ -0,0 +1,136 @@
use process_control::{ChildExt, Control};
use std::ffi::OsStr;
use std::fmt::Debug;
use std::io::{Error, ErrorKind, Result};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[must_use]
pub fn home_dir() -> Option<PathBuf> {
directories::BaseDirs::new().map(|base_dirs| base_dirs.home_dir().to_path_buf())
}
// ================================================================================================= //
// CommandOutput
// ================================================================================================= //
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandOutput {
pub stdout: String,
pub stderr: String,
}
/// # Attempt to resolve `binary_name` from and creates a new `Command` pointing at it
/// # This allows executing cmd files on Windows and prevents running executable from cwd on Windows
/// # This function also initializes std{err,out,in} to protect against processes changing the console mode
/// #
/// # Errors
///
pub fn create_command<T: AsRef<OsStr>>(binary_name: T) -> Result<Command> {
let binary_name = binary_name.as_ref();
log::trace!("Creating Command for binary {:?}", binary_name);
let full_path = match which::which(binary_name) {
Ok(full_path) => {
log::trace!("Using {:?} as {:?}", full_path, binary_name);
full_path
}
Err(error) => {
log::trace!("Unable to find {:?} in PATH, {:?}", binary_name, error);
return Err(Error::new(ErrorKind::NotFound, error));
}
};
#[allow(clippy::disallowed_methods)]
let mut cmd = Command::new(full_path);
cmd.stderr(Stdio::piped())
.stdout(Stdio::piped())
.stdin(Stdio::null());
Ok(cmd)
}
/// Execute a command and return the output on stdout and stderr if successful
pub fn exec_cmd<T: AsRef<OsStr> + Debug, U: AsRef<OsStr> + Debug>(
cmd: T,
args: &[U],
time_limit: Duration,
) -> Option<CommandOutput> {
log::trace!("Executing command {:?} with args {:?}", cmd, args);
internal_exec_cmd(cmd, args, time_limit)
}
fn internal_exec_cmd<T: AsRef<OsStr> + Debug, U: AsRef<OsStr> + Debug>(
cmd: T,
args: &[U],
time_limit: Duration,
) -> Option<CommandOutput> {
let mut cmd = create_command(cmd).ok()?;
cmd.args(args);
exec_timeout(&mut cmd, time_limit)
}
pub fn exec_timeout(cmd: &mut Command, time_limit: Duration) -> Option<CommandOutput> {
let start = Instant::now();
let process = match cmd.spawn() {
Ok(process) => process,
Err(error) => {
log::trace!("Unable to run {:?}, {:?}", cmd.get_program(), error);
return None;
}
};
match process
.controlled_with_output()
.time_limit(time_limit)
.terminate_for_timeout()
.wait()
{
Ok(Some(output)) => {
let stdout_string = match String::from_utf8(output.stdout) {
Ok(stdout) => stdout,
Err(error) => {
log::warn!("Unable to decode stdout: {:?}", error);
return None;
}
};
let stderr_string = match String::from_utf8(output.stderr) {
Ok(stderr) => stderr,
Err(error) => {
log::warn!("Unable to decode stderr: {:?}", error);
return None;
}
};
log::trace!(
"stdout: {:?}, stderr: {:?}, exit code: \"{:?}\", took {:?}",
stdout_string,
stderr_string,
output.status.code(),
start.elapsed()
);
if !output.status.success() {
return None;
}
Some(CommandOutput {
stdout: stdout_string,
stderr: stderr_string,
})
}
Ok(None) => {
log::warn!("Executing command {:?} timed out.", cmd.get_program());
log::warn!("You can set command_timeout in your config to a higher value to allow longer-running commands to keep executing.");
None
}
Err(error) => {
log::trace!(
"Executing command {:?} failed by: {:?}",
cmd.get_program(),
error
);
None
}
}
}

View file

@ -1,19 +1,14 @@
//! The root library for blue-build.
#![warn(
clippy::correctness,
clippy::suspicious,
clippy::perf,
clippy::style,
clippy::pedantic
)]
#![warn(clippy::correctness, clippy::suspicious, clippy::perf, clippy::style)]
#![doc(
html_logo_url = "https://gitlab.com/wunker-bunker/blue-build/-/raw/main/logos/BlueBuild-logo.png"
)]
#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
#![allow(unused_imports)]
#![allow(clippy::module_name_repetitions)]
shadow_rs::shadow!(shadow);
pub mod commands;
pub mod module_recipe;
mod ops;

View file

@ -1,32 +1,31 @@
use std::{env, fs, path::PathBuf, process};
use std::{borrow::Cow, env, fs, path::Path, process};
use askama::Template;
use chrono::Local;
use indexmap::IndexMap;
use log::{debug, error, info, trace, warn};
use log::{debug, error, trace, warn};
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use typed_builder::TypedBuilder;
#[derive(Serialize, Clone, Deserialize, Debug, TypedBuilder)]
pub struct Recipe {
#[derive(Default, Serialize, Clone, Deserialize, Debug, TypedBuilder)]
pub struct Recipe<'a> {
#[builder(setter(into))]
pub name: String,
pub name: Cow<'a, str>,
#[builder(setter(into))]
pub description: String,
pub description: Cow<'a, str>,
#[serde(alias = "base-image")]
#[builder(setter(into))]
pub base_image: String,
pub base_image: Cow<'a, str>,
#[serde(alias = "image-version")]
#[builder(setter(into))]
pub image_version: String,
pub image_version: Cow<'a, str>,
#[serde(alias = "blue-build-tag")]
#[builder(default, setter(into, strip_option))]
pub blue_build_tag: Option<String>,
pub blue_build_tag: Option<Cow<'a, str>>,
#[serde(flatten)]
pub modules_ext: ModuleExt,
@ -36,11 +35,11 @@ pub struct Recipe {
pub extra: IndexMap<String, Value>,
}
impl Recipe {
impl<'a> Recipe<'a> {
#[must_use]
pub fn generate_tags(&self) -> Vec<String> {
trace!("Recipe::generate_tags()");
debug!("Generating image tags for {}", &self.name);
trace!("Generating image tags for {}", &self.name);
let mut tags: Vec<String> = Vec::new();
let image_version = &self.image_version;
@ -95,7 +94,7 @@ impl Recipe {
debug!("Running in a PR");
tags.push(format!("pr-{github_event_number}-{image_version}"));
} else if github_ref_name == "live" {
tags.push(image_version.to_owned());
tags.push(image_version.to_string());
tags.push(format!("{image_version}-{timestamp}"));
tags.push("latest".to_string());
} else {
@ -106,13 +105,40 @@ impl Recipe {
warn!("Running locally");
tags.push(format!("{image_version}-local"));
}
info!("Finished generating tags!");
debug!("Finished generating tags!");
debug!("Tags: {tags:#?}");
tags
}
/// # Parse a recipe file
/// #
/// # Errors
pub fn parse<P: AsRef<Path>>(path: &P) -> anyhow::Result<Self> {
let file_path = if Path::new(path.as_ref()).is_absolute() {
path.as_ref().to_path_buf()
} else {
std::env::current_dir()?.join(path.as_ref())
};
let recipe_path = fs::canonicalize(file_path)?;
let recipe_path_string = recipe_path.display().to_string();
debug!("Recipe::parse_recipe({recipe_path_string})");
let file = fs::read_to_string(recipe_path).unwrap_or_else(|e| {
error!("Failed to read file {recipe_path_string}: {e}");
process::exit(1);
});
debug!("Recipe contents: {file}");
serde_yaml::from_str::<Recipe>(file.as_str()).map_err(|e| {
error!("Failed to parse recipe {recipe_path_string}: {e}");
process::exit(1);
})
}
}
#[derive(Serialize, Clone, Deserialize, Debug, TypedBuilder)]
#[derive(Default, Serialize, Clone, Deserialize, Debug, TypedBuilder)]
pub struct ModuleExt {
#[builder(default, setter(into))]
pub modules: Vec<Module>,
@ -120,19 +146,15 @@ pub struct ModuleExt {
#[derive(Serialize, Deserialize, Debug, Clone, TypedBuilder)]
pub struct Module {
#[serde(rename = "type")]
#[builder(default, setter(into, strip_option))]
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub module_type: Option<String>,
#[serde(rename = "from-file")]
#[builder(default, setter(into, strip_option))]
#[serde(rename = "from-file", skip_serializing_if = "Option::is_none")]
pub from_file: Option<String>,
#[serde(flatten)]
#[builder(default, setter(into))]
pub config: IndexMap<String, Value>,
}
// ======================================================== //
// ========================= Helpers ====================== //
// ======================================================== //

View file

@ -1,6 +1,6 @@
use anyhow::{anyhow, Result};
use log::{debug, trace};
use std::{path::Path, process::Command};
use std::process::Command;
pub const LOCAL_BUILD: &str = "/etc/blue-build";
pub const ARCHIVE_SUFFIX: &str = "tar.gz";

View file

@ -10,7 +10,7 @@
RUN chmod +x /tmp/modules/{{ type }}/{{ type }}.sh && source /tmp/exports.sh && /tmp/modules/{{ type }}/{{ type }}.sh '{{ self::print_module_context(module) }}'
{%- endif %}
{%- else if let Some(from_file) = module.from_file %}
{{ self::get_module_from_file(from_file) }}
{{ self::template_module_from_file(from_file) }}
{%- endif %}
{%- endfor %}

35
templates/github_issue.j2 Normal file
View file

@ -0,0 +1,35 @@
#### Current Behavior
<!-- A clear and concise description of the behavior. -->
#### Expected Behavior
<!-- A clear and concise description of what you expected to happen. -->
#### Additional context/Screenshots
<!-- Add any other context about the problem here. If applicable, add screenshots to help explain. -->
#### Possible Solution
<!--- Only if you have suggestions on a fix for the bug -->
#### Environment
- Blue Build Version: {{ bb_version }}
- Operating system: {{ os_name }} {{ os_version }}
- Branch/Tag: {{ pkg_branch_tag }}
- Git Commit Hash: {{ git_commit_hash }}
#### Shell
- Name: {{ shell_name }}
- Version: {{ shell_version }}
- Terminal emulator: {{ terminal_name }} {{ terminal_version }}
#### Rust
- Rust Version: {{ rust_version }}
- Rust channel: {{ rust_channel }} {{ build_rust_channel }}
- Build Time: {{ build_time }}
{%- if !recipe.is_empty() %}
#### Recipe:
```yml
{{ recipe }}
```
{%- endif %}