krabka_format/format.rs
1//! `krabka format` subcommand.
2//!
3//! Formats every log directory of a node, as `kafka-storage format` does:
4//! each directory gets the same cluster id and its own directory id. The set
5//! is `--metadata-log-dir`, the counterpart of Kafka's `metadata.log.dir`,
6//! and every `--log-dir`, the entries of Kafka's `log.dirs`. Without
7//! `--metadata-log-dir`, the first `--log-dir` is the metadata log directory,
8//! as `metadata.log.dir` defaults to the first entry of `log.dirs`.
9//!
10//! ## Output
11//!
12//! Non-Raft metadata is written as a bootstrap stream. The active controller
13//! writes it to the metadata log when it activates on an empty log, and a
14//! broker never writes it. Dynamic KIP-853 modes also write the offset-zero
15//! bootstrap checkpoint, and a controller takes the records from that
16//! checkpoint first, as Kafka's does. Only the metadata log directory
17//! receives them. Kafka trunk also writes its bootstrap snapshot only into a
18//! metadata directory, and the controller reads the bootstrap records only
19//! from there:
20//!
21//! - `bootstrap.json` — a human-readable manifest with the cluster id and a
22//! base64'd `serde_wincode` blob per metadata record.
23//! - `bootstrap.records.bin` — the same records concatenated as
24//! length-prefixed `serde_wincode<SerdeCompat<MetadataRecord>>` payloads, so
25//! the controller can stream them without touching JSON.
26//! - `__cluster_metadata-0/00000000000000000000-0000000000.checkpoint` — the
27//! KIP-630/KIP-853 bootstrap snapshot for dynamic membership, for a dynamic
28//! format only. It holds the control state, then the same records in the
29//! same order, as Kafka's `Formatter.writeBoostrapSnapshot` writes them. The
30//! path is Kafka's.
31//!
32//! Every directory receives Kafka's `meta.properties`, with the cluster id,
33//! the node id, and the directory's own id, written last. A data directory
34//! receives nothing else.
35//!
36//! ## Exit codes
37//!
38//! `kafka-storage format` exits 1 for every failure. `krabka format` keeps a
39//! code per cause, so an orchestrator can tell an operator error from an I/O
40//! fault:
41//!
42//! | Code | Cause |
43//! | :--- | :--- |
44//! | 0 | Every directory is formatted, or was already and `--ignore-formatted` is set. |
45//! | 2 | An `--add-scram` iteration count is below 4096, or clap refused the command line. |
46//! | 3 | A directory is already formatted without `--ignore-formatted`, holds files `format` did not write, or disagrees with the others on the cluster id. |
47//! | 4 | A write failed, or the quorum flags name an invalid voter set. |
48//! | 5 | A `--feature`, `--release-version`, or quorum-mode combination is invalid. |
49
50use {
51 krabka_metadata::{
52 KRaftVersionRecord, MetadataRecord, ScramCredentialRecord, VoterSet, VotersRecord,
53 metadata_version,
54 },
55 krabka_security::scram::{MIN_SCRAM_ITERATIONS, hash_scram_password_with_salt},
56 ring::rand::{SecureRandom, SystemRandom},
57 std::{
58 fmt::Display,
59 fs,
60 io::{self, Write},
61 path::{Path, PathBuf},
62 },
63};
64
65mod acl;
66mod args;
67mod ensemble;
68mod features;
69mod output;
70mod quorum;
71mod scram;
72#[cfg(test)]
73mod tests;
74
75pub use self::{
76 args::{FormatArgs, ScramSpec, parse_node_id},
77 features::LATEST_PRODUCTION_METADATA_VERSION,
78 output::FAIL_AFTER_ENV,
79};
80use {
81 self::{
82 ensemble::{Ensemble, SurveyError, remove_partial_output},
83 features::{ResolvedFeatures, resolve_format_features},
84 output::{Fault, write_bootstrap_files, write_dynamic_checkpoint, write_meta_properties},
85 quorum::{build_initial_voters, is_dynamic_format},
86 },
87 crate::{
88 ids::{ClusterId, DirectoryId},
89 meta_properties::MetaProperties,
90 },
91};
92
93/// Exit codes. The table in the module documentation gives the cause of each.
94const EXIT_OK: i32 = 0;
95const EXIT_LOW_ITERATIONS: i32 = 2;
96const EXIT_DIRTY_LOG_DIR: i32 = 3;
97const EXIT_BOOTSTRAP_FAIL: i32 = 4;
98const EXIT_INVALID_FEATURE: i32 = 5;
99
100/// Formats the metadata log directory and `args.log_dirs`, returning the
101/// process exit code.
102///
103/// Every failure a caller can cause -- an unwritable directory, a malformed
104/// `--add-scram` spec, an unknown feature -- is reported on stderr and returned
105/// as a non-zero code rather than raised.
106///
107/// # Panics
108///
109/// Panics if `--initial-controllers` was given without the node's own identity
110/// appearing in it. `is_dynamic_format` rejects that combination before this
111/// point, so reaching the panic means that validation and this branch have
112/// drifted apart.
113pub async fn run(args: FormatArgs) -> i32 { run_with_records(args, Vec::new()).await }
114
115// `async` matches the entry point in `main.rs`; the body is sync today
116// (purely fs + crypto) but a real raft-log bootstrap would await tokio I/O.
117// The body yields an `i32` (not a future), so `#[instrument]` is safe here
118// w.r.t. `clippy::async_yields_async`.
119/// Formats the metadata log directory and `args.log_dirs` with `extra` seeded
120/// alongside the records the flags produce, returning the process exit code.
121///
122/// A cluster restored from tiered-storage archives has to come up with its
123/// topics already present, so the restore tool hands the topic and partition
124/// records it recovered to the formatter instead of repeating the bootstrap
125/// write itself. The extra records join the one seed stream, so they reach both
126/// the offset-zero checkpoint and the bootstrap files.
127///
128/// `extra` lands directly after the feature records and ahead of the
129/// `--add-scram` and `--add-acl` records. The finalized feature levels,
130/// `metadata.version` first, decide how every later record is read, so they
131/// lead the stream and a topic record can only come after them. Seeded topics
132/// ahead of seeded ACL entries then match the order a live cluster writes them
133/// in, where a topic exists before an entry names it as a resource. The
134/// KIP-853 control state -- the `KRaft` version and the initial voters -- is a
135/// separate stream that the checkpoint applies before any of these.
136///
137/// Ordering inside `extra` is the caller's. A `MetadataImage` derives a topic's
138/// partition count from the partition records that apply after it, so each
139/// `TopicRecord` must come before its own partitions.
140///
141/// # Panics
142///
143/// Panics if `--initial-controllers` was given without the node's own identity
144/// appearing in it. `is_dynamic_format` rejects that combination before this
145/// point, so reaching the panic means that validation and this branch have
146/// drifted apart.
147pub async fn run_with_records(args: FormatArgs, extra: Vec<MetadataRecord>) -> i32 {
148 run_with_records_to(args, extra, &mut io::stdout()).await
149}
150
151/// [`run_with_records`], with Kafka's progress lines written to `progress`
152/// instead of stdout.
153///
154/// A tool that formats as one step of its own command, such as
155/// `krabka-restore`, keeps its stdout for its own output: a `--report json`
156/// that a script pipes to `jq` must hold the report and nothing else.
157///
158/// # Panics
159///
160/// As [`run_with_records`].
161#[tracing::instrument(
162 level = "info",
163 name = "cli.format",
164 skip_all,
165 fields(
166 log_dirs = ?args.log_dirs,
167 metadata_log_dir = ?args.metadata_log_dir,
168 standalone = args.standalone,
169 extra_records = extra.len(),
170 )
171)]
172pub async fn run_with_records_to(
173 args: FormatArgs,
174 extra: Vec<MetadataRecord>,
175 progress: &mut (dyn Write + Send),
176) -> i32 {
177 match plan(args, extra).and_then(|plan| plan.execute(&Fault::from_env(), progress)) {
178 Ok(()) => EXIT_OK,
179 Err(Failure { code, message }) => {
180 eprintln!("{message}");
181 code
182 }
183 }
184}
185
186/// A failed run: its exit code and the line it prints on stderr.
187#[derive(Debug, PartialEq, Eq)]
188struct Failure {
189 code: i32,
190 message: String,
191}
192
193/// Prefixes `message` with the command name, as every krabka-specific
194/// message is. Kafka's own messages are printed bare.
195fn krabka(code: i32, message: impl Display) -> Failure {
196 Failure {
197 code,
198 message: format!("krabka format: {message}"),
199 }
200}
201
202/// What a run writes, and where. [`plan`] builds it without touching the
203/// disk beyond reading it, so every validation failure leaves the directories
204/// as they were.
205struct Plan {
206 cluster_id: ClusterId,
207 /// `--node-id`, as the `int` that `meta.properties` records.
208 node_id: i32,
209 metadata_version: String,
210 /// The directories to write, the metadata log directory first when it is
211 /// one of them.
212 targets: Vec<Target>,
213 /// The formatted directories `--ignore-formatted` skips.
214 skipped: Vec<PathBuf>,
215 /// The directories whose `meta.properties` does not read.
216 errors: Vec<PathBuf>,
217 raft_control_records: Vec<MetadataRecord>,
218 records: Vec<MetadataRecord>,
219}
220
221/// One directory to format.
222#[derive(Debug, PartialEq, Eq)]
223struct Target {
224 dir: PathBuf,
225 kind: DirectoryKind,
226 directory_id: DirectoryId,
227}
228
229/// Kafka's `Formatter.DirectoryType`: what a directory holds, which decides
230/// whether it gets the bootstrap files and the checkpoint, and how the run
231/// describes it.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233enum DirectoryKind {
234 Data,
235 StaticMetadata,
236 DynamicMetadata,
237 DynamicMetadataVoter,
238}
239
240impl DirectoryKind {
241 /// Kafka's `DirectoryType.description`.
242 fn description(self) -> &'static str {
243 match self {
244 Self::Data => "data directory",
245 Self::StaticMetadata => "metadata directory",
246 Self::DynamicMetadata => "dynamic metadata directory",
247 Self::DynamicMetadataVoter => "dynamic metadata voter directory",
248 }
249 }
250
251 /// Kafka's `DirectoryType.isMetadataDirectory`: the directory gets the
252 /// bootstrap files.
253 fn is_metadata(self) -> bool { self != Self::Data }
254
255 /// The directory also gets the offset-zero checkpoint.
256 fn is_dynamic_metadata(self) -> bool {
257 matches!(self, Self::DynamicMetadata | Self::DynamicMetadataVoter)
258 }
259}
260
261/// Validates the command line, reads the directories, and decides what to
262/// write.
263fn plan(args: FormatArgs, extra: Vec<MetadataRecord>) -> Result<Plan, Failure> {
264 // KIP-584 / KIP-778 / KIP-1022 bootstrap: finalize each registered feature
265 // at its `--feature` override, else its per-release default for the
266 // resolved bootstrap metadata.version (`--feature metadata.version` >
267 // `--release-version` > latest stable). A 4.0 format thus seeds
268 // metadata.version, group.version, etc. at their 4.0 defaults so a fresh
269 // cluster engages each feature with no manual step; a level-0 feature is
270 // omitted (absent = disabled), matching `kafka-storage format`.
271 let ResolvedFeatures {
272 metadata_version: bootstrap_mv,
273 overrides: feature_overrides,
274 } = resolve_format_features(
275 args.release_version.as_deref(),
276 &args.feature,
277 args.unstable_feature_versions_enable,
278 )
279 .map_err(|e| krabka(EXIT_INVALID_FEATURE, e))?;
280 let metadata_version = metadata_version::from_feature_level(bootstrap_mv)
281 .map_or_else(|| bootstrap_mv.to_string(), |mv| mv.ivn().to_owned());
282
283 // The `kraft.version` rules come after the release and the feature names,
284 // as in `Formatter.run`: it resolves the release, then checks the names in
285 // `calculateEffectiveFeatureLevels`, which is where `kraft.version` is
286 // reconciled with the quorum flags.
287 let dynamic_format = is_dynamic_format(&args).map_err(|e| krabka(EXIT_INVALID_FEATURE, e))?;
288
289 // `meta.properties` holds the node id as Kafka's `int`. `--node-id`
290 // parses only that range, so this refuses nothing that clap accepted.
291 let node_id = i32::try_from(args.node_id.0).map_err(|_| {
292 krabka(
293 EXIT_BOOTSTRAP_FAIL,
294 "You must specify a valid non-negative node ID.",
295 )
296 })?;
297
298 // KIP-853: this node's stable directory id for the metadata log
299 // directory. The broker reads it back from `meta.properties` on every
300 // boot; it is the identity component of every `Voter` this node ever
301 // appears as.
302 let generated_directory_id = args.directory_id.unwrap_or_else(DirectoryId::random);
303 let initial_voters = build_initial_voters(&args, generated_directory_id)
304 .map_err(|e| krabka(EXIT_BOOTSTRAP_FAIL, e))?;
305 let metadata_directory_id = if args.initial_controllers.is_empty() {
306 generated_directory_id
307 } else {
308 DirectoryId(
309 initial_voters
310 .get(args.node_id)
311 .ok_or_else(|| {
312 krabka(
313 EXIT_BOOTSTRAP_FAIL,
314 "--initial-controllers has no entry for this node",
315 )
316 })?
317 .directory_id,
318 )
319 };
320 if args.directory_id.is_some() && metadata_directory_id != generated_directory_id {
321 return Err(krabka(
322 EXIT_BOOTSTRAP_FAIL,
323 "--directory-id must match the local --initial-controllers entry",
324 ));
325 }
326 let metadata_kind = metadata_directory_kind(dynamic_format, &args, &initial_voters);
327
328 // KIP-853 control records live in the offset-zero metadata checkpoint,
329 // separate from the non-Raft bootstrap record stream.
330 let mut raft_control_records = Vec::new();
331 if dynamic_format {
332 raft_control_records.push(MetadataRecord::V1KRaftVersion(KRaftVersionRecord {
333 kraft_version: 1,
334 }));
335 if !initial_voters.is_empty() {
336 raft_control_records.push(MetadataRecord::V1Voters(VotersRecord {
337 voters: initial_voters,
338 }));
339 }
340 }
341
342 let mut records: Vec<MetadataRecord> =
343 krabka_metadata::bootstrap_feature_records_with_overrides(bootstrap_mv, &feature_overrides);
344 // Caller-seeded records (a restore's recovered topics) follow the feature
345 // levels that decide how they are read, and precede the credential and ACL
346 // records so a seeded ACL names a topic the image already holds.
347 records.extend(extra);
348 records.extend(scram_records(&args)?);
349 records.extend(
350 args.add_acl
351 .into_iter()
352 .map(MetadataRecord::V1AccessControlEntry),
353 );
354
355 let metadata_log_dir = args
356 .metadata_log_dir
357 .or_else(|| args.log_dirs.first().cloned())
358 .ok_or_else(|| krabka(EXIT_BOOTSTRAP_FAIL, "no log directory to format"))?;
359 let log_dirs = directory_set(&metadata_log_dir, args.log_dirs);
360 let ensemble = Ensemble::load(&log_dirs).map_err(|error| match error {
361 SurveyError::Foreign(dir) => krabka(
362 EXIT_DIRTY_LOG_DIR,
363 format_args!(
364 "refusing to overwrite non-empty log directory {}: it holds files krabka format \
365 did not write",
366 dir.display()
367 ),
368 ),
369 SurveyError::Io(dir, e) => krabka(
370 EXIT_BOOTSTRAP_FAIL,
371 format_args!("cannot read log directory {}: {e}", dir.display()),
372 ),
373 })?;
374 if ensemble.errors.contains(&metadata_log_dir) {
375 return Err(Failure {
376 code: EXIT_DIRTY_LOG_DIR,
377 message: format!(
378 "Encountered I/O error in metadata log directory {}. Cannot continue.",
379 metadata_log_dir.display()
380 ),
381 });
382 }
383 let cluster_id = ensemble
384 .verify(args.cluster_id, node_id)
385 .map_err(|message| Failure {
386 code: EXIT_DIRTY_LOG_DIR,
387 message,
388 })?
389 .unwrap_or_else(ClusterId::random);
390 if !args.ignore_formatted
391 && let Some(first) = ensemble.formatted.first()
392 {
393 return Err(Failure {
394 code: EXIT_DIRTY_LOG_DIR,
395 message: format!(
396 "Log directory {} is already formatted. Use --ignore-formatted to ignore this \
397 directory and format the others.",
398 first.dir.display()
399 ),
400 });
401 }
402
403 let mut used: Vec<DirectoryId> = ensemble
404 .formatted
405 .iter()
406 .filter_map(|formatted| formatted.meta.directory_id)
407 .collect();
408 let mut targets = Vec::with_capacity(ensemble.empty.len());
409 for dir in ensemble.empty {
410 let (kind, directory_id) = if dir == metadata_log_dir {
411 (metadata_kind, metadata_directory_id)
412 } else {
413 (DirectoryKind::Data, DirectoryId::random_unused(&used))
414 };
415 used.push(directory_id);
416 targets.push(Target {
417 dir,
418 kind,
419 directory_id,
420 });
421 }
422 if targets.is_empty() && ensemble.formatted.is_empty() {
423 return Err(Failure {
424 code: EXIT_BOOTSTRAP_FAIL,
425 message: "No available log directories to format.".to_owned(),
426 });
427 }
428
429 Ok(Plan {
430 cluster_id,
431 node_id,
432 metadata_version,
433 targets,
434 skipped: ensemble
435 .formatted
436 .into_iter()
437 .map(|formatted| formatted.dir)
438 .collect(),
439 errors: ensemble.errors,
440 raft_control_records,
441 records,
442 })
443}
444
445/// The directories one run formats: `metadata_log_dir` first, then every
446/// `log_dirs` entry in the order given.
447///
448/// This is Kafka's `StorageTool.configToLogDirectories`, which adds
449/// `metadata.log.dir` to the `log.dirs` set. Kafka keeps the set in a
450/// `TreeSet`, so a path named twice is formatted once. The metadata log
451/// directory can also be one of `log_dirs`, and then it is formatted once, as
452/// the metadata log directory.
453fn directory_set(metadata_log_dir: &Path, log_dirs: Vec<PathBuf>) -> Vec<PathBuf> {
454 let mut set = Vec::with_capacity(log_dirs.len() + 1);
455 set.push(metadata_log_dir.to_path_buf());
456 for dir in log_dirs {
457 if !set.contains(&dir) {
458 set.push(dir);
459 }
460 }
461 set
462}
463
464/// Kafka's `DirectoryType.calculate` for the metadata log directory.
465fn metadata_directory_kind(
466 dynamic_format: bool,
467 args: &FormatArgs,
468 initial_voters: &VoterSet,
469) -> DirectoryKind {
470 if !dynamic_format {
471 DirectoryKind::StaticMetadata
472 } else if initial_voters.contains(args.node_id) {
473 DirectoryKind::DynamicMetadataVoter
474 } else {
475 DirectoryKind::DynamicMetadata
476 }
477}
478
479/// Hashes each `--add-scram` credential. The hashing happens here, on the
480/// formatter's side, with `hash_scram_password_with_salt` from
481/// `krabka-security`, so the record on disk carries the stretched keys and
482/// never the plain password.
483fn scram_records(args: &FormatArgs) -> Result<Vec<MetadataRecord>, Failure> {
484 // The minimum is positive, so its magnitude is the minimum itself.
485 let min = MIN_SCRAM_ITERATIONS.unsigned_abs();
486 let mut records = Vec::with_capacity(args.add_scram.len());
487 for spec in &args.add_scram {
488 if spec.iterations < min {
489 return Err(krabka(
490 EXIT_LOW_ITERATIONS,
491 format_args!(
492 "iterations must be >= {MIN_SCRAM_ITERATIONS}, got {} for user {}",
493 spec.iterations, spec.name,
494 ),
495 ));
496 }
497 let mut salt = vec![0u8; 16];
498 SystemRandom::new()
499 .fill(&mut salt)
500 .map_err(|e| krabka(EXIT_BOOTSTRAP_FAIL, format_args!("rng failure: {e}")))?;
501 let cred = hash_scram_password_with_salt(
502 spec.password.as_bytes(),
503 spec.mechanism,
504 spec.iterations,
505 salt,
506 );
507 records.push(MetadataRecord::V1ScramCredential(ScramCredentialRecord {
508 user: spec.name.clone(),
509 mechanism: spec.mechanism,
510 salt: cred.salt,
511 stored_key: cred.stored_key,
512 server_key: cred.server_key,
513 iterations: cred.iterations,
514 }));
515 }
516 Ok(records)
517}
518
519impl Plan {
520 /// Writes every target directory, printing Kafka's progress lines to
521 /// `progress`.
522 fn execute(self, fault: &Fault, progress: &mut dyn Write) -> Result<(), Failure> {
523 // A progress line is best effort: losing one does not fail the format.
524 for dir in &self.errors {
525 let _ = writeln!(
526 progress,
527 "I/O error trying to read log directory {}. Ignoring...",
528 dir.display()
529 );
530 }
531 for dir in &self.skipped {
532 let _ = writeln!(
533 progress,
534 "krabka format: {} is already formatted; leaving it alone",
535 dir.display()
536 );
537 }
538 if self.targets.is_empty() {
539 let _ = writeln!(
540 progress,
541 "All of the log directories are already formatted."
542 );
543 return Ok(());
544 }
545 for target in &self.targets {
546 let _ = writeln!(
547 progress,
548 "Formatting {} {} with metadata.version {}.",
549 target.kind.description(),
550 target.dir.display(),
551 self.metadata_version,
552 );
553 self.write(target, fault)
554 .map_err(|e| krabka(EXIT_BOOTSTRAP_FAIL, e))?;
555 }
556 let _ = writeln!(
557 progress,
558 "Formatted {} log director{} with cluster-id {} ({} seed record(s))",
559 self.targets.len(),
560 if self.targets.len() == 1 { "y" } else { "ies" },
561 self.cluster_id,
562 self.records.len(),
563 );
564 Ok(())
565 }
566
567 /// Writes one directory.
568 ///
569 /// Whatever an interrupted run left is removed first, and
570 /// `meta.properties` is written last and published by a rename. Its
571 /// presence thus means "a format ran here to completion". A run that
572 /// fails partway -- an unwritable checkpoint, a killed process -- leaves a
573 /// directory without it, which the next run treats as empty and formats
574 /// again. Written first, the marker would make the next unconditional
575 /// init-container run exit 0 on a directory with no seed checkpoint, no
576 /// voter set, and no bootstrap records.
577 fn write(&self, target: &Target, fault: &Fault) -> Result<(), String> {
578 let dir = &target.dir;
579 remove_partial_output(dir)
580 .map_err(|e| format!("cannot clear log directory {}: {e}", dir.display()))?;
581 fs::create_dir_all(dir)
582 .map_err(|e| format!("cannot create log directory {}: {e}", dir.display()))?;
583 if target.kind.is_dynamic_metadata() {
584 write_dynamic_checkpoint(
585 dir,
586 self.cluster_id,
587 &self.raft_control_records,
588 &self.records,
589 fault,
590 )
591 .map_err(|e| format!("checkpoint failed: {e}"))?;
592 }
593 if target.kind.is_metadata() {
594 write_bootstrap_files(dir, self.cluster_id, &self.records, fault)
595 .map_err(|e| format!("bootstrap failed: {e}"))?;
596 }
597 let meta = MetaProperties {
598 cluster_id: self.cluster_id,
599 node_id: self.node_id,
600 directory_id: Some(target.directory_id),
601 };
602 write_meta_properties(dir, &meta, fault)
603 }
604}