#![deny(missing_docs)]
use eyre::{Result, bail, eyre};
use log::warn;
use reqwest::header::HeaderValue;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use url::Url;
mod agent;
mod client;
mod local;
mod remote_http;
mod remote_s3;
mod sigv4;
mod uploads;
pub use agent::{
AGENT_PROTOCOL_VERSION, AgentEvent, AgentEventObserver, AgentRemoteCache, AgentRequest,
AgentResponse, AgentStats, CacheAgent, CompilerStats, FileDigestCache, FileDigestScope,
FileIdentity, NoFileDigestCache, RecordedFileDigest, RestoreStats, is_task_identity,
task_manifest_actions,
};
pub use client::BlockingAgentClient;
pub use local::{LocalActionCache, LocalCas};
pub use mbx_cache_protocol::{
ACTION_RESULT_BATCH_MEDIA_TYPE, ACTION_RESULT_MEDIA_TYPE, ActionPrediction,
ActionResult as RemoteActionResult, BLOB_MEDIA_TYPE, BLOB_PACK_BLOBS_HEADER,
BLOB_PACK_BYTES_HEADER, BLOB_PACK_HEADER_BYTES, BLOB_PACK_MAGIC, BLOB_PACK_MEDIA_TYPE,
BLOB_PACK_RECEIPT_MEDIA_TYPE, CLIENT_METADATA_MEDIA_TYPE, Capabilities, CapabilityFeatures,
CapabilityLimits, CapabilityProtocol, CcMetadata, DIGEST_LIST_MEDIA_TYPE, DIRECTORY_MEDIA_TYPE,
Digest as CacheDigest, DigestAlgorithm, Directory as CacheDirectory,
DirectoryNode as CacheDirectoryNode, FileNode as CacheFileNode, MAX_ACTION_PREDICTION_PAYLOAD,
NAMESPACE_HEADER, PROTOCOL_HEADER, PROTOCOL_VERSION, RustcMetadata,
SymlinkNode as CacheSymlinkNode, TASK_ACTION_MANIFEST_MEDIA_TYPE, TaskActionManifest,
};
use remote_http::HttpRemoteCache;
#[cfg(feature = "fuzzing")]
#[doc(hidden)]
pub use remote_http::fuzz_decode_blob_pack;
pub(crate) use remote_http::{BlobPackLimits, blob_pack_chunk};
use remote_s3::S3RemoteCache;
pub use remote_s3::{S3ConditionalWrites, S3RemoteCacheConfig};
pub use sigv4::S3Credentials;
const MAX_REMOTE_JSON_BYTES: u64 = 16 * 1024 * 1024;
const MAX_ETAG_BYTES: usize = 256;
const MAX_REMOTE_BLOB_BYTES: u64 = 5 * 1024 * 1024 * 1024;
const MAX_STAGED_BLOB_PACK_BYTES: u64 = 256 * 1024 * 1024;
const MAX_STAGED_BLOB_PACK_ITEMS: usize = 2 * 1024;
const BLOB_PACK_TIMEOUT_BYTES_PER_UNIT: u64 = MAX_STAGED_BLOB_PACK_BYTES / 4;
const BLOB_PACK_TIMEOUT_ITEMS_PER_UNIT: usize = MAX_STAGED_BLOB_PACK_ITEMS / 4;
const PACK_STREAM_CHUNK_BYTES: usize = 64 * 1024;
const MAX_ACTION_BATCH_ITEMS: usize = 256;
const MAX_ACTION_RESULT_BYTES: u64 = 64 * 1024;
pub fn canonical_json(value: &impl Serialize) -> Result<Vec<u8>> {
Ok(mbx_cache_protocol::canonical_json(value)?)
}
#[derive(
Debug,
Clone,
Copy,
Serialize,
Deserialize,
Default,
strum::EnumString,
strum::Display,
PartialEq,
Eq,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum RemoteCacheMode {
#[default]
ReadWrite,
ReadOnly,
WriteOnly,
}
impl RemoteCacheMode {
pub fn reads(self) -> bool {
matches!(self, Self::ReadWrite | Self::ReadOnly)
}
pub fn writes(self) -> bool {
matches!(self, Self::ReadWrite | Self::WriteOnly)
}
}
pub struct RemoteCacheConfig {
pub base_url: Url,
pub namespace: String,
pub token: Option<String>,
pub token_file: Option<PathBuf>,
pub oidc_audience: Option<String>,
pub connect_timeout: Duration,
pub read_timeout: Duration,
pub download_timeout: Duration,
pub retries: i64,
}
pub enum BlobSource {
Bytes(Vec<u8>),
File(tempfile::NamedTempFile),
Path(PathBuf),
}
pub struct BlobUpload {
pub digest: CacheDigest,
pub source: BlobSource,
}
pub struct RemoteActionManifest {
pub bytes: Vec<u8>,
pub etag: String,
}
pub struct RemoteBlobPack {
_directory: tempfile::TempDir,
pub blobs: Vec<(CacheDigest, PathBuf)>,
pub requests: u64,
pub requested: Vec<CacheDigest>,
pub blob_count: u64,
pub payload_bytes: u64,
pub framed_bytes: u64,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct BlobPackReceipt {
#[serde(default)]
pub created: u64,
#[serde(default)]
pub existing: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManifestPutOutcome {
Stored,
PreconditionFailed,
}
pub struct RemoteCacheClient {
backend: Backend,
}
enum Backend {
Http(HttpRemoteCache),
S3(S3RemoteCache),
}
impl RemoteCacheClient {
pub fn new(config: RemoteCacheConfig) -> Result<Self> {
Ok(Self {
backend: Backend::Http(HttpRemoteCache::new(config)?),
})
}
pub fn new_s3(config: S3RemoteCacheConfig) -> Result<Self> {
Ok(Self {
backend: Backend::S3(S3RemoteCache::new(config)?),
})
}
pub async fn check_connection(&self) -> Result<()> {
match &self.backend {
Backend::Http(client) => client.check_connection().await,
Backend::S3(store) => store.check_connection().await,
}
}
pub async fn get_blob_pack(
&self,
digests: &[CacheDigest],
staging_dir: &Path,
) -> Result<Option<RemoteBlobPack>> {
match &self.backend {
Backend::Http(client) => client.get_blob_pack(digests, staging_dir).await,
Backend::S3(store) => store.get_blob_pack(digests, staging_dir).await,
}
}
pub(crate) async fn get_blob_pack_with_limit(
&self,
digests: &[CacheDigest],
staging_dir: &Path,
max_bytes: u64,
) -> Result<Option<RemoteBlobPack>> {
match &self.backend {
Backend::Http(client) => {
client
.get_blob_pack_with_limit(digests, staging_dir, max_bytes)
.await
}
Backend::S3(store) => {
store
.get_blob_pack_with_limit(digests, staging_dir, max_bytes)
.await
}
}
}
pub async fn get_action_result(
&self,
action: &CacheDigest,
) -> Result<Option<RemoteActionResult>> {
match &self.backend {
Backend::Http(client) => client.get_action_result(action).await,
Backend::S3(store) => store.get_action_result(action).await,
}
}
pub(crate) async fn action_batch_limit(&self) -> Result<Option<usize>> {
match &self.backend {
Backend::Http(client) => client.action_batch_limit().await,
Backend::S3(store) => store.action_batch_limit().await,
}
}
pub async fn get_action_results(
&self,
actions: &[CacheDigest],
) -> Result<Option<Vec<RemoteActionResult>>> {
match &self.backend {
Backend::Http(client) => client.get_action_results(actions).await,
Backend::S3(store) => store.get_action_results(actions).await,
}
}
pub async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
match &self.backend {
Backend::Http(client) => client.put_action_result(result).await,
Backend::S3(store) => store.put_action_result(result).await,
}
}
pub async fn get_action_manifest(
&self,
key: &CacheDigest,
) -> Result<Option<RemoteActionManifest>> {
match &self.backend {
Backend::Http(client) => client.get_action_manifest(key).await,
Backend::S3(store) => store.get_action_manifest(key).await,
}
}
pub async fn put_action_manifest(
&self,
key: &CacheDigest,
bytes: &[u8],
expected_etag: Option<&str>,
) -> Result<ManifestPutOutcome> {
match &self.backend {
Backend::Http(client) => client.put_action_manifest(key, bytes, expected_etag).await,
Backend::S3(store) => store.put_action_manifest(key, bytes, expected_etag).await,
}
}
pub async fn get_blob(
&self,
digest: &CacheDigest,
media_type: &'static str,
) -> Result<Vec<u8>> {
match &self.backend {
Backend::Http(client) => client.get_blob(digest, media_type).await,
Backend::S3(store) => store.get_blob(digest, media_type).await,
}
}
pub async fn get_blob_file(
&self,
digest: &CacheDigest,
staging_dir: &Path,
) -> Result<tempfile::NamedTempFile> {
match &self.backend {
Backend::Http(client) => client.get_blob_file(digest, staging_dir).await,
Backend::S3(store) => store.get_blob_file(digest, staging_dir).await,
}
}
pub(crate) async fn blob_pack_upload_limits(&self) -> Result<Option<BlobPackLimits>> {
match &self.backend {
Backend::Http(client) => client.blob_pack_upload_limits().await,
Backend::S3(store) => store.blob_pack_upload_limits().await,
}
}
pub async fn put_blob_pack(&self, uploads: &[BlobUpload]) -> Result<Option<BlobPackReceipt>> {
match &self.backend {
Backend::Http(client) => client.put_blob_pack(uploads).await,
Backend::S3(store) => store.put_blob_pack(uploads).await,
}
}
pub async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
match &self.backend {
Backend::Http(client) => client.put_blob(upload).await,
Backend::S3(store) => store.put_blob(upload).await,
}
}
}
async fn read_bounded_json(response: reqwest::Response, what: &str) -> Result<Vec<u8>> {
read_json_within(response, what, MAX_REMOTE_JSON_BYTES).await
}
async fn read_json_within(response: reqwest::Response, what: &str, limit: u64) -> Result<Vec<u8>> {
if let Some(length) = response.content_length()
&& length > limit
{
bail!("remote cache {what} declared {length} bytes, over the {limit} byte limit");
}
let mut response = response;
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await? {
if bytes.len() as u64 + chunk.len() as u64 > limit {
bail!("remote cache {what} exceeded the {limit} byte limit");
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}
fn parse_strong_etag(value: Option<&HeaderValue>) -> Result<String> {
let value = value
.and_then(|value| value.to_str().ok())
.ok_or_else(|| eyre!("remote action manifest response is missing an ETag"))?;
if value.starts_with("W/") {
bail!("remote action manifest response has a weak ETag");
}
let etag = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.filter(|value| is_entity_tag(value))
.ok_or_else(|| eyre!("remote action manifest response has an invalid ETag"))?;
Ok(etag.to_owned())
}
fn quoted_etag(etag: &str) -> Result<HeaderValue> {
if !is_entity_tag(etag) {
bail!("invalid remote action manifest ETag");
}
Ok(HeaderValue::from_str(&format!("\"{etag}\""))?)
}
fn is_entity_tag(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_ETAG_BYTES
&& value.bytes().all(|byte| matches!(byte, 0x21 | 0x23..=0x7e))
}
fn retry_delays(retries: i64) -> impl Iterator<Item = Duration> {
[200u64, 1_000, 4_000, 15_000]
.into_iter()
.chain(std::iter::repeat(15_000))
.map(Duration::from_millis)
.map(|duration| {
let factor = 0.5 + rand::random::<f64>() * 0.5;
Duration::from_secs_f64(duration.as_secs_f64() * factor)
})
.take(retries.max(0) as usize)
}
fn is_dns_error(error: &(dyn std::error::Error + 'static)) -> bool {
let mut current = Some(error);
while let Some(source) = current {
if source.to_string() == "dns error" {
return true;
}
current = source.source();
}
false
}
#[derive(Debug)]
pub(crate) struct TransientRequest(pub(crate) &'static str);
impl std::fmt::Display for TransientRequest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.0)
}
}
impl std::error::Error for TransientRequest {}
fn is_transient(error: &eyre::Report) -> bool {
if is_dns_error(error.as_ref()) {
return false;
}
error.chain().any(|source| {
if source.downcast_ref::<TransientRequest>().is_some() {
return true;
}
let Some(error) = source.downcast_ref::<reqwest::Error>() else {
return false;
};
if error.is_timeout() || error.is_connect() || error.is_body() {
return true;
}
error.status().is_some_and(|status| {
let status = status.as_u16();
status == 408 || status == 429 || (500..600).contains(&status)
})
})
}
async fn retry_async<F, Fut, T>(verb: &str, url: &Url, retries: i64, mut operation: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let mut delays = retry_delays(retries);
let mut attempt = 1;
loop {
let started_at = Instant::now();
match operation().await {
Ok(value) => return Ok(value),
Err(error) if is_transient(&error) => {
let Some(delay) = delays.next() else {
return Err(error);
};
warn!(
"HTTP {verb} {url} attempt {attempt} failed after {:?} (transient): {error}; retrying in {delay:?}",
started_at.elapsed()
);
tokio::time::sleep(delay).await;
attempt += 1;
}
Err(error) => return Err(error),
}
}
}