Skip to main content

krabka_backup/
report.rs

1//! Structured operation results and the command boundary's terminal reporting.
2
3use {
4    crate::{
5        error::BackupError,
6        manifest::{CAPTURE_ROOT, Manifest},
7    },
8    krabka_remote_storage::ChainHead,
9    std::io::{self, Write},
10};
11
12/// An operation's outcome and the diagnostics produced before it completed.
13///
14/// Events remain available when the operation fails, including successful
15/// artifact checks and group commits before the failure.
16#[derive(Debug)]
17#[must_use]
18pub struct OperationReport<T> {
19    /// The operation's result, with the original backup error on failure.
20    pub result: Result<T, BackupError>,
21    /// Diagnostics in operation order.
22    pub events: Vec<BackupEvent>,
23}
24
25/// Result information that a caller can inspect without capturing stdout.
26#[derive(Debug)]
27pub enum BackupEvent {
28    /// The diskless boundary was sealed with a WORM signature.
29    DisklessSealed {
30        /// The signed WORM chain head.
31        head: ChainHead,
32    },
33    /// A capture whose manifest was successfully stored.
34    Capture {
35        /// The manifest that was stored with the capture.
36        manifest: Manifest,
37        /// Metadata checkpoint diagnostics in their original order.
38        notes: Vec<String>,
39        /// Artifacts that were absent from the capture source.
40        missing: Vec<String>,
41    },
42    /// The archive holds no captures.
43    EmptyList,
44    /// The result of reading one manifest during a listing.
45    Listed {
46        /// The capture directory's name.
47        id: String,
48        /// The decoded manifest or its original read error.
49        manifest: Result<Manifest, BackupError>,
50    },
51    /// An artifact passed its size and digest checks.
52    ArtifactVerified {
53        /// The artifact's object name.
54        name: String,
55        /// The artifact's recorded size.
56        size_bytes: u64,
57    },
58    /// Every artifact in a capture passed verification.
59    CaptureVerified {
60        /// The capture's identifier.
61        id: String,
62        /// The number of verified artifacts.
63        artifacts: usize,
64    },
65    /// One offset selected by a dry run, without a cluster commit.
66    WouldCommit {
67        /// The consumer group identifier.
68        group: String,
69        /// The topic name.
70        topic: String,
71        /// The partition index.
72        partition: i32,
73        /// The committed offset to restore.
74        offset: i64,
75    },
76    /// A group's offset commit succeeded.
77    OffsetsCommitted {
78        /// The consumer group identifier.
79        group: String,
80        /// The number of offsets committed for the group.
81        offsets: usize,
82    },
83}
84
85pub(crate) fn render(events: &[BackupEvent], output: &mut impl Write) -> io::Result<()> {
86    for event in events {
87        match event {
88            BackupEvent::DisklessSealed { head } => {
89                writeln!(output, "  diskless-capture WORM head {head}")?;
90            }
91            BackupEvent::Capture {
92                manifest,
93                notes,
94                missing,
95            } => {
96                writeln!(output, "capture {}", manifest.capture_id)?;
97                for artifact in &manifest.artifacts {
98                    writeln!(
99                        output,
100                        "  {} <- {} ({} bytes, sha256 {})",
101                        artifact.name, artifact.source, artifact.size_bytes, artifact.sha256
102                    )?;
103                }
104                for note in notes {
105                    writeln!(output, "  NOTE: {note}")?;
106                }
107                for absent in missing {
108                    writeln!(output, "  WARNING: {absent}")?;
109                }
110            }
111            BackupEvent::EmptyList => writeln!(output, "no captures under {CAPTURE_ROOT}")?,
112            BackupEvent::Listed { id, manifest } => match manifest {
113                Ok(manifest) => {
114                    let names: Vec<&str> = manifest
115                        .artifacts
116                        .iter()
117                        .map(|artifact| artifact.name.as_str())
118                        .collect();
119                    writeln!(output, "{id}  {}", names.join(" "))?;
120                }
121                Err(error) => writeln!(output, "{id}  UNREADABLE: {error}")?,
122            },
123            BackupEvent::ArtifactVerified { name, size_bytes } => {
124                writeln!(output, "  {name} ok ({size_bytes} bytes)")?;
125            }
126            BackupEvent::CaptureVerified { id, artifacts } => {
127                writeln!(output, "capture {id} verified: {artifacts} artifacts")?;
128            }
129            BackupEvent::WouldCommit {
130                group,
131                topic,
132                partition,
133                offset,
134            } => writeln!(
135                output,
136                "would commit {group}: {topic}-{partition} = {offset}"
137            )?,
138            BackupEvent::OffsetsCommitted { group, offsets } => {
139                writeln!(output, "{group}: {offsets} offsets committed")?;
140            }
141        }
142    }
143    Ok(())
144}
145
146#[cfg(test)]
147mod tests {
148    use {
149        super::{BackupEvent, render},
150        crate::{
151            error::BackupError,
152            manifest::{Artifact, MANIFEST_VERSION, Manifest},
153        },
154        assert2::check,
155        krabka_remote_storage::ChainHead,
156    };
157
158    fn manifest() -> Manifest {
159        Manifest {
160            version: MANIFEST_VERSION,
161            capture_id: "capture-id".to_owned(),
162            captured_at_ms: 0,
163            log_dir: None,
164            bootstrap_server: None,
165            artifacts: vec![Artifact {
166                name: "rlmm-snapshot".to_owned(),
167                source: "/node/snapshot".to_owned(),
168                size_bytes: 12,
169                sha256: "digest".to_owned(),
170            }],
171        }
172    }
173
174    #[test]
175    fn terminal_output_preserves_operation_order_and_text() {
176        let events = [
177            BackupEvent::DisklessSealed {
178                head: ChainHead([0xab; 32]),
179            },
180            BackupEvent::Capture {
181                manifest: manifest(),
182                notes: vec!["checkpoint age".to_owned()],
183                missing: vec!["no checkpoint".to_owned()],
184            },
185            BackupEvent::EmptyList,
186            BackupEvent::Listed {
187                id: "capture-id".to_owned(),
188                manifest: Ok(manifest()),
189            },
190            BackupEvent::Listed {
191                id: "broken-id".to_owned(),
192                manifest: Err(BackupError::Integrity("bad manifest".to_owned())),
193            },
194            BackupEvent::ArtifactVerified {
195                name: "rlmm-snapshot".to_owned(),
196                size_bytes: 12,
197            },
198            BackupEvent::CaptureVerified {
199                id: "capture-id".to_owned(),
200                artifacts: 1,
201            },
202            BackupEvent::WouldCommit {
203                group: "readers".to_owned(),
204                topic: "orders".to_owned(),
205                partition: 2,
206                offset: 9,
207            },
208            BackupEvent::OffsetsCommitted {
209                group: "readers".to_owned(),
210                offsets: 1,
211            },
212        ];
213        let mut output = Vec::new();
214        render(&events, &mut output).expect("render the operation events");
215        check!(
216            String::from_utf8(output).unwrap()
217                == concat!(
218                    "  diskless-capture WORM head abababababababababababababababababababababababababababababababab\n",
219                    "capture capture-id\n",
220                    "  rlmm-snapshot <- /node/snapshot (12 bytes, sha256 digest)\n",
221                    "  NOTE: checkpoint age\n",
222                    "  WARNING: no checkpoint\n",
223                    "no captures under restore-inputs\n",
224                    "capture-id  rlmm-snapshot\n",
225                    "broken-id  UNREADABLE: integrity failure: bad manifest\n",
226                    "  rlmm-snapshot ok (12 bytes)\n",
227                    "capture capture-id verified: 1 artifacts\n",
228                    "would commit readers: orders-2 = 9\n",
229                    "readers: 1 offsets committed\n",
230                )
231        );
232    }
233
234    #[test]
235    fn an_empty_event_stream_writes_nothing() {
236        let mut output = Vec::new();
237        render(&[], &mut output).expect("render no events");
238        check!(output.is_empty());
239    }
240}