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 without_git: bool,
49}
50
51impl fmt::Debug for DependencyCaches {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 f.debug_struct("DependencyCaches")
54 .field("config_file", &self.config_file)
55 .field("fallback", &self.fallback)
56 .finish_non_exhaustive()
57 }
58}
59
60impl DependencyCaches {
61 #[must_use]
64 pub fn new(
65 config_file: PathBuf,
66 runner_root: RunnerRootSource,
67 fallback: Option<PathBuf>,
68 ) -> Self {
69 Self {
70 config_file,
71 runner_root,
72 fallback,
73 without_git: false,
74 }
75 }
76
77 #[must_use]
84 pub fn without_git(mut self) -> Self {
85 self.without_git = true;
86 self
87 }
88
89 pub fn config(&self) -> Result<CacheConfig, FailureReason> {
95 CacheConfig::load(&self.config_file).map_err(|error| {
96 tracing::warn!(
97 reason = "dependency_cache_config_invalid",
98 "caches.toml could not be applied, so no runner starts until it is fixed: {error}"
99 );
100 FailureReason::Other(format!(
101 "caches.toml cannot be applied ({error}); fix it with `runner-manager host cache` \
102 or delete the file"
103 ))
104 })
105 }
106
107 pub fn root(&self, config: &CacheConfig) -> Result<ResolvedRoot, String> {
112 dependency_cache::resolve_root(
113 config,
114 (self.runner_root)().as_deref(),
115 self.fallback.clone(),
116 )
117 }
118
119 #[must_use]
128 pub fn for_launch(
129 &self,
130 target: &ScaleTarget,
131 runtime: &Path,
132 host_env: &RunnerEnv,
133 ) -> Vec<(&'static str, OsString)> {
134 let config = match CacheConfig::load(&self.config_file) {
135 Ok(config) => config,
136 Err(error) => {
137 tracing::warn!(
138 reason = "dependency_cache_config_invalid",
139 "starting the runner without dependency caches: {error}"
140 );
141 return Vec::new();
142 }
143 };
144 let platform = RunnerPlatform::current();
145 let mut selection = dependency_cache::select(&config, target, platform);
146 if selection.disabled.is_some() {
147 return Vec::new();
148 }
149 if self.without_git {
150 selection.tools.retain(|state| state.tool.id != "git");
151 }
152 selection.defer_to_operator(platform, |name| {
153 dependency_cache::inherited(name)
154 || host_env
155 .entries()
156 .any(|(set, _)| set.eq_ignore_ascii_case(name))
157 });
158 let root = match self.root(&config) {
159 Ok(root) => root,
160 Err(why) => {
161 tracing::warn!(
162 reason = "dependency_cache_root_unresolved",
163 "starting the runner without dependency caches: {why}"
164 );
165 return Vec::new();
166 }
167 };
168 match dependency_cache::prepare_launch(
169 &root.path,
170 &selection,
171 runtime,
172 platform,
173 &runtime_holds_runner,
174 ) {
175 Ok(launch) => {
176 if matches!(target, ScaleTarget::Repository(_))
177 && let Some((_, git_dir)) = launch
178 .variables
179 .iter()
180 .find(|(name, _)| *name == runner_manager_platform::git_cache::ENVIRONMENT)
181 {
182 match runner_manager_platform::git_cache::prepare(
183 &root.path,
184 Path::new(git_dir),
185 target,
186 runtime,
187 ) {
188 Ok(outcome) => {
189 tracing::info!(event = "git_cache_prepare", reason = ?outcome, "Git checkout cache prepared")
190 }
191 Err(_) => tracing::warn!(
192 event = "git_cache_unavailable",
193 "starting the runner without a Git checkout seed"
194 ),
195 }
196 }
197 launch.variables
198 }
199 Err(error) => {
200 tracing::warn!(
201 reason = "dependency_cache_unavailable",
202 "starting the runner without dependency caches: {error}"
203 );
204 Vec::new()
205 }
206 }
207 }
208
209 pub fn capture_git(&self, runtime: &Path) {
213 if self.without_git || !runtime_holds_runner(runtime) {
216 return;
217 }
218 let result = (|| -> Result<(), String> {
219 let Some(target) = runner_manager_platform::git_cache::target_for_runtime(runtime)
220 .map_err(|error| error.to_string())?
221 else {
222 return Ok(());
223 };
224 let config = CacheConfig::load(&self.config_file).map_err(|error| error.to_string())?;
225 let platform = RunnerPlatform::current();
226 let mut selection = dependency_cache::select(&config, &target, platform);
227 if selection.disabled.is_some()
228 || !selection.enabled_tools().any(|tool| tool.id == "git")
229 {
230 return Ok(());
231 }
232 let root = self.root(&config)?;
233 selection.tools.retain(|state| state.tool.id == "git");
234 dependency_cache::prepare_launch(
238 &root.path,
239 &selection,
240 runtime,
241 platform,
242 &runtime_holds_runner,
243 )
244 .map_err(|error| error.to_string())?;
245 let git_dir = selection.namespace.dir(&root.path).join("git");
246 let outcome =
247 runner_manager_platform::git_cache::capture(&root.path, &git_dir, &target, runtime)
248 .map_err(|error| error.to_string())?;
249 tracing::info!(event = "git_cache_capture", reason = ?outcome, "Git checkout cache publication finished");
250 Ok(())
251 })();
252 if result.is_err() {
253 tracing::warn!(
254 event = "git_cache_capture_skipped",
255 "Git checkout cache could not be refreshed; ordinary workspace cleanup continues"
256 );
257 }
258 }
259
260 pub fn prune_scheduled(&self) {
264 let _ = self.prune_guarded(true);
265 }
266
267 pub fn answer_prune_request(&self, state_dir: &Path, request: String) {
275 let outcome = self
276 .prune_guarded(false)
277 .and_then(|usage| usage.ok_or_else(|| "caches are off".to_owned()));
278 if let Err(error) = (PruneResult { request, outcome }).write(state_dir) {
279 tracing::warn!(
280 reason = "dependency_cache_prune_unanswered",
281 "an operator's prune request could not be answered: {error}"
282 );
283 }
284 }
285
286 fn prune_guarded(&self, only_when_enabled: bool) -> Result<Option<CacheUsage>, String> {
287 let Some(_pruning) = PruningGuard::take() else {
288 return Err(
289 "another prune is still running in the service; run the command again in a moment"
290 .to_owned(),
291 );
292 };
293 self.prune_unguarded(only_when_enabled)
294 }
295
296 fn prune_unguarded(&self, only_when_enabled: bool) -> Result<Option<CacheUsage>, String> {
297 let config = CacheConfig::load(&self.config_file).map_err(|error| error.to_string())?;
298 if only_when_enabled && !config.host_enabled() {
299 return Ok(None);
300 }
301 let root = self.root(&config)?;
302 if !root.path.is_dir() {
303 return Err(format!(
304 "{} does not exist yet; there is nothing to prune",
305 root.path.display()
306 ));
307 }
308 match dependency_cache::prune(&root.path, config.max_bytes(), &runtime_holds_runner) {
309 Ok(usage) => {
310 if !usage.pruned.is_empty() {
311 tracing::info!(
312 reason = "dependency_cache_pruned",
313 "removed {} least-recently-used dependency cache namespace(s) to stay \
314 under the cap: {}",
315 usage.pruned.len(),
316 usage.pruned.join(", ")
317 );
318 }
319 if usage.max_bytes.is_some_and(|max| usage.total_bytes > max) {
320 tracing::warn!(
321 reason = "dependency_cache_over_cap",
322 "the dependency caches use {} against a cap of {}, and every remaining \
323 namespace is in use; raise it with `host cache set-max-size`",
324 dependency_cache::human_bytes(usage.total_bytes),
325 dependency_cache::human_bytes(usage.max_bytes.unwrap_or_default())
326 );
327 }
328 Ok(Some(usage))
329 }
330 Err(error) => {
331 tracing::warn!(
332 reason = "dependency_cache_prune_failed",
333 "the dependency caches could not be pruned: {error}"
334 );
335 Err(error.to_string())
336 }
337 }
338 }
339}
340
341struct PruningGuard;
344
345impl PruningGuard {
346 fn take() -> Option<Self> {
347 (!PRUNING.swap(true, Ordering::AcqRel)).then_some(Self)
348 }
349}
350
351impl Drop for PruningGuard {
352 fn drop(&mut self) {
353 PRUNING.store(false, Ordering::Release);
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 fn caches(dir: &Path, runner_root: Option<PathBuf>) -> DependencyCaches {
362 DependencyCaches::new(
363 dependency_cache::config_path_in(dir),
364 Arc::new(move || runner_root.clone()),
365 None,
366 )
367 }
368
369 #[test]
370 fn an_operators_prune_request_is_carried_out_even_with_caches_off() {
371 let dir = tempfile::tempdir().unwrap();
372 let state = dir.path().join("state");
373 std::fs::create_dir_all(&state).unwrap();
374 let root = dir.path().join("_cache");
375 std::fs::create_dir_all(root.join("octo")).unwrap();
376 let caches = caches(dir.path(), Some(dir.path().to_path_buf()));
377 let config = CacheConfig {
378 enabled: Some(false),
379 ..CacheConfig::default()
380 };
381 config
382 .save(&dependency_cache::config_path_in(dir.path()))
383 .unwrap();
384
385 caches.prune_scheduled();
386 assert!(
387 CacheUsage::read(&root).is_none(),
388 "the scheduled pass respects caches being off"
389 );
390
391 caches.answer_prune_request(&state, "request-1".to_owned());
392 let answer = PruneResult::read_for(&state, "request-1").expect("an answer");
393 let usage = answer
394 .outcome
395 .expect("the operator's request is carried out");
396 assert_eq!(
397 CacheUsage::read(&root),
398 Some(usage),
399 "and the answer is the measurement it recorded"
400 );
401
402 std::fs::remove_dir_all(&root).unwrap();
403 caches.answer_prune_request(&state, "request-2".to_owned());
404 let answer = PruneResult::read_for(&state, "request-2").expect("an answer");
405 assert!(
406 answer
407 .outcome
408 .is_err_and(|why| why.contains("does not exist")),
409 "a pass that cannot run says why instead of leaving the command waiting"
410 );
411 }
412
413 #[test]
414 fn a_repository_launch_gets_variables_under_the_runner_roots_cache() {
415 let dir = tempfile::tempdir().unwrap();
416 let runner_root = dir.path().join("rman");
417 let runtime = runner_root.join("a1b2c3d4");
418 std::fs::create_dir_all(runtime.join("bin")).unwrap();
419 let target = ScaleTarget::repository("Octo/Repo").unwrap();
420 let variables = caches(dir.path(), Some(runner_root.clone())).for_launch(
421 &target,
422 &runtime,
423 &RunnerEnv::default(),
424 );
425 let npm = variables
426 .iter()
427 .find(|(name, _)| *name == "npm_config_cache")
428 .map(|(_, value)| PathBuf::from(value))
429 .unwrap();
430 assert_eq!(
431 npm,
432 runner_root
433 .join("_cache")
434 .join("octo")
435 .join("repo")
436 .join("npm")
437 );
438 assert!(npm.is_dir());
439 }
440
441 #[test]
442 fn an_organization_a_disabled_host_or_a_broken_file_gets_nothing() {
443 let dir = tempfile::tempdir().unwrap();
444 let runtime = dir.path().join("a1");
445 let none = RunnerEnv::default();
446 let caches = caches(dir.path(), Some(dir.path().to_path_buf()));
447 let org = ScaleTarget::organization("acme").unwrap();
448 assert!(caches.for_launch(&org, &runtime, &none).is_empty());
449
450 let path = dependency_cache::config_path_in(dir.path());
451 std::fs::write(&path, "enabled = false\n").unwrap();
452 let repo = ScaleTarget::repository("o/r").unwrap();
453 assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
454
455 std::fs::write(&path, "[tools]\nnope = true\n").unwrap();
458 assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
459 let failure = caches.config().unwrap_err().to_string();
460 assert!(failure.contains("caches.toml"), "{failure}");
461 }
462
463 #[test]
465 fn runner_env_takes_the_whole_tool_whatever_the_case() {
466 let dir = tempfile::tempdir().unwrap();
467 let runtime = dir.path().join("rman").join("a1");
468 std::fs::create_dir_all(runtime.join("bin")).unwrap();
469 let caches = caches(dir.path(), Some(dir.path().join("rman")));
470 let repo = ScaleTarget::repository("o/r").unwrap();
471 let host_env =
472 RunnerEnv::parse("NPM_CONFIG_STORE_DIR=/operator/pnpm\nRUNNER_TOOL_CACHE=/tc\n")
473 .unwrap();
474 let names: Vec<&str> = caches
475 .for_launch(&repo, &runtime, &host_env)
476 .into_iter()
477 .map(|(name, _)| name)
478 .collect();
479 for gone in [
480 "npm_config_store_dir",
481 "PNPM_CONFIG_STORE_DIR",
482 "RUNNER_TOOL_CACHE",
483 "AGENT_TOOLSDIRECTORY",
484 ] {
485 assert!(
486 !names.contains(&gone),
487 "{gone} was set beside the operator's"
488 );
489 }
490 assert!(names.contains(&"npm_config_cache"));
491 }
492
493 #[test]
494 fn no_root_means_no_caches_rather_than_no_runner() {
495 let dir = tempfile::tempdir().unwrap();
496 let caches = caches(dir.path(), Some(PathBuf::from("/has space/rman")));
497 let repo = ScaleTarget::repository("o/r").unwrap();
498 assert!(
499 caches
500 .for_launch(&repo, &dir.path().join("a"), &RunnerEnv::default())
501 .is_empty()
502 );
503 }
504}