1use {
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#[derive(Parser)]
99#[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 #[command(flatten)]
111 pub args: RestoreArgs,
112}
113
114pub 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
143pub 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
165pub async fn restore(args: &RestoreArgs) -> Result<RestoreReport, RestoreError> {
178 args.validate()?;
179 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
351async 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
377fn 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}