use std::path::Path; use anyhow::{Context as _, Result}; use aws_config::Region; use aws_sdk_s3::primitives::ByteStream; use serde::{Deserialize, Serialize}; /// The manifest stored at `//latest.json`, recording the most /// recent backup so that unchanged repositories can be skipped. #[derive(Debug, Serialize, Deserialize)] pub struct Manifest { /// A SHA-256 hash of the repository's refs at the time of the backup. pub refs_hash: String, /// The key of the most recent bundle. pub bundle_key: String, /// When the backup was taken, as an RFC 3339 timestamp. pub timestamp: String, } pub struct S3Destination { client: aws_sdk_s3::Client, bucket: String, prefix: String, } impl S3Destination { pub async fn new( bucket: &str, endpoint: Option<&str>, region: Option<&str>, prefix: Option<&str>, ) -> Self { let sdk_config = aws_config::defaults(aws_config::BehaviorVersion::latest()) .region(Region::new(region.unwrap_or("auto").to_string())) .load() .await; let mut builder = aws_sdk_s3::config::Builder::from(&sdk_config).force_path_style(true); if let Some(endpoint) = endpoint { builder = builder.endpoint_url(endpoint); } Self { client: aws_sdk_s3::Client::from_conf(builder.build()), bucket: bucket.to_string(), prefix: prefix.unwrap_or("repos").trim_matches('/').to_string(), } } fn manifest_key(&self, repo_name: &str) -> String { format!("{}/{}/latest.json", self.prefix, repo_name) } pub fn bundle_key(&self, repo_name: &str, timestamp: &str, refs_hash: &str) -> String { format!( "{}/{}/{}-{}.bundle", self.prefix, repo_name, timestamp, &refs_hash[..8.min(refs_hash.len())] ) } /// Fetches the manifest for the given repository, if one exists. pub async fn manifest(&self, repo_name: &str) -> Result> { let response = self .client .get_object() .bucket(&self.bucket) .key(self.manifest_key(repo_name)) .send() .await; let output = match response { Ok(output) => output, Err(err) => { if err .as_service_error() .is_some_and(|err| err.is_no_such_key()) { return Ok(None); } return Err(err).context("failed to fetch manifest"); } }; let bytes = output .body .collect() .await .context("failed to read manifest body")? .into_bytes(); let manifest = serde_json::from_slice(&bytes).context("failed to parse manifest")?; Ok(Some(manifest)) } pub async fn upload_bundle(&self, key: &str, bundle_path: &Path) -> Result<()> { let body = ByteStream::from_path(bundle_path) .await .context("failed to open bundle for upload")?; self.client .put_object() .bucket(&self.bucket) .key(key) .content_type("application/octet-stream") .body(body) .send() .await .with_context(|| format!("failed to upload bundle to {key:?}"))?; Ok(()) } pub async fn put_manifest(&self, repo_name: &str, manifest: &Manifest) -> Result<()> { let body = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?; self.client .put_object() .bucket(&self.bucket) .key(self.manifest_key(repo_name)) .content_type("application/json") .body(ByteStream::from(body)) .send() .await .context("failed to upload manifest")?; Ok(()) } }