diff --git a/src/cli/setup.rs b/src/cli/setup.rs index c27b945c0..e1f63f6dd 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -67,39 +67,49 @@ async fn setup(modes: &[RunnerMode], setup_cache_dir: Option<&Path>) -> Result<( } /// Set up a single executor based on its support level on the current system. -/// -/// Unsupported executors or executors that require manual installation are -/// skipped, not treated as fatal. async fn setup_executor( executor: &dyn Executor, system_info: &SystemInfo, setup_cache_dir: Option<&Path>, ) -> Result<()> { - match executor.support_level(system_info) { - ExecutorSupport::Unsupported => { - info!( - "Skipping setup for the {} executor: not supported on {}", - executor.name(), - system_info.os - ); - } - ExecutorSupport::RequiresManualInstallation => { - info!( - "Skipping automatic setup for the {} executor on {}; install required tooling manually.", + let support_level = executor.support_level(system_info); + if support_level == ExecutorSupport::Unsupported { + info!( + "Skipping setup for the {} executor: not supported on {}", + executor.name(), + system_info.os + ); + return Ok(()); + } + + info!( + "Setting up the environment for the executor: {}", + executor.name() + ); + let result = setup_and_grant(executor, system_info, setup_cache_dir).await; + + match result { + // We publish no tooling for this host, so the executor could only try. Leave the + // installation to the user and carry on with the other executors. + Err(error) if support_level == ExecutorSupport::RequiresManualInstallation => { + warn!( + "Could not set up the {} executor on {}, install its tooling manually: {error}", executor.name(), system_info.os ); + Ok(()) } - ExecutorSupport::FullySupported => { - info!( - "Setting up the environment for the executor: {}", - executor.name() - ); - executor.setup(system_info, setup_cache_dir).await?; - executor.grant_privileges()?; - } + result => result, } - Ok(()) +} + +async fn setup_and_grant( + executor: &dyn Executor, + system_info: &SystemInfo, + setup_cache_dir: Option<&Path>, +) -> Result<()> { + executor.setup(system_info, setup_cache_dir).await?; + executor.grant_privileges() } pub fn status(modes: &[RunnerMode]) -> Result<()> { diff --git a/src/executor/valgrind/build_from_source.rs b/src/executor/valgrind/build_from_source.rs new file mode 100644 index 000000000..9e4dbc75a --- /dev/null +++ b/src/executor/valgrind/build_from_source.rs @@ -0,0 +1,220 @@ +//! 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 console::Term; +use std::env; +use std::ffi::OsStr; +use std::path::Path; +use std::process::{Command, Stdio}; +use tempfile::TempDir; + +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"; + +/// 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() +} + +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 into a temporary directory, wiped once the build is done. +async fn clone_sources() -> Result { + let source_dir = + TempDir::new().context("failed to create a temporary directory for the sources")?; + + let source_dir_str = source_dir.path().to_string_lossy().into_owned(); + let mut builder = CommandBuilder::new("git"); + builder.args([ + "clone", + "--depth", + "1", + 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?; + let path = source_dir.path(); + + // 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(path, path.join("autogen.sh"), &[])).await?; + run_build_command(command_in(path, path.join("configure"), &[])).await?; + run_build_command(command_in( + path, + "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(); + + // Default to yes on empty input (just pressing Enter), as the `[Y/n]` prompt announces. + 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() -> Result<()> { + let missing_dependencies = missing_build_dependencies(); + if !missing_dependencies.is_empty() { + bail!( + "the build toolchain is incomplete, install the missing tools: {}", + missing_dependencies.join(", ") + ); + } + + 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.path()).await?; + + Ok(()) +} diff --git a/src/executor/valgrind/executor.rs b/src/executor/valgrind/executor.rs index accd5b34a..aa28c8c8b 100644 --- a/src/executor/valgrind/executor.rs +++ b/src/executor/valgrind/executor.rs @@ -9,8 +9,9 @@ use crate::prelude::*; use crate::system::{SupportedOs, SystemInfo}; use super::setup::get_valgrind_status; -use super::setup::install_valgrind; +use super::setup::install_valgrind_from_package; use super::setup::is_codspeed_valgrind_installation_supported; +use super::setup::try_install_from_source; use super::{helpers::perf_maps::harvest_perf_maps, helpers::venv_compat, measure}; pub struct ValgrindExecutor; @@ -39,7 +40,21 @@ impl Executor for ValgrindExecutor { } async fn setup(&self, system_info: &SystemInfo, setup_cache_dir: Option<&Path>) -> Result<()> { - install_valgrind(system_info, setup_cache_dir).await?; + match self.support_level(system_info) { + ExecutorSupport::FullySupported => { + install_valgrind_from_package(system_info, setup_cache_dir).await? + } + // No package exists for this system, so there is nothing to install automatically. + ExecutorSupport::RequiresManualInstallation => { + try_install_from_source(system_info).await? + } + ExecutorSupport::Unsupported => { + bail!( + "The valgrind executor is not supported on {}", + system_info.os + ) + } + } if let Err(error) = venv_compat::symlink_libpython(None) { warn!("Failed to symlink libpython"); 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..0f6b82306 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,89 @@ 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 the separate debug file `libc6-dbg` provides. +/// +/// Probed by file rather than through dpkg, because the setup cache restores package +/// files without touching dpkg's database. +fn has_libc_debug_symbols(system_info: &SystemInfo) -> bool { + system_libc_path(system_info).is_some_and(|libc| has_debug_symbols(&libc)) +} +/// Warn when valgrind will run against a libc it has no debug symbols for: they sharpen +/// its output but are not needed to run it. +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" + ); + } +} + +/// Provide valgrind on the systems we publish no package for: take the installation the +/// user brings, or build one from source, and otherwise ask for a manual installation. +pub(super) async fn try_install_from_source(system_info: &SystemInfo) -> Result<()> { + 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(()); + } + + // 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().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() + ); } -pub async fn install_valgrind( +/// Install the valgrind-codspeed package we publish for this system, from apt. +pub(super) async fn install_valgrind_from_package( system_info: &SystemInfo, setup_cache_dir: Option<&Path>, ) -> Result<()> { 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..77795fd12 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::*; + +/// Version reported on the wire for distributions that expose none. +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,15 @@ 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(); - Ok(Self::Linux(LinuxDistribution::from_id(&os_id, &os_version))) + // Rolling releases do not expose a `VERSION_ID` in `/etc/os-release`. + let os_version = System::os_version(); + 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}"), } @@ -43,17 +48,21 @@ impl SupportedOs { } } - pub fn version(&self) -> &str { + /// The OS version, absent on the distributions that report none. + pub fn version(&self) -> Option<&str> { match self { Self::Linux(distro) => distro.version(), - Self::Macos { version } => version, + Self::Macos { version } => Some(version), } } } impl Display for SupportedOs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} {}", self.id(), self.version()) + match self.version() { + Some(version) => write!(f, "{} {version}", self.id()), + None => write!(f, "{}", self.id()), + } } } @@ -69,7 +78,7 @@ impl From for SupportedOsSerde { fn from(os: SupportedOs) -> Self { SupportedOsSerde { os: os.id().to_string(), - os_version: os.version().to_string(), + os_version: os.version().unwrap_or(UNKNOWN_OS_VERSION).to_string(), } } } @@ -77,24 +86,31 @@ impl From for SupportedOsSerde { /// Linux distribution, identified by the `sysinfo` distribution id. #[derive(Eq, PartialEq, Hash, Debug, Clone)] pub enum LinuxDistribution { - Ubuntu { version: String }, - Debian { version: String }, - Other { name: String, version: String }, + Ubuntu { + version: String, + }, + Debian { + version: String, + }, + Other { + name: String, + /// Absent on rolling releases, which expose no `VERSION_ID`. + version: Option, + }, } impl LinuxDistribution { - /// Build a [`LinuxDistribution`] from the raw `(os_id, version)` strings reported by `sysinfo`. - fn from_id(os_id: &str, version: &str) -> Self { - match os_id { - "ubuntu" => Self::Ubuntu { - version: version.to_string(), - }, - "debian" => Self::Debian { - version: version.to_string(), - }, - _ => Self::Other { - name: os_id.to_string(), - version: version.to_string(), + /// Build a [`LinuxDistribution`] from the raw `(os_id, version)` reported by `sysinfo`. + /// + /// The distributions we ship packages for all report a version, so one reporting none + /// is by construction not one of them. + fn from_id(os_id: &str, version: Option) -> Self { + match (os_id, version) { + ("ubuntu", Some(version)) => Self::Ubuntu { version }, + ("debian", Some(version)) => Self::Debian { version }, + (name, version) => Self::Other { + name: name.to_string(), + version, }, } } @@ -108,11 +124,11 @@ impl LinuxDistribution { } } - pub fn version(&self) -> &str { + /// The distribution version, absent on the ones that report none. + pub fn version(&self) -> Option<&str> { match self { - Self::Ubuntu { version } | Self::Debian { version } | Self::Other { version, .. } => { - version - } + Self::Ubuntu { version } | Self::Debian { version } => Some(version), + Self::Other { version, .. } => version.as_deref(), } } @@ -124,7 +140,10 @@ impl LinuxDistribution { impl Display for LinuxDistribution { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} {}", self.id(), self.version()) + match self.version() { + Some(version) => write!(f, "{} {version}", self.id()), + None => write!(f, "{}", self.id()), + } } } @@ -137,4 +156,12 @@ mod tests { let err = SupportedOs::from_os("windows").unwrap_err(); assert_eq!(err.to_string(), "Unsupported operating system: windows"); } + + #[test] + fn distribution_without_version_id_is_not_supported() { + let distro = LinuxDistribution::from_id("arch", None); + assert_eq!(distro.version(), None); + assert_eq!(distro.to_string(), "arch"); + assert!(!distro.is_supported()); + } } diff --git a/src/upload/upload_metadata.rs b/src/upload/upload_metadata.rs index 9f0dc02d3..b90e75070 100644 --- a/src/upload/upload_metadata.rs +++ b/src/upload/upload_metadata.rs @@ -96,7 +96,7 @@ mod tests { os: crate::system::SupportedOs::Linux( crate::system::LinuxDistribution::Other { name: "nixos".into(), - version: "25.11".into(), + version: Some("25.11".into()), }, ), arch: "x86_64".to_string(),