Skip to main content

krabka_backup/
archive.rs

1//! The archive the capture is written to: the `--archive-*` flags turned into
2//! an object store, plus the operations this tool needs from it.
3//!
4//! The flags carry the names `krabka restore` gives them, because the capture
5//! is written into the archive the restore reads and an operator must not have
6//! to learn two spellings for one bucket.
7
8pub 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
16/// A read and write handle on the archive, with the operator's key prefix
17/// applied.
18pub struct Archive {
19    client: ObjectStoreClient,
20    prefix: Option<String>,
21}
22
23impl Archive {
24    /// Build the handle the `--archive-*` flags describe.
25    ///
26    /// # Errors
27    ///
28    /// Returns [`BackupError::InvalidArgument`] when the flags contradict each
29    /// other, and [`BackupError::ObjectStore`] when the backend builder rejects
30    /// the bucket, region, endpoint, or credentials.
31    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    /// The absolute object key for a path relative to the archive root.
41    #[must_use]
42    pub fn key(&self, relative: &str) -> Path {
43        krabka_object_store::prefixed_key(self.prefix.as_deref(), relative)
44    }
45
46    /// Write one object, overwriting whatever was there.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`BackupError::ObjectStore`] when the write fails.
51    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    /// Create a capture object, refusing to replace an existing object.
59    ///
60    /// # Errors
61    /// Returns [`BackupError::ObjectStore`] when the key already exists or the write fails.
62    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    /// Read one object whole.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`BackupError::ObjectStore`] when the object is absent or the
77    /// read fails.
78    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    /// Read an archive-relative object within an independent byte limit.
83    ///
84    /// # Errors
85    /// Returns [`BackupError::ObjectStore`] when the object is absent, exceeds `max_bytes`, changes during the read, or cannot be read.
86    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    /// Read an exact object key, refusing oversized bodies before buffering.
95    ///
96    /// # Errors
97    /// Returns [`BackupError::ObjectStore`] when the object is absent, exceeds
98    /// `max_bytes`, or cannot be read.
99    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    /// The distinct directory names one level below `relative`.
112    ///
113    /// Object stores have no directories, so this lists the keys under the
114    /// prefix and keeps the segment that follows it. An empty listing is an
115    /// empty set rather than an error: a bucket that has never been captured
116    /// into is not a failure, and the caller says what that means.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`BackupError::ObjectStore`] when the listing fails.
121    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    /// The shared flag checks reach the operator as a bad argument, exit 2.
176    #[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            // A key directly under the root names no capture directory.
224            "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}