Skip to main content

krabka_restore/
lib.rs

1//! Offline point-in-time restore of a krabka cluster.
2//!
3//! `krabka restore` reads a KIP-405 tiered-storage archive from object storage
4//! and materializes a complete, bootable krabka data directory, replayed up to
5//! a bound and verified segment by segment as it rehydrates. The bound is an
6//! offset, a timestamp, or a set of exclude-record predicates, so an operator
7//! can recover an event-sourced system to the state it held just before a bad
8//! write. Recovery of that kind is a hand-built runbook everywhere else.
9//!
10//! The tool runs when the cluster does not. It reads the archive through the
11//! object-store layer and formats the target through [`krabka_format`], and it
12//! does not depend on the broker.
13//!
14//! # Stages
15//!
16//! A restore runs five stages, and [`restore`] drives them in order:
17//!
18//! 1. **Discover** lists the archive and groups the keys into segments per
19//!    topic partition.
20//! 2. **Verify** checks the framing and the CRC of every archived segment, and
21//!    derives its true end offset and maximum timestamp.
22//! 3. **Bound** compiles the operator's predicates and decides which batches
23//!    and records survive.
24//! 4. **Materialize** formats the target log directory and writes the
25//!    partition data.
26//! 5. **Report** renders what happened, as text or as JSON.
27//!
28//! # Limits
29//!
30//! A batch that the bound filters is re-encoded from the records that survive,
31//! so its bytes are not identical to the archived bytes. `--exclude-key` and
32//! `--exclude-header` match raw bytes and decode no payload. Without
33//! `--rlmm-snapshot` a segment the old cluster had marked for deletion is
34//! indistinguishable from a live one. Without `--metadata-snapshot`, topic
35//! configuration, ACLs, client quotas, SCRAM credentials, and finalized
36//! feature levels cannot be recovered.
37//!
38//! # Errors and exit codes
39//!
40//! The library returns [`RestoreError`]. Only [`run`] and the binary map one
41//! onto an exit code, so an embedding tool keeps the structured error. See
42//! [`EXIT_OK`] for the codes.
43
44use {
45    self::authenticity::SourceAuthentication,
46    clap::Parser,
47    krabka_remote_storage::{AuthenticatedArchive, diskless::capture::DisklessWalCapture},
48    std::{
49        collections::{BTreeMap, BTreeSet},
50        ffi::OsString,
51        fs,
52        path::Path,
53    },
54};
55
56mod args;
57mod authenticity;
58mod backend;
59mod bound;
60mod discover;
61mod diskless;
62mod error;
63mod materialize;
64mod report;
65mod verify;
66
67pub use self::{
68    args::{
69        ArchiveArgs, HeaderPattern, OffsetBound, OffsetRange, PartitionRef, RestoreArgs, TargetArgs,
70    },
71    backend::{ArchiveStore, object_store_config, open_archive},
72    bound::{BatchDecision, Predicates, RecordDecision},
73    discover::{
74        ArchiveInventory, ArchiveObject, PartitionInventory, SegmentInventory,
75        UNRECOGNIZED_SAMPLE_LIMIT, UnrecognizedKeys, inventory,
76    },
77    error::{
78        EXIT_ARCHIVE_UNREADABLE, EXIT_BAD_ARGUMENTS, EXIT_DIRTY_LOG_DIR, EXIT_INTEGRITY,
79        EXIT_MATERIALIZE, EXIT_OK, RestoreError,
80    },
81    materialize::{
82        FormatTargetOutcome, SegmentOutcome, format_target, seed_rlmm_snapshot, write_segment,
83    },
84    report::{
85        AuthenticationReport, DisklessPartitionReport, DisklessRestoreReport,
86        MetadataRestoreReport, PartitionReport, ReportFormat, RestoreReport, SkippedSegment,
87    },
88    verify::{
89        LeaderEpochStart, SegmentFacts, VerifiedSegment, verify_segment,
90        verify_segment_authenticated,
91    },
92};
93
94/// The restore command line.
95///
96/// The binary and [`run_from_args`] share it, so both accept exactly the same
97/// flags.
98#[derive(Parser)]
99// `long_about = None` keeps the struct's rustdoc out of `--help`. Without it
100// clap renders the doc comment, intra-doc links and all, as the long help.
101#[command(
102    name = "krabka-restore",
103    version,
104    about = "Rebuild a bootable krabka log directory from a KIP-405 tiered-storage archive, \
105             replayed to a point in time",
106    long_about = None
107)]
108pub struct Cli {
109    /// The restore's arguments, flattened so they are top-level flags.
110    #[command(flatten)]
111    pub args: RestoreArgs,
112}
113
114/// Run a restore and return the process exit code.
115///
116/// Every failure is reported on stderr and mapped onto the exit code
117/// [`RestoreError::exit_code`] gives, rather than raised.
118pub async fn run(args: RestoreArgs) -> i32 {
119    let format = args.report;
120    match restore(&args).await {
121        Ok(report) => {
122            if let Some(warning) = report.metadata.warning() {
123                eprintln!("krabka restore: {warning}");
124            }
125            match report.render(format) {
126                Ok(rendered) => {
127                    println!("{rendered}");
128                    EXIT_OK
129                }
130                Err(error) => {
131                    eprintln!("krabka restore: cannot render the report: {error}");
132                    EXIT_MATERIALIZE
133                }
134            }
135        }
136        Err(error) => {
137            eprintln!("krabka restore: {error}");
138            error.exit_code()
139        }
140    }
141}
142
143/// Run a restore from an argv-style iterator and return the process exit code.
144///
145/// `--help` and `--version` render to stdout and return [`EXIT_OK`]. A
146/// malformed command line renders to stderr and returns [`EXIT_BAD_ARGUMENTS`].
147pub async fn run_from_args<I, T>(argv: I) -> i32
148where
149    I: IntoIterator<Item = T>,
150    T: Into<OsString> + Clone,
151{
152    match Cli::try_parse_from(argv) {
153        Ok(cli) => run(cli.args).await,
154        Err(error) => {
155            let _ = error.print();
156            if error.use_stderr() {
157                EXIT_BAD_ARGUMENTS
158            } else {
159                EXIT_OK
160            }
161        }
162    }
163}
164
165/// Run a restore and return its report.
166///
167/// This is the entry point for a caller that wants the structured outcome and
168/// the structured error rather than an exit code.
169///
170/// # Errors
171///
172/// Returns [`RestoreError`] for a bad bound, a bound that names a topic
173/// partition the archive does not hold, a target that is not empty, an
174/// archive that cannot be read, a segment that fails verification, or a target
175/// that rejects a write. `--continue-on-corrupt` turns a verification failure
176/// into a skipped segment in the report instead of an error.
177pub async fn restore(args: &RestoreArgs) -> Result<RestoreReport, RestoreError> {
178    args.validate()?;
179    // Check the target before the archive scan. An operator who pointed at a
180    // live data directory learns it in a second, not after a full download.
181    ensure_empty_log_dir(&args.target.log_dir)?;
182
183    let store = open_archive(args)?;
184    let diskless_capture = diskless::load(args.archive.diskless_wal_capture.as_deref()).await?;
185    let mut archive = inventory(&store, args).await?;
186    let authenticity::AuthenticatedInputs {
187        source: mut authenticated,
188        capture: diskless_authentication,
189        snapshots:
190            authenticity::AuthenticatedSnapshots {
191                metadata: metadata_bytes,
192                rlmm: rlmm_snapshot,
193            },
194        has_classic,
195        unclaimed,
196    } = authenticity::authenticate_inputs(&store, args, &mut archive, diskless_capture.as_ref())
197        .await?;
198    let diskless_claims = diskless_authentication
199        .as_ref()
200        .map(|authentication| &authentication.claims);
201    if let Some(snapshot) = rlmm_snapshot.as_ref().filter(|_| has_classic) {
202        discover::reconcile_authenticated_snapshot(&mut archive.partitions, args, snapshot)?;
203        if archive.partitions.is_empty() {
204            return Err(RestoreError::EmptyArchive {
205                prefix: store.prefix().unwrap_or("").to_owned(),
206            });
207        }
208    }
209    if let Some(capture) = &diskless_capture {
210        diskless::add_partitions(&mut archive, capture, args)?;
211    }
212    if archive.partitions.is_empty() {
213        return Err(RestoreError::EmptyArchive {
214            prefix: store.prefix().unwrap_or("").to_owned(),
215        });
216    }
217    let predicates = Predicates::from_args(args)?;
218    let (format, diskless_next_offsets) = format_with_next_offsets(
219        &store,
220        args,
221        &archive,
222        &predicates,
223        diskless_capture.as_ref(),
224        diskless_claims,
225        metadata_bytes.as_deref(),
226    )
227    .await?;
228    materialize::seed_rlmm_snapshot_from(
229        args,
230        authenticated
231            .as_ref()
232            .and_then(|source| source.archive.as_ref())
233            .map(AuthenticatedArchive::report),
234        rlmm_snapshot.as_ref(),
235    )?;
236
237    let mut consumed_authenticated_objects = BTreeSet::new();
238    if metadata_bytes.is_some() {
239        consumed_authenticated_objects.insert("cluster-metadata.checkpoint".to_owned());
240    }
241    if rlmm_snapshot.is_some() {
242        consumed_authenticated_objects.insert("rlmm-snapshot".to_owned());
243    }
244    let mut partitions = Vec::with_capacity(archive.partitions.len());
245    let mut skipped = unclaimed;
246    for entry in &archive.partitions {
247        if entry.segments.is_empty() {
248            continue;
249        }
250        let mut segments = Vec::with_capacity(entry.segments.len());
251        let mut skipped_predecessor = false;
252        for segment in &entry.segments {
253            let verification = match &authenticated {
254                Some(SourceAuthentication {
255                    archive: Some(authenticated),
256                    ..
257                }) => {
258                    verify_segment_authenticated(
259                        &store,
260                        &entry.partition,
261                        segment,
262                        authenticated.objects(),
263                    )
264                    .await
265                }
266                Some(SourceAuthentication { archive: None, .. }) | None => {
267                    verify_segment(&store, &entry.partition, segment).await
268                }
269            };
270            let verified = match verification {
271                Ok(verified) => verified,
272                Err(error)
273                    if args.continue_on_corrupt
274                        && !matches!(error, RestoreError::Authenticity { .. }) =>
275                {
276                    skipped.push(SkippedSegment {
277                        topic: entry.partition.topic.clone(),
278                        partition: entry.partition.partition,
279                        segment_id: segment.segment_id,
280                        reason: error.to_string(),
281                    });
282                    skipped_predecessor = true;
283                    continue;
284                }
285                Err(error) => return Err(error),
286            };
287            if matches!(
288                &authenticated,
289                Some(SourceAuthentication {
290                    archive: Some(_),
291                    ..
292                })
293            ) {
294                consumed_authenticated_objects
295                    .extend(segment.artifacts().map(|object| object.key.to_string()));
296            }
297            segments.push(
298                materialize::write_segment_after_skip(
299                    args,
300                    &entry.partition,
301                    &verified,
302                    &predicates,
303                    skipped_predecessor,
304                )
305                .await?,
306            );
307            skipped_predecessor = false;
308        }
309        partitions.push(PartitionReport {
310            topic: entry.partition.topic.clone(),
311            partition: entry.partition.partition,
312            topic_id: entry.partition.topic_id,
313            segments,
314        });
315    }
316
317    let diskless = match diskless_capture {
318        Some(capture) => {
319            let report =
320                diskless::materialize(&store, args, &predicates, &capture, diskless_claims).await?;
321            diskless::check_next_offsets(&report, &diskless_next_offsets)?;
322            if diskless_claims.is_some() {
323                consumed_authenticated_objects.extend(
324                    capture
325                        .partitions
326                        .iter()
327                        .filter(|partition| args.selects_topic(&partition.topic))
328                        .flat_map(|partition| &partition.ranges)
329                        .map(|range| range.object_key.clone()),
330                );
331            }
332            Some(report)
333        }
334        None => None,
335    };
336    if let Some(SourceAuthentication { report, .. }) = &mut authenticated {
337        report.objects = u64::try_from(consumed_authenticated_objects.len()).unwrap_or(u64::MAX);
338    }
339    Ok(RestoreReport {
340        dry_run: args.dry_run,
341        log_dir: args.target.log_dir.clone(),
342        cluster_id: format.cluster_id,
343        authentication: authenticated.map(|source| source.report),
344        diskless,
345        metadata: format.metadata,
346        partitions,
347        skipped,
348    })
349}
350
351/// Format the target, seeding the restored metadata with each diskless
352/// partition's next offset, and return the format outcome and those offsets.
353///
354/// The restored metadata starts each diskless partition's offsets at its
355/// materialized end (#1524), so that end is settled before the target is
356/// formatted, and [`diskless::check_next_offsets`] later holds the restore to
357/// it.
358async fn format_with_next_offsets(
359    store: &ArchiveStore,
360    args: &RestoreArgs,
361    archive: &ArchiveInventory,
362    predicates: &Predicates,
363    capture: Option<&DisklessWalCapture>,
364    claims: Option<&BTreeMap<String, krabka_remote_storage::ObjectEntry>>,
365    metadata_snapshot: Option<&[u8]>,
366) -> Result<(FormatTargetOutcome, Vec<diskless::DisklessNextOffset>), RestoreError> {
367    let next_offsets = match capture {
368        Some(capture) => diskless::next_offsets(store, predicates, capture, args, claims).await?,
369        None => Vec::new(),
370    };
371    let format =
372        materialize::format_target_with_snapshot(args, archive, metadata_snapshot, &next_offsets)
373            .await?;
374    Ok((format, next_offsets))
375}
376
377/// Refuse a target that already holds entries.
378///
379/// A restore writes a whole cluster, so it never merges into existing state.
380/// An absent path is acceptable, and the formatter creates it.
381fn ensure_empty_log_dir(log_dir: &Path) -> Result<(), RestoreError> {
382    if !log_dir.exists() {
383        return Ok(());
384    }
385    if fs::read_dir(log_dir)?.next().is_some() {
386        return Err(RestoreError::LogDirNotEmpty(log_dir.to_path_buf()));
387    }
388    Ok(())
389}
390
391#[cfg(test)]
392mod tests {
393    use {super::*, assert2::check, clap::CommandFactory as _};
394
395    #[test]
396    fn the_command_line_is_internally_consistent() { Cli::command().debug_assert(); }
397
398    #[test]
399    fn an_absent_target_is_acceptable() {
400        let parent = tempfile::tempdir().expect("temp dir");
401        check!(ensure_empty_log_dir(&parent.path().join("missing")).is_ok());
402    }
403
404    #[test]
405    fn an_empty_target_is_acceptable() {
406        let target = tempfile::tempdir().expect("temp dir");
407        check!(ensure_empty_log_dir(target.path()).is_ok());
408    }
409
410    #[test]
411    fn a_target_that_holds_anything_is_refused() {
412        let target = tempfile::tempdir().expect("temp dir");
413        fs::write(target.path().join("meta.properties"), b"version=1\n").expect("write");
414        let refused = ensure_empty_log_dir(target.path());
415        check!(matches!(refused, Err(RestoreError::LogDirNotEmpty(_))));
416        check!(refused.expect_err("refused").exit_code() == EXIT_DIRTY_LOG_DIR);
417    }
418
419    #[tokio::test]
420    async fn help_renders_and_succeeds() {
421        check!(run_from_args(["krabka-restore", "--help"]).await == EXIT_OK);
422    }
423
424    #[tokio::test]
425    async fn a_malformed_command_line_is_a_bad_argument() {
426        check!(run_from_args(["krabka-restore", "--not-a-flag"]).await == EXIT_BAD_ARGUMENTS);
427        check!(run_from_args(["krabka-restore"]).await == EXIT_BAD_ARGUMENTS);
428    }
429}