krabka_remote_storage/s3/
client.rs1use {
13 super::{S3RemoteStorage, WormBucket, WormMode, size_from_usize},
14 crate::{
15 error::RemoteStorageError,
16 worm::{WormArchiver, WormConfig},
17 },
18 krabka_object_store::{
19 DEFAULT_MULTIPART_CHUNK_SIZE, DEFAULT_MULTIPART_THRESHOLD, ObjectStoreClient,
20 ObjectStoreConfig, ObjectStoreError, S3Config, build_object_store, verify_gcs_worm_bucket,
21 verify_s3_worm_bucket,
22 },
23 krabka_units::prelude::{ByteSize, ByteSizeExt as _},
24 object_store::ObjectStore,
25 std::{future::Future, sync::Arc, thread},
26 tokio::{runtime, runtime::Handle, task},
27};
28
29impl S3RemoteStorage {
30 pub const DEFAULT_MAX_INDEX_BYTES: u64 = i32::MAX as u64;
34
35 #[must_use]
40 pub fn with_max_index_bytes(mut self, max_bytes: u64) -> Self {
41 self.max_index_bytes = max_bytes.min(Self::DEFAULT_MAX_INDEX_BYTES);
42 self
43 }
44
45 #[must_use]
52 pub fn with_store(store: Arc<dyn ObjectStore>, prefix: Option<String>) -> Self {
53 Self {
54 ops: ObjectStoreClient::new(store),
55 prefix,
56 multipart_threshold: ByteSize::from_bytes(DEFAULT_MULTIPART_THRESHOLD),
57 multipart_chunk_size: size_from_usize(DEFAULT_MULTIPART_CHUNK_SIZE),
58 max_index_bytes: Self::DEFAULT_MAX_INDEX_BYTES,
59 worm: None,
60 worm_bucket: WormBucket::Unverified,
61 }
62 }
63
64 pub fn with_worm(self, cfg: &WormConfig) -> Result<Self, RemoteStorageError> {
75 match &self.worm_bucket {
76 WormBucket::S3(s3) => {
77 if !s3.conditional_put {
78 return Err(RemoteStorageError::InvalidArgument(
79 "WORM mode requires remote_storage.s3.conditional_put = true".into(),
80 ));
81 }
82 }
83 WormBucket::Gcs(_) => {}
84 WormBucket::Unverified => {
85 return Err(RemoteStorageError::InvalidArgument(
86 "WORM mode requires an object-store configuration so bucket versioning and \
87 retention can be confirmed"
88 .into(),
89 ));
90 }
91 }
92 verify_worm_bucket(&self.worm_bucket)?;
93 self.enable_worm(cfg, true)
94 }
95
96 fn enable_worm(
97 mut self,
98 cfg: &WormConfig,
99 require_version_id: bool,
100 ) -> Result<Self, RemoteStorageError> {
101 self.worm = Some(WormMode {
102 archiver: WormArchiver::from_config(cfg)?,
103 write_only: cfg.write_only,
104 require_version_id,
105 });
106 Ok(self)
107 }
108
109 #[cfg(test)]
110 pub(super) fn with_worm_unchecked(self, cfg: &WormConfig) -> Result<Self, RemoteStorageError> {
111 self.enable_worm(cfg, false)
112 }
113
114 #[must_use]
118 pub fn with_multipart_tuning(mut self, threshold: ByteSize, chunk_size: ByteSize) -> Self {
119 self.multipart_threshold = threshold;
120 self.multipart_chunk_size = chunk_size;
121 self
122 }
123
124 pub(crate) fn from_backend_config(
125 config: &ObjectStoreConfig,
126 prefix: Option<String>,
127 threshold: u64,
128 chunk_size: usize,
129 worm_bucket: WormBucket,
130 ) -> Result<Self, RemoteStorageError> {
131 let store = build_object_store(config)
132 .map_err(|error| RemoteStorageError::InvalidArgument(error.to_string()))?;
133 let mut storage = Self::with_store(store, prefix)
134 .with_multipart_tuning(ByteSize::from_bytes(threshold), size_from_usize(chunk_size));
135 storage.worm_bucket = worm_bucket;
136 Ok(storage)
137 }
138
139 pub fn from_s3_config(cfg: &S3Config) -> Result<Self, RemoteStorageError> {
146 Self::from_backend_config(
147 &ObjectStoreConfig::S3(cfg.clone()),
148 cfg.prefix.clone(),
149 cfg.multipart_threshold,
150 cfg.multipart_chunk_size,
151 WormBucket::S3(cfg.clone()),
152 )
153 }
154
155 pub(super) fn block_os<T, F>(fut: F) -> Result<T, ObjectStoreError>
160 where
161 F: Future<Output = Result<T, ObjectStoreError>>,
162 {
163 let handle = Handle::try_current().map_err(|_| {
164 ObjectStoreError::Backend(
165 "S3RemoteStorage requires an active Tokio runtime; call from spawn_blocking".into(),
166 )
167 })?;
168 task::block_in_place(|| handle.block_on(fut))
169 }
170}
171
172fn verify_worm_bucket(bucket: &WormBucket) -> Result<(), RemoteStorageError> {
173 thread::scope(|scope| {
174 scope
175 .spawn(|| {
176 runtime::Builder::new_current_thread()
177 .enable_all()
178 .build()
179 .map_err(ObjectStoreError::from)?
180 .block_on(async {
181 match bucket {
182 WormBucket::S3(cfg) => verify_s3_worm_bucket(cfg).await,
183 WormBucket::Gcs(cfg) => verify_gcs_worm_bucket(cfg).await,
184 WormBucket::Unverified => unreachable!("checked by with_worm"),
185 }
186 })
187 })
188 .join()
189 .map_err(|_| ObjectStoreError::Backend("WORM bucket check panicked".into()))?
190 })?;
191 Ok(())
192}
193
194#[cfg(test)]
195mod tests {
196 use {
197 super::S3RemoteStorage,
198 crate::{
199 s3::test_support::{WormAccess, worm_config},
200 worm::WormConfig,
201 },
202 assert2::check,
203 krabka_object_store::{
204 DEFAULT_MULTIPART_CHUNK_SIZE, DEFAULT_MULTIPART_THRESHOLD, S3Config,
205 },
206 krabka_units::prelude::{ByteSizeExt as _, kibibytes, mebibytes},
207 object_store::memory::InMemory,
208 std::sync::Arc,
209 tempfile::TempDir,
210 };
211
212 #[test]
219 fn multipart_tuning_round_trips_through_the_primitive_seams() {
220 let store = S3RemoteStorage::with_store(Arc::new(InMemory::new()), None);
221 check!(store.multipart_threshold.bytes_u64() == DEFAULT_MULTIPART_THRESHOLD);
222 check!(store.multipart_chunk_size.bytes_usize() == DEFAULT_MULTIPART_CHUNK_SIZE);
223
224 let tuned = store.with_multipart_tuning(mebibytes(64), kibibytes(512));
225 check!(tuned.multipart_threshold.bytes_u64() == 64 * 1024 * 1024);
226 check!(tuned.multipart_chunk_size.bytes_usize() == 512 * 1024);
227 }
228
229 #[test]
230 fn worm_refuses_disabled_conditional_put_before_contacting_s3() {
231 let keys = TempDir::new().unwrap();
232 let s3 = S3Config {
233 bucket: "archive".into(),
234 region: "us-east-1".into(),
235 conditional_put: false,
236 ..Default::default()
237 };
238
239 let error = S3RemoteStorage::from_s3_config(&s3)
240 .unwrap()
241 .with_worm(&worm_config(keys.path(), WormAccess::ReadWrite))
242 .unwrap_err();
243
244 check!(error.to_string().contains("conditional_put = true"));
245 }
246
247 #[test]
248 fn worm_refuses_an_opaque_store_without_policy_config() {
249 let error = S3RemoteStorage::with_store(Arc::new(InMemory::new()), None)
250 .with_worm(&WormConfig::default())
251 .unwrap_err();
252
253 check!(error.to_string().contains("object-store configuration"));
254 }
255}