diff --git a/Cargo.toml b/Cargo.toml index dd6c2de4..d97717e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,9 @@ validate = [ "cached/async", "blue-build-process-management/validate" ] +prune = [ + "blue-build-process-management/prune" +] [dev-dependencies] rusty-hook = "0.11" diff --git a/process/Cargo.toml b/process/Cargo.toml index c46cca9e..14fea098 100644 --- a/process/Cargo.toml +++ b/process/Cargo.toml @@ -54,3 +54,4 @@ workspace = true [features] sigstore = ["dep:tokio", "dep:sigstore"] validate = ["dep:tokio"] +prune = [] diff --git a/process/drivers.rs b/process/drivers.rs index 8d12caed..eb66f792 100644 --- a/process/drivers.rs +++ b/process/drivers.rs @@ -326,6 +326,11 @@ impl BuildDriver for Driver { impl_build_driver!(login()) } + #[cfg(feature = "prune")] + fn prune(opts: &opts::PruneOpts) -> Result<()> { + impl_build_driver!(prune(opts)) + } + fn build_tag_push(opts: &BuildTagPushOpts) -> Result> { impl_build_driver!(build_tag_push(opts)) } diff --git a/process/drivers/buildah_driver.rs b/process/drivers/buildah_driver.rs index 2dc77c95..72b7fd95 100644 --- a/process/drivers/buildah_driver.rs +++ b/process/drivers/buildah_driver.rs @@ -24,8 +24,13 @@ pub struct BuildahDriver; impl DriverVersion for BuildahDriver { // RUN mounts for bind, cache, and tmpfs first supported in 1.24.0 // https://buildah.io/releases/#changes-for-v1240 + #[cfg(not(feature = "prune"))] const VERSION_REQ: &'static str = ">=1.24"; + // The prune command wasn't present until 1.29 + #[cfg(feature = "prune")] + const VERSION_REQ: &'static str = ">=1.29"; + fn version() -> Result { trace!("BuildahDriver::version()"); @@ -64,7 +69,7 @@ impl BuildDriver for BuildahDriver { trace!("{command:?}"); let status = command - .status_image_ref_progress(&opts.image, "Building Image") + .build_status(&opts.image, "Building Image") .into_diagnostic()?; if status.success() { @@ -104,7 +109,7 @@ impl BuildDriver for BuildahDriver { trace!("{command:?}"); let status = command - .status_image_ref_progress(&opts.image, "Pushing Image") + .build_status(&opts.image, "Pushing Image") .into_diagnostic()?; if status.success() { @@ -159,4 +164,24 @@ impl BuildDriver for BuildahDriver { } Ok(()) } + + #[cfg(feature = "prune")] + fn prune(opts: &super::opts::PruneOpts) -> Result<()> { + trace!("PodmanDriver::prune({opts:?})"); + + let status = cmd!( + "buildah", + "prune", + "--force", + if opts.all => "-all", + ) + .message_status("buildah prune", "Pruning Buildah System") + .into_diagnostic()?; + + if !status.success() { + bail!("Failed to prune buildah"); + } + + Ok(()) + } } diff --git a/process/drivers/docker_driver.rs b/process/drivers/docker_driver.rs index 9d927dca..069538a1 100644 --- a/process/drivers/docker_driver.rs +++ b/process/drivers/docker_driver.rs @@ -228,6 +228,59 @@ impl BuildDriver for DockerDriver { Ok(()) } + #[cfg(feature = "prune")] + fn prune(opts: &super::opts::PruneOpts) -> Result<()> { + trace!("DockerDriver::prune({opts:?})"); + + let (system, buildx) = std::thread::scope( + |scope| -> std::thread::Result<(Result, Result)> { + let system = scope.spawn(|| { + cmd!( + "docker", + "system", + "prune", + "--force", + if opts.all => "--all", + if opts.volumes => "--volumes", + ) + .message_status("docker system prune", "Pruning Docker System") + .into_diagnostic() + }); + + let buildx = scope.spawn(|| { + cmd!( + "docker", + "buildx", + "prune", + "--force", + |command|? { + if !env::var(DOCKER_HOST).is_ok_and(|dh| !dh.is_empty()) { + Self::setup()?; + cmd!(command, "--builder=bluebuild"); + } + }, + if opts.all => "--all", + ) + .message_status("docker buildx prune", "Pruning Docker Buildx") + .into_diagnostic() + }); + + Ok((system.join()?, buildx.join()?)) + }, + ) + .map_err(|e| miette!("{e:?}"))?; + + if !system?.success() { + bail!("Failed to prune docker system"); + } + + if !buildx?.success() { + bail!("Failed to prune docker buildx"); + } + + Ok(()) + } + fn build_tag_push(opts: &BuildTagPushOpts) -> Result> { trace!("DockerDriver::build_tag_push({opts:#?})"); @@ -305,7 +358,7 @@ impl BuildDriver for DockerDriver { trace!("{command:?}"); if command - .status_image_ref_progress(display_image, "Building Image") + .build_status(display_image, "Building Image") .into_diagnostic()? .success() { @@ -383,8 +436,7 @@ impl RunDriver for DockerDriver { add_cid(&cid); - let status = docker_run(opts, &cid_file) - .status_image_ref_progress(&*opts.image, "Running container")?; + let status = docker_run(opts, &cid_file).build_status(&*opts.image, "Running container")?; remove_cid(&cid); diff --git a/process/drivers/opts/build.rs b/process/drivers/opts/build.rs index 0810f69a..ed2ae238 100644 --- a/process/drivers/opts/build.rs +++ b/process/drivers/opts/build.rs @@ -36,6 +36,13 @@ pub struct PushOpts<'scope> { pub compression_type: Option, } +#[derive(Debug, Clone, Builder)] +#[cfg(feature = "prune")] +pub struct PruneOpts { + pub all: bool, + pub volumes: bool, +} + /// Options for building, tagging, and pusing images. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Builder)] diff --git a/process/drivers/podman_driver.rs b/process/drivers/podman_driver.rs index 39e7c725..30c0d1f6 100644 --- a/process/drivers/podman_driver.rs +++ b/process/drivers/podman_driver.rs @@ -146,7 +146,7 @@ impl BuildDriver for PodmanDriver { trace!("{command:?}"); let status = command - .status_image_ref_progress(&opts.image, "Building Image") + .build_status(&opts.image, "Building Image") .into_diagnostic()?; if status.success() { @@ -188,7 +188,7 @@ impl BuildDriver for PodmanDriver { trace!("{command:?}"); let status = command - .status_image_ref_progress(&opts.image, "Pushing Image") + .build_status(&opts.image, "Pushing Image") .into_diagnostic()?; if status.success() { @@ -243,6 +243,28 @@ impl BuildDriver for PodmanDriver { } Ok(()) } + + #[cfg(feature = "prune")] + fn prune(opts: &super::opts::PruneOpts) -> Result<()> { + trace!("PodmanDriver::prune({opts:?})"); + + let status = cmd!( + "podman", + "system", + "prune", + "--force", + if opts.all => "-all", + if opts.volumes => "--volumes", + ) + .message_status("podman system prune", "Pruning Podman System") + .into_diagnostic()?; + + if !status.success() { + bail!("Failed to prune podman"); + } + + Ok(()) + } } impl InspectDriver for PodmanDriver { @@ -326,8 +348,7 @@ impl RunDriver for PodmanDriver { let status = if opts.privileged { podman_run(opts, &cid_file).status()? } else { - podman_run(opts, &cid_file) - .status_image_ref_progress(&*opts.image, "Running container")? + podman_run(opts, &cid_file).build_status(&*opts.image, "Running container")? }; remove_cid(&cid); diff --git a/process/drivers/traits.rs b/process/drivers/traits.rs index 4680a811..f5c58e16 100644 --- a/process/drivers/traits.rs +++ b/process/drivers/traits.rs @@ -106,6 +106,13 @@ pub trait BuildDriver: PrivateDriver { /// Will error if login fails. fn login() -> Result<()>; + /// Runs prune commands for the driver. + /// + /// # Errors + /// Will error if the driver fails to prune. + #[cfg(feature = "prune")] + fn prune(opts: &super::opts::PruneOpts) -> Result<()>; + /// Runs the logic for building, tagging, and pushing an image. /// /// # Errors diff --git a/process/logging.rs b/process/logging.rs index 005353c9..7fa10565 100644 --- a/process/logging.rs +++ b/process/logging.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, env, fs::OpenOptions, io::{BufRead, BufReader, Result, Write as IoWrite}, @@ -31,10 +32,17 @@ use log4rs::{ }; use nu_ansi_term::Color; use once_cell::sync::Lazy; +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: Lazy = Lazy::new(MultiProgress::new); static LOG_DIR: Lazy> = Lazy::new(|| Mutex::new(PathBuf::new())); @@ -174,87 +182,159 @@ impl ColoredLevel for Level { } } -pub trait CommandLogging { +pub trait CommandLogging: Private { /// Prints each line of stdout/stderr with an image ref string - /// and a progress spinner. This helps to keep track of every - /// build running in parallel. + /// 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 status_image_ref_progress(self, image_ref: T, message: U) -> Result + fn build_status(self, image_ref: T, message: U) -> Result where T: AsRef, U: AsRef; + + /// 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(self, header: S, message: D) -> Result + where + S: AsRef, + D: Into>; } impl CommandLogging for Command { - fn status_image_ref_progress(mut self, image_ref: T, message: U) -> Result + fn build_status(self, image_ref: T, message: U) -> Result where T: AsRef, U: AsRef, { - 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()?; - - self.stdout(writer.try_clone()?) - .stderr(writer) - .stdin(Stdio::piped()); - - let progress = Logger::multi_progress() - .add(ProgressBar::new_spinner().with_message(format!("{} {name}", message.as_ref()))); - progress.enable_steady_tick(Duration::from_millis(100)); - - let mut child = self.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(self); - - 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.as_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(); + fn inner(mut command: Command, image_ref: &str, message: &str) -> Result { + 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() + ); + } } - 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(self, header: S, message: D) -> Result + where + S: AsRef, + D: Into>, + { + fn inner( + mut command: Command, + header: &str, + message: Cow<'static, str>, + ) -> Result { + 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); + let status = child.wait()?; + remove_pid(child_pid); - progress.finish(); - Logger::multi_progress().remove(&progress); + progress.finish(); + Logger::multi_progress().remove(&progress); - Ok(status) + Ok(status) + } + inner(self, header.as_ref(), message.into()) } } diff --git a/src/bin/bluebuild.rs b/src/bin/bluebuild.rs index 01f9c763..8a205c31 100644 --- a/src/bin/bluebuild.rs +++ b/src/bin/bluebuild.rs @@ -52,6 +52,9 @@ fn main() { #[cfg(feature = "validate")] CommandArgs::Validate(mut command) => command.run(), + #[cfg(feature = "prune")] + CommandArgs::Prune(mut command) => command.run(), + CommandArgs::BugReport(mut command) => command.run(), CommandArgs::Completions(mut command) => command.run(), diff --git a/src/commands.rs b/src/commands.rs index ddb197bc..14850829 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -19,6 +19,8 @@ pub mod init; pub mod local; #[cfg(feature = "login")] pub mod login; +#[cfg(feature = "prune")] +pub mod prune; #[cfg(feature = "switch")] pub mod switch; #[cfg(feature = "validate")] @@ -130,12 +132,10 @@ pub enum CommandArgs { #[cfg(feature = "validate")] Validate(Box), - // /// Initialize a new Ublue Starting Point repo - // #[cfg(feature = "init")] - // Init(init::InitCommand), + /// Clean up cache and images for build drivers. + #[cfg(feature = "prune")] + Prune(prune::PruneCommand), - // #[cfg(feature = "init")] - // New(init::NewCommand), /// Create a pre-populated GitHub issue with information about your configuration BugReport(bug_report::BugReportCommand), diff --git a/src/commands/prune.rs b/src/commands/prune.rs new file mode 100644 index 00000000..b972ecf6 --- /dev/null +++ b/src/commands/prune.rs @@ -0,0 +1,82 @@ +use blue_build_process_management::drivers::{opts::PruneOpts, BuildDriver, Driver, DriverArgs}; +use bon::Builder; +use clap::Args; +use colored::Colorize; +use miette::bail; + +use super::BlueBuildCommand; + +#[derive(Debug, Args, Builder)] +pub struct PruneCommand { + /// Remove all unused images + #[builder(default)] + #[arg(short, long)] + all: bool, + + /// Do not prompt for confirmation + #[builder(default)] + #[arg(short, long)] + force: bool, + + /// Prune volumes + #[builder(default)] + #[arg(long)] + volumes: bool, + + #[clap(flatten)] + #[builder(default)] + drivers: DriverArgs, +} + +impl BlueBuildCommand for PruneCommand { + fn try_run(&mut self) -> miette::Result<()> { + Driver::init(self.drivers); + + if !self.force { + eprintln!( + "{} This will remove:{default}{images}{build_cache}{volumes}", + "WARNING!".bright_yellow(), + default = concat!( + "\n - all stopped containers", + "\n - all networks not used by at least one container", + ), + images = if self.all { + "\n - all images without at least one container associated to them" + } else { + "\n - all dangling images" + }, + build_cache = if self.all { + "\n - all build cache" + } else { + "\n - unused build cache" + }, + volumes = if self.volumes { + "\n - all anonymous volumes not used by at least one container" + } else { + "" + }, + ); + + match requestty::prompt_one( + requestty::Question::confirm("anonymous") + .message("Are you sure you want to continue?") + .default(false) + .build(), + ) { + Err(e) => bail!("Canceled {e:?}"), + Ok(answer) => { + if answer.as_bool().is_some_and(|a| !a) { + return Ok(()); + } + } + } + } + + Driver::prune( + &PruneOpts::builder() + .all(self.all) + .volumes(self.volumes) + .build(), + ) + } +} diff --git a/src/commands/switch.rs b/src/commands/switch.rs index 3b41e58d..d193e38a 100644 --- a/src/commands/switch.rs +++ b/src/commands/switch.rs @@ -138,7 +138,7 @@ impl SwitchCommand { trace!("{command:?}"); command } - .status_image_ref_progress( + .build_status( format!("{}", archive_path.display()), "Switching to new image", )