Skip to main content

mongreldb_cluster/
cluster_backup.rs

1//! Cluster backup and PITR (spec section 12.12, Stage 3L).
2//!
3//! A cluster backup is a validated multi-tablet artifact set plus one
4//! published [`ClusterBackupManifest`]. The protocol is strict:
5//!
6//! ```text
7//! 1. Choose cluster backup timestamp.
8//! 2. Pin metadata version (meta plane snapshot of tablet descriptors).
9//! 3. Ask each tablet for a snapshot covering the timestamp.
10//! 4. Archive logs needed to advance to the timestamp.
11//! 5. Validate every tablet entry (hashes, coverage).
12//! 6. Publish one backup manifest last (atomic rename).
13//! ```
14//!
15//! The manifest is published **last** so a crashed backup never leaves a
16//! readable incomplete manifest: restore only trusts a fully published file.
17//! Restore creates a new cluster/database identity unless the caller
18//! explicitly opts into disaster-recovery identity reuse.
19//!
20//! # Core-free design
21//!
22//! This crate never depends on `mongreldb-core`. Snapshot capture and file
23//! copy go through the [`BackupSource`] trait so the server/runtime (which
24//! can open tablet storage cores) supplies the I/O while this module owns
25//! the protocol, validation, and manifest format.
26//!
27//! # Fault hooks
28//!
29//! ```text
30//! cluster.backup.before          — before any durable side effect
31//! cluster.backup.pin             — after meta pin is recorded
32//! cluster.backup.tablet          — after each tablet snapshot is written
33//! cluster.backup.validate        — after full validation, before publish
34//! cluster.backup.publish.before  — immediately before manifest publish
35//! cluster.backup.publish.after   — immediately after manifest is durable
36//! cluster.backup.after           — end of a successful run
37//! ```
38
39use 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
56/// Manifest format version written by this build.
57pub const CLUSTER_BACKUP_FORMAT_VERSION: u32 = 1;
58/// Oldest format version this build accepts.
59pub const MIN_CLUSTER_BACKUP_FORMAT_VERSION: u32 = 1;
60/// Manifest filename inside a backup destination directory.
61pub const CLUSTER_BACKUP_MANIFEST_FILENAME: &str = "cluster-backup.json";
62/// Subdirectory holding per-tablet snapshot artifacts.
63pub const TABLET_SNAPSHOTS_DIR: &str = "tablets";
64/// Subdirectory holding archived raft log tails.
65pub const LOG_ARCHIVE_DIR: &str = "logs";
66/// Upper bound on a single artifact file listed in the manifest.
67const MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
68
69// ---------------------------------------------------------------------------
70// Errors
71// ---------------------------------------------------------------------------
72
73/// Errors of the cluster backup / restore surface.
74#[derive(Debug, thiserror::Error)]
75pub enum ClusterBackupError {
76    /// Caller-supplied parameters failed validation.
77    #[error("invalid cluster backup request: {0}")]
78    InvalidRequest(&'static str),
79    /// A tablet or source failed during capture.
80    #[error("cluster backup source error for tablet {tablet_id}: {detail}")]
81    Source {
82        /// Tablet that failed.
83        tablet_id: TabletId,
84        /// Underlying detail.
85        detail: String,
86    },
87    /// Validation of a captured entry failed.
88    #[error("cluster backup validation failed: {0}")]
89    Validation(String),
90    /// The backup destination is not usable.
91    #[error("cluster backup destination error: {0}")]
92    Destination(String),
93    /// A durable metadata/manifest file failed verification.
94    #[error("cluster backup manifest error: {0}")]
95    Manifest(String),
96    /// An injected fault aborted the protocol.
97    #[error("injected fault at `{0}`")]
98    Fault(&'static str),
99    /// Cluster metadata I/O failed.
100    #[error("cluster backup I/O error: {0}")]
101    Io(#[from] io::Error),
102    /// Underlying cluster error (encoding helpers).
103    #[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// ---------------------------------------------------------------------------
116// Manifest types
117// ---------------------------------------------------------------------------
118
119/// Encryption / KMS metadata recorded on a cluster backup (spec §12.12).
120///
121/// The KEK material itself never travels in the manifest; only scheme
122/// identifiers and optional key-id references are stored so operators can
123/// locate the wrapping key in their KMS.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct ClusterBackupEncryption {
126    /// Key-derivation / wrap scheme (e.g. `"aes-256-gcm-kms"`).
127    pub scheme: String,
128    /// Optional KMS key id used to wrap the DEK.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub kms_key_id: Option<String>,
131    /// Optional key version / generation for online rotation.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub key_version: Option<String>,
134}
135
136/// One file artifact listed in the manifest (relative path + integrity).
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct ClusterBackupFile {
139    /// Path relative to the backup root.
140    pub path: String,
141    /// File size in bytes.
142    pub bytes: u64,
143    /// Lowercase hex SHA-256 of the file contents.
144    pub sha256: String,
145}
146
147/// Per-tablet entry inside a cluster backup (spec §12.12).
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct TabletBackupEntry {
150    /// Tablet identity.
151    pub tablet_id: TabletId,
152    /// Table the tablet belongs to (opaque u64 for core-free manifests).
153    pub table_id: u64,
154    /// Raft group replicating the tablet.
155    pub raft_group_id: RaftGroupId,
156    /// Descriptor generation pinned at backup time.
157    pub generation: u64,
158    /// Snapshot covering the chosen backup timestamp.
159    pub snapshot_files: Vec<ClusterBackupFile>,
160    /// Archived raft log tail needed to advance to the backup timestamp.
161    pub log_archive: Option<ClusterBackupFile>,
162    /// Log continuation position after the archived tail
163    /// (`term`/`index` of the last included entry; `0/0` when empty).
164    pub log_continuation_term: u64,
165    /// See [`Self::log_continuation_term`].
166    pub log_continuation_index: u64,
167    /// HLC commit timestamp covered by the tablet snapshot.
168    pub covered_commit_ts: HlcTimestamp,
169}
170
171/// The published cluster backup manifest (spec §12.12).
172///
173/// Published **last** via atomic rename. A destination without this file is
174/// an incomplete backup and must not be restored.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct ClusterBackupManifest {
178    /// Manifest format version.
179    pub format_version: u32,
180    /// Source cluster identity.
181    pub cluster_id: ClusterId,
182    /// Source database identity.
183    pub database_id: DatabaseId,
184    /// Meta-plane metadata version pinned for this backup.
185    pub meta_version: MetadataVersion,
186    /// Chosen cluster backup timestamp (HLC).
187    pub backup_ts: HlcTimestamp,
188    /// Wall-clock capture time (unix micros) for operator tooling; not used
189    /// for correctness.
190    pub created_unix_micros: u64,
191    /// Per-tablet entries, ordered by tablet id for determinism.
192    pub tablets: Vec<TabletBackupEntry>,
193    /// Optional encryption/KMS metadata.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub encryption: Option<ClusterBackupEncryption>,
196    /// SHA-256 over the canonical tablet-entry payload (excluding this field).
197    /// Computed at publish time; verified by [`verify_backup`].
198    pub content_sha256: String,
199}
200
201impl ClusterBackupManifest {
202    /// Total bytes of all listed tablet artifacts.
203    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    /// Number of tablets in the backup.
215    pub fn tablet_count(&self) -> usize {
216        self.tablets.len()
217    }
218}
219
220/// Outcome of a successful cluster backup run.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct ClusterBackupReport {
223    /// Destination directory that holds the published manifest.
224    pub destination: PathBuf,
225    /// The published manifest.
226    pub manifest: ClusterBackupManifest,
227    /// Number of tablets captured.
228    pub tablets: usize,
229    /// Total artifact bytes.
230    pub bytes: u64,
231}
232
233/// Outcome of [`verify_backup`].
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct ClusterBackupVerifyReport {
236    /// Manifest structure and content hash held.
237    pub manifest_ok: bool,
238    /// Every listed file re-hashed and matched.
239    pub files_ok: bool,
240    /// Files re-checked.
241    pub files_checked: usize,
242    /// Total bytes re-hashed.
243    pub bytes_checked: u64,
244    /// Soft findings (non-fatal).
245    pub issues: Vec<String>,
246}
247
248/// How restore should treat cluster/database identity (spec §12.12).
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250pub enum RestoreIdentityMode {
251    /// Mint a fresh cluster id and database id (default, safe).
252    NewIdentity,
253    /// Keep the source identities — disaster recovery only.
254    DisasterRecovery,
255}
256
257/// A pure restore plan derived from a verified manifest.
258///
259/// The plan describes *what* restore must do; actual file materialization is
260/// performed by the runtime that owns tablet storage cores.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct ClusterRestorePlan {
263    /// Identity mode for the restored cluster.
264    pub identity_mode: RestoreIdentityMode,
265    /// Cluster id the restored deployment will use.
266    pub target_cluster_id: ClusterId,
267    /// Database id the restored deployment will use.
268    pub target_database_id: DatabaseId,
269    /// Source cluster id recorded in the backup.
270    pub source_cluster_id: ClusterId,
271    /// Source database id recorded in the backup.
272    pub source_database_id: DatabaseId,
273    /// Backup timestamp the restore targets.
274    pub backup_ts: HlcTimestamp,
275    /// Meta version to re-seed.
276    pub meta_version: MetadataVersion,
277    /// Per-tablet restore steps, ordered by tablet id.
278    pub tablets: Vec<TabletRestoreStep>,
279}
280
281/// One tablet's restore step inside a [`ClusterRestorePlan`].
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct TabletRestoreStep {
284    /// Tablet identity (preserved; tablet ids are never reused).
285    pub tablet_id: TabletId,
286    /// Table the tablet belongs to.
287    pub table_id: u64,
288    /// Raft group id to re-create.
289    pub raft_group_id: RaftGroupId,
290    /// Descriptor generation at backup time.
291    pub generation: u64,
292    /// Relative snapshot artifact paths under the backup root.
293    pub snapshot_paths: Vec<String>,
294    /// Relative log-archive path, if any.
295    pub log_archive_path: Option<String>,
296    /// Log continuation after archive install.
297    pub log_continuation_term: u64,
298    /// See [`Self::log_continuation_term`].
299    pub log_continuation_index: u64,
300    /// Commit timestamp covered by the snapshot.
301    pub covered_commit_ts: HlcTimestamp,
302}
303
304// ---------------------------------------------------------------------------
305// BackupSource trait (keeps cluster free of core)
306// ---------------------------------------------------------------------------
307
308/// Snapshot artifact produced by a [`BackupSource`] for one tablet.
309#[derive(Debug, Clone)]
310pub struct TabletSnapshotArtifact {
311    /// Opaque snapshot payload bytes (EngineSnapshot-derived or equivalent).
312    pub snapshot_payload: Vec<u8>,
313    /// Optional additional named files (relative name → bytes).
314    pub extra_files: BTreeMap<String, Vec<u8>>,
315    /// Commit timestamp covered by the snapshot (≥ requested backup_ts when
316    /// the source can provide an exact cover; otherwise the best available).
317    pub covered_commit_ts: HlcTimestamp,
318    /// Term of the last log entry included in the snapshot/log archive.
319    pub log_continuation_term: u64,
320    /// Index of the last log entry included.
321    pub log_continuation_index: u64,
322    /// Optional raft log-tail archive covering entries after the snapshot
323    /// base up through the backup timestamp.
324    pub log_archive: Option<Vec<u8>>,
325}
326
327/// Abstraction over tablet storage used by the backup protocol.
328///
329/// Implemented by the node runtime / server (which may open tablet cores).
330/// Tests supply an in-memory source.
331pub trait BackupSource {
332    /// Capture a snapshot of `tablet` covering (at least) `backup_ts`.
333    fn capture_tablet(
334        &self,
335        tablet: &TabletDescriptor,
336        backup_ts: HlcTimestamp,
337    ) -> Result<TabletSnapshotArtifact, ClusterBackupError>;
338}
339
340// ---------------------------------------------------------------------------
341// Request / driver
342// ---------------------------------------------------------------------------
343
344/// Inputs for one cluster backup run.
345#[derive(Debug, Clone)]
346pub struct ClusterBackupRequest {
347    /// Source cluster id.
348    pub cluster_id: ClusterId,
349    /// Source database id.
350    pub database_id: DatabaseId,
351    /// Meta-plane metadata version to pin.
352    pub meta_version: MetadataVersion,
353    /// Chosen backup timestamp. When `None`, the driver uses the maximum
354    /// covered commit ts observed across tablets after capture (step 1 is
355    /// then "choose after first pass"). Preferred path: supply an explicit ts.
356    pub backup_ts: Option<HlcTimestamp>,
357    /// Tablets to include (must be the full Active/Splitting/Merging set for
358    /// a consistent database backup).
359    pub tablets: Vec<TabletDescriptor>,
360    /// Destination directory (created if absent). The manifest is published
361    /// here last.
362    pub destination: PathBuf,
363    /// Optional encryption metadata to record.
364    pub encryption: Option<ClusterBackupEncryption>,
365}
366
367/// Drive the six-step cluster backup protocol against a [`BackupSource`].
368pub 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    // Dedup tablet ids; reject duplicates.
385    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    // Step 1–2: choose/pin. Meta version is caller-supplied (already pinned
406    // by the control plane before this call). Backup ts is either supplied
407    // or derived after capture from covered commit timestamps.
408    let pinned_meta = request.meta_version;
409    mongreldb_fault::inject("cluster.backup.pin")?;
410
411    // Steps 3–4: per-tablet snapshots + log archives.
412    let mut entries: Vec<TabletBackupEntry> = Vec::with_capacity(request.tablets.len());
413    // Stable order by tablet id for deterministic manifests.
414    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    // Resolve backup_ts: explicit request wins; else max covered commit ts.
426    let backup_ts = match request.backup_ts {
427        Some(ts) => {
428            // Every tablet must cover the requested ts.
429            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    // Step 5: validate every entry (re-hash listed files under destination).
447    validate_entries(&request.destination, &entries)?;
448    mongreldb_fault::inject("cluster.backup.validate")?;
449
450    // Step 6: publish manifest last.
451    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
486/// Re-verify a published backup directory: load manifest, check format,
487/// re-hash every listed file, recompute content hash.
488pub 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
554/// Build a restore plan from a verified backup. Default identity mode mints
555/// fresh cluster/database ids; disaster recovery reuses source identities.
556pub 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
609/// Load a published manifest from a backup root.
610pub 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
636// ---------------------------------------------------------------------------
637// Internals
638// ---------------------------------------------------------------------------
639
640fn 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    // Primary snapshot payload.
653    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    // Confirm the published file is readable.
748    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    // Canonical payload: ids + meta + ts + each tablet entry serialized
760    // without relying on the outer manifest's content_sha256 field.
761    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        // Deterministic: serde_json on the entry.
772        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// ---------------------------------------------------------------------------
839// Tests
840// ---------------------------------------------------------------------------
841
842#[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    /// Fault hooks are process-global; serialize backup tests that arm them.
851    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        /// tablet_id byte → payload
911        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        // Before run: no manifest.
979        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        // Manifest published and ordered by tablet id.
987        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); // 3 snapshots + 3 log archives
991        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        // Content hash is stable across reload.
1006        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        // Write a stray file but no manifest — restore/verify must fail closed.
1017        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        // DR mode reuses source ids.
1064        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        // NewIdentity refusing source reuse.
1074        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        // Artifacts may exist, but manifest must NOT be published.
1111        assert!(
1112            load_manifest(&dest).is_err(),
1113            "manifest must not exist after pre-publish fault"
1114        );
1115        // Tablet artifacts were written (protocol reached validate).
1116        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        // Source covers ts=10, request asks for ts=20.
1126        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        // Tamper with the snapshot bytes after publish.
1169        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}