499 lines
16 KiB
Rust
499 lines
16 KiB
Rust
use std::{
|
|
borrow::Cow,
|
|
fs::OpenOptions,
|
|
io::{BufRead, BufReader, Result, Write as IoWrite},
|
|
path::{Path, PathBuf},
|
|
process::{Command, ExitStatus, Stdio},
|
|
sync::Mutex,
|
|
thread,
|
|
time::Duration,
|
|
};
|
|
|
|
use blue_build_utils::get_env_var;
|
|
use bon::Builder;
|
|
use chrono::Local;
|
|
use colored::{ColoredString, Colorize, control::ShouldColorize};
|
|
use indicatif::{MultiProgress, ProgressBar};
|
|
use indicatif_log_bridge::LogWrapper;
|
|
use log::{Level, LevelFilter, Record, warn};
|
|
use log4rs::{
|
|
Config, Logger as L4RSLogger,
|
|
append::{
|
|
console::ConsoleAppender,
|
|
rolling_file::{
|
|
RollingFileAppender,
|
|
policy::compound::{
|
|
CompoundPolicy, roll::fixed_window::FixedWindowRoller, trigger::size::SizeTrigger,
|
|
},
|
|
},
|
|
},
|
|
config::{Appender, Root},
|
|
encode::{Encode, Write, pattern::PatternEncoder},
|
|
};
|
|
use nu_ansi_term::Color;
|
|
use private::Private;
|
|
use rand::Rng;
|
|
|
|
use crate::signal_handler::{add_pid, remove_pid};
|
|
|
|
mod private {
|
|
pub trait Private {}
|
|
}
|
|
|
|
impl Private for Command {}
|
|
|
|
static MULTI_PROGRESS: std::sync::LazyLock<MultiProgress> =
|
|
std::sync::LazyLock::new(MultiProgress::new);
|
|
static LOG_DIR: std::sync::LazyLock<Mutex<PathBuf>> =
|
|
std::sync::LazyLock::new(|| Mutex::new(PathBuf::new()));
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Logger {
|
|
modules: Vec<(String, LevelFilter)>,
|
|
level: LevelFilter,
|
|
log_dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl Logger {
|
|
const TRIGGER_FILE_SIZE: u64 = 10 * 1024;
|
|
const ARCHIVE_FILENAME_PATTERN: &'static str = "bluebuild.{}.log";
|
|
const LOG_FILENAME: &'static str = "bluebuild.log";
|
|
const LOG_FILE_COUNT: u32 = 4;
|
|
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn filter_modules<I, S>(&mut self, filter_modules: I) -> &mut Self
|
|
where
|
|
I: IntoIterator<Item = (S, LevelFilter)>,
|
|
S: AsRef<str>,
|
|
{
|
|
self.modules = filter_modules
|
|
.into_iter()
|
|
.map(|(module, level)| (module.as_ref().to_string(), level))
|
|
.collect::<Vec<_>>();
|
|
self
|
|
}
|
|
|
|
pub const fn filter_level(&mut self, filter_level: LevelFilter) -> &mut Self {
|
|
self.level = filter_level;
|
|
self
|
|
}
|
|
|
|
pub fn log_out_dir<P>(&mut self, path: Option<P>) -> &mut Self
|
|
where
|
|
P: AsRef<Path>,
|
|
{
|
|
self.log_dir = path.map(|p| p.as_ref().to_path_buf());
|
|
self
|
|
}
|
|
|
|
/// Initializes logging for the application.
|
|
///
|
|
/// # Panics
|
|
/// Will panic if logging is unable to be initialized.
|
|
pub fn init(&self) {
|
|
let home = get_env_var("HOME").expect("$HOME should be defined");
|
|
let log_dir = self.log_dir.as_ref().map_or_else(
|
|
|| Path::new(home.as_str()).join(".cache/bluebuild"),
|
|
Clone::clone,
|
|
);
|
|
|
|
let mut lock = LOG_DIR.lock().expect("Should lock LOG_DIR");
|
|
lock.clone_from(&log_dir);
|
|
drop(lock);
|
|
|
|
let log_out_path = log_dir.join(Self::LOG_FILENAME);
|
|
let log_archive_pattern =
|
|
format!("{}/{}", log_dir.display(), Self::ARCHIVE_FILENAME_PATTERN);
|
|
|
|
let stderr = ConsoleAppender::builder()
|
|
.encoder(Box::new(
|
|
CustomPatternEncoder::builder()
|
|
.filter_modules(self.modules.clone())
|
|
.build(),
|
|
))
|
|
.target(log4rs::append::console::Target::Stderr)
|
|
.tty_only(true)
|
|
.build();
|
|
|
|
let file = RollingFileAppender::builder()
|
|
.encoder(Box::new(PatternEncoder::new("{d} - {l} - {m}{n}")))
|
|
.build(
|
|
log_out_path,
|
|
Box::new(CompoundPolicy::new(
|
|
Box::new(SizeTrigger::new(Self::TRIGGER_FILE_SIZE)),
|
|
Box::new(
|
|
FixedWindowRoller::builder()
|
|
.build(&log_archive_pattern, Self::LOG_FILE_COUNT)
|
|
.expect("Roller should be created"),
|
|
),
|
|
)),
|
|
)
|
|
.expect("Must be able to create log FileAppender");
|
|
|
|
let config = Config::builder()
|
|
.appender(Appender::builder().build("stderr", Box::new(stderr)))
|
|
.appender(Appender::builder().build("file", Box::new(file)))
|
|
.build(
|
|
Root::builder()
|
|
.appender("stderr")
|
|
.appender("file")
|
|
.build(self.level),
|
|
)
|
|
.expect("Logger config should build");
|
|
|
|
let logger = L4RSLogger::new(config);
|
|
|
|
LogWrapper::new(MULTI_PROGRESS.clone(), logger)
|
|
.try_init()
|
|
.expect("LogWrapper should initialize");
|
|
}
|
|
|
|
pub fn multi_progress() -> MultiProgress {
|
|
MULTI_PROGRESS.clone()
|
|
}
|
|
}
|
|
|
|
impl Default for Logger {
|
|
fn default() -> Self {
|
|
Self {
|
|
modules: vec![],
|
|
level: LevelFilter::Info,
|
|
log_dir: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
trait ColoredLevel {
|
|
fn colored(&self) -> ColoredString;
|
|
}
|
|
|
|
impl ColoredLevel for Level {
|
|
fn colored(&self) -> ColoredString {
|
|
match self {
|
|
Self::Error => Self::Error.as_str().red(),
|
|
Self::Warn => Self::Warn.as_str().yellow(),
|
|
Self::Info => Self::Info.as_str().green(),
|
|
Self::Debug => Self::Debug.as_str().blue(),
|
|
Self::Trace => Self::Trace.as_str().cyan(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait CommandLogging: Private {
|
|
/// Prints each line of stdout/stderr with an image ref string
|
|
/// and a progress spinner while also logging the build output.
|
|
/// This helps to keep track of every build running in parallel.
|
|
///
|
|
/// # Errors
|
|
/// Will error if there was an issue executing the process.
|
|
fn build_status<T, U>(self, image_ref: T, message: U) -> Result<ExitStatus>
|
|
where
|
|
T: AsRef<str>,
|
|
U: AsRef<str>;
|
|
|
|
/// Prints each line of stdout/stderr with a log header
|
|
/// and a progress spinner. This helps to keep track of every
|
|
/// command running in parallel.
|
|
///
|
|
/// # Errors
|
|
/// Will error if there was an issue executing the process.
|
|
fn message_status<S, D>(self, header: S, message: D) -> Result<ExitStatus>
|
|
where
|
|
S: AsRef<str>,
|
|
D: Into<Cow<'static, str>>;
|
|
}
|
|
|
|
impl CommandLogging for Command {
|
|
fn build_status<T, U>(self, image_ref: T, message: U) -> Result<ExitStatus>
|
|
where
|
|
T: AsRef<str>,
|
|
U: AsRef<str>,
|
|
{
|
|
fn inner(mut command: Command, image_ref: &str, message: &str) -> Result<ExitStatus> {
|
|
let ansi_color = gen_random_ansi_color();
|
|
let name = color_str(image_ref, ansi_color);
|
|
let short_name = color_str(shorten_name(image_ref), ansi_color);
|
|
let (reader, writer) = os_pipe::pipe()?;
|
|
|
|
command
|
|
.stdout(writer.try_clone()?)
|
|
.stderr(writer)
|
|
.stdin(Stdio::piped());
|
|
|
|
let progress = Logger::multi_progress()
|
|
.add(ProgressBar::new_spinner().with_message(format!("{message} {name}")));
|
|
progress.enable_steady_tick(Duration::from_millis(100));
|
|
|
|
let mut child = command.spawn()?;
|
|
|
|
let child_pid = child.id();
|
|
add_pid(child_pid);
|
|
|
|
// We drop the `Command` to prevent blocking on writer
|
|
// https://docs.rs/os_pipe/latest/os_pipe/#examples
|
|
drop(command);
|
|
|
|
let reader = BufReader::new(reader);
|
|
let log_file_path = {
|
|
let lock = LOG_DIR.lock().expect("Should lock LOG_DIR");
|
|
lock.join(format!("{}.log", image_ref.replace(['/', ':', '.'], "_")))
|
|
};
|
|
let log_file = OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(log_file_path.as_path())?;
|
|
|
|
thread::spawn(move || {
|
|
let mp = Logger::multi_progress();
|
|
reader.lines().for_each(|line| {
|
|
if let Ok(l) = line {
|
|
let text =
|
|
format!("{log_prefix} {l}", log_prefix = log_header(&short_name));
|
|
if mp.is_hidden() {
|
|
eprintln!("{text}");
|
|
} else {
|
|
mp.println(text).unwrap();
|
|
}
|
|
if let Err(e) = writeln!(&log_file, "{l}") {
|
|
warn!(
|
|
"Failed to write to log for build {}: {e:?}",
|
|
log_file_path.display()
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
let status = child.wait()?;
|
|
remove_pid(child_pid);
|
|
|
|
progress.finish();
|
|
Logger::multi_progress().remove(&progress);
|
|
|
|
Ok(status)
|
|
}
|
|
inner(self, image_ref.as_ref(), message.as_ref())
|
|
}
|
|
|
|
fn message_status<S, D>(self, header: S, message: D) -> Result<ExitStatus>
|
|
where
|
|
S: AsRef<str>,
|
|
D: Into<Cow<'static, str>>,
|
|
{
|
|
fn inner(
|
|
mut command: Command,
|
|
header: &str,
|
|
message: Cow<'static, str>,
|
|
) -> Result<ExitStatus> {
|
|
let ansi_color = gen_random_ansi_color();
|
|
let header = color_str(header, ansi_color);
|
|
let (reader, writer) = os_pipe::pipe()?;
|
|
|
|
command
|
|
.stdout(writer.try_clone()?)
|
|
.stderr(writer)
|
|
.stdin(Stdio::piped());
|
|
|
|
let progress =
|
|
Logger::multi_progress().add(ProgressBar::new_spinner().with_message(message));
|
|
progress.enable_steady_tick(Duration::from_millis(100));
|
|
|
|
let mut child = command.spawn()?;
|
|
|
|
let child_pid = child.id();
|
|
add_pid(child_pid);
|
|
|
|
// We drop the `Command` to prevent blocking on writer
|
|
// https://docs.rs/os_pipe/latest/os_pipe/#examples
|
|
drop(command);
|
|
|
|
let reader = BufReader::new(reader);
|
|
|
|
thread::spawn(move || {
|
|
let mp = Logger::multi_progress();
|
|
reader.lines().for_each(|line| {
|
|
if let Ok(l) = line {
|
|
let text = format!("{log_prefix} {l}", log_prefix = log_header(&header));
|
|
if mp.is_hidden() {
|
|
eprintln!("{text}");
|
|
} else {
|
|
mp.println(text).unwrap();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
let status = child.wait()?;
|
|
remove_pid(child_pid);
|
|
|
|
progress.finish();
|
|
Logger::multi_progress().remove(&progress);
|
|
|
|
Ok(status)
|
|
}
|
|
inner(self, header.as_ref(), message.into())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Builder)]
|
|
struct CustomPatternEncoder {
|
|
#[builder(default, into)]
|
|
filter_modules: Vec<(String, LevelFilter)>,
|
|
}
|
|
|
|
impl Encode for CustomPatternEncoder {
|
|
fn encode(&self, w: &mut dyn Write, record: &Record) -> anyhow::Result<()> {
|
|
if record.module_path().is_some_and(|mp| {
|
|
self.filter_modules
|
|
.iter()
|
|
.any(|(module, level)| mp.contains(module) && *level <= record.level())
|
|
}) {
|
|
Ok(())
|
|
} else {
|
|
match log::max_level() {
|
|
LevelFilter::Error | LevelFilter::Warn | LevelFilter::Info => Ok(writeln!(
|
|
w,
|
|
"{prefix} {args}",
|
|
prefix = log_header(format!(
|
|
"{level:width$}",
|
|
level = record.level().colored(),
|
|
width = 5,
|
|
)),
|
|
args = record.args(),
|
|
)?),
|
|
LevelFilter::Debug => Ok(writeln!(
|
|
w,
|
|
"{prefix} {args}",
|
|
prefix = log_header(format!(
|
|
"{level:>width$}",
|
|
level = record.level().colored(),
|
|
width = 5,
|
|
)),
|
|
args = record.args(),
|
|
)?),
|
|
LevelFilter::Trace => Ok(writeln!(
|
|
w,
|
|
"{prefix} {args}",
|
|
prefix = log_header(format!(
|
|
"{level:width$} {module}:{line}",
|
|
level = record.level().colored(),
|
|
width = 5,
|
|
module = record
|
|
.module_path()
|
|
.map_or_else(|| "", |p| p)
|
|
.bright_yellow(),
|
|
line = record
|
|
.line()
|
|
.map_or_else(String::new, |l| l.to_string())
|
|
.bright_green(),
|
|
)),
|
|
args = record.args(),
|
|
)?),
|
|
LevelFilter::Off => Ok(()),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Used to keep the style of logs consistent between
|
|
/// normal log use and command output.
|
|
fn log_header<T>(text: T) -> String
|
|
where
|
|
T: AsRef<str>,
|
|
{
|
|
fn inner(text: &str) -> String {
|
|
match log::max_level() {
|
|
LevelFilter::Error | LevelFilter::Warn | LevelFilter::Info => {
|
|
format!("{text} {sep}", sep = "=>".bold())
|
|
}
|
|
LevelFilter::Debug | LevelFilter::Trace => format!(
|
|
"[{time} {text}] {sep}",
|
|
time = Local::now().format("%H:%M:%S"),
|
|
sep = "=>".bold(),
|
|
),
|
|
LevelFilter::Off => String::new(),
|
|
}
|
|
}
|
|
inner(text.as_ref())
|
|
}
|
|
|
|
/// Shortens the image name so that it won't take up the
|
|
/// entire width of the terminal. This is a similar format
|
|
/// to what Earthly does in their terminal output for long
|
|
/// images on their log prefix output.
|
|
///
|
|
/// # Examples
|
|
/// `ghcr.io/blue-build/cli:latest` -> `g.i/b/cli:latest`
|
|
/// `registry.gitlab.com/some/namespace/image:latest` -> `r.g.c/s/n/image:latest`
|
|
#[must_use]
|
|
fn shorten_name<T>(text: T) -> String
|
|
where
|
|
T: AsRef<str>,
|
|
{
|
|
let text = text.as_ref();
|
|
|
|
// Split the reference by colon to separate the tag or digest
|
|
let mut parts = text.split(':');
|
|
|
|
let Some(path) = parts.next() else {
|
|
return text.to_string();
|
|
};
|
|
let tag = parts.next();
|
|
|
|
// Split the path by slash to work on each part
|
|
let path_parts: Vec<&str> = path.split('/').collect();
|
|
|
|
// Shorten each part except the last one to their initial letters
|
|
let shortened_parts: Vec<String> = path_parts
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, part)| {
|
|
if i < path_parts.len() - 1 {
|
|
// Split on '.' and shorten each section
|
|
part.split('.')
|
|
.filter_map(|p| p.chars().next())
|
|
.map(|c| c.to_string())
|
|
.collect::<Vec<String>>()
|
|
.join(".")
|
|
} else {
|
|
(*part).into() // Keep the last part as it is
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Rejoin the parts with '/'
|
|
let joined_path = shortened_parts.join("/");
|
|
|
|
// If there was a tag, append it back with ':', otherwise just return the path
|
|
match tag {
|
|
Some(t) => format!("{joined_path}:{t}"),
|
|
None => joined_path,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn gen_random_ansi_color() -> u8 {
|
|
// ANSI extended color range
|
|
// https://www.ditig.com/publications/256-colors-cheat-sheet
|
|
const LOW_END: u8 = 21; // Blue1 #0000ff rgb(0,0,255) hsl(240,100%,50%)
|
|
const HIGH_END: u8 = 230; // Cornsilk1 #ffffd7 rgb(255,255,215) hsl(60,100%,92%)
|
|
|
|
rand::rng().random_range(LOW_END..=HIGH_END)
|
|
}
|
|
|
|
pub fn color_str<T>(text: T, ansi_color: u8) -> String
|
|
where
|
|
T: AsRef<str>,
|
|
{
|
|
if ShouldColorize::from_env().should_colorize() {
|
|
Color::Fixed(ansi_color)
|
|
.paint(text.as_ref().to_string())
|
|
.to_string()
|
|
} else {
|
|
text.as_ref().to_string()
|
|
}
|
|
}
|