use crate::sigv4::{PayloadHash, S3Credentials, SigningContext, sign};
use crate::{
BlobPackReceipt, BlobSource, BlobUpload, CacheDigest, MAX_REMOTE_BLOB_BYTES,
MAX_REMOTE_JSON_BYTES, ManifestPutOutcome, RemoteActionManifest, RemoteActionResult,
RemoteBlobPack, TransientRequest, parse_strong_etag, quoted_etag, read_bounded_json,
retry_async,
};
use eyre::{Result, bail, eyre};
use log::warn;
use reqwest::StatusCode;
use reqwest::header::{CONTENT_LENGTH, ETAG, IF_MATCH, IF_NONE_MATCH};
use std::fs;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime};
use tokio::io::AsyncWriteExt;
use url::Url;
const LAYOUT_VERSION: u8 = 1;
const CONNECTIVITY_PROBE_KEY: &str = "connectivity-probe";
const MAX_ERROR_BODY_BYTES: usize = 8 * 1024;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, strum::EnumString, strum::Display)]
#[strum(serialize_all = "kebab-case")]
pub enum S3ConditionalWrites {
#[default]
Auto,
Required,
Off,
}
pub struct S3RemoteCacheConfig {
pub bucket: String,
pub prefix: String,
pub namespace: String,
pub region: String,
pub endpoint: Option<Url>,
pub force_path_style: Option<bool>,
pub conditional_writes: S3ConditionalWrites,
pub credentials: S3Credentials,
pub connect_timeout: Duration,
pub read_timeout: Duration,
pub download_timeout: Duration,
pub retries: i64,
}
#[derive(Clone, Copy)]
enum ObjectKind {
Blob,
ActionResult,
ActionManifest,
}
impl ObjectKind {
fn as_str(self) -> &'static str {
match self {
Self::Blob => "blobs",
Self::ActionResult => "action-results",
Self::ActionManifest => "action-manifests",
}
}
}
pub(crate) struct S3RemoteCache {
client: reqwest::Client,
base_url: Url,
root: String,
region: String,
credentials: S3Credentials,
conditional_writes: S3ConditionalWrites,
conditionals_disabled: AtomicBool,
absence_is_ambiguous: AtomicBool,
download_timeout: Duration,
retries: i64,
}
impl S3RemoteCache {
pub(crate) fn new(config: S3RemoteCacheConfig) -> Result<Self> {
validate_bucket(&config.bucket)?;
let prefix = normalize_prefix(&config.prefix)?;
validate_key_path(&config.namespace, "remote cache namespace")?;
if config.region.trim().is_empty() {
bail!("an S3 remote cache needs a region");
}
let client = reqwest::Client::builder()
.connect_timeout(config.connect_timeout)
.read_timeout(config.read_timeout)
.redirect(reqwest::redirect::Policy::none())
.build()?;
Ok(Self {
client,
base_url: base_url(&config)?,
root: format!("{prefix}{}/v{LAYOUT_VERSION}/", config.namespace.trim()),
region: config.region.trim().to_string(),
credentials: config.credentials,
conditional_writes: config.conditional_writes,
conditionals_disabled: AtomicBool::new(false),
absence_is_ambiguous: AtomicBool::new(false),
download_timeout: config.download_timeout,
retries: config.retries,
})
}
fn object_url(&self, kind: ObjectKind, digest: &CacheDigest) -> Result<Url> {
digest.validate()?;
if matches!(kind, ObjectKind::ActionResult | ObjectKind::ActionManifest)
&& digest.algorithm != "blake3"
{
bail!("remote cache action keys must use blake3");
}
self.key_url(&format!(
"{}/{}/{}/{}",
kind.as_str(),
digest.algorithm,
digest.hash,
digest.size
))
}
fn key_url(&self, key: &str) -> Result<Url> {
Ok(self.base_url.join(&format!("{}{key}", self.root))?)
}
fn signed(
&self,
method: reqwest::Method,
url: &Url,
payload: &PayloadHash,
) -> Result<reqwest::RequestBuilder> {
let context = SigningContext {
credentials: &self.credentials,
region: &self.region,
timestamp: SystemTime::now(),
};
let mut request = self.client.request(method.clone(), url.clone());
for (name, value) in sign(method.as_str(), url, &context, payload)? {
request = request.header(name, value);
}
Ok(request)
}
fn conditionals_enabled(&self) -> bool {
self.conditional_writes != S3ConditionalWrites::Off
&& !self.conditionals_disabled.load(Ordering::Relaxed)
}
fn may_drop_conditionals(&self) -> bool {
self.conditional_writes == S3ConditionalWrites::Auto
}
fn note_conditionals_unsupported(&self) {
if !self.conditionals_disabled.swap(true, Ordering::Relaxed) {
warn!(
"the remote object store does not implement conditional writes; \
continuing without them. Blobs and action results are content-addressed, so \
this is safe; concurrent task manifest updates can now lose predictions, \
which costs prefetch coverage on later builds"
);
}
}
pub(crate) async fn check_connection(&self) -> Result<()> {
let url = self.key_url(CONNECTIVITY_PROBE_KEY)?;
retry_async("GET", &url, self.retries, || async {
let response = self
.signed(reqwest::Method::GET, &url, &PayloadHash::empty())?
.send()
.await?;
match response.status() {
StatusCode::OK | StatusCode::NOT_FOUND => Ok(()),
StatusCode::FORBIDDEN => {
let failure = FailedRequest::read(response).await;
if failure.is_credentials_rejected() {
bail!(
"the remote object store rejected these credentials for {url}: {}. \
Check AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, and that this \
machine's clock is correct",
failure.code.as_deref().unwrap_or("forbidden")
);
}
warn!(
"the remote object store did not confirm access to {url}. That is \
expected without s3:ListBucket on the bucket, where S3 refuses a \
read rather than reporting the object absent; grant it to tell the \
two apart. If the cache never hits, these credentials may not be \
allowed to read the prefix"
);
Ok(())
}
StatusCode::MOVED_PERMANENTLY | StatusCode::TEMPORARY_REDIRECT => {
let region = response
.headers()
.get("x-amz-bucket-region")
.and_then(|value| value.to_str().ok())
.unwrap_or("another region");
bail!(
"the bucket is in {region}, not {}; set the remote region to match",
self.region
)
}
_ => Err(FailedRequest::read(response)
.await
.report("connect to", &url)),
}
})
.await
}
pub(crate) async fn get_blob(
&self,
digest: &CacheDigest,
_media_type: &'static str,
) -> Result<Vec<u8>> {
if digest.size > MAX_REMOTE_JSON_BYTES {
bail!(
"remote cache in-memory blob declared {} bytes, over the {} byte limit",
digest.size,
MAX_REMOTE_JSON_BYTES
);
}
let url = self.object_url(ObjectKind::Blob, digest)?;
retry_async("GET", &url, self.retries, || async {
let mut response = self.get(&url).await?;
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await? {
if bytes.len() as u64 + chunk.len() as u64 > digest.size {
bail!("remote cache blob exceeded the size of its digest");
}
bytes.extend_from_slice(&chunk);
}
if !digest.matches_bytes(&bytes)? {
bail!("remote cache blob failed digest verification");
}
Ok(bytes)
})
.await
}
pub(crate) async fn get_blob_file(
&self,
digest: &CacheDigest,
staging_dir: &Path,
) -> Result<tempfile::NamedTempFile> {
if digest.size > MAX_REMOTE_BLOB_BYTES {
bail!(
"remote cache blob declared {} bytes, over the {} byte limit",
digest.size,
MAX_REMOTE_BLOB_BYTES
);
}
let url = self.object_url(ObjectKind::Blob, digest)?;
let download = retry_async("GET", &url, self.retries, || async {
let mut response = self.get(&url).await?;
fs::create_dir_all(staging_dir)?;
let temporary = tempfile::NamedTempFile::new_in(staging_dir)?;
let mut output = tokio::fs::File::from_std(temporary.reopen()?);
let mut written = 0u64;
while let Some(chunk) = response.chunk().await? {
written += chunk.len() as u64;
if written > digest.size {
bail!("remote cache blob exceeded the size of its digest");
}
output.write_all(&chunk).await?;
}
output.flush().await?;
drop(output);
if !digest.matches_file(temporary.path())? {
bail!("remote cache blob failed digest verification");
}
Ok(temporary)
});
let download_timeout = self.download_timeout;
tokio::time::timeout(download_timeout, download)
.await
.map_err(|_| {
eyre!(
"remote cache blob download for {url} exceeded its {download_timeout:?} budget across all attempts"
)
})?
}
async fn get(&self, url: &Url) -> Result<reqwest::Response> {
let response = self
.signed(reqwest::Method::GET, url, &PayloadHash::empty())?
.send()
.await?;
if response.status().is_success() {
Ok(response)
} else {
Err(FailedRequest::read(response).await.report("read", url))
}
}
fn reads_as_absent(&self, failure: &FailedRequest) -> bool {
if failure.status == StatusCode::NOT_FOUND {
return true;
}
if failure.status != StatusCode::FORBIDDEN || failure.is_credentials_rejected() {
return false;
}
if !self.absence_is_ambiguous.swap(true, Ordering::Relaxed) {
warn!(
"the remote object store refused a read instead of reporting the object \
absent, which is what S3 does without s3:ListBucket on the bucket. \
Treating it as a cache miss. Grant s3:ListBucket so a miss is a miss; \
if the cache never hits, these credentials may simply not be allowed to \
read it"
);
}
true
}
pub(crate) async fn get_action_result(
&self,
action: &CacheDigest,
) -> Result<Option<RemoteActionResult>> {
let url = self.object_url(ObjectKind::ActionResult, action)?;
let result = retry_async("GET", &url, self.retries, || async {
let response = self
.signed(reqwest::Method::GET, &url, &PayloadHash::empty())?
.send()
.await?;
if !response.status().is_success() {
let failure = FailedRequest::read(response).await;
return if self.reads_as_absent(&failure) {
Ok(None)
} else {
Err(failure.report("read", &url))
};
}
let bytes = read_bounded_json(response, "action result").await?;
Ok(Some(serde_json::from_slice::<RemoteActionResult>(&bytes)?))
})
.await?;
if let Some(result) = &result
&& (result.version != 1 || result.action != *action)
{
bail!("remote action result does not match requested action");
}
Ok(result)
}
pub(crate) async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
let url = self.object_url(ObjectKind::ActionResult, &result.action)?;
let body = serde_json::to_vec(result)?;
retry_async("PUT", &url, self.retries, || async {
self.put_create(&url, &body).await.map(drop)
})
.await
}
pub(crate) async fn get_action_manifest(
&self,
key: &CacheDigest,
) -> Result<Option<RemoteActionManifest>> {
let url = self.object_url(ObjectKind::ActionManifest, key)?;
retry_async("GET", &url, self.retries, || async {
let response = self
.signed(reqwest::Method::GET, &url, &PayloadHash::empty())?
.send()
.await?;
if !response.status().is_success() {
let failure = FailedRequest::read(response).await;
return if self.reads_as_absent(&failure) {
Ok(None)
} else {
Err(failure.report("read", &url))
};
}
let etag = parse_strong_etag(response.headers().get(ETAG))?;
let bytes = read_bounded_json(response, "action manifest").await?;
Ok(Some(RemoteActionManifest { bytes, etag }))
})
.await
}
pub(crate) async fn put_action_manifest(
&self,
key: &CacheDigest,
bytes: &[u8],
expected_etag: Option<&str>,
) -> Result<ManifestPutOutcome> {
let url = self.object_url(ObjectKind::ActionManifest, key)?;
let body = bytes.to_vec();
let expected_etag = expected_etag.map(quoted_etag).transpose()?;
retry_async("PUT", &url, self.retries, || async {
let mut dropped_condition = false;
let outcome = loop {
let conditional = self.conditionals_enabled() && !dropped_condition;
let mut request = self
.signed(reqwest::Method::PUT, &url, &PayloadHash::of(&body))?
.header(CONTENT_LENGTH, body.len())
.body(body.clone());
if conditional {
request = match &expected_etag {
Some(etag) => request.header(IF_MATCH, etag),
None => request.header(IF_NONE_MATCH, "*"),
};
}
let response = request.send().await?;
let status = response.status();
if status.is_success() {
if dropped_condition {
self.note_conditionals_unsupported();
}
break ManifestPutOutcome::Stored;
}
if conditional && status == StatusCode::PRECONDITION_FAILED {
break ManifestPutOutcome::PreconditionFailed;
}
if status == StatusCode::CONFLICT {
return Err(conditional_request_conflict(&url));
}
let failure = FailedRequest::read(response).await;
if conditional && failure.is_not_implemented() {
if self.may_drop_conditionals() {
dropped_condition = true;
continue;
}
return Err(failure.report("update", &url).wrap_err(
"conditional writes are required but this store does not implement them",
));
}
return Err(failure.report("update", &url));
};
Ok(outcome)
})
.await
}
pub(crate) async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
upload.digest.validate()?;
let url = self.object_url(ObjectKind::Blob, &upload.digest)?;
retry_async("PUT", &url, self.retries, || async {
match &upload.source {
BlobSource::Bytes(bytes) => self.put_create(&url, bytes).await.map(drop),
BlobSource::File(file) => self.put_create_file(&url, file.path()).await,
BlobSource::Path(path) => self.put_create_file(&url, path).await,
}
})
.await
}
async fn put_create(&self, url: &Url, body: &[u8]) -> Result<bool> {
let mut dropped_condition = false;
loop {
let conditional = self.conditionals_enabled() && !dropped_condition;
let mut request = self
.signed(reqwest::Method::PUT, url, &PayloadHash::of(body))?
.header(CONTENT_LENGTH, body.len())
.body(body.to_vec());
if conditional {
request = request.header(IF_NONE_MATCH, "*");
}
match self
.finish_create(url, request.send().await?, conditional)
.await?
{
Some(created) => {
if dropped_condition {
self.note_conditionals_unsupported();
}
return Ok(created);
}
None => dropped_condition = true,
}
}
}
async fn put_create_file(&self, url: &Url, path: &Path) -> Result<()> {
let mut dropped_condition = false;
loop {
let conditional = self.conditionals_enabled() && !dropped_condition;
let file = tokio::fs::File::open(path).await?;
let length = file.metadata().await?.len();
let mut request = self
.signed(reqwest::Method::PUT, url, &PayloadHash::Unsigned)?
.header(CONTENT_LENGTH, length)
.body(reqwest::Body::wrap_stream(
tokio_util::io::ReaderStream::new(file),
));
if conditional {
request = request.header(IF_NONE_MATCH, "*");
}
match self
.finish_create(url, request.send().await?, conditional)
.await?
{
Some(_) => {
if dropped_condition {
self.note_conditionals_unsupported();
}
return Ok(());
}
None => dropped_condition = true,
}
}
}
async fn finish_create(
&self,
url: &Url,
response: reqwest::Response,
conditional: bool,
) -> Result<Option<bool>> {
let status = response.status();
if status.is_success() {
return Ok(Some(true));
}
if conditional && status == StatusCode::PRECONDITION_FAILED {
return Ok(Some(false));
}
if status == StatusCode::CONFLICT {
return Err(conditional_request_conflict(url));
}
let failure = FailedRequest::read(response).await;
if conditional && failure.is_not_implemented() {
if self.may_drop_conditionals() {
return Ok(None);
}
return Err(failure.report("store", url).wrap_err(
"conditional writes are required but this store does not implement them",
));
}
Err(failure.report("store", url))
}
pub(crate) async fn get_action_results(
&self,
actions: &[CacheDigest],
) -> Result<Option<Vec<RemoteActionResult>>> {
Ok(actions.is_empty().then(Vec::new))
}
pub(crate) async fn action_batch_limit(&self) -> Result<Option<usize>> {
Ok(None)
}
pub(crate) async fn get_blob_pack(
&self,
_digests: &[CacheDigest],
_staging_dir: &Path,
) -> Result<Option<RemoteBlobPack>> {
Ok(None)
}
pub(crate) async fn get_blob_pack_with_limit(
&self,
_digests: &[CacheDigest],
_staging_dir: &Path,
_max_bytes: u64,
) -> Result<Option<RemoteBlobPack>> {
Ok(None)
}
pub(crate) async fn blob_pack_upload_limits(&self) -> Result<Option<crate::BlobPackLimits>> {
Ok(None)
}
pub(crate) async fn put_blob_pack(
&self,
uploads: &[BlobUpload],
) -> Result<Option<BlobPackReceipt>> {
Ok(uploads.is_empty().then_some(BlobPackReceipt {
created: 0,
existing: 0,
}))
}
}
fn conditional_request_conflict(url: &Url) -> eyre::Report {
eyre::Report::new(TransientRequest("conditional request conflict")).wrap_err(format!(
"a concurrent conditional write to {url} conflicted"
))
}
struct FailedRequest {
status: StatusCode,
code: Option<String>,
}
impl FailedRequest {
async fn read(mut response: reqwest::Response) -> Self {
let status = response.status();
let mut body = Vec::new();
while body.len() < MAX_ERROR_BODY_BYTES {
match response.chunk().await {
Ok(Some(chunk)) => body.extend_from_slice(&chunk),
Ok(None) | Err(_) => break,
}
}
body.truncate(MAX_ERROR_BODY_BYTES);
Self {
status,
code: error_code(&String::from_utf8_lossy(&body)).map(str::to_string),
}
}
fn is_not_implemented(&self) -> bool {
self.status == StatusCode::NOT_IMPLEMENTED
|| (self.status == StatusCode::BAD_REQUEST
&& self.code.as_deref() == Some("NotImplemented"))
}
fn is_credentials_rejected(&self) -> bool {
self.status == StatusCode::FORBIDDEN
&& matches!(
self.code.as_deref(),
Some(
"SignatureDoesNotMatch"
| "InvalidAccessKeyId"
| "InvalidSecurity"
| "ExpiredToken"
| "TokenRefreshRequired"
| "RequestTimeTooSkewed"
)
)
}
fn is_retryable(&self) -> bool {
matches!(self.status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504)
}
fn report(&self, verb: &str, url: &Url) -> eyre::Report {
let detail = match &self.code {
Some(code) => format!("failed to {verb} {url}: {} ({code})", self.status),
None => format!("failed to {verb} {url}: {}", self.status),
};
if self.is_retryable() {
return eyre::Report::new(TransientRequest("the store asked to be retried"))
.wrap_err(detail);
}
eyre!(detail)
}
}
fn error_code(body: &str) -> Option<&str> {
let start = body.find("<Code>")? + "<Code>".len();
let end = body[start..].find("</Code>")? + start;
Some(body[start..end].trim()).filter(|code| !code.is_empty())
}
fn validate_bucket(bucket: &str) -> Result<()> {
let bucket = bucket.trim();
if bucket.is_empty() {
bail!("an S3 remote cache needs a bucket");
}
if bucket.contains('/') || bucket.starts_with('.') || bucket.ends_with('.') {
bail!("invalid S3 bucket name {bucket:?}");
}
Ok(())
}
fn normalize_prefix(prefix: &str) -> Result<String> {
let prefix = prefix.trim().trim_matches('/');
if prefix.is_empty() {
return Ok(String::new());
}
validate_key_path(prefix, "remote cache prefix")?;
Ok(format!("{prefix}/"))
}
fn validate_key_path(value: &str, what: &str) -> Result<()> {
let value = value.trim();
if value.is_empty() {
bail!("{what} must not be empty");
}
if value.starts_with('/') || value.ends_with('/') {
bail!("{what} {value:?} must not start or end with a slash");
}
for segment in value.split('/') {
if segment.is_empty() {
bail!("{what} {value:?} must not contain an empty path segment");
}
if segment == "." || segment == ".." {
bail!("{what} {value:?} must not contain a relative path segment");
}
if !segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
bail!(
"{what} {value:?} must use only letters, digits, '.', '_', '-', and '/' \
when the remote cache is an object store"
);
}
}
Ok(())
}
fn base_url(config: &S3RemoteCacheConfig) -> Result<Url> {
let bucket = config.bucket.trim();
let (mut url, path_style) = match &config.endpoint {
Some(endpoint) => (endpoint.clone(), config.force_path_style.unwrap_or(true)),
None => (
format!("https://s3.{}.amazonaws.com", config.region.trim()).parse()?,
config
.force_path_style
.unwrap_or_else(|| bucket.contains('.')),
),
};
if path_style {
let path = url.path().trim_end_matches('/').to_string();
url.set_path(&format!("{path}/{bucket}/"));
} else {
let host = url
.host_str()
.ok_or_else(|| eyre!("an S3 endpoint must have a host"))?;
url.set_host(Some(&format!("{bucket}.{host}")))?;
let path = url.path().trim_end_matches('/').to_string();
url.set_path(&format!("{path}/"));
}
Ok(url)
}
#[cfg(test)]
#[path = "remote_s3_tests.rs"]
mod tests;