1#[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
40pub struct FormatTargetOutcome {
42 pub cluster_id: Uuid,
44 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(®istered.name()))
53 .map(|registered| {
54 MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
55 name: registered.name().to_owned(),
56 level: 0,
57 })
58 })
59}
60
61pub 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
78pub 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 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 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
161pub fn seed_rlmm_snapshot(
169 args: &RestoreArgs,
170 authenticated: Option<&ArchiveVerifyReport>,
171) -> Result<(), RestoreError> {
172 seed_rlmm_snapshot_from(args, authenticated, None)
173}
174
175pub 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
302struct SeededMetadata {
304 records: Vec<MetadataRecord>,
305 report: MetadataRestoreReport,
306}
307
308#[derive(Debug, Clone, Copy)]
310struct TopicTally {
311 topic_id: Uuid,
312 partitions: i32,
313}
314
315fn 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 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 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 partition_epoch: 0,
494 }));
495 }
496 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 #[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 #[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 #[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}