1use crate::sigv4::{PayloadHash, S3Credentials, SigningContext, sign};
17use crate::{
18 BlobPackReceipt, BlobSource, BlobUpload, CacheDigest, MAX_REMOTE_BLOB_BYTES,
19 MAX_REMOTE_JSON_BYTES, ManifestPutOutcome, RemoteActionManifest, RemoteActionResult,
20 RemoteBlobPack, TransientRequest, parse_strong_etag, quoted_etag, read_bounded_json,
21 retry_async,
22};
23use eyre::{Result, bail, eyre};
24use log::warn;
25use reqwest::StatusCode;
26use reqwest::header::{CONTENT_LENGTH, ETAG, IF_MATCH, IF_NONE_MATCH};
27use std::fs;
28use std::path::Path;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::time::{Duration, SystemTime};
31use tokio::io::AsyncWriteExt;
32use url::Url;
33
34const LAYOUT_VERSION: u8 = 1;
39const CONNECTIVITY_PROBE_KEY: &str = "connectivity-probe";
42const MAX_ERROR_BODY_BYTES: usize = 8 * 1024;
44
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, strum::EnumString, strum::Display)]
47#[strum(serialize_all = "kebab-case")]
48pub enum S3ConditionalWrites {
49 #[default]
51 Auto,
52 Required,
54 Off,
56}
57
58pub struct S3RemoteCacheConfig {
60 pub bucket: String,
62 pub prefix: String,
64 pub namespace: String,
66 pub region: String,
68 pub endpoint: Option<Url>,
70 pub force_path_style: Option<bool>,
72 pub conditional_writes: S3ConditionalWrites,
74 pub credentials: S3Credentials,
76 pub connect_timeout: Duration,
78 pub read_timeout: Duration,
80 pub download_timeout: Duration,
89 pub retries: i64,
91}
92
93#[derive(Clone, Copy)]
95enum ObjectKind {
96 Blob,
97 ActionResult,
98 ActionManifest,
99}
100
101impl ObjectKind {
102 fn as_str(self) -> &'static str {
103 match self {
104 Self::Blob => "blobs",
105 Self::ActionResult => "action-results",
106 Self::ActionManifest => "action-manifests",
107 }
108 }
109}
110
111pub(crate) struct S3RemoteCache {
112 client: reqwest::Client,
113 base_url: Url,
115 root: String,
117 region: String,
118 credentials: S3Credentials,
119 conditional_writes: S3ConditionalWrites,
120 conditionals_disabled: AtomicBool,
123 absence_is_ambiguous: AtomicBool,
126 download_timeout: Duration,
127 retries: i64,
128}
129
130impl S3RemoteCache {
131 pub(crate) fn new(config: S3RemoteCacheConfig) -> Result<Self> {
132 validate_bucket(&config.bucket)?;
133 let prefix = normalize_prefix(&config.prefix)?;
134 validate_key_path(&config.namespace, "remote cache namespace")?;
135 if config.region.trim().is_empty() {
136 bail!("an S3 remote cache needs a region");
137 }
138 let client = reqwest::Client::builder()
139 .connect_timeout(config.connect_timeout)
140 .read_timeout(config.read_timeout)
141 .redirect(reqwest::redirect::Policy::none())
142 .build()?;
143 Ok(Self {
144 client,
145 base_url: base_url(&config)?,
146 root: format!("{prefix}{}/v{LAYOUT_VERSION}/", config.namespace.trim()),
147 region: config.region.trim().to_string(),
148 credentials: config.credentials,
149 conditional_writes: config.conditional_writes,
150 conditionals_disabled: AtomicBool::new(false),
151 absence_is_ambiguous: AtomicBool::new(false),
152 download_timeout: config.download_timeout,
153 retries: config.retries,
154 })
155 }
156
157 fn object_url(&self, kind: ObjectKind, digest: &CacheDigest) -> Result<Url> {
158 digest.validate()?;
159 if matches!(kind, ObjectKind::ActionResult | ObjectKind::ActionManifest)
160 && digest.algorithm != "blake3"
161 {
162 bail!("remote cache action keys must use blake3");
163 }
164 self.key_url(&format!(
165 "{}/{}/{}/{}",
166 kind.as_str(),
167 digest.algorithm,
168 digest.hash,
169 digest.size
170 ))
171 }
172
173 fn key_url(&self, key: &str) -> Result<Url> {
174 Ok(self.base_url.join(&format!("{}{key}", self.root))?)
175 }
176
177 fn signed(
183 &self,
184 method: reqwest::Method,
185 url: &Url,
186 payload: &PayloadHash,
187 ) -> Result<reqwest::RequestBuilder> {
188 let context = SigningContext {
189 credentials: &self.credentials,
190 region: &self.region,
191 timestamp: SystemTime::now(),
192 };
193 let mut request = self.client.request(method.clone(), url.clone());
194 for (name, value) in sign(method.as_str(), url, &context, payload)? {
195 request = request.header(name, value);
196 }
197 Ok(request)
198 }
199
200 fn conditionals_enabled(&self) -> bool {
202 self.conditional_writes != S3ConditionalWrites::Off
203 && !self.conditionals_disabled.load(Ordering::Relaxed)
204 }
205
206 fn may_drop_conditionals(&self) -> bool {
211 self.conditional_writes == S3ConditionalWrites::Auto
212 }
213
214 fn note_conditionals_unsupported(&self) {
223 if !self.conditionals_disabled.swap(true, Ordering::Relaxed) {
224 warn!(
225 "the remote object store does not implement conditional writes; \
226 continuing without them. Blobs and action results are content-addressed, so \
227 this is safe; concurrent task manifest updates can now lose predictions, \
228 which costs prefetch coverage on later builds"
229 );
230 }
231 }
232
233 pub(crate) async fn check_connection(&self) -> Result<()> {
234 let url = self.key_url(CONNECTIVITY_PROBE_KEY)?;
235 retry_async("GET", &url, self.retries, || async {
239 let response = self
240 .signed(reqwest::Method::GET, &url, &PayloadHash::empty())?
241 .send()
242 .await?;
243 match response.status() {
244 StatusCode::OK | StatusCode::NOT_FOUND => Ok(()),
247 StatusCode::FORBIDDEN => {
248 let failure = FailedRequest::read(response).await;
249 if failure.is_credentials_rejected() {
250 bail!(
251 "the remote object store rejected these credentials for {url}: {}. \
252 Check AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, and that this \
253 machine's clock is correct",
254 failure.code.as_deref().unwrap_or("forbidden")
255 );
256 }
257 warn!(
263 "the remote object store did not confirm access to {url}. That is \
264 expected without s3:ListBucket on the bucket, where S3 refuses a \
265 read rather than reporting the object absent; grant it to tell the \
266 two apart. If the cache never hits, these credentials may not be \
267 allowed to read the prefix"
268 );
269 Ok(())
270 }
271 StatusCode::MOVED_PERMANENTLY | StatusCode::TEMPORARY_REDIRECT => {
272 let region = response
273 .headers()
274 .get("x-amz-bucket-region")
275 .and_then(|value| value.to_str().ok())
276 .unwrap_or("another region");
277 bail!(
278 "the bucket is in {region}, not {}; set the remote region to match",
279 self.region
280 )
281 }
282 _ => Err(FailedRequest::read(response)
283 .await
284 .report("connect to", &url)),
285 }
286 })
287 .await
288 }
289
290 pub(crate) async fn get_blob(
291 &self,
292 digest: &CacheDigest,
293 _media_type: &'static str,
294 ) -> Result<Vec<u8>> {
295 if digest.size > MAX_REMOTE_JSON_BYTES {
296 bail!(
297 "remote cache in-memory blob declared {} bytes, over the {} byte limit",
298 digest.size,
299 MAX_REMOTE_JSON_BYTES
300 );
301 }
302 let url = self.object_url(ObjectKind::Blob, digest)?;
303 retry_async("GET", &url, self.retries, || async {
304 let mut response = self.get(&url).await?;
305 let mut bytes = Vec::new();
308 while let Some(chunk) = response.chunk().await? {
309 if bytes.len() as u64 + chunk.len() as u64 > digest.size {
310 bail!("remote cache blob exceeded the size of its digest");
311 }
312 bytes.extend_from_slice(&chunk);
313 }
314 if !digest.matches_bytes(&bytes)? {
315 bail!("remote cache blob failed digest verification");
316 }
317 Ok(bytes)
318 })
319 .await
320 }
321
322 pub(crate) async fn get_blob_file(
323 &self,
324 digest: &CacheDigest,
325 staging_dir: &Path,
326 ) -> Result<tempfile::NamedTempFile> {
327 if digest.size > MAX_REMOTE_BLOB_BYTES {
328 bail!(
329 "remote cache blob declared {} bytes, over the {} byte limit",
330 digest.size,
331 MAX_REMOTE_BLOB_BYTES
332 );
333 }
334 let url = self.object_url(ObjectKind::Blob, digest)?;
335 let download = retry_async("GET", &url, self.retries, || async {
336 let mut response = self.get(&url).await?;
337 fs::create_dir_all(staging_dir)?;
338 let temporary = tempfile::NamedTempFile::new_in(staging_dir)?;
339 let mut output = tokio::fs::File::from_std(temporary.reopen()?);
340 let mut written = 0u64;
341 while let Some(chunk) = response.chunk().await? {
342 written += chunk.len() as u64;
343 if written > digest.size {
344 bail!("remote cache blob exceeded the size of its digest");
345 }
346 output.write_all(&chunk).await?;
347 }
348 output.flush().await?;
349 drop(output);
350 if !digest.matches_file(temporary.path())? {
351 bail!("remote cache blob failed digest verification");
352 }
353 Ok(temporary)
354 });
355 let download_timeout = self.download_timeout;
356 tokio::time::timeout(download_timeout, download)
360 .await
361 .map_err(|_| {
362 eyre!(
363 "remote cache blob download for {url} exceeded its {download_timeout:?} budget across all attempts"
364 )
365 })?
366 }
367
368 async fn get(&self, url: &Url) -> Result<reqwest::Response> {
370 let response = self
371 .signed(reqwest::Method::GET, url, &PayloadHash::empty())?
372 .send()
373 .await?;
374 if response.status().is_success() {
375 Ok(response)
376 } else {
377 Err(FailedRequest::read(response).await.report("read", url))
378 }
379 }
380
381 fn reads_as_absent(&self, failure: &FailedRequest) -> bool {
391 if failure.status == StatusCode::NOT_FOUND {
392 return true;
393 }
394 if failure.status != StatusCode::FORBIDDEN || failure.is_credentials_rejected() {
395 return false;
396 }
397 if !self.absence_is_ambiguous.swap(true, Ordering::Relaxed) {
398 warn!(
399 "the remote object store refused a read instead of reporting the object \
400 absent, which is what S3 does without s3:ListBucket on the bucket. \
401 Treating it as a cache miss. Grant s3:ListBucket so a miss is a miss; \
402 if the cache never hits, these credentials may simply not be allowed to \
403 read it"
404 );
405 }
406 true
407 }
408
409 pub(crate) async fn get_action_result(
410 &self,
411 action: &CacheDigest,
412 ) -> Result<Option<RemoteActionResult>> {
413 let url = self.object_url(ObjectKind::ActionResult, action)?;
414 let result = retry_async("GET", &url, self.retries, || async {
415 let response = self
416 .signed(reqwest::Method::GET, &url, &PayloadHash::empty())?
417 .send()
418 .await?;
419 if !response.status().is_success() {
420 let failure = FailedRequest::read(response).await;
421 return if self.reads_as_absent(&failure) {
422 Ok(None)
423 } else {
424 Err(failure.report("read", &url))
425 };
426 }
427 let bytes = read_bounded_json(response, "action result").await?;
428 Ok(Some(serde_json::from_slice::<RemoteActionResult>(&bytes)?))
429 })
430 .await?;
431 if let Some(result) = &result
432 && (result.version != 1 || result.action != *action)
433 {
434 bail!("remote action result does not match requested action");
435 }
436 Ok(result)
437 }
438
439 pub(crate) async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
440 let url = self.object_url(ObjectKind::ActionResult, &result.action)?;
441 let body = serde_json::to_vec(result)?;
442 retry_async("PUT", &url, self.retries, || async {
443 self.put_create(&url, &body).await.map(drop)
446 })
447 .await
448 }
449
450 pub(crate) async fn get_action_manifest(
451 &self,
452 key: &CacheDigest,
453 ) -> Result<Option<RemoteActionManifest>> {
454 let url = self.object_url(ObjectKind::ActionManifest, key)?;
455 retry_async("GET", &url, self.retries, || async {
456 let response = self
457 .signed(reqwest::Method::GET, &url, &PayloadHash::empty())?
458 .send()
459 .await?;
460 if !response.status().is_success() {
461 let failure = FailedRequest::read(response).await;
462 return if self.reads_as_absent(&failure) {
463 Ok(None)
464 } else {
465 Err(failure.report("read", &url))
466 };
467 }
468 let etag = parse_strong_etag(response.headers().get(ETAG))?;
469 let bytes = read_bounded_json(response, "action manifest").await?;
470 Ok(Some(RemoteActionManifest { bytes, etag }))
471 })
472 .await
473 }
474
475 pub(crate) async fn put_action_manifest(
476 &self,
477 key: &CacheDigest,
478 bytes: &[u8],
479 expected_etag: Option<&str>,
480 ) -> Result<ManifestPutOutcome> {
481 let url = self.object_url(ObjectKind::ActionManifest, key)?;
482 let body = bytes.to_vec();
483 let expected_etag = expected_etag.map(quoted_etag).transpose()?;
484 retry_async("PUT", &url, self.retries, || async {
485 let mut dropped_condition = false;
488 let outcome = loop {
489 let conditional = self.conditionals_enabled() && !dropped_condition;
490 let mut request = self
491 .signed(reqwest::Method::PUT, &url, &PayloadHash::of(&body))?
492 .header(CONTENT_LENGTH, body.len())
493 .body(body.clone());
494 if conditional {
495 request = match &expected_etag {
496 Some(etag) => request.header(IF_MATCH, etag),
497 None => request.header(IF_NONE_MATCH, "*"),
498 };
499 }
500 let response = request.send().await?;
501 let status = response.status();
502 if status.is_success() {
503 if dropped_condition {
504 self.note_conditionals_unsupported();
505 }
506 break ManifestPutOutcome::Stored;
507 }
508 if conditional && status == StatusCode::PRECONDITION_FAILED {
509 break ManifestPutOutcome::PreconditionFailed;
512 }
513 if status == StatusCode::CONFLICT {
514 return Err(conditional_request_conflict(&url));
518 }
519 let failure = FailedRequest::read(response).await;
520 if conditional && failure.is_not_implemented() {
521 if self.may_drop_conditionals() {
522 dropped_condition = true;
523 continue;
524 }
525 return Err(failure.report("update", &url).wrap_err(
526 "conditional writes are required but this store does not implement them",
527 ));
528 }
529 return Err(failure.report("update", &url));
530 };
531 Ok(outcome)
532 })
533 .await
534 }
535
536 pub(crate) async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
537 upload.digest.validate()?;
538 let url = self.object_url(ObjectKind::Blob, &upload.digest)?;
539 retry_async("PUT", &url, self.retries, || async {
540 match &upload.source {
541 BlobSource::Bytes(bytes) => self.put_create(&url, bytes).await.map(drop),
542 BlobSource::File(file) => self.put_create_file(&url, file.path()).await,
543 BlobSource::Path(path) => self.put_create_file(&url, path).await,
544 }
545 })
546 .await
547 }
548
549 async fn put_create(&self, url: &Url, body: &[u8]) -> Result<bool> {
554 let mut dropped_condition = false;
555 loop {
556 let conditional = self.conditionals_enabled() && !dropped_condition;
557 let mut request = self
558 .signed(reqwest::Method::PUT, url, &PayloadHash::of(body))?
559 .header(CONTENT_LENGTH, body.len())
560 .body(body.to_vec());
561 if conditional {
562 request = request.header(IF_NONE_MATCH, "*");
563 }
564 match self
565 .finish_create(url, request.send().await?, conditional)
566 .await?
567 {
568 Some(created) => {
569 if dropped_condition {
570 self.note_conditionals_unsupported();
571 }
572 return Ok(created);
573 }
574 None => dropped_condition = true,
575 }
576 }
577 }
578
579 async fn put_create_file(&self, url: &Url, path: &Path) -> Result<()> {
586 let mut dropped_condition = false;
587 loop {
588 let conditional = self.conditionals_enabled() && !dropped_condition;
589 let file = tokio::fs::File::open(path).await?;
590 let length = file.metadata().await?.len();
591 let mut request = self
592 .signed(reqwest::Method::PUT, url, &PayloadHash::Unsigned)?
593 .header(CONTENT_LENGTH, length)
594 .body(reqwest::Body::wrap_stream(
595 tokio_util::io::ReaderStream::new(file),
596 ));
597 if conditional {
598 request = request.header(IF_NONE_MATCH, "*");
599 }
600 match self
601 .finish_create(url, request.send().await?, conditional)
602 .await?
603 {
604 Some(_) => {
605 if dropped_condition {
606 self.note_conditionals_unsupported();
607 }
608 return Ok(());
609 }
610 None => dropped_condition = true,
611 }
612 }
613 }
614
615 async fn finish_create(
620 &self,
621 url: &Url,
622 response: reqwest::Response,
623 conditional: bool,
624 ) -> Result<Option<bool>> {
625 let status = response.status();
626 if status.is_success() {
627 return Ok(Some(true));
628 }
629 if conditional && status == StatusCode::PRECONDITION_FAILED {
633 return Ok(Some(false));
634 }
635 if status == StatusCode::CONFLICT {
636 return Err(conditional_request_conflict(url));
637 }
638 let failure = FailedRequest::read(response).await;
639 if conditional && failure.is_not_implemented() {
640 if self.may_drop_conditionals() {
641 return Ok(None);
642 }
643 return Err(failure.report("store", url).wrap_err(
644 "conditional writes are required but this store does not implement them",
645 ));
646 }
647 Err(failure.report("store", url))
648 }
649
650 pub(crate) async fn get_action_results(
655 &self,
656 actions: &[CacheDigest],
657 ) -> Result<Option<Vec<RemoteActionResult>>> {
658 Ok(actions.is_empty().then(Vec::new))
659 }
660
661 pub(crate) async fn action_batch_limit(&self) -> Result<Option<usize>> {
662 Ok(None)
663 }
664
665 pub(crate) async fn get_blob_pack(
666 &self,
667 _digests: &[CacheDigest],
668 _staging_dir: &Path,
669 ) -> Result<Option<RemoteBlobPack>> {
670 Ok(None)
671 }
672
673 pub(crate) async fn get_blob_pack_with_limit(
674 &self,
675 _digests: &[CacheDigest],
676 _staging_dir: &Path,
677 _max_bytes: u64,
678 ) -> Result<Option<RemoteBlobPack>> {
679 Ok(None)
680 }
681
682 pub(crate) async fn blob_pack_upload_limits(&self) -> Result<Option<crate::BlobPackLimits>> {
683 Ok(None)
684 }
685
686 pub(crate) async fn put_blob_pack(
687 &self,
688 uploads: &[BlobUpload],
689 ) -> Result<Option<BlobPackReceipt>> {
690 Ok(uploads.is_empty().then_some(BlobPackReceipt {
691 created: 0,
692 existing: 0,
693 }))
694 }
695}
696
697fn conditional_request_conflict(url: &Url) -> eyre::Report {
699 eyre::Report::new(TransientRequest("conditional request conflict")).wrap_err(format!(
700 "a concurrent conditional write to {url} conflicted"
701 ))
702}
703
704struct FailedRequest {
709 status: StatusCode,
710 code: Option<String>,
711}
712
713impl FailedRequest {
714 async fn read(mut response: reqwest::Response) -> Self {
715 let status = response.status();
716 let mut body = Vec::new();
720 while body.len() < MAX_ERROR_BODY_BYTES {
721 match response.chunk().await {
722 Ok(Some(chunk)) => body.extend_from_slice(&chunk),
723 Ok(None) | Err(_) => break,
724 }
725 }
726 body.truncate(MAX_ERROR_BODY_BYTES);
727 Self {
728 status,
729 code: error_code(&String::from_utf8_lossy(&body)).map(str::to_string),
730 }
731 }
732
733 fn is_not_implemented(&self) -> bool {
740 self.status == StatusCode::NOT_IMPLEMENTED
741 || (self.status == StatusCode::BAD_REQUEST
742 && self.code.as_deref() == Some("NotImplemented"))
743 }
744
745 fn is_credentials_rejected(&self) -> bool {
752 self.status == StatusCode::FORBIDDEN
753 && matches!(
754 self.code.as_deref(),
755 Some(
756 "SignatureDoesNotMatch"
757 | "InvalidAccessKeyId"
758 | "InvalidSecurity"
759 | "ExpiredToken"
760 | "TokenRefreshRequired"
761 | "RequestTimeTooSkewed"
762 )
763 )
764 }
765
766 fn is_retryable(&self) -> bool {
773 matches!(self.status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504)
774 }
775
776 fn report(&self, verb: &str, url: &Url) -> eyre::Report {
777 let detail = match &self.code {
778 Some(code) => format!("failed to {verb} {url}: {} ({code})", self.status),
779 None => format!("failed to {verb} {url}: {}", self.status),
780 };
781 if self.is_retryable() {
782 return eyre::Report::new(TransientRequest("the store asked to be retried"))
787 .wrap_err(detail);
788 }
789 eyre!(detail)
790 }
791}
792
793fn error_code(body: &str) -> Option<&str> {
799 let start = body.find("<Code>")? + "<Code>".len();
800 let end = body[start..].find("</Code>")? + start;
801 Some(body[start..end].trim()).filter(|code| !code.is_empty())
802}
803
804fn validate_bucket(bucket: &str) -> Result<()> {
806 let bucket = bucket.trim();
807 if bucket.is_empty() {
808 bail!("an S3 remote cache needs a bucket");
809 }
810 if bucket.contains('/') || bucket.starts_with('.') || bucket.ends_with('.') {
811 bail!("invalid S3 bucket name {bucket:?}");
812 }
813 Ok(())
814}
815
816fn normalize_prefix(prefix: &str) -> Result<String> {
818 let prefix = prefix.trim().trim_matches('/');
819 if prefix.is_empty() {
820 return Ok(String::new());
821 }
822 validate_key_path(prefix, "remote cache prefix")?;
823 Ok(format!("{prefix}/"))
824}
825
826fn validate_key_path(value: &str, what: &str) -> Result<()> {
832 let value = value.trim();
833 if value.is_empty() {
834 bail!("{what} must not be empty");
835 }
836 if value.starts_with('/') || value.ends_with('/') {
837 bail!("{what} {value:?} must not start or end with a slash");
838 }
839 for segment in value.split('/') {
840 if segment.is_empty() {
841 bail!("{what} {value:?} must not contain an empty path segment");
842 }
843 if segment == "." || segment == ".." {
844 bail!("{what} {value:?} must not contain a relative path segment");
845 }
846 if !segment
847 .bytes()
848 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
849 {
850 bail!(
851 "{what} {value:?} must use only letters, digits, '.', '_', '-', and '/' \
852 when the remote cache is an object store"
853 );
854 }
855 }
856 Ok(())
857}
858
859fn base_url(config: &S3RemoteCacheConfig) -> Result<Url> {
867 let bucket = config.bucket.trim();
868 let (mut url, path_style) = match &config.endpoint {
869 Some(endpoint) => (endpoint.clone(), config.force_path_style.unwrap_or(true)),
870 None => (
871 format!("https://s3.{}.amazonaws.com", config.region.trim()).parse()?,
872 config
873 .force_path_style
874 .unwrap_or_else(|| bucket.contains('.')),
875 ),
876 };
877 if path_style {
878 let path = url.path().trim_end_matches('/').to_string();
879 url.set_path(&format!("{path}/{bucket}/"));
880 } else {
881 let host = url
882 .host_str()
883 .ok_or_else(|| eyre!("an S3 endpoint must have a host"))?;
884 url.set_host(Some(&format!("{bucket}.{host}")))?;
885 let path = url.path().trim_end_matches('/').to_string();
888 url.set_path(&format!("{path}/"));
889 }
890 Ok(url)
891}
892
893#[cfg(test)]
894#[path = "remote_s3_tests.rs"]
895mod tests;