use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use runner_manager_domain::model::ScaleTarget;
use serde::{Deserialize, Serialize};
use sha2::Digest as _;
use crate::lock::{HostLock, LockKind};
use crate::paths::AppPaths;
use crate::runner_env::RunnerPlatform;
pub const CONFIG_FILE: &str = "caches.toml";
pub const DEFAULT_ROOT_NAME: &str = "_cache";
pub const DEFAULT_MAX_SIZE_GIB: u64 = 20;
pub const PRUNE_INTERVAL: Duration = Duration::from_secs(30 * 60);
pub const LOCK_WAIT: Duration = Duration::from_secs(30);
pub const USAGE_FILE: &str = ".usage.json";
pub const PRUNE_REQUEST_FILE: &str = "cache-prune.request";
pub const PRUNE_RESULT_FILE: &str = "cache-prune.result";
pub const PRUNE_REQUEST_POLL: Duration = Duration::from_secs(5);
#[must_use]
pub fn prune_request_path(state_dir: &Path) -> PathBuf {
crate::service_request::CACHE_PRUNE.request_path(state_dir)
}
#[must_use]
pub fn take_prune_request(state_dir: &Path) -> Option<String> {
crate::service_request::CACHE_PRUNE
.take(state_dir)
.into_iter()
.next()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PruneResult {
pub request: String,
pub outcome: Result<CacheUsage, String>,
}
impl PruneResult {
pub fn write(&self, state_dir: &Path) -> Result<(), CacheError> {
crate::service_request::CACHE_PRUNE
.answer(state_dir, &self.request, self.outcome.clone())
.map_err(|source| CacheError::Io {
action: "write",
path: state_dir.join(PRUNE_RESULT_FILE),
source,
})
}
#[must_use]
pub fn read_for(state_dir: &Path, request: &str) -> Option<Self> {
crate::service_request::CACHE_PRUNE
.read_answer(state_dir, request)
.map(|outcome| Self {
request: request.to_owned(),
outcome,
})
}
}
const TRASH_DIR: &str = ".trash";
const SLOTS_DIR: &str = "_slots";
const LEASE_EXTENSION: &str = "lease";
const LAST_USED_FILE: &str = ".last-used";
const ORG_PREFIX: &str = "_org";
const SHARED_PREFIX: &str = "_shared";
const GIB: u64 = 1024 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Platforms {
pub windows: bool,
pub macos: bool,
pub linux: bool,
}
impl Platforms {
pub const ALL: Self = Self {
windows: true,
macos: true,
linux: true,
};
pub const UNIX: Self = Self {
windows: false,
macos: true,
linux: true,
};
#[must_use]
pub const fn includes(self, platform: RunnerPlatform) -> bool {
match platform {
RunnerPlatform::Windows => self.windows,
RunnerPlatform::MacOs => self.macos,
RunnerPlatform::Linux => self.linux,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sharing {
Namespace,
Slot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VarValue {
Dir,
Sub(&'static str),
Literal(&'static str),
Template(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CacheVariable {
pub name: &'static str,
pub value: VarValue,
pub platforms: Platforms,
}
const fn all(name: &'static str, value: VarValue) -> CacheVariable {
CacheVariable {
name,
value,
platforms: Platforms::ALL,
}
}
const fn unix(name: &'static str, value: VarValue) -> CacheVariable {
CacheVariable {
name,
value,
platforms: Platforms::UNIX,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CacheTool {
pub id: &'static str,
pub dir: &'static str,
pub sharing: Sharing,
pub default_enabled: bool,
pub variables: &'static [CacheVariable],
pub note: &'static str,
}
impl CacheTool {
pub fn variables_on(&self, platform: RunnerPlatform) -> impl Iterator<Item = &CacheVariable> {
self.variables
.iter()
.filter(move |variable| variable.platforms.includes(platform))
}
#[must_use]
pub fn applies_to(&self, platform: RunnerPlatform) -> bool {
self.variables_on(platform).next().is_some()
}
}
pub const TOOLS: &[CacheTool] = &[
CacheTool {
id: "xdg",
dir: "xdg",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[unix("XDG_CACHE_HOME", VarValue::Dir)],
note: "catch-all for Unix tools that follow the XDG base directory specification; not set \
on Windows, where pnpm, Bun and Deno would also move global state",
},
CacheTool {
id: "tool-cache",
dir: "tool-cache",
sharing: Sharing::Slot,
default_enabled: true,
variables: &[
all("RUNNER_TOOL_CACHE", VarValue::Dir),
all("AGENT_TOOLSDIRECTORY", VarValue::Dir),
],
note: "toolchains setup-node, setup-go and setup-java download; per slot because \
@actions/tool-cache deletes a version's folder before filling it",
},
CacheTool {
id: "dotnet",
dir: "dotnet",
sharing: Sharing::Slot,
default_enabled: true,
variables: &[all("DOTNET_INSTALL_DIR", VarValue::Dir)],
note: "SDKs setup-dotnet installs; per slot because an install replaces the shared dotnet \
host",
},
CacheTool {
id: "npm",
dir: "npm",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[all("npm_config_cache", VarValue::Dir)],
note: "npm's cacache and npx",
},
CacheTool {
id: "pnpm",
dir: "pnpm-store",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[
all("npm_config_store_dir", VarValue::Dir),
all("PNPM_CONFIG_STORE_DIR", VarValue::Dir),
],
note: "the content-addressed store: pnpm 9 and 10 read npm_config_store_dir, pnpm 11 and \
later PNPM_CONFIG_STORE_DIR; npm 12 warns that store-dir is unknown",
},
CacheTool {
id: "yarn",
dir: "yarn",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[all("YARN_GLOBAL_FOLDER", VarValue::Dir)],
note: "Yarn 2+ global cache; YARN_CACHE_FOLDER is left alone because it would override a \
zero-install project's committed cache",
},
CacheTool {
id: "bun",
dir: "bun",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[all("BUN_INSTALL_CACHE_DIR", VarValue::Dir)],
note: "Bun's package cache",
},
CacheTool {
id: "deno",
dir: "deno",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[all("DENO_DIR", VarValue::Dir)],
note: "Deno's module and npm cache (it also keeps localStorage there)",
},
CacheTool {
id: "node-gyp",
dir: "node-gyp",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[
all("npm_config_devdir", VarValue::Dir),
all("npm_package_config_node_gyp_devdir", VarValue::Dir),
],
note: "Node headers node-gyp downloads for native modules",
},
CacheTool {
id: "electron",
dir: "electron",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[
all("electron_config_cache", VarValue::Dir),
all("ELECTRON_CACHE", VarValue::Dir),
],
note: "Electron release downloads",
},
CacheTool {
id: "electron-builder",
dir: "electron-builder",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[all("ELECTRON_BUILDER_CACHE", VarValue::Dir)],
note: "NSIS, winCodeSign, AppImage and other packaging tools",
},
CacheTool {
id: "playwright",
dir: "ms-playwright",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[
all("PLAYWRIGHT_BROWSERS_PATH", VarValue::Dir),
all("PLAYWRIGHT_SKIP_BROWSER_GC", VarValue::Literal("1")),
],
note: "Playwright browsers",
},
CacheTool {
id: "cypress",
dir: "cypress",
sharing: Sharing::Slot,
default_enabled: true,
variables: &[all("CYPRESS_CACHE_FOLDER", VarValue::Dir)],
note: "Cypress binaries; per slot because cypress install takes no lock",
},
CacheTool {
id: "nuget",
dir: "nuget",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[
all("NUGET_PACKAGES", VarValue::Sub("packages")),
all("NUGET_HTTP_CACHE_PATH", VarValue::Sub("http-cache")),
all("NUGET_PLUGINS_CACHE_PATH", VarValue::Sub("plugins-cache")),
all("NUGET_SCRATCH", VarValue::Sub("scratch")),
],
note: "global packages and HTTP cache",
},
CacheTool {
id: "pip",
dir: "pip",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[all("PIP_CACHE_DIR", VarValue::Dir)],
note: "pip's HTTP and wheel cache",
},
CacheTool {
id: "uv",
dir: "uv",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[all("UV_CACHE_DIR", VarValue::Dir)],
note: "uv's cache",
},
CacheTool {
id: "go",
dir: "go",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[
all("GOMODCACHE", VarValue::Sub("mod")),
all("GOCACHE", VarValue::Sub("build")),
],
note: "Go module and build caches",
},
CacheTool {
id: "composer",
dir: "composer",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[all("COMPOSER_CACHE_DIR", VarValue::Dir)],
note: "Composer's download cache",
},
CacheTool {
id: "cocoapods",
dir: "cocoapods",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[unix("CP_CACHE_DIR", VarValue::Dir)],
note: "CocoaPods download cache",
},
CacheTool {
id: "ccache",
dir: "ccache",
sharing: Sharing::Namespace,
default_enabled: true,
variables: &[all("CCACHE_DIR", VarValue::Dir)],
note: "ccache objects",
},
CacheTool {
id: "poetry",
dir: "poetry",
sharing: Sharing::Slot,
default_enabled: false,
variables: &[all("POETRY_CACHE_DIR", VarValue::Dir)],
note: "opt-in: Poetry also keeps virtualenvs in its cache directory, one per workspace path",
},
CacheTool {
id: "cargo",
dir: "cargo",
sharing: Sharing::Namespace,
default_enabled: false,
variables: &[all("CARGO_HOME", VarValue::Dir)],
note: "opt-in: CARGO_HOME also holds config, registry credentials and installed binaries",
},
CacheTool {
id: "gradle",
dir: "gradle",
sharing: Sharing::Namespace,
default_enabled: false,
variables: &[all("GRADLE_USER_HOME", VarValue::Dir)],
note: "opt-in: GRADLE_USER_HOME also holds gradle.properties, init scripts and daemons",
},
CacheTool {
id: "maven",
dir: "maven",
sharing: Sharing::Slot,
default_enabled: false,
variables: &[all(
"MAVEN_ARGS",
VarValue::Template("-Dmaven.repo.local={dir}"),
)],
note: "opt-in: Maven 3.9+ local repository via MAVEN_ARGS, which a workflow's own \
MAVEN_ARGS replaces",
},
CacheTool {
id: "pub",
dir: "pub",
sharing: Sharing::Slot,
default_enabled: false,
variables: &[all("PUB_CACHE", VarValue::Dir)],
note: "opt-in: PUB_CACHE also holds globally activated Dart tools",
},
CacheTool {
id: "bundler",
dir: "bundler",
sharing: Sharing::Slot,
default_enabled: false,
variables: &[all("BUNDLE_USER_CACHE", VarValue::Dir)],
note: "opt-in: Bundler's download cache",
},
];
#[must_use]
pub fn tool(id: &str) -> Option<&'static CacheTool> {
TOOLS.iter().find(|tool| tool.id == id)
}
#[derive(Debug, thiserror::Error)]
pub enum CacheError {
#[error("{} is not valid TOML for caches: {message}", path.display())]
Parse { path: PathBuf, message: String },
#[error("`{0}` is not a cache this version knows; `host cache show` lists them")]
UnknownTool(String),
#[error(
"`{0}` is not a valid shared namespace name; use 1 to 64 lower-case letters, digits, \
`.`, `_` or `-`, not starting with `.`"
)]
InvalidNamespace(String),
#[error("`{0}` is not a repository (OWNER/REPO) or an organization")]
InvalidTarget(String),
#[error("the cache root {0} must be an absolute path without spaces")]
InvalidRoot(String),
#[error("cannot {action} {}: {source}", path.display())]
Io {
action: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
#[error("{0}")]
Lock(String),
}
impl CacheError {
#[must_use]
pub fn is_permission_denied(&self) -> bool {
matches!(self, Self::Io { source, .. } if source.kind() == io::ErrorKind::PermissionDenied)
}
}
fn io_error(action: &'static str, path: &Path) -> impl FnOnce(io::Error) -> CacheError + use<> {
let path = path.to_path_buf();
move |source| CacheError::Io {
action,
path,
source,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CacheConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_size_gib: Option<u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tools: BTreeMap<String, bool>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub targets: BTreeMap<String, TargetCacheConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TargetCacheConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tools: BTreeMap<String, bool>,
}
impl TargetCacheConfig {
fn is_empty(&self) -> bool {
self.enabled.is_none() && self.namespace.is_none() && self.tools.is_empty()
}
}
#[must_use]
pub fn config_path_in(config_dir: &Path) -> PathBuf {
config_dir.join(CONFIG_FILE)
}
#[must_use]
pub fn target_key(target: &ScaleTarget) -> String {
target.slug().to_ascii_lowercase()
}
impl CacheConfig {
pub fn parse(text: &str, path: &Path) -> Result<Self, CacheError> {
let config: Self = toml::from_str(text).map_err(|error| CacheError::Parse {
path: path.to_path_buf(),
message: error.message().to_owned(),
})?;
config.validate()?;
Ok(config)
}
pub fn load(path: &Path) -> Result<Self, CacheError> {
match fs::read_to_string(path) {
Ok(text) => Self::parse(&text, path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(source) => Err(CacheError::Io {
action: "read",
path: path.to_path_buf(),
source,
}),
}
}
fn validate(&self) -> Result<(), CacheError> {
if let Some(root) = &self.root {
validate_root_text(root)?;
}
let tool_ids = self
.tools
.keys()
.chain(self.targets.values().flat_map(|target| target.tools.keys()));
for id in tool_ids {
if tool(id).is_none() {
return Err(CacheError::UnknownTool(id.clone()));
}
}
for (key, target) in &self.targets {
ScaleTarget::repository(key)
.or_else(|_| ScaleTarget::organization(key))
.map_err(|_| CacheError::InvalidTarget(key.clone()))?;
if let Some(name) = &target.namespace {
validate_namespace_name(name)?;
}
}
Ok(())
}
pub fn save(&self, path: &Path) -> Result<(), CacheError> {
let mut config = self.clone();
config.targets.retain(|_, target| !target.is_empty());
config.validate()?;
let mut text = String::from(
"# Dependency caches for native runners. Edited by `runner-manager host cache`,\n\
# `repo cache` and `org cache`; see `host cache show`.\n",
);
text.push_str(
&toml::to_string(&config).map_err(|error| CacheError::Parse {
path: path.to_path_buf(),
message: error.to_string(),
})?,
);
write_atomically(path, text.as_bytes())
}
#[must_use]
pub fn target(&self, target: &ScaleTarget) -> Option<&TargetCacheConfig> {
self.targets.get(&target_key(target))
}
pub fn target_mut(&mut self, target: &ScaleTarget) -> &mut TargetCacheConfig {
self.targets.entry(target_key(target)).or_default()
}
#[must_use]
pub fn max_bytes(&self) -> Option<u64> {
match self.max_size_gib.unwrap_or(DEFAULT_MAX_SIZE_GIB) {
0 => None,
gib => Some(gib.saturating_mul(GIB)),
}
}
#[must_use]
pub fn host_enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
}
fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), CacheError> {
crate::host_fitness::write_atomically(path, bytes).map_err(io_error("write", path))
}
pub fn validate_root_text(root: &str) -> Result<(), CacheError> {
let network = root.starts_with(r"\\") || root.starts_with("//");
if network || root.chars().any(char::is_whitespace) || !Path::new(root).is_absolute() {
return Err(CacheError::InvalidRoot(root.to_owned()));
}
Ok(())
}
pub fn validate_namespace_name(name: &str) -> Result<(), CacheError> {
let valid = !name.is_empty()
&& name.len() <= 64
&& !name.starts_with('.')
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
&& !is_windows_device_name(name);
if valid {
Ok(())
} else {
Err(CacheError::InvalidNamespace(name.to_owned()))
}
}
fn is_windows_device_name(segment: &str) -> bool {
let stem = segment
.split('.')
.next()
.unwrap_or(segment)
.to_ascii_uppercase();
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (stem.len() == 4
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
&& stem.as_bytes()[3].is_ascii_digit())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RootSource {
Configured,
RunnerRoot,
PlatformFallback,
}
impl RootSource {
#[must_use]
pub const fn as_token(self) -> &'static str {
match self {
Self::Configured => "configured",
Self::RunnerRoot => "runner-root",
Self::PlatformFallback => "platform-fallback",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedRoot {
pub path: PathBuf,
pub source: RootSource,
}
pub fn resolve_root(
config: &CacheConfig,
runner_root: Option<&Path>,
fallback: Option<PathBuf>,
) -> Result<ResolvedRoot, String> {
if let Some(root) = &config.root {
return Ok(ResolvedRoot {
path: PathBuf::from(root),
source: RootSource::Configured,
});
}
let usable = |path: &Path| !path.to_string_lossy().chars().any(char::is_whitespace);
if let Some(runner_root) = runner_root {
let candidate = runner_root.join(DEFAULT_ROOT_NAME);
if usable(&candidate) {
return Ok(ResolvedRoot {
path: candidate,
source: RootSource::RunnerRoot,
});
}
}
match fallback.filter(|path| usable(path) && path.is_absolute()) {
Some(path) => Ok(ResolvedRoot {
path,
source: RootSource::PlatformFallback,
}),
None => Err(
"no cache root without spaces could be derived; set one with \
`runner-manager host cache set-root --path <PATH>`"
.to_owned(),
),
}
}
#[must_use]
pub fn platform_fallback_root(app_paths: &AppPaths) -> Option<PathBuf> {
if cfg!(windows) {
crate::runner_root::default_runner_root(app_paths)
.ok()
.map(|root| root.as_path().join(DEFAULT_ROOT_NAME))
} else {
library_caches_beside(app_paths.runtime_dir())
.or_else(|| directories::BaseDirs::new().map(|dirs| dirs.cache_dir().to_path_buf()))
.map(|caches| caches.join("runner-manager"))
}
}
fn library_caches_beside(path: &Path) -> Option<PathBuf> {
path.ancestors().find_map(|ancestor| {
let library = ancestor.parent()?;
(ancestor.file_name()? == "Application Support" && library.file_name()? == "Library")
.then(|| library.join("Caches"))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Namespace {
Repository { owner: String, repo: String },
Organization(String),
Shared(String),
}
impl Namespace {
#[must_use]
pub fn of(target: &ScaleTarget) -> Self {
match target {
ScaleTarget::Repository(repository) => Self::Repository {
owner: repository.owner().to_ascii_lowercase(),
repo: repository.repo().to_ascii_lowercase(),
},
ScaleTarget::Organization(org) => Self::Organization(org.as_str().to_ascii_lowercase()),
}
}
#[must_use]
pub fn segments(&self) -> [String; 2] {
match self {
Self::Repository { owner, repo } => [segment(owner), segment(repo)],
Self::Organization(org) => [ORG_PREFIX.to_owned(), segment(org)],
Self::Shared(name) => [SHARED_PREFIX.to_owned(), segment(name)],
}
}
#[must_use]
pub fn dir(&self, root: &Path) -> PathBuf {
let [first, second] = self.segments();
root.join(first).join(second)
}
#[must_use]
pub fn label(&self) -> String {
self.segments().join("/")
}
}
fn segment(raw: &str) -> String {
let cleaned = portable_segment(raw);
if cleaned.len() <= MAX_SEGMENT_LEN {
return cleaned;
}
let digest = hex::encode(sha2::Sha256::digest(raw.as_bytes()));
format!(
"{}-{}",
&cleaned[..MAX_SEGMENT_LEN - SEGMENT_DIGEST_LEN - 1],
&digest[..SEGMENT_DIGEST_LEN]
)
}
const MAX_SEGMENT_LEN: usize = 32;
const SEGMENT_DIGEST_LEN: usize = 8;
fn portable_segment(raw: &str) -> String {
let cleaned: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect();
let cleaned = match cleaned.as_str() {
"" | "." | ".." => format!("_{cleaned}"),
_ => cleaned,
};
if is_windows_device_name(&cleaned) || cleaned.ends_with('.') {
format!("{cleaned}_")
} else {
cleaned
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSource {
Default,
Host,
Target,
Operator,
}
impl ToolSource {
#[must_use]
pub const fn as_token(self) -> &'static str {
match self {
Self::Default => "default",
Self::Host => "host",
Self::Target => "this target",
Self::Operator => "set by runner.env or the service",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolState {
pub tool: &'static CacheTool,
pub enabled: bool,
pub source: ToolSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selection {
pub disabled: Option<&'static str>,
pub namespace: Namespace,
pub tools: Vec<ToolState>,
}
impl Selection {
pub fn enabled_tools(&self) -> impl Iterator<Item = &'static CacheTool> + '_ {
self.tools
.iter()
.filter(|state| state.enabled)
.map(|state| state.tool)
}
}
pub const ORGANIZATION_OPT_IN: &str = "organization policies accept jobs from every repository \
in the organization, so they get a cache only with `org cache set-enabled ORG --enabled \
true`";
#[must_use]
pub fn select(config: &CacheConfig, target: &ScaleTarget, platform: RunnerPlatform) -> Selection {
let own = config.target(target);
let namespace = own
.and_then(|own| own.namespace.clone())
.map_or_else(|| Namespace::of(target), Namespace::Shared);
let disabled = if !config.host_enabled() {
Some("caches are turned off on this host")
} else {
match (own.and_then(|own| own.enabled), target) {
(Some(true), _) => None,
(Some(false), _) => Some("caches are turned off for this target"),
(None, ScaleTarget::Repository(_)) => None,
(None, ScaleTarget::Organization(_)) => Some(ORGANIZATION_OPT_IN),
}
};
let tools = TOOLS
.iter()
.filter(|tool| tool.applies_to(platform))
.map(|tool| {
let mut state = ToolState {
tool,
enabled: tool.default_enabled,
source: ToolSource::Default,
};
if let Some(enabled) = config.tools.get(tool.id) {
state.enabled = *enabled;
state.source = ToolSource::Host;
}
if let Some(enabled) = own.and_then(|own| own.tools.get(tool.id)) {
state.enabled = *enabled;
state.source = ToolSource::Target;
}
state
})
.collect();
Selection {
disabled,
namespace,
tools,
}
}
#[must_use]
fn tool_dir(tool: &CacheTool, namespace_dir: &Path, slot_dir: &Path) -> PathBuf {
match tool.sharing {
Sharing::Namespace => namespace_dir.join(tool.dir),
Sharing::Slot => slot_dir.join(tool.dir),
}
}
fn render(variable: &CacheVariable, dir: &Path) -> (OsString, Option<PathBuf>) {
match variable.value {
VarValue::Dir => (dir.as_os_str().to_owned(), Some(dir.to_path_buf())),
VarValue::Sub(sub) => {
let path = dir.join(sub);
(path.as_os_str().to_owned(), Some(path))
}
VarValue::Literal(value) => (OsString::from(value), None),
VarValue::Template(template) => (
OsString::from(template.replace("{dir}", &dir.to_string_lossy())),
Some(dir.to_path_buf()),
),
}
}
#[must_use]
pub fn environment<'a>(
tools: impl IntoIterator<Item = &'a CacheTool>,
namespace_dir: &Path,
slot_dir: &Path,
platform: RunnerPlatform,
) -> (Vec<(&'static str, OsString)>, Vec<PathBuf>) {
let mut variables = Vec::new();
let mut dirs = Vec::new();
for tool in tools {
let dir = tool_dir(tool, namespace_dir, slot_dir);
for variable in tool.variables_on(platform) {
let (value, named) = render(variable, &dir);
variables.push((variable.name, value));
dirs.extend(named);
}
}
(variables, dirs)
}
impl Selection {
pub fn defer_to_operator(&mut self, platform: RunnerPlatform, is_set: impl Fn(&str) -> bool) {
for state in &mut self.tools {
if state.enabled && state.tool.variables_on(platform).any(|v| is_set(v.name)) {
state.enabled = false;
state.source = ToolSource::Operator;
}
}
}
}
#[must_use]
pub fn inherited(name: &str) -> bool {
std::env::vars_os().any(|(key, value)| {
!value.is_empty()
&& key
.to_str()
.is_some_and(|key| key.eq_ignore_ascii_case(name))
})
}
#[must_use]
pub fn runtime_holds_runner(runtime: &Path) -> bool {
match fs::metadata(runtime.join("bin")) {
Ok(metadata) => metadata.is_dir(),
Err(error) => error.kind() != io::ErrorKind::NotFound,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchCaches {
pub namespace_dir: PathBuf,
pub slot: u32,
pub variables: Vec<(&'static str, OsString)>,
}
fn lock_cache_root(root: &Path) -> Result<HostLock, CacheError> {
HostLock::acquire_at(
&root.join(LockKind::DependencyCache.file_name()),
LockKind::DependencyCache,
LOCK_WAIT,
)
.map_err(|error| match error {
crate::lock::LockError::Io { path, source } => CacheError::Io {
action: "lock",
path,
source,
},
other => CacheError::Lock(other.to_string()),
})
}
pub fn prepare_launch(
root: &Path,
selection: &Selection,
runtime: &Path,
platform: RunnerPlatform,
holds_runner: &dyn Fn(&Path) -> bool,
) -> Result<LaunchCaches, CacheError> {
fs::create_dir_all(root).map_err(io_error("create", root))?;
let namespace_dir = selection.namespace.dir(root);
let slots = namespace_dir.join(SLOTS_DIR);
let slot = {
let _lock = lock_cache_root(root)?;
fs::create_dir_all(&slots).map_err(io_error("create", &slots))?;
let slot = free_slot(&slots, runtime, holds_runner);
let lease = slots.join(format!("{slot}.{LEASE_EXTENSION}"));
fs::write(&lease, runtime.to_string_lossy().as_bytes())
.map_err(io_error("write", &lease))?;
let marker = namespace_dir.join(LAST_USED_FILE);
fs::write(&marker, Utc::now().to_rfc3339().as_bytes())
.map_err(io_error("write", &marker))?;
slot
};
let slot_dir = slots.join(slot.to_string());
let (variables, dirs) = environment(
selection.enabled_tools(),
&namespace_dir,
&slot_dir,
platform,
);
for dir in dirs {
fs::create_dir_all(&dir).map_err(io_error("create", &dir))?;
}
Ok(LaunchCaches {
namespace_dir,
slot,
variables,
})
}
fn free_slot(slots: &Path, runtime: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> u32 {
let own = runtime.to_string_lossy();
(1..)
.find(
|slot| match fs::read_to_string(slots.join(format!("{slot}.{LEASE_EXTENSION}"))) {
Err(error) => error.kind() == io::ErrorKind::NotFound,
Ok(holder) => holder == own || !holds_runner(Path::new(&holder)),
},
)
.expect("an unbounded range has a free slot")
}
fn namespace_in_use(namespace_dir: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> bool {
let Ok(entries) = fs::read_dir(namespace_dir.join(SLOTS_DIR)) else {
return false;
};
entries.flatten().any(|entry| {
let path = entry.path();
path.extension().is_some_and(|ext| ext == LEASE_EXTENSION)
&& fs::read_to_string(&path).map_or(true, |holder| holds_runner(Path::new(&holder)))
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NamespaceUsage {
pub name: String,
pub bytes: u64,
pub last_used: Option<DateTime<Utc>>,
pub in_use: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheUsage {
pub measured_at: DateTime<Utc>,
pub total_bytes: u64,
pub max_bytes: Option<u64>,
pub namespaces: Vec<NamespaceUsage>,
pub pruned: Vec<String>,
}
impl CacheUsage {
#[must_use]
pub fn read(root: &Path) -> Option<Self> {
let bytes = fs::read(root.join(USAGE_FILE)).ok()?;
serde_json::from_slice(&bytes).ok()
}
}
fn tree_size(path: &Path) -> u64 {
let mut total = 0_u64;
let mut pending = vec![path.to_path_buf()];
while let Some(dir) = pending.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_dir() {
pending.push(entry.path());
} else if metadata.is_file() {
total = total.saturating_add(metadata.len());
}
}
}
total
}
fn modified(path: &Path) -> Option<SystemTime> {
fs::metadata(path).and_then(|m| m.modified()).ok()
}
fn namespace_dirs(root: &Path) -> Vec<(String, PathBuf)> {
let mut found = Vec::new();
let Ok(first) = fs::read_dir(root) else {
return found;
};
for outer in first.flatten() {
let outer_name = outer.file_name().to_string_lossy().into_owned();
if outer_name.starts_with('.') || !outer.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
let Ok(second) = fs::read_dir(outer.path()) else {
continue;
};
for inner in second.flatten() {
let path = inner.path();
let marked = path.join(LAST_USED_FILE).is_file() || path.join(SLOTS_DIR).is_dir();
if marked && inner.file_type().is_ok_and(|t| t.is_dir()) {
let name = format!("{outer_name}/{}", inner.file_name().to_string_lossy());
found.push((name, path));
}
}
}
found.sort();
found
}
#[must_use]
pub(crate) fn measure(root: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> Vec<NamespaceUsage> {
namespace_dirs(root)
.into_iter()
.map(|(name, dir)| NamespaceUsage {
bytes: tree_size(&dir),
last_used: modified(&dir.join(LAST_USED_FILE))
.or_else(|| modified(&dir))
.map(DateTime::<Utc>::from),
in_use: namespace_in_use(&dir, holds_runner),
name,
})
.collect()
}
#[must_use]
pub(crate) fn plan_prune(namespaces: &[NamespaceUsage], max_bytes: Option<u64>) -> Vec<String> {
let Some(max) = max_bytes else {
return Vec::new();
};
let mut total: u64 = namespaces.iter().map(|ns| ns.bytes).sum();
let mut idle: Vec<&NamespaceUsage> = namespaces.iter().filter(|ns| !ns.in_use).collect();
idle.sort_by(|a, b| a.last_used.cmp(&b.last_used).then(a.name.cmp(&b.name)));
let mut chosen = Vec::new();
for namespace in idle {
if total <= max {
break;
}
total = total.saturating_sub(namespace.bytes);
chosen.push(namespace.name.clone());
}
chosen
}
pub fn prune(
root: &Path,
max_bytes: Option<u64>,
holds_runner: &dyn Fn(&Path) -> bool,
) -> Result<CacheUsage, CacheError> {
empty_trash(root);
let mut namespaces = measure(root, holds_runner);
let planned = plan_prune(&namespaces, max_bytes);
let mut pruned = Vec::new();
if !planned.is_empty() {
let trash = root.join(TRASH_DIR);
fs::create_dir_all(&trash).map_err(io_error("create", &trash))?;
let _lock = lock_cache_root(root)?;
for name in planned {
let dir = root.join(&name);
if namespace_in_use(&dir, holds_runner) {
continue;
}
let aside = trash.join(uuid::Uuid::new_v4().simple().to_string());
if fs::rename(&dir, &aside).is_ok() {
pruned.push(name);
}
}
}
empty_trash(root);
namespaces.retain(|ns| !pruned.contains(&ns.name));
let usage = CacheUsage {
measured_at: Utc::now(),
total_bytes: namespaces.iter().map(|ns| ns.bytes).sum(),
max_bytes,
namespaces,
pruned,
};
let json = serde_json::to_vec_pretty(&usage).map_err(|error| CacheError::Io {
action: "encode",
path: root.join(USAGE_FILE),
source: io::Error::other(error),
})?;
write_atomically(&root.join(USAGE_FILE), &json)?;
Ok(usage)
}
fn empty_trash(root: &Path) {
let Ok(entries) = fs::read_dir(root.join(TRASH_DIR)) else {
return;
};
for entry in entries.flatten() {
let _ = remove_tree(&entry.path());
}
}
fn remove_tree(path: &Path) -> io::Result<()> {
#[cfg(windows)]
{
remove_dir_all::remove_dir_all(path)
}
#[cfg(not(windows))]
{
match fs::remove_dir_all(path) {
Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
open_directories(path);
fs::remove_dir_all(path)
}
other => other,
}
}
}
#[cfg(not(windows))]
fn open_directories(path: &Path) {
use std::os::unix::fs::PermissionsExt as _;
let mut pending = vec![path.to_path_buf()];
while let Some(current) = pending.pop() {
let Ok(metadata) = fs::symlink_metadata(¤t) else {
continue;
};
if !metadata.is_dir() {
continue;
}
let mode = metadata.permissions().mode();
if mode & 0o700 != 0o700 {
let _ = fs::set_permissions(¤t, fs::Permissions::from_mode(mode | 0o700));
}
if let Ok(entries) = fs::read_dir(¤t) {
pending.extend(entries.flatten().map(|entry| entry.path()));
}
}
}
#[must_use]
pub fn tool_ids() -> Vec<&'static str> {
TOOLS.iter().map(|tool| tool.id).collect()
}
#[must_use]
pub fn human_bytes(bytes: u64) -> String {
#[allow(clippy::cast_precision_loss)]
let value = bytes as f64;
if bytes >= GIB {
format!("{:.1} GiB", value / GIB as f64)
} else if bytes >= 1024 * 1024 {
format!("{:.0} MiB", value / (1024.0 * 1024.0))
} else {
format!("{:.0} KiB", value / 1024.0)
}
}
#[cfg(test)]
mod tests;