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) => 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    /// The service's scheduled pass: measures the cache root and prunes it to
169    /// the configured cap. Does nothing when caches are off, no root resolves
170    /// or the root does not exist yet.
171    pub fn prune_scheduled(&self) {
172        let _ = self.prune_guarded(true);
173    }
174
175    /// Carries out an operator's `host cache prune` that their account could
176    /// not, and answers it in [`dependency_cache::PRUNE_RESULT_FILE`] under
177    /// the identifier `request` carried.
178    ///
179    /// A request is carried out whether or not caches are on, exactly as the
180    /// command does when its account can write the root itself; only the
181    /// scheduled pass respects the switch.
182    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
249/// Holds [`PRUNING`] for one pass, and lets go of it however the pass ends --
250/// a panic included, which would otherwise stop every later pass for good.
251struct 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        // `prepare` refuses a broken file before registration; one broken
364        // after it costs the job its caches, not its runner.
365        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    /// An operator's `runner.env` takes a whole tool, in any letter case.
372    #[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}