Skip to main content

krabka_backup/
lib.rs

1//! Captures the inputs a point-in-time restore needs, and puts committed
2//! consumer-group offsets back after one.
3//!
4//! `krabka restore` rebuilds a log directory out of a KIP-405 archive, and it
5//! needs three things the archive does not hold. Two are files on a broker's
6//! own disk: `<log.dir>/remote-log-metadata/snapshot`, without which a segment
7//! the old cluster had already released is indistinguishable from a live one,
8//! and the controller's newest `<end-offset>-<epoch>.checkpoint`, which is
9//! where topic configuration, ACLs, client quotas, SCRAM credentials and
10//! finalized feature levels live. The third is not a file at all: the committed
11//! offsets of every consumer group, which sit in the compacted, never-tiered
12//! `__consumer_offsets` topic.
13//!
14//! All three are destroyed by the disaster that makes an operator reach for a
15//! restore, so a copy has to exist before it. This tool makes that copy and
16//! checks it.
17//!
18//! # Why a tool and not a `kubectl exec`
19//!
20//! The krabka container image is built from an apko base and has no shell, no
21//! `cp` and no `tar`, so `kubectl exec` and `kubectl cp` cannot take a file off
22//! a running broker. The supported copy is this binary, run beside the broker
23//! with the same volume mounted read-only — a `CronJob` in the same namespace —
24//! or on the node itself. Both snapshot files are written temp-then-rename, so
25//! a reader that opens one by name always sees a whole file.
26//!
27//! Group offsets need a cluster rather than a volume, so the same run takes
28//! them over the ordinary Kafka wire protocol, with `ListGroups` and
29//! `OffsetFetch`. Nothing here speaks a krabka-private API key, which is what
30//! lets the same capture run against a cluster that is not krabka.
31//!
32//! # The four subcommands
33//!
34//! `capture` copies the inputs into the archive under
35//! `restore-inputs/<capture-id>/`, beside the tiered segments the restore reads,
36//! with a `manifest.json` recording each artifact's size and SHA-256. `list`
37//! names the captures. `verify` re-reads one and checks it against those
38//! digests, which is the check a backup is worth nothing without. `restore-
39//! offsets` commits a capture's offsets into a restored cluster, so a group
40//! resumes where it stopped instead of at `auto.offset.reset`.
41//!
42//! # Exit codes
43//!
44//! `0` success, [`EXIT_BAD_ARGUMENT`], [`EXIT_UNREADABLE`], [`EXIT_INTEGRITY`]
45//! and [`EXIT_CLUSTER`]. They line up with `krabka restore`'s own where the two
46//! tools mean the same thing, because one runbook branches on both.
47//!
48//! The crate is a library as well as a binary, for the same reason
49//! `krabka-guard`, `krabka-barrier` and `krabka-format` are: a test that spawns
50//! the binary needs a Cargo working tree to build it from, and a Bazel test
51//! sandbox has none. Tests call [`run_from_args`] in process instead.
52//!
53//! The operations in [`mod@run`] return an [`OperationReport`] with an outcome and ordered diagnostics, including progress before an error. They do not print. [`run()`] renders those diagnostics for the CLI.
54
55use {clap::Parser as _, std::io};
56
57pub mod archive;
58pub mod capture;
59pub mod cli;
60pub mod connection;
61pub mod diskless;
62pub mod error;
63pub mod manifest;
64pub mod offsets;
65pub mod report;
66pub mod run;
67
68pub use self::{
69    cli::{Cli, Command},
70    error::{BackupError, EXIT_BAD_ARGUMENT, EXIT_CLUSTER, EXIT_INTEGRITY, EXIT_UNREADABLE},
71    report::{BackupEvent, OperationReport},
72};
73
74krabka_macros::parsed_cli_entrypoint!({
75    /// Run the tool from an argv-style iterator, returning its exit code.
76    ///
77    /// # Panics
78    ///
79    /// Panics if `argv` does not parse, which for a caller passing a literal
80    /// argument list is a bug in that list rather than a runtime condition.
81});
82
83/// Run one parsed command line, mapping its error onto an exit code.
84pub async fn run(cli: Cli) -> i32 {
85    let outcome = match &cli.command {
86        Command::Capture {
87            log_dir,
88            bootstrap_server,
89            security,
90            signing,
91            archive,
92        } => render_report(
93            run::capture_configured(
94                log_dir.as_deref(),
95                bootstrap_server.as_deref(),
96                security,
97                signing,
98                archive,
99            )
100            .await,
101        ),
102        Command::List { archive } => render_report(run::list(archive).await),
103        Command::Verify { capture, archive } => render_report(run::verify(capture, archive).await),
104        Command::RestoreOffsets {
105            capture,
106            bootstrap_server,
107            security,
108            dry_run,
109            trust,
110            archive,
111        } => render_report(
112            run::restore_offsets_configured(
113                capture,
114                bootstrap_server,
115                security,
116                trust,
117                *dry_run,
118                archive,
119            )
120            .await,
121        ),
122    };
123    match outcome {
124        Ok(()) => 0,
125        Err(error) => {
126            eprintln!("{error}");
127            error.exit_code()
128        }
129    }
130}
131
132fn render_report<T>(report: OperationReport<T>) -> Result<(), BackupError> {
133    report::render(&report.events, &mut io::stdout().lock())?;
134    report.result.map(|_| ())
135}
136
137#[cfg(test)]
138mod tests {
139    use {
140        super::{
141            Cli, Command, EXIT_BAD_ARGUMENT, EXIT_INTEGRITY, EXIT_UNREADABLE, run, run_from_args,
142        },
143        crate::{
144            capture::capture_key,
145            manifest::{MANIFEST, RLMM_SNAPSHOT},
146        },
147        assert2::check,
148        clap::Parser as _,
149        std::{fs, path::Path},
150    };
151
152    // A log directory holding the RLMM snapshot a capture takes off a node.
153    krabka_macros::snapshot_node_fixture!(node);
154
155    /// The one capture id the archive holds, which `latest` also resolves to.
156    fn only_capture(archive_root: &Path) -> String {
157        let mut ids: Vec<String> = fs::read_dir(archive_root.join("restore-inputs"))
158            .expect("read the capture root")
159            .flatten()
160            .filter_map(|entry| entry.file_name().to_str().map(ToOwned::to_owned))
161            .collect();
162        ids.sort();
163        ids.pop().expect("the archive holds a capture")
164    }
165
166    #[tokio::test(flavor = "multi_thread")]
167    async fn a_capture_and_the_checks_that_read_it_back_all_exit_zero() {
168        let node = node();
169        let archive_root = tempfile::tempdir().expect("archive root");
170        let log_dir = node.path().display().to_string();
171        let archive = archive_root.path().display().to_string();
172
173        check!(
174            run_from_args([
175                "krabka-backup",
176                "capture",
177                "--log-dir",
178                &log_dir,
179                "--archive-local",
180                &archive,
181            ])
182            .await
183                == 0
184        );
185        check!(run_from_args(["krabka-backup", "list", "--archive-local", &archive]).await == 0);
186        check!(run_from_args(["krabka-backup", "verify", "--archive-local", &archive]).await == 0);
187        check!(
188            run_from_args([
189                "krabka-backup",
190                "restore-offsets",
191                "--dry-run",
192                "-b",
193                "127.0.0.1:9092",
194                "--capture",
195                &only_capture(archive_root.path()),
196                "--archive-local",
197                &archive,
198            ])
199            .await
200                == EXIT_UNREADABLE,
201            "a capture of the on-disk inputs alone holds no offsets to restore",
202        );
203    }
204
205    #[tokio::test(flavor = "multi_thread")]
206    async fn a_damaged_capture_exits_with_the_integrity_code_a_runbook_branches_on() {
207        let node = node();
208        let archive_root = tempfile::tempdir().expect("archive root");
209        let archive = archive_root.path().display().to_string();
210        check!(
211            run_from_args([
212                "krabka-backup",
213                "capture",
214                "--log-dir",
215                &node.path().display().to_string(),
216                "--archive-local",
217                &archive,
218            ])
219            .await
220                == 0
221        );
222
223        let id = only_capture(archive_root.path());
224        fs::write(
225            archive_root.path().join(capture_key(&id, RLMM_SNAPSHOT)),
226            b"truncated",
227        )
228        .expect("truncate the captured snapshot");
229
230        check!(
231            run_from_args(["krabka-backup", "verify", "--archive-local", &archive]).await
232                == EXIT_INTEGRITY
233        );
234
235        // And an archive whose manifest is gone is unreadable rather than
236        // corrupt: the two codes send a runbook down different branches.
237        fs::remove_file(archive_root.path().join(capture_key(&id, MANIFEST)))
238            .expect("remove the manifest");
239        check!(
240            run_from_args(["krabka-backup", "verify", "--archive-local", &archive]).await
241                == EXIT_UNREADABLE
242        );
243    }
244
245    #[tokio::test(flavor = "multi_thread")]
246    async fn a_capture_that_finds_nothing_exits_unreadable() {
247        let empty = tempfile::tempdir().expect("an empty log dir");
248        let archive_root = tempfile::tempdir().expect("archive root");
249        check!(
250            run_from_args([
251                "krabka-backup",
252                "capture",
253                "--log-dir",
254                &empty.path().display().to_string(),
255                "--archive-local",
256                &archive_root.path().display().to_string(),
257            ])
258            .await
259                == EXIT_UNREADABLE
260        );
261    }
262
263    #[tokio::test(flavor = "multi_thread")]
264    async fn a_flag_that_names_a_backend_that_was_not_selected_exits_bad_argument() {
265        let archive_root = tempfile::tempdir().expect("archive root");
266        let cli = Cli::parse_from([
267            "krabka-backup",
268            "list",
269            "--archive-local",
270            &archive_root.path().display().to_string(),
271            "--archive-s3-region",
272            "eu-west-1",
273        ]);
274        check!(let Command::List { .. } = &cli.command);
275        check!(run(cli).await == EXIT_BAD_ARGUMENT);
276    }
277}