1use std::ffi::OsString;
20use std::fmt;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::sync::atomic::{AtomicBool, Ordering};
24
25use runner_manager_domain::attempt::FailureReason;
26use runner_manager_domain::model::ScaleTarget;
27use runner_manager_platform::dependency_cache::{
28 self, CacheConfig, CacheUsage, ResolvedRoot, runtime_holds_runner,
29};
30use runner_manager_platform::runner_env::{RunnerEnv, RunnerPlatform};
31
32static PRUNING: AtomicBool = AtomicBool::new(false);
36
37pub type RunnerRootSource = Arc<dyn Fn() -> Option<PathBuf> + Send + Sync>;
40
41#[derive(Clone)]
43pub struct DependencyCaches {
44 config_file: PathBuf,
45 runner_root: RunnerRootSource,
46 fallback: Option<PathBuf>,
47}
48
49impl fmt::Debug for DependencyCaches {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 f.debug_struct("DependencyCaches")
52 .field("config_file", &self.config_file)
53 .field("fallback", &self.fallback)
54 .finish_non_exhaustive()
55 }
56}
57
58impl DependencyCaches {
59 #[must_use]
62 pub fn new(
63 config_file: PathBuf,
64 runner_root: RunnerRootSource,
65 fallback: Option<PathBuf>,
66 ) -> Self {
67 Self {
68 config_file,
69 runner_root,
70 fallback,
71 }
72 }
73
74 pub fn config(&self) -> Result<CacheConfig, FailureReason> {
80 CacheConfig::load(&self.config_file).map_err(|error| {
81 tracing::warn!(
82 reason = "dependency_cache_config_invalid",
83 "caches.toml could not be applied, so no runner starts until it is fixed: {error}"
84 );
85 FailureReason::Other(format!(
86 "caches.toml cannot be applied ({error}); fix it with `runner-manager host cache` \
87 or delete the file"
88 ))
89 })
90 }
91
92 pub fn root(&self, config: &CacheConfig) -> Result<ResolvedRoot, String> {
97 dependency_cache::resolve_root(
98 config,
99 (self.runner_root)().as_deref(),
100 self.fallback.clone(),
101 )
102 }
103
104 #[must_use]
113 pub fn for_launch(
114 &self,
115 target: &ScaleTarget,
116 runtime: &Path,
117 host_env: &RunnerEnv,
118 ) -> Vec<(&'static str, OsString)> {
119 let config = match CacheConfig::load(&self.config_file) {
120 Ok(config) => config,
121 Err(error) => {
122 tracing::warn!(
123 reason = "dependency_cache_config_invalid",
124 "starting the runner without dependency caches: {error}"
125 );
126 return Vec::new();
127 }
128 };
129 let platform = RunnerPlatform::current();
130 let mut selection = dependency_cache::select(&config, target, platform);
131 if selection.disabled.is_some() {
132 return Vec::new();
133 }
134 selection.defer_to_operator(platform, |name| {
135 dependency_cache::inherited(name)
136 || host_env
137 .entries()
138 .any(|(set, _)| set.eq_ignore_ascii_case(name))
139 });
140 let root = match self.root(&config) {
141 Ok(root) => root,
142 Err(why) => {
143 tracing::warn!(
144 reason = "dependency_cache_root_unresolved",
145 "starting the runner without dependency caches: {why}"
146 );
147 return Vec::new();
148 }
149 };
150 match dependency_cache::prepare_launch(
151 &root.path,
152 &selection,
153 runtime,
154 platform,
155 &runtime_holds_runner,
156 ) {
157 Ok(launch) => launch.variables,
158 Err(error) => {
159 tracing::warn!(
160 reason = "dependency_cache_unavailable",
161 "starting the runner without dependency caches: {error}"
162 );
163 Vec::new()
164 }
165 }
166 }
167
168 #[must_use]
171 pub fn prune_once(&self) -> Option<CacheUsage> {
172 if PRUNING.swap(true, Ordering::AcqRel) {
173 return None;
174 }
175 let usage = self.prune_unguarded();
176 PRUNING.store(false, Ordering::Release);
177 usage
178 }
179
180 fn prune_unguarded(&self) -> Option<CacheUsage> {
181 let config = CacheConfig::load(&self.config_file).ok()?;
182 if !config.host_enabled() {
183 return None;
184 }
185 let root = self.root(&config).ok()?;
186 if !root.path.is_dir() {
187 return None;
188 }
189 match dependency_cache::prune(&root.path, config.max_bytes(), &runtime_holds_runner) {
190 Ok(usage) => {
191 if !usage.pruned.is_empty() {
192 tracing::info!(
193 reason = "dependency_cache_pruned",
194 "removed {} least-recently-used dependency cache namespace(s) to stay \
195 under the cap: {}",
196 usage.pruned.len(),
197 usage.pruned.join(", ")
198 );
199 }
200 if usage.max_bytes.is_some_and(|max| usage.total_bytes > max) {
201 tracing::warn!(
202 reason = "dependency_cache_over_cap",
203 "the dependency caches use {} against a cap of {}, and every remaining \
204 namespace is in use; raise it with `host cache set-max-size`",
205 dependency_cache::human_bytes(usage.total_bytes),
206 dependency_cache::human_bytes(usage.max_bytes.unwrap_or_default())
207 );
208 }
209 Some(usage)
210 }
211 Err(error) => {
212 tracing::warn!(
213 reason = "dependency_cache_prune_failed",
214 "the dependency caches could not be pruned: {error}"
215 );
216 None
217 }
218 }
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 fn caches(dir: &Path, runner_root: Option<PathBuf>) -> DependencyCaches {
227 DependencyCaches::new(
228 dependency_cache::config_path_in(dir),
229 Arc::new(move || runner_root.clone()),
230 None,
231 )
232 }
233
234 #[test]
235 fn a_repository_launch_gets_variables_under_the_runner_roots_cache() {
236 let dir = tempfile::tempdir().unwrap();
237 let runner_root = dir.path().join("rman");
238 let runtime = runner_root.join("a1b2c3d4");
239 std::fs::create_dir_all(runtime.join("bin")).unwrap();
240 let target = ScaleTarget::repository("Octo/Repo").unwrap();
241 let variables = caches(dir.path(), Some(runner_root.clone())).for_launch(
242 &target,
243 &runtime,
244 &RunnerEnv::default(),
245 );
246 let npm = variables
247 .iter()
248 .find(|(name, _)| *name == "npm_config_cache")
249 .map(|(_, value)| PathBuf::from(value))
250 .unwrap();
251 assert_eq!(
252 npm,
253 runner_root
254 .join("_cache")
255 .join("octo")
256 .join("repo")
257 .join("npm")
258 );
259 assert!(npm.is_dir());
260 }
261
262 #[test]
263 fn an_organization_a_disabled_host_or_a_broken_file_gets_nothing() {
264 let dir = tempfile::tempdir().unwrap();
265 let runtime = dir.path().join("a1");
266 let none = RunnerEnv::default();
267 let caches = caches(dir.path(), Some(dir.path().to_path_buf()));
268 let org = ScaleTarget::organization("acme").unwrap();
269 assert!(caches.for_launch(&org, &runtime, &none).is_empty());
270
271 let path = dependency_cache::config_path_in(dir.path());
272 std::fs::write(&path, "enabled = false\n").unwrap();
273 let repo = ScaleTarget::repository("o/r").unwrap();
274 assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
275
276 std::fs::write(&path, "[tools]\nnope = true\n").unwrap();
279 assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
280 let failure = caches.config().unwrap_err().to_string();
281 assert!(failure.contains("caches.toml"), "{failure}");
282 }
283
284 #[test]
286 fn runner_env_takes_the_whole_tool_whatever_the_case() {
287 let dir = tempfile::tempdir().unwrap();
288 let runtime = dir.path().join("rman").join("a1");
289 std::fs::create_dir_all(runtime.join("bin")).unwrap();
290 let caches = caches(dir.path(), Some(dir.path().join("rman")));
291 let repo = ScaleTarget::repository("o/r").unwrap();
292 let host_env =
293 RunnerEnv::parse("NPM_CONFIG_STORE_DIR=/operator/pnpm\nRUNNER_TOOL_CACHE=/tc\n")
294 .unwrap();
295 let names: Vec<&str> = caches
296 .for_launch(&repo, &runtime, &host_env)
297 .into_iter()
298 .map(|(name, _)| name)
299 .collect();
300 for gone in [
301 "npm_config_store_dir",
302 "PNPM_CONFIG_STORE_DIR",
303 "RUNNER_TOOL_CACHE",
304 "AGENT_TOOLSDIRECTORY",
305 ] {
306 assert!(
307 !names.contains(&gone),
308 "{gone} was set beside the operator's"
309 );
310 }
311 assert!(names.contains(&"npm_config_cache"));
312 }
313
314 #[test]
315 fn no_root_means_no_caches_rather_than_no_runner() {
316 let dir = tempfile::tempdir().unwrap();
317 let caches = caches(dir.path(), Some(PathBuf::from("/has space/rman")));
318 let repo = ScaleTarget::repository("o/r").unwrap();
319 assert!(
320 caches
321 .for_launch(&repo, &dir.path().join("a"), &RunnerEnv::default())
322 .is_empty()
323 );
324 }
325}