Skip to content

Commit bd4642d

Browse files
feat: allow caching installed executor instruments on ubuntu/debian
1 parent a6ece91 commit bd4642d

15 files changed

Lines changed: 339 additions & 72 deletions

File tree

Cargo.lock

Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ memmap2 = "0.9.5"
5656
nix = { version = "0.29.0", features = ["fs", "time", "user"] }
5757
futures = "0.3.31"
5858
runner-shared = { path = "crates/runner-shared" }
59+
shellexpand = { version = "3.1.1", features = ["tilde"] }
5960

6061
[target.'cfg(target_os = "linux")'.dependencies]
6162
procfs = "0.17.0"

src/app.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::path::PathBuf;
2+
13
use crate::{
24
api_client::CodSpeedAPIClient,
35
auth,
@@ -38,6 +40,13 @@ pub struct Cli {
3840
#[arg(long, env = "CODSPEED_OAUTH_TOKEN", global = true, hide = true)]
3941
pub oauth_token: Option<String>,
4042

43+
/// The directory to use for caching installed tools
44+
/// The runner will restore cached tools from this directory before installing them.
45+
/// After successful installation, the runner will cache the installed tools to this directory.
46+
/// Only supported on ubuntu and debian systems.
47+
#[arg(long, env = "CODSPEED_SETUP_CACHE_DIR", global = true)]
48+
pub setup_cache_dir: Option<String>,
49+
4150
#[command(subcommand)]
4251
command: Commands,
4352
}
@@ -56,6 +65,12 @@ pub async fn run() -> Result<()> {
5665
let cli = Cli::parse();
5766
let codspeed_config = CodSpeedConfig::load_with_override(cli.oauth_token.as_deref())?;
5867
let api_client = CodSpeedAPIClient::try_from((&cli, &codspeed_config))?;
68+
// In the context of the CI, it is likely that a ~ made its way here without being expanded by the shell
69+
let setup_cache_dir = cli
70+
.setup_cache_dir
71+
.as_ref()
72+
.map(|d| PathBuf::from(shellexpand::tilde(d).as_ref()));
73+
let setup_cache_dir = setup_cache_dir.as_deref();
5974

6075
match cli.command {
6176
Commands::Run(_) => {} // Run is responsible for its own logger initialization
@@ -65,9 +80,11 @@ pub async fn run() -> Result<()> {
6580
}
6681

6782
match cli.command {
68-
Commands::Run(args) => run::run(args, &api_client, &codspeed_config).await?,
83+
Commands::Run(args) => {
84+
run::run(args, &api_client, &codspeed_config, setup_cache_dir).await?
85+
}
6986
Commands::Auth(args) => auth::run(args, &api_client).await?,
70-
Commands::Setup => setup::setup().await?,
87+
Commands::Setup => setup::setup(setup_cache_dir).await?,
7188
}
7289
Ok(())
7390
}

src/run/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use instruments::mongo_tracer::{MongoTracer, install_mongodb_tracer};
99
use run_environment::interfaces::{RepositoryProvider, RunEnvironment};
1010
use runner::get_run_data;
1111
use serde::Serialize;
12+
use std::path::Path;
1213
use std::path::PathBuf;
1314

1415
pub mod check_system;
@@ -176,6 +177,7 @@ pub async fn run(
176177
args: RunArgs,
177178
api_client: &CodSpeedAPIClient,
178179
codspeed_config: &CodSpeedConfig,
180+
setup_cache_dir: Option<&Path>,
179181
) -> Result<()> {
180182
let output_json = args.message_format == Some(MessageFormat::Json);
181183
let mut config = Config::try_from(args)?;
@@ -202,7 +204,7 @@ pub async fn run(
202204

203205
if !config.skip_setup {
204206
start_group!("Preparing the environment");
205-
executor.setup(&system_info).await?;
207+
executor.setup(&system_info, setup_cache_dir).await?;
206208
// TODO: refactor and move directly in the Instruments struct as a `setup` method
207209
if config.instruments.is_mongodb_enabled() {
208210
install_mongodb_tracer().await?;

src/run/runner/executor.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ use crate::prelude::*;
33
use crate::run::instruments::mongo_tracer::MongoTracer;
44
use crate::run::{check_system::SystemInfo, config::Config};
55
use async_trait::async_trait;
6+
use std::path::Path;
67

78
#[async_trait(?Send)]
89
pub trait Executor {
910
fn name(&self) -> ExecutorName;
1011

11-
async fn setup(&self, _system_info: &SystemInfo) -> Result<()> {
12+
async fn setup(
13+
&self,
14+
_system_info: &SystemInfo,
15+
_setup_cache_dir: Option<&Path>,
16+
) -> Result<()> {
1217
Ok(())
1318
}
1419

src/run/runner/helpers/apt.rs

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
use super::run_with_sudo::run_with_sudo;
2+
use crate::prelude::*;
3+
use crate::run::check_system::SystemInfo;
4+
use std::path::Path;
5+
use std::process::Command;
6+
7+
fn is_system_compatible(system_info: &SystemInfo) -> bool {
8+
system_info.os == "ubuntu" || system_info.os == "debian"
9+
}
10+
11+
/// Installs packages with caching support.
12+
///
13+
/// This function provides a common pattern for installing tools on Ubuntu/Debian systems
14+
/// with automatic caching to speed up subsequent installations (e.g., in CI environments).
15+
///
16+
/// # Arguments
17+
///
18+
/// * `system_info` - System information to determine compatibility
19+
/// * `setup_cache_dir` - Optional directory to restore from/save to cache
20+
/// * `is_installed` - Function that checks if the tool is already installed
21+
/// * `install_packages` - Async closure that:
22+
/// 1. Performs the installation (e.g., downloads .deb files, calls `apt::install`)
23+
/// 2. Returns a Vec of package names that should be cached via `dpkg -L`
24+
///
25+
/// # Flow
26+
///
27+
/// 1. Check if already installed - if yes, skip everything
28+
/// 2. Try to restore from cache (if cache_dir provided)
29+
/// 3. Check again if installed - if yes, we're done
30+
/// 4. Run the install closure to install and get package names
31+
/// 5. Save installed packages to cache (if cache_dir provided)
32+
///
33+
/// # Example
34+
///
35+
/// ```rust,ignore
36+
/// apt::install_cached(
37+
/// system_info,
38+
/// setup_cache_dir,
39+
/// || Command::new("which").arg("perf").status().is_ok(),
40+
/// || async {
41+
/// let packages = vec!["linux-tools-common".to_string()];
42+
/// let refs: Vec<&str> = packages.iter().map(|s| s.as_str()).collect();
43+
/// apt::install(system_info, &refs)?;
44+
/// Ok(packages) // Return package names for caching
45+
/// },
46+
/// ).await?;
47+
/// ```
48+
pub async fn install_cached<F, I, Fut>(
49+
system_info: &SystemInfo,
50+
setup_cache_dir: Option<&Path>,
51+
is_installed: F,
52+
install_packages: I,
53+
) -> Result<()>
54+
where
55+
F: Fn() -> bool,
56+
I: FnOnce() -> Fut,
57+
Fut: std::future::Future<Output = Result<Vec<String>>>,
58+
{
59+
if is_installed() {
60+
info!("Tool already installed, skipping installation");
61+
return Ok(());
62+
}
63+
64+
// Try to restore from cache first
65+
if let Some(cache_dir) = setup_cache_dir {
66+
restore_from_cache(system_info, cache_dir)?;
67+
68+
if is_installed() {
69+
info!("Tool has been successfully restored from cache");
70+
return Ok(());
71+
}
72+
}
73+
74+
// Install and get the package names for caching
75+
let cache_packages = install_packages().await?;
76+
77+
info!("Installation completed successfully");
78+
79+
// Save to cache after successful installation
80+
if let Some(cache_dir) = setup_cache_dir {
81+
let cache_refs: Vec<&str> = cache_packages.iter().map(|s| s.as_str()).collect();
82+
save_to_cache(system_info, cache_dir, &cache_refs)?;
83+
}
84+
85+
Ok(())
86+
}
87+
88+
pub fn install(system_info: &SystemInfo, packages: &[&str]) -> Result<()> {
89+
if !is_system_compatible(system_info) {
90+
bail!(
91+
"Package installation is not supported on this system, please install necessary packages manually"
92+
);
93+
}
94+
95+
debug!("Installing packages: {packages:?}");
96+
97+
run_with_sudo(&["apt-get", "update"])?;
98+
let mut install_cmd = vec!["apt-get", "install", "-y", "--allow-downgrades"];
99+
install_cmd.extend_from_slice(packages);
100+
run_with_sudo(&install_cmd)?;
101+
102+
debug!("Packages installed successfully");
103+
Ok(())
104+
}
105+
106+
/// Restore cached tools from the cache directory to the root filesystem
107+
fn restore_from_cache(system_info: &SystemInfo, cache_dir: &Path) -> Result<()> {
108+
if !is_system_compatible(system_info) {
109+
warn!("Cache restore is not supported on this system, skipping");
110+
return Ok(());
111+
}
112+
113+
if !cache_dir.exists() {
114+
debug!("Cache directory does not exist: {}", cache_dir.display());
115+
return Ok(());
116+
}
117+
118+
// Check if the directory has any contents
119+
let has_contents = std::fs::read_dir(cache_dir)
120+
.map(|mut entries| entries.next().is_some())
121+
.unwrap_or(false);
122+
123+
if !has_contents {
124+
debug!("Cache directory is empty: {}", cache_dir.display());
125+
return Ok(());
126+
}
127+
128+
debug!(
129+
"Restoring tools from cache directory: {}",
130+
cache_dir.display()
131+
);
132+
133+
// Use bash to properly handle glob expansion
134+
let cache_dir_str = cache_dir
135+
.to_str()
136+
.ok_or_else(|| anyhow!("Invalid cache directory path"))?;
137+
138+
let copy_cmd = format!("cp -r {cache_dir_str}/* /");
139+
140+
run_with_sudo(&["bash", "-c", &copy_cmd])?;
141+
142+
debug!("Cache restored successfully");
143+
Ok(())
144+
}
145+
146+
/// Save installed packages to the cache directory
147+
fn save_to_cache(system_info: &SystemInfo, cache_dir: &Path, packages: &[&str]) -> Result<()> {
148+
if !is_system_compatible(system_info) {
149+
warn!("Caching of installed package is not supported on this system, skipping");
150+
return Ok(());
151+
}
152+
153+
debug!(
154+
"Saving installed packages to cache: {}",
155+
cache_dir.display()
156+
);
157+
158+
// Create cache directory if it doesn't exist
159+
std::fs::create_dir_all(cache_dir).context("Failed to create cache directory")?;
160+
161+
let cache_dir_str = cache_dir
162+
.to_str()
163+
.ok_or_else(|| anyhow!("Invalid cache directory path"))?;
164+
165+
// Logic taken from https://stackoverflow.com/a/59277514
166+
// This shell command lists all the files outputted by the given packages and copy them to the cache directory
167+
let packages_str = packages.join(" ");
168+
let shell_cmd = format!(
169+
"dpkg -L {packages_str} | while IFS= read -r f; do if test -f \"$f\"; then echo \"$f\"; fi; done | xargs cp --parents --target-directory {cache_dir_str}",
170+
);
171+
172+
debug!("Running cache save command: {shell_cmd}");
173+
174+
let output = Command::new("sh")
175+
.arg("-c")
176+
.arg(&shell_cmd)
177+
.output()
178+
.context("Failed to execute cache save command")?;
179+
180+
if !output.status.success() {
181+
let stderr = String::from_utf8_lossy(&output.stderr);
182+
error!("stderr: {stderr}");
183+
bail!("Failed to save packages to cache");
184+
}
185+
186+
debug!("Packages cached successfully");
187+
Ok(())
188+
}

src/run/runner/helpers/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
pub mod apt;
12
pub mod env;
23
pub mod get_bench_command;
34
pub mod introspected_golang;
45
pub mod introspected_nodejs;
56
pub mod profile_folder;
67
pub mod run_command_with_log_pipe;
7-
pub mod setup;
8+
pub mod run_with_sudo;
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use crate::prelude::*;
2-
use log::{debug, info};
32
use std::process::{Command, Stdio};
43

54
/// Run a command with sudo if available

0 commit comments

Comments
 (0)