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    /// Whether the Git checkout cache is withheld whatever `caches.toml` says.
48    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    /// `config_file` is `caches.toml`; `fallback` is
62    /// [`dependency_cache::platform_fallback_root`].
63    #[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    /// Withholds the Git checkout cache.
78    ///
79    /// Preparing and capturing it runs `git` in this process over a checkout
80    /// the job wrote. When the job runs as another, unprivileged account, that
81    /// would let it hand this process repository configuration to act on --
82    /// `core.fsmonitor` names a command -- so the cache is not offered at all.
83    #[must_use]
84    pub fn without_git(mut self) -> Self {
85        self.without_git = true;
86        self
87    }
88
89    /// The settings, read now.
90    ///
91    /// # Errors
92    /// A file that cannot be read or parsed, as a launch failure that names
93    /// the file and the command that fixes it.
94    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    /// The cache root under `config`.
108    ///
109    /// # Errors
110    /// Why no root resolves.
111    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    /// The variables a native runner of `target` whose attempt is at `runtime`
120    /// starts with, after leasing its slot and creating its directories.
121    ///
122    /// A tool any of whose variables `host_env` or the service's own
123    /// environment already sets is left out entirely, so an operator's setting
124    /// is never mixed with this one. Never fails: `prepare` already refused a
125    /// broken file, and anything wrong now costs the job its caches, not its
126    /// runner.
127    #[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    /// Refresh the object snapshot after native runner termination. Cache
210    /// failures never prevent ordinary workspace cleanup, and switches changed
211    /// while a job ran are respected before any publication.
212    pub fn capture_git(&self, runtime: &Path) {
213        // A partial cleanup retry may no longer own a cache lease. It must not
214        // race pruning to publish a snapshot after the runner package is gone.
215        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            // Re-pin the effective namespace if root/namespace settings changed
235            // while the job ran. This takes the global lock only for the lease,
236            // never for the potentially expensive Git copy.
237            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    /// The service's scheduled pass: measures the cache root and prunes it to
261    /// the configured cap. Does nothing when caches are off, no root resolves
262    /// or the root does not exist yet.
263    pub fn prune_scheduled(&self) {
264        let _ = self.prune_guarded(true);
265    }
266
267    /// Carries out an operator's `host cache prune` that their account could
268    /// not, and answers it in [`dependency_cache::PRUNE_RESULT_FILE`] under
269    /// the identifier `request` carried.
270    ///
271    /// A request is carried out whether or not caches are on, exactly as the
272    /// command does when its account can write the root itself; only the
273    /// scheduled pass respects the switch.
274    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
341/// Holds [`PRUNING`] for one pass, and lets go of it however the pass ends --
342/// a panic included, which would otherwise stop every later pass for good.
343struct 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        // `prepare` refuses a broken file before registration; one broken
456        // after it costs the job its caches, not its runner.
457        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    /// An operator's `runner.env` takes a whole tool, in any letter case.
464    #[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}