diff --git a/src/executor/valgrind/build_from_source.rs b/src/executor/valgrind/build_from_source.rs new file mode 100644 index 000000000..437da06ec --- /dev/null +++ b/src/executor/valgrind/build_from_source.rs @@ -0,0 +1,286 @@ +//! Best-effort fallback that builds valgrind-codspeed from source, for the +//! systems we do not publish a package for (rolling releases, non-apt +//! distributions, ...). +//! +//! This is deliberately a "best effort": the toolchain needed to build valgrind +//! is not guaranteed to be present, so every failure is reported back to the +//! caller, which falls back to asking for a manual installation. +//! +//! The build is also opt-in rather than automatic, see [`is_wanted`]: it takes +//! minutes and installs system-wide, so an interactive user is asked first. + +use crate::executor::helpers::command::CommandBuilder; +use crate::executor::helpers::run_command_with_log_pipe::run_command_with_log_pipe; +use crate::executor::helpers::run_with_sudo::wrap_with_sudo; +use crate::local_logger::rolling_buffer::{activate_rolling_buffer, deactivate_rolling_buffer}; +use crate::local_logger::{IS_TTY, suspend_progress_bar}; +use crate::prelude::*; +use crate::system::{SupportedOs, SystemInfo}; +use console::Term; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::{env, fs}; + +const VALGRIND_CODSPEED_REPOSITORY: &str = "https://github.com/CodSpeedHQ/valgrind-codspeed.git"; + +/// Environment variable that answers [`is_wanted`] without asking, for CI and any +/// other unattended run that wants the opposite of the default. +const BUILD_FROM_SOURCE_ENV: &str = "CODSPEED_VALGRIND_BUILD_FROM_SOURCE"; + +/// Branch of the valgrind-codspeed repository to build from. +// TODO: switch back to `main` once the self-contained build script has landed there. +const VALGRIND_CODSPEED_BRANCH: &str = "cod-3465-create-a-self-contained-valgrind-build-script"; + +/// Directory name, under the system temporary directory, the sources are cloned into. +const SOURCE_DIR_NAME: &str = "valgrind-codspeed-src"; + +/// Tools required to configure and build valgrind. Each entry lists the +/// interchangeable executables that satisfy the requirement. +const BUILD_DEPENDENCIES: &[&[&str]] = &[ + &["git"], + &["make"], + &["autoconf"], + &["automake"], + &["cc", "gcc", "clang"], +]; + +fn is_executable_available(executable: &str) -> bool { + Command::new("which") + .arg(executable) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +/// Names of the missing build dependencies, one per unsatisfied requirement. +fn missing_build_dependencies() -> Vec<&'static str> { + BUILD_DEPENDENCIES + .iter() + .filter(|alternatives| { + !alternatives + .iter() + .any(|executable| is_executable_available(executable)) + }) + .map(|alternatives| alternatives[0]) + .collect() +} + +/// Command that installs the build toolchain, for the distributions we can name it for. +/// +/// Valgrind itself needs no development library: it links `-nodefaultlibs` and vendors its only +/// third-party decoder, so an autotools and C toolchain is the whole requirement. +fn build_toolchain_install_hint(system_info: &SystemInfo) -> Option<&'static str> { + let SupportedOs::Linux(distro) = &system_info.os else { + return None; + }; + + // `id` is the `ID` field of /etc/os-release, so derivatives report their own id. + let hint = match distro.id() { + "arch" | "archarm" | "manjaro" | "endeavouros" | "cachyos" => { + "sudo pacman -S --needed base-devel git" + } + "fedora" | "rhel" | "centos" | "rocky" | "almalinux" => { + "sudo dnf install -y @development-tools git" + } + "opensuse-tumbleweed" | "opensuse-leap" | "sles" => { + "sudo zypper install -y -t pattern devel_basis git" + } + "alpine" => "sudo apk add build-base autoconf automake git", + "ubuntu" | "debian" | "linuxmint" | "pop" | "raspbian" => { + "sudo apt-get install -y build-essential autoconf automake git" + } + _ => return None, + }; + Some(hint) +} + +fn parallel_jobs() -> usize { + std::thread::available_parallelism() + .map(|jobs| jobs.get()) + .unwrap_or(1) +} + +fn command_in>(directory: &Path, program: S, args: &[&str]) -> CommandBuilder { + let mut builder = CommandBuilder::new(program); + builder.args(args); + builder.current_dir(directory); + builder +} + +/// Run a build command, piping its output to the logs, and fail on a non-zero exit status. +async fn run_build_command(builder: CommandBuilder) -> Result<()> { + let command_line = builder.as_command_line(); + debug!("Running: {command_line}"); + + let status = run_command_with_log_pipe(builder.build()) + .await + .with_context(|| format!("failed to run `{command_line}`"))?; + + if !status.success() { + bail!("`{command_line}` failed with {status}"); + } + + Ok(()) +} + +/// Clone the sources from scratch, so that a partial or outdated checkout left +/// over by a previous attempt never leaks into the build. +async fn clone_sources() -> Result { + let source_dir = env::temp_dir().join(SOURCE_DIR_NAME); + if source_dir.exists() { + debug!("Removing the previous checkout at {}", source_dir.display()); + fs::remove_dir_all(&source_dir).with_context(|| { + format!( + "failed to remove the previous checkout at {}", + source_dir.display() + ) + })?; + } + + let source_dir_str = source_dir.to_string_lossy().into_owned(); + let mut builder = CommandBuilder::new("git"); + builder.args([ + "clone", + "--depth", + "1", + "--branch", + VALGRIND_CODSPEED_BRANCH, + VALGRIND_CODSPEED_REPOSITORY, + &source_dir_str, + ]); + run_build_command(builder).await?; + + Ok(source_dir) +} + +/// Everything that runs unprivileged: fetching the sources and compiling them. +async fn fetch_and_compile() -> Result { + let source_dir = clone_sources().await?; + + // The scripts are addressed by absolute path: how a relative program path is resolved against + // the working directory of the child is platform specific and unspecified. + run_build_command(command_in(&source_dir, source_dir.join("autogen.sh"), &[])).await?; + run_build_command(command_in(&source_dir, source_dir.join("configure"), &[])).await?; + run_build_command(command_in( + &source_dir, + "make", + &[&format!("-j{}", parallel_jobs())], + )) + .await?; + + Ok(source_dir) +} + +/// Install the freshly built valgrind system-wide. Kept out of the rolling +/// buffer so that a sudo password prompt stays visible to the user. +async fn install_build(source_dir: &Path) -> Result<()> { + let builder = wrap_with_sudo(command_in(source_dir, "make", &["install"]))?; + run_build_command(builder).await +} + +/// Whether to build valgrind-codspeed from source, asking the user when we can. +/// +/// Decision, in order: +/// +/// - [`BUILD_FROM_SOURCE_ENV`] set to `true` or `false`: that answer, unconditionally; +/// - not a TTY (CI, unattended runs): build, since nobody is there to answer and +/// failing the run outright is the worse outcome; +/// - otherwise: ask, defaulting to building when the answer is empty. +/// +/// Declining is a legitimate choice, not a failure: the caller then points at a +/// manual installation, which is what happens on a failed build too. +pub(super) fn is_wanted() -> bool { + match env::var(BUILD_FROM_SOURCE_ENV).as_deref() { + Ok("true") => { + debug!("{BUILD_FROM_SOURCE_ENV} is true, building valgrind from source"); + return true; + } + Ok("false") => { + debug!("{BUILD_FROM_SOURCE_ENV} is false, not building valgrind from source"); + return false; + } + Ok(value) => warn!("Ignoring {BUILD_FROM_SOURCE_ENV}={value}, expected `true` or `false`"), + Err(_) => {} + } + + if !*IS_TTY { + debug!("Not attached to a terminal, building valgrind from source without asking"); + return true; + } + + suspend_progress_bar(prompt_for_source_build) +} + +/// Ask whether to build valgrind from source, defaulting to yes on an empty answer. +/// +/// Mirrors the confirmation the walltime executor uses before installing bash: the +/// question goes to stderr so it stays visible whatever the caller does with stdout. +fn prompt_for_source_build() -> bool { + eprintln!( + "CodSpeed can build valgrind-codspeed from source for this system. It clones the sources \ + into a temporary directory, compiles them (a few minutes) and installs them system-wide \ + with sudo. Declining leaves the installation to you, see \ + https://github.com/CodSpeedHQ/valgrind-codspeed" + ); + eprint!("\nBuild valgrind-codspeed from source now? [Y/n] "); + + let line = Term::stderr().read_line().unwrap_or_default(); + let answer = line.trim(); + + let accepted = + answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"); + if !accepted { + info!( + "Skipping the source build. Set {BUILD_FROM_SOURCE_ENV}=true to build without being asked" + ); + } + accepted +} + +/// Build and install valgrind-codspeed from source. +/// +/// Returns an error describing the first failing step, leaving the caller free +/// to fall back to instructions for a manual installation. +pub(super) async fn build_and_install(system_info: &SystemInfo) -> Result<()> { + let missing_dependencies = missing_build_dependencies(); + if !missing_dependencies.is_empty() { + let missing = missing_dependencies.join(", "); + match build_toolchain_install_hint(system_info) { + Some(hint) => bail!( + "the build toolchain is incomplete ({missing} missing), install it with `{hint}`" + ), + None => bail!("the build toolchain is incomplete ({missing} missing)"), + } + } + + info!("Building valgrind-codspeed from source, this can take a few minutes"); + + activate_rolling_buffer("Building valgrind from source"); + let compilation_result = fetch_and_compile().await; + deactivate_rolling_buffer(); + + let source_dir = compilation_result?; + install_build(&source_dir).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Only the explicit env-var arms are covered: every other input reaches the + /// TTY check, and `cargo test -- --nocapture` from a terminal would then block + /// the suite on an interactive prompt. + #[test] + fn is_wanted_honours_an_explicit_env_var() { + temp_env::with_var(BUILD_FROM_SOURCE_ENV, Some("true"), || { + assert!(is_wanted()); + }); + temp_env::with_var(BUILD_FROM_SOURCE_ENV, Some("false"), || { + assert!(!is_wanted()); + }); + } +} diff --git a/src/executor/valgrind/mod.rs b/src/executor/valgrind/mod.rs index 3db9a666e..371bab43c 100644 --- a/src/executor/valgrind/mod.rs +++ b/src/executor/valgrind/mod.rs @@ -1,3 +1,4 @@ +mod build_from_source; pub mod executor; pub mod helpers; mod measure; diff --git a/src/executor/valgrind/setup.rs b/src/executor/valgrind/setup.rs index 32832c089..a07b540fe 100644 --- a/src/executor/valgrind/setup.rs +++ b/src/executor/valgrind/setup.rs @@ -1,3 +1,4 @@ +use super::build_from_source; use crate::binary_pins::{ Arch, DistroVersion, PinnedBinary, VALGRIND_CODSPEED_ITERATION, VALGRIND_CODSPEED_VERSION, VALGRIND_CODSPEED_VERSION_STRING, ValgrindTarget, @@ -225,30 +226,97 @@ fn has_debug_symbols(binary: &Path) -> bool { } } -fn is_valgrind_installed(system_info: &SystemInfo) -> bool { - if !matches!( +/// Whether a valgrind-codspeed recent enough for this runner is on `PATH`. +fn is_valgrind_installed() -> bool { + matches!( get_valgrind_status().status, ToolInstallStatus::Installed { .. } - ) { - return false; - } + ) +} +/// Whether the system libc has a resolvable separate debug file, as the `libc6-dbg` +/// package provides. +/// +/// Probed by file rather than through dpkg, because the setup cache restores package +/// files onto the root filesystem without touching dpkg's database. Only meaningful +/// where [`system_libc_path`] resolves, so callers must already know they are on the +/// Debian multiarch layout. +fn has_libc_debug_symbols(system_info: &SystemInfo) -> bool { + system_libc_path(system_info).is_some_and(|libc| has_debug_symbols(&libc)) +} + +/// Warn, without failing, when valgrind will run against a libc it has no debug +/// symbols for. +/// +/// The symbols sharpen valgrind's output but are not needed to run it, so a missing +/// `libc6-dbg` must not reject an installation we did not package ourselves. Scoped to +/// apt-based systems, the only layout [`system_libc_path`] knows. +fn warn_on_missing_libc_debug_symbols(system_info: &SystemInfo) { if !apt::is_system_compatible(system_info) { - debug!("Skipping libc debug symbol check on non-apt-based system"); - return true; + debug!("Skipping the libc debug symbol check on a non-apt-based system"); + return; } - system_libc_path(system_info).is_some_and(|libc| has_debug_symbols(&libc)) + if !has_libc_debug_symbols(system_info) { + warn!( + "Debug info for the system libc not found. Install libc6-dbg (Debian/Ubuntu) \ + or glibc-debuginfo (Fedora/RHEL) for more accurate valgrind results" + ); + } } pub async fn install_valgrind( system_info: &SystemInfo, setup_cache_dir: Option<&Path>, ) -> Result<()> { + // On distributions we do not publish a valgrind-codspeed package for (rolling releases, + // non-apt systems, ...), there is nothing to install automatically: the user brings their own + // build. Accept that installation instead of failing on the package we cannot provide. + if !is_codspeed_valgrind_installation_supported(system_info) { + if is_valgrind_installed() { + debug!( + "Using the valgrind installation already present on {}", + system_info.os + ); + warn_on_missing_libc_debug_symbols(system_info); + return Ok(()); + } + + // No package to publish means nothing to install automatically. Offer to build + // valgrind-codspeed from source instead: the build compiles for a few minutes and + // installs system-wide, so the user decides whether we do it or they install by hand. + warn!( + "CodSpeed does not publish a valgrind package for {}", + system_info.os + ); + + if build_from_source::is_wanted() { + // A best effort: the toolchain may be missing or the build may fail, in which case + // the user is pointed to a manual installation like a declined build would be. + match build_from_source::build_and_install(system_info).await { + Ok(()) if is_valgrind_installed() => { + info!("valgrind-codspeed has been built and installed from source"); + warn_on_missing_libc_debug_symbols(system_info); + return Ok(()); + } + Ok(()) => warn!("The freshly built valgrind is not usable, see the logs above"), + Err(error) => warn!("Building valgrind from source failed: {error}"), + } + } + + bail!( + "valgrind-codspeed {} or higher is required and could not be installed automatically. \ + Install it manually, see https://github.com/CodSpeedHQ/valgrind-codspeed", + VALGRIND_CODSPEED_VERSION_STRING.as_str() + ); + } + apt::install_cached( system_info, setup_cache_dir, - || is_valgrind_installed(system_info), + // The libc debug symbols are part of what this path installs, so a cache restore that + // brought back only valgrind must still count as incomplete. + || is_valgrind_installed() && has_libc_debug_symbols(system_info), || async { debug!("Installing valgrind"); let binary = get_codspeed_valgrind_binary(system_info)?; diff --git a/src/system/os.rs b/src/system/os.rs index b83cb22b5..547df34f4 100644 --- a/src/system/os.rs +++ b/src/system/os.rs @@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize}; use sysinfo::System; use crate::prelude::*; + +/// Placeholder version for distributions that do not report one (rolling releases). +const UNKNOWN_OS_VERSION: &str = "unknown"; + /// Typed representation of the host operating system. /// /// Only operating systems that CodSpeed can run on are represented here. @@ -22,14 +26,21 @@ impl SupportedOs { /// For Linux, the distribution is identified via `sysinfo::System::distribution_id()`. /// The OS version is read from `sysinfo::System::os_version()`. pub fn from_os(os: &str) -> Result { - let os_version = System::os_version().ok_or(anyhow!("Failed to get OS version"))?; match os { "linux" => { let os_id = System::distribution_id(); + // Rolling release distributions (Arch, Gentoo, ...) do not expose a `VERSION_ID` + // in `/etc/os-release`, so `sysinfo` reports no version for them. This is not + // fatal: the version only matters for the distributions we ship packages for, + // which all expose one. + let os_version = System::os_version().unwrap_or_else(|| { + debug!("No OS version reported for distribution {os_id}"); + UNKNOWN_OS_VERSION.to_string() + }); Ok(Self::Linux(LinuxDistribution::from_id(&os_id, &os_version))) } "macos" => Ok(Self::Macos { - version: os_version, + version: System::os_version().ok_or(anyhow!("Failed to get OS version"))?, }), unsupported => bail!("Unsupported operating system: {unsupported}"), } @@ -137,4 +148,13 @@ mod tests { let err = SupportedOs::from_os("windows").unwrap_err(); assert_eq!(err.to_string(), "Unsupported operating system: windows"); } + + #[test] + #[cfg(target_os = "linux")] + fn from_os_succeeds_on_linux_without_version_id() { + // Rolling releases report no version: we must still build a `SupportedOs`. + let os = SupportedOs::from_os("linux").unwrap(); + assert!(matches!(os, SupportedOs::Linux(_))); + assert!(!os.version().is_empty()); + } }