Skip to main content

runner_manager_agent/
dependency_caches.rs

1//! The daemon's half of `runner_manager_platform::dependency_cache`: which
2//! variables a native runner gets at launch, and the periodic prune.
3//!
4//! The settings (`caches.toml`) are read at every launch, like `runner.env`,
5//! so `host cache` and `repo cache` reach the next runner without a restart.
6//! A file that does not parse fails the launch in `prepare`, before the runner
7//! is registered with GitHub: it may hold an operator's decision to turn a
8//! repository's caches off, and starting runners without it would silently
9//! reverse that. Anything that goes wrong after registration — the file broken
10//! since, no root, a lock that cannot be taken, a directory that cannot be
11//! created — starts the runner without caches and is logged: a cache makes a
12//! job faster, never possible.
13//!
14//! Isolated runners (OCI, Hyper-V containers) get no caches. Their environment
15//! is the image's and the providers mount no host directory by design, so a
16//! cache would need a bind mount those providers deliberately refuse;
17//! `host cache show` says so for each such policy.
18
19use 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
32/// Set while a prune pass walks the cache root. A daemon reload starts new
33/// maintenance loops while a blocking walk of the old one may still run; two
34/// walks of a pnpm store at once would only double the I/O.
35static PRUNING: AtomicBool = AtomicBool::new(false);
36
37/// The effective host runner root, read when needed: it lives in the journal
38/// and `host set-runtime-root` may change it while the daemon runs.
39pub type RunnerRootSource = Arc<dyn Fn() -> Option<PathBuf> + Send + Sync>;
40
41/// Where the cache settings and the default root come from.
42#[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    /// `config_file` is `caches.toml`; `fallback` is
60    /// [`dependency_cache::platform_fallback_root`].
61    #[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    /// The settings, read now.
75    ///
76    /// # Errors
77    /// A file that cannot be read or parsed, as a launch failure that names
78    /// the file and the command that fixes it.
79    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    /// The cache root under `config`.
93    ///
94    /// # Errors
95    /// Why no root resolves.
96    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    /// The variables a native runner of `target` whose attempt is at `runtime`
105    /// starts with, after leasing its slot and creating its directories.
106    ///
107    /// A tool any of whose variables `host_env` or the service's own
108    /// environment already sets is left out entirely, so an operator's setting
109    /// is never mixed with this one. Never fails: `prepare` already refused a
110    /// broken file, and anything wrong now costs the job its caches, not its
111    /// runner.
112    #[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    /// Refresh the object snapshot after native runner termination. Cache
192    /// failures never prevent ordinary workspace cleanup, and switches changed
193    /// while a job ran are respected before any publication.
194    pub fn capture_git(&self, runtime: &Path) {
195        // A partial cleanup retry may no longer own a cache lease. It must not
196        // race pruning to publish a snapshot after the runner package is gone.
197        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            // Re-pin the effective namespace if root/namespace settings changed
217            // while the job ran. This takes the global lock only for the lease,
218            // never for the potentially expensive Git copy.
219            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    /// The service's scheduled pass: measures the cache root and prunes it to
243    /// the configured cap. Does nothing when caches are off, no root resolves
244    /// or the root does not exist yet.
245    pub fn prune_scheduled(&self) {
246        let _ = self.prune_guarded(true);
247    }
248
249    /// Carries out an operator's `host cache prune` that their account could
250    /// not, and answers it in [`dependency_cache::PRUNE_RESULT_FILE`] under
251    /// the identifier `request` carried.
252    ///
253    /// A request is carried out whether or not caches are on, exactly as the
254    /// command does when its account can write the root itself; only the
255    /// scheduled pass respects the switch.
256    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
323/// Holds [`PRUNING`] for one pass, and lets go of it however the pass ends --
324/// a panic included, which would otherwise stop every later pass for good.
325struct 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        // `prepare` refuses a broken file before registration; one broken
438        // after it costs the job its caches, not its runner.
439        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    /// An operator's `runner.env` takes a whole tool, in any letter case.
446    #[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}