Skip to main content

krabka_restore/
backend.rs

1//! The archive handle: the `--archive-*` flags turned into an object store.
2//!
3//! The broker holds the same backend mapping behind its own TOML config, but a
4//! recovery tool must run when the broker does not, so this builds straight on
5//! the object-store layer instead.
6
7use {
8    crate::{args::RestoreArgs, error::RestoreError},
9    krabka_object_store::{
10        ObjectOps, ObjectStoreClient, ObjectStoreConfig, build_object_store, normalize_prefix,
11        prefixed_key,
12    },
13    object_store::path::Path,
14    std::sync::Arc,
15};
16
17/// A read handle on the archive, with the operator's key prefix applied.
18///
19/// The client is fully async. `S3RemoteStorage` would also serve the reads,
20/// but it uses `block_in_place`, which fails without a current multi-thread
21/// Tokio runtime handle.
22#[derive(Clone)]
23pub struct ArchiveStore {
24    store: Arc<dyn object_store::ObjectStore>,
25    client: ObjectStoreClient,
26    prefix: Option<String>,
27}
28
29impl ArchiveStore {
30    /// The object operations to read the archive with.
31    #[must_use]
32    pub fn ops(&self) -> &dyn ObjectOps { &self.client }
33
34    /// The object-store handle used by the WORM verifier.
35    #[must_use]
36    pub fn store(&self) -> &Arc<dyn object_store::ObjectStore> { &self.store }
37
38    /// The key prefix every archive key carries, absent when the archive is
39    /// the whole bucket.
40    #[must_use]
41    pub fn prefix(&self) -> Option<&str> { self.prefix.as_deref() }
42
43    /// The absolute object key for a path relative to the archive root.
44    #[must_use]
45    pub fn key(&self, relative: &str) -> Path { prefixed_key(self.prefix.as_deref(), relative) }
46
47    /// The prefix to list the archive root under.
48    #[must_use]
49    pub fn root(&self) -> Option<Path> { self.prefix.as_deref().map(Path::from) }
50
51    /// A handle onto an already-built object store, under `prefix`.
52    ///
53    /// [`open_archive`] is what the binary calls; this is the seam a test
54    /// drives the scan through a store of its own with, such as one that
55    /// synthesises a listing no fixture could write to disk.
56    #[must_use]
57    pub fn with_store(store: Arc<dyn object_store::ObjectStore>, prefix: Option<&str>) -> Self {
58        Self {
59            client: ObjectStoreClient::new(Arc::clone(&store)),
60            store,
61            prefix: normalize_prefix(prefix),
62        }
63    }
64}
65
66/// Build the archive handle the `--archive-*` flags describe.
67///
68/// # Errors
69///
70/// Returns [`RestoreError::ObjectStore`] when the backend builder rejects the
71/// bucket, region, endpoint, or credentials, and
72/// [`RestoreError::InvalidArgument`] when no backend was selected.
73pub fn open_archive(args: &RestoreArgs) -> Result<ArchiveStore, RestoreError> {
74    let config = object_store_config(args)?;
75    let store: Arc<dyn object_store::ObjectStore> = build_object_store(&config)?;
76    Ok(ArchiveStore {
77        client: ObjectStoreClient::new(Arc::clone(&store)),
78        store,
79        prefix: args.archive.location.normalized_prefix(),
80    })
81}
82
83/// Map the `--archive-*` flags onto an object-store configuration.
84///
85/// The prefix is not set here. It stays on [`ArchiveStore`], so the same
86/// handle can address a key inside the archive and list the archive root.
87///
88/// # Errors
89///
90/// Returns [`RestoreError::InvalidArgument`] when no backend was selected.
91/// The argument parser makes exactly one of them required, so this reports a
92/// caller that built [`RestoreArgs`] by hand.
93pub fn object_store_config(args: &RestoreArgs) -> Result<ObjectStoreConfig, RestoreError> {
94    args.archive
95        .location
96        .to_config()
97        .map_err(|error| RestoreError::InvalidArgument(error.to_string()))
98}
99
100#[cfg(test)]
101mod tests {
102    use {super::*, crate::Cli, assert2::check, clap::Parser as _, object_store::memory::InMemory};
103
104    fn args_from(extra: &[&str]) -> RestoreArgs {
105        let mut argv = vec!["krabka-restore", "--log-dir", "/target"];
106        argv.extend_from_slice(extra);
107        Cli::parse_from(argv).args
108    }
109
110    krabka_macros::s3_archive_config_fixture!(expected_s3_config, krabka_object_store::S3Config);
111
112    /// The mapping itself is pinned in `krabka-object-store`; this checks the
113    /// flags reach it through restore's own command line.
114    #[test]
115    fn s3_flags_map_onto_the_s3_config() {
116        let config = object_store_config(&args_from(&[
117            "--archive-s3-bucket",
118            "backups",
119            "--archive-s3-region",
120            "eu-west-1",
121            "--archive-s3-endpoint",
122            "http://minio:9000",
123            "--archive-s3-access-key-id",
124            "key",
125            "--archive-s3-secret-access-key",
126            "secret",
127            "--archive-s3-allow-http",
128        ]))
129        .expect("config");
130        let ObjectStoreConfig::S3(s3) = config else {
131            panic!("expected an S3 config");
132        };
133        check!(s3 == expected_s3_config());
134    }
135
136    #[test]
137    fn a_hand_built_args_without_a_backend_is_rejected() {
138        let mut args = args_from(&["--archive-local", "/archive"]);
139        args.archive.location.local = None;
140        check!(matches!(
141            object_store_config(&args),
142            Err(RestoreError::InvalidArgument(_))
143        ));
144    }
145
146    #[yare::parameterized(
147        prefixed = { Some("/tier/") },
148        bare = { None },
149    )]
150    fn keys_are_built_with_an_optional_prefix(prefix: Option<&str>) {
151        // `LocalFileSystem` canonicalizes its root, so the archive must exist.
152        let archive = tempfile::tempdir().expect("temp dir");
153        let local = archive.path().display().to_string();
154        let mut args = vec!["--archive-local", local.as_str()];
155        if let Some(prefix) = prefix {
156            args.extend(["--archive-prefix", prefix]);
157        }
158        let store = open_archive(&args_from(&args)).expect("store");
159        let expected_prefix = prefix.map(|_| Path::from("tier"));
160        let expected_key = match prefix {
161            Some(_) => "tier/orders-0-abc/000.log",
162            None => "orders-0-abc/000.log",
163        };
164        check!(store.prefix() == prefix.map(|_| "tier"));
165        check!(store.key("orders-0-abc/000.log") == Path::from(expected_key));
166        check!(store.root() == expected_prefix);
167    }
168
169    /// The seam a test drives the scan through: a store built elsewhere, with
170    /// the same prefix handling the flags get, and reads that reach it.
171    #[tokio::test]
172    async fn a_handle_on_a_store_built_elsewhere_reads_it_under_the_prefix() {
173        let inner = Arc::new(InMemory::new());
174        let store = ArchiveStore::with_store(Arc::<InMemory>::clone(&inner), Some("/tier/"));
175
176        check!(store.prefix() == Some("tier"));
177        check!(store.root() == Some(Path::from("tier")));
178        check!(store.key("orders-0-abc/000.log") == Path::from("tier/orders-0-abc/000.log"));
179
180        object_store::ObjectStoreExt::put(
181            inner.as_ref(),
182            &store.key("orders-0-abc/000.log"),
183            object_store::PutPayload::from_static(b"segment bytes"),
184        )
185        .await
186        .expect("write an object");
187        let listed = store.ops().list(store.root()).await.expect("list");
188        check!(
189            listed
190                .iter()
191                .map(|meta| meta.location.clone())
192                .collect::<Vec<_>>()
193                == vec![Path::from("tier/orders-0-abc/000.log")]
194        );
195
196        // And with no prefix the same store is the whole archive.
197        let bare = ArchiveStore::with_store(inner, None);
198        check!(bare.prefix().is_none());
199        check!(bare.root().is_none());
200        check!(bare.key("orders-0-abc/000.log") == Path::from("orders-0-abc/000.log"));
201    }
202}