1use std::collections::BTreeMap;
82use std::ffi::OsString;
83use std::fs;
84use std::io;
85use std::path::{Path, PathBuf};
86use std::time::{Duration, SystemTime};
87
88use chrono::{DateTime, Utc};
89use runner_manager_domain::model::ScaleTarget;
90use serde::{Deserialize, Serialize};
91
92use sha2::Digest as _;
93
94use crate::lock::{HostLock, LockKind};
95use crate::paths::AppPaths;
96use crate::runner_env::RunnerPlatform;
97
98pub const CONFIG_FILE: &str = "caches.toml";
100
101pub const DEFAULT_ROOT_NAME: &str = "_cache";
103
104pub const DEFAULT_MAX_SIZE_GIB: u64 = 20;
106
107pub const PRUNE_INTERVAL: Duration = Duration::from_secs(30 * 60);
109
110pub const LOCK_WAIT: Duration = Duration::from_secs(30);
112
113pub const USAGE_FILE: &str = ".usage.json";
115
116pub const PRUNE_REQUEST_FILE: &str = "cache-prune.request";
132
133pub const PRUNE_RESULT_FILE: &str = "cache-prune.result";
135
136pub const PRUNE_REQUEST_POLL: Duration = Duration::from_secs(5);
140
141#[must_use]
143pub fn prune_request_path(state_dir: &Path) -> PathBuf {
144 state_dir.join(PRUNE_REQUEST_FILE)
145}
146
147#[must_use]
152pub fn take_prune_request(state_dir: &Path) -> Option<String> {
153 let path = prune_request_path(state_dir);
154 let request = fs::read_to_string(&path).ok()?;
155 fs::remove_file(&path).ok()?;
156 Some(request.trim().to_owned())
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct PruneResult {
162 pub request: String,
164 pub outcome: Result<CacheUsage, String>,
166}
167
168impl PruneResult {
169 pub fn write(&self, state_dir: &Path) -> Result<(), CacheError> {
174 let path = state_dir.join(PRUNE_RESULT_FILE);
175 let json = serde_json::to_vec_pretty(self).map_err(|error| CacheError::Io {
176 action: "encode",
177 path: path.clone(),
178 source: io::Error::other(error),
179 })?;
180 write_atomically(&path, &json)
181 }
182
183 #[must_use]
185 pub fn read_for(state_dir: &Path, request: &str) -> Option<Self> {
186 let bytes = fs::read(state_dir.join(PRUNE_RESULT_FILE)).ok()?;
187 serde_json::from_slice::<Self>(&bytes)
188 .ok()
189 .filter(|result| result.request == request)
190 }
191}
192
193const TRASH_DIR: &str = ".trash";
194const SLOTS_DIR: &str = "_slots";
195const LEASE_EXTENSION: &str = "lease";
196const LAST_USED_FILE: &str = ".last-used";
197const ORG_PREFIX: &str = "_org";
198const SHARED_PREFIX: &str = "_shared";
199const GIB: u64 = 1024 * 1024 * 1024;
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub struct Platforms {
208 pub windows: bool,
209 pub macos: bool,
210 pub linux: bool,
211}
212
213impl Platforms {
214 pub const ALL: Self = Self {
215 windows: true,
216 macos: true,
217 linux: true,
218 };
219 pub const UNIX: Self = Self {
220 windows: false,
221 macos: true,
222 linux: true,
223 };
224
225 #[must_use]
226 pub const fn includes(self, platform: RunnerPlatform) -> bool {
227 match platform {
228 RunnerPlatform::Windows => self.windows,
229 RunnerPlatform::MacOs => self.macos,
230 RunnerPlatform::Linux => self.linux,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum Sharing {
238 Namespace,
241 Slot,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum VarValue {
248 Dir,
250 Sub(&'static str),
252 Literal(&'static str),
254 Template(&'static str),
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub struct CacheVariable {
262 pub name: &'static str,
263 pub value: VarValue,
264 pub platforms: Platforms,
265}
266
267const fn all(name: &'static str, value: VarValue) -> CacheVariable {
268 CacheVariable {
269 name,
270 value,
271 platforms: Platforms::ALL,
272 }
273}
274
275const fn unix(name: &'static str, value: VarValue) -> CacheVariable {
276 CacheVariable {
277 name,
278 value,
279 platforms: Platforms::UNIX,
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct CacheTool {
286 pub id: &'static str,
288 pub dir: &'static str,
290 pub sharing: Sharing,
291 pub default_enabled: bool,
293 pub variables: &'static [CacheVariable],
294 pub note: &'static str,
296}
297
298impl CacheTool {
299 pub fn variables_on(&self, platform: RunnerPlatform) -> impl Iterator<Item = &CacheVariable> {
301 self.variables
302 .iter()
303 .filter(move |variable| variable.platforms.includes(platform))
304 }
305
306 #[must_use]
308 pub fn applies_to(&self, platform: RunnerPlatform) -> bool {
309 self.variables_on(platform).next().is_some()
310 }
311}
312
313pub const TOOLS: &[CacheTool] = &[
319 CacheTool {
320 id: "xdg",
321 dir: "xdg",
322 sharing: Sharing::Namespace,
323 default_enabled: true,
324 variables: &[unix("XDG_CACHE_HOME", VarValue::Dir)],
325 note: "catch-all for Unix tools that follow the XDG base directory specification; not set \
326 on Windows, where pnpm, Bun and Deno would also move global state",
327 },
328 CacheTool {
329 id: "tool-cache",
330 dir: "tool-cache",
331 sharing: Sharing::Slot,
332 default_enabled: true,
333 variables: &[
334 all("RUNNER_TOOL_CACHE", VarValue::Dir),
335 all("AGENT_TOOLSDIRECTORY", VarValue::Dir),
336 ],
337 note: "toolchains setup-node, setup-go and setup-java download; per slot because \
338 @actions/tool-cache deletes a version's folder before filling it",
339 },
340 CacheTool {
341 id: "dotnet",
342 dir: "dotnet",
343 sharing: Sharing::Slot,
344 default_enabled: true,
345 variables: &[all("DOTNET_INSTALL_DIR", VarValue::Dir)],
346 note: "SDKs setup-dotnet installs; per slot because an install replaces the shared dotnet \
347 host",
348 },
349 CacheTool {
350 id: "npm",
351 dir: "npm",
352 sharing: Sharing::Namespace,
353 default_enabled: true,
354 variables: &[all("npm_config_cache", VarValue::Dir)],
355 note: "npm's cacache and npx",
356 },
357 CacheTool {
358 id: "pnpm",
359 dir: "pnpm-store",
360 sharing: Sharing::Namespace,
361 default_enabled: true,
362 variables: &[
363 all("npm_config_store_dir", VarValue::Dir),
364 all("PNPM_CONFIG_STORE_DIR", VarValue::Dir),
365 ],
366 note: "the content-addressed store: pnpm 9 and 10 read npm_config_store_dir, pnpm 11 and \
367 later PNPM_CONFIG_STORE_DIR; npm 12 warns that store-dir is unknown",
368 },
369 CacheTool {
370 id: "yarn",
371 dir: "yarn",
372 sharing: Sharing::Namespace,
373 default_enabled: true,
374 variables: &[all("YARN_GLOBAL_FOLDER", VarValue::Dir)],
375 note: "Yarn 2+ global cache; YARN_CACHE_FOLDER is left alone because it would override a \
376 zero-install project's committed cache",
377 },
378 CacheTool {
379 id: "bun",
380 dir: "bun",
381 sharing: Sharing::Namespace,
382 default_enabled: true,
383 variables: &[all("BUN_INSTALL_CACHE_DIR", VarValue::Dir)],
384 note: "Bun's package cache",
385 },
386 CacheTool {
387 id: "deno",
388 dir: "deno",
389 sharing: Sharing::Namespace,
390 default_enabled: true,
391 variables: &[all("DENO_DIR", VarValue::Dir)],
392 note: "Deno's module and npm cache (it also keeps localStorage there)",
393 },
394 CacheTool {
395 id: "node-gyp",
396 dir: "node-gyp",
397 sharing: Sharing::Namespace,
398 default_enabled: true,
399 variables: &[
400 all("npm_config_devdir", VarValue::Dir),
401 all("npm_package_config_node_gyp_devdir", VarValue::Dir),
402 ],
403 note: "Node headers node-gyp downloads for native modules",
404 },
405 CacheTool {
406 id: "electron",
407 dir: "electron",
408 sharing: Sharing::Namespace,
409 default_enabled: true,
410 variables: &[
411 all("electron_config_cache", VarValue::Dir),
414 all("ELECTRON_CACHE", VarValue::Dir),
415 ],
416 note: "Electron release downloads",
417 },
418 CacheTool {
419 id: "electron-builder",
420 dir: "electron-builder",
421 sharing: Sharing::Namespace,
422 default_enabled: true,
423 variables: &[all("ELECTRON_BUILDER_CACHE", VarValue::Dir)],
424 note: "NSIS, winCodeSign, AppImage and other packaging tools",
425 },
426 CacheTool {
427 id: "playwright",
428 dir: "ms-playwright",
429 sharing: Sharing::Namespace,
430 default_enabled: true,
431 variables: &[
432 all("PLAYWRIGHT_BROWSERS_PATH", VarValue::Dir),
433 all("PLAYWRIGHT_SKIP_BROWSER_GC", VarValue::Literal("1")),
436 ],
437 note: "Playwright browsers",
438 },
439 CacheTool {
440 id: "cypress",
441 dir: "cypress",
442 sharing: Sharing::Slot,
443 default_enabled: true,
444 variables: &[all("CYPRESS_CACHE_FOLDER", VarValue::Dir)],
445 note: "Cypress binaries; per slot because cypress install takes no lock",
446 },
447 CacheTool {
448 id: "nuget",
449 dir: "nuget",
450 sharing: Sharing::Namespace,
451 default_enabled: true,
452 variables: &[
453 all("NUGET_PACKAGES", VarValue::Sub("packages")),
454 all("NUGET_HTTP_CACHE_PATH", VarValue::Sub("http-cache")),
455 all("NUGET_PLUGINS_CACHE_PATH", VarValue::Sub("plugins-cache")),
456 all("NUGET_SCRATCH", VarValue::Sub("scratch")),
460 ],
461 note: "global packages and HTTP cache",
462 },
463 CacheTool {
464 id: "pip",
465 dir: "pip",
466 sharing: Sharing::Namespace,
467 default_enabled: true,
468 variables: &[all("PIP_CACHE_DIR", VarValue::Dir)],
469 note: "pip's HTTP and wheel cache",
470 },
471 CacheTool {
472 id: "uv",
473 dir: "uv",
474 sharing: Sharing::Namespace,
475 default_enabled: true,
476 variables: &[all("UV_CACHE_DIR", VarValue::Dir)],
477 note: "uv's cache",
478 },
479 CacheTool {
480 id: "go",
481 dir: "go",
482 sharing: Sharing::Namespace,
483 default_enabled: true,
484 variables: &[
485 all("GOMODCACHE", VarValue::Sub("mod")),
486 all("GOCACHE", VarValue::Sub("build")),
487 ],
488 note: "Go module and build caches",
489 },
490 CacheTool {
491 id: "composer",
492 dir: "composer",
493 sharing: Sharing::Namespace,
494 default_enabled: true,
495 variables: &[all("COMPOSER_CACHE_DIR", VarValue::Dir)],
496 note: "Composer's download cache",
497 },
498 CacheTool {
499 id: "cocoapods",
500 dir: "cocoapods",
501 sharing: Sharing::Namespace,
502 default_enabled: true,
503 variables: &[unix("CP_CACHE_DIR", VarValue::Dir)],
504 note: "CocoaPods download cache",
505 },
506 CacheTool {
507 id: "ccache",
508 dir: "ccache",
509 sharing: Sharing::Namespace,
510 default_enabled: true,
511 variables: &[all("CCACHE_DIR", VarValue::Dir)],
512 note: "ccache objects",
513 },
514 CacheTool {
515 id: "poetry",
516 dir: "poetry",
517 sharing: Sharing::Slot,
518 default_enabled: false,
519 variables: &[all("POETRY_CACHE_DIR", VarValue::Dir)],
520 note: "opt-in: Poetry also keeps virtualenvs in its cache directory, one per workspace path",
521 },
522 CacheTool {
523 id: "cargo",
524 dir: "cargo",
525 sharing: Sharing::Namespace,
526 default_enabled: false,
527 variables: &[all("CARGO_HOME", VarValue::Dir)],
528 note: "opt-in: CARGO_HOME also holds config, registry credentials and installed binaries",
529 },
530 CacheTool {
531 id: "gradle",
532 dir: "gradle",
533 sharing: Sharing::Namespace,
534 default_enabled: false,
535 variables: &[all("GRADLE_USER_HOME", VarValue::Dir)],
536 note: "opt-in: GRADLE_USER_HOME also holds gradle.properties, init scripts and daemons",
537 },
538 CacheTool {
539 id: "maven",
540 dir: "maven",
541 sharing: Sharing::Slot,
542 default_enabled: false,
543 variables: &[all(
544 "MAVEN_ARGS",
545 VarValue::Template("-Dmaven.repo.local={dir}"),
546 )],
547 note: "opt-in: Maven 3.9+ local repository via MAVEN_ARGS, which a workflow's own \
548 MAVEN_ARGS replaces",
549 },
550 CacheTool {
551 id: "pub",
552 dir: "pub",
553 sharing: Sharing::Slot,
554 default_enabled: false,
555 variables: &[all("PUB_CACHE", VarValue::Dir)],
556 note: "opt-in: PUB_CACHE also holds globally activated Dart tools",
557 },
558 CacheTool {
559 id: "bundler",
560 dir: "bundler",
561 sharing: Sharing::Slot,
562 default_enabled: false,
563 variables: &[all("BUNDLE_USER_CACHE", VarValue::Dir)],
564 note: "opt-in: Bundler's download cache",
565 },
566];
567
568#[must_use]
570pub fn tool(id: &str) -> Option<&'static CacheTool> {
571 TOOLS.iter().find(|tool| tool.id == id)
572}
573
574#[derive(Debug, thiserror::Error)]
580pub enum CacheError {
581 #[error("{} is not valid TOML for caches: {message}", path.display())]
582 Parse { path: PathBuf, message: String },
583 #[error("`{0}` is not a cache this version knows; `host cache show` lists them")]
584 UnknownTool(String),
585 #[error(
586 "`{0}` is not a valid shared namespace name; use 1 to 64 lower-case letters, digits, \
587 `.`, `_` or `-`, not starting with `.`"
588 )]
589 InvalidNamespace(String),
590 #[error("`{0}` is not a repository (OWNER/REPO) or an organization")]
591 InvalidTarget(String),
592 #[error("the cache root {0} must be an absolute path without spaces")]
593 InvalidRoot(String),
594 #[error("cannot {action} {}: {source}", path.display())]
595 Io {
596 action: &'static str,
597 path: PathBuf,
598 #[source]
599 source: io::Error,
600 },
601 #[error("{0}")]
602 Lock(String),
603}
604
605impl CacheError {
606 #[must_use]
609 pub fn is_permission_denied(&self) -> bool {
610 matches!(self, Self::Io { source, .. } if source.kind() == io::ErrorKind::PermissionDenied)
611 }
612}
613
614fn io_error(action: &'static str, path: &Path) -> impl FnOnce(io::Error) -> CacheError + use<> {
615 let path = path.to_path_buf();
616 move |source| CacheError::Io {
617 action,
618 path,
619 source,
620 }
621}
622
623#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(deny_unknown_fields)]
631pub struct CacheConfig {
632 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub enabled: Option<bool>,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub root: Option<String>,
638 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub max_size_gib: Option<u64>,
641 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
643 pub tools: BTreeMap<String, bool>,
644 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
647 pub targets: BTreeMap<String, TargetCacheConfig>,
648}
649
650#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
652#[serde(deny_unknown_fields)]
653pub struct TargetCacheConfig {
654 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub enabled: Option<bool>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub namespace: Option<String>,
659 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
660 pub tools: BTreeMap<String, bool>,
661}
662
663impl TargetCacheConfig {
664 fn is_empty(&self) -> bool {
665 self.enabled.is_none() && self.namespace.is_none() && self.tools.is_empty()
666 }
667}
668
669#[must_use]
671pub fn config_path_in(config_dir: &Path) -> PathBuf {
672 config_dir.join(CONFIG_FILE)
673}
674
675#[must_use]
677pub fn target_key(target: &ScaleTarget) -> String {
678 target.slug().to_ascii_lowercase()
679}
680
681impl CacheConfig {
682 pub fn parse(text: &str, path: &Path) -> Result<Self, CacheError> {
688 let config: Self = toml::from_str(text).map_err(|error| CacheError::Parse {
689 path: path.to_path_buf(),
690 message: error.message().to_owned(),
691 })?;
692 config.validate()?;
693 Ok(config)
694 }
695
696 pub fn load(path: &Path) -> Result<Self, CacheError> {
701 match fs::read_to_string(path) {
702 Ok(text) => Self::parse(&text, path),
703 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
704 Err(source) => Err(CacheError::Io {
705 action: "read",
706 path: path.to_path_buf(),
707 source,
708 }),
709 }
710 }
711
712 fn validate(&self) -> Result<(), CacheError> {
713 if let Some(root) = &self.root {
714 validate_root_text(root)?;
715 }
716 let tool_ids = self
717 .tools
718 .keys()
719 .chain(self.targets.values().flat_map(|target| target.tools.keys()));
720 for id in tool_ids {
721 if tool(id).is_none() {
722 return Err(CacheError::UnknownTool(id.clone()));
723 }
724 }
725 for (key, target) in &self.targets {
726 ScaleTarget::repository(key)
727 .or_else(|_| ScaleTarget::organization(key))
728 .map_err(|_| CacheError::InvalidTarget(key.clone()))?;
729 if let Some(name) = &target.namespace {
730 validate_namespace_name(name)?;
731 }
732 }
733 Ok(())
734 }
735
736 pub fn save(&self, path: &Path) -> Result<(), CacheError> {
742 let mut config = self.clone();
743 config.targets.retain(|_, target| !target.is_empty());
744 config.validate()?;
745 let mut text = String::from(
746 "# Dependency caches for native runners. Edited by `runner-manager host cache`,\n\
747 # `repo cache` and `org cache`; see `host cache show`.\n",
748 );
749 text.push_str(
750 &toml::to_string(&config).map_err(|error| CacheError::Parse {
751 path: path.to_path_buf(),
752 message: error.to_string(),
753 })?,
754 );
755 write_atomically(path, text.as_bytes())
756 }
757
758 #[must_use]
760 pub fn target(&self, target: &ScaleTarget) -> Option<&TargetCacheConfig> {
761 self.targets.get(&target_key(target))
762 }
763
764 pub fn target_mut(&mut self, target: &ScaleTarget) -> &mut TargetCacheConfig {
766 self.targets.entry(target_key(target)).or_default()
767 }
768
769 #[must_use]
771 pub fn max_bytes(&self) -> Option<u64> {
772 match self.max_size_gib.unwrap_or(DEFAULT_MAX_SIZE_GIB) {
773 0 => None,
774 gib => Some(gib.saturating_mul(GIB)),
775 }
776 }
777
778 #[must_use]
780 pub fn host_enabled(&self) -> bool {
781 self.enabled.unwrap_or(true)
782 }
783}
784
785fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), CacheError> {
786 crate::host_fitness::write_atomically(path, bytes).map_err(io_error("write", path))
787}
788
789pub fn validate_root_text(root: &str) -> Result<(), CacheError> {
799 let network = root.starts_with(r"\\") || root.starts_with("//");
803 if network || root.chars().any(char::is_whitespace) || !Path::new(root).is_absolute() {
804 return Err(CacheError::InvalidRoot(root.to_owned()));
805 }
806 Ok(())
807}
808
809pub fn validate_namespace_name(name: &str) -> Result<(), CacheError> {
814 let valid = !name.is_empty()
815 && name.len() <= 64
816 && !name.starts_with('.')
817 && name
818 .chars()
819 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
820 && !is_windows_device_name(name);
821 if valid {
822 Ok(())
823 } else {
824 Err(CacheError::InvalidNamespace(name.to_owned()))
825 }
826}
827
828fn is_windows_device_name(segment: &str) -> bool {
831 let stem = segment
832 .split('.')
833 .next()
834 .unwrap_or(segment)
835 .to_ascii_uppercase();
836 matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
837 || (stem.len() == 4
838 && (stem.starts_with("COM") || stem.starts_with("LPT"))
839 && stem.as_bytes()[3].is_ascii_digit())
840}
841
842#[derive(Debug, Clone, Copy, PartialEq, Eq)]
848pub enum RootSource {
849 Configured,
851 RunnerRoot,
853 PlatformFallback,
855}
856
857impl RootSource {
858 #[must_use]
859 pub const fn as_token(self) -> &'static str {
860 match self {
861 Self::Configured => "configured",
862 Self::RunnerRoot => "runner-root",
863 Self::PlatformFallback => "platform-fallback",
864 }
865 }
866}
867
868#[derive(Debug, Clone, PartialEq, Eq)]
870pub struct ResolvedRoot {
871 pub path: PathBuf,
872 pub source: RootSource,
873}
874
875pub fn resolve_root(
885 config: &CacheConfig,
886 runner_root: Option<&Path>,
887 fallback: Option<PathBuf>,
888) -> Result<ResolvedRoot, String> {
889 if let Some(root) = &config.root {
890 return Ok(ResolvedRoot {
891 path: PathBuf::from(root),
892 source: RootSource::Configured,
893 });
894 }
895 let usable = |path: &Path| !path.to_string_lossy().chars().any(char::is_whitespace);
896 if let Some(runner_root) = runner_root {
897 let candidate = runner_root.join(DEFAULT_ROOT_NAME);
898 if usable(&candidate) {
899 return Ok(ResolvedRoot {
900 path: candidate,
901 source: RootSource::RunnerRoot,
902 });
903 }
904 }
905 match fallback.filter(|path| usable(path) && path.is_absolute()) {
906 Some(path) => Ok(ResolvedRoot {
907 path,
908 source: RootSource::PlatformFallback,
909 }),
910 None => Err(
911 "no cache root without spaces could be derived; set one with \
912 `runner-manager host cache set-root --path <PATH>`"
913 .to_owned(),
914 ),
915 }
916}
917
918#[must_use]
927pub fn platform_fallback_root(app_paths: &AppPaths) -> Option<PathBuf> {
928 if cfg!(windows) {
929 crate::runner_root::default_runner_root(app_paths)
930 .ok()
931 .map(|root| root.as_path().join(DEFAULT_ROOT_NAME))
932 } else {
933 library_caches_beside(app_paths.runtime_dir())
934 .or_else(|| directories::BaseDirs::new().map(|dirs| dirs.cache_dir().to_path_buf()))
935 .map(|caches| caches.join("runner-manager"))
936 }
937}
938
939fn library_caches_beside(path: &Path) -> Option<PathBuf> {
942 path.ancestors().find_map(|ancestor| {
943 let library = ancestor.parent()?;
944 (ancestor.file_name()? == "Application Support" && library.file_name()? == "Library")
945 .then(|| library.join("Caches"))
946 })
947}
948
949#[derive(Debug, Clone, PartialEq, Eq)]
955pub enum Namespace {
956 Repository { owner: String, repo: String },
957 Organization(String),
958 Shared(String),
959}
960
961impl Namespace {
962 #[must_use]
964 pub fn of(target: &ScaleTarget) -> Self {
965 match target {
966 ScaleTarget::Repository(repository) => Self::Repository {
967 owner: repository.owner().to_ascii_lowercase(),
968 repo: repository.repo().to_ascii_lowercase(),
969 },
970 ScaleTarget::Organization(org) => Self::Organization(org.as_str().to_ascii_lowercase()),
971 }
972 }
973
974 #[must_use]
976 pub fn segments(&self) -> [String; 2] {
977 match self {
978 Self::Repository { owner, repo } => [segment(owner), segment(repo)],
979 Self::Organization(org) => [ORG_PREFIX.to_owned(), segment(org)],
980 Self::Shared(name) => [SHARED_PREFIX.to_owned(), segment(name)],
981 }
982 }
983
984 #[must_use]
986 pub fn dir(&self, root: &Path) -> PathBuf {
987 let [first, second] = self.segments();
988 root.join(first).join(second)
989 }
990
991 #[must_use]
993 pub fn label(&self) -> String {
994 self.segments().join("/")
995 }
996}
997
998fn segment(raw: &str) -> String {
1004 let cleaned = portable_segment(raw);
1005 if cleaned.len() <= MAX_SEGMENT_LEN {
1006 return cleaned;
1007 }
1008 let digest = hex::encode(sha2::Sha256::digest(raw.as_bytes()));
1013 format!(
1014 "{}-{}",
1015 &cleaned[..MAX_SEGMENT_LEN - SEGMENT_DIGEST_LEN - 1],
1016 &digest[..SEGMENT_DIGEST_LEN]
1017 )
1018}
1019
1020const MAX_SEGMENT_LEN: usize = 32;
1022const SEGMENT_DIGEST_LEN: usize = 8;
1023
1024fn portable_segment(raw: &str) -> String {
1025 let cleaned: String = raw
1026 .chars()
1027 .map(|c| {
1028 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
1029 c
1030 } else {
1031 '_'
1032 }
1033 })
1034 .collect();
1035 let cleaned = match cleaned.as_str() {
1036 "" | "." | ".." => format!("_{cleaned}"),
1037 _ => cleaned,
1038 };
1039 if is_windows_device_name(&cleaned) || cleaned.ends_with('.') {
1040 format!("{cleaned}_")
1041 } else {
1042 cleaned
1043 }
1044}
1045
1046#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1048pub enum ToolSource {
1049 Default,
1050 Host,
1051 Target,
1052 Operator,
1054}
1055
1056impl ToolSource {
1057 #[must_use]
1058 pub const fn as_token(self) -> &'static str {
1059 match self {
1060 Self::Default => "default",
1061 Self::Host => "host",
1062 Self::Target => "this target",
1063 Self::Operator => "set by runner.env or the service",
1064 }
1065 }
1066}
1067
1068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1070pub struct ToolState {
1071 pub tool: &'static CacheTool,
1072 pub enabled: bool,
1073 pub source: ToolSource,
1074}
1075
1076#[derive(Debug, Clone, PartialEq, Eq)]
1078pub struct Selection {
1079 pub disabled: Option<&'static str>,
1081 pub namespace: Namespace,
1082 pub tools: Vec<ToolState>,
1084}
1085
1086impl Selection {
1087 pub fn enabled_tools(&self) -> impl Iterator<Item = &'static CacheTool> + '_ {
1089 self.tools
1090 .iter()
1091 .filter(|state| state.enabled)
1092 .map(|state| state.tool)
1093 }
1094}
1095
1096pub const ORGANIZATION_OPT_IN: &str = "organization policies accept jobs from every repository \
1098 in the organization, so they get a cache only with `org cache set-enabled ORG --enabled \
1099 true`";
1100
1101#[must_use]
1104pub fn select(config: &CacheConfig, target: &ScaleTarget, platform: RunnerPlatform) -> Selection {
1105 let own = config.target(target);
1106 let namespace = own
1107 .and_then(|own| own.namespace.clone())
1108 .map_or_else(|| Namespace::of(target), Namespace::Shared);
1109 let disabled = if !config.host_enabled() {
1110 Some("caches are turned off on this host")
1111 } else {
1112 match (own.and_then(|own| own.enabled), target) {
1113 (Some(true), _) => None,
1114 (Some(false), _) => Some("caches are turned off for this target"),
1115 (None, ScaleTarget::Repository(_)) => None,
1116 (None, ScaleTarget::Organization(_)) => Some(ORGANIZATION_OPT_IN),
1117 }
1118 };
1119 let tools = TOOLS
1120 .iter()
1121 .filter(|tool| tool.applies_to(platform))
1122 .map(|tool| {
1123 let mut state = ToolState {
1124 tool,
1125 enabled: tool.default_enabled,
1126 source: ToolSource::Default,
1127 };
1128 if let Some(enabled) = config.tools.get(tool.id) {
1129 state.enabled = *enabled;
1130 state.source = ToolSource::Host;
1131 }
1132 if let Some(enabled) = own.and_then(|own| own.tools.get(tool.id)) {
1133 state.enabled = *enabled;
1134 state.source = ToolSource::Target;
1135 }
1136 state
1137 })
1138 .collect();
1139 Selection {
1140 disabled,
1141 namespace,
1142 tools,
1143 }
1144}
1145
1146#[must_use]
1152fn tool_dir(tool: &CacheTool, namespace_dir: &Path, slot_dir: &Path) -> PathBuf {
1153 match tool.sharing {
1154 Sharing::Namespace => namespace_dir.join(tool.dir),
1155 Sharing::Slot => slot_dir.join(tool.dir),
1156 }
1157}
1158
1159fn render(variable: &CacheVariable, dir: &Path) -> (OsString, Option<PathBuf>) {
1161 match variable.value {
1162 VarValue::Dir => (dir.as_os_str().to_owned(), Some(dir.to_path_buf())),
1163 VarValue::Sub(sub) => {
1164 let path = dir.join(sub);
1165 (path.as_os_str().to_owned(), Some(path))
1166 }
1167 VarValue::Literal(value) => (OsString::from(value), None),
1168 VarValue::Template(template) => (
1169 OsString::from(template.replace("{dir}", &dir.to_string_lossy())),
1170 Some(dir.to_path_buf()),
1171 ),
1172 }
1173}
1174
1175#[must_use]
1177pub fn environment<'a>(
1178 tools: impl IntoIterator<Item = &'a CacheTool>,
1179 namespace_dir: &Path,
1180 slot_dir: &Path,
1181 platform: RunnerPlatform,
1182) -> (Vec<(&'static str, OsString)>, Vec<PathBuf>) {
1183 let mut variables = Vec::new();
1184 let mut dirs = Vec::new();
1185 for tool in tools {
1186 let dir = tool_dir(tool, namespace_dir, slot_dir);
1187 for variable in tool.variables_on(platform) {
1188 let (value, named) = render(variable, &dir);
1189 variables.push((variable.name, value));
1190 dirs.extend(named);
1191 }
1192 }
1193 (variables, dirs)
1194}
1195
1196impl Selection {
1197 pub fn defer_to_operator(&mut self, platform: RunnerPlatform, is_set: impl Fn(&str) -> bool) {
1207 for state in &mut self.tools {
1208 if state.enabled && state.tool.variables_on(platform).any(|v| is_set(v.name)) {
1209 state.enabled = false;
1210 state.source = ToolSource::Operator;
1211 }
1212 }
1213 }
1214}
1215
1216#[must_use]
1221pub fn inherited(name: &str) -> bool {
1222 std::env::vars_os().any(|(key, value)| {
1223 !value.is_empty()
1224 && key
1225 .to_str()
1226 .is_some_and(|key| key.eq_ignore_ascii_case(name))
1227 })
1228}
1229
1230#[must_use]
1238pub fn runtime_holds_runner(runtime: &Path) -> bool {
1239 match fs::metadata(runtime.join("bin")) {
1243 Ok(metadata) => metadata.is_dir(),
1244 Err(error) => error.kind() != io::ErrorKind::NotFound,
1245 }
1246}
1247
1248#[derive(Debug, Clone, PartialEq, Eq)]
1250pub struct LaunchCaches {
1251 pub namespace_dir: PathBuf,
1252 pub slot: u32,
1253 pub variables: Vec<(&'static str, OsString)>,
1254}
1255
1256fn lock_cache_root(root: &Path) -> Result<HostLock, CacheError> {
1257 HostLock::acquire_at(
1258 &root.join(LockKind::DependencyCache.file_name()),
1259 LockKind::DependencyCache,
1260 LOCK_WAIT,
1261 )
1262 .map_err(|error| match error {
1263 crate::lock::LockError::Io { path, source } => CacheError::Io {
1267 action: "lock",
1268 path,
1269 source,
1270 },
1271 other => CacheError::Lock(other.to_string()),
1272 })
1273}
1274
1275pub fn prepare_launch(
1284 root: &Path,
1285 selection: &Selection,
1286 runtime: &Path,
1287 platform: RunnerPlatform,
1288 holds_runner: &dyn Fn(&Path) -> bool,
1289) -> Result<LaunchCaches, CacheError> {
1290 fs::create_dir_all(root).map_err(io_error("create", root))?;
1291 let namespace_dir = selection.namespace.dir(root);
1292 let slots = namespace_dir.join(SLOTS_DIR);
1293 let slot = {
1294 let _lock = lock_cache_root(root)?;
1295 fs::create_dir_all(&slots).map_err(io_error("create", &slots))?;
1296 let slot = free_slot(&slots, runtime, holds_runner);
1297 let lease = slots.join(format!("{slot}.{LEASE_EXTENSION}"));
1298 fs::write(&lease, runtime.to_string_lossy().as_bytes())
1299 .map_err(io_error("write", &lease))?;
1300 let marker = namespace_dir.join(LAST_USED_FILE);
1301 fs::write(&marker, Utc::now().to_rfc3339().as_bytes())
1302 .map_err(io_error("write", &marker))?;
1303 slot
1304 };
1305 let slot_dir = slots.join(slot.to_string());
1306 let (variables, dirs) = environment(
1307 selection.enabled_tools(),
1308 &namespace_dir,
1309 &slot_dir,
1310 platform,
1311 );
1312 for dir in dirs {
1313 fs::create_dir_all(&dir).map_err(io_error("create", &dir))?;
1314 }
1315 Ok(LaunchCaches {
1316 namespace_dir,
1317 slot,
1318 variables,
1319 })
1320}
1321
1322fn free_slot(slots: &Path, runtime: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> u32 {
1325 let own = runtime.to_string_lossy();
1326 (1..)
1327 .find(
1328 |slot| match fs::read_to_string(slots.join(format!("{slot}.{LEASE_EXTENSION}"))) {
1329 Err(error) => error.kind() == io::ErrorKind::NotFound,
1330 Ok(holder) => holder == own || !holds_runner(Path::new(&holder)),
1331 },
1332 )
1333 .expect("an unbounded range has a free slot")
1334}
1335
1336fn namespace_in_use(namespace_dir: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> bool {
1338 let Ok(entries) = fs::read_dir(namespace_dir.join(SLOTS_DIR)) else {
1339 return false;
1340 };
1341 entries.flatten().any(|entry| {
1342 let path = entry.path();
1343 path.extension().is_some_and(|ext| ext == LEASE_EXTENSION)
1344 && fs::read_to_string(&path).map_or(true, |holder| holds_runner(Path::new(&holder)))
1345 })
1346}
1347
1348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1354pub struct NamespaceUsage {
1355 pub name: String,
1357 pub bytes: u64,
1358 pub last_used: Option<DateTime<Utc>>,
1359 pub in_use: bool,
1360}
1361
1362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1364pub struct CacheUsage {
1365 pub measured_at: DateTime<Utc>,
1366 pub total_bytes: u64,
1367 pub max_bytes: Option<u64>,
1368 pub namespaces: Vec<NamespaceUsage>,
1369 pub pruned: Vec<String>,
1371}
1372
1373impl CacheUsage {
1374 #[must_use]
1376 pub fn read(root: &Path) -> Option<Self> {
1377 let bytes = fs::read(root.join(USAGE_FILE)).ok()?;
1378 serde_json::from_slice(&bytes).ok()
1379 }
1380}
1381
1382fn tree_size(path: &Path) -> u64 {
1384 let mut total = 0_u64;
1385 let mut pending = vec![path.to_path_buf()];
1386 while let Some(dir) = pending.pop() {
1387 let Ok(entries) = fs::read_dir(&dir) else {
1388 continue;
1389 };
1390 for entry in entries.flatten() {
1391 let Ok(metadata) = entry.metadata() else {
1392 continue;
1393 };
1394 if metadata.is_dir() {
1395 pending.push(entry.path());
1396 } else if metadata.is_file() {
1397 total = total.saturating_add(metadata.len());
1398 }
1399 }
1400 }
1401 total
1402}
1403
1404fn modified(path: &Path) -> Option<SystemTime> {
1405 fs::metadata(path).and_then(|m| m.modified()).ok()
1406}
1407
1408fn namespace_dirs(root: &Path) -> Vec<(String, PathBuf)> {
1413 let mut found = Vec::new();
1414 let Ok(first) = fs::read_dir(root) else {
1415 return found;
1416 };
1417 for outer in first.flatten() {
1418 let outer_name = outer.file_name().to_string_lossy().into_owned();
1419 if outer_name.starts_with('.') || !outer.file_type().is_ok_and(|t| t.is_dir()) {
1420 continue;
1421 }
1422 let Ok(second) = fs::read_dir(outer.path()) else {
1423 continue;
1424 };
1425 for inner in second.flatten() {
1426 let path = inner.path();
1427 let marked = path.join(LAST_USED_FILE).is_file() || path.join(SLOTS_DIR).is_dir();
1428 if marked && inner.file_type().is_ok_and(|t| t.is_dir()) {
1429 let name = format!("{outer_name}/{}", inner.file_name().to_string_lossy());
1430 found.push((name, path));
1431 }
1432 }
1433 }
1434 found.sort();
1435 found
1436}
1437
1438#[must_use]
1440pub(crate) fn measure(root: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> Vec<NamespaceUsage> {
1441 namespace_dirs(root)
1442 .into_iter()
1443 .map(|(name, dir)| NamespaceUsage {
1444 bytes: tree_size(&dir),
1445 last_used: modified(&dir.join(LAST_USED_FILE))
1446 .or_else(|| modified(&dir))
1447 .map(DateTime::<Utc>::from),
1448 in_use: namespace_in_use(&dir, holds_runner),
1449 name,
1450 })
1451 .collect()
1452}
1453
1454#[must_use]
1458pub(crate) fn plan_prune(namespaces: &[NamespaceUsage], max_bytes: Option<u64>) -> Vec<String> {
1459 let Some(max) = max_bytes else {
1460 return Vec::new();
1461 };
1462 let mut total: u64 = namespaces.iter().map(|ns| ns.bytes).sum();
1463 let mut idle: Vec<&NamespaceUsage> = namespaces.iter().filter(|ns| !ns.in_use).collect();
1464 idle.sort_by(|a, b| a.last_used.cmp(&b.last_used).then(a.name.cmp(&b.name)));
1466 let mut chosen = Vec::new();
1467 for namespace in idle {
1468 if total <= max {
1469 break;
1470 }
1471 total = total.saturating_sub(namespace.bytes);
1472 chosen.push(namespace.name.clone());
1473 }
1474 chosen
1475}
1476
1477pub fn prune(
1488 root: &Path,
1489 max_bytes: Option<u64>,
1490 holds_runner: &dyn Fn(&Path) -> bool,
1491) -> Result<CacheUsage, CacheError> {
1492 empty_trash(root);
1493 let mut namespaces = measure(root, holds_runner);
1494 let planned = plan_prune(&namespaces, max_bytes);
1495 let mut pruned = Vec::new();
1496 if !planned.is_empty() {
1497 let trash = root.join(TRASH_DIR);
1498 fs::create_dir_all(&trash).map_err(io_error("create", &trash))?;
1499 let _lock = lock_cache_root(root)?;
1500 for name in planned {
1501 let dir = root.join(&name);
1502 if namespace_in_use(&dir, holds_runner) {
1503 continue;
1504 }
1505 let aside = trash.join(uuid::Uuid::new_v4().simple().to_string());
1506 if fs::rename(&dir, &aside).is_ok() {
1507 pruned.push(name);
1508 }
1509 }
1510 }
1511 empty_trash(root);
1512 namespaces.retain(|ns| !pruned.contains(&ns.name));
1513 let usage = CacheUsage {
1514 measured_at: Utc::now(),
1515 total_bytes: namespaces.iter().map(|ns| ns.bytes).sum(),
1516 max_bytes,
1517 namespaces,
1518 pruned,
1519 };
1520 let json = serde_json::to_vec_pretty(&usage).map_err(|error| CacheError::Io {
1521 action: "encode",
1522 path: root.join(USAGE_FILE),
1523 source: io::Error::other(error),
1524 })?;
1525 write_atomically(&root.join(USAGE_FILE), &json)?;
1526 Ok(usage)
1527}
1528
1529fn empty_trash(root: &Path) {
1532 let Ok(entries) = fs::read_dir(root.join(TRASH_DIR)) else {
1533 return;
1534 };
1535 for entry in entries.flatten() {
1536 let _ = remove_tree(&entry.path());
1537 }
1538}
1539
1540fn remove_tree(path: &Path) -> io::Result<()> {
1547 #[cfg(windows)]
1548 {
1549 remove_dir_all::remove_dir_all(path)
1550 }
1551 #[cfg(not(windows))]
1552 {
1553 match fs::remove_dir_all(path) {
1554 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
1555 open_directories(path);
1556 fs::remove_dir_all(path)
1557 }
1558 other => other,
1559 }
1560 }
1561}
1562
1563#[cfg(not(windows))]
1565fn open_directories(path: &Path) {
1566 use std::os::unix::fs::PermissionsExt as _;
1567 let mut pending = vec![path.to_path_buf()];
1568 while let Some(current) = pending.pop() {
1569 let Ok(metadata) = fs::symlink_metadata(¤t) else {
1570 continue;
1571 };
1572 if !metadata.is_dir() {
1573 continue;
1574 }
1575 let mode = metadata.permissions().mode();
1576 if mode & 0o700 != 0o700 {
1577 let _ = fs::set_permissions(¤t, fs::Permissions::from_mode(mode | 0o700));
1578 }
1579 if let Ok(entries) = fs::read_dir(¤t) {
1580 pending.extend(entries.flatten().map(|entry| entry.path()));
1581 }
1582 }
1583}
1584
1585#[must_use]
1587pub fn tool_ids() -> Vec<&'static str> {
1588 TOOLS.iter().map(|tool| tool.id).collect()
1589}
1590
1591#[must_use]
1593pub fn human_bytes(bytes: u64) -> String {
1594 #[allow(clippy::cast_precision_loss)]
1595 let value = bytes as f64;
1596 if bytes >= GIB {
1597 format!("{:.1} GiB", value / GIB as f64)
1598 } else if bytes >= 1024 * 1024 {
1599 format!("{:.0} MiB", value / (1024.0 * 1024.0))
1600 } else {
1601 format!("{:.0} KiB", value / 1024.0)
1602 }
1603}
1604
1605#[cfg(test)]
1606mod tests;