Skip to main content

krabka_restore/materialize/
format.rs

1//! Formatting the target log directory and seeding it with the topics the
2//! archive scan recovered.
3//!
4//! A restore drives `krabka_format::run_from_args_with_records` in process,
5//! forwarding the target-side flags and handing it one `TopicRecord` per topic
6//! and one `PartitionRecord` per partition, so the restored cluster boots with
7//! its topics already present. This runs once, before any segment is written.
8//! Under `--dry-run` it formats a throwaway directory instead, so the target
9//! stays as it was.
10
11#[cfg(test)]
12use krabka_metadata::FeatureLevelRecord;
13use {
14    crate::{
15        args::RestoreArgs,
16        discover::{ArchiveInventory, load_snapshot},
17        diskless::DisklessNextOffset,
18        error::RestoreError,
19        report::MetadataRestoreReport,
20    },
21    krabka_ids::LeaderEpoch,
22    krabka_metadata::{
23        MetadataRecord, NodeId, PartitionOffsetAdvanceRecord, PartitionRecord, TopicRecord,
24        metadata_version,
25    },
26    krabka_remote_storage::{
27        ArchiveVerifyReport, ChainHead, ChainStamp, RemoteLogSegmentState, WormChainRecord,
28        parse_partition_dir_name,
29    },
30    krabka_remote_storage_topic::{Snapshot, snapshot},
31    std::{
32        collections::{HashMap, HashSet},
33        io::{self, Error, Write},
34        path::{Path, PathBuf},
35    },
36    tokio::fs,
37    uuid::Uuid,
38};
39
40/// The formatter outcome needed by the final restore report.
41pub struct FormatTargetOutcome {
42    /// Cluster id written to the target.
43    pub cluster_id: Uuid,
44    /// Metadata recovered from the optional controller snapshot.
45    pub metadata: MetadataRestoreReport,
46}
47
48#[cfg(test)]
49fn feature_level_records<'a>(excluded: &'a [&str]) -> impl Iterator<Item = MetadataRecord> + 'a {
50    krabka_metadata::feature_registry()
51        .iter()
52        .filter(|registered| !excluded.contains(&registered.name()))
53        .map(|registered| {
54            MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
55                name: registered.name().to_owned(),
56                level: 0,
57            })
58        })
59}
60
61/// Format the target log directory, seed it with the recovered topics, and
62/// return the cluster id it was formatted with.
63///
64/// The formatter generates a cluster id when none is given and does not report
65/// it back, so this passes an explicit `--cluster-id` and keeps it for the
66/// report. An operator who restores a cluster has to know its identity.
67///
68/// # Errors
69///
70/// Returns [`RestoreError::InvalidArgument`] when `--node-id` is absent: every restored partition names the target node as leader and sole replica, and defaulting that identity to node 0 could silently name a node the operator never said exists. Returns [`RestoreError::Format`] when the formatter rejects the target-side flags, and [`RestoreError::Io`] when the target cannot be written.
71pub async fn format_target(
72    args: &RestoreArgs,
73    inventory: &ArchiveInventory,
74) -> Result<FormatTargetOutcome, RestoreError> {
75    format_target_with_snapshot(args, inventory, None, &[]).await
76}
77
78/// Format from retained authenticated bytes when supplied, without reopening the source path.
79pub async fn format_target_with_snapshot(
80    args: &RestoreArgs,
81    inventory: &ArchiveInventory,
82    authenticated_snapshot: Option<&[u8]>,
83    diskless_next_offsets: &[DisklessNextOffset],
84) -> Result<FormatTargetOutcome, RestoreError> {
85    let node_id = args.target.node_id.ok_or_else(|| {
86        RestoreError::InvalidArgument(
87            "--node-id is required: every restored partition names the target node as leader \
88             and sole replica, so a restore cannot default that identity to node 0"
89                .to_owned(),
90        )
91    })?;
92    let cluster_id = args.target.cluster_id.unwrap_or_else(Uuid::new_v4);
93
94    // A dry run formats a throwaway directory instead of the target, so the
95    // formatter still checks the target-side flags and the seed records, and
96    // the target is left exactly as it was. The restore that follows the plan
97    // then gets the same `--log-dir` (#1512).
98    let scratch = if args.dry_run {
99        Some(tempfile::tempdir()?)
100    } else {
101        None
102    };
103    let format_dir = scratch
104        .as_ref()
105        .map_or(args.target.log_dir.as_path(), tempfile::TempDir::path);
106    let mut format_argv = vec![
107        "krabka-format".to_owned(),
108        "--log-dir".to_owned(),
109        format_dir.to_string_lossy().into_owned(),
110        "--cluster-id".to_owned(),
111        cluster_id.to_string(),
112        "--node-id".to_owned(),
113        node_id.to_string(),
114    ];
115    if args.target.standalone {
116        format_argv.push("--standalone".to_owned());
117    }
118    if !args.target.initial_controllers.is_empty() {
119        format_argv.push("--initial-controllers".to_owned());
120        format_argv.push(args.target.initial_controllers.join(","));
121    }
122    if args.target.no_initial_controllers {
123        format_argv.push("--no-initial-controllers".to_owned());
124    }
125    if let Some(listener) = &args.target.controller_listener {
126        format_argv.push("--controller-listener".to_owned());
127        format_argv.push(listener.clone());
128    }
129
130    let snapshot_records = read_metadata_snapshot(
131        args.archive.metadata_snapshot.as_deref(),
132        authenticated_snapshot,
133    )
134    .await?;
135    let SeededMetadata {
136        records: extra,
137        report: metadata,
138    } = seed_metadata_records(
139        inventory,
140        node_id,
141        args.archive.metadata_snapshot.clone(),
142        snapshot_records.as_deref(),
143        diskless_next_offsets,
144    )?;
145    // stdout is the report's: `--report json` piped to `jq` must hold the
146    // report alone, so the formatter's progress lines go to stderr. A dry run
147    // drops them, because it formats nothing the operator keeps.
148    let (mut stderr, mut sink) = (io::stderr(), io::sink());
149    let progress: &mut (dyn Write + Send) = if args.dry_run { &mut sink } else { &mut stderr };
150    let code = krabka_format::run_from_args_with_records_to(format_argv, extra, progress).await;
151    if code == 0 {
152        Ok(FormatTargetOutcome {
153            cluster_id,
154            metadata,
155        })
156    } else {
157        Err(RestoreError::Format { code })
158    }
159}
160
161/// Seed the restored broker's local RLMM cache while resetting its Kafka
162/// cursors for the new `__remote_log_metadata` topic.
163///
164/// # Errors
165/// Returns [`RestoreError`] when the source snapshot cannot be read, its
166/// authenticated chain tips cannot be established, or the target snapshot
167/// cannot be written.
168pub fn seed_rlmm_snapshot(
169    args: &RestoreArgs,
170    authenticated: Option<&ArchiveVerifyReport>,
171) -> Result<(), RestoreError> {
172    seed_rlmm_snapshot_from(args, authenticated, None)
173}
174
175/// Seed from the decoded authenticated snapshot when supplied, without reopening its path.
176pub fn seed_rlmm_snapshot_from(
177    args: &RestoreArgs,
178    authenticated: Option<&ArchiveVerifyReport>,
179    authenticated_snapshot: Option<&Snapshot>,
180) -> Result<(), RestoreError> {
181    let Some(path) = args.archive.rlmm_snapshot.as_ref() else {
182        return Ok(());
183    };
184    let mut snapshot = match authenticated_snapshot {
185        Some(snapshot) => snapshot.clone(),
186        None => load_snapshot(path)?,
187    };
188    snapshot.committed_offsets.fill(-1);
189    snapshot
190        .dump
191        .partitions
192        .retain(|partition| args.selects_topic(&partition.topic_id_partition.topic));
193    if let Some(report) = authenticated {
194        seed_authenticated_chain_tips(&mut snapshot.dump, report)?;
195    }
196    if args.dry_run {
197        return Ok(());
198    }
199    snapshot
200        .write_atomic(
201            &args
202                .target
203                .log_dir
204                .join("remote-log-metadata")
205                .join(snapshot::SNAPSHOT_FILE_NAME),
206        )
207        .map_err(|error| {
208            RestoreError::Io(Error::other(format!(
209                "cannot seed restored RLMM snapshot: {error}"
210            )))
211        })
212}
213
214fn seed_authenticated_chain_tips(
215    dump: &mut krabka_remote_storage::RlmmCacheDump,
216    report: &ArchiveVerifyReport,
217) -> Result<(), RestoreError> {
218    for partition in &mut dump.partitions {
219        let tp = &partition.topic_id_partition;
220        let verified = report
221            .partitions
222            .iter()
223            .find(|candidate| {
224                candidate
225                    .partition_dir
226                    .rsplit('/')
227                    .next()
228                    .and_then(parse_partition_dir_name)
229                    .is_some_and(|parsed| {
230                        parsed.topic_id == tp.topic_id && parsed.partition == tp.partition
231                    })
232            })
233            .and_then(|partition| partition.epochs.last())
234            .ok_or_else(|| RestoreError::Authenticity {
235                reason: format!(
236                    "RLMM snapshot partition {}-{} has no authenticated WORM chain tip",
237                    tp.topic, tp.partition
238                ),
239            })?;
240        let latest = partition
241            .segments
242            .iter_mut()
243            .filter(|segment| {
244                !matches!(
245                    segment.state(),
246                    RemoteLogSegmentState::DeleteSegmentStarted
247                        | RemoteLogSegmentState::DeleteSegmentFinished
248                )
249            })
250            .max_by_key(|segment| segment.start_offset())
251            .ok_or_else(|| RestoreError::Authenticity {
252                reason: format!(
253                    "RLMM snapshot partition {}-{} has no live segment for its authenticated WORM chain tip",
254                    tp.topic, tp.partition
255                ),
256            })?;
257        let chain_record = WormChainRecord::request(ChainStamp {
258            epoch_id: verified.epoch_id,
259            seq: verified.last_seq,
260            prev_head: ChainHead::GENESIS,
261        })
262        .with_head(verified.head)
263        .to_custom_metadata()
264        .map_err(|error| RestoreError::Authenticity {
265            reason: format!(
266                "RLMM snapshot partition {}-{} WORM chain tip does not encode: {error}",
267                tp.topic, tp.partition
268            ),
269        })?;
270        *latest = latest.clone().with_custom_metadata(chain_record);
271    }
272    Ok(())
273}
274
275async fn read_metadata_snapshot(
276    path: Option<&Path>,
277    authenticated_snapshot: Option<&[u8]>,
278) -> Result<Option<Vec<MetadataRecord>>, RestoreError> {
279    let Some(path) = path else {
280        return Ok(None);
281    };
282    let loaded;
283    let bytes = if let Some(bytes) = authenticated_snapshot {
284        bytes
285    } else {
286        loaded = fs::read(path).await.map_err(|error| {
287            RestoreError::Io(Error::new(
288                error.kind(),
289                format!("cannot read metadata snapshot {}: {error}", path.display()),
290            ))
291        })?;
292        &loaded
293    };
294    krabka_raft::deserialize_metadata_snapshot(bytes)
295        .map(Some)
296        .map_err(|error| RestoreError::MetadataSnapshot {
297            path: path.to_path_buf(),
298            reason: error.to_string(),
299        })
300}
301
302/// The records a restore seeds into the target formatter, with the report of what they restore.
303struct SeededMetadata {
304    records: Vec<MetadataRecord>,
305    report: MetadataRestoreReport,
306}
307
308/// How many partitions the archive holds for one topic, and the topic's id.
309#[derive(Debug, Clone, Copy)]
310struct TopicTally {
311    topic_id: Uuid,
312    partitions: i32,
313}
314
315/// Build the topic and partition records a restore seeds into the target formatter, from what the archive scan recovered.
316///
317/// Every topic's [`MetadataRecord::V1Topic`] precedes every [`MetadataRecord::V1Partition`], which is the ordering `krabka_format::run_with_records`'s own doc requires: a `MetadataImage` derives a topic's partition count from the partition records that apply after it, so a partition can only follow its own topic.
318///
319///
320/// Each diskless partition in `diskless_next_offsets` then gets one [`MetadataRecord::V1PartitionOffsetAdvance`] that starts its committed next offset at its materialized end. That is the record a metadata snapshot carries a diskless partition's next offset in, and the one the write path appends, so every 1.x broker reads it (#1524).
321///
322/// Pulled out as a pure function, separate from [`format_target`]'s formatter call, so a test can check exactly what gets seeded without running the formatter at all.
323fn seed_metadata_records(
324    inventory: &ArchiveInventory,
325    node_id: NodeId,
326    snapshot: Option<PathBuf>,
327    snapshot_records: Option<&[MetadataRecord]>,
328    diskless_next_offsets: &[DisklessNextOffset],
329) -> Result<SeededMetadata, RestoreError> {
330    let mut topic_order: Vec<&str> = Vec::new();
331    let mut topics: HashMap<&str, TopicTally> = HashMap::new();
332    for entry in &inventory.partitions {
333        let topic = entry.partition.topic.as_str();
334        let counted = topics.entry(topic).or_insert_with(|| {
335            topic_order.push(topic);
336            TopicTally {
337                topic_id: entry.partition.topic_id,
338                partitions: 0,
339            }
340        });
341        counted.partitions += 1;
342    }
343
344    let mut metadata = MetadataRestoreReport {
345        snapshot,
346        topic_configs: 0,
347        access_control_entries: 0,
348        client_quotas: 0,
349        scram_credentials: 0,
350        feature_levels: 0,
351        topics_without_configuration: Vec::new(),
352    };
353    let mut features = Vec::new();
354    let mut feature_epoch = Vec::new();
355    let mut topic_configs = Vec::new();
356    let mut scram = Vec::new();
357    let mut acls = Vec::new();
358    let mut quotas = Vec::new();
359    let snapshot_topics: HashMap<&str, Uuid> = snapshot_records
360        .into_iter()
361        .flatten()
362        .filter_map(|record| match record {
363            MetadataRecord::V1Topic(topic) => Some((topic.name.as_str(), topic.topic_id)),
364            _ => None,
365        })
366        .collect();
367    // A restored topic whose configuration cannot come back: every topic when
368    // there is no snapshot, and otherwise each one the snapshot does not hold
369    // under the archive's topic id. The format-time checkpoint holds no topic
370    // at all, so a restore from it names every topic here rather than none
371    // (#1514).
372    metadata.topics_without_configuration = topic_order
373        .iter()
374        .filter(|topic| {
375            snapshot_records.is_none()
376                || snapshot_topics.get(**topic) != topics.get(**topic).map(|tally| &tally.topic_id)
377        })
378        .map(|topic| (*topic).to_owned())
379        .collect();
380    let mut snapshot_features = HashSet::new();
381    for record in snapshot_records.into_iter().flatten() {
382        match record {
383            MetadataRecord::V1FeatureLevel(feature) => {
384                metadata.feature_levels += 1;
385                snapshot_features.insert(feature.name.as_str());
386                features.push(record.clone());
387            }
388            MetadataRecord::V1FeaturesEpoch(_) => feature_epoch.push(record.clone()),
389            MetadataRecord::V1TopicConfig(config) => {
390                if let Some(TopicTally {
391                    topic_id: archive_topic_id,
392                    ..
393                }) = topics.get(config.topic.as_str())
394                {
395                    match snapshot_topics.get(config.topic.as_str()) {
396                        Some(snapshot_topic_id) if snapshot_topic_id == archive_topic_id => {
397                            metadata.topic_configs += 1;
398                            topic_configs.push(record.clone());
399                        }
400                        snapshot_topic_id => {
401                            return Err(RestoreError::MetadataSnapshotTopicMismatch {
402                                topic: config.topic.clone(),
403                                archive_topic_id: *archive_topic_id,
404                                snapshot_topic_id: snapshot_topic_id
405                                    .map_or_else(|| "absent".to_owned(), ToString::to_string),
406                            });
407                        }
408                    }
409                }
410            }
411            MetadataRecord::V1ScramCredential(_) => {
412                metadata.scram_credentials += 1;
413                scram.push(record.clone());
414            }
415            MetadataRecord::V1AccessControlEntry(_) => {
416                metadata.access_control_entries += 1;
417                acls.push(record.clone());
418            }
419            MetadataRecord::V1ClientQuota(_) => {
420                metadata.client_quotas += 1;
421                quotas.push(record.clone());
422            }
423            _ => {}
424        }
425    }
426    if snapshot_records.is_some() {
427        // A feature that the snapshot does not finalize is disabled in the
428        // restored cluster. `metadata.version` is never disabled: Kafka has no
429        // `MetadataVersion` at level 0, and the active controller refuses
430        // bootstrap records that remove it. So a snapshot without one keeps
431        // the level of the format.
432        features.extend(
433            krabka_metadata::feature_registry()
434                .iter()
435                .filter(|feature| {
436                    !matches!(
437                        feature.name(),
438                        metadata_version::KRAFT_VERSION_FEATURE
439                            | metadata_version::METADATA_VERSION_FEATURE
440                    ) && !snapshot_features.contains(feature.name())
441                })
442                .map(|feature| {
443                    MetadataRecord::V1FeatureLevel(krabka_metadata::FeatureLevelRecord {
444                        name: feature.name().to_owned(),
445                        level: 0,
446                    })
447                }),
448        );
449    }
450
451    let mut records = Vec::with_capacity(
452        features.len()
453            + feature_epoch.len()
454            + topic_order.len()
455            + inventory.partitions.len()
456            + topic_configs.len()
457            + scram.len()
458            + acls.len()
459            + quotas.len(),
460    );
461    records.append(&mut features);
462    records.append(&mut feature_epoch);
463    for topic in &topic_order {
464        let TopicTally {
465            topic_id,
466            partitions,
467        } = topics.get(topic).copied().ok_or_else(|| {
468            RestoreError::Integrity(format!("topic {topic} has no partition tally"))
469        })?;
470        records.push(MetadataRecord::V1Topic(TopicRecord {
471            name: (*topic).to_owned(),
472            topic_id,
473            partitions,
474            replication_factor: 1,
475        }));
476    }
477    for entry in &inventory.partitions {
478        records.push(MetadataRecord::V1Partition(PartitionRecord {
479            topic: entry.partition.topic.clone(),
480            partition: entry.partition.partition,
481            leader: node_id,
482            replicas: vec![node_id],
483            isr: vec![node_id],
484            leader_epoch: LeaderEpoch(0),
485            adding_replicas: Vec::new(),
486            removing_replicas: Vec::new(),
487            directories: Vec::new(),
488            // KIP-631: 0 on creation. `PartitionRecord::default()`'s
489            // `partition_epoch` is -1, the on-disk deserialization default for
490            // a record written before this field existed; a freshly restored
491            // partition is neither, so this is set explicitly rather than
492            // pulled from `..Default::default()`.
493            partition_epoch: 0,
494        }));
495    }
496    // KRaft is a diskless partition's offset authority: its first write is
497    // assigned the committed next offset, so it starts where the restored log
498    // ends. A partition restored empty from offset 0 needs no record.
499    records.extend(
500        diskless_next_offsets
501            .iter()
502            .filter(|next| next.next_offset > 0)
503            .map(|next| {
504                MetadataRecord::V1PartitionOffsetAdvance(PartitionOffsetAdvanceRecord {
505                    topic: next.topic.clone(),
506                    partition: next.partition,
507                    count: next.next_offset,
508                })
509            }),
510    );
511    records.append(&mut topic_configs);
512    records.append(&mut scram);
513    records.append(&mut acls);
514    records.append(&mut quotas);
515    Ok(SeededMetadata {
516        records,
517        report: metadata,
518    })
519}
520
521#[cfg(test)]
522mod tests {
523    use {
524        super::*,
525        crate::{
526            authenticity::authenticate_captured_snapshot,
527            discover::UnrecognizedKeys,
528            error,
529            materialize::test_support::{args_from, partition_inventory},
530        },
531        assert2::check,
532        krabka_metadata::{
533            AclEntry, AclOperation, ClientQuotaRecord, FeatureLevelRecord, FeaturesEpochRecord,
534            MetadataImage, PatternType, PermissionType, QuotaEntity, ResourceType,
535            ScramCredentialRecord, TopicConfigRecord,
536        },
537        krabka_remote_storage::{
538            PartitionDump, RemotePartitionDeleteState, RlmmCacheDump, Sha256Digest,
539            TopicIdPartition,
540        },
541        krabka_security::SaslMechanism,
542        std::{fs, slice},
543    };
544
545    fn delete_marked_dump() -> RlmmCacheDump {
546        RlmmCacheDump {
547            partitions: vec![PartitionDump {
548                topic_id_partition: TopicIdPartition::new(uuid::Uuid::from_u128(7), "orders", 0),
549                segments: Vec::new(),
550                delete_state: Some(RemotePartitionDeleteState::DeletePartitionMarked),
551            }],
552        }
553    }
554
555    #[test]
556    fn seed_metadata_records_emits_every_topic_before_any_partition() {
557        let orders_id = Uuid::new_v4();
558        let payments_id = Uuid::new_v4();
559        let inventory = ArchiveInventory {
560            partitions: vec![
561                partition_inventory("orders", orders_id, 0),
562                partition_inventory("orders", orders_id, 1),
563                partition_inventory("payments", payments_id, 0),
564            ],
565            unrecognized: UnrecognizedKeys::default(),
566        };
567
568        let SeededMetadata {
569            records,
570            report: metadata,
571        } = seed_metadata_records(&inventory, NodeId(7), None, None, &[]).expect("seed metadata");
572
573        let partition_record = |topic: &str, partition: i32| {
574            MetadataRecord::V1Partition(PartitionRecord {
575                topic: topic.to_owned(),
576                partition,
577                leader: NodeId(7),
578                replicas: vec![NodeId(7)],
579                isr: vec![NodeId(7)],
580                leader_epoch: LeaderEpoch(0),
581                adding_replicas: Vec::new(),
582                removing_replicas: Vec::new(),
583                directories: Vec::new(),
584                partition_epoch: 0,
585            })
586        };
587        let expected = vec![
588            MetadataRecord::V1Topic(TopicRecord {
589                name: "orders".to_owned(),
590                topic_id: orders_id,
591                partitions: 2,
592                replication_factor: 1,
593            }),
594            MetadataRecord::V1Topic(TopicRecord {
595                name: "payments".to_owned(),
596                topic_id: payments_id,
597                partitions: 1,
598                replication_factor: 1,
599            }),
600            partition_record("orders", 0),
601            partition_record("orders", 1),
602            partition_record("payments", 0),
603        ];
604        check!(records == expected);
605        check!(
606            metadata.topics_without_configuration
607                == vec!["orders".to_owned(), "payments".to_owned()]
608        );
609    }
610
611    /// A metadata snapshot that does not hold a restored topic, under the
612    /// archive's topic id, cannot restore its configuration, and the report
613    /// names it. The format-time checkpoint, which holds no topic, names every
614    /// restored topic (#1514).
615    #[yare::parameterized(
616        format_time_checkpoint = { &[], &["orders", "payments"] },
617        knows_one_topic = { &["orders"], &["payments"] },
618        knows_every_topic = { &["orders", "payments"], &[] },
619        knows_a_name_under_another_id = { &["orders@other", "payments"], &["orders"] },
620    )]
621    fn a_snapshot_names_each_restored_topic_it_does_not_know(
622        snapshot_topics: &[&str],
623        expected: &[&str],
624    ) {
625        let ids: HashMap<&str, Uuid> = HashMap::from([
626            ("orders", Uuid::from_u128(1)),
627            ("payments", Uuid::from_u128(2)),
628        ]);
629        let inventory = ArchiveInventory {
630            partitions: vec![
631                partition_inventory("orders", ids["orders"], 0),
632                partition_inventory("payments", ids["payments"], 0),
633            ],
634            unrecognized: UnrecognizedKeys::default(),
635        };
636        let snapshot: Vec<MetadataRecord> = snapshot_topics
637            .iter()
638            .map(|topic| {
639                let (name, topic_id) = match topic.split_once('@') {
640                    Some((name, _)) => (name, Uuid::from_u128(99)),
641                    None => (*topic, ids[topic]),
642                };
643                MetadataRecord::V1Topic(TopicRecord {
644                    name: name.to_owned(),
645                    topic_id,
646                    partitions: 1,
647                    replication_factor: 1,
648                })
649            })
650            .collect();
651
652        let SeededMetadata { report, .. } = seed_metadata_records(
653            &inventory,
654            NodeId(7),
655            Some(PathBuf::from("cluster-metadata.checkpoint")),
656            Some(&snapshot),
657            &[],
658        )
659        .expect("seed metadata");
660
661        check!(report.topics_without_configuration == expected);
662    }
663
664    /// Each restored diskless partition's committed next offset starts at its
665    /// materialized end, after every partition record, so the restored
666    /// cluster's first write to it is assigned that end (#1524). A partition
667    /// restored empty from offset 0 needs no record: an absent next offset is
668    /// 0.
669    #[yare::parameterized(
670        none = { &[], &[] },
671        one_partition = { &[("orders", 1, 712)], &[("orders", 1, 712)] },
672        empty_partition = { &[("orders", 0, 0), ("orders", 1, 5)], &[("orders", 1, 5)] },
673    )]
674    fn seed_metadata_records_starts_each_diskless_partition_at_its_materialized_end(
675        next_offsets: &[(&str, i32, i64)],
676        advances: &[(&str, i32, i64)],
677    ) {
678        let orders_id = Uuid::new_v4();
679        let inventory = ArchiveInventory {
680            partitions: vec![
681                partition_inventory("orders", orders_id, 0),
682                partition_inventory("orders", orders_id, 1),
683            ],
684            unrecognized: UnrecognizedKeys::default(),
685        };
686        let next_offsets: Vec<DisklessNextOffset> = next_offsets
687            .iter()
688            .map(|(topic, partition, next_offset)| DisklessNextOffset {
689                topic: (*topic).to_owned(),
690                partition: *partition,
691                next_offset: *next_offset,
692            })
693            .collect();
694
695        let SeededMetadata { records, .. } =
696            seed_metadata_records(&inventory, NodeId(7), None, None, &next_offsets)
697                .expect("seed metadata");
698
699        let expected: Vec<MetadataRecord> = advances
700            .iter()
701            .map(|(topic, partition, count)| {
702                MetadataRecord::V1PartitionOffsetAdvance(PartitionOffsetAdvanceRecord {
703                    topic: (*topic).to_owned(),
704                    partition: *partition,
705                    count: *count,
706                })
707            })
708            .collect();
709        let last_partition = records
710            .iter()
711            .rposition(|record| matches!(record, MetadataRecord::V1Partition(_)))
712            .expect("partition records");
713        check!(records[last_partition + 1..] == expected);
714    }
715
716    #[test]
717    fn restored_rlmm_snapshot_keeps_cached_state_and_replays_the_new_topic_from_zero() {
718        let source = tempfile::tempdir().expect("source tempdir");
719        let target = tempfile::tempdir().expect("target tempdir");
720        let source_path = source.path().join("snapshot");
721        let dump = delete_marked_dump();
722        Snapshot {
723            committed_offsets: vec![19],
724            dump: dump.clone(),
725        }
726        .write_atomic(&source_path)
727        .expect("write source snapshot");
728        let args = args_from(
729            &["--rlmm-snapshot", &source_path.display().to_string()],
730            target.path(),
731        );
732
733        seed_rlmm_snapshot(&args, None).expect("seed restored snapshot");
734
735        let restored = Snapshot::load(
736            &target
737                .path()
738                .join("remote-log-metadata")
739                .join(snapshot::SNAPSHOT_FILE_NAME),
740        )
741        .expect("read restored snapshot")
742        .expect("restored snapshot exists");
743        check!(restored.committed_offsets == vec![-1]);
744        check!(restored.dump == dump);
745    }
746
747    #[tokio::test]
748    async fn authenticated_snapshots_are_consumed_after_the_source_paths_change() {
749        let source = tempfile::tempdir().expect("source");
750        let target = tempfile::tempdir().expect("target");
751        let metadata_path = source.path().join("metadata.checkpoint");
752        let rlmm_path = source.path().join("snapshot");
753        let mut image = MetadataImage::default();
754        image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
755            name: "metadata.version".to_owned(),
756            level: 25,
757        }));
758        let metadata_bytes =
759            krabka_raft::serialize_metadata_snapshot(&image, 42).expect("metadata");
760        let snapshot = Snapshot {
761            committed_offsets: vec![19],
762            dump: delete_marked_dump(),
763        };
764        let rlmm_bytes = snapshot.encode().unwrap();
765        fs::write(&metadata_path, &metadata_bytes).unwrap();
766        fs::write(&rlmm_path, &rlmm_bytes).unwrap();
767        let retained_metadata = authenticate_captured_snapshot(
768            Some(&metadata_path),
769            Some(Sha256Digest::of(&metadata_bytes)),
770            true,
771            "metadata",
772        )
773        .await
774        .unwrap()
775        .unwrap();
776        let retained_rlmm = authenticate_captured_snapshot(
777            Some(&rlmm_path),
778            Some(Sha256Digest::of(&rlmm_bytes)),
779            true,
780            "RLMM",
781        )
782        .await
783        .unwrap()
784        .unwrap();
785        fs::write(&metadata_path, b"changed metadata").unwrap();
786        fs::write(&rlmm_path, b"changed RLMM").unwrap();
787        check!(
788            read_metadata_snapshot(Some(&metadata_path), Some(&retained_metadata))
789                .await
790                .unwrap()
791                == Some(krabka_raft::deserialize_metadata_snapshot(&metadata_bytes).unwrap())
792        );
793        let args = args_from(
794            &["--rlmm-snapshot", &rlmm_path.display().to_string()],
795            target.path(),
796        );
797        seed_rlmm_snapshot_from(
798            &args,
799            None,
800            Some(&Snapshot::decode(&retained_rlmm).unwrap()),
801        )
802        .unwrap();
803        let restored = Snapshot::load(&target.path().join("remote-log-metadata/snapshot"))
804            .unwrap()
805            .unwrap();
806        check!(
807            restored
808                == Snapshot {
809                    committed_offsets: vec![-1],
810                    ..snapshot
811                }
812        );
813    }
814
815    #[test]
816    fn authenticated_dry_run_still_validates_the_rlmm_snapshot() {
817        let source = tempfile::tempdir().expect("source tempdir");
818        let target = tempfile::tempdir().expect("target tempdir");
819        let source_path = source.path().join("snapshot");
820        fs::write(&source_path, b"not a snapshot").expect("write malformed snapshot");
821        let args = args_from(
822            &[
823                "--rlmm-snapshot",
824                &source_path.display().to_string(),
825                "--dry-run",
826            ],
827            target.path(),
828        );
829
830        check!(seed_rlmm_snapshot(&args, None).is_err());
831        check!(!target.path().join("remote-log-metadata/snapshot").exists());
832    }
833
834    #[test]
835    fn seed_metadata_records_restores_supported_snapshot_families_in_dependency_order() {
836        let topic_id = Uuid::new_v4();
837        let inventory = ArchiveInventory {
838            partitions: vec![partition_inventory("orders", topic_id, 0)],
839            unrecognized: UnrecognizedKeys::default(),
840        };
841        let feature = MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
842            name: "metadata.version".to_owned(),
843            level: 25,
844        });
845        let epoch = MetadataRecord::V1FeaturesEpoch(FeaturesEpochRecord { epoch: 7 });
846        let config = MetadataRecord::V1TopicConfig(TopicConfigRecord {
847            topic: "orders".to_owned(),
848            overrides: maplit::btreemap! {"cleanup.policy".to_owned() => "compact".to_owned()},
849        });
850        let ignored_config = MetadataRecord::V1TopicConfig(TopicConfigRecord {
851            topic: "not-restored".to_owned(),
852            overrides: maplit::btreemap! {"retention.ms".to_owned() => "1".to_owned()},
853        });
854        let scram = MetadataRecord::V1ScramCredential(ScramCredentialRecord {
855            user: "alice".to_owned(),
856            mechanism: SaslMechanism::ScramSha256,
857            salt: vec![1; 16],
858            stored_key: vec![2; 32],
859            server_key: vec![3; 32],
860            iterations: 4096,
861        });
862        let acl = MetadataRecord::V1AccessControlEntry(AclEntry {
863            resource_type: ResourceType::Topic,
864            resource_name: "orders".to_owned(),
865            pattern_type: PatternType::Literal,
866            principal: "User:alice".to_owned(),
867            host: "*".to_owned(),
868            operation: AclOperation::Read,
869            permission_type: PermissionType::Allow,
870        });
871        let quota = MetadataRecord::V1ClientQuota(ClientQuotaRecord {
872            entity: vec![QuotaEntity {
873                entity_type: "user".to_owned(),
874                entity_name: Some("alice".to_owned()),
875            }],
876            config_key: "producer_byte_rate".to_owned(),
877            config_value: Some(1024.0),
878        });
879        let snapshot_records = vec![
880            quota.clone(),
881            ignored_config,
882            acl.clone(),
883            scram.clone(),
884            config.clone(),
885            MetadataRecord::V1Topic(TopicRecord {
886                name: "orders".to_owned(),
887                topic_id,
888                partitions: 1,
889                replication_factor: 1,
890            }),
891            epoch.clone(),
892            feature.clone(),
893        ];
894        let snapshot = PathBuf::from("/backup/metadata.checkpoint");
895
896        let SeededMetadata { records, report } = seed_metadata_records(
897            &inventory,
898            NodeId(7),
899            Some(snapshot.clone()),
900            Some(&snapshot_records),
901            &[],
902        )
903        .expect("seed metadata");
904
905        let mut expected = vec![feature];
906        expected.extend(feature_level_records(&[
907            "metadata.version",
908            metadata_version::KRAFT_VERSION_FEATURE,
909        ]));
910        expected.extend([
911            epoch,
912            MetadataRecord::V1Topic(TopicRecord {
913                name: "orders".to_owned(),
914                topic_id,
915                partitions: 1,
916                replication_factor: 1,
917            }),
918            MetadataRecord::V1Partition(PartitionRecord {
919                topic: "orders".to_owned(),
920                partition: 0,
921                leader: NodeId(7),
922                replicas: vec![NodeId(7)],
923                isr: vec![NodeId(7)],
924                leader_epoch: LeaderEpoch(0),
925                adding_replicas: vec![],
926                removing_replicas: vec![],
927                directories: vec![],
928                partition_epoch: 0,
929            }),
930            config,
931            scram,
932            acl,
933            quota,
934        ]);
935        check!(records == expected);
936        check!(
937            report
938                == MetadataRestoreReport {
939                    snapshot: Some(snapshot),
940                    topic_configs: 1,
941                    access_control_entries: 1,
942                    client_quotas: 1,
943                    scram_credentials: 1,
944                    feature_levels: 1,
945                    topics_without_configuration: vec![],
946                }
947        );
948    }
949
950    /// A feature that the snapshot does not finalize is disabled, except
951    /// `metadata.version`: no `MetadataVersion` has level 0, so a snapshot
952    /// without one keeps the level the format gives.
953    #[test]
954    fn a_snapshot_without_a_metadata_version_keeps_the_formatted_level() {
955        let inventory = ArchiveInventory {
956            partitions: Vec::new(),
957            unrecognized: UnrecognizedKeys::default(),
958        };
959        let group_version = MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
960            name: "group.version".to_owned(),
961            level: 1,
962        });
963
964        let SeededMetadata { records, .. } = seed_metadata_records(
965            &inventory,
966            NodeId(7),
967            None,
968            Some(slice::from_ref(&group_version)),
969            &[],
970        )
971        .expect("seed metadata");
972
973        let mut expected = vec![group_version];
974        expected.extend(feature_level_records(&[
975            "metadata.version",
976            "group.version",
977            metadata_version::KRAFT_VERSION_FEATURE,
978        ]));
979        check!(records == expected);
980    }
981
982    #[test]
983    fn snapshot_topic_config_must_match_the_archived_topic_id() {
984        let archive_topic_id = Uuid::new_v4();
985        let snapshot_topic_id = Uuid::new_v4();
986        let inventory = ArchiveInventory {
987            partitions: vec![partition_inventory("orders", archive_topic_id, 0)],
988            unrecognized: UnrecognizedKeys::default(),
989        };
990        let snapshot_records = vec![
991            MetadataRecord::V1Topic(TopicRecord {
992                name: "orders".to_owned(),
993                topic_id: snapshot_topic_id,
994                partitions: 1,
995                replication_factor: 1,
996            }),
997            MetadataRecord::V1TopicConfig(TopicConfigRecord {
998                topic: "orders".to_owned(),
999                overrides: maplit::btreemap! {
1000                    "cleanup.policy".to_owned() => "delete".to_owned()
1001                },
1002            }),
1003        ];
1004
1005        let result = seed_metadata_records(
1006            &inventory,
1007            NodeId(7),
1008            Some("metadata.checkpoint".into()),
1009            Some(&snapshot_records),
1010            &[],
1011        );
1012
1013        check!(matches!(
1014            result,
1015            Err(RestoreError::MetadataSnapshotTopicMismatch {
1016                topic,
1017                archive_topic_id: archive,
1018                snapshot_topic_id: snapshot,
1019            }) if topic == "orders"
1020                && archive == archive_topic_id
1021                && snapshot == snapshot_topic_id.to_string()
1022        ));
1023
1024        let missing_topic = seed_metadata_records(
1025            &inventory,
1026            NodeId(7),
1027            Some("metadata.checkpoint".into()),
1028            Some(&snapshot_records[1..]),
1029            &[],
1030        );
1031        check!(matches!(
1032            missing_topic,
1033            Err(RestoreError::MetadataSnapshotTopicMismatch {
1034                snapshot_topic_id,
1035                ..
1036            }) if snapshot_topic_id == "absent"
1037        ));
1038    }
1039
1040    #[tokio::test]
1041    async fn unreadable_metadata_snapshot_is_an_io_failure() {
1042        let missing = tempfile::tempdir()
1043            .expect("temp dir")
1044            .path()
1045            .join("missing.checkpoint");
1046
1047        let result = read_metadata_snapshot(Some(&missing), None).await;
1048
1049        let error = result.unwrap_err();
1050        check!(error.exit_code() == error::EXIT_MATERIALIZE);
1051        check!(matches!(error, RestoreError::Io(_)));
1052    }
1053
1054    #[tokio::test]
1055    async fn corrupt_metadata_snapshot_is_an_integrity_failure() {
1056        let dir = tempfile::tempdir().expect("temp dir");
1057        let path = dir.path().join("metadata.checkpoint");
1058        fs::write(&path, b"not a metadata snapshot").expect("write corrupt snapshot");
1059
1060        let error = read_metadata_snapshot(Some(&path), None).await.unwrap_err();
1061
1062        check!(error.exit_code() == error::EXIT_INTEGRITY);
1063        check!(matches!(
1064            error,
1065            RestoreError::MetadataSnapshot {
1066                path: error_path,
1067                ..
1068            } if error_path == path
1069        ));
1070    }
1071
1072    #[tokio::test]
1073    async fn format_target_requires_node_id() {
1074        let target = tempfile::tempdir().expect("tempdir");
1075        let args = args_from(&[], target.path());
1076        let inventory = ArchiveInventory {
1077            partitions: vec![partition_inventory("orders", Uuid::new_v4(), 0)],
1078            unrecognized: UnrecognizedKeys::default(),
1079        };
1080
1081        let result = format_target(&args, &inventory).await;
1082        check!(matches!(result, Err(RestoreError::InvalidArgument(_))));
1083    }
1084
1085    #[tokio::test]
1086    async fn format_target_formats_the_target_and_returns_the_cluster_id() {
1087        let target = tempfile::tempdir().expect("tempdir");
1088        let args = args_from(
1089            &[
1090                "--node-id",
1091                "1",
1092                "--standalone",
1093                "--controller-listener",
1094                "127.0.0.1:9093",
1095            ],
1096            target.path(),
1097        );
1098        let topic_id = Uuid::new_v4();
1099        let inventory = ArchiveInventory {
1100            partitions: vec![
1101                partition_inventory("orders", topic_id, 0),
1102                partition_inventory("orders", topic_id, 1),
1103            ],
1104            unrecognized: UnrecognizedKeys::default(),
1105        };
1106
1107        let outcome = format_target(&args, &inventory)
1108            .await
1109            .expect("format_target");
1110        check!(
1111            args.target.cluster_id.is_none() || Some(outcome.cluster_id) == args.target.cluster_id
1112        );
1113        check!(target.path().join("bootstrap.json").exists());
1114        check!(target.path().join("bootstrap.records.bin").exists());
1115    }
1116
1117    #[tokio::test]
1118    async fn format_target_honors_an_explicit_cluster_id() {
1119        let target = tempfile::tempdir().expect("tempdir");
1120        let fixed = Uuid::new_v4();
1121        let args = args_from(
1122            &[
1123                "--node-id",
1124                "1",
1125                "--standalone",
1126                "--controller-listener",
1127                "127.0.0.1:9093",
1128                "--cluster-id",
1129                &fixed.to_string(),
1130            ],
1131            target.path(),
1132        );
1133        let inventory = ArchiveInventory {
1134            partitions: vec![partition_inventory("orders", Uuid::new_v4(), 0)],
1135            unrecognized: UnrecognizedKeys::default(),
1136        };
1137
1138        let outcome = format_target(&args, &inventory)
1139            .await
1140            .expect("format_target");
1141        check!(outcome.cluster_id == fixed);
1142    }
1143}