1use {
7 crate::{
8 archive::{Archive, ArchiveArgs},
9 capture::{
10 MetadataCheckpoint, RLMM_SNAPSHOT_RELATIVE, capture_id, capture_key, holds_a_topic,
11 newest_metadata_checkpoint,
12 },
13 cli::{CaptureSigningArgs, CaptureTrustArgs, LATEST},
14 connection::SecurityArgs,
15 diskless::capture_projection,
16 error::BackupError,
17 manifest::{
18 Artifact, CAPTURE_ROOT, DISKLESS_WAL_INDEX, GROUP_OFFSETS, MANIFEST, MANIFEST_VERSION,
19 METADATA_CHECKPOINT, Manifest, RLMM_SNAPSHOT, artifact_problem, sha256_hex,
20 },
21 offsets::{GroupOffsetsFile, admin_offsets, commit_outcome, group_offsets},
22 report::{BackupEvent, OperationReport},
23 },
24 krabka_audit::FileEd25519Signer,
25 krabka_client_admin::{
26 AdminClient, AdminError, ConfigResource, DescribeConfigsOptions, TopicMetadata,
27 groups::ListGroupsOptions,
28 },
29 krabka_client_core::security::ClientSecurity,
30 krabka_raft::{deserialize_metadata_snapshot, metadata_snapshot_last_contained_log_timestamp},
31 krabka_remote_storage::{ObjectEntry, Sha256Digest, diskless::capture::DisklessWalCapture},
32 krabka_remote_storage_topic::{KafkaMetadataEventLog, KafkaMetadataLogConfig},
33 std::{
34 collections::{BTreeMap, BTreeSet, HashMap},
35 io::{Error, ErrorKind},
36 path::Path,
37 sync::Arc,
38 time::{self, SystemTime},
39 },
40 tokio::fs,
41};
42
43const MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024;
44const MAX_GROUP_OFFSETS_BYTES: u64 = 64 * 1024 * 1024;
45const MAX_SNAPSHOT_BYTES: u64 = 512 * 1024 * 1024;
46const MAX_DISKLESS_INDEX_BYTES: u64 = 256 * 1024 * 1024;
47
48fn artifact_byte_limit(name: &str) -> u64 {
49 match name {
50 MANIFEST => MAX_MANIFEST_BYTES,
51 GROUP_OFFSETS => MAX_GROUP_OFFSETS_BYTES,
52 METADATA_CHECKPOINT | RLMM_SNAPSHOT => MAX_SNAPSHOT_BYTES,
53 _ => MAX_DISKLESS_INDEX_BYTES,
54 }
55}
56
57fn check_capture_size(name: &str, size: u64, max_bytes: u64) -> Result<(), BackupError> {
58 if size > max_bytes {
59 return Err(BackupError::Io(Error::new(
60 ErrorKind::InvalidData,
61 format!(
62 "capture artifact `{name}` is {size} bytes, exceeds restore limit of {max_bytes} bytes"
63 ),
64 )));
65 }
66 Ok(())
67}
68
69async fn read_capture_file(path: &Path, name: &str) -> Result<Vec<u8>, BackupError> {
70 check_capture_size(
71 name,
72 fs::metadata(path).await?.len(),
73 artifact_byte_limit(name),
74 )?;
75 let bytes = fs::read(path).await?;
76 check_capture_size(name, bytes.len() as u64, artifact_byte_limit(name))?;
78 Ok(bytes)
79}
80
81fn now_ms() -> u64 {
85 u64::try_from(
86 SystemTime::now()
87 .duration_since(time::UNIX_EPOCH)
88 .map_or(0, |elapsed| elapsed.as_millis()),
89 )
90 .unwrap_or(0)
91}
92
93pub async fn capture(
101 log_dir: Option<&Path>,
102 bootstrap_server: Option<&str>,
103 archive: &ArchiveArgs,
104) -> OperationReport<String> {
105 let mut events = Vec::new();
106 let result = capture_secured_inner(
107 log_dir,
108 bootstrap_server,
109 None,
110 &CaptureSigningArgs::default(),
111 archive,
112 &mut events,
113 )
114 .await;
115 OperationReport { result, events }
116}
117
118pub async fn capture_configured(
125 log_dir: Option<&Path>,
126 bootstrap_server: Option<&str>,
127 security: &SecurityArgs,
128 signing: &CaptureSigningArgs,
129 archive: &ArchiveArgs,
130) -> OperationReport<String> {
131 let mut events = Vec::new();
132 let result = async {
133 let client_security = match bootstrap_server {
134 Some(_) => security.load().await?,
135 None => None,
136 };
137 capture_secured_inner(
138 log_dir,
139 bootstrap_server,
140 client_security,
141 signing,
142 archive,
143 &mut events,
144 )
145 .await
146 }
147 .await;
148 OperationReport { result, events }
149}
150
151async fn capture_secured_inner(
152 log_dir: Option<&Path>,
153 bootstrap_server: Option<&str>,
154 security: Option<ClientSecurity>,
155 signing: &CaptureSigningArgs,
156 archive: &ArchiveArgs,
157 events: &mut Vec<BackupEvent>,
158) -> Result<String, BackupError> {
159 capture_secured_at_inner(
160 log_dir,
161 bootstrap_server,
162 security,
163 signing,
164 archive,
165 now_ms(),
166 events,
167 )
168 .await
169}
170
171#[cfg(test)]
172async fn capture_secured_at(
173 log_dir: Option<&Path>,
174 bootstrap_server: Option<&str>,
175 security: Option<ClientSecurity>,
176 signing: &CaptureSigningArgs,
177 archive: &ArchiveArgs,
178 captured_at_ms: u64,
179) -> OperationReport<String> {
180 let mut events = Vec::new();
181 let result = capture_secured_at_inner(
182 log_dir,
183 bootstrap_server,
184 security,
185 signing,
186 archive,
187 captured_at_ms,
188 &mut events,
189 )
190 .await;
191 OperationReport { result, events }
192}
193
194async fn capture_secured_at_inner(
195 log_dir: Option<&Path>,
196 bootstrap_server: Option<&str>,
197 security: Option<ClientSecurity>,
198 signing: &CaptureSigningArgs,
199 archive: &ArchiveArgs,
200 captured_at_ms: u64,
201 events: &mut Vec<BackupEvent>,
202) -> Result<String, BackupError> {
203 let store = Archive::open(archive)?;
204 let id = capture_id(captured_at_ms);
205 let mut artifacts: Vec<Artifact> = Vec::new();
206 let mut absent: Vec<String> = Vec::new();
207 let mut metadata_snapshot_sha256 = None;
208 let mut rlmm_snapshot_sha256 = None;
209 let mut notes: Vec<String> = Vec::new();
210
211 if let Some(log_dir) = log_dir {
212 let checkpoint = match newest_metadata_checkpoint(log_dir) {
217 Some(checkpoint) => {
218 let bytes = read_capture_file(&checkpoint.path, METADATA_CHECKPOINT).await?;
219 refuse_format_time_checkpoint(&checkpoint, &bytes)?;
220 Some((checkpoint, bytes))
221 }
222 None => None,
223 };
224
225 let rlmm = log_dir.join(RLMM_SNAPSHOT_RELATIVE);
226 match read_capture_file(&rlmm, RLMM_SNAPSHOT).await {
227 Ok(bytes) => {
228 rlmm_snapshot_sha256 = Some(Sha256Digest::of(&bytes));
229 artifacts.push(
230 upload(
231 &store,
232 &id,
233 RLMM_SNAPSHOT,
234 &rlmm.display().to_string(),
235 bytes,
236 )
237 .await?,
238 );
239 }
240 Err(BackupError::Io(error)) if error.kind() == ErrorKind::NotFound => {
241 absent.push(format!("{} is absent", rlmm.display()));
242 }
243 Err(error) => return Err(error),
244 }
245
246 match checkpoint {
247 Some((MetadataCheckpoint { path, .. }, bytes)) => {
248 notes.push(checkpoint_note(&bytes, captured_at_ms));
249 metadata_snapshot_sha256 = Some(Sha256Digest::of(&bytes));
250 artifacts.push(
251 upload(
252 &store,
253 &id,
254 METADATA_CHECKPOINT,
255 &path.display().to_string(),
256 bytes,
257 )
258 .await?,
259 );
260 }
261 None => absent.push(format!(
262 "{} holds no controller or observer metadata checkpoint",
263 log_dir.display()
264 )),
265 }
266 }
267
268 if let Some(bootstrap) = bootstrap_server {
269 let offsets = fetch_group_offsets(bootstrap, security.clone()).await?;
270 let bytes = serde_json::to_vec_pretty(&offsets).map_err(|source| BackupError::Json {
271 context: GROUP_OFFSETS.to_owned(),
272 source,
273 })?;
274 let group_offsets_sha256 = Some(Sha256Digest::of(&bytes));
275 artifacts.push(upload(&store, &id, GROUP_OFFSETS, bootstrap, bytes).await?);
276 if let Some(bytes) = capture_diskless_index(
277 &store,
278 bootstrap,
279 security.clone(),
280 signing,
281 CaptureDigests {
282 metadata_snapshot: metadata_snapshot_sha256,
283 rlmm_snapshot: rlmm_snapshot_sha256,
284 group_offsets: group_offsets_sha256,
285 },
286 events,
287 )
288 .await?
289 {
290 artifacts.push(upload(&store, &id, DISKLESS_WAL_INDEX, bootstrap, bytes).await?);
291 }
292 }
293
294 if artifacts.is_empty() {
295 return Err(BackupError::Io(Error::new(
296 ErrorKind::NotFound,
297 format!(
298 "captured nothing: {}",
299 if absent.is_empty() {
300 "pass --log-dir, --bootstrap-server, or both".to_owned()
301 } else {
302 absent.join("; ")
303 }
304 ),
305 )));
306 }
307
308 let manifest = Manifest {
309 version: MANIFEST_VERSION,
310 capture_id: id.clone(),
311 captured_at_ms,
312 log_dir: log_dir.map(|dir| dir.display().to_string()),
313 bootstrap_server: bootstrap_server.map(ToOwned::to_owned),
314 artifacts,
315 };
316 let encoded = serde_json::to_vec_pretty(&manifest).map_err(|source| BackupError::Json {
317 context: MANIFEST.to_owned(),
318 source,
319 })?;
320 check_capture_size(MANIFEST, encoded.len() as u64, MAX_MANIFEST_BYTES)?;
321 store.put_new(&capture_key(&id, MANIFEST), encoded).await?;
322
323 events.push(BackupEvent::Capture {
324 manifest,
325 notes,
326 missing: absent,
327 });
328 Ok(id)
329}
330
331fn refuse_format_time_checkpoint(
343 checkpoint: &MetadataCheckpoint,
344 bytes: &[u8],
345) -> Result<(), BackupError> {
346 if !checkpoint.id.is_format_time() {
347 return Ok(());
348 }
349 let records = deserialize_metadata_snapshot(bytes).map_err(|error| {
350 BackupError::Integrity(format!(
351 "metadata checkpoint {}: {error}",
352 checkpoint.path.display()
353 ))
354 })?;
355 if holds_a_topic(&records) {
356 return Ok(());
357 }
358 Err(BackupError::StaleMetadata(format!(
359 "the newest metadata checkpoint is {}, the one `krabka format` wrote. It holds no \
360 topic, so a restore from it would bring back no topic configuration. The controller \
361 writes its next checkpoint after `metadata_max_snapshot_interval` (1 hour by \
362 default), or sooner once enough metadata is committed. Capture again after that, or \
363 lower `metadata_max_snapshot_interval`",
364 checkpoint.path.display()
365 )))
366}
367
368fn checkpoint_note(bytes: &[u8], now_ms: u64) -> String {
377 let age = metadata_snapshot_last_contained_log_timestamp(bytes)
378 .ok()
379 .and_then(|timestamp| u64::try_from(timestamp).ok())
380 .filter(|timestamp| *timestamp > 0)
381 .map_or_else(
382 || "the age of its newest record is unknown".to_owned(),
383 |timestamp| {
384 format!(
385 "its newest record is {}s old",
386 now_ms.saturating_sub(timestamp) / 1000
387 )
388 },
389 );
390 format!(
391 "{METADATA_CHECKPOINT}: {age}. Metadata changes committed after it are not in \
392 this capture"
393 )
394}
395
396struct CaptureDigests {
397 metadata_snapshot: Option<Sha256Digest>,
398 rlmm_snapshot: Option<Sha256Digest>,
399 group_offsets: Option<Sha256Digest>,
400}
401
402async fn capture_diskless_index(
403 store: &Archive,
404 bootstrap: &str,
405 security: Option<ClientSecurity>,
406 signing: &CaptureSigningArgs,
407 digests: CaptureDigests,
408 events: &mut Vec<BackupEvent>,
409) -> Result<Option<Vec<u8>>, BackupError> {
410 const TOPIC: &str = "__diskless_wal_index";
411 const MAX_WAL_OBJECT_BYTES: u64 = 8 * 1024 * 1024 * 1024;
412 let mut admin = connect_admin(bootstrap, security.clone()).await?;
413 let index_metadata = admin
414 .metadata(&[TOPIC])
415 .await
416 .map_err(|error| BackupError::Cluster(format!("diskless WAL index Metadata: {error}")))?;
417 if !diskless_index_present(&index_metadata, TOPIC)? {
418 return Ok(None);
419 }
420 let metadata = admin
421 .metadata(&[])
422 .await
423 .map_err(|error| BackupError::Cluster(format!("Metadata: {error}")))?;
424 let topic_names = metadata
425 .topics
426 .iter()
427 .filter(|topic| topic.name != TOPIC)
428 .map(|topic| topic.name.as_str())
429 .collect::<Vec<_>>();
430 let resources = topic_names
431 .iter()
432 .map(|name| ConfigResource::topic(*name))
433 .collect::<Vec<_>>();
434 let configs = admin
435 .describe_configs(&resources, DescribeConfigsOptions::default())
436 .await
437 .map_err(|error| BackupError::Cluster(format!("DescribeConfigs: {error}")))?
438 .into_iter()
439 .map(|(resource, config)| {
440 let config = config.map_err(|error| {
443 let error = AdminError::Broker {
444 api: "DescribeConfigs",
445 code: error.code,
446 name: error.name,
447 message: error.message,
448 };
449 BackupError::Cluster(format!("DescribeConfigs: {error}"))
450 })?;
451 let overrides = config.dynamic_overrides(&resource);
452 Ok((resource.name, overrides))
453 })
454 .collect::<Result<BTreeMap<_, _>, BackupError>>()?;
455 let topics = diskless_topic_topology(&metadata, &configs, TOPIC)?;
456 let mut config = KafkaMetadataLogConfig::new(bootstrap);
457 TOPIC.clone_into(&mut config.topic);
458 "krabka-backup-diskless-capture".clone_into(&mut config.client_id);
459 config.security = security;
460 let log = KafkaMetadataEventLog::open_read_only(config)
464 .await
465 .map_err(|error| BackupError::Cluster(format!("diskless WAL index: {error}")))?;
466 let mut capture =
467 capture_projection(Arc::<KafkaMetadataEventLog>::clone(&log), &topics, now_ms())
468 .await
469 .map_err(BackupError::Integrity)?;
470 capture.metadata_snapshot_sha256 = digests.metadata_snapshot;
471 capture.rlmm_snapshot_sha256 = digests.rlmm_snapshot;
472 capture.group_offsets_sha256 = digests.group_offsets;
473 log.shutdown().await;
474 drop(log);
475 if let (Some(key_id), Some(key_path)) = (
476 signing.worm_signing_key_id.as_ref(),
477 signing.worm_signing_key.as_ref(),
478 ) {
479 let signer = FileEd25519Signer::from_pkcs8_file(key_path, key_id.clone())
480 .map_err(|error| BackupError::InvalidArgument(error.to_string()))?;
481 let keys = capture
482 .partitions
483 .iter()
484 .flat_map(|partition| &partition.ranges)
485 .map(|range| range.object_key.clone())
486 .collect::<BTreeSet<_>>();
487 let mut claims = Vec::with_capacity(keys.len());
488 for key in keys {
489 let bytes = store
490 .get_absolute_capped(&key, MAX_WAL_OBJECT_BYTES)
491 .await?;
492 claims.push(ObjectEntry {
493 suffix: Path::new(&key)
494 .extension()
495 .map_or_else(String::new, |extension| {
496 format!(".{}", extension.to_string_lossy())
497 }),
498 key,
499 size_bytes: bytes.len() as u64,
500 sha256: Sha256Digest::of(&bytes),
501 e_tag: None,
502 version_id: None,
503 create_precondition: false,
504 });
505 }
506 let head = capture
507 .seal(claims, &signer)
508 .map_err(BackupError::Integrity)?;
509 events.push(BackupEvent::DisklessSealed { head });
510 }
511 serde_json::to_vec_pretty(&capture)
512 .map(Some)
513 .map_err(|source| BackupError::Json {
514 context: DISKLESS_WAL_INDEX.to_owned(),
515 source,
516 })
517}
518
519fn diskless_topic_topology(
520 metadata: &TopicMetadata,
521 configs: &BTreeMap<String, BTreeMap<String, String>>,
522 index_topic: &str,
523) -> Result<HashMap<uuid::Uuid, (String, i32)>, BackupError> {
524 let mut topics = HashMap::new();
525 for topic in metadata
526 .topics
527 .iter()
528 .filter(|topic| topic.name != index_topic)
529 {
530 if let Some(error) = &topic.error {
531 return Err(BackupError::Cluster(format!(
532 "Metadata for `{}`: code={} ({})",
533 topic.name, error.code, error.name
534 )));
535 }
536 let config = configs.get(topic.name.as_str()).ok_or_else(|| {
537 BackupError::Cluster(format!(
538 "DescribeConfigs omitted metadata topic `{}`",
539 topic.name
540 ))
541 })?;
542 if config
543 .get("krabka.diskless")
544 .is_none_or(|value| value != "true")
545 {
546 continue;
547 }
548 let topic_id = topic.topic_id.ok_or_else(|| {
549 BackupError::Cluster(format!("diskless topic `{}` has no topic id", topic.name))
550 })?;
551 topics.insert(
552 uuid::Uuid::from_bytes(topic_id.into_bytes()),
553 (topic.name.clone(), topic.partition_count),
554 );
555 }
556 Ok(topics)
557}
558
559fn diskless_index_present(metadata: &TopicMetadata, name: &str) -> Result<bool, BackupError> {
560 let topic = metadata
561 .topics
562 .iter()
563 .find(|topic| topic.name == name)
564 .ok_or_else(|| {
565 BackupError::Cluster(format!(
566 "diskless WAL index Metadata omitted requested topic `{name}`"
567 ))
568 })?;
569 match &topic.error {
570 None => Ok(true),
571 Some(error) if error.code == 3 => Ok(false),
572 Some(error) => Err(BackupError::Cluster(format!(
573 "diskless WAL index Metadata: code={} ({}){}",
574 error.code,
575 error.name,
576 error
577 .message
578 .as_deref()
579 .map_or_default(|message| format!(" {message:?}"))
580 ))),
581 }
582}
583
584#[cfg(test)]
585mod diskless_tests {
586 use {
587 super::*,
588 assert2::check,
589 krabka_client_admin::{KafkaError, TopicMetadataEntry},
590 };
591
592 fn metadata(error: Option<KafkaError>) -> TopicMetadata {
593 TopicMetadata {
594 topics: vec![TopicMetadataEntry {
595 name: "__diskless_wal_index".into(),
596 topic_id: None,
597 partition_count: 0,
598 replication_factor: 0,
599 error,
600 }],
601 ..Default::default()
602 }
603 }
604
605 #[test]
606 fn diskless_index_discovery_distinguishes_absence_from_denial() {
607 check!(diskless_index_present(&metadata(None), "__diskless_wal_index").unwrap());
608 check!(
609 !diskless_index_present(
610 &metadata(Some(KafkaError {
611 code: 3,
612 name: "UNKNOWN_TOPIC_OR_PARTITION",
613 message: None,
614 })),
615 "__diskless_wal_index",
616 )
617 .unwrap()
618 );
619 check!(
620 diskless_index_present(
621 &metadata(Some(KafkaError {
622 code: 29,
623 name: "TOPIC_AUTHORIZATION_FAILED",
624 message: None,
625 })),
626 "__diskless_wal_index",
627 )
628 .is_err()
629 );
630 check!(diskless_index_present(&TopicMetadata::default(), "__diskless_wal_index").is_err());
631 }
632
633 #[test]
634 fn diskless_topology_keeps_every_partition_including_empty_ones() {
635 let metadata = TopicMetadata {
636 topics: vec![TopicMetadataEntry {
637 name: "orders".into(),
638 topic_id: Some(uuid::Uuid::from_u128(7)),
639 partition_count: 3,
640 replication_factor: 1,
641 error: None,
642 }],
643 ..Default::default()
644 };
645 let configs = BTreeMap::from([(
646 "orders".to_owned(),
647 BTreeMap::from([("krabka.diskless".into(), "true".into())]),
648 )]);
649
650 check!(
651 diskless_topic_topology(&metadata, &configs, "__diskless_wal_index").unwrap()
652 == HashMap::from([(uuid::Uuid::from_u128(7), ("orders".to_owned(), 3))])
653 );
654 }
655}
656
657async fn upload(
659 store: &Archive,
660 id: &str,
661 name: &str,
662 source: &str,
663 bytes: Vec<u8>,
664) -> Result<Artifact, BackupError> {
665 upload_capped(store, id, name, source, bytes, artifact_byte_limit(name)).await
666}
667
668async fn upload_capped(
669 store: &Archive,
670 id: &str,
671 name: &str,
672 source: &str,
673 bytes: Vec<u8>,
674 max_bytes: u64,
675) -> Result<Artifact, BackupError> {
676 check_capture_size(name, bytes.len() as u64, max_bytes)?;
677 let artifact = Artifact {
678 name: name.to_owned(),
679 source: source.to_owned(),
680 size_bytes: bytes.len() as u64,
681 sha256: sha256_hex(&bytes),
682 };
683 store.put_new(&capture_key(id, name), bytes).await?;
684 Ok(artifact)
685}
686
687pub async fn list(archive: &ArchiveArgs) -> OperationReport<Vec<String>> {
693 let mut events = Vec::new();
694 let result = list_inner(archive, &mut events).await;
695 OperationReport { result, events }
696}
697
698async fn list_inner(
699 archive: &ArchiveArgs,
700 events: &mut Vec<BackupEvent>,
701) -> Result<Vec<String>, BackupError> {
702 let store = Archive::open(archive)?;
703 let ids = store.child_directories(CAPTURE_ROOT).await?;
704 if ids.is_empty() {
705 events.push(BackupEvent::EmptyList);
706 }
707 for id in &ids {
708 events.push(BackupEvent::Listed {
709 id: id.clone(),
710 manifest: read_manifest(&store, id).await,
711 });
712 }
713 Ok(ids.into_iter().collect())
714}
715
716pub async fn verify(capture: &str, archive: &ArchiveArgs) -> OperationReport<()> {
724 let mut events = Vec::new();
725 let result = verify_inner(capture, archive, &mut events).await;
726 OperationReport { result, events }
727}
728
729async fn verify_inner(
730 capture: &str,
731 archive: &ArchiveArgs,
732 events: &mut Vec<BackupEvent>,
733) -> Result<(), BackupError> {
734 let OpenedCapture {
735 store,
736 id,
737 manifest,
738 } = open_capture(archive, capture).await?;
739
740 let mut problems: Vec<String> = Vec::new();
741 for artifact in &manifest.artifacts {
742 let bytes = store
743 .get_capped(
744 &capture_key(&id, &artifact.name),
745 artifact_byte_limit(&artifact.name),
746 )
747 .await?;
748 match artifact_problem(artifact, &bytes) {
749 Some(problem) => problems.push(problem),
750 None => events.push(BackupEvent::ArtifactVerified {
751 name: artifact.name.clone(),
752 size_bytes: artifact.size_bytes,
753 }),
754 }
755 }
756 if problems.is_empty() {
757 events.push(BackupEvent::CaptureVerified {
758 id,
759 artifacts: manifest.artifacts.len(),
760 });
761 return Ok(());
762 }
763 Err(BackupError::Integrity(format!(
764 "capture {id}: {}",
765 problems.join("; ")
766 )))
767}
768
769pub async fn restore_offsets(
777 capture: &str,
778 bootstrap_server: &str,
779 dry_run: bool,
780 archive: &ArchiveArgs,
781) -> OperationReport<usize> {
782 let mut events = Vec::new();
783 let result = restore_offsets_secured_inner(
784 capture,
785 bootstrap_server,
786 None,
787 &CaptureTrustArgs::default(),
788 dry_run,
789 archive,
790 &mut events,
791 )
792 .await;
793 OperationReport { result, events }
794}
795
796pub async fn restore_offsets_configured(
803 capture: &str,
804 bootstrap_server: &str,
805 security: &SecurityArgs,
806 trust: &CaptureTrustArgs,
807 dry_run: bool,
808 archive: &ArchiveArgs,
809) -> OperationReport<usize> {
810 let mut events = Vec::new();
811 let result = async {
812 restore_offsets_secured_inner(
813 capture,
814 bootstrap_server,
815 security.load().await?,
816 trust,
817 dry_run,
818 archive,
819 &mut events,
820 )
821 .await
822 }
823 .await;
824 OperationReport { result, events }
825}
826
827async fn restore_offsets_secured_inner(
828 capture: &str,
829 bootstrap_server: &str,
830 security: Option<ClientSecurity>,
831 trust: &CaptureTrustArgs,
832 dry_run: bool,
833 archive: &ArchiveArgs,
834 events: &mut Vec<BackupEvent>,
835) -> Result<usize, BackupError> {
836 let OpenedCapture {
837 store,
838 id,
839 manifest,
840 } = open_capture(archive, capture).await?;
841 let recorded = manifest.artifact(GROUP_OFFSETS).ok_or_else(|| {
842 BackupError::NoSuchCapture(format!("capture {id} holds no {GROUP_OFFSETS}"))
843 })?;
844 let bytes = store
845 .get_capped(&capture_key(&id, GROUP_OFFSETS), MAX_GROUP_OFFSETS_BYTES)
846 .await?;
847 if let Some(problem) = artifact_problem(recorded, &bytes) {
848 return Err(BackupError::Integrity(format!("capture {id}: {problem}")));
849 }
850 verify_offsets_boundary(&store, &id, &manifest, &bytes, trust).await?;
851 let offsets = GroupOffsetsFile::from_slice(&bytes, &capture_key(&id, GROUP_OFFSETS))?;
852
853 if dry_run {
854 for group in &offsets.groups {
855 for offset in &group.offsets {
856 events.push(BackupEvent::WouldCommit {
857 group: group.group.clone(),
858 topic: offset.topic.clone(),
859 partition: offset.partition,
860 offset: offset.offset,
861 });
862 }
863 }
864 return Ok(offsets.offset_count());
865 }
866
867 if offsets.offset_count() == 0 {
868 return Ok(0);
869 }
870 let mut admin = connect_admin(bootstrap_server, security).await?;
871 let mut committed = 0;
872 for group in &offsets.groups {
873 if group.offsets.is_empty() {
874 continue;
875 }
876 let result = admin
877 .alter_consumer_group_offsets(&group.group, &admin_offsets(group))
878 .await;
879 committed += commit_outcome(group, result)?;
880 events.push(BackupEvent::OffsetsCommitted {
881 group: group.group.clone(),
882 offsets: group.offsets.len(),
883 });
884 }
885 Ok(committed)
886}
887
888async fn verify_offsets_boundary(
889 store: &Archive,
890 id: &str,
891 manifest: &Manifest,
892 offsets: &[u8],
893 trust: &CaptureTrustArgs,
894) -> Result<(), BackupError> {
895 let configured = trust.worm_key_id.is_some()
896 || trust.worm_public_key.is_some()
897 || trust.worm_expect_head.is_some();
898 let Some(recorded) = manifest.artifact(DISKLESS_WAL_INDEX) else {
899 if configured {
900 return Err(BackupError::Integrity(format!(
901 "capture {id} holds no signed diskless boundary for group offsets"
902 )));
903 }
904 return Ok(());
905 };
906 let bytes = store
907 .get_capped(
908 &capture_key(id, DISKLESS_WAL_INDEX),
909 MAX_DISKLESS_INDEX_BYTES,
910 )
911 .await?;
912 if let Some(problem) = artifact_problem(recorded, &bytes) {
913 return Err(BackupError::Integrity(format!("capture {id}: {problem}")));
914 }
915 let capture = DisklessWalCapture::from_slice(&bytes).map_err(BackupError::Integrity)?;
916 if capture.authentication.is_none() && !configured {
917 return Ok(());
918 }
919 let key_id = trust.worm_key_id.as_ref().ok_or_else(|| {
920 BackupError::Integrity("signed offset restore requires --worm-key-id".to_owned())
921 })?;
922 let key_path = trust.worm_public_key.as_ref().ok_or_else(|| {
923 BackupError::Integrity("signed offset restore requires --worm-public-key".to_owned())
924 })?;
925 let expected_head = trust.worm_expect_head.as_deref().ok_or_else(|| {
926 BackupError::Integrity("signed offset restore requires --worm-expect-head".to_owned())
927 })?;
928 let public_key = fs::read(key_path).await?;
929 let trusted = krabka_remote_storage::TrustedManifestKeys::single(key_id.clone(), public_key);
930 capture
931 .authenticate(&trusted, Some(expected_head))
932 .map_err(BackupError::Integrity)?;
933 if capture.group_offsets_sha256 != Some(Sha256Digest::of(offsets)) {
934 return Err(BackupError::Integrity(
935 "group offsets do not match the signed diskless capture boundary".to_owned(),
936 ));
937 }
938 Ok(())
939}
940
941async fn fetch_group_offsets(
943 bootstrap_server: &str,
944 security: Option<ClientSecurity>,
945) -> Result<GroupOffsetsFile, BackupError> {
946 let mut admin = connect_admin(bootstrap_server, security).await?;
947 let groups = admin
948 .list_groups(&ListGroupsOptions::default())
949 .await
950 .map_err(|error| BackupError::Cluster(format!("ListGroups: {error}")))?
951 .all()
952 .map_err(|failure| {
953 BackupError::Cluster(format!(
954 "ListGroups on broker {}: {} ({})",
955 failure.node_id, failure.error.name, failure.error.code
956 ))
957 })?;
958 let mut captured = GroupOffsetsFile::default();
959 for group in groups.into_iter().map(|listing| listing.group_id) {
960 let fetched = admin
961 .list_consumer_group_offsets(&group)
962 .await
963 .map_err(|error| BackupError::Cluster(format!("OffsetFetch for {group}: {error}")))?;
964 if fetched.is_empty() {
965 continue;
966 }
967 captured.groups.push(group_offsets(&group, &fetched));
968 }
969 Ok(captured)
970}
971
972async fn connect_admin(
973 bootstrap_server: &str,
974 security: Option<ClientSecurity>,
975) -> Result<AdminClient, BackupError> {
976 AdminClient::connect_secured(&[bootstrap_server.to_owned()], security)
977 .await
978 .map_err(|error| BackupError::Cluster(format!("cannot reach {bootstrap_server}: {error}")))
979}
980
981struct OpenedCapture {
983 store: Archive,
984 id: String,
985 manifest: Manifest,
986}
987
988async fn open_capture(archive: &ArchiveArgs, capture: &str) -> Result<OpenedCapture, BackupError> {
990 let store = Archive::open(archive)?;
991 let id = resolve_capture(&store, capture).await?;
992 let manifest = read_manifest(&store, &id).await?;
993 Ok(OpenedCapture {
994 store,
995 id,
996 manifest,
997 })
998}
999
1000async fn resolve_capture(store: &Archive, capture: &str) -> Result<String, BackupError> {
1002 let ids = store.child_directories(CAPTURE_ROOT).await?;
1003 if capture == LATEST {
1004 for id in ids.into_iter().rev() {
1005 match read_manifest(store, &id).await {
1006 Ok(_) => return Ok(id),
1007 Err(BackupError::ObjectStore(krabka_object_store::ObjectStoreError::NotFound(
1010 _,
1011 ))) => {}
1012 Err(error) => return Err(error),
1013 }
1014 }
1015 return Err(BackupError::NoSuchCapture(format!(
1016 "the archive holds no completed capture under {CAPTURE_ROOT}"
1017 )));
1018 }
1019 if ids.contains(capture) {
1020 return Ok(capture.to_owned());
1021 }
1022 Err(BackupError::NoSuchCapture(format!(
1023 "the archive holds no capture {capture}"
1024 )))
1025}
1026
1027async fn read_manifest(store: &Archive, id: &str) -> Result<Manifest, BackupError> {
1028 let key = capture_key(id, MANIFEST);
1029 let bytes = store.get_capped(&key, MAX_MANIFEST_BYTES).await?;
1030 Manifest::from_slice(&bytes, &key)
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035 use {
1036 super::{
1037 BackupError, CAPTURE_ROOT, DISKLESS_WAL_INDEX, DisklessWalCapture, FileEd25519Signer,
1038 GROUP_OFFSETS, MANIFEST, MAX_SNAPSHOT_BYTES, METADATA_CHECKPOINT, RLMM_SNAPSHOT,
1039 RLMM_SNAPSHOT_RELATIVE, Sha256Digest, artifact_byte_limit, capture, check_capture_size,
1040 checkpoint_note, list, newest_metadata_checkpoint, restore_offsets, upload_capped,
1041 verify, verify_offsets_boundary,
1042 },
1043 crate::{
1044 archive::{Archive, ArchiveArgs},
1045 capture::capture_key,
1046 cli::CaptureTrustArgs,
1047 manifest::{Artifact, MANIFEST_VERSION, Manifest, sha256_hex},
1048 offsets::{CommittedOffset, GROUP_OFFSETS_VERSION, GroupOffsets, GroupOffsetsFile},
1049 report::BackupEvent,
1050 },
1051 assert2::{assert, check},
1052 krabka_ids::PartitionIndex,
1053 krabka_metadata::{MetadataImage, MetadataRecord, NodeId, PartitionRecord, TopicRecord},
1054 std::{
1055 fs,
1056 fs::OpenOptions,
1057 io::ErrorKind,
1058 net::TcpListener,
1059 path::{Path, PathBuf},
1060 },
1061 };
1062
1063 const RLMM_BYTES: &[u8] = b"rlmm snapshot bytes";
1066
1067 const CHECKPOINT_BYTES: &[u8] = b"the newest controller checkpoint";
1069
1070 fn archive_args(root: &Path) -> ArchiveArgs {
1071 ArchiveArgs {
1072 local: Some(root.to_path_buf()),
1073 ..ArchiveArgs::default()
1074 }
1075 }
1076
1077 krabka_macros::snapshot_node_fixture!(node_with_rlmm);
1078
1079 #[yare::parameterized(
1080 manifest = { MANIFEST, 8 * 1024 * 1024 },
1081 group_offsets = { GROUP_OFFSETS, 64 * 1024 * 1024 },
1082 metadata_checkpoint = { METADATA_CHECKPOINT, 512 * 1024 * 1024 },
1083 rlmm_snapshot = { RLMM_SNAPSHOT, 512 * 1024 * 1024 },
1084 diskless_wal_index = { DISKLESS_WAL_INDEX, 256 * 1024 * 1024 },
1085 )]
1086 fn capture_size_limits_match_readers_at_the_boundary(name: &str, expected: u64) {
1087 let limit = artifact_byte_limit(name);
1088 check!(limit == expected);
1089 check!(check_capture_size(name, limit, limit).is_ok());
1090 check!(check_capture_size(name, limit + 1, limit).is_err());
1091 check!(check_capture_size(name, u64::MAX, limit).is_err());
1092 }
1093
1094 #[yare::parameterized(
1095 rlmm_at_limit = { RLMM_SNAPSHOT, 8usize, true },
1096 rlmm_over_limit = { RLMM_SNAPSHOT, 9usize, false },
1097 metadata_at_limit = { METADATA_CHECKPOINT, 8usize, true },
1098 metadata_over_limit = { METADATA_CHECKPOINT, 9usize, false },
1099 offsets_at_limit = { GROUP_OFFSETS, 8usize, true },
1100 offsets_over_limit = { GROUP_OFFSETS, 9usize, false },
1101 wal_index_at_limit = { DISKLESS_WAL_INDEX, 8usize, true },
1102 wal_index_over_limit = { DISKLESS_WAL_INDEX, 9usize, false },
1103 )]
1104 #[test_macro(tokio::test)]
1105 async fn capture_upload_caps_accept_equality_and_reject_overflow_before_put(
1106 name: &str,
1107 size: usize,
1108 expected_success: bool,
1109 ) {
1110 let ArchiveFixture {
1111 root: _root, store, ..
1112 } = archive_fixture();
1113 let id = "0000000000000042";
1114 store
1116 .put_new(&capture_key(id, "earlier"), b"valid".to_vec())
1117 .await
1118 .unwrap();
1119 let result = upload_capped(&store, id, name, "node", vec![b'x'; size], 8).await;
1120 if expected_success {
1121 check!(
1122 result.unwrap()
1123 == Artifact {
1124 name: name.to_owned(),
1125 source: "node".to_owned(),
1126 size_bytes: 8,
1127 sha256: sha256_hex(&[b'x'; 8]),
1128 }
1129 );
1130 check!(store.get(&capture_key(id, name)).await.unwrap() == vec![b'x'; size]);
1131 } else {
1132 let error = result.unwrap_err();
1133 check!(
1134 matches!(&error, BackupError::Io(error) if error.kind() == ErrorKind::InvalidData)
1135 );
1136 check!(error.to_string().contains(name));
1137 check!(matches!(
1138 store.get(&capture_key(id, name)).await,
1139 Err(BackupError::ObjectStore(
1140 krabka_object_store::ObjectStoreError::NotFound(_)
1141 ))
1142 ));
1143 check!(matches!(
1144 store.get(&capture_key(id, MANIFEST)).await,
1145 Err(BackupError::ObjectStore(
1146 krabka_object_store::ObjectStoreError::NotFound(_)
1147 ))
1148 ));
1149 check!(store.get(&capture_key(id, "earlier")).await.unwrap() == b"valid");
1150 }
1151 }
1152
1153 #[yare::parameterized(
1154 rlmm = { RLMM_SNAPSHOT },
1155 metadata_checkpoint = { METADATA_CHECKPOINT },
1156 )]
1157 #[test_macro(tokio::test)]
1158 async fn capture_rejects_oversized_local_snapshots_without_committing(name: &str) {
1159 let node = node_with_both_inputs();
1160 let path = if name == RLMM_SNAPSHOT {
1161 node.path().join(RLMM_SNAPSHOT_RELATIVE)
1162 } else {
1163 newest_metadata_checkpoint(node.path()).unwrap().path
1164 };
1165 OpenOptions::new()
1166 .write(true)
1167 .open(path)
1168 .unwrap()
1169 .set_len(MAX_SNAPSHOT_BYTES + 1)
1170 .unwrap();
1171 let ArchiveFixture {
1172 root: _root,
1173 args,
1174 store,
1175 } = archive_fixture();
1176 let error = capture(Some(node.path()), None, &args)
1177 .await
1178 .result
1179 .unwrap_err();
1180 check!(error.to_string().contains(name));
1181 check!(
1184 store
1185 .child_directories(CAPTURE_ROOT)
1186 .await
1187 .unwrap()
1188 .is_empty()
1189 );
1190 }
1191
1192 #[tokio::test]
1193 async fn concurrent_captures_at_the_same_timestamp_keep_both_nodes_bytes() {
1194 let first = node_with_rlmm();
1195 let second = node_with_rlmm();
1196 let snapshot = second.path().join(super::RLMM_SNAPSHOT_RELATIVE);
1197 fs::write(snapshot, b"second node snapshot").unwrap();
1198 let ArchiveFixture {
1199 root: _root,
1200 args: archive,
1201 store,
1202 } = archive_fixture();
1203 let signing = super::CaptureSigningArgs::default();
1204 let (left, right) = tokio::join!(
1205 super::capture_secured_at(Some(first.path()), None, None, &signing, &archive, 42),
1206 super::capture_secured_at(Some(second.path()), None, None, &signing, &archive, 42),
1207 );
1208 let left = left.result.unwrap();
1209 let right = right.result.unwrap();
1210 check!(left != right);
1211 check!(list(&archive).await.result.unwrap().len() == 2);
1212 for (id, expected) in [
1213 (&left, RLMM_BYTES),
1214 (&right, b"second node snapshot".as_slice()),
1215 ] {
1216 verify(id, &archive).await.result.unwrap();
1217 check!(store.get(&capture_key(id, RLMM_SNAPSHOT)).await.unwrap() == expected);
1218 check!(capture_manifest(&store, id).await.captured_at_ms == 42);
1219 }
1220 }
1221
1222 #[tokio::test]
1223 async fn latest_ignores_newer_capture_artifacts_without_a_committed_manifest() {
1224 let ArchiveFixture {
1225 root: _root,
1226 args,
1227 store,
1228 } = archive_fixture();
1229 let complete = "0000000000000001";
1230 let orphan = "0000000000000002-0123456789abcdef0123456789abcdef";
1231 write_offsets_capture(&store, complete, b"offsets", b"offsets").await;
1232 store
1233 .put(&capture_key(orphan, GROUP_OFFSETS), b"orphan".to_vec())
1234 .await
1235 .unwrap();
1236 check!(super::resolve_capture(&store, "latest").await.unwrap() == complete);
1237 check!(verify("latest", &args).await.result.is_ok());
1238 check!(super::resolve_capture(&store, orphan).await.unwrap() == orphan);
1239 check!(matches!(
1240 verify(orphan, &args).await.result,
1241 Err(BackupError::ObjectStore(
1242 krabka_object_store::ObjectStoreError::NotFound(_)
1243 ))
1244 ));
1245 }
1246
1247 #[tokio::test]
1248 async fn document_reads_reject_independent_limits_before_buffering() {
1249 let ArchiveFixture {
1250 root,
1251 args: archive,
1252 store,
1253 } = archive_fixture();
1254 let id = "0000000000000001";
1255 let key = capture_key(id, MANIFEST);
1256 store.put(&key, b"{}".to_vec()).await.unwrap();
1257 set_file_len(root.path(), &key, super::MAX_MANIFEST_BYTES + 1);
1258 let error = super::read_manifest(&store, id).await.unwrap_err();
1259 check!(
1260 matches!(error, BackupError::ObjectStore(krabka_object_store::ObjectStoreError::TooLarge { max_bytes, .. }) if max_bytes == super::MAX_MANIFEST_BYTES)
1261 );
1262
1263 write_offsets_capture(&store, id, b"offsets", b"offsets").await;
1264 let key = capture_key(id, GROUP_OFFSETS);
1265 set_file_len(root.path(), &key, super::MAX_GROUP_OFFSETS_BYTES + 1);
1266 let error = verify(id, &archive).await.result.unwrap_err();
1267 check!(
1268 matches!(error, BackupError::ObjectStore(krabka_object_store::ObjectStoreError::TooLarge { max_bytes, .. }) if max_bytes == super::MAX_GROUP_OFFSETS_BYTES)
1269 );
1270 }
1271
1272 struct ArchiveFixture {
1274 root: tempfile::TempDir,
1275 args: ArchiveArgs,
1276 store: Archive,
1277 }
1278
1279 fn archive_fixture() -> ArchiveFixture {
1280 let archive_root = tempfile::tempdir().expect("archive root");
1281 let args = archive_args(archive_root.path());
1282 let store = Archive::open(&args).expect("open the archive");
1283 ArchiveFixture {
1284 root: archive_root,
1285 args,
1286 store,
1287 }
1288 }
1289
1290 fn set_file_len(root: &Path, key: &str, len: u64) {
1291 OpenOptions::new()
1292 .write(true)
1293 .open(root.join(key))
1294 .unwrap()
1295 .set_len(len)
1296 .unwrap();
1297 }
1298
1299 struct CapturedNode {
1301 node: tempfile::TempDir,
1302 archive_root: tempfile::TempDir,
1303 args: ArchiveArgs,
1304 id: String,
1305 }
1306
1307 async fn captured_node() -> CapturedNode {
1308 let node = node_with_both_inputs();
1309 let archive_root = tempfile::tempdir().expect("archive root");
1310 let args = archive_args(archive_root.path());
1311 let id = capture(Some(node.path()), None, &args)
1312 .await
1313 .result
1314 .expect("capture the node");
1315 CapturedNode {
1316 node,
1317 archive_root,
1318 args,
1319 id,
1320 }
1321 }
1322
1323 async fn capture_manifest(store: &Archive, id: &str) -> Manifest {
1324 serde_json::from_slice(
1325 &store
1326 .get(&capture_key(id, MANIFEST))
1327 .await
1328 .expect("read the manifest"),
1329 )
1330 .expect("decode the manifest")
1331 }
1332
1333 fn check_unreachable(error: &BackupError, address: &str) {
1334 assert!(let BackupError::Cluster(_) = error, "got: {error}");
1335 check!(error.to_string().contains(address), "got: {error}");
1336 }
1337
1338 fn node_with_both_inputs() -> tempfile::TempDir {
1340 let log_dir = node_with_rlmm();
1341 write_checkpoint(log_dir.path());
1342 log_dir
1343 }
1344
1345 fn write_checkpoint(log_dir: &Path) {
1347 let metadata = log_dir.join("__cluster_metadata-0");
1348 fs::create_dir_all(&metadata).expect("create the metadata dir");
1349 fs::write(
1350 metadata.join("00000000000000000042-0000000001.checkpoint"),
1351 CHECKPOINT_BYTES,
1352 )
1353 .expect("write the newest checkpoint");
1354 }
1355
1356 fn closed_address() -> String {
1359 let listener = TcpListener::bind("127.0.0.1:0").expect("bind a probe listener");
1360 let port = listener.local_addr().expect("the probe's address").port();
1361 drop(listener);
1362 format!("127.0.0.1:{port}")
1363 }
1364
1365 async fn write_offsets_capture(store: &Archive, id: &str, bytes: &[u8], recorded: &[u8]) {
1370 let manifest = Manifest {
1371 version: MANIFEST_VERSION,
1372 capture_id: id.to_owned(),
1373 captured_at_ms: 1_700_000_000_000,
1374 log_dir: None,
1375 bootstrap_server: Some("broker-1:9092".to_owned()),
1376 artifacts: vec![Artifact {
1377 name: GROUP_OFFSETS.to_owned(),
1378 source: "broker-1:9092".to_owned(),
1379 size_bytes: recorded.len() as u64,
1380 sha256: sha256_hex(recorded),
1381 }],
1382 };
1383 store
1384 .put(
1385 &capture_key(id, MANIFEST),
1386 serde_json::to_vec(&manifest).expect("encode the manifest"),
1387 )
1388 .await
1389 .expect("write the manifest");
1390 store
1391 .put(&capture_key(id, GROUP_OFFSETS), bytes.to_vec())
1392 .await
1393 .expect("write the offsets");
1394 }
1395
1396 fn offsets_json() -> Vec<u8> {
1398 serde_json::to_vec(&GroupOffsetsFile {
1399 version: GROUP_OFFSETS_VERSION,
1400 groups: vec![GroupOffsets {
1401 group: "analytics".to_owned(),
1402 offsets: vec![CommittedOffset {
1403 topic: "orders".to_owned(),
1404 partition: 0,
1405 offset: 42,
1406 }],
1407 }],
1408 })
1409 .expect("encode the offsets")
1410 }
1411
1412 #[tokio::test]
1413 async fn signed_capture_boundary_rejects_forged_group_offsets() {
1414 const PKCS8: &[u8] = &[
1415 0x30, 0x53, 0x02, 0x01, 0x01, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
1416 0x04, 0x20, 0x10, 0xb4, 0x98, 0x61, 0xa2, 0x6e, 0xcb, 0x9b, 0x94, 0x33, 0x41, 0xa3,
1417 0x7f, 0xb7, 0x7d, 0x80, 0x95, 0x6a, 0xa7, 0xda, 0x95, 0x25, 0x62, 0xc6, 0x9b, 0x61,
1418 0xfd, 0xb6, 0x63, 0x77, 0xe5, 0xba, 0xa1, 0x23, 0x03, 0x21, 0x00, 0x13, 0x57, 0x2a,
1419 0x6e, 0xa1, 0xd1, 0xe1, 0x9d, 0x61, 0x77, 0x0e, 0xcb, 0xe3, 0x12, 0x91, 0xe9, 0xac,
1420 0x86, 0x0f, 0x0b, 0x93, 0xf5, 0x60, 0x09, 0xfe, 0xa8, 0xcc, 0xc9, 0x0e, 0xfe, 0x2a,
1421 0x87,
1422 ];
1423 let offsets = offsets_json();
1424 let signer = FileEd25519Signer::from_pkcs8_bytes(PKCS8, "capture-key".into()).unwrap();
1425 let mut capture = DisklessWalCapture {
1426 format_version: DisklessWalCapture::FORMAT_VERSION,
1427 captured_at_ms: 42,
1428 source_cutoffs: Vec::new(),
1429 partitions: Vec::new(),
1430 metadata_snapshot_sha256: None,
1431 rlmm_snapshot_sha256: None,
1432 group_offsets_sha256: Some(Sha256Digest::of(&offsets)),
1433 authentication: None,
1434 };
1435 let head = capture.seal(Vec::new(), &signer).unwrap();
1436 let capture_bytes = serde_json::to_vec(&capture).unwrap();
1437 let root = tempfile::tempdir().unwrap();
1438 let public_key = root.path().join("capture.pub");
1439 fs::write(&public_key, signer.public_key()).unwrap();
1440 let archive = Archive::open(&archive_args(root.path())).unwrap();
1441 let id = "0000000000000042";
1442 archive
1443 .put(&capture_key(id, DISKLESS_WAL_INDEX), capture_bytes.clone())
1444 .await
1445 .unwrap();
1446 let manifest = Manifest {
1447 version: MANIFEST_VERSION,
1448 capture_id: id.into(),
1449 captured_at_ms: 42,
1450 log_dir: None,
1451 bootstrap_server: Some("broker:9092".into()),
1452 artifacts: vec![
1453 Artifact {
1454 name: GROUP_OFFSETS.into(),
1455 source: "broker:9092".into(),
1456 size_bytes: offsets.len() as u64,
1457 sha256: sha256_hex(&offsets),
1458 },
1459 Artifact {
1460 name: DISKLESS_WAL_INDEX.into(),
1461 source: "broker:9092".into(),
1462 size_bytes: capture_bytes.len() as u64,
1463 sha256: sha256_hex(&capture_bytes),
1464 },
1465 ],
1466 };
1467 let trust = CaptureTrustArgs {
1468 worm_key_id: Some("capture-key".into()),
1469 worm_public_key: Some(public_key),
1470 worm_expect_head: Some(head.to_string()),
1471 };
1472
1473 verify_offsets_boundary(&archive, id, &manifest, &offsets, &trust)
1474 .await
1475 .expect("authentic offsets");
1476 check!(
1477 verify_offsets_boundary(&archive, id, &manifest, b"forged", &trust)
1478 .await
1479 .is_err()
1480 );
1481 }
1482
1483 #[tokio::test(flavor = "multi_thread")]
1484 async fn a_capture_records_both_snapshots_with_their_digests() {
1485 let CapturedNode {
1486 node: _node,
1487 archive_root: _archive_root,
1488 args,
1489 id,
1490 } = captured_node().await;
1491
1492 let store = Archive::open(&args).expect("open the archive");
1493 let manifest = capture_manifest(&store, &id).await;
1494 check!(manifest.capture_id == id);
1495 check!(manifest.bootstrap_server == None);
1496 check!(
1497 manifest.artifact(RLMM_SNAPSHOT).map(|a| a.sha256.clone())
1498 == Some(sha256_hex(RLMM_BYTES))
1499 );
1500 check!(
1501 manifest
1502 .artifact(METADATA_CHECKPOINT)
1503 .map(|a| a.sha256.clone())
1504 == Some(sha256_hex(CHECKPOINT_BYTES))
1505 );
1506 check!(manifest.artifact(GROUP_OFFSETS) == None);
1507 }
1508
1509 const FORMAT_TIME_CHECKPOINT: &str = "00000000000000000000-0000000000.checkpoint";
1511
1512 krabka_macros::single_replica_partition_fixture!(single_replica_partition);
1513
1514 fn restored_topic() -> Vec<MetadataRecord> {
1516 vec![
1517 MetadataRecord::V1Topic(TopicRecord {
1518 name: "ledger".to_owned(),
1519 topic_id: uuid::Uuid::from_u128(1),
1520 partitions: 1,
1521 replication_factor: 1,
1522 }),
1523 MetadataRecord::V1Partition(PartitionRecord {
1524 directories: vec![uuid::Uuid::nil()],
1525 ..single_replica_partition("ledger", PartitionIndex(0), NodeId(1))
1526 }),
1527 ]
1528 }
1529
1530 async fn formatted_node(
1534 seeded: Vec<MetadataRecord>,
1535 subdir: &str,
1536 ) -> (tempfile::TempDir, PathBuf) {
1537 let parent = tempfile::tempdir().expect("parent dir");
1538 let log_dir = parent.path().join("node");
1540 let argv = [
1541 "krabka-format",
1542 "--log-dir",
1543 &log_dir.display().to_string(),
1544 "--cluster-id",
1545 "8a2f4e1c-0000-4000-8000-00000000c1d0",
1546 "--node-id",
1547 "1",
1548 "--no-initial-controllers",
1549 ]
1550 .map(ToOwned::to_owned);
1551 check!(krabka_format::run_from_args_with_records(argv, seeded).await == 0);
1552 let formatted = log_dir
1553 .join("__cluster_metadata-0")
1554 .join(FORMAT_TIME_CHECKPOINT);
1555 let target = log_dir.join(subdir);
1556 fs::create_dir_all(&target).expect("create the checkpoint dir");
1557 fs::rename(&formatted, target.join(FORMAT_TIME_CHECKPOINT))
1558 .expect("move the format-time checkpoint");
1559 let rlmm = log_dir.join(RLMM_SNAPSHOT_RELATIVE);
1560 fs::create_dir_all(rlmm.parent().expect("a parent")).expect("create the rlmm dir");
1561 fs::write(rlmm, RLMM_BYTES).expect("write the rlmm snapshot");
1562 (parent, log_dir)
1563 }
1564
1565 #[yare::parameterized(
1566 controller = { "__cluster_metadata-0" },
1567 observer = { "__cluster_metadata-0/observer" },
1568 )]
1569 #[test_macro(tokio::test(flavor = "multi_thread"))]
1570 async fn a_capture_refuses_a_plain_format_time_checkpoint_and_writes_nothing(subdir: &str) {
1571 let (_parent, log_dir) = formatted_node(Vec::new(), subdir).await;
1572 let ArchiveFixture { root, args, .. } = archive_fixture();
1573
1574 let error = capture(Some(&log_dir), None, &args)
1575 .await
1576 .result
1577 .expect_err("a capture of the format-time checkpoint fails");
1578
1579 check!(
1580 matches!(&error, BackupError::StaleMetadata(_)),
1581 "got: {error}"
1582 );
1583 let message = error.to_string();
1584 check!(message.contains(FORMAT_TIME_CHECKPOINT), "got: {message}");
1585 check!(
1586 message.contains("metadata_max_snapshot_interval"),
1587 "got: {message}"
1588 );
1589 check!(
1590 fs::read_dir(root.path()).unwrap().next().is_none(),
1591 "the refused capture wrote no artifact"
1592 );
1593 }
1594
1595 #[tokio::test(flavor = "multi_thread")]
1596 async fn an_undecodable_format_time_checkpoint_is_an_integrity_failure_that_writes_nothing() {
1597 let log_dir = node_with_rlmm();
1598 let metadata = log_dir.path().join("__cluster_metadata-0");
1599 fs::create_dir_all(&metadata).expect("create the checkpoint dir");
1600 fs::write(metadata.join(FORMAT_TIME_CHECKPOINT), CHECKPOINT_BYTES)
1601 .expect("write a damaged format-time checkpoint");
1602 let ArchiveFixture { root, args, .. } = archive_fixture();
1603
1604 let error = capture(Some(log_dir.path()), None, &args)
1605 .await
1606 .result
1607 .expect_err("a damaged checkpoint fails the capture");
1608
1609 check!(matches!(&error, BackupError::Integrity(_)), "got: {error}");
1610 check!(
1611 error.to_string().contains(FORMAT_TIME_CHECKPOINT),
1612 "got: {error}"
1613 );
1614 check!(
1615 fs::read_dir(root.path()).unwrap().next().is_none(),
1616 "the failed capture wrote no artifact"
1617 );
1618 }
1619
1620 #[yare::parameterized(
1621 controller = { "__cluster_metadata-0" },
1622 observer = { "__cluster_metadata-0/observer" },
1623 )]
1624 #[test_macro(tokio::test(flavor = "multi_thread"))]
1625 async fn a_capture_takes_the_format_time_checkpoint_a_restore_seeded(subdir: &str) {
1626 let (_parent, log_dir) = formatted_node(restored_topic(), subdir).await;
1627 let checkpoint = fs::read(log_dir.join(subdir).join(FORMAT_TIME_CHECKPOINT))
1628 .expect("read the seeded checkpoint");
1629 let ArchiveFixture {
1630 root: _root,
1631 args,
1632 store,
1633 } = archive_fixture();
1634
1635 let id = capture(Some(&log_dir), None, &args)
1636 .await
1637 .result
1638 .expect("a capture of a restored node succeeds");
1639
1640 let manifest = capture_manifest(&store, &id).await;
1641 check!(
1642 manifest
1643 .artifact(METADATA_CHECKPOINT)
1644 .map(|a| a.sha256.clone())
1645 == Some(sha256_hex(&checkpoint))
1646 );
1647 }
1648
1649 #[yare::parameterized(
1650 an_hour_old = { 1_700_000_000_000, 1_700_003_600_000, "its newest record is 3600s old" },
1651 no_timestamp = { 0, 1_700_003_600_000, "the age of its newest record is unknown" },
1652 )]
1653 fn the_checkpoint_note_gives_the_age_of_its_newest_record(
1654 last_contained_ms: i64,
1655 now_ms: u64,
1656 age: &str,
1657 ) {
1658 let bytes = krabka_raft::serialize_metadata_snapshot(
1659 &MetadataImage::new(uuid::Uuid::from_u128(7)),
1660 last_contained_ms,
1661 )
1662 .expect("serialize a checkpoint");
1663
1664 check!(
1665 checkpoint_note(&bytes, now_ms)
1666 == format!(
1667 "cluster-metadata.checkpoint: {age}. Metadata changes committed after it \
1668 are not in this capture"
1669 )
1670 );
1671 }
1672
1673 #[test]
1674 fn the_checkpoint_note_of_an_undecodable_checkpoint_has_no_age() {
1675 check!(
1676 checkpoint_note(CHECKPOINT_BYTES, 1)
1677 == "cluster-metadata.checkpoint: the age of its newest record is unknown. \
1678 Metadata changes committed after it are not in this capture"
1679 );
1680 }
1681
1682 #[tokio::test(flavor = "multi_thread")]
1683 async fn a_capture_takes_a_controller_checkpoint_past_the_format_time_one() {
1684 let log_dir = node_with_rlmm();
1685 let metadata = log_dir.path().join("__cluster_metadata-0");
1686 fs::create_dir_all(&metadata).expect("create the checkpoint dir");
1687 fs::write(
1688 metadata.join("00000000000000000000-0000000000.checkpoint"),
1689 b"format-time",
1690 )
1691 .expect("write the format-time checkpoint");
1692 write_checkpoint(log_dir.path());
1693 let ArchiveFixture {
1694 root: _root,
1695 args,
1696 store,
1697 } = archive_fixture();
1698
1699 let id = capture(Some(log_dir.path()), None, &args)
1700 .await
1701 .result
1702 .expect("a capture past the format-time checkpoint succeeds");
1703
1704 let manifest = capture_manifest(&store, &id).await;
1705 check!(
1706 manifest
1707 .artifact(METADATA_CHECKPOINT)
1708 .map(|a| a.sha256.clone())
1709 == Some(sha256_hex(CHECKPOINT_BYTES))
1710 );
1711 }
1712
1713 #[tokio::test(flavor = "multi_thread")]
1714 async fn a_capture_reports_each_input_it_did_not_find_and_takes_the_other() {
1715 let log_dir = tempfile::tempdir().expect("log dir");
1716 write_checkpoint(log_dir.path());
1717 let archive_root = tempfile::tempdir().expect("archive root");
1718 let args = archive_args(archive_root.path());
1719
1720 let id = capture(Some(log_dir.path()), None, &args)
1721 .await
1722 .result
1723 .expect("a capture that found one input succeeds");
1724
1725 let store = Archive::open(&args).expect("open the archive");
1726 let manifest = capture_manifest(&store, &id).await;
1727 check!(manifest.artifact(RLMM_SNAPSHOT) == None);
1728 check!(manifest.artifact(METADATA_CHECKPOINT).is_some());
1729 }
1730
1731 #[tokio::test(flavor = "multi_thread")]
1732 async fn a_capture_that_finds_nothing_names_the_flags_that_would_have_helped() {
1733 let archive_root = tempfile::tempdir().expect("archive root");
1734 let args = archive_args(archive_root.path());
1735
1736 let message = capture(None, None, &args)
1737 .await
1738 .result
1739 .expect_err("a capture with no source fails")
1740 .to_string();
1741 check!(message.contains("pass --log-dir"), "got: {message}");
1742
1743 let empty = tempfile::tempdir().expect("an empty log dir");
1744 let message = capture(Some(empty.path()), None, &args)
1745 .await
1746 .result
1747 .expect_err("a capture that found nothing fails")
1748 .to_string();
1749 check!(message.contains("is absent"), "got: {message}");
1750 check!(message.contains("checkpoint"), "got: {message}");
1751 }
1752
1753 #[tokio::test(flavor = "multi_thread")]
1754 async fn an_unreadable_snapshot_is_reported_rather_than_treated_as_absent() {
1755 let log_dir = tempfile::tempdir().expect("log dir");
1756 fs::create_dir_all(log_dir.path().join("remote-log-metadata/snapshot"))
1759 .expect("create a directory in the snapshot's place");
1760 let archive_root = tempfile::tempdir().expect("archive root");
1761
1762 let error = capture(
1763 Some(log_dir.path()),
1764 None,
1765 &archive_args(archive_root.path()),
1766 )
1767 .await
1768 .result
1769 .expect_err("an unreadable snapshot fails the capture");
1770 assert!(let BackupError::Io(_) = &error, "got: {error}");
1771 }
1772
1773 #[tokio::test(flavor = "multi_thread")]
1774 async fn a_capture_of_group_offsets_reports_the_cluster_it_could_not_reach() {
1775 let archive_root = tempfile::tempdir().expect("archive root");
1776 let address = closed_address();
1777
1778 let error = capture(None, Some(&address), &archive_args(archive_root.path()))
1779 .await
1780 .result
1781 .expect_err("a capture cannot read offsets from a cluster that is not there");
1782 check_unreachable(&error, &address);
1783 }
1784
1785 #[tokio::test(flavor = "multi_thread")]
1786 async fn list_names_every_capture_and_says_so_when_there_are_none() {
1787 let archive_root = tempfile::tempdir().expect("archive root");
1788 let args = archive_args(archive_root.path());
1789 check!(
1790 list(&args)
1791 .await
1792 .result
1793 .expect("list an empty archive")
1794 .is_empty()
1795 );
1796
1797 let node = node_with_both_inputs();
1798 let first = capture(Some(node.path()), None, &args)
1799 .await
1800 .result
1801 .expect("capture the node");
1802 let store = Archive::open(&args).expect("open the archive");
1805 let second = "9999999999999999";
1806 write_offsets_capture(&store, second, &offsets_json(), &offsets_json()).await;
1807
1808 check!(
1809 list(&args).await.result.expect("list the captures") == vec![first, second.to_owned()]
1810 );
1811 }
1812
1813 #[tokio::test(flavor = "multi_thread")]
1814 async fn list_reports_a_capture_whose_manifest_cannot_be_read_and_keeps_going() {
1815 let ArchiveFixture {
1816 root: _archive_root,
1817 args,
1818 store,
1819 } = archive_fixture();
1820 store
1821 .put(&capture_key("0000000000000001", MANIFEST), b"{".to_vec())
1822 .await
1823 .expect("write a truncated manifest");
1824
1825 let report = list(&args).await;
1826 check!(
1827 report
1828 .result
1829 .expect("an unreadable manifest is listed, not fatal")
1830 == vec!["0000000000000001".to_owned()]
1831 );
1832 assert!(let [BackupEvent::Listed { id, manifest }] = report.events.as_slice());
1833 check!(id == "0000000000000001");
1834 check!(matches!(manifest, Err(BackupError::Json { .. })));
1835 }
1836
1837 #[tokio::test(flavor = "multi_thread")]
1838 async fn verify_accepts_a_capture_it_just_wrote_under_either_selector() {
1839 let CapturedNode {
1840 node: _node,
1841 archive_root: _archive_root,
1842 args,
1843 id,
1844 } = captured_node().await;
1845
1846 check!(verify("latest", &args).await.result.is_ok());
1847 check!(verify(&id, &args).await.result.is_ok());
1848 }
1849
1850 #[tokio::test(flavor = "multi_thread")]
1851 async fn verify_reports_an_artifact_the_archive_no_longer_holds_whole() {
1852 let CapturedNode {
1853 node: _node,
1854 archive_root,
1855 args,
1856 id,
1857 } = captured_node().await;
1858
1859 fs::write(
1862 archive_root.path().join(capture_key(&id, RLMM_SNAPSHOT)),
1863 b"rlmm snapshot byt",
1864 )
1865 .expect("truncate the captured snapshot");
1866
1867 let error = verify("latest", &args)
1868 .await
1869 .result
1870 .expect_err("a truncated artifact fails verification");
1871 assert!(let BackupError::Integrity(_) = &error, "got: {error}");
1872 check!(error.to_string().contains(RLMM_SNAPSHOT), "got: {error}");
1873 }
1874
1875 #[tokio::test(flavor = "multi_thread")]
1876 async fn a_capture_that_is_not_in_the_archive_is_named_rather_than_guessed_at() {
1877 let archive_root = tempfile::tempdir().expect("archive root");
1878 let args = archive_args(archive_root.path());
1879
1880 let error = verify("latest", &args)
1881 .await
1882 .result
1883 .expect_err("an empty archive has no newest capture");
1884 assert!(let BackupError::NoSuchCapture(_) = &error, "got: {error}");
1885
1886 let node = node_with_both_inputs();
1887 capture(Some(node.path()), None, &args)
1888 .await
1889 .result
1890 .expect("capture the node");
1891 let error = verify("0000000000000007", &args)
1892 .await
1893 .result
1894 .expect_err("a capture the archive does not hold");
1895 assert!(let BackupError::NoSuchCapture(_) = &error, "got: {error}");
1896 check!(
1897 error.to_string().contains("0000000000000007"),
1898 "got: {error}"
1899 );
1900 }
1901
1902 #[tokio::test(flavor = "multi_thread")]
1903 async fn a_dry_run_reports_every_offset_and_commits_nothing() {
1904 let ArchiveFixture {
1905 root: _archive_root,
1906 args,
1907 store,
1908 } = archive_fixture();
1909 let offsets = offsets_json();
1910 write_offsets_capture(&store, "0000000000000001", &offsets, &offsets).await;
1911
1912 let report = restore_offsets("latest", &closed_address(), true, &args).await;
1915 check!(report.result.expect("a dry run reads the capture alone") == 1);
1916 assert!(let [BackupEvent::WouldCommit { group, topic, partition, offset }] = report.events.as_slice());
1917 let expected = GroupOffsetsFile::from_slice(&offsets, GROUP_OFFSETS).unwrap();
1918 let expected_group = &expected.groups[0];
1919 let expected_offset = &expected_group.offsets[0];
1920 check!(
1921 (group.as_str(), topic.as_str(), *partition, *offset)
1922 == (
1923 expected_group.group.as_str(),
1924 expected_offset.topic.as_str(),
1925 expected_offset.partition,
1926 expected_offset.offset
1927 )
1928 );
1929 }
1930
1931 #[tokio::test(flavor = "multi_thread")]
1932 async fn a_capture_without_offsets_cannot_restore_them() {
1933 let node = node_with_both_inputs();
1934 let archive_root = tempfile::tempdir().expect("archive root");
1935 let args = archive_args(archive_root.path());
1936 capture(Some(node.path()), None, &args)
1937 .await
1938 .result
1939 .expect("capture the node");
1940
1941 let error = restore_offsets("latest", &closed_address(), true, &args)
1942 .await
1943 .result
1944 .expect_err("a capture of the two files holds no offsets");
1945 assert!(let BackupError::NoSuchCapture(_) = &error, "got: {error}");
1946 check!(error.to_string().contains(GROUP_OFFSETS), "got: {error}");
1947 }
1948
1949 #[tokio::test(flavor = "multi_thread")]
1950 async fn offsets_that_do_not_match_their_digest_are_never_committed() {
1951 let ArchiveFixture {
1952 root: _archive_root,
1953 args,
1954 store,
1955 } = archive_fixture();
1956 write_offsets_capture(&store, "0000000000000001", b"{}", &offsets_json()).await;
1957
1958 let error = restore_offsets("latest", &closed_address(), true, &args)
1959 .await
1960 .result
1961 .expect_err("offsets that do not match the manifest are an integrity failure");
1962 assert!(let BackupError::Integrity(_) = &error, "got: {error}");
1963 }
1964
1965 #[tokio::test(flavor = "multi_thread")]
1966 async fn offsets_that_are_not_the_captured_shape_name_the_object_that_holds_them() {
1967 let ArchiveFixture {
1968 root: _archive_root,
1969 args,
1970 store,
1971 } = archive_fixture();
1972 let bytes = b"[]";
1973 write_offsets_capture(&store, "0000000000000001", bytes, bytes).await;
1974
1975 let error = restore_offsets("latest", &closed_address(), true, &args)
1976 .await
1977 .result
1978 .expect_err("a JSON array is not the captured shape");
1979 assert!(let BackupError::Json { .. } = &error, "got: {error}");
1980 check!(error.to_string().contains(GROUP_OFFSETS), "got: {error}");
1981 }
1982
1983 #[tokio::test(flavor = "multi_thread")]
1984 async fn offsets_or_a_manifest_from_before_1_0_are_refused_as_an_unsupported_version() {
1985 let archive_root = tempfile::tempdir().expect("archive root");
1986 let args = archive_args(archive_root.path());
1987 let store = Archive::open(&args).expect("open the archive");
1988 let offsets = br#"{"groups":[]}"#;
1989 write_offsets_capture(&store, "0000000000000001", offsets, offsets).await;
1990
1991 let error = restore_offsets("latest", &closed_address(), true, &args)
1992 .await
1993 .result
1994 .expect_err("an offsets file with no version is refused");
1995 check!(
1996 let BackupError::UnsupportedVersion { found: None, expected: GROUP_OFFSETS_VERSION, .. } = &error,
1997 "got: {error}"
1998 );
1999 check!(error.to_string().contains(GROUP_OFFSETS), "got: {error}");
2000 check!(
2001 error.to_string().contains("predates krabka 1.0"),
2002 "got: {error}"
2003 );
2004
2005 let mut manifest: serde_json::Value = serde_json::from_slice(
2006 &store
2007 .get(&capture_key("0000000000000001", MANIFEST))
2008 .await
2009 .expect("read the manifest"),
2010 )
2011 .expect("decode the manifest");
2012 manifest["version"] = serde_json::json!(MANIFEST_VERSION + 1);
2013 store
2014 .put(
2015 &capture_key("0000000000000001", MANIFEST),
2016 serde_json::to_vec(&manifest).expect("encode the manifest"),
2017 )
2018 .await
2019 .expect("rewrite the manifest");
2020
2021 let error = restore_offsets("latest", &closed_address(), true, &args)
2022 .await
2023 .result
2024 .expect_err("a manifest of a later version is refused");
2025 assert2::assert!(
2026 let BackupError::UnsupportedVersion {
2027 found: Some(found),
2028 expected: MANIFEST_VERSION,
2029 ..
2030 } = &error,
2031 "got: {error}"
2032 );
2033 check!(found == "1");
2034 check!(error.to_string().contains(MANIFEST), "got: {error}");
2035 }
2036
2037 #[tokio::test(flavor = "multi_thread")]
2038 async fn a_commit_reports_the_cluster_it_could_not_reach() {
2039 let ArchiveFixture {
2040 root: _archive_root,
2041 args,
2042 store,
2043 } = archive_fixture();
2044 let offsets = offsets_json();
2045 write_offsets_capture(&store, "0000000000000001", &offsets, &offsets).await;
2046 let address = closed_address();
2047
2048 let error = restore_offsets("latest", &address, false, &args)
2050 .await
2051 .result
2052 .expect_err("a commit cannot reach a cluster that is not there");
2053 check_unreachable(&error, &address);
2054 }
2055
2056 #[tokio::test(flavor = "multi_thread")]
2057 async fn a_capture_that_holds_no_offsets_commits_nothing_and_asks_the_cluster_nothing() {
2058 let ArchiveFixture {
2059 root: _archive_root,
2060 args,
2061 store,
2062 } = archive_fixture();
2063 let empty = serde_json::to_vec(&GroupOffsetsFile::default()).expect("encode the offsets");
2064 write_offsets_capture(&store, "0000000000000001", &empty, &empty).await;
2065
2066 check!(
2071 restore_offsets("latest", &closed_address(), false, &args)
2072 .await
2073 .result
2074 .expect("an empty capture restores nothing")
2075 == 0
2076 );
2077 }
2078}