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) => 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 pub fn prune_scheduled(&self) {
172 let _ = self.prune_guarded(true);
173 }
174
175 pub fn answer_prune_request(&self, state_dir: &Path, request: String) {
183 let outcome = self
184 .prune_guarded(false)
185 .and_then(|usage| usage.ok_or_else(|| "caches are off".to_owned()));
186 if let Err(error) = (PruneResult { request, outcome }).write(state_dir) {
187 tracing::warn!(
188 reason = "dependency_cache_prune_unanswered",
189 "an operator's prune request could not be answered: {error}"
190 );
191 }
192 }
193
194 fn prune_guarded(&self, only_when_enabled: bool) -> Result<Option<CacheUsage>, String> {
195 let Some(_pruning) = PruningGuard::take() else {
196 return Err(
197 "another prune is still running in the service; run the command again in a moment"
198 .to_owned(),
199 );
200 };
201 self.prune_unguarded(only_when_enabled)
202 }
203
204 fn prune_unguarded(&self, only_when_enabled: bool) -> Result<Option<CacheUsage>, String> {
205 let config = CacheConfig::load(&self.config_file).map_err(|error| error.to_string())?;
206 if only_when_enabled && !config.host_enabled() {
207 return Ok(None);
208 }
209 let root = self.root(&config)?;
210 if !root.path.is_dir() {
211 return Err(format!(
212 "{} does not exist yet; there is nothing to prune",
213 root.path.display()
214 ));
215 }
216 match dependency_cache::prune(&root.path, config.max_bytes(), &runtime_holds_runner) {
217 Ok(usage) => {
218 if !usage.pruned.is_empty() {
219 tracing::info!(
220 reason = "dependency_cache_pruned",
221 "removed {} least-recently-used dependency cache namespace(s) to stay \
222 under the cap: {}",
223 usage.pruned.len(),
224 usage.pruned.join(", ")
225 );
226 }
227 if usage.max_bytes.is_some_and(|max| usage.total_bytes > max) {
228 tracing::warn!(
229 reason = "dependency_cache_over_cap",
230 "the dependency caches use {} against a cap of {}, and every remaining \
231 namespace is in use; raise it with `host cache set-max-size`",
232 dependency_cache::human_bytes(usage.total_bytes),
233 dependency_cache::human_bytes(usage.max_bytes.unwrap_or_default())
234 );
235 }
236 Ok(Some(usage))
237 }
238 Err(error) => {
239 tracing::warn!(
240 reason = "dependency_cache_prune_failed",
241 "the dependency caches could not be pruned: {error}"
242 );
243 Err(error.to_string())
244 }
245 }
246 }
247}
248
249struct PruningGuard;
252
253impl PruningGuard {
254 fn take() -> Option<Self> {
255 (!PRUNING.swap(true, Ordering::AcqRel)).then_some(Self)
256 }
257}
258
259impl Drop for PruningGuard {
260 fn drop(&mut self) {
261 PRUNING.store(false, Ordering::Release);
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 fn caches(dir: &Path, runner_root: Option<PathBuf>) -> DependencyCaches {
270 DependencyCaches::new(
271 dependency_cache::config_path_in(dir),
272 Arc::new(move || runner_root.clone()),
273 None,
274 )
275 }
276
277 #[test]
278 fn an_operators_prune_request_is_carried_out_even_with_caches_off() {
279 let dir = tempfile::tempdir().unwrap();
280 let state = dir.path().join("state");
281 std::fs::create_dir_all(&state).unwrap();
282 let root = dir.path().join("_cache");
283 std::fs::create_dir_all(root.join("octo")).unwrap();
284 let caches = caches(dir.path(), Some(dir.path().to_path_buf()));
285 let config = CacheConfig {
286 enabled: Some(false),
287 ..CacheConfig::default()
288 };
289 config
290 .save(&dependency_cache::config_path_in(dir.path()))
291 .unwrap();
292
293 caches.prune_scheduled();
294 assert!(
295 CacheUsage::read(&root).is_none(),
296 "the scheduled pass respects caches being off"
297 );
298
299 caches.answer_prune_request(&state, "request-1".to_owned());
300 let answer = PruneResult::read_for(&state, "request-1").expect("an answer");
301 let usage = answer
302 .outcome
303 .expect("the operator's request is carried out");
304 assert_eq!(
305 CacheUsage::read(&root),
306 Some(usage),
307 "and the answer is the measurement it recorded"
308 );
309
310 std::fs::remove_dir_all(&root).unwrap();
311 caches.answer_prune_request(&state, "request-2".to_owned());
312 let answer = PruneResult::read_for(&state, "request-2").expect("an answer");
313 assert!(
314 answer
315 .outcome
316 .is_err_and(|why| why.contains("does not exist")),
317 "a pass that cannot run says why instead of leaving the command waiting"
318 );
319 }
320
321 #[test]
322 fn a_repository_launch_gets_variables_under_the_runner_roots_cache() {
323 let dir = tempfile::tempdir().unwrap();
324 let runner_root = dir.path().join("rman");
325 let runtime = runner_root.join("a1b2c3d4");
326 std::fs::create_dir_all(runtime.join("bin")).unwrap();
327 let target = ScaleTarget::repository("Octo/Repo").unwrap();
328 let variables = caches(dir.path(), Some(runner_root.clone())).for_launch(
329 &target,
330 &runtime,
331 &RunnerEnv::default(),
332 );
333 let npm = variables
334 .iter()
335 .find(|(name, _)| *name == "npm_config_cache")
336 .map(|(_, value)| PathBuf::from(value))
337 .unwrap();
338 assert_eq!(
339 npm,
340 runner_root
341 .join("_cache")
342 .join("octo")
343 .join("repo")
344 .join("npm")
345 );
346 assert!(npm.is_dir());
347 }
348
349 #[test]
350 fn an_organization_a_disabled_host_or_a_broken_file_gets_nothing() {
351 let dir = tempfile::tempdir().unwrap();
352 let runtime = dir.path().join("a1");
353 let none = RunnerEnv::default();
354 let caches = caches(dir.path(), Some(dir.path().to_path_buf()));
355 let org = ScaleTarget::organization("acme").unwrap();
356 assert!(caches.for_launch(&org, &runtime, &none).is_empty());
357
358 let path = dependency_cache::config_path_in(dir.path());
359 std::fs::write(&path, "enabled = false\n").unwrap();
360 let repo = ScaleTarget::repository("o/r").unwrap();
361 assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
362
363 std::fs::write(&path, "[tools]\nnope = true\n").unwrap();
366 assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
367 let failure = caches.config().unwrap_err().to_string();
368 assert!(failure.contains("caches.toml"), "{failure}");
369 }
370
371 #[test]
373 fn runner_env_takes_the_whole_tool_whatever_the_case() {
374 let dir = tempfile::tempdir().unwrap();
375 let runtime = dir.path().join("rman").join("a1");
376 std::fs::create_dir_all(runtime.join("bin")).unwrap();
377 let caches = caches(dir.path(), Some(dir.path().join("rman")));
378 let repo = ScaleTarget::repository("o/r").unwrap();
379 let host_env =
380 RunnerEnv::parse("NPM_CONFIG_STORE_DIR=/operator/pnpm\nRUNNER_TOOL_CACHE=/tc\n")
381 .unwrap();
382 let names: Vec<&str> = caches
383 .for_launch(&repo, &runtime, &host_env)
384 .into_iter()
385 .map(|(name, _)| name)
386 .collect();
387 for gone in [
388 "npm_config_store_dir",
389 "PNPM_CONFIG_STORE_DIR",
390 "RUNNER_TOOL_CACHE",
391 "AGENT_TOOLSDIRECTORY",
392 ] {
393 assert!(
394 !names.contains(&gone),
395 "{gone} was set beside the operator's"
396 );
397 }
398 assert!(names.contains(&"npm_config_cache"));
399 }
400
401 #[test]
402 fn no_root_means_no_caches_rather_than_no_runner() {
403 let dir = tempfile::tempdir().unwrap();
404 let caches = caches(dir.path(), Some(PathBuf::from("/has space/rman")));
405 let repo = ScaleTarget::repository("o/r").unwrap();
406 assert!(
407 caches
408 .for_launch(&repo, &dir.path().join("a"), &RunnerEnv::default())
409 .is_empty()
410 );
411 }
412}