entrenar/storage/cloud/
s3.rs1use crate::storage::cloud::error::Result;
4use crate::storage::cloud::memory::InMemoryBackend;
5use crate::storage::cloud::metadata::ArtifactMetadata;
6use crate::storage::cloud::traits::ArtifactBackend;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct S3Config {
12 pub bucket: String,
14 pub prefix: String,
16 pub region: Option<String>,
18 pub endpoint: Option<String>,
20 pub access_key_id: Option<String>,
22 pub secret_access_key: Option<String>,
24}
25
26impl S3Config {
27 pub fn new(bucket: &str, prefix: &str) -> Self {
29 Self {
30 bucket: bucket.to_string(),
31 prefix: prefix.to_string(),
32 region: None,
33 endpoint: None,
34 access_key_id: None,
35 secret_access_key: None,
36 }
37 }
38
39 pub fn with_region(mut self, region: &str) -> Self {
41 self.region = Some(region.to_string());
42 self
43 }
44
45 pub fn with_endpoint(mut self, endpoint: &str) -> Self {
47 self.endpoint = Some(endpoint.to_string());
48 self
49 }
50
51 pub fn with_credentials(mut self, access_key_id: &str, secret_access_key: &str) -> Self {
53 self.access_key_id = Some(access_key_id.to_string());
54 self.secret_access_key = Some(secret_access_key.to_string());
55 self
56 }
57
58 pub fn key_for_hash(&self, hash: &str) -> String {
60 if self.prefix.is_empty() {
61 hash.to_string()
62 } else {
63 format!("{}/{}", self.prefix.trim_end_matches('/'), hash)
64 }
65 }
66}
67
68#[derive(Debug)]
70pub struct MockS3Backend {
71 config: S3Config,
72 inner: InMemoryBackend,
73}
74
75impl MockS3Backend {
76 pub fn new(config: S3Config) -> Self {
78 Self { config, inner: InMemoryBackend::new() }
79 }
80
81 pub fn config(&self) -> &S3Config {
83 &self.config
84 }
85}
86
87impl ArtifactBackend for MockS3Backend {
88 fn put(&self, name: &str, data: &[u8]) -> Result<String> {
89 self.inner.put(name, data)
90 }
91
92 fn get(&self, hash: &str) -> Result<Vec<u8>> {
93 self.inner.get(hash)
94 }
95
96 fn exists(&self, hash: &str) -> Result<bool> {
97 self.inner.exists(hash)
98 }
99
100 fn delete(&self, hash: &str) -> Result<()> {
101 self.inner.delete(hash)
102 }
103
104 fn get_metadata(&self, hash: &str) -> Result<ArtifactMetadata> {
105 self.inner.get_metadata(hash)
106 }
107
108 fn list(&self) -> Result<Vec<ArtifactMetadata>> {
109 self.inner.list()
110 }
111
112 fn backend_type(&self) -> &'static str {
113 "s3"
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn test_s3_config_new() {
123 let config = S3Config::new("my-bucket", "artifacts/");
124 assert_eq!(config.bucket, "my-bucket");
125 assert_eq!(config.prefix, "artifacts/");
126 }
127
128 #[test]
129 fn test_s3_config_with_region() {
130 let config = S3Config::new("bucket", "prefix").with_region("us-west-2");
131 assert_eq!(config.region, Some("us-west-2".to_string()));
132 }
133
134 #[test]
135 fn test_s3_config_with_endpoint() {
136 let config = S3Config::new("bucket", "prefix").with_endpoint("http://minio:9000");
137 assert_eq!(config.endpoint, Some("http://minio:9000".to_string()));
138 }
139
140 #[test]
141 fn test_s3_config_key_for_hash() {
142 let config = S3Config::new("bucket", "artifacts");
143 assert_eq!(config.key_for_hash("abc123"), "artifacts/abc123");
144
145 let config = S3Config::new("bucket", "");
146 assert_eq!(config.key_for_hash("abc123"), "abc123");
147 }
148
149 #[test]
150 fn test_mock_s3_backend_put_get() {
151 let config = S3Config::new("test-bucket", "prefix");
152 let backend = MockS3Backend::new(config);
153
154 let data = b"s3 test data";
155 let hash = backend.put("file.bin", data).expect("operation should succeed");
156
157 let retrieved = backend.get(&hash).expect("key should exist");
158 assert_eq!(retrieved, data);
159 }
160
161 #[test]
162 fn test_mock_s3_backend_type() {
163 let config = S3Config::new("bucket", "prefix");
164 let backend = MockS3Backend::new(config);
165 assert_eq!(backend.backend_type(), "s3");
166 }
167
168 #[test]
169 fn test_s3_config_with_credentials() {
170 let config = S3Config::new("bucket", "prefix").with_credentials("access_key", "secret_key");
171 assert_eq!(config.access_key_id, Some("access_key".to_string()));
172 assert_eq!(config.secret_access_key, Some("secret_key".to_string()));
173 }
174
175 #[test]
176 fn test_s3_config_key_for_hash_with_trailing_slash() {
177 let config = S3Config::new("bucket", "artifacts/");
178 assert_eq!(config.key_for_hash("abc123"), "artifacts/abc123");
179 }
180
181 #[test]
182 fn test_mock_s3_backend_exists() {
183 let config = S3Config::new("bucket", "prefix");
184 let backend = MockS3Backend::new(config);
185
186 let hash = backend.put("file.bin", b"data").expect("operation should succeed");
187 assert!(backend.exists(&hash).expect("operation should succeed"));
188 assert!(!backend.exists("nonexistent").expect("operation should succeed"));
189 }
190
191 #[test]
192 fn test_mock_s3_backend_delete() {
193 let config = S3Config::new("bucket", "prefix");
194 let backend = MockS3Backend::new(config);
195
196 let hash = backend.put("file.bin", b"data").expect("operation should succeed");
197 backend.delete(&hash).expect("operation should succeed");
198 assert!(!backend.exists(&hash).expect("operation should succeed"));
199 }
200
201 #[test]
202 fn test_mock_s3_backend_get_metadata() {
203 let config = S3Config::new("bucket", "prefix");
204 let backend = MockS3Backend::new(config);
205
206 let hash = backend.put("model.bin", b"model data").expect("operation should succeed");
207 let meta = backend.get_metadata(&hash).expect("operation should succeed");
208 assert_eq!(meta.name, "model.bin");
209 }
210
211 #[test]
212 fn test_mock_s3_backend_list() {
213 let config = S3Config::new("bucket", "prefix");
214 let backend = MockS3Backend::new(config);
215
216 backend.put("file1.bin", b"data1").expect("operation should succeed");
217 backend.put("file2.bin", b"data2").expect("operation should succeed");
218
219 let list = backend.list().expect("operation should succeed");
220 assert_eq!(list.len(), 2);
221 }
222
223 #[test]
224 fn test_mock_s3_backend_config() {
225 let config = S3Config::new("test-bucket", "models/").with_region("us-east-1");
226 let backend = MockS3Backend::new(config);
227
228 assert_eq!(backend.config().bucket, "test-bucket");
229 assert_eq!(backend.config().prefix, "models/");
230 assert_eq!(backend.config().region, Some("us-east-1".to_string()));
231 }
232
233 #[test]
234 fn test_s3_config_serde() {
235 let config = S3Config::new("bucket", "prefix")
236 .with_region("us-west-2")
237 .with_endpoint("http://localhost:9000")
238 .with_credentials("key", "secret");
239
240 let json = serde_json::to_string(&config).expect("JSON serialization should succeed");
241 let parsed: S3Config =
242 serde_json::from_str(&json).expect("JSON deserialization should succeed");
243
244 assert_eq!(config.bucket, parsed.bucket);
245 assert_eq!(config.region, parsed.region);
246 assert_eq!(config.endpoint, parsed.endpoint);
247 }
248}
249
250#[cfg(test)]
251mod property_tests {
252 use super::*;
253 use proptest::prelude::*;
254
255 proptest! {
256 #![proptest_config(ProptestConfig::with_cases(200))]
257
258 #[test]
259 fn prop_s3_key_contains_hash(
260 prefix in "[a-zA-Z0-9/]{0,20}",
261 hash in "[a-f0-9]{64}"
262 ) {
263 let config = S3Config::new("bucket", &prefix);
264 let key = config.key_for_hash(&hash);
265 prop_assert!(key.contains(&hash));
266 }
267 }
268}