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, PruneResult, 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) => {
158 if matches!(target, ScaleTarget::Repository(_))
159 && let Some((_, git_dir)) = launch
160 .variables
161 .iter()
162 .find(|(name, _)| *name == runner_manager_platform::git_cache::ENVIRONMENT)
163 {
164 match runner_manager_platform::git_cache::prepare(
165 &root.path,
166 Path::new(git_dir),
167 target,
168 runtime,
169 ) {
170 Ok(outcome) => {
171 tracing::info!(event = "git_cache_prepare", reason = ?outcome, "Git checkout cache prepared")
172 }
173 Err(_) => tracing::warn!(
174 event = "git_cache_unavailable",
175 "starting the runner without a Git checkout seed"
176 ),
177 }
178 }
179 launch.variables
180 }
181 Err(error) => {
182 tracing::warn!(
183 reason = "dependency_cache_unavailable",
184 "starting the runner without dependency caches: {error}"
185 );
186 Vec::new()
187 }
188 }
189 }
190
191 pub fn capture_git(&self, runtime: &Path) {
195 if !runtime_holds_runner(runtime) {
198 return;
199 }
200 let result = (|| -> Result<(), String> {
201 let Some(target) = runner_manager_platform::git_cache::target_for_runtime(runtime)
202 .map_err(|error| error.to_string())?
203 else {
204 return Ok(());
205 };
206 let config = CacheConfig::load(&self.config_file).map_err(|error| error.to_string())?;
207 let platform = RunnerPlatform::current();
208 let mut selection = dependency_cache::select(&config, &target, platform);
209 if selection.disabled.is_some()
210 || !selection.enabled_tools().any(|tool| tool.id == "git")
211 {
212 return Ok(());
213 }
214 let root = self.root(&config)?;
215 selection.tools.retain(|state| state.tool.id == "git");
216 dependency_cache::prepare_launch(
220 &root.path,
221 &selection,
222 runtime,
223 platform,
224 &runtime_holds_runner,
225 )
226 .map_err(|error| error.to_string())?;
227 let git_dir = selection.namespace.dir(&root.path).join("git");
228 let outcome =
229 runner_manager_platform::git_cache::capture(&root.path, &git_dir, &target, runtime)
230 .map_err(|error| error.to_string())?;
231 tracing::info!(event = "git_cache_capture", reason = ?outcome, "Git checkout cache publication finished");
232 Ok(())
233 })();
234 if result.is_err() {
235 tracing::warn!(
236 event = "git_cache_capture_skipped",
237 "Git checkout cache could not be refreshed; ordinary workspace cleanup continues"
238 );
239 }
240 }
241
242 pub fn prune_scheduled(&self) {
246 let _ = self.prune_guarded(true);
247 }
248
249 pub fn answer_prune_request(&self, state_dir: &Path, request: String) {
257 let outcome = self
258 .prune_guarded(false)
259 .and_then(|usage| usage.ok_or_else(|| "caches are off".to_owned()));
260 if let Err(error) = (PruneResult { request, outcome }).write(state_dir) {
261 tracing::warn!(
262 reason = "dependency_cache_prune_unanswered",
263 "an operator's prune request could not be answered: {error}"
264 );
265 }
266 }
267
268 fn prune_guarded(&self, only_when_enabled: bool) -> Result<Option<CacheUsage>, String> {
269 let Some(_pruning) = PruningGuard::take() else {
270 return Err(
271 "another prune is still running in the service; run the command again in a moment"
272 .to_owned(),
273 );
274 };
275 self.prune_unguarded(only_when_enabled)
276 }
277
278 fn prune_unguarded(&self, only_when_enabled: bool) -> Result<Option<CacheUsage>, String> {
279 let config = CacheConfig::load(&self.config_file).map_err(|error| error.to_string())?;
280 if only_when_enabled && !config.host_enabled() {
281 return Ok(None);
282 }
283 let root = self.root(&config)?;
284 if !root.path.is_dir() {
285 return Err(format!(
286 "{} does not exist yet; there is nothing to prune",
287 root.path.display()
288 ));
289 }
290 match dependency_cache::prune(&root.path, config.max_bytes(), &runtime_holds_runner) {
291 Ok(usage) => {
292 if !usage.pruned.is_empty() {
293 tracing::info!(
294 reason = "dependency_cache_pruned",
295 "removed {} least-recently-used dependency cache namespace(s) to stay \
296 under the cap: {}",
297 usage.pruned.len(),
298 usage.pruned.join(", ")
299 );
300 }
301 if usage.max_bytes.is_some_and(|max| usage.total_bytes > max) {
302 tracing::warn!(
303 reason = "dependency_cache_over_cap",
304 "the dependency caches use {} against a cap of {}, and every remaining \
305 namespace is in use; raise it with `host cache set-max-size`",
306 dependency_cache::human_bytes(usage.total_bytes),
307 dependency_cache::human_bytes(usage.max_bytes.unwrap_or_default())
308 );
309 }
310 Ok(Some(usage))
311 }
312 Err(error) => {
313 tracing::warn!(
314 reason = "dependency_cache_prune_failed",
315 "the dependency caches could not be pruned: {error}"
316 );
317 Err(error.to_string())
318 }
319 }
320 }
321}
322
323struct PruningGuard;
326
327impl PruningGuard {
328 fn take() -> Option<Self> {
329 (!PRUNING.swap(true, Ordering::AcqRel)).then_some(Self)
330 }
331}
332
333impl Drop for PruningGuard {
334 fn drop(&mut self) {
335 PRUNING.store(false, Ordering::Release);
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 fn caches(dir: &Path, runner_root: Option<PathBuf>) -> DependencyCaches {
344 DependencyCaches::new(
345 dependency_cache::config_path_in(dir),
346 Arc::new(move || runner_root.clone()),
347 None,
348 )
349 }
350
351 #[test]
352 fn an_operators_prune_request_is_carried_out_even_with_caches_off() {
353 let dir = tempfile::tempdir().unwrap();
354 let state = dir.path().join("state");
355 std::fs::create_dir_all(&state).unwrap();
356 let root = dir.path().join("_cache");
357 std::fs::create_dir_all(root.join("octo")).unwrap();
358 let caches = caches(dir.path(), Some(dir.path().to_path_buf()));
359 let config = CacheConfig {
360 enabled: Some(false),
361 ..CacheConfig::default()
362 };
363 config
364 .save(&dependency_cache::config_path_in(dir.path()))
365 .unwrap();
366
367 caches.prune_scheduled();
368 assert!(
369 CacheUsage::read(&root).is_none(),
370 "the scheduled pass respects caches being off"
371 );
372
373 caches.answer_prune_request(&state, "request-1".to_owned());
374 let answer = PruneResult::read_for(&state, "request-1").expect("an answer");
375 let usage = answer
376 .outcome
377 .expect("the operator's request is carried out");
378 assert_eq!(
379 CacheUsage::read(&root),
380 Some(usage),
381 "and the answer is the measurement it recorded"
382 );
383
384 std::fs::remove_dir_all(&root).unwrap();
385 caches.answer_prune_request(&state, "request-2".to_owned());
386 let answer = PruneResult::read_for(&state, "request-2").expect("an answer");
387 assert!(
388 answer
389 .outcome
390 .is_err_and(|why| why.contains("does not exist")),
391 "a pass that cannot run says why instead of leaving the command waiting"
392 );
393 }
394
395 #[test]
396 fn a_repository_launch_gets_variables_under_the_runner_roots_cache() {
397 let dir = tempfile::tempdir().unwrap();
398 let runner_root = dir.path().join("rman");
399 let runtime = runner_root.join("a1b2c3d4");
400 std::fs::create_dir_all(runtime.join("bin")).unwrap();
401 let target = ScaleTarget::repository("Octo/Repo").unwrap();
402 let variables = caches(dir.path(), Some(runner_root.clone())).for_launch(
403 &target,
404 &runtime,
405 &RunnerEnv::default(),
406 );
407 let npm = variables
408 .iter()
409 .find(|(name, _)| *name == "npm_config_cache")
410 .map(|(_, value)| PathBuf::from(value))
411 .unwrap();
412 assert_eq!(
413 npm,
414 runner_root
415 .join("_cache")
416 .join("octo")
417 .join("repo")
418 .join("npm")
419 );
420 assert!(npm.is_dir());
421 }
422
423 #[test]
424 fn an_organization_a_disabled_host_or_a_broken_file_gets_nothing() {
425 let dir = tempfile::tempdir().unwrap();
426 let runtime = dir.path().join("a1");
427 let none = RunnerEnv::default();
428 let caches = caches(dir.path(), Some(dir.path().to_path_buf()));
429 let org = ScaleTarget::organization("acme").unwrap();
430 assert!(caches.for_launch(&org, &runtime, &none).is_empty());
431
432 let path = dependency_cache::config_path_in(dir.path());
433 std::fs::write(&path, "enabled = false\n").unwrap();
434 let repo = ScaleTarget::repository("o/r").unwrap();
435 assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
436
437 std::fs::write(&path, "[tools]\nnope = true\n").unwrap();
440 assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
441 let failure = caches.config().unwrap_err().to_string();
442 assert!(failure.contains("caches.toml"), "{failure}");
443 }
444
445 #[test]
447 fn runner_env_takes_the_whole_tool_whatever_the_case() {
448 let dir = tempfile::tempdir().unwrap();
449 let runtime = dir.path().join("rman").join("a1");
450 std::fs::create_dir_all(runtime.join("bin")).unwrap();
451 let caches = caches(dir.path(), Some(dir.path().join("rman")));
452 let repo = ScaleTarget::repository("o/r").unwrap();
453 let host_env =
454 RunnerEnv::parse("NPM_CONFIG_STORE_DIR=/operator/pnpm\nRUNNER_TOOL_CACHE=/tc\n")
455 .unwrap();
456 let names: Vec<&str> = caches
457 .for_launch(&repo, &runtime, &host_env)
458 .into_iter()
459 .map(|(name, _)| name)
460 .collect();
461 for gone in [
462 "npm_config_store_dir",
463 "PNPM_CONFIG_STORE_DIR",
464 "RUNNER_TOOL_CACHE",
465 "AGENT_TOOLSDIRECTORY",
466 ] {
467 assert!(
468 !names.contains(&gone),
469 "{gone} was set beside the operator's"
470 );
471 }
472 assert!(names.contains(&"npm_config_cache"));
473 }
474
475 #[test]
476 fn no_root_means_no_caches_rather_than_no_runner() {
477 let dir = tempfile::tempdir().unwrap();
478 let caches = caches(dir.path(), Some(PathBuf::from("/has space/rman")));
479 let repo = ScaleTarget::repository("o/r").unwrap();
480 assert!(
481 caches
482 .for_launch(&repo, &dir.path().join("a"), &RunnerEnv::default())
483 .is_empty()
484 );
485 }
486}