vv_agent/workspace/s3/
backend.rs1use std::any::Any;
2use std::io::{Error, ErrorKind};
3use std::sync::Arc;
4
5use futures_util::TryStreamExt;
6use object_store::aws::AmazonS3Builder;
7use object_store::memory::InMemory;
8use object_store::path::Path as ObjectPath;
9use object_store::{ObjectStore, ObjectStoreExt, PutPayload};
10use tokio::runtime::Runtime;
11
12use crate::workspace::{
13 glob_match, non_empty_option, normalize_workspace_path, normalized_glob_pattern,
14 object_store_error_to_io, suffix_with_dot, FileInfo, WorkspaceBackend,
15};
16
17use super::config::S3WorkspaceConfig;
18use super::paths::{list_prefix, object_key, relative_key};
19use super::runtime::{block_on_object_store, build_runtime};
20
21#[derive(Clone)]
22pub struct S3WorkspaceBackend {
23 store: Arc<dyn ObjectStore>,
24 prefix: String,
25 runtime: Arc<Runtime>,
26}
27
28impl std::fmt::Debug for S3WorkspaceBackend {
29 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 formatter
31 .debug_struct("S3WorkspaceBackend")
32 .field("store", &self.store.to_string())
33 .field("prefix", &self.prefix)
34 .finish_non_exhaustive()
35 }
36}
37
38impl Default for S3WorkspaceBackend {
39 fn default() -> Self {
40 Self::from_object_store(InMemory::new(), "")
41 .expect("in-memory S3 workspace backend must initialize")
42 }
43}
44
45impl S3WorkspaceBackend {
46 pub fn new(bucket: impl Into<String>) -> std::io::Result<Self> {
47 Self::from_config(S3WorkspaceConfig::new(bucket))
48 }
49
50 pub fn from_config(config: S3WorkspaceConfig) -> std::io::Result<Self> {
51 let bucket = config.bucket.trim();
52 if bucket.is_empty() {
53 return Err(Error::new(
54 ErrorKind::InvalidInput,
55 "S3 bucket cannot be empty",
56 ));
57 }
58 let mut builder = AmazonS3Builder::from_env().with_bucket_name(bucket.to_string());
59 if let Some(endpoint) = non_empty_option(config.endpoint_url) {
60 if endpoint.starts_with("http://") {
61 builder = builder.with_allow_http(true);
62 }
63 builder = builder.with_endpoint(endpoint);
64 }
65 if let Some(region) = non_empty_option(config.region_name) {
66 builder = builder.with_region(region);
67 }
68 if let Some(access_key_id) = non_empty_option(config.aws_access_key_id) {
69 builder = builder.with_access_key_id(access_key_id);
70 }
71 if let Some(secret_access_key) = non_empty_option(config.aws_secret_access_key) {
72 builder = builder.with_secret_access_key(secret_access_key);
73 }
74 if let Some(token) = non_empty_option(config.aws_session_token) {
75 builder = builder.with_token(token);
76 }
77 let virtual_hosted = match config.addressing_style.trim().to_ascii_lowercase().as_str() {
78 "" | "virtual" => true,
79 "path" => false,
80 other => {
81 return Err(Error::new(
82 ErrorKind::InvalidInput,
83 format!("unsupported S3 addressing_style: {other}"),
84 ))
85 }
86 };
87 builder = builder.with_virtual_hosted_style_request(virtual_hosted);
88 let store = builder.build().map_err(object_store_error_to_io)?;
89 Self::from_object_store(store, config.prefix)
90 }
91
92 pub fn from_object_store(
93 store: impl ObjectStore + 'static,
94 prefix: impl Into<String>,
95 ) -> std::io::Result<Self> {
96 Ok(Self {
97 store: Arc::new(store),
98 prefix: normalize_workspace_path(&prefix.into()),
99 runtime: Arc::new(build_runtime()?),
100 })
101 }
102
103 fn object_key(&self, path: &str) -> String {
104 object_key(&self.prefix, path)
105 }
106
107 fn relative_key(&self, key: &str) -> Option<String> {
108 relative_key(&self.prefix, key)
109 }
110
111 fn list_prefix(&self) -> Option<ObjectPath> {
112 list_prefix(&self.prefix)
113 }
114
115 fn block_on<T>(
116 &self,
117 future: impl std::future::Future<Output = object_store::Result<T>>,
118 ) -> std::io::Result<T> {
119 block_on_object_store(&self.runtime, future)
120 }
121}
122
123impl WorkspaceBackend for S3WorkspaceBackend {
124 fn as_any(&self) -> &dyn Any {
125 self
126 }
127
128 fn list_files(&self, base: &str, glob: &str) -> std::io::Result<Vec<String>> {
129 let base = normalize_workspace_path(base);
130 let pattern = if base.is_empty() {
131 normalized_glob_pattern(glob)
132 } else {
133 format!("{base}/{}", normalized_glob_pattern(glob))
134 };
135 let prefix = self.list_prefix();
136 let objects = self.block_on(async {
137 self.store
138 .list(prefix.as_ref())
139 .try_collect::<Vec<_>>()
140 .await
141 })?;
142 let mut files = objects
143 .into_iter()
144 .filter_map(|object| self.relative_key(object.location.as_ref()))
145 .filter(|path| !path.is_empty() && !path.ends_with('/'))
146 .filter(|path| glob_match(path, &pattern))
147 .collect::<Vec<_>>();
148 files.sort();
149 Ok(files)
150 }
151
152 fn read_text(&self, path: &str) -> std::io::Result<String> {
153 let bytes = self.read_bytes(path)?;
154 Ok(String::from_utf8_lossy(&bytes).to_string())
155 }
156
157 fn read_bytes(&self, path: &str) -> std::io::Result<Vec<u8>> {
158 let key = ObjectPath::from(self.object_key(path));
159 self.block_on(async { Ok(self.store.get(&key).await?.bytes().await?.to_vec()) })
160 }
161
162 fn write_text(&self, path: &str, content: &str, append: bool) -> std::io::Result<usize> {
163 let key = ObjectPath::from(self.object_key(path));
164 let content = if append {
165 match self.read_text(path) {
166 Ok(existing) => existing + content,
167 Err(error) if error.kind() == ErrorKind::NotFound => content.to_string(),
168 Err(error) => return Err(error),
169 }
170 } else {
171 content.to_string()
172 };
173 let len = content.len();
174 self.block_on(async {
175 self.store
176 .put(&key, PutPayload::from(content.into_bytes()))
177 .await
178 .map(|_| ())
179 })?;
180 Ok(len)
181 }
182
183 fn file_info(&self, path: &str) -> std::io::Result<Option<FileInfo>> {
184 let key = ObjectPath::from(self.object_key(path));
185 let metadata = match self.block_on(async { self.store.head(&key).await }) {
186 Ok(metadata) => metadata,
187 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
188 Err(error) => return Err(error),
189 };
190 let object_path = metadata.location.as_ref();
191 Ok(Some(FileInfo {
192 path: self
193 .relative_key(object_path)
194 .unwrap_or_else(|| normalize_workspace_path(path)),
195 is_file: true,
196 is_dir: false,
197 size: metadata.size,
198 modified_at: metadata.last_modified.to_rfc3339(),
199 suffix: suffix_with_dot(path),
200 }))
201 }
202
203 fn exists(&self, path: &str) -> bool {
204 self.file_info(path).ok().flatten().is_some()
205 }
206
207 fn is_file(&self, path: &str) -> bool {
208 self.exists(path)
209 }
210
211 fn mkdir(&self, _path: &str) -> std::io::Result<()> {
212 Ok(())
213 }
214}