1use std::collections::{BTreeMap, BTreeSet};
40use std::fmt;
41use std::fs::{self, File};
42use std::io::{self, Read, Write};
43use std::path::{Path, PathBuf};
44use std::time::{SystemTime, UNIX_EPOCH};
45
46use mongreldb_types::hlc::HlcTimestamp;
47use mongreldb_types::ids::{ClusterId, DatabaseId, MetadataVersion, RaftGroupId, TabletId};
48use serde::{Deserialize, Serialize};
49use sha2::{Digest, Sha256};
50
51use crate::node::{
52 decode_json, encode_json, read_meta_file, write_meta_atomic, ClusterError, MAX_META_BYTES,
53};
54use crate::tablet::TabletDescriptor;
55
56pub const CLUSTER_BACKUP_FORMAT_VERSION: u32 = 1;
58pub const MIN_CLUSTER_BACKUP_FORMAT_VERSION: u32 = 1;
60pub const CLUSTER_BACKUP_MANIFEST_FILENAME: &str = "cluster-backup.json";
62pub const TABLET_SNAPSHOTS_DIR: &str = "tablets";
64pub const LOG_ARCHIVE_DIR: &str = "logs";
66const MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
68
69#[derive(Debug, thiserror::Error)]
75pub enum ClusterBackupError {
76 #[error("invalid cluster backup request: {0}")]
78 InvalidRequest(&'static str),
79 #[error("cluster backup source error for tablet {tablet_id}: {detail}")]
81 Source {
82 tablet_id: TabletId,
84 detail: String,
86 },
87 #[error("cluster backup validation failed: {0}")]
89 Validation(String),
90 #[error("cluster backup destination error: {0}")]
92 Destination(String),
93 #[error("cluster backup manifest error: {0}")]
95 Manifest(String),
96 #[error("injected fault at `{0}`")]
98 Fault(&'static str),
99 #[error("cluster backup I/O error: {0}")]
101 Io(#[from] io::Error),
102 #[error(transparent)]
104 Cluster(#[from] ClusterError),
105}
106
107impl From<mongreldb_fault::Fault> for ClusterBackupError {
108 fn from(fault: mongreldb_fault::Fault) -> Self {
109 match fault {
110 mongreldb_fault::Fault::Injected(name) => ClusterBackupError::Fault(name),
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct ClusterBackupEncryption {
126 pub scheme: String,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub kms_key_id: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub key_version: Option<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct ClusterBackupFile {
139 pub path: String,
141 pub bytes: u64,
143 pub sha256: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct TabletBackupEntry {
150 pub tablet_id: TabletId,
152 pub table_id: u64,
154 pub raft_group_id: RaftGroupId,
156 pub generation: u64,
158 pub snapshot_files: Vec<ClusterBackupFile>,
160 pub log_archive: Option<ClusterBackupFile>,
162 pub log_continuation_term: u64,
165 pub log_continuation_index: u64,
167 pub covered_commit_ts: HlcTimestamp,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct ClusterBackupManifest {
178 pub format_version: u32,
180 pub cluster_id: ClusterId,
182 pub database_id: DatabaseId,
184 pub meta_version: MetadataVersion,
186 pub backup_ts: HlcTimestamp,
188 pub created_unix_micros: u64,
191 pub tablets: Vec<TabletBackupEntry>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub encryption: Option<ClusterBackupEncryption>,
196 pub content_sha256: String,
199}
200
201impl ClusterBackupManifest {
202 pub fn total_bytes(&self) -> u64 {
204 self.tablets
205 .iter()
206 .map(|t| {
207 let snap: u64 = t.snapshot_files.iter().map(|f| f.bytes).sum();
208 let log = t.log_archive.as_ref().map(|f| f.bytes).unwrap_or(0);
209 snap + log
210 })
211 .sum()
212 }
213
214 pub fn tablet_count(&self) -> usize {
216 self.tablets.len()
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct ClusterBackupReport {
223 pub destination: PathBuf,
225 pub manifest: ClusterBackupManifest,
227 pub tablets: usize,
229 pub bytes: u64,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct ClusterBackupVerifyReport {
236 pub manifest_ok: bool,
238 pub files_ok: bool,
240 pub files_checked: usize,
242 pub bytes_checked: u64,
244 pub issues: Vec<String>,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250pub enum RestoreIdentityMode {
251 NewIdentity,
253 DisasterRecovery,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct ClusterRestorePlan {
263 pub identity_mode: RestoreIdentityMode,
265 pub target_cluster_id: ClusterId,
267 pub target_database_id: DatabaseId,
269 pub source_cluster_id: ClusterId,
271 pub source_database_id: DatabaseId,
273 pub backup_ts: HlcTimestamp,
275 pub meta_version: MetadataVersion,
277 pub tablets: Vec<TabletRestoreStep>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct TabletRestoreStep {
284 pub tablet_id: TabletId,
286 pub table_id: u64,
288 pub raft_group_id: RaftGroupId,
290 pub generation: u64,
292 pub snapshot_paths: Vec<String>,
294 pub log_archive_path: Option<String>,
296 pub log_continuation_term: u64,
298 pub log_continuation_index: u64,
300 pub covered_commit_ts: HlcTimestamp,
302}
303
304#[derive(Debug, Clone)]
310pub struct TabletSnapshotArtifact {
311 pub snapshot_payload: Vec<u8>,
313 pub extra_files: BTreeMap<String, Vec<u8>>,
315 pub covered_commit_ts: HlcTimestamp,
318 pub log_continuation_term: u64,
320 pub log_continuation_index: u64,
322 pub log_archive: Option<Vec<u8>>,
325}
326
327pub trait BackupSource {
332 fn capture_tablet(
334 &self,
335 tablet: &TabletDescriptor,
336 backup_ts: HlcTimestamp,
337 ) -> Result<TabletSnapshotArtifact, ClusterBackupError>;
338}
339
340#[derive(Debug, Clone)]
346pub struct ClusterBackupRequest {
347 pub cluster_id: ClusterId,
349 pub database_id: DatabaseId,
351 pub meta_version: MetadataVersion,
353 pub backup_ts: Option<HlcTimestamp>,
357 pub tablets: Vec<TabletDescriptor>,
360 pub destination: PathBuf,
363 pub encryption: Option<ClusterBackupEncryption>,
365}
366
367pub fn run_cluster_backup<S: BackupSource>(
369 request: &ClusterBackupRequest,
370 source: &S,
371) -> Result<ClusterBackupReport, ClusterBackupError> {
372 if request.tablets.is_empty() {
373 return Err(ClusterBackupError::InvalidRequest(
374 "backup requires at least one tablet",
375 ));
376 }
377 if request.cluster_id == ClusterId::ZERO {
378 return Err(ClusterBackupError::InvalidRequest("cluster_id is ZERO"));
379 }
380 if request.database_id == DatabaseId::ZERO {
381 return Err(ClusterBackupError::InvalidRequest("database_id is ZERO"));
382 }
383
384 let mut seen = BTreeSet::new();
386 for t in &request.tablets {
387 if !seen.insert(t.tablet_id) {
388 return Err(ClusterBackupError::InvalidRequest(
389 "duplicate tablet_id in backup request",
390 ));
391 }
392 if t.tablet_id == TabletId::ZERO {
393 return Err(ClusterBackupError::InvalidRequest("tablet_id is ZERO"));
394 }
395 }
396
397 mongreldb_fault::inject("cluster.backup.before")?;
398
399 fs::create_dir_all(&request.destination)?;
400 let tablets_dir = request.destination.join(TABLET_SNAPSHOTS_DIR);
401 let logs_dir = request.destination.join(LOG_ARCHIVE_DIR);
402 fs::create_dir_all(&tablets_dir)?;
403 fs::create_dir_all(&logs_dir)?;
404
405 let pinned_meta = request.meta_version;
409 mongreldb_fault::inject("cluster.backup.pin")?;
410
411 let mut entries: Vec<TabletBackupEntry> = Vec::with_capacity(request.tablets.len());
413 let mut ordered: Vec<&TabletDescriptor> = request.tablets.iter().collect();
415 ordered.sort_by_key(|t| t.tablet_id);
416
417 for tablet in ordered {
418 let artifact =
419 source.capture_tablet(tablet, request.backup_ts.unwrap_or(HlcTimestamp::ZERO))?;
420 let entry = materialize_tablet_entry(tablet, &artifact, &tablets_dir, &logs_dir)?;
421 mongreldb_fault::inject("cluster.backup.tablet")?;
422 entries.push(entry);
423 }
424
425 let backup_ts = match request.backup_ts {
427 Some(ts) => {
428 for e in &entries {
430 if e.covered_commit_ts < ts {
431 return Err(ClusterBackupError::Validation(format!(
432 "tablet {} covered_commit_ts {:?} is behind requested backup_ts {:?}",
433 e.tablet_id, e.covered_commit_ts, ts
434 )));
435 }
436 }
437 ts
438 }
439 None => entries
440 .iter()
441 .map(|e| e.covered_commit_ts)
442 .max()
443 .ok_or(ClusterBackupError::InvalidRequest("no tablet entries"))?,
444 };
445
446 validate_entries(&request.destination, &entries)?;
448 mongreldb_fault::inject("cluster.backup.validate")?;
449
450 let created_unix_micros = unix_micros_now();
452 let content_sha256 = content_hash(
453 &request.cluster_id,
454 &request.database_id,
455 pinned_meta,
456 backup_ts,
457 &entries,
458 );
459 let manifest = ClusterBackupManifest {
460 format_version: CLUSTER_BACKUP_FORMAT_VERSION,
461 cluster_id: request.cluster_id,
462 database_id: request.database_id,
463 meta_version: pinned_meta,
464 backup_ts,
465 created_unix_micros,
466 tablets: entries,
467 encryption: request.encryption.clone(),
468 content_sha256,
469 };
470
471 mongreldb_fault::inject("cluster.backup.publish.before")?;
472 publish_manifest(&request.destination, &manifest)?;
473 mongreldb_fault::inject("cluster.backup.publish.after")?;
474 mongreldb_fault::inject("cluster.backup.after")?;
475
476 let bytes = manifest.total_bytes();
477 let tablets = manifest.tablet_count();
478 Ok(ClusterBackupReport {
479 destination: request.destination.clone(),
480 manifest,
481 tablets,
482 bytes,
483 })
484}
485
486pub fn verify_backup(
489 backup_root: impl AsRef<Path>,
490) -> Result<(ClusterBackupManifest, ClusterBackupVerifyReport), ClusterBackupError> {
491 let root = backup_root.as_ref();
492 let manifest = load_manifest(root)?;
493 let mut report = ClusterBackupVerifyReport {
494 manifest_ok: true,
495 files_ok: true,
496 files_checked: 0,
497 bytes_checked: 0,
498 issues: Vec::new(),
499 };
500
501 if manifest.format_version < MIN_CLUSTER_BACKUP_FORMAT_VERSION
502 || manifest.format_version > CLUSTER_BACKUP_FORMAT_VERSION
503 {
504 return Err(ClusterBackupError::Manifest(format!(
505 "unsupported format_version {}",
506 manifest.format_version
507 )));
508 }
509
510 let expected = content_hash(
511 &manifest.cluster_id,
512 &manifest.database_id,
513 manifest.meta_version,
514 manifest.backup_ts,
515 &manifest.tablets,
516 );
517 if expected != manifest.content_sha256 {
518 report.manifest_ok = false;
519 return Err(ClusterBackupError::Validation(format!(
520 "content_sha256 mismatch: manifest has {}, recomputed {}",
521 manifest.content_sha256, expected
522 )));
523 }
524
525 for entry in &manifest.tablets {
526 for file in entry.snapshot_files.iter().chain(entry.log_archive.iter()) {
527 report.files_checked += 1;
528 report.bytes_checked += file.bytes;
529 let path = root.join(&file.path);
530 match hash_file(&path) {
531 Ok((bytes, sha)) => {
532 if bytes != file.bytes || sha != file.sha256 {
533 report.files_ok = false;
534 return Err(ClusterBackupError::Validation(format!(
535 "file {} hash/size mismatch",
536 file.path
537 )));
538 }
539 }
540 Err(error) => {
541 report.files_ok = false;
542 return Err(ClusterBackupError::Validation(format!(
543 "file {} unreadable: {error}",
544 file.path
545 )));
546 }
547 }
548 }
549 }
550
551 Ok((manifest, report))
552}
553
554pub fn plan_restore(
557 manifest: &ClusterBackupManifest,
558 identity_mode: RestoreIdentityMode,
559 fresh_ids: Option<(ClusterId, DatabaseId)>,
560) -> Result<ClusterRestorePlan, ClusterBackupError> {
561 let (target_cluster_id, target_database_id) = match identity_mode {
562 RestoreIdentityMode::NewIdentity => {
563 let (c, d) = fresh_ids.ok_or(ClusterBackupError::InvalidRequest(
564 "NewIdentity restore requires fresh cluster/database ids",
565 ))?;
566 if c == ClusterId::ZERO || d == DatabaseId::ZERO {
567 return Err(ClusterBackupError::InvalidRequest(
568 "fresh ids must be non-zero",
569 ));
570 }
571 if c == manifest.cluster_id && d == manifest.database_id {
572 return Err(ClusterBackupError::InvalidRequest(
573 "NewIdentity restore must not reuse both source identities",
574 ));
575 }
576 (c, d)
577 }
578 RestoreIdentityMode::DisasterRecovery => (manifest.cluster_id, manifest.database_id),
579 };
580
581 let tablets = manifest
582 .tablets
583 .iter()
584 .map(|t| TabletRestoreStep {
585 tablet_id: t.tablet_id,
586 table_id: t.table_id,
587 raft_group_id: t.raft_group_id,
588 generation: t.generation,
589 snapshot_paths: t.snapshot_files.iter().map(|f| f.path.clone()).collect(),
590 log_archive_path: t.log_archive.as_ref().map(|f| f.path.clone()),
591 log_continuation_term: t.log_continuation_term,
592 log_continuation_index: t.log_continuation_index,
593 covered_commit_ts: t.covered_commit_ts,
594 })
595 .collect();
596
597 Ok(ClusterRestorePlan {
598 identity_mode,
599 target_cluster_id,
600 target_database_id,
601 source_cluster_id: manifest.cluster_id,
602 source_database_id: manifest.database_id,
603 backup_ts: manifest.backup_ts,
604 meta_version: manifest.meta_version,
605 tablets,
606 })
607}
608
609pub fn load_manifest(
611 backup_root: impl AsRef<Path>,
612) -> Result<ClusterBackupManifest, ClusterBackupError> {
613 let path = backup_root.as_ref().join(CLUSTER_BACKUP_MANIFEST_FILENAME);
614 let Some(bytes) = read_meta_file(&path)? else {
615 return Err(ClusterBackupError::Manifest(format!(
616 "missing {CLUSTER_BACKUP_MANIFEST_FILENAME} (backup incomplete or not published)"
617 )));
618 };
619 if bytes.len() as u64 > MAX_META_BYTES {
620 return Err(ClusterBackupError::Manifest(
621 "manifest exceeds size limit".into(),
622 ));
623 }
624 let manifest: ClusterBackupManifest = decode_json(CLUSTER_BACKUP_MANIFEST_FILENAME, &bytes)?;
625 if manifest.format_version < MIN_CLUSTER_BACKUP_FORMAT_VERSION
626 || manifest.format_version > CLUSTER_BACKUP_FORMAT_VERSION
627 {
628 return Err(ClusterBackupError::Manifest(format!(
629 "unsupported format_version {}",
630 manifest.format_version
631 )));
632 }
633 Ok(manifest)
634}
635
636fn materialize_tablet_entry(
641 tablet: &TabletDescriptor,
642 artifact: &TabletSnapshotArtifact,
643 tablets_dir: &Path,
644 logs_dir: &Path,
645) -> Result<TabletBackupEntry, ClusterBackupError> {
646 let tablet_hex = tablet.tablet_id.to_string();
647 let tablet_dir = tablets_dir.join(&tablet_hex);
648 fs::create_dir_all(&tablet_dir)?;
649
650 let mut snapshot_files = Vec::new();
651
652 let snap_rel = format!("{TABLET_SNAPSHOTS_DIR}/{tablet_hex}/snapshot.bin");
654 let snap_abs = tablet_dir.join("snapshot.bin");
655 write_bytes_exclusive(&snap_abs, &artifact.snapshot_payload)?;
656 snapshot_files.push(ClusterBackupFile {
657 path: snap_rel,
658 bytes: artifact.snapshot_payload.len() as u64,
659 sha256: sha256_hex(&artifact.snapshot_payload),
660 });
661
662 for (name, bytes) in &artifact.extra_files {
663 if name.contains("..") || name.contains('/') || name.contains('\\') {
664 return Err(ClusterBackupError::Source {
665 tablet_id: tablet.tablet_id,
666 detail: format!("illegal extra file name {name:?}"),
667 });
668 }
669 let rel = format!("{TABLET_SNAPSHOTS_DIR}/{tablet_hex}/{name}");
670 let abs = tablet_dir.join(name);
671 write_bytes_exclusive(&abs, bytes)?;
672 snapshot_files.push(ClusterBackupFile {
673 path: rel,
674 bytes: bytes.len() as u64,
675 sha256: sha256_hex(bytes),
676 });
677 }
678
679 let log_archive = match &artifact.log_archive {
680 Some(bytes) => {
681 let rel = format!("{LOG_ARCHIVE_DIR}/{tablet_hex}.log");
682 let abs = logs_dir.join(format!("{tablet_hex}.log"));
683 write_bytes_exclusive(&abs, bytes)?;
684 Some(ClusterBackupFile {
685 path: rel,
686 bytes: bytes.len() as u64,
687 sha256: sha256_hex(bytes),
688 })
689 }
690 None => None,
691 };
692
693 Ok(TabletBackupEntry {
694 tablet_id: tablet.tablet_id,
695 table_id: tablet.table_id.get(),
696 raft_group_id: tablet.raft_group_id,
697 generation: tablet.generation,
698 snapshot_files,
699 log_archive,
700 log_continuation_term: artifact.log_continuation_term,
701 log_continuation_index: artifact.log_continuation_index,
702 covered_commit_ts: artifact.covered_commit_ts,
703 })
704}
705
706fn validate_entries(root: &Path, entries: &[TabletBackupEntry]) -> Result<(), ClusterBackupError> {
707 if entries.is_empty() {
708 return Err(ClusterBackupError::Validation(
709 "no tablet entries to validate".into(),
710 ));
711 }
712 for entry in entries {
713 if entry.snapshot_files.is_empty() {
714 return Err(ClusterBackupError::Validation(format!(
715 "tablet {} has no snapshot files",
716 entry.tablet_id
717 )));
718 }
719 for file in entry.snapshot_files.iter().chain(entry.log_archive.iter()) {
720 if file.bytes > MAX_ARTIFACT_BYTES {
721 return Err(ClusterBackupError::Validation(format!(
722 "file {} exceeds size limit",
723 file.path
724 )));
725 }
726 let path = root.join(&file.path);
727 let (bytes, sha) = hash_file(&path).map_err(|e| {
728 ClusterBackupError::Validation(format!("validate {}: {e}", file.path))
729 })?;
730 if bytes != file.bytes || sha != file.sha256 {
731 return Err(ClusterBackupError::Validation(format!(
732 "file {} failed hash verification",
733 file.path
734 )));
735 }
736 }
737 }
738 Ok(())
739}
740
741fn publish_manifest(
742 destination: &Path,
743 manifest: &ClusterBackupManifest,
744) -> Result<(), ClusterBackupError> {
745 let bytes = encode_json(CLUSTER_BACKUP_MANIFEST_FILENAME, manifest)?;
746 write_meta_atomic(destination, CLUSTER_BACKUP_MANIFEST_FILENAME, &bytes)?;
747 let _ = load_manifest(destination)?;
749 Ok(())
750}
751
752fn content_hash(
753 cluster_id: &ClusterId,
754 database_id: &DatabaseId,
755 meta_version: MetadataVersion,
756 backup_ts: HlcTimestamp,
757 tablets: &[TabletBackupEntry],
758) -> String {
759 let mut hasher = Sha256::new();
762 hasher.update(cluster_id.to_string().as_bytes());
763 hasher.update([0]);
764 hasher.update(database_id.to_string().as_bytes());
765 hasher.update([0]);
766 hasher.update(meta_version.get().to_le_bytes());
767 hasher.update(backup_ts.physical_micros.to_le_bytes());
768 hasher.update(backup_ts.logical.to_le_bytes());
769 hasher.update(backup_ts.node_tiebreaker.to_le_bytes());
770 for t in tablets {
771 let encoded = serde_json::to_vec(t).expect("tablet entry serializes");
773 hasher.update((encoded.len() as u64).to_le_bytes());
774 hasher.update(&encoded);
775 }
776 hex_encode(hasher.finalize())
777}
778
779fn write_bytes_exclusive(path: &Path, bytes: &[u8]) -> io::Result<()> {
780 if let Some(parent) = path.parent() {
781 fs::create_dir_all(parent)?;
782 }
783 let mut file = File::create(path)?;
784 file.write_all(bytes)?;
785 file.sync_all()?;
786 Ok(())
787}
788
789fn hash_file(path: &Path) -> io::Result<(u64, String)> {
790 let mut file = File::open(path)?;
791 let mut hasher = Sha256::new();
792 let mut buf = [0u8; 64 * 1024];
793 let mut total = 0u64;
794 loop {
795 let n = file.read(&mut buf)?;
796 if n == 0 {
797 break;
798 }
799 hasher.update(&buf[..n]);
800 total += n as u64;
801 }
802 Ok((total, hex_encode(hasher.finalize())))
803}
804
805fn sha256_hex(bytes: &[u8]) -> String {
806 let mut hasher = Sha256::new();
807 hasher.update(bytes);
808 hex_encode(hasher.finalize())
809}
810
811fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
812 const HEX: &[u8; 16] = b"0123456789abcdef";
813 let bytes = bytes.as_ref();
814 let mut out = String::with_capacity(bytes.len() * 2);
815 for b in bytes {
816 out.push(HEX[(b >> 4) as usize] as char);
817 out.push(HEX[(b & 0x0f) as usize] as char);
818 }
819 out
820}
821
822fn unix_micros_now() -> u64 {
823 SystemTime::now()
824 .duration_since(UNIX_EPOCH)
825 .map(|d| d.as_micros() as u64)
826 .unwrap_or(0)
827}
828
829impl fmt::Display for RestoreIdentityMode {
830 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
831 match self {
832 Self::NewIdentity => f.write_str("new_identity"),
833 Self::DisasterRecovery => f.write_str("disaster_recovery"),
834 }
835 }
836}
837
838#[cfg(test)]
843mod tests {
844 use super::*;
845 use crate::tablet::{ReplicaDescriptor, ReplicaRole, TabletDescriptor, TabletState};
846 use mongreldb_types::ids::{NodeId, TableId};
847 use std::sync::atomic::{AtomicUsize, Ordering};
848 use std::sync::{Arc, Mutex, OnceLock};
849
850 fn fault_lock() -> std::sync::MutexGuard<'static, ()> {
852 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
853 LOCK.get_or_init(|| Mutex::new(()))
854 .lock()
855 .unwrap_or_else(|e| e.into_inner())
856 }
857
858 fn tid(n: u8) -> TabletId {
859 let mut b = [0u8; 16];
860 b[15] = n;
861 TabletId::from_bytes(b)
862 }
863 fn rid(n: u8) -> RaftGroupId {
864 let mut b = [0u8; 16];
865 b[15] = n;
866 RaftGroupId::from_bytes(b)
867 }
868 fn nid(n: u8) -> NodeId {
869 let mut b = [0u8; 16];
870 b[15] = n;
871 NodeId::from_bytes(b)
872 }
873 fn cid(n: u8) -> ClusterId {
874 let mut b = [0u8; 16];
875 b[15] = n;
876 ClusterId::from_bytes(b)
877 }
878 fn did(n: u8) -> DatabaseId {
879 let mut b = [0u8; 16];
880 b[15] = n;
881 DatabaseId::from_bytes(b)
882 }
883 fn hlc(micros: u64) -> HlcTimestamp {
884 HlcTimestamp {
885 physical_micros: micros,
886 logical: 0,
887 node_tiebreaker: 1,
888 }
889 }
890
891 fn descriptor(tablet: u8, table: u64, gen: u64) -> TabletDescriptor {
892 TabletDescriptor {
893 tablet_id: tid(tablet),
894 table_id: TableId::new(table),
895 database_id: mongreldb_types::ids::DatabaseId::ZERO,
896 raft_group_id: rid(tablet),
897 partition: crate::tablet::PartitionBounds::unbounded(),
898 replicas: vec![ReplicaDescriptor {
899 node_id: nid(1),
900 role: ReplicaRole::Voter,
901 raft_node_id: 1,
902 }],
903 leader_hint: Some(nid(1)),
904 generation: gen,
905 state: TabletState::Active,
906 }
907 }
908
909 struct MemSource {
910 payloads: BTreeMap<u8, Vec<u8>>,
912 covered: HlcTimestamp,
913 captures: Arc<AtomicUsize>,
914 }
915
916 impl BackupSource for MemSource {
917 fn capture_tablet(
918 &self,
919 tablet: &TabletDescriptor,
920 _backup_ts: HlcTimestamp,
921 ) -> Result<TabletSnapshotArtifact, ClusterBackupError> {
922 self.captures.fetch_add(1, Ordering::SeqCst);
923 let key = tablet.tablet_id.as_bytes()[15];
924 let payload = self
925 .payloads
926 .get(&key)
927 .cloned()
928 .unwrap_or_else(|| format!("snap-{key}").into_bytes());
929 Ok(TabletSnapshotArtifact {
930 snapshot_payload: payload,
931 extra_files: BTreeMap::new(),
932 covered_commit_ts: self.covered,
933 log_continuation_term: 3,
934 log_continuation_index: 42 + key as u64,
935 log_archive: Some(format!("log-tail-{key}").into_bytes()),
936 })
937 }
938 }
939
940 fn mem_source(covered: HlcTimestamp) -> MemSource {
941 let mut payloads = BTreeMap::new();
942 payloads.insert(1, b"tablet-one-snapshot".to_vec());
943 payloads.insert(2, b"tablet-two-snapshot".to_vec());
944 payloads.insert(3, b"tablet-three-snapshot".to_vec());
945 MemSource {
946 payloads,
947 covered,
948 captures: Arc::new(AtomicUsize::new(0)),
949 }
950 }
951
952 #[test]
953 fn backup_publishes_manifest_last_and_verifies() {
954 let _serial = fault_lock();
955 mongreldb_fault::clear();
956 let dir = tempfile::tempdir().unwrap();
957 let dest = dir.path().join("backup-1");
958 let covered = hlc(1_700_000_000_000_000);
959 let source = mem_source(covered);
960 let request = ClusterBackupRequest {
961 cluster_id: cid(0xAA),
962 database_id: did(0xBB),
963 meta_version: MetadataVersion::new(7),
964 backup_ts: Some(covered),
965 tablets: vec![
966 descriptor(1, 10, 5),
967 descriptor(2, 10, 5),
968 descriptor(3, 11, 2),
969 ],
970 destination: dest.clone(),
971 encryption: Some(ClusterBackupEncryption {
972 scheme: "aes-256-gcm-kms".into(),
973 kms_key_id: Some("kms/test-key".into()),
974 key_version: Some("v1".into()),
975 }),
976 };
977
978 assert!(load_manifest(&dest).is_err());
980
981 let report = run_cluster_backup(&request, &source).expect("backup");
982 assert_eq!(report.tablets, 3);
983 assert!(report.bytes > 0);
984 assert_eq!(source.captures.load(Ordering::SeqCst), 3);
985
986 let (manifest, verify) = verify_backup(&dest).expect("verify");
988 assert!(verify.manifest_ok);
989 assert!(verify.files_ok);
990 assert_eq!(verify.files_checked, 6); assert_eq!(manifest.cluster_id, cid(0xAA));
992 assert_eq!(manifest.database_id, did(0xBB));
993 assert_eq!(manifest.meta_version, MetadataVersion::new(7));
994 assert_eq!(manifest.backup_ts, covered);
995 assert_eq!(manifest.tablets.len(), 3);
996 assert!(manifest
997 .tablets
998 .windows(2)
999 .all(|w| w[0].tablet_id <= w[1].tablet_id));
1000 assert_eq!(
1001 manifest.encryption.as_ref().map(|e| e.scheme.as_str()),
1002 Some("aes-256-gcm-kms")
1003 );
1004
1005 let reloaded = load_manifest(&dest).unwrap();
1007 assert_eq!(reloaded.content_sha256, manifest.content_sha256);
1008 assert_eq!(reloaded, manifest);
1009 }
1010
1011 #[test]
1012 fn incomplete_backup_without_manifest_is_rejected() {
1013 let dir = tempfile::tempdir().unwrap();
1014 let dest = dir.path().join("partial");
1015 fs::create_dir_all(dest.join(TABLET_SNAPSHOTS_DIR)).unwrap();
1016 fs::write(dest.join("tablets/x.bin"), b"orphan").unwrap();
1018 let err = load_manifest(&dest).unwrap_err();
1019 assert!(
1020 matches!(err, ClusterBackupError::Manifest(ref m) if m.contains("missing")),
1021 "got {err:?}"
1022 );
1023 let err = verify_backup(&dest).unwrap_err();
1024 assert!(matches!(err, ClusterBackupError::Manifest(_)));
1025 }
1026
1027 #[test]
1028 fn restore_plan_mints_new_identity_by_default() {
1029 let _serial = fault_lock();
1030 mongreldb_fault::clear();
1031 let dir = tempfile::tempdir().unwrap();
1032 let dest = dir.path().join("backup-2");
1033 let covered = hlc(100);
1034 let source = mem_source(covered);
1035 let report = run_cluster_backup(
1036 &ClusterBackupRequest {
1037 cluster_id: cid(1),
1038 database_id: did(2),
1039 meta_version: MetadataVersion::new(1),
1040 backup_ts: Some(covered),
1041 tablets: vec![descriptor(1, 1, 1)],
1042 destination: dest,
1043 encryption: None,
1044 },
1045 &source,
1046 )
1047 .unwrap();
1048
1049 let plan = plan_restore(
1050 &report.manifest,
1051 RestoreIdentityMode::NewIdentity,
1052 Some((cid(9), did(8))),
1053 )
1054 .unwrap();
1055 assert_eq!(plan.identity_mode, RestoreIdentityMode::NewIdentity);
1056 assert_eq!(plan.target_cluster_id, cid(9));
1057 assert_eq!(plan.target_database_id, did(8));
1058 assert_eq!(plan.source_cluster_id, cid(1));
1059 assert_eq!(plan.source_database_id, did(2));
1060 assert_eq!(plan.tablets.len(), 1);
1061 assert_eq!(plan.tablets[0].tablet_id, tid(1));
1062
1063 let dr = plan_restore(
1065 &report.manifest,
1066 RestoreIdentityMode::DisasterRecovery,
1067 None,
1068 )
1069 .unwrap();
1070 assert_eq!(dr.target_cluster_id, cid(1));
1071 assert_eq!(dr.target_database_id, did(2));
1072
1073 let err = plan_restore(
1075 &report.manifest,
1076 RestoreIdentityMode::NewIdentity,
1077 Some((cid(1), did(2))),
1078 )
1079 .unwrap_err();
1080 assert!(matches!(err, ClusterBackupError::InvalidRequest(_)));
1081 }
1082
1083 #[test]
1084 fn publish_last_survives_fault_before_publish() {
1085 let _serial = fault_lock();
1086 mongreldb_fault::clear();
1087 let dir = tempfile::tempdir().unwrap();
1088 let dest = dir.path().join("backup-fault");
1089 let covered = hlc(50);
1090 let source = mem_source(covered);
1091 let request = ClusterBackupRequest {
1092 cluster_id: cid(3),
1093 database_id: did(4),
1094 meta_version: MetadataVersion::new(2),
1095 backup_ts: Some(covered),
1096 tablets: vec![descriptor(1, 1, 1), descriptor(2, 1, 1)],
1097 destination: dest.clone(),
1098 encryption: None,
1099 };
1100
1101 let _guard = mongreldb_fault::ScopedGuard::new(
1102 "cluster.backup.publish.before",
1103 mongreldb_fault::Action::Fail,
1104 );
1105 let err = run_cluster_backup(&request, &source).unwrap_err();
1106 assert!(matches!(
1107 err,
1108 ClusterBackupError::Fault("cluster.backup.publish.before")
1109 ));
1110 assert!(
1112 load_manifest(&dest).is_err(),
1113 "manifest must not exist after pre-publish fault"
1114 );
1115 assert!(dest.join(TABLET_SNAPSHOTS_DIR).exists());
1117 }
1118
1119 #[test]
1120 fn tablet_coverage_behind_requested_ts_fails_validation() {
1121 let _serial = fault_lock();
1122 mongreldb_fault::clear();
1123 let dir = tempfile::tempdir().unwrap();
1124 let dest = dir.path().join("backup-stale");
1125 let source = mem_source(hlc(10));
1127 let err = run_cluster_backup(
1128 &ClusterBackupRequest {
1129 cluster_id: cid(1),
1130 database_id: did(1),
1131 meta_version: MetadataVersion::new(1),
1132 backup_ts: Some(hlc(20)),
1133 tablets: vec![descriptor(1, 1, 1)],
1134 destination: dest,
1135 encryption: None,
1136 },
1137 &source,
1138 )
1139 .unwrap_err();
1140 assert!(
1141 matches!(err, ClusterBackupError::Validation(ref m) if m.contains("behind")),
1142 "got {err:?}"
1143 );
1144 }
1145
1146 #[test]
1147 fn tampered_snapshot_fails_verify() {
1148 let _serial = fault_lock();
1149 mongreldb_fault::clear();
1150 let dir = tempfile::tempdir().unwrap();
1151 let dest = dir.path().join("backup-tamper");
1152 let covered = hlc(1);
1153 let source = mem_source(covered);
1154 run_cluster_backup(
1155 &ClusterBackupRequest {
1156 cluster_id: cid(1),
1157 database_id: did(1),
1158 meta_version: MetadataVersion::new(1),
1159 backup_ts: Some(covered),
1160 tablets: vec![descriptor(1, 1, 1)],
1161 destination: dest.clone(),
1162 encryption: None,
1163 },
1164 &source,
1165 )
1166 .unwrap();
1167
1168 let snap = dest
1170 .join(TABLET_SNAPSHOTS_DIR)
1171 .join(tid(1).to_string())
1172 .join("snapshot.bin");
1173 fs::write(&snap, b"TAMPERED").unwrap();
1174 let err = verify_backup(&dest).unwrap_err();
1175 assert!(matches!(err, ClusterBackupError::Validation(_)));
1176 }
1177
1178 #[test]
1179 fn empty_tablet_set_rejected() {
1180 let _serial = fault_lock();
1181 mongreldb_fault::clear();
1182 let dir = tempfile::tempdir().unwrap();
1183 let err = run_cluster_backup(
1184 &ClusterBackupRequest {
1185 cluster_id: cid(1),
1186 database_id: did(1),
1187 meta_version: MetadataVersion::new(1),
1188 backup_ts: None,
1189 tablets: vec![],
1190 destination: dir.path().join("x"),
1191 encryption: None,
1192 },
1193 &mem_source(hlc(1)),
1194 )
1195 .unwrap_err();
1196 assert!(matches!(err, ClusterBackupError::InvalidRequest(_)));
1197 }
1198
1199 #[test]
1200 fn backup_ts_derived_when_unspecified() {
1201 let _serial = fault_lock();
1202 mongreldb_fault::clear();
1203 let dir = tempfile::tempdir().unwrap();
1204 let dest = dir.path().join("backup-derived-ts");
1205 let covered = hlc(999);
1206 let source = mem_source(covered);
1207 let report = run_cluster_backup(
1208 &ClusterBackupRequest {
1209 cluster_id: cid(1),
1210 database_id: did(1),
1211 meta_version: MetadataVersion::new(1),
1212 backup_ts: None,
1213 tablets: vec![descriptor(1, 1, 1), descriptor(2, 1, 1)],
1214 destination: dest,
1215 encryption: None,
1216 },
1217 &source,
1218 )
1219 .unwrap();
1220 assert_eq!(report.manifest.backup_ts, covered);
1221 }
1222}