Skip to main content

krabka_restore/args/
validate.rs

1//! The flag combinations clap cannot express, checked after parsing.
2//!
3//! A backend sub-flag such as `--archive-s3-region` belongs to the backend its
4//! `--archive-*` flag selected, and a bound belongs to a partition of a topic
5//! `--topic` selects; neither is a constraint clap's `ArgGroup` can state, so
6//! both are checked here and reported as `RestoreError::InvalidArgument`. The
7//! backend rule is `krabka_object_store::ArchiveArgs::validate`, the one
8//! `krabka backup` applies to the same flags.
9
10use {
11    super::{PartitionRef, RestoreArgs},
12    crate::error::RestoreError,
13    std::collections::HashSet,
14};
15
16impl RestoreArgs {
17    /// Check the flag combinations clap cannot express.
18    ///
19    /// # Errors
20    ///
21    /// Returns [`RestoreError::InvalidArgument`] when a backend sub-flag names
22    /// a backend that `--archive-*` did not select, when one partition carries
23    /// two `--to-offset` bounds, or when a bound names a topic that `--topic`
24    /// excludes. Each means the operator wrote a flag that can never apply.
25    pub fn validate(&self) -> Result<(), RestoreError> {
26        self.archive
27            .location
28            .validate()
29            .map_err(|error| RestoreError::InvalidArgument(error.to_string()))?;
30
31        if self.archive.worm_key_id.len() != self.archive.worm_public_key.len() {
32            return Err(RestoreError::InvalidArgument(format!(
33                "--worm-key-id was given {} time(s) and --worm-public-key {} time(s); they pair by position",
34                self.archive.worm_key_id.len(),
35                self.archive.worm_public_key.len()
36            )));
37        }
38        if !self.worm_expect_head.is_empty() && self.archive.worm_key_id.is_empty() {
39            return Err(RestoreError::InvalidArgument(
40                "--worm-expect-head requires --worm-key-id and --worm-public-key".to_owned(),
41            ));
42        }
43        let mut expected_partitions = HashSet::new();
44        for expected in &self.worm_expect_head {
45            let Some((partition, head)) = expected.split_once('=') else {
46                return Err(RestoreError::InvalidArgument(format!(
47                    "--worm-expect-head must be PARTITION_DIR=64_HEX, got `{expected}`"
48                )));
49            };
50            if partition.is_empty()
51                || head.len() != 64
52                || !head.bytes().all(|byte| byte.is_ascii_hexdigit())
53            {
54                return Err(RestoreError::InvalidArgument(format!(
55                    "--worm-expect-head must be PARTITION_DIR=64_HEX, got `{expected}`"
56                )));
57            }
58            if !expected_partitions.insert(partition) {
59                return Err(RestoreError::InvalidArgument(format!(
60                    "--worm-expect-head names `{partition}` more than once"
61                )));
62            }
63        }
64
65        let mut bounded: Vec<&PartitionRef> = Vec::with_capacity(self.to_offset.len());
66        for bound in &self.to_offset {
67            if bounded.contains(&&bound.partition) {
68                return Err(RestoreError::InvalidArgument(format!(
69                    "--to-offset names {} more than once",
70                    bound.partition
71                )));
72            }
73            bounded.push(&bound.partition);
74        }
75
76        for partition in super::offset_partitions(self) {
77            if !self.selects_topic(&partition.topic) {
78                return Err(RestoreError::InvalidArgument(format!(
79                    "a bound names {partition}, which --topic does not select"
80                )));
81            }
82        }
83        Ok(())
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use {
90        crate::{Cli, args::test_support::args_from, error::RestoreError},
91        assert2::check,
92        clap::Parser as _,
93    };
94
95    #[yare::parameterized(
96        s3_region = { &["--archive-s3-region", "eu-west-1"][..] },
97        s3_endpoint = { &["--archive-s3-endpoint", "http://minio:9000"][..] },
98        s3_access_key = { &["--archive-s3-access-key-id", "key"][..] },
99        s3_secret_key = { &["--archive-s3-secret-access-key", "secret"][..] },
100        s3_http = { &["--archive-s3-allow-http"][..] },
101        gcs_service_account = { &["--archive-gcs-service-account-path", "/etc/sa.json"][..] },
102        gcs_endpoint = { &["--archive-gcs-endpoint", "http://fake-gcs:4443"][..] },
103        gcs_http = { &["--archive-gcs-allow-http"][..] },
104    )]
105    fn backend_sub_flags_require_their_bucket(stray: &[&str]) {
106        // The archive source in `args_from` is `--archive-local`, so every one
107        // of these names a backend that was not selected.
108        let args = args_from(stray).expect("args");
109        check!(
110            matches!(args.validate(), Err(RestoreError::InvalidArgument(_))),
111            "{stray:?}"
112        );
113    }
114
115    #[test]
116    fn backend_sub_flags_are_accepted_with_their_bucket() {
117        let args = Cli::try_parse_from([
118            "krabka-restore",
119            "--log-dir",
120            "/target",
121            "--archive-s3-bucket",
122            "backups",
123            "--archive-s3-region",
124            "eu-west-1",
125            "--archive-s3-allow-http",
126        ])
127        .expect("args")
128        .args;
129        check!(args.validate().is_ok());
130    }
131
132    #[test]
133    fn validate_rejects_two_bounds_on_one_partition() {
134        let args =
135            args_from(&["--to-offset", "orders:0=10", "--to-offset", "orders:0=20"]).expect("args");
136        check!(args.validate().is_err());
137    }
138
139    #[test]
140    fn validate_accepts_bounds_on_distinct_partitions() {
141        let args =
142            args_from(&["--to-offset", "orders:0=10", "--to-offset", "orders:1=20"]).expect("args");
143        check!(args.validate().is_ok());
144    }
145
146    #[test]
147    fn validate_rejects_a_bound_on_an_unselected_topic() {
148        let args = args_from(&["--topic", "orders", "--to-offset", "payments:0=10"]).expect("args");
149        check!(args.validate().is_err());
150
151        let args =
152            args_from(&["--topic", "orders", "--exclude-offset", "payments:0=1..2"]).expect("args");
153        check!(args.validate().is_err());
154    }
155
156    #[test]
157    fn validate_accepts_a_bound_on_a_selected_topic() {
158        let args = args_from(&["--topic", "orders", "--to-offset", "orders:0=10"]).expect("args");
159        check!(args.validate().is_ok());
160    }
161}