1pub use krabka_object_store::ArchiveArgs;
9use {
10 crate::error::BackupError,
11 krabka_object_store::{ObjectOps, ObjectStoreClient, PutMode, PutRequest, build_object_store},
12 object_store::path::Path,
13 std::{collections::BTreeSet, sync::Arc},
14};
15
16pub struct Archive {
19 client: ObjectStoreClient,
20 prefix: Option<String>,
21}
22
23impl Archive {
24 pub fn open(args: &ArchiveArgs) -> Result<Self, BackupError> {
32 args.validate()?;
33 let store: Arc<dyn object_store::ObjectStore> = build_object_store(&args.to_config()?)?;
34 Ok(Self {
35 client: ObjectStoreClient::new(store),
36 prefix: args.normalized_prefix(),
37 })
38 }
39
40 #[must_use]
42 pub fn key(&self, relative: &str) -> Path {
43 krabka_object_store::prefixed_key(self.prefix.as_deref(), relative)
44 }
45
46 pub async fn put(&self, relative: &str, bytes: Vec<u8>) -> Result<(), BackupError> {
52 self.client
53 .put(&self.key(relative), bytes.into(), PutRequest::default())
54 .await?;
55 Ok(())
56 }
57
58 pub async fn put_new(&self, relative: &str, bytes: Vec<u8>) -> Result<(), BackupError> {
63 self.client
64 .put(&self.key(relative), bytes.into(), PutRequest {
65 mode: PutMode::Create,
66 ..PutRequest::default()
67 })
68 .await?;
69 Ok(())
70 }
71
72 pub async fn get(&self, relative: &str) -> Result<Vec<u8>, BackupError> {
79 Ok(self.client.get(&self.key(relative)).await?.to_vec())
80 }
81
82 pub async fn get_capped(&self, relative: &str, max_bytes: u64) -> Result<Vec<u8>, BackupError> {
87 Ok(self
88 .client
89 .get_capped(&self.key(relative), max_bytes)
90 .await?
91 .to_vec())
92 }
93
94 pub async fn get_absolute_capped(
100 &self,
101 key: &str,
102 max_bytes: u64,
103 ) -> Result<Vec<u8>, BackupError> {
104 Ok(self
105 .client
106 .get_capped(&Path::from(key), max_bytes)
107 .await?
108 .to_vec())
109 }
110
111 pub async fn child_directories(&self, relative: &str) -> Result<BTreeSet<String>, BackupError> {
122 let prefix = self.key(relative);
123 let listing = self.client.list(Some(prefix.clone())).await?;
124 let under = format!("{prefix}/");
125 Ok(listing
126 .into_iter()
127 .filter_map(|meta| {
128 let key = meta.location.as_ref().to_owned();
129 let rest = key.strip_prefix(&under)?;
130 let (head, _) = rest.split_once('/')?;
131 (!head.is_empty()).then(|| head.to_owned())
132 })
133 .collect())
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use {
140 super::{Archive, ArchiveArgs, BTreeSet},
141 crate::error::{BackupError, EXIT_BAD_ARGUMENT},
142 assert2::check,
143 bytes::Bytes,
144 krabka_object_store::{ObjectStoreClient, ObjectStoreError, fault::FaultInjectingStore},
145 object_store::{ObjectStoreExt as _, PutPayload, path::Path},
146 std::{fs, path, sync::Arc},
147 };
148
149 fn local_args(root: &path::Path) -> ArchiveArgs {
150 ArchiveArgs {
151 local: Some(root.to_path_buf()),
152 ..ArchiveArgs::default()
153 }
154 }
155
156 #[test]
157 fn a_key_carries_the_normalized_prefix_when_there_is_one() {
158 let root = tempfile::tempdir().expect("archive root");
159 let archive = Archive::open(&ArchiveArgs {
160 prefix: Some(" /prod/tier/ ".to_owned()),
161 ..local_args(root.path())
162 })
163 .expect("open a local archive");
164 check!(
165 archive.key("restore-inputs/1/manifest.json").as_ref()
166 == "prod/tier/restore-inputs/1/manifest.json"
167 );
168
169 let bare = Archive::open(&local_args(root.path())).expect("open a local archive");
170 check!(
171 bare.key("restore-inputs/1/manifest.json").as_ref() == "restore-inputs/1/manifest.json"
172 );
173 }
174
175 #[yare::parameterized(
177 orphan_s3_region = {
178 Some("eu-west-1"),
179 true,
180 "--archive-s3-region needs --archive-s3-bucket",
181 },
182 no_backend = {
183 None,
184 false,
185 "no archive backend selected: pass one of --archive-local, --archive-s3-bucket, or --archive-gcs-bucket",
186 },
187 )]
188 fn a_flag_set_the_archive_cannot_mean_is_a_bad_argument(
189 s3_region: Option<&str>,
190 local: bool,
191 expected: &str,
192 ) {
193 let root = tempfile::tempdir().expect("archive root");
194 let args = ArchiveArgs {
195 s3_region: s3_region.map(str::to_owned),
196 local: local.then(|| root.path().to_path_buf()),
197 ..ArchiveArgs::default()
198 };
199 let Err(error) = Archive::open(&args) else {
200 panic!("{expected}: the archive opened")
201 };
202 check!(matches!(&error, BackupError::InvalidArgument(_)));
203 check!(error.exit_code() == EXIT_BAD_ARGUMENT);
204 check!(error.to_string() == expected);
205 }
206
207 #[tokio::test(flavor = "multi_thread")]
208 async fn the_archive_lists_the_directories_one_level_below_a_prefix() {
209 let root = tempfile::tempdir().expect("archive root");
210 let archive = Archive::open(&local_args(root.path())).expect("open the archive");
211 check!(
212 archive
213 .child_directories("restore-inputs")
214 .await
215 .expect("list an archive that has never been captured into")
216 .is_empty()
217 );
218
219 for key in [
220 "restore-inputs/0000000000000001/manifest.json",
221 "restore-inputs/0000000000000001/rlmm-snapshot",
222 "restore-inputs/0000000000000002/manifest.json",
223 "restore-inputs/stray",
225 ] {
226 archive
227 .put(key, b"bytes".to_vec())
228 .await
229 .expect("write an object");
230 }
231
232 check!(
233 archive
234 .child_directories("restore-inputs")
235 .await
236 .expect("list the captures")
237 == BTreeSet::from(["0000000000000001".to_owned(), "0000000000000002".to_owned()])
238 );
239 check!(
240 archive
241 .get("restore-inputs/stray")
242 .await
243 .expect("read an object back")
244 == b"bytes".to_vec()
245 );
246 }
247
248 #[tokio::test]
249 async fn capture_objects_cannot_replace_a_completed_object() {
250 let root = tempfile::tempdir().unwrap();
251 let archive = Archive::open(&local_args(root.path())).unwrap();
252 archive
253 .put_new("capture/manifest.json", b"first".to_vec())
254 .await
255 .unwrap();
256 check!(
257 archive
258 .put_new("capture/manifest.json", b"second".to_vec())
259 .await
260 .is_err()
261 );
262 check!(archive.get("capture/manifest.json").await.unwrap() == b"first");
263 }
264
265 async fn archive_that_grows_during_get(key: &str, prefix: Option<&str>) -> Archive {
266 let store = Arc::new(
267 FaultInjectingStore::memory().with_get_overwrite(Bytes::from_static(b"123456")),
268 );
269 store
270 .put(&Path::from(key), PutPayload::from_static(b"123"))
271 .await
272 .unwrap();
273 Archive {
274 client: ObjectStoreClient::new(store),
275 prefix: prefix.map(str::to_owned),
276 }
277 }
278
279 #[tokio::test]
280 async fn capped_relative_reads_apply_the_prefix_and_detect_growth() {
281 let archive = archive_that_grows_during_get("prod/document", Some("prod")).await;
282 check!(matches!(
283 archive.get_capped("document", 5).await,
284 Err(BackupError::ObjectStore(
285 ObjectStoreError::Precondition { .. }
286 ))
287 ));
288 }
289
290 #[tokio::test]
291 async fn an_absolute_read_rejects_an_oversized_object_before_fetching_it() {
292 let root = tempfile::tempdir().expect("archive root");
293 fs::write(root.path().join("wal.ckwl"), b"oversized").expect("write object");
294 let archive = Archive::open(&local_args(root.path())).expect("open the archive");
295
296 let error = archive
297 .get_absolute_capped("wal.ckwl", 4)
298 .await
299 .expect_err("object exceeds the cap");
300
301 check!(error.to_string().contains("exceeds cap of 4 bytes"));
302 }
303
304 #[tokio::test]
305 async fn an_absolute_read_rejects_growth_between_head_and_get() {
306 let archive = archive_that_grows_during_get("wal.ckwl", None).await;
307 let error = archive
308 .get_absolute_capped("wal.ckwl", 5)
309 .await
310 .unwrap_err();
311 check!(matches!(
312 error,
313 BackupError::ObjectStore(ObjectStoreError::Precondition { .. })
314 ));
315 }
316}