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, 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) => 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    /// Measures the cache root and prunes it to the configured cap. `None`
169    /// when caches are off, no root resolves or the root does not exist yet.
170    #[must_use]
171    pub fn prune_once(&self) -> Option<CacheUsage> {
172        if PRUNING.swap(true, Ordering::AcqRel) {
173            return None;
174        }
175        let usage = self.prune_unguarded();
176        PRUNING.store(false, Ordering::Release);
177        usage
178    }
179
180    fn prune_unguarded(&self) -> Option<CacheUsage> {
181        let config = CacheConfig::load(&self.config_file).ok()?;
182        if !config.host_enabled() {
183            return None;
184        }
185        let root = self.root(&config).ok()?;
186        if !root.path.is_dir() {
187            return None;
188        }
189        match dependency_cache::prune(&root.path, config.max_bytes(), &runtime_holds_runner) {
190            Ok(usage) => {
191                if !usage.pruned.is_empty() {
192                    tracing::info!(
193                        reason = "dependency_cache_pruned",
194                        "removed {} least-recently-used dependency cache namespace(s) to stay \
195                         under the cap: {}",
196                        usage.pruned.len(),
197                        usage.pruned.join(", ")
198                    );
199                }
200                if usage.max_bytes.is_some_and(|max| usage.total_bytes > max) {
201                    tracing::warn!(
202                        reason = "dependency_cache_over_cap",
203                        "the dependency caches use {} against a cap of {}, and every remaining \
204                         namespace is in use; raise it with `host cache set-max-size`",
205                        dependency_cache::human_bytes(usage.total_bytes),
206                        dependency_cache::human_bytes(usage.max_bytes.unwrap_or_default())
207                    );
208                }
209                Some(usage)
210            }
211            Err(error) => {
212                tracing::warn!(
213                    reason = "dependency_cache_prune_failed",
214                    "the dependency caches could not be pruned: {error}"
215                );
216                None
217            }
218        }
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn caches(dir: &Path, runner_root: Option<PathBuf>) -> DependencyCaches {
227        DependencyCaches::new(
228            dependency_cache::config_path_in(dir),
229            Arc::new(move || runner_root.clone()),
230            None,
231        )
232    }
233
234    #[test]
235    fn a_repository_launch_gets_variables_under_the_runner_roots_cache() {
236        let dir = tempfile::tempdir().unwrap();
237        let runner_root = dir.path().join("rman");
238        let runtime = runner_root.join("a1b2c3d4");
239        std::fs::create_dir_all(runtime.join("bin")).unwrap();
240        let target = ScaleTarget::repository("Octo/Repo").unwrap();
241        let variables = caches(dir.path(), Some(runner_root.clone())).for_launch(
242            &target,
243            &runtime,
244            &RunnerEnv::default(),
245        );
246        let npm = variables
247            .iter()
248            .find(|(name, _)| *name == "npm_config_cache")
249            .map(|(_, value)| PathBuf::from(value))
250            .unwrap();
251        assert_eq!(
252            npm,
253            runner_root
254                .join("_cache")
255                .join("octo")
256                .join("repo")
257                .join("npm")
258        );
259        assert!(npm.is_dir());
260    }
261
262    #[test]
263    fn an_organization_a_disabled_host_or_a_broken_file_gets_nothing() {
264        let dir = tempfile::tempdir().unwrap();
265        let runtime = dir.path().join("a1");
266        let none = RunnerEnv::default();
267        let caches = caches(dir.path(), Some(dir.path().to_path_buf()));
268        let org = ScaleTarget::organization("acme").unwrap();
269        assert!(caches.for_launch(&org, &runtime, &none).is_empty());
270
271        let path = dependency_cache::config_path_in(dir.path());
272        std::fs::write(&path, "enabled = false\n").unwrap();
273        let repo = ScaleTarget::repository("o/r").unwrap();
274        assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
275
276        // `prepare` refuses a broken file before registration; one broken
277        // after it costs the job its caches, not its runner.
278        std::fs::write(&path, "[tools]\nnope = true\n").unwrap();
279        assert!(caches.for_launch(&repo, &runtime, &none).is_empty());
280        let failure = caches.config().unwrap_err().to_string();
281        assert!(failure.contains("caches.toml"), "{failure}");
282    }
283
284    /// An operator's `runner.env` takes a whole tool, in any letter case.
285    #[test]
286    fn runner_env_takes_the_whole_tool_whatever_the_case() {
287        let dir = tempfile::tempdir().unwrap();
288        let runtime = dir.path().join("rman").join("a1");
289        std::fs::create_dir_all(runtime.join("bin")).unwrap();
290        let caches = caches(dir.path(), Some(dir.path().join("rman")));
291        let repo = ScaleTarget::repository("o/r").unwrap();
292        let host_env =
293            RunnerEnv::parse("NPM_CONFIG_STORE_DIR=/operator/pnpm\nRUNNER_TOOL_CACHE=/tc\n")
294                .unwrap();
295        let names: Vec<&str> = caches
296            .for_launch(&repo, &runtime, &host_env)
297            .into_iter()
298            .map(|(name, _)| name)
299            .collect();
300        for gone in [
301            "npm_config_store_dir",
302            "PNPM_CONFIG_STORE_DIR",
303            "RUNNER_TOOL_CACHE",
304            "AGENT_TOOLSDIRECTORY",
305        ] {
306            assert!(
307                !names.contains(&gone),
308                "{gone} was set beside the operator's"
309            );
310        }
311        assert!(names.contains(&"npm_config_cache"));
312    }
313
314    #[test]
315    fn no_root_means_no_caches_rather_than_no_runner() {
316        let dir = tempfile::tempdir().unwrap();
317        let caches = caches(dir.path(), Some(PathBuf::from("/has space/rman")));
318        let repo = ScaleTarget::repository("o/r").unwrap();
319        assert!(
320            caches
321                .for_launch(&repo, &dir.path().join("a"), &RunnerEnv::default())
322                .is_empty()
323        );
324    }
325}