Skip to main content

krabka_restore/
report.rs

1//! The restore summary an operator reads, and its text and JSON renderings.
2//!
3//! This module owns the report model and nothing else. It aggregates what the
4//! other stages establish: which partitions were restored, how many segments
5//! and records each kept, where each partition's log now ends, which segments
6//! failed verification and were skipped under `--continue-on-corrupt`, and
7//! which bounds were applied. The text rendering is for a human during an
8//! incident, and the JSON rendering is for a runbook that has to assert on the
9//! outcome. Both render the same model, so they can never disagree. A dry run
10//! produces the same report as a real run, with `dry_run` set.
11
12use {
13    crate::materialize::SegmentOutcome,
14    serde::Serialize,
15    std::{
16        collections::BTreeMap,
17        fmt::{self, Display, Write},
18        path::PathBuf,
19    },
20    uuid::Uuid,
21};
22
23/// How [`RestoreReport::render`] writes the summary.
24///
25/// This is the `--report` value, so it is part of the command-line surface.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, clap::ValueEnum, Serialize)]
27#[serde(rename_all = "lowercase")]
28pub enum ReportFormat {
29    /// Aligned plain text, for a person.
30    #[default]
31    Text,
32    /// One JSON object, for a script.
33    Json,
34}
35
36/// What the restore did, as a whole.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct RestoreReport {
39    /// Whether the run wrote anything.
40    pub dry_run: bool,
41    /// The target log directory.
42    pub log_dir: PathBuf,
43    /// The cluster id the target was formatted with.
44    pub cluster_id: Uuid,
45    /// Trusted WORM coverage, absent for an integrity-only restore.
46    pub authentication: Option<AuthenticationReport>,
47    /// Diskless-WAL recovery boundary, absent when no capture was supplied.
48    pub diskless: Option<DisklessRestoreReport>,
49    /// Cluster metadata recovered from the controller snapshot.
50    pub metadata: MetadataRestoreReport,
51    /// One entry per restored partition, ordered by topic then partition.
52    pub partitions: Vec<PartitionReport>,
53    /// Segments that failed verification and were skipped.
54    pub skipped: Vec<SkippedSegment>,
55}
56
57/// Diskless-WAL recovery outcome and its explicit limitations.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct DisklessRestoreReport {
60    pub captured_at_ms: u64,
61    pub source_cutoffs: Vec<i64>,
62    pub partitions: Vec<DisklessPartitionReport>,
63    pub limitations: Vec<String>,
64}
65
66/// Recovery boundary for one diskless partition.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct DisklessPartitionReport {
69    pub topic: String,
70    pub partition: i32,
71    pub topic_id: Uuid,
72    pub delete_floor: i64,
73    pub recovery_cutoff: i64,
74    pub objects: u64,
75    pub batches: u64,
76    pub records: u64,
77    pub records_dropped: u64,
78}
79
80/// Signed WORM evidence applied to the bytes consumed by restore.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82pub struct AuthenticationReport {
83    /// Manifest-chain partitions authenticated.
84    pub partitions: usize,
85    /// Signed manifests authenticated.
86    pub manifests: u64,
87    /// Objects whose consumed bytes matched a signed digest.
88    pub objects: u64,
89    /// Trusted public keys supplied, including rotation keys.
90    pub trusted_keys: usize,
91    /// Verified tip of each partition chain.
92    pub chain_heads: BTreeMap<String, String>,
93    /// Whether each tip was checked against an independently held boundary.
94    pub tail_truncation_protected: bool,
95}
96
97/// Cluster metadata recovered alongside the archived partition data.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub struct MetadataRestoreReport {
100    /// Snapshot used for recovery, absent when none was supplied.
101    pub snapshot: Option<PathBuf>,
102    /// Topic-configuration records restored for selected topics.
103    pub topic_configs: usize,
104    /// ACL records restored.
105    pub access_control_entries: usize,
106    /// Client-quota records restored.
107    pub client_quotas: usize,
108    /// SCRAM credential records restored.
109    pub scram_credentials: usize,
110    /// Finalized feature-level records restored.
111    pub feature_levels: usize,
112    /// Restored topics whose configuration was not recovered: every topic when
113    /// no snapshot was supplied, and otherwise each one the snapshot does not
114    /// hold under the archive's topic id.
115    pub topics_without_configuration: Vec<String>,
116}
117
118impl MetadataRestoreReport {
119    /// Warning emitted when cluster metadata could not be recovered.
120    #[must_use]
121    pub fn warning(&self) -> Option<String> {
122        self.snapshot.as_ref().map_or_else(
123            || {
124                Some(format!(
125                    "WARNING: no --metadata-snapshot; topic configuration was not recovered for: {}; ACLs, client quotas, SCRAM credentials, and feature levels were not recovered",
126                    self.topics_without_configuration.join(", ")
127                ))
128            },
129            |snapshot| {
130                (!self.topics_without_configuration.is_empty()).then(|| {
131                    format!(
132                        "WARNING: --metadata-snapshot {} does not hold these restored topics \
133                         under their archived topic ids, so their configuration was not \
134                         recovered: {}. Set their configuration again with kafka-configs",
135                        snapshot.display(),
136                        self.topics_without_configuration.join(", ")
137                    )
138                })
139            },
140        )
141    }
142}
143
144/// What the restore did for one partition.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
146pub struct PartitionReport {
147    /// Topic name.
148    pub topic: String,
149    /// Partition index.
150    pub partition: i32,
151    /// Stable topic id, as the archive records it.
152    pub topic_id: Uuid,
153    /// One entry per segment the restore wrote, in offset order.
154    pub segments: Vec<SegmentOutcome>,
155}
156
157/// One segment the restore could not verify and did not write.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
159pub struct SkippedSegment {
160    /// Topic of the skipped segment.
161    pub topic: String,
162    /// Partition index of the skipped segment.
163    pub partition: i32,
164    /// The segment id the archive names the copy with.
165    pub segment_id: Uuid,
166    /// Why verification rejected it.
167    pub reason: String,
168}
169
170impl RestoreReport {
171    /// Render the report in `format`.
172    ///
173    /// # Errors
174    ///
175    /// The JSON encoder's error. It fails only on a map with non-string keys,
176    /// a `NaN` or infinite float, or a type whose `Serialize` impl itself
177    /// errors, and every field of the model is a string, an integer, a `Uuid`,
178    /// a `PathBuf`, a bool, or a sequence or struct built from the same. The
179    /// text rendering does not fail.
180    pub fn render(&self, format: ReportFormat) -> serde_json::Result<String> {
181        match format {
182            ReportFormat::Text => Ok(TextReport(self).to_string()),
183            ReportFormat::Json => serde_json::to_string_pretty(self),
184        }
185    }
186
187    /// Render as aligned text for an operator reading the terminal or a log.
188    ///
189    /// This groups segments by partition and sums their counters, rather than
190    /// listing every segment: a wall of per-segment lines is not what an
191    /// operator scanning the output first needs, and per-segment detail is
192    /// exactly what the JSON rendering is for. What a person mid-incident
193    /// wants first, in order, is: did this write anything, what cluster is
194    /// it, and then per partition, how far it got and what changed on the
195    /// way. Batches the bound rewrote or emptied are called out separately
196    /// from the kept/dropped record counts, because "records dropped" alone
197    /// does not tell an operator who ran `--exclude-key` that the exclusion
198    /// actually matched something.
199    fn write_text(&self, out: &mut impl Write) -> fmt::Result {
200        let status = if self.dry_run {
201            "dry run against"
202        } else {
203            "wrote"
204        };
205        writeln!(out, "krabka restore: {status} {}", self.log_dir.display())?;
206        if self.dry_run {
207            writeln!(out, "  (dry run: nothing was written to disk)")?;
208        }
209        writeln!(out, "cluster id: {}", self.cluster_id)?;
210
211        if let Some(diskless) = &self.diskless {
212            writeln!(
213                out,
214                "diskless WAL capture: {} partition{}, captured at {} ms",
215                diskless.partitions.len(),
216                plural(diskless.partitions.len() as u64),
217                diskless.captured_at_ms
218            )?;
219            for partition in &diskless.partitions {
220                writeln!(
221                    out,
222                    "  {}-{}: floor {}, restored through offset {} (exclusive); {} batch{}, {} record{} kept, {} dropped",
223                    partition.topic,
224                    partition.partition,
225                    partition.delete_floor,
226                    partition.recovery_cutoff,
227                    partition.batches,
228                    plural(partition.batches),
229                    partition.records,
230                    plural(partition.records),
231                    partition.records_dropped,
232                )?;
233            }
234            for limitation in &diskless.limitations {
235                writeln!(out, "WARNING: {limitation}")?;
236            }
237        }
238
239        if let Some(authentication) = &self.authentication {
240            writeln!(
241                out,
242                "authentication: {} manifest{}, {} object{}, {} partition{}, {} trusted key{}",
243                authentication.manifests,
244                plural(authentication.manifests),
245                authentication.objects,
246                plural(authentication.objects),
247                authentication.partitions,
248                plural(authentication.partitions as u64),
249                authentication.trusted_keys,
250                plural(authentication.trusted_keys as u64),
251            )?;
252            let tail = if authentication.tail_truncation_protected {
253                "tail truncation protected by independently pinned heads"
254            } else {
255                "tail truncation not protected without independently pinned heads"
256            };
257            writeln!(
258                out,
259                "authentication boundary: verified chain tips {}; {tail}",
260                authentication
261                    .chain_heads
262                    .iter()
263                    .map(|(partition, head)| format!("{partition}={head}"))
264                    .collect::<Vec<_>>()
265                    .join(", ")
266            )?;
267        }
268
269        if let Some(warning) = self.metadata.warning() {
270            writeln!(out, "{warning}")?;
271        }
272        // Without a snapshot nothing was recovered, and the warning says so.
273        // With one, the counts stay visible beside any warning.
274        if self.metadata.snapshot.is_some() {
275            writeln!(
276                out,
277                "metadata: {} topic config{}, {} ACL{}, {} client quota{}, {} SCRAM credential{}, {} feature level{}",
278                self.metadata.topic_configs,
279                plural(self.metadata.topic_configs as u64),
280                self.metadata.access_control_entries,
281                plural(self.metadata.access_control_entries as u64),
282                self.metadata.client_quotas,
283                plural(self.metadata.client_quotas as u64),
284                self.metadata.scram_credentials,
285                plural(self.metadata.scram_credentials as u64),
286                self.metadata.feature_levels,
287                plural(self.metadata.feature_levels as u64),
288            )?;
289        }
290
291        let mut current_topic: Option<TopicHeading<'_>> = None;
292        for partition in &self.partitions {
293            let topic_key = TopicHeading {
294                topic: partition.topic.as_str(),
295                topic_id: partition.topic_id,
296            };
297            if current_topic != Some(topic_key) {
298                writeln!(out)?;
299                writeln!(out, "{} (topic id {})", partition.topic, partition.topic_id)?;
300                current_topic = Some(topic_key);
301            }
302            write_partition_line(out, partition)?;
303        }
304
305        if !self.skipped.is_empty() {
306            writeln!(out)?;
307            writeln!(
308                out,
309                "skipped ({} segment{} failed verification):",
310                self.skipped.len(),
311                plural(self.skipped.len() as u64)
312            )?;
313            for segment in &self.skipped {
314                writeln!(
315                    out,
316                    "  {} partition {} segment {}: {}",
317                    segment.topic, segment.partition, segment.segment_id, segment.reason
318                )?;
319            }
320        }
321
322        Ok(())
323    }
324}
325
326/// [`RestoreReport::write_text`] as a [`Display`].
327struct TextReport<'a>(&'a RestoreReport);
328
329impl Display for TextReport<'_> {
330    fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.write_text(out) }
331}
332
333/// The topic a text rendering last printed a heading for.
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335struct TopicHeading<'a> {
336    topic: &'a str,
337    topic_id: Uuid,
338}
339
340/// Write one partition's summary line, aggregating its segments' counters.
341///
342/// A partition with no restored segments still renders, rather than indexing
343/// into an empty `segments` slice or dividing by zero: the report model does
344/// not forbid it even though `discover.rs`'s `EmptyArchive` check makes it
345/// unlikely in practice.
346fn write_partition_line(out: &mut impl Write, partition: &PartitionReport) -> fmt::Result {
347    let (Some(first), Some(last)) = (partition.segments.first(), partition.segments.last()) else {
348        writeln!(
349            out,
350            "  partition {}: no segments restored",
351            partition.partition
352        )?;
353        return Ok(());
354    };
355
356    let mut records_kept = 0u64;
357    let mut records_dropped = 0u64;
358    let mut batches_rewritten = 0u64;
359    let mut batches_emptied = 0u64;
360    for segment in &partition.segments {
361        records_kept += segment.records_kept;
362        records_dropped += segment.records_dropped;
363        batches_rewritten += segment.batches_rewritten;
364        batches_emptied += segment.batches_emptied;
365    }
366
367    let segment_count = partition.segments.len() as u64;
368    let mut notes = Vec::new();
369    if batches_rewritten > 0 {
370        notes.push(format!(
371            "{} batch{} rewritten",
372            format_thousands(batches_rewritten),
373            plural(batches_rewritten)
374        ));
375    }
376    if batches_emptied > 0 {
377        notes.push(format!(
378            "{} batch{} emptied",
379            format_thousands(batches_emptied),
380            plural(batches_emptied)
381        ));
382    }
383    let notes = if notes.is_empty() {
384        String::new()
385    } else {
386        format!(" ({})", notes.join(", "))
387    };
388
389    writeln!(
390        out,
391        "  partition {}: {} segment{}, offsets [{}, {}], {} records kept, {} dropped{notes}",
392        partition.partition,
393        format_thousands(segment_count),
394        plural(segment_count),
395        first.base_offset,
396        last.end_offset,
397        format_thousands(records_kept),
398        format_thousands(records_dropped),
399    )
400}
401
402/// `"s"` for anything but exactly one, so counts read as plain English.
403fn plural(count: u64) -> &'static str { if count == 1 { "" } else { "s" } }
404
405/// Group `n`'s digits with `,` every three places, for a human scanning a
406/// terminal. `krabka-restore` has no formatting-number dependency in its
407/// `Cargo.toml`, and this is a handful of lines, so it is hand-rolled rather
408/// than pulling one in.
409fn format_thousands(n: u64) -> String {
410    let digits = n.to_string();
411    let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
412    for (index, digit) in digits.chars().enumerate() {
413        if index > 0 && (digits.len() - index).is_multiple_of(3) {
414            grouped.push(',');
415        }
416        grouped.push(digit);
417    }
418    grouped
419}
420
421#[cfg(test)]
422mod tests {
423    use {
424        super::{
425            MetadataRestoreReport, PartitionReport, ReportFormat, RestoreReport, SegmentOutcome,
426            SkippedSegment, format_thousands, plural,
427        },
428        assert2::check,
429        krabka_ids::Offset,
430        std::path::PathBuf,
431        uuid::Uuid,
432    };
433
434    #[derive(derive_more::From, derive_more::Into, Clone, Copy, Default)]
435    struct RestoredRecordCount(u64);
436
437    #[derive(derive_more::From, derive_more::Into, Clone, Copy, Default)]
438    struct RestoredBatchCount(u64);
439
440    #[derive(Clone, Copy, krabka_macros::FieldDefaults)]
441    struct SegmentOutcomeSetup {
442        #[default(Offset(0))]
443        base: Offset,
444        #[default(Offset(402))]
445        end: Offset,
446        #[default(RestoredRecordCount(402))]
447        kept: RestoredRecordCount,
448        dropped: RestoredRecordCount,
449        rewritten: RestoredBatchCount,
450        emptied: RestoredBatchCount,
451    }
452
453    #[derive(Clone, Copy, PartialEq, Eq)]
454    enum RestoreMode {
455        Perform,
456        DryRun,
457    }
458
459    #[bon::builder]
460    fn segment(
461        #[builder(into, default = Offset (0))] base: Offset,
462        #[builder(into, default = Offset (402))] end: Offset,
463        #[builder(into, default = RestoredRecordCount (402))] kept: RestoredRecordCount,
464        #[builder(into, default)] dropped: RestoredRecordCount,
465        #[builder(into, default)] rewritten: RestoredBatchCount,
466        #[builder(into, default)] emptied: RestoredBatchCount,
467    ) -> SegmentOutcome {
468        let setup = SegmentOutcomeSetup {
469            base,
470            end,
471            kept,
472            dropped,
473            rewritten,
474            emptied,
475        };
476        let SegmentOutcomeSetup {
477            base,
478            end,
479            kept,
480            dropped,
481            rewritten,
482            emptied,
483        } = setup;
484        SegmentOutcome {
485            segment_id: Uuid::from_u128(u128::from(base.0.unsigned_abs()) + 1),
486            base_offset: base,
487            end_offset: end,
488            batches_kept: 1,
489            batches_rewritten: rewritten.0,
490            batches_emptied: emptied.0,
491            records_kept: kept.0,
492            records_dropped: dropped.0,
493            bytes_written: 1024,
494        }
495    }
496
497    fn sample_report(mode: RestoreMode) -> RestoreReport {
498        RestoreReport {
499            dry_run: mode == RestoreMode::DryRun,
500            log_dir: "/var/lib/krabka/restored".into(),
501            cluster_id: Uuid::from_u128(0xC1_A5_7E_00),
502            authentication: None,
503            diskless: None,
504            metadata: MetadataRestoreReport {
505                snapshot: Some("/backup/metadata.checkpoint".into()),
506                topic_configs: 2,
507                access_control_entries: 1,
508                client_quotas: 1,
509                scram_credentials: 1,
510                feature_levels: 2,
511                topics_without_configuration: vec![],
512            },
513            partitions: vec![
514                PartitionReport {
515                    topic: "orders".to_owned(),
516                    partition: 0,
517                    topic_id: Uuid::from_u128(0x0001),
518                    segments: vec![
519                        segment()
520                            .end(4999)
521                            .kept(4995)
522                            .dropped(5)
523                            .rewritten(1)
524                            .call(),
525                        segment()
526                            .base(5000)
527                            .end(12489)
528                            .kept(7485)
529                            .dropped(4)
530                            .emptied(1)
531                            .call(),
532                    ],
533                },
534                PartitionReport {
535                    topic: "orders".to_owned(),
536                    partition: 1,
537                    topic_id: Uuid::from_u128(0x0001),
538                    segments: vec![segment().end(8021).kept(8015).dropped(6).call()],
539                },
540                PartitionReport {
541                    topic: "orders-archive".to_owned(),
542                    partition: 0,
543                    topic_id: Uuid::from_u128(0x0002),
544                    segments: vec![segment().call()],
545                },
546            ],
547            skipped: vec![SkippedSegment {
548                topic: "orders-2".to_owned(),
549                partition: 3,
550                segment_id: Uuid::from_u128(0xBAD),
551                reason: "checksum mismatch in batch at offset 900".to_owned(),
552            }],
553        }
554    }
555
556    #[test]
557    fn default_report_format_is_text() {
558        check!(ReportFormat::default() == ReportFormat::Text);
559    }
560
561    #[test]
562    fn format_thousands_groups_digits() {
563        let cases = [
564            (0u64, "0"),
565            (5, "5"),
566            (999, "999"),
567            (1000, "1,000"),
568            (12480, "12,480"),
569            (1_234_567, "1,234,567"),
570        ];
571        for (input, expected) in cases {
572            check!(format_thousands(input) == expected);
573        }
574    }
575
576    #[test]
577    fn plural_is_empty_only_for_one() {
578        check!(plural(0) == "s");
579        check!(plural(1) == "");
580        check!(plural(2) == "s");
581    }
582
583    #[test]
584    fn text_rendering_contains_key_facts() {
585        let report = sample_report(RestoreMode::Perform);
586        let text = report.render(ReportFormat::Text).unwrap();
587
588        check!(text.contains("orders"));
589        check!(text.contains("orders-archive"));
590        check!(text.contains(&report.cluster_id.to_string()));
591        check!(text.contains("partition 0"));
592        check!(text.contains("partition 1"));
593        check!(text.contains("[0, 12489]"));
594        check!(text.contains("12,480 records kept"));
595        check!(text.contains("9 dropped"));
596        check!(text.contains("1 batch rewritten"));
597        check!(text.contains("1 batch emptied"));
598        check!(text.contains("checksum mismatch in batch at offset 900"));
599        check!(text.contains("orders-2"));
600    }
601
602    /// A snapshot that misses a restored topic adds its warning above the
603    /// metadata counts; the counts of what it did restore stay visible.
604    #[yare::parameterized(
605        knows_every_topic = { &[], "" },
606        misses_a_topic = {
607            &["payments"],
608            "WARNING: --metadata-snapshot /backup/metadata.checkpoint does not hold these \
609             restored topics under their archived topic ids, so their configuration was not \
610             recovered: payments. Set their configuration again with kafka-configs\n"
611        },
612    )]
613    fn text_rendering_exact_snapshot_for_one_partition(missing: &[&str], warning: &str) {
614        let report = RestoreReport {
615            dry_run: false,
616            log_dir: "/var/lib/krabka/restored".into(),
617            cluster_id: Uuid::nil(),
618            authentication: None,
619            diskless: None,
620            metadata: MetadataRestoreReport {
621                snapshot: Some("/backup/metadata.checkpoint".into()),
622                topic_configs: 1,
623                access_control_entries: 0,
624                client_quotas: 0,
625                scram_credentials: 0,
626                feature_levels: 0,
627                topics_without_configuration: missing
628                    .iter()
629                    .map(|topic| (*topic).to_owned())
630                    .collect(),
631            },
632            partitions: vec![PartitionReport {
633                topic: "orders-archive".to_owned(),
634                partition: 0,
635                topic_id: Uuid::nil(),
636                segments: vec![segment().call()],
637            }],
638            skipped: vec![],
639        };
640
641        let expected = format!(
642            "krabka restore: wrote /var/lib/krabka/restored\n\
643             cluster id: 00000000-0000-0000-0000-000000000000\n\
644             {warning}metadata: 1 topic config, 0 ACLs, 0 client quotas, 0 SCRAM credentials, 0 feature levels\n\
645             \n\
646             orders-archive (topic id 00000000-0000-0000-0000-000000000000)\n\
647             \x20 partition 0: 1 segment, offsets [0, 402], 402 records kept, 0 dropped\n"
648        );
649
650        check!(report.render(ReportFormat::Text).unwrap() == expected);
651    }
652
653    #[test]
654    fn dry_run_is_visible_in_text() {
655        let dry = sample_report(RestoreMode::DryRun)
656            .render(ReportFormat::Text)
657            .unwrap();
658        let real = sample_report(RestoreMode::Perform)
659            .render(ReportFormat::Text)
660            .unwrap();
661
662        check!(dry.contains("dry run"));
663        check!(!real.contains("dry run"));
664    }
665
666    #[test]
667    fn empty_skipped_list_omits_section() {
668        let mut report = sample_report(RestoreMode::Perform);
669        report.skipped.clear();
670        let text = report.render(ReportFormat::Text).unwrap();
671
672        check!(!text.contains("skipped"));
673    }
674
675    #[test]
676    fn nonempty_skipped_list_names_every_segment_and_reason() {
677        let mut report = sample_report(RestoreMode::Perform);
678        report.skipped.push(SkippedSegment {
679            topic: "returns".to_owned(),
680            partition: 7,
681            segment_id: Uuid::from_u128(0xF00D),
682            reason: "truncated batch header".to_owned(),
683        });
684        let text = report.render(ReportFormat::Text).unwrap();
685
686        for skipped in &report.skipped {
687            check!(text.contains(&skipped.topic));
688            check!(text.contains(&skipped.segment_id.to_string()));
689            check!(text.contains(&skipped.reason));
690        }
691    }
692
693    #[test]
694    fn zero_segment_partition_does_not_panic() {
695        let report = RestoreReport {
696            dry_run: true,
697            log_dir: "/tmp/restore".into(),
698            cluster_id: Uuid::nil(),
699            authentication: None,
700            diskless: None,
701            metadata: MetadataRestoreReport {
702                snapshot: None,
703                topic_configs: 0,
704                access_control_entries: 0,
705                client_quotas: 0,
706                scram_credentials: 0,
707                feature_levels: 0,
708                topics_without_configuration: vec!["empty-topic".to_owned()],
709            },
710            partitions: vec![PartitionReport {
711                topic: "empty-topic".to_owned(),
712                partition: 0,
713                topic_id: Uuid::nil(),
714                segments: vec![],
715            }],
716            skipped: vec![],
717        };
718
719        let text = report.render(ReportFormat::Text).unwrap();
720
721        check!(text.contains("empty-topic"));
722        check!(text.contains("no segments restored"));
723        check!(text.contains("WARNING: no --metadata-snapshot"));
724        check!(text.contains("ACLs, client quotas, SCRAM credentials, and feature levels"));
725    }
726
727    /// With a snapshot, the warning fires only for restored topics the
728    /// snapshot does not know, and names them (#1514).
729    #[yare::parameterized(
730        knows_every_topic = { &[], None },
731        misses_two_topics = {
732            &["orders", "payments"],
733            Some(
734                "WARNING: --metadata-snapshot cluster-metadata.checkpoint does not hold these \
735                 restored topics under their archived topic ids, so their configuration was not \
736                 recovered: orders, payments. Set their configuration again with kafka-configs"
737            )
738        },
739    )]
740    fn a_snapshot_warns_for_each_restored_topic_it_does_not_know(
741        missing: &[&str],
742        expected: Option<&str>,
743    ) {
744        let metadata = MetadataRestoreReport {
745            snapshot: Some(PathBuf::from("cluster-metadata.checkpoint")),
746            topic_configs: 0,
747            access_control_entries: 0,
748            client_quotas: 0,
749            scram_credentials: 0,
750            feature_levels: 0,
751            topics_without_configuration: missing.iter().map(|topic| (*topic).to_owned()).collect(),
752        };
753
754        check!(metadata.warning().as_deref() == expected);
755    }
756
757    #[test]
758    fn json_rendering_round_trips_field_values() {
759        let report = sample_report(RestoreMode::Perform);
760        let json = report.render(ReportFormat::Json).unwrap();
761        let value: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
762
763        check!(value["dry_run"] == serde_json::json!(false));
764        for (pointer, expected) in [
765            (
766                "/cluster_id",
767                serde_json::json!(report.cluster_id.to_string()),
768            ),
769            ("/partitions/0/topic", serde_json::json!("orders")),
770            ("/partitions/0/partition", serde_json::json!(0)),
771            (
772                "/partitions/0/segments/0/records_kept",
773                serde_json::json!(4995),
774            ),
775            (
776                "/partitions/0/segments/1/batches_emptied",
777                serde_json::json!(1),
778            ),
779        ] {
780            check!(value.pointer(pointer) == Some(&expected), "{pointer}");
781        }
782        check!(
783            value["skipped"][0]["reason"]
784                == serde_json::json!("checksum mismatch in batch at offset 900")
785        );
786    }
787}