Skip to main content

krabka_restore/
args.rs

1//! The `krabka restore` command line and the parsers for its compound values.
2//!
3//! Every flag is long-form. The target-side flags carry the same names as
4//! `krabka format`, because a restore formats the cluster it writes into and an
5//! operator must not have to learn two spellings for one concept.
6//!
7//! The flags are grouped into three flattened structs, one per stage of the
8//! command: where the archive is, what the target cluster is, and what the
9//! restore keeps. `#[command(flatten)]` keeps every flag top-level, so the
10//! grouping shows up in `--help` as headings and nowhere else.
11//!
12//! This file holds the clap definition itself. The values those flags take, and
13//! the parsers clap calls to build them, live in `value` and `timestamp`, and
14//! the cross-flag checks clap cannot express live in `validate`.
15
16use {
17    self::{
18        timestamp::parse_timestamp,
19        value::{
20            parse_cluster_id, parse_header_pattern, parse_node_id, parse_offset_bound,
21            parse_offset_range, parse_producer_id, parse_regex, parse_topic_name,
22        },
23    },
24    crate::report::ReportFormat,
25    clap::Args,
26    krabka_ids::ProducerId,
27    krabka_metadata::NodeId,
28    regex::Regex,
29    std::path::PathBuf,
30    uuid::Uuid,
31};
32
33mod timestamp;
34mod validate;
35mod value;
36
37#[cfg(test)]
38pub mod test_support;
39#[cfg(test)]
40mod tests;
41
42pub use self::value::{HeaderPattern, OffsetBound, OffsetRange, PartitionRef};
43
44/// Where the archive is, and the restore inputs that sit beside it.
45///
46/// The `--archive-*` flags are the ones `krabka backup` takes, from
47/// [`krabka_object_store::ArchiveArgs`]; the rest are restore's own.
48#[derive(Args, Debug)]
49#[command(next_help_heading = "Archive source")]
50pub struct ArchiveArgs {
51    /// The backend and key prefix the archive is read from.
52    #[command(flatten)]
53    pub location: krabka_object_store::ArchiveArgs,
54
55    /// A broker's `<log.dir>/remote-log-metadata/snapshot`.
56    ///
57    /// The snapshot is authoritative about segment lifecycle state for an
58    /// unauthenticated restore. Authenticated restore uses WORM manifests for
59    /// inventory and requires this snapshot's digest in the signed diskless
60    /// capture before seeding its chain receipts into the restored broker.
61    #[arg(long, value_name = "PATH")]
62    pub rlmm_snapshot: Option<PathBuf>,
63
64    /// A controller `<offset>-<epoch>.checkpoint` metadata snapshot.
65    ///
66    /// Topic configuration, ACLs, client quotas, SCRAM credentials, and
67    /// finalized feature levels are recovered from it. Authenticated restore
68    /// requires its digest to be bound into the signed diskless capture.
69    #[arg(long, value_name = "PATH")]
70    pub metadata_snapshot: Option<PathBuf>,
71
72    /// Committed diskless-WAL projection captured by `krabka backup`.
73    #[arg(long, value_name = "PATH")]
74    pub diskless_wal_capture: Option<PathBuf>,
75
76    /// Trusted WORM manifest signing-key id. Repeat with
77    /// `--worm-public-key` to trust archives spanning a key rotation.
78    #[arg(long, value_name = "ID", requires = "worm_public_key")]
79    pub worm_key_id: Vec<String>,
80
81    /// Raw 32-byte Ed25519 public key paired by position with
82    /// `--worm-key-id`. Supplying a pair enables authenticated restore.
83    #[arg(long, value_name = "PATH", requires = "worm_key_id")]
84    pub worm_public_key: Vec<PathBuf>,
85}
86
87/// The cluster the restore writes.
88///
89/// Every flag here carries the name `krabka format` gives it, and is forwarded
90/// to the formatter unchanged.
91#[derive(Args, Debug)]
92#[command(next_help_heading = "Target cluster")]
93pub struct TargetArgs {
94    /// Directory to restore into. Must be empty or absent.
95    #[arg(long, value_name = "DIR")]
96    pub log_dir: PathBuf,
97
98    /// Cluster id of the restored cluster. Generated if not provided.
99    /// Accepts Kafka's base64 `Uuid` form -- what `Metadata` and
100    /// `DescribeCluster` report (#1042) -- or `java.util.UUID`'s hyphenated
101    /// form.
102    #[arg(long, value_parser = parse_cluster_id)]
103    pub cluster_id: Option<Uuid>,
104
105    /// This node's id, Kafka's `node.id`: an integer from 0 to 2147483647.
106    /// Required: the formatter records it in `meta.properties`, and every
107    /// restored partition names this node as its leader and sole replica.
108    #[arg(long, value_parser = parse_node_id)]
109    pub node_id: Option<NodeId>,
110
111    /// Format the restored node as the sole initial controller voter.
112    #[arg(long, conflicts_with_all = ["initial_controllers", "no_initial_controllers"])]
113    pub standalone: bool,
114
115    /// Explicit initial controllers: `id@host:port:directory-id`,
116    /// comma-separated.
117    #[arg(
118        long,
119        value_delimiter = ',',
120        conflicts_with_all = ["standalone", "no_initial_controllers"]
121    )]
122    pub initial_controllers: Vec<String>,
123
124    /// Format a dynamic controller that will join an existing quorum.
125    #[arg(long, conflicts_with_all = ["standalone", "initial_controllers"])]
126    pub no_initial_controllers: bool,
127
128    /// This node's controller listener, as `host:port`.
129    #[arg(long, value_name = "HOST:PORT")]
130    pub controller_listener: Option<String>,
131}
132
133/// Arguments of an offline point-in-time restore.
134///
135/// The fields are public so a test or an embedding tool can build the struct
136/// directly. A `clap::Args` struct with private fields is reachable only
137/// through an argv, which forces every caller through string formatting.
138#[derive(Args, Debug)]
139pub struct RestoreArgs {
140    /// Where the archive is.
141    #[command(flatten)]
142    pub archive: ArchiveArgs,
143
144    /// Independently pinned WORM chain head, `PARTITION_DIR=64_HEX`.
145    #[arg(long, value_name = "PARTITION_DIR=HEX", requires = "worm_key_id")]
146    pub worm_expect_head: Vec<String>,
147
148    /// The cluster the restore writes.
149    #[command(flatten)]
150    pub target: TargetArgs,
151
152    /// Restore this topic. May be repeated. Every topic the archive holds is
153    /// restored when the flag is absent.
154    #[arg(
155        long = "topic",
156        value_name = "NAME",
157        value_parser = parse_topic_name,
158        help_heading = HEADING_BOUNDS
159    )]
160    pub topic: Vec<String>,
161
162    /// Keep offsets at or below `N` in one partition: `topic:partition=N`.
163    /// May be repeated.
164    #[arg(
165        long,
166        value_name = "TOPIC:PARTITION=N",
167        value_parser = parse_offset_bound,
168        help_heading = HEADING_BOUNDS
169    )]
170    pub to_offset: Vec<OffsetBound>,
171
172    /// Keep records whose timestamp is below this instant. Accepts RFC 3339
173    /// with an explicit zone, or bare epoch milliseconds.
174    #[arg(
175        long,
176        value_name = "RFC3339|EPOCH_MS",
177        value_parser = parse_timestamp,
178        help_heading = HEADING_BOUNDS
179    )]
180    pub to_timestamp: Option<i64>,
181
182    /// Drop records whose key matches this pattern. May be repeated.
183    #[arg(
184        long,
185        value_name = "REGEX",
186        value_parser = parse_regex,
187        help_heading = HEADING_BOUNDS
188    )]
189    pub exclude_key: Vec<Regex>,
190
191    /// Drop records that carry a header matching `NAME=REGEX`. May be
192    /// repeated.
193    #[arg(
194        long,
195        value_name = "NAME=REGEX",
196        value_parser = parse_header_pattern,
197        help_heading = HEADING_BOUNDS
198    )]
199    pub exclude_header: Vec<HeaderPattern>,
200
201    /// Drop records written by this producer id. May be repeated.
202    #[arg(
203        long,
204        value_name = "ID",
205        value_parser = parse_producer_id,
206        help_heading = HEADING_BOUNDS
207    )]
208    pub exclude_producer_id: Vec<ProducerId>,
209
210    /// Drop an offset range in one partition: `topic:partition=A..B`, with `B`
211    /// exclusive. Write `A..=B` to include `B`. May be repeated.
212    #[arg(
213        long,
214        value_name = "TOPIC:PARTITION=A..B",
215        value_parser = parse_offset_range,
216        help_heading = HEADING_BOUNDS
217    )]
218    pub exclude_offset: Vec<OffsetRange>,
219
220    /// Verify, check the format, and report without writing to --log-dir.
221    #[arg(long, help_heading = HEADING_BEHAVIOUR)]
222    pub dry_run: bool,
223
224    /// Report format.
225    #[arg(long, value_enum, default_value = "text", help_heading = HEADING_BEHAVIOUR)]
226    pub report: ReportFormat,
227
228    /// Skip a segment that fails verification instead of stopping. The report
229    /// names every segment that was skipped.
230    #[arg(long, help_heading = HEADING_BEHAVIOUR)]
231    pub continue_on_corrupt: bool,
232}
233
234/// Help headings for the flags that are not in a flattened group.
235const HEADING_BOUNDS: &str = "Selection and bounds";
236const HEADING_BEHAVIOUR: &str = "Behaviour";
237
238/// Partitions named by offset bounds or exclusions.
239pub fn offset_partitions(args: &RestoreArgs) -> impl Iterator<Item = &PartitionRef> {
240    args.to_offset
241        .iter()
242        .map(|bound| &bound.partition)
243        .chain(args.exclude_offset.iter().map(|range| &range.partition))
244}
245
246impl RestoreArgs {
247    /// Whether `topic` is in the restore set.
248    ///
249    /// An empty `--topic` list selects every topic the archive holds.
250    #[must_use]
251    pub fn selects_topic(&self, topic: &str) -> bool {
252        self.topic.is_empty() || self.topic.iter().any(|selected| selected == topic)
253    }
254}