Skip to main content

krabka_backup/
error.rs

1//! The one error type the tool reports, and the exit codes it maps onto.
2//!
3//! A runbook branches on `$?`, so the numbers are fixed here and stated in the
4//! crate README and in `docs/operations/backup-restore.md`. They are chosen to
5//! sit beside `krabka restore`'s own: `2` is a bad argument in both tools, `4`
6//! is an unreadable archive in both, and `5` is an integrity failure in both.
7
8use std::io::Error;
9
10/// Everything that can stop a capture, a verification, or an offset restore.
11#[derive(Debug, thiserror::Error)]
12pub enum BackupError {
13    /// A local file could not be read, or a directory could not be walked.
14    #[error("{0}")]
15    Io(#[from] Error),
16
17    /// The archive could not be read or written.
18    #[error("archive: {0}")]
19    ObjectStore(#[from] krabka_object_store::ObjectStoreError),
20
21    /// A manifest or an offsets file could not be decoded.
22    #[error("{context}: {source}")]
23    Json {
24        /// What was being decoded, named by its object key.
25        context: String,
26        /// The decoder's own complaint.
27        source: serde_json::Error,
28    },
29
30    /// A manifest or an offsets file has no `"version"`, as one written
31    /// before krabka 1.0 does, or one this build does not read.
32    #[error("{context}: {}", unsupported_version_reason(found.as_deref(), *expected))]
33    UnsupportedVersion {
34        /// What was being decoded, named by its object key.
35        context: String,
36        /// The `"version"` the document carries, as JSON text, or `None` when
37        /// it has none.
38        found: Option<String>,
39        /// The version this build reads.
40        expected: i16,
41    },
42
43    /// The flags contradict each other, or name something that cannot exist.
44    #[error("{0}")]
45    InvalidArgument(String),
46
47    /// A captured artifact does not match the digest its manifest recorded.
48    #[error("integrity failure: {0}")]
49    Integrity(String),
50
51    /// The archive holds no capture, or not the one that was named.
52    #[error("{0}")]
53    NoSuchCapture(String),
54
55    /// The cluster could not be reached, or refused a request.
56    #[error("cluster: {0}")]
57    Cluster(String),
58
59    /// The node's newest metadata checkpoint is the one `krabka format`
60    /// wrote. It holds no topic, so a restore from it would bring back no
61    /// topic configuration and say nothing about it.
62    #[error("stale metadata checkpoint: {0}")]
63    StaleMetadata(String),
64}
65
66/// Exit code for a bad argument, or a flag set that contradicts itself.
67pub const EXIT_BAD_ARGUMENT: i32 = 2;
68/// Exit code for an archive or a log directory that cannot be read.
69pub const EXIT_UNREADABLE: i32 = 4;
70/// Exit code for a captured artifact that does not match its recorded digest.
71pub const EXIT_INTEGRITY: i32 = 5;
72/// Exit code for a cluster that could not be reached, or that refused.
73pub const EXIT_CLUSTER: i32 = 6;
74/// Exit code for a node whose only metadata checkpoint is the format-time one.
75pub const EXIT_STALE_METADATA: i32 = 7;
76
77/// A contradictory `--archive-*` flag set is a bad argument like any other.
78impl From<krabka_object_store::ArchiveArgsError> for BackupError {
79    fn from(error: krabka_object_store::ArchiveArgsError) -> Self {
80        Self::InvalidArgument(error.to_string())
81    }
82}
83
84/// The reason half of [`BackupError::UnsupportedVersion`]'s message.
85fn unsupported_version_reason(found: Option<&str>, expected: i16) -> String {
86    found.map_or_else(
87        || {
88            "no \"version\" field: the capture predates krabka 1.0, which this build does not \
89             read"
90                .to_owned()
91        },
92        |found| format!("unsupported version {found}: this build reads version {expected}"),
93    )
94}
95
96impl BackupError {
97    /// The process exit code this error reports.
98    #[must_use]
99    pub fn exit_code(&self) -> i32 {
100        match self {
101            Self::InvalidArgument(_) => EXIT_BAD_ARGUMENT,
102            Self::Io(_)
103            | Self::ObjectStore(_)
104            | Self::Json { .. }
105            | Self::UnsupportedVersion { .. }
106            | Self::NoSuchCapture(_) => EXIT_UNREADABLE,
107            Self::Integrity(_) => EXIT_INTEGRITY,
108            Self::Cluster(_) => EXIT_CLUSTER,
109            Self::StaleMetadata(_) => EXIT_STALE_METADATA,
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use {
117        super::{
118            BackupError, EXIT_BAD_ARGUMENT, EXIT_CLUSTER, EXIT_INTEGRITY, EXIT_STALE_METADATA,
119            EXIT_UNREADABLE,
120        },
121        assert2::check,
122        std::io::Error,
123    };
124
125    #[yare::parameterized(
126        invalid_argument = { BackupError::InvalidArgument("bad".to_owned()), EXIT_BAD_ARGUMENT },
127        io = { BackupError::Io(Error::other("disk")), EXIT_UNREADABLE },
128        no_such_capture = { BackupError::NoSuchCapture("none".to_owned()), EXIT_UNREADABLE },
129        unsupported_version = {
130            BackupError::UnsupportedVersion { context: "manifest.json".to_owned(), found: None, expected: 0 },
131            EXIT_UNREADABLE,
132        },
133        integrity = { BackupError::Integrity("digest".to_owned()), EXIT_INTEGRITY },
134        cluster = { BackupError::Cluster("refused".to_owned()), EXIT_CLUSTER },
135        stale_metadata = { BackupError::StaleMetadata("format-time".to_owned()), EXIT_STALE_METADATA },
136    )]
137    fn every_variant_maps_onto_its_documented_exit_code(error: BackupError, expected: i32) {
138        check!(error.exit_code() == expected, "for {error}");
139    }
140}