1#![deny(missing_docs)]
30
31use eyre::{Result, bail, eyre};
32use log::warn;
33use reqwest::header::HeaderValue;
34use serde::{Deserialize, Serialize};
35use std::path::{Path, PathBuf};
36use std::time::{Duration, Instant};
37use url::Url;
38
39mod agent;
40mod client;
41mod local;
42mod remote_http;
43mod remote_s3;
44mod sigv4;
45mod uploads;
46
47pub use agent::{
48 AGENT_PROTOCOL_VERSION, AgentEvent, AgentEventObserver, AgentRemoteCache, AgentRequest,
49 AgentResponse, AgentStats, CacheAgent, CompilerStats, RestoreStats, is_task_identity,
50 task_manifest_actions,
51};
52pub use client::BlockingAgentClient;
53pub use local::{LocalActionCache, LocalCas};
54pub use mbx_cache_protocol::{
55 ACTION_RESULT_BATCH_MEDIA_TYPE, ACTION_RESULT_MEDIA_TYPE, ActionPrediction,
56 ActionResult as RemoteActionResult, BLOB_MEDIA_TYPE, BLOB_PACK_BLOBS_HEADER,
57 BLOB_PACK_BYTES_HEADER, BLOB_PACK_HEADER_BYTES, BLOB_PACK_MAGIC, BLOB_PACK_MEDIA_TYPE,
58 BLOB_PACK_RECEIPT_MEDIA_TYPE, CLIENT_METADATA_MEDIA_TYPE, Capabilities, CapabilityFeatures,
59 CapabilityLimits, CapabilityProtocol, CcMetadata, DIGEST_LIST_MEDIA_TYPE, DIRECTORY_MEDIA_TYPE,
60 Digest as CacheDigest, DigestAlgorithm, Directory as CacheDirectory,
61 DirectoryNode as CacheDirectoryNode, FileNode as CacheFileNode, NAMESPACE_HEADER,
62 PROTOCOL_HEADER, PROTOCOL_VERSION, RustcMetadata, SymlinkNode as CacheSymlinkNode,
63 TASK_ACTION_MANIFEST_MEDIA_TYPE, TaskActionManifest,
64};
65use remote_http::HttpRemoteCache;
66#[cfg(feature = "fuzzing")]
67#[doc(hidden)]
68pub use remote_http::fuzz_decode_blob_pack;
69pub(crate) use remote_http::{BlobPackLimits, blob_pack_chunk};
70use remote_s3::S3RemoteCache;
71pub use remote_s3::{S3ConditionalWrites, S3RemoteCacheConfig};
72pub use sigv4::S3Credentials;
73const MAX_REMOTE_JSON_BYTES: u64 = 16 * 1024 * 1024;
80const MAX_ETAG_BYTES: usize = 256;
85const MAX_REMOTE_BLOB_BYTES: u64 = 5 * 1024 * 1024 * 1024;
88const MAX_STAGED_BLOB_PACK_BYTES: u64 = 256 * 1024 * 1024;
89const MAX_STAGED_BLOB_PACK_ITEMS: usize = 2 * 1024;
90const BLOB_PACK_TIMEOUT_BYTES_PER_UNIT: u64 = MAX_STAGED_BLOB_PACK_BYTES / 4;
91const BLOB_PACK_TIMEOUT_ITEMS_PER_UNIT: usize = MAX_STAGED_BLOB_PACK_ITEMS / 4;
92const PACK_STREAM_CHUNK_BYTES: usize = 64 * 1024;
94const MAX_ACTION_BATCH_ITEMS: usize = 256;
100const MAX_ACTION_RESULT_BYTES: u64 = 64 * 1024;
102
103pub fn canonical_json(value: &impl Serialize) -> Result<Vec<u8>> {
108 Ok(mbx_cache_protocol::canonical_json(value)?)
109}
110
111#[derive(
112 Debug,
113 Clone,
114 Copy,
115 Serialize,
116 Deserialize,
117 Default,
118 strum::EnumString,
119 strum::Display,
120 PartialEq,
121 Eq,
122)]
123#[serde(rename_all = "kebab-case")]
124#[strum(serialize_all = "kebab-case")]
125pub enum RemoteCacheMode {
127 #[default]
129 ReadWrite,
130 ReadOnly,
132 WriteOnly,
134}
135
136impl RemoteCacheMode {
137 pub fn reads(self) -> bool {
139 matches!(self, Self::ReadWrite | Self::ReadOnly)
140 }
141
142 pub fn writes(self) -> bool {
144 matches!(self, Self::ReadWrite | Self::WriteOnly)
145 }
146}
147
148pub struct RemoteCacheConfig {
150 pub base_url: Url,
152 pub namespace: String,
154 pub token: Option<String>,
156 pub token_file: Option<PathBuf>,
158 pub oidc_audience: Option<String>,
160 pub connect_timeout: Duration,
162 pub read_timeout: Duration,
164 pub download_timeout: Duration,
173 pub retries: i64,
175}
176
177pub enum BlobSource {
179 Bytes(Vec<u8>),
181 File(tempfile::NamedTempFile),
183 Path(PathBuf),
185}
186
187pub struct BlobUpload {
189 pub digest: CacheDigest,
191 pub source: BlobSource,
193}
194
195pub struct RemoteActionManifest {
197 pub bytes: Vec<u8>,
199 pub etag: String,
201}
202
203pub struct RemoteBlobPack {
205 _directory: tempfile::TempDir,
206 pub blobs: Vec<(CacheDigest, PathBuf)>,
208 pub requests: u64,
210 pub requested: Vec<CacheDigest>,
212 pub blob_count: u64,
214 pub payload_bytes: u64,
216 pub framed_bytes: u64,
218}
219
220#[derive(Debug, Clone, Copy, Deserialize)]
222pub struct BlobPackReceipt {
223 #[serde(default)]
225 pub created: u64,
226 #[serde(default)]
228 pub existing: u64,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum ManifestPutOutcome {
234 Stored,
236 PreconditionFailed,
238}
239
240pub struct RemoteCacheClient {
250 backend: Backend,
251}
252
253enum Backend {
259 Http(HttpRemoteCache),
260 S3(S3RemoteCache),
261}
262
263impl RemoteCacheClient {
264 pub fn new(config: RemoteCacheConfig) -> Result<Self> {
266 Ok(Self {
267 backend: Backend::Http(HttpRemoteCache::new(config)?),
268 })
269 }
270
271 pub fn new_s3(config: S3RemoteCacheConfig) -> Result<Self> {
276 Ok(Self {
277 backend: Backend::S3(S3RemoteCache::new(config)?),
278 })
279 }
280
281 pub async fn check_connection(&self) -> Result<()> {
287 match &self.backend {
288 Backend::Http(client) => client.check_connection().await,
289 Backend::S3(store) => store.check_connection().await,
290 }
291 }
292
293 pub async fn get_blob_pack(
299 &self,
300 digests: &[CacheDigest],
301 staging_dir: &Path,
302 ) -> Result<Option<RemoteBlobPack>> {
303 match &self.backend {
304 Backend::Http(client) => client.get_blob_pack(digests, staging_dir).await,
305 Backend::S3(store) => store.get_blob_pack(digests, staging_dir).await,
306 }
307 }
308
309 pub(crate) async fn get_blob_pack_with_limit(
310 &self,
311 digests: &[CacheDigest],
312 staging_dir: &Path,
313 max_bytes: u64,
314 ) -> Result<Option<RemoteBlobPack>> {
315 match &self.backend {
316 Backend::Http(client) => {
317 client
318 .get_blob_pack_with_limit(digests, staging_dir, max_bytes)
319 .await
320 }
321 Backend::S3(store) => {
322 store
323 .get_blob_pack_with_limit(digests, staging_dir, max_bytes)
324 .await
325 }
326 }
327 }
328
329 pub async fn get_action_result(
331 &self,
332 action: &CacheDigest,
333 ) -> Result<Option<RemoteActionResult>> {
334 match &self.backend {
335 Backend::Http(client) => client.get_action_result(action).await,
336 Backend::S3(store) => store.get_action_result(action).await,
337 }
338 }
339
340 pub(crate) async fn action_batch_limit(&self) -> Result<Option<usize>> {
344 match &self.backend {
345 Backend::Http(client) => client.action_batch_limit().await,
346 Backend::S3(store) => store.action_batch_limit().await,
347 }
348 }
349
350 pub async fn get_action_results(
357 &self,
358 actions: &[CacheDigest],
359 ) -> Result<Option<Vec<RemoteActionResult>>> {
360 match &self.backend {
361 Backend::Http(client) => client.get_action_results(actions).await,
362 Backend::S3(store) => store.get_action_results(actions).await,
363 }
364 }
365
366 pub async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
368 match &self.backend {
369 Backend::Http(client) => client.put_action_result(result).await,
370 Backend::S3(store) => store.put_action_result(result).await,
371 }
372 }
373
374 pub async fn get_action_manifest(
376 &self,
377 key: &CacheDigest,
378 ) -> Result<Option<RemoteActionManifest>> {
379 match &self.backend {
380 Backend::Http(client) => client.get_action_manifest(key).await,
381 Backend::S3(store) => store.get_action_manifest(key).await,
382 }
383 }
384
385 pub async fn put_action_manifest(
387 &self,
388 key: &CacheDigest,
389 bytes: &[u8],
390 expected_etag: Option<&str>,
391 ) -> Result<ManifestPutOutcome> {
392 match &self.backend {
393 Backend::Http(client) => client.put_action_manifest(key, bytes, expected_etag).await,
394 Backend::S3(store) => store.put_action_manifest(key, bytes, expected_etag).await,
395 }
396 }
397
398 pub async fn get_blob(
400 &self,
401 digest: &CacheDigest,
402 media_type: &'static str,
403 ) -> Result<Vec<u8>> {
404 match &self.backend {
405 Backend::Http(client) => client.get_blob(digest, media_type).await,
406 Backend::S3(store) => store.get_blob(digest, media_type).await,
407 }
408 }
409
410 pub async fn get_blob_file(
412 &self,
413 digest: &CacheDigest,
414 staging_dir: &Path,
415 ) -> Result<tempfile::NamedTempFile> {
416 match &self.backend {
417 Backend::Http(client) => client.get_blob_file(digest, staging_dir).await,
418 Backend::S3(store) => store.get_blob_file(digest, staging_dir).await,
419 }
420 }
421
422 pub(crate) async fn blob_pack_upload_limits(&self) -> Result<Option<BlobPackLimits>> {
426 match &self.backend {
427 Backend::Http(client) => client.blob_pack_upload_limits().await,
428 Backend::S3(store) => store.blob_pack_upload_limits().await,
429 }
430 }
431
432 pub async fn put_blob_pack(&self, uploads: &[BlobUpload]) -> Result<Option<BlobPackReceipt>> {
440 match &self.backend {
441 Backend::Http(client) => client.put_blob_pack(uploads).await,
442 Backend::S3(store) => store.put_blob_pack(uploads).await,
443 }
444 }
445
446 pub async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
448 match &self.backend {
449 Backend::Http(client) => client.put_blob(upload).await,
450 Backend::S3(store) => store.put_blob(upload).await,
451 }
452 }
453}
454
455async fn read_bounded_json(response: reqwest::Response, what: &str) -> Result<Vec<u8>> {
461 read_json_within(response, what, MAX_REMOTE_JSON_BYTES).await
462}
463
464async fn read_json_within(response: reqwest::Response, what: &str, limit: u64) -> Result<Vec<u8>> {
465 if let Some(length) = response.content_length()
466 && length > limit
467 {
468 bail!("remote cache {what} declared {length} bytes, over the {limit} byte limit");
469 }
470 let mut response = response;
471 let mut bytes = Vec::new();
472 while let Some(chunk) = response.chunk().await? {
473 if bytes.len() as u64 + chunk.len() as u64 > limit {
474 bail!("remote cache {what} exceeded the {limit} byte limit");
475 }
476 bytes.extend_from_slice(&chunk);
477 }
478 Ok(bytes)
479}
480fn parse_strong_etag(value: Option<&HeaderValue>) -> Result<String> {
489 let value = value
490 .and_then(|value| value.to_str().ok())
491 .ok_or_else(|| eyre!("remote action manifest response is missing an ETag"))?;
492 if value.starts_with("W/") {
493 bail!("remote action manifest response has a weak ETag");
496 }
497 let etag = value
498 .strip_prefix('"')
499 .and_then(|value| value.strip_suffix('"'))
500 .filter(|value| is_entity_tag(value))
501 .ok_or_else(|| eyre!("remote action manifest response has an invalid ETag"))?;
502 Ok(etag.to_owned())
503}
504
505fn quoted_etag(etag: &str) -> Result<HeaderValue> {
506 if !is_entity_tag(etag) {
507 bail!("invalid remote action manifest ETag");
508 }
509 Ok(HeaderValue::from_str(&format!("\"{etag}\""))?)
510}
511
512fn is_entity_tag(value: &str) -> bool {
517 !value.is_empty()
518 && value.len() <= MAX_ETAG_BYTES
519 && value.bytes().all(|byte| matches!(byte, 0x21 | 0x23..=0x7e))
520}
521fn retry_delays(retries: i64) -> impl Iterator<Item = Duration> {
522 [200u64, 1_000, 4_000, 15_000]
523 .into_iter()
524 .chain(std::iter::repeat(15_000))
525 .map(Duration::from_millis)
526 .map(|duration| {
527 let factor = 0.5 + rand::random::<f64>() * 0.5;
528 Duration::from_secs_f64(duration.as_secs_f64() * factor)
529 })
530 .take(retries.max(0) as usize)
531}
532
533fn is_dns_error(error: &(dyn std::error::Error + 'static)) -> bool {
537 let mut current = Some(error);
538 while let Some(source) = current {
539 if source.to_string() == "dns error" {
540 return true;
541 }
542 current = source.source();
543 }
544 false
545}
546
547#[derive(Debug)]
554pub(crate) struct TransientRequest(pub(crate) &'static str);
555
556impl std::fmt::Display for TransientRequest {
557 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558 formatter.write_str(self.0)
559 }
560}
561
562impl std::error::Error for TransientRequest {}
563
564fn is_transient(error: &eyre::Report) -> bool {
565 if is_dns_error(error.as_ref()) {
568 return false;
569 }
570 error.chain().any(|source| {
571 if source.downcast_ref::<TransientRequest>().is_some() {
572 return true;
573 }
574 let Some(error) = source.downcast_ref::<reqwest::Error>() else {
575 return false;
576 };
577 if error.is_timeout() || error.is_connect() || error.is_body() {
578 return true;
579 }
580 error.status().is_some_and(|status| {
581 let status = status.as_u16();
582 status == 408 || status == 429 || (500..600).contains(&status)
583 })
584 })
585}
586
587async fn retry_async<F, Fut, T>(verb: &str, url: &Url, retries: i64, mut operation: F) -> Result<T>
588where
589 F: FnMut() -> Fut,
590 Fut: std::future::Future<Output = Result<T>>,
591{
592 let mut delays = retry_delays(retries);
593 let mut attempt = 1;
594 loop {
595 let started_at = Instant::now();
596 match operation().await {
597 Ok(value) => return Ok(value),
598 Err(error) if is_transient(&error) => {
599 let Some(delay) = delays.next() else {
600 return Err(error);
601 };
602 warn!(
603 "HTTP {verb} {url} attempt {attempt} failed after {:?} (transient): {error}; retrying in {delay:?}",
604 started_at.elapsed()
605 );
606 tokio::time::sleep(delay).await;
607 attempt += 1;
608 }
609 Err(error) => return Err(error),
610 }
611 }
612}