Skip to main content

krabka_format/format/
args.rs

1//! The command line `krabka format` accepts, and the scalar value parsers that
2//! belong to no larger concern.
3//!
4//! [`FormatArgs`] is the one description of that command line: the binary and
5//! [`crate::run_from_args`] both parse into it, so a caller of either sees the
6//! same flags. The structured values behind `--feature`, `--add-scram`, and
7//! `--add-acl` parse in the module that owns each of those concerns, and this
8//! module names their parsers in the `value_parser` attributes.
9
10use {
11    super::{acl::parse_acl_spec, features::parse_feature_spec, scram::parse_scram_spec},
12    crate::ids::{ClusterId, DirectoryId},
13    clap::Args,
14    krabka_metadata::{AclEntry, FeatureLevelRecord},
15    krabka_security::SaslMechanism,
16    std::path::PathBuf,
17};
18
19#[derive(Args, Debug)]
20pub struct FormatArgs {
21    /// A log directory to format, an entry of Kafka's `log.dirs`. Repeat the
22    /// flag, or separate paths with commas, to format every directory of the
23    /// node in one run. Without `--metadata-log-dir`, the first directory is
24    /// also the metadata log directory, as Kafka's `metadata.log.dir` defaults
25    /// to the first entry of `log.dirs`. `kafka-storage format` reads the same
26    /// set from its `--config` file. Krabka takes no `server.properties`, so
27    /// there is no `--config`.
28    #[arg(
29        long = "log-dir",
30        value_name = "DIR",
31        value_delimiter = ',',
32        required = true
33    )]
34    pub(super) log_dirs: Vec<PathBuf>,
35    /// The metadata log directory, Kafka's `metadata.log.dir`. The run formats
36    /// it together with every `--log-dir`, as `kafka-storage format` adds
37    /// `metadata.log.dir` to the `log.dirs` set. Only this directory gets the
38    /// bootstrap files and the `__cluster_metadata-0` checkpoint. It can also
39    /// be one of the `--log-dir` entries. Give the broker the same directory
40    /// as its `--metadata-log-dir`. Defaults to the first `--log-dir`.
41    #[arg(long, value_name = "DIR")]
42    pub(super) metadata_log_dir: Option<PathBuf>,
43    /// Cluster id. Written and printed in Kafka's 22-character base64 form.
44    /// Accepts that form or the hyphenated form. When omitted, the id of an
45    /// already formatted directory in the set is kept, and otherwise a new id
46    /// is generated.
47    #[arg(long, value_parser = ClusterId::parse_cli)]
48    pub(super) cluster_id: Option<ClusterId>,
49    /// Bootstrap `metadata.version` (KIP-778), e.g. `4.0` or `4.0-IV3`. A
50    /// string with more than two dot-separated segments keeps the first two,
51    /// as Kafka's `MetadataVersion.fromVersionString` does, so `4.3.1` is
52    /// `4.3`. Defaults to Kafka 4.3's latest production level, `4.3-IV0`, when
53    /// omitted.
54    #[arg(long)]
55    pub(super) release_version: Option<String>,
56    /// Set an individual feature's finalized level at format time (KIP-1022),
57    /// e.g. `--feature transaction.version=2`. May be repeated. Combines with
58    /// `--release-version` (which sets the base release) for every feature
59    /// except `metadata.version`, where the two conflict.
60    #[arg(long = "feature", value_parser = parse_feature_spec)]
61    pub(super) feature: Vec<FeatureLevelRecord>,
62    /// Accept feature levels past the latest production ones: Kafka trunk's
63    /// `metadata.version` `4.4-IV0` to `4.4-IV2`, which a stock Kafka 4.3.1
64    /// does not know. It is Kafka's internal `unstable.feature.versions.enable`,
65    /// which `kafka-storage format` reads from its `--config` file; without it
66    /// such a release is refused as Kafka refuses it. A node formatted at one
67    /// of those levels needs the same setting in its `server_properties`.
68    #[arg(
69        long,
70        action = clap::ArgAction::SetTrue,
71        value_parser = clap::builder::TypedValueParser::map(
72            clap::builder::BoolValueParser::new(),
73            krabka_raft::UnstableFeatureVersions::from,
74        ),
75    )]
76    pub(super) unstable_feature_versions_enable: krabka_raft::UnstableFeatureVersions,
77    /// Seed a SCRAM credential. May be repeated.
78    /// Format: `SCRAM-SHA-256=[name=<u>,password=<p>,iterations=<n>]`
79    /// or `SCRAM-SHA-512=[name=<u>,password=<p>,iterations=<n>]`
80    /// (iterations defaults to 4096 when omitted)
81    #[arg(long, value_parser = parse_scram_spec)]
82    pub(super) add_scram: Vec<ScramSpec>,
83    /// Seed an ACL entry. May be repeated.
84    /// Format: `principal=User:<name>,host=<ip|*>,operation=<Op>,permission=<Allow|Deny>,resource=<Type>:<Name>[:<Pattern>]`
85    /// Pattern defaults to `Literal`.
86    #[arg(long, value_parser = parse_acl_spec)]
87    pub(super) add_acl: Vec<AclEntry>,
88    /// This node's id, Kafka's `node.id`: an integer from 0 to 2147483647.
89    /// Every `meta.properties` records it, and the broker refuses to start on
90    /// a directory that records another id. `kafka-storage format` reads it
91    /// from its `--config` file. Give the broker the same id as its
92    /// `--broker-id`.
93    #[arg(long, value_parser = parse_node_id)]
94    pub(super) node_id: krabka_metadata::NodeId,
95    /// The metadata log directory's stable id: the node's KIP-853 voter
96    /// identity. Intended for orchestrators that verify the exact node
97    /// incarnation before they declare it ready. Accepts Kafka's base64 form
98    /// or the hyphenated form. The other directories get generated ids.
99    #[arg(long, value_parser = parse_directory_id)]
100    pub(super) directory_id: Option<DirectoryId>,
101    /// Format this node as the sole initial controller voter.
102    #[arg(
103        long,
104        conflicts_with_all = ["initial_controllers", "no_initial_controllers"]
105    )]
106    pub(super) standalone: bool,
107    /// Explicit initial controllers: `id@host:port:directory-id`,
108    /// comma-separated. The directory id is in Kafka's base64 form or the
109    /// hyphenated form.
110    #[arg(
111        long,
112        value_delimiter = ',',
113        conflicts_with_all = ["standalone", "no_initial_controllers"]
114    )]
115    pub(super) initial_controllers: Vec<String>,
116    /// Format a dynamic controller that will join an existing quorum.
117    #[arg(
118        long,
119        conflicts_with_all = ["standalone", "initial_controllers"]
120    )]
121    pub(super) no_initial_controllers: bool,
122    /// This node's controller listener (`host:port`) — written into the
123    /// `VotersRecord` when `--standalone`.
124    #[arg(long)]
125    pub(super) controller_listener: Option<String>,
126    /// The name of the controller listener: the first entry of Kafka's
127    /// `controller.listener.names`. Each voter endpoint that `--standalone` or
128    /// `--initial-controllers` writes carries it, as `kafka-storage format`
129    /// names them. A leader refuses an `AddRaftVoter` whose endpoints do not
130    /// include its own listener name.
131    #[arg(long, default_value = "CONTROLLER", value_parser = parse_listener_name)]
132    pub(super) controller_listener_name: String,
133    /// Skip an already formatted directory, instead of refusing the run, and
134    /// format the others. Matches `kafka-storage format --ignore-formatted`.
135    ///
136    /// This is what makes the formatter safe to run unconditionally, which a
137    /// Kubernetes init container has to: the image carries no shell, so there
138    /// is nothing to test the directory with before the call, and a pod that
139    /// restarts against its existing volume would otherwise fail every
140    /// restart after the first.
141    #[arg(long)]
142    pub(super) ignore_formatted: bool,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ScramSpec {
147    pub(super) mechanism: SaslMechanism,
148    pub(super) name: String,
149    pub(super) password: String,
150    pub(super) iterations: u32,
151}
152
153/// Parses a node id as Kafka's `node.id`: an `int` that is not negative.
154///
155/// `meta.properties` holds the id as an `int`, which Kafka's tools read with
156/// `Integer.parseInt`, so a larger id would make a file they cannot read. The
157/// negative-id message is the one Kafka's `Formatter.run` throws.
158///
159/// # Errors
160///
161/// Returns a message for a value that is not an integer, is past the `int`
162/// range, or is negative.
163pub fn parse_node_id(s: &str) -> Result<krabka_metadata::NodeId, String> {
164    let id: i32 = s.trim().parse().map_err(|e| format!("node id: {e}"))?;
165    let id = u64::try_from(id)
166        .map_err(|_| "You must specify a valid non-negative node ID.".to_owned())?;
167    Ok(krabka_metadata::NodeId(id))
168}
169
170/// Parses `--directory-id`: Kafka's base64 form or the hyphenated form, and
171/// never one of the 100 ids that Kafka reserves as directory-id sentinels.
172///
173/// The broker refuses a reserved id at startup, as Kafka's
174/// `MetaPropertiesEnsemble.verify` does, so the format refuses it first and
175/// writes nothing.
176///
177/// # Errors
178///
179/// Returns the message of [`DirectoryId::parse_cli`], or one for a reserved
180/// id.
181pub fn parse_directory_id(s: &str) -> Result<DirectoryId, String> {
182    let id = DirectoryId::parse_cli(s).map_err(|e| e.to_string())?;
183    if id.is_reserved() {
184        return Err(format!(
185            "Invalid reserved directory ID {id}: Kafka reserves the 100 lowest directory ids"
186        ));
187    }
188    Ok(id)
189}
190
191/// Parse `--controller-listener-name` into Kafka's normalised form: upper
192/// case, as `ListenerName.normalised` makes it.
193///
194/// # Errors
195///
196/// Returns a message for an empty name, or one with leading or trailing white
197/// space, which Kafka's `RaftVoterEndpoint` refuses.
198pub fn parse_listener_name(s: &str) -> Result<String, String> {
199    if s.is_empty() || s.trim() != s {
200        return Err(format!(
201            "controller listener name {s:?} must be non-empty, without leading or trailing \
202             white space"
203        ));
204    }
205    Ok(s.to_ascii_uppercase())
206}
207
208#[cfg(test)]
209mod tests {
210
211    use {
212        super::*,
213        crate::Cli,
214        assert2::check,
215        clap::{Parser as _, error::ErrorKind},
216    };
217
218    /// `--log-dir` takes repeated flags and comma-separated lists alike, and
219    /// keeps the order they were given in.
220    #[yare::parameterized(
221        repeated_flags = { &["--log-dir", "/a", "--log-dir", "/b"] },
222        comma_separated = { &["--log-dir", "/a,/b"] },
223        equals_form = { &["--log-dir=/a", "--log-dir=/b"] },
224    )]
225    fn log_dir_is_repeatable_and_comma_separated(argv: &[&str]) {
226        let cli = Cli::try_parse_from(
227            ["krabka-format", "--node-id", "1"]
228                .into_iter()
229                .chain(argv.iter().copied()),
230        )
231        .expect("parse");
232        assert2::check!(
233            cli.args.log_dirs == vec![PathBuf::from("/a"), PathBuf::from("/b")],
234            "{argv:?}"
235        );
236    }
237
238    /// `--metadata-log-dir` takes one path, comma and all, as Kafka's
239    /// `metadata.log.dir` is one path. It is optional, it does not replace the
240    /// required `--log-dir`, and clap refuses it twice.
241    #[yare::parameterized(
242        log_only = { &["--log-dir", "/a"], None, &["/a"][..], None },
243        metadata_and_logs = {
244            &["--metadata-log-dir", "/m", "--log-dir", "/a,/b"],
245            Some("/m"),
246            &["/a", "/b"][..],
247            None,
248        },
249        same_metadata_and_log = {
250            &["--log-dir=/a", "--metadata-log-dir=/a"],
251            Some("/a"),
252            &["/a"][..],
253            None,
254        },
255        comma_in_metadata_is_path = {
256            &["--metadata-log-dir", "/m,/n", "--log-dir", "/a"],
257            Some("/m,/n"),
258            &["/a"][..],
259            None,
260        },
261        missing_required_log = {
262            &["--metadata-log-dir", "/m"],
263            None,
264            &[][..],
265            Some(ErrorKind::MissingRequiredArgument),
266        },
267        repeated_metadata_conflicts = {
268            &["--metadata-log-dir", "/m", "--metadata-log-dir", "/n", "--log-dir", "/a"],
269            None,
270            &[][..],
271            Some(ErrorKind::ArgumentConflict),
272        },
273    )]
274    fn metadata_log_dir_is_one_optional_path(
275        argv: &[&str],
276        metadata: Option<&str>,
277        log_dirs: &[&str],
278        error: Option<ErrorKind>,
279    ) {
280        let got = Cli::try_parse_from(
281            ["krabka-format", "--node-id", "1"]
282                .into_iter()
283                .chain(argv.iter().copied()),
284        )
285        .map(|cli| (cli.args.metadata_log_dir, cli.args.log_dirs))
286        .map_err(|error| error.kind());
287        let want = error.map_or_else(
288            || {
289                Ok((
290                    metadata.map(PathBuf::from),
291                    log_dirs.iter().map(PathBuf::from).collect(),
292                ))
293            },
294            Err,
295        );
296        assert2::check!(got == want, "{argv:?}");
297    }
298
299    /// A node id is Kafka's `int` without a sign: 0 through 2147483647.
300    /// Everything else is an error rather than a silent zero.
301    #[yare::parameterized(
302        ordinary = { "7", Ok(7) },
303        trims_spaces = { "  7  ", Ok(7) },
304        zero = { "0", Ok(0) },
305        max_i32 = { "2147483647", Ok(2_147_483_647) },
306        overflow = { "2147483648", Err("node id: number too large to fit in target type") },
307        negative = { "-1", Err("You must specify a valid non-negative node ID.") },
308        empty = { "", Err("node id: cannot parse integer from empty string") },
309        decimal = { "1.0", Err("node id: invalid digit found in string") },
310        hex = { "0x7", Err("node id: invalid digit found in string") },
311    )]
312    fn parse_node_id_takes_kafkas_node_id_range(input: &str, want: Result<u64, &str>) {
313        assert2::check!(
314            parse_node_id(input).map(|n| n.0) == want.map_err(str::to_owned),
315            "{input:?}"
316        );
317    }
318
319    /// `--directory-id` takes either id form and refuses the ids that Kafka
320    /// reserves, which the broker would refuse at startup.
321    #[yare::parameterized(
322        usable_uuid = {
323            uuid::Uuid::from_u64_pair(1, 1).to_string(),
324            Ok(DirectoryId::from(uuid::Uuid::from_u64_pair(1, 1))),
325        },
326        usable_kafka_form = {
327            DirectoryId::from(uuid::Uuid::from_u64_pair(1, 1)).to_string(),
328            Ok(DirectoryId::from(uuid::Uuid::from_u64_pair(1, 1))),
329        },
330        reserved_one = {
331            uuid::Uuid::from_u128(1).to_string(),
332            Err(format!(
333                "Invalid reserved directory ID {}: Kafka reserves the 100 lowest directory ids",
334                DirectoryId::from(uuid::Uuid::from_u128(1))
335            )),
336        },
337        reserved_ninety_nine = {
338            uuid::Uuid::from_u128(99).to_string(),
339            Err(format!(
340                "Invalid reserved directory ID {}: Kafka reserves the 100 lowest directory ids",
341                DirectoryId::from(uuid::Uuid::from_u128(99))
342            )),
343        },
344        first_usable = {
345            DirectoryId::from(uuid::Uuid::from_u128(100)).to_string(),
346            Ok(DirectoryId::from(uuid::Uuid::from_u128(100))),
347        },
348    )]
349    fn parse_directory_id_refuses_reserved_ids(input: String, want: Result<DirectoryId, String>) {
350        assert2::check!(parse_directory_id(&input) == want, "{input:?}");
351    }
352
353    /// `--node-id` is required, as Kafka's `node.id` is: every
354    /// `meta.properties` records it.
355    #[test]
356    fn node_id_is_required() {
357        let missing = Cli::try_parse_from(["krabka-format", "--log-dir", "/a"])
358            .map(|cli| cli.args.node_id)
359            .map_err(|error| error.kind());
360        check!(missing == Err(ErrorKind::MissingRequiredArgument));
361        let given = Cli::try_parse_from(["krabka-format", "--log-dir", "/a", "--node-id", "3"])
362            .map(|cli| cli.args.node_id)
363            .map_err(|error| error.kind());
364        check!(given == Ok(krabka_metadata::NodeId(3)));
365    }
366}