use std::ffi::OsString;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use runner_manager_domain::attempt::FailureReason;
use runner_manager_domain::model::ScaleTarget;
use runner_manager_platform::dependency_cache::{
self, CacheConfig, CacheUsage, ResolvedRoot, runtime_holds_runner,
};
use runner_manager_platform::runner_env::{RunnerEnv, RunnerPlatform};
static PRUNING: AtomicBool = AtomicBool::new(false);
pub type RunnerRootSource = Arc<dyn Fn() -> Option<PathBuf> + Send + Sync>;
#[derive(Clone)]
pub struct DependencyCaches {
config_file: PathBuf,
runner_root: RunnerRootSource,
fallback: Option<PathBuf>,
}
impl fmt::Debug for DependencyCaches {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DependencyCaches")
.field("config_file", &self.config_file)
.field("fallback", &self.fallback)
.finish_non_exhaustive()
}
}
impl DependencyCaches {
#[must_use]
pub fn new(
config_file: PathBuf,
runner_root: RunnerRootSource,
fallback: Option<PathBuf>,
) -> Self {
Self {
config_file,
runner_root,
fallback,
}
}
pub fn config(&self) -> Result<CacheConfig, FailureReason> {
CacheConfig::load(&self.config_file).map_err(|error| {
tracing::warn!(
reason = "dependency_cache_config_invalid",
"caches.toml could not be applied, so no runner starts until it is fixed: {error}"
);
FailureReason::Other(format!(
"caches.toml cannot be applied ({error}); fix it with `runner-manager host cache` \
or delete the file"
))
})
}
pub fn root(&self, config: &CacheConfig) -> Result<ResolvedRoot, String> {
dependency_cache::resolve_root(
config,
(self.runner_root)().as_deref(),
self.fallback.clone(),
)
}
#[must_use]
pub fn for_launch(
&self,
target: &ScaleTarget,
runtime: &Path,
host_env: &RunnerEnv,
) -> Vec<(&'static str, OsString)> {
let config = match CacheConfig::load(&self.config_file) {
Ok(config) => config,
Err(error) => {
tracing::warn!(
reason = "dependency_cache_config_invalid",
"starting the runner without dependency caches: {error}"
);
return Vec::new();
}
};
let platform = RunnerPlatform::current();
let mut selection = dependency_cache::select(&config, target, platform);
if selection.disabled.is_some() {
return Vec::new();
}
selection.defer_to_operator(platform, |name| {
dependency_cache::inherited(name)
|| host_env
.entries()
.any(|(set, _)| set.eq_ignore_ascii_case(name))
});
let root = match self.root(&config) {
Ok(root) => root,
Err(why) => {
tracing::warn!(
reason = "dependency_cache_root_unresolved",
"starting the runner without dependency caches: {why}"
);
return Vec::new();
}
};
match dependency_cache::prepare_launch(
&root.path,
&selection,
runtime,
platform,
&runtime_holds_runner,
) {
Ok(launch) => launch.variables,
Err(error) => {
tracing::warn!(
reason = "dependency_cache_unavailable",
"starting the runner without dependency caches: {error}"
);
Vec::new()
}
}
}
#[must_use]
pub fn prune_once(&self) -> Option<CacheUsage> {
if PRUNING.swap(true, Ordering::AcqRel) {
return None;
}
let usage = self.prune_unguarded();
PRUNING.store(false, Ordering::Release);
usage
}
fn prune_unguarded(&self) -> Option<CacheUsage> {
let config = CacheConfig::load(&self.config_file).ok()?;
if !config.host_enabled() {
return None;
}
let root = self.root(&config).ok()?;
if !root.path.is_dir() {
return None;
}
match dependency_cache::prune(&root.path, config.max_bytes(), &runtime_holds_runner) {
Ok(usage) => {
if !usage.pruned.is_empty() {
tracing::info!(
reason = "dependency_cache_pruned",
"removed {} least-recently-used dependency cache namespace(s) to stay \
under the cap: {}",
usage.pruned.len(),
usage.pruned.join(", ")
);
}
if usage.max_bytes.is_some_and(|max| usage.total_bytes > max) {
tracing::warn!(
reason = "dependency_cache_over_cap",
"the dependency caches use {} against a cap of {}, and every remaining \
namespace is in use; raise it with `host cache set-max-size`",
dependency_cache::human_bytes(usage.total_bytes),
dependency_cache::human_bytes(usage.max_bytes.unwrap_or_default())
);
}
Some(usage)
}
Err(error) => {
tracing::warn!(
reason = "dependency_cache_prune_failed",
"the dependency caches could not be pruned: {error}"
);
None
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn caches(dir: &Path, runner_root: Option<PathBuf>) -> DependencyCaches {
DependencyCaches::new(
dependency_cache::config_path_in(dir),
Arc::new(move || runner_root.clone()),
None,
)
}
#[test]
fn a_repository_launch_gets_variables_under_the_runner_roots_cache() {
let dir = tempfile::tempdir().unwrap();
let runner_root = dir.path().join("rman");
let runtime = runner_root.join("a1b2c3d4");
std::fs::create_dir_all(runtime.join("bin")).unwrap();
let target = ScaleTarget::repository("Octo/Repo").unwrap();
let variables = caches(dir.path(), Some(runner_root.clone())).for_launch(
&target,
&runtime,
&RunnerEnv::default(),
);
let npm = variables
.iter()
.find(|(name, _)| *name == "npm_config_cache")
.map(|(_, value)| PathBuf::from(value))
.unwrap();
assert_eq!(
npm,
runner_root
.join("_cache")
.join("octo")
.join("repo")
.join("npm")
);
assert!(npm.is_dir());
}
#[test]
fn an_organization_a_disabled_host_or_a_broken_file_gets_nothing() {
let dir = tempfile::tempdir().unwrap();
let runtime = dir.path().join("a1");
let none = RunnerEnv::default();
let caches = caches(dir.path(), Some(dir.path().to_path_buf()));
let org = ScaleTarget::organization("acme").unwrap();
assert!(caches.for_launch(&org, &runtime, &none).is_empty());
let path = dependency_cache::config_path_in(dir.path());
std::fs::write(&path, "enabled = false\n").unwrap();
let repo = ScaleTarget::repository("o/r").unwrap();
assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
std::fs::write(&path, "[tools]\nnope = true\n").unwrap();
assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
let failure = caches.config().unwrap_err().to_string();
assert!(failure.contains("caches.toml"), "{failure}");
}
#[test]
fn runner_env_takes_the_whole_tool_whatever_the_case() {
let dir = tempfile::tempdir().unwrap();
let runtime = dir.path().join("rman").join("a1");
std::fs::create_dir_all(runtime.join("bin")).unwrap();
let caches = caches(dir.path(), Some(dir.path().join("rman")));
let repo = ScaleTarget::repository("o/r").unwrap();
let host_env =
RunnerEnv::parse("NPM_CONFIG_STORE_DIR=/operator/pnpm\nRUNNER_TOOL_CACHE=/tc\n")
.unwrap();
let names: Vec<&str> = caches
.for_launch(&repo, &runtime, &host_env)
.into_iter()
.map(|(name, _)| name)
.collect();
for gone in [
"npm_config_store_dir",
"PNPM_CONFIG_STORE_DIR",
"RUNNER_TOOL_CACHE",
"AGENT_TOOLSDIRECTORY",
] {
assert!(
!names.contains(&gone),
"{gone} was set beside the operator's"
);
}
assert!(names.contains(&"npm_config_cache"));
}
#[test]
fn no_root_means_no_caches_rather_than_no_runner() {
let dir = tempfile::tempdir().unwrap();
let caches = caches(dir.path(), Some(PathBuf::from("/has space/rman")));
let repo = ScaleTarget::repository("o/r").unwrap();
assert!(
caches
.for_launch(&repo, &dir.path().join("a"), &RunnerEnv::default())
.is_empty()
);
}
}