Skip to main content

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}